PLWL/development.md
2026-10-07 16:29:40 +02:00

28 KiB

Development

Short guide for anyone working on the PLWL codebase.

Layout

src/               main process (Node/Electron backend)
  main.js          entry point: tray, single-instance handoff, --launch / --open
  argv.js          command-line parsing shared by boot and second-instance handoff
  window.js        main window creation
  ipc/             all ipcMain handlers
    index.js       handler registration, icon protocol, dialogs
    games.js       games service: run/stop state, wine resolution, open-with handoff
    lansync.js     LAN sync handlers (identity, devices, re-roll, sync + progress)
  runner.js        spawns wine/proton (env, prefix, playtime, logs) and owns
                   spawnTracked() - the log/playtime/exit tail every run shares
  linuxrunner.js   native Linux apps: redirected HOME/XDG, the isolation level
                   (none/relaxed/isolated/auto) and the handed-document path
  openrelay.js     the launcher side of the hand-off: the request folder an app
                   leaves what it wants opened in, watched while it runs
  linuxapp.js      native app detection (ELF/AppImage/.desktop) + icon lookup
  sandbox.js       bubblewrap probe (functional, cached) + buildBwrapArgs
                   (+ FS_ROOT, the one mount the relaxed level adds)
  library.js       apps registry (apps.json) + settings + playtime files, plus
                   the "which prefix does this app use" / in-use guards and
                   the isolation-mode gate on the way in
  wines.js         wine/proton build scan, registry, portable symlink healing
  prefixes.js      prefix registry (files/wineprefixes)
  symlinks.js      relative symlink rewiring + executable-bit restoration
                   (shared by imports, prefixes and LAN sync)
  fsutil.js        tree copy/remove with byte progress + rate-gated progress
                   events (imports, prefix/wine/app deletion, LAN sync)
  importer.js      app import (copy/move/leave/prefix)
  names.js         user-typed name sanitizing (prefix folder names, wine
                   display names)
  openwith.js      "Open with PLWL": ad-hoc runs, no library entry
  shortcuts.js     .desktop shortcut generation
  paths.js         portable layout, FILES-relative path helpers, translation sync
  i18n.js          translation loading + {0} placeholder filling
  preload.js       contextBridge API exposed to the renderer
  lansync.js       LAN sync receiver: identity + pairing, hashing, HTTP server
                   (with its receive-progress hook); uses hashcache.js,
                   lansync-identity.js, lansync-manifest.js, lansync-discovery.js
  lansyncpush.js   the pushing side (createPusher factory, keeps helper state out)
  hashcache.js     file hash cache (path -> size/mtime/hash) for LAN sync
  lansync-identity.js  identity + sync code (createIdentity factory)
  lansync-manifest.js  sender-side manifest building (createManifestBuilder)
  lansync-discovery.js UDP beacon discovery (createDiscovery factory)
  appicon.js       window/taskbar icon
renderer/          UI (plain HTML/CSS/JS, no framework)
  app.js           views, modals, custom-select, all renderer logic
  index.html       static markup for every view
  style.css        the whole look; CSS variables at the top
files/             portable data the launcher ships/creates at runtime
  translations/    en-us.json, pl.json (copied to the data dir on start)
test/              plain node scripts, run them with `node test/<name>.test.js`;
                   electron test/<name>.test.js for ones that need the real IPC/Electron
tools/             install.sh / createshortcut.sh / build helpers

Renderer never touches Node/Electron directly — everything goes through the window.plwl bridge (preload.js) and ipcMain handlers in src/ipc/. Keep new APIs in that pattern.

Rules

  • Keep it simple. Plain functions over classes, no abstractions "for later".
  • Structure: one topic per file. Extract a module once a file grows or logic repeats (see lansync-identity/manifest/discovery, hashcache). Factories (createX(...)) carry per-state helpers so module scope stays clean when several states share a module.
  • Comments are RARE and SHORT, always in English. One line, two at most. Explain only the WHY (a quirk, a workaround, a race) — never the WHAT. No comment at all when nothing needs explaining; no banners, no restating the next line, no changelog notes.
  • No duplicate logic: the second time you copy something, move it into a helper and call it from both places.
  • Everything user-visible goes through the translation files (en-us.json, pl.json) with {0}, {1} placeholders. No hardcoded strings in app.js.
  • Paths stored on disk are FILES-relative (portable layout): write with toPortablePath(), read with resolvePortablePath() — never store absolute paths for anything inside files/.
  • Registries (apps.json, wines.json, prefixes.json) are rewritten whole via writeJson(); playtime lives in its own tiny file so the registry is not rewritten while a game runs.
  • Deleting user data needs a confirm modal; best-effort cleanup is try/catch-and-ignore with a comment saying why it is safe to ignore.
  • Windows apps run through runGame()/buildEnv() — never spawn wine directly elsewhere (prefix handling, Proton env and playtime live there).
  • Native Linux apps run through linuxrunner.runLinuxGame()/buildLinuxEnv() the same way: HOME and the XDG folders are redirected into {prefix}/linux_pfx/, and bwrap (when the probe says it works) forces the same layout with mounts. Never spawn a native app elsewhere. game.kind is absent on old entries — that means Windows; game.isolation is one of four levels, resolved by linuxrunner.resolveLevel: none (plain run), relaxed (bwrap + the whole host filesystem mounted writable at sandbox.FS_ROOT) and isolated (bwrap with the stock read-only profile), with auto (default) meaning relaxed where bwrap works and none where it does not. Only auto may weaken itself — an explicit relaxed/isolated answers sandbox-required instead ("no silent downgrade"). library.js keeps exactly these four values, both when reading the registry and when a patch is saved (anything else falls back to auto), and the level always lands in the log and the UI. resolveLevel awaits the probe when nothing has measured yet: a shortcut boots HEADLESS, so no renderer ran sandbox:status — without that wait auto would read an empty cache and quietly pick none (and an explicit level would refuse a machine that can sandbox perfectly well).
  • Under bwrap ONLY two things stay writable: the redirected home and the app's own folder (writableAppDir: inside files/apps, or the folder the app was imported from — never /usr, /etc, /bin… and never files/wines). Portable apps write logs and data next to themselves and a read-only app folder makes them refuse to start (Prism Launcher: "couldn't create a log file - Read-only file system"). Everything else is EROFS by design — at the isolated level. Relaxed is the deliberate exception and says so in the UI: FS_ROOT is the same filesystem, mounted writable, so "everything else" is only unreachable through the real paths.
  • Two pipes feed one log in spawnTracked: neither may end the stream (pipe(ws, { end: false }), closed when both sources drained, at the latest LOG_DRAIN_MS after the exit) and the write stream carries an error listener. Ending it at exit while a pipe still had data killed the whole launcher with ERR_STREAM_WRITE_AFTER_END.
  • A prefix or wine build a running app uses cannot be renamed/deleted (wine holds files open there): the guards sit next to the handlers in src/ipc/, use the games service's running list plus library.js helpers (prefixNameForGame/gamesUsingPrefix/is*InUse) and answer { ok:false, error:'inUse'|'hasApps', apps:[names] } for the toast.
  • Long operations stream progress through the single import:progress slot as { phase: 'count'|'copy'|'remove', files, bytes, totalFiles, totalBytes } (runWithProgress for deletes/imports). Emit 'count' (a treeSize walk) first, copy/remove through the fsutil helpers. An isolated hand-off has its own slot, handoff:progress ({ phase: 'copy'|'done'|'error', name, bytes, totalBytes, notice }), because it can start before the window exists: main.js gets the event first and, when notice is set (a copy above linuxrunner.STAGE_NOTICE_BYTES), puts the launcher window up so the renderer's popup can be seen; below that the copy is over before a window could paint. Copies of one file go through fsutil.copyFileProgress (streamed, throttled by progressGate, .part + rename).
  • CSS: reuse the existing variables/classes (.button, .field-label, .custom-select, form-card); spacing comes from --button-margin and flex gaps, not per-element magic numbers.
  • The blanket transform: translateZ(0) (crisper small glyphs) makes every element its own stacking context, so a select's own z-index cannot escape the wrapper it sits in: a list opened inside .linux-only, .win-only or an override row was painted UNDER the field label and textarea that follow it (measuring with elementsFromPoint over the open list returned the label). CustomSelect lifts the chain instead of the host — lift() adds .select-lift to every STATIC ancestor while the list is open, and close()/closeAll() take it off again; positioned ancestors are skipped, so the fixed modal overlay keeps stacking over every view on its own.

Native Linux apps

  • Entry point detection (linuxapp.js): ELF, .AppImage, or a .desktop whose Exec= points at one; lib/, share/ … are skipped. The icon comes from the .desktop Icon=, an image next to the program, or the system theme — extractIconFromExe is PE-only.
  • The redirected home starts as a COPY of the desktop preferences (kdeglobals, kdedefaults/kdeglobals, gtk-3.0|gtk-4.0/settings.ini, qt5ct/qt6ct, Trolltech.conf, user-dirs.dirs). Without the seed Qt falls back to its built-in light palette (measured: #eff0f1 against the host's #202326). The copy keeps following the host while the app has not touched it: the hashes of what PLWL wrote live in {prefix}/linux_pfx/.seeded.json, OUTSIDE the app's home, and a copy with no marker entry (or one the app has since changed) is the app's own and is never overwritten. The PulseAudio cookie is seeded once, because with a redirected home Pulse looks in $HOME/.config/pulse/cookie and the app otherwise comes up silent; ~/.Xauthority is refreshed instead, since nothing exports XAUTHORITY to a sandboxed app.
  • Host fonts/icons/themes/colour schemes are not copied but --ro-bind under /tmp/.plwl-host/… and prepended to XDG_DATA_DIRS: live, and unable to write back into the user's ~. Symlinks were measured and rejected — the prefix is mounted ON ~, so a link to ~/.config/kdeglobals points at itself (Too many levels of symbolic links); without bwrap the same link writes through into the real home.
  • forceX11 (per app) sets XDG_SESSION_TYPE=x11 + QT_QPA_PLATFORM=xcb + GDK_BACKEND=x11 so the app and everything it launches falls back to XWayland, where window icons still exist at all (Wayland forbids them; a compiled GLFW probe showed XDG_SESSION_TYPE picks X11 vs Wayland, GLFW_PLATFORM does not exist). game.env still wins.
  • SESSION_MANAGER is dropped in the sandbox: the session socket sits in the private /tmp, so the pointer led nowhere (Qt: "Could not open network socket").
  • Imports normalise permissions (fsutil.chmodTree): owner rwx, x mirrored from r (0644 → 0755). Sources are archives, FAT sticks and read-only backups.
  • Reset Linux config on every prefix row removes exactly what PLWL seeded (DESKTOP_SEEDS, the pulse cookie, home/.Xauthority, .seeded.json) plus the folders that existed only for the seed, so the next start seeds clean. syncDesktopConfig can only follow a copy it can still prove is its own; the reset does not argue. App data (data/, cache/, state/, config/<app>/, the rest of home/) is never touched, and a running app from that prefix blocks it (the usual inUse guard).
  • With HOME bound straight to {prefix}/linux_pfx/home, the user's real ~ is invisible to the app — one reason absolute paths an app wrote into its config before the import (Prism's instance JavaPath) do not resolve. relaxed does not change that shadowing, but it does make the real filesystem reachable through FS_ROOT/home/… — and none gives the app the real paths outright, which is what those two levels are for.
  • A clicked link belongs to the host desktop, so it leaves through org.freedesktop.portal.OpenURI: the xdg-open BINARY is replaced inside the sandbox (a written shim --ro-bind over /usr/bin/xdg-open) and Qt execs it (measured — QGenericUnixServices picks xdg-open first, and reaches the portal itself only under /.flatpak-info/SNAP, which a bwrap profile does not have); Electron does the same. GIO resolves an URI IN-PROCESS, which no shim can reach: for that the prefix seeds home/.plwl/open-url (launcher-written, like the shim) plus a desktop entry and a mimeapps.list naming it the default for x-scheme-handler/http|https, text/html, x-scheme-handler/file and inode/directory. Without the seed GIO falls back to the host's /usr/share list — measured: it offered brave-origin.desktop, and gio open answered "The specified location is not supported". Measured end to end (both backends): the portal launches the host's helium.desktop with the exact URI. GIO_USE_PORTALS=1 was measured and REJECTED: it opened NOTHING and still reported success (GIO's portal path wants a sandbox marker this launcher does not fake). A KDE-native app resolving the link in-process through KIO (kde-open) stays uncovered — KDE ignores the seeded list (measured).
  • The games service owns a run through the entry object it created: a start that failed, and the exit of a PREVIOUS run of the same app (stopped, then relaunched — SIGKILL is asynchronous), may not clear the new run's entry or close its request watcher (forget/handleExit compare that object). A start still in flight is never pruned, however long its isolated hand-off copy takes, because pruning it would drop the watcher mid-start; Stop during that window only records a kill request (killRequests), which the start fulfils the moment it has a pid — clearing the state there instead would let a second launch race past the first. Measured in test/games-service.test.js.
  • The portal is not told about a FOLDER or a document, which is what "Open Pack Folder" or a drop asks for: it cannot see a path from in here and answers Response 2 for file:// (measured, while https answered 0). The shim leaves those in home/.plwl/open-request (written as .part and renamed, so nothing ever reads half a request) — a live bind of the app's home, so it reaches an app that is ALREADY running — and the launcher opens it out here with the host's own xdg-open (openrelay.js: createOpenRelay watches that folder for exactly as long as the app runs, consumes each request once, and quits watching on exit). The target is rewritten on the way out — the private $HOME back to {prefix}/linux_pfx/home, FS_ROOT back to / at relaxed — so the host opens the REAL folder and never a path that only exists inside. Nothing in the sandbox can start a host program itself: measured, the host's own xdg-open run in there ends in kde-open, which exits 0 and opens no window at all. A scheme nothing on the host claims ends in the desktop's own "choose an application" dialog, which is the point of handing it out instead of guessing.
  • The portal's Response is NOT read back: it is a signal addressed to the caller's own bus connection, so a separate gdbus monitor never receives it (measured: 15 calls, 15 timeouts, while the portal did open what it was given) and the readback only cost the app 3 s per click before giving up. Only whether the call went out is trusted, so a machine without a portal needs no special case any more: the call fails, and the request file is what still lands. (0.5.x had the host's own script parked inside the sandbox as the shim's fallback; measured, that route opens nothing.)
  • A file handed over (a shortcut's --file %f, a drop on it) is the one path the sandbox cannot see by itself: a file out of the user's home is buried under the prefix home, one out of /tmp sits behind the private tmpfs, and the rest of the machine is read-only. Per-file --bind (what 0.5.0 did) is the wrong tool: a mount cannot be added to a namespace that is ALREADY running, so a hand-off into a running app could never be served that way. handedPathFor instead names the document in the one shape the chosen level can reach, and the app is told nothing else: none → the real path; relaxed → FS_ROOT + the real path; isolated → a copy in {prefix}/linux_pfx/home/plwl-handoff/<name>, which is a live bind of the app's home, so it reaches a running app too (measured end to end in test/linux-e2e.js: both levels hand a document to a sandbox that is already up). Copies are one per name and refreshed on every open (the folder cannot grow), written through a .part name and renamed so nothing ever reads a half-written document, and the app's edits stay in the copy — that IS the level's promise. A handed path that is gone fails the hand-off instead of launching an app with no document.
  • FS_ROOT (/tmp/plwl-fs-root) is where relaxed mounts the host filesystem, and it has to live under the private /tmp: bubblewrap creates the mount point itself, and the root of a read-only root cannot be written to (measured on 0.12.0: Can't create file /PLWL_FS_ROOT: Read-only file system, for a --dir before the --ro-bind / / too). Measured also: through it the real ~ reads, a write lands on the real file, and a file created AFTER a sandboxed run started is visible inside it — that last one is what makes a hand-off into a running app possible at all.
  • Not built: a private or overlay /etc (buildBwrapArgs accepts etcRoot, nothing sets it; --ro-bind / / leaves /etc read-only, which is all we promise today), --tmpfs /home against OTHER users' homes (hideHome is plumbed, no UI) and dataMode — app data always lands in {prefix}/linux_pfx.
  • Measured, not assumed (Debian 13, kernel 6.12, bwrap 0.12.0): the functional probe passes here, no firejail/proot/fuse-overlayfs on the box, /etc is 16 MB and its machine-id is read-only even on the host.

LAN sync

  • Two sides: the receiver lives in lansync.js, the sender in lansyncpush.js, built by createPusher(helpers) — several launcher states share the module, so nothing may sit in its module scope. Both sides report their own progress (receiver through the onReceive hook).
  • An incoming sync needs CONSENT: a correct code opens the gate, but the receiver's onIncoming hook (registered in main.js) shows Accept/Decline, answered through lansync:respondIncoming. Unanswered requests expire (INCOMING_TIMEOUT_MS); a decline returns 'rejected' and KEEPS the pairing (the code was proven). Without an onIncoming hook (tests, headless) the gate stays open.
  • Progress is bytes plus per-category counters (items = announced units, itemsDone = finished, totals = everything picked) — "Apps: 2/5". The bar follows the CURRENT PHASE's bytes (barBytes/barTotalBytes) so a phase starts from an empty bar. Hashing and copying walk tens of thousands of files: every per-file update goes through progressGate (~10/s, phase changes forced) — never one IPC message per file. The gate holds a snapshot FUNCTION on the sender side: the snapshot is built when it flushes, so a throttled update never shows a byte count one gate window old.
  • The sender's transferred counter counts bytes AS THEY ARE READ (putFile's onBytes callback), not on upload completion: a multi-GB file has to move it while it flies or the sender shows hundreds of MB behind the receiver. A unit's byte total travels as the { unitBytes } control record BEFORE its files; the receiver has to handle it before the entry filter (it has no path/type, so a filter-first order dropped it and the comparing bar measured against a placeholder total of 1 — read as 100% for the whole phase). The comparing phase ends on a full bar, then the copy bar restarts empty (its first PUT byte emits it).
  • While the receiver answers the consent popup (and whenever its side waits on the other one), both popups show "Waiting for the other device…" (lansync.waiting): the sender emits a 'wait' phase before verify, the receiver's ping handler emits stage 'wait'. A unit counts toward "Apps: 1/5" as soon as its comparison starts (not when it commits), and phases without a file count (hash/compare/commit) clear the counter so a stale "3 / 10 files" never lingers. On the wire the receiver's comparing phase is called 'compare' (sender-side 'plan' is unchanged).
  • Registry-level settings are opt-in (the "settings" category). The payload carries defaults, wineSettings (displayName + disabled per build), prefixSettings, appSettings (name, wine, prefix, args, env, dllOverrides, disableUmu, isolation and forceX11 per app) and appOrder. The receiver only patches what already exists (unknown ids skipped, nothing created or deleted, unknown local entries kept at the end of the order) through the normal helpers (setWineDisplayName, library.updateGame, reorderGames).
  • File hashes are remembered in files/data/lansync-hashes.json (path -> size + mtime + hash; machine-local, never synced): equal size and mtime means equal bytes, so a repeat sync only stats the tree. Hashing and the receiver's comparison both run HASH_CONCURRENCY files at a time.
  • A file's unit and path travel in the PUT's query, not in headers: Node rejects any header value holding a code above U+00FF (ERR_INVALID_CHAR), and a real library has those names (Minecraft pack names, Polish file names) — one such file killed the whole sync, leaving the receiver with half an app and no registry entry. Categories are matched by exact name; anything this build does not know is ignored.
  • File permissions travel with the manifest (mode) and are applied after the bytes; a file with the right hash but wrong mode is repaired without re-upload.
  • A sender hashing a big unit is minutes of silence: it pings /plwl/ping every HEARTBEAT_MS, the receiver re-arms its popup watchdog (stage 'wait' shows "Waiting for the other device…" but never while a file is being written). A receiving popup its watchdog closed reopens on the next real event — only 'done' ends a sync, and reopening never asks for consent again (consent is the onIncoming popup at verify time; the progress popup is driven by the same token's events).
  • Native Linux app data ({prefix}/linux_pfx/: the redirected home, configs, caches, PulseAudio cookie, state) travels INSIDE its prefix unit like the Windows side of the same prefix: the manifest walks it and the mirror prunes it, so no file of the launcher's own trees is special-cased on the wire. Native apps sit in the same categories as Windows ones (apps-portable/apps-prefix), and only the per-app fields that are Linux-only (isolation, forceX11) are named as such in the settings payload.
  • A sync must never walk through a symlinked ROOT: pruneTree/healUnitRoots (sync), removeTree/treeSize (fsutil) and healBuild (wines) lstat the root first. listDirs() counts symlinked folders as units, so a build on another disk still travels — as the link it is (wineUnits maps nested builds to their TOP folder under wines/).
  • Wine/Proton rewrite prefix links to absolute targets whenever they update a prefix, so on-disk links are not always relative after a run: the sync relativizes them on the wire (portableLinkTarget) and refreshWines()/healPrefix() repair absolute links that reached disk anyway. A build whose binaries lost the executable bit is detected in refreshWines() and healed once (spawn() on a non-executable proton fails with EACCES while the game looks started).

Paths and prefixes

  • Symlinks inside everything PLWL owns are stored RELATIVE whenever the target can be expressed locally (symlinks.js owns this: rewireSymlinks + restoreExecutableBits; every writer calls it — imports, healPrefix, sync on the wire and after a unit lands). dosdevices drive mappings and targets leaving files/ stay absolute — machine-specific by design.
  • A prefix with NO pfx yet gets ONE C: drive: pfx is a relative symlink to the prefix itself (prefixes.ensureUnifiedPrefix only CREATES it), so Proton's <compatdata>/pfx shares drive_c, registry and dosdevices with plain wine. Existing layouts are left as they are — hoisting files one level up was tried and removed (it breaks relative links, the umu.exe link kills a Proton launch). Never assume a single drive_c: use prefixDriveRoots() and compare realpaths.
  • App launch env is built in runner.buildEnv only. WINEDLLOVERRIDES is an ordered semicolon list (inherited first, app's last; an empty dll= is "disabled" and must survive the filter). Proton appends its dxvk/nvapi defaults AFTER everything. disableUmu drops UMU_ID and runs Proton's runinprefix verb. STEAM_COMPAT_CLIENT_INSTALL_PATH is required in BOTH modes (Proton's session init reads it on every verb; missing it aborts the launch before wine starts): a real Steam client when found, else the empty files/data/fake-steam.

Launch and shortcuts

  • Generated shortcuts are file handlers too: Exec=<launcher> --launch <id> --file %f (a literal % is %%). argv.js takes the file from --file or a bare trailing path, and runner.windowsPathFor passes it to the app as Windows would (C:\... inside the prefix, Z:\... otherwise). A file given to an already running app is a handoff (untracked process, no log/playtime writes).
  • The second-instance handoff must never read the token next to its option: Electron RE-SERIALIZES argv (--launch <id> --file <path> arrives as --launch --file --allow-file-access-from-files <id> <path>), so argv.js pairs options with positionals IN ORDER (inline --name=value wins, the dev app path is skipped). Reading the next token used to take the launch id as the opened file. electron test/argv-handoff.test.js proves the real serialization.
  • A modal showing a running sync opens locked: showModal(html, { locked: true }) ignores backdrop and Escape.
  • Tests are plain assert-by-print scripts (see test/): set PLWL_BUILD_DIR to a temp dir, check(), exit 1 on failure. Add one when fixing a bug.

Verify before you commit

node --check <changed js files>          # syntax
node test/smoke.js                       # quick sanity
node test/lansync.test.js                # LAN sync contract (identity, pairing, mirror)
node test/lansync-receiving.test.js      # receive-side progress events (the popup)
node test/lansync-progress.test.js       # live sender bytes, comparing bar widths
node test/lansync-settings.test.js       # playtime, app settings, wine/prefix + defaults
node test/lansync-links.test.js          # wire: exec bits + relative links
node test/lansync-pending-links.test.js  # links to files created later, startup heal
node test/lansync-consent.test.js        # accept / decline / timeout gate
node test/lansync-token.test.js          # long-sync token expiry (sliding TTL)
node test/bench-sync.js                  # timing: cold vs cached sync
node test/linux-sandbox.test.js          # bwrap argv profile + the four levels, handed paths, mode gate
node test/linux-env.test.js              # redirected HOME/XDG_* for both backends
node test/linux-import.test.js           # ELF/AppImage/.desktop detection + icons
node test/linux-links.test.js            # link opening: shim binds + mime seed
node test/linux-open.test.js             # what an app opens: the script + the launcher relay
node test/games-service.test.js          # run bookkeeping: the request watcher, Stop while starting
node test/lansync-linux-apps.test.js     # Linux apps + linux_pfx travel with their categories
node test/linux-e2e.js                   # REAL runs: plain, relaxed (+ its mount), isolated, hand-off into a running app
npm start                                # click through the flow you touched

# Repairing / testing a real instance (test/ scripts are gitignored helpers):
PLWL_BUILD_DIR=/path/to/instance node test/heal-instance.js          # links + exec bits
PLWL_BUILD_DIR=/path/to/instance node test/launch-instance.js <id>   # really start a game