The previous GitHub history was lost with the account (the local .git was missing its refs and most objects), so this repository starts from the existing code. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
9.4 KiB
CLAUDE.md
Guidance for Claude Code when working in this repository.
What CraftDock is
A lightweight, self-hosted web panel that manages a single Minecraft server (Java or Bedrock) running as a Docker container. Node.js + Express + Socket.io backend, vanilla-JS tabbed SPA frontend (Tailwind via CDN). The panel never modifies the Minecraft core: it controls the game container over the Docker socket and sends commands via RCON (Java) or container stdin (Bedrock).
Commands
npm test # full Vitest suite — must pass before any commit
npm run test:watch
npm run dev # dev server (loads .env, watches files), panel on :3000
npm run hash -- <pw> # generate the admin bcrypt hash (raw + base64 forms)
docker compose -f docker-compose.bedrock.yaml up -d --build # full Bedrock stack
docker compose -f docker-compose.java.yaml up -d --build # full Java stack
Run a single test file: npx vitest run test/services/playerDetailService.test.js.
Git workflow (mandatory)
- One numbered branch per feature/fix:
feature/N-slugorfix/N-slug, where N is sequential across the repo (checkgit branch -afor the highest existing N). - Every commit message is prefixed with the branch number:
#N: message(e.g.#10: resolve Bedrock player detail via player_<uuid> ServerId mapping). This makes it trivial to see which branch a change came from. - Merge into
mainwith--no-ffso the graph shows one merge bubble per feature. Never commit directly tomain. - Squash small commits: repeated touch-ups to the same file or tiny adjustments belong inside one larger, complete commit — don't inflate the tree.
- Messages in English, imperative, descriptive of the why when non-obvious.
Architecture
Composition root + dependency injection
src/main.js is the only place that wires real dependencies; src/server.js composes the Express app + Socket.io namespaces from injected pieces. Every service/route/adapter is a factory function taking its collaborators as arguments (createXxx({ dep1, dep2 })), so unit tests inject fakes — never module-level singletons for anything that touches I/O.
Layers
src/
├── main.js # composition root (real deps wired here, and only here)
├── server.js # Express + Socket.io composition (takes deps)
├── config.js # env loading + validation (single source of env truth)
├── logger.js # structured JSON logger (ts/level/component/msg/meta)
├── appState.js # cached edition detection + adapter resolution
├── middleware/auth.js # session auth for HTTP routes AND socket handshake
├── adapters/ # ServerAdapter interface: java (RCON) / bedrock (stdin)
│ └── playerFile/ # read-only world-save readers (see below)
├── services/ # docker, rcon, stdin, properties, world, seed, auth,
│ # playerDirectory, playerDetailService, bedrockIdentityBridge
├── routes/ # auth, status, players, properties, world (Express routers)
├── sockets/ # logs stream + status push namespaces
└── public/ # vanilla-JS SPA: js/app.js shell + js/tabs/*.js (one file per tab)
The adapter pattern (edition differences)
ServerAdapter abstracts the two editions behind one interface. Capability differences are declared, not special-cased in routes/UI:
| Java | Bedrock | |
|---|---|---|
| Command channel | RCON (rconService) |
container stdin via docker attach (stdinService) |
| whitelist | whitelist ... |
allowlist ... |
| ban/pardon | supported | no native command → NotSupportedError, buttons hidden |
| seed | seed command |
no command — read RandomSeed from level.dat (seedService) |
| save snapshot | save-all flush |
save hold → poll save query → read → save resume |
The frontend gets capabilities from /api/players and only renders controls the edition supports. When adding a command, add it to both adapters (or throw NotSupportedError) and update CAPABILITIES in serverAdapter.js.
Player data: read-only from world saves
Rule: ALL player data (position, health, food, inventory, gamemode, XP) is read directly from the world save files, read-only — never via console commands. Per-edition file adapters in src/adapters/playerFile/:
nbtPlayer.js— normalizes decoded NBT into one edition-agnosticPlayerDatashape.javaPlayerFile.js— readsworld/playerdata/<uuid>.dat(gzip NBT, big-endian).bedrockPlayerFile.js— reads the world LevelDB (leveldb-zlib, Mojang variant, little-endian NBT).
While the server runs, reads happen inside a save snapshot (save hold/save-all flush) orchestrated by playerDetailService — never open the live LevelDB without it.
Bedrock identity model (critical, hard-won knowledge)
Bedrock never stores the gamertag or XUID readably inside the world DB. Three identity layers exist:
- XUID + gamertag — only appear in the server log (
Player Spawned: <name> xuid: <n>).playerDirectory.jsscans docker logs for these lines and persists an XUID-keyed directory at<dataRoot>/craftdock_players.json(Java seeds fromusercache.jsoninstead). uniqueIdUUID (MsaId/SelfSignedId) — returned by thequerytargetconsole command for an online player. ⚠️querytargetalso returns an int64id(the entity UniqueID); do not confuse the two.- LevelDB records —
player_<uuid>mapping records (keyed by MsaId or SelfSignedId) whose NBTServerIdfield points to the actual data recordplayer_server_<leveldb-uuid>.
bedrockIdentityBridge.js learns and persists the XUID → leveldbUuid binding at <dataRoot>/craftdock_bedrock_ids.json (learned once while the player is online, works offline forever after). Resolution order in bedrockPlayerFile.findByUniqueId: direct player_<uuid> mapping lookup first, NBT UniqueID scan as fallback.
Properties (Options tab)
PROPERTY_SCHEMA in src/routes/properties.js is the single source of managed keys: type-based validation on PUT and vanilla defaults merged into GET (so the UI always starts fully populated; motd deliberately has no default). The frontend options.js renders from its own CATEGORIES list — when adding a property, update both the schema (backend) and CATEGORIES (frontend).
Item icon assets (generated, never committed)
The player inspector renders Minecraft item icons served from src/public/assets/mc/items/ — that directory is gitignored and generated: scripts/extract-mc-assets.mjs copies flat item PNGs out of the minecraft-assets devDependency. It runs automatically via postinstall for local dev, and in the Dockerfile assets stage at image build time (no runtime download — the panel stays offline-capable). Bump the game version with the ASSETS_VERSION build arg or npm run assets -- <version>. Unknown/modded items fall back to a text tile in the UI.
CraftDock metadata files
Panel-owned files live at the data root, siblings of the world, so they ride along in world exports/backups: craftdock_players.json (XUID directory), craftdock_bedrock_ids.json (identity bridge).
Testing rules
- Vitest, tests mirror
src/undertest/. TDD: write the failing test before the fix/feature. - Unit tests inject fake collaborators (plain objects with
vi.fn()); route tests usesupertestagainst a minimal Express app. test/fixtures/bedrock-player.nbtis a real captured Bedrock player NBT; the bedrockPlayerFile tests build a real LevelDB in a temp dir around it. Prefer real-format fixtures over hand-mocked parsing.- The whole suite must stay green (
npm test) before committing.
Environment & deployment facts
- Panel listens on 3000 (config default). The composes only
exposeit (reverse-proxy first); publish on the host only for local testing. Under Coolify the service's target port must be 3000. - Auth: single admin password; prefer
ADMIN_PASSWORD_HASH_B64under docker compose/Coolify (bcrypt$gets eaten by interpolation).SESSION_SECRETsigns the cookie;NODE_ENV=productionmarks itsecure(needs HTTPS). - The Minecraft service must have
stdin_open: true+tty: true(stdin channel + non-multiplexed logs) andOVERRIDE_SERVER_PROPERTIES=false(so the Options tab persists). - The
mc-datavolume is shared panel ↔ game container;MC_DATA_PATHpoints at it inside the panel. - Edition:
MC_EDITION=auto|java|bedrock(auto reads the container'sTYPEenv).MC_WORLD_NAMEisworld(Java) /worlds(Bedrock). - Local integration testing: the maintainer runs the stacks via WSL docker; the world lives in the
craftdock_mc-datavolume. To inspect real world data safely, stop the containers and copy it out (e.g.docker run --rm -v craftdock_mc-data:/d alpine tar cf - -C /d worlds).
Code style
- ES modules (
"type": "module"), Node ≥ 18, no build step, no TypeScript. - Factory functions + closures over classes. Small focused files — split before a file grows into multiple responsibilities.
- Frontend: no framework, no bundler; each tab exports
render<Tab>(root)and returns an optional cleanup function. Escape all user-derived strings with the localesc()before injecting into HTML. - Comments explain why/constraints (protocol quirks, Bedrock oddities), not what the next line does.
- UI text is Brazilian Portuguese; code, comments and commits are English.