Autoapp: packaging, platforms and offline
The design is in design.md; what the gate protects and what it does not is in security.md. This file is about the artifacts: what is built, where it has actually been run, and which of the offline claims have evidence behind them.
The launcher binary
One binary supervises every application on a machine, hosts the engineer, and serves the MCP adapter. It is built the way an application is — one compiled Bun executable with its page inlined and its content-security policy pinned to the hashes the build computed.
bun run --cwd packages/broapp-autoapp build:launcher # this machine
bun run --cwd packages/broapp-autoapp build:all # every target
bun run --cwd packages/broapp-autoapp scripts/build-launcher.ts --all-targets --only-macos
bun run --cwd packages/broapp-autoapp scripts/build-launcher.ts --target linux-x64
| Target | Suffix | Size | Smoke-tested in CI |
|---|---|---|---|
darwin-arm64 | — | . MB | yes — macos-latest, the shipped binary |
darwin-x64 | — | . MB | no runner; compiled on macos-latest only |
linux-x64 | — | . MB | yes — ubuntu-latest |
linux-arm64 | — | . MB | no runner; compiled only |
linux-x64-musl | — | . MB | no runner; compiled only |
windows-x64 | .exe | . MB | yes — windows-latest |
Sizes are from Bun ..--compile --bytecode --minify with --compile --bytecode --minify, measured on macOS. A Notes binary built from the same tree is . MB, so the launcher costs about MB more than an application: almost all of both numbers is the Bun runtime.
A cross-compiled binary is compiled, not run. bun:sqlite links a platform SQLite into the executable, so the only evidence a target works is a run on that platform. The release workflow keeps "compiled" and "smoke-tested" in separate columns for the launcher exactly as ../packaging.md does for an application, and the release notes say which is which.
What a release ships next to it. A launcher can only import a source workspace, and somebody who downloaded a binary has no repository to take one from. scripts/autoapp-starter.ts copies the Notes example with its workspace:* dependencies pointed at the published packages, and the release workflow zips it as notes-starter.zip. import installs those from npm — the one time the launcher reaches the registry — so the ranges are read from the packages' own manifests, never typed in.
The compiled binary resolves differently. Inside a compiled executable Bun.resolveSync answers from the modules embedded in the binary rather than from the directory it is handed, so a dependency check written over it called an installed package missing — but only for a workspace with its own node_modules, which nothing in the repository has. The check now walks node_modules itself, and step of scripts/autoapp-smoke.ts imports a workspace outside the repository through the binary so the case stays covered.
What runs where
| Check | What it does | Runs on |
|---|---|---|
bun test tests/autoapp-*.test.ts | The modules, over real bridges and real child processes | ubuntu-latest, macos-latest, windows-latest |
scripts/autoapp-smoke.ts | The compiled binary: import, serve, the loopback control connection, build, activate, a crash past the switch, recovery, and the control file's removal | the same three |
scripts/autoapp-dry-run.ts | Packs every publishable package, installs them outside the workspace, imports the Notes workspace through them and builds a candidate | the same three |
bun run --cwd packages/broapp-autoapp build:all | Compiles all six targets | ubuntu-latest |
A macOS launcher is signed ad hoc by the build script, and only on a Mac: Bun macos-latest..launcher-macos leaves every macOS binary with a signature that does not verify, and macOS on Apple silicon kills such a binary before it prints anything. So the release workflow builds the two macOS targets in launcher-macos on macos-latest, checks them with codesign --verify --strict, and smoke-tests the Apple silicon binary it archives. Built on Linux, a macOS launcher is labelled UNSIGNED and will not start on a current Mac.
The CI job is autoapp in .github/workflows/ci.yml. Nothing is skipped by platform; if a case ever has to be, it belongs in the table below with its reason, not behind a silent skipIf.
| Skipped check | Platform | Why |
|---|---|---|
autoapp-smoke.ts step , "the control file is gone once nothing is serving" | windows-latest | A Windows console process is terminated rather than signalled, so no exit handler runs and the script removes launcher.json itself. Checking it there would assert the script's own cleanup, not the launcher's. What this leaves untested on Windows is real: a launcher that is killed leaves a stale control file, and an MCP client reading it finds a dead port. It reports "the launcher is not running", which is true but arrived the long way. In the backlog. |
No test is skipped.
Windows
Four differences, each with a comment where the code makes the choice.
- Moving the
currentpointer.renameSyncover an existing file is atomic
on POSIX; on Windows it goes through MoveFileEx with MOVEFILE_REPLACE_EXISTING, which replaces but can fail against a reader holding the file open. tests/autoapp-spec.test.ts moves the pointer between two releases and runs in the Windows matrix job for that reason. The activation journal, not the pointer file, is the record of what was being activated, so a torn pointer is recoverable.
- File modes.
0600is not enforced on Windows.<root>/launcher.json
holds the control port and secret, and its protection there is the user profile directory's ACL rather than the mode bits. This is stated again in security.md.
PATHin the supervision spike. The spike gives its child aPATHthat
leads nowhere, to prove nothing in the chain falls back to a bun on the path. On Windows PATH is also the DLL search path, so the value is empty there rather than /nonexistent. The child still starts, because it is spawned by absolute path from process.execPath.
- Stopping. A Windows console process is not delivered
SIGTERMthe way a
POSIX one is, so the launcher also kills its children from process.on('exit') — Supervisor.killAll, synchronous, because an exit handler cannot await. The smoke script stops a background launcher with taskkill /F /T for the same reason: nothing else takes the child tree with it.
Dependencies, and why there is no offline flag
A candidate build must resolve its imports without a network. It does that from a vendored dependency directory per application, not from a warmed cache:
<root>/apps/<appId>/source/node_modulesis created by
BUN_BE_BUN=1 <launcher> install --production run in the source workspace, once, by import or by create. Those are the two moments dependencies may be fetched, and they are the same moment in the same function: a workspace written from the starter has no lockfile to freeze, and the one that install writes becomes the workspace's.
- Before bundling,
buildCandidatechecks that every top-level dependency in
the workspace's package.json resolves. A missing one is a host build problem naming the package: "dependencies are installed when an application is imported; re-import to add one". The engineer's instructions say the same sentence.
- No flag prevents a socket from opening. Bun ..
BUN_OFFLINEhas noBUN_OFFLINE;
--offline is accepted and still downloads, and --prefer-offline only skips staleness checks. Nothing in this repository claims otherwise, and no guarantee here rests on one.
A built release resolves nothing at run time: Bun.build inlines every dependency into host.js, and the only specifiers left in it are bun:sqlite and Node builtins. tests/autoapp-offline.test.ts asserts both — it deletes the entire source workspace and starts the release anyway.
The three offline tiers
| Tier | What is claimed | The evidence | Tested in CI on |
|---|---|---|---|
| Run offline | An installed application's local features work with no network. | A release serves a read and a write with its whole source workspace deleted, and its host bundle imports only bun: and Node builtins. | ubuntu-latest, macos-latest, windows-latest |
| Edit offline | Changes to an application's source build and activate with no network — as long as they use dependencies that are already installed. The engineer needs a model: with a remote provider it is unavailable offline; with a local one (Ollama) it works. | A source change rebuilds to a new release identity with no network involved, and with a fetch that refuses, the provider test and the engineer's chat both report the provider as unreachable rather than hanging. | the same three |
| Extend dependencies offline | Refused. Adding a dependency needs the network and a re-import. | A build with a package that was never installed fails with the sentence above, naming the package. | the same three |
| Create offline | Refused. The starter is in the binary, but the packages it depends on come from the registry. | A creation whose install fails keeps the workspace, writes no grant it did not earn, and reports what is missing; nothing pretends the dependencies arrived. | the same three |
The engineer's web.search and web.read are outside every tier: they need the network, and a browser — Bun.WebView, which is the system WebKit on macOS and an installed Chrome, Chromium, Edge or Brave on Linux and Windows. Without one, the tools are still offered and answer with what is missing.
What these cases do not do is sever the interface — a test may not, and a flag cannot be trusted to. Each proves the part that is under Broapp's control and says so in the file's own comment. The one thing genuinely outside it, bun install reaching the registry, is confined to import and create by design and stated as such rather than tested.
Publishing
broapp-autoapp is published from .github/workflows/publish.yml, manually, with a reviewer on the environment, like every other package here.
Two of its files are build artifacts rather than git contents, and both are imported by src/launcher/main.ts: the launcher's page, and dist/templates.json — templates/autoapp-starter and templates/autoapp-blank packed by scripts/build-template.ts, embedded in every target's binary the same way the page is. build:launcher builds both before it compiles, publish.yml builds both before it publishes, and scripts/autoapp-dry-run.ts checks that both are in the installed tarball. A package without the templates is a launcher whose New application button has nothing to write.
Two things are particular to it. Its page is a build artifact — not in git — and src/launcher/main.ts imports it, so the workflow builds the page before publishing and files names dist/launcher-page.html explicitly. And it is the one package with Broapp packages as runtime dependencies rather than peers, because the launcher binary really does contain them; they are declared by version range, so they resolve to the workspace copies here and to the registry everywhere else.
scripts/autoapp-dry-run.ts is the check that both of those hold. It packs the tarballs, installs them in a project outside this repository, and drives the installed launcher through import and build against the Notes workspace. A files list that omits the page, or a dependency that only resolves inside the monorepo, fails there rather than after publication.