Files
open-design/tools/pack
a2ba3ee2ad feat(updater): manual cache clear, installer-reinstall floor, and crash-recovery lifecycle (#6032)
* feat(updater): manual cache clear + installer-reinstall floor on the installed outer version

Two disaster-recovery capabilities for the packaged updater:

- Reuse control.launcher.version.min properly: the gate now compares the
  physically installed outer package version (read at check time from the
  outer bundle's open-design-config.json via the launcher launch path;
  OD_UPDATE_INSTALLED_VERSION overrides for tests), not the running payload
  version. When the floor trips - or min is set but the outer version is
  unreadable (conservative) - the updater selects the installer artifact and
  offers a same-version installer reinstall when no newer release exists
  (clamped to min <= candidate so the offer always converges). The status
  snapshot carries a reinstall block {reason, installedVersion, minVersion,
  url}; UpdateDialog/UpdaterPopup render reinstall copy with an optional
  operator jump link. tools-release publish-metadata publishes the block from
  RELEASE_LAUNCHER_VERSION_MIN(_URL) (per-channel repo vars with a _STABLE
  fallback wired in all release workflows) and hard-fails when min exceeds
  the release version; verify-metadata re-checks the published block and
  summary-metadata surfaces the floor.

- Manual clear-cache action (od:update:clear-cache IPC, sidecar clear-cache
  action, Settings > About row with a two-stage inline confirm): resets
  one-shot update state (downloaded release, install freeze) back to idle,
  purges releases/staging/downloads/.back, removes a stale launcher
  attempt.json plus any non-confirmed desktop-handoff journal, and deletes
  non-retained payload versions. Runtime active/lastSuccessful versions,
  retained cleanup entries, install.json, and a confirmed handoff journal
  are never touched; locked files defer through the existing cleanup.json
  retry machinery.

Also: tools-pack --update-action now validates through sidecar-proto's
exported isDesktopUpdateAction so clear-cache reaches the ops surface;
tools-serve updater fixture gains control min/url knobs and CLI flags;
9 new i18n keys across all 19 locales; tools/pack/AGENTS.md documents the
gate semantics and clear-cache boundaries.

Validated locally per the updater matrix (desktop/web/tools-serve/tools-pack
suites, guard, typechecks) plus a mac release-beta closed loop: platform full
spec, same-version reinstall against a real outer/payload version split,
clear-cache recovery, and a real-feed payload update to 0.15.1-beta.4.

* test(updater): close Windows recovery acceptance

* test(updater): mirror Windows same-version reinstall recovery on the mac lane

Port the Windows runSameVersionUpdaterRecoveryAcceptance shape into the mac
full profile: after the payload cold-start acceptance, restart the packaged
app against a fixture publishing an installed-outer floor at the running
payload version and assert the same-version installer reinstall snapshot
(reason/installedVersion/minVersion/url), the popup reinstall learn-more
link, clear-cache back to idle with launcher pointers untouched, explicit
re-check recovery, and the installer open in dry-run mode — macOS has no
silent DMG install to execute, unlike the Windows NSIS transaction lane.
Extends the popup eval expression/validator with reinstallLinkVisible and
records an updaterRecovery block in the smoke summary. Full-profile test
timeout raised 240s -> 360s for the extra app restart.

Validated locally: release-smoke full profile green in 80s with the recovery
summary populated (reinstall block exact, cleared idle, recovered dmg,
dryRun install).

* refactor(release): centralize launcher version floor channel policy in one resolver

Channel policy for control.launcher.version.{min,url} is now managed as one
repo-vars pair per channel (RELEASE_LAUNCHER_VERSION_MIN[_URL]_<CHANNEL>).
Workflows pass the channel pair plus the STABLE pair through verbatim — the
|| fallback expressions are gone from YAML — and the shared resolver in
tools/release/src/storage/launcher-version-floor.ts owns resolution and
validation: pair-level stable fallback for non-stable channels (never mixing
one channel's min with another's url), version format, http(s) url, orphan
url rejection, and the floor <= releaseVersion guard. publish-metadata and
verify-metadata consume the same resolver, so verify re-derives the expected
block from the same channel policy instead of trusting a pre-resolved env.
release-workflows.test.ts pins the verbatim passthrough keys in all five
release lanes and rejects any reintroduced YAML fallback.

* feat(updater): let clear-cache rebuild a provably-owned corrupt update store

A corrupt store is one of the blocking scenarios the manual clear exists to
recover from, yet the action previously bailed on the very store errors it
should heal. clear-cache now rebuilds the update root when — and only when —
ownership is provable: the current-generation sentinel is the proof, and
everything else inside an owned root is updater cache by definition. Roots
without a sentinel, with a foreign-generation marker, or failing IO are never
touched; the original store error surfaces unchanged. Four specs pin the
boundary (corrupt metadata rebuilt, stray entries rebuilt, unowned root
refused with files intact, foreign-generation marker refused).

* fix(launcher): pre-arm delegated payload launches so pre-bookkeeping crashes roll back

A payload that spawned successfully but died before reaching its own launcher
bookkeeping left no attempt.json behind, so every later cold start re-selected
the same broken active pointer and re-delegated to the dead payload — a
permanent dead loop the installer fallback could never surface (found while
designing the crash-rollback acceptance).

The delegating parent now arms attempt.json BEFORE spawning the payload and
passes the pointer as --od-launcher-delegated-* argv. Selection gains an
active-delegated reason: an attempt matching active AND the delegated pointer
is the launch in progress, not a previous failure; only an exact active match
qualifies, so a stale pointer cannot defeat the rollback. Rollback
(last-successful) delegations deliberately carry no marker and never re-arm —
the attempt on disk IS the rollback evidence the child must re-derive.

Covered end to end: packaged outer delegation pre-arms (and skips pre-arm on
rollback), the updater's payload activation pre-arms and threads the pointer
through the deferred relaunch, and confirm clears the attempt for delegated
launches. launcher-proto 18, packaged 237, desktop 264 all green.

* fix(updater): scrub a stale payload install freeze after a rollback

A payload install freezes the updater with installResult.activeVersion
awaiting the relaunch. When that payload crashed and the launcher rolled
back, the app restarts on the OLD version with the freeze still persisted —
every later check became a frozen no-op, so a rolled-back install never saw
another update offer (the crash-rollback acceptance exposed this in its
self-heal phase). Cold-start restore now detects the broken promise —
installResult.activeVersion newer than the running version on a live process
— and drops the freeze and stale install result while keeping the verified
downloaded release actionable. The installer-artifact freeze (no
activeVersion) keeps its existing restore semantics. Red spec:
'clears a stale payload install freeze when the promised relaunch never
became the running version'.

* test(updater): full-lifecycle acceptance — silent startup, crash rollback, coverage map

Two new packaged acceptance tests on both platform lanes (mac validated
locally; win symmetric for the Windows box):

- 'applies a downloaded payload silently on the next cold start': enables the
  daemon-owned allowSilentUpdates through the production GET+PUT
  /api/app-config path, restarts, and asserts the first scheduler tick
  installs, quits, and relaunches with converged pointers — no updater action
  issued.
- 'rolls back a crashing payload and self-heals on the next good update':
  repacks the real payload archive with a crash-before-bookkeeping stub
  (mac: shebang script via ditto; win: a real executable — System32 where.exe
  — via the installed app's bundled 7z, since a script cannot stand in for an
  exe), drives the update through the popup, asserts the pre-armed attempt
  survives the crash as rollback evidence, cold-starts into the rolled-back
  base version (degraded steady state pinned: broken pointer stays active
  with its attempt), then self-heals via a version-bumped healthy payload —
  real recovery releases ship as version+1.

Spec hygiene: resetPackagedRuntimeState wipes launcher/runtime/daemon state
before each lifecycle test (uninstall keeps runtime data by design, and
leftover pointers silently delegate fresh installs onto stale payloads);
mac waitForHealthyDesktopVersion gains win's requireSettledLauncher opt-out
for rollback waits.

docs/testing/updater-lifecycle.md is the new lifecycle-to-test coverage map
(incl. deliberate manual-only nodes), linked from tools/pack/AGENTS.md and
the e2e coverage status doc.

* fix(updater): harden Windows full-lifecycle acceptance

* test(release): make channel-prepare hermetic against local git tags

prepare-preview derives its latest-stable floor from the clone's real
open-design-v* tags, so the channel-prepare tests were green on tagless CI
checkouts but permanently red on any developer clone once shipped stable
tags advanced past main's package version (0.15.1 vs v0.15.1/v0.16.0 today).
The spawned scripts now run with GIT_DIR pointing at a throwaway repo
holding exactly the tags each test declares, and a new negative case pins
the floor rejection itself (packaged version below a v99.0.0 stable tag).

* test(e2e): model delegated payload launch

Pass the updater's pre-armed payload pointer into the cross-runtime launcher simulation so it represents the spawned payload process instead of a later crash-recovery launch.

Generated-By: looper 0.11.0 (runner=fixer, agent=codex)

* test(web): wait for question form readiness

Wait for the asynchronously derived submit-disabled state before asserting it, removing the suite-order race observed by the Web workspace tests check.

Generated-By: looper 0.11.0 (runner=fixer, agent=codex)

* fix(updater): preserve recovery state across locks and restarts

Generated-By: looper 0.11.0 (runner=fixer, agent=codex)

---------

Co-authored-by: Looper <looper@noreply.github.com>
2026-07-24 05:18:48 +00:00
..

tools/pack

Local packaging control plane for Open Design.

tools-pack is the cross-platform packaging and smoke-lifecycle control plane. The macOS commands include:

  • tools-pack mac build --to all
  • tools-pack mac build --to app|dmg|zip
  • tools-pack mac build --to all --signed
  • tools-pack mac build --to all --portable for release artifacts that must not bake local tools-pack runtime paths
  • tools-pack mac install
  • tools-pack mac start
  • tools-pack mac stop
  • tools-pack mac logs
  • tools-pack mac uninstall
  • tools-pack mac cleanup

Build artifacts are namespace-scoped under .tmp/tools-pack/out/mac/namespaces/<namespace>/. Public release bundles keep channel-distinct identities: Open Design.app, Open Design Beta.app, Open Design Prerelease.app, or Open Design Preview.app. Local tools-pack install adds the developer namespace so installs can coexist without affecting runtime data/log/cache paths.

Packaged runtime state is namespace-scoped under .tmp/tools-pack/runtime/mac/namespaces/<namespace>/:

  • Packaged daemon storage is governed only by the root AGENTS.md section Daemon data directory contract. Before changing or documenting packaged storage propagation, you MUST read that section; this README MUST NOT restate it.
  • logs/ contains packaged process logs for desktop, web, and daemon.
  • runtime/ is the sidecar runtime base used by the packaged desktop/web/daemon process group.
  • cache/ is reserved for namespace-local packaged cache state.
  • user-data/ is the Electron/Chromium userData root, with user-data/session/ used for sessionData.

Finder/manual launches cannot carry argv stamps on the root desktop process. To keep process fallback safe, apps/packaged writes runtime/desktop-root.json with the desktop stamp, PID, executable path, app path, and log path. tools-pack mac stop trusts that marker only when namespace/stamp/PID/command validation passes; otherwise it reports the unmanaged/not-owned reason instead of killing unknown processes.

tools-pack mac stop validation

  • If the marker is absent, stop reports not-running.
  • If the marker PID is gone, stop reports not-running and clears the stale marker.
  • If the marker PID was reused by an unrelated process, stop reports unmanaged.
  • If the marker namespace, stamp, runtime root, or command does not match the current namespace, stop reports unmanaged.

This keeps stop from killing processes outside the current namespace.

Packaged desktop also writes main-process lifecycle logs to logs/desktop/latest.log so Finder/manual launches are diagnosable. This log is intentionally scoped to packaged desktop startup/shutdown/process errors and does not capture web/renderer console output.

The packaged daemon path contract lives only in the root AGENTS.md section Daemon data directory contract. Before changing or documenting packaged path propagation, you MUST read that section; this README MUST NOT restate it.

Packaged desktop checks release metadata, verifies the downloaded artifact, and exposes update actions through desktop IPC. Launcher-based builds prefer verified payload activation followed by relaunch; installer replacement remains the fallback for artifact types and older builds that cannot apply a payload in place.

Electron-builder resources live under tools/pack/resources/mac/. The current logo is staged there as the mac icon/DMG placeholder so future design-provided assets can replace the resource files without changing packaging code.

Local developer artifacts bake the tools-pack namespace runtime root so tools-pack mac start/stop/logs/cleanup can manage them from the repo. Release artifacts use --portable so the installed app resolves namespace data/log/runtime/user-data from the user's Electron userData root instead of the build machine's .tmp path.

macOS compatibility notes

  • tools-pack mac build --portable --to zip is the safest manual-install artifact for Intel Macs. This path was smoke-tested on macOS 12.7.6 Monterey on a 2015 Intel iMac and the app launched successfully from /Applications.
  • Finder/manual launches on macOS may not inherit your shell-managed PATH. If packaged Open Design cannot detect agent CLIs that work in Terminal, expose those binaries to the GUI login environment or launch the packaged app from a shell session that already sees them.

Windows

Local lifecycle commands:

  • tools-pack win build --to dir for fast unpacked smoke builds.
  • tools-pack win build --to nsis for installer builds.
  • tools-pack win build --to all for both outputs.
  • tools-pack win install
  • tools-pack win start
  • tools-pack win inspect --expr "document.title"
  • tools-pack win logs
  • tools-pack win stop
  • tools-pack win cleanup
  • tools-pack win list
  • tools-pack win reset

Build artifacts are namespace-scoped under .tmp/tools-pack/out/win/namespaces/<namespace>/. Packaged runtime state is namespace-scoped under .tmp/tools-pack/runtime/win/namespaces/<namespace>/. --to dir may point built-app.json at an immutable cached win-unpacked executable while keeping namespace-local config and runtime paths outside that cache entry.

Linux

Local lifecycle commands:

  • tools-pack linux build --to all (default; produces AppImage)
  • tools-pack linux build --to appimage (explicit AppImage)
  • tools-pack linux build --to dir (unpacked output for fast iteration)
  • tools-pack linux build --containerized (run electron-builder inside electronuserland/builder:base Docker for a wider glibc compatibility target — requires Docker)
  • tools-pack linux build --to all --portable (release artifacts that must not bake local tools-pack runtime paths)
  • tools-pack linux install
  • tools-pack linux install --headless (install the headless launcher script instead of the AppImage)
  • tools-pack linux start
  • tools-pack linux start --headless (start the headless entry — daemon + web, no Electron)
  • tools-pack linux stop
  • tools-pack linux stop --headless (stop a running headless process)
  • tools-pack linux inspect (desktop status, eval, and screenshot for AppImage mode)
  • tools-pack linux inspect --headless (status only)
  • tools-pack linux logs
  • tools-pack linux uninstall
  • tools-pack linux uninstall --headless
  • tools-pack linux cleanup
  • tools-pack linux cleanup --headless

Build artifacts are namespace-scoped under .tmp/tools-pack/out/linux/namespaces/<namespace>/. Packaged logs, sidecar runtime, cache, and Electron user-data are namespace-scoped under the tools-pack runtime root. Daemon storage follows only the root AGENTS.md Daemon data directory contract. Containerized build cache lives under .tmp/tools-pack/.docker-cache/{electron,electron-builder}/.

Local installs use XDG paths:

  • AppImage: ~/.local/bin/Open-Design.<namespace>.AppImage
  • Menu entry: ~/.local/share/applications/open-design-<namespace>.desktop
  • Icon: ~/.local/share/icons/hicolor/512x512/apps/open-design-<namespace>.png

The <namespace> suffix is unconditional so multiple developer namespaces can coexist on the same desktop. The .desktop file registers the od:// scheme via MimeType=x-scheme-handler/od; and pre-sets OD_PACKAGED_NAMESPACE on the Exec= line so menu launches identify the correct namespace.

Headless mode (--headless)

Headless mode targets environments without a display (WSL2, headless servers, CI) where Electron can't run. If you have a desktop, use the AppImage; if you're SSH'd into a machine or in WSL, use headless.

--headless makes install, start, stop, uninstall, and cleanup operate on the headless entry (@open-design/packaged/dist/headless.mjs) instead of the AppImage. Headless mode runs daemon + web without Electron.

  • install --headless writes a shell launcher at ~/.local/bin/open-design-headless-<namespace> that bakes in the namespace and resource paths. The launcher is self-contained, but the assembled app directory at those paths must remain in place — don't move it after install.
  • start --headless spawns the headless process directly, redirects stdout/stderr to logs/desktop/latest.log, and waits up to 95s (35s for identity marker + 60s for web URL) before returning.
  • stop --headless reads the same runtime/desktop-root.json identity marker as the AppImage path, validates stamp.source === PACKAGED, sends a graceful SHUTDOWN over IPC, then terminates the process tree. It does not perform the AppImage-specific process-command check.
  • inspect --headless returns status only. Eval and screenshot require AppImage mode because there is no Electron renderer in headless mode.
  • uninstall --headless removes the headless launcher after a safe stop.
  • cleanup --headless stops the headless process before removing namespace output/runtime roots.

logs always reads logs/desktop/latest.log regardless of mode, so headless output is visible via tools-pack linux logs.

AppImage launch mode (FUSE caveat)

tools-pack linux start always spawns the AppImage with --appimage-extract-and-run. Smoke testing on Ubuntu 24.04 and Arch Linux showed that direct FUSE-mounted AppImage launches make Node module loads (Express, better-sqlite3, etc.) slow enough that the daemon sidecar consistently failed to clear apps/packaged's 35-second startup timeout. Extract-and-run unpacks the AppImage into /tmp/appimage_extracted_<hex>/ and exec's the inner Electron from there, bypassing FUSE and getting daemon boot in under 5 seconds — roughly an order-of-magnitude improvement.

Implication for end-users: if launching the installed AppImage manually (not via tools-pack linux start), pass --appimage-extract-and-run yourself, or rely on a desktop launcher / appimage-launcher daemon that handles extract-and-run automatically.

Optional system tools

tools-pack linux install and tools-pack linux uninstall invoke update-desktop-database and gtk-update-icon-cache as best-effort post-hooks. Either tool being absent (iconCache: "missing" in the output) is harmless — the icon and menu entry still work, the cache just isn't refreshed. Install via your distro:

  • Arch / CachyOS: sudo pacman -S desktop-file-utils gtk-update-icon-cache
  • Debian / Ubuntu: sudo apt install desktop-file-utils gtk-update-icon-cache
  • Fedora: sudo dnf install desktop-file-utils gtk-update-icon-cache

libfuse2 is needed for FUSE-mounted AppImage launch (the default mode when running an AppImage directly without --appimage-extract-and-run). tools-pack linux start always uses extract-and-run and bypasses FUSE entirely, so it does not need libfuse2. Most modern distros ship libfuse2 by default; older Ubuntu LTS hosts may need sudo apt install libfuse2t64 (or libfuse2 on pre-24.04).

Sandbox / chrome-sandbox

Electron 41 on Linux requires kernel.unprivileged_userns_clone=1 (default on Arch, Ubuntu 24+, Debian 12+) or AppImage's --no-sandbox fallback. Most modern distros need no extra setup.

Distro compatibility target

AppImages built natively on a rolling distro (e.g., Arch / CachyOS) link against recent glibc and may not run on stable distros (Ubuntu 22.04, Debian 12). Use --containerized to build against the electronuserland/builder:base baseline (Ubuntu 18.04 / glibc 2.27), which is the compatibility target for release AppImages rather than a guarantee for every Linux distribution.

Verified smoke coverage in this repository currently includes:

  • PR lane: Ubuntu GitHub-hosted runner, headless Linux runtime.
  • Release lane: Ubuntu GitHub-hosted runner, containerized AppImage build plus Xvfb AppImage runtime smoke when the Linux release lane is enabled.
  • Manual AppImage behavior used to choose --appimage-extract-and-run: Ubuntu 24.04 and Arch Linux.

Format choice: why AppImage first

Linux desktop apps in this space split across formats: VS Code ships .deb + .rpm + Snap; Discord ships AppImage + .deb; Slack ships .deb + .rpm; Cursor and Obsidian ship AppImage. We start with AppImage because one artifact can cover the widest glibc-compatible target without distro repositories, store packaging, signing infrastructure, or per-format install scripts, and it integrates cleanly with the namespace-scoped install layout. .deb / .rpm / Snap / Flatpak can land incrementally when user demand justifies the extra release ownership.

Out of scope (later phases)

  • AppImage signing (--signed) — deferred pending a GPG key infrastructure decision and a user-facing verification flow design (no ETA).
  • AppImage auto-update feed (latest-linux.yml) — the linux electron-builder config has no publish block wired, so a generated feed would point users at a feed that never updates. Tracked alongside signing.
  • Additional package formats: .deb, .rpm, Snap, Flatpak — deferred until there is demand and an owner for per-distro metadata, signing/store/repository plumbing, install/remove hooks, and release validation.
  • Full Linux AppImage PR smoke remains release-lane only; PR validation runs the Linux headless packaged smoke because it does not require a display server.

--to dmg is manual-install DMG output only. Any builder-generated updater metadata such as latest-mac.yml or .blockmap files is treated as scratch and cleaned from the builder directory; release-beta generates the authoritative latest-mac.yml feed during release asset preparation, pointing at the update ZIP.