diff --git a/CLAUDE.md b/CLAUDE.md index ae6fa81a..b16cdad8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,6 +113,19 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper building node-pty, or `officer-pty` cannot load its native module, restore the exact pin first. The source build also needs `build-essential` and `python3`. + **Bun itself is held at the 1.3 series, on purpose and temporarily.** 1.4.0 was published + 2026-08-20; the owner asked on 2026-08-22 to stay on 1.3 for a few months until 1.4 has been + watched. Two places carry that number and they move together: `BUN_SERIES` in + `scripts/setup/machine-setup/lib/dev.sh`, which resolves the newest `1.3.x` from the npm registry + and passes it to the installer as a release tag, and `engines.bun` in `package.json`, which is + what `bun run doctor` reads. bun ignores `engines` (verified — an install declaring `1.9.x` ran + clean under 1.3.14), so that field is the declaration and doctor is the enforcement. + + **Do not run `bun upgrade` on a machine running Officer.** It takes no version argument — latest + stable or `--canary`, nothing else — so it jumps straight past the pin and says nothing. That is + the whole reason `doctor` checks the running version at all; `machine-setup.sh --only 'JavaScript +runtimes'` offers to pin a drifted machine back down. Lifting the hold is `todo/infra/`. + - **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way. - **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components - **Backend**: Hono diff --git a/TODO.md b/TODO.md index d4684647..b64ae860 100644 --- a/TODO.md +++ b/TODO.md @@ -20,7 +20,7 @@ the owner's OS user and can never be granted. Indirection there really is accide ## Themes -**88 open, 25 closed**, across 29 themes. +**89 open, 25 closed**, across 29 themes. | Theme | Open | Closed | | ------------------------------------------------------------------------------------------ | ---- | ------ | @@ -34,7 +34,7 @@ the owner's OS user and can never be granted. Indirection there really is accide | [File watchers](todo/file-watchers/) | 1 | 0 | | [Fleet — one command across every server](todo/fleet/) | 5 | 2 | | [Headscale](todo/headscale/) | 0 | 5 | -| [Infra (alpha)](todo/infra/) | 6 | 0 | +| [Infra (alpha)](todo/infra/) | 7 | 0 | | [Known bugs](todo/known-bugs/) | 3 | 2 | | [Mobile apps](todo/mobile-apps/) | 3 | 0 | | [Multi-user](todo/multi-user/) | 16 | 5 | @@ -63,6 +63,7 @@ the owner's OS user and can never be granted. Indirection there really is accide ## Newest ten +- `2026-08-22` **'Revisit the bun 1.3 pin once 1.4 has been watched for a while'** — [infra/revisit-the-bun-1-3-pin-once-1-4-has-been](todo/infra/revisit-the-bun-1-3-pin-once-1-4-has-been.md) - `2026-08-22` **A plugin's permission should be on for every role by default, off only on request** — [multi-user/a-plugin-s-permission-should-be-on-for-every](todo/multi-user/a-plugin-s-permission-should-be-on-for-every.md) - `2026-08-22` **A user's settings should export and import as a plain file** — [settings/a-user-s-settings-should-export-and-import-as](todo/settings/a-user-s-settings-should-export-and-import-as.md) - `2026-08-22` **The same for system settings, owned by the Super Admin** — [settings/the-same-for-system-settings-owned-by-the](todo/settings/the-same-for-system-settings-owned-by-the.md) @@ -72,4 +73,3 @@ the owner's OS user and can never be granted. Indirection there really is accide - `2026-08-22` **Review officer-secret-store, built overnight and untested by the owner** — [waiting-on-owner/review-the-officer-secret-store-built-last](todo/waiting-on-owner/review-the-officer-secret-store-built-last.md) - `2026-08-21` **No way to see which affordances are reaching your agents, or to turn one off** — [affordances/no-way-to-see-which-affordances-are-reaching](todo/affordances/no-way-to-see-which-affordances-are-reaching.md) - `2026-08-21` **Generalise it: deliver a turn to a named session, from anywhere in the backend** — [agent-delivery/generalise-it-deliver-a-turn-to-a-named](todo/agent-delivery/generalise-it-deliver-a-turn-to-a-named.md) -- `2026-08-21` **The cost of resuming grows with the transcript** — [agent-delivery/the-cost-of-resuming-grows-with-the-transcript](todo/agent-delivery/the-cost-of-resuming-grows-with-the-transcript.md) diff --git a/package.json b/package.json index 63d46271..f3b5f266 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "src/databases/*", "src/workspaces/*" ], + "engines": { + "bun": "1.3.x" + }, "scripts": { "preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22) { console.error('Node 22 or newer required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"", "gen:index": "bun run ./scripts/gen-index.ts", diff --git a/scripts/doctor.ts b/scripts/doctor.ts index 28438ca8..cde00d6e 100644 --- a/scripts/doctor.ts +++ b/scripts/doctor.ts @@ -34,6 +34,44 @@ const fail = (what: string, fix: string) => { }; const pass = (what: string) => console.log(`${green('✓')} ${what}`); +console.log('\n── Runtime ──\n'); + +/** + * bun is deliberately held at a series — `BUN_SERIES` in + * scripts/setup/machine-setup/lib/dev.sh — while 1.4 is watched for a while. + * + * The setup script can pin the INSTALL and nothing more. `bun upgrade` takes no version + * argument (it offers latest-stable or --canary and nothing else), so it always jumps to the + * newest release and walks a pinned machine off the pin without saying so. This is the only + * thing that notices, which is why a version check sits in a script otherwise about accounts. + * + * The wanted series is READ from `engines.bun` rather than repeated here, because a second + * hardcoded copy is a second thing to drift. bun does not enforce that field — verified: an + * install declaring `engines.bun: "1.9.x"` ran clean under 1.3.14, no warning — so the field is + * the declaration and this is the enforcement. + */ +const pkg = (await Bun.file(new URL('../package.json', import.meta.url)).json()) as { + engines?: { bun?: string }; +}; +const wantSeries = (pkg.engines?.bun ?? '').replace(/\.x$/, ''); +const nowSeries = Bun.version.split('.').slice(0, 2).join('.'); + +if (!wantSeries) { + fail( + 'package.json declares no engines.bun', + 'The setup scripts pin a bun series and nothing else records which one. Add it.', + ); +} else if (nowSeries === wantSeries) { + pass(`bun ${Bun.version} ${dim(`(inside the pinned ${wantSeries} series)`)}`); +} else { + fail( + `bun ${Bun.version} is outside the pinned ${wantSeries} series`, + `Usually a 'bun upgrade', which always takes the newest release. Pin it back with ` + + `'curl -fsSL https://bun.sh/install | bash -s bun-v', or raise ` + + `engines.bun and BUN_SERIES together if ${nowSeries} is now wanted.`, + ); +} + console.log('\n── Accounts ──\n'); const users = await getUsers(); diff --git a/scripts/setup/machine-setup/lib/dev.sh b/scripts/setup/machine-setup/lib/dev.sh index bc1d2dbf..0ce0f893 100644 --- a/scripts/setup/machine-setup/lib/dev.sh +++ b/scripts/setup/machine-setup/lib/dev.sh @@ -206,7 +206,7 @@ nvim_clone_config() { # every process here. officer-pty imports node-pty, a native addon with # no Linux prebuild — it compiles against the installed Node on every # machine. Either one alone makes Node load-bearing. -# bun the platform itself and nineteen of the twenty pm2 apps. +# bun the platform itself, and every pm2 app except officer-pty. # pm2 the process manager the ecosystem files are written for. # # Deno is not. Nothing in the platform imports it — checked across the whole @@ -243,6 +243,69 @@ install_node() { npm config set prefix /usr/local >/dev/null 2>&1 || true } +# ── Held at the 1.3 series, deliberately and temporarily ── +# +# bun 1.4.0 landed on 2026-08-20 and https://bun.sh/install takes whatever is +# newest, so a machine provisioned two days later puts a brand-new release under +# the platform and every pm2 app it runs. Held at 1.3 until 1.4 has been watched +# for a while. That is the owner's call and it is meant to be revisited in a few +# months, not a permanent ceiling — raise BUN_SERIES when 1.4 has earned it. +# +# The SERIES is pinned, not a patch. 1.3.x fixes still land and should still be +# taken; a hardcoded 1.3.14 would rot into "the version we happened to pick" +# exactly the way setup_22.x did for Node. +# +# This covers the INSTALL path only, and cannot cover the other one: `bun upgrade` +# takes no version argument — checked against `bun upgrade --help` on 1.3.14, which +# offers latest-stable or --canary and nothing else — so it always jumps to the +# newest release and will walk a pinned machine onto 1.4 on its own. Nothing in a +# setup script can prevent that. `bun run doctor` is where that drift is caught. +BUN_SERIES="${BUN_SERIES:-1.3}" + +# The newest stable release inside the series. +# +# Asked of the npm registry rather than GitHub's releases API, which is the obvious +# source and the wrong one: it rate-limits unauthenticated callers to 60 an hour per +# IP, and it returned 504 twice while this was being written. An empty answer here +# means no bun gets installed at all (see install_bun), so where it comes from is +# load-bearing rather than a matter of taste. bun publishes every release to npm. +# +# `sort -V` rather than trusting the order the registry returns them in, and the +# pattern is anchored so 1.3.1 cannot be mistaken for the newest when 1.3.14 exists. +bun_series_latest() { + curl -fsSL https://registry.npmjs.org/bun 2>/dev/null | + jq -r '.versions | keys[]' 2>/dev/null | + grep -E "^${BUN_SERIES//./\\.}\.[0-9]+$" | + sort -V | tail -1 +} + +# The major.minor of a full version — "1.3.14" is in series "1.3". +bun_series_of() { sed -E 's/^([0-9]+\.[0-9]+).*/\1/' <<<"${1:-}"; } + +# Where an installed bun sits relative to the pin: in, above, below, or unknown. +# +# Above and below are different problems and get different answers — above is a +# machine that has upgraded itself past the pin, below is one that is simply old — +# so this reports which rather than a yes/no. `sort -V` does the comparing, so +# nothing here has to parse a version. +bun_series_position() { + local now + now="$(bun_series_of "${1:-}")" + [[ -n "$now" ]] || { + echo unknown + return + } + [[ "$now" == "$BUN_SERIES" ]] && { + echo in + return + } + if [[ "$(printf '%s\n%s\n' "$now" "$BUN_SERIES" | sort -V | tail -1)" == "$now" ]]; then + echo above + else + echo below + fi +} + # Present anywhere: on PATH for this root shell, or in the account's own # ~/.bun/bin, which is where the installer puts it and where root cannot see it. bun_installed() { command -v bun &>/dev/null || [[ -x "${USER_HOME}/.bun/bin/bun" ]]; } @@ -260,8 +323,8 @@ bun_version() { # The system-wide link, ensured on every run rather than only after an install. # # pm2 started at boot by systemd has no login shell, so ~/.bun/bin is not on its -# PATH — and every one of the twenty ecosystem apps that says `script: 'bun'` -# then fails to start on reboot while working perfectly when started by hand. A +# PATH — and every ecosystem app that says `script: 'bun'` then fails to start on +# reboot while working perfectly when started by hand. A # machine that already had bun before this script ran would never get the link if # it were only made as part of installing. # @@ -280,8 +343,22 @@ ensure_bun_symlink() { # systemd has no login shell and therefore no ~/.bun/bin on PATH — without the # symlink every bun-based sidecar fails to start on reboot and works fine when # started by hand, which is a miserable thing to debug. +# +# The version goes in as the installer's first positional argument, which it +# documents as "a specific tag of bun to install (e.g. bun-v0.1.4)" and turns into a +# releases/download// URL. `bash -s` is what carries an argument through a pipe. +# The installer handles being pointed at an older tag on purpose — it re-probes AVX2 +# rather than assuming the baseline the newest release was built for — so this both +# installs and pins back down. +# +# It REFUSES to run unpinned. An empty tag means bun_series_latest could not reach +# the registry, and installing whatever is newest is the single outcome the pin +# exists to prevent, so a machine with no bun is the better failure: it is loud, and +# officer-setup's pre-flight stops on a missing bun rather than building on 1.4. install_bun() { - (cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://bun.sh/install | bash') >/dev/null 2>&1 + local tag="${1:-}" + [[ -n "$tag" ]] || return 1 + (cd / && sudo -H -u "$USERNAME" bash -c "curl -fsSL https://bun.sh/install | bash -s '${tag}'") >/dev/null 2>&1 [[ -x "${USER_HOME}/.bun/bin/bun" ]] } diff --git a/scripts/setup/machine-setup/machine-setup.sh b/scripts/setup/machine-setup/machine-setup.sh index 113d2a4f..745785cf 100755 --- a/scripts/setup/machine-setup/machine-setup.sh +++ b/scripts/setup/machine-setup/machine-setup.sh @@ -1981,14 +1981,18 @@ step "JavaScript runtimes" if ! skip; then NODE_LTS="$(node_lts_major)" NODE_NOW="$(node_installed_major)" + # Resolved before the banner so it can state the pin rather than only the version + # that happens to be here. One network call, same as node_lts_label above it. + BUN_WANT="$(bun_series_latest)" + BUN_NOW="$(bun_version)" echo "" info "JavaScript runtimes — required, so these are installed rather than offered" echo " node ${NODE_NOW:+v$NODE_NOW }${NODE_NOW:+-> }LTS $(node_lts_label)" echo " pm2 is a Node application, and officer-pty compiles node-pty" echo " against whatever Node is installed — there is no Linux prebuild." - echo " bun $(bun_installed && bun_version || echo 'not installed')" - echo " the platform itself, and nineteen of the twenty pm2 apps." + echo " bun ${BUN_NOW:-not installed} — pinned to the ${BUN_SERIES} series${BUN_WANT:+, newest is ${BUN_WANT}}" + echo " the platform itself, and every pm2 app except officer-pty." echo " pm2 $(pm2_installed && echo installed || echo 'not installed')" echo " supervises all of them, and the ecosystem files are written for it." echo "" @@ -2014,18 +2018,93 @@ if ! skip; then fi fi - # ── bun ── - if bun_installed; then - ok "bun $(bun_version) already installed" - else - info " installing bun..." - if install_bun && bun_installed; then + # ── bun, held at the pinned series ── + # + # Four states, because "installed" is not one answer. A machine can be inside the + # pin, behind it, or past it — and past it is the case worth catching, since that + # is what a `bun upgrade` leaves behind and nothing else reports it. + if [[ -z "$BUN_WANT" ]]; then + # No pin resolved means no install: taking whatever bun.sh serves is the one + # thing the pin exists to stop. + warn "could not reach the npm registry to find the newest bun ${BUN_SERIES}.x" + if bun_installed; then + echo " leaving bun ${BUN_NOW} where it is" + ERRORS+=("Bun: could not resolve ${BUN_SERIES}.x; left ${BUN_NOW} alone") + else + echo " nothing installed — this will not install an unpinned bun, and" + echo " officer-setup stops on a missing bun rather than building on 1.4." + ERRORS+=("Bun: could not resolve ${BUN_SERIES}.x; nothing installed") + fi + + elif ! bun_installed; then + info " installing bun ${BUN_WANT}, pinned to the ${BUN_SERIES} series..." + if install_bun "bun-v${BUN_WANT}" && bun_installed; then ok "bun $(bun_version)" - SUMMARY+=("Bun: $(bun_version)") + SUMMARY+=("Bun: $(bun_version) (pinned to ${BUN_SERIES}.x)") else warn "the bun install did not complete" ERRORS+=("Bun: install failed") fi + + else + case "$(bun_series_position "$BUN_NOW")" in + in) + if [[ "$BUN_NOW" == "$BUN_WANT" ]]; then + ok "bun ${BUN_NOW} — the newest in the pinned ${BUN_SERIES} series" + else + info "bun ${BUN_NOW} is inside the pin, and ${BUN_WANT} is out" + echo " A patch inside the series is what the pin is meant to let" + echo " through. Note that 'bun upgrade' would not stop there — it" + echo " takes the newest release, which is past the pin." + if confirm "Move to ${BUN_WANT}?"; then + if install_bun "bun-v${BUN_WANT}"; then + ok "bun $(bun_version)" + SUMMARY+=("Bun: ${BUN_NOW} -> $(bun_version)") + else + warn "the install did not complete — bun ${BUN_NOW} is untouched" + ERRORS+=("Bun: could not move to ${BUN_WANT}") + fi + else + echo " left at ${BUN_NOW}" + fi + fi + ;; + above) + # The `bun upgrade` case. Reported rather than corrected silently: moving + # somebody's runtime backwards is a decision, and this is the only place + # that knows it happened. + warn "bun ${BUN_NOW} is PAST the pinned ${BUN_SERIES} series" + echo " Most likely a 'bun upgrade', which takes no version argument and" + echo " always jumps to the newest release. The platform and every pm2" + echo " app except officer-pty run on this." + if confirm "Pin it back to ${BUN_WANT}?"; then + if install_bun "bun-v${BUN_WANT}"; then + ok "bun $(bun_version) — back inside the pin" + SUMMARY+=("Bun: pinned back, ${BUN_NOW} -> $(bun_version)") + else + warn "the install did not complete — bun ${BUN_NOW} is untouched" + ERRORS+=("Bun: could not pin ${BUN_NOW} back to ${BUN_WANT}") + fi + else + warn "left at ${BUN_NOW}, outside the pin" + SUMMARY+=("Bun: ${BUN_NOW}, PAST the ${BUN_SERIES} pin by choice") + fi + ;; + *) + info "bun ${BUN_NOW} is older than the pinned ${BUN_SERIES} series" + if confirm "Move to ${BUN_WANT}?"; then + if install_bun "bun-v${BUN_WANT}"; then + ok "bun $(bun_version)" + SUMMARY+=("Bun: ${BUN_NOW} -> $(bun_version)") + else + warn "the install did not complete — bun ${BUN_NOW} is untouched" + ERRORS+=("Bun: could not move to ${BUN_WANT}") + fi + else + echo " left at ${BUN_NOW}" + fi + ;; + esac fi # Ensured every run, not only after an install. A machine that already had bun diff --git a/todo/infra/revisit-the-bun-1-3-pin-once-1-4-has-been.md b/todo/infra/revisit-the-bun-1-3-pin-once-1-4-has-been.md new file mode 100644 index 00000000..10493646 --- /dev/null +++ b/todo/infra/revisit-the-bun-1-3-pin-once-1-4-has-been.md @@ -0,0 +1,32 @@ +--- +id: infra/revisit-the-bun-1-3-pin-once-1-4-has-been +title: 'Revisit the bun 1.3 pin once 1.4 has been watched for a while' +theme: infra +status: open +created: 2026-08-22 +date_source: stated +order: 6 +--- + +**Revisit the bun 1.3 pin once 1.4 has been watched for a while.** Raised by the owner 2026-08-22, +who asked for it explicitly as a temporary hold — "a few months, until I'm sure the new 1.4 is going +to be safe" — so this exists to stop a deliberate pause becoming permanent by nobody remembering it. + +bun 1.4.0 was published 2026-08-20 and `https://bun.sh/install` serves whatever is newest, so a +machine provisioned on 2026-08-22 would have put a two-day-old release under the platform and every +pm2 app except `officer-pty`. The hold is at the SERIES, not a patch: `BUN_SERIES=1.3` in +`scripts/setup/machine-setup/lib/dev.sh`, resolved to the newest `1.3.x` from the npm registry at +install time, so bug fixes inside the series still land. + +Two places carry the number and they must move together — `BUN_SERIES` in `dev.sh`, and +`engines.bun` in `package.json`, which is what `bun run doctor` reads to decide whether the running +bun has drifted. bun itself ignores `engines` (verified: an install declaring `1.9.x` ran clean under +1.3.14), so that field is a declaration and doctor is the enforcement. + +What is NOT covered, and is the reason a drift check exists at all: `bun upgrade` takes no version +argument — latest-stable or `--canary`, nothing else — so it always jumps to the newest release and +walks a pinned machine off the pin silently. Nothing in a setup script can prevent that; `bun run +doctor` reports it after the fact, and `machine-setup.sh --only 'JavaScript runtimes'` offers to pin +it back down. + +To lift the hold: raise both numbers, run `bun run doctor` on each machine, and delete this item.