diff --git a/.plans/boot-lifecycle-refactor.md b/.plans/boot-lifecycle-refactor.md new file mode 100644 index 0000000..86fce36 --- /dev/null +++ b/.plans/boot-lifecycle-refactor.md @@ -0,0 +1,161 @@ +# Plan: Fix the TrapOS boot lifecycle + +## Context + +`startup/servers.lua` is filed as a CraftOS **startup hook** but behaves as an **init +process**. CraftOS runs `/startup/*` files *inside* its own shell and expects each to +return; `servers.lua` instead blocks forever (`parallel.waitForAny(shellFn, eventLoopFn)`) +and then calls `os.shutdown()` (`startup/servers.lua:69`). That `os.shutdown()` is not a +feature — it's a guard against falling through to the host CraftOS shell with no eventloop +and no servers running (a degraded bare prompt). + +The installer makes this worse: `install-trapos.lua:229-231` "boots TrapOS in place" via +`shell.execute('/startup/servers.lua')`, three shells deep, where the `os.shutdown()` kills +the machine instead of letting `shell.setDir(previousDir)` (line 233) run. + +Goal: keep the (correct, unavoidable) `parallel(shell, eventLoop)` shape, but stop running +the orchestrator as a startup-hook-pretending-to-be-init. Make the boot path single and +honest, decouple the installer, and turn the implicit shutdown into an explicit policy. + +### Decisions (confirmed with user) + +- **Session-end default = `shutdown`**, with `reboot` and `relaunch` as opt-in policy values. +- **Move `motd` into `programs/`** and invoke it from the orchestrator (single `/startup/` file). +- **Docs: new ADR-0019** for the boot-lifecycle decision; update living docs + (README/AGENTS/periphemu); leave accepted ADRs 0002/0005/0010 as historical record. +- **`trapos.shutdown_on_shell_exit`**: clean break — replaced by `trapos.on_session_end`. + The old key is undocumented and never persisted (only read in the orchestrator + one test), + so no migration shim. (Recorded in ADR-0019.) + +## Implementation + +### 1. Rename orchestrator → `startup/boot.lua` + explicit session-end policy + +Rename `startup/servers.lua` → `startup/boot.lua`. Replace the `SHUTDOWN_ON_SHELL_EXIT_SETTING` +boolean logic with a named policy: + +- New setting `trapos.on_session_end`, values `"shutdown"` (default) | `"reboot"` | `"relaunch"`. + Any unset/invalid value falls back to `"shutdown"`. +- `relaunch`: when the shell exits, re-run it (never drop to bare CraftOS); shutdown/reboot + only via explicit command. Implement as a `repeat shell.run("shell") until action ~= "relaunch"` + loop inside `shellFn`, re-reading the setting each iteration (so `set ... shutdown` then exit + actually powers off). +- After `parallel.waitForAny`: if `shellExited`, dispatch `os.reboot()` for `reboot`, else + `os.shutdown()` (covers `shutdown`; `relaunch` never reaches here). +- Drop the file's self-deprecating TODO comment (`servers.lua:6`). + +### 2. Move `motd` into `programs/` and call it from `boot.lua` + +- Move `startup/motd.lua` → `programs/motd.lua` (drop its TODO comment, lines 3-4). No code + change otherwise — it already reads `/trapos/manifest.json` and returns early if absent. +- In `boot.lua`, after `init()` (which appends `:/programs` to the path), call + `shell.run('/programs/motd.lua')` early — before the "Starting servers..." block — to + preserve the current banner-before-servers order. Use the absolute path so it doesn't depend + on path-setup ordering. + +### 3. Installer: replace "boot in place" with `os.reboot()` + +In `install-trapos.lua`, replace lines 229-231 (the `fs.exists('/startup/servers.lua')` + +`shell.execute(...)` block) with a reboot at the end of the full-install path: + +```lua +shell.execute('trapos-postinstall'); +shell.setDir(previousDir); +print(); +print('=> Rebooting...'); +os.reboot(); +``` + +This gives one boot path (fresh install == normal power-on), drops the deep nesting, and the +installer no longer needs to know the orchestrator's filename. (`--cpm-only` already returns +early at line 220, so it won't reboot.) Mirrors the existing `programs/trapos-upgrade.lua:34-37` +reboot pattern. + +### 4. Upgrade migration: prune stale startup files (self-heal) + +`api.upgrade` → `api.install(force=true)` (`apis/libccpm.lua:412,432,372`) writes the new file +list but **never deletes** files dropped from a package (only `uninstall` deletes, +`libccpm.lua:463-466`). So upgraded machines would keep the old `startup/servers.lua` + +`startup/motd.lua` alongside the new files → two orchestrators in `/startup/`. + +Fix in `boot.lua`: at startup (in `init()`), prune known-stale paths, guarded by `fs.exists`: + +```lua +for _, p in ipairs({ '/startup/servers.lua', '/startup/motd.lua' }) do + if fs.exists(p) then fs.delete(p) end +end +``` + +CraftOS runs `/startup/*` alphabetically; `boot.lua` sorts before `servers.lua`, so it deletes +the stale orchestrator before it would ever run. Idempotent; converges in one boot. +(The general "ccpm should prune removed files on force-install" gap is noted as future work in +ADR-0019, not fixed here to keep scope contained.) + +### 5. Package metadata + +- `packages/trapos-boot/ccpm.json`: `files` → `["programs/motd.lua", "startup/boot.lua"]`; + bump `version` `0.3.3` → `0.4.0` (file set + behavior changed). +- `packages/index.json:5`: bump `trapos-boot` `0.3.3` → `0.4.0` so `ccpm update` advertises it + and `ccpm upgrade` detects the new version on existing machines. + +### 6. Tests + +- **`tests/startup-servers.lua` → rename `tests/boot.lua`** (or keep name; rename preferred for + clarity). Update `loadfile('/startup/servers.lua', ...)` → `/startup/boot.lua`. Keep the two + existing cases retargeted to `trapos.on_session_end` semantics: + - default → `shutdown` (existing "shuts down by default"). + - add `reboot` case → asserts `calls.rebooted == true`, `calls.shutdown == false`. + - add `relaunch` case → make the `settings.get` stub return `"relaunch"` once then `"shutdown"` + so the `repeat...until` loop terminates; assert `shellRuns` ran twice and ended in shutdown. + The harness already stubs `os.reboot`/`os.shutdown`/`parallel.waitForAny` (lines 21-39). +- **`tests/install-trapos.lua`**: the env stub (lines 75-101) does **not** stub `os`, so the new + `os.reboot()` would really reboot during the test. Add an `os` stub that records the reboot + (e.g. push a `{ program = '__reboot__' }` marker into `calls.executes` or set `calls.rebooted`). + Rewrite the "postinstall before startup servers" test (lines 164-174): drop the + `/startup/servers.lua` lookup; assert reboot occurs after `trapos-postinstall`. In the + `--cpm-only` test (176-184), assert reboot was **not** called. +- `tools/trap-sandbox/test-integration/lua/full-boot.lua:1`: update the comment reference + `startup/servers.lua` → `startup/boot.lua` (no functional change — it doesn't load the file). + +### 7. Docs + +- **New `docs/adrs/adr-0019-boot-lifecycle.md`**: orchestrator renamed `boot.lua` and framed as + init; installer reboots instead of boot-in-place (single boot path); `trapos.on_session_end` + policy (shutdown default / reboot / relaunch) replacing the `shutdown_on_shell_exit` boolean; + motd moved to `programs/`; boot.lua self-heals stale startup files. Note ccpm-prune-on-upgrade + as future work. Add it to the Records list in `docs/adrs/README.md` (markdown link — lychee + validates it). +- Update living-doc prose mentions `startup/servers.lua` → `startup/boot.lua`: + `README.md:73`, `AGENTS.md:27,36,42`, `docs/periphemu.md:9,26`. +- Leave accepted ADRs `0002:36`, `0005:22,41,86`, `0010:57,86` unchanged (historical; the + mentions are backticked prose, not links, so `just check`/lychee is unaffected). + +## Files to modify + +- `startup/servers.lua` → **rename** `startup/boot.lua` (policy + prune + motd call) +- `startup/motd.lua` → **move** `programs/motd.lua` +- `install-trapos.lua` (reboot instead of boot-in-place) +- `packages/trapos-boot/ccpm.json`, `packages/index.json` (file list + version bumps) +- `tests/startup-servers.lua` → **rename** `tests/boot.lua` (policy cases) +- `tests/install-trapos.lua` (os stub + reboot assertions) +- `tools/trap-sandbox/test-integration/lua/full-boot.lua` (comment) +- `docs/adrs/adr-0019-boot-lifecycle.md` (**new**), `docs/adrs/README.md` +- `README.md`, `AGENTS.md`, `docs/periphemu.md` + +## Verification + +- **Unit tests**: run the suite (`just test` / `programs/runtest.lua`). Confirm the renamed + `tests/boot.lua` passes all three session-end cases and `tests/install-trapos.lua` passes with + the new reboot assertions. Confirm the test runner discovers the renamed file. +- **Markdown lint**: `just check` (lychee `lint-markdown`) — confirms the new ADR-0019 link in + `docs/adrs/README.md` resolves and no doc links broke. +- **Boot path (CraftOS-PC harness, per ADR-0005)**: launch a clean computer, confirm `boot.lua` + prints the motd banner, starts autostart servers, and gives an interactive shell with the + eventloop live (e.g. a `net.call`-based program responds). Type `exit` → machine powers off + (default). `set trapos.on_session_end reboot` → exit re-enters TrapOS. `set ... relaunch` → + exit returns a fresh prompt without powering off. +- **Upgrade migration**: on a computer with the old `startup/servers.lua` + `startup/motd.lua` + present, drop in the new `boot.lua` and reboot; confirm both stale files are deleted and only + `boot.lua` remains in `/startup/`. +- **Installer**: run `install-trapos.lua` end-to-end (or via the sandbox harness) and confirm it + finishes with `=> Rebooting...` and comes back up into a live TrapOS rather than a nested shell. diff --git a/.plans/trapos-create-installer-disk-plan.md b/.plans/trapos-create-installer-disk-plan.md new file mode 100644 index 0000000..c304f9c --- /dev/null +++ b/.plans/trapos-create-installer-disk-plan.md @@ -0,0 +1,152 @@ +# Plan: `trapos-create-installer-disk` — one-shot TrapOS installer floppies + +## Context + +Today TrapOS is installed by typing `wget run https://trapos.trapcloud.fr/install` on each +fresh computer, then manually configuring settings (`sandbox.url`, `sandbox.password`, …) per +machine. We want a **provisioning floppy**: run one program on a configured machine to "bake" +an installer disk, then on any blank machine just insert the disk and boot — it installs the +latest TrapOS online and clones *your* `.settings` onto the new computer, hands-free. + +The disk's value is **hands-free install + config cloning**, not offline file shipping. + +## Resolved design decisions (from grill session) + +- **Install method**: autorun installs **online** via the existing `wget run` bootstrap. No + files are bundled on the disk; the disk stays tiny and never goes stale vs the registry. +- **Disk boot file**: CC only auto-runs `startup`/`startup.lua` from a floppy at boot. We put + the autorun logic **directly in `/startup.lua`** (the name "autorun" is conceptual). +- **Ordering (copy-then-install, no reboot)**: `install-trapos.lua` ends by handing off to the + live `/startup/servers.lua` shell, which never returns. So the autorun **copies `.settings` + first**, calls `settings.load()`, *then* runs the installer (whose `trapos-postinstall` + respects the already-set `sandbox.url` and never touches `sandbox.password`, so the disk's + config wins). **No changes to `install-trapos.lua`** and **no reboot** here — the + boot/reboot rework is a separate effort. +- **Sentinel**: autorun no-ops if `/trapos` exists. To reinstall: `rm /trapos`, reinsert, boot. +- **Drive selection**: pick the **first eligible** drive = present, writable floppy that is + **empty** (`#fs.list(mountPath) == 0`). Empty is preferred over non-empty. `0` empty → error + (distinguish "disk not empty" vs "insert a blank floppy"); `2+` empty → print "multiple blank + disks found, remove all but one" and stop. No interactive insert handling. +- **Missing `/.settings` at creation**: warn + still build a valid (settings-less) installer. + When `/.settings` exists, `settings.save()` first to flush in-session values, then copy. +- **Label**: `TrapOS Installer`. +- **Code shape**: injectable factory `apis/libinstallerdisk.lua` + thin CLI + `programs/trapos-create-installer-disk.lua` + `tests/installer-disk.lua`; ship in + `trapos-core`. Mirrors `apis/libccpm.lua` + `programs/ccpm.lua` + `tests/ccpm.lua`. + +## Conventions to follow + +2-space indent, semicolons, `local function` (per `AGENTS.md`). `require` absolute paths. +Programs support `-version`/`--version` and `-help`/`--help` via +`require('/apis/libversion')().forSelf()`. Colored output guarded by `term.isColor()` (see +`printColored` in `programs/ccpm.lua`). Factory opts default to globals (see `createCcpm(opts)` +in `apis/libccpm.lua`). + +## 1. New: `apis/libinstallerdisk.lua` (testable factory) + +```lua +-- createInstallerDisk(opts) -> api +-- opts.peripheral (default global `peripheral`) -- drive discovery +-- opts.settings (default global `settings`) -- save() flush +-- opts.settingsSourcePath (default '/.settings') -- computer config to clone +-- opts.installerLabel (default 'TrapOS Installer') +-- opts.installUrl (default 'https://trapos.trapcloud.fr/install') +-- (uses real global `fs`; tests sandbox with isolated real dirs, like tests/ccpm.lua) +``` + +Public API: +- `api.buildAutorun()` → returns the `startup.lua` source string (pure, fully unit-testable). +- `api.findTargetDrive()` → `ok, { name, mountPath } | err`. Iterates + `peripheral.find('drive')`/`getNames`, keeps present writable floppies (`isDiskPresent()` + and `hasData()`), filters to empty (`#fs.list(getMountPath()) == 0`), applies the + 0/1/2+ rules above with the distinct error messages. +- `api.create()` → `ok, result | err`. Orchestrates: find drive → `setDiskLabel('TrapOS + Installer')` → if `settingsSourcePath` exists: `settings.save()` then + `fs.copy(settingsSourcePath, mount/.settings)` (delete dest first) else set + `result.settingsWarning = true` → write `buildAutorun()` to `mount/startup.lua`. + `result = { name, mountPath, label, settingsCopied, settingsWarning }`. + +`buildAutorun()` returns (note: copy-then-install, locate-own-disk-by-label, no reboot): + +```lua +-- TrapOS installer disk autorun (generated; do not edit) +local INSTALLER_LABEL = 'TrapOS Installer'; +local INSTALL_URL = 'https://trapos.trapcloud.fr/install'; + +if fs.exists('/trapos') then return; end -- already installed: leave machine alone + +local function installerMount() + for _, name in ipairs(peripheral.getNames()) do + if peripheral.getType(name) == 'drive' then + local drive = peripheral.wrap(name); + if drive.isDiskPresent() and drive.getDiskLabel() == INSTALLER_LABEL then + return drive.getMountPath(); + end + end + end +end + +local mount = installerMount(); +if mount then + local src = fs.combine(mount, '.settings'); + if fs.exists(src) then + fs.delete('/.settings'); + fs.copy(src, '/.settings'); + settings.load(); + end +end + +print('TrapOS Installer: installing TrapOS...'); +shell.run('wget', 'run', INSTALL_URL); +``` + +## 2. New: `programs/trapos-create-installer-disk.lua` (thin CLI) + +- Handles `version`/`-version`/`--version` (prints `v' .. createVersion().forSelf()`) and + `help`/`-help`/`--help` (usage), mirroring `programs/trapos-upgrade.lua`. +- Calls `createInstallerDisk().create()`. On error: print message in red, `return`. On success: + print a green summary (label set, `.settings` cloned / or the missing-settings warning, + which drive), plus a hint: "Insert this disk into a blank computer and reboot it." +- No confirmation prompt (target disk is required to be empty → low risk). + +## 3. New: `tests/installer-disk.lua` + +Follow `tests/ccpm.lua`: `local createLibTest = require('/apis/libtest')`, isolated real-fs +dirs per case, fake `peripheral` injected. Cases: +- `buildAutorun()` contains the `/trapos` sentinel, the label, the install URL, copies + `.settings` before `wget run`, and has no `os.reboot`. +- `findTargetDrive`: zero drives → "insert a blank" error; one non-empty floppy → "not empty" + error; one empty + one non-empty → picks the empty; two empty → "remove all but one" error. +- `create`: sets label, writes `startup.lua` (== `buildAutorun()`) into the fake mount dir, + copies a temp `settingsSourcePath` into `mount/.settings`; missing source → `settingsWarning` + true and no copy. Fake `settings.save` is a recording no-op so the real `/.settings` is never + touched. + +Fake `peripheral`: `getNames`/`getType`/`wrap` returning fake drives whose `getMountPath()` +points at a real temp dir (e.g. `/installer-disk-test/disk-N`) so `fs.copy`/`fs.open`/`fs.list` +operate on real fs; `isDiskPresent`/`hasData` return true; `set/getDiskLabel` store a field. + +## 4. Packaging + +- `packages/trapos-core/ccpm.json`: append `"apis/libinstallerdisk.lua"` and + `"programs/trapos-create-installer-disk.lua"` to `files`; bump `version` `0.6.3` → `0.7.0`. +- `packages/index.json`: mirror `trapos-core` → `0.7.0`. + +## Verification + +1. `just check` — luacheck clean (respect `.luacheckrc` `lua51+cc`) and lychee link check. +2. `just test` (or `runtest tests/installer-disk.lua`) — suite prints `__TRAPOS_TEST_OK__`. +3. Optional headless probe: `just trapos-exec ''` to smoke-test against an emulated drive end-to-end. +4. In-game (human): run `trapos-create-installer-disk` on a configured machine → verify label + `TrapOS Installer`, `startup.lua` + `.settings` present on the floppy. Insert into a blank + computer, reboot → TrapOS installs and `.settings` is cloned. Confirm re-boot with disk + still in is a no-op (because `/trapos` exists), and that `rm /trapos` + reboot reinstalls. + +## Assumptions / caveats + +- Target blank computer has `shell.allow_disk_startup` enabled (CC default `true`) and HTTP + enabled with `trapcloud.fr` reachable/allowed (CC default). +- Literal order is **copy-then-install** (not "install-then-copy"); functionally the disk's + settings still win. The pre-existing "exit the OS shell re-runs `servers.lua`" wart and any + forced reboot are **out of scope** — owned by the separate boot/reboot rework.