Four findings from the #103 seam review, each reproduced before being fixed.
The suite depended on the developer's shell
init() read CRAFT_IOS_RUNTIME straight from process.env with no way to override it:
CRAFT_IOS_RUNTIME | bun test src/index.test.ts |
|---|---|
| unset | 12 pass, 0 fail |
| set to an empty dir | 8 pass, 4 fail |
InitOptions.runtimeDir is the escape hatch CLAUDE.md already documents for the sibling variable — AppConfig.craftPath is to CRAFT_BIN what this is to CRAFT_IOS_RUNTIME — and null means no runtime, whatever the environment says. Every init() in the suite now declares it, and the result is identical either way: 22 pass, 0 fail.
And nothing ran that suite
No CI job touched packages/ios. That is how the above survived. The new ios-builder job runs it with CRAFT_IOS_RUNTIME explicitly empty — so it is the job that notices if the dependency returns — and publish-commit gates on it.
installRuntime destroyed a working install before validating the source
rmSync(Runtime/) ran first; the "none of these archives" check ran after. Pointing at an incomplete runtime directory deleted the archives already in place on the way to throwing, leaving project.yml linking -lcraft-ios against a directory that no longer existed. Every SDK is resolved before anything is removed, and there is a test that installs, then fails an install, then asserts the first one survived.
A single simulator slice was copied silently
RUNTIME_ARCHIVES' own comment calls a single-slice archive the break that "only shows up on someone else's laptop" — and the code then shipped one without a word. It still copies, because a one-architecture dev loop is legitimate, but it now names the missing slice and says the project will not link on the other architecture.
Re-running init without a runtime orphaned the archives
The link settings came out of project.yml and Runtime/ stayed on disk, so the directory and the project disagreed. Whatever a run decides, the tree agrees with it.
build() relinked a stale runtime
installRuntime had one caller: init. build never touched Runtime/, and run just calls build. Because the archives are copied rather than symlinked, the dev loop this option exists for did not work — edit Zig, zig build build-ios-all, craft ios run, and xcodebuild relinks the copy from init. The build succeeds, the app launches, the change is absent, and there is no error to chase.
build now refreshes a runtime the project already links, taking the same runtimeDir override. Refresh only: it never installs into a project that links none (those archives would be unreferenced — that is init's decision) and never removes one when the variable is simply unset. Both pinned by tests.
Not covered
The two-slice lipo path needs genuine Mach-O input and lipo does not exist on the Linux runner, so it is not unit-tested. It is covered by building a real generated app, which is how the fat archive was verified when the seam landed.