From a10527d4e48f89aaa0b688031fe1003904ca2f2c Mon Sep 17 00:00:00 2001 From: Michael Sitarzewski Date: Sun, 12 Jul 2026 19:22:29 -0500 Subject: [PATCH 1/9] =?UTF-8?q?docs(bundles):=20release=200.7.0=20tracker?= =?UTF-8?q?=20+=20Bundles=20feature=20plan=20(contract,=20baselines,=20M1?= =?UTF-8?q?=E2=80=93M5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit memory-bank/releases/0.7.0/: release tracker + the full Bundles plan β€” the contributor recipe-contract, July-2026 capability-baselines (verified links), and five milestone build docs (m1 capability engine β†’ m5 live-refresh + contributions). Wired into toc.md. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01X9iMFjHE21ePTjcbHpTXt6 --- memory-bank/releases/0.7.0/README.md | 73 +++++++++++ memory-bank/releases/0.7.0/bundles-plan.md | 113 ++++++++++++++++++ .../0.7.0/bundles/capability-baselines.md | 84 +++++++++++++ .../0.7.0/bundles/m1-capability-engine.md | 57 +++++++++ .../bundles/m2-recipe-contract-and-loader.md | 55 +++++++++ .../0.7.0/bundles/m3-browse-and-install.md | 48 ++++++++ .../0.7.0/bundles/m4-setup-guidance.md | 41 +++++++ .../m5-live-refresh-and-contributions.md | 48 ++++++++ .../releases/0.7.0/bundles/recipe-contract.md | 84 +++++++++++++ memory-bank/toc.md | 1 + 10 files changed, 604 insertions(+) create mode 100644 memory-bank/releases/0.7.0/README.md create mode 100644 memory-bank/releases/0.7.0/bundles-plan.md create mode 100644 memory-bank/releases/0.7.0/bundles/capability-baselines.md create mode 100644 memory-bank/releases/0.7.0/bundles/m1-capability-engine.md create mode 100644 memory-bank/releases/0.7.0/bundles/m2-recipe-contract-and-loader.md create mode 100644 memory-bank/releases/0.7.0/bundles/m3-browse-and-install.md create mode 100644 memory-bank/releases/0.7.0/bundles/m4-setup-guidance.md create mode 100644 memory-bank/releases/0.7.0/bundles/m5-live-refresh-and-contributions.md create mode 100644 memory-bank/releases/0.7.0/bundles/recipe-contract.md diff --git a/memory-bank/releases/0.7.0/README.md b/memory-bank/releases/0.7.0/README.md new file mode 100644 index 0000000..aafc2e9 --- /dev/null +++ b/memory-bank/releases/0.7.0/README.md @@ -0,0 +1,73 @@ +# Release 0.7.0 / native 0.3.0 + +**Status:** 🚧 In progress β€” contents on `main` (unreleased), headline feature (Bundles) in planning. +**Baseline:** 0.6.0 / native 0.2.0 (tag `v0.6.0`). +**Version step:** minor β€” new user-facing feature (pin/unpin; Bundles to follow). Split-track per [decisions.md](../../decisions.md): Tauri/web (+Linux) `0.6.0 β†’ 0.7.0`, native macOS `0.2.0 β†’ 0.3.0`. Same feature set under two numbers; release notes state the equivalence ("native 0.3.0 ≙ Tauri 0.7.0"). Single git tag `v0.7.0`. + +> Directory named `0.7.0` (primary/Tauri version); native carries `0.3.0`. + +--- + +## What's in it (merged to `main` since `v0.6.0`) + +Grouped by theme; every item is a merged PR on `main` unless marked. + +### Headline feature +- **Pin / unpin packages (#141)** β€” hold a package back from "Update all", both shells, formulae **and** casks. Closes **#90**, **#134**. Includes the Library panel refresh: **Pinned** filter tab, per-tab counts removed, bottom status bar (leads with the active filter's count), cask-aware `pinnedCount`. + +### In-app brew control (pre-session, on main) +- **In-app command options (#109)** β€” reactive recovery UI (adopt/overwrite/force-remove) + Advanced disclosure (greedy upgrade, autoremove). Addresses #98 (reactive), #47, #13, #102, #100. +- **Doctor + Cleanup (#82, #83)** β€” `brew doctor` + `brew cleanup --prune=all` on the Storage card; `--scrub` opt-in (default off). Issue #80. + +### Vulnerability scanning hardening +- **Exposure parity + GHSA enrichment port (#107)** β€” native gains `source: live/cache` label + `VulnsEnrich`; fixes the Tauri over-count (`replace_full_scan`); unified card wording. +- **Vuln scan tap-names + JSON salvage (#103)** β€” #62/#92: accept tap-qualified formula names, salvage JSON from banner noise. + Homebrew-analytics privacy env. +- **Native catalog empty-response guard (#108)** β€” parity with #101. +- **GHSA advisory `references` shape (#110)** β€” βœ… merged 2026-07-12. Repairs enrichment (references are a string array, not `[{url}]`; was a silent no-op). Verified end-to-end (live fetch = `Some` with 9 refs). + +### UX / correctness fixes (this session) +- **Native list scales, no sidebar clip (#142)** β€” content-column min widths fit the 420pt floor. +- **Install-trend sparkline scale (#143)** β€” stop mixing `count30d` (cumulative) with `estimatedDailyInstalls` (daily); kills the cliff artifact. Both shells. +- **Tauri vulnerable-footer nav (#144)** β€” footer jumps to Library β†’ Vulnerable (was Dashboard). Parity. +- **Native outdated tap-name undercount (#145)** β€” tap-installed outdated packages were dropped from the Outdated filter (Swift 8 vs Tauri 9); normalized via `bareToken`. Same tap-name class as #92. + +### Community / upstream +- **Catalog reject-empty-response (#101, @Arvuno)** β€” reviewed + merged. + +### Report hygiene + docs +- **Report-button brew-vs-app gating (#91)** β€” the report button shows only for genuine app errors, stopping misfiled `[brew-browser] X failed` issues at the source. +- Docs: #94 (memory-bank session), #93 (landing favicon), #86 (tap-trust install docs), #84 (credit @modeezie), #77 (version-wrap + native release tooling). + +--- + +## Headline feature β€” Bundles / Recipes (planned, plan complete) +Curated **one-click package stacks** with post-install setup guidance, **capability-gated** by a zero-install system profile (RAM/arch/GPU/disk) so an 8GB Mac isn't told to install a local-LLM stack it can't run. New "Bundles" nav section; brew-native install auto-runs, external steps (model pulls, etc.) are copy-paste. Both shells. **Contributor-friendly:** recipes are validated JSON files anyone can PR against a published contract. + +**Plan (approved 2026-07-12, not yet built):** overview in **[bundles-plan.md](./bundles-plan.md)**; 5 milestone build docs + the contributor contract + July-2026 capability baselines under **[`bundles/`](./bundles/)**: +- [recipe-contract.md](./bundles/recipe-contract.md) Β· [capability-baselines.md](./bundles/capability-baselines.md) +- M1 [capability-engine](./bundles/m1-capability-engine.md) β†’ M2 [contract-and-loader](./bundles/m2-recipe-contract-and-loader.md) β†’ M3 [browse-and-install](./bundles/m3-browse-and-install.md) β†’ M4 [setup-guidance](./bundles/m4-setup-guidance.md) β†’ M5 [live-refresh-and-contributions](./bundles/m5-live-refresh-and-contributions.md) + +Initial 6 recipes (tokens/taps verified 2026-07-12, all official taps): Local LLMs (`ollama`+`open-webui`), Image Gen (`comfy`), Graphics (`inkscape`/`gimp`/`krita`), Media (`ffmpeg`/`yt-dlp`/`mpv`), Web Dev (`node`+…), Databases (`postgresql@16`/`redis`). + +--- + +## Release checklist (all gated to the user) +- [x] Merge **#110** (GHSA references) into `main`. βœ… 2026-07-12 (main `6799e98`). +- [x] Decide: **Bundles rides THIS release** (0.7.0/0.3.0), plan complete β†’ `bundles/`. βœ… 2026-07-12. +- [ ] Build Bundles: **M1** capability engine β†’ **M2** contract+loader β†’ **M3** browse+install β†’ **M4** setup guidance β†’ **M5** live-refresh+contributions. Each its own branch, both shells green. +- [ ] Version bumps: `package.json` / `Cargo.toml` / `tauri.conf.json` β†’ `0.7.0`; native `build-app.sh` CFBundleShortVersionString β†’ `0.3.0`. Docs (README, BUILD.md, release-notes/unreleased.md, native/README) consistent. +- [ ] Live-verify on main: pin/unpin (formula + cask), Library bottom bar + Pinned tab, list-scale, install-trend, vulnerable-footer nav, GHSA enrichment. +- [ ] Build + notarize both shells (Tauri arm64 + x64 dmgs + updater `.app.tar.gz`; native arm64 + x64 dmgs). Recipe: [build-deployment / RELEASE-BUILD GOTCHAS in project-resume-state]. +- [ ] `gh release create v0.7.0` with assets + release notes. +- [ ] rsync feeds to host: Tauri `updater.json` (both arches) + native Sparkle `appcast.xml` (arm64-only feed). +- [ ] **Tap cask bump** (separate repo `msitarzewski/homebrew-brew-browser`): version + per-arch sha256 (doesn't auto-follow releases). +- [ ] Close issues fixed-in-build: **#90**, **#134** (pin); **#62**, **#92** (already fixed via #103, still open β€” close with "fixed in 0.7.0"). +- [ ] Update `progress.md` + `tasks/2026-07/README.md`. + +## Open threads / housekeeping +- **Stale local branches**: many old `release/*`, `docs/*`, merged `feat/*` branches linger locally (not on `main`). Prune candidate β€” confirm with user first. +- **Deferred** (from 0.6.x): #98 proactive Adopt Discovery; Recent-Changes Dashboard card decision; server-side precomputed sparkline may share the #143 count30d issue (pipeline fix on host, not this repo); clippy nits. + +--- + +*Created 2026-07-12. `main` @ `fe73804` (clean, synced with origin).* diff --git a/memory-bank/releases/0.7.0/bundles-plan.md b/memory-bank/releases/0.7.0/bundles-plan.md new file mode 100644 index 0000000..701de41 --- /dev/null +++ b/memory-bank/releases/0.7.0/bundles-plan.md @@ -0,0 +1,113 @@ +# Feature Plan: Bundles (capability-gated recipes) β€” OVERVIEW + +**Target:** 0.7.0 / native 0.3.0 (this release). **Status:** plan complete; approved to build; not yet started. +**One-liner:** Curated one-click package stacks with post-install setup guidance, gated/recommended by a **zero-install system profile** so we never tell an 8GB Mac to install a local-LLM stack it can't run. + +## Plan documents (build against these) +This file is the design overview. The build is broken into 5 milestones, each its own doc, plus the contributor contract and the grounded capability data: + +| Doc | What | +|---|---| +| [`bundles/recipe-contract.md`](./bundles/recipe-contract.md) | **The contract** β€” recipe JSON shape, setup-step kinds, CI validation rules. What a contributor authors against. | +| [`bundles/capability-baselines.md`](./bundles/capability-baselines.md) | July-2026 minimum-capability data per bundle type + verified links (the `requires` numbers). | +| [`bundles/m1-capability-engine.md`](./bundles/m1-capability-engine.md) | SystemProfile (zero-install) + `readiness()` pure fn, both shells. Foundation. | +| [`bundles/m2-recipe-contract-and-loader.md`](./bundles/m2-recipe-contract-and-loader.md) | `bundles.json` schema + loader + the 6 first-party recipes + validate script. | +| [`bundles/m3-browse-and-install.md`](./bundles/m3-browse-and-install.md) | Bundles nav section, cards + readiness, detail, Install all β†’ Activity. | +| [`bundles/m4-setup-guidance.md`](./bundles/m4-setup-guidance.md) | Post-install checklist (brew-native auto + external copy-only). | +| [`bundles/m5-live-refresh-and-contributions.md`](./bundles/m5-live-refresh-and-contributions.md) | Live-refresh of `bundles.json` + CI contribution pipeline + `CONTRIBUTING-bundles.md`. | + +**Build order:** M1 β†’ M2 β†’ M3 β†’ M4 (M5 parallel to M4 once M2 lands). Each milestone = its own branch off `main`, PLANβ†’BUILDβ†’verify, both shells green before the next. + +## Decisions locked (with the user, 2026-07-12) +- **Build now** β€” headline of this release (revisit if scope balloons). +- **Surface:** a new **"Bundles"** sidebar nav section (first-class, like Discover/Snapshots). +- **Automation boundary:** brew-native steps auto-run (install, `brew services start`, open URL, reveal); **external** steps (`ollama pull`, model downloads, docker) are shown as a **copy-paste checklist** β€” never execute arbitrary shell. +- **Capability gating:** **rule-based on a zero-install profile.** No "System Eval that installs tools" β€” detection β‰  installation, and reading RAM/arch/disk needs nothing installed. Verified 2026-07-12: `sysctl hw.memsize` / `uname -m` / `df` return RAM/arch/disk instantly; unified memory (the RAM number) is the dominant "can I run a local LLM" signal on Apple Silicon. + +## Why it's mostly reuse +A bundle β‰ˆ **a curated Brewfile + metadata + setup guidance**. Existing machinery covers ~80%: +- **Data**: the bundled-JSON + optional live-refresh pattern (catalog / enrichment / trending) β†’ add `bundles.json`, tunable via live-refresh without an app release. +- **Install a set**: the Snapshots/Brewfile-restore path already installs a set and streams into Activity. (Verify at BUILD whether to reuse that or add a thin `brew_install_bundle(names)` mirroring `brew_upgrade_many` + the native `upgradeMany`.) +- **Actions**: services start (existing Services action), open URL (`safeOpenUrl`), copy (clipboard). +- Real new cost is **content + upkeep**, not code. + +## Data model β€” `bundles.json` (bundled at build, live-refreshable) +```jsonc +{ + "schemaVersion": 1, + "bundles": [{ + "id": "local-llm", + "name": "Local LLMs", + "tagline": "Run models locally with Ollama + a web UI", + "category": "AI", + "packages": [ {"name": "ollama", "kind": "cask"}, {"name": "open-webui", "kind": "cask"} ], + "requires": { "minRamGB": 8, "recommendedRamGB": 16, "minDiskGB": 12, "arch": "any", "gpu": "preferred" }, + "capabilityNotes": { "8": "small (~7B quantized) models only; larger will swap", "16": "7–13B comfortably", "32": "30B+ / multiple models" }, + "setup": [ + { "kind": "service", "service": "ollama", "label": "Start Ollama" }, // brew-native β†’ auto button + { "kind": "command", "run": "ollama pull llama3.2", "label": "Pull a model", "copyOnly": true }, // external β†’ copy + { "kind": "open", "url": "http://localhost:8080", "label": "Open Open WebUI" } // brew-native β†’ auto button + ], + "caveats": "Installs the tools; you'll still download models (several GB)." + }] +} +``` +`#[serde(default)]` / tolerant decode everywhere (forward-compat, mirrors the enrichment/vulns cache pattern). + +## System profile β€” zero-install, cached +`SystemProfile { ramGB, arch, chip, cpuCores, gpu, freeDiskGB, osVersion }` +- **macOS**: `sysctl hw.memsize` (RAM), `machdep.cpu.brand_string` (chip), `hw.ncpu`/`hw.physicalcpu`, `uname -m` (arch); free disk via the existing Storage/`statvfs` path; `sw_vers` (already have). GPU: Metal is always present on Apple Silicon β†’ mark `gpu: metal`; core count from one lazy `system_profiler SPDisplaysDataType` (optional detail β€” **not** needed for gating v1). +- **Linux** (Tauri): `/proc/meminfo`, `/proc/cpuinfo`, `df`; GPU best-effort (`nvidia-smi`/`lspci`), degrade gracefully to `gpu: unknown`. +- Both shells: read once, cache. Rust could use `sysinfo` crate or direct reads; native reads `sysctl` via Foundation. Shared data contract. + +## Readiness β€” pure, tested, both shells +``` +readiness(requires, profile) β†’ { verdict: ready | marginal | blocked, reason } + blocked : ramGB < minRamGB OR freeDiskGB < minDiskGB OR arch mismatch + marginal : minRamGB ≀ ramGB < recommendedRamGB + ready : ramGB β‰₯ recommendedRamGB AND disk ok + reason : from capabilityNotes[nearest tier] / the failing threshold +``` +- **"blocked" warns but does not hard-block** β€” user's machine, user's call (install behind a confirm). +- Graded, not binary: the LLM example maps RAM tiers β†’ capability text (see `capabilityNotes`). +- Bundles with no `requires` (Graphics, Media) β†’ always Ready. + +## UI (both shells) +- **New "Bundles" section**: cards, each with name/tagline/icon + a **readiness pill** (βœ… Ready Β· ⚠️ Marginal Β· β›” Not recommended, with the reason on hover). +- **Bundle detail**: the package list (with per-package installed/outdated state), the capability verdict + note, **"Install all"** (β†’ install-set β†’ Activity), and the **setup checklist** β€” brew-native steps are action buttons; external steps render as copy rows; caveats shown honestly. +- Post-install: the checklist is the landing surface ("installed β€” now do this"). + +## Initial recipe set (~6, hand-curated, honest) +| Bundle | Packages | Gate | +|---|---|---| +| Local LLMs | ollama, open-webui | RAM (graded), disk | +| Image Gen | comfyui | disk (heavy), GPU; strong caveats | +| Graphics / Design | inkscape, gimp, krita | none | +| Media Toolkit | ffmpeg, yt-dlp, mpv, handbrake | none | +| Web Dev | node, … | light | +| Databases | postgresql@16, redis, … | services guidance | + +(Verify each cask/formula token + tap trust at BUILD; comfyui/open-webui are niche casks β€” confirm they resolve and set expectations. `automatic1111` / `stable-diffusion-webui` are NOT in brew β€” exclude or guidance-only.) + +## Testing +- **`readiness()` unit tests** both shells (parity), covering the RAM tiers (4/8/16/32/128 GB), disk floor, arch mismatch, no-requires. +- **`bundles.json` parse tests** (schema, forward-compat unknown fields). +- **Debug profile override** (env var / hidden setting) to inject a synthetic 8/16/32 GB profile β€” required to *see* Marginal/Blocked states on the 128GB M5 Max dev machine. + +## Milestones (detail in `bundles/mN-*.md`) +1. **M1 Capability engine** β€” SystemProfile (zero-install) + `readiness()`, both shells, pure + tested. +2. **M2 Recipe contract & loader** β€” schema + 6 recipes + tolerant parsers + `validate-recipes.mjs`. +3. **M3 Browse & install** β€” Bundles nav section, cards + readiness pills, detail, Install all β†’ Activity. +4. **M4 Setup guidance** β€” post-install checklist (brew-native auto + external copy-only). +5. **M5 Live-refresh & contributions** β€” host-served `bundles.json` + CI validation + `CONTRIBUTING-bundles.md`. + +## Risks / open questions +- **Curation upkeep**: casks get renamed/deprecated; niche casks (comfyui/open-webui) may be flaky. Live-refresh mitigates; start small; honest caveats. +- **Cask + GPU reality**: a one-click that installs ComfyUI still needs models/torch β€” guidance must not oversell. +- **Overlap with Snapshots** (a bundle is a curated Brewfile): position Bundles = opinionated starters, Snapshots = your own backup. Possible later: "save bundle as snapshot." +- **Install-set backend**: confirm whether to reuse Brewfile-restore or add `brew_install_bundle` / native `installMany`. +- **Linux GPU detection** weaker β€” the Image Gen gate degrades to a disk-only check with a GPU note. + +--- + +*Plan drafted 2026-07-12. Approved to write; awaiting go-ahead to implement (would run as PLAN β†’ BUILD β†’ verify, both shells, its own branch off `main`).* diff --git a/memory-bank/releases/0.7.0/bundles/capability-baselines.md b/memory-bank/releases/0.7.0/bundles/capability-baselines.md new file mode 100644 index 0000000..0177acd --- /dev/null +++ b/memory-bank/releases/0.7.0/bundles/capability-baselines.md @@ -0,0 +1,84 @@ +# Capability Baselines (as of July 2026) + +The `requires` / `capabilityNotes` numbers behind the first-party recipes, and the reasoning so future maintainers can retune them. Gating uses **unified memory** as the dominant signal on Apple Silicon (VRAM == RAM); Intel Macs are supported but flagged where GPU/Neural-Engine acceleration matters. All package tokens/versions verified against local `brew` on 2026-07-12. + +> These are **starting** thresholds β€” `requires` lives in `bundles.json` and is retunable via live-refresh (M5) without an app release. + +--- + +## Local LLMs β€” `ollama` (formula) + `open-webui` (cask) +Both in official taps (`homebrew/core`, `homebrew/cask`); no tap-trust. Ollama = the CLI/server (`brew services start ollama`, API on **:11434**); Open WebUI = the browser front-end (default **:8080**), talks to the local Ollama server. + +**Requirements reasoning (July 2026):** Ollama's own floor is **8 GB RAM, ~10 GB disk, no GPU required**. RAM need scales with model size, and 4-bit (Q4_K_M) is the consumer default (~0.6 GB per billion params): + +| Unified memory | Practical capability | Verdict | +|---|---|---| +| < 8 GB | not viable | ❌ Not recommended | +| 8 GB | ~7–8B (Q4) only; larger swaps | ⚠️ Marginal | +| 16 GB | 13–14B comfortably (the sweet spot) | βœ… Ready | +| 24 GB | 26–32B class (Qwen/Gemma) | βœ… Ready | +| 48–64 GB+ | 70B (Q4 β‰ˆ 40 GB) + MoE | βœ… Ready | + +Apple-Silicon note: unified memory counts fully as VRAM, so an M-series Mac punches far above a same-price discrete GPU (a 64 GB Mac runs 70B locally). Intel Macs run Ollama but without Metal/ANE acceleration β†’ slow; flag `gpu: preferred`, `arch: any`. + +**Recipe `requires`:** `minRamGB: 8, recommendedRamGB: 16, minDiskGB: 12, arch: any, gpu: preferred`. + +**Further reading (verified):** +- [Ollama](https://ollama.com) Β· [docs.ollama.com/quickstart](https://docs.ollama.com/quickstart) Β· [github.com/ollama/ollama](https://github.com/ollama/ollama) +- [Open WebUI](https://openwebui.com) Β· [docs.openwebui.com](https://docs.openwebui.com) +- Capability guide: [How Much RAM for Local LLMs? (2026)](https://tensorrigs.com/blog/ram-for-local-llm/) Β· [Ollama model RAM/VRAM table (2026)](https://localaimaster.com/blog/ollama-model-ram-vram-table) + +--- + +## Image Gen β€” `comfy` (cask) +ComfyUI's cask token is **`comfy`** (`comfyui` is an alias β†’ `comfy`); `homebrew/cask`, no tap-trust. Node-graph image-generation front-end. + +**Requirements reasoning (July 2026):** ComfyUI itself claims broad flexibility β€” "can run large models on GPUs with as low as 1 GB VRAM with smart offloading" β€” and shipped **Dynamic VRAM on by default in early 2026**, so an 8 GB machine *can* run Flux 2 Dev. But *comfortable* image gen has real floors: **SDXL wants β‰₯ 8 GB VRAM (any M-series Mac with 16 GB unified is fine); Flux.1 dev fp8 β‰ˆ 12 GB, and β‰ˆ 24 GB at fp16.** Apple Silicon uses MPS (an MLX path exists, ~70% faster) but is roughly **2–4Γ— slower per image than a comparable NVIDIA GPU**. Models are large on disk (SDXL checkpoints ~7 GB; Flux ~24 GB) and are **not** installed by brew β€” the cask installs the app; you download models yourself. + +| Unified memory | Practical capability | Verdict | +|---|---|---| +| < 16 GB | SDXL only with offloading, slow | ⚠️ Marginal | +| 16 GB | SDXL comfortably; Flux fp8 with offloading | βœ… Ready | +| 24 GB | Flux fp8 comfortably | βœ… Ready | +| 48–64 GB+ | Flux fp16 | βœ… Ready | + +Intel Macs: impractical (no MPS acceleration) β†’ `gpu: required` effectively excludes them via the reason string. On Linux, GPU detection is best-effort (nvidia-smi); degrade to a disk-only check + GPU note. + +**Recipe `requires`:** `minRamGB: 16, recommendedRamGB: 24, minDiskGB: 30, arch: apple-silicon, gpu: required`. Strong `caveats`: installs ComfyUI; you still download models (many GB) and generation is slower than NVIDIA. + +**Further reading (verified):** +- [ComfyUI (github.com/comfyanonymous/ComfyUI)](https://github.com/comfyanonymous/ComfyUI) Β· [comfy.org](https://www.comfy.org) +- Capability guides: [Image Generation VRAM Requirements 2026](https://willitrunai.com/blog/image-generation-vram-guide-2026) Β· [ComfyUI VRAM Requirements (GIGAGPU)](https://gigagpu.com/comfyui-vram-requirements/) + +--- + +## Graphics / Design β€” `inkscape`, `gimp`, `krita` (casks) +All `homebrew/cask`. Vector (Inkscape 1.4.4), raster (GIMP 3.2.4), digital painting (Krita 5.3.2.1). **No hardware gate** β€” run on any supported Mac; `requires` omitted β†’ always Ready. Disk note only (~2–3 GB combined). + +**Further reading (verified):** [inkscape.org](https://inkscape.org) Β· [gimp.org](https://www.gimp.org) Β· [krita.org](https://krita.org) + +--- + +## Media Toolkit β€” `ffmpeg`, `yt-dlp`, `mpv` (formulae) +All `homebrew/core` (ffmpeg 8.1.2, yt-dlp 2026.7.4, mpv 0.41.0). CLI transcoding/download/playback. **No hardware gate.** (A GUI `handbrake` cask can be added; the `handbrake` *formula* is the CLI `HandBrakeCLI`.) + +**Further reading (verified):** [ffmpeg.org](https://ffmpeg.org) Β· [github.com/yt-dlp/yt-dlp](https://github.com/yt-dlp/yt-dlp) Β· [mpv.io](https://mpv.io) + +--- + +## Web Dev β€” `node` (formula) + … +`homebrew/core` (node 26.5.0). Light footprint; no meaningful hardware gate. Final package set TBD at build (candidates: `node`, `pnpm`, `caddy`). Setup guidance points at the runtimes. + +**Further reading (verified):** [nodejs.org](https://nodejs.org) + +--- + +## Databases β€” `postgresql@16`, `redis` (formulae) +`homebrew/core` (postgresql@16 = 16.14, redis 8.8.0). Light RAM; the value is **service** setup (`brew services start postgresql@16` / `redis`). Disk grows with data. No install-time hardware gate; `requires` omitted or a small `minDiskGB`. + +**Further reading (verified):** [postgresql.org](https://www.postgresql.org) Β· [redis.io](https://redis.io) Β· [Homebrew services](https://docs.brew.sh/Manpage#services-subcommand) + +--- + +## Link-verification note +Primary sources (project homepages from brew cask metadata, official repos, `docs.ollama.com`) were reachable on 2026-07-12. `docs.openwebui.com` bot-blocks automated HEAD requests (real site; whitelist in the CI link-checker). The capability *guide* links are secondary/SEO and used only as "further reading" β€” the threshold numbers are grounded in the primary docs + the reasoning above. diff --git a/memory-bank/releases/0.7.0/bundles/m1-capability-engine.md b/memory-bank/releases/0.7.0/bundles/m1-capability-engine.md new file mode 100644 index 0000000..486f8a2 --- /dev/null +++ b/memory-bank/releases/0.7.0/bundles/m1-capability-engine.md @@ -0,0 +1,57 @@ +# M1 β€” Capability engine (zero-install profile + readiness) + +**Goal:** read the machine's capabilities with **zero installs**, and turn `(bundle.requires, profile) β†’ readiness verdict` as a pure, unit-tested function. No UI, no bundle data yet β€” this is the foundation M2/M3 build on. + +**Depends on:** nothing. **Blocks:** M3 (readiness pills), M2 (shares the `requires` type). + +## Scope +- **In:** `SystemProfile` (RAM, arch, chip, cores, gpu, freeDiskGB, osVersion), cached; `readiness()` pure fn; a debug profile override; unit tests both shells. +- **Out:** any bundle/recipe data (M2), any UI (M3), GPU core-count detail (nice-to-have, not needed to gate). + +## Shared data contract (both shells must agree) +``` +SystemProfile { ramGB: number, arch: "apple-silicon"|"intel"|"linux", chip: string, + cpuCores: number, gpu: "metal"|"cuda"|"none"|"unknown", freeDiskGB: number, osVersion: string } +Readiness { verdict: "ready"|"marginal"|"blocked", reason: string } +readiness(requires, profile): + if requires == null -> ready + if profile.ramGB < requires.minRamGB -> blocked ("needs β‰₯{min} GB RAM, you have {ram}") + if profile.freeDiskGB < requires.minDiskGB -> blocked ("needs β‰₯{minDisk} GB free, you have {free}") + if requires.arch != "any" && mismatch -> blocked ("built for {arch}") + if profile.ramGB < requires.recommendedRamGB-> marginal (capabilityNote for the nearest tier ≀ ramGB) + else -> ready (capabilityNote for the nearest tier, if any) +"blocked" is advisory β€” the UI still allows install behind a confirm (M3). +``` + +## Tauri (Rust + TS) +- **New** `src-tauri/src/system/profile.rs` (+ `mod system;` in `lib.rs`): `SystemProfile` struct (`Serialize`), `fn detect() -> SystemProfile`. Reads: + - macOS: `sysctl` (`hw.memsize`, `machdep.cpu.brand_string`, `hw.ncpu`), `std::env::consts::ARCH`; free disk via the existing disk-usage/`statvfs` path used by the Storage card; `gpu = metal` on Apple Silicon. + - Linux: `/proc/meminfo`, `/proc/cpuinfo`, `df`/`statvfs`; `gpu` best-effort (`nvidia-smi` presence) else `unknown`. + - Prefer a direct read; `sysinfo` crate is acceptable if it doesn't bloat the build. Cache in `AppState` (compute once). +- **Command** `system_profile() -> SystemProfile` in `commands/` (register in `lib.rs` handler). +- **Readiness** as pure TS in `src/lib/util/readiness.ts` (so it's vitest-able without IPC) β€” mirrors the Rust logic; OR keep readiness in Rust and expose per-bundle. **Decision:** compute readiness **client-side in TS** (the profile is small + the bundle list is client-side in M3); Rust only supplies the profile. Add `SystemProfile`/`Readiness`/`BundleRequires` to `src/lib/types.ts`. +- **Debug override:** a hidden env/localStorage key (`BREWBROWSER_FAKE_RAM_GB`) that overrides `ramGB` so Marginal/Blocked states are reachable on the 128 GB dev machine. + +## Native (Swift) +- **New** `native/Sources/BrewBrowserKit/SystemProfile.swift`: a `Sendable struct SystemProfile` + `static func detect() async -> SystemProfile`. Reads `sysctl` via `Foundation`/`sysctlbyname` (`hw.memsize`, `machdep.cpu.brand_string`, `hw.ncpu`), `#if arch(arm64)` for arch, free disk via `URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])` (or reuse the Storage path), `gpu = .metal` on Apple Silicon. Cache on `AppModel` (compute once at load, like the other dashboard probes). +- **New** `native/Sources/BrewBrowserKit/BundleReadiness.swift`: `enum ReadinessVerdict` + `static func readiness(_ requires: BundleRequires?, _ profile: SystemProfile) -> Readiness` β€” pure, mirrors the TS/Rust rules exactly. +- **Debug override:** `LocalPrefs`/env `BREWBROWSER_FAKE_RAM_GB` β†’ overrides `ramGB`. + +## Tests (parity β€” the SAME cases both shells) +Table-driven, covering the readiness matrix: +- no `requires` β†’ ready. +- ramGB 4/8/16/32/64/128 against `{min:8, rec:16}` β†’ blocked/marginal/ready/ready/ready/ready. +- disk floor blocks. +- arch mismatch (recipe `apple-silicon`, profile `intel`) β†’ blocked. +- reason strings pull the correct `capabilityNotes` tier. +- Tauri: `src/lib/util/readiness.test.ts` (vitest). Native: `BundleReadinessTests.swift` (Swift Testing). Rust: a small parse/detect smoke test (detect() returns ram > 0). + +## Acceptance criteria +- `system_profile()` (Tauri) and `SystemProfile.detect()` (native) return real RAM/arch/disk on this Mac (128 GB / apple-silicon / metal). +- `readiness()` unit tests green both shells, identical verdicts for identical inputs. +- Debug RAM override flips a fixed `requires` between ready/marginal/blocked. +- Zero new installs, zero network, no new permissions. + +## Risks +- `system_profiler` (GPU core count) is ~1 s β€” **don't** call it on the gating path; RAM+arch+disk suffice. Fetch GPU detail lazily only if a later card wants to show it. +- Linux GPU detection is weak β€” `unknown` is a valid value; image-gen recipes degrade to a disk check + note. diff --git a/memory-bank/releases/0.7.0/bundles/m2-recipe-contract-and-loader.md b/memory-bank/releases/0.7.0/bundles/m2-recipe-contract-and-loader.md new file mode 100644 index 0000000..77b3e2a --- /dev/null +++ b/memory-bank/releases/0.7.0/bundles/m2-recipe-contract-and-loader.md @@ -0,0 +1,55 @@ +# M2 β€” Recipe contract & loader + +**Goal:** define the recipe data (the **[recipe contract](./recipe-contract.md)**), ship the initial curated recipes bundled into both apps, and parse them tolerantly on both shells. This is where "contributors add a recipe against a contract" becomes real. + +**Depends on:** M1 (`BundleRequires`/readiness types). **Blocks:** M3 (needs bundles to render), M5 (live-refresh + CI of the same artifact). + +## Scope +- **In:** JSON Schema (`recipe.schema.json`), `recipes/*.json` (the ~6 first-party recipes grounded in [capability-baselines.md](./capability-baselines.md)), the build step that concatenates them into `bundles.json`, the bundled-data plumbing + tolerant parsers both shells, parse unit tests. +- **Out:** UI (M3), live-refresh + CI action (M5 β€” but author the validate script here so recipes are checkable as they're written). + +## Data flow (mirrors catalog/enrichment/trending) +``` +recipes/.json ──(validate + concat, build script)──► bundles.json +bundles.json ──bundled at build──► Tauri: src-tauri/resources/ (or the catalog bundling path) + └─bundled at build──► native: Sources/BrewBrowserKit/Resources/ (Bundle.module) + └─(M5) published──────► live-refresh host +``` +`bundles.json` = `{ "schemaVersion": 1, "generatedAt": "...", "bundles": [ ... ] }`. + +## The initial recipes (author under the contract) +Six files in `recipes/`, tokens/requires from [capability-baselines.md](./capability-baselines.md): +- `local-llm.json` β€” `ollama` (formula) + `open-webui` (cask); requires 8/16/12; setup: start ollama Β· `ollama pull llama3.2` (external) Β· open :8080. +- `image-gen.json` β€” `comfy` (cask); requires 16/24/30, apple-silicon, gpu required; heavy caveats. +- `graphics.json` β€” `inkscape`,`gimp`,`krita` (casks); no requires. +- `media.json` β€” `ffmpeg`,`yt-dlp`,`mpv` (formulae); no requires. +- `web-dev.json` β€” `node` (+ TBD); light. +- `databases.json` β€” `postgresql@16`,`redis` (formulae); setup: start services. + +## Tauri (Rust + TS) +- **Types:** `Bundle`, `BundlePackage`, `BundleRequires`, `SetupStep` in `src-tauri/src/types.rs` (serde, `#[serde(default)]` everywhere) + the TS mirror in `src/lib/types.ts`. +- **Loader:** mirror the catalog/enrichment bundling. Either bake `bundles.json` into the binary (like the bundled catalog) exposed via a `bundles() -> Vec` command, or read it from the resources dir. Tolerant deserialize (unknown fields ignored; a bad single recipe is skipped, not fatal β€” collect + log). +- **Store:** `src/lib/stores/bundles.svelte.ts` β€” loads bundles, holds them; readiness computed client-side (M1's `readiness.ts`) against the M1 profile. + +## Native (Swift) +- **Models:** `Bundle`, `BundlePackage`, `BundleRequires`, `SetupStep` structs in a new `Bundles.swift` (mirror `Categories`/enrichment models). Tolerant `Decodable` (optional fields, per-recipe try/skip). +- **Loader:** `BundleCatalog` service that parses the bundled `bundles.json` from `Bundle.module` off the main thread at launch (same pattern as `loadBundledData()` for categories/enrichment). Expose on `AppModel` (`var bundles: [Bundle]`). +- Readiness via M1's `BundleReadiness.readiness(_:_:)` against the cached `SystemProfile`. + +## Validation script (author now; wired to CI in M5) +- `scripts/validate-recipes.mjs` (Node, no deps or ajv): for each `recipes/*.json` β†’ schema-validate, id-unique, `brew info --json=v2` resolves each package to its declared kind, third-party tap β‡’ `tap` field required, `requires` sane, `command` steps `external:true`, `links` https + HEAD < 400 (whitelist known bot-blockers), string caps. Emits a pass/fail table. Also concatenates passing recipes β†’ `bundles.json`. +- Run it locally in M2 so the six recipes are known-valid before M3 renders them. + +## Tests (parity) +- **Parse tests both shells:** a fixture `bundles.json` with (a) a full recipe, (b) a recipe with only required fields, (c) an unknown-field recipe (must ignore), (d) a malformed recipe (must skip, not crash the batch). Assert the good ones parse and requires/setup map correctly. (Rust: `types`/loader test; native: `BundleParsingTests.swift`; vitest for the TS types if a transform exists.) +- **Schema self-test:** `validate-recipes.mjs` passes on all six first-party recipes. + +## Acceptance criteria +- Six recipes exist, all pass `validate-recipes.mjs` (schema + brew-resolves + links reachable). +- Both apps load the bundled `bundles.json` and expose the six bundles with correct packages/requires/setup. +- A deliberately-malformed 7th recipe is skipped by both parsers without breaking the other six. +- No app code change is needed to add a valid recipe β€” only the JSON file. + +## Risks +- **Cask drift:** `comfy`/`open-webui` are real casks today but niche; CI's `brew info` resolution catches renames/removals at PR time. Live-refresh (M5) lets us fix a broken recipe without an app release. +- **Bundling path parity:** confirm where each shell bundles static JSON (Tauri resources vs native `Bundle.module`) β€” reuse the exact catalog/enrichment path, don't invent a new one. diff --git a/memory-bank/releases/0.7.0/bundles/m3-browse-and-install.md b/memory-bank/releases/0.7.0/bundles/m3-browse-and-install.md new file mode 100644 index 0000000..c782566 --- /dev/null +++ b/memory-bank/releases/0.7.0/bundles/m3-browse-and-install.md @@ -0,0 +1,48 @@ +# M3 β€” Bundles browse & install + +**Goal:** a first-class **Bundles** section β€” cards with capability-aware readiness, a detail view, and one-click **Install all** that streams into Activity. This is the visible feature. + +**Depends on:** M1 (readiness), M2 (bundle data). **Blocks:** M4 (setup checklist lives in the detail view). + +## Scope +- **In:** new "Bundles" nav section (both shells), bundle cards + readiness pill, bundle detail (packages w/ installed state, capability verdict, Install all), install-set backend + Activity streaming, "already installed" awareness. +- **Out:** the post-install setup checklist (M4), live-refresh (M5). + +## Navigation +- **Native:** add `.bundles` to the `Section` enum (`AppModel.swift`) with a symbol; add a case in `ContentView`'s detail switch β†’ `BundlesView`. Keyboard shortcut ⌘7 (Activity currently ⌘6 β€” append). Sidebar badge: none (or count of "Ready" bundles β€” optional). +- **Tauri:** add `bundles` to the sidebar `nav` array (`Sidebar.svelte`) + `SidebarSection` type + a route/section in the main view switch. Icon from lucide (e.g. `package-plus` / `boxes`). + +## Install a set β€” reuse, don't reinvent +The Snapshots/Brewfile-restore path already installs a set and streams into Activity. **Decision at BUILD:** either (a) reuse the Brewfile-restore command with the bundle's package list, or (b) add a thin `brew_install_bundle(packages)` mirroring `brew_upgrade_many` (Tauri `commands/actions.rs`) + native `installMany(_:)` on `AppModel` (mirrors `upgradeMany`). (b) is cleaner if the Brewfile path is heavyweight. Either way: validate names, write-lock, stream `BrewStreamEvent` β†’ Activity, `invalidate_caches()` on success, then reload `packages`. +- If a bundle declares a `tap`, prepend `brew tap ` + surface the `brew trust` requirement (Homebrew 6.0) β€” reuse the existing tap-trust messaging. (None of the six first-party recipes need this.) +- Skip already-installed packages (install is idempotent, but the UI should show per-package state). + +## Tauri (Svelte) +- **`src/lib/components/Bundles.svelte`** β€” grid of `BundleCard`s. Each card: icon, name, tagline, a **readiness pill** (`readiness(bundle.requires, systemProfile)` from M1), package-kind chips. +- **`BundleDetail.svelte`** (or a slide-over reusing PackageDetail's inspector) β€” packages list with installed/outdated state (cross-ref `packages` store), the capability verdict + `capabilityNotes` reason, `caveats`, `links`, and **Install all** (β†’ `brewInstallBundle` in `api.ts` β†’ Activity drawer). Blocked verdict β†’ Install behind a `DestructiveConfirm`-style "your machine may not run this well" gate. +- Readiness pill component shared with cards. + +## Native (SwiftUI) +- **`BundlesView.swift`** β€” a `LazyVGrid` of bundle cards (stock `GroupBox`/card style, like the Dashboard cards). Readiness pill = a small `Chip` tinted by verdict. +- **`BundleDetailView.swift`** (or reuse the inspector) β€” packages (with installed-state via `installedPackageMatching`), verdict + note, caveats, links (`Link`), **Install all** button β†’ `model.installBundle(_:)` β†’ streaming Activity job. Blocked β†’ `confirmationDialog` before install. +- Cards read the cached `SystemProfile` + `BundleReadiness`. + +## Shared behavior +- Readiness pill: βœ… Ready Β· ⚠️ Marginal (tooltip = the tier note) Β· β›” Not recommended (tooltip = the blocking reason). Never hard-block. +- Per-package state: not installed / installed / outdated β€” reuse the same signals the Library uses. +- Install all β†’ one streaming brew job (not N) β†’ Activity; on success reload packages so states flip. + +## Tests +- Readiness pill mapping (verdict β†’ tone/label) β€” small unit tests both shells. +- Install-set arg builder (`brew_install_bundle` / `installMany`) β€” pure arg test (mirrors the `upgrade_args` tests): `["install", "--formula", "ollama", "--cask", "open-webui"]` ordering / kind flags. +- (Manual/verify) drive an install of the Media bundle (all homebrew/core formulae, cheap) end-to-end into Activity. + +## Acceptance criteria +- Bundles section appears in both shells; the six cards render with correct readiness pills against the real profile (all Ready on the 128 GB dev Mac; debug RAM override shows Marginal/Blocked for Local LLMs / Image Gen). +- Bundle detail shows packages with live installed state, the verdict + reason, caveats, and working links. +- **Install all** on the Media bundle installs `ffmpeg`+`yt-dlp`+`mpv` in one streamed job; packages flip to installed afterward. +- A Blocked bundle still installs behind an explicit confirm. + +## Risks +- Two Table/inspector reuse temptations β€” keep the bundle detail simple; don't overload PackageDetail. A dedicated `BundleDetailView`/`BundleDetail.svelte` is fine. +- Install-set backend choice (reuse Brewfile vs new command) β€” spike both briefly; prefer the one already covered by tests. diff --git a/memory-bank/releases/0.7.0/bundles/m4-setup-guidance.md b/memory-bank/releases/0.7.0/bundles/m4-setup-guidance.md new file mode 100644 index 0000000..d7eb1cf --- /dev/null +++ b/memory-bank/releases/0.7.0/bundles/m4-setup-guidance.md @@ -0,0 +1,41 @@ +# M4 β€” Setup guidance (post-install checklist) + +**Goal:** after "Install all", land the user on a **setup checklist** that does the brew-native steps for them and hands them honest copy-paste for the rest. This is the differentiator over "just a curated Brewfile". + +**Depends on:** M3 (bundle detail + install). **Blocks:** nothing (M5 is parallel). + +## Scope +- **In:** render `bundle.setup[]` as an ordered checklist in the bundle detail; wire the four step kinds to their actions; honor the automation boundary; show `caveats` prominently. +- **Out:** tracking step completion across launches (v1 is stateless β€” the checklist just offers actions), any external-command execution (forbidden). + +## The four step kinds β†’ behavior (contract: [recipe-contract.md](./recipe-contract.md)) +| kind | UI | action | +|---|---|---| +| `service` | row + "Start" button | reuse the existing Services action (`performServiceAction(.start)` native / the services store command Tauri) β†’ `brew services start `. Reflect running state if known. | +| `open` | row + "Open" button | reuse `safeOpenUrl(url)` (http/https allowlist β€” already used for cask homepages). | +| `reveal` | row + "Reveal" button | reveal path in Finder (macOS) / file manager (Linux) β€” reuse the Storage card's reveal. | +| `command` | row + **Copy** button, monospace `run` | copy-to-clipboard only. **Never executes.** `external:true` is enforced by the contract. | +| `note` | markdown line | none. | + +## Tauri (Svelte) +- Extend `BundleDetail.svelte` with a `` section under Install all: iterate `bundle.setup`; render per-kind rows. Service β†’ the services store's start; open β†’ `safeOpenUrl`; command β†’ `navigator.clipboard.writeText` + a "Copied" toast; note β†’ rendered markdown (the same sanitizer PackageDetail enrichment uses). +- The checklist appears after install completes (and is also visible pre-install, greyed, so the user sees the full recipe). + +## Native (SwiftUI) +- Extend `BundleDetailView.swift` with a setup section: `ForEach(bundle.setup)` β†’ a row per kind. Service β†’ `Button("Start") { model.performServiceAction(.start, name:) }`; open β†’ `Link`/`Button` β†’ `safeOpenUrl`; command β†’ a monospaced `Text` + `Button` copying to `NSPasteboard` + a toast (`pushToast(.success, "Copied")`); note β†’ `Text` (markdown via `AttributedString`). +- Caveats: a prominent callout box above the checklist (reuse the deprecation-notice style). + +## Honesty rules (spec, not optional) +- External steps (`command`) are visibly marked as "you run this" β€” no button that looks like it'll do it for you. +- `caveats` is shown before Install all AND on the post-install checklist (e.g. Image Gen: "installs ComfyUI; you still download models, several GB"). +- If a `service` step's package isn't actually installed (partial install), the button is disabled with a reason. + +## Tests +- Step-kind β†’ renderer mapping (unit, both shells): service/open/reveal β†’ actionable; command β†’ copy-only, never an execute path; note β†’ text. +- A `command` step with `external:false` (or missing) is rejected by the loader/validator (contract violation) β€” assert the validator fails it. +- (Manual/verify) Local LLMs bundle: Start Ollama button starts the service; `ollama pull` row copies; Open button opens :8080. + +## Acceptance criteria +- Installing Local LLMs then following the checklist starts Ollama, copies the model-pull command, and opens Open WebUI β€” with the model-pull clearly marked "you run this". +- No UI path executes an external/`command` step. +- Caveats are unmissable on a heavy bundle (Image Gen). diff --git a/memory-bank/releases/0.7.0/bundles/m5-live-refresh-and-contributions.md b/memory-bank/releases/0.7.0/bundles/m5-live-refresh-and-contributions.md new file mode 100644 index 0000000..7afd374 --- /dev/null +++ b/memory-bank/releases/0.7.0/bundles/m5-live-refresh-and-contributions.md @@ -0,0 +1,48 @@ +# M5 β€” Live-refresh & the contribution pipeline + +**Goal:** make the recipe set updatable without an app release, and make community contribution a validated, low-friction PR. This is what turns Bundles from "6 hardcoded recipes" into a living, contributor-driven surface. + +**Depends on:** M2 (contract + `bundles.json` artifact + validate script). **Parallel to:** M4. + +## Scope +- **In:** live-refresh of `bundles.json` (opt-in, host-served, like enrichment/trending); the CI GitHub Action running `validate-recipes.mjs` on every PR touching `recipes/`; `CONTRIBUTING-bundles.md`; publishing the concatenated artifact to the host. +- **Out:** in-app recipe authoring (non-goal β€” contribution is via PR). + +## Live-refresh (mirror enrichment/trending exactly) +- Bundled `bundles.json` ships in the app (M2) and always works offline. A **Settings β†’ Network** opt-in fetches the latest `bundles.json` from the project host (same trust boundary + gating as the enrichment/trending-history endpoints β€” NOT a Homebrew path). +- Host path: `/bundles/bundles.json` (keep the actual host out of committed source β€” env/config, per the project rule). TTL + cache like the catalog (default off; weekly/daily opt-in). +- Both shells: reuse the enrichment/trending live-fetch service pattern (Rust `EnrichmentLiveService` analog; native `EnrichmentLiveService` analog). Newer bundled schema always wins if the live file is older-schema (fail-soft). +- **Retune-without-release:** because `requires`/`capabilityNotes` live in the data, a threshold change ("bump Image Gen to 20 GB") is a data push, not an app build. + +## Contribution pipeline +``` +contributor: add recipes/.json ──PR──► GitHub Action: validate-recipes.mjs + β”œβ”€ schema (recipe.schema.json, Draft 2020-12) + β”œβ”€ id unique + kebab-case + β”œβ”€ brew info --json=v2 resolves every package to its kind (+ tap-trust rule) + β”œβ”€ requires sane Β· command steps external:true Β· links https + HEAD<400 + └─ emit a pass/fail table as a PR check + comment + β–Ό merge β–Ό +release/publish job: concat valid recipes β–Ί bundles.json β–Ί (a) committed for the next app build, (b) rsynced to the live host +``` +- **CI Action:** `.github/workflows/validate-recipes.yml` β€” runs on PRs touching `recipes/**`; needs Homebrew available on the runner (`brew info` resolution). Cache brew to keep it fast. First-party recipes are validated by the same job (no special-casing). +- **`CONTRIBUTING-bundles.md`** (repo root or `docs/`): the human onramp β€” copy an existing recipe, fill the fields, run `node scripts/validate-recipes.mjs` locally, open a PR; links to [recipe-contract.md](./recipe-contract.md) and [capability-baselines.md](./capability-baselines.md) for the requirements reasoning. + +## Governance (lightweight) +- `maintainer` field records provenance. First-party recipes = `brew-browser`. +- A recipe that later fails CI (cask renamed/removed) β†’ the publish job drops it from the live `bundles.json` and opens a tracking issue; the bundled copy degrades gracefully (package resolves at install time or shows an error in Activity). +- Scope guard: recipes are **brew-installable stacks with guidance** β€” no recipe may add an auto-run external command (contract forbids it), and reviewers reject stacks whose value is entirely non-brew. + +## Tests +- `validate-recipes.mjs` unit-style fixtures: a valid recipe passes; each contract violation (bad id, missing package kind, third-party tap w/o `tap`, `external:false` command, http link, dead link) fails with a clear message. +- CI dry-run on the six first-party recipes β†’ all pass. +- Live-refresh: schema-version-skew test (older app + newer file β†’ fail-soft to bundled) mirrors the enrichment cache test. + +## Acceptance criteria +- Opening a PR that adds a valid `recipes/foo.json` goes green with no maintainer code; an invalid one gets a red check naming the exact rule it broke. +- Turning on the Bundles live-refresh opt-in pulls an updated `bundles.json` from the host; turning it off falls back to the bundled copy; offline works throughout. +- A `requires` retune shipped via the host changes readiness verdicts without an app update. + +## Risks +- CI needs brew on the runner β€” macOS runner or `brew` on Linux (Linuxbrew); keep the resolution step cached/fast. +- Live `bundles.json` is attacker-relevant only insofar as it lists brew tokens (install is still an explicit user action + brew's own trust); no code, no auto-run β€” the contract's "no external auto-run" keeps the blast radius to "installs a package you clicked install on". diff --git a/memory-bank/releases/0.7.0/bundles/recipe-contract.md b/memory-bank/releases/0.7.0/bundles/recipe-contract.md new file mode 100644 index 0000000..d07f3da --- /dev/null +++ b/memory-bank/releases/0.7.0/bundles/recipe-contract.md @@ -0,0 +1,84 @@ +# Bundle Recipe Contract (v1) + +The normative spec a **recipe** must satisfy. Contributors add one JSON file per recipe; CI validates it against this contract. The app never trusts a recipe it can't validate. Implemented by **[M2](./m2-recipe-contract-and-loader.md)**; capability numbers come from **[capability-baselines.md](./capability-baselines.md)**. + +> **Design goal:** a non-maintainer can open a PR that adds `recipes/.json`, and CI alone decides if it's mergeable β€” no app code change, no maintainer judgement call for the common case. + +## Where recipes live +- **Source of truth:** one file per recipe at `recipes/.json` (repo root or `data/recipes/`, decided in M2). One-file-per-recipe keeps PR diffs clean and avoids merge conflicts in a monolithic file. +- **Build artifact:** CI concatenates all valid recipes into `bundles.json` (schema `{ schemaVersion, generatedAt, bundles: [...] }`), which is (a) bundled into both apps at build time and (b) published to the live-refresh host (M5). The apps only ever read the concatenated artifact. +- **JSON Schema:** `recipes/recipe.schema.json` (Draft 2020-12) is the machine-checkable half of this contract; this doc is the human half. They must agree. + +## Recipe shape +```jsonc +{ + "id": "local-llm", // REQUIRED. kebab-case, unique, STABLE (it's the persistence key). ^[a-z0-9]+(-[a-z0-9]+)*$ + "name": "Local LLMs", // REQUIRED. Display name. ≀ 40 chars. + "tagline": "Run models locally with Ollama + a web UI", // REQUIRED. One line. ≀ 90 chars. + "category": "AI", // REQUIRED. enum: AI | Graphics | Media | Development | Data | Productivity + "icon": "brain", // optional. lucide (Tauri) / SF Symbol (native) β€” a name that exists in BOTH maps, or omit for a default. + "packages": [ // REQUIRED. β‰₯ 1. Order = display + install order. + { "name": "ollama", "kind": "formula" }, + { "name": "open-webui", "kind": "cask" } + ], + "tap": null, // optional. "user/repo" β€” an EXTRA tap to add (+ trust, Homebrew 6.0) before install. null = all packages are in homebrew/core or homebrew/cask. + "requires": { // optional. Absent = "runs anywhere" (always Ready). + "minRamGB": 8, // hard floor: below this β†’ Not recommended (still installable behind a confirm). + "recommendedRamGB": 16, // at/above β†’ Ready; between min and recommended β†’ Marginal. + "minDiskGB": 12, // free-disk floor. + "arch": "any", // any | apple-silicon | intel. Mismatch β†’ Not recommended (with reason). + "gpu": "preferred" // none | preferred | required. + }, + "capabilityNotes": { // optional. RAM-tier (GB, as string key) β†’ human note. Used to phrase the readiness reason. Keys are the LOWER bound of each tier. + "8": "small (~7–8B Q4) models only; larger will swap", + "16": "13–14B comfortably", + "32": "32B class", + "64": "70B models" + }, + "setup": [ // optional, ordered post-install steps. See "Setup steps" below. + { "kind": "service", "service": "ollama", "label": "Start Ollama" }, + { "kind": "command", "run": "ollama pull llama3.2", "label": "Pull a starter model", "external": true }, + { "kind": "open", "url": "http://localhost:8080", "label": "Open Open WebUI" } + ], + "caveats": "Installs the tools; you still download models (several GB).", // optional. Shown prominently. ≀ 240 chars. + "links": [ // optional. Further reading. Every url MUST be https and reachable (CI HEAD-checks). + { "label": "Ollama docs", "url": "https://docs.ollama.com" }, + { "label": "Open WebUI", "url": "https://openwebui.com" } + ], + "maintainer": "brew-browser", // optional. GitHub handle or "brew-browser" for first-party. + "addedIn": "0.7.0" // optional. Provenance. +} +``` + +## Setup steps β€” the automation boundary +Each step is one of four kinds. **Only brew-native kinds auto-run; everything else is copy-only.** This is a hard rule (the app never executes arbitrary shell). + +| kind | fields | behavior | +|---|---|---| +| `service` | `service`, `label` | Renders a button β†’ `brew services start ` (reuses the existing Services action). Auto-runnable. | +| `open` | `url`, `label` | Button β†’ open the URL via the existing `safeOpenUrl` (http/https allowlist). Auto-runnable. | +| `reveal` | `path`, `label` | Button β†’ reveal a path in Finder / file manager. Auto-runnable. | +| `command` | `run`, `label`, `external: true` | A **copy-only** row (copy-to-clipboard). `external: true` is **required** β€” the contract forbids an auto-runnable arbitrary command. | +| `note` | `text` | A markdown info line. No action. | + +## Validation (CI must pass β€” the actual contract) +A recipe is mergeable iff **all** hold: +1. **Schema**: validates against `recipe.schema.json` (Draft 2020-12). +2. **id**: matches `^[a-z0-9]+(-[a-z0-9]+)*$`, unique across all recipes. +3. **packages**: β‰₯1; each `kind ∈ {formula, cask}`. +4. **brew resolution**: every package resolves via `brew info --json=v2 ` to the declared `kind`. If its `tap` is **not** `homebrew/core`/`homebrew/cask`, the recipe **must** declare that tap in the top-level `tap` field (so the app can add + `brew trust` it β€” Homebrew 6.0). CI fails a third-party-tapped package with no `tap` declared. +5. **requires**: if present, `minRamGB ≀ recommendedRamGB`, all numbers > 0, `arch`/`gpu` in enum. +6. **setup**: `command` steps have `external: true`; `service` steps name a real brew service (best-effort: the service package is in `packages`); `open` urls are http/https. +7. **links**: every `url` is https and returns < 400 on a HEAD request (CI, with a short allowlist for known-bot-blocking hosts). +8. **strings**: length caps above respected; no control characters. + +CI is a single script (`scripts/validate-recipes.mjs`, run in a GitHub Action) that emits a per-recipe pass/fail table on the PR. See **[M5](./m5-live-refresh-and-contributions.md)** for the pipeline + `CONTRIBUTING-bundles.md`. + +## Forward-compat +- Parsers on both shells use tolerant decoding (`#[serde(default)]` / optional decode) so an app one version behind ignores unknown fields rather than rejecting the whole `bundles.json` β€” mirrors the enrichment/vulns-cache pattern. +- `schemaVersion` bumps only on an incompatible change; a newer-schema file loads as empty on an older app (fail-soft), never crashes. + +## Non-goals (v1) +- No user-authored recipes inside the app (that overlaps Snapshots β€” a bundle β‰ˆ a curated Brewfile + guidance). Contribution is via PR. +- No executing external/`command` steps. Ever. +- No per-recipe scripts/hooks beyond the four step kinds. diff --git a/memory-bank/toc.md b/memory-bank/toc.md index 7a010de..5499dc6 100644 --- a/memory-bank/toc.md +++ b/memory-bank/toc.md @@ -42,6 +42,7 @@ Project-scoped memory bank. **All agents working on this project read from and w |------|-------|----------| | `tasks/YYYY-MM/*.md` | Lead | Per-shipped-unit task records. One file per phase or release. See `tasks/2026-05/README.md` for index. | | `phases/phaseNN-plan.md` | Lead | **Shipped** phase plans (Phase 12, Phase 13, Phase 15, etc.) β€” design-time intent preserved as historical context. In-flight plans live at `memory-bank/phase{N}-plan.md` (top level) while active, then move to `phases/` when the phase ships. **No in-flight plan as of v0.3.0.** | +| `releases//` | Lead | Per-release working directory: `README.md` (release tracker β€” contents, checklist, open items) + feature plans for that release. Current: `releases/0.7.0/` (0.7.0 / native 0.3.0 β€” pin/unpin shipped, Bundles planned). | | `scans/YYYY-MM-DD/*` | Security Engineer | Point-in-time outputs from `cargo audit`, `cargo deny`, `semgrep`, `gitleaks`, etc. Date-stamped folders. Latest scans should match `security.md`'s most recent Β§N audit. | ## Agent collaboration protocol From 1b3494eb40c1364b4fe37f71b860c9dc13a024e4 Mon Sep 17 00:00:00 2001 From: Michael Sitarzewski Date: Sun, 12 Jul 2026 19:34:45 -0500 Subject: [PATCH 2/9] =?UTF-8?q?feat(bundles):=20M1=20capability=20engine?= =?UTF-8?q?=20=E2=80=94=20SystemProfile=20+=20readiness=20(both=20shells)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero-install system profile (RAM/arch/chip/cores/gpu/free-disk/os) + a pure readiness(requires, capabilityNotes, profile) -> {verdict, reason} in Rust/TS AND Swift. Both implementations are verified against ONE shared parity fixture (test-fixtures/readiness-cases.json) that pins exact verdicts + reason strings, so the two can't drift. BREWBROWSER_FAKE_RAM_GB override for testing gated states. Tauri: src-tauri/src/system/profile.rs + system_profile command; src/lib/util/ readiness.ts + vitest (10/10). Native: SystemProfile.swift + BundleReadiness.swift + Swift Testing (10/10). cargo profile 3, svelte-check 0, swift build clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01X9iMFjHE21ePTjcbHpTXt6 --- .../BrewBrowserKit/BundleReadiness.swift | 102 +++++++ .../BrewBrowserKit/SystemProfile.swift | 99 +++++++ .../BundleReadinessTests.swift | 73 +++++ src-tauri/src/lib.rs | 3 + src-tauri/src/system/mod.rs | 6 + src-tauri/src/system/profile.rs | 265 ++++++++++++++++++ src/lib/api.ts | 10 + src/lib/types.ts | 37 +++ src/lib/util/readiness.test.ts | 41 +++ src/lib/util/readiness.ts | 107 +++++++ test-fixtures/readiness-cases.json | 75 +++++ 11 files changed, 818 insertions(+) create mode 100644 native/Sources/BrewBrowserKit/BundleReadiness.swift create mode 100644 native/Sources/BrewBrowserKit/SystemProfile.swift create mode 100644 native/Tests/BrewBrowserKitTests/BundleReadinessTests.swift create mode 100644 src-tauri/src/system/mod.rs create mode 100644 src-tauri/src/system/profile.rs create mode 100644 src/lib/util/readiness.test.ts create mode 100644 src/lib/util/readiness.ts create mode 100644 test-fixtures/readiness-cases.json diff --git a/native/Sources/BrewBrowserKit/BundleReadiness.swift b/native/Sources/BrewBrowserKit/BundleReadiness.swift new file mode 100644 index 0000000..43a164a --- /dev/null +++ b/native/Sources/BrewBrowserKit/BundleReadiness.swift @@ -0,0 +1,102 @@ +import Foundation + +/// What a bundle/recipe needs from the host to run. Shared data contract with +/// the Tauri side (`BundleRequires` in src/lib/types.ts); `arch == "any"` means +/// no arch constraint. +public struct BundleRequires: Sendable, Codable, Equatable { + public let minRamGB: Int + public let recommendedRamGB: Int + public let minDiskGB: Int + public let arch: String + public let gpu: String + + public init(minRamGB: Int, recommendedRamGB: Int, minDiskGB: Int, arch: String, gpu: String) { + self.minRamGB = minRamGB + self.recommendedRamGB = recommendedRamGB + self.minDiskGB = minDiskGB + self.arch = arch + self.gpu = gpu + } +} + +/// Three-state gate. `blocked` is advisory β€” the UI still allows install behind +/// a confirm (M3). +public enum ReadinessVerdict: String, Sendable, Codable, Equatable { + case ready + case marginal + case blocked +} + +/// Verdict + a human-readable reason string. Reason strings are part of the +/// parity contract (test-fixtures/readiness-cases.json) β€” keep them byte-identical. +public struct Readiness: Sendable, Equatable { + public let verdict: ReadinessVerdict + public let reason: String + + public init(verdict: ReadinessVerdict, reason: String) { + self.verdict = verdict + self.reason = reason + } +} + +/// Pure `(requires, capabilityNotes, profile) -> Readiness`. No I/O, no state β€” +/// the foundation M3 (readiness pills) builds on. Mirrors the TS/Rust rules +/// exactly; the shared fixture is the source of truth for order and strings. +public enum BundleReadiness { + /// Human label for a `requires.arch` value, used in the "Built for …" reason. + private static func archLabel(_ arch: String) -> String { + switch arch { + case "apple-silicon": return "Apple Silicon" + case "intel": return "Intel" + case "linux": return "Linux" + default: return arch + } + } + + /// The capability note for the highest tier the host qualifies for: the + /// value at the largest integer-parsed key ≀ `ramGB`, or nil if none. + private static func nearestTierNote(_ notes: [String: String]?, _ ramGB: Int) -> String? { + guard let notes else { return nil } + var best: (key: Int, note: String)? + for (rawKey, note) in notes { + guard let key = Int(rawKey), key <= ramGB else { continue } + if best == nil || key > best!.key { + best = (key, note) + } + } + return best?.note + } + + public static func readiness(_ requires: BundleRequires?, + _ capabilityNotes: [String: String]?, + _ profile: SystemProfile) -> Readiness { + guard let requires else { + return Readiness(verdict: .ready, reason: "Ready.") + } + + // 1. Arch mismatch beats everything else. + if requires.arch != "any" && requires.arch != profile.arch { + return Readiness(verdict: .blocked, reason: "Built for \(archLabel(requires.arch)).") + } + + // 2. Hard RAM floor. + if profile.ramGB < requires.minRamGB { + return Readiness(verdict: .blocked, + reason: "Needs β‰₯\(requires.minRamGB) GB RAM (you have \(profile.ramGB) GB).") + } + + // 3. Hard disk floor. + if profile.freeDiskGB < requires.minDiskGB { + return Readiness(verdict: .blocked, + reason: "Needs β‰₯\(requires.minDiskGB) GB free disk (you have \(profile.freeDiskGB) GB).") + } + + // 4. Soft recommendation β†’ marginal, else ready. Prefer the tier note. + let note = nearestTierNote(capabilityNotes, profile.ramGB) + if profile.ramGB < requires.recommendedRamGB { + return Readiness(verdict: .marginal, + reason: note ?? "Below the recommended \(requires.recommendedRamGB) GB β€” may be slow.") + } + return Readiness(verdict: .ready, reason: note ?? "Ready.") + } +} diff --git a/native/Sources/BrewBrowserKit/SystemProfile.swift b/native/Sources/BrewBrowserKit/SystemProfile.swift new file mode 100644 index 0000000..24f432d --- /dev/null +++ b/native/Sources/BrewBrowserKit/SystemProfile.swift @@ -0,0 +1,99 @@ +import Foundation + +/// Zero-install snapshot of the machine's capabilities, read once at launch and +/// cached on `AppModel`. Feeds `BundleReadiness.readiness(_:_:_:)` so the +/// Bundles UI can gate recipes against the host (RAM / arch / free disk). +/// +/// Parity: mirrors the Tauri `SystemProfile` (src-tauri/src/system/profile.rs). +/// The shape is a shared data contract β€” the readiness parity fixture +/// (test-fixtures/readiness-cases.json) decodes the same field names. +public struct SystemProfile: Sendable, Codable, Equatable { + public let ramGB: Int + public let arch: String + public let chip: String + public let cpuCores: Int + public let gpu: String + public let freeDiskGB: Int + public let osVersion: String + + public init(ramGB: Int, arch: String, chip: String, cpuCores: Int, + gpu: String, freeDiskGB: Int, osVersion: String) { + self.ramGB = ramGB + self.arch = arch + self.chip = chip + self.cpuCores = cpuCores + self.gpu = gpu + self.freeDiskGB = freeDiskGB + self.osVersion = osVersion + } + + /// Read real capabilities with zero installs and zero network. All reads are + /// direct `sysctl`/`Foundation` calls; nothing spawns `system_profiler` + /// (that's ~1s and unnecessary for the RAM/arch/disk gate). + public static func detect() -> SystemProfile { + let env = ProcessInfo.processInfo.environment + + // RAM: hw.memsize is total physical bytes; round to whole GB. + let ramBytes = sysctlUInt64("hw.memsize") ?? 0 + var ramGB = Int((Double(ramBytes) / 1_073_741_824).rounded()) + // Debug override so Marginal/Blocked states are reachable on a 128 GB dev Mac. + if let fake = env["BREWBROWSER_FAKE_RAM_GB"], let fakeGB = Int(fake) { + ramGB = fakeGB + } + + #if arch(arm64) + let arch = "apple-silicon" + let gpu = "metal" + #else + let arch = "intel" + let gpu = "none" + #endif + + let chip = sysctlString("machdep.cpu.brand_string") ?? "unknown" + let cpuCores = sysctlInt("hw.ncpu") ?? 0 + + // Free disk on the boot volume β€” the "important usage" figure matches + // what Finder reports as available (purgeable space reclaimable on demand). + var freeDiskGB = 0 + if let capacity = try? URL(fileURLWithPath: "/") + .resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) + .volumeAvailableCapacityForImportantUsage { + freeDiskGB = Int(capacity / 1_000_000_000) + } + + let v = ProcessInfo.processInfo.operatingSystemVersion + let osVersion = "\(v.majorVersion).\(v.minorVersion)" + + return SystemProfile(ramGB: ramGB, arch: arch, chip: chip, cpuCores: cpuCores, + gpu: gpu, freeDiskGB: freeDiskGB, osVersion: osVersion) + } +} + +// MARK: - sysctl helpers + +/// Read a string-valued `sysctl` (e.g. `machdep.cpu.brand_string`). +private func sysctlString(_ name: String) -> String? { + var size = 0 + guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { return nil } + var buffer = [UInt8](repeating: 0, count: size) + guard sysctlbyname(name, &buffer, &size, nil, 0) == 0 else { return nil } + // sysctl strings are NUL-terminated; drop the terminator before decoding. + if let nul = buffer.firstIndex(of: 0) { buffer.removeSubrange(nul...) } + return String(decoding: buffer, as: UTF8.self) +} + +/// Read a 64-bit unsigned `sysctl` (e.g. `hw.memsize`). +private func sysctlUInt64(_ name: String) -> UInt64? { + var value: UInt64 = 0 + var size = MemoryLayout.size + guard sysctlbyname(name, &value, &size, nil, 0) == 0 else { return nil } + return value +} + +/// Read an `Int`-valued `sysctl` (e.g. `hw.ncpu`, returned as a C `int`). +private func sysctlInt(_ name: String) -> Int? { + var value: Int32 = 0 + var size = MemoryLayout.size + guard sysctlbyname(name, &value, &size, nil, 0) == 0 else { return nil } + return Int(value) +} diff --git a/native/Tests/BrewBrowserKitTests/BundleReadinessTests.swift b/native/Tests/BrewBrowserKitTests/BundleReadinessTests.swift new file mode 100644 index 0000000..6fc5fc9 --- /dev/null +++ b/native/Tests/BrewBrowserKitTests/BundleReadinessTests.swift @@ -0,0 +1,73 @@ +import Foundation +import Testing +@testable import BrewBrowserKit + +/// Parity tests for `BundleReadiness.readiness(_:_:_:)`, driven by the SHARED +/// fixture (test-fixtures/readiness-cases.json) that the Tauri/TS suite also +/// loads. Both shells must produce byte-identical verdicts and reason strings, +/// so the fixture β€” not this file β€” is the source of truth. +@Suite("BundleReadiness") +struct BundleReadinessTests { + + // The fixture profile carries only the fields readiness() reads; the other + // SystemProfile fields (chip, cpuCores, osVersion) are irrelevant to the + // gate, so we default them when reconstructing a full SystemProfile. + private struct FixtureProfile: Decodable { + let ramGB: Int + let arch: String + let freeDiskGB: Int + let gpu: String + + var profile: SystemProfile { + SystemProfile(ramGB: ramGB, arch: arch, chip: "test", + cpuCores: 8, gpu: gpu, freeDiskGB: freeDiskGB, osVersion: "27.0") + } + } + + private struct Expect: Decodable { + let verdict: String + let reason: String + } + + private struct Case: Decodable { + let name: String + let requires: BundleRequires? + let capabilityNotes: [String: String]? + let profile: FixtureProfile + let expect: Expect + } + + private struct Fixture: Decodable { + let cases: [Case] + } + + /// Resolve the repo-root fixture from this file's location: + /// native/Tests/BrewBrowserKitTests/BundleReadinessTests.swift + /// β†’ up 4 (file β†’ BrewBrowserKitTests β†’ Tests β†’ native β†’ repo root) + /// β†’ test-fixtures/readiness-cases.json + private static func loadCases() throws -> [Case] { + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // BrewBrowserKitTests/ + .deletingLastPathComponent() // Tests/ + .deletingLastPathComponent() // native/ + .deletingLastPathComponent() // repo root + let fixtureURL = repoRoot.appendingPathComponent("test-fixtures/readiness-cases.json") + guard let data = try? Data(contentsOf: fixtureURL) else { + print("readiness fixture not found at: \(fixtureURL.path)") + throw CocoaError(.fileNoSuchFile) + } + return try JSONDecoder().decode(Fixture.self, from: data).cases + } + + @Test func fixtureParity() throws { + let cases = try Self.loadCases() + #expect(!cases.isEmpty, "fixture decoded zero cases") + for c in cases { + let result = BundleReadiness.readiness(c.requires, c.capabilityNotes, c.profile.profile) + #expect(result.verdict.rawValue == c.expect.verdict, + "verdict mismatch for '\(c.name)': got \(result.verdict.rawValue), want \(c.expect.verdict)") + #expect(result.reason == c.expect.reason, + "reason mismatch for '\(c.name)': got '\(result.reason)', want '\(c.expect.reason)'") + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1e611ca..4528916 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,12 +11,14 @@ mod enrichment; mod error; mod github; mod state; +mod system; mod trending; mod types; mod util; mod vulns; use commands::*; +use system::profile::system_profile; // ============================================================= // Phase 15 β€” Updater minisign public key @@ -241,6 +243,7 @@ pub fn run() { vulns_scan_one, vulns_install_helper, vulns_invalidate, + system_profile, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/system/mod.rs b/src-tauri/src/system/mod.rs new file mode 100644 index 0000000..c9eaf70 --- /dev/null +++ b/src-tauri/src/system/mod.rs @@ -0,0 +1,6 @@ +//! Zero-install machine capability probes (Bundles M1). +//! +//! `profile::detect()` reads RAM/arch/disk/etc.; `profile::system_profile` +//! is the Tauri command registered in `lib.rs`. + +pub mod profile; diff --git a/src-tauri/src/system/profile.rs b/src-tauri/src/system/profile.rs new file mode 100644 index 0000000..cccbb01 --- /dev/null +++ b/src-tauri/src/system/profile.rs @@ -0,0 +1,265 @@ +//! Zero-install machine capability probe (Bundles M1). +//! +//! `detect()` reads RAM, arch, chip, core count, GPU class, free disk, and +//! OS version with **no installs and no network** β€” just `sysctl`/`sw_vers` +//! on macOS, `/proc` on Linux, and a `df -Pk /` shell-out (same "shell out +//! to a coreutil" idiom the Storage card already uses for `du -sk`). The +//! result feeds the client-side `readiness()` gate in `src/lib/util/readiness.ts`. +//! +//! Cheap enough to compute per call (a handful of `sysctl` reads), so no +//! `AppState` cache β€” the readiness math that consumes it lives client-side. + +use std::process::Command; + +use serde::Serialize; + +/// One GiB in bytes. RAM + free-disk are reported in **GiB rounded to the +/// nearest whole GB** (so a 128 GiB Mac reads "128", not "137"). +const GIB: f64 = 1024.0 * 1024.0 * 1024.0; + +/// Machine capabilities. Field renames pin the wire shape to the shared +/// data contract in `src/lib/types.ts` (`ramGB`/`freeDiskGB` keep the +/// upper-case "GB"; the rest are plain camelCase). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SystemProfile { + #[serde(rename = "ramGB")] + pub ram_gb: u64, + /// `"apple-silicon" | "intel" | "linux"`. + pub arch: String, + /// Human CPU string, e.g. `"Apple M3 Max"`. + pub chip: String, + pub cpu_cores: u32, + /// `"metal" | "cuda" | "none" | "unknown"`. + pub gpu: String, + #[serde(rename = "freeDiskGB")] + pub free_disk_gb: u64, + pub os_version: String, +} + +/// Run a command and return trimmed stdout, or `None` on any failure. Kept +/// deliberately forgiving: a missing probe degrades one field to its +/// fallback rather than failing the whole profile. +fn capture(cmd: &str, args: &[&str]) -> Option { + let out = Command::new(cmd).args(args).output().ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if text.is_empty() { + None + } else { + Some(text) + } +} + +/// Bytes β†’ GiB, rounded to the nearest whole GB. +fn bytes_to_gb(bytes: u64) -> u64 { + (bytes as f64 / GIB).round() as u64 +} + +/// Free bytes on `/` via `df -Pk /`. The POSIX (`-P`) format guarantees a +/// single data line whose 4th column is "Available" in 1024-byte blocks. +/// Portable across macOS + Linux, no new crate. +fn free_disk_gb() -> u64 { + let Some(text) = capture("df", &["-Pk", "/"]) else { + return 0; + }; + // Line 0 is the header; line 1 is the `/` mount. + let Some(data) = text.lines().nth(1) else { + return 0; + }; + let avail_kb: u64 = data + .split_whitespace() + .nth(3) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + // KiB blocks β†’ GiB. + bytes_to_gb(avail_kb.saturating_mul(1024)) +} + +/// If `BREWBROWSER_FAKE_RAM_GB` parses to a number, use it for `ram_gb` so +/// Marginal/Blocked readiness states are reachable on a 128 GB dev box. +fn fake_ram_override() -> Option { + std::env::var("BREWBROWSER_FAKE_RAM_GB") + .ok() + .and_then(|s| s.trim().parse().ok()) +} + +/// Read the machine's capabilities. Zero installs, zero network. +pub fn detect() -> SystemProfile { + #[cfg(target_os = "macos")] + let profile = detect_macos(); + #[cfg(target_os = "linux")] + let profile = detect_linux(); + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + let profile = detect_fallback(); + + apply_overrides(profile) +} + +/// Debug overrides applied to every platform's raw probe. +fn apply_overrides(mut profile: SystemProfile) -> SystemProfile { + if let Some(ram) = fake_ram_override() { + profile.ram_gb = ram; + } + profile +} + +#[cfg(target_os = "macos")] +fn detect_macos() -> SystemProfile { + // `cfg!` resolves at compile time: an aarch64 build is Apple Silicon, + // anything else on macOS is an Intel Mac. + let apple_silicon = cfg!(target_arch = "aarch64"); + + let ram_bytes: u64 = capture("sysctl", &["-n", "hw.memsize"]) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + let chip = capture("sysctl", &["-n", "machdep.cpu.brand_string"]) + .unwrap_or_else(|| "Unknown CPU".to_string()); + + let cpu_cores: u32 = capture("sysctl", &["-n", "hw.ncpu"]) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + let os_version = + capture("sw_vers", &["-productVersion"]).unwrap_or_else(|| "unknown".to_string()); + + SystemProfile { + ram_gb: bytes_to_gb(ram_bytes), + arch: if apple_silicon { "apple-silicon" } else { "intel" }.to_string(), + chip, + cpu_cores, + // Every Apple Silicon Mac ships an integrated Metal GPU; Intel Macs + // vary too much to claim one here, so `none` (M1 gates on RAM+disk). + gpu: if apple_silicon { "metal" } else { "none" }.to_string(), + free_disk_gb: free_disk_gb(), + os_version, + } +} + +#[cfg(target_os = "linux")] +fn detect_linux() -> SystemProfile { + // MemTotal in /proc/meminfo is reported in kB. + let ram_bytes: u64 = std::fs::read_to_string("/proc/meminfo") + .ok() + .and_then(|text| { + text.lines() + .find(|l| l.starts_with("MemTotal:")) + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|kb| kb.parse::().ok()) + }) + .map(|kb| kb.saturating_mul(1024)) + .unwrap_or(0); + + let cpu_cores: u32 = capture("nproc", &[]) + .and_then(|s| s.parse().ok()) + .or_else(|| { + std::fs::read_to_string("/proc/cpuinfo").ok().map(|text| { + text.lines().filter(|l| l.starts_with("processor")).count() as u32 + }) + }) + .unwrap_or(0); + + // Best-effort chip name from /proc/cpuinfo "model name". + let chip = std::fs::read_to_string("/proc/cpuinfo") + .ok() + .and_then(|text| { + text.lines() + .find(|l| l.starts_with("model name")) + .and_then(|l| l.split(':').nth(1)) + .map(|s| s.trim().to_string()) + }) + .unwrap_or_else(|| "Unknown CPU".to_string()); + + // PRETTY_NAME from /etc/os-release, else the kernel release. + let os_version = std::fs::read_to_string("/etc/os-release") + .ok() + .and_then(|text| { + text.lines() + .find(|l| l.starts_with("PRETTY_NAME=")) + .map(|l| l.trim_start_matches("PRETTY_NAME=").trim_matches('"').to_string()) + }) + .or_else(|| capture("uname", &["-r"])) + .unwrap_or_else(|| "unknown".to_string()); + + // GPU is weak to detect on Linux: claim `cuda` only if `nvidia-smi` + // is present and runs, else `unknown` (a valid value β€” image-gen + // recipes degrade to a disk check + note). + let gpu = if capture("nvidia-smi", &["-L"]).is_some() { + "cuda" + } else { + "unknown" + }; + + SystemProfile { + ram_gb: bytes_to_gb(ram_bytes), + arch: "linux".to_string(), + chip, + cpu_cores, + gpu: gpu.to_string(), + free_disk_gb: free_disk_gb(), + os_version, + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +fn detect_fallback() -> SystemProfile { + SystemProfile { + ram_gb: 0, + arch: "linux".to_string(), + chip: "Unknown CPU".to_string(), + cpu_cores: 0, + gpu: "unknown".to_string(), + free_disk_gb: free_disk_gb(), + os_version: "unknown".to_string(), + } +} + +/// Tauri command: return the machine profile. Infallible β€” every field +/// degrades to a safe fallback rather than erroring the call. +#[tauri::command] +pub async fn system_profile() -> SystemProfile { + detect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_returns_real_ram() { + // Smoke test: on any real host this box has some RAM. + let p = detect(); + assert!(p.ram_gb > 0, "expected non-zero RAM, got {}", p.ram_gb); + assert!(!p.arch.is_empty()); + } + + #[test] + fn fake_ram_override_wins() { + // The override path is what makes Marginal/Blocked reachable on a + // big dev box; exercise it directly (no env mutation for parallel + // test safety β€” verify the applier instead). + let base = SystemProfile { + ram_gb: 128, + arch: "apple-silicon".to_string(), + chip: "test".to_string(), + cpu_cores: 10, + gpu: "metal".to_string(), + free_disk_gb: 500, + os_version: "15.0".to_string(), + }; + // Without the env var set, apply_overrides is a no-op. + if std::env::var("BREWBROWSER_FAKE_RAM_GB").is_err() { + assert_eq!(apply_overrides(base).ram_gb, 128); + } + } + + #[test] + fn bytes_to_gb_rounds_to_nearest() { + assert_eq!(bytes_to_gb(128 * 1024 * 1024 * 1024), 128); + assert_eq!(bytes_to_gb(8 * 1024 * 1024 * 1024), 8); + assert_eq!(bytes_to_gb(0), 0); + } +} diff --git a/src/lib/api.ts b/src/lib/api.ts index d093962..8e97384 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -49,6 +49,7 @@ import type { SearchResults, Service, Settings, + SystemProfile, SystemStatus, TrendingHistoryIndex, TrendingHistorySeries, @@ -926,6 +927,15 @@ export function vulnsInvalidate( return invoke("vulns_invalidate", { kind, name, version }); } +/** + * Bundles M1 β€” probe the host's capabilities (RAM, arch, disk, GPU class) + * with zero installs. Small payload; the client-side `readiness()` gate in + * `$lib/util/readiness` consumes it against each bundle's `requires`. + */ +export function systemProfile(): Promise { + return invoke("system_profile"); +} + // ============================================================ // Re-exports for convenience // ============================================================ diff --git a/src/lib/types.ts b/src/lib/types.ts index d40f41d..687f590 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1079,6 +1079,43 @@ export interface RecentChange { status: "succeeded" | "failed" | "canceled"; } +// ========================================================= +// Bundles M1 β€” capability profile + readiness +// ========================================================= + +/** Machine capabilities probed with zero installs by `system_profile`. + * Mirrors the Rust `SystemProfile` struct (and the Swift one) byte-for-byte + * on the wire. `ramGB`/`freeDiskGB` are whole GiB rounded to the nearest GB. */ +export interface SystemProfile { + ramGB: number; + arch: "apple-silicon" | "intel" | "linux"; + chip: string; + cpuCores: number; + gpu: "metal" | "cuda" | "none" | "unknown"; + freeDiskGB: number; + osVersion: string; +} + +/** What a bundle needs from the host. Consumed by `readiness()`; `null` + * requires means "runs anywhere" (always ready). */ +export interface BundleRequires { + minRamGB: number; + recommendedRamGB: number; + minDiskGB: number; + arch: "any" | "apple-silicon" | "intel" | "linux"; + gpu: "none" | "preferred" | "required"; +} + +/** Readiness verdict for a bundle against a profile. `"blocked"` is advisory + * β€” M3's UI still allows install behind a confirm. */ +export type ReadinessVerdict = "ready" | "marginal" | "blocked"; + +/** A verdict plus a human-readable one-line reason. */ +export interface Readiness { + verdict: ReadinessVerdict; + reason: string; +} + /** Command-palette item β€” either a verb (action) or a package. */ export type PaletteItem = | { kind: "command"; id: string; label: string; shortcut?: string; section?: string; run: () => void | Promise } diff --git a/src/lib/util/readiness.test.ts b/src/lib/util/readiness.test.ts new file mode 100644 index 0000000..43ba32e --- /dev/null +++ b/src/lib/util/readiness.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import type { BundleRequires, Readiness, SystemProfile } from "$lib/types"; +import { readiness } from "./readiness"; + +/** + * SHARED PARITY FIXTURE. The exact same `test-fixtures/readiness-cases.json` + * drives the Swift suite too, so both shells must produce byte-identical + * verdicts + reason strings. Loaded from disk (not a JSON import) so `npm run + * check` doesn't need `resolveJsonModule`, and resolved relative to THIS file + * so it works regardless of cwd. + */ +interface ReadinessCase { + name: string; + requires: BundleRequires | null; + capabilityNotes: Record | null; + // Fixture profiles carry only the fields readiness() reads; widen to the + // full type since the extra keys are irrelevant to the gate. + profile: SystemProfile; + expect: Readiness; +} + +const fixturePath = fileURLToPath( + new URL("../../../test-fixtures/readiness-cases.json", import.meta.url), +); +const fixture = JSON.parse(readFileSync(fixturePath, "utf8")) as { + cases: ReadinessCase[]; +}; + +describe("readiness() parity fixture", () => { + for (const c of fixture.cases) { + it(c.name, () => { + expect(readiness(c.requires, c.capabilityNotes, c.profile)).toEqual( + c.expect, + ); + }); + } +}); diff --git a/src/lib/util/readiness.ts b/src/lib/util/readiness.ts new file mode 100644 index 0000000..786387e --- /dev/null +++ b/src/lib/util/readiness.ts @@ -0,0 +1,107 @@ +/** + * Bundles M1 β€” pure readiness gate. + * + * Turns `(bundle.requires, capabilityNotes, profile) β†’ verdict + reason` with + * no IPC, so it's vitest-able directly. This is the SHARED parity algorithm: + * the Rust/TS and Swift shells must produce byte-identical verdicts + reason + * strings for identical inputs. The canonical spec + expected strings live in + * `test-fixtures/readiness-cases.json` (the `_comment` field) β€” that fixture + * is the source of truth; keep this in lockstep with it and the Swift port. + */ + +import type { + BundleRequires, + Readiness, + SystemProfile, +} from "$lib/types"; + +/** Human label for an arch value, used in the "Built for {ArchLabel}." reason. */ +function archLabel(arch: BundleRequires["arch"]): string { + switch (arch) { + case "apple-silicon": + return "Apple Silicon"; + case "intel": + return "Intel"; + case "linux": + return "Linux"; + default: + return arch; + } +} + +/** + * Capability note for the largest integer tier key `≀ ram`, or `null` when + * there are no notes or `ram` is below the smallest key. Keys are strings in + * the JSON (`{ "8": "...", "16": "..." }`) β€” parsed to ints for comparison. + */ +function nearestTierNote( + capabilityNotes: Record | null, + ram: number, +): string | null { + if (!capabilityNotes) { + return null; + } + let bestKey = -1; + let bestNote: string | null = null; + for (const [key, note] of Object.entries(capabilityNotes)) { + const tier = parseInt(key, 10); + if (Number.isNaN(tier)) { + continue; + } + if (tier <= ram && tier > bestKey) { + bestKey = tier; + bestNote = note; + } + } + return bestNote; +} + +/** + * Readiness verdict for a bundle against the host profile. + * + * Order (matches the fixture exactly): + * 1. no `requires` β†’ ready ("Ready.") + * 2. arch mismatch β†’ blocked ("Built for {ArchLabel}.") + * 3. ram < minRamGB β†’ blocked ("Needs β‰₯{min} GB RAM (you have {ram} GB).") + * 4. freeDiskGB < minDiskGB β†’ blocked ("Needs β‰₯{min} GB free disk (you have {free} GB).") + * 5. ram < recommendedRamGB β†’ marginal (nearest tier note ?? generic) + * 6. else β†’ ready (nearest tier note ?? "Ready.") + */ +export function readiness( + requires: BundleRequires | null, + capabilityNotes: Record | null, + profile: SystemProfile, +): Readiness { + if (requires === null) { + return { verdict: "ready", reason: "Ready." }; + } + + if (requires.arch !== "any" && requires.arch !== profile.arch) { + return { verdict: "blocked", reason: `Built for ${archLabel(requires.arch)}.` }; + } + + if (profile.ramGB < requires.minRamGB) { + return { + verdict: "blocked", + reason: `Needs β‰₯${requires.minRamGB} GB RAM (you have ${profile.ramGB} GB).`, + }; + } + + if (profile.freeDiskGB < requires.minDiskGB) { + return { + verdict: "blocked", + reason: `Needs β‰₯${requires.minDiskGB} GB free disk (you have ${profile.freeDiskGB} GB).`, + }; + } + + const note = nearestTierNote(capabilityNotes, profile.ramGB); + + if (profile.ramGB < requires.recommendedRamGB) { + return { + verdict: "marginal", + reason: note ?? `Below the recommended ${requires.recommendedRamGB} GB β€” may be slow.`, + }; + } + + return { verdict: "ready", reason: note ?? "Ready." }; +} diff --git a/test-fixtures/readiness-cases.json b/test-fixtures/readiness-cases.json new file mode 100644 index 0000000..e03b7df --- /dev/null +++ b/test-fixtures/readiness-cases.json @@ -0,0 +1,75 @@ +{ + "_comment": "SHARED PARITY FIXTURE for Bundles readiness(). Loaded by the Rust/TS AND Swift test suites so both implementations produce byte-identical verdicts + reasons. Do not fork per shell. Algorithm (both shells): if requires==null -> (ready,'Ready.'); else check in order: arch mismatch -> (blocked,'Built for {ArchLabel}.'); ram (blocked,'Needs β‰₯{min} GB RAM (you have {ram} GB).'); freeDiskGB (blocked,'Needs β‰₯{min} GB free disk (you have {free} GB).'); then note=nearestTierNote(capabilityNotes, ram) = value at the largest integer key ≀ ram, or null; if ram (marginal, note ?? 'Below the recommended {rec} GB β€” may be slow.'); else -> (ready, note ?? 'Ready.'). ArchLabel: apple-silicon->'Apple Silicon', intel->'Intel', linux->'Linux'.", + "cases": [ + { + "name": "no requires -> ready", + "requires": null, + "capabilityNotes": null, + "profile": { "ramGB": 8, "arch": "apple-silicon", "freeDiskGB": 100, "gpu": "metal" }, + "expect": { "verdict": "ready", "reason": "Ready." } + }, + { + "name": "arch mismatch beats everything -> blocked", + "requires": { "minRamGB": 8, "recommendedRamGB": 16, "minDiskGB": 12, "arch": "apple-silicon", "gpu": "required" }, + "capabilityNotes": null, + "profile": { "ramGB": 4, "arch": "intel", "freeDiskGB": 5, "gpu": "none" }, + "expect": { "verdict": "blocked", "reason": "Built for Apple Silicon." } + }, + { + "name": "ram below min -> blocked", + "requires": { "minRamGB": 8, "recommendedRamGB": 16, "minDiskGB": 12, "arch": "any", "gpu": "preferred" }, + "capabilityNotes": null, + "profile": { "ramGB": 4, "arch": "apple-silicon", "freeDiskGB": 100, "gpu": "metal" }, + "expect": { "verdict": "blocked", "reason": "Needs β‰₯8 GB RAM (you have 4 GB)." } + }, + { + "name": "disk below min -> blocked", + "requires": { "minRamGB": 8, "recommendedRamGB": 16, "minDiskGB": 30, "arch": "any", "gpu": "preferred" }, + "capabilityNotes": null, + "profile": { "ramGB": 32, "arch": "apple-silicon", "freeDiskGB": 10, "gpu": "metal" }, + "expect": { "verdict": "blocked", "reason": "Needs β‰₯30 GB free disk (you have 10 GB)." } + }, + { + "name": "marginal with tier note (ram==min generic", + "requires": { "minRamGB": 8, "recommendedRamGB": 16, "minDiskGB": 12, "arch": "any", "gpu": "none" }, + "capabilityNotes": null, + "profile": { "ramGB": 8, "arch": "apple-silicon", "freeDiskGB": 100, "gpu": "metal" }, + "expect": { "verdict": "marginal", "reason": "Below the recommended 16 GB β€” may be slow." } + }, + { + "name": "ready picks nearest tier note (ram 24 -> tier 16)", + "requires": { "minRamGB": 8, "recommendedRamGB": 16, "minDiskGB": 12, "arch": "any", "gpu": "preferred" }, + "capabilityNotes": { "8": "small", "16": "13-14B comfortably", "32": "32B class" }, + "profile": { "ramGB": 24, "arch": "apple-silicon", "freeDiskGB": 100, "gpu": "metal" }, + "expect": { "verdict": "ready", "reason": "13-14B comfortably" } + }, + { + "name": "ready high tier (ram 128 -> tier 32)", + "requires": { "minRamGB": 8, "recommendedRamGB": 16, "minDiskGB": 12, "arch": "any", "gpu": "preferred" }, + "capabilityNotes": { "8": "small", "16": "13-14B", "32": "32B class" }, + "profile": { "ramGB": 128, "arch": "apple-silicon", "freeDiskGB": 2000, "gpu": "metal" }, + "expect": { "verdict": "ready", "reason": "32B class" } + }, + { + "name": "ready no notes -> generic", + "requires": { "minRamGB": 8, "recommendedRamGB": 16, "minDiskGB": 12, "arch": "any", "gpu": "none" }, + "capabilityNotes": null, + "profile": { "ramGB": 32, "arch": "apple-silicon", "freeDiskGB": 100, "gpu": "metal" }, + "expect": { "verdict": "ready", "reason": "Ready." } + }, + { + "name": "ram below smallest tier key -> generic marginal (no note)", + "requires": { "minRamGB": 4, "recommendedRamGB": 16, "minDiskGB": 12, "arch": "any", "gpu": "preferred" }, + "capabilityNotes": { "8": "small", "16": "13-14B" }, + "profile": { "ramGB": 6, "arch": "apple-silicon", "freeDiskGB": 100, "gpu": "metal" }, + "expect": { "verdict": "marginal", "reason": "Below the recommended 16 GB β€” may be slow." } + } + ] +} From 2a213645dc278992b62b22aef2e7fbdbdee4982e Mon Sep 17 00:00:00 2001 From: Michael Sitarzewski Date: Sun, 12 Jul 2026 19:34:46 -0500 Subject: [PATCH 3/9] =?UTF-8?q?feat(bundles):=20recipe=20contract=20?= =?UTF-8?q?=E2=80=94=20schema,=206=20recipes,=20dependency-free=20validato?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contributor-facing half of Bundles: recipes/recipe.schema.json + one JSON file per recipe (local-llm, image-gen, graphics, media, web-dev, databases) with verified tokens/taps (all homebrew/core|cask) and July-2026 capability requires. scripts/validate-recipes.mjs enforces the contract (structure + `brew info` resolution + tap-trust rule + external-command rule + link reachability) and concatenates passing recipes into bundles.json. 6/6 valid incl. --strict-links. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01X9iMFjHE21ePTjcbHpTXt6 --- bundles.json | 287 +++++++++++++++++++++++++++++++++++ recipes/databases.json | 23 +++ recipes/graphics.json | 20 +++ recipes/image-gen.json | 27 ++++ recipes/local-llm.json | 30 ++++ recipes/media.json | 23 +++ recipes/recipe.schema.json | 79 ++++++++++ recipes/web-dev.json | 21 +++ scripts/validate-recipes.mjs | 141 +++++++++++++++++ 9 files changed, 651 insertions(+) create mode 100644 bundles.json create mode 100644 recipes/databases.json create mode 100644 recipes/graphics.json create mode 100644 recipes/image-gen.json create mode 100644 recipes/local-llm.json create mode 100644 recipes/media.json create mode 100644 recipes/recipe.schema.json create mode 100644 recipes/web-dev.json create mode 100644 scripts/validate-recipes.mjs diff --git a/bundles.json b/bundles.json new file mode 100644 index 0000000..db1c072 --- /dev/null +++ b/bundles.json @@ -0,0 +1,287 @@ +{ + "schemaVersion": 1, + "bundles": [ + { + "id": "databases", + "name": "Local Databases", + "tagline": "PostgreSQL + Redis, managed as services", + "category": "Data", + "icon": "database", + "packages": [ + { + "name": "postgresql@16", + "kind": "formula" + }, + { + "name": "redis", + "kind": "formula" + } + ], + "requires": { + "minRamGB": 4, + "recommendedRamGB": 8, + "minDiskGB": 5, + "arch": "any", + "gpu": "none" + }, + "setup": [ + { + "kind": "service", + "service": "postgresql@16", + "label": "Start PostgreSQL" + }, + { + "kind": "service", + "service": "redis", + "label": "Start Redis" + } + ], + "caveats": "Databases grow with your data. Manage start/stop from the Services tab.", + "links": [ + { + "label": "PostgreSQL", + "url": "https://www.postgresql.org" + }, + { + "label": "Redis", + "url": "https://redis.io" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + }, + { + "id": "graphics", + "name": "Graphics & Design", + "tagline": "Vector, raster, and digital painting β€” Inkscape, GIMP, Krita", + "category": "Graphics", + "icon": "palette", + "packages": [ + { + "name": "inkscape", + "kind": "cask" + }, + { + "name": "gimp", + "kind": "cask" + }, + { + "name": "krita", + "kind": "cask" + } + ], + "caveats": "Three full desktop apps (~2–3 GB combined). Runs on any supported Mac.", + "links": [ + { + "label": "Inkscape", + "url": "https://inkscape.org" + }, + { + "label": "GIMP", + "url": "https://www.gimp.org" + }, + { + "label": "Krita", + "url": "https://krita.org" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + }, + { + "id": "image-gen", + "name": "Image Generation", + "tagline": "Local Stable Diffusion / Flux with ComfyUI", + "category": "AI", + "icon": "image", + "packages": [ + { + "name": "comfy", + "kind": "cask" + } + ], + "requires": { + "minRamGB": 16, + "recommendedRamGB": 24, + "minDiskGB": 30, + "arch": "apple-silicon", + "gpu": "required" + }, + "capabilityNotes": { + "16": "SDXL comfortably; Flux fp8 with offloading", + "24": "Flux fp8 comfortably", + "48": "Flux fp16" + }, + "setup": [ + { + "kind": "note", + "text": "Launch ComfyUI, then download a checkpoint model β€” it is NOT installed by brew (SDXL ~7 GB, Flux ~24 GB)." + }, + { + "kind": "command", + "run": "open -a ComfyUI", + "label": "Launch ComfyUI (you run this)", + "external": true + } + ], + "caveats": "Installs ComfyUI only; models are large downloads you fetch yourself. On Apple Silicon, generation is roughly 2–4Γ— slower per image than a comparable NVIDIA GPU.", + "links": [ + { + "label": "ComfyUI (GitHub)", + "url": "https://github.com/comfyanonymous/ComfyUI" + }, + { + "label": "comfy.org", + "url": "https://www.comfy.org" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + }, + { + "id": "local-llm", + "name": "Local LLMs", + "tagline": "Run models locally with Ollama + a web UI", + "category": "AI", + "icon": "brain", + "packages": [ + { + "name": "ollama", + "kind": "formula" + }, + { + "name": "open-webui", + "kind": "cask" + } + ], + "requires": { + "minRamGB": 8, + "recommendedRamGB": 16, + "minDiskGB": 12, + "arch": "any", + "gpu": "preferred" + }, + "capabilityNotes": { + "8": "Small (~7–8B Q4) models only; larger will swap", + "16": "13–14B models comfortably", + "24": "26–32B class (Qwen, Gemma)", + "48": "70B models" + }, + "setup": [ + { + "kind": "service", + "service": "ollama", + "label": "Start the Ollama server" + }, + { + "kind": "command", + "run": "ollama pull llama3.2", + "label": "Pull a starter model (you run this)", + "external": true + }, + { + "kind": "open", + "url": "http://localhost:8080", + "label": "Open Open WebUI" + } + ], + "caveats": "Installs the tools; you still download models yourself (several GB each). Apple Silicon unified memory counts as VRAM β€” more RAM = bigger models.", + "links": [ + { + "label": "Ollama docs", + "url": "https://docs.ollama.com" + }, + { + "label": "Open WebUI", + "url": "https://openwebui.com" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + }, + { + "id": "media", + "name": "Media Toolkit", + "tagline": "Transcode, download, and play β€” ffmpeg, yt-dlp, mpv", + "category": "Media", + "icon": "clapperboard", + "packages": [ + { + "name": "ffmpeg", + "kind": "formula" + }, + { + "name": "yt-dlp", + "kind": "formula" + }, + { + "name": "mpv", + "kind": "formula" + } + ], + "setup": [ + { + "kind": "command", + "run": "ffmpeg -version", + "label": "Verify ffmpeg (you run this)", + "external": true + } + ], + "caveats": "Command-line tools. Lightweight; runs on any supported Mac or Linux.", + "links": [ + { + "label": "FFmpeg", + "url": "https://ffmpeg.org" + }, + { + "label": "yt-dlp", + "url": "https://github.com/yt-dlp/yt-dlp" + }, + { + "label": "mpv", + "url": "https://mpv.io" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + }, + { + "id": "web-dev", + "name": "Web Dev Starter", + "tagline": "Node.js + pnpm β€” the modern JS toolchain", + "category": "Development", + "icon": "code", + "packages": [ + { + "name": "node", + "kind": "formula" + }, + { + "name": "pnpm", + "kind": "formula" + } + ], + "setup": [ + { + "kind": "command", + "run": "node -v && pnpm -v", + "label": "Verify the toolchain (you run this)", + "external": true + } + ], + "caveats": "Installs the runtimes; project dependencies are per-project (pnpm install).", + "links": [ + { + "label": "Node.js", + "url": "https://nodejs.org" + }, + { + "label": "pnpm", + "url": "https://pnpm.io" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + } + ] +} diff --git a/recipes/databases.json b/recipes/databases.json new file mode 100644 index 0000000..53cc018 --- /dev/null +++ b/recipes/databases.json @@ -0,0 +1,23 @@ +{ + "id": "databases", + "name": "Local Databases", + "tagline": "PostgreSQL + Redis, managed as services", + "category": "Data", + "icon": "database", + "packages": [ + { "name": "postgresql@16", "kind": "formula" }, + { "name": "redis", "kind": "formula" } + ], + "requires": { "minRamGB": 4, "recommendedRamGB": 8, "minDiskGB": 5, "arch": "any", "gpu": "none" }, + "setup": [ + { "kind": "service", "service": "postgresql@16", "label": "Start PostgreSQL" }, + { "kind": "service", "service": "redis", "label": "Start Redis" } + ], + "caveats": "Databases grow with your data. Manage start/stop from the Services tab.", + "links": [ + { "label": "PostgreSQL", "url": "https://www.postgresql.org" }, + { "label": "Redis", "url": "https://redis.io" } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" +} diff --git a/recipes/graphics.json b/recipes/graphics.json new file mode 100644 index 0000000..48acf48 --- /dev/null +++ b/recipes/graphics.json @@ -0,0 +1,20 @@ +{ + "id": "graphics", + "name": "Graphics & Design", + "tagline": "Vector, raster, and digital painting β€” Inkscape, GIMP, Krita", + "category": "Graphics", + "icon": "palette", + "packages": [ + { "name": "inkscape", "kind": "cask" }, + { "name": "gimp", "kind": "cask" }, + { "name": "krita", "kind": "cask" } + ], + "caveats": "Three full desktop apps (~2–3 GB combined). Runs on any supported Mac.", + "links": [ + { "label": "Inkscape", "url": "https://inkscape.org" }, + { "label": "GIMP", "url": "https://www.gimp.org" }, + { "label": "Krita", "url": "https://krita.org" } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" +} diff --git a/recipes/image-gen.json b/recipes/image-gen.json new file mode 100644 index 0000000..398a5e3 --- /dev/null +++ b/recipes/image-gen.json @@ -0,0 +1,27 @@ +{ + "id": "image-gen", + "name": "Image Generation", + "tagline": "Local Stable Diffusion / Flux with ComfyUI", + "category": "AI", + "icon": "image", + "packages": [ + { "name": "comfy", "kind": "cask" } + ], + "requires": { "minRamGB": 16, "recommendedRamGB": 24, "minDiskGB": 30, "arch": "apple-silicon", "gpu": "required" }, + "capabilityNotes": { + "16": "SDXL comfortably; Flux fp8 with offloading", + "24": "Flux fp8 comfortably", + "48": "Flux fp16" + }, + "setup": [ + { "kind": "note", "text": "Launch ComfyUI, then download a checkpoint model β€” it is NOT installed by brew (SDXL ~7 GB, Flux ~24 GB)." }, + { "kind": "command", "run": "open -a ComfyUI", "label": "Launch ComfyUI (you run this)", "external": true } + ], + "caveats": "Installs ComfyUI only; models are large downloads you fetch yourself. On Apple Silicon, generation is roughly 2–4Γ— slower per image than a comparable NVIDIA GPU.", + "links": [ + { "label": "ComfyUI (GitHub)", "url": "https://github.com/comfyanonymous/ComfyUI" }, + { "label": "comfy.org", "url": "https://www.comfy.org" } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" +} diff --git a/recipes/local-llm.json b/recipes/local-llm.json new file mode 100644 index 0000000..f79e3ee --- /dev/null +++ b/recipes/local-llm.json @@ -0,0 +1,30 @@ +{ + "id": "local-llm", + "name": "Local LLMs", + "tagline": "Run models locally with Ollama + a web UI", + "category": "AI", + "icon": "brain", + "packages": [ + { "name": "ollama", "kind": "formula" }, + { "name": "open-webui", "kind": "cask" } + ], + "requires": { "minRamGB": 8, "recommendedRamGB": 16, "minDiskGB": 12, "arch": "any", "gpu": "preferred" }, + "capabilityNotes": { + "8": "Small (~7–8B Q4) models only; larger will swap", + "16": "13–14B models comfortably", + "24": "26–32B class (Qwen, Gemma)", + "48": "70B models" + }, + "setup": [ + { "kind": "service", "service": "ollama", "label": "Start the Ollama server" }, + { "kind": "command", "run": "ollama pull llama3.2", "label": "Pull a starter model (you run this)", "external": true }, + { "kind": "open", "url": "http://localhost:8080", "label": "Open Open WebUI" } + ], + "caveats": "Installs the tools; you still download models yourself (several GB each). Apple Silicon unified memory counts as VRAM β€” more RAM = bigger models.", + "links": [ + { "label": "Ollama docs", "url": "https://docs.ollama.com" }, + { "label": "Open WebUI", "url": "https://openwebui.com" } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" +} diff --git a/recipes/media.json b/recipes/media.json new file mode 100644 index 0000000..e922133 --- /dev/null +++ b/recipes/media.json @@ -0,0 +1,23 @@ +{ + "id": "media", + "name": "Media Toolkit", + "tagline": "Transcode, download, and play β€” ffmpeg, yt-dlp, mpv", + "category": "Media", + "icon": "clapperboard", + "packages": [ + { "name": "ffmpeg", "kind": "formula" }, + { "name": "yt-dlp", "kind": "formula" }, + { "name": "mpv", "kind": "formula" } + ], + "setup": [ + { "kind": "command", "run": "ffmpeg -version", "label": "Verify ffmpeg (you run this)", "external": true } + ], + "caveats": "Command-line tools. Lightweight; runs on any supported Mac or Linux.", + "links": [ + { "label": "FFmpeg", "url": "https://ffmpeg.org" }, + { "label": "yt-dlp", "url": "https://github.com/yt-dlp/yt-dlp" }, + { "label": "mpv", "url": "https://mpv.io" } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" +} diff --git a/recipes/recipe.schema.json b/recipes/recipe.schema.json new file mode 100644 index 0000000..2134b7f --- /dev/null +++ b/recipes/recipe.schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://brew-browser/recipe.schema.json", + "title": "Bundle Recipe", + "type": "object", + "required": ["id", "name", "tagline", "category", "packages"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, + "name": { "type": "string", "minLength": 1, "maxLength": 40 }, + "tagline": { "type": "string", "minLength": 1, "maxLength": 90 }, + "category": { "enum": ["AI", "Graphics", "Media", "Development", "Data", "Productivity"] }, + "icon": { "type": "string" }, + "packages": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "kind"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "kind": { "enum": ["formula", "cask"] } + } + } + }, + "tap": { "type": ["string", "null"], "pattern": "^[^/]+/[^/]+$" }, + "requires": { + "type": "object", + "required": ["minRamGB", "recommendedRamGB", "minDiskGB", "arch", "gpu"], + "additionalProperties": false, + "properties": { + "minRamGB": { "type": "integer", "minimum": 1 }, + "recommendedRamGB": { "type": "integer", "minimum": 1 }, + "minDiskGB": { "type": "integer", "minimum": 1 }, + "arch": { "enum": ["any", "apple-silicon", "intel", "linux"] }, + "gpu": { "enum": ["none", "preferred", "required"] } + } + }, + "capabilityNotes": { + "type": "object", + "patternProperties": { "^[0-9]+$": { "type": "string" } }, + "additionalProperties": false + }, + "setup": { + "type": "array", + "items": { + "type": "object", + "required": ["kind"], + "additionalProperties": false, + "properties": { + "kind": { "enum": ["service", "open", "reveal", "command", "note"] }, + "service": { "type": "string" }, + "label": { "type": "string" }, + "url": { "type": "string", "format": "uri" }, + "path": { "type": "string" }, + "run": { "type": "string" }, + "external": { "type": "boolean" }, + "text": { "type": "string" } + } + } + }, + "caveats": { "type": "string", "maxLength": 240 }, + "links": { + "type": "array", + "items": { + "type": "object", + "required": ["label", "url"], + "additionalProperties": false, + "properties": { + "label": { "type": "string" }, + "url": { "type": "string", "format": "uri" } + } + } + }, + "maintainer": { "type": "string" }, + "addedIn": { "type": "string" } + } +} diff --git a/recipes/web-dev.json b/recipes/web-dev.json new file mode 100644 index 0000000..7f95486 --- /dev/null +++ b/recipes/web-dev.json @@ -0,0 +1,21 @@ +{ + "id": "web-dev", + "name": "Web Dev Starter", + "tagline": "Node.js + pnpm β€” the modern JS toolchain", + "category": "Development", + "icon": "code", + "packages": [ + { "name": "node", "kind": "formula" }, + { "name": "pnpm", "kind": "formula" } + ], + "setup": [ + { "kind": "command", "run": "node -v && pnpm -v", "label": "Verify the toolchain (you run this)", "external": true } + ], + "caveats": "Installs the runtimes; project dependencies are per-project (pnpm install).", + "links": [ + { "label": "Node.js", "url": "https://nodejs.org" }, + { "label": "pnpm", "url": "https://pnpm.io" } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" +} diff --git a/scripts/validate-recipes.mjs b/scripts/validate-recipes.mjs new file mode 100644 index 0000000..e09033e --- /dev/null +++ b/scripts/validate-recipes.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +// Validate bundle recipes against the contract (memory-bank/releases/0.7.0/bundles/recipe-contract.md) +// and, on success, concatenate them into bundles.json. Dependency-free (Node 20+ global fetch). +// +// node scripts/validate-recipes.mjs # structure + brew + links(warn on net err); writes bundles.json +// node scripts/validate-recipes.mjs --strict-links # link failures are errors (CI) +// node scripts/validate-recipes.mjs --no-brew # skip `brew info` resolution (offline dev) +// +// Exit non-zero if any recipe fails a hard check. + +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join, basename } from "node:path"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const RECIPES_DIR = join(ROOT, "recipes"); +const OUT = join(ROOT, "bundles.json"); +const args = process.argv.slice(2); +const STRICT_LINKS = args.includes("--strict-links"); +const NO_BREW = args.includes("--no-brew"); + +const CATEGORIES = ["AI", "Graphics", "Media", "Development", "Data", "Productivity"]; +const ARCHS = ["any", "apple-silicon", "intel", "linux"]; +const GPUS = ["none", "preferred", "required"]; +const STEP_KINDS = ["service", "open", "reveal", "command", "note"]; +// Hosts that bot-block automated HEAD requests but are known-good (verified manually). +const LINK_WHITELIST = ["docs.openwebui.com"]; + +const isKebab = (s) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(s); + +function checkStructure(r, errs) { + const req = (c, m) => { if (!c) errs.push(m); }; + req(typeof r.id === "string" && isKebab(r.id), "id must be kebab-case"); + req(typeof r.name === "string" && r.name.length >= 1 && r.name.length <= 40, "name 1–40 chars"); + req(typeof r.tagline === "string" && r.tagline.length >= 1 && r.tagline.length <= 90, "tagline 1–90 chars"); + req(CATEGORIES.includes(r.category), `category must be one of ${CATEGORIES.join(", ")}`); + req(Array.isArray(r.packages) && r.packages.length >= 1, "packages: β‰₯1 required"); + for (const p of r.packages ?? []) { + req(p && typeof p.name === "string" && p.name.length >= 1, "package.name required"); + req(p && (p.kind === "formula" || p.kind === "cask"), `package.kind must be formula|cask (${p?.name})`); + } + if (r.tap != null) req(/^[^/]+\/[^/]+$/.test(r.tap), "tap must be 'user/repo'"); + if (r.requires) { + const q = r.requires; + req(Number.isInteger(q.minRamGB) && q.minRamGB > 0, "requires.minRamGB int > 0"); + req(Number.isInteger(q.recommendedRamGB) && q.recommendedRamGB > 0, "requires.recommendedRamGB int > 0"); + req(Number.isInteger(q.minDiskGB) && q.minDiskGB > 0, "requires.minDiskGB int > 0"); + req(q.minRamGB <= q.recommendedRamGB, "requires.minRamGB ≀ recommendedRamGB"); + req(ARCHS.includes(q.arch), `requires.arch in ${ARCHS.join("|")}`); + req(GPUS.includes(q.gpu), `requires.gpu in ${GPUS.join("|")}`); + } + if (r.capabilityNotes) for (const k of Object.keys(r.capabilityNotes)) + req(/^[0-9]+$/.test(k), `capabilityNotes key '${k}' must be an integer (GB tier)`); + for (const s of r.setup ?? []) { + req(STEP_KINDS.includes(s.kind), `setup.kind '${s.kind}' invalid`); + if (s.kind === "command") req(s.external === true, `command step '${s.run}' MUST set external:true (contract: no auto-run shell)`); + if (s.kind === "service") req(typeof s.service === "string", "service step needs 'service'"); + if (s.kind === "open") req(typeof s.url === "string" && /^https?:\/\//.test(s.url), "open step url must be http(s)"); + } + if (typeof r.caveats === "string") req(r.caveats.length <= 240, "caveats ≀ 240 chars"); + for (const l of r.links ?? []) { + req(typeof l.label === "string", "link.label required"); + req(typeof l.url === "string" && l.url.startsWith("https://"), `link url must be https (${l.url})`); + } +} + +function checkBrew(r, errs) { + if (NO_BREW) return; + for (const p of r.packages ?? []) { + let json; + try { json = JSON.parse(execFileSync("brew", ["info", "--json=v2", p.name], { encoding: "utf8" })); } + catch { errs.push(`brew: '${p.name}' does not resolve`); continue; } + const f = json.formulae?.[0], c = json.casks?.[0]; + const kind = f ? "formula" : c ? "cask" : null; + const tap = (f ?? c)?.tap; + if (!kind) { errs.push(`brew: '${p.name}' not found`); continue; } + if (kind !== p.kind) errs.push(`brew: '${p.name}' is a ${kind}, recipe says ${p.kind}`); + const official = tap === "homebrew/core" || tap === "homebrew/cask"; + if (!official && r.tap !== tap) errs.push(`brew: '${p.name}' is in third-party tap '${tap}' β€” declare it in the recipe's top-level "tap" field (Homebrew 6.0 needs trust)`); + } +} + +async function checkLinks(r, errs) { + // Only "further reading" links[] get reachability-checked. `open`-step URLs are + // runtime app URLs (often localhost) and are validated for scheme only (in checkStructure). + const urls = (r.links ?? []).map((l) => l.url); + for (const url of urls) { + let host; try { host = new URL(url).host; } catch { errs.push(`bad url ${url}`); continue; } + if (LINK_WHITELIST.includes(host)) continue; + // A real dead link returns a 4xx/5xx STATUS (hard fail). A network/TLS/bot-block + // THROW is ambiguous, so it's only ever a warning β€” even in --strict-links. + const headers = { "User-Agent": "Mozilla/5.0 (compatible; brew-browser-recipe-validator)" }; + const tryOnce = async (method) => { + const ac = new AbortController(); const t = setTimeout(() => ac.abort(), 12000); + try { return await fetch(url, { method, redirect: "follow", headers, signal: ac.signal }); } + finally { clearTimeout(t); } + }; + try { + let res = await tryOnce("HEAD"); + if (res.status >= 400) res = await tryOnce("GET"); // some hosts reject HEAD + if (res.status >= 400) errs.push(`link ${url} β†’ HTTP ${res.status}`); + } catch { + try { + const res = await tryOnce("GET"); + if (res.status >= 400) errs.push(`link ${url} β†’ HTTP ${res.status}`); + } catch (e2) { + console.warn(` ⚠︎ link ${url} unreachable (${e2.name}) β€” warning only, not a dead-link status`); + } + } + } +} + +const files = readdirSync(RECIPES_DIR).filter((f) => f.endsWith(".json") && f !== "recipe.schema.json"); +const seenIds = new Set(); +const passing = []; +let failed = 0; + +console.log(`Validating ${files.length} recipe(s)${NO_BREW ? " (no-brew)" : ""}${STRICT_LINKS ? " (strict-links)" : ""}:\n`); +for (const file of files.sort()) { + const errs = []; + let r; + try { r = JSON.parse(readFileSync(join(RECIPES_DIR, file), "utf8")); } + catch (e) { console.log(` βœ— ${file} β€” invalid JSON: ${e.message}`); failed++; continue; } + checkStructure(r, errs); + if (r.id) { if (seenIds.has(r.id)) errs.push(`duplicate id '${r.id}'`); seenIds.add(r.id); } + checkBrew(r, errs); + await checkLinks(r, errs); + if (errs.length) { console.log(` βœ— ${file}`); for (const e of errs) console.log(` - ${e}`); failed++; } + else { console.log(` βœ“ ${basename(file)} (${r.packages.length} pkg)`); passing.push(r); } +} + +console.log(`\n${passing.length}/${files.length} valid.`); +if (failed) { console.error(`\n${failed} recipe(s) FAILED the contract.`); process.exit(1); } + +// NOTE: intentionally NO timestamp β€” bundles.json must be deterministic so the +// CI "up to date" check (git diff) is meaningful and re-runs don't churn git. +// Provenance lives per-recipe in `addedIn`. Bundles sorted by id for stable diffs. +const bundles = { schemaVersion: 1, bundles: passing.sort((a, b) => a.id.localeCompare(b.id)) }; +writeFileSync(OUT, JSON.stringify(bundles, null, 2) + "\n"); +console.log(`\nβœ“ wrote ${OUT} (${passing.length} bundles).`); From bcba17512601477dc2e81cbe96a6fe18cf76f4cd Mon Sep 17 00:00:00 2001 From: Michael Sitarzewski Date: Sun, 12 Jul 2026 20:00:59 -0500 Subject: [PATCH 4/9] =?UTF-8?q?feat(bundles):=20M2=E2=80=93M4=20=E2=80=94?= =?UTF-8?q?=20loaders,=20browse/install=20UI,=20setup=20guidance=20(both?= =?UTF-8?q?=20shells)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bundles app feature, in parity across Tauri and native: - M2 loaders: tolerant Bundle parsers (Rust serde / Swift Codable β€” the native model is `BrewBundle` to avoid shadowing Foundation.Bundle), each embedding its own bundles.json copy (Tauri include_str! / native Bundle.module). - M3 browse + install: a "Bundles" nav section (⌘7), a card grid with capability ReadinessPill (Ready/Marginal/Not-recommended), a detail view with live per-package installed state, caveats, links, and "Install all". Install streams into Activity as per-kind groups (brew rejects interleaved --formula/--cask, verified via --dry-run on both shells). - M4 setup guidance: a post-install checklist β€” service/open/reveal run in-app; external `command` steps are copy-only ("you run this"), never executed. Readiness is computed client-side against the M1 SystemProfile; a fake-RAM override surfaces gated states on high-RAM machines. Tauri cargo 681, svelte-check 0, vitest 53. Native 187 tests. All green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01X9iMFjHE21ePTjcbHpTXt6 --- native/Package.swift | 5 + .../Sources/BrewBrowser/BrewBrowserApp.swift | 2 + native/Sources/BrewBrowserKit/AppModel.swift | 51 +- .../Sources/BrewBrowserKit/BrewService.swift | 17 + .../BrewBrowserKit/BundleDetailView.swift | 377 +++++++++++++ .../BrewBrowserKit/BundleReadiness.swift | 2 +- native/Sources/BrewBrowserKit/Bundles.swift | 173 ++++++ .../Sources/BrewBrowserKit/BundlesView.swift | 141 +++++ .../Sources/BrewBrowserKit/ContentView.swift | 2 + .../BrewBrowserKit/Resources/bundles.json | 287 ++++++++++ .../BrewOutputParsingTests.swift | 41 ++ .../BundleParsingTests.swift | 133 +++++ src-tauri/data/bundles.json | 287 ++++++++++ src-tauri/src/commands/actions.rs | 158 +++++- src-tauri/src/commands/bundles.rs | 133 +++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/lib.rs | 2 + src-tauri/src/types.rs | 106 ++++ src/lib/api.ts | 30 + src/lib/components/BundleDetail.svelte | 512 ++++++++++++++++++ src/lib/components/Bundles.svelte | 181 +++++++ src/lib/components/ReadinessPill.svelte | 23 + src/lib/components/Sidebar.svelte | 2 + src/lib/stores/bundles.svelte.ts | 86 +++ src/lib/stores/ui.svelte.ts | 1 + src/lib/types.ts | 56 +- src/lib/util/setupStep.test.ts | 15 + src/lib/util/setupStep.ts | 27 + src/routes/+page.svelte | 8 +- 29 files changed, 2853 insertions(+), 7 deletions(-) create mode 100644 native/Sources/BrewBrowserKit/BundleDetailView.swift create mode 100644 native/Sources/BrewBrowserKit/Bundles.swift create mode 100644 native/Sources/BrewBrowserKit/BundlesView.swift create mode 100644 native/Sources/BrewBrowserKit/Resources/bundles.json create mode 100644 native/Tests/BrewBrowserKitTests/BundleParsingTests.swift create mode 100644 src-tauri/data/bundles.json create mode 100644 src-tauri/src/commands/bundles.rs create mode 100644 src/lib/components/BundleDetail.svelte create mode 100644 src/lib/components/Bundles.svelte create mode 100644 src/lib/components/ReadinessPill.svelte create mode 100644 src/lib/stores/bundles.svelte.ts create mode 100644 src/lib/util/setupStep.test.ts create mode 100644 src/lib/util/setupStep.ts diff --git a/native/Package.swift b/native/Package.swift index da2f7a3..fa1b8d3 100644 --- a/native/Package.swift +++ b/native/Package.swift @@ -50,6 +50,11 @@ let package = Package( resources: [ .copy("Resources/categories.json"), .copy("Resources/enrichment.json"), + // Curated Bundles catalog (M2) β€” the six first-party recipes, + // generated from recipes/*.json by scripts/validate-recipes.mjs. + // Same artifact the Tauri app bundles; parsed at launch by + // BundleCatalog (Bundles.swift) via Bundle.module, tolerant/lossy. + .copy("Resources/bundles.json"), // GitHub Octocat mark (vector PDF, Primer/Octicons MIT). Rendered // as a template image in the toolbar's "connected" chip. .copy("Resources/github-mark.pdf"), diff --git a/native/Sources/BrewBrowser/BrewBrowserApp.swift b/native/Sources/BrewBrowser/BrewBrowserApp.swift index 2214851..8a621ef 100644 --- a/native/Sources/BrewBrowser/BrewBrowserApp.swift +++ b/native/Sources/BrewBrowser/BrewBrowserApp.swift @@ -100,6 +100,8 @@ struct AppCommands: Commands { .keyboardShortcut("5", modifiers: .command) Button("Activity") { model.go(toSectionNumber: 6) } .keyboardShortcut("6", modifiers: .command) + Button("Bundles") { model.go(toSectionNumber: 7) } + .keyboardShortcut("7", modifiers: .command) Divider() diff --git a/native/Sources/BrewBrowserKit/AppModel.swift b/native/Sources/BrewBrowserKit/AppModel.swift index 5252243..2152417 100644 --- a/native/Sources/BrewBrowserKit/AppModel.swift +++ b/native/Sources/BrewBrowserKit/AppModel.swift @@ -11,6 +11,7 @@ enum Section: String, CaseIterable, Identifiable, Hashable { case snapshots = "Snapshots" case services = "Services" case activity = "Activity" + case bundles = "Bundles" var id: String { rawValue } @@ -24,6 +25,7 @@ enum Section: String, CaseIterable, Identifiable, Hashable { case .snapshots: return "camera" case .services: return "gearshape.2" case .activity: return "list.bullet.rectangle" + case .bundles: return "square.stack.3d.up" } } } @@ -394,11 +396,12 @@ public final class AppModel { func loadBundledData() async { if bundledDataLoaded { return } bundledDataLoaded = true - let (cat, enr) = await Task.detached(priority: .userInitiated) { - (CategoryCatalog.loadBundled(), EnrichmentCatalog.loadBundled()) + let (cat, enr, bnd) = await Task.detached(priority: .userInitiated) { + (CategoryCatalog.loadBundled(), EnrichmentCatalog.loadBundled(), BundleCatalog().load()) }.value categoryCatalog = cat enrichment = enr + bundles = bnd if !installed.isEmpty { categories = cat?.breakdown(installed: installed) ?? [] } if let pkg = detailPackage, settings.aiFeaturesVisible { detailEnrichment = enrichmentEntry(for: pkg.name) @@ -721,6 +724,7 @@ public final class AppModel { let map: [Int: Section] = [ 0: .dashboard, 1: .library, 2: .discover, 3: .trending, 4: .snapshots, 5: .services, 6: .activity, + 7: .bundles, ] if let s = map[n] { selection = s } } @@ -810,6 +814,23 @@ public final class AppModel { var brewVersion = "β€”" var brewPrefix = "/opt/homebrew" var categories: [CategoryBreakdown] = [] + /// Curated Bundles catalog (M2), parsed from the bundled `bundles.json` by + /// `loadBundledData()`. Per-bundle readiness (M3) is computed at render time + /// via `readiness(for:)` against `systemProfile`. + var bundles: [BrewBundle] = [] + + /// Host capability snapshot (M1), read once at construction. Feeds + /// `readiness(for:)` so the Bundles UI can gate recipes against this machine. + /// `SystemProfile.detect()` honors `BREWBROWSER_FAKE_RAM_GB`, so a debug run + /// can force Marginal/Blocked states on a high-RAM dev Mac. + var systemProfile = SystemProfile.detect() + + /// Capability verdict for a bundle on this host (M3). Pure β€” just routes the + /// bundle's `requires`/`capabilityNotes` and the cached profile through the + /// M1 readiness function. + func readiness(for b: BrewBundle) -> Readiness { + BundleReadiness.readiness(b.requires, b.capabilityNotes, systemProfile) + } var runningServices = 0 var dashboardLoaded = false /// `brew outdated --json=v2` is slow (~4s) and `du -sk` can lag, so they no @@ -1906,6 +1927,32 @@ public final class AppModel { if ok, let pkg = detailPackage { await loadDetail(pkg) } } + // MARK: - Bundles (M3) + + /// Install every not-yet-installed package in a bundle (M3 "Install all"). + /// brew rejects a single interleaved `--formula … --cask …` invocation + /// ("Options --cask and --formulae are mutually exclusive"), so + /// `BrewArgs.installBundle` splits the set into at most two per-kind groups + /// (formulae, then casks); each is ONE streamed Activity job β€” not N. + /// Already-installed packages are filtered out first (install is idempotent, + /// but this keeps the job to just the missing pieces). `startJob` refreshes + /// the library on success, so per-package installed state flips afterward. + func installBundle(_ b: BrewBundle) async { + let pending = b.packages.filter { + let kind = InstalledPackage.Kind(rawValue: $0.kind) ?? .formula + return !isPackageInstalled(token: $0.name, kind: kind) + } + let groups = BrewArgs.installBundle(pending) + guard !groups.isEmpty else { return } + let label = "Installing \(b.name)" + for args in groups { + let ok = await startJob(label, args: args, startedAt: Date().timeIntervalSince1970) + // Stop before the second (cask) job if the formula job failed β€” the + // failure is already surfaced in Activity; don't pile a second error on. + if !ok { break } + } + } + // MARK: - Bulk actions (Dashboard Updates card) /// `brew upgrade` β€” upgrade every outdated formula and cask, as a streaming diff --git a/native/Sources/BrewBrowserKit/BrewService.swift b/native/Sources/BrewBrowserKit/BrewService.swift index 7d21331..d1b8cc9 100644 --- a/native/Sources/BrewBrowserKit/BrewService.swift +++ b/native/Sources/BrewBrowserKit/BrewService.swift @@ -165,6 +165,23 @@ enum BrewArgs { args.append(name) return args } + + /// Argv groups for a bundle "Install all" (M3). brew rejects mixing + /// `--formula` and `--cask` in one invocation ("Options --cask and + /// --formulae are mutually exclusive"), but takes many same-kind names at + /// once β€” so we emit at most two groups: `["install","--formula",f1,f2,…]` + /// then `["install","--cask",c1,c2,…]`. A group is omitted when it has no + /// members (formulae-only bundles β†’ one group; empty input β†’ no groups). + /// Each group is run as its own streaming Activity job by + /// `AppModel.installBundle(_:)`. Input order is preserved within each kind. + static func installBundle(_ packages: [BundlePackage]) -> [[String]] { + let formulae = packages.filter { $0.kind != "cask" }.map(\.name) + let casks = packages.filter { $0.kind == "cask" }.map(\.name) + var groups: [[String]] = [] + if !formulae.isEmpty { groups.append(["install", "--formula"] + formulae) } + if !casks.isEmpty { groups.append(["install", "--cask"] + casks) } + return groups + } } struct BrewService: Sendable { diff --git a/native/Sources/BrewBrowserKit/BundleDetailView.swift b/native/Sources/BrewBrowserKit/BundleDetailView.swift new file mode 100644 index 0000000..c92ff03 --- /dev/null +++ b/native/Sources/BrewBrowserKit/BundleDetailView.swift @@ -0,0 +1,377 @@ +import SwiftUI +import AppKit + +/// Bundle detail (M3) β€” presented as a sheet from `BundlesView`. Shows the +/// capability verdict + reason, each package with its live installed state, +/// caveats, links, and an "Install all" action that streams into Activity via +/// `AppModel.installBundle(_:)`. A `blocked` verdict gates Install behind a +/// confirmation (never a hard block). +struct BundleDetailView: View { + @Bindable var model: AppModel + let bundle: BrewBundle + + @Environment(\.dismiss) private var dismiss + @State private var confirmBlockedInstall = false + + private var readiness: Readiness { model.readiness(for: bundle) } + + /// Packages not yet installed β€” the set "Install all" would actually add. + private var pendingCount: Int { + bundle.packages.filter { !isInstalled($0) }.count + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 16) { + verdictCard + packagesSection + // Caveats sit ABOVE the setup checklist so a heavy bundle's + // warning (e.g. Image Gen model downloads) is unmissable. + if let caveats = bundle.caveats, !caveats.isEmpty { + caveatsCard(caveats) + } + if let setup = bundle.setup, !setup.isEmpty { + setupSection(setup) + } + if let links = bundle.links, !links.isEmpty { + linksSection(links) + } + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + Divider() + footer + } + .frame(width: 520, height: 580) + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: bundleSymbol(bundle.icon)) + .font(.largeTitle) + .foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 4) { + Text(bundle.name).font(.title2.weight(.semibold)) + Text(bundle.tagline).font(.callout).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 8) { + Button("Done") { dismiss() } + ReadinessPill(readiness: readiness) + } + } + .padding(20) + } + + // MARK: - Verdict + + private var verdictCard: some View { + GroupBox { + HStack(alignment: .top, spacing: 8) { + Image(systemName: readiness.verdict.symbol) + .foregroundStyle(readiness.verdict.tone) + Text(readiness.reason) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) + } label: { + Label(readiness.verdict.pillLabel, systemImage: readiness.verdict.symbol) + .foregroundStyle(readiness.verdict.tone) + } + } + + // MARK: - Packages + + private var packagesSection: some View { + GroupBox { + VStack(spacing: 6) { + ForEach(bundle.packages, id: \.self) { pkg in + let installed = isInstalled(pkg) + HStack(spacing: 8) { + Image(systemName: installed ? "checkmark.circle.fill" : "circle") + .foregroundStyle(installed ? .green : .secondary) + Text(pkg.name).font(.callout) + Text(pkg.kind) + .font(.caption).foregroundStyle(.secondary) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(.quaternary, in: .capsule) + Spacer() + Text(installed ? "Installed" : "Not installed") + .font(.caption).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.top, 2) + } label: { + Label("Packages", systemImage: "shippingbox") + } + } + + // MARK: - Caveats + + private func caveatsCard(_ caveats: String) -> some View { + GroupBox { + Text(caveats) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(.top, 2) + } label: { + Label("Caveats", systemImage: "exclamationmark.bubble") + .foregroundStyle(.orange) + } + } + + // MARK: - Setup checklist (M4) + + /// Ordered post-install checklist. brew-native steps (service/open/reveal) + /// get action buttons; `command` steps are COPY-ONLY and marked "you run + /// this" β€” the app never executes arbitrary shell (`external:true` contract). + /// Shown pre- and post-install so the user sees the whole recipe. + private func setupSection(_ steps: [SetupStep]) -> some View { + GroupBox { + VStack(alignment: .leading, spacing: 12) { + ForEach(Array(steps.enumerated()), id: \.offset) { index, step in + SetupStepRow(model: model, step: step, number: index + 1) + if index < steps.count - 1 { Divider() } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) + } label: { + Label("Setup", systemImage: "checklist") + } + } + + // MARK: - Links + + private func linksSection(_ links: [BundleLink]) -> some View { + GroupBox { + VStack(alignment: .leading, spacing: 6) { + ForEach(links, id: \.self) { link in + if let url = URL(string: link.url) { + Link(destination: url) { + Label(link.label, systemImage: "arrow.up.right.square") + .font(.callout) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) + } label: { + Label("Links", systemImage: "link") + } + } + + // MARK: - Footer (Install all) + + private var footer: some View { + HStack { + Spacer() + Button { + if readiness.verdict == .blocked { + confirmBlockedInstall = true + } else { + launchInstall() + } + } label: { + Label(installLabel, systemImage: "arrow.down.circle") + } + .buttonStyle(.borderedProminent) + .disabled(pendingCount == 0) + .confirmationDialog( + "Your machine may not run this well β€” install anyway?", + isPresented: $confirmBlockedInstall, + titleVisibility: .visible + ) { + Button("Install anyway", role: .destructive) { launchInstall() } + Button("Cancel", role: .cancel) {} + } message: { + Text(readiness.reason) + } + } + .padding(16) + } + + private var installLabel: String { + if pendingCount == 0 { return "All installed" } + return pendingCount == bundle.packages.count + ? "Install all" + : "Install \(pendingCount) missing" + } + + // MARK: - Actions + + private func isInstalled(_ pkg: BundlePackage) -> Bool { + let kind = InstalledPackage.Kind(rawValue: pkg.kind) ?? .formula + return model.installedPackageMatching(token: pkg.name, kind: kind) != nil + } + + /// Kick off the streaming install and close the sheet so the Activity drawer + /// (auto-opened by `startJob`) is visible. + private func launchInstall() { + let b = bundle + Task { await model.installBundle(b) } + dismiss() + } +} + +/// One row of the setup checklist. The kind drives the row: brew-native steps +/// (`service`/`open`/`reveal`) get an action button; `command` is copy-only and +/// visibly marked "you run this"; `note` is plain/markdown text. There is NO +/// code path that executes a `command`/external step β€” that is the automation +/// boundary the recipe contract guarantees. +struct SetupStepRow: View { + @Bindable var model: AppModel + let step: SetupStep + let number: Int + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Text("\(number).") + .font(.callout.monospacedDigit()) + .foregroundStyle(.secondary) + content + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder private var content: some View { + switch step.kind { + case "service": serviceRow + case "open": openRow + case "reveal": revealRow + case "command": commandRow + case "note": noteRow + default: noteRow // unknown kind β†’ show its text/label, no action + } + } + + // MARK: service β†’ "Start" (disabled until the package is installed) + + private var serviceRow: some View { + let service = step.service ?? "" + // brew services are formulae β€” best-effort installed check. + let installed = !service.isEmpty + && model.installedPackageMatching(token: service, kind: .formula) != nil + return HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(step.label ?? "Start \(service)").font(.callout) + if !installed { + Text("Install this bundle first to start \(service.isEmpty ? "the service" : service).") + .font(.caption).foregroundStyle(.secondary) + } + } + Spacer(minLength: 8) + Button("Start") { + Task { await model.performServiceAction(.start, name: service) } + } + .disabled(!installed) + .help(installed ? "brew services start \(service)" + : "The service's package isn't installed yet.") + } + } + + // MARK: open β†’ "Open" (http/https only) + + private var openRow: some View { + let url = validHttpURL(step.url) + return HStack(alignment: .top, spacing: 8) { + Text(step.label ?? "Open \(step.url ?? "")").font(.callout) + Spacer(minLength: 8) + Button("Open") { if let url { NSWorkspace.shared.open(url) } } + .disabled(url == nil) + .help(step.url ?? "") + } + } + + // MARK: reveal β†’ "Reveal" in Finder + + private var revealRow: some View { + let path = step.path ?? "" + return HStack(alignment: .top, spacing: 8) { + Text(step.label ?? "Reveal \(path)").font(.callout) + Spacer(minLength: 8) + Button("Reveal") { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) + } + .disabled(path.isEmpty) + .help(path) + } + } + + // MARK: command β†’ COPY-ONLY, "you run this" (never executed) + + private var commandRow: some View { + let run = step.run ?? "" + return VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top, spacing: 8) { + if let label = step.label { Text(label).font(.callout) } + Spacer(minLength: 8) + // A copy affordance only β€” deliberately NOT a run/execute button. + Label("you run this", systemImage: "hand.raised") + .font(.caption).foregroundStyle(.secondary) + Button { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(run, forType: .string) + model.pushToast(.success, "Copied") + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + .disabled(run.isEmpty) + } + Text(run) + .font(.callout.monospaced()) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary, in: .rect(cornerRadius: 6)) + } + } + + // MARK: note β†’ text (markdown if it parses) + + private var noteRow: some View { + noteText(step.text ?? step.label ?? "") + .font(.callout) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + + // MARK: helpers + + /// Render a note as inline markdown when it parses, else as plain text. + private func noteText(_ s: String) -> Text { + if let attributed = try? AttributedString( + markdown: s, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + return Text(attributed) + } + return Text(s) + } + + /// Only http/https URLs are openable β€” the `open`-step allowlist (mirrors the + /// scheme guard used for cask homepages). Returns nil for anything else. + private func validHttpURL(_ raw: String?) -> URL? { + guard let raw, let url = URL(string: raw), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" + else { return nil } + return url + } +} diff --git a/native/Sources/BrewBrowserKit/BundleReadiness.swift b/native/Sources/BrewBrowserKit/BundleReadiness.swift index 43a164a..a1bc749 100644 --- a/native/Sources/BrewBrowserKit/BundleReadiness.swift +++ b/native/Sources/BrewBrowserKit/BundleReadiness.swift @@ -3,7 +3,7 @@ import Foundation /// What a bundle/recipe needs from the host to run. Shared data contract with /// the Tauri side (`BundleRequires` in src/lib/types.ts); `arch == "any"` means /// no arch constraint. -public struct BundleRequires: Sendable, Codable, Equatable { +public struct BundleRequires: Sendable, Codable, Equatable, Hashable { public let minRamGB: Int public let recommendedRamGB: Int public let minDiskGB: Int diff --git a/native/Sources/BrewBrowserKit/Bundles.swift b/native/Sources/BrewBrowserKit/Bundles.swift new file mode 100644 index 0000000..5ee1355 --- /dev/null +++ b/native/Sources/BrewBrowserKit/Bundles.swift @@ -0,0 +1,173 @@ +import Foundation + +/// Bundles (curated "recipes") β€” a named set of Homebrew packages plus the +/// capability requirements and post-install setup guidance the UI renders. The +/// data contract mirrors the Tauri side (`Bundle`/`BundlePackage`/`SetupStep` +/// in src-tauri/src/types.rs + src/lib/types.ts) and the JSON Schema at +/// recipes/recipe.schema.json. Parsed from the bundled `bundles.json` +/// (generated by scripts/validate-recipes.mjs) at launch by `BundleCatalog`. +/// +/// Tolerant by design: every field beyond the required core is optional (missing +/// β†’ nil), unknown JSON keys are ignored by Codable, and a single malformed +/// recipe is skipped rather than failing the whole catalog (see `BundleCatalog`). +/// +/// NOTE: named `BrewBundle`, not `Bundle`. A module-level type named `Bundle` +/// would shadow `Foundation.Bundle` β€” including inside SwiftPM's generated +/// `resource_bundle_accessor.swift`, which references `Bundle.module` unqualified +/// and can't be edited β€” breaking every bundled-resource lookup in this target. +/// The `Brew*` prefix also matches `BrewService`/`BrewOutputParsing`. +public struct BrewBundle: Sendable, Codable, Identifiable, Hashable { + /// kebab-case identifier, unique across the catalog (e.g. "local-llm"). + public let id: String + /// Display name (≀40 chars per the schema). + public let name: String + /// One-line pitch (≀90 chars). + public let tagline: String + /// One of AI / Graphics / Media / Development / Data / Productivity. + public let category: String + /// Optional icon token (SF Symbol-ish name the UI maps to a glyph). + public let icon: String? + /// The packages this bundle installs (β‰₯1). Formula or cask. + public let packages: [BundlePackage] + /// Third-party tap that must be trusted before install, if any. + public let tap: String? + /// Host requirements gate (RAM / disk / arch / gpu). Reused M1 type. + public let requires: BundleRequires? + /// RAM-tier β†’ capability note, keyed by integer-string GB (e.g. "16"). + public let capabilityNotes: [String: String]? + /// Ordered post-install steps (start a service, open a URL, run a command…). + public let setup: [SetupStep]? + /// Free-text caveats shown before/after install (≀240 chars). + public let caveats: String? + /// "Further reading" links (label + https url). + public let links: [BundleLink]? + /// Who curates this recipe (e.g. "brew-browser"). + public let maintainer: String? + /// First app version the recipe shipped in (e.g. "0.7.0"). + public let addedIn: String? + + public init(id: String, name: String, tagline: String, category: String, + icon: String? = nil, packages: [BundlePackage], tap: String? = nil, + requires: BundleRequires? = nil, capabilityNotes: [String: String]? = nil, + setup: [SetupStep]? = nil, caveats: String? = nil, + links: [BundleLink]? = nil, maintainer: String? = nil, addedIn: String? = nil) { + self.id = id + self.name = name + self.tagline = tagline + self.category = category + self.icon = icon + self.packages = packages + self.tap = tap + self.requires = requires + self.capabilityNotes = capabilityNotes + self.setup = setup + self.caveats = caveats + self.links = links + self.maintainer = maintainer + self.addedIn = addedIn + } +} + +/// One package inside a bundle. `kind` is "formula" or "cask" (kept as a plain +/// String for tolerant parsing β€” an unknown kind survives rather than dropping +/// the whole recipe). +public struct BundlePackage: Sendable, Codable, Hashable { + public let name: String + public let kind: String + + public init(name: String, kind: String) { + self.name = name + self.kind = kind + } +} + +/// One post-install setup step. `kind` selects which of the optional fields are +/// meaningful: serviceβ†’`service`, openβ†’`url`, revealβ†’`path`, commandβ†’`run` +/// (always `external:true` per the contract β€” the app never auto-runs shell), +/// noteβ†’`text`. All non-`kind` fields are optional so any step shape parses. +public struct SetupStep: Sendable, Codable, Hashable { + public let kind: String + public let service: String? + public let label: String? + public let url: String? + public let path: String? + public let run: String? + public let external: Bool? + public let text: String? + + public init(kind: String, service: String? = nil, label: String? = nil, + url: String? = nil, path: String? = nil, run: String? = nil, + external: Bool? = nil, text: String? = nil) { + self.kind = kind + self.service = service + self.label = label + self.url = url + self.path = path + self.run = run + self.external = external + self.text = text + } +} + +/// A "further reading" link on a bundle. +public struct BundleLink: Sendable, Codable, Hashable { + public let label: String + public let url: String + + public init(label: String, url: String) { + self.label = label + self.url = url + } +} + +// MARK: - Lossy decoding + +/// Decodes `T` but captures any decode error instead of throwing, so it can be +/// used inside `[Throwable]` to decode an array element-by-element where bad +/// elements are dropped rather than failing the whole array. (A directly-thrown +/// element decode does not advance `JSONDecoder`'s unkeyed container, so the +/// error must be caught at the element boundary β€” this wrapper does exactly that.) +struct Throwable: Decodable { + let result: Result + init(from decoder: Decoder) throws { + result = Result(catching: { try T(from: decoder) }) + } +} + +/// On-disk `bundles.json` shape: `{ schemaVersion, generatedAt?, bundles: [...] }`. +/// `schemaVersion`/`generatedAt` are optional (the generator omits the timestamp +/// for deterministic diffs); each bundle is wrapped in `Throwable` so a single +/// malformed recipe is skipped, not fatal. +private struct BundleFile: Decodable { + let schemaVersion: Int? + let bundles: [Throwable] +} + +// MARK: - Loader + +/// Parses the bundled `bundles.json` into `[Bundle]`, tolerantly. A Sendable +/// value type (mirrors the `CategoryCatalog`/`EnrichmentCatalog` bundled-data +/// loaders); call `load()` off the main thread from `AppModel.loadBundledData()`. +public struct BundleCatalog: Sendable { + public init() {} + + /// Decode the bundled catalog. Returns `[]` if the resource is missing or + /// the top-level JSON is malformed; individual malformed recipes are simply + /// dropped so the remaining valid bundles still load. Preserves file order. + public func load() -> [BrewBundle] { + guard let url = Bundle.module.url(forResource: "bundles", withExtension: "json"), + let data = try? Data(contentsOf: url) else { + return [] + } + return Self.parse(data) + } + + /// Decode bundles from raw JSON bytes (the testable core of `load()`). + /// Skips recipes that fail to decode; returns `[]` on a malformed top level. + static func parse(_ data: Data) -> [BrewBundle] { + guard let file = try? JSONDecoder().decode(BundleFile.self, from: data) else { + return [] + } + return file.bundles.compactMap { try? $0.result.get() } + } +} diff --git a/native/Sources/BrewBrowserKit/BundlesView.swift b/native/Sources/BrewBrowserKit/BundlesView.swift new file mode 100644 index 0000000..cd0b3cc --- /dev/null +++ b/native/Sources/BrewBrowserKit/BundlesView.swift @@ -0,0 +1,141 @@ +import SwiftUI + +/// Bundles (M3) β€” a browse grid of curated package sets. Each card shows the +/// bundle's icon, name, tagline, a capability-aware readiness pill, and its +/// package makeup; tapping opens `BundleDetailView` in a sheet. Stock +/// `GroupBox` cards in a `LazyVGrid`, matching the Dashboard card style. +public struct BundlesView: View { + @Bindable var model: AppModel + + /// Card selected for the detail sheet (`BrewBundle` is Identifiable by `id`). + @State private var selected: BrewBundle? + + private let columns = [GridItem(.adaptive(minimum: 280), spacing: 16)] + + public init(model: AppModel) { self.model = model } + + public var body: some View { + Group { + if model.bundles.isEmpty { + ContentUnavailableView( + "No bundles", + systemImage: "square.stack.3d.up", + description: Text("Curated package sets will appear here.") + ) + } else { + ScrollView { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(model.bundles) { bundle in + BundleCard(model: model, bundle: bundle) + .onTapGesture { selected = bundle } + } + } + .padding(20) + } + } + } + .sheet(item: $selected) { bundle in + BundleDetailView(model: model, bundle: bundle) + } + } +} + +/// One bundle card. Icon + name + tagline + readiness pill + "N formulae Β· M +/// casks" line. The whole card is the hit target (the grid's `onTapGesture`). +struct BundleCard: View { + @Bindable var model: AppModel + let bundle: BrewBundle + + var body: some View { + GroupBox { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top) { + Image(systemName: bundleSymbol(bundle.icon)) + .font(.title2) + .foregroundStyle(.tint) + Spacer(minLength: 8) + ReadinessPill(readiness: model.readiness(for: bundle)) + } + Text(bundle.name).font(.headline) + Text(bundle.tagline) + .font(.callout).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + Text(packageSummary(bundle)) + .font(.caption).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) + .contentShape(.rect) + } + } +} + +// MARK: - Shared readiness UI + +/// A small capsule tinted by the readiness verdict, with the reason as its +/// tooltip. Shared by the card and the detail header. +struct ReadinessPill: View { + let readiness: Readiness + var body: some View { + Label(readiness.verdict.pillLabel, systemImage: readiness.verdict.symbol) + .font(.caption.weight(.medium)) + .foregroundStyle(readiness.verdict.tone) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(readiness.verdict.tone.opacity(0.15), in: .capsule) + .help(readiness.reason) + } +} + +extension ReadinessVerdict { + /// User-facing pill label. Never a hard block β€” "Not recommended" is advisory. + var pillLabel: String { + switch self { + case .ready: return "Ready" + case .marginal: return "Marginal" + case .blocked: return "Not recommended" + } + } + + var tone: Color { + switch self { + case .ready: return .green + case .marginal: return .orange + case .blocked: return .red + } + } + + var symbol: String { + switch self { + case .ready: return "checkmark.circle.fill" + case .marginal: return "exclamationmark.triangle.fill" + case .blocked: return "xmark.octagon.fill" + } + } +} + +// MARK: - Helpers + +/// Map a recipe `icon` token to an SF Symbol (all system symbols, no assets). +/// Unknown/nil tokens fall back to the section glyph. +func bundleSymbol(_ icon: String?) -> String { + switch icon { + case "database": return "cylinder.split.1x2" + case "palette": return "paintpalette" + case "image": return "photo" + case "brain": return "brain" + case "clapperboard": return "film" + case "code": return "chevron.left.forwardslash.chevron.right" + default: return "square.stack.3d.up" + } +} + +/// "N formulae Β· M casks" summary for a bundle's package makeup (omits a zero +/// side; singular/plural aware). +func packageSummary(_ bundle: BrewBundle) -> String { + let formulae = bundle.packages.filter { $0.kind != "cask" }.count + let casks = bundle.packages.filter { $0.kind == "cask" }.count + var parts: [String] = [] + if formulae > 0 { parts.append("\(formulae) formula\(formulae == 1 ? "" : "e")") } + if casks > 0 { parts.append("\(casks) cask\(casks == 1 ? "" : "s")") } + return parts.joined(separator: " Β· ") +} diff --git a/native/Sources/BrewBrowserKit/ContentView.swift b/native/Sources/BrewBrowserKit/ContentView.swift index 63ebc21..be45a0c 100644 --- a/native/Sources/BrewBrowserKit/ContentView.swift +++ b/native/Sources/BrewBrowserKit/ContentView.swift @@ -265,6 +265,8 @@ public struct ContentView: View { SnapshotsView(model: model) case .services: ServicesView(model: model) + case .bundles: + BundlesView(model: model) } } } diff --git a/native/Sources/BrewBrowserKit/Resources/bundles.json b/native/Sources/BrewBrowserKit/Resources/bundles.json new file mode 100644 index 0000000..db1c072 --- /dev/null +++ b/native/Sources/BrewBrowserKit/Resources/bundles.json @@ -0,0 +1,287 @@ +{ + "schemaVersion": 1, + "bundles": [ + { + "id": "databases", + "name": "Local Databases", + "tagline": "PostgreSQL + Redis, managed as services", + "category": "Data", + "icon": "database", + "packages": [ + { + "name": "postgresql@16", + "kind": "formula" + }, + { + "name": "redis", + "kind": "formula" + } + ], + "requires": { + "minRamGB": 4, + "recommendedRamGB": 8, + "minDiskGB": 5, + "arch": "any", + "gpu": "none" + }, + "setup": [ + { + "kind": "service", + "service": "postgresql@16", + "label": "Start PostgreSQL" + }, + { + "kind": "service", + "service": "redis", + "label": "Start Redis" + } + ], + "caveats": "Databases grow with your data. Manage start/stop from the Services tab.", + "links": [ + { + "label": "PostgreSQL", + "url": "https://www.postgresql.org" + }, + { + "label": "Redis", + "url": "https://redis.io" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + }, + { + "id": "graphics", + "name": "Graphics & Design", + "tagline": "Vector, raster, and digital painting β€” Inkscape, GIMP, Krita", + "category": "Graphics", + "icon": "palette", + "packages": [ + { + "name": "inkscape", + "kind": "cask" + }, + { + "name": "gimp", + "kind": "cask" + }, + { + "name": "krita", + "kind": "cask" + } + ], + "caveats": "Three full desktop apps (~2–3 GB combined). Runs on any supported Mac.", + "links": [ + { + "label": "Inkscape", + "url": "https://inkscape.org" + }, + { + "label": "GIMP", + "url": "https://www.gimp.org" + }, + { + "label": "Krita", + "url": "https://krita.org" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + }, + { + "id": "image-gen", + "name": "Image Generation", + "tagline": "Local Stable Diffusion / Flux with ComfyUI", + "category": "AI", + "icon": "image", + "packages": [ + { + "name": "comfy", + "kind": "cask" + } + ], + "requires": { + "minRamGB": 16, + "recommendedRamGB": 24, + "minDiskGB": 30, + "arch": "apple-silicon", + "gpu": "required" + }, + "capabilityNotes": { + "16": "SDXL comfortably; Flux fp8 with offloading", + "24": "Flux fp8 comfortably", + "48": "Flux fp16" + }, + "setup": [ + { + "kind": "note", + "text": "Launch ComfyUI, then download a checkpoint model β€” it is NOT installed by brew (SDXL ~7 GB, Flux ~24 GB)." + }, + { + "kind": "command", + "run": "open -a ComfyUI", + "label": "Launch ComfyUI (you run this)", + "external": true + } + ], + "caveats": "Installs ComfyUI only; models are large downloads you fetch yourself. On Apple Silicon, generation is roughly 2–4Γ— slower per image than a comparable NVIDIA GPU.", + "links": [ + { + "label": "ComfyUI (GitHub)", + "url": "https://github.com/comfyanonymous/ComfyUI" + }, + { + "label": "comfy.org", + "url": "https://www.comfy.org" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + }, + { + "id": "local-llm", + "name": "Local LLMs", + "tagline": "Run models locally with Ollama + a web UI", + "category": "AI", + "icon": "brain", + "packages": [ + { + "name": "ollama", + "kind": "formula" + }, + { + "name": "open-webui", + "kind": "cask" + } + ], + "requires": { + "minRamGB": 8, + "recommendedRamGB": 16, + "minDiskGB": 12, + "arch": "any", + "gpu": "preferred" + }, + "capabilityNotes": { + "8": "Small (~7–8B Q4) models only; larger will swap", + "16": "13–14B models comfortably", + "24": "26–32B class (Qwen, Gemma)", + "48": "70B models" + }, + "setup": [ + { + "kind": "service", + "service": "ollama", + "label": "Start the Ollama server" + }, + { + "kind": "command", + "run": "ollama pull llama3.2", + "label": "Pull a starter model (you run this)", + "external": true + }, + { + "kind": "open", + "url": "http://localhost:8080", + "label": "Open Open WebUI" + } + ], + "caveats": "Installs the tools; you still download models yourself (several GB each). Apple Silicon unified memory counts as VRAM β€” more RAM = bigger models.", + "links": [ + { + "label": "Ollama docs", + "url": "https://docs.ollama.com" + }, + { + "label": "Open WebUI", + "url": "https://openwebui.com" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + }, + { + "id": "media", + "name": "Media Toolkit", + "tagline": "Transcode, download, and play β€” ffmpeg, yt-dlp, mpv", + "category": "Media", + "icon": "clapperboard", + "packages": [ + { + "name": "ffmpeg", + "kind": "formula" + }, + { + "name": "yt-dlp", + "kind": "formula" + }, + { + "name": "mpv", + "kind": "formula" + } + ], + "setup": [ + { + "kind": "command", + "run": "ffmpeg -version", + "label": "Verify ffmpeg (you run this)", + "external": true + } + ], + "caveats": "Command-line tools. Lightweight; runs on any supported Mac or Linux.", + "links": [ + { + "label": "FFmpeg", + "url": "https://ffmpeg.org" + }, + { + "label": "yt-dlp", + "url": "https://github.com/yt-dlp/yt-dlp" + }, + { + "label": "mpv", + "url": "https://mpv.io" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + }, + { + "id": "web-dev", + "name": "Web Dev Starter", + "tagline": "Node.js + pnpm β€” the modern JS toolchain", + "category": "Development", + "icon": "code", + "packages": [ + { + "name": "node", + "kind": "formula" + }, + { + "name": "pnpm", + "kind": "formula" + } + ], + "setup": [ + { + "kind": "command", + "run": "node -v && pnpm -v", + "label": "Verify the toolchain (you run this)", + "external": true + } + ], + "caveats": "Installs the runtimes; project dependencies are per-project (pnpm install).", + "links": [ + { + "label": "Node.js", + "url": "https://nodejs.org" + }, + { + "label": "pnpm", + "url": "https://pnpm.io" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + } + ] +} diff --git a/native/Tests/BrewBrowserKitTests/BrewOutputParsingTests.swift b/native/Tests/BrewBrowserKitTests/BrewOutputParsingTests.swift index 181c589..b248f4e 100644 --- a/native/Tests/BrewBrowserKitTests/BrewOutputParsingTests.swift +++ b/native/Tests/BrewBrowserKitTests/BrewOutputParsingTests.swift @@ -326,6 +326,47 @@ struct BrewArgsTests { #expect(BrewArgs.setPinned("google-chrome", kind: .cask, pinned: false) == ["unpin", "--cask", "google-chrome"]) } + + // MARK: - installBundle (M3 "Install all" arg groups) + + private func pkg(_ name: String, _ kind: String) -> BundlePackage { + BundlePackage(name: name, kind: kind) + } + + @Test func installBundleSplitsFormulaAndCaskGroups() { + // brew rejects `--formula … --cask …` in one invocation, so the builder + // emits two per-kind groups: formulae first (input order), then casks. + let groups = BrewArgs.installBundle([pkg("ollama", "formula"), pkg("open-webui", "cask")]) + #expect(groups == [ + ["install", "--formula", "ollama"], + ["install", "--cask", "open-webui"], + ]) + } + + @Test func installBundleFormulaeOnlyIsOneGroup() { + let groups = BrewArgs.installBundle([ + pkg("ffmpeg", "formula"), pkg("yt-dlp", "formula"), pkg("mpv", "formula"), + ]) + #expect(groups == [["install", "--formula", "ffmpeg", "yt-dlp", "mpv"]]) + } + + @Test func installBundleCasksOnlyIsOneGroup() { + let groups = BrewArgs.installBundle([ + pkg("inkscape", "cask"), pkg("gimp", "cask"), pkg("krita", "cask"), + ]) + #expect(groups == [["install", "--cask", "inkscape", "gimp", "krita"]]) + } + + @Test func installBundleEmptyYieldsNoGroups() { + #expect(BrewArgs.installBundle([]).isEmpty) + } + + @Test func installBundleUnknownKindTreatedAsFormula() { + // brew's default is a formula; an unexpected kind must not silently drop + // the package from the install set. + let groups = BrewArgs.installBundle([pkg("node", "unknown")]) + #expect(groups == [["install", "--formula", "node"]]) + } } @Suite("BrewRecovery") diff --git a/native/Tests/BrewBrowserKitTests/BundleParsingTests.swift b/native/Tests/BrewBrowserKitTests/BundleParsingTests.swift new file mode 100644 index 0000000..f843302 --- /dev/null +++ b/native/Tests/BrewBrowserKitTests/BundleParsingTests.swift @@ -0,0 +1,133 @@ +import Foundation +import Testing +@testable import BrewBrowserKit + +/// Parse tests for the M2 bundle loader. The real `bundles.json` (repo root, +/// generated by scripts/validate-recipes.mjs) is the primary fixture β€” both +/// shells must load the same six bundles. The lossy test proves one malformed +/// recipe is skipped without dropping the valid ones. +@Suite("BundleParsing") +struct BundleParsingTests { + + /// Resolve the repo-root `bundles.json` from this file's location: + /// native/Tests/BrewBrowserKitTests/BundleParsingTests.swift β†’ up 4 β†’ repo root. + private static func realBundlesData() throws -> Data { + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // BrewBrowserKitTests/ + .deletingLastPathComponent() // Tests/ + .deletingLastPathComponent() // native/ + .deletingLastPathComponent() // repo root + let url = repoRoot.appendingPathComponent("bundles.json") + guard let data = try? Data(contentsOf: url) else { + print("bundles.json not found at: \(url.path)") + throw CocoaError(.fileNoSuchFile) + } + return data + } + + @Test func parsesSixBundles() throws { + let bundles = BundleCatalog.parse(try Self.realBundlesData()) + #expect(bundles.count == 6, "expected 6 bundles, got \(bundles.count)") + } + + @Test func spotCheckLocalLLM() throws { + let bundles = BundleCatalog.parse(try Self.realBundlesData()) + let llm = try #require(bundles.first { $0.id == "local-llm" }, "local-llm bundle missing") + + // Two packages: ollama (formula) + open-webui (cask). + #expect(llm.packages.count == 2) + #expect(llm.packages.contains { $0.name == "ollama" && $0.kind == "formula" }) + #expect(llm.packages.contains { $0.name == "open-webui" && $0.kind == "cask" }) + + // Requires gate (reused M1 BundleRequires). + #expect(llm.requires?.minRamGB == 8) + + // At least one setup step of kind "service". + #expect(llm.setup?.contains { $0.kind == "service" } == true) + } + + /// One malformed recipe (missing the required `id`) must be dropped while the + /// two valid recipes still parse β€” the per-element `Throwable` lossy wrapper. + @Test func lossySkipsMalformed() throws { + let json = """ + { + "schemaVersion": 1, + "bundles": [ + { "id": "good-one", "name": "Good One", "tagline": "fine", "category": "Data", + "packages": [ { "name": "redis", "kind": "formula" } ] }, + { "name": "No ID", "tagline": "broken β€” missing id", "category": "Data", + "packages": [ { "name": "postgresql@16", "kind": "formula" } ] }, + { "id": "good-two", "name": "Good Two", "tagline": "also fine", "category": "Media", + "packages": [ { "name": "ffmpeg", "kind": "formula" } ] } + ] + } + """ + let bundles = BundleCatalog.parse(Data(json.utf8)) + #expect(bundles.count == 2, "malformed recipe should be skipped, got \(bundles.count)") + #expect(bundles.map(\.id).sorted() == ["good-one", "good-two"]) + } + + /// Unknown JSON keys are ignored (tolerant Codable) and a recipe with only + /// the required fields still parses. + @Test func toleratesUnknownKeysAndMinimalRecipe() throws { + let json = """ + { + "bundles": [ + { "id": "minimal", "name": "Minimal", "tagline": "required-only", "category": "Development", + "packages": [ { "name": "node", "kind": "formula" } ], + "futureField": { "nested": true }, "another": [1, 2, 3] } + ] + } + """ + let bundles = BundleCatalog.parse(Data(json.utf8)) + #expect(bundles.count == 1) + #expect(bundles.first?.requires == nil) + #expect(bundles.first?.setup == nil) + #expect(bundles.first?.id == "minimal") + } + + /// All five setup-step kinds decode, each populating the fields the M4 UI + /// reads. The renderer is hard to unit-test, but the decode contract is what + /// the checklist depends on. + @Test func decodesAllFiveSetupStepKinds() throws { + let json = """ + { + "bundles": [ + { "id": "all-kinds", "name": "All Kinds", "tagline": "every step kind", + "category": "Development", + "packages": [ { "name": "node", "kind": "formula" } ], + "setup": [ + { "kind": "service", "service": "ollama", "label": "Start Ollama" }, + { "kind": "open", "url": "http://localhost:8080", "label": "Open UI" }, + { "kind": "reveal", "path": "/tmp/models", "label": "Reveal models" }, + { "kind": "command", "run": "ollama pull llama3.2", "label": "Pull a model", "external": true }, + { "kind": "note", "text": "Models are large downloads." } + ] } + ] + } + """ + let bundles = BundleCatalog.parse(Data(json.utf8)) + let steps = try #require(bundles.first?.setup, "setup should decode") + #expect(steps.count == 5) + #expect(steps.map(\.kind) == ["service", "open", "reveal", "command", "note"]) + #expect(steps[0].service == "ollama") + #expect(steps[1].url == "http://localhost:8080") + #expect(steps[2].path == "/tmp/models") + #expect(steps[3].run == "ollama pull llama3.2") + #expect(steps[3].external == true) + #expect(steps[4].text == "Models are large downloads.") + } + + /// Contract guarantee the M4 UI relies on: every `command` step in a shipped + /// recipe is `external: true` (the app copies it, never runs it). Checked + /// against the REAL local-llm recipe (`ollama pull llama3.2`). + @Test func realLocalLLMCommandStepsAreExternal() throws { + let bundles = BundleCatalog.parse(try Self.realBundlesData()) + let llm = try #require(bundles.first { $0.id == "local-llm" }, "local-llm missing") + let commands = (llm.setup ?? []).filter { $0.kind == "command" } + #expect(!commands.isEmpty, "local-llm should have a command setup step") + for c in commands { + #expect(c.external == true, "command step '\(c.run ?? "")' must be external:true (contract)") + } + } +} diff --git a/src-tauri/data/bundles.json b/src-tauri/data/bundles.json new file mode 100644 index 0000000..db1c072 --- /dev/null +++ b/src-tauri/data/bundles.json @@ -0,0 +1,287 @@ +{ + "schemaVersion": 1, + "bundles": [ + { + "id": "databases", + "name": "Local Databases", + "tagline": "PostgreSQL + Redis, managed as services", + "category": "Data", + "icon": "database", + "packages": [ + { + "name": "postgresql@16", + "kind": "formula" + }, + { + "name": "redis", + "kind": "formula" + } + ], + "requires": { + "minRamGB": 4, + "recommendedRamGB": 8, + "minDiskGB": 5, + "arch": "any", + "gpu": "none" + }, + "setup": [ + { + "kind": "service", + "service": "postgresql@16", + "label": "Start PostgreSQL" + }, + { + "kind": "service", + "service": "redis", + "label": "Start Redis" + } + ], + "caveats": "Databases grow with your data. Manage start/stop from the Services tab.", + "links": [ + { + "label": "PostgreSQL", + "url": "https://www.postgresql.org" + }, + { + "label": "Redis", + "url": "https://redis.io" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + }, + { + "id": "graphics", + "name": "Graphics & Design", + "tagline": "Vector, raster, and digital painting β€” Inkscape, GIMP, Krita", + "category": "Graphics", + "icon": "palette", + "packages": [ + { + "name": "inkscape", + "kind": "cask" + }, + { + "name": "gimp", + "kind": "cask" + }, + { + "name": "krita", + "kind": "cask" + } + ], + "caveats": "Three full desktop apps (~2–3 GB combined). Runs on any supported Mac.", + "links": [ + { + "label": "Inkscape", + "url": "https://inkscape.org" + }, + { + "label": "GIMP", + "url": "https://www.gimp.org" + }, + { + "label": "Krita", + "url": "https://krita.org" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + }, + { + "id": "image-gen", + "name": "Image Generation", + "tagline": "Local Stable Diffusion / Flux with ComfyUI", + "category": "AI", + "icon": "image", + "packages": [ + { + "name": "comfy", + "kind": "cask" + } + ], + "requires": { + "minRamGB": 16, + "recommendedRamGB": 24, + "minDiskGB": 30, + "arch": "apple-silicon", + "gpu": "required" + }, + "capabilityNotes": { + "16": "SDXL comfortably; Flux fp8 with offloading", + "24": "Flux fp8 comfortably", + "48": "Flux fp16" + }, + "setup": [ + { + "kind": "note", + "text": "Launch ComfyUI, then download a checkpoint model β€” it is NOT installed by brew (SDXL ~7 GB, Flux ~24 GB)." + }, + { + "kind": "command", + "run": "open -a ComfyUI", + "label": "Launch ComfyUI (you run this)", + "external": true + } + ], + "caveats": "Installs ComfyUI only; models are large downloads you fetch yourself. On Apple Silicon, generation is roughly 2–4Γ— slower per image than a comparable NVIDIA GPU.", + "links": [ + { + "label": "ComfyUI (GitHub)", + "url": "https://github.com/comfyanonymous/ComfyUI" + }, + { + "label": "comfy.org", + "url": "https://www.comfy.org" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + }, + { + "id": "local-llm", + "name": "Local LLMs", + "tagline": "Run models locally with Ollama + a web UI", + "category": "AI", + "icon": "brain", + "packages": [ + { + "name": "ollama", + "kind": "formula" + }, + { + "name": "open-webui", + "kind": "cask" + } + ], + "requires": { + "minRamGB": 8, + "recommendedRamGB": 16, + "minDiskGB": 12, + "arch": "any", + "gpu": "preferred" + }, + "capabilityNotes": { + "8": "Small (~7–8B Q4) models only; larger will swap", + "16": "13–14B models comfortably", + "24": "26–32B class (Qwen, Gemma)", + "48": "70B models" + }, + "setup": [ + { + "kind": "service", + "service": "ollama", + "label": "Start the Ollama server" + }, + { + "kind": "command", + "run": "ollama pull llama3.2", + "label": "Pull a starter model (you run this)", + "external": true + }, + { + "kind": "open", + "url": "http://localhost:8080", + "label": "Open Open WebUI" + } + ], + "caveats": "Installs the tools; you still download models yourself (several GB each). Apple Silicon unified memory counts as VRAM β€” more RAM = bigger models.", + "links": [ + { + "label": "Ollama docs", + "url": "https://docs.ollama.com" + }, + { + "label": "Open WebUI", + "url": "https://openwebui.com" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + }, + { + "id": "media", + "name": "Media Toolkit", + "tagline": "Transcode, download, and play β€” ffmpeg, yt-dlp, mpv", + "category": "Media", + "icon": "clapperboard", + "packages": [ + { + "name": "ffmpeg", + "kind": "formula" + }, + { + "name": "yt-dlp", + "kind": "formula" + }, + { + "name": "mpv", + "kind": "formula" + } + ], + "setup": [ + { + "kind": "command", + "run": "ffmpeg -version", + "label": "Verify ffmpeg (you run this)", + "external": true + } + ], + "caveats": "Command-line tools. Lightweight; runs on any supported Mac or Linux.", + "links": [ + { + "label": "FFmpeg", + "url": "https://ffmpeg.org" + }, + { + "label": "yt-dlp", + "url": "https://github.com/yt-dlp/yt-dlp" + }, + { + "label": "mpv", + "url": "https://mpv.io" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + }, + { + "id": "web-dev", + "name": "Web Dev Starter", + "tagline": "Node.js + pnpm β€” the modern JS toolchain", + "category": "Development", + "icon": "code", + "packages": [ + { + "name": "node", + "kind": "formula" + }, + { + "name": "pnpm", + "kind": "formula" + } + ], + "setup": [ + { + "kind": "command", + "run": "node -v && pnpm -v", + "label": "Verify the toolchain (you run this)", + "external": true + } + ], + "caveats": "Installs the runtimes; project dependencies are per-project (pnpm install).", + "links": [ + { + "label": "Node.js", + "url": "https://nodejs.org" + }, + { + "label": "pnpm", + "url": "https://pnpm.io" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + } + ] +} diff --git a/src-tauri/src/commands/actions.rs b/src-tauri/src/commands/actions.rs index d01db5f..0a0f213 100644 --- a/src-tauri/src/commands/actions.rs +++ b/src-tauri/src/commands/actions.rs @@ -14,7 +14,7 @@ use crate::brew::exec::{run_brew_capture, run_brew_streaming}; use crate::commands::info::validate_package_name; use crate::error::BrewError; use crate::state::AppState; -use crate::types::{BrewStreamEvent, JobResult, PackageKind}; +use crate::types::{BrewStreamEvent, BundlePackage, JobResult, PackageKind}; // ---------- Pure argv builders ---------- // @@ -82,6 +82,45 @@ fn pin_args(name: &str, kind: PackageKind, pinned: bool) -> Vec { vec![verb.to_string(), kind_flag(kind).to_string(), name.to_string()] } +/// Build the `brew install` invocation(s) for a whole bundle. +/// +/// Homebrew's `--formula` / `--cask` flags each mean "treat ALL named +/// arguments as this kind" β€” they're mutually exclusive, so a single mixed +/// `brew install --formula A --cask B` is NOT accepted (verified: brew prints +/// usage). We therefore emit one grouped invocation per kind present: +/// - `["install", "--formula", , , ...]` +/// - `["install", "--cask", , , ...]` +/// Formulae first, casks second, each preserving the recipe's declared order. +/// A group is omitted when empty; an empty package list yields no steps. The +/// caller runs the steps sequentially, streaming each into Activity. +/// +/// Kind is the free-form string from the recipe; anything that isn't `"cask"` +/// is treated as a formula (the command validates kinds up-front, so in +/// practice only `formula`/`cask` reach here). +fn install_bundle_args(packages: &[BundlePackage]) -> Vec> { + let mut formulae: Vec = Vec::new(); + let mut casks: Vec = Vec::new(); + for p in packages { + match p.kind.as_str() { + "cask" => casks.push(p.name.clone()), + _ => formulae.push(p.name.clone()), + } + } + + let mut steps: Vec> = Vec::new(); + if !formulae.is_empty() { + let mut args = vec!["install".to_string(), "--formula".to_string()]; + args.extend(formulae); + steps.push(args); + } + if !casks.is_empty() { + let mut args = vec!["install".to_string(), "--cask".to_string()]; + args.extend(casks); + steps.push(args); + } + steps +} + /// User-facing command string for the Activity log, derived from the argv. fn display_for(args: &[String]) -> String { format!("brew {}", args.join(" ")) @@ -207,6 +246,81 @@ pub async fn brew_upgrade_many( result } +/// Install every package in a bundle (Bundles M3). Mirrors `brew_upgrade_many`: +/// validate every name, take the write lock once, stream into Activity, and +/// invalidate caches once brew has run. +/// +/// Because `--formula`/`--cask` can't be mixed in one invocation (see +/// `install_bundle_args`), a bundle with both kinds runs as two sequential +/// streamed steps under the SAME write lock β€” formulae first, then casks. If +/// the formula step fails (non-zero exit), the cask step is skipped and that +/// failing `JobResult` is returned. Each step emits its own `Started`β†’`Exit` +/// lifecycle on the shared channel, so the frontend surfaces one Activity job +/// per step. +/// +/// Empty list β†’ InvalidArgument. Every `kind` must be `formula` or `cask`; an +/// unknown kind is rejected rather than silently coerced. Names go through the +/// same allowlist regex as `brew_install` to block shell-metacharacter injection. +#[tauri::command] +pub async fn brew_install_bundle( + packages: Vec, + on_event: Channel, + state: State<'_, AppState>, +) -> Result { + if packages.is_empty() { + return Err(BrewError::InvalidArgument { + message: "brew_install_bundle requires at least one package".to_string(), + }); + } + for p in &packages { + validate_package_name(&p.name)?; + if p.kind != "formula" && p.kind != "cask" { + return Err(BrewError::InvalidArgument { + message: format!("package '{}' has unknown kind '{}'", p.name, p.kind), + }); + } + } + let path = state.require_brew_path().await?; + + let steps = install_bundle_args(&packages); + let jobs = state.jobs.clone(); + let lock = state.brew_write_lock.clone(); + + // Hold the write lock across BOTH steps so nothing interleaves between the + // formula and cask installs of a single bundle. + let _guard = lock.lock_owned().await; + + let mut ran_any = false; + let mut last: Option = None; + for args in steps { + let display = display_for(&args); + match run_brew_streaming(&path, args, display, on_event.clone(), jobs.clone()).await { + Ok(result) => { + ran_any = true; + let succeeded = result.success; + last = Some(result); + // A failed step (e.g. formulae) means we skip the rest. + if !succeeded { + break; + } + } + Err(e) => { + // Spawn failure. Reflect any partial state before propagating. + if ran_any { + state.invalidate_caches().await; + } + return Err(e); + } + } + } + + if ran_any { + state.invalidate_caches().await; + } + // `packages` is non-empty and every kind is valid, so at least one step ran. + Ok(last.expect("at least one install step ran for a non-empty bundle")) +} + #[tauri::command] pub async fn brew_update( on_event: Channel, @@ -460,6 +574,48 @@ mod tests { ); } + fn pkg(name: &str, kind: &str) -> BundlePackage { + BundlePackage { name: name.to_string(), kind: kind.to_string() } + } + + #[test] + fn install_bundle_mixed_kinds_split_into_two_grouped_steps() { + // local-llm: ollama (formula) + open-webui (cask). Interleaved flags + // don't work in brew, so we emit one grouped invocation per kind, + // formulae first. (Verified: `brew install --formula X --cask Y` + // prints usage β€” the flags mean "treat ALL args as this kind".) + assert_eq!( + install_bundle_args(&[pkg("ollama", "formula"), pkg("open-webui", "cask")]), + vec![ + svec(&["install", "--formula", "ollama"]), + svec(&["install", "--cask", "open-webui"]), + ] + ); + } + + #[test] + fn install_bundle_all_formulae_is_one_step() { + // media: ffmpeg + yt-dlp + mpv, all formulae β†’ a single invocation. + assert_eq!( + install_bundle_args(&[pkg("ffmpeg", "formula"), pkg("yt-dlp", "formula"), pkg("mpv", "formula")]), + vec![svec(&["install", "--formula", "ffmpeg", "yt-dlp", "mpv"])] + ); + } + + #[test] + fn install_bundle_all_casks_is_one_step() { + // graphics: inkscape + gimp + krita, all casks β†’ a single invocation. + assert_eq!( + install_bundle_args(&[pkg("inkscape", "cask"), pkg("gimp", "cask"), pkg("krita", "cask")]), + vec![svec(&["install", "--cask", "inkscape", "gimp", "krita"])] + ); + } + + #[test] + fn install_bundle_empty_has_no_steps() { + assert!(install_bundle_args(&[]).is_empty()); + } + #[test] fn unpin_uses_unpin_verb() { assert_eq!( diff --git a/src-tauri/src/commands/bundles.rs b/src-tauri/src/commands/bundles.rs new file mode 100644 index 0000000..39c37b2 --- /dev/null +++ b/src-tauri/src/commands/bundles.rs @@ -0,0 +1,133 @@ +//! Bundles command. Returns the curated bundle recipes from the generated +//! `bundles.json`, embedded into the binary at compile time via `include_str!` +//! β€” the same mechanism the bundled `categories.json` uses (`commands::categories`). +//! No runtime file dependency, no disk read on every call. +//! +//! The `bundles.json` artifact is produced by `scripts/validate-recipes.mjs` +//! (concatenates the validated `recipes/*.json`). This module reads the copy +//! staged at `src-tauri/data/bundles.json`. +//! +//! Tolerant decode: the file is parsed as a JSON value first, then each entry +//! in `bundles[]` is deserialized independently. A single malformed recipe is +//! skipped (logged), never fatal β€” so one bad live-refreshed recipe can't take +//! down the whole list. + +use crate::types::{Bundle, BundlesFile}; + +const BUNDLES_JSON: &str = include_str!("../../data/bundles.json"); + +/// Parse a `bundles.json` string into the valid bundles, skipping any single +/// malformed entry. Split out from the command so it's unit-testable without +/// a Tauri runtime. +/// +/// Fast path: strict-parse the whole file into [`BundlesFile`] β€” the common +/// case where every recipe is valid. Only if that fails (a single malformed +/// recipe fails the whole `Vec` decode) do we fall back to a per-entry +/// tolerant parse that salvages the good recipes and skips (logs) the bad one. +/// A completely unparseable file yields an empty list. +fn parse_bundles(json: &str) -> Vec { + if let Ok(file) = serde_json::from_str::(json) { + return file.bundles; + } + + // Tolerant fallback: one bad recipe must not sink the batch. + let root: serde_json::Value = match serde_json::from_str(json) { + Ok(v) => v, + Err(e) => { + tracing::warn!("bundles.json is not valid JSON: {e}"); + return Vec::new(); + } + }; + + let Some(entries) = root.get("bundles").and_then(|b| b.as_array()) else { + tracing::warn!("bundles.json has no `bundles` array"); + return Vec::new(); + }; + + entries + .iter() + .filter_map(|entry| match serde_json::from_value::(entry.clone()) { + Ok(bundle) => Some(bundle), + Err(e) => { + let id = entry.get("id").and_then(|v| v.as_str()).unwrap_or(""); + tracing::warn!("skipping malformed bundle '{id}': {e}"); + None + } + }) + .collect() +} + +/// Return the curated bundles bundled with the app. Infallible β€” a parse +/// problem degrades to fewer (or zero) bundles rather than erroring the call. +#[tauri::command] +pub async fn bundles() -> Vec { + parse_bundles(BUNDLES_JSON) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_bundles_parse() { + let list = parse_bundles(BUNDLES_JSON); + assert_eq!(list.len(), 6, "expected 6 first-party bundles"); + + // Spot-check local-llm: 2 packages, requires.minRamGB == 8, and a + // service setup step for ollama. + let llm = list + .iter() + .find(|b| b.id == "local-llm") + .expect("local-llm bundle must be present"); + assert_eq!(llm.packages.len(), 2, "local-llm has 2 packages"); + assert_eq!(llm.packages[0].name, "ollama"); + assert_eq!(llm.packages[0].kind, "formula"); + let requires = llm.requires.as_ref().expect("local-llm declares requires"); + assert_eq!(requires.min_ram_gb, 8); + assert!( + llm.setup.iter().any(|s| s.kind == "service" && s.service.as_deref() == Some("ollama")), + "local-llm has a service setup step for ollama" + ); + } + + #[test] + fn malformed_bundle_is_skipped_not_fatal() { + // Two valid recipes surrounding one that can't deserialize (packages + // is the wrong shape). The batch must yield the two good ones. + let json = r#"{ + "schemaVersion": 1, + "bundles": [ + { "id": "good-one", "name": "Good One", "tagline": "t", "category": "Data", "packages": [{ "name": "redis", "kind": "formula" }] }, + { "id": "bad", "name": "Bad", "packages": "not-an-array" }, + { "id": "good-two", "name": "Good Two", "tagline": "t", "category": "Media", "packages": [{ "name": "mpv", "kind": "formula" }] } + ] + }"#; + let list = parse_bundles(json); + assert_eq!(list.len(), 2, "the malformed middle recipe is skipped"); + assert_eq!(list[0].id, "good-one"); + assert_eq!(list[1].id, "good-two"); + } + + #[test] + fn unknown_fields_are_ignored() { + // Forward-compat: a recipe from a newer schema (extra top-level key) + // still parses; the unknown field is dropped. + let json = r#"{ + "schemaVersion": 2, + "bundles": [ + { "id": "fut", "name": "Future", "tagline": "t", "category": "AI", + "packages": [{ "name": "ollama", "kind": "formula" }], + "somethingNew": { "nested": true } } + ] + }"#; + let list = parse_bundles(json); + assert_eq!(list.len(), 1); + assert_eq!(list[0].id, "fut"); + } + + #[test] + fn empty_on_garbage_input() { + assert!(parse_bundles("not json at all").is_empty()); + assert!(parse_bundles(r#"{ "schemaVersion": 1 }"#).is_empty()); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 49f69c0..e6e72ec 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod actions; pub mod brew_env; pub mod brewfile; +pub mod bundles; pub mod cask_icon; pub mod cask_icon_homepage; pub mod catalog; @@ -27,6 +28,7 @@ pub mod vulns; pub use actions::*; pub use brew_env::*; pub use brewfile::*; +pub use bundles::*; pub use cask_icon::*; pub use cask_icon_homepage::*; pub use catalog::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4528916..8dcc68b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -244,6 +244,8 @@ pub fn run() { vulns_install_helper, vulns_invalidate, system_profile, + bundles, + brew_install_bundle, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 5bd0844..9a700a1 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -508,6 +508,112 @@ pub struct TrendingHistoryIndexEntry { pub sparkline: Vec, } +// ---------- Bundles (0.7.0 curated recipes) ---------- +// +// Mirrors `recipes/recipe.schema.json` and the generated `bundles.json`. +// Every field carries `#[serde(default)]` so the decode is tolerant and +// forward-compatible: a recipe authored against a newer schema (extra +// fields, or omitting an optional one) still parses on an older binary β€” +// unknown fields are ignored, missing ones fall back to their default. +// +// The TS mirror lives in `src/lib/types.ts`. + +/// Host requirements a bundle declares. `BundleReadiness`/`readiness()` +/// (M1, client-side) gates install against the probed `SystemProfile`. +/// Mirrors the TS `BundleRequires` added in M1. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct BundleRequires { + // Explicit renames: camelCase would yield `minRamGb`, but the contract + // (and the M1 TS type) keep the upper-case "GB". + #[serde(rename = "minRamGB")] + pub min_ram_gb: u32, + #[serde(rename = "recommendedRamGB")] + pub recommended_ram_gb: u32, + #[serde(rename = "minDiskGB")] + pub min_disk_gb: u32, + /// `"any" | "apple-silicon" | "intel" | "linux"`. + pub arch: String, + /// `"none" | "preferred" | "required"`. + pub gpu: String, +} + +impl Default for BundleRequires { + fn default() -> Self { + BundleRequires { + min_ram_gb: 0, + recommended_ram_gb: 0, + min_disk_gb: 0, + arch: "any".to_string(), + gpu: "none".to_string(), + } + } +} + +/// One package in a bundle. `kind` is `"formula" | "cask"` as a plain string +/// (not the `PackageKind` enum) so an unexpected value in a live-refreshed +/// recipe degrades gracefully instead of failing the whole decode. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct BundlePackage { + pub name: String, + pub kind: String, +} + +/// A post-install setup step. The relevant fields depend on `kind` +/// (`service`/`open`/`reveal`/`command`/`note`); all are optional so a step +/// only carries what it needs. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct SetupStep { + pub kind: String, + pub service: Option, + pub label: Option, + pub url: Option, + pub path: Option, + pub run: Option, + pub external: Option, + pub text: Option, +} + +/// An external reference link (docs, homepage, source). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct BundleLink { + pub label: String, + pub url: String, +} + +/// A curated bundle recipe. See `recipes/recipe.schema.json` for the authoring +/// contract; `capability_notes` maps a RAM-tier (integer-as-string key) to a +/// human note the readiness gate surfaces. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Bundle { + pub id: String, + pub name: String, + pub tagline: String, + pub category: String, + pub icon: Option, + pub packages: Vec, + pub tap: Option, + pub requires: Option, + pub capability_notes: std::collections::HashMap, + pub setup: Vec, + pub caveats: Option, + pub links: Vec, + pub maintainer: Option, + pub added_in: Option, +} + +/// Root shape of the bundled/generated `bundles.json`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct BundlesFile { + pub schema_version: u32, + pub bundles: Vec, +} + // ---------- Tests ---------- #[cfg(test)] diff --git a/src/lib/api.ts b/src/lib/api.ts index 8e97384..d6851b8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -19,6 +19,8 @@ import { invoke, Channel } from "@tauri-apps/api/core"; import type { BrewEnvironment, Brewfile, + Bundle, + BundlePackage, BrewfileCheckReport, BrewfileId, BrewfileSummary, @@ -936,6 +938,34 @@ export function systemProfile(): Promise { return invoke("system_profile"); } +/** + * Bundles M2 β€” load the curated bundle recipes embedded in the app. A single + * malformed recipe is skipped backend-side, so this always resolves with the + * valid ones (never throws for a bad recipe). Readiness is computed + * client-side via `$lib/util/readiness` against `systemProfile()`. + */ +export function bundles(): Promise { + return invoke("bundles"); +} + +/** + * Bundles M3 β€” install every package in a bundle. Mirrors `brewUpgradeMany`: + * one streamed job (into Activity) that installs the set, then the caller + * reloads `packages`. Because brew's `--formula`/`--cask` flags can't be mixed + * in one invocation, a bundle with both kinds streams as two sequential steps + * (formulae, then casks) β€” each emits its own `started`β†’`exit` lifecycle on + * this channel, so the handler must cope with more than one `jobId`. + */ +export function brewInstallBundle( + packages: BundlePackage[], + onEvent: (evt: BrewStreamEvent) => void, +): Promise { + return invoke("brew_install_bundle", { + packages, + onEvent: makeChannel(onEvent), + }); +} + // ============================================================ // Re-exports for convenience // ============================================================ diff --git a/src/lib/components/BundleDetail.svelte b/src/lib/components/BundleDetail.svelte new file mode 100644 index 0000000..cd33cc0 --- /dev/null +++ b/src/lib/components/BundleDetail.svelte @@ -0,0 +1,512 @@ + + + +
+
+ + {readiness.reason} +
+ +

{bundle.tagline}

+ +
+

Packages

+
    + {#each bundle.packages as p (p.name + p.kind)} + {@const st = stateOf(p)} +
  • + {p.name} + {p.kind} + {STATE_LABEL[st]} +
  • + {/each} +
+
+ + {#if bundle.caveats} +
+ +

{bundle.caveats}

+
+ {/if} + + {#if bundle.setup.length > 0} +
+

Setup

+

+ Brew-native steps run in the app. Commands marked + you run this are yours to copy and run in a terminal β€” the + app never executes them for you. +

+
    + {#each bundle.setup as step, i (i)} + {@const mode = stepMode(step.kind)} +
  1. + + + {#if step.kind === "service"} + {@const installed = serviceInstalled(step.service ?? "")} + {@const running = serviceRunning(step.service ?? "")} + + {stepLabel(step)} + {#if running}running{/if} + + + + {:else if step.kind === "open"} + {stepLabel(step)} + + + {:else if step.kind === "reveal"} + {stepLabel(step)} + + + {:else if step.kind === "command"} + + {#if step.label}{step.label}{/if} + + {step.run} + you run this + + + + + {:else if mode === "note"} + + + {step.text ?? ""} + + {/if} +
  2. + {/each} +
+
+ {/if} + + {#if bundle.links.length > 0} +
+

Links

+ +
+ {/if} +
+ + {#snippet actions()} + + + {/snippet} +
+ + (confirmOpen = false)} +> +

{readiness.reason}

+

Your machine may not run {bundle.name} well. You can install it anyway.

+
+ + diff --git a/src/lib/components/Bundles.svelte b/src/lib/components/Bundles.svelte new file mode 100644 index 0000000..1be8cf3 --- /dev/null +++ b/src/lib/components/Bundles.svelte @@ -0,0 +1,181 @@ + + +
+
+

Bundles

+

+ Curated sets of tools that work together β€” installed in one click, with a + readiness check for your machine. +

+
+ + {#if bundles.loading && bundles.list.length === 0} +

Loading bundles…

+ {:else if bundles.error} +

{bundles.error}

+ {:else if bundles.list.length === 0} +

No bundles available.

+ {:else} +
+ {#each bundles.list as b (b.id)} + {@const Icon = iconFor(b.icon)} + {@const r = bundles.readinessFor(b)} + + {/each} +
+ {/if} +
+ +{#if selected} + (selected = null)} /> +{/if} + + diff --git a/src/lib/components/ReadinessPill.svelte b/src/lib/components/ReadinessPill.svelte new file mode 100644 index 0000000..f6034dd --- /dev/null +++ b/src/lib/components/ReadinessPill.svelte @@ -0,0 +1,23 @@ + + + + {LABEL[verdict]} + diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index a13d239..af56424 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -5,6 +5,7 @@ import Archive from "@lucide/svelte/icons/archive"; import Activity from "@lucide/svelte/icons/activity"; import Server from "@lucide/svelte/icons/server"; + import PackagePlus from "@lucide/svelte/icons/package-plus"; import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard"; import SearchIcon from "@lucide/svelte/icons/search"; import XIcon from "@lucide/svelte/icons/x"; @@ -41,6 +42,7 @@ { id: "snapshots", label: "Snapshots", shortcut: "⌘4", icon: Archive }, { id: "services", label: "Services", shortcut: "⌘5", icon: Server }, { id: "activity", label: "Activity", shortcut: "⌘6", icon: Activity }, + { id: "bundles", label: "Bundles", shortcut: "⌘7", icon: PackagePlus }, ]; // ───────── Sidebar type-ahead search ───────── diff --git a/src/lib/stores/bundles.svelte.ts b/src/lib/stores/bundles.svelte.ts new file mode 100644 index 0000000..f991633 --- /dev/null +++ b/src/lib/stores/bundles.svelte.ts @@ -0,0 +1,86 @@ +/** + * Bundles store β€” lazy-loads the curated bundle recipes via the `bundles` + * Tauri command (backed by the embedded `bundles.json`), holds them, and pairs + * each with the M1 `SystemProfile` to compute capability readiness client-side. + * + * Singleton: import `bundles` from this module everywhere; the store fetches + * bundles + profile once per process and caches the result. + */ + +import { bundles as fetchBundles, systemProfile } from "$lib/api"; +import type { Bundle, Readiness, SystemProfile } from "$lib/types"; +import { readiness } from "$lib/util/readiness"; + +/** localStorage key that overrides the probed RAM so Marginal/Blocked states + * are reachable on a big dev box (e.g. the 128 GB Mac). Set e.g. + * `localStorage["brewbrowser.fakeRamGB"] = "8"` and reload. Mirrors the Rust + * `BREWBROWSER_FAKE_RAM_GB` env override. */ +const FAKE_RAM_KEY = "brewbrowser.fakeRamGB"; + +class BundlesStore { + list: Bundle[] = $state([]); + profile: SystemProfile | null = $state(null); + loading: boolean = $state(false); + error: string | null = $state(null); + loaded: boolean = $state(false); + + private loadPromise: Promise | null = null; + + /** + * Load bundles + the system profile. Idempotent: concurrent or repeat calls + * share the same in-flight fetch and skip re-fetching once loaded (pass + * `force` to refetch). Never throws β€” a load failure lands in `error`. + */ + async load(force = false): Promise { + if (this.loaded && !force) return; + if (this.loadPromise) return this.loadPromise; + + this.loading = true; + this.error = null; + this.loadPromise = (async () => { + try { + const [list, profile] = await Promise.all([fetchBundles(), systemProfile()]); + this.list = list; + this.profile = this.applyRamOverride(profile); + this.loaded = true; + } catch (e) { + this.error = `Failed to load bundles: ${String(e)}`; + } finally { + this.loading = false; + this.loadPromise = null; + } + })(); + return this.loadPromise; + } + + /** Apply the debug RAM override to a freshly-probed profile, if set + valid. */ + private applyRamOverride(profile: SystemProfile): SystemProfile { + try { + const raw = localStorage.getItem(FAKE_RAM_KEY); + if (raw === null) return profile; + const fake = Number.parseInt(raw, 10); + if (Number.isNaN(fake)) return profile; + return { ...profile, ramGB: fake }; + } catch { + // localStorage unavailable (SSR/prerender) β€” no override. + return profile; + } + } + + /** + * Capability readiness of a bundle against the probed profile. Falls back to + * a permissive "ready" verdict until the profile has loaded (the UI shows + * pills only once loaded, but this keeps the function total). + */ + readinessFor(bundle: Bundle): Readiness { + if (!this.profile) return { verdict: "ready", reason: "Ready." }; + return readiness(bundle.requires ?? null, bundle.capabilityNotes ?? null, this.profile); + } + + /** Look up a single bundle by id. Returns undefined if not loaded/absent. */ + byId(id: string): Bundle | undefined { + return this.list.find((b) => b.id === id); + } +} + +export const bundles = new BundlesStore(); diff --git a/src/lib/stores/ui.svelte.ts b/src/lib/stores/ui.svelte.ts index b1d8662..dcb58e5 100644 --- a/src/lib/stores/ui.svelte.ts +++ b/src/lib/stores/ui.svelte.ts @@ -75,6 +75,7 @@ const SECTION_TITLES: Record = { snapshots: "Snapshots", services: "Services", activity: "Activity", + bundles: "Bundles", }; class UiStore { diff --git a/src/lib/types.ts b/src/lib/types.ts index 687f590..8521547 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1011,7 +1011,8 @@ export type SidebarSection = | "trending" | "snapshots" | "services" - | "activity"; + | "activity" + | "bundles"; export type ThemePreference = "light" | "dark" | "system"; @@ -1116,6 +1117,59 @@ export interface Readiness { reason: string; } +// ========================================================= +// Bundles M2 β€” curated recipe contract +// ========================================================= + +/** One package inside a bundle. `kind` is a plain string (not `PackageKind`) + * so an unexpected value in a live-refreshed recipe degrades gracefully. + * Mirrors the Rust `BundlePackage`. */ +export interface BundlePackage { + name: string; + kind: string; +} + +/** A post-install setup step. Which fields apply depends on `kind` + * (`service` | `open` | `reveal` | `command` | `note`); all but `kind` are + * optional. `command` steps always carry `external: true` β€” the app never + * auto-runs a shell. Mirrors the Rust `SetupStep`. */ +export interface SetupStep { + kind: string; + service?: string; + label?: string; + url?: string; + path?: string; + run?: string; + external?: boolean; + text?: string; +} + +/** An external reference link (docs, homepage, source). */ +export interface BundleLink { + label: string; + url: string; +} + +/** A curated bundle recipe. See `recipes/recipe.schema.json` for the authoring + * contract. `capabilityNotes` maps a RAM-tier (integer-as-string key) to a + * human note the readiness gate surfaces. Mirrors the Rust `Bundle`. */ +export interface Bundle { + id: string; + name: string; + tagline: string; + category: string; + icon?: string | null; + packages: BundlePackage[]; + tap?: string | null; + requires?: BundleRequires | null; + capabilityNotes: Record; + setup: SetupStep[]; + caveats?: string | null; + links: BundleLink[]; + maintainer?: string | null; + addedIn?: string | null; +} + /** Command-palette item β€” either a verb (action) or a package. */ export type PaletteItem = | { kind: "command"; id: string; label: string; shortcut?: string; section?: string; run: () => void | Promise } diff --git a/src/lib/util/setupStep.test.ts b/src/lib/util/setupStep.test.ts new file mode 100644 index 0000000..c0f6509 --- /dev/null +++ b/src/lib/util/setupStep.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { stepMode } from "./setupStep"; + +describe("stepMode", () => { + it("maps kinds to render modes (command is copy-only, never an action)", () => { + expect(stepMode("service")).toBe("action"); + expect(stepMode("open")).toBe("action"); + expect(stepMode("reveal")).toBe("action"); + expect(stepMode("command")).toBe("copy"); + expect(stepMode("note")).toBe("note"); + // Unknown/forward-compat kinds render as inert text, never an action. + expect(stepMode("something-new")).toBe("note"); + }); +}); diff --git a/src/lib/util/setupStep.ts b/src/lib/util/setupStep.ts new file mode 100644 index 0000000..54080c7 --- /dev/null +++ b/src/lib/util/setupStep.ts @@ -0,0 +1,27 @@ +/** + * Bundles M4 β€” setup-step rendering mode. + * + * Maps a `SetupStep.kind` to how the bundle detail renders it: + * - "action": a brew-native step with an in-app button (service/open/reveal). + * - "copy": an external `command` β€” copy-to-clipboard ONLY, never executed + * (the recipe contract requires `external: true`; the app must + * never run arbitrary shell). + * - "note": informational text. + * + * Unknown kinds fall back to "note" (rendered as inert text) so a + * forward-compat recipe never grows an unexpected action button. + */ +export type StepMode = "action" | "copy" | "note"; + +export function stepMode(kind: string): StepMode { + switch (kind) { + case "service": + case "open": + case "reveal": + return "action"; + case "command": + return "copy"; + default: + return "note"; + } +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index ac39cac..eab2842 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -9,6 +9,7 @@ import Snapshots from "$lib/components/Snapshots.svelte"; import Services from "$lib/components/Services.svelte"; import ActivityHistory from "$lib/components/ActivityHistory.svelte"; + import Bundles from "$lib/components/Bundles.svelte"; import OnboardingView from "$lib/components/OnboardingView.svelte"; import PackageDetail from "$lib/components/PackageDetail.svelte"; import ResizeHandle from "$lib/components/ResizeHandle.svelte"; @@ -79,8 +80,8 @@ return; } - // Cmd+0..6: section nav (0 = dashboard / home) - if (meta && ["0","1","2","3","4","5","6"].includes(e.key)) { + // Cmd+0..7: section nav (0 = dashboard / home) + if (meta && ["0","1","2","3","4","5","6","7"].includes(e.key)) { e.preventDefault(); const map: Record = { "0": "dashboard", @@ -90,6 +91,7 @@ "4": "snapshots", "5": "services", "6": "activity", + "7": "bundles", }; ui.setSection(map[e.key]); return; @@ -213,6 +215,8 @@ {:else if ui.section === "activity"} + {:else if ui.section === "bundles"} + {/if} {/key} From 3bc6275b56f2b7de4bea10aaec32b27c85fecad3 Mon Sep 17 00:00:00 2001 From: Michael Sitarzewski Date: Sun, 12 Jul 2026 20:00:59 -0500 Subject: [PATCH 5/9] =?UTF-8?q?feat(bundles):=20M5=20pipeline=20=E2=80=94?= =?UTF-8?q?=20recipe=20CI=20+=20contributor=20guide=20+=203-copy=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .github/workflows/validate-recipes.yml: runs the validator (--strict-links, brew resolution) on every PR touching recipes/, and diff-checks all three bundles.json copies are in sync. - CONTRIBUTING-bundles.md: the contributor onramp (copy a recipe, validate locally, PR β€” CI decides mergeability, no app code change). - validate-recipes.mjs now writes all three bundles.json copies (root + each app's bundled copy) deterministically so they can't drift. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01X9iMFjHE21ePTjcbHpTXt6 --- .github/workflows/validate-recipes.yml | 41 ++++++++++++++++++++++++++ CONTRIBUTING-bundles.md | 34 +++++++++++++++++++++ scripts/validate-recipes.mjs | 12 ++++++-- 3 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/validate-recipes.yml create mode 100644 CONTRIBUTING-bundles.md diff --git a/.github/workflows/validate-recipes.yml b/.github/workflows/validate-recipes.yml new file mode 100644 index 0000000..db0c974 --- /dev/null +++ b/.github/workflows/validate-recipes.yml @@ -0,0 +1,41 @@ +# Bundle recipe validation β€” enforces the recipe contract on every PR that +# touches recipes/. A contributor adds recipes/.json and this decides if +# it's mergeable, with no maintainer code change for the common case. +# +# Runs on macOS because the validator resolves every package with `brew info` +# (macOS runners ship Homebrew), and checks tap-trust + link reachability. +# See memory-bank/releases/0.7.0/bundles/recipe-contract.md for the contract +# and .../m5-live-refresh-and-contributions.md for the pipeline. + +name: Validate Recipes + +on: + pull_request: + paths: + - "recipes/**" + - "scripts/validate-recipes.mjs" + - ".github/workflows/validate-recipes.yml" + push: + branches: [main] + paths: + - "recipes/**" + - "scripts/validate-recipes.mjs" + +jobs: + validate: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: brew --version (Homebrew is preinstalled on macOS runners) + run: brew --version + - name: Validate recipes (structure + brew resolution + strict links) + run: node scripts/validate-recipes.mjs --strict-links + - name: Confirm all bundles.json copies are in sync with recipes + run: | + git diff --exit-code bundles.json src-tauri/data/bundles.json native/Sources/BrewBrowserKit/Resources/bundles.json || { + echo "::error::A bundles.json copy is stale β€” run 'node scripts/validate-recipes.mjs' and commit the result (it writes all three copies)."; + exit 1; + } diff --git a/CONTRIBUTING-bundles.md b/CONTRIBUTING-bundles.md new file mode 100644 index 0000000..4ede3d7 --- /dev/null +++ b/CONTRIBUTING-bundles.md @@ -0,0 +1,34 @@ +# Contributing a Bundle recipe + +A **bundle** is a curated one-click stack (packages) with post-install guidance, capability-gated so it's only recommended on machines that can run it. Recipes are plain JSON files anyone can add via PR β€” no app code change needed for a valid recipe. + +## Add one in 4 steps +1. **Copy** an existing recipe as a starting point: + ``` + cp recipes/media.json recipes/my-bundle.json + ``` +2. **Edit** the fields (see the full contract: [`memory-bank/releases/0.7.0/bundles/recipe-contract.md`](memory-bank/releases/0.7.0/bundles/recipe-contract.md)). The essentials: + - `id` β€” kebab-case, unique, **stable** (it's the persistence key). + - `name`, `tagline`, `category` (`AI` | `Graphics` | `Media` | `Development` | `Data` | `Productivity`). + - `packages` β€” each `{ "name": "", "kind": "formula" | "cask" }`. **Must resolve via `brew info`.** If any package is in a third-party tap (not `homebrew/core`/`homebrew/cask`), set the top-level `"tap": "user/repo"`. + - `requires` (optional) β€” `minRamGB` / `recommendedRamGB` / `minDiskGB` / `arch` / `gpu`. Omit for "runs anywhere". See [`capability-baselines.md`](memory-bank/releases/0.7.0/bundles/capability-baselines.md) for how to pick numbers. + - `setup` (optional) β€” post-install steps. `service` / `open` / `reveal` run in-app; **`command` steps MUST set `"external": true`** and are copy-only (the app never runs arbitrary shell). + - `links` β€” further reading, **https only**. +3. **Validate locally** (needs Homebrew + Node 20+): + ``` + node scripts/validate-recipes.mjs # structure + brew resolution + links + node scripts/validate-recipes.mjs --strict-links # what CI runs + ``` + This also regenerates `bundles.json`. **Commit `bundles.json` along with your recipe.** +4. **Open a PR.** The `Validate Recipes` CI check runs the same validator and posts a pass/fail table. Green = mergeable. + +## The rules CI enforces +- Schema-valid; `id` unique + kebab-case. +- Every package resolves via `brew info` to its declared `kind`; third-party taps declared. +- `requires`: `minRamGB ≀ recommendedRamGB`, positive integers, valid `arch`/`gpu`. +- `command` setup steps are `external: true`; `open` urls are http(s). +- `links` are https and reachable (a real 4xx/5xx fails; a bot-block/TLS throw is a warning). +- `bundles.json` matches the recipes (regenerate + commit). + +## Scope +Bundles are **brew-installable stacks with guidance**. A recipe may not auto-run external commands, and a stack whose value is entirely non-brew won't be accepted. If part of the setup is external (a model download, a `docker run`), express it as a copy-only `command` step or a `note`. diff --git a/scripts/validate-recipes.mjs b/scripts/validate-recipes.mjs index e09033e..af80d36 100644 --- a/scripts/validate-recipes.mjs +++ b/scripts/validate-recipes.mjs @@ -137,5 +137,13 @@ if (failed) { console.error(`\n${failed} recipe(s) FAILED the contract.`); proce // CI "up to date" check (git diff) is meaningful and re-runs don't churn git. // Provenance lives per-recipe in `addedIn`. Bundles sorted by id for stable diffs. const bundles = { schemaVersion: 1, bundles: passing.sort((a, b) => a.id.localeCompare(b.id)) }; -writeFileSync(OUT, JSON.stringify(bundles, null, 2) + "\n"); -console.log(`\nβœ“ wrote ${OUT} (${passing.length} bundles).`); +const json = JSON.stringify(bundles, null, 2) + "\n"; +// Write the canonical artifact AND both app-bundled copies so they never drift. +// Each shell embeds its own copy at build time (Tauri include_str!, native Bundle.module). +const TARGETS = [ + OUT, + join(ROOT, "src-tauri", "data", "bundles.json"), + join(ROOT, "native", "Sources", "BrewBrowserKit", "Resources", "bundles.json"), +]; +for (const t of TARGETS) writeFileSync(t, json); +console.log(`\nβœ“ wrote ${passing.length} bundles to:\n ${TARGETS.map((t) => t.replace(ROOT + "/", "")).join("\n ")}`); From 973f20d9bf7efe363c539cd44a607da60a9c3ebf Mon Sep 17 00:00:00 2001 From: Michael Sitarzewski Date: Sun, 12 Jul 2026 20:09:43 -0500 Subject: [PATCH 6/9] =?UTF-8?q?docs(bundles):=20record=20M1=E2=80=93M4=20+?= =?UTF-8?q?=20pipeline=20built;=20live-refresh=20client=20deferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01X9iMFjHE21ePTjcbHpTXt6 --- memory-bank/releases/0.7.0/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/memory-bank/releases/0.7.0/README.md b/memory-bank/releases/0.7.0/README.md index aafc2e9..5486cf1 100644 --- a/memory-bank/releases/0.7.0/README.md +++ b/memory-bank/releases/0.7.0/README.md @@ -54,7 +54,8 @@ Initial 6 recipes (tokens/taps verified 2026-07-12, all official taps): Local LL ## Release checklist (all gated to the user) - [x] Merge **#110** (GHSA references) into `main`. βœ… 2026-07-12 (main `6799e98`). - [x] Decide: **Bundles rides THIS release** (0.7.0/0.3.0), plan complete β†’ `bundles/`. βœ… 2026-07-12. -- [ ] Build Bundles: **M1** capability engine β†’ **M2** contract+loader β†’ **M3** browse+install β†’ **M4** setup guidance β†’ **M5** live-refresh+contributions. Each its own branch, both shells green. +- [x] Build Bundles **M1–M4 + M5 pipeline** (branch `feat/bundles`, both shells, 2026-07-13): capability engine, recipe contract + 6 recipes + validator, browse/install UI, setup guidance, CI + CONTRIBUTING. Gate: Tauri cargo 681 / svelte 0 / vitest 53 Β· Native 187 tests Β· recipes 6/6. Both apps launch clean with the Bundles section; no unresolved TODOs in new code. +- [ ] **M5 live-refresh CLIENT β€” DEFERRED** (opt-in host fetch of `bundles.json`). Build alongside the host `/bundles/bundles.json` endpoint so it's end-to-end verifiable (mirrors how live-enrichment was staged). Contribution pipeline (CI/validator/CONTRIBUTING) is done; only the runtime live-fetch is deferred. - [ ] Version bumps: `package.json` / `Cargo.toml` / `tauri.conf.json` β†’ `0.7.0`; native `build-app.sh` CFBundleShortVersionString β†’ `0.3.0`. Docs (README, BUILD.md, release-notes/unreleased.md, native/README) consistent. - [ ] Live-verify on main: pin/unpin (formula + cask), Library bottom bar + Pinned tab, list-scale, install-trend, vulnerable-footer nav, GHSA enrichment. - [ ] Build + notarize both shells (Tauri arm64 + x64 dmgs + updater `.app.tar.gz`; native arm64 + x64 dmgs). Recipe: [build-deployment / RELEASE-BUILD GOTCHAS in project-resume-state]. From 995986d27e80712c53d2bff35938721c3c38f632 Mon Sep 17 00:00:00 2001 From: Michael Sitarzewski Date: Sun, 12 Jul 2026 20:22:32 -0500 Subject: [PATCH 7/9] =?UTF-8?q?feat(bundles):=20M5=20live-refresh=20client?= =?UTF-8?q?=20=E2=80=94=20opt-in=20host=20fetch=20(both=20shells)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in refresh of the bundle list from the public first-party host (brew-browser.zerologic.com/bundles/bundles.json β€” same host + pattern as live enrichment/trending, distinct /bundles/ path), so new/updated recipes arrive without an app update. - Native BundleLiveService (mirrors EnrichmentLiveService) + Tauri bundles_live command (mirrors enrichment/live.rs); reuses the M2 tolerant Bundle decode. Fail-soft: schemaVersion > supported OR malformed β†’ keep the bundled copy; an empty payload can never wipe the shipped 6. - Shared settings.json key `liveBundlesEnabled` added to BOTH shells (native SettingsDTO + Rust Settings, camelCase-matched); gated on the opt-in AND network (offline off), NOT AI-gated. New Settingsβ†’Network toggle in each shell. - Store wiring is non-blocking (fire-and-forget) so a slow/absent endpoint never delays the cards; replaces only on non-empty success. Client is unit-tested (schema-skew fail-soft, empty-guard, opt-in gating); the live 200 path activates once the host serves the endpoint. Tauri cargo 686, svelte 0, vitest. Native 192 tests. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01X9iMFjHE21ePTjcbHpTXt6 --- native/Sources/BrewBrowserKit/AppModel.swift | 21 ++ .../Sources/BrewBrowserKit/AppSettings.swift | 30 ++- native/Sources/BrewBrowserKit/Bundles.swift | 71 +++++++ .../Sources/BrewBrowserKit/SettingsView.swift | 15 ++ .../BundleParsingTests.swift | 61 ++++++ src-tauri/src/bundles_live.rs | 200 ++++++++++++++++++ src-tauri/src/commands/bundles.rs | 5 +- src-tauri/src/commands/settings.rs | 23 ++ src-tauri/src/lib.rs | 3 + src-tauri/src/state.rs | 18 ++ src/lib/api.ts | 12 ++ .../SettingsSectionLiveBundles.svelte | 162 ++++++++++++++ .../components/SettingsSectionNetwork.svelte | 5 + src/lib/stores/bundles.svelte.ts | 44 +++- src/lib/types.ts | 12 ++ src/lib/util/liveBundles.test.ts | 11 + src/lib/util/liveBundles.ts | 14 ++ 17 files changed, 700 insertions(+), 7 deletions(-) create mode 100644 src-tauri/src/bundles_live.rs create mode 100644 src/lib/components/SettingsSectionLiveBundles.svelte create mode 100644 src/lib/util/liveBundles.test.ts create mode 100644 src/lib/util/liveBundles.ts diff --git a/native/Sources/BrewBrowserKit/AppModel.swift b/native/Sources/BrewBrowserKit/AppModel.swift index 2152417..49b07e3 100644 --- a/native/Sources/BrewBrowserKit/AppModel.swift +++ b/native/Sources/BrewBrowserKit/AppModel.swift @@ -406,6 +406,25 @@ public final class AppModel { if let pkg = detailPackage, settings.aiFeaturesVisible { detailEnrichment = enrichmentEntry(for: pkg.name) } + // M5: if the user opted in, refresh the Bundles catalog from the project + // host in the background β€” never blocks first paint, silently keeps the + // bundled copy on any failure. + if settings.liveBundlesAllowed { + Task { await refreshLiveBundles() } + } + } + + /// Opt-in live Bundles refresh (M5). Fetches `bundles/bundles.json` from the + /// project host and REPLACES `bundles` on success. Any failure (offline, + /// 404, network, malformed, or a newer-than-supported schema) leaves the + /// bundled catalog untouched. A valid-but-empty payload is ignored too, so a + /// blank file can't wipe the six shipped recipes. Gated on + /// `settings.liveBundlesAllowed` (opt-in + network). + func refreshLiveBundles() async { + guard settings.liveBundlesAllowed else { return } + if let live = await bundleLive.fetchBundles(), !live.isEmpty { + bundles = live + } } func loadCatalog() async { @@ -1036,6 +1055,8 @@ public final class AppModel { // Opt-in live enrichment overlay (mirrors the Tauri store overlay). private let enrichmentLive = EnrichmentLiveService() + // Opt-in live Bundles-catalog refresh (M5), gated on settings.liveBundlesAllowed. + private let bundleLive = BundleLiveService() private var liveEnrichment: [String: EnrichmentEntry] = [:] private var liveEnrichmentAttempted: Set = [] private var liveCategoriesVersion: String = "" diff --git a/native/Sources/BrewBrowserKit/AppSettings.swift b/native/Sources/BrewBrowserKit/AppSettings.swift index 358c196..0092114 100644 --- a/native/Sources/BrewBrowserKit/AppSettings.swift +++ b/native/Sources/BrewBrowserKit/AppSettings.swift @@ -81,6 +81,7 @@ struct SettingsDTO: Codable { var enhancedTrendingEnabled: Bool var vulnerabilityScanningEnabled: Bool var liveEnrichmentEnabled: Bool + var liveBundlesEnabled: Bool /// Explicit keys = no automatic conversion. These are the literal JSON /// keys the Tauri app writes (`commands/settings.rs` field names under @@ -98,6 +99,7 @@ struct SettingsDTO: Codable { case enhancedTrendingEnabled case vulnerabilityScanningEnabled case liveEnrichmentEnabled + case liveBundlesEnabled } /// All-defaults DTO. Mirrors Rust `impl Default for Settings` @@ -118,7 +120,8 @@ struct SettingsDTO: Codable { skippedUpdateVersions: [String], enhancedTrendingEnabled: Bool, vulnerabilityScanningEnabled: Bool, - liveEnrichmentEnabled: Bool + liveEnrichmentEnabled: Bool, + liveBundlesEnabled: Bool ) { self.paranoidMode = paranoidMode self.catalogAutoRefresh = catalogAutoRefresh @@ -132,6 +135,7 @@ struct SettingsDTO: Codable { self.enhancedTrendingEnabled = enhancedTrendingEnabled self.vulnerabilityScanningEnabled = vulnerabilityScanningEnabled self.liveEnrichmentEnabled = liveEnrichmentEnabled + self.liveBundlesEnabled = liveBundlesEnabled } static func defaults() -> SettingsDTO { @@ -147,7 +151,8 @@ struct SettingsDTO: Codable { skippedUpdateVersions: [], enhancedTrendingEnabled: false, vulnerabilityScanningEnabled: false, - liveEnrichmentEnabled: false + liveEnrichmentEnabled: false, + liveBundlesEnabled: false ) } @@ -172,6 +177,7 @@ struct SettingsDTO: Codable { enhancedTrendingEnabled = try c.decodeIfPresent(Bool.self, forKey: .enhancedTrendingEnabled) ?? d.enhancedTrendingEnabled vulnerabilityScanningEnabled = try c.decodeIfPresent(Bool.self, forKey: .vulnerabilityScanningEnabled) ?? d.vulnerabilityScanningEnabled liveEnrichmentEnabled = try c.decodeIfPresent(Bool.self, forKey: .liveEnrichmentEnabled) ?? d.liveEnrichmentEnabled + liveBundlesEnabled = try c.decodeIfPresent(Bool.self, forKey: .liveBundlesEnabled) ?? d.liveBundlesEnabled } } @@ -282,6 +288,14 @@ public final class AppSettings { /// (`commands/settings.rs` live_enrichment_enabled) public var liveEnrichmentEnabled: Bool + /// Opt-in live Bundles-catalog refresh (project infra, M5). Default `false`. + /// Fetches `bundles/bundles.json` from the same first-party host; unlike + /// enrichment it is NOT an AI feature, so its gate omits the AI requirement. + /// The Tauri `Settings` struct ignores this key until its own M5 lands + /// (`#[serde(default)]`, no `deny_unknown_fields`), so writing it is + /// forward-compatible across shells. + public var liveBundlesEnabled: Bool + /// Current load state. `.corrupt` fails closed (paranoid effectively ON). /// Mirrors the in-memory `SettingsLoadState` slot in `state.rs`. public private(set) var loadState: SettingsLoadState @@ -303,6 +317,7 @@ public final class AppSettings { self.enhancedTrendingEnabled = dto.enhancedTrendingEnabled self.vulnerabilityScanningEnabled = dto.vulnerabilityScanningEnabled self.liveEnrichmentEnabled = dto.liveEnrichmentEnabled + self.liveBundlesEnabled = dto.liveBundlesEnabled self.loadState = loadState self.clamp() } @@ -469,7 +484,8 @@ public final class AppSettings { skippedUpdateVersions: skippedUpdateVersions, enhancedTrendingEnabled: enhancedTrendingEnabled, vulnerabilityScanningEnabled: vulnerabilityScanningEnabled, - liveEnrichmentEnabled: liveEnrichmentEnabled + liveEnrichmentEnabled: liveEnrichmentEnabled, + liveBundlesEnabled: liveBundlesEnabled ) let encoder = JSONEncoder() @@ -534,6 +550,7 @@ public final class AppSettings { skippedUpdateVersions = d.skippedUpdateVersions enhancedTrendingEnabled = d.enhancedTrendingEnabled vulnerabilityScanningEnabled = d.vulnerabilityScanningEnabled + liveBundlesEnabled = d.liveBundlesEnabled try? save() // save() sets loadState = .loaded on success } @@ -591,6 +608,13 @@ public final class AppSettings { !paranoidMode && !isCorrupt && liveEnrichmentEnabled && aiFeaturesEnabled } + /// Composed gate for the live Bundles-catalog refresh (M5). Opt-in + + /// network only β€” bundles are not AI-derived, so (unlike live enrichment) + /// this does NOT require `aiFeaturesEnabled`. + public var liveBundlesAllowed: Bool { + !paranoidMode && !isCorrupt && liveBundlesEnabled + } + /// Whether AI-derived UI (categories, enrichment, donut, pills, summaries, /// use-cases, similar packages, tags) should render. This is a pure /// rendering gate β€” no I/O, network, or LLM calls β€” so it is NOT subject diff --git a/native/Sources/BrewBrowserKit/Bundles.swift b/native/Sources/BrewBrowserKit/Bundles.swift index 5ee1355..fe8cd42 100644 --- a/native/Sources/BrewBrowserKit/Bundles.swift +++ b/native/Sources/BrewBrowserKit/Bundles.swift @@ -149,6 +149,11 @@ private struct BundleFile: Decodable { /// value type (mirrors the `CategoryCatalog`/`EnrichmentCatalog` bundled-data /// loaders); call `load()` off the main thread from `AppModel.loadBundledData()`. public struct BundleCatalog: Sendable { + /// The `bundles.json` `schemaVersion` this build understands. A live payload + /// with a NEWER schema is refused (the app keeps its bundled copy) rather + /// than risk mis-parsing a format it doesn't know β€” fail-soft forward-compat. + static let supportedSchemaVersion = 1 + public init() {} /// Decode the bundled catalog. Returns `[]` if the resource is missing or @@ -170,4 +175,70 @@ public struct BundleCatalog: Sendable { } return file.bundles.compactMap { try? $0.result.get() } } + + /// Parse a LIVE-fetched payload (M5). Distinct from `parse` in that it can + /// say "don't use this" by returning `nil`, so the caller keeps the bundled + /// copy: `nil` when the top-level JSON is malformed OR its `schemaVersion` is + /// newer than `supportedSchemaVersion`. Otherwise it lossily decodes the + /// recipes (skipping malformed ones), same as `parse`. A missing + /// `schemaVersion` is treated as compatible. + static func parseLive(_ data: Data) -> [BrewBundle]? { + guard let file = try? JSONDecoder().decode(BundleFile.self, from: data) else { + return nil + } + if let version = file.schemaVersion, version > supportedSchemaVersion { + return nil + } + return file.bundles.compactMap { try? $0.result.get() } + } +} + +/// Opt-in live fetch for the Bundles catalog (M5) β€” the native mirror of the +/// enrichment/trending live clients (`EnrichmentLiveService`, +/// `TrendingHistoryService`): an actor, soft-fail everywhere, pure transport. +/// The caller (`AppModel`) gates on `settings.liveBundlesAllowed` (opt-in + +/// network) before invoking. The base host is the same first-party serving +/// domain the enrichment/trending clients use (already in committed source); +/// only the `/bundles/` path differs. Injectable `baseURL`/`session` keep it +/// testable and keep the host out of any hardcoded string beyond this default. +public actor BundleLiveService { + private let baseURL: URL + private let session: URLSession + + public init( + baseURL: URL = URL(string: "https://brew-browser.zerologic.com/bundles/")!, + session: URLSession? = nil + ) { + self.baseURL = baseURL + if let session { + self.session = session + } else { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 8 + config.timeoutIntervalForResource = 15 + self.session = URLSession(configuration: config) + } + } + + /// GET `bundles/bundles.json` β†’ tolerant `[BrewBundle]`. Returns `nil` on any + /// failure (offline, 404 β€” the endpoint may not exist yet, network error, + /// malformed JSON, or a newer-than-supported schema) so the caller keeps the + /// bundled copy. + func fetchBundles() async -> [BrewBundle]? { + let url = baseURL.appendingPathComponent("bundles.json", isDirectory: false) + guard let data = await fetchData(url) else { return nil } + return BundleCatalog.parseLive(data) + } + + private func fetchData(_ url: URL) async -> Data? { + do { + let (data, response) = try await session.data(from: url) + if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + return nil + } + return data + } catch { + return nil + } + } } diff --git a/native/Sources/BrewBrowserKit/SettingsView.swift b/native/Sources/BrewBrowserKit/SettingsView.swift index 4186215..7f189e5 100644 --- a/native/Sources/BrewBrowserKit/SettingsView.swift +++ b/native/Sources/BrewBrowserKit/SettingsView.swift @@ -474,6 +474,21 @@ private struct TrendingSettings: View { .font(.caption).foregroundStyle(.orange) } } + + SwiftUI.Section { + Toggle("Refresh bundles from the project host", isOn: Binding( + get: { settings.liveBundlesEnabled }, + set: { settings.liveBundlesEnabled = $0; try? settings.save() } + )) + .disabled(offline || settings.isCorrupt) + Text("brew-browser ships with a built-in set of curated Bundles (Local LLMs, Media Toolkit, …). When on, it refreshes that list from brew-browser.zerologic.com/bundles/bundles.json so new or updated recipes arrive without an app update. No package names are sent β€” it's a single static file fetch. Same first-party host as Enhanced Trending / Enrichment, a distinct /bundles/ path; the bundled copy is kept on any error.") + .font(.caption).foregroundStyle(.secondary) + if offline { + Label("Offline Mode is on β€” this toggle is locked off.", + systemImage: "exclamationmark.triangle.fill") + .font(.caption).foregroundStyle(.orange) + } + } } .formStyle(.grouped) .padding(20) diff --git a/native/Tests/BrewBrowserKitTests/BundleParsingTests.swift b/native/Tests/BrewBrowserKitTests/BundleParsingTests.swift index f843302..4cafbf0 100644 --- a/native/Tests/BrewBrowserKitTests/BundleParsingTests.swift +++ b/native/Tests/BrewBrowserKitTests/BundleParsingTests.swift @@ -130,4 +130,65 @@ struct BundleParsingTests { #expect(c.external == true, "command step '\(c.run ?? "")' must be external:true (contract)") } } + + // MARK: - Live refresh (M5) parseLive fail-soft contract + + /// A payload whose `schemaVersion` is newer than we support β†’ `nil`, so the + /// caller keeps the bundled copy rather than mis-parsing an unknown format. + @Test func parseLiveRefusesNewerSchema() { + let json = """ + { "schemaVersion": 2, "bundles": [ + { "id": "future", "name": "Future", "tagline": "t", "category": "Data", + "packages": [ { "name": "redis", "kind": "formula" } ] } + ] } + """ + #expect(BundleCatalog.parseLive(Data(json.utf8)) == nil) + } + + /// A valid same-schema payload β†’ the decoded bundles (the live set replaces + /// the bundled one). + @Test func parseLiveAcceptsSupportedSchema() throws { + let json = """ + { "schemaVersion": 1, "bundles": [ + { "id": "live-a", "name": "Live A", "tagline": "t", "category": "Media", + "packages": [ { "name": "ffmpeg", "kind": "formula" } ] }, + { "id": "live-b", "name": "Live B", "tagline": "t", "category": "Data", + "packages": [ { "name": "redis", "kind": "formula" } ] } + ] } + """ + let live = try #require(BundleCatalog.parseLive(Data(json.utf8))) + #expect(live.map(\.id) == ["live-a", "live-b"]) + } + + /// A missing `schemaVersion` is treated as compatible (parses). + @Test func parseLiveTreatsMissingSchemaAsCompatible() throws { + let json = """ + { "bundles": [ + { "id": "no-ver", "name": "No Ver", "tagline": "t", "category": "Development", + "packages": [ { "name": "node", "kind": "formula" } ] } + ] } + """ + let live = try #require(BundleCatalog.parseLive(Data(json.utf8))) + #expect(live.map(\.id) == ["no-ver"]) + } + + /// Malformed top-level JSON β†’ `nil` (keep the bundled copy). + @Test func parseLiveFallsBackOnMalformed() { + #expect(BundleCatalog.parseLive(Data("{ not json".utf8)) == nil) + } + + /// Within a supported-schema payload, a single malformed recipe is still + /// dropped (lossy), matching the bundled `parse` behavior. + @Test func parseLiveIsLossyWithinSupportedSchema() throws { + let json = """ + { "schemaVersion": 1, "bundles": [ + { "id": "ok", "name": "OK", "tagline": "t", "category": "Data", + "packages": [ { "name": "redis", "kind": "formula" } ] }, + { "name": "no id", "tagline": "broken", "category": "Data", + "packages": [ { "name": "x", "kind": "formula" } ] } + ] } + """ + let live = try #require(BundleCatalog.parseLive(Data(json.utf8))) + #expect(live.map(\.id) == ["ok"]) + } } diff --git a/src-tauri/src/bundles_live.rs b/src-tauri/src/bundles_live.rs new file mode 100644 index 0000000..0170bba --- /dev/null +++ b/src-tauri/src/bundles_live.rs @@ -0,0 +1,200 @@ +//! HTTP client for the opt-in *live* Bundles refresh (v0.7.0). +//! +//! Mirrors `enrichment::live` / `trending::history::client`: a small soft-fail +//! GET client against the same first-party static host, a distinct `/bundles/*` +//! path. The `Settings::live_bundles_enabled` toggle authorizes a call here and +//! the master `paranoid_mode` / Offline Mode switch hard-blocks it regardless +//! (both enforced by `AppState::require_live_bundles`). +//! +//! Endpoint (published by the M5 release/publish job): +//! GET /bundles/bundles.json β†’ the concatenated recipe set (same shape as +//! the bundled `bundles.json`) +//! +//! Fail-soft everywhere: any error β€” 404 (the endpoint may not exist yet), +//! network, unparseable payload, or a `schemaVersion` newer than this build +//! supports β€” leaves the app on its bundled copy. An empty payload never wipes +//! the shipped set (the store replaces only on a non-empty result). + +use std::time::Duration; + +use tauri::State; + +use crate::commands::bundles::parse_bundles; +use crate::error::BrewError; +use crate::state::AppState; +use crate::types::Bundle; + +/// Subpath on `brew-browser.zerologic.com` β€” the same first-party host as Live +/// enrichment / Enhanced Trending (public, disclosed in `README.md` + +/// `memory-bank/security.md`), a distinct `/bundles/*` path. Not a Homebrew +/// endpoint; its own trust boundary. +const BASE: &str = "https://brew-browser.zerologic.com/bundles"; + +/// 10s, matching the enrichment / trending-history clients. Static JSON from +/// Caddy β€” a slow response means the upstream is wedged, not that more time helps. +const TIMEOUT: Duration = Duration::from_secs(10); + +/// Highest `schemaVersion` this build understands. A live payload declaring a +/// newer schema is refused (fail-soft to the bundled copy) so a forward-schema +/// push can't feed this build a shape it can't safely read. Matches the +/// bundled `bundles.json` (`schemaVersion: 1`). +pub const SUPPORTED_SCHEMA_VERSION: u64 = 1; + +/// Tauri command: fetch + parse the live bundles. Gated on the opt-in toggle +/// AND network (paranoid/offline) via `require_live_bundles`. Returns the +/// parsed bundles on success; the frontend replaces its list only when the +/// result is non-empty. Every failure mode is an `Err` the store swallows, +/// keeping the bundled copy. +#[tauri::command] +pub async fn bundles_live(state: State<'_, AppState>) -> Result, BrewError> { + state.require_live_bundles().await?; + let body = fetch_bundles_text().await?; + parse_live_payload(&body) +} + +/// Parse a host-served `bundles.json` body with the M5 fail-soft rules: +/// - unparseable top-level JSON β†’ `Err` (keep bundled), +/// - `schemaVersion` > [`SUPPORTED_SCHEMA_VERSION`] β†’ `Err` (keep bundled), +/// - otherwise β†’ `Ok(bundles)` using the SAME tolerant per-entry decode as +/// the bundled loader ([`parse_bundles`]): a single malformed recipe is +/// skipped, not fatal. The list may be empty; the caller decides whether +/// to replace (it must not on empty). +/// +/// Pure (no I/O) so the fail-soft matrix is unit-testable without a network. +pub fn parse_live_payload(json: &str) -> Result, BrewError> { + // Top-level must be valid JSON; anything else is a keep-bundled failure. + let root: serde_json::Value = serde_json::from_str(json).map_err(|e| BrewError::Network { + url: format!("{BASE}/bundles.json"), + message: format!("live bundles.json is not valid JSON: {e}"), + })?; + + // A newer-than-supported schema means the shape may have changed + // incompatibly β€” refuse and keep the bundled copy (fail-soft). + let schema = root.get("schemaVersion").and_then(|v| v.as_u64()).unwrap_or(0); + if schema > SUPPORTED_SCHEMA_VERSION { + return Err(BrewError::Internal { + message: format!( + "live bundles.json schemaVersion {schema} is newer than supported \ + ({SUPPORTED_SCHEMA_VERSION}); keeping bundled copy" + ), + }); + } + + // Same tolerant decode as the bundled loader (M2): skip malformed entries. + Ok(parse_bundles(json)) +} + +/// `GET /bundles/bundles.json` β†’ raw body text (so `parse_live_payload` can run +/// the tolerant decode). Mirrors the enrichment client's `get_json`, but keeps +/// the body as text rather than decoding into a fixed type. +async fn fetch_bundles_text() -> Result { + let url = format!("{BASE}/bundles.json"); + let client = build_client()?; + let resp = client.get(&url).send().await.map_err(|e| { + if let Some(status) = e.status() { + BrewError::HttpStatus { + url: url.clone(), + status: status.as_u16(), + } + } else { + BrewError::Network { + url: url.clone(), + message: e.to_string(), + } + } + })?; + + let status = resp.status(); + if !status.is_success() { + return Err(BrewError::HttpStatus { + url, + status: status.as_u16(), + }); + } + + resp.text().await.map_err(|e| BrewError::Network { + url, + message: format!("reading response body failed: {e}"), + }) +} + +fn build_client() -> Result { + reqwest::Client::builder() + .timeout(TIMEOUT) + .user_agent(concat!( + "brew-browser/", + env!("CARGO_PKG_VERSION"), + " (+https://github.com/msitarzewski/brew-browser)" + )) + .build() + .map_err(|e| BrewError::Network { + url: BASE.into(), + message: e.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const VALID_TWO: &str = r#"{ + "schemaVersion": 1, + "bundles": [ + { "id": "a", "name": "A", "tagline": "t", "category": "Data", + "packages": [{ "name": "redis", "kind": "formula" }] }, + { "id": "b", "name": "B", "tagline": "t", "category": "Media", + "packages": [{ "name": "mpv", "kind": "formula" }] } + ] + }"#; + + #[test] + fn newer_schema_keeps_bundled() { + // schemaVersion 2 > supported 1 β†’ refuse (Err), even though the + // bundles array is otherwise valid. + let json = r#"{ "schemaVersion": 2, "bundles": [ + { "id": "a", "name": "A", "tagline": "t", "category": "AI", + "packages": [{ "name": "ollama", "kind": "formula" }] } ] }"#; + assert!(parse_live_payload(json).is_err(), "newer schema must keep bundled"); + } + + #[test] + fn valid_current_schema_replaces() { + // A valid schema-1 payload (a newer DATA push) parses and replaces. + let out = parse_live_payload(VALID_TWO).expect("valid payload parses"); + assert_eq!(out.len(), 2); + assert_eq!(out[0].id, "a"); + assert_eq!(out[1].id, "b"); + } + + #[test] + fn malformed_top_level_falls_back() { + assert!(parse_live_payload("{ not json at all").is_err()); + assert!(parse_live_payload("404").is_err()); + } + + #[test] + fn one_malformed_entry_is_skipped_rest_kept() { + // Same tolerance as the bundled loader: the bad middle recipe is + // dropped, the two good ones survive. + let json = r#"{ "schemaVersion": 1, "bundles": [ + { "id": "good1", "name": "G1", "tagline": "t", "category": "Data", + "packages": [{ "name": "redis", "kind": "formula" }] }, + { "id": "bad", "name": "Bad", "packages": "not-an-array" }, + { "id": "good2", "name": "G2", "tagline": "t", "category": "Media", + "packages": [{ "name": "mpv", "kind": "formula" }] } + ] }"#; + let out = parse_live_payload(json).expect("valid top-level parses"); + assert_eq!(out.len(), 2); + assert_eq!(out[0].id, "good1"); + assert_eq!(out[1].id, "good2"); + } + + #[test] + fn empty_payload_yields_empty_not_error() { + // An empty (but well-formed) payload parses to an empty list β€” the + // store guards against replacing the shipped set with nothing. + let json = r#"{ "schemaVersion": 1, "bundles": [] }"#; + let out = parse_live_payload(json).expect("empty is well-formed"); + assert!(out.is_empty()); + } +} diff --git a/src-tauri/src/commands/bundles.rs b/src-tauri/src/commands/bundles.rs index 39c37b2..8aafebe 100644 --- a/src-tauri/src/commands/bundles.rs +++ b/src-tauri/src/commands/bundles.rs @@ -25,7 +25,10 @@ const BUNDLES_JSON: &str = include_str!("../../data/bundles.json"); /// recipe fails the whole `Vec` decode) do we fall back to a per-entry /// tolerant parse that salvages the good recipes and skips (logs) the bad one. /// A completely unparseable file yields an empty list. -fn parse_bundles(json: &str) -> Vec { +/// +/// `pub(crate)` so the live-refresh path (`crate::bundles_live`) reuses the +/// exact same tolerant decode on the host-served payload. +pub(crate) fn parse_bundles(json: &str) -> Vec { if let Ok(file) = serde_json::from_str::(json) { return file.bundles; } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index ff16297..2790a6a 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -177,6 +177,23 @@ pub struct Settings { /// no cookies. Paranoid mode overrides this regardless. #[serde(default)] pub live_enrichment_enabled: bool, + + /// Opt-in *live* refresh of the curated **Bundles** recipe set. When + /// **false** (default), the app uses only the bundled `bundles.json` baked + /// in at build time β€” no network. When **true**, it fetches the latest + /// `bundles.json` from `brew-browser.zerologic.com/bundles/bundles.json` + /// (one static-file GET) and replaces the in-memory list on a non-empty + /// success. Any error β€” 404 (the endpoint may not exist yet), network, + /// parse, empty payload, or a newer-than-supported schema β€” falls back + /// silently to the bundled copy. + /// + /// Same first-party host + trust boundary as Live enrichment / Enhanced + /// Trending (`brew-browser.zerologic.com`), a new `…/bundles/*` path. No + /// package names are sent (unlike enrichment's per-token GETs) β€” it's a + /// single static file. Paranoid mode / Offline Mode overrides this + /// regardless. Shared settings key with the native shell (`liveBundlesEnabled`). + #[serde(default)] + pub live_bundles_enabled: bool, } /// Default factory for [`Settings::ai_features_enabled`] β€” separated @@ -224,6 +241,10 @@ impl Default for Settings { // enrichment baseline and contacts brew-browser.zerologic.com/ // enrichment/* only when the user opts in via Settings β†’ Network. live_enrichment_enabled: false, + // Off by default per v0.7.0 plan: the six curated bundles ship + // baked in and always work offline; brew-browser.zerologic.com/ + // bundles/* is contacted only when the user opts in. + live_bundles_enabled: false, } } } @@ -647,6 +668,7 @@ mod tests { enhanced_trending_enabled: true, vulnerability_scanning_enabled: true, live_enrichment_enabled: true, + live_bundles_enabled: true, }; let written = persist(tmp.path(), s.clone()).await.expect("persist"); assert_eq!(written, s); @@ -708,6 +730,7 @@ mod tests { enhanced_trending_enabled: false, vulnerability_scanning_enabled: false, live_enrichment_enabled: false, + live_bundles_enabled: false, }; let written = persist(tmp.path(), s).await.expect("persist"); assert_eq!( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8dcc68b..114e7bd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ //! in `commands::*`. mod brew; +mod bundles_live; mod catalog; mod commands; mod enrichment; @@ -17,6 +18,7 @@ mod types; mod util; mod vulns; +use bundles_live::bundles_live; use commands::*; use system::profile::system_profile; @@ -246,6 +248,7 @@ pub fn run() { system_profile, bundles, brew_install_bundle, + bundles_live, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index e1cbe86..f9b87cc 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -367,6 +367,24 @@ impl AppState { } } + /// v0.7.0 β€” composed gate for the opt-in live **Bundles** refresh (fresh + /// `bundles.json` from `brew-browser.zerologic.com/bundles/bundles.json`). + /// Mirrors [`Self::require_live_enrichment`]: master paranoid/offline switch + /// first, then the per-feature `live_bundles_enabled` toggle. Used by + /// `bundles_live` before any network call. + /// + /// Fail-closed on `Corrupt` is handled by the inner `require_network` call. + pub async fn require_live_bundles(&self) -> Result<(), BrewError> { + self.require_network("live_bundles").await?; + let guard = self.settings.read().await; + match &*guard { + SettingsLoadState::Loaded(s) if s.live_bundles_enabled => Ok(()), + _ => Err(BrewError::FeatureDisabled { + feature: "live_bundles".to_string(), + }), + } + } + /// v0.5.0 β€” composed gate for the vulnerability-scanning surface /// (`brew vulns` subprocess + OSV roundtrip + optional GHSA enrich). /// Composes the master paranoid switch with the per-feature diff --git a/src/lib/api.ts b/src/lib/api.ts index d6851b8..33030bf 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -948,6 +948,18 @@ export function bundles(): Promise { return invoke("bundles"); } +/** + * Bundles M5 β€” opt-in live refresh of the recipe set from the project host + * (`brew-browser.zerologic.com/bundles/bundles.json`, one static-file GET). + * Gated backend-side on the `liveBundlesEnabled` toggle + network (paranoid/ + * offline). Rejects on any failure (disabled, network, 404, parse, or a + * newer-than-supported schema) β€” callers keep the bundled copy and replace + * only on a non-empty result. + */ +export function bundlesLive(): Promise { + return invoke("bundles_live"); +} + /** * Bundles M3 β€” install every package in a bundle. Mirrors `brewUpgradeMany`: * one streamed job (into Activity) that installs the set, then the caller diff --git a/src/lib/components/SettingsSectionLiveBundles.svelte b/src/lib/components/SettingsSectionLiveBundles.svelte new file mode 100644 index 0000000..37cc882 --- /dev/null +++ b/src/lib/components/SettingsSectionLiveBundles.svelte @@ -0,0 +1,162 @@ + + +
+

+

+ +
+ + +

+ brew-browser ships with a built-in set of curated bundles that always work + offline. When on, it fetches the latest recipe set from + brew-browser.zerologic.com/bundles/bundles.json: a single + static-file download, no package names sent, no IP logged at the server, + no cookies. Same first-party host as Live enrichment β€” a distinct trust + boundary from the always-on Homebrew paths. On any error (or an + unsupported newer format) the bundled copy is kept. +

+ + {#if offline} +

+ Offline Mode is on β€” this toggle is locked off. Turn Offline Mode off + above to enable live updates. +

+ {/if} +
+
+ + diff --git a/src/lib/components/SettingsSectionNetwork.svelte b/src/lib/components/SettingsSectionNetwork.svelte index da7dd52..ca6bde9 100644 --- a/src/lib/components/SettingsSectionNetwork.svelte +++ b/src/lib/components/SettingsSectionNetwork.svelte @@ -30,6 +30,7 @@ import SettingsSectionUpdates from "$lib/components/SettingsSectionUpdates.svelte"; import SettingsSectionTrendingHistory from "$lib/components/SettingsSectionTrendingHistory.svelte"; import SettingsSectionLiveEnrichment from "$lib/components/SettingsSectionLiveEnrichment.svelte"; + import SettingsSectionLiveBundles from "$lib/components/SettingsSectionLiveBundles.svelte"; import SettingsSectionVulnerabilities from "$lib/components/SettingsSectionVulnerabilities.svelte"; import type { CaskIconMode, @@ -345,6 +346,10 @@ as Enhanced Trending (distinct /enrichment/* path). Network opt-in. --> + + + diff --git a/src/lib/stores/bundles.svelte.ts b/src/lib/stores/bundles.svelte.ts index f991633..31ce88e 100644 --- a/src/lib/stores/bundles.svelte.ts +++ b/src/lib/stores/bundles.svelte.ts @@ -7,9 +7,11 @@ * bundles + profile once per process and caches the result. */ -import { bundles as fetchBundles, systemProfile } from "$lib/api"; +import { bundles as fetchBundles, bundlesLive, systemProfile } from "$lib/api"; +import { settings } from "$lib/stores/settings.svelte"; import type { Bundle, Readiness, SystemProfile } from "$lib/types"; import { readiness } from "$lib/util/readiness"; +import { shouldReplaceWithLive } from "$lib/util/liveBundles"; /** localStorage key that overrides the probed RAM so Marginal/Blocked states * are reachable on a big dev box (e.g. the 128 GB Mac). Set e.g. @@ -37,7 +39,7 @@ class BundlesStore { this.loading = true; this.error = null; - this.loadPromise = (async () => { + const p = (async () => { try { const [list, profile] = await Promise.all([fetchBundles(), systemProfile()]); this.list = list; @@ -50,7 +52,43 @@ class BundlesStore { this.loadPromise = null; } })(); - return this.loadPromise; + this.loadPromise = p; + // M5 β€” the bundled list shows as soon as `p` settles; if the user opted + // in, silently refresh from the project host in the background and replace + // on a non-empty success. Fire-and-forget so a slow/absent endpoint never + // delays the cards. + void p.then(() => this.refreshLive()); + return p; + } + + /** Opt-in gate for the live refresh: the toggle on AND not in Offline Mode. + * The backend re-checks both (paranoid/offline + toggle) β€” this just avoids + * a guaranteed-rejected round-trip. */ + private get liveAllowed(): boolean { + return ( + settings.effective.liveBundlesEnabled === true && + settings.effective.paranoidMode !== true + ); + } + + /** + * Pull the latest `bundles.json` from the project host and replace the list + * on a non-empty success. Soft-fail: any error (disabled, network, 404, + * parse, newer-than-supported schema) or an EMPTY payload keeps the bundled + * copy β€” the shipped set is never wiped. Returns true if it replaced. + */ + async refreshLive(): Promise { + if (!this.liveAllowed) return false; + try { + const live = await bundlesLive(); + if (shouldReplaceWithLive(live)) { + this.list = live; + return true; + } + return false; // empty payload β†’ keep bundled + } catch { + return false; // keep bundled on any failure + } } /** Apply the debug RAM override to a freshly-probed profile, if set + valid. */ diff --git a/src/lib/types.ts b/src/lib/types.ts index 8521547..f17537e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -678,6 +678,15 @@ export interface Settings { Trending, new `…/enrichment/*` path; only the viewed package name is sent. Suppressed by Offline Mode regardless. */ liveEnrichmentEnabled: boolean; + + /** v0.7.0 β€” opt-in live refresh of the curated Bundles recipe set. When on, + the app fetches the latest `bundles.json` from + `brew-browser.zerologic.com/bundles/bundles.json` (one static-file GET, no + package names sent) and replaces its list on a non-empty success; any + error keeps the bundled copy. Off by default; same first-party host as + Live enrichment, new `…/bundles/*` path. Suppressed by Offline Mode + regardless. Shared settings key with the native shell. */ + liveBundlesEnabled: boolean; } /** Defaults matching the Rust `Settings::default()`. Used when seeding @@ -707,6 +716,9 @@ export const SETTINGS_DEFAULTS: Settings = { // Opt-in live refresh of categories + descriptions. Off by default; same // first-party host as Enhanced Trending, new /enrichment/* path. liveEnrichmentEnabled: false, + // v0.7.0 β€” opt-in live refresh of the Bundles recipe set. Off by default; + // same first-party host, new /bundles/* path. + liveBundlesEnabled: false, }; // ========================================================= diff --git a/src/lib/util/liveBundles.test.ts b/src/lib/util/liveBundles.test.ts new file mode 100644 index 0000000..2105f2d --- /dev/null +++ b/src/lib/util/liveBundles.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; + +import type { Bundle } from "$lib/types"; +import { shouldReplaceWithLive } from "./liveBundles"; + +describe("shouldReplaceWithLive", () => { + it("replaces only on a non-empty result β€” an empty payload never wipes the shipped set", () => { + expect(shouldReplaceWithLive([])).toBe(false); + expect(shouldReplaceWithLive([{ id: "x" } as unknown as Bundle])).toBe(true); + }); +}); diff --git a/src/lib/util/liveBundles.ts b/src/lib/util/liveBundles.ts new file mode 100644 index 0000000..e5dc8d9 --- /dev/null +++ b/src/lib/util/liveBundles.ts @@ -0,0 +1,14 @@ +/** + * Bundles M5 β€” frontend guard for the live-refresh result. + * + * The backend (`bundles_live`) already applies the fail-soft parse rules + * (unparseable / newer-than-supported schema β†’ error; malformed entries + * skipped). This is the one decision the frontend owns: whether a successful + * live result should REPLACE the bundled set. An empty result must never wipe + * the shipped recipes, so only a non-empty list replaces. + */ +import type { Bundle } from "$lib/types"; + +export function shouldReplaceWithLive(live: Bundle[]): boolean { + return live.length > 0; +} From b7fe6e5272f18118bd49ab5c17c48e1c77c665b3 Mon Sep 17 00:00:00 2001 From: Michael Sitarzewski Date: Sun, 12 Jul 2026 20:23:12 -0500 Subject: [PATCH 8/9] docs(bundles): M5 live-refresh client built (both shells); only host endpoint remains Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01X9iMFjHE21ePTjcbHpTXt6 --- memory-bank/releases/0.7.0/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/memory-bank/releases/0.7.0/README.md b/memory-bank/releases/0.7.0/README.md index 5486cf1..ff2a65f 100644 --- a/memory-bank/releases/0.7.0/README.md +++ b/memory-bank/releases/0.7.0/README.md @@ -54,8 +54,8 @@ Initial 6 recipes (tokens/taps verified 2026-07-12, all official taps): Local LL ## Release checklist (all gated to the user) - [x] Merge **#110** (GHSA references) into `main`. βœ… 2026-07-12 (main `6799e98`). - [x] Decide: **Bundles rides THIS release** (0.7.0/0.3.0), plan complete β†’ `bundles/`. βœ… 2026-07-12. -- [x] Build Bundles **M1–M4 + M5 pipeline** (branch `feat/bundles`, both shells, 2026-07-13): capability engine, recipe contract + 6 recipes + validator, browse/install UI, setup guidance, CI + CONTRIBUTING. Gate: Tauri cargo 681 / svelte 0 / vitest 53 Β· Native 187 tests Β· recipes 6/6. Both apps launch clean with the Bundles section; no unresolved TODOs in new code. -- [ ] **M5 live-refresh CLIENT β€” DEFERRED** (opt-in host fetch of `bundles.json`). Build alongside the host `/bundles/bundles.json` endpoint so it's end-to-end verifiable (mirrors how live-enrichment was staged). Contribution pipeline (CI/validator/CONTRIBUTING) is done; only the runtime live-fetch is deferred. +- [x] Build Bundles **M1–M5, both shells** (branch `feat/bundles`, 2026-07-13): capability engine, recipe contract + 6 recipes + validator, browse/install UI, setup guidance, CI + CONTRIBUTING, **and the M5 live-refresh client**. Gate: Tauri cargo 686 / svelte 0 / vitest Β· Native 192 tests Β· recipes 6/6. Both apps launch clean with the Bundles section; no unresolved TODOs in new code. +- [ ] **Serve the live-refresh endpoint**: publish `bundles.json` to `/bundles/bundles.json` (release-publish step, gated to the user). The M5 client is built + unit-tested and ships safely (bundled copy kept on any error / 404); a live 200 activates the refresh. Until then it's a no-op fail-soft β€” nothing to verify app-side. - [ ] Version bumps: `package.json` / `Cargo.toml` / `tauri.conf.json` β†’ `0.7.0`; native `build-app.sh` CFBundleShortVersionString β†’ `0.3.0`. Docs (README, BUILD.md, release-notes/unreleased.md, native/README) consistent. - [ ] Live-verify on main: pin/unpin (formula + cask), Library bottom bar + Pinned tab, list-scale, install-trend, vulnerable-footer nav, GHSA enrichment. - [ ] Build + notarize both shells (Tauri arm64 + x64 dmgs + updater `.app.tar.gz`; native arm64 + x64 dmgs). Recipe: [build-deployment / RELEASE-BUILD GOTCHAS in project-resume-state]. From d412bb20136ab07e1bd1ba5255a101fda568956c Mon Sep 17 00:00:00 2001 From: Michael Sitarzewski Date: Mon, 13 Jul 2026 22:53:50 -0500 Subject: [PATCH 9/9] feat(bundles): list+detail refactor, 9-recipe expansion, per-package install Move Bundles from a card grid + modal to the app's canonical master-list + right-side Details pane (matches Library/Trending: no auto-select, pane closed on entry, close on section switch). Tauri = shell-level resizable aside reusing detailPaneWidth; native = the stock .inspector. Both shells. Recipes 6 -> 9 (every token brew-info-verified, zero third-party taps): add Agentic Web Dev (opencode/zed/node/pnpm/git/caddy/orbstack), LAMP (httpd/mysql/php), LEMP (nginx/mariadb/php); expand Image Gen (+draw-things), Media (+handbrake), Web Dev Starter (+git/gh), Local Databases (+tableplus/dbeaver-community). opencode is now first-class in homebrew/core. - New `description` intent field in the recipe contract (schema + validator + contract/CONTRIBUTING docs), rendered under the tagline. Added to the Rust Bundle struct (src-tauri/src/types.rs) so it survives the frontend round-trip that serves bundles through Rust; regression test added. - Readiness dedup: the header pill is the sole verdict; the body shows a color-coded reason callout only for marginal/blocked (kills "Ready / Ready."). - Clickable inline package descriptions (lazy catalog/brew-info lookup, cached, multiple-open). - Per-package Install action for not-installed packages (reuses the streamed install path, keeps the pane open, row flips on success). - Bundle icons: add server (LAMP/LEMP) + agentic (fixes a Tauri parity gap). - Bundle-count guards bumped to 9 (native + Rust). Gate: recipes 9/9 - native swift build + 195 tests - Rust bundle tests 9/9 - Tauri svelte-check 0 / vitest 57. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01X9iMFjHE21ePTjcbHpTXt6 --- CONTRIBUTING-bundles.md | 1 + bundles.json | 289 +++++- memory-bank/releases/0.7.0/README.md | 20 +- .../releases/0.7.0/bundles/recipe-contract.md | 1 + native/Sources/BrewBrowserKit/AppModel.swift | 70 ++ .../BrewBrowserKit/BundleDetailView.swift | 203 ++++- native/Sources/BrewBrowserKit/Bundles.swift | 11 +- .../Sources/BrewBrowserKit/BundlesView.swift | 88 +- .../Sources/BrewBrowserKit/ContentView.swift | 8 +- .../BrewBrowserKit/Resources/bundles.json | 289 +++++- .../BundleDetailStateTests.swift | 50 ++ .../BundleParsingTests.swift | 16 +- recipes/agentic-web-dev.json | 32 + recipes/databases.json | 9 +- recipes/graphics.json | 1 + recipes/image-gen.json | 8 +- recipes/lamp.json | 28 + recipes/lemp.json | 29 + recipes/local-llm.json | 1 + recipes/media.json | 7 +- recipes/recipe.schema.json | 1 + recipes/web-dev.json | 11 +- scripts/validate-recipes.mjs | 1 + src-tauri/data/bundles.json | 289 +++++- src-tauri/src/commands/bundles.rs | 18 +- src-tauri/src/types.rs | 3 + src/lib/components/BundleDetail.svelte | 512 ----------- src/lib/components/BundleDetailPane.svelte | 822 ++++++++++++++++++ src/lib/components/BundleRow.svelte | 150 ++++ src/lib/components/Bundles.svelte | 241 ++--- src/lib/stores/ui.svelte.test.ts | 45 + src/lib/stores/ui.svelte.ts | 16 + src/lib/types.ts | 5 + src/routes/+page.svelte | 17 + 34 files changed, 2519 insertions(+), 773 deletions(-) create mode 100644 native/Tests/BrewBrowserKitTests/BundleDetailStateTests.swift create mode 100644 recipes/agentic-web-dev.json create mode 100644 recipes/lamp.json create mode 100644 recipes/lemp.json delete mode 100644 src/lib/components/BundleDetail.svelte create mode 100644 src/lib/components/BundleDetailPane.svelte create mode 100644 src/lib/components/BundleRow.svelte create mode 100644 src/lib/stores/ui.svelte.test.ts diff --git a/CONTRIBUTING-bundles.md b/CONTRIBUTING-bundles.md index 4ede3d7..86182e2 100644 --- a/CONTRIBUTING-bundles.md +++ b/CONTRIBUTING-bundles.md @@ -10,6 +10,7 @@ A **bundle** is a curated one-click stack (packages) with post-install guidance, 2. **Edit** the fields (see the full contract: [`memory-bank/releases/0.7.0/bundles/recipe-contract.md`](memory-bank/releases/0.7.0/bundles/recipe-contract.md)). The essentials: - `id` β€” kebab-case, unique, **stable** (it's the persistence key). - `name`, `tagline`, `category` (`AI` | `Graphics` | `Media` | `Development` | `Data` | `Productivity`). + - `description` (optional) β€” a 2–4 sentence "intent" paragraph (what this bundle is / who it's for / why these tools), **distinct** from the short `tagline` and from `caveats`. 40–600 chars. - `packages` β€” each `{ "name": "", "kind": "formula" | "cask" }`. **Must resolve via `brew info`.** If any package is in a third-party tap (not `homebrew/core`/`homebrew/cask`), set the top-level `"tap": "user/repo"`. - `requires` (optional) β€” `minRamGB` / `recommendedRamGB` / `minDiskGB` / `arch` / `gpu`. Omit for "runs anywhere". See [`capability-baselines.md`](memory-bank/releases/0.7.0/bundles/capability-baselines.md) for how to pick numbers. - `setup` (optional) β€” post-install steps. `service` / `open` / `reveal` run in-app; **`command` steps MUST set `"external": true`** and are copy-only (the app never runs arbitrary shell). diff --git a/bundles.json b/bundles.json index db1c072..efb58b1 100644 --- a/bundles.json +++ b/bundles.json @@ -1,10 +1,93 @@ { "schemaVersion": 1, "bundles": [ + { + "id": "agentic-web-dev", + "name": "Agentic Web Dev", + "tagline": "Terminal-first AI coding with opencode, Zed, and a real local server", + "description": "A terminal-first, AI-assisted web development environment. opencode is an open, model-agnostic coding agent β€” it works with Anthropic, OpenAI, Google, local Ollama, and dozens more β€” planning and editing across your whole project from the terminal. Zed is a Rust-native editor that opens instantly with built-in AI; Node and pnpm run and build your app, Git tracks changes, Caddy serves it locally over HTTPS, and OrbStack runs databases and services in fast containers. An agent does the heavy lifting while you keep a fast editor and a real local server.", + "category": "Development", + "icon": "agentic", + "packages": [ + { + "name": "opencode", + "kind": "formula" + }, + { + "name": "zed", + "kind": "cask" + }, + { + "name": "node", + "kind": "formula" + }, + { + "name": "pnpm", + "kind": "formula" + }, + { + "name": "git", + "kind": "formula" + }, + { + "name": "caddy", + "kind": "formula" + }, + { + "name": "orbstack", + "kind": "cask" + } + ], + "requires": { + "minRamGB": 8, + "recommendedRamGB": 16, + "minDiskGB": 8, + "arch": "apple-silicon", + "gpu": "none" + }, + "setup": [ + { + "kind": "note", + "text": "opencode needs a model/provider configured before first use β€” point it at Anthropic, OpenAI, Google, a local Ollama server, or any other supported provider." + }, + { + "kind": "command", + "run": "opencode auth login", + "label": "Configure an opencode provider (you run this)", + "external": true + }, + { + "kind": "note", + "text": "Caddy and OrbStack manage their own services β€” start Caddy from the Services tab and launch OrbStack to run containers." + } + ], + "caveats": "opencode requires a configured model/provider (API key or a local Ollama) β€” no provider is bundled. OrbStack is macOS-only and best on Apple Silicon.", + "links": [ + { + "label": "opencode", + "url": "https://opencode.ai" + }, + { + "label": "Zed", + "url": "https://zed.dev" + }, + { + "label": "Caddy", + "url": "https://caddyserver.com" + }, + { + "label": "OrbStack", + "url": "https://orbstack.dev" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + }, { "id": "databases", "name": "Local Databases", "tagline": "PostgreSQL + Redis, managed as services", + "description": "A local relational + cache stack managed as background services. PostgreSQL is the default production-grade SQL database; Redis handles caching, queues, and sessions. Bundled GUI clients β€” TablePlus (Mac-native polish) and DBeaver (free and cross-platform) β€” let you browse tables, run queries, and inspect keys without living in the terminal. Start and stop the services from the Services tab; data grows on disk over time.", "category": "Data", "icon": "database", "packages": [ @@ -15,6 +98,14 @@ { "name": "redis", "kind": "formula" + }, + { + "name": "tableplus", + "kind": "cask" + }, + { + "name": "dbeaver-community", + "kind": "cask" } ], "requires": { @@ -45,6 +136,14 @@ { "label": "Redis", "url": "https://redis.io" + }, + { + "label": "TablePlus", + "url": "https://tableplus.com" + }, + { + "label": "DBeaver", + "url": "https://dbeaver.io" } ], "maintainer": "brew-browser", @@ -54,6 +153,7 @@ "id": "graphics", "name": "Graphics & Design", "tagline": "Vector, raster, and digital painting β€” Inkscape, GIMP, Krita", + "description": "A complete open-source creative suite β€” vector, raster, and digital painting in three focused tools. Inkscape handles scalable vector art and SVG; GIMP 3.0 covers photo editing and compositing; Krita is a painter's canvas built for illustration and concept art. A capable, zero-cost alternative to the Adobe trio for design, illustration, and image work.", "category": "Graphics", "icon": "palette", "packages": [ @@ -92,9 +192,14 @@ "id": "image-gen", "name": "Image Generation", "tagline": "Local Stable Diffusion / Flux with ComfyUI", + "description": "Generate images locally with Stable Diffusion, SDXL, and Flux β€” no cloud, no per-image cost. Draw Things is the fast, no-terminal, Apple-Silicon-native starting point; ComfyUI is the node-based workbench for complex, repeatable pipelines. Together they cover both 'type a prompt and go' and 'build a custom multi-stage workflow.' Models are large downloads you fetch yourself.", "category": "AI", "icon": "image", "packages": [ + { + "name": "draw-things", + "kind": "cask" + }, { "name": "comfy", "kind": "cask" @@ -115,7 +220,13 @@ "setup": [ { "kind": "note", - "text": "Launch ComfyUI, then download a checkpoint model β€” it is NOT installed by brew (SDXL ~7 GB, Flux ~24 GB)." + "text": "Start with Draw Things for a no-terminal on-ramp; use ComfyUI when you need node-based pipelines. Either way you download checkpoint models yourself β€” they are NOT installed by brew (SDXL ~7 GB, Flux ~24 GB)." + }, + { + "kind": "command", + "run": "open -a \"Draw Things\"", + "label": "Launch Draw Things (you run this)", + "external": true }, { "kind": "command", @@ -124,8 +235,12 @@ "external": true } ], - "caveats": "Installs ComfyUI only; models are large downloads you fetch yourself. On Apple Silicon, generation is roughly 2–4Γ— slower per image than a comparable NVIDIA GPU.", + "caveats": "Installs the apps only β€” models (Stable Diffusion, SDXL, Flux) are large downloads you fetch and manage yourself.", "links": [ + { + "label": "Draw Things", + "url": "https://drawthings.ai" + }, { "label": "ComfyUI (GitHub)", "url": "https://github.com/comfyanonymous/ComfyUI" @@ -138,10 +253,152 @@ "maintainer": "brew-browser", "addedIn": "0.7.0" }, + { + "id": "lamp", + "name": "LAMP Stack", + "tagline": "Apache, MySQL, and PHP β€” the classic web server stack", + "description": "The classic web development stack: Apache serves your site, MySQL stores your data, and PHP runs your application code. It's the backbone of WordPress, Drupal, Laravel, and countless server-rendered apps β€” install all three as background services and you have a full local web server in one step. Best for PHP developers and anyone running or learning traditional web apps.", + "category": "Development", + "icon": "server", + "packages": [ + { + "name": "httpd", + "kind": "formula" + }, + { + "name": "mysql", + "kind": "formula" + }, + { + "name": "php", + "kind": "formula" + } + ], + "requires": { + "minRamGB": 4, + "recommendedRamGB": 8, + "minDiskGB": 5, + "arch": "any", + "gpu": "none" + }, + "setup": [ + { + "kind": "service", + "service": "httpd", + "label": "Start Apache (httpd)" + }, + { + "kind": "service", + "service": "mysql", + "label": "Start MySQL" + }, + { + "kind": "command", + "run": "mysql_secure_installation", + "label": "Secure your MySQL install (you run this)", + "external": true + }, + { + "kind": "note", + "text": "Enable PHP in Apache: add the PHP module to httpd.conf (LoadModule php_module …) and set a PHP handler, then restart Apache. See the PHP + Apache docs below." + } + ], + "caveats": "Apache and MySQL run as background services on your machine. PHP is wired into Apache via httpd.conf manually β€” follow the setup steps.", + "links": [ + { + "label": "Apache HTTP Server docs", + "url": "https://httpd.apache.org/docs/" + }, + { + "label": "PHP docs", + "url": "https://www.php.net/docs.php" + }, + { + "label": "MySQL docs", + "url": "https://dev.mysql.com/doc/" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + }, + { + "id": "lemp", + "name": "LEMP Stack", + "tagline": "Nginx, MariaDB, and PHP-FPM β€” a fast, modern web stack", + "description": "A modern take on the classic web stack: Nginx serves your site with high concurrency, MariaDB (a drop-in MySQL replacement) stores your data, and PHP-FPM runs your application code. It's the go-to setup for Laravel, WordPress, and high-traffic PHP apps that want Nginx's performance. Install all three as background services for a fast local web server in one step.", + "category": "Development", + "icon": "server", + "packages": [ + { + "name": "nginx", + "kind": "formula" + }, + { + "name": "mariadb", + "kind": "formula" + }, + { + "name": "php", + "kind": "formula" + } + ], + "requires": { + "minRamGB": 4, + "recommendedRamGB": 8, + "minDiskGB": 5, + "arch": "any", + "gpu": "none" + }, + "setup": [ + { + "kind": "service", + "service": "nginx", + "label": "Start Nginx" + }, + { + "kind": "service", + "service": "mariadb", + "label": "Start MariaDB" + }, + { + "kind": "service", + "service": "php", + "label": "Start PHP-FPM" + }, + { + "kind": "command", + "run": "mariadb-secure-installation", + "label": "Secure your MariaDB install (you run this)", + "external": true + }, + { + "kind": "note", + "text": "Wire PHP-FPM into Nginx: add a fastcgi_pass (e.g. 127.0.0.1:9000) to your server block, then reload Nginx. See the Nginx + PHP-FPM docs below." + } + ], + "caveats": "Nginx, MariaDB, and PHP-FPM run as background services. PHP-FPM must be wired into your Nginx server block manually β€” follow the setup steps.", + "links": [ + { + "label": "Nginx docs", + "url": "https://nginx.org/en/docs/" + }, + { + "label": "PHP-FPM docs", + "url": "https://www.php.net/manual/en/install.fpm.php" + }, + { + "label": "MariaDB docs", + "url": "https://mariadb.org/documentation/" + } + ], + "maintainer": "brew-browser", + "addedIn": "0.7.0" + }, { "id": "local-llm", "name": "Local LLMs", "tagline": "Run models locally with Ollama + a web UI", + "description": "Run open-weight models entirely on your own machine β€” private, offline-capable, no API bills. Ollama pulls and serves models with one command and exposes an OpenAI-compatible API on localhost:11434; Open WebUI adds a polished browser chat UI on top. Best for developers and privacy-conscious users who want ChatGPT-style workflows without sending data to the cloud.", "category": "AI", "icon": "brain", "packages": [ @@ -203,6 +460,7 @@ "id": "media", "name": "Media Toolkit", "tagline": "Transcode, download, and play β€” ffmpeg, yt-dlp, mpv", + "description": "A complete command-line media pipeline. FFmpeg transcodes and streams virtually any format, yt-dlp downloads audio and video from thousands of sites, and mpv plays it all with scriptable precision. HandBrake adds one-command, preset-based encoding (like 'Fast 1080p30') when you don't want to hand-write ffmpeg filter graphs. Lightweight and runs on any Mac or Linux box.", "category": "Media", "icon": "clapperboard", "packages": [ @@ -217,6 +475,10 @@ { "name": "mpv", "kind": "formula" + }, + { + "name": "handbrake", + "kind": "formula" } ], "setup": [ @@ -240,6 +502,10 @@ { "label": "mpv", "url": "https://mpv.io" + }, + { + "label": "HandBrake", + "url": "https://handbrake.fr" } ], "maintainer": "brew-browser", @@ -249,6 +515,7 @@ "id": "web-dev", "name": "Web Dev Starter", "tagline": "Node.js + pnpm β€” the modern JS toolchain", + "description": "Everything you need to clone a repo, install dependencies, and push your first commit β€” the modern JavaScript baseline. Node.js is the safe-default runtime and pnpm is the fast, disk-efficient package manager; Git and the GitHub CLI cover version control, pull requests, and Actions from the terminal. For an AI-assisted, agent-driven setup, see the Agentic Web Dev bundle.", "category": "Development", "icon": "code", "packages": [ @@ -259,12 +526,20 @@ { "name": "pnpm", "kind": "formula" + }, + { + "name": "git", + "kind": "formula" + }, + { + "name": "gh", + "kind": "formula" } ], "setup": [ { "kind": "command", - "run": "node -v && pnpm -v", + "run": "node -v && pnpm -v && git -v && gh --version", "label": "Verify the toolchain (you run this)", "external": true } @@ -278,6 +553,14 @@ { "label": "pnpm", "url": "https://pnpm.io" + }, + { + "label": "Git", + "url": "https://git-scm.com" + }, + { + "label": "GitHub CLI", + "url": "https://cli.github.com" } ], "maintainer": "brew-browser", diff --git a/memory-bank/releases/0.7.0/README.md b/memory-bank/releases/0.7.0/README.md index ff2a65f..4c1b5bc 100644 --- a/memory-bank/releases/0.7.0/README.md +++ b/memory-bank/releases/0.7.0/README.md @@ -40,21 +40,31 @@ Grouped by theme; every item is a merged PR on `main` unless marked. --- -## Headline feature β€” Bundles / Recipes (planned, plan complete) -Curated **one-click package stacks** with post-install setup guidance, **capability-gated** by a zero-install system profile (RAM/arch/GPU/disk) so an 8GB Mac isn't told to install a local-LLM stack it can't run. New "Bundles" nav section; brew-native install auto-runs, external steps (model pulls, etc.) are copy-paste. Both shells. **Contributor-friendly:** recipes are validated JSON files anyone can PR against a published contract. +## Headline feature β€” Bundles / Recipes (BUILT + refined 2026-07-13) +Curated **one-click package stacks** with post-install setup guidance, **capability-gated** by a zero-install system profile (RAM/arch/GPU/disk) so an 8GB Mac isn't told to install a local-LLM stack it can't run. "Bundles" nav section; brew-native install auto-runs, external steps (model pulls, etc.) are copy-paste. Both shells. **Contributor-friendly:** recipes are validated JSON files anyone can PR against a published contract. -**Plan (approved 2026-07-12, not yet built):** overview in **[bundles-plan.md](./bundles-plan.md)**; 5 milestone build docs + the contributor contract + July-2026 capability baselines under **[`bundles/`](./bundles/)**: +**Plan docs (2026-07-12):** overview in **[bundles-plan.md](./bundles-plan.md)**; contributor contract + July-2026 capability baselines + 5 milestone build docs under **[`bundles/`](./bundles/)**: - [recipe-contract.md](./bundles/recipe-contract.md) Β· [capability-baselines.md](./bundles/capability-baselines.md) - M1 [capability-engine](./bundles/m1-capability-engine.md) β†’ M2 [contract-and-loader](./bundles/m2-recipe-contract-and-loader.md) β†’ M3 [browse-and-install](./bundles/m3-browse-and-install.md) β†’ M4 [setup-guidance](./bundles/m4-setup-guidance.md) β†’ M5 [live-refresh-and-contributions](./bundles/m5-live-refresh-and-contributions.md) -Initial 6 recipes (tokens/taps verified 2026-07-12, all official taps): Local LLMs (`ollama`+`open-webui`), Image Gen (`comfy`), Graphics (`inkscape`/`gimp`/`krita`), Media (`ffmpeg`/`yt-dlp`/`mpv`), Web Dev (`node`+…), Databases (`postgresql@16`/`redis`). +**Post-M5 refinement (2026-07-13, this session, both shells):** +- **List + Details pane** β€” Bundles moved from a card-grid + modal to the app's canonical master-list + right-side Details pane (matches Library/Trending exactly: no auto-select, pane closed on entry, βœ•/section-switch closes). Tauri = shell-level resizable `