Decisions
One line per non obvious choice: the decision, then the reason. The
decisions of the app work live in macos/DECISIONS.md. The 0.5.5, 0.5 and 0.4
runs come first, the 0.3 easy install run follows.
0.5.5: about, help and docs links
benchbar docschecks the page with onecurl -I(3 seconds) before opening it: a 404 opens the start page with a warning, and no answer (offline, DNS, a slow network) opens the page anyway, so the check can only improve on a plainopen, never block it.benchbar docs --printprints the URL without opening it or touching the network: agents and scripts want the link, not a browser window.- Topics are short names with aliases (
doctorandrepair,teams,profilesandlock), kept in one table inlib/frappe-local/docs.sh; an unknown topic exits 1 with the list, so a typo never opens a wrong page. - The doctor
see:line goes under every[FAIL], afterfix:, and never under[WARN]or in--json: AGENTS.md promises parsers the existing lines byte for byte, and a new line starting withsee:is ignored by anything that reads[FAIL]andfix:. Every check id has an anchor in the doctor guide, so no per check table is needed. --versionkeepsbenchbar X.Y.Zas the first line and addsBenchBar app X.Y.Z (path)only when an app is installed: the app,install.shand the tests read the first line. The app version is read from the XML Info.plist with awk, the helperreportalready used, instead ofplutilordefaults, so the tests can fake an app with a small file.report --jsonsends the human lines to stderr and prints one object on stdout, withschema_versionandcli_versionlike every other document;redactionscounts the lines where something was replaced (the sum of what REDACTIONS.txt lists).--jsonand--printtogether are refused.- The bug report form’s version fields are prefilled by the ids
bug_report.ymluses:macosandversion.
0.5.5: repo foundation
- The Code of Conduct’s contact is a private advisory on GitHub until a contact email exists: the git address is a noreply address that cannot receive mail, and an advisory is private to the maintainers.
- The AI policy welcomes AI assisted pull requests with a one line disclosure instead of banning them: BenchBar is itself built with coding agents (AGENTS.md), and a ban nobody can check only hides the tool.
good first issueitems are kept for people working by hand: they exist to teach the codebase, and an agent solving them teaches nobody.- The bug form requires the
benchbar reportzip and doctor output: those two answer most first questions, and the zip is already redacted. - Blank issues are off and questions go to Discussions Q&A: the issue list stays a list of things to fix.
scripts/social-preview.swiftuses AppKit, not Python: the Mac has no imaging library by default and the repo already builds with Swift;scripts/app-icon.pyonly writes SVG text.- The social preview and the og:image come from one script with a size argument: one design, two sizes (1280x640 for GitHub, 1200x630 for the docs site), no drift between them.
- The runner strip in the preview is cut from
docs/images/runners.png, with the grey tiles made transparent: the frames stay the ones the app draws, without rendering them again. - Dependabot watches
/macosfor Swift: the package list lives inmacos/project.yml(XcodeGen) and the generated.xcodeprojis not committed, so Swift updates only start once aPackage.resolvedis in the repo. - Vouch waits until drive-by pull requests appear: a trust list for a project with one maintainer and few contributors is ceremony.
0.5.5: docs site
- The site reads
docs/in place through a glob loader withbase: ".."insite/src/content.config.ts, not a symlink: Starlight 0.42 supports any Astro loader for thedocscollection, a symlink breaks on Windows clones and in some editors, andentry.filePathstays a real repo path, so “Last updated” comes from the file’s own git history. editLink.baseUrlishttps://github.com/askysh/benchbar/edit/main/site/: Starlight appendsentry.filePath, which is../docs/<page>.mdrelative tosite/, and the URL resolves toedit/main/docs/<page>.md.- Page URLs are the lowercased file paths under
docs/(DECISIONS.mdis/decisions/), set by the loader’sgenerateId, so the URLs the README and the app link to follow the file names. ROADMAP.mdandCONTRIBUTING.mdstay at the repo root with no frontmatter;site/scripts/prepare.mjscopies them intosite/.generated/(ignored by git) before each build with a title from their#heading, their own edit link and last commit date. A missingCONTRIBUTING.mdis skipped, and the sidebar leaves it out.- Links in
docs/stay relative.mdpaths so GitHub renders them; a remark plugin (site/src/remark-repo-links.mjs) turns them into site URLs, and links to other repo files into GitHub links, at build time. - Screenshots in the moved README content use markdown image syntax, not
<img width>: Astro optimises markdown images (webp, a fifth of the size) and ignores raw<img>paths. - The heading slugs come from github-slugger, which keeps underscores, so
### port_clashis#port_clash;tests/test-docs.shchecks a heading for every check id inFL_CHECK_ORDERplusport_block. - The CLI reference documents only flags that
benchbar --helpshows (tests/test-docs.shchecks it).report --out DIRandapp update --jsonwithout--dry-runexist in the code but not in--help, so they are left out until--helpnames them. - Astro and Starlight run under Bun (
bun --bun astro build): Astro 7 needs Node 22.12 or later, and Bun alone avoids a second runtime in CI and on a contributor’s Mac.@astrojs/markdown-remarkis a direct dependency because Astro 7 only runs remark plugins through it. - The docs workflow is the official Astro Pages workflow with its steps written out (
withastro/actionwould runbun installwithout--frozen-lockfileand has no place for the link check). - Docs only changes skip the CLI and app jobs through a
changesjob (dorny/paths-filter) andif:conditions, not a workflowpaths-ignore: a workflow that never starts leaves a required check pending forever, a skipped job counts as a pass. The matrix shards skip step by step on an Ubuntu runner, because a matrix job skipped as a whole reports one check under its unexpanded name. A push tomainskips the same way: the pull request already ran everything, andworkflow_dispatchruns all jobs. - The docs workflow runs on every pull request and skips its build job the same way when no docs file changed, so “Docs build” can be required too. It also runs
tests/test-docs.sh, which the CLI shards skip on a docs only change. - The link check is linkinator over
site/distwith fragment checks on, skipping every external URL: GitHub and the site’s own domain (before DNS) would make the check flaky, and the internal links are the ones a docs change breaks. - The Umami script uses the benchbar.akashmishra.com website of Akash’s Umami, limited with
data-domainsso local previews are not counted, andog:imagepoints athttps://benchbar.akashmishra.com/og.png, which the build copies fromdocs/images/og-image.pngwhen that file exists.
0.5.5: readme
- The README keeps only what a visitor needs to decide and start (why, install, quick start, features) and links four deep pages of the docs site: a 3,200 word README had become the manual, which nobody can navigate on GitHub, and the docs site now has search and a sidebar.
- The README runs about 800 words, not the 1,100 the brief suggested: the fixed order and one line per item leave no room for more without padding.
- The logo and the hero image switch with the reader’s appearance through
<picture>; the icons are exported fromAppIcon.iconwith Icon Composer’sictool(Default and Dark renditions), so they match the app icon exactly.
0.5: app installs
Designed against frappe/bench develop (c9d1250) and frappe version-15; the code moved on since the design, and the code won where they disagreed:
- Everything per bench goes through the existing per bench state (
.benchbar/benches/<name>-<hash>.env,fl_bstate_get/set); the site app cache is a sibling file,<name>-<hash>.site-apps, not the<name>.site-appsof the design. - install-app and migrate run with the bench’s own Redis up (
fl_bench_redis_up/downfrom 0.4), because frappe v16 connects to it during both; the exit handler stops what was started, however the command ends. - Never
get-app --resolve-deps: it readshooks.pythrough the GitHub API (no private repos) and canrmtreean existing app.required_appsis read from the localhooks.pyafter the clone, resolved throughapps.tsv, and cloned after a second plan and question. - Never
get-app --overwrite, and stdin is/dev/null: an app that exists is reported with the manualgit fetchandgit checkout, and a surpriseclick.confirmfails instead of hanging. --skip-assets, then onebench build --app X(or--apps a,bwith the required apps): one build, after every dependency is in place.- Access is checked before any change with
git ls-remoteunderGIT_TERMINAL_PROMPT=0andGIT_SSH_COMMAND='ssh -o BatchMode=yes': a private repo without a key or token fails in a second with a fix line (ssh -T git@HOSTandssh-addfor SSH, which also covers host aliases; the SSH URL orgh auth setup-gitfor HTTPS) instead of waiting on a hidden prompt. - A repo URL with a user name or token is refused: it would land in
.git/config,app list --jsonand later a committed lockfile. Remote URLs are always shown without userinfo. get-appis always given the resolved URL, also for names likeerpnextthat bench could resolve itself: the preflight checked exactly that URL, and apps.tsv entries without a repo map togithub.com/frappe/<name>, which is what bench uses.- The app name is the repo name (lower case,
-as_) or--name; the folder bench actually created is found by comparingapps/before and after, soRavenbecomingravenis followed. - A get-app that fails half way moves the new folder to
.benchbar/backups/<ts>/apps/only whilesites/apps.txtdoes not list it. Once bench has listed it, the folder and the line stay and the fix (bench remove-app NAME) is printed: benchbar never editssites/(AGENTS.md), and doctor’sapps_txtcheck keeps reporting it. The first version removed the line itself; the review before the 0.5 PR reverted that. - A dirty app is left alone by
add(it only installs on sites) but refused byupdate:addnever touches the code,updatemoves it. app updateisgit fetchplusmerge --ff-only, neverbench update: one app changes, nothing is rebased or reset. A diverged branch stops with thegit log --graphto look at; an app ahead of its remote is “unchanged”. On a failure thereset --hard OLDis printed, never run.- A shallow clone is fetched with
--shallow-since=@<HEAD time - 1s>, soold..newexists locally for the changelog and the fast forward check. update --dry-runrunsgit fetch: the changelog needs the new commits. Only the app’s.gitchanges; the working tree, the sites and benchbar’s state do not, and the test snapshots exactly that.- Every site that has the app is backed up (
bench --site S backup) before the merge,--skip-backupopts out: the repo rule is a backup before every change, and migrate is the step that can hurt data. - Which sites have an app comes from
bench --site S list-apps --format jsonwith a 15 second limit per site, cached per bench: it needs MariaDB, and doctor (on a timer in the app),app list --no-sitesand the lockfile check must never need it. A failed read keeps the last good list and says so insites_error. app update --jsonis only the plan (--dry-run --json): the app shows the changelog, then runs the update with--yesand a live log, and a JSON stream of an apply would need its own format.- The doctor checks
apps_txtandapp_branch_policyhave no repair action: fixing either means changing which code runs, a person’s call. --sitestays the global option, soapp install NAME --site Sandapp add X --site Sparse like every other command.- The fake bench of the tests now has an
apps/erpnextfolder: it always listed erpnext inapps.txt, which the newapps_txtcheck rightly fails. app addtakes the app name from the repo name (or--name), never fromapps.tsv, when given a URL:apps.tsvis only the catalog for names.- The git mock passes everything to the real git under
MOCK_GIT_REAL=1, and the bench mock’sget-appthen really clones (--origin upstream, shallow withMOCK_BENCH_SHALLOW=1) from bare repos in the test folder overfile://.MOCK_GIT_LSREMOTE_EXITfakes a private repo. No test touches the network.
0.5: team profiles
- A team profile lives outside BenchBar:
~/.config/benchbar/profiles/NAME.toml, then each folder ofBENCHBAR_PROFILE_PATH(colon separated), so an organisation’s repos and branches never land inconfig/or in this repo, and a team shares its recipe through its own config repo. - Built in profiles are looked up first, and a team file named like one (
v15-lts.toml) is reported as invalid instead of loaded:--profile v15-ltsmust mean the same thing on every Mac. A later file with an earlier file’s name is hidden, andprofile listsays so. - A team profile names a built in
baseand never its own Python, Node or MariaDB: those formulae, the MariaDB range and the doctor checks are tested per built in profile. Onlyfrappe_branchmay be overridden. PROFILEin the bench’s state stays the base, andTEAM_PROFILEnames the team profile: every daily command, doctor included, works from the base alone, and follows the team’s app branches (theapp_branch_policycheck,app add NAME) while the file exists and parses. A missing or broken team file falls back to the base instead of breaking doctor.- The team’s apps plug into the existing policy lookup (
fl_lookup_app_policyasks the loaded team profile first), so phase 01,app add NAMEand required apps all resolve a team app the same way. - The phases get the team profile’s name (
--profile acme) and load it themselves: phase 00 needs only the base, phase 01 the apps, site and pins, andbenchbar installstays a thin wrapper. bundleand[[apps]]exclude each other: a bundle is the built in app list,[[apps]]the team’s own, and mixing them would make the order ofapps.txtdepend on rules nobody wrote down.- An optional
commitin[[apps]]goes through phase 01’s existing pin (fetch --all --tags,checkout COMMIT), which leaves a detached HEAD; exact pins for identical benches are the lockfile’s job, a profile is the recipe. profile create --from-benchreads the bench only: remote URLs without user info, current branches (the policy branch for a detached app, else the app is skipped with a warning), the base fromapps/frappe’s version, the default site name and the scheduler choice. It writes no commits and nothing fromsites/except that name; the written file is parsed back before it is offered, and an existing file is shown as a diff and backed up.- The format is the strict TOML subset of
lib/frappe-local/toml.sh, shared with the lockfile: one parser, and a file this parser accepts is valid TOML.schema = 1is optional in a profile (written bycreate) and refused when it is another number. profile createtakes the checkout’s lock like other writing commands;profile listandshowdo not.
0.5: the team lockfile
- The lockfile is an awk parsed TOML subset (
toml.sh, shared with team profiles), nottomllib: the Command Line Tools’/usr/bin/python3is 3.9 without it, the bench’s env Python is missing exactly when a check matters, doctor stays pure shell, and a fallback would mean two parsers that can disagree. Every refusal names the line (benchbar.toml:3: not supported: inline tables). - The code lives in
teamlock.sh:lock.shalready is the checkout’s run lock, and the two must not be confused. - Lookup is
--lock PATH,BENCHBAR_LOCK,LOCK_FILEin the bench’s own state file (fl_bstate_set, not the<name>.envof the design), then<bench>/benchbar.toml. A relative--lockis taken from the current folder when it exists there, else from the bench, so--lock apps/acme/benchbar.tomlworks from anywhere. The path is remembered by write, check and apply (not in a dry run);BENCHBAR_LOCKnever is, andlist --jsonignores it since it names one bench, not all. - The app that holds the lockfile gets no
commit: committing the lockfile moves that app’s HEAD, so its pin would always be one commit behind and every teammate would seecommit_ahead. - Frappe is an ordinary
[[app]]: one code path, and the entry order isapps.txt’s, soapplyclones in dependency order. - Repos compare as host and path (
git@github.com:acme/x.gitequalshttps://github.com/acme/x): teammates clone over SSH or HTTPS as they like. An SSH host alias from~/.ssh/configis not resolved and reads as another repo (a warning only). lock checkis read only and offline: git reads,apps.txt, and the site app cache thatapp listfills, so it works with MariaDB stopped. The one outside call isbench --version(15 seconds at most) forbench_version_mismatch; doctor’slock_driftleaves that kind out and calls nothing.app_missingis the onlyfaillevel: the bench lacks code the team’s sites need. Everything else is a warning, and doctor reports the whole lock as onelock_driftwarning pointing atlock check.- Drift exits 1 like a failing doctor, through the dispatcher (
|| exit 1), solock check --jsonprints only the JSON. lock applystops at code: nonew-site,install-app,migrateorbench update. Missing sites and site apps are printed asbenchbar site addandbenchbar app installlines, and a changed app ends with the reminder to migrate: site data is the user’s to change.- A pinned commit is reached with
git merge --ff-only, aftergit fetch REMOTE SHAwhen it is not local (protocol v2 serves any reachable commit), and--unshallowfor a shallow clone as the fallback. The design’sfetch --depth 1 SHAwas dropped: a depth 1 fetch cuts the history between HEAD and the pin, so the fast forward check fails. - An app that is ahead of its pin, diverged from it, or dirty is skipped with a warning and apply still exits 0: local work outranks the lock, and a second apply must say
unchanged, not fail forever. - A branch switch happens only on a clean tree: the locked branch is fetched into its remote tracking ref (
--depth 1for a shallow clone), then checked out as the local branch when there is one, else created with--track.checkout -Bis never used on an existing branch, since it would drop that branch’s local commits. - A freshly cloned app whose branch tip is ahead of the pin is moved to the pin with
checkout -B BRANCH SHA: the clone is seconds old and holds no local work, so this is not the reset the rules forbid. - Cloning goes through
app add’s own steps (access check,get-app --skip-assets, the half clone rollback);setup requirements --pythonand--noderun only for apps whose code changed in place (get-app already did it for new clones), and onebench build --apps a,bcovers all of them. lock writerefreshes the site app lists from bench (it is the one lock command that may ask the database) and falls back to the cache with a warning. A dirty or detached app is refused unless--allow-dirty, which pins the commit as it is (a detached app then takes its policy branch).- The written file is parsed back before it is shown, so
writecan never produce a filecheckrejects. decisions of the app work live inmacos/DECISIONS.md. The 0.5 and 0.4 runs come first, the 0.3 easy install run follows.
0.5: benchbar pull
Upstream facts behind the design, frappe version-15 and version-16 (the same in both), from the design notes:
- F1
frappe/commands/site.pyrestore:--encryption-keyis the gpg passphrase for-encbackup files, not the siteencryption_key; without it restore readsbackup_encryption_keyfrom the site config (get_or_generate_backup_encryption_key()infrappe/utils/backups.py). - F2
decrypt_backupinfrappe/utils/backups.pyrunsgpg --yes --passphrase {passphrase} ...in a shell, so the passphrase shows in the process list. - F3
frappe/utils/password.py:get_encryption_key()generates and saves a new key whenencryption_keyis missing, anddecrypt()then fails with “Encryption key is invalid! Please check site_config.json”. Restore never copies the key. - F4
restore_backupcalls_new_site(..., force=True): on an existing site it recreates the database; a fresh name is created with a generateddb_name(get_sitesinfrappe/utils/bench_helper.pyaccepts any name). - F5
_new_siteinstalls only frappe,install_appsand--install-app; restore never checks that the dumped apps exist on the bench. - F6 restore
--forceonly skips the downgrade question and turns a missing__Authinto a warning; it does nothing for missing apps. - F7
bench backupcallsnew_backup(force=True), which first runsdelete_temp_backups()and removes files inprivate/backupsolder thankeep_backups_for_hours(default 23). - F8 migrate’s
pre_schema_updatesandafter_migratecallget_hooks(app_name=app)for every installed app;_load_app_hooksre-raisesImportErrorfor an app not on the bench.remove-from-installed-appsedits only the list. - F9
are_emails_muted()isflags.mute_emails or cint(conf.get("mute_emails")); there is nodisable_emailkey. The scheduler stops withpause_schedulerin the config ordisable-scheduler.
Decisions:
- The default source is the latest existing backup: a plain pull writes nothing on the server.
--new-backupneeds the production site name typed, even with--yes, becausebench backupalso deletes older backups (F7). Without a terminal the name comes from--confirm-site SITE, still typed by whoever runs the command;--yesalone never takes a backup.- Restore goes only into a new site name;
--replacefirst runsbench --site NAME backup --with-fileslocally, because restore over an existing site recreates its database (F4). - The keys come from the live production
site_config.json, read by a Python snippet on the server that prints only the one value asked for, not from the downloadedsite_config_backup.json(the design): the database password and Redis settings of production never reach the Mac, and it also works when the config backup is encrypted.--from-dirhas no server and reads thesite_config_backup.jsonin the folder. - Only
encryption_keyis written into the new site (F3);backup_encryption_keyis used only as the gpg passphrase (F1), so the copy’s own backups are not encrypted with the production key. - The key flows through pipes only (ssh stdout into a Python snippet’s stdin); it is never on a command line, in a shell variable, on disk outside
site_config.json, in the output or in the log. The snippet writes the file the wayupdate_site_configdoes (indent 1, sorted keys, temp file and rename) with plain Python, so it needs no frappe import and runs under the test mocks. - The MariaDB root password and the Administrator password reach frappe on stdin: a small wrapper reads the secret, puts it into
sys.argvinside the process and callsfrappe.utils.bench_helper.main(), which is whatbenchitself runs fromsites/. The design passed--db-root-passwordon the command line (accepted in 0.3 fornew-site); the wrapper keeps it out ofpsat no cost. The option used is--mariadb-root-password, the spellingnew-sitealready uses. - Encrypted backups are decrypted here with
gpg --batch --pinentry-mode loopback --passphrase-fd 0(F2); gpg is needed only then, and a missing one stops before the download withbrew install gnupg. - Restore
--forceis never passed: it does not help with missing apps (F6) and would hide a downgrade. Instead pull stops before the download when production frappe is newer than the bench’s. - Missing apps stop the run before the download with the
bench get-app --branch B URLcommands (F5, F8). The design offeredbenchbar app add; that command is being built separately, so pull only prints the commands for now. --skip-apprunsremove-from-installed-appsand prints that its doctypes and tables stay as orphans (F8). frappe cannot be skipped.- Tokens in an app’s git remote URL (
https://user:token@host/...) are cut before the URL is shown or logged. - The production app list comes from the text form of
list-apps(name, version, branch), parsed by its first columns;--from-dirreads theinstalled_appsglobal from the dump (tabDefaultValue, whatfrappe.get_installed_apps()reads), after decryption when the dump is encrypted. - Migrate runs when any app version differs from production, is unknown, or an app was skipped; the design ran it only when local code was newer, but an older local app with the same schema is the rare case and a needless migrate is cheap.
- Email is muted (
mute_emails), the scheduler paused (pause_scheduleranddisable-scheduler) andhost_nameset before the first start (F9): a copy must never mail customers or poll inboxes.--keep-schedulerleaves the scheduler alone. - The Administrator password is reset only with
ADMIN_PASSWORDor after a yes at the prompt; otherwise the production password keeps working. The design’s--admin-password-promptflag was left out: without--yespull asks anyway. - A decrypt probe after the restore counts the encrypted
__Authrows that decrypt and that fail and prints only the counts, so a wrong key shows up at once, not at the first email sync. - Restore and migrate run with the bench’s own Redis (
fl_bench_redis_up, fromsite add): frappe v16 connects to it during site setup, and the code has moved on since the design. - The staging folder is
<bench>/.benchbar/pulls/<site>-<backup timestamp>/, not<timestamp of the run>: a rerun of the same pull finds the partial files and resumes. It is mode 0700, removed file by file after a successful run, kept after a failed one. - rsync gets
--partial --append-verify --info=progress2only when the local rsync is 3.x; macOS ships openrsync (2.6.9 compatible), which has neither, so it gets--partial --progress, and the partial file is the basis of the next transfer.scpis used when the server has no rsync; it cannot resume. - A file whose local size already matches the server’s is not downloaded again.
- The free space check wants 3x the backup (download, decrypted copy or database, extracted files) on the bench’s volume, before any transfer.
- SSH settings come only from
~/.ssh/config(ProxyJump, keys, ports); benchbar addsBatchMode=yesunder--yesor--jsonand one control connection (ControlMaster=auto,ControlPersist=60) closed with-O exitwhen the run ends. Its socket is in/tmp/benchbar-ssh.XXXXXX, because socket paths are limited to 104 bytes and bench paths can be long. - The host must look like a Host alias (
[A-Za-z0-9@._-], not starting with-) and every remote word is single quoted, so no argument becomes an ssh option or remote shell code. - Remote commands are an allowlist (the site check,
list-apps,git rev-parseandremote get-url,ls,wc -c, the key read,command -v rsync, andbench backupafter the gate); the test fails on anything else. - Remote frappe runs through
env/bin/python -m frappe.utils.bench_helperfromsites/: non login SSH shells often lackbenchon PATH. pull --jsonstreams one object per line on stdout and moves every human line to stderr;doneis always the last line, written by the exit handler after a refusal or a failure too.- Only the source (
PULL_SOURCE) and the remote bench are remembered, per bench, sobenchbar pullalone repeats the last pull; no password is stored. - test-pull fails first when an ssh, rsync, scp, gpg or df mock is not the first one on PATH: during development a mock that was not yet executable let
/usr/bin/sshlook up the hostprod, which did not resolve, so nothing connected.
0.4: roadmap
-
Work happens in a second git worktree (
~/dev/benchbar-work):~/.local/bin/benchbarand the app run the checkout in~/dev/benchbar, so a feature branch checked out there would change the CLI in daily use. That checkout stays on main and is fast forwarded after each merge. -
Pulling a production site moved from the 0.7 plan to Ideas, as a wizard that also handles the encryption key and the app list mismatch: without those two it restores a site that cannot decrypt its passwords or fails on missing apps, and it is not scheduled yet.
-
The old 0.4 items the brief did not schedule (worker restart on Python changes, more speed sources, a runner gallery, running one scheduler event) moved to Ideas; runner import from a zip was dropped from the list because 0.3 already ships it.
-
App installs, team profiles, the lockfile and
pullwere scheduled as 0.6, then folded into 0.5 (Akash, 2026-09-26): they are being built at the same time as the 0.5 app work, and one bigger release is less to write up and tag than two. Patch numbers stay for fixes.
0.4: the v16 profile
Checked in frappe version-16 at 012667b and bench develop at c9d1250 (September 2026), not from memory:
- v16 needs Python 3.14 exactly (
requires-python = ">=3.14,<3.15") and Node 24 (engines.node >=24); frappe itself only warns below Node 18 at build time, so the profile’s pin is what enforces 24. - frappe v16 refuses no MariaDB version:
check_compatible_versionsinfrappe/database/mariadb/setup_db.pyonly warns below 10.6 or above 11.8, at new-site and restore. 10.11 gets no warning at all. The docs’ 11.8 is a recommendation. - wkhtmltopdf stays installed on v16:
pdf_generatoron Print Format defaults towkhtmltopdfand a v16 patch sets every existing format to it; Chromium is opt in per format. So the check becamepdf_engine(wkhtmltopdf everywhere, plus Chromium on v16) instead of swapping one tool for the other. - Chromium is looked up the way frappe does (
find_or_download_chromium_executable):chromium_pathfrom common_site_config, else<bench>/chromium/chrome-mac/headless_shell. A missing one is a warning withbench setup-chrome, not a repair action: frappe downloads it on first use anyway, and its download removes<bench>/chromiumfirst, which benchbar should not trigger on its own. pkgconfandmariadb-connector-care system dependencies of every profile: v16 pinsmysqlclient==2.2.7, which does not build without them.pkg-configis only an alias ofpkgconfin Homebrew, andbrew list pkg-configdoes not follow it, so the formula is namedpkgconf.- bench sets
PKG_CONFIG_PATHitself frombrew --prefix mariadb-connector-c, only for frappe 16 or newer on darwin, when it builds the env or installs an app. benchbar adds the same folder to its own exports (shell block, repair, phase 01) so a pip build outside bench finds it too. The launchd plist and the runner set noPKG_CONFIG_PATH, so nothing undoes bench’s value; the running bench needs none. - Missing build formulae are a warning, not a failure: a v15 bench runs without them and a v15 machine set up before 0.4 should not turn red.
- bench 5.31 depends on uv and uses it for the env by default (
BENCH_DISABLE_UV=1turns that off), so “uv or pipx” only decides how thebenchcommand itself is installed. uv wins when it is on PATH, as in the Frappe docs; an existing pipxbenchis reported and never moved. - honcho 2.0.0 imports
importlib.metadata, notpkg_resources; only honcho 1.x breaks on Python 3.12 or newer without setuptools. - The v16 CI row is a separate job running
tests/test-profile-v16.shon macOS/bin/bash3.2: the rest of the suite asserts v15 strings on purpose, and a second full run would only repeat them.
0.4: doctor hardening
- Full Disk Access: frappe/bench#1730 (crontab “Operation not permitted” during
bench init) is still open with no fix merged as of 2026-09-25; the rollback bench offers is a prompt (click.confirm), not automatic. benchbar passes--no-backups, which skips python-crontab entirely, and keeps a doctor check plus a warning beforebench init, sincebench setup backupswould still need it. - The probe is
crontab -l: “no crontab for” and exit 1 is a normal answer, only “Operation not permitted” counts. Run from BenchBar the check reports ok without probing: the access that matters is the Terminal’s, not the app’s. - The toolchain is four checks (
toolchain_node,toolchain_yarn,toolchain_pkgconfig, plusmariadb_version), one line each in doctor’s existing one line per check format. Python is not repeated:env_pythonalready compares the env’s version with the profile. - Tools are resolved on the launchd PATH the agent uses, not the caller’s: the forum case “nvm’s node is not seen by bench” is exactly a node that only exists on the shell’s PATH.
env/bin/nodewins when bench put one there. - The MariaDB server version comes from the binary of the process listening on 3306 (
ps), else the installed formula’s client; doctor never logs in, so it never reads the Keychain. The range is frappe’s own: below 10.6 unsupported, above 11.8 untested, both warnings. honcho_setuptoolsimportshoncho.commandwith the interpreter in honcho’s shebang instead of importingpkg_resourcesdirectly: honcho 2.0 does not need it, and a barepkg_resourcesprobe would warn on every healthy Python 3.12 venv. The repair installs setuptools into that venv (pipx or uv), never into the bench env, and adopt skips it likehoncho_install.fork_safetyreads the plist only: the variables reach honcho and every worker through the agent’s environment, andProcfile.leandoes not need to repeat them.benchfgsets them itself.orphanscounts listeners on the bench’s ports only when neither the agent nor a honcho runs the bench, so a bench started withbenchfgis not reported.- Found while testing:
doctor --jsonwith a failing check went through the ERR trap on its way to exit 1 and printed[FAIL] Last command failedon stdout after the JSON. The dispatcher now exits 1 itself, and test-json parses a failing report.
0.4: several benches
- Per bench settings live in the checkout,
.benchbar/benches/<name>.env, not in the bench: benchbar writes only its service files into a bench, and a folder name is already the agent’s unique key (com.benchbar.<name>), so it is a safe file name too. - Keys that were per bench but global before 0.4 (
PROFILE,SITE_NAME,AUTOSTART,HONCHO_BIN,APP_BUNDLE,APPS) are read fromstate.envfor the default bench until its own file has them, and moved by the next writing command. Doctor and status stay read only, and an upgrade needs no migration step. - When the default bench changes, its old settings move into its own file first, so
AUTOSTART=offof the old default never leaks to the new one. - A second
install,adoptorservicekeeps the default bench (--make-defaultchanges it): “set up a v16 bench to try something” must not change whatbenchupstarts. - A bench with no stored profile gets the one whose Frappe branch matches
apps/frappe/frappe/__init__.py(__version__ = "16.x"isv16-lts), before the default profile: doctor on a freshly found v16 bench would otherwise judge it by v15 rules. - The shell block’s PATH lines follow the default bench’s profile, and every bench renders the block the same way. Before, a v16 bench would have put Python 3.14 and Node 24 first in the user’s shell, and the two benches’ doctors would have called each other’s block outdated forever.
- Phase 00 writes the shell block only when it is missing: it knows a profile but not which bench is the default.
serviceandrepairkeep it current. - honcho and socketio are matched by their working folder (
lsof -a -p PID -d cwd): both start with a relative path (honcho start -f Procfile.lean,node apps/frappe/socketio.js), so the command line is identical in every bench. A process whose folder cannot be read is still matched, the pre 0.4 behaviour, since it is usually exiting. - The runner (template v3) clears only its own bench’s stale socketio at start, with the same working folder test; before, starting a second bench killed the first bench’s socketio.
- The launchctl mock now stops only the agent’s own honcho, so the tests can run two benches at once.
0.4: Codex review findings on PRs 10 to 12
- Bench paths are resolved once, in
fl_abs_path(symlinks,.and..), so state, ports, the default bench and agents see one spelling per bench; a storedBENCH_DIRfrom before is compared throughfl_same_path. A local review before pushing found a symlinked spelling clashing with its own ports and getting its own state file. - The per bench state file is
<name>-<8 hex of the canonical path>.env(cksum ofpwd -P, so every spelling of a bench maps to one file; a plain<name>.envfrom the first 0.4 builds is read and renamed on the next write, by the default bench, or by a bench no other known bench shares the folder name with):~/frappe-benchand~/dev/frappe-benchare both detected candidates and would otherwise share one file. The agent label still uses the folder name only; two benches with the same folder name remain unsupported, as before 0.4. - A bench installed with uv is looked up in
uv tool dir --bin, which followsUV_TOOL_BIN_DIRandXDG_BIN_HOME, instead of assuming~/.local/bin. - Phase 01 records the default bench only after the site is verified, so a failed
install --make-defaultleavesbenchuppointing at the bench that worked. - With
--make-default, the shell block is rendered for the bench being set up even in a dry run, so the plan shows the PATH change the real run makes. - The
orphansfinding (a machine widepgrepfor honcho) was already fixed by the working folder match in the multi bench PR.
0.4: port blocks
bench initalready chooses ports:make_portsin bench’sconfig/common_site_config.pytakes max+1 over the benches in the same parent folder. benchbar keeps that and only moves a bench when it clashes with another bench it knows (anywhere under~or~/dev, or registered by an agent) or a port is taken by a listener.- The block is written with frappe’s own
bench set-config -g(-pfor the two integer ports) and the Redis files withbench setup redis, not by editingcommon_site_config.jsonorconfig/redis_*.conf: the AGENTS.md rule “never edit sites/” holds, and the files look exactly as bench would write them. The config is backed up first. redis_socketiois written equal toredis_cacheand no12000 + nport is reserved: bench keeps the two equal “for backward compatibility”, and neither frappe v15 nor v16 connects to it (v16 realtime usesredis_queue).- Only a newcomer moves: the default bench (or the one about to become it) never changes ports on its own, and a bench moves only when it clashes with an established bench, one with a benchbar agent or the default. Found by the tests: without that rule, adopting the first bench on a Mac with two more unregistered benches moved the first bench off 8000.
- Moving ports is the
port_blockaction of the same plan as the service files (a check that exists only while a plan moves ports, never in doctor): one confirmation covers it, and a cancelled adopt really changed nothing. Codex found the first version asking separately and moving the ports before the plan could still be cancelled. - Listener ownership has two strictness levels. For the bench’s current ports an unreadable working folder counts as its own, so it never moves on a guess; for a block it does not use yet, only a listener known to run inside the bench is its own (Codex found the first version trusting every listener on the current ports, which could then be killed by the runner’s cleanup).
service --port-offsetvalidates the block before remembering the bench, so a refused block never makes it the default (Codex).- The action rewrites
Procfile.leanand the runner itself after the move: they carry the ports, and when the plan was made they may have been current and therefore not in it. --port-offsetchecks the block for conflicts even when it is the current one, so two benches already sharing block 0 are not confirmed by asking for block 0 (Codex).- A newcomer also moves when a foreign process listens on its own ports, not only when a known bench has them (Codex). “Foreign” is decided by working folder: honcho starts redis, web and socketio inside the bench, and an unreadable folder counts as the bench’s own, so nothing moves on a guess. The default bench is never moved for a listener either; it gets a warning.
- The port clash check now also reports a bench that is only configured with the same ports, since only one of them can run;
benchbar upstill asks only about a running one, so the app (which cannot answer) can start either bench. - MariaDB ranges live in the profile (
mariadb_min,mariadb_max). v16 uses frappe’s own bounds, 10.6 to 11.8. v15’s code warns above 10.8, but the v15 docs and this installer use 10.11, so v15’s range ends at 10.11; the check is a warning either way, as in frappe. - The toolchain’s MariaDB check was named
mariadb_versionbefore 0.4 shipped, to match the brief; nothing released used the other name.
0.4: the MariaDB decision
- Option (a), decided with Akash on 2026-09-26 (“let’s struggle and find out”): the v16 bench uses the
mariadb@10.11server the v15 bench already runs on 3306. frappe v16 accepts it without a warning, and it keeps one server, one data folder and one Keychain password. Option (b) (a secondmariadb@11.8service on 3307) stays possible later if v16 turns out to need 11.8. - The supported path: when a MariaDB server runs on 3306 and its version is inside the profile’s range, phases 00 and 01 and every command use that server’s formula instead of the profile’s (named from the server binary’s
opt/<formula>orCellar/<formula>path, read withps), and the formula is stored per bench (MARIADB_FORMULA) by phase 01 or the firstservice,adoptorinstall. Daily commands read only the stored value:test-processguards thatdownnever touches port 3306, and a detection in every command’s context broke that guarantee. So a v16 install on this Mac never installs or startsmariadb@11.8, and a machine without MariaDB still gets the profile’s formula.mariadb_versionin doctor checks the range per profile.
0.4: sites and the scheduler
- The default site is the one benchbar remembers per bench;
site defaultalso runsbench use, socurrentsite.txtagrees, and rewrites the runner, whose ping embeds the site.benchup’s wait,statusand the app all use it; a second site never changes it on its own. site addcreates a new site only, on the same MariaDB server as the bench (option (a) of the MariaDB decision), with the Keychain password; it never drops or overwrites a site, and there is nosite dropin 0.4.- Apps for a new site come only from
apps/(--bundleor--apps); a missing app is refused with thebench get-appcommand. Fetching apps belongs to the 0.6 app commands. - A site’s
ping_codeis only tried when something listens on the web port, solist --jsonon stopped benches costs no three second timeouts per site. site hostsasks once for all missing lines and uses one sudo prompt, through the existing marker block logic.site listtakes no lock (it is read only);add,defaultandhoststake the checkout’s lock like every other writing command.- The scheduler line is an optional template line: a line that is only a token rendering to nothing is dropped. With the scheduler off,
Procfile.leanrenders byte for byte as before 0.4 (hash 92548cf35913 checked against the real bench), so no existing bench sees an outdated Procfile. The file’s comment still says “no schedule” when it is on: changing it would change every existing Procfile’s hash. - The scheduler choice is per bench state (
SCHEDULERin its state file), sorepairrenders the same Procfile and never undoes it;status --jsonreportsscheduler.
0.4: found on a real Mac (v16 bench, 2026-09-26)
- The MariaDB root password of a bench set up before 0.3 was never in the Keychain, so phase 00 stopped with exit 2 as designed; Akash reset it with the documented recipe (databases kept, the v15 bench stopped around it with
benchbar downand brought back withbenchbar up), and the next run verified the new password and saved it to the Keychain. bench initin~/dev/v16-benchchose 8001, 9001, 11001, 13001 (and file watcher 6788) by itself, as a sibling of~/dev/frappe-bench; benchbar’s port check agreed and moved nothing.- On v16,
bench --site v16dev install-app erpnextfailed with “Error 61 connecting to 127.0.0.1:11001. Connection refused”: frappe v16 connects to the bench’s Redis during site setup, and a fresh bench has none running. Phase 01 andsite addnow start the bench’s own Redis servers fromconfig/redis_*.confwhen nothing listens on their ports, and stop only the ones they started: the queue withshutdown save, so jobs an app install enqueued wait for the first worker, the cache withnosave(Codex). Redis startup errors go to the run log, whose path is now exported to the phase scripts. A listener on the bench’s Redis ports is used only when it runs inside the bench (working folder); another process there, such as another bench on the same default ports, stops the setup with the--port-offsetfix, so this bench’s cache and install jobs never land in someone else’s Redis (Codex). A running bench’s Redis is used as it is.
0.4: the v16 bench on a real Mac, verified (2026-09-26, macOS 27, Apple Silicon)
- Installed
~/dev/v16-bench(profilev16-lts, bundleminimal, sitev16dev) withbenchbar installnext to the running v15 bench: frappe and erpnextversion-16, Python 3.14.7, Node 24.21.0, the existingmariadb@10.11server (10.11.19, option (a)), ports 8001, 9001, 11001, 13001. The profile is nowsupported. - Both benches up at once:
macdevon 8000 andv16devon 8001 each answer/api/method/pingwith 200 (Host header);benchbar listshows both,frappe-benchas the default; the shell block still puts python@3.11 and node@20 first. benchbar downon v16 left v15 running (ping 200), anddownon v15 left v16 running (ping 200).upon v15 then crashed v16 once: the v15 bench still had runner template v2 (Akash has not runrepairsince 0.4’s runner v3), whose start up cleanup kills everysocketio.json the Mac; honcho in v16 exited with 241, launchd restarted it after about 20 seconds and it answered 200 again. This is the bug the working folder match fixes;benchbar repairon the v15 bench installs runner v3. The crash guard counted one start and did not trip.- Doctor: v15 30 ok, 2 warn (CleanMyMac, the outdated runner). v16 28 ok, 4 warn: CleanMyMac, the missing
v16devhosts line (sudo, pending Akash), Chromium not downloaded yet (expected: frappe fetches it on first chrome PDF), and the python formula check, which was wrong (next section). - The resumed install ran with
--yesand no terminal: phase 01 installed erpnext on the existing site with the setup Redis, the service files were written and the agent loaded with the bench stopped, and the hosts step was skipped with its manual command. The run ended with the verify warnings and a misleading[FAIL] Last command failed ... brew tabfrom the ERR trap; the trap now stays quiet when the last command is a finished verify pass. - Then, with Akash:
benchbar repairon the v15 bench (runner v3; doctor 31 ok, 1 warn),benchbar site add v16two(his Administrator password, the hosts line with sudo) andbenchbar site hostsforv16dev;macdev:8000,v16dev:8001andv16two:8001all answer ping 200 by hostname, and the hosts block holds exactly one line per v16 site. - frappe v16’s
bench new-sitecalls--no-mariadb-socketdeprecated in favour of--mariadb-user-host-login-scope='%'; v16 profiles now pass the new flag, v15 keeps the old one, which it still needs.
0.4: python formula check
python_leavesasksbrew list --formula --installed-on-request, notbrew leaves: on the real Macpython@3.14was installed on request (andbrew tabset it again), but pipx, uv and ollama depend on it, andbrew leaveshides every formula another formula depends on, so the check could never pass. What protects a formula frombrew autoremoveis “installed on request”, which is what the check now reads.
0.5: benchbar mcp
- The server is stdlib only Python 3.9 (
lib/frappe-local/mcp.py), run with whateverpython3is on PATH: the Command Line Tools’ 3.9 is enough, so CLI only users need nothing new. The Swift MCP SDK would tie it to the app. - Every tool shells out to
benchbar ... --jsonand returns its output as text and asstructuredContent; the server holds no bench logic. Actions (up,down,restart) return their text output plus a freshstatus --json, so an agent sees the outcome without a second call. - No repair, install, service, site add or anything with sudo behind a tool: those change files or need a password, and belong in a terminal with the user. Read tools carry
readOnlyHint: true, actionsfalse. - stdin of every CLI call is closed, so a question (a port clash on
up) is answered no instead of hanging the session; one bad message gets a JSON-RPC error and the session goes on. - Protocol versions 2025-06-18, 2025-03-26 and 2024-11-05 are accepted as asked; anything else gets 2025-06-18. The transport is newline delimited JSON on stdio, as the spec says for stdio.
logs --jsonexists forbenchbar_logs_tail: the brief keeps the server free of logic, so the process filter (honcho’sHH:MM:SS name.N |prefix, with unprefixed lines such as tracebacks kept under their process) lives in the CLI.fl_json_escapenow drops control characters, since log lines may carry terminal colors.
0.5: repair —json
- The events go to fd 3 (the command’s stdout) and every human line to the run’s log: the engine prints with the same helpers as always, so the text and the JSON can never disagree, and the log keeps the full story for a failed step.
- Without
--yesa JSON run applies nothing: the confirmation would be invisible, so stdin is closed and the plan is answered no (plan, then done with 1). The app shows the plan from--dry-run --json, asks, and then passes--yes. sudoactions are marked in the plan ("sudo": true): without a terminal they are skipped with their manual command as the message, which the app shows as “run in Terminal”.- A step’s message is its own last
[FAIL]line, else its[WARN]line, from the log lines written during the step; the step summary line is ignored.
0.5: local review before the pull request
bench new-sitegets both passwords on stdin, not in its arguments: the bench’s own Python reads them and swaps@secret0@,@secret1@in its argv before handing over to frappe’sbench_helper, so the passwords never appear inpsor the run log.- Every
git ls-remoteputs--before the repository, and a profile or lockfile entry whose repo, branch, commit or name starts with-is refused: a team file must not be able to pass git an option. app list --jsonon a bench without sites reads its cache withgrep ... || true, so pipefail does not end the run.benchbar mcpanswers a message that is not an object with -32600 and parameters that are not objects with -32602, and keeps the session going.- The app’s repair sheet applies stream events on the main actor in order, through one
AsyncStream, so a late step event can never overwrite a failed run. - Codex on the pull request:
app updaterefuses to run when any site’slist-appsfails, since a stale list could skip a site’s backup or migrate.pullinto a running bench setspause_schedulerincommon_site_config.jsonbefore the restore and removes it once the copy has its ownpause_schedulerandmute_emails, so the bench’s scheduler never sees the production copy unguarded; a bench already paused by hand is left alone, and a pull that stops halfway leaves the bench paused and says how to resume. The test runner kills a timed out test’s whole process tree, collected before anything dies.
0.5: found on the real Mac before the release
app add URLfor an appconfig/apps.tsvknows follows that branch when the repository has it (Raven added by its URL came in ondevelop, the repository default, and doctor then warned that it was offmain). A URL for an unknown app still takes the default branch;--branchalways wins.- Raven moved to
github.com/frappe/raven; the registry points there (the old URL redirects). - The app reads
app listwith the 90 second limit, not 20: at app start it runs next to status and doctor for every bench, and it makes several git calls per app.
The easy install run (v0.3)
Setup and environment
- Work happened in a Linux cloud container, not on a Mac: there is no Xcode, launchctl, codesign or hdiutil here. Everything Mac only (the app build, the Swift tests,
release-local.sh) is exercised on GitHub Actions macOS runners; the CLI is tested locally under mocks and in CI under macOS/bin/bash3.2. - Branch
feat/easy-installis pushed and a draft pull request opened againstmain: the brief for this environment allows exactly that remote write and nothing else (no tags, no releases, no settings). - Conflict with AGENTS.md: it says never write LaunchAgents or
Procfileyourself; this run changes the code that generates them (adopt,install). The brief wins; nothing was loaded on a real machine. - bash 3.2 could not be built locally: the proxy blocks the GNU mirrors and only the project repository is reachable on GitHub. CI’s macOS
/bin/bash3.2 is the bash 3.2 check. - The mocked test suite was made to run on Linux too (GNU stat and sed, a
unamemock, a fake uid): it gives fast local feedback here and a quick Linux job in CI, while the macOS job stays the one that counts.
Stage 1: harden
benchbar reportmasks by key name (password, secret, token, key, api, auth, case insensitive, anywhere in the key) rather than by value shape: values are unpredictable, key names are not. Over masking (for exampleencryption_key,Authorization:headers) is accepted.- Header style
Key: valuemasking runs to the end of the line, or to the next quote, comma or brace inside a one line JSON document, so a bearer token afterAuthorization:is gone but a JSON file is not wiped. - The username and hostname are replaced by simple string substitution (any occurrence, no word boundaries, names shorter than three characters skipped): BSD sed has no
\b, and replacing too much is safer than too little. - Site config files are never copied;
site-config-keys.txtlists their key names so a helper can still see whether, say,developer_modeordb_hostis set. REDACTIONS.txtreports what was replaced per file, so the sender can check the bundle before attaching it.reportworks without a bench (versions only) so a tester whose install failed early can still send something.- The CLI job in CI runs on
macos-latest, the app jobs onmacos-26: the project uses Xcode 26 build settings (default MainActor isolation) that the Xcode onmacos-latestdoes not have. The old release workflow already targetedmacos-26. - CI also runs the CLI tests on
ubuntu-latest: it finishes in a minute and catches most breakage before the macOS runner is even assigned. - The old
macos-release.ymlfailed on every push with “workflow file issue”: its job levelenvread${{ runner.temp }}, and therunnercontext is not available there. The mergedrelease.ymluses$RUNNER_TEMPin steps. release-local.shis the ad hoc path of the release and is also what CI runs on every pull request to produce a downloadable app;macos-release.shstays the Developer ID path.macos-build.shtakesBENCHBAR_VERSIONandBENCHBAR_BUILDand passes them to xcodebuild instead of editingproject.ymlin CI, so the checkout stays clean and the tag decides the version.
Stage 2: the manual steps
- The MariaDB root password goes into the macOS Keychain (service
benchbar-mariadb, accountroot) throughsecurity add-generic-password -U: the only non interactive way to write a Keychain item. The password is an argument of that one call; everywhere else it travels inMYSQL_PWD, never on a command line. The mocked test asserts that no call log ever contains it. - Order of password sources:
MARIADB_ROOT_PASSWORD, then the Keychain, then a prompt. A password that verifies against the server is always saved, whatever its source, so the next run needs no source at all. - The secure installation is plain SQL (
DELETE FROM mysql.global_privfor anonymous and remote root,DROP DATABASE test,ALTER USER ... IDENTIFIED VIA mysql_native_password):mariadb-secure-installationis interactive and the answer table in the old README was the whole reason it was manual. Native password auth is kept on purpose: Frappe needs it. - Phase 00 exits 2 only when MariaDB already has a root password that no source knows and there is no terminal to ask; every other former manual step is automated or skipped with a warning, so
benchbar install --yesnormally finishes in one run. - A generated password is 24 letters and digits: safe in SQL, in a shell and on bench’s own
--mariadb-root-passwordargument, which cannot be avoided. - The pinned wkhtmltopdf is 0.12.6-2 from github.com/wkhtmltopdf/packaging, sha256
81a66b77...94f8, computed from a download made during this run. The package’s binary is x86_64 only (checked by reading the Mach-O header inside the nested tarball: cputype 0x01000007, no arm64 slice), so on Apple Silicon Rosetta 2 is required and offered first; declining it or the download only costs PDFs, and the run continues. - Rosetta is detected with
arch -x86_64 /usr/bin/true, not by looking for the daemon: it is what the binary will actually need. - Downloads land in
.benchbar/downloads/and are reused when their checksum matches, so a re-run after a failedinstallerdoes not fetch 50 MB again. - One sudo prompt per run:
benchbar installlooks ahead (patched wkhtmltopdf present? hosts line present?) and runssudo -vwith a keepalive before phase 00; the phase scripts inherit the session throughFL_SUDO_SESSIONand sudo’s own per terminal timestamp. Standalone00-mac-system-deps.shasks itself, only when it gets to the package. - The
/etc/hostsline sits inside# >>> benchbar >>>markers. A missing block is appended withsudo tee -a; an existing block gets the new line inside it through a temp copy andsudo cp, afterfl_backup_file. Rewriting the whole file is limited to the second case. - The utf8mb4 drop-in and wkhtmltopdf became doctor checks with repair actions (
mariadb_utf8,wkhtmltopdf_install): the same code serves phase 00 andbenchbar repair, and the tester guide can say “run doctor”. benchbar adoptisbenchbar servicewith a positional path, validation and an explicit safety statement, run through the same engine restricted to the service group: that group has nomigrate,build,updateor env rebuild by construction. It remembers the bench only after the plan was applied or found unchanged, so a cancelled adopt never makes that bench the default (a Codex review finding).benchbar mariadb-passwordasks before printing unless--yes: a terminal print is a deliberate act, and the app or a script can pass--yes.- The mocks gained state (
mariadb_root_pw, a keychain folder,wkhtml_installed,rosetta,sudo_refused,download_payload) instead of environment switches, so a test reads like a machine’s history.
Stage 3: install.sh
install.shis self contained bash 3.2 with its own small output helpers: it runs before the repository exists on the machine, so it cannot sourcelib/.- Prompts read and write
/dev/ttysocurl | bashcan ask; without a usable tty every question takes its default (--yesdoes the same). Defaults are “yes” for the things the person asked for (Homebrew installer, adopt) and “no” for anything destructive (removing agents or the checkout on--uninstall, where--yesflips them to yes because that is what a non interactive uninstall means). - The CLI is cloned into
~/.local/share/benchbarand updated withgit pull --ff-only;--versionpins the app only. Pinning the CLI to a tag would leave a detached checkout that the nextgit pullcannot update. benchbarandfrappe-macare symlinks into the checkout, sogit pullis the whole CLI update; a regular file at either path is left alone with a warning.- The PATH block in
~/.zshrcuses its own markers (# >>> benchbar-path >>>): the CLI’s# >>> benchbar >>>block is regenerated byrepairfrom a template and only exists afterinstalloradopt. - The app zip is checked against the release’s
SHA256SUMS; a release without that file is refused, not installed unchecked. The installed version is read fromInfo.plistwith awk (noplutil, so the same code runs in the Linux tests). - The app is unpacked with
ditto -x -kand copied withditto: the tools macOS uses for bundles. Becausecurlwrites the download without thecom.apple.quarantineattribute, Gatekeeper never assesses the app on first open. That is the reason the one liner is the recommended install for an ad hoc signed build; the DMG path gets the “Open Anyway” steps in README instead. - A found bench is offered to
benchbar adopt, which keeps its own plan and question;install.shonly passes--yeson when it was given--yes.benchbar installis never started without a terminal: it needs the Administrator password. - On an Intel Mac the app is skipped with a warning (it is built for arm64 only); the CLI installs.
--uninstallremoves only what the installer made (app, links, PATH block), offersbenchbar uninstall-servicepercom.benchbar.*agent and the removal of the checkout, and never lists or touches a bench folder.- The tests drive
install.shagainst a fake HOME with agitmock that copies this checkout, acurlmock that serves a release JSON, zip andSHA256SUMSfrom the mock state, and adittomock on top ofunzip; scenarios: no release, fresh, unchanged rerun, upgrade with a running app, version pin, tampered zip,--no-app,--app-only,--dry-run, adopt offer, Intel,--uninstalltwice.
Stage 4: releases
- One
release.ymlreplacesmacos-release.yml: acheckjob reads the secrets and picks the path; the build job runsscripts/macos-release.sh(Developer ID, notarized, Sparkle, cask) orscripts/release-local.sh(ad hoc). Everything after the build is shared: notes from the CHANGELOG section, a workflow artifact, a draft release, uploads with--clobberso a re-run replaces files. - The version comes from the tag and is passed to xcodebuild (
BENCHBAR_VERSION,BENCHBAR_BUILD= commit count) instead of editingproject.yml; the workflow still refuses a tag that disagrees withMARKETING_VERSIONor has no CHANGELOG section, so the source of truth stays in the repo. - Releases are created as drafts: the person publishes after looking at the files.
workflow_dispatchwith a version builds the artifact without touching releases, for a rehearsal. - Sparkle stays out of the ad hoc path: unsigned updates would defeat its purpose, and the ad hoc app contains no update code.
- The DMG carries an Applications symlink and is not signed on the ad hoc path (signing a DMG ad hoc buys nothing); Gatekeeper’s warning is expected and documented with the macOS 15 “Open Anyway” steps in README.
Found on the macOS runners
- A generated password reads a bounded 4 KB of
/dev/urandom: BSDtron an endless stream never exits whenheadcloses the pipe and SIGPIPE is ignored, as it is under GitHub Actions. The CLI job hung on that for 45 minutes before the cause was found. - The sudo keepalive owns no stdio and sleeps in five second slices: with the caller’s stdout inherited, every
$(...)capture of a run waited for itssleep 50, and on macOS never returned. The tests capture output, so this showed up only in CI. - The
gitmock’s pass through to the real git picks the firstgiton PATH outsidetests/mocksand refuses to exec itself: on macOS/bin/bash3.2,command -v -p gitstill returned the mock, which then exec’d itself in a loop at full CPU. This was the hang behind three timed out macOS CLI jobs; the Linux job never saw it. The test runner now kills a test after ten minutes and prints its process tree, which is how this was found.
Review findings on the pull request
-
fl_wkhtmltopdf_ensurereturns 2 for a deliberate skip (Rosetta or the package declined, no sudo) and 1 for an error; the repair action reports a skip asskipped, and the engine’s verify pass ignores the optional actionswkhtmltopdf_installandredis_stop, so a bench install without PDFs exits 0. -
The report’s assignment style masking takes a quoted value whole (
key="secret",key='secret'): the unquoted form stopped at the quote and left the secret in place. -
install.sh --app-onlyskips the Command Line Tools and Homebrew checks: the prebuilt app needs onlycurl,shasumandditto; macOS and the architecture are still checked. -
The installer’s PATH block is generated from the configured bin folder (
BENCHBAR_BIN_DIR), written as$HOME/...when it lives under the home folder. -
A refused up-front
sudo -vsetsFL_SUDO_REFUSED=1, exported to the phase scripts, so an install asks for sudo exactly once whatever the answer and skips every sudo step with a message after a refusal. -
A hosts line that cannot be written because sudo was refused is a skipped step with the manual command printed, and
hosts_entryjoins the optional actions, so a run the user chose to keep sudo free still ends with exit 0 and warnings. -
The repair engine resets
FL_STEP_RESULTbefore each action and reports it, so a skipped wkhtmltopdf or hosts step printsskipped, the documented step status, notdone. -
The report’s assignment style masking also treats
?as a key boundary, so a credential in a URL query string (?api_key=...&x=1) is masked like any other. -
release-local.shno longer re-signs the app:macos-build.shalready signed it ad hoc with the Hardened Runtime and the entitlements, and a plaincodesign --force -s -dropped both. The script verifies the signature and the runtime flag instead. -
Phase 01 no longer requires
wkhtmltopdf: a bench without it works, only PDF printing does not, and phase 00 already said so when the package was declined. -
The report’s quoted value patterns accept backslash escaped characters inside the quotes, so
"api_key":"abc\"tail"is masked whole. -
The utf8mb4 doctor check also requires the
!includedirline inmy.cnf(a missingmy.cnfcounts as missing). The livecharacter_set_serverquery that 0.3.0 added was removed in 0.3.1: it read the root password from the Keychain, and doctor is read only and runs on a timer in the app, so it must never touch the Keychain. A test asserts that doctor makes nosecuritycall. -
adopt --dry-runnever touched state (fl_state_setis a no-op underFL_DRY_RUN); a test now proves it. -
The report also masks Python repr mappings (
'password': 'x'): worker logs print dicts that way. -
adoptruns the engine withFL_ENGINE_SKIP_ACTIONS=honcho_install: a missing honcho is reported withpipx install honchoorbenchbar repairas the fix, and nothing is ever installed into the bench’senv/by adopt. -
Phase 00 tells a failed wkhtmltopdf install (download, checksum, installer) apart from a deliberate skip: it prints FAILED and a manual step, but still exits 0, since PDFs are optional and the bench can be created.
-
A generated MariaDB password is written to the Keychain before it is applied to the server; when the Keychain refuses (locked), MariaDB is left unchanged and the run stops with the fix, so no password ever exists only in a dying process.
-
Phase 01 exits 2, the documented “root password unknown” code, when a fresh site needs the MariaDB password and no source has it;
benchbar installreports it as a pending manual step like phase 00 does. -
wkhtmltopdf detection prefers the package binary at
/usr/local/bin/wkhtmltopdfwhen it is the patched build, and warns withbrew uninstall wkhtmltopdfwhen an unpatched build earlier on PATH would shadow it: Frappe finds the binary through PATH, and the launchd PATH puts Homebrew’s bin before /usr/local/bin. -
A missing
my.cnfcounts as a missing!includedirin the utf8mb4 check: without it the drop-in folder is never read.
Found on a real Mac (macOS 27, Apple Silicon)
- Verified read only on the real bench:
install.sh --dry-runand--dry-run --uninstallwrite nothing;doctorandreport --printrun clean (no home path, username, hostname or site config value in the report);adopton an already adopted bench is a no-op that only remembers the path.release-local.shbuilds, passes the 114 Swift tests, andshasum -a 256 -c dist/SHA256SUMSpasses; the DMG (UDZO, read-only, Applications symlink) mounts and the unpacked app carries a valid ad hoc signature. Every macOS flag the mocks assume was checked against the man pages:security add-generic-password -U,arch -x86_64 prog,softwareupdate --install-rosetta --agree-to-license,installer -pkg X -target /,ditto -x -kand-c -k --keepParent,hdiutil create -format UDZO,sw_vers -productVersion(the man page lists--productVersion; the single dash form still works and is what macOS 12 and older knew). benchbar reportreplaces every name the Mac goes by, not onlyhostname: on the test Machostnamereturned the router’s name (Mac.lan) while the Bonjour name fromscutil --get LocalHostNamewas the identifying one. The computer name (scutil --get ComputerName) is included too, and the sed patterns built from those names,$HOMEand the username are escaped, since a computer name can hold brackets or a dot.install.sh --uninstall --dry-runended with “BenchBar removed”: the uninstall summary has its own dry-run branch now, andquit_appprints what it would do instead of hiding the line behind the redirectedosascript.- The Gatekeeper dialog for a quarantined ad hoc build reads “BenchBar” Not Opened, Apple could not verify it is free of malware, with Done and Move to Trash (Move to Bin in British English) where Move to Trash is the highlighted default. README says so. The System Settings steps were not clicked: Open Anyway changes a security setting, which the brief reserves for the user.
fl_password_generateand the sudo keepalive were exercised on macOS/bin/bash3.2: the generator returns 24 characters at once, also with SIGPIPE ignored, and 4 KB of urandom never yields fewer than about 900 usable characters; a capturedfl_sudo_beginreturns immediately and its keepalive exits within one five second slice of the parent leaving.
CI speed
Measured on five PR runs (September 2026): 11 to 14 minutes wall clock, all of it the macOS CLI job (10.7 to 13.5 min; its test step 605 to 763s). Linux CLI took 7.5 to 8.8 min, the app job 1.4 to 1.7 min, the release bundle 1.2 to 1.8 min after it, v16 0.3 to 0.7 min, shellcheck 22 to 32s, brew install 2 to 4s (bottle cache hits), DerivedData restores from its restore key in about 5s.
tests/run-tests.shruns the test files in a bash 3.2 worker pool,PARALLELat a time (default: the CPU count), output buffered per test and printed whole in list order, the ten minute deadline kept per test (polled every 0.2s instead of 1s), and every failure reported at the end instead of stopping at the first: the files were already independent (each builds its own HOME and mock state under mktemp; the realpsfallback only reads, the mocks only read from the checkout). Local, this Mac: 512s one at a time, 233s with 3, 113s with 10.- The deadline’s process dump lists only the stuck test’s process tree: with tests side by side, the old
ps | grep tests/would name every running test. - The list is ordered longest first by measured time and a test file missing from it fails the run: the pool then never waits on a slow test started last, and a new file cannot be silently skipped.
SHARD=i/ntakes every nth test of that list: shards are balanced by construction (local, 3 workers each: 75s, 65s, 70s).- The macOS CLI job is a matrix of three shards, each running its share 3 at a time (macos-latest has 3 CPUs): the 11 minute test step should drop to about 2 minutes per shard. Three shards plus the app job stay under the five concurrent macOS jobs of the free plan.
- shellcheck runs as one more pool job in shard 1 (and on Linux), over the full file list the CI step had (
scripts/*.shandinstall.shincluded): it overlaps the tests instead of adding 25s in front of them, and needs no extra runner. - The separate v16 job is gone:
run-tests.shhas runtest-profile-v16.shsince 4d666ee, so it ran twice per PR. - The release bundle is built in the app job (
release-local.sh --skip-testsreplaces the plain Release build step, then the Swift tests run on the warm DerivedData): its Release build is a superset of the old unsigned build (it also signs ad hoc and verifies), and it saves a second runner, Xcode selection, XcodeGen install, cache restore and a full Release build (about 1.5 min on the critical path), while still producing the bundle on every PR. - The Linux job is gone (Akash, 0.5 pull request): BenchBar only runs on macOS, the macOS shards already run the suite on the
/bin/bash3.2 users have, and the Linux job was the slowest one left (4.5 minutes) while only ever finding GNU tool differences no user hits. The suite still aims to run on Linux for a Linux dev container, without a CI guarantee. brew installis skipped when the tool is already on the runner, and the Homebrew and DerivedData caches stay as they were: both already hit (restore key for DerivedData, which is expected when sources change).- Estimate after: about 3 to 4 minutes wall clock per PR, bounded by the slowest shard. First run on the 0.5 pull request: shards 2m28s to 3m50s, the app job 1m50s.