Compare commits
No commits in common. "next" and "master" have entirely different histories.
@ -1,9 +0,0 @@
|
||||
# Host-side watchdog for the normal `just test` CraftOS-PC process.
|
||||
TRAP_CCLIBS_TEST_TIMEOUT_SECONDS=3
|
||||
|
||||
# Dedicated `just test-timeout` fixture timings.
|
||||
TRAP_CCLIBS_TEST_TIMEOUT_WATCHDOG_SECONDS=1
|
||||
|
||||
# Test placeholder. The real value is generated in `.env` on first `just install`,
|
||||
# or after `just clean` / `just reinstall`.
|
||||
OPENCODE_SERVER_PASSWORD=redacted
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -1,5 +1,3 @@
|
||||
.craftos
|
||||
.craftos-vanilla
|
||||
.env
|
||||
node_modules/
|
||||
dist/
|
||||
.cuberc
|
||||
.cubestartup
|
||||
.cubeboot
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
# ATM10 Expert Context Index
|
||||
|
||||
Local context for the `atm10-expert` opencode agent.
|
||||
|
||||
## Target Pack
|
||||
|
||||
- ATM10 / All The Mods 10 version `7.0` (`0.7.0` is accepted as a user alias).
|
||||
- Minecraft `1.21.1`.
|
||||
- NeoForge `21.1.228`.
|
||||
- CurseForge project id `925200`.
|
||||
- Client pack file id `8091114`.
|
||||
- Server pack file id `8094893`.
|
||||
- Source: [AllTheMods/ATM-10](https://github.com/AllTheMods/ATM-10).
|
||||
|
||||
## Local Glossaries
|
||||
|
||||
- [CC:Tweaked glossary](glossaries/cc_glossary.md) - globals, modules, peripherals, events, and guides.
|
||||
- [Advanced Peripherals glossary](glossaries/advanced_peripherals_glossary.md) - Advanced Peripherals 0.7 guides, peripherals, turtles, integrations, and changelog pages.
|
||||
- [Create CC:Tweaked glossary](glossaries/create_cc_tweaked_glossary.md) - Create CC:Tweaked integration pages.
|
||||
|
||||
## Modpack Notes
|
||||
|
||||
- [ATM10 7.0 modpack](modpacks/atm10-7.0.md) - pack metadata and generated CurseForge mod ids.
|
||||
|
||||
## Update Workflow
|
||||
|
||||
- Use local glossaries first when answering in-game questions.
|
||||
- Use web lookup when local context is missing, stale, or too shallow.
|
||||
- When a useful durable documentation source is found, update or create a glossary and update this index.
|
||||
- Refresh the mod id list with `./.opencode/agent-context/atm10-expert/scripts/fetch-atm10-7.0-mods.sh`.
|
||||
@ -1,20 +0,0 @@
|
||||
# ATM10 7.0 Modpack
|
||||
|
||||
Known facts for ATM10 / All The Mods 10 version `7.0`.
|
||||
|
||||
## Pack Metadata
|
||||
|
||||
- Minecraft: `1.21.1`.
|
||||
- NeoForge: `21.1.228`.
|
||||
- CurseForge project id: `925200`.
|
||||
- Client pack file id: `8091114`.
|
||||
- Server pack file id: `8094893`.
|
||||
- Source: [AllTheMods/ATM-10](https://github.com/AllTheMods/ATM-10).
|
||||
|
||||
## Mod List
|
||||
|
||||
The mod list is generated by [`../scripts/fetch-atm10-7.0-mods.sh`](../scripts/fetch-atm10-7.0-mods.sh).
|
||||
|
||||
Run it with `CURSEFORGE_API_KEY` to resolve project names through the CurseForge API. Without an API key, the generated table preserves raw project/file ids and API URLs for later enrichment.
|
||||
|
||||
Generated list: not fetched yet.
|
||||
@ -1,93 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ID=925200
|
||||
FILE_ID=8091114
|
||||
SERVER_FILE_ID=8094893
|
||||
NEOFORGE_VERSION=21.1.228
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CONTEXT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||
OUTPUT="${CONTEXT_DIR}/modpacks/atm10-7.0.md"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "${TMP_DIR}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
printf 'missing required command: %s\n' "$1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_cmd curl
|
||||
require_cmd jq
|
||||
require_cmd unzip
|
||||
|
||||
ARCHIVE="${TMP_DIR}/atm10-${FILE_ID}.zip"
|
||||
MANIFEST="${TMP_DIR}/manifest.json"
|
||||
PROJECTS="${TMP_DIR}/projects.json"
|
||||
|
||||
download_url=''
|
||||
if [ -n "${CURSEFORGE_API_KEY:-}" ]; then
|
||||
download_url="$(curl -fsS \
|
||||
-H "x-api-key: ${CURSEFORGE_API_KEY}" \
|
||||
"https://api.curseforge.com/v1/mods/${PROJECT_ID}/files/${FILE_ID}/download-url" \
|
||||
| jq -r '.data // empty')"
|
||||
fi
|
||||
|
||||
if [ -n "${download_url}" ]; then
|
||||
curl -fL --retry 3 -o "${ARCHIVE}" "${download_url}"
|
||||
else
|
||||
curl -fL --retry 3 -o "${ARCHIVE}" \
|
||||
"https://www.curseforge.com/api/v1/mods/${PROJECT_ID}/files/${FILE_ID}/download"
|
||||
fi
|
||||
|
||||
unzip -p "${ARCHIVE}" manifest.json > "${MANIFEST}"
|
||||
|
||||
if [ -n "${CURSEFORGE_API_KEY:-}" ]; then
|
||||
mod_ids="$(jq -c '[.files[].projectID] | unique' "${MANIFEST}")"
|
||||
curl -fsS \
|
||||
-H "x-api-key: ${CURSEFORGE_API_KEY}" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"modIds\":${mod_ids}}" \
|
||||
'https://api.curseforge.com/v1/mods' > "${PROJECTS}"
|
||||
else
|
||||
printf '{"data":[]}\n' > "${PROJECTS}"
|
||||
fi
|
||||
|
||||
{
|
||||
printf '# ATM10 7.0 Modpack\n\n'
|
||||
printf 'Generated from CurseForge client file `%s`.\n\n' "${FILE_ID}"
|
||||
printf 'Last generated: `%s`.\n\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
printf '## Pack Metadata\n\n'
|
||||
printf -- '- Minecraft: `%s`.\n' "$(jq -r '.minecraft.version // "1.21.1"' "${MANIFEST}")"
|
||||
printf -- '- NeoForge: `%s`.\n' "${NEOFORGE_VERSION}"
|
||||
printf -- '- CurseForge project id: `%s`.\n' "${PROJECT_ID}"
|
||||
printf -- '- Client pack file id: `%s`.\n' "${FILE_ID}"
|
||||
printf -- '- Server pack file id: `%s`.\n' "${SERVER_FILE_ID}"
|
||||
printf -- '- Source: [AllTheMods/ATM-10](https://github.com/AllTheMods/ATM-10).\n\n'
|
||||
printf '## Manifest\n\n'
|
||||
jq -r '"- Name: `" + (.name // "unknown") + "`.", "- Version: `" + (.version // "unknown") + "`."' "${MANIFEST}"
|
||||
printf '\n## Mods\n\n'
|
||||
printf '| Project id | File id | Name | Reference |\n'
|
||||
printf '| --- | --- | --- | --- |\n'
|
||||
jq -r --slurpfile projects "${PROJECTS}" '
|
||||
def project($id): (($projects[0].data // [])[]? | select(.id == $id)) // {};
|
||||
def cell: tostring | gsub("\\|"; "\\\\|");
|
||||
.files[]
|
||||
| project(.projectID) as $project
|
||||
| [
|
||||
(.projectID | tostring),
|
||||
(.fileID | tostring),
|
||||
(($project.name // "unresolved") | cell),
|
||||
(($project.links.websiteUrl // ("https://api.curseforge.com/v1/mods/" + (.projectID | tostring))) | cell)
|
||||
]
|
||||
| "| " + join(" | ") + " |"
|
||||
' "${MANIFEST}"
|
||||
} > "${OUTPUT}"
|
||||
|
||||
printf 'wrote %s\n' "${OUTPUT}"
|
||||
@ -1,61 +0,0 @@
|
||||
---
|
||||
description: Answers in-game ATM10, ComputerCraft, CC:Tweaked, Advanced Peripherals, Create, and TrapOS user questions with concise player-facing replies; use for programs/ai.lua requests when opencc.agent is atm10-expert.
|
||||
mode: primary
|
||||
permission:
|
||||
"*": deny
|
||||
read: allow
|
||||
glob: allow
|
||||
grep: allow
|
||||
webfetch: allow
|
||||
websearch: allow
|
||||
edit: ask
|
||||
bash:
|
||||
"*": ask
|
||||
"just check": allow
|
||||
computercraft-mcp-bridge_probe-computers: allow
|
||||
computercraft-mcp-bridge_exec-lua: allow
|
||||
---
|
||||
|
||||
You answer players using TrapOS `ai` from inside ATM10 / All The Mods 10.
|
||||
|
||||
Target environment:
|
||||
|
||||
- ATM10 / All The Mods 10 pack version `7.0`; accept `0.7.0` as the same user alias.
|
||||
- Minecraft `1.21.1`.
|
||||
- NeoForge `21.1.228`.
|
||||
- ComputerCraft is CC:Tweaked in the ComputerCraft sandbox, not desktop Lua.
|
||||
|
||||
Response style:
|
||||
|
||||
- Keep replies short for in-game terminals. Prefer 1-4 concise lines.
|
||||
- Default to practical, non-technical answers unless the user asks for details.
|
||||
- Answer in the user's language; usually French when the prompt is French.
|
||||
- When giving commands, make them directly runnable in CraftOS when possible.
|
||||
- When giving code, keep it minimal and runnable. Avoid markdown fences unless the user clearly asks for a code block.
|
||||
- If the prompt asks for raw Lua code only, output raw Lua only with no markdown or explanation.
|
||||
|
||||
Context workflow:
|
||||
|
||||
- Use `.opencode/agent-context/atm10-expert/INDEX.md` first.
|
||||
- Prefer local glossaries and modpack notes before web lookup.
|
||||
- Use web search or fetch only for current external facts, missing mod documentation, or stale local references.
|
||||
- If you find a useful durable mod documentation source, ask before editing, then update or create a glossary and update `INDEX.md`.
|
||||
|
||||
MCP bridge safety:
|
||||
|
||||
- You may use the ComputerCraft MCP bridge only through `probe-computers` and `exec-lua`.
|
||||
- Use `probe-computers` before `exec-lua` unless the target computer id is already clear from the hidden caller context or conversation.
|
||||
- Treat `exec-lua` as privileged in-game execution. Prefer read-only inspection.
|
||||
- Do not delete files, reboot or shut down computers, move turtles, change inventories, transmit network traffic, mutate peripherals, or run long loops unless the user explicitly asks for that specific effect.
|
||||
- Keep `exec-lua` snippets small and bounded. Use short timeouts. Avoid blocking pulls, sleeps, infinite loops, and assumptions that a timeout stops code already running in ComputerCraft.
|
||||
- `print()` and `write()` output is captured in MCP results. To intentionally write to the visible ComputerCraft screen, use terminal APIs such as `term.clear()`, `term.setCursorPos()`, and `term.write()`.
|
||||
|
||||
Caller context:
|
||||
|
||||
- TrapOS may prepend hidden caller context with the ComputerCraft computer id and label.
|
||||
- Use that context to choose the right MCP target, but do not expose it unless it helps the user.
|
||||
|
||||
Repository scope:
|
||||
|
||||
- If the user asks for repo internals, answer only what is needed to unblock them.
|
||||
- This agent is explicit and player-facing. Do not assume it is the default coding assistant.
|
||||
@ -1,10 +0,0 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"computercraft-mcp-bridge": {
|
||||
"type": "remote",
|
||||
"url": "http://127.0.0.1:3000",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
|
||||
const SOURCE_EXTENSIONS = [".lua", ".json"]
|
||||
|
||||
function isSourcePath(value: unknown) {
|
||||
return typeof value === "string" && SOURCE_EXTENSIONS.some((extension) => value.endsWith(extension))
|
||||
}
|
||||
|
||||
function patchTouchesSource(value: unknown) {
|
||||
if (typeof value !== "string") return false
|
||||
for (const line of value.split("\n")) {
|
||||
if (!line.startsWith("*** Update File: ") && !line.startsWith("*** Add File: ")) continue
|
||||
if (isSourcePath(line.slice(line.indexOf(": ") + 2).trim())) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function toolTouchesSource(args: Record<string, unknown>) {
|
||||
return (
|
||||
isSourcePath(args.filePath) ||
|
||||
isSourcePath(args.path) ||
|
||||
isSourcePath(args.target) ||
|
||||
patchTouchesSource(args.patchText) ||
|
||||
patchTouchesSource(args.patch)
|
||||
)
|
||||
}
|
||||
|
||||
export const FixOnPackageSourceEdit: Plugin = async ({ $ }) => {
|
||||
let running = false
|
||||
|
||||
return {
|
||||
"tool.execute.after": async (_input, output) => {
|
||||
if (running) return
|
||||
const args = output.args
|
||||
if (!args || typeof args !== "object" || !toolTouchesSource(args as Record<string, unknown>)) return
|
||||
|
||||
running = true
|
||||
try {
|
||||
await $`just fix`
|
||||
} finally {
|
||||
running = false
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
# TRoR Hello World POC Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Build a small proof-of-concept that drives an in-game ComputerCraft computer (on an ATM10 server) from a local CraftOS-PC instance using TRoR (Terminal Redirection over Rednet).
|
||||
|
||||
The POC is intentionally minimal: see "Hello World" printed by an in-game computer rendered locally, and have a local keystroke produce a CC event in-game.
|
||||
|
||||
## Background
|
||||
|
||||
- `craftos --tror` enables the TRoR renderer but only reads/writes packets over **stdio** — it does not speak real rednet. See `docs/craftos_pc_glossary.md`.
|
||||
- TRoR is a ComputerCraft standard (oeed/CraftOS-Standards #10): packet format `<Code>:<Meta>;<Payload>` (e.g. `TW` write, `TC` cursor, `EV` event).
|
||||
- [`lyqyd/cc-netshell`](https://github.com/lyqyd/cc-netshell) already implements a TRoR server/client over rednet inside CC. We piggyback on it instead of reimplementing the protocol.
|
||||
|
||||
## Architecture
|
||||
|
||||
Three actors:
|
||||
|
||||
```text
|
||||
[ local craftos --tror ] <-- stdio --> [ ws bridge ] <-- ws --> [ in-game relay CC ] <-- rednet --> [ in-game target CC ]
|
||||
```
|
||||
|
||||
1. **Target CC** (in-game): runs `cc-netshell` server, prints "Hello World", reads `key`/`char` events.
|
||||
2. **Relay CC** (in-game): has wireless modem + uses `http.websocket` to bridge TRoR packets between rednet and an external WS endpoint.
|
||||
3. **WS bridge** (host): tiny Node/Python WS server that also pipes stdio to/from `craftos --tror`. Could be a single script that spawns `craftos` as a child process.
|
||||
|
||||
Why a relay: CC has no raw TCP socket; the only off-world transport is `http`/`websocket`. The ATM10 server must allow outbound HTTP/WS to our host (default CC:Tweaked config does).
|
||||
|
||||
## Milestones
|
||||
|
||||
1. **In-game baseline**: install `cc-netshell` on two test computers (creative world / single-player first), confirm server/client TRoR shell works over rednet alone.
|
||||
2. **WS bridge skeleton**: minimal local WS server that logs frames. Verify a CC computer can connect via `http.websocket` and exchange text frames.
|
||||
3. **Relay program**: in-game program that joins the WS server and forwards every WS frame as a rednet message to a configured target ID, and vice versa. Treat frames as opaque TRoR packets.
|
||||
4. **Local TRoR client**: spawn `craftos --tror`, pipe its stdout to the WS bridge, pipe WS frames to its stdin. A "Hello World" written by the target CC should render in CraftOS-PC.
|
||||
5. **Input loopback**: confirm keystrokes typed in local CraftOS-PC reach the target as `key`/`char` events (TRoR `EV` packets).
|
||||
6. **ATM10 deployment**: copy the relay + target programs to the real server, point relay at the public host running the WS bridge.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Does `cc-netshell`'s wire format match the on-the-wire TRoR packet format byte-for-byte, or does it wrap packets in a rednet envelope? If wrapped, the relay must unwrap before forwarding to the WS bridge (and rewrap on the way back).
|
||||
- ATM10 default CC:Tweaked config: is outbound WS to arbitrary hosts allowed? May need a whitelist entry in `computercraft-server.toml`.
|
||||
- Auth: any pairing/handshake between local client and target CC, or do we rely on "obscure WS URL" for the POC?
|
||||
- One target only, or do we want the relay to multiplex by target ID from day one?
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Multi-user, auth, TLS hardening.
|
||||
- Reconnect logic beyond "restart both ends".
|
||||
- Packaging into this repo's `programs/` / `servers/` layout — that comes after the POC proves the loop works.
|
||||
|
||||
## Deliverables
|
||||
|
||||
- `programs/tror-relay.lua` (in-game relay, prototype quality).
|
||||
- `tools/tror-bridge/` (host-side WS + craftos spawner script, language TBD).
|
||||
- Notes appended to `docs/craftos_pc_glossary.md` once the stdio contract is verified.
|
||||
144
.plans/goo-plan.md
Normal file
144
.plans/goo-plan.md
Normal file
@ -0,0 +1,144 @@
|
||||
# Goo Program Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Add a turtle program named `goo` for automating Just Dire Things goo block processing.
|
||||
|
||||
The turtle is placed directly below the goo block. It should run forever, adapt to its inventory, feed the goo when it is not alive, place supported process blocks around the goo, and mine the resulting crystal blocks.
|
||||
|
||||
## Command
|
||||
|
||||
- `goo start` starts the automation loop.
|
||||
- `goo help` / `goo --help` prints usage.
|
||||
- `goo version` / `goo --version` prints the version.
|
||||
|
||||
## Starting Position
|
||||
|
||||
The turtle must start directly below the goo block:
|
||||
|
||||
```text
|
||||
[goo]
|
||||
[turtle]
|
||||
[ground]
|
||||
```
|
||||
|
||||
The turtle cannot move down because of the ground. The block below the goo is the turtle's starting cell, so the bottom side must be handled last and from an adjacent position.
|
||||
|
||||
## Goo Detection
|
||||
|
||||
Use `turtle.inspectUp()` from the starting position.
|
||||
|
||||
Supported goo blocks:
|
||||
|
||||
- `justdirethings:gooblock_tier1`
|
||||
- `justdirethings:gooblock_tier2`
|
||||
- `justdirethings:gooblock_tier3`
|
||||
- `justdirethings:gooblock_tier4`
|
||||
|
||||
Parse the tier from the block name. Re-inspect regularly so the program adapts if the goo tier changes.
|
||||
|
||||
## Alive Handling
|
||||
|
||||
The goo is ready when:
|
||||
|
||||
```lua
|
||||
inspected.state and inspected.state.alive == true
|
||||
```
|
||||
|
||||
If `alive` is false or nil, the turtle should use the appropriate feeding item directly on the goo with `turtle.placeUp()`.
|
||||
|
||||
Feeding items are ordered arrays so preference is data-driven:
|
||||
|
||||
```lua
|
||||
local FEEDING_ITEMS_BY_TIER = {
|
||||
[1] = { 'minecraft:sugar', 'minecraft:rotten_flesh' },
|
||||
[2] = { 'minecraft:nether_wart' },
|
||||
[3] = { 'minecraft:chorus_fruit' },
|
||||
[4] = { 'minecraft:sculk' },
|
||||
};
|
||||
```
|
||||
|
||||
Tier 1 naturally prefers sugar because it appears first.
|
||||
|
||||
If no valid feeding item is present, log a useful message and wait forever for the player to add one.
|
||||
|
||||
## Process Blocks
|
||||
|
||||
Supported process blocks and required goo tiers:
|
||||
|
||||
```lua
|
||||
local PROCESS_ITEMS = {
|
||||
['minecraft:iron_block'] = { tier = 1, label = 'iron' },
|
||||
['minecraft:coal_block'] = { tier = 1, label = 'coal' },
|
||||
['mekanism:block_charcoal'] = { tier = 1, label = 'charcoal' },
|
||||
['minecraft:gold_block'] = { tier = 2, label = 'gold' },
|
||||
['minecraft:diamond_block'] = { tier = 3, label = 'diamond' },
|
||||
['minecraft:netherite_block'] = { tier = 4, label = 'netherite' },
|
||||
};
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- A goo tier can process items whose required tier is less than or equal to the goo tier.
|
||||
- Lower-tier goo skips higher-tier items and logs the required tier.
|
||||
- Tier 4 goo can process every supported item.
|
||||
- Materials may be mixed; a full six-block batch of one material is not required.
|
||||
- The program should continue forever, even if no eligible process blocks are currently present.
|
||||
|
||||
## Six-Side Handling
|
||||
|
||||
The program should work all six sides of the goo:
|
||||
|
||||
- top
|
||||
- north
|
||||
- south
|
||||
- east
|
||||
- west
|
||||
- bottom
|
||||
|
||||
Horizontal sides:
|
||||
|
||||
- Move from center to adjacent floor position.
|
||||
- Use `inspectUp`, `digUp`, and `placeUp` to work the side target.
|
||||
- Return to center.
|
||||
|
||||
Top side:
|
||||
|
||||
- Move to an adjacent floor position.
|
||||
- Climb up twice when the side path allows it.
|
||||
- Face the top target.
|
||||
- Use `inspect`, `dig`, and `place`.
|
||||
- Return to center.
|
||||
|
||||
Bottom side:
|
||||
|
||||
- Ensure the goo is alive before leaving the center.
|
||||
- Move to an adjacent floor position and face the original center cell.
|
||||
- Work the original center cell with `inspect`, `dig`, and `place`.
|
||||
- If a process block was placed in the bottom position, wait outside until it is consumed or transformed.
|
||||
- Mine the resulting crystal block, then return under the goo when the center is clear.
|
||||
|
||||
## Crystal Mining Rule
|
||||
|
||||
After placement, the goo transforms process blocks into crystal blocks that should be mined with a pickaxe-equipped turtle.
|
||||
|
||||
No tag or exact crystal ID check is required. For any goo target position:
|
||||
|
||||
- Empty means available for placement.
|
||||
- Known process material means already placed and still processing; do not dig.
|
||||
- Goo block means protected; do not dig.
|
||||
- Any other block means processed result/crystal; use the appropriate `turtle.dig*` call.
|
||||
|
||||
## First-Iteration Assumptions
|
||||
|
||||
- The area around the turtle has clear walking space.
|
||||
- The program does not dig movement-path obstacles.
|
||||
- The turtle has a suitable pickaxe equipped for crystal mining.
|
||||
- Runtime validation must happen in CC:Tweaked/CraftOS-PC or in-game; local Lua execution is not available for this repo.
|
||||
|
||||
## Integration Notes
|
||||
|
||||
- Ship `programs/goo.lua` via `install.lua` `LIST_FILES`.
|
||||
- Document `goo` in `README.md`.
|
||||
- Add `turtle` to `.luacheckrc` globals.
|
||||
- Run `just check` after Lua edits.
|
||||
@ -1,157 +0,0 @@
|
||||
# Plan: Full AI CLI Integration Through Real Opencode
|
||||
|
||||
## Goal
|
||||
|
||||
Run the real ComputerCraft `ai` CLI against a real `opencode serve` process through the WebSocket bridge proxy, while using the fake provider/model fixture proven by `opencode-fake-provider-direct-plan.md`.
|
||||
|
||||
This test should cover the actual runtime chain used in-game.
|
||||
|
||||
## Dependency
|
||||
|
||||
Do not implement this plan until `.plans/opencode-fake-provider-direct-plan.md` has produced a working fake provider fixture.
|
||||
|
||||
Update this plan first with the concrete results from plan 1:
|
||||
|
||||
- Working fake provider plugin code shape.
|
||||
- Working opencode startup command/env.
|
||||
- Working readiness endpoint.
|
||||
- Any internal prompt behavior discovered.
|
||||
|
||||
## Desired Boundary
|
||||
|
||||
Real:
|
||||
|
||||
- CraftOS-PC harness
|
||||
- `/programs/ai.lua`
|
||||
- `/apis/libai.lua`
|
||||
- `/apis/libhttpws.lua`
|
||||
- `tools/mcp-bridge` opencode proxy
|
||||
- `opencode serve`
|
||||
- opencode sessions/messages/agents/model selection
|
||||
|
||||
Fake:
|
||||
|
||||
- The provider/model response behavior only, through the reusable fake provider fixture from plan 1
|
||||
|
||||
## Runtime Chain
|
||||
|
||||
```text
|
||||
CraftOS /programs/ai.lua
|
||||
-> libai.lua
|
||||
-> libhttpws.lua
|
||||
-> mcp-bridge opencode proxy
|
||||
-> real opencode serve
|
||||
-> fake provider/model
|
||||
```
|
||||
|
||||
## Test Fixture
|
||||
|
||||
Reuse the fake provider workspace generator from plan 1.
|
||||
|
||||
Response mappings needed for the CLI cases:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "match": "reply with exactly: pong", "reply": "pong" },
|
||||
{ "match": "fresh start", "reply": "new reply" },
|
||||
{ "match": "continue please", "reply": "plain reply" }
|
||||
]
|
||||
```
|
||||
|
||||
Keep the mapping fixture easy to extend so future CLI cases can add entries without changing provider code.
|
||||
|
||||
## CraftOS Wrapper
|
||||
|
||||
Create or update a Lua wrapper under:
|
||||
|
||||
- `tools/mcp-bridge/test-integration/lua/ai-cli-check.lua`
|
||||
|
||||
The wrapper should:
|
||||
|
||||
1. Accept the WebSocket proxy URL as its first argument.
|
||||
2. Clear stale settings:
|
||||
- `opencc.server_url`
|
||||
- `opencc.session_id`
|
||||
3. Set:
|
||||
- `opencc.bridge_url`
|
||||
- `opencc.request_timeout_seconds`
|
||||
4. Run:
|
||||
- `ai sessions`
|
||||
- `ai ping`
|
||||
- `ai new fresh start`
|
||||
- `ai continue please`
|
||||
5. Print markers around each command.
|
||||
6. Print persisted session markers after commands.
|
||||
7. Call `os.shutdown()`.
|
||||
|
||||
Expected marker examples:
|
||||
|
||||
```lua
|
||||
print('--- sessions ---');
|
||||
shell.run('/programs/ai.lua', 'sessions');
|
||||
|
||||
print('--- ping ---');
|
||||
shell.run('/programs/ai.lua', 'ping');
|
||||
print('SESSION_AFTER_PING=' .. tostring(settings.get('opencc.session_id')));
|
||||
|
||||
print('--- new ---');
|
||||
shell.run('/programs/ai.lua', 'new', 'fresh', 'start');
|
||||
print('SESSION_AFTER_NEW=' .. tostring(settings.get('opencc.session_id')));
|
||||
|
||||
print('--- ask ---');
|
||||
shell.run('/programs/ai.lua', 'continue', 'please');
|
||||
print('SESSION_AFTER_ASK=' .. tostring(settings.get('opencc.session_id')));
|
||||
```
|
||||
|
||||
## Node Test Implementation
|
||||
|
||||
Add or replace the current CLI integration test under:
|
||||
|
||||
- `tools/mcp-bridge/test-integration/ai-cli.test.ts`
|
||||
|
||||
Test steps:
|
||||
|
||||
1. Create temp fake-provider opencode workspace using the plan 1 fixture.
|
||||
2. Start `opencode serve` on a random local port.
|
||||
3. Poll until opencode is ready.
|
||||
4. Start `startOpencodeProxy({ opencodeUrl })`.
|
||||
5. Start CraftOS with:
|
||||
- `mountRepo: true`
|
||||
- `shellArgs: [proxyUrl]`
|
||||
- a generous timeout, likely `30_000` or higher depending on measured opencode startup time
|
||||
6. Assert CLI output includes:
|
||||
- `pong`
|
||||
- `new reply`
|
||||
- `plain reply`
|
||||
- session markers proving `ai new` replaces the session and plain `ai ...` reuses it
|
||||
7. Stop CraftOS, proxy, and opencode in `finally`.
|
||||
|
||||
## Useful Assertions
|
||||
|
||||
- `ai sessions` exits without an opencode transport/config error.
|
||||
- `ai ping` prints `pong`.
|
||||
- `ai new fresh start` prints `new reply`.
|
||||
- Plain `ai continue please` prints `plain reply`.
|
||||
- `SESSION_AFTER_NEW` is non-empty.
|
||||
- `SESSION_AFTER_ASK` equals `SESSION_AFTER_NEW`.
|
||||
- If `SESSION_AFTER_PING` is printed, decide whether ping should persist a session or whether `ai ping` should become non-persistent in a separate behavior change.
|
||||
|
||||
## Current Open Questions
|
||||
|
||||
- Should `ai ping` persist `opencc.session_id`? Current `programs/ai.lua` calls `ai.ping(askOptions())`, and `libai.ping` behavior must be checked before asserting this too tightly.
|
||||
- Should `ai sessions` be expected to show no sessions, one session, or just avoid failing before messages are created? Real opencode behavior may differ from the old fake HTTP server.
|
||||
- Does opencode generate a title/summary for each message during the synchronous `/message` call? If yes, the fake provider fallback must make that harmless.
|
||||
- What is the most stable way to choose a free opencode port in CI?
|
||||
|
||||
## Verification
|
||||
|
||||
After implementation, run:
|
||||
|
||||
```sh
|
||||
npx tsx --test test-integration/opencode-fake-provider.test.ts
|
||||
npx tsx --test test-integration/ai-cli.test.ts
|
||||
npm run check
|
||||
just check
|
||||
```
|
||||
|
||||
If the full test is too slow for the default integration suite, keep it as a separately named test command or document why it is excluded from `npm run test:integration`.
|
||||
@ -1,121 +0,0 @@
|
||||
# Plan: Direct Fake Provider Integration
|
||||
|
||||
## Goal
|
||||
|
||||
Prove that a real `opencode serve` process can run with a deterministic fake provider/model and answer HTTP API requests without calling an external LLM.
|
||||
|
||||
This plan deliberately stops before CraftOS, the WebSocket bridge, or `/programs/ai.lua`. It validates only the opencode-side fixture that the full integration test will reuse.
|
||||
|
||||
## Desired Boundary
|
||||
|
||||
Real:
|
||||
|
||||
- `opencode serve`
|
||||
- opencode config validation and loading
|
||||
- opencode session/message HTTP endpoints
|
||||
- opencode model/provider selection
|
||||
- opencode agent/title/summary plumbing as far as it is triggered by simple messages
|
||||
|
||||
Fake:
|
||||
|
||||
- The provider/model response behavior only
|
||||
|
||||
## Proposed Test Fixture
|
||||
|
||||
Create a test-only temporary opencode workspace during the test. Do not modify the project `.opencode/opencode.json` for this.
|
||||
|
||||
Files generated under a temp directory:
|
||||
|
||||
- `opencode.json`
|
||||
- `fake-provider.ts` or `fake-provider.js`
|
||||
- `fake-responses.json`
|
||||
|
||||
Example response mapping:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "match": "reply with exactly: pong", "reply": "pong" },
|
||||
{ "match": "fresh start", "reply": "new reply" },
|
||||
{ "match": "continue please", "reply": "plain reply" }
|
||||
]
|
||||
```
|
||||
|
||||
The fake provider should return the first response whose `match` appears in the final model prompt. Unknown prompts should return a deterministic fallback such as `ok` or `unhandled fake prompt`, not fail immediately, because opencode may issue title/summary/internal prompts.
|
||||
|
||||
## Config Shape To Validate
|
||||
|
||||
Use the published schema as the source of truth before finalizing fields.
|
||||
|
||||
Candidate config:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "traptest/fake",
|
||||
"small_model": "traptest/fake",
|
||||
"enabled_providers": ["traptest"],
|
||||
"plugin": ["./fake-provider.ts"],
|
||||
"provider": {
|
||||
"traptest": {
|
||||
"name": "Trap Test",
|
||||
"models": {
|
||||
"fake": {
|
||||
"id": "fake",
|
||||
"name": "Trap Test Fake Model",
|
||||
"limit": { "context": 100000, "output": 10000 },
|
||||
"cost": { "input": 0, "output": 0 },
|
||||
"status": "active"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"build": { "model": "traptest/fake" },
|
||||
"title": { "model": "traptest/fake" },
|
||||
"summary": { "model": "traptest/fake" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Open question: the exact plugin provider hook shape must be verified against opencode's runtime/plugin API. Do not guess this implementation from the config schema alone.
|
||||
|
||||
## Test Implementation
|
||||
|
||||
Add a Node integration test, likely under:
|
||||
|
||||
- `tools/mcp-bridge/test-integration/opencode-fake-provider.test.ts`
|
||||
|
||||
Test steps:
|
||||
|
||||
1. Create a temp directory.
|
||||
2. Write the test `opencode.json`.
|
||||
3. Write `fake-responses.json`.
|
||||
4. Write the fake provider plugin.
|
||||
5. Start `opencode serve` on `127.0.0.1` with a random free port.
|
||||
6. Wait for readiness by polling an HTTP endpoint such as `GET /session`.
|
||||
7. Call opencode HTTP directly:
|
||||
- `POST /session`
|
||||
- `POST /session/:id/message` with `reply with exactly: pong`
|
||||
- `POST /session/:id/message` with `fresh start`
|
||||
8. Assert the responses contain `pong` and `new reply`.
|
||||
9. Stop the opencode process and clean up the temp directory.
|
||||
|
||||
## Useful Assertions
|
||||
|
||||
- `opencode serve` starts successfully with the generated config.
|
||||
- `POST /session` returns a usable session ID.
|
||||
- `POST /session/:id/message` returns text from the fake mapping.
|
||||
- Unknown/internal prompts do not break the test fixture.
|
||||
- No external provider credentials are required.
|
||||
|
||||
## Result To Capture For Plan 2
|
||||
|
||||
After this plan is run, record:
|
||||
|
||||
- Exact working fake provider plugin API shape.
|
||||
- Exact command/env used to start `opencode serve` reliably.
|
||||
- Confirmed readiness endpoint and polling logic.
|
||||
- Whether title/summary/internal model calls happen during simple message requests.
|
||||
- Any required config fields not listed above.
|
||||
|
||||
Plan 2 should be updated with these facts before implementation.
|
||||
78
.plans/repo-fix-plan.md
Normal file
78
.plans/repo-fix-plan.md
Normal file
@ -0,0 +1,78 @@
|
||||
# Repo Fix Plan
|
||||
|
||||
## Findings
|
||||
|
||||
### Medium: Fresh install does not create `/servers`
|
||||
|
||||
Reference: `install.lua:32-38`
|
||||
|
||||
`LIST_FILES` downloads files under `servers/`, but `install.lua` only creates `/programs`, `/apis`, and `/startup`. On a fresh ComputerCraft machine, `wget ... servers/ping-server.lua` can fail because the parent directory does not exist.
|
||||
|
||||
Fix:
|
||||
- Add `fs.makeDir('/servers');` before downloading files.
|
||||
- Bump `install.lua` `_VERSION`.
|
||||
|
||||
### Medium: `cube set-boot <machineId>` cannot clear boot safely
|
||||
|
||||
Reference: `programs/cube.lua:177-204`, `servers/cube-server.lua:57-60`
|
||||
|
||||
The documented behavior says an empty command deletes the boot hook. The client sends `nil` when no command is provided, and the server calls `writeFile('/.cubeboot', startupCommand)`. Writing `nil` can error, and even an empty string currently leaves an empty file instead of deleting `/.cubeboot`.
|
||||
|
||||
Fix:
|
||||
- In `servers/cube-server.lua`, if `startupCommand == nil or startupCommand == ''`, delete `/.cubeboot` and reply `true`.
|
||||
- Otherwise write the command.
|
||||
- Bump `servers/cube-server.lua` `_VERSION`.
|
||||
- Optionally adjust the client message to say `boot DELETED` only when the server replies successfully.
|
||||
|
||||
### Medium: `deploy-file` reports success even when writes fail
|
||||
|
||||
Reference: `servers/cube-server.lua:62-66`, `programs/cube.lua:242-248`
|
||||
|
||||
`deploy-file` ignores the return value from `writeFile` and always replies `true`. If a parent directory is missing, the destination is read-only, or `fs.open` fails, the client counts the file as transferred.
|
||||
|
||||
Fix:
|
||||
- Reply with the boolean result of `writeFile`.
|
||||
- Have the client keep the existing error print when `res` is false.
|
||||
- Bump `servers/cube-server.lua` `_VERSION`.
|
||||
|
||||
### Medium: Deployment cannot create new nested directories
|
||||
|
||||
Reference: `servers/cube-server.lua:24-35`, `programs/cube.lua:20-38`
|
||||
|
||||
`cube deploy` sends file paths recursively, but the server writes files directly without creating parent directories. This fails for new directories that do not already exist on the target cube.
|
||||
|
||||
Fix:
|
||||
- Before writing a deployed file, create its parent directory when needed.
|
||||
- Keep behavior minimal: derive the parent path from `payload.path`, call `fs.makeDir(parent)` if not empty and not present, then write.
|
||||
- Return false if `payload` is malformed or the file write fails.
|
||||
|
||||
### Low: `set-boot` help says `[command]`, but only one argument is accepted
|
||||
|
||||
Reference: `programs/cube.lua:6`, `programs/cube.lua:325`
|
||||
|
||||
`local cubeCommand, firstArg, secondArg = ...` means `cube set-boot 12 mining turtle start` only sends `mining`; extra words are dropped. This matters for shell commands with spaces.
|
||||
|
||||
Fix:
|
||||
- Use `table.pack(...)` to collect all arguments.
|
||||
- For `set-boot`, concatenate arguments after the machine id with spaces.
|
||||
- Preserve existing aliases and help behavior.
|
||||
- Bump `programs/cube.lua` `_VERSION`.
|
||||
|
||||
## Proposed Order
|
||||
|
||||
1. Fix `install.lua` to create `/servers`.
|
||||
2. Fix `cube-server.lua` boot clearing and deploy write reporting.
|
||||
3. Add parent-directory creation for deployed files.
|
||||
4. Fix multi-word `cube set-boot` command parsing.
|
||||
5. Update module versions for changed files.
|
||||
|
||||
## Verification
|
||||
|
||||
Manual/runtime verification is required inside ComputerCraft or CraftOS-PC because this repo has no runnable local test harness.
|
||||
|
||||
Suggested checks:
|
||||
- On a clean machine, run the installer and confirm `/servers/*.lua` downloads.
|
||||
- Run `cube set-boot <id>` and confirm `/.cubeboot` is deleted remotely.
|
||||
- Run `cube set-boot <id> program arg` and confirm the full command is stored.
|
||||
- Deploy a file inside a new nested directory and confirm it exists remotely.
|
||||
- Force a bad deploy path or write failure and confirm the client reports an error instead of counting success.
|
||||
51
AGENTS.md
51
AGENTS.md
@ -1,51 +0,0 @@
|
||||
# AGENTS / CLAUDE.md
|
||||
|
||||
Concise guidance for agents working in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
ComputerCraft / CC:Tweaked Lua APIs, servers, and programs for Minecraft 1.21. Code runs in the ComputerCraft sandbox, not standard Lua.
|
||||
|
||||
Use [`docs/README.md`](docs/README.md) as the entrypoint for CC:Tweaked, CraftOS-PC, Advanced Peripherals, and Create integration documentation links. Use [`docs/adrs/README.md`](docs/adrs/README.md) for repository architecture decisions.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not add a standalone Lua test harness unless asked. Local execution happens through the CraftOS-PC harness (see [`docs/install-craftos-pc.md`](docs/install-craftos-pc.md), [`docs/craftos_pc_glossary.md`](docs/craftos_pc_glossary.md), and [ADR-0005](docs/adrs/adr-0005-craftos-pc-harness-and-probes.md)); code otherwise executes in-game.
|
||||
- Do not run `just repl` as an LLM agent; it is a human-only interactive CraftOS-PC wrapper. Use `just trapos-exec '<lua>'` for automated probes against the TrapOS dev environment, or `just craftos-exec '<lua>'` for probes against vanilla CraftOS (no TrapOS mounts). These wrappers shut down the machine and include a host watchdog. Headless probes are the recommended way to verify hypotheses about CC:Tweaked behavior; see [ADR-0005](docs/adrs/adr-0005-craftos-pc-harness-and-probes.md).
|
||||
- When changing behavior, add as many useful CraftOS-PC tests as practical. It is acceptable to skip tests that require human-only validation, such as complex turtle motion, in-game UX feel, or visual approval, but still add unit-style non-regression tests for deterministic parts when possible.
|
||||
- Use `/apis/libtest.lua` for test scripts under `tests/`; `/programs/runtest.lua` prints `__TRAPOS_TEST_OK__` only after the suite passes.
|
||||
- `libtest` cancels each case after `3`s (`--timeout <s>` / `--no-timeout` to override); never commit a hanging test to `tests/`. Slow harness fixtures go in `tests/harness/` behind dedicated recipes. See [ADR-0007](docs/adrs/adr-0007-test-framework.md).
|
||||
- Git hooks own commit/push verification: pre-commit runs `just check test`, and pre-push runs `just ci`. When explicitly asked to commit and/or push, do not run `just test` manually first; rely on the hooks. See [ADR-0011](docs/adrs/adr-0011-repo-conventions.md).
|
||||
- After editing Lua or Markdown, run `just check` and fix all `luacheck` warnings and `lychee` broken-link reports (markdown link validation is offline-only via `just lint-markdown`).
|
||||
- Use 2-space indent, semicolons, and `local function`.
|
||||
- `require` paths are absolute ComputerCraft paths, for example `require('/apis/net')()`.
|
||||
- Most API modules return factories; call the required module once before use.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `apis/eventloop.lua` is the single-threaded event loop around `os.pullEventRaw`; consider using it everywhere async behavior is needed. A handler that returns `api.STOP` auto-unregisters.
|
||||
- `startup/servers.lua` creates a single boot eventloop at `_G.bootEventLoop` and runs it alongside the shell via `parallel.waitForAny`. Servers register handlers on it and return; programs call services via `os.pullEvent` without touching the loop. See [ADR-0002](docs/adrs/adr-0002-eventloop-and-service-bus.md).
|
||||
- `apis/libtest.lua` is the lightweight test helper used by scripts under `tests/`; `/programs/runtest.lua` discovers tests, renders suite output, and owns the `__TRAPOS_TEST_OK__` success marker.
|
||||
- `apis/net.lua` is a service-name bus on a single channel (`10`). `net.serve(name, handler)` registers a server handler; `net.call(name, payload, { destId, timeout })` and `net.send(name, payload, { destId })` are the client surface. `require('/apis/net')()` returns a singleton bound to `_G.bootEventLoop` when present, otherwise an ephemeral instance.
|
||||
- A router (`/programs/router.lua`) must be running somewhere on the network; it registers a `modem_message` handler that stamps `routerId`, resolves label-addressed packets via a TTL map populated by `servers/net-registrar.lua`, and rebroadcasts. Without it, packets stay unrouted and consumers ignore them.
|
||||
- `servers/` register handlers on the boot eventloop and return; `programs/` are clients that exit.
|
||||
- Single well-known channel: `10` (the bus). Service multiplexing happens inside the packet body.
|
||||
|
||||
## Boot And Install
|
||||
|
||||
- `startup/servers.lua` creates the boot eventloop, runs autostart server files (which register handlers and return), then runs the shell and the eventloop in parallel via `parallel.waitForAny`.
|
||||
- Preserve `periphemu` guards used for CraftOS-PC emulation; see [`docs/craftos_pc_glossary.md`](docs/craftos_pc_glossary.md) for upstream emulator references.
|
||||
- TrapOS ships as packages, each described by `packages/<name>/ccpm.json` (`name`, `version`, `dependencies`, `files`, `autostart`); `packages/index.json` lists them. Source files stay in place — descriptors only reference them. To ship a new file, add it to the right package's `files` (and `autostart` if it is a server). `packages/trapos/ccpm.json` is the full OS meta-package. See [ADR-0010](docs/adrs/adr-0010-ccpm-package-manager.md).
|
||||
- `install-ccpm.lua` is the one-time wget bootstrap. It installs only `trapos-core`/`ccpm`, seeds the default `guillaumearm/cc-libs` registry as a `gitea` registry on `git.trapcloud.fr` tracking `master` (or `next` with `--beta`), and tells users to run `ccpm update` then `ccpm install trapos`. The legacy `github` registry type still resolves but is deprecated.
|
||||
- `ccpm` (in `trapos-core`) is the package manager: `apis/libccpm.lua` is the testable core (factory with injectable `http`/`stateDir`/`installRoot`), `programs/ccpm.lua` the CLI. State: `/trapos/ccpm.json` (registries), `/trapos/ccpm.lock.json` (installed packages), and `/trapos/ccpm.cache.json` (available packages from `ccpm update`). Never use the word "manifest" in ccpm — it is reserved for the OS manifest.
|
||||
- Add new servers to `startup/servers.lua` as needed.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Bump the owning `packages/<name>/ccpm.json` version (and mirror it in `packages/index.json`) when changing module behavior; programs report it at runtime via `require('/apis/libversion')().forSelf()`. `install-ccpm.lua` is the only file that still carries its own `_VERSION` because it is the wget bootstrap and lives outside the package system.
|
||||
- Programs support `-version`/`--version` and `-help`/`--help`; router also supports `-silent`/`--silent`.
|
||||
- French or English comments are fine; match surrounding code.
|
||||
- Commit messages use lightweight conventional style: `topic(scope): description` or `topic: description`.
|
||||
- Reference other `.md` files (and `ADR-####`) with `[text](path)` link syntax so `just lint-markdown` (lychee) can validate them. See [ADR-0011](docs/adrs/adr-0011-repo-conventions.md).
|
||||
|
||||
See [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.
|
||||
@ -1,109 +0,0 @@
|
||||
# ATM Expert Agent Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Replace `.opencode/agent/computercraft.md` with `atm10-expert`.
|
||||
|
||||
The agent targets ATM10 / All The Mods 10, Minecraft `1.21.1`, NeoForge, pack version `7.0` (`0.7.0` accepted as user alias).
|
||||
|
||||
## Known Facts
|
||||
|
||||
CurseForge project id: `925200`.
|
||||
|
||||
ATM10 `7.0` file id: `8091114`.
|
||||
|
||||
Server pack file id: `8094893`.
|
||||
|
||||
NeoForge version: `21.1.228`.
|
||||
|
||||
GitHub source: <https://github.com/AllTheMods/ATM-10>
|
||||
|
||||
## Files To Create
|
||||
|
||||
Create `.opencode/agent/atm10-expert.md`.
|
||||
|
||||
Create `.opencode/agent-context/atm10-expert/INDEX.md`.
|
||||
|
||||
Create `.opencode/agent-context/atm10-expert/glossaries/`.
|
||||
|
||||
Create `.opencode/agent-context/atm10-expert/scripts/`.
|
||||
|
||||
Create `.opencode/agent-context/atm10-expert/modpacks/atm10-7.0.md`.
|
||||
|
||||
## Files To Move
|
||||
|
||||
Move in-game glossaries only:
|
||||
|
||||
`docs/cc_glossary.md` to agent context.
|
||||
|
||||
`docs/create_cc_tweaked_glossary.md` to agent context.
|
||||
|
||||
`docs/advanced_peripherals_glossary.md` to agent context.
|
||||
|
||||
Keep `docs/craftos_pc_glossary.md` in `docs/`.
|
||||
|
||||
Update `docs/README.md` links.
|
||||
|
||||
## Agent Behavior
|
||||
|
||||
Delete old `computercraft` agent.
|
||||
|
||||
New agent answers players concisely for in-game terminals.
|
||||
|
||||
Use local glossaries first, then websearch/webfetch.
|
||||
|
||||
Default to non-technical answers.
|
||||
|
||||
Adapt language to the user, usually French.
|
||||
|
||||
If a new useful mod documentation source is found, update or create a glossary and update `INDEX.md`.
|
||||
|
||||
Keep replies short unless the user asks for detail.
|
||||
|
||||
Use MCP bridge carefully: probe first, execute only bounded/read-only Lua unless explicitly asked.
|
||||
|
||||
## Permissions
|
||||
|
||||
Allow local read/search and web lookup.
|
||||
|
||||
Keep MCP bridge `probe-computers` and `exec-lua`.
|
||||
|
||||
Make edit/bash approval-gated or tightly limited, because this agent is player-facing.
|
||||
|
||||
Allow `just check` for markdown validation.
|
||||
|
||||
## ATM10 Mod List Script
|
||||
|
||||
Add `.opencode/agent-context/atm10-expert/scripts/fetch-atm10-7.0-mods.sh`.
|
||||
|
||||
Script should download CurseForge file `8091114`.
|
||||
|
||||
Extract `manifest.json`.
|
||||
|
||||
Write raw project/file ids and best available names to `modpacks/atm10-7.0.md`.
|
||||
|
||||
If `CURSEFORGE_API_KEY` exists, resolve project names through the CurseForge API.
|
||||
|
||||
Without API key, preserve ids and URLs for later glossary enrichment.
|
||||
|
||||
## TrapOS AI Context
|
||||
|
||||
Update `programs/ai.lua` / `apis/libai.lua` so each prompt includes hidden caller context:
|
||||
|
||||
computer id.
|
||||
|
||||
computer label, if any.
|
||||
|
||||
This helps `atm10-expert` know which in-game computer is calling.
|
||||
|
||||
Add tests in `tests/ai.lua`.
|
||||
|
||||
Bump `packages/trapos-ai/ccpm.json` and `packages/index.json`.
|
||||
|
||||
## Verification
|
||||
|
||||
Run `just check`.
|
||||
|
||||
Fix Lua lint and markdown link issues.
|
||||
|
||||
Restart opencode after agent/config changes.
|
||||
41
CLAUDE.md
Normal file
41
CLAUDE.md
Normal file
@ -0,0 +1,41 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Concise guidance for agents working in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
ComputerCraft / CC:Tweaked Lua APIs, servers, and programs for Minecraft 1.21. Code runs in the ComputerCraft sandbox, not standard Lua.
|
||||
|
||||
Use `docs/README.md` as the entrypoint for CC:Tweaked, Advanced Peripherals, and Create integration documentation links. Use `docs/adrs/README.md` for repository architecture decisions.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not run Lua locally or add a test harness unless asked; code executes in-game or CraftOS-PC.
|
||||
- After editing Lua, run `just check` and fix all `luacheck` warnings.
|
||||
- Use 2-space indent, semicolons, and `local function`.
|
||||
- `require` paths are absolute ComputerCraft paths, for example `require('/apis/net')()`.
|
||||
- Most API modules return factories; call the required module once before use.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `apis/eventloop.lua` is the single-threaded event loop around `os.pullEventRaw`; consider using it everywhere async behavior is needed. A handler that returns `api.STOP` auto-unregisters.
|
||||
- `apis/net.lua` builds modem packet messaging, routing, and request/response RPC on the event loop. `sendRequest` returns `ok, result, packet` and defaults to a 0.5s timeout.
|
||||
- A router (`/programs/router.lua`) must be running somewhere on the network; without it, packets lack `routerId`, `isPacketOk` rejects them, and cross-machine messaging silently fails.
|
||||
- `servers/` listen for requests and start loops; `programs/` are clients that send requests and exit.
|
||||
- Well-known channels: `9` ping, `10` router/default routing. Keep duplicated constants in sync.
|
||||
|
||||
## Boot And Install
|
||||
|
||||
- `startup/servers.lua` starts `/programs`, the shell, and configured servers via `parallel.waitForAll`.
|
||||
- Preserve `periphemu` guards used for CraftOS-PC emulation.
|
||||
- `install.lua` downloads files listed in `LIST_FILES` from `master` by default, or from `next` with `--beta`; add shipped files there.
|
||||
- Add new servers to `startup/servers.lua` as needed.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Bump `local _VERSION = '...'` when changing module behavior.
|
||||
- Programs support `-version`/`--version` and `-help`/`--help`; router also supports `-silent`/`--silent`.
|
||||
- French or English comments are fine; match surrounding code.
|
||||
- Commit messages use lightweight conventional style: `topic(scope): description` or `topic: description`.
|
||||
|
||||
See `DEVELOPMENT.md` for local setup.
|
||||
@ -1,18 +1,13 @@
|
||||
# Development
|
||||
|
||||
## Installation
|
||||
Requirements:
|
||||
- `just`
|
||||
- `luacheck`
|
||||
|
||||
Install `just`, `jq`, `luacheck`, `openssl`, `lychee` (`brew install lychee`, or `cargo install lychee` if you already have a Rust toolchain), and [CraftOS-PC v2.8.3+](docs/install-craftos-pc.md), then run:
|
||||
After cloning the repository, run:
|
||||
|
||||
```sh
|
||||
just install trapos-install && echo ok
|
||||
just install
|
||||
```
|
||||
|
||||
This installs local Git hooks, checks required tools, generates `.env`, and verifies a full TrapOS install on a fresh CraftOS-PC state.
|
||||
|
||||
## Cleaning
|
||||
|
||||
- `just clean` — clears the `mcp-bridge` build caches (`node_modules/.cache/tsc`, `node_modules/.cache/eslint`). Safe to run anytime; `just reinstall` chains it with `just install`.
|
||||
- `just clean-env` — deletes `.env`. Run only when you actually want to drop locally generated tokens.
|
||||
|
||||
See [`docs/README.md`](docs/README.md) for repository docs and [`docs/adrs/README.md`](docs/adrs/README.md) for architecture decisions.
|
||||
This installs the local Git hooks, including a pre-push hook that runs `just check`.
|
||||
|
||||
24
Justfile
24
Justfile
@ -1,14 +1,20 @@
|
||||
# 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'
|
||||
# Run `just check` to lint all Lua code with luacheck.
|
||||
|
||||
# List available recipes.
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# Install local development tooling.
|
||||
install: install-git-hooks
|
||||
|
||||
# Install Git hooks for this repository.
|
||||
install-git-hooks:
|
||||
@mkdir -p .git/hooks
|
||||
@printf '%s\n' '#!/bin/sh' '' 'just check' > .git/hooks/pre-push
|
||||
@chmod +x .git/hooks/pre-push
|
||||
@printf '%s\n' 'Installed .git/hooks/pre-push'
|
||||
|
||||
# Lint all Lua source with luacheck.
|
||||
check:
|
||||
luacheck .
|
||||
|
||||
64
README.md
64
README.md
@ -4,70 +4,22 @@ A small in-game operating system for ComputerCraft / CC:Tweaked, built around a
|
||||
|
||||
## Installation
|
||||
|
||||
Install `ccpm` first. This is the only step that needs `wget` with a URL:
|
||||
```
|
||||
wget run https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/master/install-ccpm.lua
|
||||
wget run https://raw.githubusercontent.com/guillaumearm/cc-libs/master/install.lua
|
||||
```
|
||||
|
||||
Then sync the default registry (`guillaumearm/cc-libs`) and install TrapOS:
|
||||
Install the beta branch (one-time opt-in, asks for confirmation):
|
||||
```
|
||||
ccpm update
|
||||
ccpm install trapos
|
||||
wget run https://raw.githubusercontent.com/guillaumearm/cc-libs/next/install.lua --beta
|
||||
```
|
||||
|
||||
Install individual packages instead if you want to cherry-pick:
|
||||
```
|
||||
> ccpm install trapos-net
|
||||
> ccpm install trapos-ui
|
||||
```
|
||||
|
||||
Install `ccpm` from the beta branch (one-time opt-in, asks for confirmation):
|
||||
```
|
||||
wget run https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/next/install-ccpm.lua --beta
|
||||
```
|
||||
|
||||
Once `ccpm` is installed from beta, the default registry tracks `next`; `ccpm update` and `ccpm upgrade` keep using that branch.
|
||||
Once a machine is on beta, `upgrade` keeps it on beta — `--beta` is not needed again. Use `upgrade --stable` to go back to the stable branch.
|
||||
|
||||
After install, every boot shows a colored MOTD with the installed version and branch (lime for stable, orange + `[BETA]` for beta).
|
||||
|
||||
## Packages
|
||||
## Manifest
|
||||
|
||||
TrapOS is split into packages, each described by a `packages/<name>/ccpm.json`:
|
||||
|
||||
- `trapos-core`: the package manager (`ccpm`), event loop, `upgrade`, `events`.
|
||||
- `trapos-test`: the test framework (`libtest`) and suite runner (`runtest`).
|
||||
- `trapos-boot`: the startup MOTD and autostart server launcher.
|
||||
- `trapos-net`: routed modem networking (`net`, `router`, `ping`, `ping-server`).
|
||||
- `trapos-ui`: the terminal UI toolkit (`libtui`, `tuidemo`).
|
||||
- `trapos-ai`: the AI client for `opencode serve`.
|
||||
- `trapos`: full TrapOS meta-package (`trapos-boot`, `trapos-net`, `trapos-ui`, `trapos-test`, and `trapos-ai` during beta).
|
||||
|
||||
The `trapos` meta-package is the user-facing full install. Package descriptors list
|
||||
files and autostart servers; installed state is tracked under `/trapos`.
|
||||
|
||||
## ccpm
|
||||
|
||||
`ccpm` is the TrapOS package manager. `install-ccpm.lua` installs it by installing
|
||||
the required `trapos-core` package and configures the default registry
|
||||
(`guillaumearm/cc-libs`).
|
||||
|
||||
```
|
||||
ccpm install <package> ccpm reinstall <package> ccpm uninstall <package>
|
||||
ccpm update ccpm upgrade
|
||||
ccpm ls ccpm available [term] ccpm search [term]
|
||||
ccpm info <package>
|
||||
ccpm registry ls ccpm registry add <name> [--branch <b>] [--type gitea|github|http]
|
||||
ccpm registry rm <name>
|
||||
```
|
||||
|
||||
`ccpm update` fetches registry package indexes into `/trapos/ccpm.cache.json`.
|
||||
`ccpm available` lists cached packages and marks installed packages as up-to-date or
|
||||
updatable. `ccpm upgrade` upgrades installed packages based on that cache.
|
||||
|
||||
Registries default to GitHub (`owner/repo`); `http`/`https` base URLs are also
|
||||
supported. State lives in `/trapos/ccpm.json` (registries),
|
||||
`/trapos/ccpm.lock.json` (installed packages), and `/trapos/ccpm.cache.json`
|
||||
(available packages). See [ADR-0010](docs/adrs/adr-0010-ccpm-package-manager.md).
|
||||
The installed file list, version, autostart servers, and current branch all live in `manifest.json` at the repo root. A copy is persisted locally at `/trapos/manifest.json` after install, and drives `upgrade`, `startup/motd.lua`, and `startup/servers.lua`.
|
||||
|
||||
## APIs
|
||||
- `/apis/eventloop`: a simple event loop API.
|
||||
@ -82,7 +34,7 @@ Servers listed in `manifest.autostart` are launched at boot by `startup/servers.
|
||||
- `router`: routes messages. You need to set up a router to use all `apis/net`-based programs and libraries.
|
||||
- `ping`: pings machines using `apis/net`.
|
||||
- `events`: emits and logs computer events.
|
||||
- `upgrade`: alias for `ccpm upgrade`.
|
||||
- `upgrade`: upgrades the machine. Reads `/trapos/manifest.json` to stay on the current branch; use `--beta` to opt in or `--stable` to opt out.
|
||||
|
||||
## Development
|
||||
See [DEVELOPMENT.md](./DEVELOPMENT.md) for local development installation.
|
||||
See [DEVELOPMENT.md](./DEVELOPMENT.md) for development setup and workflow.
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
local _VERSION = '2.0.0'
|
||||
|
||||
-- Basic event loop library for computer craft
|
||||
--
|
||||
-- Example usage:
|
||||
|
||||
622
apis/libai.lua
622
apis/libai.lua
@ -1,622 +0,0 @@
|
||||
local PING_PROMPT = 'reply with exactly: pong';
|
||||
|
||||
local DEFAULT_TIMEOUT_SECONDS = 60;
|
||||
local MAX_TIMEOUT_SECONDS = 60;
|
||||
local DEFAULT_REQUEST_TIMEOUT_SECONDS = 600;
|
||||
local MAX_REQUEST_TIMEOUT_SECONDS = 600;
|
||||
local DEFAULT_LUA_EXEC_MAX_RETRIES = 2;
|
||||
local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5;
|
||||
local DEFAULT_SESSION_SETTING_KEY = 'opencc.session_id';
|
||||
local DEFAULT_AGENT_SETTING_KEY = 'opencc.agent';
|
||||
local DEFAULT_VARIANT_SETTING_KEY = 'opencc.variant';
|
||||
local DEFAULT_BRIDGE_SETTING_KEY = 'opencc.bridge_url';
|
||||
|
||||
local createHttp = require('/apis/libhttp');
|
||||
local createHttpWs = require('/apis/libhttpws');
|
||||
|
||||
local function isWsUrl(url)
|
||||
return type(url) == 'string' and string.match(url, '^wss?://') ~= nil;
|
||||
end
|
||||
|
||||
local function isBlank(s)
|
||||
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
|
||||
end
|
||||
|
||||
local function extractTextParts(parts)
|
||||
if type(parts) ~= 'table' then
|
||||
return '';
|
||||
end
|
||||
local texts = {};
|
||||
for _, part in ipairs(parts) do
|
||||
if type(part) == 'table' and part.type == 'text' and type(part.text) == 'string' then
|
||||
texts[#texts + 1] = part.text;
|
||||
end
|
||||
end
|
||||
return table.concat(texts, '');
|
||||
end
|
||||
|
||||
local function tablePack(...)
|
||||
return { n = select('#', ...), ... };
|
||||
end
|
||||
|
||||
local function endsWithNewline(s)
|
||||
return type(s) == 'string' and string.sub(s, -1) == '\n';
|
||||
end
|
||||
|
||||
local function valuesToLine(values, first, last)
|
||||
local parts = {};
|
||||
for i = first, last do
|
||||
parts[#parts + 1] = tostring(values[i]);
|
||||
end
|
||||
return table.concat(parts, '\t');
|
||||
end
|
||||
|
||||
local function classifyLuaRuntimeError(err)
|
||||
local text = tostring(err or '');
|
||||
if string.find(text, 'attempt to', 1, true) and string.find(text, 'nil value', 1, true) then
|
||||
return 'identifier';
|
||||
end
|
||||
if string.find(text, 'global', 1, true) and string.find(text, 'nil', 1, true) then
|
||||
return 'identifier';
|
||||
end
|
||||
return 'other';
|
||||
end
|
||||
|
||||
local function renderOutput(output)
|
||||
if output == nil or output == '' then
|
||||
return '(no output)';
|
||||
end
|
||||
return output;
|
||||
end
|
||||
|
||||
local function buildLuaExecPrompt(userPrompt)
|
||||
return table.concat({
|
||||
'Write ComputerCraft Lua code to answer this user request.',
|
||||
'Reply with raw Lua code only. Do not use markdown fences or explanations.',
|
||||
'The code runs locally with normal ComputerCraft globals available.',
|
||||
'Use print() or write() for values that should be sent back. Returned values are captured too.',
|
||||
'',
|
||||
'User request:',
|
||||
userPrompt,
|
||||
}, '\n');
|
||||
end
|
||||
|
||||
local function buildLuaCorrectionPrompt(userPrompt, code, err, errorKind)
|
||||
return table.concat({
|
||||
'The previous ComputerCraft Lua failed.',
|
||||
'Reply with corrected raw Lua code only. Do not use markdown fences or explanations.',
|
||||
'',
|
||||
'Original user request:',
|
||||
userPrompt,
|
||||
'',
|
||||
'Error kind: ' .. tostring(errorKind),
|
||||
'Error:',
|
||||
tostring(err),
|
||||
'',
|
||||
'Previous code:',
|
||||
code,
|
||||
}, '\n');
|
||||
end
|
||||
|
||||
local function buildLuaOutputPrompt(userPrompt, output)
|
||||
return table.concat({
|
||||
'The Lua executed successfully.',
|
||||
'Answer the original user request in natural language using the output below.',
|
||||
'Do not write more Lua unless the user explicitly asked for code.',
|
||||
'',
|
||||
'Original user request:',
|
||||
userPrompt,
|
||||
'',
|
||||
'Lua output:',
|
||||
renderOutput(output),
|
||||
}, '\n');
|
||||
end
|
||||
|
||||
local function readOsValue(osLib, name)
|
||||
if type(osLib) ~= 'table' or type(osLib[name]) ~= 'function' then
|
||||
return nil;
|
||||
end
|
||||
local ok, value = pcall(osLib[name]);
|
||||
if not ok then return nil; end
|
||||
return value;
|
||||
end
|
||||
|
||||
local function buildPromptWithCallerContext(prompt, osLib)
|
||||
local lines = {
|
||||
'<caller-context hidden="true">',
|
||||
'Use this context silently to identify the in-game ComputerCraft caller.',
|
||||
};
|
||||
local computerId = readOsValue(osLib, 'getComputerID');
|
||||
local computerLabel = readOsValue(osLib, 'getComputerLabel');
|
||||
if computerId ~= nil then
|
||||
lines[#lines + 1] = 'computer id: ' .. tostring(computerId);
|
||||
end
|
||||
if not isBlank(computerLabel) then
|
||||
lines[#lines + 1] = 'computer label: ' .. tostring(computerLabel);
|
||||
end
|
||||
lines[#lines + 1] = '</caller-context>';
|
||||
lines[#lines + 1] = '';
|
||||
lines[#lines + 1] = 'User prompt:';
|
||||
lines[#lines + 1] = prompt;
|
||||
return table.concat(lines, '\n');
|
||||
end
|
||||
|
||||
local function sessionTime(session)
|
||||
if type(session) ~= 'table' or type(session.time) ~= 'table' then
|
||||
return 0;
|
||||
end
|
||||
return tonumber(session.time.updated or session.time.created) or 0;
|
||||
end
|
||||
|
||||
local function createAi(opts)
|
||||
opts = opts or {};
|
||||
|
||||
local httpLib = opts.http or http;
|
||||
local settingsLib = opts.settings or settings;
|
||||
local osLib = opts.os or os;
|
||||
|
||||
local function resolveRequestTimeout()
|
||||
local raw = settingsLib.get('opencc.request_timeout_seconds');
|
||||
local n = tonumber(raw);
|
||||
if not n or n <= 0 then n = DEFAULT_REQUEST_TIMEOUT_SECONDS; end
|
||||
if n > MAX_REQUEST_TIMEOUT_SECONDS then n = MAX_REQUEST_TIMEOUT_SECONDS; end
|
||||
return n;
|
||||
end
|
||||
|
||||
-- A ws:// bridge routes opencode calls through the mcp-bridge proxy, escaping
|
||||
-- ComputerCraft's hard ~60s http timeout. Falls back to direct http otherwise.
|
||||
local bridgeUrl = opts.bridgeUrl or settingsLib.get(DEFAULT_BRIDGE_SETTING_KEY);
|
||||
if isBlank(bridgeUrl) and isWsUrl(settingsLib.get('opencc.server_url')) then
|
||||
bridgeUrl = settingsLib.get('opencc.server_url');
|
||||
end
|
||||
local bridgeActive = not isBlank(bridgeUrl);
|
||||
|
||||
local httpClient = opts.httpClient;
|
||||
if not httpClient then
|
||||
if bridgeActive then
|
||||
httpClient = createHttpWs({
|
||||
http = httpLib,
|
||||
bridgeUrl = bridgeUrl,
|
||||
receiveTimeout = resolveRequestTimeout(),
|
||||
});
|
||||
else
|
||||
httpClient = createHttp({ http = httpLib });
|
||||
end
|
||||
end
|
||||
|
||||
local api = {};
|
||||
|
||||
local function resolveTimeout(options)
|
||||
local raw = options.timeoutSeconds;
|
||||
if raw == nil then raw = settingsLib.get('opencc.timeout_seconds'); end
|
||||
local n = tonumber(raw);
|
||||
if not n or n <= 0 then n = DEFAULT_TIMEOUT_SECONDS; end
|
||||
if n > MAX_TIMEOUT_SECONDS then n = MAX_TIMEOUT_SECONDS; end
|
||||
return n;
|
||||
end
|
||||
|
||||
local function resolveLuaExecMaxRetries(options)
|
||||
local n = tonumber(options.maxRetries);
|
||||
if n and n >= 0 then return math.floor(n); end
|
||||
return DEFAULT_LUA_EXEC_MAX_RETRIES;
|
||||
end
|
||||
|
||||
local function resolveLuaExecTimeout(options)
|
||||
if options.luaTimeoutSeconds == false then return nil; end
|
||||
local n = tonumber(options.luaTimeoutSeconds);
|
||||
if n and n > 0 then return n; end
|
||||
return DEFAULT_LUA_EXEC_TIMEOUT_SECONDS;
|
||||
end
|
||||
|
||||
local function resolveModel(options)
|
||||
local providerId = options.providerID or settingsLib.get('opencc.provider_id');
|
||||
local modelId = options.modelID or settingsLib.get('opencc.model_id');
|
||||
if isBlank(providerId) or isBlank(modelId) then
|
||||
return nil, nil;
|
||||
end
|
||||
return providerId, modelId;
|
||||
end
|
||||
|
||||
local function resolveAgent(options)
|
||||
local agent = options.agent;
|
||||
if agent == nil then agent = settingsLib.get(DEFAULT_AGENT_SETTING_KEY); end
|
||||
if isBlank(agent) then return nil; end
|
||||
return agent;
|
||||
end
|
||||
|
||||
local function resolveVariant(options)
|
||||
local variant = options.variant;
|
||||
if variant == nil then variant = settingsLib.get(DEFAULT_VARIANT_SETTING_KEY); end
|
||||
if isBlank(variant) then return nil; end
|
||||
return variant;
|
||||
end
|
||||
|
||||
local function resolveConfig(options)
|
||||
local url = options.serverUrl or settingsLib.get('opencc.server_url');
|
||||
-- In bridge mode the proxy holds the real opencode URL, so server_url is optional.
|
||||
if isBlank(url) then
|
||||
if not bridgeActive then
|
||||
return nil, 'missing opencc.server_url; run: set opencc.server_url <url>';
|
||||
end
|
||||
url = '';
|
||||
end
|
||||
local username = options.username or settingsLib.get('opencc.username') or 'opencode';
|
||||
local password = options.password or settingsLib.get('opencc.password') or '';
|
||||
local directory = options.directory or settingsLib.get('opencc.directory');
|
||||
local providerId, modelId = resolveModel(options);
|
||||
return {
|
||||
url = httpClient.trimTrailingSlash(url),
|
||||
username = username,
|
||||
password = password,
|
||||
directory = directory,
|
||||
providerID = providerId,
|
||||
modelID = modelId,
|
||||
agent = resolveAgent(options),
|
||||
variant = resolveVariant(options),
|
||||
timeoutSeconds = resolveTimeout(options),
|
||||
};
|
||||
end
|
||||
|
||||
local function buildMessageBody(cfg, prompt)
|
||||
local body = {
|
||||
parts = { { type = 'text', text = prompt } },
|
||||
};
|
||||
if cfg.providerID and cfg.modelID then
|
||||
body.model = { providerID = cfg.providerID, modelID = cfg.modelID };
|
||||
end
|
||||
if cfg.agent then
|
||||
body.agent = cfg.agent;
|
||||
end
|
||||
if cfg.variant then
|
||||
body.variant = cfg.variant;
|
||||
end
|
||||
return body;
|
||||
end
|
||||
|
||||
local function decodeMessage(value)
|
||||
local decoded = value;
|
||||
if type(value) == 'string' then
|
||||
decoded = textutils.unserializeJSON(value);
|
||||
end
|
||||
if type(decoded) ~= 'table' or type(decoded.parts) ~= 'table' then
|
||||
return nil, 'reponse message invalide';
|
||||
end
|
||||
return decoded, nil;
|
||||
end
|
||||
|
||||
local function handleMissingSession(persist, sessionSettingKey)
|
||||
if persist then
|
||||
settingsLib.unset(sessionSettingKey or DEFAULT_SESSION_SETTING_KEY);
|
||||
if settingsLib.save then settingsLib.save(); end
|
||||
end
|
||||
return false, 'session introuvable; lance: ai new <prompt>';
|
||||
end
|
||||
|
||||
local function doGet(cfg, path)
|
||||
return httpClient.getJson(cfg, path);
|
||||
end
|
||||
|
||||
local function doPost(cfg, path, payload)
|
||||
return httpClient.postJson(cfg, path, payload);
|
||||
end
|
||||
|
||||
local function askBlocking(cfg, sessionId, prompt, persist, sessionSettingKey, log)
|
||||
log('sending blocking message');
|
||||
local body, code = doPost(cfg, '/session/' .. sessionId .. '/message', buildMessageBody(cfg, prompt));
|
||||
if not body then return false, code; end
|
||||
if code == 404 then
|
||||
return handleMissingSession(persist, sessionSettingKey);
|
||||
end
|
||||
if code and code ~= 200 then
|
||||
return false, 'erreur message: HTTP ' .. tostring(code);
|
||||
end
|
||||
|
||||
local decoded, decodeErr = decodeMessage(body);
|
||||
if not decoded then return false, decodeErr; end
|
||||
local reply = extractTextParts(decoded.parts);
|
||||
if reply == '' then return false, 'reponse vide'; end
|
||||
return true, {
|
||||
reply = reply,
|
||||
sessionId = sessionId,
|
||||
messageId = type(decoded.info) == 'table' and decoded.info.id or nil,
|
||||
};
|
||||
end
|
||||
|
||||
local function listSessionsWithDirectory(cfg, directory)
|
||||
return doGet(cfg, '/session' .. httpClient.queryString({ { 'directory', directory } }));
|
||||
end
|
||||
|
||||
local function decodeSessionList(body, log)
|
||||
local decoded = textutils.unserializeJSON(body);
|
||||
if type(decoded) ~= 'table' then
|
||||
log('list sessions failed: invalid response');
|
||||
return nil, 'reponse invalide';
|
||||
end
|
||||
table.sort(decoded, function(a, b)
|
||||
return sessionTime(a) > sessionTime(b);
|
||||
end);
|
||||
return decoded, nil;
|
||||
end
|
||||
|
||||
function api.clearSession(options)
|
||||
options = options or {};
|
||||
settingsLib.unset(options.sessionSettingKey or DEFAULT_SESSION_SETTING_KEY);
|
||||
if settingsLib.save then settingsLib.save(); end
|
||||
end
|
||||
|
||||
function api.listSessions(options)
|
||||
options = options or {};
|
||||
local log = options.log or function() end;
|
||||
local cfg, err = resolveConfig(options);
|
||||
if not cfg then return false, err; end
|
||||
|
||||
local directory = cfg.directory;
|
||||
local sessionSettingKey = options.sessionSettingKey or DEFAULT_SESSION_SETTING_KEY;
|
||||
log('listing sessions from ' .. cfg.url);
|
||||
local body, code;
|
||||
if isBlank(directory) then
|
||||
body, code = doGet(cfg, '/session');
|
||||
else
|
||||
log('listing sessions for directory ' .. tostring(directory));
|
||||
body, code = listSessionsWithDirectory(cfg, directory);
|
||||
end
|
||||
if not body then
|
||||
log('list sessions failed: ' .. tostring(code));
|
||||
return false, code;
|
||||
end
|
||||
if code and code ~= 200 then
|
||||
log('list sessions failed: HTTP ' .. tostring(code));
|
||||
return false, 'erreur serveur: HTTP ' .. tostring(code);
|
||||
end
|
||||
|
||||
local decoded, decodeErr = decodeSessionList(body, log);
|
||||
if not decoded then return false, decodeErr; end
|
||||
|
||||
if #decoded == 0 and isBlank(directory) then
|
||||
local sessionId = options.sessionId or settingsLib.get(sessionSettingKey);
|
||||
if not isBlank(sessionId) then
|
||||
log('session list empty; resolving directory from ' .. tostring(sessionId));
|
||||
local sessionBody, sessionCode = doGet(cfg, '/session/' .. sessionId);
|
||||
if sessionBody and (not sessionCode or sessionCode == 200) then
|
||||
local session = textutils.unserializeJSON(sessionBody);
|
||||
if type(session) == 'table' and not isBlank(session.directory) then
|
||||
log('retrying sessions for directory ' .. tostring(session.directory));
|
||||
local scopedBody, scopedCode = listSessionsWithDirectory(cfg, session.directory);
|
||||
if scopedBody and (not scopedCode or scopedCode == 200) then
|
||||
local scoped, scopedErr = decodeSessionList(scopedBody, log);
|
||||
if not scoped then return false, scopedErr; end
|
||||
decoded = scoped;
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
log('sessions returned: ' .. tostring(#decoded));
|
||||
return true, decoded;
|
||||
end
|
||||
|
||||
function api.ask(prompt, options)
|
||||
options = options or {};
|
||||
local log = options.log or function() end;
|
||||
if isBlank(prompt) then
|
||||
return false, 'missing prompt; usage: ai <prompt>';
|
||||
end
|
||||
|
||||
local cfg, err = resolveConfig(options);
|
||||
if not cfg then return false, err; end
|
||||
|
||||
local persist = options.persist ~= false;
|
||||
local sessionSettingKey = options.sessionSettingKey or DEFAULT_SESSION_SETTING_KEY;
|
||||
local sessionId = options.sessionId;
|
||||
if persist and sessionId == nil then
|
||||
sessionId = settingsLib.get(sessionSettingKey);
|
||||
end
|
||||
|
||||
if not sessionId or sessionId == '' then
|
||||
log('creating session');
|
||||
local body, code = doPost(cfg, '/session', { title = options.sessionTitle or 'cc-ai' });
|
||||
if not body then return false, code; end
|
||||
if code and code ~= 200 then
|
||||
return false, 'impossible de creer une session: HTTP ' .. tostring(code);
|
||||
end
|
||||
local decoded = textutils.unserializeJSON(body);
|
||||
if type(decoded) ~= 'table' or type(decoded.id) ~= 'string' then
|
||||
return false, 'reponse session invalide';
|
||||
end
|
||||
sessionId = decoded.id;
|
||||
if persist then
|
||||
settingsLib.set(sessionSettingKey, sessionId);
|
||||
if settingsLib.save then settingsLib.save(); end
|
||||
end
|
||||
else
|
||||
log('reusing session ' .. sessionId);
|
||||
end
|
||||
|
||||
local promptWithContext = prompt;
|
||||
if options.includeCallerContext ~= false then
|
||||
promptWithContext = buildPromptWithCallerContext(prompt, osLib);
|
||||
end
|
||||
|
||||
-- Synchronous /message only: opencode's prompt_async loops the model on some
|
||||
-- builds and strands sessions as busy. The bridge ws transport removes the
|
||||
-- ~60s http cap that previously made blocking impractical.
|
||||
return askBlocking(cfg, sessionId, promptWithContext, persist, sessionSettingKey, log);
|
||||
end
|
||||
|
||||
function api.createLuaExecutor(options)
|
||||
options = options or {};
|
||||
local baseEnv = options.env or _G;
|
||||
local live = options.live ~= false;
|
||||
local livePrint = options.print or print;
|
||||
local liveWrite = options.write or write;
|
||||
local timeoutSeconds = resolveLuaExecTimeout(options);
|
||||
|
||||
return function(code)
|
||||
local buffer = {};
|
||||
|
||||
local function append(text)
|
||||
buffer[#buffer + 1] = text;
|
||||
end
|
||||
|
||||
local function capturedPrint(...)
|
||||
local values = tablePack(...);
|
||||
local line = valuesToLine(values, 1, values.n);
|
||||
append(line .. '\n');
|
||||
if live then livePrint(...); end
|
||||
end
|
||||
|
||||
local function capturedWrite(text)
|
||||
text = tostring(text or '');
|
||||
append(text);
|
||||
if live then liveWrite(text); end
|
||||
end
|
||||
|
||||
local env = setmetatable({
|
||||
print = capturedPrint,
|
||||
write = capturedWrite,
|
||||
}, { __index = baseEnv });
|
||||
local chunk, loadErr = load(code, 'ai-lua-exec', 't', env);
|
||||
if not chunk then
|
||||
return false, tostring(loadErr), 'syntax';
|
||||
end
|
||||
|
||||
local result;
|
||||
local finished = false;
|
||||
local function runner()
|
||||
result = tablePack(pcall(chunk));
|
||||
finished = true;
|
||||
end
|
||||
|
||||
if timeoutSeconds then
|
||||
parallel.waitForAny(runner, function() sleep(timeoutSeconds); end);
|
||||
else
|
||||
runner();
|
||||
end
|
||||
|
||||
if not finished then
|
||||
return false, 'lua execution timed out after ' .. tostring(timeoutSeconds) .. 's', 'other';
|
||||
end
|
||||
if not result[1] then
|
||||
return false, tostring(result[2]), classifyLuaRuntimeError(result[2]);
|
||||
end
|
||||
if result.n > 1 then
|
||||
if #buffer > 0 and not endsWithNewline(buffer[#buffer]) then
|
||||
append('\n');
|
||||
end
|
||||
append(valuesToLine(result, 2, result.n) .. '\n');
|
||||
end
|
||||
return true, table.concat(buffer), nil;
|
||||
end;
|
||||
end
|
||||
|
||||
function api.luaExec(userPrompt, options)
|
||||
options = options or {};
|
||||
if isBlank(userPrompt) then
|
||||
return false, { error = 'missing prompt; usage: ai lua-exec <prompt>', attempts = 0 };
|
||||
end
|
||||
|
||||
local log = options.log or function() end;
|
||||
local executor = options.executor or api.createLuaExecutor(options);
|
||||
local maxRetries = resolveLuaExecMaxRetries(options);
|
||||
local maxAttempts = maxRetries + 1;
|
||||
local sessionId;
|
||||
|
||||
local function askOptions()
|
||||
return {
|
||||
persist = false,
|
||||
sessionId = sessionId,
|
||||
sessionTitle = 'cc-ai lua-exec',
|
||||
serverUrl = options.serverUrl,
|
||||
username = options.username,
|
||||
password = options.password,
|
||||
providerID = options.providerID,
|
||||
modelID = options.modelID,
|
||||
agent = options.agent,
|
||||
variant = options.variant,
|
||||
timeoutSeconds = options.timeoutSeconds,
|
||||
};
|
||||
end
|
||||
|
||||
log('requesting Lua from AI');
|
||||
local ok, result = api.ask(buildLuaExecPrompt(userPrompt), askOptions());
|
||||
if not ok then
|
||||
return false, { error = result, attempts = 0, errorKind = 'ai' };
|
||||
end
|
||||
sessionId = result.sessionId;
|
||||
log('session: ' .. sessionId);
|
||||
|
||||
local code = result.reply;
|
||||
for attempt = 1, maxAttempts do
|
||||
log('attempt ' .. tostring(attempt) .. '/' .. tostring(maxAttempts));
|
||||
log('code:\n' .. code);
|
||||
|
||||
local execOk, outputOrErr, errorKind = executor(code);
|
||||
if execOk then
|
||||
local output = outputOrErr or '';
|
||||
log('output:\n' .. renderOutput(output));
|
||||
log('requesting final reply');
|
||||
local finalOk, finalResult = api.ask(buildLuaOutputPrompt(userPrompt, output), askOptions());
|
||||
if not finalOk then
|
||||
return false, {
|
||||
error = finalResult,
|
||||
attempts = attempt,
|
||||
errorKind = 'ai',
|
||||
code = code,
|
||||
output = output,
|
||||
sessionId = sessionId,
|
||||
};
|
||||
end
|
||||
log('final reply received');
|
||||
return true, {
|
||||
reply = finalResult.reply,
|
||||
output = output,
|
||||
code = code,
|
||||
attempts = attempt,
|
||||
sessionId = sessionId,
|
||||
};
|
||||
end
|
||||
|
||||
errorKind = errorKind or 'other';
|
||||
log('error (' .. tostring(errorKind) .. '):\n' .. tostring(outputOrErr));
|
||||
if (errorKind ~= 'syntax' and errorKind ~= 'identifier') or attempt >= maxAttempts then
|
||||
return false, {
|
||||
error = outputOrErr,
|
||||
attempts = attempt,
|
||||
errorKind = errorKind,
|
||||
code = code,
|
||||
sessionId = sessionId,
|
||||
retryExhausted = attempt >= maxAttempts,
|
||||
};
|
||||
end
|
||||
|
||||
log('requesting corrected Lua');
|
||||
local correctionOk, correctionResult = api.ask(
|
||||
buildLuaCorrectionPrompt(userPrompt, code, outputOrErr, errorKind),
|
||||
askOptions()
|
||||
);
|
||||
if not correctionOk then
|
||||
return false, {
|
||||
error = correctionResult,
|
||||
attempts = attempt,
|
||||
errorKind = 'ai',
|
||||
code = code,
|
||||
sessionId = sessionId,
|
||||
};
|
||||
end
|
||||
code = correctionResult.reply;
|
||||
end
|
||||
|
||||
return false, { error = 'lua-exec failed unexpectedly', attempts = maxAttempts };
|
||||
end
|
||||
|
||||
function api.ping(options)
|
||||
options = options or {};
|
||||
options.includeCallerContext = false;
|
||||
return api.ask(PING_PROMPT, options);
|
||||
end
|
||||
|
||||
return api;
|
||||
end
|
||||
|
||||
return createAi;
|
||||
@ -1,171 +0,0 @@
|
||||
local DEFAULT_SIZE = 8;
|
||||
local DEFAULT_CHAR = '#';
|
||||
local RANDOM_CHARS = { '#', '*', '+', 'x', '@', '=' };
|
||||
|
||||
local function firstChar(value)
|
||||
value = tostring(value or '');
|
||||
if value == '' then return nil; end
|
||||
return string.sub(value, 1, 1);
|
||||
end
|
||||
|
||||
local function parsePositiveInteger(value, name)
|
||||
local number = tonumber(value);
|
||||
if not number or number < 1 or number ~= math.floor(number) then
|
||||
return nil, name .. ' doit etre un entier positif';
|
||||
end
|
||||
return number;
|
||||
end
|
||||
|
||||
local function parseNonNegativeNumber(value, name)
|
||||
local number = tonumber(value);
|
||||
if not number or number < 0 then
|
||||
return nil, name .. ' doit etre un nombre positif ou nul';
|
||||
end
|
||||
return number;
|
||||
end
|
||||
|
||||
local function clamp(value, minValue, maxValue)
|
||||
if value < minValue then return minValue; end
|
||||
if value > maxValue then return maxValue; end
|
||||
return value;
|
||||
end
|
||||
|
||||
local function createCarre(opts)
|
||||
opts = opts or {};
|
||||
local random = opts.random or math.random;
|
||||
|
||||
local api = {};
|
||||
|
||||
function api.parseArgs(args)
|
||||
args = args or {};
|
||||
local config = {
|
||||
size = DEFAULT_SIZE,
|
||||
char = DEFAULT_CHAR,
|
||||
fill = false,
|
||||
random = false,
|
||||
count = 1,
|
||||
delay = 0,
|
||||
clear = false,
|
||||
explicit = {},
|
||||
};
|
||||
|
||||
local i = 1;
|
||||
while i <= (args.n or #args) do
|
||||
local arg = args[i];
|
||||
|
||||
if arg == '-size' then
|
||||
local value, err = parsePositiveInteger(args[i + 1], '-size');
|
||||
if not value then return nil, err; end
|
||||
config.size = value;
|
||||
config.explicit.size = true;
|
||||
i = i + 1;
|
||||
elseif arg == '-x' then
|
||||
local value, err = parsePositiveInteger(args[i + 1], '-x');
|
||||
if not value then return nil, err; end
|
||||
config.x = value;
|
||||
config.explicit.x = true;
|
||||
i = i + 1;
|
||||
elseif arg == '-y' then
|
||||
local value, err = parsePositiveInteger(args[i + 1], '-y');
|
||||
if not value then return nil, err; end
|
||||
config.y = value;
|
||||
config.explicit.y = true;
|
||||
i = i + 1;
|
||||
elseif arg == '-char' then
|
||||
local value = firstChar(args[i + 1]);
|
||||
if not value then return nil, '-char doit recevoir un caractere'; end
|
||||
config.char = value;
|
||||
config.explicit.char = true;
|
||||
i = i + 1;
|
||||
elseif arg == '-fill' then
|
||||
config.fill = true;
|
||||
elseif arg == '-random' then
|
||||
config.random = true;
|
||||
elseif arg == '-count' then
|
||||
local value, err = parsePositiveInteger(args[i + 1], '-count');
|
||||
if not value then return nil, err; end
|
||||
config.count = value;
|
||||
i = i + 1;
|
||||
elseif arg == '-delay' then
|
||||
local value, err = parseNonNegativeNumber(args[i + 1], '-delay');
|
||||
if not value then return nil, err; end
|
||||
config.delay = value;
|
||||
i = i + 1;
|
||||
elseif arg == '-clear' then
|
||||
config.clear = true;
|
||||
else
|
||||
return nil, 'option inconnue: ' .. tostring(arg);
|
||||
end
|
||||
|
||||
i = i + 1;
|
||||
end
|
||||
|
||||
return config;
|
||||
end
|
||||
|
||||
function api.computeSquare(config, width, height)
|
||||
width = math.max(1, tonumber(width) or 1);
|
||||
height = math.max(1, tonumber(height) or 1);
|
||||
config = config or {};
|
||||
|
||||
local explicit = config.explicit or {};
|
||||
local maxSize = math.max(1, math.min(width, height));
|
||||
local size = config.size or DEFAULT_SIZE;
|
||||
local char = config.char or DEFAULT_CHAR;
|
||||
|
||||
if config.random then
|
||||
if not explicit.size then
|
||||
size = random(1, maxSize);
|
||||
end
|
||||
if not explicit.char then
|
||||
char = RANDOM_CHARS[random(1, #RANDOM_CHARS)];
|
||||
end
|
||||
end
|
||||
|
||||
size = clamp(size, 1, maxSize);
|
||||
|
||||
local maxX = math.max(1, width - size + 1);
|
||||
local maxY = math.max(1, height - size + 1);
|
||||
local x = config.x;
|
||||
local y = config.y;
|
||||
|
||||
if config.random and not explicit.x then
|
||||
x = random(1, maxX);
|
||||
elseif not x then
|
||||
x = math.floor((width - size) / 2) + 1;
|
||||
end
|
||||
|
||||
if config.random and not explicit.y then
|
||||
y = random(1, maxY);
|
||||
elseif not y then
|
||||
y = math.floor((height - size) / 2) + 1;
|
||||
end
|
||||
|
||||
return {
|
||||
x = clamp(x, 1, maxX),
|
||||
y = clamp(y, 1, maxY),
|
||||
size = size,
|
||||
char = firstChar(char) or DEFAULT_CHAR,
|
||||
fill = config.fill == true,
|
||||
};
|
||||
end
|
||||
|
||||
function api.drawSquare(termLib, square)
|
||||
for row = 1, square.size do
|
||||
termLib.setCursorPos(square.x, square.y + row - 1);
|
||||
|
||||
for column = 1, square.size do
|
||||
local isBorder = row == 1 or row == square.size or column == 1 or column == square.size;
|
||||
if square.fill or isBorder then
|
||||
termLib.write(square.char);
|
||||
else
|
||||
termLib.write(' ');
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return api;
|
||||
end
|
||||
|
||||
return createCarre;
|
||||
479
apis/libccpm.lua
479
apis/libccpm.lua
@ -1,479 +0,0 @@
|
||||
-- libccpm: the testable core of the TrapOS package manager (ccpm).
|
||||
--
|
||||
-- A factory: `local createCcpm = require('/apis/libccpm'); local ccpm = createCcpm();`
|
||||
--
|
||||
-- State lives under `opts.stateDir` (default `/trapos`):
|
||||
-- - ccpm.json -> { registries = { { name, type, branch }, ... } }
|
||||
-- - ccpm.lock.json -> { packages = { <name> = { version, registry, files,
|
||||
-- dependencies, autostart } } }
|
||||
-- - ccpm.cache.json -> { packages = { <name> = { version, registry } } }
|
||||
--
|
||||
-- Files are written under `opts.installRoot` (default '' -> filesystem root), so
|
||||
-- tests can sandbox downloads. `opts.http` overrides the global `http` for tests.
|
||||
|
||||
local DEFAULT_STATE_DIR = '/trapos';
|
||||
|
||||
local function normalizePath(path)
|
||||
path = path:gsub('//+', '/');
|
||||
if path:sub(1, 1) ~= '/' then
|
||||
path = '/' .. path;
|
||||
end
|
||||
return path;
|
||||
end
|
||||
|
||||
local function parentDir(path)
|
||||
return path:match('^(.*)/[^/]+$');
|
||||
end
|
||||
|
||||
local function createCcpm(opts)
|
||||
opts = opts or {};
|
||||
local httpLib = opts.http or http;
|
||||
local stateDir = opts.stateDir or DEFAULT_STATE_DIR;
|
||||
local installRoot = opts.installRoot or '';
|
||||
|
||||
local configPath = stateDir .. '/ccpm.json';
|
||||
local lockPath = stateDir .. '/ccpm.lock.json';
|
||||
local cachePath = stateDir .. '/ccpm.cache.json';
|
||||
local manifestPath = stateDir .. '/manifest.json';
|
||||
|
||||
local api = {};
|
||||
|
||||
-- ---------- JSON file helpers ----------
|
||||
|
||||
local function readJsonFile(path)
|
||||
if not fs.exists(path) then return nil; end
|
||||
local f = fs.open(path, 'r');
|
||||
if not f then return nil; end
|
||||
local data = f.readAll();
|
||||
f.close();
|
||||
if not data or data == '' then return nil; end
|
||||
return textutils.unserializeJSON(data);
|
||||
end
|
||||
|
||||
local function writeJsonFile(path, value)
|
||||
local dir = parentDir(path);
|
||||
if dir then fs.makeDir(dir); end
|
||||
local f = fs.open(path, 'w');
|
||||
if not f then return false; end
|
||||
f.write(textutils.serializeJSON(value));
|
||||
f.close();
|
||||
return true;
|
||||
end
|
||||
|
||||
-- ---------- config (registries) ----------
|
||||
|
||||
function api.readConfig()
|
||||
return readJsonFile(configPath) or { registries = {} };
|
||||
end
|
||||
|
||||
function api.writeConfig(cfg)
|
||||
return writeJsonFile(configPath, cfg);
|
||||
end
|
||||
|
||||
function api.listRegistries()
|
||||
return api.readConfig().registries or {};
|
||||
end
|
||||
|
||||
function api.addRegistry(name, registryOpts)
|
||||
registryOpts = registryOpts or {};
|
||||
local cfg = api.readConfig();
|
||||
cfg.registries = cfg.registries or {};
|
||||
for _, r in ipairs(cfg.registries) do
|
||||
if r.name == name then
|
||||
return false, 'registry already exists: ' .. name;
|
||||
end
|
||||
end
|
||||
local registry = {
|
||||
name = name,
|
||||
type = registryOpts.type or 'gitea',
|
||||
branch = registryOpts.branch or 'master',
|
||||
};
|
||||
cfg.registries[#cfg.registries + 1] = registry;
|
||||
api.writeConfig(cfg);
|
||||
return true, registry;
|
||||
end
|
||||
|
||||
function api.removeRegistry(name)
|
||||
local cfg = api.readConfig();
|
||||
cfg.registries = cfg.registries or {};
|
||||
local kept = {};
|
||||
local found = false;
|
||||
for _, r in ipairs(cfg.registries) do
|
||||
if r.name == name then
|
||||
found = true;
|
||||
else
|
||||
kept[#kept + 1] = r;
|
||||
end
|
||||
end
|
||||
if not found then
|
||||
return false, 'registry not found: ' .. name;
|
||||
end
|
||||
cfg.registries = kept;
|
||||
api.writeConfig(cfg);
|
||||
return true;
|
||||
end
|
||||
|
||||
-- ---------- lock (installed packages) ----------
|
||||
|
||||
function api.readLock()
|
||||
return readJsonFile(lockPath) or { packages = {} };
|
||||
end
|
||||
|
||||
function api.writeLock(lock)
|
||||
return writeJsonFile(lockPath, lock);
|
||||
end
|
||||
|
||||
function api.list()
|
||||
return api.readLock().packages or {};
|
||||
end
|
||||
|
||||
local function writeOsState(lock)
|
||||
local files = {};
|
||||
local seenFile = {};
|
||||
local autostart = {};
|
||||
local seenAuto = {};
|
||||
for _, entry in pairs(lock.packages or {}) do
|
||||
for _, filePath in ipairs(entry.files or {}) do
|
||||
if not seenFile[filePath] then
|
||||
seenFile[filePath] = true;
|
||||
files[#files + 1] = filePath;
|
||||
end
|
||||
end
|
||||
for _, server in ipairs(entry.autostart or {}) do
|
||||
if not seenAuto[server] then
|
||||
seenAuto[server] = true;
|
||||
autostart[#autostart + 1] = server;
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(files);
|
||||
table.sort(autostart);
|
||||
local cfg = api.readConfig();
|
||||
local registry = cfg.registries and cfg.registries[1] or {};
|
||||
local trapos = lock.packages and lock.packages.trapos;
|
||||
return writeJsonFile(manifestPath, {
|
||||
name = 'TrapOS',
|
||||
version = trapos and trapos.version or '?',
|
||||
branch = registry.branch or 'master',
|
||||
files = files,
|
||||
autostart = autostart,
|
||||
});
|
||||
end
|
||||
|
||||
-- ---------- cache (available packages) ----------
|
||||
|
||||
function api.readCache()
|
||||
return readJsonFile(cachePath) or { packages = {} };
|
||||
end
|
||||
|
||||
function api.writeCache(cache)
|
||||
return writeJsonFile(cachePath, cache);
|
||||
end
|
||||
|
||||
-- ---------- URL resolution ----------
|
||||
|
||||
function api.registryBaseUrl(registry)
|
||||
if registry.type == 'github' then
|
||||
local branch = registry.branch or 'master';
|
||||
return 'https://raw.githubusercontent.com/' .. registry.name .. '/' .. branch .. '/';
|
||||
elseif registry.type == 'gitea' then
|
||||
local branch = registry.branch or 'master';
|
||||
return 'https://git.trapcloud.fr/' .. registry.name .. '/raw/branch/' .. branch .. '/';
|
||||
end
|
||||
local base = registry.name;
|
||||
if base:sub(-1) ~= '/' then base = base .. '/'; end
|
||||
return base;
|
||||
end
|
||||
|
||||
function api.descriptorUrl(registry, pkg)
|
||||
return api.registryBaseUrl(registry) .. 'packages/' .. pkg .. '/ccpm.json';
|
||||
end
|
||||
|
||||
function api.indexUrl(registry)
|
||||
return api.registryBaseUrl(registry) .. 'packages/index.json';
|
||||
end
|
||||
|
||||
function api.fileUrl(registry, filePath)
|
||||
return api.registryBaseUrl(registry) .. filePath;
|
||||
end
|
||||
|
||||
-- ---------- HTTP ----------
|
||||
|
||||
local function httpGetBody(url)
|
||||
local res = httpLib.get(url);
|
||||
if not res then return nil; end
|
||||
local body = res.readAll();
|
||||
res.close();
|
||||
return body;
|
||||
end
|
||||
|
||||
function api.fetchJson(url)
|
||||
local body = httpGetBody(url);
|
||||
if not body or body == '' then return nil; end
|
||||
return textutils.unserializeJSON(body);
|
||||
end
|
||||
|
||||
-- Find a package descriptor across the configured registries.
|
||||
-- Returns descriptor, registry (or nil when not found).
|
||||
function api.findPackage(pkg)
|
||||
local cfg = api.readConfig();
|
||||
for _, registry in ipairs(cfg.registries or {}) do
|
||||
local desc = api.fetchJson(api.descriptorUrl(registry, pkg));
|
||||
if desc then return desc, registry; end
|
||||
end
|
||||
return nil;
|
||||
end
|
||||
|
||||
function api.info(pkg)
|
||||
return api.findPackage(pkg);
|
||||
end
|
||||
|
||||
function api.search(term)
|
||||
local results = {};
|
||||
local cache = api.readCache();
|
||||
for name, entry in pairs(cache.packages or {}) do
|
||||
if not term or term == '' or string.find(name, term, 1, true) then
|
||||
results[#results + 1] = { name = name, version = entry.version, registry = entry.registry };
|
||||
end
|
||||
end
|
||||
table.sort(results, function(a, b) return a.name < b.name; end);
|
||||
return results;
|
||||
end
|
||||
|
||||
function api.update()
|
||||
local cfg = api.readConfig();
|
||||
local cache = { packages = {} };
|
||||
for _, registry in ipairs(cfg.registries or {}) do
|
||||
local index = api.fetchJson(api.indexUrl(registry));
|
||||
if index and index.packages then
|
||||
for name, version in pairs(index.packages) do
|
||||
if not cache.packages[name] then
|
||||
cache.packages[name] = { version = version, registry = registry.name };
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
api.writeCache(cache);
|
||||
return cache;
|
||||
end
|
||||
|
||||
local function compareVersions(a, b)
|
||||
a = tostring(a or '');
|
||||
b = tostring(b or '');
|
||||
if a == b then return 0; end
|
||||
local aparts = {};
|
||||
local bparts = {};
|
||||
for part in string.gmatch(a, '[^%.]+') do aparts[#aparts + 1] = part; end
|
||||
for part in string.gmatch(b, '[^%.]+') do bparts[#bparts + 1] = part; end
|
||||
local max = math.max(#aparts, #bparts);
|
||||
local hitBreak = false;
|
||||
for i = 1, max do
|
||||
local apart = aparts[i] or '0';
|
||||
local bpart = bparts[i] or '0';
|
||||
local anum = tonumber(apart);
|
||||
local bnum = tonumber(bpart);
|
||||
if not anum or not bnum then hitBreak = true; break; end
|
||||
if anum < bnum then return -1; end
|
||||
if anum > bnum then return 1; end
|
||||
end
|
||||
if not hitBreak then return 0; end
|
||||
if a < b then return -1; end
|
||||
return 1;
|
||||
end
|
||||
|
||||
function api.available(term)
|
||||
local cache = api.readCache();
|
||||
local installed = api.list();
|
||||
local results = {};
|
||||
for name, entry in pairs(cache.packages or {}) do
|
||||
if not term or term == '' or string.find(name, term, 1, true) then
|
||||
local status = 'available';
|
||||
local installedVersion = nil;
|
||||
if installed[name] then
|
||||
installedVersion = installed[name].version;
|
||||
if compareVersions(installedVersion, entry.version) < 0 then
|
||||
status = 'updatable';
|
||||
else
|
||||
status = 'up-to-date';
|
||||
end
|
||||
end
|
||||
results[#results + 1] = {
|
||||
name = name,
|
||||
version = entry.version,
|
||||
installedVersion = installedVersion,
|
||||
registry = entry.registry,
|
||||
status = status,
|
||||
};
|
||||
end
|
||||
end
|
||||
table.sort(results, function(a, b) return a.name < b.name; end);
|
||||
return results;
|
||||
end
|
||||
|
||||
-- ---------- dependency resolution ----------
|
||||
|
||||
-- Resolve `pkg` and all of its dependencies into install order (deps first,
|
||||
-- target last). Returns an ordered list of { name, desc, registry } or nil, err.
|
||||
function api.resolve(pkg)
|
||||
local ordered = {};
|
||||
local state = {}; -- name -> 'visiting' | 'done'
|
||||
local errMsg = nil;
|
||||
|
||||
local function visit(name, chain)
|
||||
if errMsg then return; end
|
||||
if state[name] == 'done' then return; end
|
||||
if state[name] == 'visiting' then
|
||||
errMsg = 'dependency cycle detected: ' .. table.concat(chain, ' -> ') .. ' -> ' .. name;
|
||||
return;
|
||||
end
|
||||
state[name] = 'visiting';
|
||||
local desc, registry = api.findPackage(name);
|
||||
if not desc then
|
||||
errMsg = 'package not found: ' .. name;
|
||||
return;
|
||||
end
|
||||
chain[#chain + 1] = name;
|
||||
for _, dep in ipairs(desc.dependencies or {}) do
|
||||
visit(dep, chain);
|
||||
if errMsg then return; end
|
||||
end
|
||||
chain[#chain] = nil;
|
||||
state[name] = 'done';
|
||||
ordered[#ordered + 1] = { name = name, desc = desc, registry = registry };
|
||||
end
|
||||
|
||||
visit(pkg, {});
|
||||
if errMsg then return nil, errMsg; end
|
||||
return ordered;
|
||||
end
|
||||
|
||||
-- ---------- install / uninstall ----------
|
||||
|
||||
local function installTarget(filePath)
|
||||
return normalizePath(installRoot .. '/' .. filePath);
|
||||
end
|
||||
|
||||
function api.downloadFile(registry, filePath)
|
||||
local body = httpGetBody(api.fileUrl(registry, filePath));
|
||||
if not body then
|
||||
return false, 'failed to download ' .. filePath;
|
||||
end
|
||||
local target = installTarget(filePath);
|
||||
local dir = parentDir(target);
|
||||
if dir then fs.makeDir(dir); end
|
||||
fs.delete(target);
|
||||
local f = fs.open(target, 'w');
|
||||
if not f then
|
||||
return false, 'failed to write ' .. target;
|
||||
end
|
||||
f.write(body);
|
||||
f.close();
|
||||
return true;
|
||||
end
|
||||
|
||||
-- installOpts: { force = bool, log = function(msg) }
|
||||
function api.install(pkg, installOpts)
|
||||
installOpts = installOpts or {};
|
||||
local log = installOpts.log or function() end;
|
||||
local lock = api.readLock();
|
||||
lock.packages = lock.packages or {};
|
||||
|
||||
if lock.packages[pkg] and not installOpts.force then
|
||||
return false, "package already installed, use 'ccpm reinstall " .. pkg .. "' instead.";
|
||||
end
|
||||
|
||||
local ordered, err = api.resolve(pkg);
|
||||
if not ordered then return false, err; end
|
||||
|
||||
for _, item in ipairs(ordered) do
|
||||
local isTarget = item.name == pkg;
|
||||
local alreadyInstalled = lock.packages[item.name] ~= nil;
|
||||
if alreadyInstalled and not (isTarget and installOpts.force) then
|
||||
log('skip ' .. item.name .. ' (already installed)');
|
||||
else
|
||||
log('install ' .. item.name .. ' v' .. tostring(item.desc.version or '?'));
|
||||
for _, filePath in ipairs(item.desc.files or {}) do
|
||||
log(' ' .. filePath);
|
||||
local ok, derr = api.downloadFile(item.registry, filePath);
|
||||
if not ok then return false, derr; end
|
||||
end
|
||||
lock.packages[item.name] = {
|
||||
version = item.desc.version,
|
||||
registry = item.registry.name,
|
||||
files = item.desc.files or {},
|
||||
dependencies = item.desc.dependencies or {},
|
||||
autostart = item.desc.autostart or {},
|
||||
};
|
||||
end
|
||||
end
|
||||
|
||||
api.writeLock(lock);
|
||||
writeOsState(lock);
|
||||
return true, lock.packages[pkg];
|
||||
end
|
||||
|
||||
function api.upgrade(upgradeOpts)
|
||||
upgradeOpts = upgradeOpts or {};
|
||||
local log = upgradeOpts.log or function() end;
|
||||
local lock = api.readLock();
|
||||
local cache = api.readCache();
|
||||
if not cache.packages or not next(cache.packages) then
|
||||
return false, "package cache is empty, run 'ccpm update' first.";
|
||||
end
|
||||
|
||||
local names = {};
|
||||
for name, entry in pairs(lock.packages or {}) do
|
||||
local cached = cache.packages[name];
|
||||
if cached and compareVersions(entry.version, cached.version) < 0 then
|
||||
names[#names + 1] = name;
|
||||
end
|
||||
end
|
||||
table.sort(names);
|
||||
|
||||
for _, name in ipairs(names) do
|
||||
log('upgrade ' .. name);
|
||||
local ok, err = api.install(name, { force = true, log = log });
|
||||
if not ok then return false, err; end
|
||||
end
|
||||
return true, names;
|
||||
end
|
||||
|
||||
-- uninstallOpts: { force = bool, log = function(msg) }
|
||||
function api.uninstall(pkg, uninstallOpts)
|
||||
uninstallOpts = uninstallOpts or {};
|
||||
local log = uninstallOpts.log or function() end;
|
||||
local lock = api.readLock();
|
||||
lock.packages = lock.packages or {};
|
||||
|
||||
local entry = lock.packages[pkg];
|
||||
if not entry then
|
||||
return false, 'package not installed: ' .. pkg;
|
||||
end
|
||||
|
||||
local dependents = {};
|
||||
for name, e in pairs(lock.packages) do
|
||||
if name ~= pkg then
|
||||
for _, dep in ipairs(e.dependencies or {}) do
|
||||
if dep == pkg then dependents[#dependents + 1] = name; end
|
||||
end
|
||||
end
|
||||
end
|
||||
if #dependents > 0 and not uninstallOpts.force then
|
||||
table.sort(dependents);
|
||||
return false, 'cannot uninstall ' .. pkg .. ': required by ' .. table.concat(dependents, ', ');
|
||||
end
|
||||
|
||||
for _, filePath in ipairs(entry.files or {}) do
|
||||
log('remove ' .. filePath);
|
||||
fs.delete(installTarget(filePath));
|
||||
end
|
||||
lock.packages[pkg] = nil;
|
||||
api.writeLock(lock);
|
||||
writeOsState(lock);
|
||||
return true;
|
||||
end
|
||||
|
||||
return api;
|
||||
end
|
||||
|
||||
return createCcpm;
|
||||
122
apis/libhttp.lua
122
apis/libhttp.lua
@ -1,122 +0,0 @@
|
||||
local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
local function base64encode(s)
|
||||
local pad = (3 - #s % 3) % 3;
|
||||
s = s .. string.rep('\0', pad);
|
||||
local r = {};
|
||||
for i = 1, #s, 3 do
|
||||
local a, b, c = s:byte(i), s:byte(i + 1), s:byte(i + 2);
|
||||
local n = a * 65536 + b * 256 + c;
|
||||
r[#r + 1] = B64:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1)
|
||||
.. B64:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1)
|
||||
.. B64:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1)
|
||||
.. B64:sub(n % 64 + 1, n % 64 + 1);
|
||||
end
|
||||
local result = table.concat(r);
|
||||
if pad > 0 then
|
||||
result = result:sub(1, #result - pad) .. string.rep('=', pad);
|
||||
end
|
||||
return result;
|
||||
end
|
||||
|
||||
local function trimTrailingSlash(s)
|
||||
return (s:gsub('/+$', ''));
|
||||
end
|
||||
|
||||
local function urlEncode(s)
|
||||
return (tostring(s):gsub('[^%w%-_%.~]', function(c)
|
||||
return string.format('%%%02X', string.byte(c));
|
||||
end));
|
||||
end
|
||||
|
||||
local function isBlank(s)
|
||||
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
|
||||
end
|
||||
|
||||
local function queryString(params)
|
||||
local parts = {};
|
||||
for _, item in ipairs(params) do
|
||||
if not isBlank(item[2]) then
|
||||
parts[#parts + 1] = urlEncode(item[1]) .. '=' .. urlEncode(item[2]);
|
||||
end
|
||||
end
|
||||
if #parts == 0 then return ''; end
|
||||
return '?' .. table.concat(parts, '&');
|
||||
end
|
||||
|
||||
local function readAllAndClose(response)
|
||||
local body = response.readAll();
|
||||
response.close();
|
||||
return body;
|
||||
end
|
||||
|
||||
local function statusCode(response)
|
||||
if response.getResponseCode then
|
||||
return response.getResponseCode();
|
||||
end
|
||||
return nil;
|
||||
end
|
||||
|
||||
local function createHttp(opts)
|
||||
opts = opts or {};
|
||||
local httpLib = opts.http or http;
|
||||
local textutilsLib = opts.textutils or textutils;
|
||||
|
||||
local api = {
|
||||
base64encode = base64encode,
|
||||
trimTrailingSlash = trimTrailingSlash,
|
||||
urlEncode = urlEncode,
|
||||
queryString = queryString,
|
||||
};
|
||||
|
||||
function api.basicAuth(username, password)
|
||||
return 'Basic ' .. base64encode(tostring(username or '') .. ':' .. tostring(password or ''));
|
||||
end
|
||||
|
||||
function api.jsonHeaders(options)
|
||||
options = options or {};
|
||||
local headers = {
|
||||
['Content-Type'] = 'application/json',
|
||||
['Accept'] = 'application/json',
|
||||
};
|
||||
if options.password and options.password ~= '' then
|
||||
headers['Authorization'] = api.basicAuth(options.username, options.password);
|
||||
end
|
||||
return headers;
|
||||
end
|
||||
|
||||
function api.call(method, request)
|
||||
local ok, response, httpErr, errorResponse = pcall(httpLib[method], request);
|
||||
if not ok then
|
||||
return nil, 'http ' .. method .. ' threw: ' .. tostring(response);
|
||||
end
|
||||
response = response or errorResponse;
|
||||
if not response then
|
||||
return nil, 'serveur injoignable: ' .. tostring(httpErr or 'unknown error');
|
||||
end
|
||||
local code = statusCode(response);
|
||||
local body = readAllAndClose(response);
|
||||
return body, code;
|
||||
end
|
||||
|
||||
function api.getJson(cfg, path)
|
||||
return api.call('get', {
|
||||
url = cfg.url .. path,
|
||||
headers = api.jsonHeaders(cfg),
|
||||
timeout = cfg.timeoutSeconds,
|
||||
});
|
||||
end
|
||||
|
||||
function api.postJson(cfg, path, payload)
|
||||
return api.call('post', {
|
||||
url = cfg.url .. path,
|
||||
body = textutilsLib.serializeJSON(payload),
|
||||
headers = api.jsonHeaders(cfg),
|
||||
timeout = cfg.timeoutSeconds,
|
||||
});
|
||||
end
|
||||
|
||||
return api;
|
||||
end
|
||||
|
||||
return createHttp;
|
||||
@ -1,140 +0,0 @@
|
||||
-- WebSocket transport for the opencode bridge proxy.
|
||||
--
|
||||
-- Implements the same client surface as libhttp (getJson/postJson plus the
|
||||
-- trimTrailingSlash/queryString helpers libai calls), but performs each call as
|
||||
-- a blocking websocket round-trip to the mcp-bridge opencode proxy instead of a
|
||||
-- direct http.get/post. ComputerCraft's http API caps requests at ~60s; a
|
||||
-- websocket round-trip has no such cap, so the bridge can run a synchronous
|
||||
-- opencode request server-side for as long as it needs.
|
||||
|
||||
local DEFAULT_RECEIVE_TIMEOUT_SECONDS = 600;
|
||||
|
||||
local function isBlank(s)
|
||||
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
|
||||
end
|
||||
|
||||
local function trimTrailingSlash(s)
|
||||
return (s:gsub('/+$', ''));
|
||||
end
|
||||
|
||||
local function urlEncode(s)
|
||||
return (tostring(s):gsub('[^%w%-_%.~]', function(c)
|
||||
return string.format('%%%02X', string.byte(c));
|
||||
end));
|
||||
end
|
||||
|
||||
local function queryString(params)
|
||||
local parts = {};
|
||||
for _, item in ipairs(params) do
|
||||
if not isBlank(item[2]) then
|
||||
parts[#parts + 1] = urlEncode(item[1]) .. '=' .. urlEncode(item[2]);
|
||||
end
|
||||
end
|
||||
if #parts == 0 then return ''; end
|
||||
return '?' .. table.concat(parts, '&');
|
||||
end
|
||||
|
||||
local function createHttpWs(opts)
|
||||
opts = opts or {};
|
||||
local httpLib = opts.http or http;
|
||||
local textutilsLib = opts.textutils or textutils;
|
||||
local bridgeUrl = opts.bridgeUrl;
|
||||
local receiveTimeout = tonumber(opts.receiveTimeout) or DEFAULT_RECEIVE_TIMEOUT_SECONDS;
|
||||
|
||||
local api = {
|
||||
trimTrailingSlash = trimTrailingSlash,
|
||||
urlEncode = urlEncode,
|
||||
queryString = queryString,
|
||||
};
|
||||
|
||||
local activeWs = nil;
|
||||
|
||||
local function ensureSocket()
|
||||
if activeWs then return activeWs, nil; end
|
||||
if isBlank(bridgeUrl) then
|
||||
return nil, 'missing opencc.bridge_url';
|
||||
end
|
||||
local ws, err = httpLib.websocket(bridgeUrl);
|
||||
if not ws then
|
||||
return nil, 'bridge unreachable: ' .. tostring(err);
|
||||
end
|
||||
activeWs = ws;
|
||||
return ws, nil;
|
||||
end
|
||||
|
||||
local function closeSocket()
|
||||
if activeWs then
|
||||
pcall(function() activeWs.close(); end);
|
||||
activeWs = nil;
|
||||
end
|
||||
end
|
||||
|
||||
local idCounter = 0;
|
||||
local function nextId()
|
||||
idCounter = idCounter + 1;
|
||||
local stamp = os.epoch and os.epoch('utc') or os.clock();
|
||||
return 'req_' .. tostring(stamp) .. '_' .. tostring(idCounter) .. '_' .. tostring(math.random(100000, 999999));
|
||||
end
|
||||
|
||||
local function trySend(ws, text)
|
||||
return pcall(function() ws.send(text); end);
|
||||
end
|
||||
|
||||
local function roundtrip(method, path, payload)
|
||||
local ws, err = ensureSocket();
|
||||
if not ws then return nil, err; end
|
||||
|
||||
local id = nextId();
|
||||
local frame = { type = 'http', id = id, method = method, path = path };
|
||||
if payload ~= nil then
|
||||
frame.body = textutilsLib.serializeJSON(payload);
|
||||
end
|
||||
local text = textutilsLib.serializeJSON(frame);
|
||||
|
||||
if not trySend(ws, text) then
|
||||
-- Socket went away; drop it and try one fresh reconnect.
|
||||
closeSocket();
|
||||
ws, err = ensureSocket();
|
||||
if not ws then return nil, err; end
|
||||
if not trySend(ws, text) then
|
||||
closeSocket();
|
||||
return nil, 'bridge send failed';
|
||||
end
|
||||
end
|
||||
|
||||
while true do
|
||||
local ok, message = pcall(function() return ws.receive(receiveTimeout); end);
|
||||
if not ok then
|
||||
closeSocket();
|
||||
return nil, 'bridge connection closed';
|
||||
end
|
||||
if message == nil then
|
||||
return nil, 'timeout waiting for bridge response';
|
||||
end
|
||||
local decoded = textutilsLib.unserializeJSON(message);
|
||||
if type(decoded) == 'table' and decoded.type == 'http-response' and decoded.id == id then
|
||||
if decoded.status == nil or decoded.status == 0 then
|
||||
return nil, decoded.error or 'bridge error';
|
||||
end
|
||||
return decoded.body, decoded.status;
|
||||
end
|
||||
-- Ignore frames that don't correlate (stale/other ids) and keep waiting.
|
||||
end
|
||||
end
|
||||
|
||||
function api.getJson(_cfg, path)
|
||||
return roundtrip('GET', path, nil);
|
||||
end
|
||||
|
||||
function api.postJson(_cfg, path, payload)
|
||||
return roundtrip('POST', path, payload);
|
||||
end
|
||||
|
||||
function api.close()
|
||||
closeSocket();
|
||||
end
|
||||
|
||||
return api;
|
||||
end
|
||||
|
||||
return createHttpWs;
|
||||
@ -1,310 +0,0 @@
|
||||
local function defaultOs()
|
||||
return os;
|
||||
end
|
||||
|
||||
local function formatLabel(label)
|
||||
if type(label) ~= 'string' or label == '' then
|
||||
return 'null';
|
||||
end
|
||||
return label;
|
||||
end
|
||||
|
||||
local function appendOutput(output, value)
|
||||
output[#output + 1] = tostring(value);
|
||||
end
|
||||
|
||||
local function serializeReturn(value)
|
||||
local valueType = type(value);
|
||||
if valueType == 'nil' then
|
||||
return { type = 'nil' };
|
||||
end
|
||||
|
||||
if valueType == 'string' or valueType == 'number' or valueType == 'boolean' then
|
||||
return { type = valueType, value = value };
|
||||
end
|
||||
|
||||
local ok, serialized = pcall(textutils.serialize, value);
|
||||
if ok then
|
||||
return { type = valueType, repr = serialized };
|
||||
end
|
||||
return { type = valueType, repr = tostring(value) };
|
||||
end
|
||||
|
||||
local function createExecEnv(output)
|
||||
local env = setmetatable({}, { __index = _G });
|
||||
|
||||
env.write = function(value)
|
||||
appendOutput(output, value);
|
||||
end;
|
||||
|
||||
env.print = function(...)
|
||||
local values = table.pack(...);
|
||||
for i = 1, values.n do
|
||||
if i > 1 then
|
||||
appendOutput(output, '\t');
|
||||
end
|
||||
appendOutput(output, values[i]);
|
||||
end
|
||||
appendOutput(output, '\n');
|
||||
end;
|
||||
|
||||
return env;
|
||||
end
|
||||
|
||||
local function createMcpComputer()
|
||||
local api = {};
|
||||
|
||||
api.formatLabel = formatLabel;
|
||||
|
||||
function api.formatPong(osLike)
|
||||
osLike = osLike or defaultOs();
|
||||
return 'pong from ' .. tostring(osLike.getComputerID())
|
||||
.. ' (Label: ' .. formatLabel(osLike.getComputerLabel()) .. ')';
|
||||
end
|
||||
|
||||
function api.parseArgs(args)
|
||||
args = args or {};
|
||||
local count = args.n or #args;
|
||||
local url = nil;
|
||||
|
||||
if count == 0 then
|
||||
return nil, 'missing websocket URL';
|
||||
end
|
||||
|
||||
local i = 1;
|
||||
while i <= count do
|
||||
local arg = args[i];
|
||||
if arg == '-url' then
|
||||
if not args[i + 1] or args[i + 1] == '' then
|
||||
return nil, 'missing value for -url';
|
||||
end
|
||||
url = args[i + 1];
|
||||
i = i + 1;
|
||||
elseif string.sub(tostring(arg), 1, 1) == '-' then
|
||||
return nil, 'unknown option: ' .. tostring(arg);
|
||||
elseif not url then
|
||||
url = arg;
|
||||
else
|
||||
return nil, 'unexpected argument: ' .. tostring(arg);
|
||||
end
|
||||
i = i + 1;
|
||||
end
|
||||
|
||||
if not url or url == '' then
|
||||
return nil, 'missing websocket URL';
|
||||
end
|
||||
return { url = url };
|
||||
end
|
||||
|
||||
function api.hello(osLike)
|
||||
osLike = osLike or defaultOs();
|
||||
return {
|
||||
type = 'hello',
|
||||
computerId = osLike.getComputerID(),
|
||||
computerLabel = osLike.getComputerLabel(),
|
||||
};
|
||||
end
|
||||
|
||||
function api.executeLua(code)
|
||||
local output = {};
|
||||
if type(code) ~= 'string' or code == '' then
|
||||
return { ok = false, error = 'code must be a non-empty string', output = '' };
|
||||
end
|
||||
|
||||
local fn, loadErr = load(code, 'mcp-exec', 't', createExecEnv(output));
|
||||
if not fn then
|
||||
return { ok = false, error = tostring(loadErr), output = table.concat(output) };
|
||||
end
|
||||
|
||||
local values = table.pack(pcall(fn));
|
||||
if not values[1] then
|
||||
return { ok = false, error = tostring(values[2]), output = table.concat(output) };
|
||||
end
|
||||
|
||||
local returns = {};
|
||||
for i = 2, values.n do
|
||||
returns[#returns + 1] = serializeReturn(values[i]);
|
||||
end
|
||||
return { ok = true, returns = returns, output = table.concat(output) };
|
||||
end
|
||||
|
||||
function api.writeFile(path, content, fsLike)
|
||||
fsLike = fsLike or fs;
|
||||
if type(path) ~= 'string' or path == '' then
|
||||
return { ok = false, error = 'path must be a non-empty string' };
|
||||
end
|
||||
|
||||
if type(content) ~= 'string' then
|
||||
return { ok = false, error = 'content must be a string' };
|
||||
end
|
||||
|
||||
local handle, openErr = fsLike.open(path, 'w');
|
||||
if not handle then
|
||||
return { ok = false, error = tostring(openErr or 'failed to open file') };
|
||||
end
|
||||
|
||||
local ok, writeErr = pcall(function()
|
||||
handle.write(content);
|
||||
end);
|
||||
local closeOk, closeErr = pcall(function()
|
||||
handle.close();
|
||||
end);
|
||||
|
||||
if not ok then
|
||||
return { ok = false, error = tostring(writeErr) };
|
||||
end
|
||||
if not closeOk then
|
||||
return { ok = false, error = tostring(closeErr) };
|
||||
end
|
||||
|
||||
return { ok = true, path = path, bytes = string.len(content) };
|
||||
end
|
||||
|
||||
function api.handleRequest(request, osLike)
|
||||
if type(request) ~= 'table' or request.type ~= 'request' or type(request.id) ~= 'string' then
|
||||
return nil;
|
||||
end
|
||||
|
||||
if request.method == 'ping' then
|
||||
return {
|
||||
type = 'response',
|
||||
id = request.id,
|
||||
ok = true,
|
||||
result = api.formatPong(osLike),
|
||||
};
|
||||
end
|
||||
|
||||
if request.method == 'exec-lua' then
|
||||
local params = request.params;
|
||||
local result = api.executeLua(type(params) == 'table' and params.code or nil);
|
||||
return {
|
||||
type = 'response',
|
||||
id = request.id,
|
||||
ok = result.ok,
|
||||
result = {
|
||||
returns = result.returns or {},
|
||||
output = result.output or '',
|
||||
},
|
||||
error = result.error,
|
||||
};
|
||||
end
|
||||
|
||||
if request.method == 'write-file' then
|
||||
local params = request.params;
|
||||
local result = api.writeFile(
|
||||
type(params) == 'table' and params.path or nil,
|
||||
type(params) == 'table' and params.content or nil
|
||||
);
|
||||
return {
|
||||
type = 'response',
|
||||
id = request.id,
|
||||
ok = result.ok,
|
||||
result = {
|
||||
path = result.path,
|
||||
bytes = result.bytes,
|
||||
},
|
||||
error = result.error,
|
||||
};
|
||||
end
|
||||
|
||||
return {
|
||||
type = 'response',
|
||||
id = request.id,
|
||||
ok = false,
|
||||
error = 'unknown method',
|
||||
};
|
||||
end
|
||||
|
||||
-- Resolve the websocket URL from CLI args first, then fall back to the
|
||||
-- 'mcp-computer.ws-url' setting when no argument is provided.
|
||||
function api.resolveUrl(args, settingsLib)
|
||||
args = args or {};
|
||||
local count = args.n or #args;
|
||||
|
||||
if count > 0 then
|
||||
return api.parseArgs(args);
|
||||
end
|
||||
|
||||
local url = settingsLib and settingsLib.get('mcp-computer.ws-url');
|
||||
if type(url) ~= 'string' or url == '' then
|
||||
return nil, 'missing websocket URL (pass a URL or set mcp-computer.ws-url)';
|
||||
end
|
||||
return { url = url };
|
||||
end
|
||||
|
||||
-- Decode a raw websocket frame and dispatch it. Non-request frames (e.g.
|
||||
-- 'hello-ok') and malformed payloads produce no response.
|
||||
function api.onMessage(content, osLike, sendFn, decode)
|
||||
decode = decode or textutils.unserializeJSON;
|
||||
|
||||
local ok, frame = pcall(decode, content);
|
||||
if not ok then
|
||||
return;
|
||||
end
|
||||
|
||||
local response = api.handleRequest(frame, osLike);
|
||||
if response and sendFn then
|
||||
sendFn(response);
|
||||
end
|
||||
end
|
||||
|
||||
-- Wire an event-driven MCP session onto an eventloop and connect. Registers
|
||||
-- websocket handlers and returns immediately, so it never blocks the boot
|
||||
-- sequence. Auto-reconnects on failure or close.
|
||||
function api.startSession(opts)
|
||||
opts = opts or {};
|
||||
local el = assert(opts.eventloop, 'startSession requires an eventloop');
|
||||
local url = assert(opts.url, 'startSession requires a url');
|
||||
local osLike = opts.os or defaultOs();
|
||||
local httpLike = opts.http or http;
|
||||
local reconnectDelay = opts.reconnectDelay or 5;
|
||||
local encode = opts.encode or textutils.serializeJSON;
|
||||
local decode = opts.decode or textutils.unserializeJSON;
|
||||
|
||||
local activeWs = nil;
|
||||
|
||||
local function connect()
|
||||
httpLike.websocketAsync(url);
|
||||
end
|
||||
|
||||
local function scheduleReconnect()
|
||||
activeWs = nil;
|
||||
el.setTimeout(connect, reconnectDelay);
|
||||
end
|
||||
|
||||
el.register('websocket_success', function(eventUrl, ws)
|
||||
if eventUrl ~= url then return; end
|
||||
activeWs = ws;
|
||||
ws.send(encode(api.hello(osLike)));
|
||||
end);
|
||||
|
||||
el.register('websocket_message', function(eventUrl, content)
|
||||
if eventUrl ~= url then return; end
|
||||
api.onMessage(content, osLike, function(response)
|
||||
if activeWs then
|
||||
activeWs.send(encode(response));
|
||||
end
|
||||
end, decode);
|
||||
end);
|
||||
|
||||
el.register('websocket_failure', function(eventUrl)
|
||||
if eventUrl ~= url then return; end
|
||||
scheduleReconnect();
|
||||
end);
|
||||
|
||||
el.register('websocket_closed', function(eventUrl)
|
||||
if eventUrl ~= url then return; end
|
||||
scheduleReconnect();
|
||||
end);
|
||||
|
||||
connect();
|
||||
|
||||
return {
|
||||
isConnected = function() return activeWs ~= nil; end,
|
||||
};
|
||||
end
|
||||
|
||||
return api;
|
||||
end
|
||||
|
||||
return createMcpComputer;
|
||||
@ -1,49 +0,0 @@
|
||||
-- TrapOS router state machine: (label -> id) map with TTL.
|
||||
--
|
||||
-- Pure logic so it can be unit-tested without a modem or eventloop.
|
||||
-- The wire glue lives in /programs/router.lua.
|
||||
local DEFAULT_TTL = 90;
|
||||
|
||||
local function createRouter(options)
|
||||
options = options or {};
|
||||
local ttl = options.ttl or DEFAULT_TTL;
|
||||
local nowFn = options.now or os.clock;
|
||||
|
||||
local labelMap = {};
|
||||
|
||||
local function isExpired(entry)
|
||||
return entry.expiresAt < nowFn();
|
||||
end
|
||||
|
||||
local function register(label, id)
|
||||
local existing = labelMap[label];
|
||||
if existing and existing.id ~= id and not isExpired(existing) then
|
||||
return false, 'duplicate label';
|
||||
end
|
||||
labelMap[label] = { id = id, expiresAt = nowFn() + ttl };
|
||||
return true;
|
||||
end
|
||||
|
||||
local function resolve(label)
|
||||
local entry = labelMap[label];
|
||||
if not entry then return nil end
|
||||
if isExpired(entry) then
|
||||
labelMap[label] = nil;
|
||||
return nil;
|
||||
end
|
||||
return entry.id;
|
||||
end
|
||||
|
||||
local function forget(label)
|
||||
labelMap[label] = nil;
|
||||
end
|
||||
|
||||
return {
|
||||
register = register,
|
||||
resolve = resolve,
|
||||
forget = forget,
|
||||
ttl = ttl,
|
||||
};
|
||||
end
|
||||
|
||||
return createRouter;
|
||||
141
apis/libtest.lua
141
apis/libtest.lua
@ -1,141 +0,0 @@
|
||||
local DEFAULT_TIMEOUT_SECONDS = 3
|
||||
|
||||
local function createLibTest(args)
|
||||
local api = {}
|
||||
local tests = {}
|
||||
local pretty = false
|
||||
local verbose = false
|
||||
local reportPath = nil
|
||||
local printMarker = true
|
||||
local timeoutSeconds = DEFAULT_TIMEOUT_SECONDS
|
||||
|
||||
local i = 1
|
||||
while i <= #(args or {}) do
|
||||
local arg = args[i]
|
||||
if arg == "--verbose" then
|
||||
pretty = true
|
||||
verbose = true
|
||||
elseif arg == "--pretty" then
|
||||
pretty = true
|
||||
elseif arg == "--report" then
|
||||
reportPath = args[i + 1]
|
||||
i = i + 1
|
||||
elseif arg == "--no-marker" then
|
||||
printMarker = false
|
||||
elseif arg == "--timeout" then
|
||||
timeoutSeconds = tonumber(args[i + 1])
|
||||
i = i + 1
|
||||
elseif arg == "--no-timeout" then
|
||||
timeoutSeconds = nil
|
||||
end
|
||||
i = i + 1
|
||||
end
|
||||
|
||||
local function fail(message)
|
||||
error(message, 2)
|
||||
end
|
||||
|
||||
local function writeReport(line)
|
||||
if not reportPath then
|
||||
return
|
||||
end
|
||||
|
||||
local file = fs.open(reportPath, "a")
|
||||
if file then
|
||||
file.writeLine(line)
|
||||
file.close()
|
||||
end
|
||||
end
|
||||
|
||||
function api.log(message)
|
||||
if verbose then
|
||||
writeReport("LOG " .. tostring(message))
|
||||
end
|
||||
end
|
||||
|
||||
function api.test(name, fn)
|
||||
assert(type(name) == "string", "bad argument #1 (string expected)")
|
||||
assert(type(fn) == "function", "bad argument #2 (function expected)")
|
||||
|
||||
tests[#tests + 1] = { name = name, fn = fn }
|
||||
end
|
||||
|
||||
function api.assertEquals(actual, expected, message)
|
||||
if actual ~= expected then
|
||||
fail(
|
||||
(message or "assertEquals failed")
|
||||
.. ": expected "
|
||||
.. tostring(expected)
|
||||
.. ", got "
|
||||
.. tostring(actual)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
function api.assertTrue(value, message)
|
||||
if not value then
|
||||
fail(message or "assertTrue failed")
|
||||
end
|
||||
end
|
||||
|
||||
function api.assertErrors(fn, expected)
|
||||
assert(type(fn) == "function", "bad argument #1 (function expected)")
|
||||
|
||||
local ok, err = pcall(fn)
|
||||
if ok then
|
||||
fail("expected error")
|
||||
end
|
||||
if expected and not string.find(tostring(err), expected, 1, true) then
|
||||
fail("expected error containing " .. expected .. ", got " .. tostring(err))
|
||||
end
|
||||
end
|
||||
|
||||
local function runCase(fn)
|
||||
if not timeoutSeconds then
|
||||
return pcall(fn)
|
||||
end
|
||||
|
||||
local ok, err
|
||||
local finished = false
|
||||
local function runner()
|
||||
ok, err = pcall(fn)
|
||||
finished = true
|
||||
end
|
||||
local function timer()
|
||||
sleep(timeoutSeconds)
|
||||
end
|
||||
|
||||
parallel.waitForAny(runner, timer)
|
||||
if not finished then
|
||||
return false, "libtest timeout after " .. timeoutSeconds .. "s", true
|
||||
end
|
||||
return ok, err
|
||||
end
|
||||
|
||||
function api.run()
|
||||
for _, t in ipairs(tests) do
|
||||
api.log("RUN " .. t.name)
|
||||
local ok, err, timedOut = runCase(t.fn)
|
||||
if not ok then
|
||||
if timedOut then
|
||||
api.log("TIMEOUT " .. t.name .. " after " .. timeoutSeconds .. "s (libtest)")
|
||||
end
|
||||
writeReport("KO " .. t.name .. ": " .. tostring(err))
|
||||
if not pretty then
|
||||
print("FAIL " .. t.name .. ": " .. tostring(err))
|
||||
end
|
||||
error(err, 0)
|
||||
end
|
||||
writeReport("OK " .. t.name)
|
||||
end
|
||||
|
||||
if printMarker then
|
||||
print("__TRAPOS_TEST_OK__")
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return api
|
||||
end
|
||||
|
||||
return createLibTest
|
||||
@ -1,178 +0,0 @@
|
||||
local DEFAULT_THROTTLE_SECONDS = 5;
|
||||
local DEFAULT_MAX_REPLY_CHARS = 160;
|
||||
local DEFAULT_PREFIX = 'TrapGPT';
|
||||
local DEFAULT_SESSION_SETTING_KEY = 'trapgpt.opencc.session_id';
|
||||
local SILENCE = 'SILENCE';
|
||||
|
||||
local function nowSeconds()
|
||||
if os.epoch then
|
||||
return os.epoch('utc') / 1000;
|
||||
end
|
||||
return os.clock();
|
||||
end
|
||||
|
||||
local function resolveNumber(value, defaultValue)
|
||||
local n = tonumber(value);
|
||||
if not n or n < 0 then return defaultValue; end
|
||||
return n;
|
||||
end
|
||||
|
||||
local function trim(s)
|
||||
return tostring(s or ''):gsub('^%s+', ''):gsub('%s+$', '');
|
||||
end
|
||||
|
||||
local function truncate(s, maxChars)
|
||||
s = trim(s);
|
||||
if #s <= maxChars then return s; end
|
||||
if maxChars <= 3 then return string.sub(s, 1, maxChars); end
|
||||
return string.sub(s, 1, maxChars - 3) .. '...';
|
||||
end
|
||||
|
||||
local function formatChatLine(message)
|
||||
local at = message.at and ('@' .. tostring(math.floor(message.at)) .. ' ') or '';
|
||||
return at .. tostring(message.username or '?') .. ': ' .. tostring(message.text or '');
|
||||
end
|
||||
|
||||
local function buildPrompt(messages, firstBatch, maxReplyChars)
|
||||
local lines = {
|
||||
'Tu es TrapGPT dans le chat Minecraft.',
|
||||
'Reponds seulement si utile.',
|
||||
'Reponse tres concise: une phrase courte, maximum ' .. tostring(maxReplyChars) .. ' caracteres.',
|
||||
'Pas de markdown. Ne repete pas l historique.',
|
||||
'Si aucune reponse utile, reponds exactement: ' .. SILENCE,
|
||||
};
|
||||
if firstBatch then
|
||||
lines[#lines + 1] = 'Contexte initial: voici les premiers messages recus.';
|
||||
end
|
||||
lines[#lines + 1] = '';
|
||||
lines[#lines + 1] = 'Nouveaux messages chat depuis le dernier envoi:';
|
||||
for _, message in ipairs(messages) do
|
||||
lines[#lines + 1] = formatChatLine(message);
|
||||
end
|
||||
return table.concat(lines, '\n');
|
||||
end
|
||||
|
||||
local function createTrapGpt(opts)
|
||||
opts = opts or {};
|
||||
|
||||
local settingsLib = opts.settings or settings;
|
||||
local nowFunc = opts.now or nowSeconds;
|
||||
local sleepFunc = opts.sleep or sleep;
|
||||
local ai = opts.ai or require('/apis/libai')();
|
||||
local chatBox = opts.chatBox;
|
||||
local log = opts.log or print;
|
||||
|
||||
local api = {};
|
||||
local history = {};
|
||||
local sentIndex = 0;
|
||||
local firstBatch = true;
|
||||
local lastSendAt = 0;
|
||||
local active = false;
|
||||
local stopped = false;
|
||||
|
||||
local function throttleSeconds()
|
||||
return resolveNumber(settingsLib.get('trapgpt.throttle_seconds'), DEFAULT_THROTTLE_SECONDS);
|
||||
end
|
||||
|
||||
local function maxReplyChars()
|
||||
return math.max(1, resolveNumber(settingsLib.get('trapgpt.max_reply_chars'), DEFAULT_MAX_REPLY_CHARS));
|
||||
end
|
||||
|
||||
local function prefix()
|
||||
local value = settingsLib.get('trapgpt.prefix');
|
||||
if type(value) ~= 'string' or value == '' then return DEFAULT_PREFIX; end
|
||||
return value;
|
||||
end
|
||||
|
||||
local function queuedMessages()
|
||||
local messages = {};
|
||||
for i = sentIndex + 1, #history do
|
||||
messages[#messages + 1] = history[i];
|
||||
end
|
||||
return messages;
|
||||
end
|
||||
|
||||
local function shouldIgnore(username, text, isHidden)
|
||||
if isHidden then return true; end
|
||||
if type(text) ~= 'string' or trim(text) == '' then return true; end
|
||||
if type(username) == 'string' and username == prefix() then return true; end
|
||||
return false;
|
||||
end
|
||||
|
||||
function api.onChat(username, message, uuid, isHidden, messageUtf8)
|
||||
local text = messageUtf8 or message;
|
||||
if shouldIgnore(username, text, isHidden) then return false; end
|
||||
history[#history + 1] = {
|
||||
username = username,
|
||||
text = text,
|
||||
uuid = uuid,
|
||||
at = nowFunc(),
|
||||
};
|
||||
return true;
|
||||
end
|
||||
|
||||
function api.pendingCount()
|
||||
return #history - sentIndex;
|
||||
end
|
||||
|
||||
function api.history()
|
||||
return history;
|
||||
end
|
||||
|
||||
function api.buildPrompt(messages)
|
||||
return buildPrompt(messages, firstBatch, maxReplyChars());
|
||||
end
|
||||
|
||||
function api.processOnce()
|
||||
if active or api.pendingCount() <= 0 then return false; end
|
||||
|
||||
local waitSeconds = throttleSeconds() - (nowFunc() - lastSendAt);
|
||||
if waitSeconds > 0 then sleepFunc(waitSeconds); end
|
||||
|
||||
local startIndex = sentIndex + 1;
|
||||
local messages = queuedMessages();
|
||||
if #messages == 0 then return false; end
|
||||
|
||||
active = true;
|
||||
local ok, result = ai.ask(buildPrompt(messages, firstBatch, maxReplyChars()), {
|
||||
sessionSettingKey = DEFAULT_SESSION_SETTING_KEY,
|
||||
sessionTitle = 'trapgpt',
|
||||
});
|
||||
active = false;
|
||||
lastSendAt = nowFunc();
|
||||
|
||||
if not ok then
|
||||
log('trapgpt ai error: ' .. tostring(result));
|
||||
return false;
|
||||
end
|
||||
|
||||
sentIndex = startIndex + #messages - 1;
|
||||
firstBatch = false;
|
||||
|
||||
local reply = truncate(result.reply, maxReplyChars());
|
||||
if reply == '' or reply == SILENCE then return true; end
|
||||
if chatBox then
|
||||
local sent, err = chatBox.sendMessage(reply, prefix());
|
||||
if not sent then log('trapgpt chat error: ' .. tostring(err)); end
|
||||
end
|
||||
return true, reply;
|
||||
end
|
||||
|
||||
function api.stop()
|
||||
stopped = true;
|
||||
end
|
||||
|
||||
function api.run()
|
||||
while not stopped do
|
||||
if api.pendingCount() > 0 then
|
||||
api.processOnce();
|
||||
else
|
||||
sleepFunc(0.25);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return api;
|
||||
end
|
||||
|
||||
return createTrapGpt;
|
||||
718
apis/libtui.lua
718
apis/libtui.lua
@ -1,718 +0,0 @@
|
||||
local utf8 = rawget(_G, 'utf8');
|
||||
|
||||
local NODE_TEXT = 'text';
|
||||
local NODE_BUTTON = 'button';
|
||||
local NODE_BOX = 'box';
|
||||
local NODE_FRAGMENT = 'fragment';
|
||||
|
||||
local DEFAULT_COLOR = colors.white;
|
||||
local DEFAULT_BG_COLOR = colors.black;
|
||||
local DISABLED_COLOR = colors.gray;
|
||||
|
||||
local function isArray(value)
|
||||
if type(value) ~= 'table' then
|
||||
return false;
|
||||
end
|
||||
|
||||
if value.kind then
|
||||
return false;
|
||||
end
|
||||
|
||||
local count = 0;
|
||||
for key, _ in pairs(value) do
|
||||
if type(key) ~= 'number' then
|
||||
return false;
|
||||
end
|
||||
if key > count then
|
||||
count = key;
|
||||
end
|
||||
end
|
||||
|
||||
return count > 0;
|
||||
end
|
||||
|
||||
local function firstColor(value, fallback)
|
||||
if type(value) == 'table' then
|
||||
return value[1] or fallback;
|
||||
end
|
||||
|
||||
return value or fallback;
|
||||
end
|
||||
|
||||
local function firstFunction(value)
|
||||
if type(value) == 'function' then
|
||||
return value;
|
||||
end
|
||||
|
||||
if type(value) == 'table' and type(value[1]) == 'function' then
|
||||
return value[1];
|
||||
end
|
||||
|
||||
return nil;
|
||||
end
|
||||
|
||||
local function shallowCopy(value)
|
||||
local result = {};
|
||||
|
||||
if type(value) ~= 'table' then
|
||||
return result;
|
||||
end
|
||||
|
||||
for key, item in pairs(value) do
|
||||
result[key] = item;
|
||||
end
|
||||
|
||||
return result;
|
||||
end
|
||||
|
||||
local function utf8Len(value)
|
||||
value = tostring(value or '');
|
||||
|
||||
local ok, iter, state, init = pcall(utf8.codes, value);
|
||||
if not ok then
|
||||
return string.len(value);
|
||||
end
|
||||
|
||||
local count = 0;
|
||||
for _ in iter, state, init do
|
||||
count = count + 1;
|
||||
end
|
||||
|
||||
return count;
|
||||
end
|
||||
|
||||
local function utf8Sub(value, startIndex, endIndex)
|
||||
value = tostring(value or '');
|
||||
startIndex = math.max(1, math.floor(startIndex or 1));
|
||||
endIndex = endIndex == nil and math.huge or math.floor(endIndex);
|
||||
|
||||
if endIndex < startIndex then
|
||||
return '';
|
||||
end
|
||||
|
||||
local ok, iter, state, init = pcall(utf8.codes, value);
|
||||
if not ok then
|
||||
return string.sub(value, startIndex, endIndex);
|
||||
end
|
||||
|
||||
local byteStart;
|
||||
local byteEnd;
|
||||
local index = 0;
|
||||
|
||||
for pos, _ in iter, state, init do
|
||||
index = index + 1;
|
||||
|
||||
if index == startIndex then
|
||||
byteStart = pos;
|
||||
end
|
||||
|
||||
if index == endIndex + 1 then
|
||||
byteEnd = pos - 1;
|
||||
break;
|
||||
end
|
||||
end
|
||||
|
||||
if not byteStart then
|
||||
return '';
|
||||
end
|
||||
|
||||
if not byteEnd then
|
||||
byteEnd = string.len(value);
|
||||
end
|
||||
|
||||
return string.sub(value, byteStart, byteEnd);
|
||||
end
|
||||
|
||||
local function makeNode(kind, props)
|
||||
props = props or {};
|
||||
return {
|
||||
kind = kind,
|
||||
props = props,
|
||||
};
|
||||
end
|
||||
|
||||
local function makeText(value, props)
|
||||
if type(value) == 'table' and props == nil then
|
||||
props = shallowCopy(value);
|
||||
else
|
||||
props = shallowCopy(props);
|
||||
props.text = value;
|
||||
end
|
||||
|
||||
return makeNode(NODE_TEXT, props);
|
||||
end
|
||||
|
||||
local function makeButton(value, props)
|
||||
if type(value) == 'table' and props == nil then
|
||||
props = shallowCopy(value);
|
||||
else
|
||||
props = shallowCopy(props);
|
||||
props.text = value;
|
||||
end
|
||||
|
||||
return makeNode(NODE_BUTTON, props);
|
||||
end
|
||||
|
||||
local function makeBox(props)
|
||||
return makeNode(NODE_BOX, shallowCopy(props));
|
||||
end
|
||||
|
||||
local function makeList(props)
|
||||
props = shallowCopy(props);
|
||||
props.direction = 'column';
|
||||
return makeNode(NODE_BOX, props);
|
||||
end
|
||||
|
||||
local function makeFragment(children)
|
||||
return makeNode(NODE_FRAGMENT, { children = children or {} });
|
||||
end
|
||||
|
||||
local function lineText(value, width)
|
||||
value = tostring(value or '');
|
||||
|
||||
if width <= 0 then
|
||||
return '';
|
||||
end
|
||||
|
||||
local valueLength = utf8Len(value);
|
||||
if valueLength > width then
|
||||
return utf8Sub(value, 1, width);
|
||||
end
|
||||
|
||||
return value .. string.rep(' ', width - valueLength);
|
||||
end
|
||||
|
||||
local function shrinkRect(rect, amount)
|
||||
amount = amount or 0;
|
||||
return {
|
||||
x = rect.x + amount,
|
||||
y = rect.y + amount,
|
||||
w = rect.w - amount * 2,
|
||||
h = rect.h - amount * 2,
|
||||
};
|
||||
end
|
||||
|
||||
local function isInside(rect, x, y)
|
||||
return x >= rect.x and x < rect.x + rect.w and y >= rect.y and y < rect.y + rect.h;
|
||||
end
|
||||
|
||||
local function fillRect(rect, bgColor)
|
||||
if rect.w <= 0 or rect.h <= 0 then
|
||||
return;
|
||||
end
|
||||
|
||||
term.setBackgroundColor(bgColor);
|
||||
for y = rect.y, rect.y + rect.h - 1 do
|
||||
term.setCursorPos(rect.x, y);
|
||||
term.write(string.rep(' ', rect.w));
|
||||
end
|
||||
end
|
||||
|
||||
local function writeAt(x, y, text, width)
|
||||
if width <= 0 then
|
||||
return;
|
||||
end
|
||||
|
||||
term.setCursorPos(x, y);
|
||||
term.write(lineText(text, width));
|
||||
end
|
||||
|
||||
local function drawBorder(rect, props)
|
||||
if rect.w <= 1 or rect.h <= 1 then
|
||||
return;
|
||||
end
|
||||
|
||||
local color = firstColor(props.color, DEFAULT_COLOR);
|
||||
local bgColor = firstColor(props.bgColor, DEFAULT_BG_COLOR);
|
||||
local top = '+' .. string.rep('-', rect.w - 2) .. '+';
|
||||
local bottom = top;
|
||||
local title = props.title;
|
||||
|
||||
if title then
|
||||
local titleText = ' ' .. tostring(title) .. ' ';
|
||||
local titleLength = utf8Len(titleText);
|
||||
if titleLength < rect.w - 1 then
|
||||
top = '+' .. titleText .. string.rep('-', rect.w - 2 - titleLength) .. '+';
|
||||
end
|
||||
end
|
||||
|
||||
term.setTextColor(color);
|
||||
term.setBackgroundColor(bgColor);
|
||||
writeAt(rect.x, rect.y, top, rect.w);
|
||||
writeAt(rect.x, rect.y + rect.h - 1, bottom, rect.w);
|
||||
|
||||
for y = rect.y + 1, rect.y + rect.h - 2 do
|
||||
term.setCursorPos(rect.x, y);
|
||||
term.write('|');
|
||||
term.setCursorPos(rect.x + rect.w - 1, y);
|
||||
term.write('|');
|
||||
end
|
||||
end
|
||||
|
||||
local function normalizeChildren(children)
|
||||
local result = {};
|
||||
|
||||
if children == nil then
|
||||
return result;
|
||||
end
|
||||
|
||||
if type(children) ~= 'table' or children.kind then
|
||||
return { children };
|
||||
end
|
||||
|
||||
if isArray(children) then
|
||||
for _, child in ipairs(children) do
|
||||
table.insert(result, child);
|
||||
end
|
||||
else
|
||||
table.insert(result, children);
|
||||
end
|
||||
|
||||
return result;
|
||||
end
|
||||
|
||||
local resolveNode;
|
||||
|
||||
local function resolveChildren(children)
|
||||
local result = {};
|
||||
|
||||
for _, child in ipairs(normalizeChildren(children)) do
|
||||
local node = resolveNode(child);
|
||||
if node then
|
||||
table.insert(result, node);
|
||||
end
|
||||
end
|
||||
|
||||
return result;
|
||||
end
|
||||
|
||||
function resolveNode(input)
|
||||
if input == nil then
|
||||
return nil;
|
||||
end
|
||||
|
||||
if type(input) == 'function' then
|
||||
return resolveNode(input());
|
||||
end
|
||||
|
||||
if type(input) == 'string' or type(input) == 'number' then
|
||||
return makeText(tostring(input));
|
||||
end
|
||||
|
||||
if type(input) ~= 'table' then
|
||||
return makeText(tostring(input));
|
||||
end
|
||||
|
||||
if input.kind then
|
||||
local props = shallowCopy(input.props);
|
||||
props.children = resolveChildren(props.children);
|
||||
return makeNode(input.kind, props);
|
||||
end
|
||||
|
||||
if isArray(input) then
|
||||
return makeFragment(resolveChildren(input));
|
||||
end
|
||||
|
||||
return makeFragment(resolveChildren(input.children));
|
||||
end
|
||||
|
||||
local function buttonLabel(props)
|
||||
return '[ ' .. tostring(props.text or props.label or '') .. ' ]';
|
||||
end
|
||||
|
||||
local function flexOf(child)
|
||||
local f = (child.props or {}).flex;
|
||||
if f == true then
|
||||
return 1;
|
||||
end
|
||||
if type(f) == 'number' and f > 0 then
|
||||
return f;
|
||||
end
|
||||
return nil;
|
||||
end
|
||||
|
||||
local naturalSize;
|
||||
|
||||
local function childrenNaturalSize(children, direction, gap)
|
||||
local width = 0;
|
||||
local height = 0;
|
||||
local mainCount = 0;
|
||||
|
||||
for _, child in ipairs(children) do
|
||||
local hasFlex = flexOf(child) ~= nil;
|
||||
local childWidth, childHeight = naturalSize(child);
|
||||
|
||||
if direction == 'row' then
|
||||
height = math.max(height, childHeight);
|
||||
if not hasFlex then
|
||||
if mainCount > 0 then
|
||||
width = width + gap;
|
||||
end
|
||||
width = width + childWidth;
|
||||
mainCount = mainCount + 1;
|
||||
end
|
||||
else
|
||||
width = math.max(width, childWidth);
|
||||
if not hasFlex then
|
||||
if mainCount > 0 then
|
||||
height = height + gap;
|
||||
end
|
||||
height = height + childHeight;
|
||||
mainCount = mainCount + 1;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return width, height;
|
||||
end
|
||||
|
||||
function naturalSize(node)
|
||||
if not node then
|
||||
return 0, 0;
|
||||
end
|
||||
|
||||
local props = node.props or {};
|
||||
|
||||
if node.kind == NODE_TEXT then
|
||||
return props.width or utf8Len(tostring(props.text or '')), props.height or 1;
|
||||
end
|
||||
|
||||
if node.kind == NODE_BUTTON then
|
||||
return props.width or utf8Len(buttonLabel(props)), props.height or 1;
|
||||
end
|
||||
|
||||
local direction = props.direction or 'column';
|
||||
local gap = props.gap or 0;
|
||||
local padding = props.padding or 0;
|
||||
local border = props.border and 2 or 0;
|
||||
local childWidth, childHeight = childrenNaturalSize(props.children or {}, direction, gap);
|
||||
|
||||
if node.kind == NODE_FRAGMENT then
|
||||
return childWidth, childHeight;
|
||||
end
|
||||
|
||||
return props.width or childWidth + padding * 2 + border, props.height or childHeight + padding * 2 + border;
|
||||
end
|
||||
|
||||
local function applyNodeColors(props)
|
||||
term.setTextColor(firstColor(props.color, DEFAULT_COLOR));
|
||||
term.setBackgroundColor(firstColor(props.bgColor, DEFAULT_BG_COLOR));
|
||||
end
|
||||
|
||||
local function childAxisSize(child, direction)
|
||||
local props = child.props or {};
|
||||
local naturalWidth, naturalHeight = naturalSize(child);
|
||||
|
||||
if direction == 'row' then
|
||||
return props.width or naturalWidth;
|
||||
end
|
||||
|
||||
return props.height or naturalHeight;
|
||||
end
|
||||
|
||||
local function layoutChildren(children, rect, direction, gap)
|
||||
local fixedSize = 0;
|
||||
local flexSize = 0;
|
||||
local axisSize = direction == 'row' and rect.w or rect.h;
|
||||
local layouts = {};
|
||||
local lastFlexIndex = nil;
|
||||
local flexes = {};
|
||||
|
||||
for index, child in ipairs(children) do
|
||||
local flex = flexOf(child);
|
||||
flexes[index] = flex;
|
||||
if flex then
|
||||
flexSize = flexSize + flex;
|
||||
lastFlexIndex = index;
|
||||
else
|
||||
fixedSize = fixedSize + childAxisSize(child, direction);
|
||||
end
|
||||
end
|
||||
|
||||
local remaining = axisSize - fixedSize - math.max(#children - 1, 0) * gap;
|
||||
if remaining < 0 then
|
||||
remaining = 0;
|
||||
end
|
||||
|
||||
local cursor = direction == 'row' and rect.x or rect.y;
|
||||
local usedFlexSize = 0;
|
||||
|
||||
for index, child in ipairs(children) do
|
||||
local props = child.props or {};
|
||||
local flex = flexes[index];
|
||||
local size;
|
||||
|
||||
if flex then
|
||||
if index == lastFlexIndex then
|
||||
size = remaining - usedFlexSize;
|
||||
else
|
||||
size = math.floor(remaining * flex / flexSize);
|
||||
usedFlexSize = usedFlexSize + size;
|
||||
end
|
||||
else
|
||||
size = childAxisSize(child, direction);
|
||||
end
|
||||
|
||||
if size < 0 then
|
||||
size = 0;
|
||||
end
|
||||
|
||||
if direction == 'row' then
|
||||
table.insert(layouts, { node = child, rect = { x = cursor, y = rect.y, w = size, h = props.height or rect.h } });
|
||||
else
|
||||
table.insert(layouts, { node = child, rect = { x = rect.x, y = cursor, w = props.width or rect.w, h = size } });
|
||||
end
|
||||
|
||||
cursor = cursor + size + gap;
|
||||
end
|
||||
|
||||
return layouts;
|
||||
end
|
||||
|
||||
local function createTui(eventloop)
|
||||
assert(type(eventloop) == 'table', 'bad argument #1 (eventloop expected)');
|
||||
assert(type(eventloop.register) == 'function', 'bad argument #1 (eventloop expected)');
|
||||
assert(type(eventloop.startLoop) == 'function', 'bad argument #1 (eventloop expected)');
|
||||
|
||||
local api = {};
|
||||
local root = nil;
|
||||
local clickables = {};
|
||||
local finalEvent = nil;
|
||||
local previousState = nil;
|
||||
|
||||
local function createErrorEvent(reason)
|
||||
if type(reason) == 'table' then
|
||||
return {
|
||||
type = 'error',
|
||||
error = {
|
||||
name = reason.name or 'libtui error',
|
||||
reason = reason.reason or tostring(reason),
|
||||
},
|
||||
};
|
||||
end
|
||||
|
||||
return {
|
||||
type = 'error',
|
||||
error = {
|
||||
name = 'libtui error',
|
||||
reason = tostring(reason),
|
||||
},
|
||||
};
|
||||
end
|
||||
|
||||
local function stopWith(event)
|
||||
finalEvent = finalEvent or event;
|
||||
if eventloop.isRunningLoop and eventloop.isRunningLoop() then
|
||||
eventloop.stopLoop();
|
||||
end
|
||||
end
|
||||
|
||||
local renderNode;
|
||||
|
||||
local function addClickable(node, rect)
|
||||
local props = node.props or {};
|
||||
local handler = firstFunction(props.onClick);
|
||||
|
||||
if not handler or props.disabled then
|
||||
return;
|
||||
end
|
||||
|
||||
table.insert(clickables, {
|
||||
rect = rect,
|
||||
node = node,
|
||||
handler = handler,
|
||||
});
|
||||
end
|
||||
|
||||
local function drawTextNode(node, rect)
|
||||
local props = node.props or {};
|
||||
|
||||
if rect.w <= 0 or rect.h <= 0 then
|
||||
return;
|
||||
end
|
||||
|
||||
fillRect(rect, firstColor(props.bgColor, DEFAULT_BG_COLOR));
|
||||
applyNodeColors(props);
|
||||
writeAt(rect.x, rect.y, props.text or '', rect.w);
|
||||
addClickable(node, rect);
|
||||
end
|
||||
|
||||
local function drawButtonNode(node, rect)
|
||||
local props = node.props or {};
|
||||
|
||||
if rect.w <= 0 or rect.h <= 0 then
|
||||
return;
|
||||
end
|
||||
|
||||
fillRect(rect, firstColor(props.bgColor, DEFAULT_BG_COLOR));
|
||||
term.setTextColor(props.disabled and DISABLED_COLOR or firstColor(props.color, DEFAULT_COLOR));
|
||||
term.setBackgroundColor(firstColor(props.bgColor, DEFAULT_BG_COLOR));
|
||||
writeAt(rect.x, rect.y, buttonLabel(props), rect.w);
|
||||
addClickable(node, rect);
|
||||
end
|
||||
|
||||
local function drawBoxNode(node, rect)
|
||||
local props = node.props or {};
|
||||
local children = props.children or {};
|
||||
local contentRect = rect;
|
||||
local padding = props.padding or 0;
|
||||
local direction = props.direction or 'column';
|
||||
local gap = props.gap or 0;
|
||||
|
||||
if rect.w <= 0 or rect.h <= 0 then
|
||||
return;
|
||||
end
|
||||
|
||||
fillRect(rect, firstColor(props.bgColor, DEFAULT_BG_COLOR));
|
||||
addClickable(node, rect);
|
||||
|
||||
if props.border then
|
||||
drawBorder(rect, props);
|
||||
contentRect = shrinkRect(contentRect, 1);
|
||||
end
|
||||
|
||||
if padding > 0 then
|
||||
contentRect = shrinkRect(contentRect, padding);
|
||||
end
|
||||
|
||||
if contentRect.w <= 0 or contentRect.h <= 0 then
|
||||
return;
|
||||
end
|
||||
|
||||
for _, item in ipairs(layoutChildren(children, contentRect, direction, gap)) do
|
||||
renderNode(item.node, item.rect);
|
||||
end
|
||||
end
|
||||
|
||||
function renderNode(node, rect)
|
||||
if node.kind == NODE_TEXT then
|
||||
drawTextNode(node, rect);
|
||||
elseif node.kind == NODE_BUTTON then
|
||||
drawButtonNode(node, rect);
|
||||
else
|
||||
drawBoxNode(node, rect);
|
||||
end
|
||||
end
|
||||
|
||||
local function redraw()
|
||||
local width, height = term.getSize();
|
||||
|
||||
clickables = {};
|
||||
term.setCursorBlink(false);
|
||||
term.setTextColor(DEFAULT_COLOR);
|
||||
term.setBackgroundColor(DEFAULT_BG_COLOR);
|
||||
term.clear();
|
||||
renderNode(resolveNode(root), { x = 1, y = 1, w = width, h = height });
|
||||
end
|
||||
|
||||
local function safeRedraw()
|
||||
local ok, reason = pcall(redraw);
|
||||
if not ok then
|
||||
stopWith(createErrorEvent(reason));
|
||||
end
|
||||
end
|
||||
|
||||
local function safeClick(handler, event)
|
||||
local ok, reason = pcall(handler, api, event);
|
||||
if not ok then
|
||||
stopWith(createErrorEvent(reason));
|
||||
end
|
||||
end
|
||||
|
||||
function api.exitUI(reason)
|
||||
stopWith({ type = 'exitUI', reason = reason });
|
||||
end
|
||||
|
||||
function api.rerender()
|
||||
if root == nil then
|
||||
return;
|
||||
end
|
||||
safeRedraw();
|
||||
end
|
||||
|
||||
local function restoreTerminal()
|
||||
if not previousState then
|
||||
return;
|
||||
end
|
||||
term.setTextColor(previousState.color);
|
||||
term.setBackgroundColor(previousState.bgColor);
|
||||
term.clear();
|
||||
term.setCursorPos(previousState.cursorX, previousState.cursorY);
|
||||
term.setCursorBlink(previousState.cursorBlink);
|
||||
previousState = nil;
|
||||
end
|
||||
|
||||
function api.render(nextRoot)
|
||||
finalEvent = nil;
|
||||
root = nextRoot;
|
||||
|
||||
previousState = {
|
||||
color = term.getTextColor(),
|
||||
bgColor = term.getBackgroundColor(),
|
||||
cursorX = 1,
|
||||
cursorY = 1,
|
||||
cursorBlink = false,
|
||||
};
|
||||
|
||||
if term.getCursorPos then
|
||||
previousState.cursorX, previousState.cursorY = term.getCursorPos();
|
||||
end
|
||||
|
||||
if term.getCursorBlink then
|
||||
previousState.cursorBlink = term.getCursorBlink();
|
||||
end
|
||||
|
||||
eventloop.onStart(safeRedraw);
|
||||
eventloop.onStop(restoreTerminal);
|
||||
|
||||
eventloop.register('mouse_click', function(button, x, y)
|
||||
for index = #clickables, 1, -1 do
|
||||
local item = clickables[index];
|
||||
if isInside(item.rect, x, y) then
|
||||
safeClick(item.handler, {
|
||||
type = 'mouse_click',
|
||||
button = button,
|
||||
x = x,
|
||||
y = y,
|
||||
node = item.node,
|
||||
});
|
||||
return;
|
||||
end
|
||||
end
|
||||
end);
|
||||
|
||||
eventloop.register('term_resize', function()
|
||||
safeRedraw();
|
||||
end);
|
||||
|
||||
eventloop.register('terminate', function()
|
||||
finalEvent = finalEvent or { type = 'terminate' };
|
||||
end);
|
||||
|
||||
local ok, reason = pcall(eventloop.startLoop);
|
||||
if not ok then
|
||||
pcall(restoreTerminal);
|
||||
finalEvent = createErrorEvent(reason);
|
||||
end
|
||||
|
||||
root = nil;
|
||||
clickables = {};
|
||||
|
||||
return finalEvent or { type = 'exitUI' };
|
||||
end
|
||||
|
||||
api.Text = makeText;
|
||||
api.text = makeText;
|
||||
api.Button = makeButton;
|
||||
api.button = makeButton;
|
||||
api.Box = makeBox;
|
||||
api.box = makeBox;
|
||||
api.List = makeList;
|
||||
api.list = makeList;
|
||||
api.Fragment = makeFragment;
|
||||
api.eventloop = eventloop;
|
||||
|
||||
return api;
|
||||
end
|
||||
|
||||
return createTui;
|
||||
@ -1,102 +0,0 @@
|
||||
-- libversion: resolve a file's version from its owning package descriptor.
|
||||
--
|
||||
-- A factory: `local createVersion = require('/apis/libversion'); local v = createVersion();`
|
||||
--
|
||||
-- Resolution order for `api.forFile(path)`:
|
||||
-- 1. `<stateDir>/ccpm.lock.json` — production lookup against installed packages.
|
||||
-- 2. `<repoRoot>/packages/*/ccpm.json` — dev fallback (e.g. `just trapos`,
|
||||
-- where the repo is mounted read-only at `/trapos` and ccpm has never run).
|
||||
-- 3. `'?'` when no descriptor lists the file.
|
||||
--
|
||||
-- `api.forSelf()` returns the version of the currently running program by
|
||||
-- delegating to `shell.getRunningProgram()`.
|
||||
|
||||
local DEFAULT_STATE_DIR = '/trapos';
|
||||
local DEFAULT_REPO_ROOT = '/trapos';
|
||||
|
||||
local function normalizePath(path)
|
||||
if type(path) ~= 'string' or path == '' then return nil; end
|
||||
path = path:gsub('\\', '/');
|
||||
path = path:gsub('//+', '/');
|
||||
if path:sub(1, 1) ~= '/' then
|
||||
path = '/' .. path;
|
||||
end
|
||||
return path;
|
||||
end
|
||||
|
||||
local function readJsonFile(fsLib, path)
|
||||
if not fsLib.exists(path) then return nil; end
|
||||
local f = fsLib.open(path, 'r');
|
||||
if not f then return nil; end
|
||||
local data = f.readAll();
|
||||
f.close();
|
||||
if not data or data == '' then return nil; end
|
||||
return textutils.unserializeJSON(data);
|
||||
end
|
||||
|
||||
local function fileMatches(files, target)
|
||||
if type(files) ~= 'table' then return false; end
|
||||
for _, raw in ipairs(files) do
|
||||
if normalizePath(raw) == target then
|
||||
return true;
|
||||
end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
|
||||
local function createVersion(opts)
|
||||
opts = opts or {};
|
||||
local fsLib = opts.fs or fs;
|
||||
local shellLib = opts.shell or shell;
|
||||
local stateDir = opts.stateDir or DEFAULT_STATE_DIR;
|
||||
local repoRoot = opts.repoRoot or DEFAULT_REPO_ROOT;
|
||||
|
||||
local lockPath = stateDir .. '/ccpm.lock.json';
|
||||
local packagesDir = repoRoot .. '/packages';
|
||||
|
||||
local api = {};
|
||||
|
||||
local function lookupInLock(target)
|
||||
local lock = readJsonFile(fsLib, lockPath);
|
||||
if not lock or type(lock.packages) ~= 'table' then return nil; end
|
||||
for _, entry in pairs(lock.packages) do
|
||||
if fileMatches(entry.files, target) then
|
||||
return entry.version;
|
||||
end
|
||||
end
|
||||
return nil;
|
||||
end
|
||||
|
||||
local function lookupInRepo(target)
|
||||
if not fsLib.isDir(packagesDir) then return nil; end
|
||||
for _, name in ipairs(fsLib.list(packagesDir)) do
|
||||
local descPath = packagesDir .. '/' .. name .. '/ccpm.json';
|
||||
local desc = readJsonFile(fsLib, descPath);
|
||||
if desc and fileMatches(desc.files, target) then
|
||||
return desc.version;
|
||||
end
|
||||
end
|
||||
return nil;
|
||||
end
|
||||
|
||||
function api.forFile(path)
|
||||
local target = normalizePath(path);
|
||||
if not target then return '?'; end
|
||||
local v = lookupInLock(target);
|
||||
if v then return v; end
|
||||
v = lookupInRepo(target);
|
||||
if v then return v; end
|
||||
return '?';
|
||||
end
|
||||
|
||||
function api.forSelf()
|
||||
if not shellLib or not shellLib.getRunningProgram then return '?'; end
|
||||
local path = shellLib.getRunningProgram();
|
||||
if not path or path == '' then return '?'; end
|
||||
return api.forFile(path);
|
||||
end
|
||||
|
||||
return api;
|
||||
end
|
||||
|
||||
return createVersion;
|
||||
403
apis/net.lua
403
apis/net.lua
@ -1,212 +1,269 @@
|
||||
-- TrapOS networking: service-name bus on a single channel.
|
||||
--
|
||||
-- Servers register handlers on the boot eventloop:
|
||||
-- net.serve('ping', function(msg, reply) reply('pong') end)
|
||||
-- net.listen('events.foo', function(msg, packet) ... end)
|
||||
--
|
||||
-- Clients (CLI programs) call or send without an eventloop:
|
||||
-- local ok, res = net.call('ping', 'ping', { destId = 5, timeout = 0.5 })
|
||||
-- net.send('events.foo', payload, { destId = 'alice' })
|
||||
--
|
||||
-- A router service (servers/router-server.lua) must run on exactly one machine.
|
||||
-- It resolves label-addressed packets, stamps routerId, and rebroadcasts.
|
||||
local _VERSION = '2.1.2';
|
||||
|
||||
local createEventLoop = require('/apis/eventloop');
|
||||
|
||||
local BUS_CHANNEL = 10;
|
||||
local DEFAULT_TIMEOUT = 0.5;
|
||||
|
||||
local nextRequestSeq = 1;
|
||||
local function newRequestId()
|
||||
local id = tostring(os.getComputerID()) .. ':' .. tostring(nextRequestSeq) .. ':' .. tostring(os.clock());
|
||||
nextRequestSeq = nextRequestSeq + 1;
|
||||
return id;
|
||||
end
|
||||
local DEFAULT_TIMEOUT_WAIT_MESSAGE = 0.5; -- in seconds
|
||||
local DEFAULT_ROUTING_CHANNEL = 10;
|
||||
|
||||
-- Utilitaire pour savoir si un packet nous est destiné.
|
||||
-- le parametre 'packet' est une table avec les champs suivants:
|
||||
-- - sourceId: l'id de la machine qui a envoyé le message
|
||||
-- - destId: l'id du destinataire, si l'id est nil le message est routé a tout le monde
|
||||
-- - routerId: l'id du routeur qui s'est occupé de transmettre le message
|
||||
-- - message: le contenu du message (qui sera le plus souvent une table)
|
||||
-- return un boolean
|
||||
local function isPacketOk(packet)
|
||||
if type(packet) ~= 'table' then return false end
|
||||
if not packet.routerId or not packet.sourceId then return false end
|
||||
if packet.destId == nil then return true end
|
||||
if type(packet.destId) == 'number' and packet.destId == os.getComputerID() then return true end
|
||||
if type(packet.destId) == 'string' and packet.destId == os.getComputerLabel() then return true end
|
||||
return false
|
||||
if type(packet) ~= "table" then
|
||||
return false;
|
||||
end
|
||||
|
||||
|
||||
if not packet.routerId or not packet.sourceId then
|
||||
return false;
|
||||
end
|
||||
|
||||
-- if packet.sourceId == os.getComputerID() then
|
||||
-- return false;
|
||||
-- end
|
||||
|
||||
if packet.destId == nil then
|
||||
return true;
|
||||
end
|
||||
|
||||
if type(packet.destId) == 'number' and packet.destId == os.getComputerID() then
|
||||
return true;
|
||||
end
|
||||
|
||||
if type(packet.destId) == 'string' and packet.destId == os.getComputerLabel() then
|
||||
return true;
|
||||
end
|
||||
|
||||
return false;
|
||||
end
|
||||
|
||||
local function createNetwork(el, modem, modemSide)
|
||||
-- -- Example: implementation simple de ping
|
||||
--
|
||||
--
|
||||
-- local createNet = require('apis/net');
|
||||
-- net = createNet();
|
||||
-- local net = createNet(nil, modem);
|
||||
|
||||
-- net.listenRequest(PING_CHANNEL, 'ping', function(message, reply)
|
||||
-- if message == 'ping' then
|
||||
-- reply('pong');
|
||||
-- end
|
||||
-- end)
|
||||
--
|
||||
local function createNetwork(el, modem, routingChannel, timeoutInSec)
|
||||
el = el or createEventLoop();
|
||||
modem = modem or peripheral.find('modem') or error('modem not found');
|
||||
modem.open(BUS_CHANNEL);
|
||||
modem = modem or peripheral.find("modem") or error("modem not found");
|
||||
routingChannel = routingChannel or DEFAULT_ROUTING_CHANNEL;
|
||||
timeoutInSec = timeoutInSec or DEFAULT_TIMEOUT_WAIT_MESSAGE;
|
||||
|
||||
local isRouter = false;
|
||||
modemSide = modemSide or peripheral.getName(modem);
|
||||
local function openChannel(chan)
|
||||
return modem.open(chan);
|
||||
end
|
||||
|
||||
local function buildPacket(service, kind, payload, destId, requestId)
|
||||
return {
|
||||
sourceId = os.getComputerID(),
|
||||
sourceLabel = os.getComputerLabel(),
|
||||
-- net.send function
|
||||
local function sendRaw(channel, message, destId)
|
||||
local sourceId = os.getComputerID()
|
||||
local sourceLabel = os.getComputerLabel();
|
||||
local routerId = nil;
|
||||
|
||||
if _G.isRouterEnabled then
|
||||
routerId = sourceId
|
||||
end
|
||||
|
||||
local packet = {
|
||||
sourceId = sourceId,
|
||||
sourceLabel = sourceLabel,
|
||||
routerId = routerId,
|
||||
destId = tonumber(destId) or destId,
|
||||
service = service,
|
||||
kind = kind,
|
||||
requestId = requestId,
|
||||
payload = payload,
|
||||
routerId = isRouter and os.getComputerID() or nil,
|
||||
};
|
||||
end
|
||||
message = message
|
||||
}
|
||||
|
||||
local function transmit(packet)
|
||||
local selfId = os.getComputerID();
|
||||
local selfLabel = os.getComputerLabel();
|
||||
|
||||
local destIsSelfId = packet.destId == selfId;
|
||||
local destIsSelfLabel = selfLabel ~= nil and packet.destId == selfLabel;
|
||||
local destIsSelf = destIsSelfId or destIsSelfLabel;
|
||||
local matchesSelf = packet.destId == nil or destIsSelf;
|
||||
|
||||
if matchesSelf then
|
||||
local localPacket = {};
|
||||
for k, v in pairs(packet) do localPacket[k] = v end
|
||||
localPacket.routerId = localPacket.routerId or selfId;
|
||||
os.queueEvent('modem_message', modemSide, BUS_CHANNEL, BUS_CHANNEL, localPacket, 0);
|
||||
if packet.destId ~= nil and packet.destId == sourceId then
|
||||
packet.routerId = packet.sourceId;
|
||||
os.queueEvent('modem_message', peripheral.getName(modem), channel, channel, packet, 0);
|
||||
return nil;
|
||||
end
|
||||
|
||||
if not destIsSelf then
|
||||
modem.transmit(BUS_CHANNEL, BUS_CHANNEL, packet);
|
||||
if packet.destId == nil or packet.destId == sourceLabel then
|
||||
os.queueEvent('modem_message', peripheral.getName(modem), channel, channel, packet, 0);
|
||||
end
|
||||
|
||||
if packet.routerId then
|
||||
return modem.transmit(channel, channel, packet);
|
||||
end
|
||||
|
||||
return modem.transmit(routingChannel, channel, packet);
|
||||
end
|
||||
|
||||
local function serve(serviceName, handler)
|
||||
local function listenRaw(channel, handler)
|
||||
openChannel(channel);
|
||||
|
||||
return el.register('modem_message', function(_, _, replyChannel, packet)
|
||||
if replyChannel ~= BUS_CHANNEL then return end
|
||||
if not isPacketOk(packet) then return end
|
||||
if packet.service ~= serviceName or packet.kind ~= 'req' then return end
|
||||
if isPacketOk(packet) and channel == replyChannel then
|
||||
handler(packet.message, packet);
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function reply(responsePayload)
|
||||
local response = buildPacket(serviceName, 'res', responsePayload, packet.sourceId, packet.requestId);
|
||||
transmit(response);
|
||||
local function send(channel, eventType, payload, destId)
|
||||
local event = { type = eventType, payload = payload };
|
||||
return sendRaw(channel, event, destId);
|
||||
end
|
||||
|
||||
local function listen(channel, eventType, handler)
|
||||
return listenRaw(channel, function(event, packet)
|
||||
if event.type == eventType then
|
||||
handler(event.payload, packet)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function listenRequest(channel, eventType, handler)
|
||||
return listen(channel, eventType, function(payload, packet)
|
||||
local reply = function(responsePayload)
|
||||
send(channel, eventType .. "_response", responsePayload, packet.sourceId);
|
||||
end
|
||||
|
||||
handler(packet.payload, reply, packet);
|
||||
end);
|
||||
handler(payload, reply, packet);
|
||||
end)
|
||||
end
|
||||
|
||||
local function listen(serviceName, handler)
|
||||
return el.register('modem_message', function(_, _, replyChannel, packet)
|
||||
if replyChannel ~= BUS_CHANNEL then return end
|
||||
if not isPacketOk(packet) then return end
|
||||
if packet.service ~= serviceName or packet.kind ~= 'evt' then return end
|
||||
local function sendRequest(channel, eventType, payload, destId)
|
||||
local ok = false;
|
||||
local result = nil;
|
||||
local packetResult = nil;
|
||||
|
||||
handler(packet.payload, packet);
|
||||
end);
|
||||
local privateEventLoop = createEventLoop();
|
||||
local privateNet = createNetwork(privateEventLoop, modem, routingChannel, timeoutInSec);
|
||||
|
||||
privateNet.listen(channel, eventType .. "_response", function(responsePayload, packet)
|
||||
ok = true;
|
||||
result = responsePayload
|
||||
packetResult = packet;
|
||||
privateNet.stop();
|
||||
end)
|
||||
|
||||
privateEventLoop.setTimeout(function()
|
||||
result = "net.sendRequest timeout!"
|
||||
privateNet.stop();
|
||||
end, timeoutInSec);
|
||||
|
||||
privateNet.onStart(function()
|
||||
privateNet.send(channel, eventType, payload, destId);
|
||||
end)
|
||||
|
||||
privateNet.startLoop();
|
||||
|
||||
return ok, result, packetResult;
|
||||
end
|
||||
|
||||
local function send(serviceName, payload, opts)
|
||||
opts = opts or {};
|
||||
transmit(buildPacket(serviceName, 'evt', payload, opts.destId, nil));
|
||||
end
|
||||
local function sendMultipleRequests(channel, eventType, payload, destId)
|
||||
if destId ~= nil and tonumber(destId) ~= nil then
|
||||
local ok, res, packet = sendRequest(channel, eventType, payload, destId);
|
||||
|
||||
local function awaitResponse(serviceName, requestId, timeout, collectMultiple)
|
||||
local timerId = os.startTimer(timeout);
|
||||
if not ok then
|
||||
return ok, res, packet
|
||||
end
|
||||
|
||||
return ok, { res }, { packet };
|
||||
end
|
||||
|
||||
local ok = false;
|
||||
local results = {};
|
||||
local packets = {};
|
||||
local packetResults = {};
|
||||
|
||||
while true do
|
||||
local event, p1, _, p3, p4 = os.pullEvent();
|
||||
if event == 'timer' and p1 == timerId then
|
||||
if collectMultiple then
|
||||
if #results == 0 then return false, 'net.call timeout', {} end
|
||||
return true, results, packets;
|
||||
end
|
||||
return false, 'net.call timeout', nil;
|
||||
elseif event == 'modem_message' then
|
||||
local replyChannel = p3;
|
||||
local recvPacket = p4;
|
||||
if replyChannel == BUS_CHANNEL
|
||||
and isPacketOk(recvPacket)
|
||||
and recvPacket.service == serviceName
|
||||
and recvPacket.kind == 'res'
|
||||
and recvPacket.requestId == requestId then
|
||||
if collectMultiple then
|
||||
table.insert(results, recvPacket.payload);
|
||||
table.insert(packets, recvPacket);
|
||||
else
|
||||
os.cancelTimer(timerId);
|
||||
return true, recvPacket.payload, recvPacket;
|
||||
end
|
||||
end
|
||||
local privateEventLoop = createEventLoop();
|
||||
local privateNet = createNetwork(privateEventLoop, modem, routingChannel, timeoutInSec);
|
||||
|
||||
privateNet.listen(channel, eventType .. "_response", function(responsePayload, packet)
|
||||
ok = true;
|
||||
table.insert(results, responsePayload)
|
||||
table.insert(packetResults, packet);
|
||||
end)
|
||||
|
||||
privateEventLoop.setTimeout(function()
|
||||
if #results == 0 then
|
||||
results = "net.sendRequest timeout!"
|
||||
end
|
||||
privateNet.stop();
|
||||
end, timeoutInSec);
|
||||
|
||||
privateNet.onStart(function()
|
||||
privateNet.send(channel, eventType, payload, destId);
|
||||
end)
|
||||
|
||||
privateNet.startLoop();
|
||||
|
||||
return ok, results, packetResults;
|
||||
end
|
||||
|
||||
local function createRequest(channel, eventType)
|
||||
local requestApi = {};
|
||||
|
||||
function requestApi.send(payload, destId)
|
||||
return sendRequest(channel, eventType, payload, destId);
|
||||
end
|
||||
end
|
||||
|
||||
local function call(serviceName, payload, opts)
|
||||
opts = opts or {};
|
||||
local timeout = opts.timeout or DEFAULT_TIMEOUT;
|
||||
local requestId = newRequestId();
|
||||
transmit(buildPacket(serviceName, 'req', payload, opts.destId, requestId));
|
||||
return awaitResponse(serviceName, requestId, timeout, false);
|
||||
end
|
||||
|
||||
local function callMultiple(serviceName, payload, opts)
|
||||
opts = opts or {};
|
||||
local timeout = opts.timeout or DEFAULT_TIMEOUT;
|
||||
local requestId = newRequestId();
|
||||
transmit(buildPacket(serviceName, 'req', payload, opts.destId, requestId));
|
||||
return awaitResponse(serviceName, requestId, timeout, true);
|
||||
end
|
||||
|
||||
local function setRouter(enabled)
|
||||
isRouter = enabled and true or false;
|
||||
end
|
||||
|
||||
local function onUnrouted(handler)
|
||||
return el.register('modem_message', function(_, _, replyChannel, packet)
|
||||
if replyChannel ~= BUS_CHANNEL then return end
|
||||
if type(packet) ~= 'table' then return end
|
||||
if packet.routerId then return end
|
||||
if not packet.sourceId then return end
|
||||
handler(packet);
|
||||
end);
|
||||
end
|
||||
|
||||
local function rebroadcast(packet)
|
||||
packet.routerId = packet.routerId or os.getComputerID();
|
||||
local selfId = os.getComputerID();
|
||||
local selfLabel = os.getComputerLabel();
|
||||
local destIsSelfId = packet.destId == selfId;
|
||||
local destIsSelfLabel = selfLabel ~= nil and packet.destId == selfLabel;
|
||||
local destIsSelf = destIsSelfId or destIsSelfLabel;
|
||||
local matchesSelf = packet.destId == nil or destIsSelf;
|
||||
|
||||
if matchesSelf then
|
||||
os.queueEvent('modem_message', modemSide, BUS_CHANNEL, BUS_CHANNEL, packet, 0);
|
||||
function requestApi.sendMultiple(payload, destId)
|
||||
return sendMultipleRequests(channel, eventType, payload, destId);
|
||||
end
|
||||
if not destIsSelf then
|
||||
modem.transmit(BUS_CHANNEL, BUS_CHANNEL, packet);
|
||||
|
||||
function requestApi.listen(handler)
|
||||
return listenRequest(channel, eventType, handler)
|
||||
end
|
||||
|
||||
return requestApi;
|
||||
end
|
||||
|
||||
local function createEvent(channel, eventType)
|
||||
local eventApi = {}
|
||||
|
||||
|
||||
function eventApi.send(payload, destId)
|
||||
return send(channel, eventType, payload, destId);
|
||||
end
|
||||
|
||||
function eventApi.listen(handler)
|
||||
return listen(channel, eventType, handler)
|
||||
end
|
||||
|
||||
return eventApi;
|
||||
end
|
||||
|
||||
local function start()
|
||||
return el.startLoop();
|
||||
end
|
||||
|
||||
local function stop()
|
||||
return el.stopLoop();
|
||||
end
|
||||
|
||||
return {
|
||||
BUS_CHANNEL = BUS_CHANNEL,
|
||||
DEFAULT_TIMEOUT = DEFAULT_TIMEOUT,
|
||||
eventloop = el,
|
||||
isPacketOk = isPacketOk,
|
||||
serve = serve,
|
||||
listen = listen,
|
||||
sendRaw = sendRaw,
|
||||
listenRaw = listenRaw,
|
||||
send = send,
|
||||
call = call,
|
||||
callMultiple = callMultiple,
|
||||
setRouter = setRouter,
|
||||
onUnrouted = onUnrouted,
|
||||
rebroadcast = rebroadcast,
|
||||
};
|
||||
listen = listen,
|
||||
sendRequest = sendRequest,
|
||||
sendMultipleRequests = sendMultipleRequests,
|
||||
listenRequest = listenRequest,
|
||||
createRequest = createRequest,
|
||||
createEvent = createEvent,
|
||||
isPacketOk = isPacketOk,
|
||||
openChannel = openChannel,
|
||||
open = openChannel,
|
||||
events = el,
|
||||
eventloop = el,
|
||||
start = start,
|
||||
startLoop = start,
|
||||
stop = stop,
|
||||
stopLoop = stop,
|
||||
onStart = el.onStart,
|
||||
onStop = el.onStop,
|
||||
}
|
||||
end
|
||||
|
||||
local singleton = nil;
|
||||
|
||||
return function(el, modem, modemSide)
|
||||
if el == nil and modem == nil and _G.bootEventLoop then
|
||||
if not singleton then
|
||||
singleton = createNetwork(_G.bootEventLoop, nil, nil);
|
||||
end
|
||||
return singleton;
|
||||
end
|
||||
return createNetwork(el, modem, modemSide);
|
||||
end
|
||||
return createNetwork;
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
# Documentation
|
||||
|
||||
Start here when looking up ComputerCraft-related APIs, CraftOS-PC behavior, peripherals, or mod integrations used by this repository.
|
||||
Start here when looking up ComputerCraft-related APIs, peripherals, or mod integrations used by this repository.
|
||||
|
||||
## Indexes
|
||||
|
||||
- [`craftos_pc_glossary.md`](craftos_pc_glossary.md) - CraftOS-PC emulator setup, CLI flags, mounting, peripheral emulation, troubleshooting, and related references.
|
||||
- [`../.opencode/agent-context/atm10-expert/INDEX.md`](../.opencode/agent-context/atm10-expert/INDEX.md) - ATM10 in-game agent context, including CC:Tweaked, Advanced Peripherals, and Create CC:Tweaked glossaries.
|
||||
- [`opencode_server_guide.md`](opencode_server_guide.md) - Running `opencode serve` for the TrapOS `ai` client.
|
||||
- [`ingame-trapos-ai-mcp-guide.md`](ingame-trapos-ai-mcp-guide.md) - Concise in-game checklist for installing TrapOS, connecting `ai`, and linking MCP.
|
||||
- [`opencode_api.md`](opencode_api.md) - Minimal opencode HTTP API reference used by TrapOS.
|
||||
- [`public-ports.md`](public-ports.md) - Public production TCP port allocation for TrapOS services.
|
||||
- [`cc_glossary.md`](cc_glossary.md) - CC:Tweaked globals, modules, peripherals, events, and guides.
|
||||
- [`advanced_peripherals_glossary.md`](advanced_peripherals_glossary.md) - Advanced Peripherals 0.7 guides, peripherals, turtles, integrations, and changelog pages.
|
||||
- [`create_cc_tweaked_glossary.md`](create_cc_tweaked_glossary.md) - Create CC:Tweaked integration pages.
|
||||
- [`adrs/`](adrs/) - Lightweight Architecture Decision Records for this repository.
|
||||
|
||||
## Notes
|
||||
|
||||
@ -8,14 +8,7 @@ Future ADRs can reuse the shape of the existing files when it is useful.
|
||||
|
||||
## Records
|
||||
|
||||
- [`adr-0001-target-computercraft.md`](adr-0001-target-computercraft.md) — Target ComputerCraft.
|
||||
- [`adr-0002-eventloop-and-service-bus.md`](adr-0002-eventloop-and-service-bus.md) — Eventloop substrate, service-name bus on a single channel, and `os.sleep` discipline.
|
||||
- [`adr-0005-craftos-pc-harness-and-probes.md`](adr-0005-craftos-pc-harness-and-probes.md) — CraftOS-PC as the local harness, minimal periphemu bootstrap, and headless probes as the canonical hypothesis-test pattern.
|
||||
- [`adr-0007-test-framework.md`](adr-0007-test-framework.md) — `libtest` per-case helper, `runtest` suite orchestration, and the two-layer timeout (libtest + shell watchdog).
|
||||
- [`adr-0010-ccpm-package-manager.md`](adr-0010-ccpm-package-manager.md) — `ccpm` package manager (packages, registries, package-aware bootstrap).
|
||||
- [`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.
|
||||
- [`adr-0001-target-computercraft.md`](adr-0001-target-computercraft.md) - Target ComputerCraft.
|
||||
- [`adr-0002-use-eventloop-for-async-code.md`](adr-0002-use-eventloop-for-async-code.md) - Use eventloop for async code.
|
||||
- [`adr-0003-current-net-api-state.md`](adr-0003-current-net-api-state.md) - Current net API state.
|
||||
- [`adr-0004-trapos-branding-and-manifest.md`](adr-0004-trapos-branding-and-manifest.md) - TrapOS branding and manifest-driven installs.
|
||||
|
||||
@ -1,77 +0,0 @@
|
||||
# ADR 0002: Eventloop Substrate, Service Bus, and Async Discipline
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-06-07
|
||||
|
||||
## Context
|
||||
|
||||
ComputerCraft is event-driven. Direct `os.pullEvent` loops are easy to write but hard to compose when multiple things need to happen at the same time. Without a single substrate the repo accumulated several distinct problems:
|
||||
|
||||
- Each long-lived process owned a private event loop, including the router (`programs/router.lua` was a hand-rolled `while true / os.pullEvent`). With N autostart servers, `parallel.waitForAll` ran N coroutines each pumping an independent `os.pullEventRaw`. Events were broadcast to every coroutine but only one would have a relevant handler — wasteful and conceptually awkward.
|
||||
- `_G.isRouterEnabled` mutated send behavior across the codebase. [`apis/net.lua`](../../apis/net.lua) `sendRaw` switched its transmit path based on a global flag set by the router program, so the same function call meant different things depending on which machine ran it.
|
||||
- Channel numbers leaked into every client. `servers/ping-server.lua` and `programs/ping.lua` both duplicated a `PING_CHANNEL` constant; there was no service registry. Adding a new service meant picking a free integer and replicating it on both ends.
|
||||
- Label collision was a silent footgun. Two machines sharing the same label both accepted and rebroadcast packets addressed to that label, producing duplicate responses and duplicate retransmits.
|
||||
- `os.sleep` looked innocent but broke the substrate. Its CC:Tweaked implementation yields via `os.pullEvent("timer")`. While the sleep is in flight, the enclosing eventloop's `os.pullEventRaw` is paused; non-`timer` events are silently discarded; even `eventloop.setTimeout` callbacks scheduled before the sleep cannot fire until it returns. This bit `apis/libai.lua` `pollMessage`, which used a sleep-based throttle and froze the whole loop the moment a caller invoked it from inside a handler.
|
||||
|
||||
Net's blast radius at the time of the bus rewrite was small (only ping consumed it), so a clean break was cheaper than incremental patching.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Eventloop is the async substrate
|
||||
|
||||
New async code uses [`apis/eventloop.lua`](../../apis/eventloop.lua). Event handlers, timers, server listeners, and UI behavior compose through the eventloop instead of each feature owning its own blocking loop.
|
||||
|
||||
- Prefer `eventloop.register`, `setTimeout`, `onStart`, `onStop`, and `startLoop` for async behavior.
|
||||
- APIs that listen for events accept an existing event loop as a constructor argument, the way [`apis/net.lua`](../../apis/net.lua) does. Do not create a private loop inside a module.
|
||||
- Direct `os.pullEvent` loops should be rare and justified (CLI programs waiting for a single reply are the main exception).
|
||||
- A handler that returns `api.STOP` auto-unregisters.
|
||||
|
||||
### 2. One boot eventloop and a service-name bus
|
||||
|
||||
`startup/servers.lua` creates a single `createEventLoop()` instance, stores it at `_G.bootEventLoop`, runs autostart server files (which register handlers and return without blocking), then runs `parallel.waitForAny(shellFn, eventLoopFn)`. The shell and the eventloop are the only two coroutines.
|
||||
|
||||
[`apis/net.lua`](../../apis/net.lua) exposes a service-name bus on a single channel:
|
||||
|
||||
- `net.serve(name, handler)` — register a server handler (server-side).
|
||||
- `net.call(name, payload, opts)` — request/response with timeout (client-side).
|
||||
- `net.send(name, payload, opts)` — fire-and-forget (client-side).
|
||||
- `net.listen(name, handler)` — passive listener.
|
||||
|
||||
All traffic flows on channel `10` and is demultiplexed inside the packet body via a `service` field. Channel numbers stop being a public concept. `require('/apis/net')()` returns a singleton bound to `_G.bootEventLoop` when present, otherwise an ephemeral instance. CLI programs stay standalone: `net.call` internally uses `os.pullEvent` with a timer, so programs do not need the boot eventloop to receive a response.
|
||||
|
||||
[`programs/router.lua`](../../programs/router.lua) registers handlers on the same boot eventloop everything else uses. It owns a TTL-based label map extracted into [`apis/librouter.lua`](../../apis/librouter.lua) for testability. Machines with a label autostart [`servers/net-registrar.lua`](../../servers/net-registrar.lua), which periodically broadcasts `(id, label)` so the router can resolve label-addressed packets. Duplicate label registrations are rejected with a printed warning. `_G.isRouterEnabled` is gone; the router service flips a local flag via `net.setRouter(true)` instead.
|
||||
|
||||
### 3. `os.sleep` discipline
|
||||
|
||||
In library, server, and program code that may run inside an eventloop (directly or transitively), use `eventloop.setTimeout` for any waiting, throttling, polling, or retry-with-delay. Libraries that need to temporize must take an eventloop factory through their constructor rather than baking a hardcoded sleep call. [`apis/net.lua`](../../apis/net.lua) `sendRequest` is the canonical private-eventloop pattern: create a private eventloop, schedule the wait through `setTimeout`, then `runLoop` until the work resolves — synchronous from the caller's perspective, but the dispatcher stays alive internally so handlers can compose around it via `parallel.waitForAll`.
|
||||
|
||||
`os.sleep` remains acceptable only in narrow cases:
|
||||
|
||||
1. One-shot programs that are purely sequential and register no event handlers — a `programs/foo.lua` that prints, sleeps, prints again, and exits.
|
||||
2. `parallel.waitForAny(task, function() sleep(t); end)` used as an isolated guard to bound an inner task (e.g. the AI Lua-exec sandbox in `apis/libai.lua` and the `parallel.waitForAny`-driven per-case timer in `apis/libtest.lua`). The guard sleep is private to its own coroutine group; it does not block anything external.
|
||||
3. Tests that are themselves driven by `libtest`'s per-case timeout (see [ADR-0007](adr-0007-test-framework.md)).
|
||||
|
||||
New code must not expose a `sleep` injection point on its constructor. If a wait is needed, accept an `eventloop` factory and schedule through `setTimeout`. Tests substitute a synchronous deterministic eventloop fake the same way they substitute `http` or `settings`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Adding a new networked service is now: write a `servers/foo.lua` that calls `net.serve('foo', handler)` and returns, then add it to a package's `autostart`. No channel allocation, no `.start()` blocking call.
|
||||
- The router program returns immediately instead of blocking the shell. Users type `router` once on the chosen machine and continue using the shell.
|
||||
- Label collisions are detected and rejected at registration time, with a clear warning, instead of causing silent duplicate delivery.
|
||||
- A router must still be running somewhere on the network for cross-machine label-addressed packets; without one, non-router senders produce packets with `routerId = nil` and consumers drop them on receive.
|
||||
- Programs that need to wait for events still work by direct `os.pullEvent`, but if a program registers a long-lived handler on `_G.bootEventLoop` and exits, the handler keeps firing with a stale closure. Programs should prefer `call`/`send` over `serve`/`listen`. This is documented in [`apis/net.lua`](../../apis/net.lua) but not enforced.
|
||||
- Tests for the router state machine live in [`tests/router.lua`](../../tests/router.lua) and exercise [`apis/librouter.lua`](../../apis/librouter.lua) with an injected clock. Tests for the net packet shape and dispatch live in [`tests/net.lua`](../../tests/net.lua) with a fake modem.
|
||||
- Slightly more ceremony in "synchronous-looking" library functions that wait: a private eventloop plus a small `attempt`/`finish` pair. The benefit is clean composition with any caller's eventloop.
|
||||
- Test fakes shift from a `sleep` stub to a synchronous eventloop double. Ergonomics are comparable; the eventloop fake additionally lets tests observe `pending` and `stopped` state, catching leaks the sleep stub would have missed.
|
||||
- Existing call sites are migrated opportunistically when they cause observable bugs. The first `os.sleep` migration is `apis/libai.lua`.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Multi-router topologies. The single-router assumption stays; a network is expected to run `router` on exactly one machine.
|
||||
- Retry and acknowledgement primitives beyond the existing per-call `timeout`.
|
||||
- Unifying `libtui`, `libai`, and `tuidemo` eventloops. They remain private; they are presentation/AI concerns, not network plumbing.
|
||||
28
docs/adrs/adr-0002-use-eventloop-for-async-code.md
Normal file
28
docs/adrs/adr-0002-use-eventloop-for-async-code.md
Normal file
@ -0,0 +1,28 @@
|
||||
# ADR 0002: Use Eventloop For Async Code
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-06-07
|
||||
|
||||
## Context
|
||||
|
||||
ComputerCraft is event-driven. Direct `os.pullEvent` loops are easy to write, but they are hard to compose when multiple things need to happen at the same time.
|
||||
|
||||
This matters for servers, network listeners, timers, peripheral events, and future UI code. UI code especially will need to handle input, redraws, network replies, and timers together.
|
||||
|
||||
## Decision
|
||||
|
||||
New async code should use `/apis/eventloop`.
|
||||
|
||||
Event handlers, timers, server listeners, and future UI behavior should compose through the event loop instead of each feature owning its own blocking event loop.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Prefer `eventloop.register`, `setTimeout`, `onStart`, `onStop`, and `startLoop` for async behavior.
|
||||
- APIs that listen for events should accept an existing event loop as a constructor argument, the way `/apis/net` already takes one. Do not create a private loop inside a module.
|
||||
- Direct `os.pullEvent` loops should be rare and justified.
|
||||
- Existing code can stay as-is for now, but future async, server, and UI code should move toward eventloop composition.
|
||||
36
docs/adrs/adr-0003-current-net-api-state.md
Normal file
36
docs/adrs/adr-0003-current-net-api-state.md
Normal file
@ -0,0 +1,36 @@
|
||||
# ADR 0003: Current Net API State
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-06-07
|
||||
|
||||
## Context
|
||||
|
||||
`/apis/net` is the current networking abstraction in this repository.
|
||||
|
||||
It wraps modem messages with packet metadata and uses `/apis/eventloop` for listeners and request/response flows. It is useful for today's basic routed messages and RPC-like requests, but it is not a final protocol design.
|
||||
|
||||
## Decision
|
||||
|
||||
Keep using `/apis/net` for simple program and server messaging.
|
||||
|
||||
Document the current behavior as the baseline, without over-designing the future protocol before real needs appear.
|
||||
|
||||
## Current State
|
||||
|
||||
- Default routing channel is `10`.
|
||||
- Ping channel is `9`.
|
||||
- Packets include `sourceId`, `sourceLabel`, `routerId`, `destId`, and `message`.
|
||||
- Main convenience APIs include `send`, `listen`, `sendRequest`, `sendMultipleRequests`, `listenRequest`, `createEvent`, `createRequest`, and `openChannel` (alias `open`). Listening on a non-default channel requires `openChannel` first.
|
||||
- `sendRequest` and `sendMultipleRequests` run a private event loop, default to a `0.5s` timeout, and return `ok, result, packet` (or `ok, results, packets` for the multi variant).
|
||||
- Router behavior currently lives separately in `/programs/router.lua`. A router must be running on the network — otherwise non-router senders produce packets with `routerId = nil` and `isPacketOk` drops them on receive, so cross-machine messages silently fail.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Use `/apis/net` for current basic messaging needs.
|
||||
- Keep duplicated well-known channel constants in sync while they remain duplicated.
|
||||
- Future ADRs can replace or refine this one if the network protocol gains discovery, retries, schemas, versioning, auth, or a different routing model.
|
||||
47
docs/adrs/adr-0004-trapos-branding-and-manifest.md
Normal file
47
docs/adrs/adr-0004-trapos-branding-and-manifest.md
Normal file
@ -0,0 +1,47 @@
|
||||
# ADR 0004: TrapOS Branding And Manifest-Driven Installs
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-06-07
|
||||
|
||||
## Context
|
||||
|
||||
The project started as a loose collection of ComputerCraft / CC:Tweaked APIs and programs. As the surface area grew (eventloop, net, router, ping, events, upgrade) and the install/upgrade flow gained a beta channel, three pain points emerged:
|
||||
|
||||
- `install.lua` carries a hardcoded `LIST_FILES` table that has to be edited every time a file is added or removed. Adding a single shipped file means editing the file itself, the installer, and sometimes `startup/servers.lua`.
|
||||
- The `--beta` flag is not persisted. The user has to remember to pass it on every `upgrade`, which makes the beta channel awkward to live on.
|
||||
- The system has no visible identity at boot. There is no name, no version line, nothing that confirms which branch is installed.
|
||||
|
||||
At the same time, the codebase has outgrown the "Trap's ComputerCraft APIs" framing. It is closer to a small in-game operating system than a library, and treating it as one unlocks a clearer story (a name, a version, a manifest, a boot banner).
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt the name **TrapOS** and a manifest-driven install architecture.
|
||||
|
||||
- A single `manifest.json` at the repo root is the source of truth for the project: name, version, branch, list of shipped files, and the list of servers to autostart at boot. Parsed with `textutils.unserializeJSON` / `textutils.serializeJSON` (built-in to CC:Tweaked).
|
||||
- A local copy of that manifest is written to `/trapos/manifest.json` at the end of every install. This local file is the authoritative system state on the computer:
|
||||
- `branch` is the persisted beta opt-in. Once a user installs with `--beta` (and confirms a one-time `(y/N)` prompt), subsequent `upgrade` calls auto-target the `next` branch with no flag needed.
|
||||
- `version` is what the boot MOTD displays.
|
||||
- `files` and `autostart` drive the next install and the boot sequence.
|
||||
- A new `startup/motd.lua` prints a colored `TrapOS v<version>` line at boot — lime for stable, orange with a `[BETA]` tag for beta. Guarded on `term.isColor()` so monochrome terminals still get the text.
|
||||
- `startup/servers.lua` reads `autostart` from the local manifest instead of carrying its own hardcoded list.
|
||||
- The shipped install URL stays at `https://raw.githubusercontent.com/guillaumearm/cc-libs/...` for now. Renaming the GitHub repository is a follow-up, tracked in the "Future Work" section below.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Adding a new file or autostart server is now a one-line edit to `manifest.json`. Both `install.lua` and `startup/servers.lua` pick it up automatically.
|
||||
- The beta channel becomes a real opt-in: a single confirmed `upgrade --beta` is enough to live on `next`. A new `--stable` flag exists for the symmetric opt-out.
|
||||
- The boot banner gives users an immediate sanity check ("am I on the right branch, the right version?").
|
||||
- The system gains an explicit local state directory (`/trapos/`) and a clear contract for what lives there.
|
||||
- The installer takes a hard dependency on `textutils.serializeJSON` / `unserializeJSON`, which require CC:Tweaked ≥ 1.79. This is well within any reasonable target version for Minecraft 1.21.
|
||||
- Existing computers running the old installer still upgrade cleanly: the old `upgrade` fetches the new `install.lua`, which then creates `/trapos/manifest.json` from the manifest it just downloaded.
|
||||
|
||||
## Future Work
|
||||
|
||||
- **Repository rename.** Once the install flow has stabilized on `TrapOS`, the GitHub repository will be renamed from `cc-libs` to a name that matches (likely `trapos`). The install URL inside `install.lua` and `programs/upgrade.lua` will be updated in the same PR, and a redirect at the old URL is sufficient for in-the-wild installs.
|
||||
- **Package manager.** The longer-term direction floated during planning was a small package manager where each directory in the repo is a package. The current manifest is a deliberate step toward that — same shape (name, version, files), single-package case — and can grow into multi-manifest discovery without changing the install/upgrade contract.
|
||||
- **Per-version migration system.** Today the installer carries a static list of legacy files to delete. A future change can replace this with a per-version migration block driven by the manifest.
|
||||
@ -1,86 +0,0 @@
|
||||
# ADR 0005: CraftOS-PC Harness, Periphemu Bootstrap, and Headless Probes
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-06-08
|
||||
|
||||
## Context
|
||||
|
||||
This repository targets CC:Tweaked on Minecraft 1.21. The Lua we ship runs inside the ComputerCraft sandbox: it depends on `os.pullEventRaw`, `peripheral`, `rednet`, `textutils.serializeJSON`, modem channels, and CC-specific globals. Standard Lua cannot execute this code as-is, so a normal local test harness was never a serious option.
|
||||
|
||||
Contributors have been running the code in two places:
|
||||
|
||||
- In-game on a real Minecraft server, which is slow to iterate on.
|
||||
- In **CraftOS-PC** (<https://www.craftos-pc.cc/>), a desktop emulator that ships the same ROM/BIOS as CC:Tweaked, supports modem peripherals via `periphemu`, and can run fully headless (`--cli --headless --script <file>`).
|
||||
|
||||
CraftOS-PC was the *de facto* local harness for months but lived only as a single line in [`AGENTS.md`](../../AGENTS.md). There was no install guide, no minimum version, and `just install` did not check the binary was present. Two related concerns emerged on top of that:
|
||||
|
||||
- `startup/servers.lua` historically called `periphemu.create` four times on computer 0 (a top modem, two `computer` peers at ids 1 and 2 both labelled `Trap`, and a router peer at id 10). In CraftOS-PC GUI mode this opened **four windows** on every launch, and the duplicate `Trap` label plus persistent per-id state across versions caused recurring confusion.
|
||||
- Headless CraftOS-PC is also a cheap, deterministic *interactive* tool: it boots the emulator, runs an arbitrary Lua snippet against the real CC:Tweaked ROM, prints output, and exits in well under a second. Humans and agents can use it to verify hypotheses about CC:Tweaked behavior *before* writing code or tests. That usage was implicit; no document framed headless exec recipes as the recommended first move when an agent is unsure about CC:Tweaked behavior.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. CraftOS-PC as a first-class local dev dependency
|
||||
|
||||
- **Minimum version `v2.8.3`** — recent enough to have the current CC:Tweaked ROM, old enough that contributors already on a 2.8.x build will not be forced to upgrade again.
|
||||
- **Documented install** in [`docs/install-craftos-pc.md`](../install-craftos-pc.md), with a SHA-256-verified macOS flow and pointers to the official Windows/Linux artifacts.
|
||||
- **Documented upstream navigation** in [`docs/craftos_pc_glossary.md`](../craftos_pc_glossary.md), covering CLI flags, mounts, `periphemu`, save data, and troubleshooting pages.
|
||||
- **Verified by `just install`** via `check-install`, which checks `craftos`, `jq`, `luacheck`, and `openssl`. `check-craftos` runs `craftos --version` and requires v2.8.3 or newer. Failure prints a one-line pointer to the install guide.
|
||||
- **Repository-local TrapOS launch.** `just trapos` runs CraftOS-PC with `--directory .craftos`, keeps the macOS `--rom /Applications/CraftOS-PC.app/Contents/Resources` workaround, mounts the repository root read-only at `/trapos`, and mounts each top-level source directory read-only at its ComputerCraft root path.
|
||||
- **Vanilla launch.** `just craftos` launches CraftOS-PC under `.craftos-vanilla/` with no mounts and no startup, for probes that should not see TrapOS files and for the `just trapos-install` end-to-end install verification.
|
||||
- **`just ci` is the local verification entry point.** It runs `check-craftos`, `check`, and `test`. Local Git hooks are installed by `just install`; see [ADR-0011](adr-0011-repo-conventions.md) for the commit/push split.
|
||||
|
||||
The existing [`AGENTS.md`](../../AGENTS.md) constraint ("Do not run Lua locally or add a test harness unless asked") is reframed rather than removed: there is still no standalone Lua harness, and we are not adding a Busted-style test runner. The harness *is* CraftOS-PC, invoked deliberately.
|
||||
|
||||
### 2. Periphemu bootstrap stays minimal
|
||||
|
||||
`startup/servers.lua` attaches **only a top modem** under `periphemu`:
|
||||
|
||||
```lua
|
||||
if periphemu then
|
||||
periphemu.create('top', 'modem');
|
||||
end
|
||||
```
|
||||
|
||||
Extra emulated computers are spawned manually from the CraftOS-PC shell when actually needed (e.g. `periphemu create 10 computer` to bring up a router peer for cross-VM testing). The pattern is documented in [`docs/periphemu.md`](../periphemu.md). The `if periphemu then` guard is preserved so in-game behavior is unchanged.
|
||||
|
||||
### 3. Headless probes as the canonical hypothesis pattern
|
||||
|
||||
Two safe-exec recipes wrap raw `--headless --exec` with `xpcall`, call `os.shutdown()` on success or Lua error, and use `TRAP_CCLIBS_HEADLESS_TIMEOUT_SECONDS` (default `10`) as a host watchdog:
|
||||
|
||||
- `just trapos-exec '<lua>'` — probe against the **TrapOS dev environment**. Mounts of `/apis`, `/programs`, `/servers`, `/startup`, `/tests`, and the repo root at `/trapos` are live, so `require('/apis/eventloop')` and friends work against the current branch. Use this when the question involves repo code.
|
||||
|
||||
- `just craftos-exec '<lua>'` — probe against **vanilla CraftOS-PC**. No mounts, no startup scripts. Use this when the question is purely about CC:Tweaked behavior and TrapOS files would be a distraction, or to confirm a behavior is upstream rather than something the dev env layered on.
|
||||
|
||||
- `just trapos-install` — drive the full real install (`install-ccpm.lua` → `ccpm update` → `ccpm install trapos`) on a fresh ephemeral state. This is the probe to run when changing anything in the install path itself.
|
||||
|
||||
Conventions:
|
||||
|
||||
- Prefer the safe exec recipes over raw `--headless --exec`.
|
||||
- Keep snippets minimal and side-effect-free. If a probe reveals a fact worth defending, add a `libtest` case under `tests/` — probes are not a substitute for committed tests.
|
||||
- LLM agents SHOULD prefer a quick headless probe over speculation when answering "does X work in CC:Tweaked?" or "does my refactor still load?". The cost is one extra emulator boot (~1s); the benefit is grounded answers instead of plausible-sounding ones.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Contributors must install CraftOS-PC before `just install` succeeds. The install guide makes this a 4-step copy/paste on macOS.
|
||||
- Headless tests live under `tests/` and are driven by `just test`. See [ADR-0007](adr-0007-test-framework.md) for the runner and timeout layering.
|
||||
- The macOS install symlinks the binary into `/usr/local/bin`, which makes CraftOS-PC unable to auto-discover the ROM that ships inside the `.app` bundle (`Could not mount ROM`). The `test:` recipe works around this by passing `--rom /Applications/CraftOS-PC.app/Contents/Resources` on Darwin. Linux (AppImage) and Windows (installer) auto-discover correctly.
|
||||
- `just trapos` uses repository-local save data under `.craftos/config/` and `.craftos/computer/`. This keeps emulator state out of `~/Library/Application Support/CraftOS-PC` during repository work and keeps repo files visible through read-only mounts instead of copying them into the VM save.
|
||||
- `just repl` is a human-only interactive wrapper around `just trapos --cli`; automation and LLM agents must use `just trapos-exec '<lua>'` or `just craftos-exec '<lua>'` instead.
|
||||
- `craftos` (GUI) now opens a single unlabelled `Computer 0` window with a top modem attached. Cross-machine testing requires an explicit `periphemu create` call from the shell rather than being implicit on boot — one extra command when you need a peer, no surprise windows or persisted ghost VMs.
|
||||
- `.craftos-vanilla/` is in `.gitignore` alongside `.craftos/`.
|
||||
- `just trapos-install` is *not* part of `just ci`: it is network-dependent and slower than `just test`. Run it manually when touching `install-ccpm.lua` or ccpm package descriptors.
|
||||
- Higher CraftOS-PC invocation traffic during agent sessions; cheap enough that this is a good trade.
|
||||
- The harness version becomes a project-level concern. When CC:Tweaked ships breaking changes that require a newer CraftOS-PC build, we bump the minimum version in [`docs/install-craftos-pc.md`](../install-craftos-pc.md) and `check-craftos` keeps contributors honest.
|
||||
- No CI integration yet. Running CraftOS-PC headless in GitHub Actions is feasible (the AppImage works on Ubuntu runners) but is out of scope; the contract is local-only for now.
|
||||
|
||||
## Future Work
|
||||
|
||||
- **API-loading smoke test.** Extend `tests/` with a script that `require`s `/apis/eventloop`, `/apis/net`, `/apis/libtest`, and the router, asserting the wiring loads without errors.
|
||||
- **CI.** Run `just test` on push using the Linux AppImage.
|
||||
- **Pinned ROM.** Point CraftOS-PC at a vendored ROM via `--rom` if we ever need to test against a specific in-game version.
|
||||
- If `tests/` grows a multi-VM scenario, drive peer creation from the test script itself (each `tests/*.lua` already owns its setup) rather than re-adding peers to `startup/servers.lua`.
|
||||
@ -1,79 +0,0 @@
|
||||
# ADR 0007: Test Framework — libtest, runtest, and Layered Timeouts
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-06-08
|
||||
|
||||
## Context
|
||||
|
||||
[ADR-0005](adr-0005-craftos-pc-harness-and-probes.md) made CraftOS-PC the local harness. The first behavior test, `tests/eventloop.lua`, proved the harness can exercise ComputerCraft APIs headlessly, but it also duplicated test-runner concerns directly in the script: collecting named cases, per-case progress in verbose mode, fail-fast messaging, success-marker emission, and process shutdown. Those details are easy to copy incorrectly.
|
||||
|
||||
A blocked eventloop test then showed two more harness needs:
|
||||
|
||||
- The shell harness needs a timeout and captured output so agentic debugging can proceed without manual interruption.
|
||||
- A single shell watchdog is coarse. `kill -TERM` on the whole CraftOS-PC process cannot say *which* case hung, produces one generic message, and cannot tell a cooperatively-blocked event loop (the common failure — waiting on an event or `sleep` that never resolves) apart from a genuinely wedged process. A per-case timeout inside Lua is both finer and faster, but the shell watchdog is still needed for the cases Lua cannot interrupt.
|
||||
|
||||
At the same time, tests in this repository are still ComputerCraft programs. They should be useful in CraftOS-PC and in-game, not only inside a host-side shell loop.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. `libtest` is the per-case helper
|
||||
|
||||
[`apis/libtest.lua`](../../apis/libtest.lua) is the repository's lightweight ComputerCraft test helper. Tests under `tests/` require it with an absolute ComputerCraft path:
|
||||
|
||||
```lua
|
||||
local createLibTest = require('/apis/libtest');
|
||||
local testlib = createLibTest({ ... });
|
||||
|
||||
testlib.test('example', function()
|
||||
testlib.assertEquals(1 + 1, 2);
|
||||
end);
|
||||
|
||||
testlib.run();
|
||||
```
|
||||
|
||||
`libtest` intentionally stays small. It provides named cases, `assertEquals`, `assertTrue`, `assertErrors`, optional case-status report output consumed by the suite runner, and failure reporting. It must stay usable from normal ComputerCraft programs in CraftOS-PC or in-game.
|
||||
|
||||
### 2. `runtest` owns suite orchestration; the Justfile stays minimal
|
||||
|
||||
[`/programs/runtest.lua`](../../programs/runtest.lua) owns suite-level concerns: test discovery under `/tests`, invoking each script with `shell.run`, grouped `--pretty` output, `--verbose` runner diagnostics, the `__TRAPOS_TEST_OK__` success marker (printed only after the full suite passes), and optional shutdown when the host harness asks with `--shutdown`. `runtest` can run inside CraftOS-PC or in-game when `/tests` and dependencies are present.
|
||||
|
||||
The `Justfile` launches CraftOS-PC, mounts repository directories, enforces the process timeout, checks for the success marker, and prints runner output files. It does not know about individual test files or cases. Verbose mode is reserved for debugging and agent work loops; `--pretty` is the normal human-readable mode.
|
||||
|
||||
### 3. Layered test timeouts
|
||||
|
||||
Two independent timeout layers, ordered so the finer one fires first.
|
||||
|
||||
**Layer 1 — `libtest` per-case timeout (primary).** [`apis/libtest.lua`](../../apis/libtest.lua) races each test case against a timer with `parallel.waitForAny(runner, timer)`. The default is `DEFAULT_TIMEOUT_SECONDS = 3`. When the timer wins, the case fails with a distinct message containing the token `libtest timeout` and, in `--verbose`, an extra `TIMEOUT … (libtest)` diagnostic. `--timeout <seconds>` overrides the default; `--no-timeout` disables the layer. `runtest` forwards both flags to each case script. This only interrupts cases that yield (the usual hang); a non-yielding CPU loop cannot be preempted in ComputerCraft.
|
||||
|
||||
**Layer 2 — shell watchdog (backstop).** The `Justfile` `test:` recipe keeps its `TRAP_CCLIBS_TEST_TIMEOUT_SECONDS` watchdog as an independent double-check. Its default matches the libtest default (`.env.test` ships `3`; the recipe falls back to `3`) so libtest fires first for yielding cases in normal runs and the watchdog only catches what Lua cannot — a non-yielding loop, a wedged libtest, or a deliberately bypassed case. Its SIGTERM message is worded differently from the `libtest timeout` message, so the two layers are never confused.
|
||||
|
||||
### How to write tests properly
|
||||
|
||||
- Normal tests live in `tests/*.lua`, use `/apis/libtest.lua`, and must finish under the libtest timeout. `runtest` auto-discovers them; `just test` runs the suite.
|
||||
- Never commit a hanging or intentionally-slow test to `tests/`: it would fail every run.
|
||||
- Intentionally-slow fixtures that exercise the harness itself live in `tests/harness/`. `runtest` discovery skips subdirectories, so they never run with the normal suite; they are driven only by dedicated recipes (`just test-timeout-lua`, `just test-timeout-shell`, aggregated by `just test-timeout`).
|
||||
- Use `--no-timeout` only for harness fixtures that must outlive the libtest layer to prove the shell watchdog, never for ordinary tests.
|
||||
|
||||
## Consequences
|
||||
|
||||
- New deterministic behavior should get as many useful CraftOS-PC tests as practical. Tests that require human validation, such as complex turtle motion, in-game UX feel, or visual approval, may be skipped, but deterministic pieces should still get unit-style non-regression coverage.
|
||||
- Test scripts remain normal ComputerCraft programs, not standalone Lua tests. They can run through `just test`, `/programs/runtest.lua`, or direct in-game execution when copied with their dependencies.
|
||||
- `libtest` lives under `/apis` and ships in `trapos-test`, so it can be required consistently in the mounted CraftOS-PC environment.
|
||||
- The `__TRAPOS_TEST_OK__` marker remains the single shell-level success contract and is owned by `runtest`.
|
||||
- A hung case fails in ~3s with a per-case message instead of taking down the whole process anonymously.
|
||||
- `just test-timeout` is a self-asserting harness regression guard wired into `just ci`. It chains `test-timeout-lua` (Layer 1: libtest cancels the slow case immediately with `--timeout 0`, before the shell backstop) and `test-timeout-shell` (Layer 2: the `TRAP_CCLIBS_TEST_TIMEOUT_WATCHDOG_SECONDS` watchdog, default `1`, kills the slow case with libtest bypassed). Both drive a single `tests/harness/slow-case.lua` fixture; the tight timeouts — not the fixture's sleep length — decide which layer fires, so the harness itself is covered against regressions on every `ci`.
|
||||
- `libtest` stays a normal ComputerCraft program: `parallel` and `sleep` are sandbox globals, so the timeout works in CraftOS-PC and in-game alike.
|
||||
- Host-specific concerns remain outside production Lua code.
|
||||
|
||||
## Future Work
|
||||
|
||||
- Add more assertions only when tests need them; avoid growing a large framework.
|
||||
- Add test selection filters when the suite grows.
|
||||
- Add runner-level or per-case timing in `--verbose` output if slow-but-passing cases become hard to spot.
|
||||
- A `libtest`-level marker for "expected timeout" if more harness fixtures appear.
|
||||
- Explore GitHub Actions with the Linux CraftOS-PC AppImage after local coverage is broader.
|
||||
@ -1,113 +0,0 @@
|
||||
# ADR 0010: ccpm Package Manager
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-06-08
|
||||
|
||||
## Context
|
||||
|
||||
The previous install flow (a `LIST_FILES` table inside `install.lua` and a single flat
|
||||
`manifest.json` read by `wget`) was all-or-nothing. There was no way to install just
|
||||
networking or just the UI, and no way to add or remove pieces of the OS after the
|
||||
initial install. The project also outgrew the "Trap's ComputerCraft APIs" framing into
|
||||
a small in-game OS — TrapOS — with a name, a version, a boot banner, and a persisted
|
||||
beta channel; that motion is preserved here even though the original install
|
||||
mechanism is fully replaced.
|
||||
|
||||
We want a package manager, `ccpm` ("ComputerCraft Package Manager"), installed first
|
||||
as a standalone user-facing step. After that, a machine can `ccpm update`,
|
||||
`ccpm install trapos`, `ccpm install trapos-net`, `ccpm uninstall trapos-ui`, and manage
|
||||
where packages come from. TrapOS itself is installed through a `trapos` meta-package;
|
||||
the `wget run .../install-ccpm.lua` bootstrap exists only to install `ccpm`.
|
||||
|
||||
## Decision
|
||||
|
||||
### Packages are descriptors over the existing tree
|
||||
|
||||
Source files stay where they are (`apis/`, `programs/`, `servers/`, `startup/`); their
|
||||
install targets remain the same absolute CC paths, so `require` paths and the dev
|
||||
mounts are unchanged. A package is a descriptor that *references* those files:
|
||||
`packages/<name>/ccpm.json` with `{ name, version, description, dependencies, files,
|
||||
autostart }`. `packages/index.json` lists the packages a registry offers (for
|
||||
`ccpm search`). There is no `ccpm.json` at the repo root.
|
||||
|
||||
The split is finer-grained than the install examples imply:
|
||||
|
||||
| package | contents | deps |
|
||||
|----------|-----------------------------------------------------------------|----------|
|
||||
| trapos-core | ccpm, libccpm, eventloop, upgrade, events | — |
|
||||
| trapos-test | libtest, runtest | trapos-core |
|
||||
| trapos-boot | motd, servers (startup) | trapos-core |
|
||||
| trapos-net | net, router, ping, ping-server | trapos-core |
|
||||
| trapos-ui | libtui, tuidemo | trapos-core |
|
||||
| trapos-ai | AI client for opencode serve | trapos-core |
|
||||
| trapos | full TrapOS meta-package | trapos-boot, trapos-net, trapos-ui, trapos-test, trapos-ai |
|
||||
|
||||
### Two files for ccpm, "manifest" reserved for the OS
|
||||
|
||||
To avoid colliding with the OS `manifest.json`, ccpm never uses the word "manifest".
|
||||
Local state lives under `/trapos`:
|
||||
|
||||
- `ccpm.json` — ordered registry list `{ registries = { { name, type, branch } } }`.
|
||||
`type` is `gitea` (resolves to `git.trapcloud.fr/<name>/raw/branch/<branch>/`, the
|
||||
default seeded by the bootstrap), `github` (resolves to
|
||||
`raw.githubusercontent.com/<name>/<branch>/`, deprecated but still supported), or
|
||||
`http`/`https` (the `name` is a base URL).
|
||||
- `ccpm.lock.json` — installed packages `{ packages = { <name> = { version, registry,
|
||||
files, dependencies, autostart } } }`, used by `ls`, `uninstall`, and `reinstall`.
|
||||
- `ccpm.cache.json` — packages advertised by configured registries, written by
|
||||
`ccpm update` from each registry's `packages/index.json`, used by `ccpm search`,
|
||||
`ccpm available`, and `ccpm upgrade`.
|
||||
|
||||
`apis/libccpm.lua` is the testable core (a factory; `http`/`stateDir`/`installRoot`
|
||||
are injectable for tests). `programs/ccpm.lua` is a thin CLI over it.
|
||||
|
||||
### The bootstrap installs only ccpm
|
||||
|
||||
`install-ccpm.lua` resolves only the `trapos-core` package descriptor (pulling any future
|
||||
dependencies), downloads its files, and writes:
|
||||
|
||||
- `/trapos/manifest.json` — the aggregated `{ name, version, branch, files, autostart }`
|
||||
still consumed by `startup/motd.lua` and `startup/servers.lua` after boot packages
|
||||
are installed. This is the surviving piece of the previous manifest-driven install:
|
||||
it is no longer the install source of truth (each package's `ccpm.json` is), but it
|
||||
is still the local system state used at boot for the colored `TrapOS v<version>`
|
||||
banner and to read the `autostart` list. `branch` is the persisted beta opt-in
|
||||
(a single confirmed `--beta` install switches subsequent `ccpm upgrade` runs to
|
||||
`next` with no flag needed; `--stable` is the symmetric opt-out);
|
||||
- `/trapos/ccpm.lock.json` — so right after a fresh install `ccpm install trapos-core`
|
||||
correctly reports "already installed";
|
||||
- `/trapos/ccpm.json` — seeding/refreshing the default `guillaumearm/cc-libs` registry
|
||||
to track the install branch.
|
||||
|
||||
The install path is:
|
||||
|
||||
- `wget run .../install-ccpm.lua` — install `ccpm` (`trapos-core`) and seed the default
|
||||
registry.
|
||||
- `ccpm update` — refresh the local package cache.
|
||||
- `ccpm install trapos` — install the full OS. During beta, `trapos` includes
|
||||
`trapos-test` by default.
|
||||
|
||||
On a subsequent `upgrade`, `programs/upgrade.lua` delegates to `ccpm upgrade`, which
|
||||
upgrades installed packages using `/trapos/ccpm.cache.json`. Users run `ccpm update`
|
||||
first to refresh available versions.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The repo gains a `packages/` descriptor tree; the flat source layout is untouched.
|
||||
- `just trapos` (formerly `just craftos`; see [ADR-0005](adr-0005-craftos-pc-harness-and-probes.md)) no longer derives mounts
|
||||
from `manifest.json .files` (it is now `.packages`); it mounts a fixed list of
|
||||
top-level dirs instead. `just test` was already on fixed mounts and is unaffected.
|
||||
- ccpm logic is covered by `tests/ccpm.lua` (URL resolution, dependency ordering,
|
||||
cycle/missing detection, already-installed, registry CRUD, cache update, available
|
||||
status, upgrade, uninstall dependency guard) with an injected `http` stub — no
|
||||
network in tests.
|
||||
|
||||
## Future Work
|
||||
|
||||
- Version ranges (today a single pinned version per package).
|
||||
- http/https registries beyond a plain base URL (auth, caching).
|
||||
@ -1,60 +0,0 @@
|
||||
# ADR 0011: Repository Conventions — Git Hooks and Markdown Link Syntax
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-06-08
|
||||
|
||||
## Context
|
||||
|
||||
Two small operational conventions emerged together as the repo grew and want to be remembered:
|
||||
|
||||
- **Where verification runs.** The repository has a CraftOS-PC harness (`just test`) and a fuller local CI path (`just ci`) that adds tool checks, `luacheck`, and harness regression guards. Agents and humans can be asked to commit and push changes, so verification can happen in two places: manually before Git operations and automatically inside Git hooks. Running the same tests manually and again in hooks makes workflows slower without improving the success contract, and risks divergent habits between human and agent paths.
|
||||
- **How docs reference each other.** The `docs/` tree increasingly cross-references itself, and ADRs reference one another by number. `lychee` is wired into `just check` (`lint-markdown` recipe) so broken local links fail the build. Lychee only validates *links it can see*: a bare prose mention of `docs/foo.md` or `ADR-0005`, or a backticked path like `` `docs/foo.md` ``, is invisible to it. When a doc is renamed or moved, those mentions silently rot until a human happens to read the surrounding paragraph.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Git hooks own commit/push verification
|
||||
|
||||
Install two local Git hooks through `just install` / `just install-git-hooks`:
|
||||
|
||||
- `.git/hooks/pre-commit` runs `just check test`.
|
||||
- `.git/hooks/pre-push` runs `just ci`.
|
||||
|
||||
When an agent is explicitly asked to commit and/or push, it should not run `just test` manually before the Git operation. The hook is the source of truth for that workflow: commit triggers `just check test`, and push triggers `just ci`.
|
||||
|
||||
Manual verification is still appropriate outside commit/push workflows. For example, run `just test` while developing a behavior change, and run `just ci` when checking the full local state without pushing.
|
||||
|
||||
### 2. Markdown link syntax for cross-references
|
||||
|
||||
In every markdown file in the repository, references to other `.md` files must use markdown link syntax `[text](relative/path.md)`. This includes:
|
||||
|
||||
- Direct file references (`` `docs/install-craftos-pc.md` `` → `` [`docs/install-craftos-pc.md`](docs/install-craftos-pc.md) ``).
|
||||
- ADR-number references (`ADR-0005` → `[ADR-0005](docs/adrs/adr-0005-craftos-pc-harness-and-probes.md)`, with the path adjusted to be relative to the referencing file).
|
||||
- Plain-prose mentions (`called out in CLAUDE.md` → `called out in [CLAUDE.md](../../CLAUDE.md)`).
|
||||
|
||||
Excluded by design:
|
||||
|
||||
- Mentions inside fenced code blocks. They are example/code content; `lychee.toml` skips them via `include_verbatim = false`.
|
||||
- Mentions inside inline code spans used purely to *illustrate* the wrong form. The reader can see they are placeholders, and lychee does not extract them as links either.
|
||||
- A file's own title (e.g. `# CLAUDE.md` as a heading is not a reference).
|
||||
|
||||
Link paths are written relative to the file containing the link, so `lychee --offline` resolves them on the local filesystem with no `--base` indirection.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Commit and push verification is consistent for humans and agents.
|
||||
- Commit workflows avoid duplicating the same CraftOS-PC test run before and during `git commit`.
|
||||
- Push workflows still get the full local CI gate before remote updates.
|
||||
- The hooks are local files under `.git/hooks`, so developers should run `just install` after cloning or after hook behavior changes.
|
||||
- `just lint-markdown` (and therefore `just check`, pre-commit, and pre-push) catches dead cross-references the moment a doc is moved or renamed.
|
||||
- The markdown-link convention is *social* — humans and agents must remember to apply it when writing prose. Lychee enforces correctness only once a link exists; it cannot flag a mention that should have been a link but wasn't.
|
||||
- The `[`path`](path)` style (backticked path as link text) is preferred for direct file references, matching the existing house style in [`docs/README.md`](../README.md) and [`docs/adrs/README.md`](README.md). For ADR mentions in flowing prose, `[ADR-####](path)` reads better than the backticked form.
|
||||
|
||||
## Future Work
|
||||
|
||||
- Revisit the verification split if `just test` becomes too slow for pre-commit or `just ci` gains checks that should also block commits.
|
||||
- If rot reappears in prose despite the link convention, add a small grep-based lint that flags unbracketed `.md` and `ADR-####` mentions and wire it into `just check`. Deferred until there is evidence the social convention is insufficient.
|
||||
@ -1,46 +0,0 @@
|
||||
# ADR 0016: JavaScript Tool Verification
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-06-10
|
||||
|
||||
## Context
|
||||
|
||||
The repository now contains `tools/mcp-bridge`, a TypeScript Node.js tool that sits next to the ComputerCraft Lua code. It has a different build and test lifecycle than TrapOS packages, but it still participates in the same local developer workflow and Git hooks described by [ADR-0011](adr-0011-repo-conventions.md).
|
||||
|
||||
The bridge also needs future end-to-end coverage that spans both runtimes: a host Node process and a headless CraftOS-PC computer. That is broader and slower than normal unit tests, and it needs the same kind of timeout discipline as the Lua harness in [ADR-0007](adr-0007-test-framework.md).
|
||||
|
||||
## Decision
|
||||
|
||||
Keep the Node package scripts simple and one-purpose:
|
||||
|
||||
- `npm run build` emits TypeScript into `dist/` and acts as the type-check gate.
|
||||
- `npm run test` runs the Node unit tests directly from TypeScript with `tsx --test test/*.test.ts`. No prior build is required.
|
||||
- `npm run check` runs ESLint. TypeScript compilation is covered by `npm run build` and `npm run test:ci` to avoid duplicate compiler runs in repository CI.
|
||||
- `npm run test:ci` runs `npm run check && npm run build && npm run test`, so type errors and lint failures both surface even though `test` itself no longer compiles.
|
||||
- `npm run test-integration` runs the bridge-to-CraftOS integration suite with `tsx --test --test-concurrency=1 test-integration/*.test.ts`. Each case boots the bridge in-process on fixed loopback ports (`127.0.0.1:2000` for MCP HTTP, `127.0.0.1:2001` for the CraftOS link), spawns a CraftOS-PC headless computer that connects back, exercises `tools/call probe-computers`, and tears everything down. `--test-concurrency=1` keeps the fixed ports collision-free.
|
||||
- These fixed integration-test ports are loopback-only and unrelated to the public production ports documented in [`../public-ports.md`](../public-ports.md).
|
||||
|
||||
Expose matching repository recipes for the Node lifecycle:
|
||||
|
||||
- `just build` delegates to `npm run build` for the bridge.
|
||||
- `just test` depends on `just build`, runs `npm run test`, then runs the existing CraftOS-PC test suite.
|
||||
- `just check` includes `npm run check` alongside Lua and Markdown checks.
|
||||
- `just ci` uses `npm run test:ci`, then runs CraftOS-PC tests and the broader integration/harness target.
|
||||
- `just test-integration` runs the placeholder Node integration target and the Lua timeout harness checks.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `npm run test` no longer needs `dist/`. Both unit and integration tests load TypeScript directly through `tsx`, so test iteration is faster and `dist/` is only required for `npm start`.
|
||||
- TypeScript errors are no longer caught by `npm run test`; they are caught by `npm run build`, which stays wired into `npm run test:ci`, `just build`, `just test`, and `just ci`.
|
||||
- `just ci` avoids duplicating Node unit tests by calling `npm run test:ci` directly and then invoking only the CraftOS-side test body.
|
||||
- ESLint failures are part of `just check`, so they are covered by the same pre-commit and pre-push hooks as Lua and Markdown checks.
|
||||
- Integration tests live in `tools/mcp-bridge/test-integration/`, with Lua client fixtures under `test-integration/lua/`. Slow end-to-end behavior stays out of the fast unit-test path.
|
||||
|
||||
## Future Work
|
||||
|
||||
- Extend the integration harness with disconnect and reconnect scenarios (computer drops mid-probe; bridge restarts while a computer is connected) once those failure modes need regression coverage.
|
||||
@ -1,54 +0,0 @@
|
||||
# ADR 0017: MCP Remote Lua Execution
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-06-11
|
||||
|
||||
## Context
|
||||
|
||||
`tools/mcp-bridge` links OpenCode MCP tools to ComputerCraft computers through the `mcp-computer` WebSocket program. The initial bridge only supported `probe-computers`, which proved that the host could reach linked computers but did not let the assistant inspect or modify in-game state directly.
|
||||
|
||||
For interactive TrapOS development, a remote Lua execution tool is useful: it can inspect files, peripherals, settings, labels, inventory-adjacent APIs, service state, and small runtime hypotheses without asking the player to type each command manually in-game.
|
||||
|
||||
This is also explicitly dangerous. A linked assistant can run arbitrary Lua with the same authority as the ComputerCraft computer. That includes file deletion, network traffic, peripheral operations, turtle movement, inventory changes, reboot/shutdown calls, and long-running or stuck programs.
|
||||
|
||||
## Decision
|
||||
|
||||
Add an MCP tool named `exec-lua` to `tools/mcp-bridge`.
|
||||
|
||||
The tool targets one linked computer by `computerId` and sends Lua source over the existing bridge request/response protocol:
|
||||
|
||||
```json
|
||||
{ "type": "request", "id": "...", "method": "exec-lua", "params": { "code": "..." } }
|
||||
```
|
||||
|
||||
The ComputerCraft side executes the code in `mcp-computer` and returns a normal bridge response with:
|
||||
|
||||
- `ok` for execution success or failure.
|
||||
- `result.returns` as JSON-safe descriptors of returned values.
|
||||
- `result.output` containing captured `print` and `write` output.
|
||||
- `error` for syntax or runtime failure.
|
||||
|
||||
Execution is enabled by default for any computer running the updated `mcp-computer` program. We intentionally do not add an `--allow-exec` flag for this first version because the current workflow is a local, explicitly trusted development bridge and the user accepts the risk.
|
||||
|
||||
The execution environment overrides `print` and `write` so their text is captured in the MCP response instead of being emitted to the visible terminal. Code that intentionally wants to affect the ComputerCraft screen should call terminal APIs such as `term.clear`, `term.setCursorPos`, and `term.write` directly.
|
||||
|
||||
Timeouts are host-side request timeouts. A timed-out MCP call stops waiting for the response, but it does not preempt a running Lua chunk inside ComputerCraft. Avoid infinite loops and long blocking calls unless the in-game computer can be restarted.
|
||||
|
||||
## Consequences
|
||||
|
||||
- OpenCode can now inspect and operate linked ComputerCraft computers without manual in-game command entry.
|
||||
- The trust boundary moves to the act of running `mcp-computer` against a bridge. Only run it against bridges and assistants you trust.
|
||||
- The bridge remains protocol-compatible with older clients for `probe-computers`; older `mcp-computer` clients will report `unknown method` for `exec-lua`.
|
||||
- Captured output is deterministic for assistant workflows; visible screen mutation remains explicit through `term.*` APIs.
|
||||
- Tests must cover both host-side MCP routing and the real CraftOS-PC `mcp-computer` path so regressions are caught across the Node/Lua boundary described in [ADR-0016](adr-0016-js-tool-verification.md).
|
||||
|
||||
## Future Work
|
||||
|
||||
- Add a separate opt-in flag or per-computer policy if the bridge is used outside local trusted development.
|
||||
- Add cooperative cancellation for yielding chunks if long-running remote execution becomes common.
|
||||
- Add more convenience tools on top of the trusted bridge once usage patterns are clear.
|
||||
@ -1,46 +0,0 @@
|
||||
# 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.
|
||||
@ -1,59 +0,0 @@
|
||||
# CraftOS-PC Documentation Glossary
|
||||
|
||||
Compact index of CraftOS-PC documentation pages from <https://www.craftos-pc.cc/docs/>. CraftOS-PC is the emulator used by this repository; `craftos` is the command-line executable it installs.
|
||||
|
||||
Last checked: 2026-06-08.
|
||||
|
||||
Repo notes:
|
||||
|
||||
- Use [Install CraftOS-PC](install-craftos-pc.md) for the pinned local setup used by this repository.
|
||||
- Use [Command-Line Flags](https://www.craftos-pc.cc/docs/cli) for `--headless`, `--script`, `--exec`, `--rom`, `--directory`, `--id`, and `--mount-*`.
|
||||
- Use [File System Mounting](https://www.craftos-pc.cc/docs/mounter) for exposing repository files inside CraftOS-PC.
|
||||
- Use [Peripheral Emulation](https://www.craftos-pc.cc/docs/periphemu) for `periphemu` and local modem/peripheral tests. See [`docs/periphemu.md`](periphemu.md) for the in-repo reference and how this repo uses it.
|
||||
- Use [Error Messages](https://www.craftos-pc.cc/docs/error-messages) when `craftos --help` works but booting a computer fails, including `Could not mount ROM`.
|
||||
|
||||
## Introduction
|
||||
|
||||
- [What is CraftOS-PC?](https://www.craftos-pc.cc/docs/about)
|
||||
- [Downloading & Installing](https://www.craftos-pc.cc/docs/installation)
|
||||
- [Getting Started](https://www.craftos-pc.cc/docs/getting-started)
|
||||
- [Save Data Location](https://www.craftos-pc.cc/docs/saves)
|
||||
- [Standards Mode](https://www.craftos-pc.cc/docs/standards)
|
||||
- [Migrating from CCEmuX](https://www.craftos-pc.cc/docs/ccemux)
|
||||
- [CraftOS-PC Mobile](https://www.craftos-pc.cc/docs/mobile)
|
||||
|
||||
## New Features
|
||||
|
||||
- [Peripheral Emulation](https://www.craftos-pc.cc/docs/periphemu)
|
||||
- [File System Mounting](https://www.craftos-pc.cc/docs/mounter)
|
||||
- [Screenshots & Recording](https://www.craftos-pc.cc/docs/screenshot)
|
||||
- [Multiple Computers](https://www.craftos-pc.cc/docs/multicomp)
|
||||
- [ComputerCraft Configuration](https://www.craftos-pc.cc/docs/config)
|
||||
- [Graphics Mode](https://www.craftos-pc.cc/docs/gfxmode)
|
||||
- [HTTP & WebSocket Server](https://www.craftos-pc.cc/docs/http-server)
|
||||
- [CC: Tweaked Features](https://www.craftos-pc.cc/docs/cctweaked)
|
||||
- [Auto-Updater](https://www.craftos-pc.cc/docs/autoupdate)
|
||||
- [Debugger](https://www.craftos-pc.cc/docs/debugger)
|
||||
- [Command-Line Flags](https://www.craftos-pc.cc/docs/cli)
|
||||
- [Rendering Options](https://www.craftos-pc.cc/docs/renderers)
|
||||
- [Raw Mode](https://www.craftos-pc.cc/docs/rawmode)
|
||||
- [VS Code Extension](https://www.craftos-pc.cc/docs/extension)
|
||||
- [Plugin System](https://www.craftos-pc.cc/docs/plugins)
|
||||
- [CraftOS-PC Accelerated](https://www.craftos-pc.cc/docs/accelerated)
|
||||
|
||||
## Other Information
|
||||
|
||||
- [CraftOS-PC Online](https://www.craftos-pc.cc/docs/online)
|
||||
- [CraftOS-PC Remote](https://www.craftos-pc.cc/docs/remote)
|
||||
- [Latest Development Build](https://www.craftos-pc.cc/artifact)
|
||||
- [Error Messages](https://www.craftos-pc.cc/docs/error-messages)
|
||||
- [Report a Bug](https://www.craftos-pc.cc/bugreport)
|
||||
- [Privacy Policy](https://www.craftos-pc.cc/docs/privacy)
|
||||
- [Contact](https://www.craftos-pc.cc/docs/contact)
|
||||
- [Changelog](https://www.craftos-pc.cc/docs/changelog)
|
||||
|
||||
## API Reference
|
||||
|
||||
- [CC: Tweaked Documentation](https://tweaked.cc/)
|
||||
- [Lua 5.2 Reference Manual](https://lua.org/manual/5.2/manual.html)
|
||||
- [CraftOS APIs (Old Wiki)](http://www.computercraft.info/wiki/Category:APIs)
|
||||
@ -1,135 +0,0 @@
|
||||
# In-Game TrapOS, AI, MCP Guide
|
||||
|
||||
Follow this order while playing.
|
||||
|
||||
## 1. Install TrapOS
|
||||
|
||||
On the ComputerCraft computer:
|
||||
|
||||
```sh
|
||||
wget run https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/next/install-ccpm.lua --beta
|
||||
ccpm update
|
||||
ccpm install trapos
|
||||
```
|
||||
|
||||
If the computer asks to reboot, reboot it.
|
||||
|
||||
## 2. Start OpenCode On Host
|
||||
|
||||
On your real machine:
|
||||
|
||||
```sh
|
||||
opencode serve --hostname 0.0.0.0 --port 4242
|
||||
```
|
||||
|
||||
If exposing beyond your machine, use a password:
|
||||
|
||||
```sh
|
||||
OPENCODE_SERVER_PASSWORD=secret opencode serve --hostname 0.0.0.0 --port 4242
|
||||
```
|
||||
|
||||
## 3. Connect `ai.lua`
|
||||
|
||||
On the ComputerCraft computer, use your public host:
|
||||
|
||||
```sh
|
||||
set opencc.server_url http://<public-host>:4242
|
||||
```
|
||||
|
||||
If you set a password:
|
||||
|
||||
```sh
|
||||
set opencc.password secret
|
||||
```
|
||||
|
||||
Optional model settings:
|
||||
|
||||
```sh
|
||||
set opencc.provider_id anthropic
|
||||
set opencc.model_id claude-opus-4-7
|
||||
```
|
||||
|
||||
Optional agent setting for the in-game ComputerCraft assistant:
|
||||
|
||||
```sh
|
||||
set opencc.agent atm10-expert
|
||||
```
|
||||
|
||||
Test it:
|
||||
|
||||
```sh
|
||||
ai ping
|
||||
ai "say hello from TrapOS"
|
||||
```
|
||||
|
||||
Expected ping: `pong`.
|
||||
|
||||
## 4. Start MCP Bridge On Host
|
||||
|
||||
From this repository on your real machine:
|
||||
|
||||
```sh
|
||||
cd tools/mcp-bridge
|
||||
npm install
|
||||
CC_LINK_PORT=4243 npm run dev
|
||||
```
|
||||
|
||||
Production ports:
|
||||
|
||||
```text
|
||||
MCP endpoint: http://127.0.0.1:3000
|
||||
ComputerCraft link: ws://<public-host>:4243
|
||||
```
|
||||
|
||||
## 5. Link The Computer To MCP
|
||||
|
||||
On the ComputerCraft computer:
|
||||
|
||||
```sh
|
||||
mcp-computer ws://<public-host>:4243
|
||||
```
|
||||
|
||||
Leave it running. You should see:
|
||||
|
||||
```text
|
||||
linked as <id> (Label: <label>)
|
||||
waiting for requests... Press Ctrl+T to stop.
|
||||
```
|
||||
|
||||
## 6. Connect OpenCode To MCP
|
||||
|
||||
Add the bridge as an MCP HTTP server in your OpenCode MCP config, pointing at:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
Then ask OpenCode to use the MCP tool `probe-computers`. A working link returns a `pong from <id>` line.
|
||||
|
||||
The bridge also exposes `exec-lua`, which runs Lua on one linked computer by id. For example, this returns captured output to OpenCode:
|
||||
|
||||
```lua
|
||||
print('captured in MCP output')
|
||||
```
|
||||
|
||||
To write to the visible ComputerCraft screen, use terminal APIs directly:
|
||||
|
||||
```lua
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
term.write('visible on screen')
|
||||
```
|
||||
|
||||
`exec-lua` is powerful and unsafe by design: it can do anything the linked computer can do, including file, peripheral, turtle, and reboot operations. Only run `mcp-computer` against a bridge you trust.
|
||||
|
||||
The bridge also exposes `write-file`, which writes content to a path on one linked computer by id and overwrites any existing file. It follows normal ComputerCraft filesystem behavior, so missing parent directories fail instead of being created automatically.
|
||||
|
||||
## Quick Fixes
|
||||
|
||||
- `ai` says missing `opencc.server_url`: run the `set opencc.server_url ...` command again.
|
||||
- `ai` cannot reach server: check `opencode serve`, public host, port `4242`, and ComputerCraft HTTP rules.
|
||||
- `mcp-computer` says WebSocket unavailable: enable ComputerCraft HTTP/WebSocket support.
|
||||
- MCP sees no computers: keep `mcp-computer ws://<public-host>:4243` running in-game.
|
||||
- `exec-lua` or `write-file` is missing after updating the bridge: restart OpenCode so it reloads the MCP tool list.
|
||||
|
||||
More detail: [`opencode_server_guide.md`](opencode_server_guide.md), [`public-ports.md`](public-ports.md).
|
||||
@ -1,116 +0,0 @@
|
||||
# Install CraftOS-PC
|
||||
|
||||
CraftOS-PC is the local harness used to run this repo's Lua outside of Minecraft. See [ADR-0005](adrs/adr-0005-craftos-pc-harness-and-probes.md) for why.
|
||||
|
||||
CraftOS-PC is the emulator; `craftos` is the command-line executable it installs. For the broader upstream documentation index, see [`craftos_pc_glossary.md`](craftos_pc_glossary.md).
|
||||
|
||||
Minimum version: **v2.8.3**. `just install` runs `craftos --version` to verify it is on `$PATH` and recent enough; it also checks that `jq`, `luacheck`, and `openssl` are installed.
|
||||
|
||||
The upstream installation page is <https://www.craftos-pc.cc/docs/installation>. The notes below pin the version we test against and add a SHA-256 verification step.
|
||||
|
||||
## macOS
|
||||
|
||||
There is no Homebrew cask, so the install is a manual drag-to-Applications from the official GitHub release.
|
||||
|
||||
```sh
|
||||
# 1. Download the dmg and the published hashes.
|
||||
curl -L -o ~/Downloads/CraftOS-PC.dmg \
|
||||
https://github.com/MCJack123/craftos2/releases/download/v2.8.3/CraftOS-PC.dmg
|
||||
|
||||
# 2. Verify the SHA-256.
|
||||
curl -sL https://github.com/MCJack123/craftos2/releases/download/v2.8.3/sha256-hashes.txt \
|
||||
| grep CraftOS-PC.dmg
|
||||
shasum -a 256 ~/Downloads/CraftOS-PC.dmg
|
||||
# The two hashes must match.
|
||||
|
||||
# 3. Mount, install, unmount.
|
||||
hdiutil attach ~/Downloads/CraftOS-PC.dmg -nobrowse
|
||||
rm -rf /Applications/CraftOS-PC.app
|
||||
cp -R "/Volumes/CraftOS-PC/CraftOS-PC.app" /Applications/
|
||||
hdiutil detach "/Volumes/CraftOS-PC"
|
||||
|
||||
# 4. Clear Gatekeeper quarantine so the first launch is not blocked.
|
||||
xattr -dr com.apple.quarantine /Applications/CraftOS-PC.app
|
||||
```
|
||||
|
||||
The binary is not on `$PATH` by default. Add a symlink so `just check-craftos` can find it:
|
||||
|
||||
```sh
|
||||
ln -sf /Applications/CraftOS-PC.app/Contents/MacOS/craftos /usr/local/bin/craftos
|
||||
```
|
||||
|
||||
User data (computer state, settings) lives in `~/Library/Application Support/CraftOS-PC` and survives a reinstall. The steps above only touch the `.app` bundle.
|
||||
|
||||
When running headless tests on macOS, prefer passing the app bundle's resource directory explicitly if `craftos` is reached through the `/usr/local/bin/craftos` symlink:
|
||||
|
||||
```sh
|
||||
craftos --headless --rom /Applications/CraftOS-PC.app/Contents/Resources --exec 'print("__TRAPOS_TEST_OK__"); os.shutdown()'
|
||||
```
|
||||
|
||||
## Windows
|
||||
|
||||
Download `CraftOS-PC-Setup.exe` from the [latest release](https://github.com/MCJack123/craftos2/releases/latest) and run it. The installer puts `craftos.exe` on `%PATH%`.
|
||||
|
||||
User data: `%appdata%\CraftOS-PC`.
|
||||
|
||||
## Linux
|
||||
|
||||
Download `CraftOS-PC.x86_64.AppImage` from the [latest release](https://github.com/MCJack123/craftos2/releases/latest), make it executable, and symlink it into `$PATH`:
|
||||
|
||||
```sh
|
||||
chmod +x ~/Downloads/CraftOS-PC.x86_64.AppImage
|
||||
sudo ln -sf "$HOME/Downloads/CraftOS-PC.x86_64.AppImage" /usr/local/bin/craftos
|
||||
```
|
||||
|
||||
User data: `~/.local/share/craftos-pc`.
|
||||
|
||||
## Verify
|
||||
|
||||
```sh
|
||||
craftos --version
|
||||
```
|
||||
|
||||
Must report `CraftOS-PC v2.8.3` or newer. Once this works, `just install` will succeed.
|
||||
|
||||
To verify that CraftOS-PC can boot a computer, not only print its executable version, run:
|
||||
|
||||
```sh
|
||||
craftos --headless --exec 'print("__TRAPOS_TEST_OK__"); os.shutdown()'
|
||||
```
|
||||
|
||||
On macOS, use the `--rom` form shown above if the command fails with `Could not mount ROM`.
|
||||
|
||||
## Running tests
|
||||
|
||||
`just test` runs `/programs/runtest.lua` headlessly through CraftOS-PC. The runner discovers tests under `/tests`, while the `Justfile` only owns host launch flags, timeout, and success-marker checks. On macOS the recipe passes `--rom /Applications/CraftOS-PC.app/Contents/Resources` because the `/usr/local/bin/craftos` symlink loses ROM auto-discovery; on Linux and Windows no flag is needed. `just ci` runs the same tests after `luacheck`.
|
||||
|
||||
## Repository-local launches
|
||||
|
||||
`just trapos` launches CraftOS-PC with the TrapOS dev environment: persistent save data rooted at `.craftos` instead of the platform default user-data directory, and read-only mounts of the repository at `/trapos` plus each shipped top-level directory at its ComputerCraft root path (such as `/apis`, `/programs`, `/servers`, `/startup`, and `/tests`). Generated files live under `.craftos/config/` and `.craftos/computer/`, while the root `.gitignore` keeps that state untracked.
|
||||
|
||||
`just craftos` launches a vanilla CraftOS-PC with no mounts, persistent under `.craftos-vanilla/` (also gitignored). Use it when a probe should not see TrapOS files — for example, to confirm a behavior is upstream rather than TrapOS-specific.
|
||||
|
||||
`just trapos-install` exercises the real ccpm bootstrap (`install-ccpm.lua` → `ccpm update` → `ccpm install trapos`) end-to-end on a fresh, ephemeral CraftOS-PC state. Network-dependent and slower than `just test`, so not part of `just ci`. Override the watchdog with `TRAP_CCLIBS_INSTALL_TIMEOUT_SECONDS` (default `60`).
|
||||
|
||||
For automated probes, use the safe wrappers. They mount the right environment,
|
||||
shut down the machine after completion or Lua errors, and kill the host process
|
||||
if the snippet blocks before shutdown:
|
||||
|
||||
```sh
|
||||
just trapos-exec 'print("__TRAPOS_TEST_OK__")'
|
||||
just craftos-exec 'print(_HOST)'
|
||||
```
|
||||
|
||||
Override the watchdog with `TRAP_CCLIBS_HEADLESS_TIMEOUT_SECONDS` (default `10`).
|
||||
Pass CraftOS-PC flags directly after `just trapos` or `just craftos` only for
|
||||
manual launches where you want raw emulator control.
|
||||
|
||||
See [ADR-0005](adrs/adr-0005-craftos-pc-harness-and-probes.md) for the canonical headless probe pattern used to verify hypotheses about CC:Tweaked behavior.
|
||||
|
||||
`just repl` delegates to `just trapos --cli` for human interactive use only. LLM agents must not run `just repl`.
|
||||
|
||||
See [Command-Line Flags](https://www.craftos-pc.cc/docs/cli) for `--headless`, `--exec`, `--script`, `--rom`, and `--mount-*`. See [Error Messages](https://www.craftos-pc.cc/docs/error-messages) for `Could not mount ROM` and other boot-time failures.
|
||||
|
||||
## Updating
|
||||
|
||||
Repeat the steps above against the newer release. The bundle replacement is in-place; the user data directory is preserved.
|
||||
@ -1,218 +0,0 @@
|
||||
# opencode serve — HTTP API reference
|
||||
|
||||
Minimal reference for the endpoints used by `libai.lua`. Full spec served at `GET /doc` when the server is running.
|
||||
|
||||
## Running the server
|
||||
|
||||
Local/dev uses opencode's default port `4096`:
|
||||
|
||||
```bash
|
||||
opencode serve \
|
||||
--hostname 127.0.0.1 \
|
||||
--port 4096
|
||||
```
|
||||
|
||||
Public production uses port `4242`; see [`public-ports.md`](public-ports.md):
|
||||
|
||||
```bash
|
||||
opencode serve \
|
||||
--hostname 0.0.0.0 \
|
||||
--port 4242
|
||||
```
|
||||
|
||||
With Basic Auth (recommended):
|
||||
|
||||
```bash
|
||||
OPENCODE_SERVER_PASSWORD=secret opencode serve \
|
||||
--hostname 127.0.0.1 \
|
||||
--port 4096
|
||||
```
|
||||
|
||||
Default username is `opencode`. Override with `OPENCODE_SERVER_USERNAME`.
|
||||
|
||||
## Authentication
|
||||
|
||||
All requests must include `Authorization: Basic <base64(username:password)>` when the server was started with a password. No auth header is needed if no password is set.
|
||||
|
||||
```
|
||||
Authorization: Basic base64("opencode:secret")
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /global/health`
|
||||
|
||||
Health check. Returns `200` when the server is up.
|
||||
|
||||
---
|
||||
|
||||
### `GET /session`
|
||||
|
||||
List all sessions for the current project.
|
||||
|
||||
Optional query parameters used by `ai`:
|
||||
- `directory` — scope results to an opencode project directory.
|
||||
|
||||
**Response** `200`:
|
||||
```json
|
||||
[
|
||||
{ "id": "ses_abc123", "title": "my session", "time": { "created": 1234567890, "updated": 1234567890 } }
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /session`
|
||||
|
||||
Create a new session.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{ "title": "optional title", "parentID": "optional" }
|
||||
```
|
||||
|
||||
**Response** `200`:
|
||||
```json
|
||||
{ "id": "ses_abc123", "title": "my session" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /session/:id/message`
|
||||
|
||||
Send a message and wait for the AI reply (blocking). Returns when the assistant has finished responding.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"parts": [
|
||||
{ "type": "text", "text": "your prompt here" }
|
||||
],
|
||||
"agent": "atm10-expert",
|
||||
"model": { "providerID": "anthropic", "modelID": "claude-opus-4-7" }
|
||||
}
|
||||
```
|
||||
|
||||
`agent` and `model` are optional — omit them to use the server's configured defaults.
|
||||
|
||||
**Response** `200`:
|
||||
```json
|
||||
{
|
||||
"info": { "id": "msg_xyz", "sessionID": "ses_abc123", "role": "assistant" },
|
||||
"parts": [
|
||||
{ "type": "text", "text": "the reply" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Parts can include non-text types (`tool-call`, `step-start`, etc.) — collect all `type == "text"` entries to reconstruct the reply.
|
||||
|
||||
**Errors:**
|
||||
- `401` — wrong credentials
|
||||
- `404` — session not found (may have been deleted or server restarted)
|
||||
- `504` — AI took too long
|
||||
|
||||
---
|
||||
|
||||
### `GET /session/:id/message/:messageID`
|
||||
|
||||
Get a message by ID. Opencode validates caller-provided message IDs; use IDs starting with `msg`.
|
||||
|
||||
**Response** `200`:
|
||||
```json
|
||||
{
|
||||
"info": { "id": "msg_xyz", "sessionID": "ses_abc123", "role": "assistant", "time": { "completed": 1234567890 } },
|
||||
"parts": [
|
||||
{ "type": "text", "text": "the reply" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /session/:id`
|
||||
|
||||
Delete a session.
|
||||
|
||||
---
|
||||
|
||||
### `POST /session/:id/abort`
|
||||
|
||||
Abort a running generation.
|
||||
|
||||
---
|
||||
|
||||
### `POST /session/:id/prompt_async`
|
||||
|
||||
Fire-and-forget variant. Returns `204` immediately and starts generation in the background. Include `messageID` in the request body so the submitted user message can be matched to the later assistant response. Opencode validates caller-provided message IDs; use IDs starting with `msg`.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"messageID": "msg_xyz",
|
||||
"parts": [
|
||||
{ "type": "text", "text": "your prompt here" }
|
||||
],
|
||||
"agent": "atm10-expert",
|
||||
"model": { "providerID": "anthropic", "modelID": "claude-opus-4-7" }
|
||||
}
|
||||
```
|
||||
|
||||
`agent` and `model` are optional. Omit `model` to use the server/session default model, or include it to force a specific provider/model for this prompt.
|
||||
|
||||
`ai` uses this endpoint by default to avoid `504` failures from the blocking `/message` endpoint when the LLM takes longer than one HTTP request timeout. The submitted `messageID` identifies the user message; `ai` polls `GET /session/:id/message` and reads the completed assistant message after it. If `opencc.agent` or `--agent <name>` is set, `ai` includes it as `agent` in the request body. If generation fails in the background, opencode records the failure on the assistant message or session event stream; `ai` surfaces assistant message errors while polling.
|
||||
|
||||
---
|
||||
|
||||
### `GET /global/event` (SSE)
|
||||
|
||||
Server-Sent Events stream. Delivers all server bus events in real time. Useful for async workflows or watching a session from outside the TUI.
|
||||
|
||||
---
|
||||
|
||||
## opencode attach
|
||||
|
||||
`opencode attach` opens the TUI and connects it to an already-running `opencode serve` instance. Both the TUI and any HTTP clients operate on the same session state.
|
||||
|
||||
```bash
|
||||
opencode attach http://127.0.0.1:4096
|
||||
opencode attach http://127.0.0.1:4096 --session ses_abc123
|
||||
```
|
||||
|
||||
To send messages to the session currently open in the TUI, set `opencc.session_id` in CC to the session ID shown in the TUI, then run `ai`:
|
||||
|
||||
```sh
|
||||
set opencc.session_id ses_abc123
|
||||
```
|
||||
|
||||
## TUI control
|
||||
|
||||
When a TUI is attached, these endpoints drive that TUI programmatically. They operate on the attached TUI prompt/dialog state, not on a specific `/session/:id`.
|
||||
|
||||
| Method | Path | Effect | Body |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/tui/append-prompt` | Append text to the prompt | `{ "text": "..." }` |
|
||||
| `POST` | `/tui/submit-prompt` | Submit the current prompt | none |
|
||||
| `POST` | `/tui/clear-prompt` | Clear the prompt | none |
|
||||
| `POST` | `/tui/open-help` | Open the help dialog | none |
|
||||
| `POST` | `/tui/open-sessions` | Open the session selector | none |
|
||||
| `POST` | `/tui/open-themes` | Open the theme selector | none |
|
||||
| `POST` | `/tui/open-models` | Open the model selector | none |
|
||||
| `POST` | `/tui/execute-command` | Execute a TUI command | `{ "command": "prompt.submit" }` |
|
||||
| `POST` | `/tui/show-toast` | Show a notification | `{ "message": "...", "variant": "info" }` |
|
||||
| `GET` | `/tui/control/next` | Wait for the next TUI control request | none |
|
||||
| `POST` | `/tui/control/response` | Respond to a TUI control request | response body |
|
||||
|
||||
`/tui/show-toast` also accepts optional `title` and `duration` fields. `variant` is one of `info`, `success`, `warning`, or `error`.
|
||||
|
||||
Example: prefill and submit the attached TUI prompt:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:4096/tui/append-prompt \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"text":"reply with exactly: pong"}'
|
||||
|
||||
curl -X POST http://127.0.0.1:4096/tui/submit-prompt
|
||||
```
|
||||
|
||||
`POST /tui/publish` also exists as a lower-level endpoint for publishing TUI events such as `tui.prompt.append`, `tui.command.execute`, and `tui.toast.show`. Prefer the specific endpoints above unless you need that raw event surface.
|
||||
@ -1,175 +0,0 @@
|
||||
# opencode server guide
|
||||
|
||||
How to run `opencode serve` and use `ai` from ComputerCraft directly, no proxy.
|
||||
|
||||
See [`opencode_api.md`](opencode_api.md) for the full API reference.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
CC Computer
|
||||
└─ ai.lua (libai.lua)
|
||||
└─ POST /session/:id/message → opencode serve
|
||||
```
|
||||
|
||||
## 0. Install TrapOS and the AI package
|
||||
|
||||
On a fresh CC computer (beta branch):
|
||||
|
||||
```
|
||||
wget run https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/next/install-ccpm.lua --beta
|
||||
ccpm update
|
||||
ccpm install trapos
|
||||
```
|
||||
|
||||
## 1. Start `opencode serve`
|
||||
|
||||
Local/dev uses opencode's default port `4096`:
|
||||
|
||||
```bash
|
||||
opencode serve --hostname 0.0.0.0 --port 4096
|
||||
```
|
||||
|
||||
Public production uses port `4242`; see [`public-ports.md`](public-ports.md):
|
||||
|
||||
```bash
|
||||
opencode serve --hostname 0.0.0.0 --port 4242
|
||||
```
|
||||
|
||||
With Basic Auth (recommended for LAN exposure):
|
||||
|
||||
```bash
|
||||
OPENCODE_SERVER_PASSWORD=secret opencode serve \
|
||||
--hostname 0.0.0.0 \
|
||||
--port 4096
|
||||
```
|
||||
|
||||
Default username is `opencode`. Override with `OPENCODE_SERVER_USERNAME=myuser`.
|
||||
|
||||
Check it's alive:
|
||||
|
||||
```bash
|
||||
curl http://localhost:4096/global/health
|
||||
```
|
||||
|
||||
With Basic Auth:
|
||||
|
||||
```bash
|
||||
curl -u opencode:secret http://localhost:4096/global/health
|
||||
```
|
||||
|
||||
## 2. (Optional) Attach the TUI
|
||||
|
||||
Open the interactive TUI connected to the running server. CC clients and the TUI share the same session state.
|
||||
|
||||
```bash
|
||||
opencode attach http://127.0.0.1:4096
|
||||
```
|
||||
|
||||
To target a specific session from CC, grab the session ID shown in the TUI and run:
|
||||
|
||||
```sh
|
||||
set opencc.session_id ses_abc123
|
||||
```
|
||||
|
||||
## 3. Configure CC settings
|
||||
|
||||
Use the CraftOS shell `set` program at the ComputerCraft console or CraftOS-PC terminal. It persists immediately — no `save` step. The Lua [settings](https://tweaked.cc/module/settings.html) API would also work from a script, but at the shell prompt use `set`.
|
||||
|
||||
```sh
|
||||
set opencc.server_url http://<host-ip>:4096
|
||||
```
|
||||
|
||||
For public production, use port `4242`:
|
||||
|
||||
```sh
|
||||
set opencc.server_url http://<public-host>:4242
|
||||
```
|
||||
|
||||
For example locally:
|
||||
|
||||
```sh
|
||||
set opencc.server_url http://127.0.0.1:4096
|
||||
```
|
||||
|
||||
With auth:
|
||||
|
||||
```sh
|
||||
set opencc.password secret
|
||||
```
|
||||
|
||||
Optional — override the Basic Auth username (default `opencode`):
|
||||
|
||||
```sh
|
||||
set opencc.username myuser
|
||||
```
|
||||
|
||||
Optional — select an opencode agent for requests from this computer:
|
||||
|
||||
```sh
|
||||
set opencc.agent atm10-expert
|
||||
```
|
||||
|
||||
Optional — scope `ai sessions` to a specific opencode project directory. If omitted, `ai sessions` falls back to the directory recorded on the saved `opencc.session_id` when the unscoped list is empty:
|
||||
|
||||
```sh
|
||||
set opencc.directory /Users/garm/trap/cc-libs
|
||||
```
|
||||
|
||||
Optional: pick the provider and model for requests from this computer. If omitted, `ai` still posts to `/session/:id/prompt_async`; opencode uses the server/session default model:
|
||||
|
||||
```sh
|
||||
set opencc.provider_id anthropic
|
||||
set opencc.model_id claude-opus-4-7
|
||||
```
|
||||
|
||||
Optional timeout settings:
|
||||
|
||||
```sh
|
||||
set opencc.timeout_seconds 60
|
||||
set opencc.poll_timeout_seconds 600
|
||||
set opencc.poll_interval_seconds 2
|
||||
```
|
||||
|
||||
`opencc.session_id` is auto-managed and saved by `ai`. Set it manually only when you want CC to target an existing session, such as one opened in the attached TUI.
|
||||
|
||||
- **CraftOS-PC (localhost):** `http://127.0.0.1:4096`
|
||||
- **In-game ATM10 local/dev:** use your LAN IP with port `4096` (e.g. `192.168.x.x:4096`) — add it to `http.rules` in `config/computercraft-server.toml`
|
||||
- **Public production:** use your public host with port `4242`
|
||||
|
||||
## 4. Run `ai`
|
||||
|
||||
```
|
||||
ai ping -- ping, reuses existing session
|
||||
ai "explain what a turtle is"
|
||||
ai new "start a fresh topic" -- forget current session, start fresh
|
||||
ai sessions -- list all server sessions with their IDs
|
||||
ai --agent atm10-expert "what peripherals are attached?"
|
||||
ai --verbose ping -- show HTTP/session diagnostics
|
||||
ai --help
|
||||
ai --version
|
||||
```
|
||||
|
||||
Expected `ai ping` output on a working setup: `pong`.
|
||||
|
||||
## 5. CraftOS-PC (no Minecraft)
|
||||
|
||||
```bash
|
||||
just trapos --headless -- /programs/ai.lua ping
|
||||
```
|
||||
|
||||
Set settings inside the harness before running, or inject them via the test API.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Error | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `missing opencc.server_url` | Setting not set | `set opencc.server_url http://...` |
|
||||
| `serveur injoignable` | Server not running or wrong URL | Start `opencode serve`, check URL/port |
|
||||
| `erreur message: HTTP 401` | Wrong password | Check `opencc.password` matches `OPENCODE_SERVER_PASSWORD` |
|
||||
| `missing prompt` | No prompt was passed | Run `ai <prompt>` or `ai ping` |
|
||||
| `session introuvable; lance: ai new <prompt>` | Session was deleted or server restarted | Run `ai new <prompt>` |
|
||||
| `erreur message: HTTP 504` | Blocking mode AI call took too long | Retry; prefer the default async mode |
|
||||
| `erreur assistant: ...` | Opencode accepted the async prompt but generation failed | Check the provider/model/agent and opencode logs |
|
||||
| `delai depasse en attendant la reponse AI` | Async polling timed out | Increase `opencc.poll_timeout_seconds`; use `ai --verbose` and check opencode logs |
|
||||
| `reponse vide` | Reply had no text parts | Check opencode logs |
|
||||
@ -1,69 +0,0 @@
|
||||
# `periphemu` — CraftOS-PC Peripheral Emulation
|
||||
|
||||
In-repo reference for the `periphemu` global exposed by [CraftOS-PC](https://www.craftos-pc.cc/). It does **not** exist in CC:Tweaked in-game — always guard usage with `if periphemu then`.
|
||||
|
||||
Upstream: <https://www.craftos-pc.cc/docs/periphemu>. See also the [CraftOS-PC glossary](craftos_pc_glossary.md) and [ADR-0005](adrs/adr-0005-craftos-pc-harness-and-probes.md) (why CraftOS-PC is our local harness, and why our startup attaches only a modem).
|
||||
|
||||
## Usage in this repo
|
||||
|
||||
`startup/servers.lua` attaches a single top modem when running under CraftOS-PC:
|
||||
|
||||
```lua
|
||||
if periphemu then
|
||||
periphemu.create('top', 'modem');
|
||||
end
|
||||
```
|
||||
|
||||
That is the only `periphemu` call in the codebase. In-game (`periphemu == nil`) the block is a no-op. Locally a top modem is enough to boot the router, `ping-server`, and clients on the same VM.
|
||||
|
||||
To exercise cross-computer behavior locally, spawn extra peers ad-hoc from the shell:
|
||||
|
||||
```
|
||||
periphemu create 10 computer # spawn router VM (id 10)
|
||||
periphemu create 1 computer # spawn a client VM
|
||||
```
|
||||
|
||||
Each call opens a new CraftOS-PC window (GUI mode). Keep this out of `startup/servers.lua` — persistent emulated peers clutter the desktop and leave stale state under `~/Library/Application Support/CraftOS-PC/computer/<id>/`.
|
||||
|
||||
## API
|
||||
|
||||
### `periphemu.create(side, type[, arg])` → `boolean`
|
||||
|
||||
Attaches a peripheral. Returns `true` on success, `false` if `side` is already occupied.
|
||||
|
||||
- `side` (string | number) — string for directional sides (`"top"`, `"left"`, …) or arbitrary names (`"modem_4"`); a number to attach a `computer` peripheral by id.
|
||||
- `type` (string) — see types below.
|
||||
- `arg` (optional) — type-specific, see table below.
|
||||
|
||||
### `periphemu.remove(side)` → `boolean`
|
||||
|
||||
Detaches the peripheral on `side`. Returns `false` if nothing is attached.
|
||||
|
||||
### `periphemu.names()` → `string[]`
|
||||
|
||||
Returns the list of peripheral type identifiers this CraftOS-PC build accepts as the `type` argument to `create`.
|
||||
|
||||
## Peripheral types
|
||||
|
||||
| Type | Third arg | Notes |
|
||||
| -------------- | -------------------- | -------------------------------------------------------------------- |
|
||||
| `modem` | network id (number) | Default network if omitted. Used to isolate modem networks. |
|
||||
| `computer` | — | `side` is the computer id (number). Spawns a new VM window. |
|
||||
| `drive` | mount path / `record:` / `treasure:` / `computer:<id>` | Initial mounted disk. |
|
||||
| `monitor` | — | Size is set via `monitor.setTextScale` after attach. |
|
||||
| `printer` | output PDF path | Required. Receives printed pages as PDF. |
|
||||
| `speaker` | — | See caveat below about `playAudio` vs CC:T. |
|
||||
| `debugger` | — | Attaches the CraftOS-PC debugger; see upstream debugger docs. |
|
||||
| `debug_adapter`| — | DAP front-end peripheral. |
|
||||
| `chest` | `true`/`false` | `true` = double chest. Also accepts `minecraft:chest`. |
|
||||
| `energy` | max energy (number) | Optional further args declare supported energy type strings. |
|
||||
| `tank` | tank count (number) | Optional further args set tank size and accepted fluid type strings. |
|
||||
|
||||
Call `periphemu.names()` from a CraftOS-PC shell to confirm what the current build supports — the upstream list grows occasionally.
|
||||
|
||||
## Caveats
|
||||
|
||||
- **CC:T has no `periphemu`.** Always guard with `if periphemu then`. [AGENTS.md](./../AGENTS.md) and [ADR-0005](adrs/adr-0005-craftos-pc-harness-and-probes.md) treat that guard as mandatory.
|
||||
- **Speaker `playAudio` differs from CC:T** unless [standards mode](https://www.craftos-pc.cc/docs/standards) is on — CraftOS-PC queues audio without ever returning `false`.
|
||||
- **Modem network id** segregates emulated networks. Two modems with different ids will not see each other; useful for testing the [router](../programs/router.lua) against a sealed network.
|
||||
- **Persisted per-computer state** lives at `~/Library/Application Support/CraftOS-PC/computer/<id>/` and `config/<id>.json` (labels stored base64-encoded). Spawning a new id leaves that state behind across sessions.
|
||||
@ -1,18 +0,0 @@
|
||||
# Public Production Ports
|
||||
|
||||
TrapOS public production services use the `4242-4244` TCP range. Keep local developer defaults separate from these public ports so tests and local tools can run without binding production-facing addresses.
|
||||
|
||||
| Port | Service | Notes |
|
||||
|---|---|---|
|
||||
| `4242` | opencode server HTTP API | Production/public port for `opencode serve`. Local/dev examples may still use opencode's default `4096`. |
|
||||
| `4243` | ComputerCraft bridge WebSocket | Production/public equivalent of the local bridge link default `3001`. Use `CC_LINK_PORT=4243` for production bridge deployments. |
|
||||
| `4244` | Reserved | Free public production port reserved for a future service. Do not assign it casually in local tooling. |
|
||||
|
||||
## Local Vs Production
|
||||
|
||||
- Local/dev opencode: `http://127.0.0.1:4096`.
|
||||
- Public/production opencode: `http://<public-host>:4242`.
|
||||
- Local/dev ComputerCraft bridge link: `ws://<host>:3001`.
|
||||
- Public/production ComputerCraft bridge link: `ws://<public-host>:4243`.
|
||||
|
||||
Production services exposed on public ports should use the normal deployment controls for the host: authentication where supported, firewall rules, and TLS or a reverse proxy when crossing untrusted networks.
|
||||
@ -1,116 +0,0 @@
|
||||
# TrapGPT Guide
|
||||
|
||||
TrapGPT est un programme TrapOS qui ecoute le chat Minecraft avec une Chat Box Advanced Peripherals, envoie les nouveaux messages a `opencode serve`, puis repond tres brievement dans le chat.
|
||||
|
||||
## Prerequis
|
||||
|
||||
- Une Chat Box Advanced Peripherals connectee au computer.
|
||||
- TrapOS installe. Le package complet `trapos` inclut `trapos-ai`; sinon installe `trapos-ai` separement.
|
||||
- Un serveur `opencode serve` accessible depuis ComputerCraft HTTP.
|
||||
- HTTP active cote serveur Minecraft/ComputerCraft.
|
||||
|
||||
Voir aussi [`opencode_server_guide.md`](opencode_server_guide.md) pour lancer `opencode serve`.
|
||||
|
||||
## Configuration In-Game
|
||||
|
||||
Dans le computer qui porte la chatbox, configure l'URL du serveur opencode:
|
||||
|
||||
```lua
|
||||
set opencc.server_url http://host:port
|
||||
set opencc.username opencode
|
||||
```
|
||||
|
||||
Si ton serveur opencode demande un mot de passe Basic Auth:
|
||||
|
||||
```lua
|
||||
set opencc.password ton_mot_de_passe
|
||||
```
|
||||
|
||||
TrapGPT utilise les memes settings `opencc.*` que le programme `ai`. Il profite aussi de `opencc.provider_id` et `opencc.model_id`; quand ils sont configures, `libai` utilise l'endpoint async de opencode et evite mieux les timeouts HTTP longs.
|
||||
|
||||
```lua
|
||||
set opencc.provider_id anthropic
|
||||
set opencc.model_id claude-opus-4-7
|
||||
```
|
||||
|
||||
Settings optionnels pour TrapGPT:
|
||||
|
||||
```lua
|
||||
set trapgpt.throttle_seconds 5
|
||||
set trapgpt.max_reply_chars 160
|
||||
set trapgpt.prefix TrapGPT
|
||||
```
|
||||
|
||||
## Lancement
|
||||
|
||||
Depuis le shell CraftOS:
|
||||
|
||||
```lua
|
||||
trapgpt
|
||||
```
|
||||
|
||||
Le programme affiche:
|
||||
|
||||
```text
|
||||
trapgpt listening
|
||||
```
|
||||
|
||||
Ensuite, parle normalement dans le chat Minecraft. TrapGPT collecte les messages, attend le throttle, envoie les nouveaux messages au LLM, puis poste une reponse courte dans le chat.
|
||||
|
||||
## Comportement
|
||||
|
||||
- TrapGPT ecoute pour l'instant seulement les messages joueur `chat`.
|
||||
- Les morts et connexions seront ajoutes plus tard.
|
||||
- Les messages caches, vides, ou envoyes avec le meme nom que `trapgpt.prefix` sont ignores.
|
||||
- Quand `messageUtf8` est fourni par l'evenement `chat`, TrapGPT l'utilise a la place de `message`.
|
||||
- Il ne lance jamais deux appels LLM en meme temps.
|
||||
- Les messages recus pendant un appel opencode sont gardes en queue.
|
||||
- Si plusieurs messages sont en queue, ils sont envoyes ensemble au batch suivant.
|
||||
- Si l'appel opencode echoue, la queue est conservee pour un prochain essai.
|
||||
- La session opencode est separee du programme `ai`: elle utilise `trapgpt.opencc.session_id` et le titre de session `trapgpt`.
|
||||
- Si le LLM repond exactement `SILENCE`, rien n'est envoye dans le chat.
|
||||
- Les reponses sont tronquees a `trapgpt.max_reply_chars` caracteres.
|
||||
|
||||
## Redemarrer Une Session TrapGPT
|
||||
|
||||
Si tu veux repartir avec une nouvelle conversation opencode pour TrapGPT:
|
||||
|
||||
```lua
|
||||
lua
|
||||
require('/apis/libai')().clearSession({ sessionSettingKey = 'trapgpt.opencc.session_id' })
|
||||
exit()
|
||||
```
|
||||
|
||||
Puis relance:
|
||||
|
||||
```lua
|
||||
trapgpt
|
||||
```
|
||||
|
||||
## Depannage
|
||||
|
||||
Si `chat_box peripheral not found` apparait:
|
||||
|
||||
- Verifie que la Chat Box est bien collee/connectee au computer.
|
||||
- En 1.21.1+, le nom de peripheral attendu est souvent `chat_box`.
|
||||
- Sur anciennes versions, TrapGPT tente aussi `chatBox`.
|
||||
|
||||
Si TrapGPT ne repond pas:
|
||||
|
||||
- Verifie `opencc.server_url`.
|
||||
- Verifie que `opencode serve` tourne.
|
||||
- Verifie que HTTP est autorise dans la config ComputerCraft.
|
||||
- Attends au moins `trapgpt.throttle_seconds` secondes.
|
||||
- Le LLM peut aussi avoir repondu `SILENCE`, donc rien n'est poste.
|
||||
|
||||
Pour voir la version installee:
|
||||
|
||||
```lua
|
||||
trapgpt --version
|
||||
```
|
||||
|
||||
Pour afficher l'aide:
|
||||
|
||||
```lua
|
||||
trapgpt --help
|
||||
```
|
||||
255
install-ccpm.lua
255
install-ccpm.lua
@ -1,255 +0,0 @@
|
||||
local _VERSION = '5.1.0';
|
||||
|
||||
local REPO_BASE = 'https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/';
|
||||
local LOCAL_STATE_DIR = '/trapos';
|
||||
local LOCAL_MANIFEST_PATH = '/trapos/manifest.json';
|
||||
local LOCAL_CONFIG_PATH = '/trapos/ccpm.json';
|
||||
local LOCAL_LOCK_PATH = '/trapos/ccpm.lock.json';
|
||||
local DEFAULT_REGISTRY_NAME = 'guillaumearm/cc-libs';
|
||||
|
||||
local function printUsage()
|
||||
print('install-ccpm usage:');
|
||||
print();
|
||||
print('\t\twget run <install-url>');
|
||||
print('\t\twget run <install-url> --beta');
|
||||
print('\t\twget run <install-url> --stable');
|
||||
end
|
||||
|
||||
local function readJsonFile(path)
|
||||
if not fs.exists(path) then return nil; end
|
||||
local f = fs.open(path, 'r');
|
||||
if not f then return nil; end
|
||||
local data = f.readAll();
|
||||
f.close();
|
||||
if not data or data == '' then return nil; end
|
||||
return textutils.unserializeJSON(data);
|
||||
end
|
||||
|
||||
local function writeJsonFile(path, value)
|
||||
fs.makeDir(LOCAL_STATE_DIR);
|
||||
local f = fs.open(path, 'w');
|
||||
if not f then return false; end
|
||||
f.write(textutils.serializeJSON(value));
|
||||
f.close();
|
||||
return true;
|
||||
end
|
||||
|
||||
local function confirmBeta()
|
||||
print();
|
||||
print('You are about to install the BETA branch (next).');
|
||||
print('Beta builds may be unstable. Continue? (y/N)');
|
||||
write('> ');
|
||||
local answer = read();
|
||||
if not answer then return false; end
|
||||
answer = answer:lower();
|
||||
return answer == 'y' or answer == 'yes';
|
||||
end
|
||||
|
||||
local function fetchJson(url)
|
||||
local response = http.get(url);
|
||||
if not response then return nil end
|
||||
local body = response.readAll();
|
||||
response.close();
|
||||
if not body or body == '' then return nil end
|
||||
return textutils.unserializeJSON(body);
|
||||
end
|
||||
|
||||
local function fetchManifest(branch)
|
||||
return fetchJson(REPO_BASE .. branch .. '/manifest.json');
|
||||
end
|
||||
|
||||
local function fetchDescriptor(branch, pkg)
|
||||
return fetchJson(REPO_BASE .. branch .. '/packages/' .. pkg .. '/ccpm.json');
|
||||
end
|
||||
|
||||
local function ensureProgramsPath()
|
||||
local current = shell.path();
|
||||
for entry in string.gmatch(current, '[^:]+') do
|
||||
if entry == '/programs' then return; end
|
||||
end
|
||||
if current == '' then
|
||||
shell.setPath('/programs');
|
||||
return;
|
||||
end
|
||||
shell.setPath(current .. ':/programs');
|
||||
end
|
||||
|
||||
-- Resolve a list of package names + their dependencies into install order
|
||||
-- (deps first). Returns an ordered list of descriptors or nil, err.
|
||||
local function resolvePackages(branch, names)
|
||||
local ordered = {};
|
||||
local state = {}; -- name -> 'visiting' | 'done'
|
||||
|
||||
local function visit(name)
|
||||
if state[name] == 'done' then return true end
|
||||
if state[name] == 'visiting' then
|
||||
return false, 'dependency cycle detected at ' .. name;
|
||||
end
|
||||
state[name] = 'visiting';
|
||||
local desc = fetchDescriptor(branch, name);
|
||||
if not desc then
|
||||
return false, 'package not found: ' .. name;
|
||||
end
|
||||
for _, dep in ipairs(desc.dependencies or {}) do
|
||||
local ok, err = visit(dep);
|
||||
if not ok then return false, err end
|
||||
end
|
||||
state[name] = 'done';
|
||||
ordered[#ordered + 1] = desc;
|
||||
return true;
|
||||
end
|
||||
|
||||
for _, name in ipairs(names) do
|
||||
local ok, err = visit(name);
|
||||
if not ok then return nil, err end
|
||||
end
|
||||
return ordered;
|
||||
end
|
||||
|
||||
-- Seed/refresh the default ccpm registry so it tracks the install branch.
|
||||
local function seedCcpmConfig(branch)
|
||||
local cfg = readJsonFile(LOCAL_CONFIG_PATH) or { registries = {} };
|
||||
cfg.registries = cfg.registries or {};
|
||||
local found = false;
|
||||
for _, r in ipairs(cfg.registries) do
|
||||
if r.name == DEFAULT_REGISTRY_NAME then
|
||||
r.type = 'gitea';
|
||||
r.branch = branch;
|
||||
found = true;
|
||||
end
|
||||
end
|
||||
if not found then
|
||||
table.insert(cfg.registries, 1, { name = DEFAULT_REGISTRY_NAME, type = 'gitea', branch = branch });
|
||||
end
|
||||
writeJsonFile(LOCAL_CONFIG_PATH, cfg);
|
||||
end
|
||||
|
||||
local rawArgs = table.pack(...);
|
||||
local forceBeta, forceStable = false, false;
|
||||
|
||||
for i = 1, rawArgs.n do
|
||||
local a = rawArgs[i];
|
||||
if a == 'version' or a == '-version' or a == '--version' then
|
||||
print('install-ccpm v' .. _VERSION);
|
||||
return;
|
||||
elseif a == 'help' or a == '-help' or a == '--help' then
|
||||
printUsage();
|
||||
return;
|
||||
elseif a == '--beta' or a == '-beta' then
|
||||
forceBeta = true;
|
||||
elseif a == '--stable' or a == '-stable' then
|
||||
forceStable = true;
|
||||
elseif a ~= nil and a ~= '' then
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
end
|
||||
|
||||
local localManifest = readJsonFile(LOCAL_MANIFEST_PATH);
|
||||
local localBranch = localManifest and localManifest.branch or nil;
|
||||
local branch;
|
||||
|
||||
if forceBeta then
|
||||
branch = 'next';
|
||||
if localBranch ~= 'next' then
|
||||
if not confirmBeta() then
|
||||
print('Aborted.');
|
||||
return;
|
||||
end
|
||||
end
|
||||
elseif forceStable then
|
||||
branch = 'master';
|
||||
else
|
||||
branch = localBranch or 'master';
|
||||
end
|
||||
|
||||
print('Fetching manifest from branch: ' .. branch);
|
||||
local manifest = fetchManifest(branch);
|
||||
if not manifest then
|
||||
print('Failed to fetch or parse manifest.json from ' .. branch);
|
||||
return;
|
||||
end
|
||||
|
||||
local requested = { 'trapos-core' };
|
||||
|
||||
local resolved, resolveErr = resolvePackages(branch, requested);
|
||||
if not resolved then
|
||||
print('Failed to resolve packages: ' .. resolveErr);
|
||||
return;
|
||||
end
|
||||
|
||||
local REPO_PREFIX = REPO_BASE .. branch .. '/';
|
||||
|
||||
-- Legacy file cleanup (pre-manifest installs).
|
||||
fs.delete('ping-server.lua');
|
||||
fs.delete('ping.lua');
|
||||
fs.delete('cube.lua');
|
||||
fs.delete('router.lua');
|
||||
fs.delete('servers/cube-startup.lua');
|
||||
fs.delete('programs/cube.lua');
|
||||
fs.delete('programs/goo.lua');
|
||||
fs.delete('servers/cube-server.lua');
|
||||
fs.delete('servers/cube-boot.lua');
|
||||
|
||||
local previousDir = shell.dir();
|
||||
shell.setDir('/');
|
||||
|
||||
fs.makeDir('/programs');
|
||||
fs.makeDir('/apis');
|
||||
fs.makeDir('/startup');
|
||||
fs.makeDir('/servers');
|
||||
fs.makeDir(LOCAL_STATE_DIR);
|
||||
|
||||
local allFiles = {};
|
||||
local seenFile = {};
|
||||
local autostart = {};
|
||||
local seenAuto = {};
|
||||
local lockPackages = {};
|
||||
|
||||
for _, desc in ipairs(resolved) do
|
||||
for _, filePath in ipairs(desc.files or {}) do
|
||||
if not seenFile[filePath] then
|
||||
seenFile[filePath] = true;
|
||||
allFiles[#allFiles + 1] = filePath;
|
||||
fs.delete(filePath);
|
||||
shell.execute('wget', REPO_PREFIX .. filePath, filePath);
|
||||
end
|
||||
end
|
||||
for _, srv in ipairs(desc.autostart or {}) do
|
||||
if not seenAuto[srv] then
|
||||
seenAuto[srv] = true;
|
||||
autostart[#autostart + 1] = srv;
|
||||
end
|
||||
end
|
||||
lockPackages[desc.name] = {
|
||||
version = desc.version,
|
||||
registry = DEFAULT_REGISTRY_NAME,
|
||||
files = desc.files or {},
|
||||
dependencies = desc.dependencies or {},
|
||||
autostart = desc.autostart or {},
|
||||
};
|
||||
end
|
||||
|
||||
-- Aggregated OS state for motd/servers/upgrade (unchanged consumers).
|
||||
writeJsonFile(LOCAL_MANIFEST_PATH, {
|
||||
name = manifest.name or 'TrapOS',
|
||||
version = manifest.version or '?',
|
||||
branch = branch,
|
||||
files = allFiles,
|
||||
autostart = autostart,
|
||||
});
|
||||
|
||||
writeJsonFile(LOCAL_LOCK_PATH, { packages = lockPackages });
|
||||
seedCcpmConfig(branch);
|
||||
ensureProgramsPath();
|
||||
|
||||
print();
|
||||
print('=> ccpm installed (branch: ' .. branch .. ')');
|
||||
print('=> Default registry: ' .. DEFAULT_REGISTRY_NAME);
|
||||
print('=> Run: ccpm update');
|
||||
print('=> Run: ccpm install trapos');
|
||||
if fs.exists('/startup/servers.lua') then
|
||||
shell.execute('/startup/servers.lua');
|
||||
end
|
||||
|
||||
shell.setDir(previousDir);
|
||||
142
install.lua
Normal file
142
install.lua
Normal file
@ -0,0 +1,142 @@
|
||||
local _VERSION = '3.0.0';
|
||||
|
||||
local REPO_BASE = 'https://raw.githubusercontent.com/guillaumearm/cc-libs/';
|
||||
local LOCAL_STATE_DIR = '/trapos';
|
||||
local LOCAL_MANIFEST_PATH = '/trapos/manifest.json';
|
||||
|
||||
local function printUsage()
|
||||
print('install usage:');
|
||||
print();
|
||||
print('\t\twget run <install-url>');
|
||||
print('\t\twget run <install-url> --beta');
|
||||
print('\t\twget run <install-url> --stable');
|
||||
end
|
||||
|
||||
local function readLocalManifest()
|
||||
if not fs.exists(LOCAL_MANIFEST_PATH) then return nil end
|
||||
local f = fs.open(LOCAL_MANIFEST_PATH, 'r');
|
||||
if not f then return nil end
|
||||
local data = f.readAll();
|
||||
f.close();
|
||||
if not data or data == '' then return nil end
|
||||
return textutils.unserializeJSON(data);
|
||||
end
|
||||
|
||||
local function writeLocalManifest(manifest)
|
||||
fs.makeDir(LOCAL_STATE_DIR);
|
||||
local f = fs.open(LOCAL_MANIFEST_PATH, 'w');
|
||||
if not f then return false end
|
||||
f.write(textutils.serializeJSON(manifest));
|
||||
f.close();
|
||||
return true;
|
||||
end
|
||||
|
||||
local function confirmBeta()
|
||||
print();
|
||||
print('You are about to install the BETA branch (next).');
|
||||
print('Beta builds may be unstable. Continue? (y/N)');
|
||||
write('> ');
|
||||
local answer = read();
|
||||
if not answer then return false end
|
||||
answer = answer:lower();
|
||||
return answer == 'y' or answer == 'yes';
|
||||
end
|
||||
|
||||
local function fetchManifest(branch)
|
||||
local url = REPO_BASE .. branch .. '/manifest.json';
|
||||
local response = http.get(url);
|
||||
if not response then return nil end
|
||||
local body = response.readAll();
|
||||
response.close();
|
||||
if not body or body == '' then return nil end
|
||||
return textutils.unserializeJSON(body);
|
||||
end
|
||||
|
||||
local command = ...;
|
||||
local forceBeta = false;
|
||||
local forceStable = false;
|
||||
|
||||
if command == 'version' or command == '-version' or command == '--version' then
|
||||
print('install v' .. _VERSION);
|
||||
return;
|
||||
end
|
||||
|
||||
if command == 'help' or command == '-help' or command == '--help' then
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
|
||||
if command == '--beta' or command == '-beta' then
|
||||
forceBeta = true;
|
||||
elseif command == '--stable' or command == '-stable' then
|
||||
forceStable = true;
|
||||
elseif command ~= nil and command ~= '' then
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
|
||||
local localManifest = readLocalManifest();
|
||||
local localBranch = localManifest and localManifest.branch or nil;
|
||||
local branch;
|
||||
|
||||
if forceBeta then
|
||||
branch = 'next';
|
||||
if localBranch ~= 'next' then
|
||||
if not confirmBeta() then
|
||||
print('Aborted.');
|
||||
return;
|
||||
end
|
||||
end
|
||||
elseif forceStable then
|
||||
branch = 'master';
|
||||
else
|
||||
branch = localBranch or 'master';
|
||||
end
|
||||
|
||||
print('Fetching manifest from branch: ' .. branch);
|
||||
local manifest = fetchManifest(branch);
|
||||
if not manifest or type(manifest.files) ~= 'table' then
|
||||
print('Failed to fetch or parse manifest.json from ' .. branch);
|
||||
return;
|
||||
end
|
||||
|
||||
-- The persisted branch reflects the actually-used branch, not the manifest default.
|
||||
manifest.branch = branch;
|
||||
|
||||
local REPO_PREFIX = REPO_BASE .. branch .. '/';
|
||||
|
||||
-- Legacy file cleanup (pre-manifest installs).
|
||||
fs.delete('ping-server.lua');
|
||||
fs.delete('ping.lua');
|
||||
fs.delete('cube.lua');
|
||||
fs.delete('router.lua');
|
||||
fs.delete('servers/cube-startup.lua');
|
||||
fs.delete('programs/cube.lua');
|
||||
fs.delete('programs/goo.lua');
|
||||
fs.delete('servers/cube-server.lua');
|
||||
fs.delete('servers/cube-boot.lua');
|
||||
|
||||
local previousDir = shell.dir();
|
||||
shell.setDir('/');
|
||||
|
||||
fs.makeDir('/programs');
|
||||
fs.makeDir('/apis');
|
||||
fs.makeDir('/startup');
|
||||
fs.makeDir('/servers');
|
||||
fs.makeDir(LOCAL_STATE_DIR);
|
||||
|
||||
for _, filePath in ipairs(manifest.files) do
|
||||
fs.delete(filePath);
|
||||
shell.execute('wget', REPO_PREFIX .. filePath, filePath);
|
||||
end
|
||||
|
||||
if not writeLocalManifest(manifest) then
|
||||
print('Warning: failed to write local manifest');
|
||||
end
|
||||
|
||||
print();
|
||||
print('=> TrapOS v' .. (manifest.version or '?') .. ' installed (branch: ' .. branch .. ')');
|
||||
print('=> Execute startup/servers.lua');
|
||||
shell.execute('/startup/servers.lua');
|
||||
|
||||
shell.setDir(previousDir);
|
||||
181
just/check.just
181
just/check.just
@ -1,181 +0,0 @@
|
||||
# Lint Lua/TypeScript source and validate markdown links.
|
||||
check: check-luacheck check-lychee npm-check
|
||||
luacheck --quiet .
|
||||
@just lint-markdown
|
||||
|
||||
# Auto-fix package version bumps derived from changed package files.
|
||||
fix:
|
||||
@just check-packages --fix
|
||||
|
||||
# Validate package descriptors and require version bumps for changed package files.
|
||||
check-packages *args: check-jq
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
repo='{{justfile_directory()}}'
|
||||
cd "$repo"
|
||||
|
||||
fix=0
|
||||
for arg in {{args}}; do
|
||||
case "$arg" in
|
||||
--fix) fix=1 ;;
|
||||
*)
|
||||
printf '%s\n' "FAIL: unknown check-packages option: $arg" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
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}" ]
|
||||
}
|
||||
|
||||
bump_patch() {
|
||||
local version major minor patch
|
||||
version="$1"
|
||||
IFS=. read -r major minor patch <<<"$version"
|
||||
printf '%s.%s.%s\n' "${major:-0}" "${minor:-0}" "$(( ${patch:-0} + 1 ))"
|
||||
}
|
||||
|
||||
set_json_version() {
|
||||
local path version tmp
|
||||
path="$1"
|
||||
version="$2"
|
||||
tmp="$(mktemp)"
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^([[:space:]]*\"version\"[[:space:]]*:[[:space:]]*\")[^\"]*(\".*)$ ]]; then
|
||||
printf '%s%s%s\n' "${BASH_REMATCH[1]}" "$version" "${BASH_REMATCH[2]}"
|
||||
else
|
||||
printf '%s\n' "$line"
|
||||
fi
|
||||
done <"$path" >"$tmp"
|
||||
mv "$tmp" "$path"
|
||||
}
|
||||
|
||||
set_index_version() {
|
||||
local name version tmp
|
||||
name="$1"
|
||||
version="$2"
|
||||
tmp="$(mktemp)"
|
||||
jq --arg name "$name" --arg version "$version" '.packages[$name] = $version' packages/index.json >"$tmp"
|
||||
mv "$tmp" packages/index.json
|
||||
}
|
||||
|
||||
bump_package() {
|
||||
local name desc old_version current_version new_version
|
||||
name="$1"
|
||||
desc="$2"
|
||||
old_version="$3"
|
||||
current_version="$(jq -r '.version // empty' "$desc")"
|
||||
if semver_gt "$current_version" "$old_version"; then
|
||||
return 0
|
||||
fi
|
||||
new_version="$(bump_patch "$old_version")"
|
||||
set_json_version "$desc" "$new_version"
|
||||
set_index_version "$name" "$new_version"
|
||||
if [ "$name" = trapos ]; then
|
||||
set_json_version manifest.json "$new_version"
|
||||
fi
|
||||
printf '%s\n' "FIX: bumped $name from $current_version to $new_version"
|
||||
}
|
||||
|
||||
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
|
||||
if [ "$fix" -eq 1 ]; then
|
||||
bump_package "$name" "$desc" "$old_version"
|
||||
desc_version="$(jq -r '.version // empty' "$desc")"
|
||||
index_version="$(jq -r --arg name "$name" '.packages[$name] // empty' packages/index.json)"
|
||||
else
|
||||
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
|
||||
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
|
||||
if [ "$fix" -eq 1 ]; then
|
||||
bump_package trapos "$trapos_desc" "$old_trapos_version"
|
||||
else
|
||||
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
|
||||
fi
|
||||
|
||||
trapos_version="$(jq -r '.version // empty' "$trapos_desc")"
|
||||
manifest_version="$(jq -r '.version // empty' manifest.json)"
|
||||
if [ "$manifest_version" != "$trapos_version" ]; then
|
||||
if [ "$fix" -eq 1 ]; then
|
||||
set_json_version manifest.json "$trapos_version"
|
||||
printf '%s\n' "FIX: synced manifest.json from $manifest_version to $trapos_version"
|
||||
else
|
||||
printf '%s\n' "FAIL: manifest.json version ($manifest_version) differs from trapos package ($trapos_version)" >&2
|
||||
fail=1
|
||||
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 .
|
||||
@ -1,244 +0,0 @@
|
||||
# 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
|
||||
@ -1,66 +0,0 @@
|
||||
# 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
|
||||
@ -1,43 +0,0 @@
|
||||
# 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'
|
||||
@ -1,26 +0,0 @@
|
||||
# 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
|
||||
@ -1,34 +0,0 @@
|
||||
# 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
159
just/test.just
@ -1,159 +0,0 @@
|
||||
# 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
|
||||
@ -1,4 +0,0 @@
|
||||
offline = true
|
||||
include_fragments = "full"
|
||||
include_verbatim = false
|
||||
extensions = ["md"]
|
||||
@ -1,8 +1,19 @@
|
||||
{
|
||||
"name": "TrapOS",
|
||||
"version": "0.8.17",
|
||||
"branch": "next",
|
||||
"packages": [
|
||||
"trapos"
|
||||
"version": "0.2.0",
|
||||
"branch": "master",
|
||||
"files": [
|
||||
"startup/motd.lua",
|
||||
"startup/servers.lua",
|
||||
"servers/ping-server.lua",
|
||||
"programs/router.lua",
|
||||
"programs/events.lua",
|
||||
"programs/ping.lua",
|
||||
"programs/upgrade.lua",
|
||||
"apis/net.lua",
|
||||
"apis/eventloop.lua"
|
||||
],
|
||||
"autostart": [
|
||||
"servers/ping-server"
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
{
|
||||
"packages": {
|
||||
"trapos-core": "0.5.0",
|
||||
"trapos-test": "0.2.1",
|
||||
"trapos-boot": "0.3.2",
|
||||
"trapos-net": "0.3.0",
|
||||
"trapos-ui": "0.2.2",
|
||||
"trapos-ai": "0.7.0",
|
||||
"trapos-sandbox": "0.2.2",
|
||||
"trapos": "0.8.17"
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "trapos-ai",
|
||||
"version": "0.7.0",
|
||||
"description": "TrapOS AI client for opencode serve",
|
||||
"dependencies": ["trapos-core"],
|
||||
"files": [
|
||||
"apis/libai.lua",
|
||||
"apis/libhttp.lua",
|
||||
"apis/libhttpws.lua",
|
||||
"apis/libtrapgpt.lua",
|
||||
"programs/ai.lua",
|
||||
"programs/trapgpt.lua"
|
||||
],
|
||||
"autostart": []
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "trapos-boot",
|
||||
"version": "0.3.2",
|
||||
"description": "TrapOS boot: startup MOTD and autostart server launcher",
|
||||
"dependencies": ["trapos-core"],
|
||||
"files": [
|
||||
"startup/motd.lua",
|
||||
"startup/servers.lua"
|
||||
],
|
||||
"autostart": []
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "trapos-core",
|
||||
"version": "0.5.0",
|
||||
"description": "TrapOS base: package manager, event loop, upgrade and event tools",
|
||||
"dependencies": [],
|
||||
"files": [
|
||||
"apis/eventloop.lua",
|
||||
"apis/libccpm.lua",
|
||||
"apis/libversion.lua",
|
||||
"programs/ccpm.lua",
|
||||
"programs/upgrade.lua",
|
||||
"programs/events.lua"
|
||||
],
|
||||
"autostart": []
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "trapos-net",
|
||||
"version": "0.3.0",
|
||||
"description": "TrapOS networking: service-name bus, router, ping",
|
||||
"dependencies": ["trapos-core"],
|
||||
"files": [
|
||||
"apis/net.lua",
|
||||
"apis/librouter.lua",
|
||||
"programs/router.lua",
|
||||
"programs/ping.lua",
|
||||
"servers/ping-server.lua",
|
||||
"servers/net-registrar.lua"
|
||||
],
|
||||
"autostart": ["servers/ping-server", "servers/net-registrar"]
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "trapos-sandbox",
|
||||
"version": "0.2.2",
|
||||
"description": "TrapOS sandbox programs for ccpm experiments and Lua learning",
|
||||
"dependencies": ["trapos-core"],
|
||||
"files": [
|
||||
"apis/libcarre.lua",
|
||||
"apis/libmcpcomputer.lua",
|
||||
"programs/carre.lua",
|
||||
"programs/creeper.lua",
|
||||
"programs/mouton.lua",
|
||||
"programs/mcp-computer.lua",
|
||||
"servers/mcp-computer-server.lua"
|
||||
],
|
||||
"autostart": ["servers/mcp-computer-server"]
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "trapos-test",
|
||||
"version": "0.2.1",
|
||||
"description": "TrapOS test framework and CraftOS-PC suite runner",
|
||||
"dependencies": ["trapos-core"],
|
||||
"files": [
|
||||
"apis/libtest.lua",
|
||||
"programs/runtest.lua"
|
||||
],
|
||||
"autostart": []
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "trapos-ui",
|
||||
"version": "0.2.2",
|
||||
"description": "TrapOS terminal UI toolkit and demo",
|
||||
"dependencies": ["trapos-core"],
|
||||
"files": [
|
||||
"apis/libtui.lua",
|
||||
"programs/tuidemo.lua"
|
||||
],
|
||||
"autostart": []
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "trapos",
|
||||
"version": "0.8.17",
|
||||
"description": "TrapOS full install meta-package",
|
||||
"dependencies": [
|
||||
"trapos-boot",
|
||||
"trapos-net",
|
||||
"trapos-ui",
|
||||
"trapos-test",
|
||||
"trapos-ai"
|
||||
],
|
||||
"files": [],
|
||||
"autostart": []
|
||||
}
|
||||
214
programs/ai.lua
214
programs/ai.lua
@ -1,214 +0,0 @@
|
||||
local createAi = require('/apis/libai');
|
||||
local createVersion = require('/apis/libversion');
|
||||
|
||||
local rawArgs = table.pack(...);
|
||||
local args = { n = 0 };
|
||||
local verbose = false;
|
||||
local agentName = nil;
|
||||
local parseError = nil;
|
||||
|
||||
local argIndex = 1;
|
||||
while argIndex <= rawArgs.n do
|
||||
if rawArgs[argIndex] == '--verbose' or rawArgs[argIndex] == '-v' then
|
||||
verbose = true;
|
||||
elseif rawArgs[argIndex] == '--agent' or rawArgs[argIndex] == '-a' then
|
||||
if argIndex == rawArgs.n then
|
||||
parseError = 'missing agent name after ' .. rawArgs[argIndex];
|
||||
else
|
||||
argIndex = argIndex + 1;
|
||||
agentName = rawArgs[argIndex];
|
||||
end
|
||||
else
|
||||
args.n = args.n + 1;
|
||||
args[args.n] = rawArgs[argIndex];
|
||||
end
|
||||
argIndex = argIndex + 1;
|
||||
end
|
||||
|
||||
local function printUsage()
|
||||
print('ai usage:');
|
||||
print();
|
||||
print(' ai <prompt>');
|
||||
print(' ai ping');
|
||||
print(' ai new <prompt>');
|
||||
print(' ai --new <prompt>');
|
||||
print(' ai lua-exec <prompt> (deprecated)');
|
||||
print(' ai --lua-exec <prompt> (deprecated)');
|
||||
print(' ai sessions');
|
||||
print(' ai --sessions');
|
||||
print(' ai --agent <name> <command>');
|
||||
print(' ai --verbose <command>');
|
||||
print(' ai --version');
|
||||
print(' ai --help');
|
||||
print();
|
||||
print('settings required (one of):');
|
||||
print(' opencc.server_url (direct http opencode URL)');
|
||||
print(' opencc.bridge_url (ws:// mcp-bridge proxy; bypasses CC 60s http cap)');
|
||||
print();
|
||||
print('settings optional:');
|
||||
print(' opencc.username (default: opencode; direct mode only)');
|
||||
print(' opencc.password (Basic Auth password; direct mode only)');
|
||||
print(' opencc.session_id (auto-managed)');
|
||||
print(' opencc.directory (optional session list scope)');
|
||||
print(' opencc.agent (e.g. atm10-expert)');
|
||||
print(' opencc.variant (e.g. low)');
|
||||
print(' opencc.provider_id (e.g. anthropic)');
|
||||
print(' opencc.model_id (e.g. claude-opus-4-7)');
|
||||
print(' opencc.timeout_seconds (per HTTP call, max 60; direct mode)');
|
||||
print(' opencc.request_timeout_seconds (ws reply wait, default/max: 600)');
|
||||
end
|
||||
|
||||
local function printAiLog(message)
|
||||
print('[ai] ' .. tostring(message));
|
||||
end
|
||||
|
||||
local function askOptions()
|
||||
local options = nil;
|
||||
if verbose then
|
||||
options = options or {};
|
||||
options.log = printAiLog;
|
||||
end
|
||||
if agentName then
|
||||
options = options or {};
|
||||
options.agent = agentName;
|
||||
end
|
||||
return options;
|
||||
end
|
||||
|
||||
local function joinArgs(start)
|
||||
local parts = {};
|
||||
for i = start, args.n do
|
||||
parts[#parts + 1] = args[i];
|
||||
end
|
||||
return table.concat(parts, ' ');
|
||||
end
|
||||
|
||||
local function printSessions(ai)
|
||||
local ok, result = ai.listSessions(askOptions());
|
||||
if not ok then
|
||||
print(result);
|
||||
return;
|
||||
end
|
||||
if #result == 0 then
|
||||
print('no sessions');
|
||||
else
|
||||
for _, s in ipairs(result) do
|
||||
print((s.id or '?') .. ' ' .. (s.title or '(untitled)'));
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function askAndPrint(ai, prompt)
|
||||
local ok, result = ai.ask(prompt, askOptions());
|
||||
if not ok then
|
||||
print(result);
|
||||
return;
|
||||
end
|
||||
print(result.reply);
|
||||
end
|
||||
|
||||
local function printLuaExecLog(message)
|
||||
local text = tostring(message or '');
|
||||
if text == '' then
|
||||
print('[lua-exec]');
|
||||
return;
|
||||
end
|
||||
|
||||
local start = 1;
|
||||
while start <= #text do
|
||||
local newline = string.find(text, '\n', start, true);
|
||||
if not newline then
|
||||
print('[lua-exec] ' .. string.sub(text, start));
|
||||
return;
|
||||
end
|
||||
print('[lua-exec] ' .. string.sub(text, start, newline - 1));
|
||||
start = newline + 1;
|
||||
end
|
||||
end
|
||||
|
||||
local function luaExec(ai, prompt)
|
||||
local ok, result = ai.luaExec(prompt, {
|
||||
executor = ai.createLuaExecutor({ live = true }),
|
||||
log = printLuaExecLog,
|
||||
});
|
||||
if not ok then
|
||||
printLuaExecLog('failed after ' .. tostring(result.attempts or 0) .. ' attempt(s)');
|
||||
if result.errorKind then
|
||||
printLuaExecLog('error kind: ' .. tostring(result.errorKind));
|
||||
end
|
||||
printLuaExecLog(tostring(result.error));
|
||||
return;
|
||||
end
|
||||
|
||||
printLuaExecLog('final reply:');
|
||||
print(result.reply);
|
||||
end
|
||||
|
||||
local command = args[1];
|
||||
|
||||
if command == '--version' or command == '-version' or command == 'version' then
|
||||
print('v' .. createVersion().forSelf());
|
||||
return;
|
||||
end
|
||||
|
||||
if command == '--help' or command == '-help' or command == 'help' then
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
|
||||
if parseError then
|
||||
print(parseError);
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
|
||||
if args.n == 0 then
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
|
||||
local ai = createAi();
|
||||
|
||||
if (command == 'sessions' or command == '--sessions') and args.n == 1 then
|
||||
printSessions(ai);
|
||||
return;
|
||||
end
|
||||
|
||||
if command == 'ping' and args.n == 1 then
|
||||
local ok, result = ai.ping(askOptions());
|
||||
if not ok then
|
||||
print(result);
|
||||
return;
|
||||
end
|
||||
print(result.reply);
|
||||
return;
|
||||
end
|
||||
|
||||
if command == 'new' or command == '--new' then
|
||||
local prompt = joinArgs(2);
|
||||
if prompt == '' then
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
ai.clearSession();
|
||||
askAndPrint(ai, prompt);
|
||||
return;
|
||||
end
|
||||
|
||||
if command == 'lua-exec' or command == '--lua-exec' then
|
||||
local prompt = joinArgs(2);
|
||||
if prompt == '' then
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
print('warning: ai lua-exec is deprecated and will be removed');
|
||||
luaExec(ai, prompt);
|
||||
return;
|
||||
end
|
||||
|
||||
if string.sub(command, 1, 1) == '-' then
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
|
||||
askAndPrint(ai, joinArgs(1));
|
||||
@ -1,62 +0,0 @@
|
||||
local createCarre = require('/apis/libcarre');
|
||||
local createVersion = require('/apis/libversion');
|
||||
|
||||
-- Les arguments du programme sont disponibles dans `...`.
|
||||
-- `table.pack` permet de les garder dans une table facile a parcourir.
|
||||
local args = table.pack(...);
|
||||
|
||||
local function printUsage()
|
||||
print('carre usage:');
|
||||
print();
|
||||
print(' carre');
|
||||
print(' carre -size <n> [-x <n>] [-y <n>] [-char <c>] [-fill] [-clear]');
|
||||
print(' carre -random [-count <n>] [-delay <s>] [-clear]');
|
||||
print(' carre --version');
|
||||
print(' carre --help');
|
||||
print();
|
||||
print('exemples:');
|
||||
print(' carre -size 10 -char * -clear');
|
||||
print(' carre -size 6 -fill -x 4 -y 3');
|
||||
print(' carre -random -count 5 -delay 0.2');
|
||||
end
|
||||
|
||||
local command = args[1];
|
||||
|
||||
if command == '-help' or command == '--help' or command == 'help' then
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
|
||||
if command == '-version' or command == '--version' or command == 'version' then
|
||||
print('v' .. createVersion().forSelf());
|
||||
return;
|
||||
end
|
||||
|
||||
local carre = createCarre();
|
||||
local config, err = carre.parseArgs(args);
|
||||
if not config then
|
||||
print(err);
|
||||
print('utilise: carre -help');
|
||||
return;
|
||||
end
|
||||
|
||||
local width, height = term.getSize();
|
||||
|
||||
for index = 1, config.count do
|
||||
if config.clear then
|
||||
term.clear();
|
||||
end
|
||||
|
||||
-- Le calcul choisit une position visible. Si rien n'est donne,
|
||||
-- le carre est centre dans le terminal.
|
||||
local square = carre.computeSquare(config, width, height);
|
||||
|
||||
-- Le mode contour ecrit seulement les bords. Le mode plein ecrit partout.
|
||||
carre.drawSquare(term.current(), square);
|
||||
|
||||
if config.delay > 0 and index < config.count then
|
||||
sleep(config.delay);
|
||||
end
|
||||
end
|
||||
|
||||
term.setCursorPos(1, height);
|
||||
@ -1,233 +0,0 @@
|
||||
local createCcpm = require('/apis/libccpm');
|
||||
local createVersion = require('/apis/libversion');
|
||||
|
||||
local args = table.pack(...);
|
||||
local command = args[1];
|
||||
|
||||
local function printUsage()
|
||||
print('ccpm usage:');
|
||||
print();
|
||||
print('\t\tccpm install <package>');
|
||||
print('\t\tccpm reinstall <package>');
|
||||
print('\t\tccpm uninstall <package>');
|
||||
print('\t\tccpm update');
|
||||
print('\t\tccpm upgrade');
|
||||
print('\t\tccpm ls');
|
||||
print('\t\tccpm available [term]');
|
||||
print('\t\tccpm search [term]');
|
||||
print('\t\tccpm info <package>');
|
||||
print('\t\tccpm registry ls');
|
||||
print('\t\tccpm registry add <name> [--branch <b>] [--type github|gitea|http]');
|
||||
print('\t\tccpm registry rm <name>');
|
||||
print('\t\tccpm version');
|
||||
print('\t\tccpm help');
|
||||
end
|
||||
|
||||
if command == 'version' or command == '-version' or command == '--version' then
|
||||
print('v' .. createVersion().forSelf());
|
||||
return;
|
||||
end
|
||||
|
||||
if command == nil or command == '' or command == 'help' or command == '-help' or command == '--help' then
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
|
||||
local ccpm = createCcpm();
|
||||
|
||||
local function logLine(msg)
|
||||
print(msg);
|
||||
end
|
||||
|
||||
local function printColored(msg, color)
|
||||
if term.isColor and term.isColor() then
|
||||
local previous = term.getTextColor();
|
||||
term.setTextColor(color);
|
||||
print(msg);
|
||||
term.setTextColor(previous);
|
||||
else
|
||||
print(msg);
|
||||
end
|
||||
end
|
||||
|
||||
local function printAvailableRow(r)
|
||||
local installed = '';
|
||||
if r.installedVersion then
|
||||
installed = ' installed v' .. tostring(r.installedVersion);
|
||||
end
|
||||
local line = r.name .. ' v' .. tostring(r.version) .. ' (' .. r.registry .. ') ' .. r.status .. installed;
|
||||
if r.status == 'up-to-date' then
|
||||
printColored(line, colors.lime);
|
||||
elseif r.status == 'updatable' then
|
||||
printColored(line, colors.orange);
|
||||
else
|
||||
print(line);
|
||||
end
|
||||
end
|
||||
|
||||
if command == 'install' or command == 'reinstall' then
|
||||
local pkg = args[2];
|
||||
if not pkg then printUsage(); return; end
|
||||
local ok, result = ccpm.install(pkg, { force = command == 'reinstall', log = logLine });
|
||||
if not ok then
|
||||
print(result);
|
||||
return;
|
||||
end
|
||||
print('=> ' .. pkg .. ' installed.');
|
||||
return;
|
||||
end
|
||||
|
||||
if command == 'update' then
|
||||
local cache = ccpm.update();
|
||||
local count = 0;
|
||||
for _ in pairs(cache.packages or {}) do count = count + 1; end
|
||||
print('=> package cache updated (' .. count .. ' packages).');
|
||||
return;
|
||||
end
|
||||
|
||||
if command == 'upgrade' then
|
||||
local ok, result = ccpm.upgrade({ log = logLine });
|
||||
if not ok then
|
||||
print(result);
|
||||
return;
|
||||
end
|
||||
if #result == 0 then
|
||||
print('=> all packages up-to-date.');
|
||||
else
|
||||
print('=> upgraded: ' .. table.concat(result, ', '));
|
||||
end
|
||||
return;
|
||||
end
|
||||
|
||||
if command == 'uninstall' or command == 'remove' or command == 'rm' then
|
||||
local pkg = args[2];
|
||||
if not pkg then printUsage(); return; end
|
||||
local ok, err = ccpm.uninstall(pkg, { log = logLine });
|
||||
if not ok then
|
||||
print(err);
|
||||
return;
|
||||
end
|
||||
print('=> ' .. pkg .. ' uninstalled.');
|
||||
return;
|
||||
end
|
||||
|
||||
if command == 'ls' or command == 'list' then
|
||||
local packages = ccpm.list();
|
||||
local names = {};
|
||||
for name in pairs(packages) do names[#names + 1] = name; end
|
||||
table.sort(names);
|
||||
if #names == 0 then
|
||||
print('No packages installed.');
|
||||
return;
|
||||
end
|
||||
for _, name in ipairs(names) do
|
||||
print(name .. ' v' .. tostring(packages[name].version or '?'));
|
||||
end
|
||||
return;
|
||||
end
|
||||
|
||||
if command == 'search' then
|
||||
local results = ccpm.search(args[2]);
|
||||
if #results == 0 then
|
||||
print('No packages found.');
|
||||
return;
|
||||
end
|
||||
for _, r in ipairs(results) do
|
||||
print(r.name .. ' v' .. tostring(r.version) .. ' (' .. r.registry .. ')');
|
||||
end
|
||||
return;
|
||||
end
|
||||
|
||||
if command == 'available' then
|
||||
local results = ccpm.available(args[2]);
|
||||
if #results == 0 then
|
||||
print("No packages found. Run 'ccpm update' first if the cache is empty.");
|
||||
return;
|
||||
end
|
||||
for _, r in ipairs(results) do
|
||||
printAvailableRow(r);
|
||||
end
|
||||
return;
|
||||
end
|
||||
|
||||
if command == 'info' then
|
||||
local pkg = args[2];
|
||||
if not pkg then printUsage(); return; end
|
||||
local desc = ccpm.info(pkg);
|
||||
if not desc then
|
||||
print('package not found: ' .. pkg);
|
||||
return;
|
||||
end
|
||||
print(desc.name .. ' v' .. tostring(desc.version or '?'));
|
||||
if desc.description then print(desc.description); end
|
||||
if desc.dependencies and #desc.dependencies > 0 then
|
||||
print('dependencies: ' .. table.concat(desc.dependencies, ', '));
|
||||
end
|
||||
print('files:');
|
||||
for _, f in ipairs(desc.files or {}) do print(' ' .. f); end
|
||||
return;
|
||||
end
|
||||
|
||||
if command == 'registry' then
|
||||
local sub = args[2];
|
||||
|
||||
if sub == nil or sub == 'ls' or sub == 'list' then
|
||||
local registries = ccpm.listRegistries();
|
||||
if #registries == 0 then
|
||||
print('No registries configured.');
|
||||
return;
|
||||
end
|
||||
for _, r in ipairs(registries) do
|
||||
if r.type == 'github' then
|
||||
print(r.name .. ' (github:' .. tostring(r.branch or 'master') .. ')');
|
||||
elseif r.type == 'gitea' then
|
||||
print(r.name .. ' (gitea:' .. tostring(r.branch or 'master') .. ')');
|
||||
else
|
||||
print(r.name .. ' (' .. tostring(r.type or 'http') .. ')');
|
||||
end
|
||||
end
|
||||
return;
|
||||
end
|
||||
|
||||
if sub == 'add' then
|
||||
local name = args[3];
|
||||
if not name then printUsage(); return; end
|
||||
local branch, rtype;
|
||||
local i = 4;
|
||||
while i <= args.n do
|
||||
local a = args[i];
|
||||
if a == '--branch' then
|
||||
branch = args[i + 1];
|
||||
i = i + 1;
|
||||
elseif a == '--type' then
|
||||
rtype = args[i + 1];
|
||||
i = i + 1;
|
||||
end
|
||||
i = i + 1;
|
||||
end
|
||||
local ok, err = ccpm.addRegistry(name, { branch = branch, type = rtype });
|
||||
if not ok then
|
||||
print(err);
|
||||
return;
|
||||
end
|
||||
print('=> registry added: ' .. name);
|
||||
return;
|
||||
end
|
||||
|
||||
if sub == 'rm' or sub == 'remove' then
|
||||
local name = args[3];
|
||||
if not name then printUsage(); return; end
|
||||
local ok, err = ccpm.removeRegistry(name);
|
||||
if not ok then
|
||||
print(err);
|
||||
return;
|
||||
end
|
||||
print('=> registry removed: ' .. name);
|
||||
return;
|
||||
end
|
||||
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
|
||||
printUsage();
|
||||
@ -1,227 +0,0 @@
|
||||
local createVersion = require('/apis/libversion');
|
||||
|
||||
local args = table.pack(...);
|
||||
|
||||
local FACE = {
|
||||
'LlGgLlGg',
|
||||
'lGGllGGL',
|
||||
'GXXggXXG',
|
||||
'gXXGGXXg',
|
||||
'LGGXXGGl',
|
||||
'gGXXXXGg',
|
||||
'LGXggXGL',
|
||||
'gGGLLGGg',
|
||||
};
|
||||
|
||||
local THEMES = {
|
||||
vanilla = {
|
||||
title = 'Creeper',
|
||||
background = colors.black,
|
||||
text = colors.white,
|
||||
palette = {
|
||||
G = colors.green,
|
||||
g = colors.lime,
|
||||
L = colors.lightGray,
|
||||
l = colors.gray,
|
||||
X = colors.black,
|
||||
},
|
||||
},
|
||||
charged = {
|
||||
title = 'Charged Creeper',
|
||||
background = colors.black,
|
||||
text = colors.white,
|
||||
palette = {
|
||||
G = colors.blue,
|
||||
g = colors.cyan,
|
||||
L = colors.lightBlue,
|
||||
l = colors.white,
|
||||
X = colors.black,
|
||||
},
|
||||
},
|
||||
magma = {
|
||||
title = 'Magma Creeper',
|
||||
background = colors.black,
|
||||
text = colors.orange,
|
||||
palette = {
|
||||
G = colors.red,
|
||||
g = colors.orange,
|
||||
L = colors.yellow,
|
||||
l = colors.brown,
|
||||
X = colors.black,
|
||||
},
|
||||
},
|
||||
ocean = {
|
||||
title = 'Ocean Creeper',
|
||||
background = colors.black,
|
||||
text = colors.lightBlue,
|
||||
palette = {
|
||||
G = colors.blue,
|
||||
g = colors.lightBlue,
|
||||
L = colors.cyan,
|
||||
l = colors.blue,
|
||||
X = colors.black,
|
||||
},
|
||||
},
|
||||
sand = {
|
||||
title = 'Sand Creeper',
|
||||
background = colors.black,
|
||||
text = colors.yellow,
|
||||
palette = {
|
||||
G = colors.yellow,
|
||||
g = colors.orange,
|
||||
L = colors.white,
|
||||
l = colors.lightGray,
|
||||
X = colors.brown,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
local THEME_NAMES = { 'vanilla', 'charged', 'magma', 'ocean', 'sand' };
|
||||
|
||||
local function printUsage()
|
||||
print('creeper usage:');
|
||||
print();
|
||||
print(' creeper');
|
||||
print(' creeper <theme>');
|
||||
print(' creeper --theme <theme>');
|
||||
print(' creeper --random');
|
||||
print(' creeper --version');
|
||||
print(' creeper --help');
|
||||
print();
|
||||
print('themes: vanilla, charged, magma, ocean, sand');
|
||||
print();
|
||||
print('examples:');
|
||||
print(' creeper charged');
|
||||
print(' creeper --theme sand');
|
||||
print(' creeper --random');
|
||||
end
|
||||
|
||||
local function drawRun(x, y, len, color)
|
||||
term.setBackgroundColor(color);
|
||||
term.setCursorPos(x, y);
|
||||
term.write(string.rep(' ', len));
|
||||
end
|
||||
|
||||
local function themeNamesText()
|
||||
return table.concat(THEME_NAMES, ', ');
|
||||
end
|
||||
|
||||
local function parseArgs(argv)
|
||||
local selectedTheme = 'vanilla';
|
||||
local selectedExplicitly = false;
|
||||
local randomTheme = false;
|
||||
local index = 1;
|
||||
|
||||
while index <= argv.n do
|
||||
local arg = argv[index];
|
||||
|
||||
if arg == '--random' or arg == '-random' then
|
||||
if selectedExplicitly then
|
||||
return nil, '--random cannot be combined with a theme';
|
||||
end
|
||||
randomTheme = true;
|
||||
elseif arg == '--theme' or arg == '-theme' then
|
||||
if randomTheme or selectedExplicitly then
|
||||
return nil, arg .. ' cannot be combined with another theme option';
|
||||
end
|
||||
index = index + 1;
|
||||
selectedTheme = argv[index];
|
||||
if selectedTheme == nil then
|
||||
return nil, 'missing theme after ' .. arg;
|
||||
end
|
||||
selectedExplicitly = true;
|
||||
elseif THEMES[arg] ~= nil then
|
||||
if randomTheme or selectedExplicitly then
|
||||
return nil, arg .. ' cannot be combined with another theme option';
|
||||
end
|
||||
selectedTheme = arg;
|
||||
selectedExplicitly = true;
|
||||
else
|
||||
return nil, 'unknown option or theme: ' .. tostring(arg);
|
||||
end
|
||||
|
||||
index = index + 1;
|
||||
end
|
||||
|
||||
if randomTheme then
|
||||
math.randomseed(os.epoch('utc'));
|
||||
selectedTheme = THEME_NAMES[math.random(1, #THEME_NAMES)];
|
||||
end
|
||||
|
||||
if THEMES[selectedTheme] == nil then
|
||||
return nil, 'unknown theme: ' .. tostring(selectedTheme)
|
||||
.. ' (available: ' .. themeNamesText() .. ')';
|
||||
end
|
||||
|
||||
return THEMES[selectedTheme];
|
||||
end
|
||||
|
||||
local function drawCreeper(theme)
|
||||
local width, height = term.getSize();
|
||||
local pixelW = math.max(1, math.floor(width / 12));
|
||||
local pixelH = math.max(1, math.floor(height / 12));
|
||||
|
||||
if pixelW < 2 and width >= #FACE[1] * 2 then pixelW = 2; end
|
||||
if pixelW > 4 then pixelW = 4; end
|
||||
if pixelH > 3 then pixelH = 3; end
|
||||
|
||||
local artW = #FACE[1] * pixelW;
|
||||
local artH = #FACE * pixelH;
|
||||
local startX = math.max(1, math.floor((width - artW) / 2) + 1);
|
||||
local startY = math.max(1, math.floor((height - artH) / 2) + 1);
|
||||
|
||||
term.setBackgroundColor(theme.background);
|
||||
term.setTextColor(theme.text);
|
||||
term.clear();
|
||||
|
||||
for row = 1, #FACE do
|
||||
for sy = 0, pixelH - 1 do
|
||||
local y = startY + ((row - 1) * pixelH) + sy;
|
||||
local col = 1;
|
||||
while col <= #FACE[row] do
|
||||
local token = FACE[row]:sub(col, col);
|
||||
local run = 1;
|
||||
|
||||
while col + run <= #FACE[row]
|
||||
and FACE[row]:sub(col + run, col + run) == token do
|
||||
run = run + 1;
|
||||
end
|
||||
|
||||
local color = theme.palette[token] or theme.palette.G;
|
||||
drawRun(startX + ((col - 1) * pixelW), y, run * pixelW, color);
|
||||
col = col + run;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
term.setBackgroundColor(theme.background);
|
||||
term.setTextColor(theme.text);
|
||||
|
||||
if height >= startY + artH then
|
||||
term.setCursorPos(math.max(1, math.floor((width - #theme.title) / 2) + 1), startY + artH + 1);
|
||||
term.write(theme.title);
|
||||
end
|
||||
|
||||
term.setCursorPos(1, height);
|
||||
end
|
||||
|
||||
local command = args[1];
|
||||
|
||||
if command == '-help' or command == '--help' or command == 'help' then
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
|
||||
if command == '-version' or command == '--version' or command == 'version' then
|
||||
print('v' .. createVersion().forSelf());
|
||||
return;
|
||||
end
|
||||
|
||||
local theme, err = parseArgs(args);
|
||||
if not theme then
|
||||
print(err);
|
||||
print('utilise: creeper -help');
|
||||
return;
|
||||
end
|
||||
|
||||
drawCreeper(theme);
|
||||
@ -1,4 +1,4 @@
|
||||
local createVersion = require('/apis/libversion');
|
||||
local _VERSION = '1.0.2';
|
||||
|
||||
local command = ...;
|
||||
|
||||
@ -51,7 +51,7 @@ local function valueToString(value)
|
||||
end
|
||||
|
||||
if command == 'version' or command == '-version' or command == '--version' then
|
||||
print('v' .. createVersion().forSelf());
|
||||
print('events v' .. _VERSION);
|
||||
return;
|
||||
end
|
||||
|
||||
|
||||
@ -1,115 +0,0 @@
|
||||
local createMcpComputer = require('/apis/libmcpcomputer');
|
||||
local createVersion = require('/apis/libversion');
|
||||
|
||||
local args = table.pack(...);
|
||||
local command = args[1];
|
||||
|
||||
local function printUsage()
|
||||
print('mcp-computer usage:');
|
||||
print();
|
||||
print(' mcp-computer <ws-url>');
|
||||
print(' mcp-computer -url <ws-url>');
|
||||
print(' mcp-computer --version');
|
||||
print(' mcp-computer --help');
|
||||
print();
|
||||
print('examples:');
|
||||
print(' mcp-computer ws://192.168.1.20:3001');
|
||||
print(' mcp-computer -url ws://mcp-bridge.local:3001');
|
||||
print();
|
||||
print('with no URL, falls back to the mcp-computer.ws-url setting.');
|
||||
end
|
||||
|
||||
local function fail(message)
|
||||
print(message);
|
||||
error('mcp-computer failed', 0);
|
||||
end
|
||||
|
||||
local function decodeJson(text)
|
||||
return textutils.unserializeJSON(text);
|
||||
end
|
||||
|
||||
local function sendJson(ws, value)
|
||||
ws.send(textutils.serializeJSON(value));
|
||||
end
|
||||
|
||||
local function waitForHelloOk(ws)
|
||||
local message = ws.receive(5);
|
||||
if not message then
|
||||
return false, 'timed out waiting for hello-ok';
|
||||
end
|
||||
|
||||
local frame = decodeJson(message);
|
||||
if type(frame) ~= 'table' or frame.type ~= 'hello-ok' then
|
||||
return false, 'unexpected hello response';
|
||||
end
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
if command == '-help' or command == '--help' or command == 'help' then
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
|
||||
if command == '-version' or command == '--version' or command == 'version' then
|
||||
print('v' .. createVersion().forSelf());
|
||||
return;
|
||||
end
|
||||
|
||||
local mcpComputer = createMcpComputer();
|
||||
local config, err = mcpComputer.resolveUrl(args, settings);
|
||||
if not config then
|
||||
print(err);
|
||||
print('use: mcp-computer -help');
|
||||
return;
|
||||
end
|
||||
|
||||
if not http then
|
||||
fail('CC:Tweaked HTTP/WebSocket is unavailable: enable the http API.');
|
||||
end
|
||||
|
||||
if not http.websocket then
|
||||
fail('CC:Tweaked WebSocket is unavailable: enable HTTP WebSocket support.');
|
||||
end
|
||||
|
||||
local version = createVersion().forSelf();
|
||||
print('mcp-computer v' .. version .. ' connecting to ' .. config.url);
|
||||
|
||||
local ws, connectErr = http.websocket(config.url);
|
||||
if not ws then
|
||||
fail('websocket failed: ' .. tostring(connectErr));
|
||||
end
|
||||
|
||||
local ok, runtimeErr = pcall(function()
|
||||
sendJson(ws, mcpComputer.hello(os));
|
||||
|
||||
local helloOk, helloErr = waitForHelloOk(ws);
|
||||
if not helloOk then
|
||||
error(helloErr, 0);
|
||||
end
|
||||
|
||||
print('linked as ' .. tostring(os.getComputerID())
|
||||
.. ' (Label: ' .. mcpComputer.formatLabel(os.getComputerLabel()) .. ')');
|
||||
print('waiting for requests... Press Ctrl+T to stop.');
|
||||
|
||||
while true do
|
||||
local message = ws.receive();
|
||||
if not message then return; end
|
||||
|
||||
local frame = decodeJson(message);
|
||||
local response = mcpComputer.handleRequest(frame, os);
|
||||
if response then
|
||||
sendJson(ws, response);
|
||||
end
|
||||
end
|
||||
end);
|
||||
|
||||
pcall(function() ws.close(); end);
|
||||
|
||||
if not ok then
|
||||
if tostring(runtimeErr) == 'Terminated' then
|
||||
print('stopped.');
|
||||
return;
|
||||
end
|
||||
fail(tostring(runtimeErr));
|
||||
end
|
||||
@ -1,252 +0,0 @@
|
||||
local createVersion = require('/apis/libversion');
|
||||
|
||||
local args = table.pack(...);
|
||||
|
||||
-- Tete de mouton style Minecraft: un carre de laine avec un visage carre au centre.
|
||||
-- Les lettres sont des pixels de couleur. Le point est le fond.
|
||||
local SHEEP = {
|
||||
'..WWWWWWWWWWWW..',
|
||||
'.WWwwWWwwWWwwWW.',
|
||||
'WWwwWWwwWWwwWWww',
|
||||
'Wwwwwwwwwwwwwwww',
|
||||
'WWwwKKKKKKKKwwWW',
|
||||
'WwwwKkkkkkkKwwww',
|
||||
'WWwwKfkkkkfKwwWW',
|
||||
'WwwwKfkkkkfKwwww',
|
||||
'WWwwKkkMMkkKwwWW',
|
||||
'WwwwKkkmmkkKwwww',
|
||||
'WWwwKkkkkkkKwwWW',
|
||||
'WwwwKKKKKKKKwwww',
|
||||
'WWwwwwwwwwwwwwWW',
|
||||
'WwWWwwWWwwWWwwWw',
|
||||
'.WWwwWWwwWWwwWW.',
|
||||
'..WWWWWWWWWWWW..',
|
||||
};
|
||||
|
||||
local THEMES = {
|
||||
classic = {
|
||||
title = 'Mouton Minecraft',
|
||||
background = colors.black,
|
||||
text = colors.white,
|
||||
palette = {
|
||||
['.'] = colors.black,
|
||||
W = colors.white,
|
||||
w = colors.lightGray,
|
||||
K = colors.gray,
|
||||
k = colors.lightGray,
|
||||
f = colors.black,
|
||||
M = colors.gray,
|
||||
m = colors.black,
|
||||
},
|
||||
},
|
||||
rose = {
|
||||
title = 'Mouton Rose',
|
||||
background = colors.black,
|
||||
text = colors.pink,
|
||||
palette = {
|
||||
['.'] = colors.black,
|
||||
W = colors.pink,
|
||||
w = colors.magenta,
|
||||
K = colors.white,
|
||||
k = colors.lightGray,
|
||||
f = colors.black,
|
||||
M = colors.pink,
|
||||
m = colors.black,
|
||||
},
|
||||
},
|
||||
nuage = {
|
||||
title = 'Mouton Nuage',
|
||||
background = colors.lightBlue,
|
||||
text = colors.white,
|
||||
palette = {
|
||||
['.'] = colors.lightBlue,
|
||||
W = colors.white,
|
||||
w = colors.lightGray,
|
||||
K = colors.gray,
|
||||
k = colors.white,
|
||||
f = colors.black,
|
||||
M = colors.lightGray,
|
||||
m = colors.black,
|
||||
},
|
||||
},
|
||||
arcenciel = {
|
||||
title = 'Mouton Arc-en-ciel',
|
||||
background = colors.black,
|
||||
text = colors.yellow,
|
||||
rainbow = true,
|
||||
palette = {
|
||||
['.'] = colors.black,
|
||||
K = colors.white,
|
||||
k = colors.lightGray,
|
||||
f = colors.black,
|
||||
M = colors.pink,
|
||||
m = colors.black,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
local THEME_NAMES = { 'classic', 'rose', 'nuage', 'arcenciel' };
|
||||
local RAINBOW = {
|
||||
colors.red,
|
||||
colors.orange,
|
||||
colors.yellow,
|
||||
colors.lime,
|
||||
colors.cyan,
|
||||
colors.blue,
|
||||
colors.purple,
|
||||
colors.magenta,
|
||||
};
|
||||
|
||||
local function printUsage()
|
||||
print('mouton usage:');
|
||||
print();
|
||||
print(' mouton');
|
||||
print(' mouton <theme>');
|
||||
print(' mouton --theme <theme>');
|
||||
print(' mouton --random');
|
||||
print(' mouton --version');
|
||||
print(' mouton --help');
|
||||
print();
|
||||
print('themes: classic, rose, nuage, arcenciel');
|
||||
end
|
||||
|
||||
local function drawRun(x, y, len, color)
|
||||
term.setBackgroundColor(color);
|
||||
term.setCursorPos(x, y);
|
||||
term.write(string.rep(' ', len));
|
||||
end
|
||||
|
||||
local function themeNamesText()
|
||||
return table.concat(THEME_NAMES, ', ');
|
||||
end
|
||||
|
||||
local function parseArgs(argv)
|
||||
local selectedTheme = 'classic';
|
||||
local selectedExplicitly = false;
|
||||
local randomTheme = false;
|
||||
local index = 1;
|
||||
|
||||
while index <= argv.n do
|
||||
local arg = argv[index];
|
||||
|
||||
if arg == '--random' or arg == '-random' then
|
||||
if selectedExplicitly then
|
||||
return nil, '--random ne peut pas etre combine avec un theme';
|
||||
end
|
||||
randomTheme = true;
|
||||
elseif arg == '--theme' or arg == '-theme' then
|
||||
if randomTheme or selectedExplicitly then
|
||||
return nil, arg .. ' ne peut pas etre combine avec un autre theme';
|
||||
end
|
||||
index = index + 1;
|
||||
selectedTheme = argv[index];
|
||||
if selectedTheme == nil then
|
||||
return nil, 'theme manquant apres ' .. arg;
|
||||
end
|
||||
selectedExplicitly = true;
|
||||
elseif THEMES[arg] ~= nil then
|
||||
if randomTheme or selectedExplicitly then
|
||||
return nil, arg .. ' ne peut pas etre combine avec un autre theme';
|
||||
end
|
||||
selectedTheme = arg;
|
||||
selectedExplicitly = true;
|
||||
else
|
||||
return nil, 'option ou theme inconnu: ' .. tostring(arg);
|
||||
end
|
||||
|
||||
index = index + 1;
|
||||
end
|
||||
|
||||
if randomTheme then
|
||||
math.randomseed(os.epoch('utc'));
|
||||
selectedTheme = THEME_NAMES[math.random(1, #THEME_NAMES)];
|
||||
end
|
||||
|
||||
if THEMES[selectedTheme] == nil then
|
||||
return nil, 'theme inconnu: ' .. tostring(selectedTheme)
|
||||
.. ' (disponibles: ' .. themeNamesText() .. ')';
|
||||
end
|
||||
|
||||
return THEMES[selectedTheme];
|
||||
end
|
||||
|
||||
local function tokenColor(theme, token, row, col)
|
||||
if theme.rainbow and (token == 'W' or token == 'w') then
|
||||
-- Pour le mouton arc-en-ciel, seules les boucles de laine changent de couleur.
|
||||
return RAINBOW[((row + col) % #RAINBOW) + 1];
|
||||
end
|
||||
|
||||
return theme.palette[token] or theme.background;
|
||||
end
|
||||
|
||||
local function drawSheep(theme)
|
||||
local width, height = term.getSize();
|
||||
local pixelW = math.max(1, math.floor(width / 20));
|
||||
local pixelH = math.max(1, math.floor(height / 20));
|
||||
|
||||
if pixelW < 2 and width >= #SHEEP[1] * 2 then pixelW = 2; end
|
||||
if pixelW > 4 then pixelW = 4; end
|
||||
if pixelH > 2 then pixelH = 2; end
|
||||
|
||||
local artW = #SHEEP[1] * pixelW;
|
||||
local artH = #SHEEP * pixelH;
|
||||
local startX = math.max(1, math.floor((width - artW) / 2) + 1);
|
||||
local startY = math.max(1, math.floor((height - artH) / 2) + 1);
|
||||
|
||||
term.setBackgroundColor(theme.background);
|
||||
term.setTextColor(theme.text);
|
||||
term.clear();
|
||||
|
||||
for row = 1, #SHEEP do
|
||||
for sy = 0, pixelH - 1 do
|
||||
local y = startY + ((row - 1) * pixelH) + sy;
|
||||
local col = 1;
|
||||
|
||||
while col <= #SHEEP[row] do
|
||||
local token = SHEEP[row]:sub(col, col);
|
||||
local run = 1;
|
||||
local color = tokenColor(theme, token, row, col);
|
||||
|
||||
while col + run <= #SHEEP[row]
|
||||
and SHEEP[row]:sub(col + run, col + run) == token
|
||||
and tokenColor(theme, token, row, col + run) == color do
|
||||
run = run + 1;
|
||||
end
|
||||
|
||||
drawRun(startX + ((col - 1) * pixelW), y, run * pixelW, color);
|
||||
col = col + run;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
term.setBackgroundColor(theme.background);
|
||||
term.setTextColor(theme.text);
|
||||
|
||||
if height >= startY + artH then
|
||||
term.setCursorPos(math.max(1, math.floor((width - #theme.title) / 2) + 1), startY + artH + 1);
|
||||
term.write(theme.title);
|
||||
end
|
||||
|
||||
term.setCursorPos(1, height);
|
||||
end
|
||||
|
||||
local command = args[1];
|
||||
|
||||
if command == '-help' or command == '--help' or command == 'help' then
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
|
||||
if command == '-version' or command == '--version' or command == 'version' then
|
||||
print('v' .. createVersion().forSelf());
|
||||
return;
|
||||
end
|
||||
|
||||
local theme, err = parseArgs(args);
|
||||
if not theme then
|
||||
print(err);
|
||||
print('utilise: mouton -help');
|
||||
return;
|
||||
end
|
||||
|
||||
drawSheep(theme);
|
||||
@ -1,13 +1,12 @@
|
||||
local _VERSION = '2.0.2';
|
||||
|
||||
local firstArg = ...;
|
||||
if firstArg == '-version' or firstArg == '--version' then
|
||||
print('v' .. require('/apis/libversion')().forSelf());
|
||||
print('v' .. _VERSION);
|
||||
return;
|
||||
end
|
||||
|
||||
if firstArg == '-help' or firstArg == '--help' then
|
||||
print('Usage: ping [<id|label>]');
|
||||
return;
|
||||
end
|
||||
local PING_CHANNEL = 9;
|
||||
|
||||
local createNet = require('/apis/net');
|
||||
local net = createNet();
|
||||
@ -15,21 +14,29 @@ local net = createNet();
|
||||
local args = table.pack(...);
|
||||
local targetComputerId = tonumber(args[1]) or args[1];
|
||||
|
||||
local sourceId = os.getComputerID();
|
||||
local sourceId = os.getComputerID()
|
||||
local sourceLabel = os.getComputerLabel();
|
||||
|
||||
local ok, results, packets = net.callMultiple('ping', 'ping', { destId = targetComputerId });
|
||||
|
||||
if not ok and targetComputerId ~= sourceId and targetComputerId ~= sourceLabel then
|
||||
error(results);
|
||||
-- envoyer un message sur le canal 9 à la machine cible
|
||||
|
||||
local ok, results, packets = net.sendMultipleRequests(PING_CHANNEL, 'ping', 'ping', targetComputerId);
|
||||
|
||||
if not ok and (targetComputerId ~= sourceId and targetComputerId ~= sourceLabel) then
|
||||
error(results)
|
||||
end
|
||||
|
||||
if not ok then return end
|
||||
if not ok then
|
||||
return;
|
||||
end
|
||||
|
||||
for k, message in ipairs(results) do
|
||||
if message == 'pong' then
|
||||
local packet = packets[k];
|
||||
|
||||
-- if targetComputerId == nil or targetComputerId == packet.sourceId or targetComputerId == packet.sourceLabel then
|
||||
print("=> pong from " .. tostring(packet.sourceId)
|
||||
.. (packet.sourceLabel and " (label=" .. tostring(packet.sourceLabel) .. ")" or ""));
|
||||
-- end
|
||||
end
|
||||
end
|
||||
|
||||
@ -1,69 +1,58 @@
|
||||
local _VERSION = '1.3.1';
|
||||
|
||||
local firstArg = ...;
|
||||
|
||||
if firstArg == '-version' or firstArg == '--version' then
|
||||
print('v' .. require('/apis/libversion')().forSelf());
|
||||
print('v' .. _VERSION);
|
||||
return;
|
||||
end
|
||||
|
||||
if firstArg == '-help' or firstArg == '--help' then
|
||||
print('Usage: router [-silent|--silent]');
|
||||
print('Enables routing on this machine. Registers handlers on the boot eventloop.');
|
||||
return;
|
||||
local printVerbose = print
|
||||
|
||||
if firstArg == '-silent' or firstArg == '--silent' then
|
||||
printVerbose = function() end
|
||||
end
|
||||
|
||||
local silent = (firstArg == '-silent' or firstArg == '--silent');
|
||||
local printVerbose = silent and function() end or print;
|
||||
local ROUTER_CHANNEL = 10;
|
||||
|
||||
local createEventLoop = require('/apis/eventloop');
|
||||
local createNet = require('/apis/net');
|
||||
local createRouter = require('/apis/librouter');
|
||||
local modem = peripheral.find("modem") or error("modem not found");
|
||||
modem.open(ROUTER_CHANNEL);
|
||||
|
||||
local ownsLoop = false;
|
||||
if not _G.bootEventLoop then
|
||||
_G.bootEventLoop = createEventLoop();
|
||||
ownsLoop = true;
|
||||
end
|
||||
printVerbose('started router on port ' .. tostring(ROUTER_CHANNEL) .. '...')
|
||||
|
||||
local net = createNet();
|
||||
net.setRouter(true);
|
||||
local routerId = os.getComputerID();
|
||||
|
||||
local router = createRouter();
|
||||
_G.isRouterEnabled = true;
|
||||
|
||||
net.listen('router.register', function(payload, packet)
|
||||
if type(payload) ~= 'table' or type(payload.label) ~= 'string' then return end
|
||||
local ok = router.register(payload.label, packet.sourceId);
|
||||
if ok then
|
||||
printVerbose("router: registered '" .. payload.label .. "' -> " .. tostring(packet.sourceId));
|
||||
else
|
||||
printVerbose("router: duplicate label '" .. payload.label .. "' from id " .. tostring(packet.sourceId));
|
||||
end
|
||||
end);
|
||||
while true do
|
||||
local channel, replyChannel, payload, distance;
|
||||
|
||||
net.onUnrouted(function(packet)
|
||||
if type(packet.destId) == 'string' then
|
||||
local id = router.resolve(packet.destId);
|
||||
if id then
|
||||
packet.destId = id;
|
||||
else
|
||||
printVerbose("router: unknown label '" .. tostring(packet.destId) .. "' (dropping)");
|
||||
return;
|
||||
repeat
|
||||
_, _, channel, replyChannel, payload, distance = os.pullEvent("modem_message");
|
||||
|
||||
local channelOk = channel == ROUTER_CHANNEL;
|
||||
local payloadOk = type(payload) == 'table' and not payload.routerId;
|
||||
local loopFinished = channelOk and payloadOk;
|
||||
until loopFinished
|
||||
|
||||
|
||||
if payload and not payload.routerId then
|
||||
payload.routerId = routerId;
|
||||
|
||||
if payload.destId == nil or payload.destId == os.getComputerID() or payload.destId == os.getComputerLabel() then
|
||||
os.queueEvent('modem_message', peripheral.getName(modem), replyChannel, replyChannel, payload, distance);
|
||||
end
|
||||
if payload.destId ~= os.getComputerID() then
|
||||
modem.transmit(replyChannel, replyChannel, payload);
|
||||
end
|
||||
end
|
||||
|
||||
if packet.destId then
|
||||
printVerbose("router: " .. tostring(packet.sourceId) .. " -> " .. tostring(packet.destId)
|
||||
.. " [" .. tostring(packet.service) .. "/" .. tostring(packet.kind) .. "]");
|
||||
if payload.destId then
|
||||
printVerbose("Routed message from " .. tostring(payload.sourceId)
|
||||
.. " to " .. tostring(payload.destId)
|
||||
.. " using channel " .. tostring(replyChannel));
|
||||
else
|
||||
printVerbose("router: " .. tostring(packet.sourceId) .. " broadcast"
|
||||
.. " [" .. tostring(packet.service) .. "/" .. tostring(packet.kind) .. "]");
|
||||
printVerbose("Broadcasted message from " .. tostring(payload.sourceId)
|
||||
.. " using channel " .. tostring(replyChannel));
|
||||
end
|
||||
|
||||
net.rebroadcast(packet);
|
||||
end);
|
||||
|
||||
printVerbose('router v' .. require('/apis/libversion')().forSelf()
|
||||
.. ' started on bus channel ' .. tostring(net.BUS_CHANNEL));
|
||||
|
||||
if ownsLoop then
|
||||
_G.bootEventLoop.startLoop();
|
||||
end
|
||||
|
||||
@ -1,222 +0,0 @@
|
||||
local createVersion = require("/apis/libversion")
|
||||
|
||||
local SUCCESS_MARKER = "__TRAPOS_TEST_OK__"
|
||||
local DEFAULT_REPORT_PATH = "/trapos-test-report"
|
||||
|
||||
local function printUsage()
|
||||
print("runtest usage:")
|
||||
print()
|
||||
print("\t\truntest [--pretty] [--verbose] [--output <path>] [--timeout <seconds>] [--no-timeout] [--shutdown] [test ...]")
|
||||
print("\t\truntest --version")
|
||||
print("\t\truntest --help")
|
||||
end
|
||||
|
||||
local function parseArgs(args)
|
||||
local opts = {
|
||||
pretty = false,
|
||||
verbose = false,
|
||||
shutdown = false,
|
||||
outputPath = nil,
|
||||
timeout = nil,
|
||||
noTimeout = false,
|
||||
tests = {},
|
||||
}
|
||||
|
||||
local i = 1
|
||||
while i <= #args do
|
||||
local arg = args[i]
|
||||
if arg == "--version" or arg == "-version" then
|
||||
print("v" .. createVersion().forSelf())
|
||||
return nil
|
||||
elseif arg == "--help" or arg == "-help" then
|
||||
printUsage()
|
||||
return nil
|
||||
elseif arg == "--pretty" then
|
||||
opts.pretty = true
|
||||
elseif arg == "--verbose" or arg == "-v" then
|
||||
opts.pretty = true
|
||||
opts.verbose = true
|
||||
elseif arg == "--output" then
|
||||
opts.outputPath = args[i + 1]
|
||||
i = i + 1
|
||||
elseif arg == "--timeout" then
|
||||
opts.timeout = args[i + 1]
|
||||
i = i + 1
|
||||
elseif arg == "--no-timeout" then
|
||||
opts.noTimeout = true
|
||||
elseif arg == "--shutdown" then
|
||||
opts.shutdown = true
|
||||
elseif string.sub(arg, 1, 1) == "-" then
|
||||
print("Unknown option: " .. arg)
|
||||
printUsage()
|
||||
return nil
|
||||
else
|
||||
opts.tests[#opts.tests + 1] = arg
|
||||
end
|
||||
i = i + 1
|
||||
end
|
||||
|
||||
return opts
|
||||
end
|
||||
|
||||
local function normalizeTestPath(path)
|
||||
if string.sub(path, 1, 1) == "/" then
|
||||
return path
|
||||
end
|
||||
return "/" .. path
|
||||
end
|
||||
|
||||
local function discoverTests()
|
||||
local tests = {}
|
||||
if not fs.exists("/tests") then
|
||||
return tests
|
||||
end
|
||||
|
||||
for _, name in ipairs(fs.list("/tests")) do
|
||||
local path = "/tests/" .. name
|
||||
if not fs.isDir(path) and string.sub(name, -4) == ".lua" then
|
||||
tests[#tests + 1] = path
|
||||
end
|
||||
end
|
||||
table.sort(tests)
|
||||
return tests
|
||||
end
|
||||
|
||||
local function readLines(path)
|
||||
local lines = {}
|
||||
local file = fs.open(path, "r")
|
||||
if not file then
|
||||
return lines
|
||||
end
|
||||
|
||||
while true do
|
||||
local line = file.readLine()
|
||||
if line == nil then
|
||||
break
|
||||
end
|
||||
lines[#lines + 1] = line
|
||||
end
|
||||
file.close()
|
||||
return lines
|
||||
end
|
||||
|
||||
local function createEmitter(outputPath)
|
||||
local file = nil
|
||||
if outputPath then
|
||||
file = fs.open(outputPath, "w")
|
||||
end
|
||||
|
||||
local function emit(line)
|
||||
if file then
|
||||
file.writeLine(line)
|
||||
else
|
||||
print(line)
|
||||
end
|
||||
end
|
||||
|
||||
local function close()
|
||||
if file then
|
||||
file.close()
|
||||
end
|
||||
end
|
||||
|
||||
return emit, close
|
||||
end
|
||||
|
||||
local function renderReport(emit, script, reportLines, ok, verbose, color)
|
||||
local green = color and string.char(27) .. "[32m" or ""
|
||||
local red = color and string.char(27) .. "[31m" or ""
|
||||
local dim = color and string.char(27) .. "[2m" or ""
|
||||
local reset = color and string.char(27) .. "[0m" or ""
|
||||
local displayScript = string.sub(script, 1, 1) == "/" and string.sub(script, 2) or script
|
||||
|
||||
emit(displayScript)
|
||||
if verbose then
|
||||
emit(" " .. dim .. "report: " .. DEFAULT_REPORT_PATH .. reset)
|
||||
end
|
||||
|
||||
if #reportLines == 0 then
|
||||
if ok then
|
||||
emit(" " .. green .. "[OK]" .. reset .. " script completed")
|
||||
else
|
||||
emit(" " .. red .. "[KO]" .. reset .. " script failed before reporting a case")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
for _, line in ipairs(reportLines) do
|
||||
if string.sub(line, 1, 3) == "OK " then
|
||||
emit(" " .. green .. "[OK]" .. reset .. " " .. string.sub(line, 4))
|
||||
elseif string.sub(line, 1, 3) == "KO " then
|
||||
emit(" " .. red .. "[KO]" .. reset .. " " .. string.sub(line, 4))
|
||||
elseif verbose and string.sub(line, 1, 4) == "LOG " then
|
||||
emit(" " .. dim .. "[log] " .. string.sub(line, 5) .. reset)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local opts = parseArgs({ ... })
|
||||
if not opts then
|
||||
return
|
||||
end
|
||||
|
||||
local tests = opts.tests
|
||||
if #tests == 0 then
|
||||
tests = discoverTests()
|
||||
else
|
||||
for i, path in ipairs(tests) do
|
||||
tests[i] = normalizeTestPath(path)
|
||||
end
|
||||
end
|
||||
|
||||
if #tests == 0 then
|
||||
print("FAIL: no tests found")
|
||||
if opts.shutdown then
|
||||
os.shutdown()
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local emit, closeOutput = createEmitter(opts.outputPath)
|
||||
local suiteOk = true
|
||||
|
||||
for _, script in ipairs(tests) do
|
||||
fs.delete(DEFAULT_REPORT_PATH)
|
||||
|
||||
local scriptArgs = { "--no-marker", "--report", DEFAULT_REPORT_PATH }
|
||||
if opts.verbose then
|
||||
scriptArgs[#scriptArgs + 1] = "--verbose"
|
||||
elseif opts.pretty then
|
||||
scriptArgs[#scriptArgs + 1] = "--pretty"
|
||||
end
|
||||
if opts.noTimeout then
|
||||
scriptArgs[#scriptArgs + 1] = "--no-timeout"
|
||||
elseif opts.timeout then
|
||||
scriptArgs[#scriptArgs + 1] = "--timeout"
|
||||
scriptArgs[#scriptArgs + 1] = opts.timeout
|
||||
end
|
||||
|
||||
local ok = shell.run(script, table.unpack(scriptArgs))
|
||||
local reportLines = readLines(DEFAULT_REPORT_PATH)
|
||||
|
||||
if opts.pretty then
|
||||
renderReport(emit, script, reportLines, ok, opts.verbose, opts.outputPath ~= nil)
|
||||
end
|
||||
|
||||
if not ok then
|
||||
suiteOk = false
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
closeOutput()
|
||||
|
||||
if suiteOk then
|
||||
print(SUCCESS_MARKER)
|
||||
else
|
||||
print("FAIL: CraftOS integration tests failed")
|
||||
end
|
||||
|
||||
if opts.shutdown then
|
||||
os.shutdown()
|
||||
end
|
||||
@ -1,54 +0,0 @@
|
||||
local createTrapGpt = require('/apis/libtrapgpt');
|
||||
local createVersion = require('/apis/libversion');
|
||||
|
||||
local args = table.pack(...);
|
||||
|
||||
local function printUsage()
|
||||
print('trapgpt usage:');
|
||||
print();
|
||||
print(' trapgpt');
|
||||
print(' trapgpt --version');
|
||||
print(' trapgpt --help');
|
||||
print();
|
||||
print('settings required:');
|
||||
print(' opencc.server_url');
|
||||
print();
|
||||
print('settings optional:');
|
||||
print(' trapgpt.throttle_seconds (default: 5)');
|
||||
print(' trapgpt.max_reply_chars (default: 160)');
|
||||
print(' trapgpt.prefix (default: TrapGPT)');
|
||||
end
|
||||
|
||||
local command = args[1];
|
||||
|
||||
if command == '--version' or command == '-version' or command == 'version' then
|
||||
print('v' .. createVersion().forSelf());
|
||||
return;
|
||||
end
|
||||
|
||||
if command == '--help' or command == '-help' or command == 'help' then
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
|
||||
if args.n > 0 then
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
|
||||
local chatBox = peripheral.find('chat_box') or peripheral.find('chatBox');
|
||||
if not chatBox then
|
||||
error('chat_box peripheral not found');
|
||||
end
|
||||
|
||||
local trapgpt = createTrapGpt({ chatBox = chatBox });
|
||||
|
||||
local function listenChat()
|
||||
while true do
|
||||
local _, username, message, uuid, isHidden, messageUtf8 = os.pullEvent('chat');
|
||||
trapgpt.onChat(username, message, uuid, isHidden, messageUtf8);
|
||||
end
|
||||
end
|
||||
|
||||
print('trapgpt listening');
|
||||
parallel.waitForAny(listenChat, function() trapgpt.run(); end);
|
||||
@ -1,211 +0,0 @@
|
||||
local createVersion = require('/apis/libversion');
|
||||
|
||||
local command = ...;
|
||||
|
||||
local function printUsage()
|
||||
print('tuidemo usage:');
|
||||
print();
|
||||
print('\t\t\ttuidemo');
|
||||
print('\t\t\ttuidemo version');
|
||||
print('\t\t\ttuidemo help');
|
||||
end
|
||||
|
||||
if command == 'version' or command == '-version' or command == '--version' then
|
||||
print('v' .. createVersion().forSelf());
|
||||
return;
|
||||
end
|
||||
|
||||
if command == 'help' or command == '-help' or command == '--help' then
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
|
||||
if command ~= nil and command ~= '' then
|
||||
printUsage();
|
||||
return;
|
||||
end
|
||||
|
||||
local createEventLoop = require('/apis/eventloop');
|
||||
local createTui = require('/apis/libtui');
|
||||
|
||||
local eventloop = createEventLoop();
|
||||
local ui = createTui(eventloop);
|
||||
|
||||
local Text = ui.Text;
|
||||
local Button = ui.Button;
|
||||
local Box = ui.Box;
|
||||
local List = ui.List;
|
||||
|
||||
local page = 1;
|
||||
local pageCount = 3;
|
||||
|
||||
local function previousPage()
|
||||
page = page - 1;
|
||||
if page < 1 then
|
||||
page = pageCount;
|
||||
end
|
||||
ui.rerender();
|
||||
end
|
||||
|
||||
local function nextPage()
|
||||
page = page + 1;
|
||||
if page > pageCount then
|
||||
page = 1;
|
||||
end
|
||||
ui.rerender();
|
||||
end
|
||||
|
||||
local function Header()
|
||||
return Box({
|
||||
direction = 'row',
|
||||
bgColor = colors.gray,
|
||||
children = {
|
||||
Text('Trap UI Demo', {
|
||||
flex = 1,
|
||||
color = colors.white,
|
||||
bgColor = colors.gray,
|
||||
}),
|
||||
Button('X', {
|
||||
color = colors.white,
|
||||
bgColor = colors.red,
|
||||
onClick = function(tui)
|
||||
tui.exitUI('closed');
|
||||
end,
|
||||
}),
|
||||
},
|
||||
});
|
||||
end
|
||||
|
||||
local function PageText()
|
||||
return List({
|
||||
gap = 1,
|
||||
padding = 1,
|
||||
children = {
|
||||
Text('Text components render strings inside their assigned rectangle.'),
|
||||
Text('This line uses pink background and black foreground.', {
|
||||
color = colors.black,
|
||||
bgColor = colors.pink,
|
||||
}),
|
||||
Text('Resize the terminal to force a redraw.'),
|
||||
},
|
||||
});
|
||||
end
|
||||
|
||||
local function PageLayout()
|
||||
return Box({
|
||||
direction = 'row',
|
||||
gap = 1,
|
||||
padding = 1,
|
||||
children = {
|
||||
Box({
|
||||
flex = 1,
|
||||
border = true,
|
||||
title = 'Left',
|
||||
children = {
|
||||
Text('flex = 1'),
|
||||
},
|
||||
}),
|
||||
Box({
|
||||
width = 18,
|
||||
border = true,
|
||||
title = 'Fixed',
|
||||
children = {
|
||||
Text('width = 18'),
|
||||
},
|
||||
}),
|
||||
Box({
|
||||
flex = 2,
|
||||
border = true,
|
||||
title = 'Right',
|
||||
children = {
|
||||
Text('flex = 2'),
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
end
|
||||
|
||||
local function PageButtons()
|
||||
return List({
|
||||
gap = 1,
|
||||
padding = 1,
|
||||
children = {
|
||||
Text('Buttons are clickable hitboxes.'),
|
||||
Button('Click me to go next', {
|
||||
color = colors.black,
|
||||
bgColor = colors.lime,
|
||||
onClick = function()
|
||||
nextPage();
|
||||
end,
|
||||
}),
|
||||
Button('Exit demo', {
|
||||
color = colors.white,
|
||||
bgColor = colors.red,
|
||||
onClick = function(tui)
|
||||
tui.exitUI('button');
|
||||
end,
|
||||
}),
|
||||
},
|
||||
});
|
||||
end
|
||||
|
||||
local function CurrentPage()
|
||||
if page == 1 then
|
||||
return PageText();
|
||||
end
|
||||
|
||||
if page == 2 then
|
||||
return PageLayout();
|
||||
end
|
||||
|
||||
return PageButtons();
|
||||
end
|
||||
|
||||
local function Footer()
|
||||
return Box({
|
||||
direction = 'row',
|
||||
gap = 1,
|
||||
children = {
|
||||
Button('Previous', {
|
||||
onClick = function()
|
||||
previousPage();
|
||||
end,
|
||||
}),
|
||||
Text('Page ' .. page .. '/' .. pageCount, { flex = 1 }),
|
||||
Button('Next', {
|
||||
onClick = function()
|
||||
nextPage();
|
||||
end,
|
||||
}),
|
||||
},
|
||||
});
|
||||
end
|
||||
|
||||
local function App()
|
||||
return Box({
|
||||
direction = 'column',
|
||||
children = {
|
||||
Header(),
|
||||
Box({
|
||||
flex = 1,
|
||||
border = true,
|
||||
title = 'Demo page ' .. page,
|
||||
children = {
|
||||
CurrentPage(),
|
||||
},
|
||||
}),
|
||||
Footer(),
|
||||
},
|
||||
});
|
||||
end
|
||||
|
||||
local finalEvent = ui.render(App);
|
||||
|
||||
if finalEvent.type == 'terminate' then
|
||||
print('> User terminated the app');
|
||||
elseif finalEvent.type == 'error' then
|
||||
print('> error name: ' .. tostring(finalEvent.error.name));
|
||||
print('> error reason/details: ' .. tostring(finalEvent.error.reason));
|
||||
elseif finalEvent.type == 'exitUI' then
|
||||
print('> User exited the app using the UI');
|
||||
end
|
||||
@ -1,17 +1,33 @@
|
||||
local createVersion = require('/apis/libversion');
|
||||
local _VERSION = '1.4.0';
|
||||
|
||||
local REPO_BASE = 'https://raw.githubusercontent.com/guillaumearm/cc-libs/';
|
||||
local LOCAL_MANIFEST_PATH = '/trapos/manifest.json';
|
||||
|
||||
local function printUsage()
|
||||
print('upgrade usage:');
|
||||
print();
|
||||
print('\t\tupgrade');
|
||||
print('\t\tupgrade --beta');
|
||||
print('\t\tupgrade --stable');
|
||||
print('\t\tupgrade version');
|
||||
print('\t\tupgrade help');
|
||||
end
|
||||
|
||||
local function readLocalBranch()
|
||||
if not fs.exists(LOCAL_MANIFEST_PATH) then return nil end
|
||||
local f = fs.open(LOCAL_MANIFEST_PATH, 'r');
|
||||
if not f then return nil end
|
||||
local data = f.readAll();
|
||||
f.close();
|
||||
if not data or data == '' then return nil end
|
||||
local manifest = textutils.unserializeJSON(data);
|
||||
return manifest and manifest.branch or nil;
|
||||
end
|
||||
|
||||
local command = ...;
|
||||
|
||||
if command == 'version' or command == '-version' or command == '--version' then
|
||||
print('v' .. createVersion().forSelf());
|
||||
print('upgrade v' .. _VERSION);
|
||||
return;
|
||||
end
|
||||
|
||||
@ -20,9 +36,26 @@ if command == 'help' or command == '-help' or command == '--help' then
|
||||
return;
|
||||
end
|
||||
|
||||
if command ~= nil and command ~= '' then
|
||||
local branch;
|
||||
local extraFlag;
|
||||
|
||||
if command == '--beta' or command == '-beta' then
|
||||
branch = 'next';
|
||||
extraFlag = '--beta';
|
||||
elseif command == '--stable' or command == '-stable' then
|
||||
branch = 'master';
|
||||
extraFlag = '--stable';
|
||||
elseif command ~= nil and command ~= '' then
|
||||
printUsage();
|
||||
return;
|
||||
else
|
||||
branch = readLocalBranch() or 'master';
|
||||
end
|
||||
|
||||
shell.execute('ccpm', 'upgrade');
|
||||
local installUrl = REPO_BASE .. branch .. '/install.lua';
|
||||
|
||||
if extraFlag then
|
||||
shell.execute('wget', 'run', installUrl, extraFlag);
|
||||
else
|
||||
shell.execute('wget', 'run', installUrl);
|
||||
end
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
local createMcpComputer = require('/apis/libmcpcomputer');
|
||||
local createVersion = require('/apis/libversion');
|
||||
|
||||
local WS_URL_SETTING = 'mcp-computer.ws-url';
|
||||
|
||||
local url = settings.get(WS_URL_SETTING);
|
||||
if type(url) ~= 'string' or url == '' then
|
||||
print('mcp-computer-server: ' .. WS_URL_SETTING .. ' not set, daemon inactive.');
|
||||
return;
|
||||
end
|
||||
|
||||
if not http or not http.websocket then
|
||||
print('mcp-computer-server: HTTP/WebSocket unavailable, daemon inactive.');
|
||||
return;
|
||||
end
|
||||
|
||||
createMcpComputer().startSession({
|
||||
eventloop = _G.bootEventLoop,
|
||||
url = url,
|
||||
os = os,
|
||||
});
|
||||
|
||||
print('mcp-computer-server v' .. createVersion().forSelf() .. ' started (' .. url .. ').');
|
||||
@ -1,24 +0,0 @@
|
||||
-- Periodically broadcasts (id, label) to the router so label-addressed
|
||||
-- packets can be resolved network-wide. Skips machines with no label.
|
||||
local createNet = require("/apis/net")
|
||||
|
||||
-- TOFIX: the idea of this file is to dynamically listen the computerLabel so in fact "os.getComputerLabel" should be called inside refresh()
|
||||
local label = os.getComputerLabel()
|
||||
if not label then
|
||||
return
|
||||
end
|
||||
|
||||
local net = createNet()
|
||||
local el = net.eventloop
|
||||
|
||||
-- In the future we might want to consider to have core events like computer-label-changed (at os level)
|
||||
local REFRESH_SECONDS = 30
|
||||
|
||||
local function refresh()
|
||||
-- Note: I don't like "router.register" and net-registrar naming here
|
||||
-- I think what we want here is a sort of hack to get an event computer_label_changed that will be used by the router directly, so maybe move this directly in `startup` dir ?
|
||||
net.send("router.register", { label = label })
|
||||
el.setTimeout(refresh, REFRESH_SECONDS)
|
||||
end
|
||||
|
||||
el.onStart(refresh)
|
||||
@ -1,23 +1,32 @@
|
||||
local createNet = require("/apis/net")
|
||||
local createVersion = require("/apis/libversion")
|
||||
local _VERSION = "2.0.0"
|
||||
|
||||
local MODEM_DETECTION_TIME = 3
|
||||
-- -- Example: implementation simple de ping-server
|
||||
local PING_CHANNEL = 9;
|
||||
local MODEM_DETECTION_TIME = 3; -- in seconds
|
||||
|
||||
if not peripheral.find("modem") then
|
||||
print("Warning: modem not found!")
|
||||
local createNet = require('/apis/net');
|
||||
|
||||
local modem = peripheral.find('modem');
|
||||
|
||||
if not modem then
|
||||
print("Warning: modem not found!");
|
||||
end
|
||||
|
||||
-- TOFIX: os.sleep should not be used anymore since we are in the main eventloop here.
|
||||
while not peripheral.find("modem") do
|
||||
os.sleep(MODEM_DETECTION_TIME)
|
||||
-- on attend le modem
|
||||
while not modem do
|
||||
modem = peripheral.find('modem');
|
||||
os.sleep(MODEM_DETECTION_TIME);
|
||||
end
|
||||
|
||||
local net = createNet()
|
||||
local net = createNet(nil, modem);
|
||||
|
||||
net.serve("ping", function(message, reply)
|
||||
if message == "ping" then
|
||||
reply("pong")
|
||||
end
|
||||
net.listenRequest(PING_CHANNEL, 'ping', function(message, reply)
|
||||
if message == 'ping' then
|
||||
-- print('=======> ping received !');
|
||||
reply('pong');
|
||||
end
|
||||
end)
|
||||
|
||||
print("ping-server v" .. createVersion().forSelf() .. " started.")
|
||||
print('ping-server v' .. _VERSION .. ' started.')
|
||||
|
||||
net.start();
|
||||
|
||||
@ -1,54 +1,45 @@
|
||||
local LOCAL_MANIFEST_PATH = "/trapos/manifest.json"
|
||||
local _VERSION = '1.0.0';
|
||||
|
||||
-- I think this should be moved in `programs`
|
||||
-- then we will run this motd program from startup/boot.lua
|
||||
local LOCAL_MANIFEST_PATH = '/trapos/manifest.json';
|
||||
|
||||
local function readLocalManifest()
|
||||
if not fs.exists(LOCAL_MANIFEST_PATH) then
|
||||
return nil
|
||||
end
|
||||
local f = fs.open(LOCAL_MANIFEST_PATH, "r")
|
||||
if not f then
|
||||
return nil
|
||||
end
|
||||
local data = f.readAll()
|
||||
f.close()
|
||||
if not data or data == "" then
|
||||
return nil
|
||||
end
|
||||
return textutils.unserializeJSON(data)
|
||||
if not fs.exists(LOCAL_MANIFEST_PATH) then return nil end
|
||||
local f = fs.open(LOCAL_MANIFEST_PATH, 'r');
|
||||
if not f then return nil end
|
||||
local data = f.readAll();
|
||||
f.close();
|
||||
if not data or data == '' then return nil end
|
||||
return textutils.unserializeJSON(data);
|
||||
end
|
||||
|
||||
local manifest = readLocalManifest()
|
||||
if not manifest then
|
||||
return
|
||||
end
|
||||
local manifest = readLocalManifest();
|
||||
if not manifest then return end
|
||||
|
||||
local name = manifest.name or "TrapOS"
|
||||
local version = manifest.version or "?"
|
||||
local branch = manifest.branch or "master"
|
||||
local isBeta = branch == "next"
|
||||
local name = manifest.name or 'TrapOS';
|
||||
local version = manifest.version or '?';
|
||||
local branch = manifest.branch or 'master';
|
||||
local isBeta = branch == 'next';
|
||||
|
||||
local hasColor = term.isColor and term.isColor()
|
||||
local previousColor
|
||||
local hasColor = term.isColor and term.isColor();
|
||||
local previousColor;
|
||||
|
||||
if hasColor then
|
||||
previousColor = term.getTextColor()
|
||||
if isBeta then
|
||||
term.setTextColor(colors.orange)
|
||||
else
|
||||
term.setTextColor(colors.lime)
|
||||
end
|
||||
previousColor = term.getTextColor();
|
||||
if isBeta then
|
||||
term.setTextColor(colors.orange);
|
||||
else
|
||||
term.setTextColor(colors.lime);
|
||||
end
|
||||
end
|
||||
|
||||
if isBeta then
|
||||
print(name .. " v" .. version .. " [BETA]")
|
||||
print(name .. ' v' .. version .. ' [BETA]');
|
||||
else
|
||||
print(name .. " v" .. version)
|
||||
print(name .. ' v' .. version);
|
||||
end
|
||||
|
||||
if hasColor and previousColor then
|
||||
term.setTextColor(previousColor)
|
||||
term.setTextColor(previousColor);
|
||||
end
|
||||
|
||||
print()
|
||||
print();
|
||||
|
||||
@ -1,67 +1,82 @@
|
||||
local createEventLoop = require("/apis/eventloop")
|
||||
local _VERSION = '1.2.0'
|
||||
|
||||
local LOCAL_MANIFEST_PATH = "/trapos/manifest.json"
|
||||
local SHUTDOWN_ON_SHELL_EXIT_SETTING = "trapos.shutdown_on_shell_exit"
|
||||
|
||||
-- OK this file should not be named servers.lua we could rename it boot.lua and this could be the only file in this startup directory so the whole startup process will be orchestrated from there.
|
||||
local LOCAL_MANIFEST_PATH = '/trapos/manifest.json';
|
||||
|
||||
local function readLocalManifest()
|
||||
if not fs.exists(LOCAL_MANIFEST_PATH) then
|
||||
return nil
|
||||
end
|
||||
local f = fs.open(LOCAL_MANIFEST_PATH, "r")
|
||||
if not f then
|
||||
return nil
|
||||
end
|
||||
local data = f.readAll()
|
||||
f.close()
|
||||
if not data or data == "" then
|
||||
return nil
|
||||
end
|
||||
return textutils.unserializeJSON(data)
|
||||
if not fs.exists(LOCAL_MANIFEST_PATH) then return nil end
|
||||
local f = fs.open(LOCAL_MANIFEST_PATH, 'r');
|
||||
if not f then return nil end
|
||||
local data = f.readAll();
|
||||
f.close();
|
||||
if not data or data == '' then return nil end
|
||||
return textutils.unserializeJSON(data);
|
||||
end
|
||||
|
||||
local function init()
|
||||
shell.setPath(shell.path() .. ":/programs")
|
||||
shell.setPath(shell.path() .. ':/programs');
|
||||
end
|
||||
|
||||
local function shouldShutdownOnShellExit()
|
||||
return settings.get(SHUTDOWN_ON_SHELL_EXIT_SETTING) ~= false
|
||||
end
|
||||
init();
|
||||
|
||||
init()
|
||||
local periphEmulation = function()
|
||||
-- attach modem
|
||||
periphemu.create('top', 'modem');
|
||||
|
||||
if os.getComputerID() == 0 then
|
||||
-- attach computers
|
||||
|
||||
os.sleep(0.1)
|
||||
periphemu.create(1, 'computer');
|
||||
|
||||
os.sleep(0.1)
|
||||
periphemu.create(2, 'computer');
|
||||
|
||||
-- attach router
|
||||
os.sleep(0.1)
|
||||
periphemu.create(10, 'computer');
|
||||
end
|
||||
end
|
||||
|
||||
if periphemu then
|
||||
periphemu.create("top", "modem")
|
||||
periphEmulation();
|
||||
end
|
||||
|
||||
_G.bootEventLoop = createEventLoop()
|
||||
|
||||
local function shellFn()
|
||||
os.sleep(0.1)
|
||||
shell.run("shell")
|
||||
os.sleep(0.1);
|
||||
shell.run("shell");
|
||||
end
|
||||
|
||||
local function eventLoopFn()
|
||||
_G.bootEventLoop.runLoop(true)
|
||||
local function getServerFns(serverList)
|
||||
local servers = {};
|
||||
|
||||
for k, v in ipairs(serverList) do
|
||||
local serverName = v;
|
||||
|
||||
servers[k] = function()
|
||||
if serverName then
|
||||
shell.run(serverName);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return servers;
|
||||
end
|
||||
|
||||
local manifest = readLocalManifest()
|
||||
local SERVERS = (manifest and manifest.autostart) or {}
|
||||
local manifest = readLocalManifest();
|
||||
local SERVERS = (manifest and manifest.autostart) or {};
|
||||
|
||||
local servers = getServerFns(SERVERS);
|
||||
|
||||
if #SERVERS > 0 then
|
||||
print("\nStarting servers...")
|
||||
for _, serverName in ipairs(SERVERS) do
|
||||
print("\t\t" .. serverName)
|
||||
local ok, err = pcall(shell.run, serverName)
|
||||
if not ok then
|
||||
print("server '" .. serverName .. "' failed to start: " .. tostring(err))
|
||||
end
|
||||
end
|
||||
print("\nStarting servers...");
|
||||
for _, v in ipairs(SERVERS) do
|
||||
print("\t\t" .. v)
|
||||
end
|
||||
end
|
||||
|
||||
parallel.waitForAny(shellFn, eventLoopFn)
|
||||
parallel.waitForAll(shellFn, table.unpack(servers));
|
||||
|
||||
if shouldShutdownOnShellExit() then
|
||||
os.shutdown()
|
||||
end
|
||||
print("Servers stopped, reboot the machine...");
|
||||
|
||||
os.sleep(1);
|
||||
os.reboot();
|
||||
|
||||
1328
tests/ai.lua
1328
tests/ai.lua
File diff suppressed because it is too large
Load Diff
@ -1,10 +0,0 @@
|
||||
-- Basic integration test: prove CraftOS-PC boots and can run a test script.
|
||||
local createLibTest = require('/apis/libtest');
|
||||
|
||||
local testlib = createLibTest({ ... });
|
||||
|
||||
testlib.test('CraftOS-PC boots and runs Lua', function()
|
||||
testlib.assertTrue(true);
|
||||
end);
|
||||
|
||||
testlib.run();
|
||||
122
tests/carre.lua
122
tests/carre.lua
@ -1,122 +0,0 @@
|
||||
local createLibTest = require('/apis/libtest');
|
||||
local createCarre = require('/apis/libcarre');
|
||||
|
||||
local testlib = createLibTest({ ... });
|
||||
|
||||
local function packed(...)
|
||||
return table.pack(...);
|
||||
end
|
||||
|
||||
local function fakeTerm(width, height)
|
||||
local buffer = {};
|
||||
local cursorX = 1;
|
||||
local cursorY = 1;
|
||||
|
||||
for y = 1, height do
|
||||
buffer[y] = {};
|
||||
for x = 1, width do
|
||||
buffer[y][x] = '.';
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
setCursorPos = function(x, y)
|
||||
cursorX = x;
|
||||
cursorY = y;
|
||||
end,
|
||||
write = function(text)
|
||||
text = tostring(text);
|
||||
for i = 1, #text do
|
||||
if buffer[cursorY] and buffer[cursorY][cursorX] then
|
||||
buffer[cursorY][cursorX] = string.sub(text, i, i);
|
||||
end
|
||||
cursorX = cursorX + 1;
|
||||
end
|
||||
end,
|
||||
line = function(y)
|
||||
return table.concat(buffer[y], '');
|
||||
end,
|
||||
};
|
||||
end
|
||||
|
||||
testlib.test('parseArgs accepts basic drawing options', function()
|
||||
local carre = createCarre();
|
||||
local config = carre.parseArgs(packed('-size', '5', '-x', '2', '-y', '3', '-char', '@', '-fill', '-clear'));
|
||||
|
||||
testlib.assertEquals(config.size, 5);
|
||||
testlib.assertEquals(config.x, 2);
|
||||
testlib.assertEquals(config.y, 3);
|
||||
testlib.assertEquals(config.char, '@');
|
||||
testlib.assertEquals(config.fill, true);
|
||||
testlib.assertEquals(config.clear, true);
|
||||
end);
|
||||
|
||||
testlib.test('parseArgs rejects invalid sizes', function()
|
||||
local carre = createCarre();
|
||||
local config, err = carre.parseArgs(packed('-size', '0'));
|
||||
|
||||
testlib.assertEquals(config, nil);
|
||||
testlib.assertTrue(string.find(err, 'entier positif', 1, true));
|
||||
end);
|
||||
|
||||
testlib.test('computeSquare centers and clamps the square', function()
|
||||
local carre = createCarre();
|
||||
local square = carre.computeSquare({ size = 4, char = '#', fill = false }, 10, 6);
|
||||
|
||||
testlib.assertEquals(square.x, 4);
|
||||
testlib.assertEquals(square.y, 2);
|
||||
testlib.assertEquals(square.size, 4);
|
||||
|
||||
square = carre.computeSquare({ size = 20, x = 99, y = 99, char = '#' }, 7, 5);
|
||||
testlib.assertEquals(square.x, 3);
|
||||
testlib.assertEquals(square.y, 1);
|
||||
testlib.assertEquals(square.size, 5);
|
||||
end);
|
||||
|
||||
testlib.test('computeSquare random keeps explicit character', function()
|
||||
local values = { 3, 2, 4 };
|
||||
local index = 0;
|
||||
local carre = createCarre({
|
||||
random = function(minValue, maxValue)
|
||||
index = index + 1;
|
||||
return math.max(minValue, math.min(maxValue, values[index]));
|
||||
end,
|
||||
});
|
||||
|
||||
local square = carre.computeSquare({
|
||||
random = true,
|
||||
char = '@',
|
||||
explicit = { char = true },
|
||||
}, 10, 8);
|
||||
|
||||
testlib.assertEquals(square.size, 3);
|
||||
testlib.assertEquals(square.x, 2);
|
||||
testlib.assertEquals(square.y, 4);
|
||||
testlib.assertEquals(square.char, '@');
|
||||
end);
|
||||
|
||||
testlib.test('drawSquare renders an outline', function()
|
||||
local carre = createCarre();
|
||||
local termLib = fakeTerm(8, 6);
|
||||
|
||||
carre.drawSquare(termLib, { x = 2, y = 2, size = 4, char = '#', fill = false });
|
||||
|
||||
testlib.assertEquals(termLib.line(1), '........');
|
||||
testlib.assertEquals(termLib.line(2), '.####...');
|
||||
testlib.assertEquals(termLib.line(3), '.# #...');
|
||||
testlib.assertEquals(termLib.line(4), '.# #...');
|
||||
testlib.assertEquals(termLib.line(5), '.####...');
|
||||
end);
|
||||
|
||||
testlib.test('drawSquare renders a filled square', function()
|
||||
local carre = createCarre();
|
||||
local termLib = fakeTerm(6, 5);
|
||||
|
||||
carre.drawSquare(termLib, { x = 2, y = 2, size = 3, char = '*', fill = true });
|
||||
|
||||
testlib.assertEquals(termLib.line(2), '.***..');
|
||||
testlib.assertEquals(termLib.line(3), '.***..');
|
||||
testlib.assertEquals(termLib.line(4), '.***..');
|
||||
end);
|
||||
|
||||
testlib.run();
|
||||
352
tests/ccpm.lua
352
tests/ccpm.lua
@ -1,352 +0,0 @@
|
||||
local createLibTest = require('/apis/libtest');
|
||||
local createCcpm = require('/apis/libccpm');
|
||||
|
||||
local testlib = createLibTest({ ... });
|
||||
|
||||
local counter = 0;
|
||||
|
||||
-- Fresh, isolated state + install sandbox per case (never touches real /trapos).
|
||||
local function freshDirs()
|
||||
counter = counter + 1;
|
||||
local stateDir = '/ccpm-test/state-' .. counter;
|
||||
local installRoot = '/ccpm-test/root-' .. counter;
|
||||
fs.delete(stateDir);
|
||||
fs.delete(installRoot);
|
||||
return stateDir, installRoot;
|
||||
end
|
||||
|
||||
-- Minimal stub of the CC `http` API backed by a url -> body map.
|
||||
local function fakeHttp(routes)
|
||||
return {
|
||||
get = function(url)
|
||||
local body = routes[url];
|
||||
if not body then return nil; end
|
||||
return {
|
||||
readAll = function() return body; end,
|
||||
close = function() end,
|
||||
};
|
||||
end,
|
||||
};
|
||||
end
|
||||
|
||||
local function ghBase(name, branch)
|
||||
return 'https://raw.githubusercontent.com/' .. name .. '/' .. branch .. '/';
|
||||
end
|
||||
|
||||
local function giteaBase(name, branch)
|
||||
return 'https://git.trapcloud.fr/' .. name .. '/raw/branch/' .. branch .. '/';
|
||||
end
|
||||
|
||||
testlib.test('registryBaseUrl resolves a github branch', function()
|
||||
local ccpm = createCcpm({ stateDir = freshDirs() });
|
||||
testlib.assertEquals(
|
||||
ccpm.registryBaseUrl({ name = 'guillaumearm/cc-libs', type = 'github', branch = 'next' }),
|
||||
'https://raw.githubusercontent.com/guillaumearm/cc-libs/next/'
|
||||
);
|
||||
end);
|
||||
|
||||
testlib.test('registryBaseUrl resolves a gitea branch', function()
|
||||
local ccpm = createCcpm({ stateDir = freshDirs() });
|
||||
testlib.assertEquals(
|
||||
ccpm.registryBaseUrl({ name = 'guillaumearm/cc-libs', type = 'gitea', branch = 'next' }),
|
||||
'https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/next/'
|
||||
);
|
||||
testlib.assertEquals(
|
||||
ccpm.descriptorUrl({ name = 'guillaumearm/cc-libs', type = 'gitea', branch = 'next' }, 'trapos-net'),
|
||||
'https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/next/packages/trapos-net/ccpm.json'
|
||||
);
|
||||
end);
|
||||
|
||||
testlib.test('registry urls resolve an http base', function()
|
||||
local ccpm = createCcpm({ stateDir = freshDirs() });
|
||||
testlib.assertEquals(
|
||||
ccpm.registryBaseUrl({ name = 'http://example.com/repo', type = 'http' }),
|
||||
'http://example.com/repo/'
|
||||
);
|
||||
testlib.assertEquals(
|
||||
ccpm.descriptorUrl({ name = 'http://example.com/repo/', type = 'http' }, 'trapos-net'),
|
||||
'http://example.com/repo/packages/trapos-net/ccpm.json'
|
||||
);
|
||||
end);
|
||||
|
||||
testlib.test('resolve orders dependencies before dependents', function()
|
||||
local base = ghBase('me/repo', 'master');
|
||||
local routes = {
|
||||
[base .. 'packages/a/ccpm.json'] = textutils.serializeJSON({ name = 'a', version = '1', dependencies = { 'b', 'c' }, files = {} }),
|
||||
[base .. 'packages/b/ccpm.json'] = textutils.serializeJSON({ name = 'b', version = '1', dependencies = { 'c' }, files = {} }),
|
||||
[base .. 'packages/c/ccpm.json'] = textutils.serializeJSON({ name = 'c', version = '1', dependencies = {}, files = {} }),
|
||||
};
|
||||
local ccpm = createCcpm({ stateDir = freshDirs(), http = fakeHttp(routes) });
|
||||
ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } });
|
||||
local ordered, err = ccpm.resolve('a');
|
||||
testlib.assertTrue(ordered, tostring(err));
|
||||
testlib.assertEquals(#ordered, 3);
|
||||
testlib.assertEquals(ordered[1].name, 'c');
|
||||
testlib.assertEquals(ordered[2].name, 'b');
|
||||
testlib.assertEquals(ordered[3].name, 'a');
|
||||
end);
|
||||
|
||||
testlib.test('resolve reports a missing package', function()
|
||||
local ccpm = createCcpm({ stateDir = freshDirs(), http = fakeHttp({}) });
|
||||
ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } });
|
||||
local ordered, err = ccpm.resolve('ghost');
|
||||
testlib.assertTrue(not ordered);
|
||||
testlib.assertTrue(string.find(err, 'not found', 1, true));
|
||||
end);
|
||||
|
||||
testlib.test('resolve detects a dependency cycle', function()
|
||||
local base = ghBase('me/repo', 'master');
|
||||
local routes = {
|
||||
[base .. 'packages/a/ccpm.json'] = textutils.serializeJSON({ name = 'a', version = '1', dependencies = { 'b' } }),
|
||||
[base .. 'packages/b/ccpm.json'] = textutils.serializeJSON({ name = 'b', version = '1', dependencies = { 'a' } }),
|
||||
};
|
||||
local ccpm = createCcpm({ stateDir = freshDirs(), http = fakeHttp(routes) });
|
||||
ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } });
|
||||
local ordered, err = ccpm.resolve('a');
|
||||
testlib.assertTrue(not ordered);
|
||||
testlib.assertTrue(string.find(err, 'cycle', 1, true));
|
||||
end);
|
||||
|
||||
testlib.test('install rejects an already-installed package', function()
|
||||
local ccpm = createCcpm({ stateDir = freshDirs() });
|
||||
ccpm.writeLock({ packages = { foo = { version = '1', files = {} } } });
|
||||
local ok, msg = ccpm.install('foo', {});
|
||||
testlib.assertTrue(not ok);
|
||||
testlib.assertTrue(string.find(msg, 'already installed', 1, true));
|
||||
testlib.assertTrue(string.find(msg, 'reinstall foo', 1, true));
|
||||
end);
|
||||
|
||||
testlib.test('install downloads files and records the lock', function()
|
||||
local base = ghBase('me/repo', 'master');
|
||||
local routes = {
|
||||
[base .. 'packages/trapos-core/ccpm.json'] = textutils.serializeJSON({ name = 'trapos-core', version = '1', dependencies = {}, files = { 'apis/eventloop.lua' } }),
|
||||
[base .. 'packages/trapos-net/ccpm.json'] = textutils.serializeJSON({ name = 'trapos-net', version = '1', dependencies = { 'trapos-core' }, files = { 'apis/net.lua' } }),
|
||||
[base .. 'apis/eventloop.lua'] = 'eventloop-body',
|
||||
[base .. 'apis/net.lua'] = 'net-body',
|
||||
};
|
||||
local sd, root = freshDirs();
|
||||
local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) });
|
||||
ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } });
|
||||
|
||||
local ok = ccpm.install('trapos-net', {});
|
||||
testlib.assertTrue(ok);
|
||||
testlib.assertTrue(fs.exists(root .. '/apis/net.lua'));
|
||||
testlib.assertTrue(fs.exists(root .. '/apis/eventloop.lua'));
|
||||
|
||||
local f = fs.open(root .. '/apis/net.lua', 'r');
|
||||
local body = f.readAll();
|
||||
f.close();
|
||||
testlib.assertEquals(body, 'net-body');
|
||||
|
||||
local lock = ccpm.readLock();
|
||||
testlib.assertTrue(lock.packages['trapos-net']);
|
||||
testlib.assertTrue(lock.packages['trapos-core']);
|
||||
testlib.assertEquals(lock.packages['trapos-net'].registry, 'me/repo');
|
||||
end);
|
||||
|
||||
testlib.test('install downloads files from a gitea registry', function()
|
||||
local base = giteaBase('guillaumearm/cc-libs', 'next');
|
||||
local routes = {
|
||||
[base .. 'packages/trapos-core/ccpm.json'] = textutils.serializeJSON({ name = 'trapos-core', version = '1', dependencies = {}, files = { 'apis/eventloop.lua' } }),
|
||||
[base .. 'packages/trapos-net/ccpm.json'] = textutils.serializeJSON({ name = 'trapos-net', version = '1', dependencies = { 'trapos-core' }, files = { 'apis/net.lua' } }),
|
||||
[base .. 'apis/eventloop.lua'] = 'eventloop-body',
|
||||
[base .. 'apis/net.lua'] = 'net-body',
|
||||
};
|
||||
local sd, root = freshDirs();
|
||||
local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) });
|
||||
ccpm.writeConfig({ registries = { { name = 'guillaumearm/cc-libs', type = 'gitea', branch = 'next' } } });
|
||||
|
||||
local ok = ccpm.install('trapos-net', {});
|
||||
testlib.assertTrue(ok);
|
||||
testlib.assertTrue(fs.exists(root .. '/apis/net.lua'));
|
||||
testlib.assertTrue(fs.exists(root .. '/apis/eventloop.lua'));
|
||||
|
||||
local f = fs.open(root .. '/apis/net.lua', 'r');
|
||||
local body = f.readAll();
|
||||
f.close();
|
||||
testlib.assertEquals(body, 'net-body');
|
||||
|
||||
local lock = ccpm.readLock();
|
||||
testlib.assertEquals(lock.packages['trapos-net'].registry, 'guillaumearm/cc-libs');
|
||||
end);
|
||||
|
||||
testlib.test('installing trapos writes aggregated os state', function()
|
||||
local base = ghBase('me/repo', 'master');
|
||||
local routes = {
|
||||
[base .. 'packages/trapos/ccpm.json'] = textutils.serializeJSON({ name = 'trapos', version = '1', dependencies = { 'trapos-net' }, files = {} }),
|
||||
[base .. 'packages/trapos-core/ccpm.json'] = textutils.serializeJSON({ name = 'trapos-core', version = '1', dependencies = {}, files = { 'programs/ccpm.lua' } }),
|
||||
[base .. 'packages/trapos-net/ccpm.json'] = textutils.serializeJSON({ name = 'trapos-net', version = '1', dependencies = { 'trapos-core' }, files = { 'apis/net.lua' }, autostart = { 'servers/ping-server' } }),
|
||||
[base .. 'programs/ccpm.lua'] = 'ccpm-body',
|
||||
[base .. 'apis/net.lua'] = 'net-body',
|
||||
};
|
||||
local sd, root = freshDirs();
|
||||
local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) });
|
||||
ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } });
|
||||
|
||||
testlib.assertTrue(ccpm.install('trapos', {}));
|
||||
|
||||
local f = fs.open(sd .. '/manifest.json', 'r');
|
||||
local manifest = textutils.unserializeJSON(f.readAll());
|
||||
f.close();
|
||||
testlib.assertEquals(manifest.version, '1');
|
||||
testlib.assertEquals(#manifest.files, 2);
|
||||
testlib.assertEquals(manifest.autostart[1], 'servers/ping-server');
|
||||
end);
|
||||
|
||||
testlib.test('registry add and remove round-trip', function()
|
||||
local ccpm = createCcpm({ stateDir = freshDirs() });
|
||||
ccpm.writeConfig({ registries = {} });
|
||||
testlib.assertTrue(ccpm.addRegistry('foo/bar', { branch = 'next' }));
|
||||
|
||||
local regs = ccpm.listRegistries();
|
||||
testlib.assertEquals(#regs, 1);
|
||||
testlib.assertEquals(regs[1].name, 'foo/bar');
|
||||
testlib.assertEquals(regs[1].branch, 'next');
|
||||
|
||||
local dupOk = ccpm.addRegistry('foo/bar', {});
|
||||
testlib.assertTrue(not dupOk);
|
||||
|
||||
testlib.assertTrue(ccpm.removeRegistry('foo/bar'));
|
||||
testlib.assertEquals(#ccpm.listRegistries(), 0);
|
||||
|
||||
local rmOk = ccpm.removeRegistry('nope');
|
||||
testlib.assertTrue(not rmOk);
|
||||
end);
|
||||
|
||||
testlib.test('addRegistry defaults to a gitea registry', function()
|
||||
local ccpm = createCcpm({ stateDir = freshDirs() });
|
||||
ccpm.writeConfig({ registries = {} });
|
||||
|
||||
local ok, registry = ccpm.addRegistry('foo/bar', {});
|
||||
testlib.assertTrue(ok);
|
||||
testlib.assertEquals(registry.type, 'gitea');
|
||||
testlib.assertEquals(registry.branch, 'master');
|
||||
|
||||
local regs = ccpm.listRegistries();
|
||||
testlib.assertEquals(regs[1].type, 'gitea');
|
||||
-- The default registry must not silently fall back to github.
|
||||
testlib.assertTrue(regs[1].type ~= 'github');
|
||||
testlib.assertEquals(
|
||||
ccpm.registryBaseUrl(regs[1]),
|
||||
'https://git.trapcloud.fr/foo/bar/raw/branch/master/'
|
||||
);
|
||||
|
||||
-- An explicit type is still honored (github stays supported but opt-in).
|
||||
testlib.assertTrue(ccpm.addRegistry('legacy/repo', { type = 'github' }));
|
||||
local legacy = ccpm.listRegistries()[2];
|
||||
testlib.assertEquals(legacy.type, 'github');
|
||||
end);
|
||||
|
||||
testlib.test('compareVersions treats padded zeros as equal', function()
|
||||
-- compareVersions is internal; probe via available() status
|
||||
local ccpm = createCcpm({ stateDir = freshDirs() });
|
||||
ccpm.writeCache({ packages = { pkg = { version = '1.0.0', registry = 'r' } } });
|
||||
ccpm.writeLock({ packages = { pkg = { version = '1.0', registry = 'r', files = {}, dependencies = {} } } });
|
||||
local avail = ccpm.available();
|
||||
testlib.assertEquals(avail[1].status, 'up-to-date');
|
||||
end);
|
||||
|
||||
testlib.test('update writes a package cache from registries', function()
|
||||
local base = ghBase('me/repo', 'master');
|
||||
local routes = {
|
||||
[base .. 'packages/index.json'] = textutils.serializeJSON({ packages = { foo = '1.0.0', bar = '2.0.0' } }),
|
||||
};
|
||||
local ccpm = createCcpm({ stateDir = freshDirs(), http = fakeHttp(routes) });
|
||||
ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } });
|
||||
|
||||
local cache = ccpm.update();
|
||||
testlib.assertEquals(cache.packages.foo.version, '1.0.0');
|
||||
testlib.assertEquals(cache.packages.foo.registry, 'me/repo');
|
||||
|
||||
local search = ccpm.search('ba');
|
||||
testlib.assertEquals(#search, 1);
|
||||
testlib.assertEquals(search[1].name, 'bar');
|
||||
end);
|
||||
|
||||
testlib.test('available marks cached packages by install status', function()
|
||||
local ccpm = createCcpm({ stateDir = freshDirs() });
|
||||
ccpm.writeCache({ packages = {
|
||||
alpha = { version = '1.0.0', registry = 'me/repo' },
|
||||
beta = { version = '2.0.0', registry = 'me/repo' },
|
||||
gamma = { version = '1.0.0', registry = 'me/repo' },
|
||||
} });
|
||||
ccpm.writeLock({ packages = {
|
||||
alpha = { version = '1.0.0', files = {}, dependencies = {} },
|
||||
beta = { version = '1.0.0', files = {}, dependencies = {} },
|
||||
} });
|
||||
|
||||
local available = ccpm.available();
|
||||
testlib.assertEquals(#available, 3);
|
||||
testlib.assertEquals(available[1].name, 'alpha');
|
||||
testlib.assertEquals(available[1].status, 'up-to-date');
|
||||
testlib.assertEquals(available[2].name, 'beta');
|
||||
testlib.assertEquals(available[2].status, 'updatable');
|
||||
testlib.assertEquals(available[3].name, 'gamma');
|
||||
testlib.assertEquals(available[3].status, 'available');
|
||||
end);
|
||||
|
||||
testlib.test('upgrade reinstalls outdated packages from cache', function()
|
||||
local base = ghBase('me/repo', 'master');
|
||||
local routes = {
|
||||
[base .. 'packages/foo/ccpm.json'] = textutils.serializeJSON({ name = 'foo', version = '2.0.0', dependencies = {}, files = { 'programs/foo.lua' } }),
|
||||
[base .. 'programs/foo.lua'] = 'foo-v2',
|
||||
};
|
||||
local sd, root = freshDirs();
|
||||
local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) });
|
||||
ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } });
|
||||
ccpm.writeCache({ packages = { foo = { version = '2.0.0', registry = 'me/repo' } } });
|
||||
ccpm.writeLock({ packages = { foo = { version = '1.0.0', registry = 'me/repo', files = { 'programs/foo.lua' }, dependencies = {} } } });
|
||||
|
||||
local ok, upgraded = ccpm.upgrade({});
|
||||
testlib.assertTrue(ok);
|
||||
testlib.assertEquals(#upgraded, 1);
|
||||
testlib.assertEquals(upgraded[1], 'foo');
|
||||
testlib.assertEquals(ccpm.readLock().packages.foo.version, '2.0.0');
|
||||
|
||||
local f = fs.open(root .. '/programs/foo.lua', 'r');
|
||||
local body = f.readAll();
|
||||
f.close();
|
||||
testlib.assertEquals(body, 'foo-v2');
|
||||
end);
|
||||
|
||||
testlib.test('upgrade requires a package cache', function()
|
||||
local ccpm = createCcpm({ stateDir = freshDirs() });
|
||||
ccpm.writeLock({ packages = { foo = { version = '1.0.0', files = {}, dependencies = {} } } });
|
||||
|
||||
local ok, err = ccpm.upgrade({});
|
||||
testlib.assertTrue(not ok);
|
||||
testlib.assertTrue(string.find(err, 'ccpm update', 1, true));
|
||||
end);
|
||||
|
||||
testlib.test('uninstall refuses a package with dependents', function()
|
||||
local ccpm = createCcpm({ stateDir = freshDirs() });
|
||||
ccpm.writeLock({ packages = {
|
||||
['trapos-core'] = { version = '1', files = {}, dependencies = {} },
|
||||
['trapos-net'] = { version = '1', files = {}, dependencies = { 'trapos-core' } },
|
||||
} });
|
||||
local ok, err = ccpm.uninstall('trapos-core', {});
|
||||
testlib.assertTrue(not ok);
|
||||
testlib.assertTrue(string.find(err, 'required by', 1, true));
|
||||
testlib.assertTrue(string.find(err, 'trapos-net', 1, true));
|
||||
end);
|
||||
|
||||
testlib.test('uninstall removes a leaf package and its files', function()
|
||||
local sd, root = freshDirs();
|
||||
fs.makeDir(root .. '/apis');
|
||||
local f = fs.open(root .. '/apis/net.lua', 'w');
|
||||
f.write('x');
|
||||
f.close();
|
||||
|
||||
local ccpm = createCcpm({ stateDir = sd, installRoot = root });
|
||||
ccpm.writeLock({ packages = {
|
||||
['trapos-core'] = { version = '1', files = {}, dependencies = {} },
|
||||
['trapos-net'] = { version = '1', files = { 'apis/net.lua' }, dependencies = { 'trapos-core' } },
|
||||
} });
|
||||
|
||||
testlib.assertTrue(ccpm.uninstall('trapos-net', {}));
|
||||
testlib.assertTrue(not fs.exists(root .. '/apis/net.lua'));
|
||||
testlib.assertTrue(ccpm.readLock().packages['trapos-net'] == nil);
|
||||
testlib.assertTrue(ccpm.readLock().packages['trapos-core'] ~= nil);
|
||||
end);
|
||||
|
||||
testlib.run();
|
||||
@ -1,179 +0,0 @@
|
||||
-- Eventloop behavior tests for the CraftOS-PC harness.
|
||||
local createEventLoop = require('/apis/eventloop');
|
||||
local createLibTest = require('/apis/libtest');
|
||||
|
||||
local testlib = createLibTest({ ... });
|
||||
|
||||
testlib.test('register dispatches queued event args', function()
|
||||
local events = createEventLoop();
|
||||
local called = 0;
|
||||
|
||||
events.register('eventloop_test_basic', function(a, b)
|
||||
called = called + 1;
|
||||
testlib.assertEquals(a, 'first');
|
||||
testlib.assertEquals(b, 42);
|
||||
events.stopLoop();
|
||||
end);
|
||||
|
||||
os.queueEvent('eventloop_test_basic', 'first', 42);
|
||||
events.runLoop();
|
||||
|
||||
testlib.assertEquals(called, 1);
|
||||
testlib.assertTrue(not events.isRunningLoop());
|
||||
end);
|
||||
|
||||
testlib.test('STOP unregisters handler after first call', function()
|
||||
local events = createEventLoop();
|
||||
local stoppedHandlerCalls = 0;
|
||||
local observerCalls = 0;
|
||||
|
||||
events.register('eventloop_test_stop', function()
|
||||
stoppedHandlerCalls = stoppedHandlerCalls + 1;
|
||||
return events.STOP;
|
||||
end);
|
||||
|
||||
events.register('eventloop_test_stop', function()
|
||||
observerCalls = observerCalls + 1;
|
||||
if observerCalls == 1 then
|
||||
os.queueEvent('eventloop_test_stop');
|
||||
else
|
||||
events.stopLoop();
|
||||
end
|
||||
end);
|
||||
|
||||
os.queueEvent('eventloop_test_stop');
|
||||
events.runLoop();
|
||||
|
||||
testlib.assertEquals(stoppedHandlerCalls, 1);
|
||||
testlib.assertEquals(observerCalls, 2);
|
||||
end);
|
||||
|
||||
testlib.test('manual unregister prevents dispatch', function()
|
||||
local events = createEventLoop();
|
||||
local called = 0;
|
||||
local dispose = events.register('eventloop_test_unregister', function()
|
||||
called = called + 1;
|
||||
end);
|
||||
|
||||
events.setTimeout(function()
|
||||
events.stopLoop();
|
||||
end, 0);
|
||||
dispose();
|
||||
|
||||
os.queueEvent('eventloop_test_unregister');
|
||||
events.runLoop();
|
||||
|
||||
testlib.assertEquals(called, 0);
|
||||
end);
|
||||
|
||||
testlib.test('setTimeout before runLoop fires once', function()
|
||||
local events = createEventLoop();
|
||||
local called = 0;
|
||||
|
||||
events.setTimeout(function()
|
||||
called = called + 1;
|
||||
events.stopLoop();
|
||||
end, 0);
|
||||
|
||||
events.runLoop();
|
||||
|
||||
testlib.assertEquals(called, 1);
|
||||
end);
|
||||
|
||||
testlib.test('setTimeout during runLoop fires after event handler', function()
|
||||
local events = createEventLoop();
|
||||
local order = '';
|
||||
|
||||
events.register('eventloop_test_runtime_timeout', function()
|
||||
order = order .. 'event>';
|
||||
events.setTimeout(function()
|
||||
order = order .. 'timeout';
|
||||
events.stopLoop();
|
||||
end, 0);
|
||||
return events.STOP;
|
||||
end);
|
||||
|
||||
os.queueEvent('eventloop_test_runtime_timeout');
|
||||
events.runLoop();
|
||||
|
||||
testlib.assertEquals(order, 'event>timeout');
|
||||
end);
|
||||
|
||||
testlib.test('cleared timeout before runLoop does not fire', function()
|
||||
local events = createEventLoop();
|
||||
local called = 0;
|
||||
|
||||
local clear = events.setTimeout(function()
|
||||
called = called + 1;
|
||||
end, 0);
|
||||
clear();
|
||||
|
||||
events.setTimeout(function()
|
||||
events.stopLoop();
|
||||
end, 0);
|
||||
events.runLoop();
|
||||
|
||||
testlib.assertEquals(called, 0);
|
||||
end);
|
||||
|
||||
testlib.test('onStart and onStop run around loop', function()
|
||||
local events = createEventLoop();
|
||||
local order = '';
|
||||
|
||||
events.onStart(function()
|
||||
order = order .. 'start>';
|
||||
end);
|
||||
|
||||
events.onStop(function()
|
||||
order = order .. 'stop';
|
||||
end);
|
||||
|
||||
events.setTimeout(function()
|
||||
order = order .. 'timeout>';
|
||||
events.stopLoop();
|
||||
end, 0);
|
||||
|
||||
events.runLoop();
|
||||
|
||||
testlib.assertEquals(order, 'start>timeout>stop');
|
||||
end);
|
||||
|
||||
testlib.test('empty loop returns and runs onStop', function()
|
||||
local events = createEventLoop();
|
||||
local stopped = false;
|
||||
|
||||
events.onStop(function()
|
||||
stopped = true;
|
||||
end);
|
||||
|
||||
events.runLoop();
|
||||
|
||||
testlib.assertTrue(stopped);
|
||||
testlib.assertTrue(not events.isRunningLoop());
|
||||
end);
|
||||
|
||||
testlib.test('error contracts are enforced', function()
|
||||
local events = createEventLoop();
|
||||
local function handler()
|
||||
end
|
||||
|
||||
events.register('eventloop_test_errors', handler);
|
||||
|
||||
testlib.assertErrors(function()
|
||||
events.register('eventloop_test_errors', handler);
|
||||
end, 'handler already registered');
|
||||
|
||||
testlib.assertErrors(function()
|
||||
events.stopLoop();
|
||||
end, 'loop is already stopped');
|
||||
|
||||
testlib.assertErrors(function()
|
||||
events.register(1, handler);
|
||||
end, 'string expected');
|
||||
|
||||
testlib.assertErrors(function()
|
||||
events.register('eventloop_test_errors_2', 'not a function');
|
||||
end, 'function expected');
|
||||
end);
|
||||
|
||||
testlib.run();
|
||||
@ -1,16 +0,0 @@
|
||||
-- Harness fixture (NOT auto-discovered: lives under tests/harness/).
|
||||
-- A single case that sleeps well past any harness timeout. The driving recipe
|
||||
-- decides which layer cancels it: `test-timeout-lua` lets libtest fire via
|
||||
-- `--timeout`, `test-timeout-shell` bypasses libtest (`--no-timeout`) so the
|
||||
-- shell watchdog kills the process. See `just test-timeout`.
|
||||
local createLibTest = require('/apis/libtest');
|
||||
|
||||
local testlib = createLibTest({ ... });
|
||||
|
||||
testlib.test('sleeps past the harness timeout', function()
|
||||
testlib.log('about to sleep 10s; the active harness timeout should cancel this first');
|
||||
sleep(10);
|
||||
testlib.assertTrue(true); -- never reached: a timeout layer cancels first
|
||||
end);
|
||||
|
||||
testlib.run();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user