Compare commits

...

30 commits

Author SHA1 Message Date
Breadway
6cb7d4bcfb feat: add breadpaper to ecosystem registry
All checks were successful
Build and publish package / package (push) Successful in 1m35s
Mirror to GitHub / mirror (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 22:55:12 +08:00
Breadway
77417d5521 bread-theme 0.2.8: fix live reload — watch the dir, not the file
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
Build and publish package / package (push) Successful in 1m10s
The stylesheet is written with write-tmp-then-rename (atomic), which replaces the
inode. A monitor on the file itself caught the first replace then went deaf
(inotify reports DELETE_SELF and never re-arms), so `bread-theme reload` updated
the file but no running GUI ever recoloured. Monitor the parent directory and
filter for the stylesheet filename instead — that fires on every reload. Verified
against a real atomic-rename write (event arrives as Renamed with the new name in
other_file, so match both file and other_file).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:53:35 +08:00
Breadway
ea87083c06 bread-theme 0.2.7: luminance-picked ink + live reload
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
Build and publish package / package (push) Successful in 1m22s
Readability: pywal can emit a light value in any palette slot, and the shared
sheet assumed dark backgrounds (white text), so text vanished on light surfaces/
accents. Add ink_on() — a WCAG-luminance pick of near-black/near-white per
background — exposed as @on-bg/@on-surface/@on-accent/@on-red/@on-overlay. The
component sheet now sets colour on containers and lets labels inherit (de-emphasis
via opacity), dropping the blanket `label { color }` rule that overrode
coloured-background text. pywal hues are untouched.

Hot reload: add gtk::apply_app_css(closure) — applies an app's own CSS now and
re-runs the closure whenever the shared theme file is rewritten, so apps recolour
in place. New `bread-theme reload` verb rewrites the file (atomic rename trips
every running GUI's monitor) — the command to run after changing pywal colours.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:35:03 +08:00
Breadway
0c8c5c00e4 docs: ecosystem overview, keybinds, shared-theming, design system
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s
Build and publish package / package (push) Successful in 1m13s
README: add recommended keybinds and a Theming section documenting the
bread-theme generator + live-reloaded shared stylesheet. Add
BREAD_DESIGN_SYSTEM.md to the repo (the README links it) and update it to
describe the single-source-of-truth architecture and the migrated apps
(incl. bos-settings).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:07:03 +08:00
Breadway
8b2572b90b bread-theme: shared component stylesheet + generator CLI
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s
Adds the single source of truth for bread GUI styling so the apps stop
each re-implementing (and drifting on) component CSS:

- stylesheet(&Palette): full component sheet (buttons, entries, switches,
  dropdowns, lists/rows/sidebars, cards, chips, scrollbars, headings) built
  from the design tokens + a canonical @define-color block (surface=color0,
  overlay=color7, accent=color4).
- render() / shared_css_path() / write_shared_css(): render for the current
  pywal palette and write to $XDG_RUNTIME_DIR/bread/theme.css.
- gtk::apply_shared(): load that file (or a rendered fallback) at APPLICATION
  priority and watch it, so every app recolours live with no rebuild.
- new `bread-theme` CLI (generate|path|print) — gtk-free, light. Run at
  session start and on palette change; apps pick it up via the file watch.

The contract is a CSS *file*, so apps stay decoupled from this crate's gtk4
version. Tests cover the stylesheet, path, and render helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:43:09 +08:00
Breadway
606f0791ba Disable debug package so the main package publishes correctly
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s
Build and publish package / package (push) Successful in 1m34s
makepkg's debug split produced a -debug pkg; the upload's head -1 could
grab it instead of the main package. !debug yields a single package.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:00:47 +08:00
Breadway
5094064925 Use REGISTRY_TOKEN (scoped write:package) for registry publish
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
Build and publish package / package (push) Successful in 1m15s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:55:39 +08:00
Breadway
8076b1333e Disable LTO in PKGBUILD (vendored ring/mlua static libs vs makepkg -flto)
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 17:06:52 +08:00
Breadway
36d2a09294 Add Arch packaging and Forgejo workflows for bakery
All checks were successful
Mirror to GitHub / mirror (push) Successful in 7s
- packaging/arch/PKGBUILD: builds the bakery CLI from the workspace
- .forgejo/workflows/package.yml: publishes to the [breadway] Arch registry on tag
- .forgejo/workflows/mirror.yml: mirrors to GitHub

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 16:40:51 +08:00
Breadway
85a1a867ce fix: look for bakery.toml in version_dir, not top-level package dir 2026-06-11 14:42:40 +08:00
Breadway
cbe66b92e5 fix: ignore pacman-dependent tests on non-Arch CI environments
Some checks failed
release / build (push) Failing after 36s
2026-06-11 14:27:43 +08:00
Breadway
c120ed8af0 chore: bump version to 0.2.3 2026-06-11 14:20:44 +08:00
Breadway
a8be86be03 fix: comprehensive bakery package manager audit and repair
Critical fixes:
- gen-index.sh: emit services, config, optional_system_deps from bakery.toml;
  parse product list from registry TOML instead of hardcoded array; fail loudly
  when bakery.toml is missing (was silently producing empty metadata in prod)
- install.rs: download service units and example configs from dl server at
  install time (were never fetched); check systemctl exit codes (were swallowed);
  save state before file cleanup in remove_package (was inconsistent on error)
- doctor.rs: rewrite dep detection to use `pacman -Q` as primary (no more
  dependency on `which` or pkg-config name mismatches); add optional_system_deps
  support returning (missing, warnings) — warnings print but never block install
- get.sh: fix GitHub fallback URL (was 404 for both latest and versioned
  releases); add SHA-256 checksum verification using published .sha256 file

High priority fixes:
- bakery doctor <unknown-pkg>: exit non-zero (was silently passing)
- bakery update: add --all flag (documented in README but missing from CLI);
  add doctor gate before update (was bypassing dep check)
- bread_deps: now resolved recursively with cycle detection (was ignored)
- manifest.rs: add artifact_urls() helper and optional_system_deps field
- state.rs: atomic save via tmp+rename; cmd_info shows optional_system_deps

Tests: 17 new unit tests across doctor, download, install, state modules;
scripts/test-gen-index.sh fixture test for full pipeline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:37:09 +08:00
Breadway
a4ea036a7c fix: force index refresh on install, fetch once for multi-package installs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 15:23:54 +08:00
Breadway
7a17fcaa93 chore: bump version to 0.2.2
Some checks failed
release / build (push) Failing after 26s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 15:16:04 +08:00
Breadway
a9175aa4ef feat: add --version flag to bakery CLI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 15:13:34 +08:00
Breadway
f7957301d5 feat: register bakery as an installable package, bump to 0.2.1
Some checks failed
release / build (push) Failing after 36s
bakery can now update itself via `bakery update all`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 14:58:09 +08:00
Breadway
b97882e715 feat: multi-package install and bakery update all
Some checks failed
release / build (push) Failing after 32s
- bakery install now accepts one or more package names
- bakery update all treated as update-everything (same as bare update)
- bump version to 0.2.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 14:51:20 +08:00
Breadway
5edb5bae31 docs: fix product descriptions and system deps in README
- breadbox: was 'cloud sync daemon + file browser'; it's actually a GTK4
  fuzzy app launcher; breadbox-sync resolves icons, not cloud data
- breadcrumbs: was 'network information CLI'; it's a profile-aware Wi-Fi
  state machine with Tailscale integration and a watch daemon
- breadpad: was 'scratchpad / quick-note app'; call out AI classification,
  reminders, recurrence, and the breadman viewer
- system deps: add gtk4-layer-shell for breadbox and breadpad (both use
  layer-shell windows); drop spurious 'dbus' from breadbox

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 09:28:39 +08:00
Breadway
74a3dc5cfa fix: use relative symlink for latest to work inside Docker containers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:59:06 +08:00
Breadway
13b7ce9a19 fix: strip arch suffix on install, fix doubled org in github_url
- bakery: strip -x86_64 / -aarch64 / -arm64 / -armv7 suffix when placing
  binary so `breadcrumbs-x86_64` installs as `breadcrumbs`
- gen-index.sh: GH_BASE was "github.com/Breadway" but repo slugs already
  include the org, producing doubled paths; change to "github.com"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:53:39 +08:00
Breadway
0057dfa89a docs: expand README with install, usage, and pipeline overview
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:17:27 +08:00
Breadway
fe68d08967 fix: add contents: write permission for GitHub Release creation
Some checks failed
release / build (push) Failing after 43s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:00:43 +08:00
Breadway
c6e0db6b72 fix: create GitHub Release before uploading artifacts 2026-06-06 23:52:29 +08:00
Breadway
d39c82d697 fix: skip non-binary files in binary loop; don't capture stderr into pkg 2026-06-06 23:43:02 +08:00
Breadway
6b5f4f475f Fix release pipeline: bakery.toml discovery and ECOSYSTEM_DIR on hestia
- Add bakery.toml describing the bakery binary as an installable product
- gen-index.sh: check DL_DIR/<pkg>/bakery.toml first (written by each
  product's release workflow), fall back to sibling checkout for local dev
- gen-index.sh: include bakery itself in the products list
- release.yml: use GITHUB_WORKSPACE instead of ECOSYSTEM_DIR (the
  bread-ecosystem runner IS the checkout, no separate clone needed)
- release.yml: copy bakery.toml to DL_DIR during artifact prep

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 22:30:29 +08:00
Breadway
b059dfb991
Merge pull request #1 from Breadway/copilot/create-readme-md
Add workspace README.md
2026-06-06 14:02:49 +08:00
copilot-swe-agent[bot]
7bd215729d
Capitalize ecosystem references in README.md 2026-06-06 05:52:01 +00:00
copilot-swe-agent[bot]
743a6b203c
Fix naming consistency in README.md 2026-06-06 05:51:41 +00:00
copilot-swe-agent[bot]
d637b63ca0
Add README.md 2026-06-06 05:50:18 +00:00
24 changed files with 1745 additions and 156 deletions

View file

@ -0,0 +1,21 @@
name: Mirror to GitHub
on:
push:
branches: ['**']
tags: ['**']
jobs:
mirror:
runs-on: [self-hosted, hestia]
steps:
- name: Mirror to GitHub
run: |
set -euo pipefail
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
cd repo.git
# Mirror only branches and tags (not refs/pull/*, which GitHub rejects);
# --prune deletes GitHub refs that no longer exist on Forgejo.
git push --prune \
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/bread-ecosystem.git" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'

View file

@ -0,0 +1,40 @@
name: Build and publish package
on:
push:
tags: ['v*']
jobs:
package:
runs-on: [self-hosted, hestia]
container:
image: archlinux:latest
steps:
# Note: no actions/checkout — the archlinux image has no Node, which JS
# actions require. Everything runs as shell steps and clones manually.
- name: Build and publish
env:
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
pacman -Syu --noconfirm base-devel git rust cargo
useradd -m builder
git config --global --add safe.directory '*'
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
cd /home/builder/src
git archive --format=tar.gz --prefix="bakery-${VERSION}/" HEAD \
> packaging/arch/bakery-${VERSION}.tar.gz
SHA=$(sha256sum packaging/arch/bakery-${VERSION}.tar.gz | awk '{print $1}')
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD
chown -R builder:builder /home/builder/src
# --nocheck: packaging builds the artifact; tests belong in a CI job.
su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck"
PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1)
curl -fsS -X PUT \
-H "Authorization: token ${PUBLISH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${PKG}" \
"https://git.breadway.dev/api/packages/Breadway/arch/os"

View file

@ -4,9 +4,11 @@ on:
push: push:
tags: ["v*"] tags: ["v*"]
permissions:
contents: write
env: env:
DL_DIR: /srv/breadway-dl DL_DIR: /srv/breadway-dl
ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem
jobs: jobs:
build: build:
@ -15,7 +17,10 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: build - name: build
run: cargo build --release --locked run: cargo build --release --locked -p bakery
- name: test
run: cargo test --locked --workspace
- name: prepare artifacts - name: prepare artifacts
run: | run: |
@ -28,11 +33,11 @@ jobs:
sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \ sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/bakery-x86_64.sha256" > "${PKG_DIR}/bakery-x86_64.sha256"
# Update the 'latest' symlink. cp bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${PKG_DIR}" "${DL_DIR}/bakery/latest" ln -sfn "${VERSION}" "${DL_DIR}/bakery/latest"
- name: regenerate index.json - name: regenerate index.json
run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" run: bash "${GITHUB_WORKSPACE}/scripts/gen-index.sh"
- name: upload to GitHub Release - name: upload to GitHub Release
env: env:
@ -40,6 +45,8 @@ jobs:
run: | run: |
VERSION="${GITHUB_REF_NAME#v}" VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="${DL_DIR}/bakery/${VERSION}" PKG_DIR="${DL_DIR}/bakery/${VERSION}"
gh release create "${GITHUB_REF_NAME}" \
--title "bakery v${VERSION}" --generate-notes 2>/dev/null || true
gh release upload "${GITHUB_REF_NAME}" \ gh release upload "${GITHUB_REF_NAME}" \
"${PKG_DIR}/bakery-x86_64" \ "${PKG_DIR}/bakery-x86_64" \
"${PKG_DIR}/bakery-x86_64.sha256" \ "${PKG_DIR}/bakery-x86_64.sha256" \

118
BREAD_DESIGN_SYSTEM.md Normal file
View file

@ -0,0 +1,118 @@
# Bread Design System
Unified visual identity for breadbar, breadbox, breadpad/breadman, and
bos-settings.
## Architecture (single source of truth)
The tokens below are implemented once in the **`bread-theme`** crate as
`stylesheet(&Palette)` — the full component stylesheet (buttons, entries,
switches, lists/rows/sidebars, cards, chips, scrollbars, headings) over a
canonical `@define-color` palette (`surface`=color0, `overlay`=color7,
`accent`=color4).
- The `bread-theme` **CLI** renders it from the live pywal palette to
`$XDG_RUNTIME_DIR/bread/theme.css` (run at login and from a pywal hook).
- Every GUI loads that file via `bread_theme::gtk::apply_shared()` and
**live-reloads** it, then layers on only its own app-specific rules.
Result: one definition, no per-app drift, and palette changes recolour the
whole desktop with no rebuilds. Apps reference the shared `@define-color`
names rather than raw palette slots.
## Typography
- **Font Family**: Varela Round, sans-serif
- **Base Size**: 14px
- **Secondary**: 12px (metadata, helper text, secondary labels)
- **Font Weight**: Normal (400) for body, Bold (700) for emphasis
## Spacing Scale (4px units)
Use these values consistently across all projects:
- **xs**: 4px (small gaps, internal padding)
- **sm**: 8px (default spacing between elements)
- **md**: 12px (medium spacing, main padding)
- **lg**: 16px (large padding, major spacing)
- **xl**: 20px (extra large spacing, section breaks)
## Border Radius
Establish a visual hierarchy with consistent rounding:
- **Primary** (buttons, cards, main containers): **8px**
- **Secondary** (input fields, chips, entries): **6px**
- **Tertiary** (small interactive elements): **4px**
- **Pill** (fully rounded buttons, badges): **999px**
## Color System
All projects use **pywal dynamic theming** with **Catppuccin Mocha** as the fallback palette:
- **Background**: `#1e1e2e` (Catppuccin)
- **Foreground**: `#cdd6f4` (Catppuccin)
- **Surface**: `#181825` (Catppuccin)
- **Accent**: Dynamic (from pywal)
Color palette slots (via wal):
- color0color7: ANSI colors
- Semantic: red, green, yellow, blue, pink, teal
## Component Standards
### Buttons
- Border Radius: 8px
- Padding: 8px 16px (primary), 4px 8px (secondary)
- Font Size: 14px
- Background: Theme accent color
### Input Fields
- Border Radius: 6px
- Padding: 12px 16px
- Font Size: 14px
- Border: 1px or 2px solid (blue on focus)
### Cards
- Border Radius: 8px
- Padding: 12px
- Margin: 8px
- Box Shadow: Optional, for depth
### Stat Labels
- Font Size: 14px
- Margin Right (between icon/text): 5px
- Group Margin Right: 12px
### Notification Cards
- Border Radius: 8px
- Padding: 12px
- Margin Bottom: 8px
- Font Size: 14px (summary), 12px (body)
## Current Implementation
All GUI apps load `bread_theme::stylesheet` (via the generated shared file) and
add only app-specific rules:
- **breadbar** — shared base + bar window, workspace buttons, stats, notification
and OSD cards.
- **breadbox** — shared base + launcher panel, search entry, result rows.
- **breadpad / breadman** — shared base + capture popup, type chips, note cards,
reminder window, sidebar rows.
- **bos-settings** — shared base + content padding only (was previously a
hardcoded Nord palette; migrated to the shared stylesheet).
- **breadcrumbs** — CLI tool; ANSI colours only, no GUI styling.
> Palette note: the fallback is Catppuccin Mocha, but installs (e.g. BOS) drive
> the real palette from pywal — BOS ships a black-base palette.
## Future Consistency Checks
When adding new components or updating existing ones:
1. Use Varela Round for all text
2. Set base font size to 14px (12px for secondary)
3. Use spacing scale (4px units: 4, 8, 12, 16, 20)
4. Use border radius from this system (8px default, 6px secondary)
5. Leverage pywal colors for dynamic theming
6. Keep margins/padding consistent across similar components

269
Cargo.lock generated
View file

@ -81,7 +81,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "bakery" name = "bakery"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@ -91,6 +91,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"tempfile",
"toml 0.8.23", "toml 0.8.23",
"ureq", "ureq",
] ]
@ -118,7 +119,7 @@ dependencies = [
[[package]] [[package]]
name = "bread-theme" name = "bread-theme"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"dirs", "dirs",
"gtk4", "gtk4",
@ -322,6 +323,22 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]] [[package]]
name = "field-offset" name = "field-offset"
version = "0.3.6" version = "0.3.6"
@ -348,6 +365,12 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@ -497,6 +520,19 @@ dependencies = [
"wasi", "wasi",
] ]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
"wasip3",
]
[[package]] [[package]]
name = "gio" name = "gio"
version = "0.22.6" version = "0.22.6"
@ -687,6 +723,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
@ -811,6 +856,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]] [[package]]
name = "idna" name = "idna"
version = "1.1.0" version = "1.1.0"
@ -839,7 +890,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown 0.17.1",
"serde",
"serde_core",
] ]
[[package]] [[package]]
@ -866,6 +919,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
@ -881,6 +940,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.2" version = "0.8.2"
@ -996,6 +1061,16 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.5.0" version = "3.5.0"
@ -1023,13 +1098,19 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.4.6" version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.2.17",
"libredox", "libredox",
"thiserror", "thiserror",
] ]
@ -1042,7 +1123,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [ dependencies = [
"cc", "cc",
"cfg-if", "cfg-if",
"getrandom", "getrandom 0.2.17",
"libc", "libc",
"untrusted", "untrusted",
"windows-sys 0.52.0", "windows-sys 0.52.0",
@ -1057,6 +1138,19 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.40" version = "0.23.40"
@ -1259,6 +1353,19 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@ -1393,6 +1500,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@ -1459,6 +1572,24 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen 0.57.1",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.122" version = "0.2.122"
@ -1504,6 +1635,40 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "0.26.11" version = "0.26.11"
@ -1747,6 +1912,100 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.3" version = "0.6.3"

View file

@ -3,7 +3,7 @@ members = ["bakery", "bread-theme"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.2.3"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Breadway <rileyhorsham@gmail.com>"] authors = ["Breadway <rileyhorsham@gmail.com>"]

146
README.md Normal file
View file

@ -0,0 +1,146 @@
# Bread Ecosystem
A collection of Rust tools for the Linux desktop (Hyprland / Wayland / Arch).
Install any product with a single command — no Rust toolchain required.
```sh
curl https://breadway.dev/get | sh
bakery install breadbar
```
## Products
| Package | Description |
|---------|-------------|
| `bread` | Reactive automation daemon (`breadd`) + CLI — Lua scripting over Hyprland, udev, power, network, and Bluetooth events |
| `breadbar` | GTK4 status bar (workspaces, clock, CPU/RAM/battery/WiFi/Bluetooth) and D-Bus notification daemon for Hyprland |
| `breadbox` | GTK4 fuzzy app launcher for Hyprland with context-aware sorting; ships an icon-sync daemon (`breadbox-sync`) |
| `breadcrumbs` | Profile-aware Wi-Fi state machine with Tailscale exit-node management and a self-healing watch daemon |
| `breadpad` | Quick-capture scratchpad popup with AI-powered note classification, reminders, recurrence, and a full note viewer (`breadman`) |
## Recommended keybinds
The ecosystem assumes a Hyprland setup with `SUPER` as the modifier. The
conventional bindings (used by BOS and recommended for any install):
| Keys | Action |
|------|--------|
| `SUPER+Space` | `breadbox` — app launcher |
| `SUPER+U` | `breadpad` — quick-capture notes/reminders |
| `SUPER+M` | `breadman` — note viewer / manager |
| `SUPER+,` | settings (`bos-settings`, where installed) |
`breadbar` and `breadd` are services started at login (`exec-once`), not bound
to keys.
## Theming
All GUIs share one look via `bread-theme`. The `bread-theme` CLI renders the
component stylesheet from your pywal palette (Catppuccin Mocha fallback) to
`$XDG_RUNTIME_DIR/bread/theme.css`; every app loads that file and **live-reloads**
it, so changing your wallpaper recolours the whole ecosystem with no rebuilds:
```sh
wal -i ~/Pictures/wall.png # regenerate pywal palette
bread-theme generate # render the shared stylesheet (run from a wal hook)
```
See [`BREAD_DESIGN_SYSTEM.md`](BREAD_DESIGN_SYSTEM.md) for the tokens (fonts,
spacing, radii, colour roles) the stylesheet is built from.
## Installing bakery
`bakery` is the package manager for the ecosystem. Install it with the bootstrap script:
```sh
curl https://breadway.dev/get | sh
# or
curl -sSfL https://get.breadway.dev | sh
```
The script downloads the prebuilt `bakery` binary to `~/.local/bin/bakery` and prints a note if that directory isn't on your `PATH` yet.
## Using bakery
```sh
bakery list # all available packages
bakery list --installed # only installed packages
bakery info breadbar # version, binaries, system deps, services
bakery doctor # check system deps for installed packages
bakery doctor breadbar # check system deps for a specific package
bakery install <pkg> # install a package
bakery update <pkg> # update a package
bakery update --all # update everything
bakery remove <pkg> # remove a package (data files are never deleted)
```
`bakery install` runs `doctor` first and bails with a clear message if any system dependency is missing. Binaries land in `~/.local/bin` (override with `BAKERY_BIN_DIR`).
## System dependencies by product
`bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block.
| Package | Required | Optional |
|---------|----------|---------|
| `bakery` | _(statically linked, none)_ | — |
| `bread` | `systemd-libs` `openssl` `zlib` | `bluez` `hyprland` |
| `breadbar` | `gtk4` `gtk4-layer-shell` `iw` `libpulse` | `hyprland` |
| `breadbox` | `gtk4` `gtk4-layer-shell` `librsvg` | `hyprland` |
| `breadcrumbs` | `networkmanager` | `tailscale` `sudo` `xdg-utils` |
| `breadpad` | `gtk4` `gtk4-layer-shell` | `rocm-hip-runtime` `ollama` `hyprland` |
Install all required deps with `sudo pacman -S <packages>`. Use `pacman -Q <pkg>` to check whether any are already present.
## Theming
All GUI products (breadbar, breadbox, breadpad) read pywal colors from
`~/.cache/wal/colors.json` and fall back to Catppuccin Mocha when that file
is absent. Per-app CSS overrides live at `~/.config/<app>/style.css`.
The shared theming logic lives in the `bread-theme` crate in this repo.
## Workspace
This repo is a Cargo workspace:
```
bread-ecosystem/
├── bakery/ # package manager binary
├── bread-theme/ # shared pywal + Catppuccin theming crate
├── registry/ # bread-ecosystem.toml — product registry
└── scripts/
├── get.sh # curl | sh bootstrap
└── gen-index.sh # generates dl.breadway.dev/index.json from release artifacts
```
## Release pipeline
Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has a
`.github/workflows/release.yml` that triggers on `v*` tags. The workflow
runs on a self-hosted runner on hestia, builds a stripped x86_64 binary,
deposits it at `dl.breadway.dev/<pkg>/<version>/`, updates `index.json`,
and mirrors the binary to GitHub Releases as a fallback.
`bakery` always tries `dl.breadway.dev` first and transparently falls back
to the GitHub Release URL recorded in the manifest.
### Release artifact contract
Each product's `release.yml` **must** upload the following files alongside
the binary to `dl.breadway.dev/<name>/<version>/`:
| File | Purpose |
|------|---------|
| `bakery.toml` | Metadata (deps, services, config) read by `gen-index.sh` |
| `<binary>-x86_64.sha256` | Checksum verified by `bakery install` and `get.sh` |
| `*.service` | systemd unit files installed by `bakery install` |
| `*.example.toml` / `config.example.toml` | Example configs copied on first install |
`gen-index.sh` **fails loudly** if `bakery.toml` is missing — this is by
design to catch omissions in the release workflow before they silently
produce empty metadata in production.
## License
MIT

9
bakery.toml Normal file
View file

@ -0,0 +1,9 @@
name = "bakery"
description = "Bread ecosystem package manager"
binaries = ["bakery"]
system_deps = []
optional_system_deps = []
bread_deps = []
[install]
post_install = []

View file

@ -18,3 +18,6 @@ sha2 = { workspace = true }
hex = { workspace = true } hex = { workspace = true }
clap = { workspace = true } clap = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
[dev-dependencies]
tempfile = "3"

View file

@ -1,37 +1,45 @@
use anyhow::Result; use anyhow::Result;
use std::process::Command; use std::process::Command;
/// Check whether a list of system dependencies are present. pub struct DepReport {
/// Returns (missing, warnings) — missing are hard fails, warnings are advisory. /// Required deps that are not present — blocks install.
pub fn check_deps(deps: &[String]) -> Result<Vec<String>> { pub missing: Vec<String>,
let mut missing = Vec::new(); /// Optional deps that are not present — advisory only, never blocks.
for dep in deps { pub warnings: Vec<String>,
if !dep_present(dep) {
missing.push(dep.clone());
}
}
Ok(missing)
} }
fn dep_present(dep: &str) -> bool { pub fn check_deps(required: &[String], optional: &[String]) -> Result<DepReport> {
// Try `which` first (covers executables like `iw`, `nmcli`). Ok(DepReport {
if which(dep) { missing: required.iter().filter(|d| !dep_present(d)).cloned().collect(),
warnings: optional.iter().filter(|d| !dep_present(d)).cloned().collect(),
})
}
fn dep_present(pkg: &str) -> bool {
// Primary: `pacman -Q` uses the exact Arch package name — no name mapping needed.
if pacman_installed(pkg) {
return true; return true;
} }
// Try `pkg-config --exists` for library packages (gtk4, gtk4-layer-shell, librsvg). // Fallback for environments without pacman: native PATH search then pkg-config.
pkg_config_exists(dep) path_has(pkg) || pkg_config_exists(pkg)
} }
fn which(bin: &str) -> bool { fn pacman_installed(pkg: &str) -> bool {
Command::new("which") Command::new("pacman")
.arg(bin) .args(["-Q", pkg])
.output() .output()
.map(|o| o.status.success()) .map(|o| o.status.success())
.unwrap_or(false) .unwrap_or(false)
} }
/// Check PATH without shelling out to `which` (avoids the external dependency).
fn path_has(bin: &str) -> bool {
std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).any(|dir| dir.join(bin).is_file()))
.unwrap_or(false)
}
fn pkg_config_exists(lib: &str) -> bool { fn pkg_config_exists(lib: &str) -> bool {
// Arch package names map directly to pkg-config names for GTK libs.
Command::new("pkg-config") Command::new("pkg-config")
.arg("--exists") .arg("--exists")
.arg(lib) .arg(lib)
@ -40,33 +48,90 @@ fn pkg_config_exists(lib: &str) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
/// Print a formatted doctor report for a list of system deps. /// Print a formatted doctor report for a package's system deps.
/// Returns true if all deps are satisfied. /// Returns true if all *required* deps are satisfied.
pub fn report(package_name: &str, deps: &[String]) -> bool { pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool {
if deps.is_empty() { if required.is_empty() && optional.is_empty() {
println!(" {package_name}: no system deps required"); println!(" {package_name}: no system deps required");
return true; return true;
} }
match check_deps(deps) { match check_deps(required, optional) {
Err(e) => { Err(e) => {
eprintln!(" error running doctor: {e}"); eprintln!(" error running doctor for {package_name}: {e}");
false false
} }
Ok(missing) => { Ok(rep) => {
if missing.is_empty() { for warn in &rep.warnings {
println!(" {package_name}: all system deps satisfied"); eprintln!(
" {package_name}: optional dep not found: {warn} \
(install for full functionality)"
);
}
if rep.missing.is_empty() {
println!(" {package_name}: all required system deps satisfied");
true true
} else { } else {
eprintln!( eprintln!(
" {package_name}: missing system deps: {}", " {package_name}: missing system deps: {}",
missing.join(", ") rep.missing.join(", ")
);
eprintln!(
" install with: sudo pacman -S {}",
missing.join(" ")
); );
eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" "));
false false
} }
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_deps_pass() {
let rep = check_deps(&[], &[]).unwrap();
assert!(rep.missing.is_empty());
assert!(rep.warnings.is_empty());
}
// This test only runs on systems where pacman is available (Arch Linux).
#[test]
#[ignore]
fn pacman_finds_itself() {
assert!(pacman_installed("pacman"));
}
#[test]
fn path_has_finds_sh() {
assert!(path_has("sh"));
}
#[test]
fn missing_required_dep_detected() {
let rep = check_deps(
&["this-package-does-not-exist-xyzzy42".to_string()],
&[],
)
.unwrap();
assert_eq!(rep.missing.len(), 1);
assert!(rep.warnings.is_empty());
}
#[test]
fn missing_optional_dep_becomes_warning_not_error() {
let rep = check_deps(
&[],
&["this-package-does-not-exist-xyzzy42".to_string()],
)
.unwrap();
assert!(rep.missing.is_empty());
assert_eq!(rep.warnings.len(), 1);
}
// This test only runs on systems where pacman is available (Arch Linux).
#[test]
#[ignore]
fn installed_dep_not_missing() {
let rep = check_deps(&["pacman".to_string()], &[]).unwrap();
assert!(rep.missing.is_empty());
}
}

View file

@ -45,3 +45,34 @@ fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
} }
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
use sha2::{Digest, Sha256};
fn sha256_hex(data: &[u8]) -> String {
hex::encode(Sha256::digest(data))
}
#[test]
fn verify_correct_hash() {
let bytes = b"hello bakery";
let hash = sha256_hex(bytes);
assert!(verify_sha256(bytes, &hash).is_ok());
}
#[test]
fn verify_wrong_hash_fails() {
let bytes = b"hello bakery";
let wrong = "0".repeat(64);
assert!(verify_sha256(bytes, &wrong).is_err());
}
#[test]
fn verify_empty_bytes() {
let bytes = b"";
let hash = sha256_hex(bytes);
assert!(verify_sha256(bytes, &hash).is_ok());
}
}

View file

@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use crate::download::fetch_and_place; use crate::download::fetch_and_place;
use crate::manifest::{Package, Service}; use crate::manifest::{fetch_binary, Package, Service};
use crate::state::{InstalledPackage, State}; use crate::state::{InstalledPackage, State};
pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> { pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> {
@ -12,20 +12,21 @@ pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> {
// 1. Download and verify all binaries. // 1. Download and verify all binaries.
let mut binary_names = Vec::new(); let mut binary_names = Vec::new();
for bin in &pkg.binaries { for bin in &pkg.binaries {
let dest = bin_dir.join(&bin.name); let install_name = strip_arch_suffix(&bin.name);
let dest = bin_dir.join(&install_name);
fetch_and_place(bin, &dest)?; fetch_and_place(bin, &dest)?;
binary_names.push(bin.name.clone()); binary_names.push(install_name.to_string());
} }
// 2. Scaffold config dir + example file. // 2. Scaffold config dir + download example file.
if let Some(cfg) = &pkg.config { if let Some(cfg) = &pkg.config {
scaffold_config(cfg)?; scaffold_config(cfg, pkg)?;
} }
// 3. Install systemd user units. // 3. Install systemd user units.
let mut service_names = Vec::new(); let mut service_names = Vec::new();
for svc in &pkg.services { for svc in &pkg.services {
install_service(svc, bin_dir)?; install_service(svc, bin_dir, pkg)?;
service_names.push(svc.unit.clone()); service_names.push(svc.unit.clone());
} }
@ -59,6 +60,8 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> {
return Ok(()); return Ok(());
} }
}; };
// Commit removal immediately — file cleanup below is best-effort.
state.save()?;
// Remove binaries. // Remove binaries.
for bin in &installed.binaries { for bin in &installed.binaries {
@ -103,66 +106,111 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> {
println!(" data preserved at {}", data_dir.display()); println!(" data preserved at {}", data_dir.display());
} }
state.save()?;
println!(" {pkg_name} removed"); println!(" {pkg_name} removed");
Ok(()) Ok(())
} }
fn scaffold_config(cfg: &crate::manifest::ConfigScaffold) -> Result<()> { fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Result<()> {
let dir = expand_tilde(&cfg.dir); let dir = expand_tilde(&cfg.dir);
std::fs::create_dir_all(&dir)?; std::fs::create_dir_all(&dir)?;
if let Some(example) = &cfg.example { if let Some(example) = &cfg.example {
let dest = dir.join(example); let dest = dir.join(example);
if !dest.exists() { if !dest.exists() {
// We don't have the actual example file here at install time — if let Some((primary, fallback)) = pkg.artifact_urls(example) {
// the product repo's release bundle should include it. match fetch_binary(&primary, &fallback) {
// For now just note it; release.yml will bundle example configs. Ok(bytes) => {
println!(" config dir ready at {}", dir.display()); std::fs::write(&dest, &bytes)
println!( .with_context(|| format!("writing {}", dest.display()))?;
" copy your {example} to {} to configure {}", println!(" installed example config at {}", dest.display());
dest.display(), }
dir.display() Err(e) => {
); eprintln!(" warning: could not download example config {example}: {e}");
println!(" config dir created at {}", dir.display());
}
}
} else {
println!(" config dir created at {}", dir.display());
}
} else { } else {
println!(" config at {} already exists, skipping", dest.display()); println!(" config at {} already exists, skipping", dest.display());
} }
} else {
println!(" config dir created at {}", dir.display());
} }
Ok(()) Ok(())
} }
fn install_service(svc: &Service, bin_dir: &Path) -> Result<()> { fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
let service_dir = systemd_user_dir(); let service_dir = systemd_user_dir();
std::fs::create_dir_all(&service_dir)?; std::fs::create_dir_all(&service_dir)?;
let unit_path = service_dir.join(&svc.unit); let unit_path = service_dir.join(&svc.unit);
// The unit file is expected to be bundled alongside the binary in the // Download the unit file if not already present.
// release artifact (or embedded). For now, patch ExecStart if the unit if !unit_path.exists() {
// already exists (same pattern as bread/scripts/install.sh). if let Some((primary, fallback)) = pkg.artifact_urls(&svc.unit) {
if unit_path.exists() { match fetch_binary(&primary, &fallback) {
patch_exec_start(&unit_path, bin_dir)?; Ok(bytes) => {
std::fs::write(&unit_path, &bytes)
.with_context(|| format!("writing {}", unit_path.display()))?;
println!(" downloaded unit {}", unit_path.display());
}
Err(e) => {
eprintln!(" warning: could not download {}: {e}", svc.unit);
}
}
} else {
eprintln!(" warning: no artifact URL to download {}", svc.unit);
}
} }
let _ = Command::new("systemctl") if !unit_path.exists() {
.args(["--user", "daemon-reload"]) eprintln!(
.status(); " warning: unit file {} not found — skipping service setup",
svc.unit
);
return Ok(());
}
if svc.enable { patch_exec_start(&unit_path, bin_dir)?;
if Command::new("systemctl")
.args(["--user", "is-active", "--quiet", &svc.unit]) if !Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status() .status()
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false) .unwrap_or(false)
{ {
let _ = Command::new("systemctl") eprintln!(" warning: systemctl daemon-reload failed");
}
if svc.enable {
let already_active = Command::new("systemctl")
.args(["--user", "is-active", "--quiet", &svc.unit])
.status()
.map(|s| s.success())
.unwrap_or(false);
if already_active {
if Command::new("systemctl")
.args(["--user", "restart", &svc.unit]) .args(["--user", "restart", &svc.unit])
.status(); .status()
.map(|s| s.success())
.unwrap_or(false)
{
println!(" {} restarted", svc.unit); println!(" {} restarted", svc.unit);
} else { } else {
let _ = Command::new("systemctl") eprintln!(" warning: failed to restart {}", svc.unit);
}
} else if Command::new("systemctl")
.args(["--user", "enable", "--now", &svc.unit]) .args(["--user", "enable", "--now", &svc.unit])
.status(); .status()
.map(|s| s.success())
.unwrap_or(false)
{
println!(" {} enabled and started", svc.unit); println!(" {} enabled and started", svc.unit);
} else {
eprintln!(" warning: failed to enable {}", svc.unit);
} }
} }
@ -175,7 +223,6 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
.lines() .lines()
.map(|line| { .map(|line| {
if line.trim_start().starts_with("ExecStart=") { if line.trim_start().starts_with("ExecStart=") {
// Replace only the path prefix, keep args.
let rest = line.splitn(2, '=').nth(1).unwrap_or(""); let rest = line.splitn(2, '=').nth(1).unwrap_or("");
let argv: Vec<&str> = rest.split_whitespace().collect(); let argv: Vec<&str> = rest.split_whitespace().collect();
if let Some(bin_name) = argv.first().and_then(|p| Path::new(p).file_name()) { if let Some(bin_name) = argv.first().and_then(|p| Path::new(p).file_name()) {
@ -195,7 +242,13 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
std::fs::write(unit_path, patched)?; // Preserve trailing newline if the original had one.
let output = if text.ends_with('\n') {
format!("{patched}\n")
} else {
patched
};
std::fs::write(unit_path, output)?;
Ok(()) Ok(())
} }
@ -240,6 +293,16 @@ fn expand_tilde(path: &str) -> PathBuf {
} }
} }
pub fn strip_arch_suffix(name: &str) -> &str {
const SUFFIXES: &[&str] = &["-x86_64", "-aarch64", "-arm64", "-armv7"];
for s in SUFFIXES {
if let Some(base) = name.strip_suffix(s) {
return base;
}
}
name
}
fn warn_path_if_needed(bin_dir: &Path) { fn warn_path_if_needed(bin_dir: &Path) {
let path_var = std::env::var("PATH").unwrap_or_default(); let path_var = std::env::var("PATH").unwrap_or_default();
let bin_str = bin_dir.to_string_lossy(); let bin_str = bin_dir.to_string_lossy();
@ -251,3 +314,53 @@ fn warn_path_if_needed(bin_dir: &Path) {
println!(" export PATH=\"{}:$PATH\"", bin_str); println!(" export PATH=\"{}:$PATH\"", bin_str);
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn strip_known_suffixes() {
assert_eq!(strip_arch_suffix("breadd-x86_64"), "breadd");
assert_eq!(strip_arch_suffix("breadd-aarch64"), "breadd");
assert_eq!(strip_arch_suffix("breadd-arm64"), "breadd");
assert_eq!(strip_arch_suffix("breadd-armv7"), "breadd");
assert_eq!(strip_arch_suffix("bakery-x86_64"), "bakery");
assert_eq!(strip_arch_suffix("breadd"), "breadd");
}
#[test]
fn patch_exec_start_with_args() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.service");
fs::write(&path, "[Service]\nExecStart=/old/path/bin arg1 arg2\n").unwrap();
patch_exec_start(&path, Path::new("/new/bin")).unwrap();
let out = fs::read_to_string(&path).unwrap();
assert!(out.contains("ExecStart=/new/bin/bin arg1 arg2"));
assert!(out.ends_with('\n'));
}
#[test]
fn patch_exec_start_no_args() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.service");
fs::write(&path, "[Service]\nExecStart=/old/path/daemon\n").unwrap();
patch_exec_start(&path, Path::new("/usr/local/bin")).unwrap();
let out = fs::read_to_string(&path).unwrap();
assert!(out.contains("ExecStart=/usr/local/bin/daemon"));
assert!(!out.contains("daemon "));
}
#[test]
fn patch_exec_start_non_exec_lines_unchanged() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.service");
fs::write(&path, "[Unit]\nDescription=foo\nExecStart=/bin/foo\n").unwrap();
patch_exec_start(&path, Path::new("/usr/bin")).unwrap();
let out = fs::read_to_string(&path).unwrap();
assert!(out.contains("Description=foo"));
assert!(out.contains("ExecStart=/usr/bin/foo"));
}
}

View file

@ -6,10 +6,11 @@ mod state;
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "bakery", about = "Package manager for the bread ecosystem")] #[command(name = "bakery", about = "Package manager for the bread ecosystem", version)]
struct Cli { struct Cli {
#[command(subcommand)] #[command(subcommand)]
command: Cmd, command: Cmd,
@ -20,9 +21,10 @@ struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
enum Cmd { enum Cmd {
/// Install a package /// Install one or more packages
Install { Install {
package: String, #[arg(required = true, num_args = 1..)]
packages: Vec<String>,
}, },
/// Remove an installed package (data files are never deleted) /// Remove an installed package (data files are never deleted)
Remove { Remove {
@ -30,8 +32,12 @@ enum Cmd {
}, },
/// Update one or all installed packages /// Update one or all installed packages
Update { Update {
/// Package to update; omit to update all installed packages /// Package to update (omit or use --all to update everything installed)
#[arg(conflicts_with = "all")]
package: Option<String>, package: Option<String>,
/// Update all installed packages
#[arg(long, conflicts_with = "package")]
all: bool,
}, },
/// List packages /// List packages
List { List {
@ -61,27 +67,59 @@ fn main() -> Result<()> {
let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir); let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir);
match cli.command { match cli.command {
Cmd::Install { package } => cmd_install(&package, &bin_dir), Cmd::Install { packages } => {
let index = manifest::load(true)?;
for pkg in &packages {
cmd_install(&index, pkg, &bin_dir)?;
}
Ok(())
}
Cmd::Remove { package } => cmd_remove(&package, &bin_dir), Cmd::Remove { package } => cmd_remove(&package, &bin_dir),
Cmd::Update { package } => cmd_update(package.as_deref(), &bin_dir), Cmd::Update { package, all } => cmd_update(package.as_deref(), all, &bin_dir),
Cmd::List { installed } => cmd_list(installed), Cmd::List { installed } => cmd_list(installed),
Cmd::Info { package } => cmd_info(&package), Cmd::Info { package } => cmd_info(&package),
Cmd::Doctor { package } => cmd_doctor(package.as_deref()), Cmd::Doctor { package } => cmd_doctor(package.as_deref()),
} }
} }
fn cmd_install(name: &str, bin_dir: &std::path::Path) -> Result<()> { fn cmd_install(index: &manifest::Index, name: &str, bin_dir: &std::path::Path) -> Result<()> {
let index = manifest::load(false)?; let mut visited = HashSet::new();
install_with_deps(index, name, bin_dir, &mut visited)
}
/// Recursively installs `name` and any bread_deps, skipping already-installed
/// packages. The `visited` set prevents cycles.
fn install_with_deps(
index: &manifest::Index,
name: &str,
bin_dir: &std::path::Path,
visited: &mut HashSet<String>,
) -> Result<()> {
if !visited.insert(name.to_string()) {
return Ok(());
}
let pkg = index let pkg = index
.get(name) .get(name)
.ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?; .ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?;
// Doctor runs first — bail if system deps are missing. // Install bread_deps first (skip those already recorded in state).
println!("checking system dependencies…"); let state = state::State::load()?;
let missing = doctor::check_deps(&pkg.system_deps)?; for dep in pkg.bread_deps.clone() {
if !missing.is_empty() { if !state.is_installed(&dep) {
eprintln!("missing system dependencies for {name}: {}", missing.join(", ")); println!("installing bread dependency: {dep}");
eprintln!("install with: sudo pacman -S {}", missing.join(" ")); install_with_deps(index, &dep, bin_dir, visited)?;
}
}
println!("checking system dependencies for {name}");
let rep = doctor::check_deps(&pkg.system_deps, &pkg.optional_system_deps)?;
for warn in &rep.warnings {
eprintln!(" note: optional dep not installed: {warn}");
}
if !rep.missing.is_empty() {
eprintln!("missing system deps for {name}: {}", rep.missing.join(", "));
eprintln!("install with: sudo pacman -S {}", rep.missing.join(" "));
bail!("system deps not satisfied"); bail!("system deps not satisfied");
} }
@ -92,15 +130,22 @@ fn cmd_remove(name: &str, bin_dir: &std::path::Path) -> Result<()> {
install::remove_package(name, bin_dir) install::remove_package(name, bin_dir)
} }
fn cmd_update(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> { fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Result<()> {
let index = manifest::load(true)?; // force refresh on update let index = manifest::load(true)?;
let state = state::State::load()?; let state = state::State::load()?;
let targets: Vec<String> = match name { let targets: Vec<String> = if all || name.is_none() {
Some(n) => vec![n.to_string()], state.packages.keys().cloned().collect()
None => state.packages.keys().cloned().collect(), } else {
vec![name.unwrap().to_string()]
}; };
if targets.is_empty() {
println!("no packages installed");
return Ok(());
}
let mut any_failed = false;
for pkg_name in &targets { for pkg_name in &targets {
let installed = match state.packages.get(pkg_name.as_str()) { let installed = match state.packages.get(pkg_name.as_str()) {
Some(p) => p, Some(p) => p,
@ -116,15 +161,45 @@ fn cmd_update(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
continue; continue;
} }
}; };
if installed.version == latest.version { if installed.version == latest.version {
println!("{pkg_name} is already at {}", installed.version); println!("{pkg_name} is already at {}", installed.version);
} else { continue;
}
println!( println!(
"updating {pkg_name} {} → {}", "updating {pkg_name} {} → {}",
installed.version, latest.version installed.version, latest.version
); );
install::install_package(latest, bin_dir)?;
let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) {
Ok(r) => r,
Err(e) => {
eprintln!(" doctor check failed for {pkg_name}: {e}");
any_failed = true;
continue;
} }
};
for warn in &rep.warnings {
eprintln!(" note: optional dep not installed: {warn}");
}
if !rep.missing.is_empty() {
eprintln!(
" missing deps for {pkg_name}: {} — skipping update",
rep.missing.join(", ")
);
any_failed = true;
continue;
}
if let Err(e) = install::install_package(latest, bin_dir) {
eprintln!(" failed to update {pkg_name}: {e}");
any_failed = true;
}
}
if any_failed {
bail!("one or more packages could not be updated");
} }
Ok(()) Ok(())
} }
@ -173,15 +248,32 @@ fn cmd_info(name: &str) -> Result<()> {
println!("{} {}", pkg.name, pkg.version); println!("{} {}", pkg.name, pkg.version);
println!(" {}", pkg.description); println!(" {}", pkg.description);
println!(" status: {status}"); println!(" status: {status}");
println!(" binaries: {}", pkg.binaries.iter().map(|b| b.name.as_str()).collect::<Vec<_>>().join(", ")); println!(
" binaries: {}",
pkg.binaries
.iter()
.map(|b| b.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
if !pkg.system_deps.is_empty() { if !pkg.system_deps.is_empty() {
println!(" system deps: {}", pkg.system_deps.join(", ")); println!(" system deps: {}", pkg.system_deps.join(", "));
} }
if !pkg.optional_system_deps.is_empty() {
println!(" optional deps: {}", pkg.optional_system_deps.join(", "));
}
if !pkg.bread_deps.is_empty() { if !pkg.bread_deps.is_empty() {
println!(" bread deps: {}", pkg.bread_deps.join(", ")); println!(" bread deps: {}", pkg.bread_deps.join(", "));
} }
if !pkg.services.is_empty() { if !pkg.services.is_empty() {
println!(" services: {}", pkg.services.iter().map(|s| s.unit.as_str()).collect::<Vec<_>>().join(", ")); println!(
" services: {}",
pkg.services
.iter()
.map(|s| s.unit.as_str())
.collect::<Vec<_>>()
.join(", ")
);
} }
Ok(()) Ok(())
} }
@ -191,7 +283,12 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> {
let state = state::State::load()?; let state = state::State::load()?;
let targets: Vec<String> = match name { let targets: Vec<String> = match name {
Some(n) => vec![n.to_string()], Some(n) => {
if index.get(n).is_none() {
bail!("unknown package: {n}");
}
vec![n.to_string()]
}
None => state.packages.keys().cloned().collect(), None => state.packages.keys().cloned().collect(),
}; };
@ -203,9 +300,12 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> {
let mut all_ok = true; let mut all_ok = true;
for pkg_name in &targets { for pkg_name in &targets {
if let Some(pkg) = index.get(pkg_name) { if let Some(pkg) = index.get(pkg_name) {
if !doctor::report(pkg_name, &pkg.system_deps) { if !doctor::report(pkg_name, &pkg.system_deps, &pkg.optional_system_deps) {
all_ok = false; all_ok = false;
} }
} else {
eprintln!(" {pkg_name}: not found in index (removed from registry?)");
all_ok = false;
} }
} }

View file

@ -23,7 +23,7 @@ pub struct Service {
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ConfigScaffold { pub struct ConfigScaffold {
pub dir: String, pub dir: String,
/// relative to the product repo root; copied as-is if absent at install time /// Example config filename, relative to the release artifact directory.
pub example: Option<String>, pub example: Option<String>,
} }
@ -36,6 +36,8 @@ pub struct Package {
#[serde(default)] #[serde(default)]
pub system_deps: Vec<String>, pub system_deps: Vec<String>,
#[serde(default)] #[serde(default)]
pub optional_system_deps: Vec<String>,
#[serde(default)]
pub bread_deps: Vec<String>, pub bread_deps: Vec<String>,
#[serde(default)] #[serde(default)]
pub services: Vec<Service>, pub services: Vec<Service>,
@ -44,6 +46,21 @@ pub struct Package {
pub post_install: Vec<String>, pub post_install: Vec<String>,
} }
impl Package {
/// Returns `(primary_url, github_url)` for any artifact filename in this
/// package's release directory. Derived by stripping the filename from the
/// first binary's URLs.
pub fn artifact_urls(&self, filename: &str) -> Option<(String, String)> {
let first = self.binaries.first()?;
let dl_base = first.dl_url.rsplit_once('/')?.0;
let gh_base = first.github_url.rsplit_once('/')?.0;
Some((
format!("{dl_base}/{filename}"),
format!("{gh_base}/{filename}"),
))
}
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Index { pub struct Index {
pub version: String, pub version: String,
@ -67,8 +84,7 @@ pub fn load(force_refresh: bool) -> Result<Index> {
let cache_path = cache_path(); let cache_path = cache_path();
if !force_refresh && cache_is_fresh(&cache_path) { if !force_refresh && cache_is_fresh(&cache_path) {
let text = std::fs::read_to_string(&cache_path) let text = std::fs::read_to_string(&cache_path).context("reading cached index")?;
.context("reading cached index")?;
return serde_json::from_str(&text).context("parsing cached index"); return serde_json::from_str(&text).context("parsing cached index");
} }
@ -132,6 +148,6 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
let mut buf = Vec::new(); let mut buf = Vec::new();
resp.into_reader() resp.into_reader()
.read_to_end(&mut buf) .read_to_end(&mut buf)
.context("reading binary")?; .context("reading response")?;
Ok(buf) Ok(buf)
} }

View file

@ -33,7 +33,12 @@ impl State {
std::fs::create_dir_all(dir)?; std::fs::create_dir_all(dir)?;
} }
let text = serde_json::to_string_pretty(self)?; let text = serde_json::to_string_pretty(self)?;
std::fs::write(&path, text).context("writing installed.json") // Write to a temp file then rename for atomicity — avoids a torn write
// if the process is killed mid-save.
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, &text).context("writing installed.json.tmp")?;
std::fs::rename(&tmp, &path).context("atomically replacing installed.json")?;
Ok(())
} }
pub fn is_installed(&self, name: &str) -> bool { pub fn is_installed(&self, name: &str) -> bool {
@ -58,3 +63,58 @@ fn state_path() -> PathBuf {
}) })
.join("bakery/installed.json") .join("bakery/installed.json")
} }
#[cfg(test)]
mod tests {
use super::*;
fn pkg(name: &str, version: &str) -> InstalledPackage {
InstalledPackage {
name: name.to_string(),
version: version.to_string(),
binaries: vec![],
services: vec![],
installed_at: "2026-01-01T00:00:00Z".to_string(),
}
}
#[test]
fn record_and_is_installed() {
let mut state = State::default();
assert!(!state.is_installed("foo"));
state.record(pkg("foo", "1.0.0"));
assert!(state.is_installed("foo"));
}
#[test]
fn remove_installed() {
let mut state = State::default();
state.record(pkg("foo", "1.0.0"));
let removed = state.remove("foo");
assert!(removed.is_some());
assert!(!state.is_installed("foo"));
}
#[test]
fn remove_unknown_returns_none() {
let mut state = State::default();
assert!(state.remove("nope").is_none());
}
#[test]
fn json_roundtrip() {
let mut state = State::default();
state.record(InstalledPackage {
name: "bar".to_string(),
version: "2.0.0".to_string(),
binaries: vec!["bar".to_string()],
services: vec!["bar.service".to_string()],
installed_at: "2026-06-01T00:00:00Z".to_string(),
});
let json = serde_json::to_string(&state).unwrap();
let restored: State = serde_json::from_str(&json).unwrap();
assert!(restored.is_installed("bar"));
assert_eq!(restored.packages["bar"].version, "2.0.0");
assert_eq!(restored.packages["bar"].services, ["bar.service"]);
}
}

View file

@ -18,3 +18,9 @@ gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
# Enable GTK4 CSS provider helpers (breadbar, breadbox, breadpad use this). # Enable GTK4 CSS provider helpers (breadbar, breadbox, breadpad use this).
# bread (daemon) and breadcrumbs (CLI) depend on this crate without the feature. # bread (daemon) and breadcrumbs (CLI) depend on this crate without the feature.
gtk = ["dep:gtk4"] gtk = ["dep:gtk4"]
# The generator CLI. It only touches the gtk-free lib API (render + write), so
# it builds without the gtk feature and stays light.
[[bin]]
name = "bread-theme"
path = "src/bin/bread-theme.rs"

View file

@ -0,0 +1,62 @@
//! `bread-theme` — generates the ecosystem's shared GTK stylesheet from the
//! current pywal palette and writes it to the canonical path that every bread
//! GUI loads. Run it at session start, and again after the wallpaper/palette
//! changes (e.g. from a pywal hook); apps watch the file and recolour live.
//!
//! bread-theme # same as `generate`
//! bread-theme generate # render + write the shared stylesheet
//! bread-theme reload # re-render from the current pywal palette and
//! # signal every running bread GUI to recolour
//! bread-theme path # print the stylesheet path
//! bread-theme print # render to stdout (no write)
use std::process::ExitCode;
fn write_and_report(verb: &str) -> ExitCode {
match bread_theme::write_shared_css() {
Ok(path) => {
eprintln!("bread-theme: {verb} {}", path.display());
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("bread-theme: failed to write stylesheet: {e}");
ExitCode::FAILURE
}
}
}
fn main() -> ExitCode {
let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into());
match cmd.as_str() {
"path" => {
println!("{}", bread_theme::shared_css_path().display());
ExitCode::SUCCESS
}
"print" => {
print!("{}", bread_theme::render());
ExitCode::SUCCESS
}
"generate" => write_and_report("wrote"),
// `reload` is `generate` from the caller's view, but it's the verb to use
// after changing pywal colours: rewriting the file (atomic rename) trips
// the file monitor in every running bread GUI, so they all re-read the
// palette and recolour live — shared widgets *and* each app's own rules.
"reload" => write_and_report("reloaded"),
"-h" | "--help" | "help" => {
eprintln!(
"bread-theme — shared stylesheet generator\n\n\
USAGE:\n bread-theme [generate|reload|path|print]\n\n\
generate render the pywal palette to the shared stylesheet (default)\n\
reload re-render and signal running bread GUIs to recolour live\n\
path print the stylesheet path ({})\n\
print render to stdout without writing",
bread_theme::shared_css_path().display()
);
ExitCode::SUCCESS
}
other => {
eprintln!("bread-theme: unknown command '{other}' (try generate|reload|path|print)");
ExitCode::FAILURE
}
}
}

View file

@ -1,7 +1,100 @@
use gtk4::gio;
use gtk4::prelude::*;
use gtk4::CssProvider; use gtk4::CssProvider;
use std::cell::RefCell; use std::cell::RefCell;
use std::path::Path; use std::path::Path;
thread_local! {
static SHARED_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
static SHARED_MONITOR: RefCell<Option<gio::FileMonitor>> = const { RefCell::new(None) };
static APP_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
static APP_MONITOR: RefCell<Option<gio::FileMonitor>> = const { RefCell::new(None) };
#[allow(clippy::type_complexity)]
static APP_BUILDER: RefCell<Option<Box<dyn Fn() -> String>>> = const { RefCell::new(None) };
}
fn reload_shared() {
let css = std::fs::read_to_string(crate::shared_css_path())
.unwrap_or_else(|_| crate::render());
SHARED_PROVIDER.with(|cell| apply_css(&css, cell));
}
fn reload_app() {
let css = APP_BUILDER.with(|b| b.borrow().as_ref().map(|f| f()));
if let Some(css) = css {
APP_PROVIDER.with(|cell| apply_css(&css, cell));
}
}
/// Watch the shared stylesheet for changes and run `reload` when it's rewritten.
///
/// `bread-theme` writes the file with write-tmp-then-rename (atomic), which
/// *replaces the inode*. A monitor on the file itself dies after the first
/// replace (inotify reports DELETE_SELF and never re-arms), so we monitor the
/// parent *directory* and filter for the stylesheet's filename — that fires
/// reliably on every reload. Returns the monitor (keep it alive to stay armed).
fn watch_theme_file(reload: fn()) -> Option<gio::FileMonitor> {
let target = crate::shared_css_path();
let dir = target.parent()?;
// The dir must exist to be monitored; `bread-theme generate` makes it at
// login, but create it here too so a GUI started first still arms the watch.
let _ = std::fs::create_dir_all(dir);
let monitor = gio::File::for_path(dir)
.monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE)
.ok()?;
monitor.connect_changed(move |_, file, other, _event| {
// The rename lands as an event whose file (or move destination) is the
// stylesheet. Match either to catch both CREATED/CHANGED and MOVED_IN.
let is_target = |f: &gio::File| f.path().as_deref() == Some(target.as_path());
if is_target(file) || other.is_some_and(is_target) {
reload();
}
});
Some(monitor)
}
/// Apply an app's *own* stylesheet and keep it live across palette changes.
///
/// `build` is called now to produce the app-specific CSS, and again every time
/// the shared theme file is rewritten — i.e. whenever `bread-theme reload` (or
/// `generate`) runs after pywal changes. The app recolours in place, no restart.
///
/// This is the counterpart to [`apply_shared`]: that hot-reloads the *shared*
/// component sheet; this hot-reloads the app's *own* rules (which are built from
/// the palette, so they'd otherwise be frozen at startup). Apps that build their
/// CSS from [`crate::stylesheet`] themselves can use this alone; apps that layer
/// on top of [`apply_shared`] call both.
///
/// Call once at startup. The closure should read the current palette
/// ([`crate::load_palette`]) each time so it picks up the new colours.
pub fn apply_app_css<F: Fn() -> String + 'static>(build: F) {
APP_BUILDER.with(|b| *b.borrow_mut() = Some(Box::new(build)));
reload_app();
APP_MONITOR.with(|cell| {
if cell.borrow().is_some() {
return;
}
*cell.borrow_mut() = watch_theme_file(reload_app);
});
}
/// Load the ecosystem's shared stylesheet (the file written by
/// `bread-theme generate`, or a freshly rendered fallback if absent) at
/// APPLICATION priority, and watch the file so the whole UI recolours live when
/// the palette changes — no app rebuild or restart needed.
///
/// Call once at startup; then add the app's own CSS provider *after* this so
/// app-specific rules win on equal specificity.
pub fn apply_shared() {
reload_shared();
SHARED_MONITOR.with(|cell| {
if cell.borrow().is_some() {
return;
}
*cell.borrow_mut() = watch_theme_file(reload_shared);
});
}
/// Apply a CSS string to the default display at APPLICATION priority. /// Apply a CSS string to the default display at APPLICATION priority.
/// Re-uses an existing provider if one is passed in (for SIGHUP reloads). /// Re-uses an existing provider if one is passed in (for SIGHUP reloads).
pub fn apply_css(css: &str, provider: &RefCell<Option<CssProvider>>) { pub fn apply_css(css: &str, provider: &RefCell<Option<CssProvider>>) {

View file

@ -54,6 +54,165 @@ pub fn css_vars(p: &Palette) -> String {
) )
} }
/// Relative luminance (WCAG, sRGB) of a `#rrggbb` colour, 0.0 (black) 1.0 (white).
pub fn luminance(hex: &str) -> f32 {
let h = hex.trim_start_matches('#');
let lin = |i: usize| -> f32 {
let c = u8::from_str_radix(h.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0) as f32 / 255.0;
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
};
0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4)
}
/// Pick a legible ink (near-black or near-white) for text drawn on `hex`.
/// 0.179 is the WCAG crossover where contrast against black equals contrast
/// against white — so whichever side we pick always wins. This is what keeps
/// text readable no matter how light or dark pywal makes a given palette slot,
/// without altering the palette colours themselves.
pub fn ink_on(hex: &str) -> &'static str {
if luminance(hex) > 0.179 { "#11111b" } else { "#f5f5f5" }
}
/// Canonical `@define-color` block: the single naming all bread apps share.
/// `surface` = color0 (darkest surface), `overlay` = color7 (muted), and
/// `accent` = color4. Apps must use these names, not raw palette slots, so the
/// whole ecosystem recolours together.
///
/// The `on-*` colours are computed ink (black/white) guaranteed to be legible on
/// the matching background — use `@on-surface` for text on a `@surface` panel,
/// `@on-accent` on an `@accent` button, etc. They exist because pywal can emit a
/// light value in any slot, and white text on a light surface disappears.
fn define_colors(p: &Palette) -> String {
format!(
"@define-color bg {bg};\n\
@define-color fg {fg};\n\
@define-color surface {c0};\n\
@define-color overlay {c7};\n\
@define-color accent {c4};\n\
@define-color red {c1};\n\
@define-color green {c2};\n\
@define-color yellow {c3};\n\
@define-color blue {c4};\n\
@define-color pink {c5};\n\
@define-color teal {c6};\n\
@define-color on-bg {on_bg};\n\
@define-color on-surface {on_surface};\n\
@define-color on-accent {on_accent};\n\
@define-color on-red {on_red};\n\
@define-color on-overlay {on_overlay};\n",
bg = p.background, fg = p.foreground,
c0 = p.color0, c1 = p.color1, c2 = p.color2, c3 = p.color3,
c4 = p.color4, c5 = p.color5, c6 = p.color6, c7 = p.color7,
on_bg = ink_on(&p.background),
on_surface = ink_on(&p.color0),
on_accent = ink_on(&p.color4),
on_red = ink_on(&p.color1),
on_overlay = ink_on(&p.color7),
)
}
/// The full shared component stylesheet — the single source of truth for how
/// every bread GUI (bos-settings, breadbar, breadbox, breadpad, breadman) styles
/// common widgets. Apps load this, then append only their own *layout* rules.
///
/// Built entirely from the design tokens (font, spacing, radii) and the
/// `@define-color` palette, so changing the palette recolours every app.
pub fn stylesheet(p: &Palette) -> String {
use tokens::*;
format!(
"{vars}\
* {{ font-family: '{font}'; font-size: {base}px; }}\n\
/* Colour is set on containers; labels inherit it, so text on any panel,\
button, or accent is always the legible ink for that background. Bare\
`label {{ color }}` is deliberately avoided as a type selector it\
would override a container's colour on its own child labels. */\n\
window {{ background-color: @bg; color: @on-bg; }}\n\
.dim-label, .dim {{ opacity: 0.6; font-size: {sec}px; }}\n\
.title {{ font-size: 1.4em; font-weight: bold; }}\n\
.heading {{ font-weight: bold; opacity: 0.85; }}\n\
.subtitle {{ opacity: 0.7; font-size: {sec}px; }}\n\
button {{ background-color: @surface; color: @on-surface; border: none;\
border-radius: {r1}px; padding: {sm}px {lg}px; }}\n\
button:hover {{ background-color: alpha(@on-surface, 0.14); }}\n\
button:active {{ background-color: alpha(@on-surface, 0.20); }}\n\
button:disabled {{ opacity: 0.5; }}\n\
button.flat {{ background-color: transparent; color: @on-bg; }}\n\
button.suggested-action {{ background-color: @accent; color: @on-accent; }}\n\
button.suggested-action:hover {{ background-color: alpha(@accent, 0.85); }}\n\
button.destructive-action {{ background-color: @red; color: @on-red; }}\n\
button.destructive-action:hover {{ background-color: alpha(@red, 0.85); }}\n\
entry, spinbutton {{ background-color: @surface; color: @on-surface;\
border: 1px solid @overlay; border-radius: {r2}px;\
padding: {xs}px {sm}px; caret-color: @on-surface; }}\n\
entry:focus-within, spinbutton:focus-within {{ border-color: @accent; outline: none; }}\n\
entry image, spinbutton button {{ color: @on-surface; }}\n\
dropdown > button {{ background-color: @surface; color: @on-surface; border-radius: {r2}px; }}\n\
popover > contents {{ background-color: @surface; color: @on-surface; border-radius: {r1}px; }}\n\
switch {{ background-color: @overlay; border-radius: {pill}px; }}\n\
switch:checked {{ background-color: @accent; }}\n\
switch slider {{ background-color: @on-surface; border-radius: {pill}px; }}\n\
list, listbox {{ background-color: transparent; }}\n\
row {{ border-radius: {r2}px; }}\n\
row:selected, list row:selected {{ background-color: @accent; color: @on-accent; }}\n\
.sidebar {{ background-color: @surface; color: @on-surface; }}\n\
.sidebar row {{ padding: {sm}px {md}px; }}\n\
.sidebar row:selected {{ background-color: @accent; color: @on-accent; }}\n\
.sidebar .section-header {{ padding: {md}px {md}px {xs}px {md}px;\
font-size: {sec}px; font-weight: bold; opacity: 0.55; }}\n\
.card {{ background-color: @surface; color: @on-surface; border-radius: {r1}px; padding: {md}px; }}\n\
.chip, .pill {{ background-color: @overlay; color: @on-overlay; border-radius: {pill}px;\
padding: {xs}px {md}px; font-size: {sec}px; }}\n\
.chip.active, .pill.active {{ background-color: @accent; color: @on-accent; }}\n\
scrollbar {{ background-color: transparent; }}\n\
scrollbar slider {{ background-color: alpha(@on-bg, 0.25); border-radius: {pill}px;\
min-width: 6px; min-height: 6px; }}\n\
scrollbar slider:hover {{ background-color: alpha(@on-bg, 0.45); }}\n\
textview, .mono {{ font-family: monospace; }}\n\
textview text {{ background-color: @surface; color: @on-surface; }}\n",
vars = define_colors(p),
font = FONT_FAMILY,
base = FONT_SIZE_BASE,
sec = FONT_SIZE_SECONDARY,
xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG,
r1 = RADIUS_PRIMARY, r2 = RADIUS_SECONDARY, pill = RADIUS_PILL,
)
}
/// Render the shared stylesheet for the current (pywal) palette. Used by the
/// `bread-theme` generator and as the in-app fallback when the generated file
/// isn't present yet.
pub fn render() -> String {
stylesheet(&load_palette())
}
/// Canonical path of the generated shared stylesheet. Apps load it; the
/// `bread-theme generate` CLI writes it. Per-session under `XDG_RUNTIME_DIR`,
/// falling back to the cache dir.
pub fn shared_css_path() -> std::path::PathBuf {
if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
if !rt.is_empty() {
return std::path::PathBuf::from(rt).join("bread").join("theme.css");
}
}
dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
.join("bread")
.join("theme.css")
}
/// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns
/// the path written. Used by the `bread-theme` CLI.
pub fn write_shared_css() -> std::io::Result<std::path::PathBuf> {
let path = shared_css_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("css.tmp");
std::fs::write(&tmp, render())?;
std::fs::rename(&tmp, &path)?;
Ok(path)
}
/// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`. /// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`.
pub fn hex_to_rgba(hex: &str, alpha: f32) -> String { pub fn hex_to_rgba(hex: &str, alpha: f32) -> String {
let h = hex.trim_start_matches('#'); let h = hex.trim_start_matches('#');
@ -82,6 +241,66 @@ mod tests {
assert!(css.contains("14px")); assert!(css.contains("14px"));
} }
#[test]
fn stylesheet_defines_canonical_colors_and_components() {
let css = stylesheet(&Palette::default());
for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] {
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
}
// a representative spread of the shared component selectors
for sel in &["button", "entry", "switch:checked", ".card", ".sidebar", "scrollbar slider", ".title"] {
assert!(css.contains(sel), "stylesheet missing selector: {sel}");
}
assert!(css.contains("Varela Round"));
}
#[test]
fn luminance_black_and_white_are_extremes() {
assert!(luminance("#000000") < 0.01);
assert!(luminance("#ffffff") > 0.99);
}
#[test]
fn ink_on_picks_dark_text_for_light_backgrounds() {
// Light pywal slots (the case that made white text vanish) get dark ink.
assert_eq!(ink_on("#ffffff"), "#11111b");
assert_eq!(ink_on("#f9e2af"), "#11111b"); // pale yellow
assert_eq!(ink_on("#a6e3a1"), "#11111b"); // pale green
}
#[test]
fn ink_on_picks_light_text_for_dark_backgrounds() {
assert_eq!(ink_on("#000000"), "#f5f5f5");
assert_eq!(ink_on("#1e1e2e"), "#f5f5f5"); // catppuccin base
}
#[test]
fn stylesheet_defines_on_colors() {
let css = stylesheet(&Palette::default());
for name in &["on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
}
}
#[test]
fn stylesheet_has_no_blanket_label_color_rule() {
// A bare `label { color: ... }` would override container colours on child
// labels — the bug that made coloured-background text illegible.
let css = stylesheet(&Palette::default());
assert!(!css.contains("label { color:"), "blanket label colour rule reintroduced");
}
#[test]
fn shared_css_path_uses_runtime_dir() {
std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
assert_eq!(shared_css_path(), std::path::PathBuf::from("/run/user/1234/bread/theme.css"));
}
#[test]
fn render_is_nonempty_css() {
assert!(render().contains("@define-color bg "));
}
#[test] #[test]
fn hex_to_rgba_known_value() { fn hex_to_rgba_known_value() {
assert_eq!(hex_to_rgba("#1e1e2e", 1.0), "rgba(30, 30, 46, 1)"); assert_eq!(hex_to_rgba("#1e1e2e", 1.0), "rgba(30, 30, 46, 1)");

28
packaging/arch/PKGBUILD Normal file
View file

@ -0,0 +1,28 @@
# Maintainer: Breadway <rileyhorsham@gmail.com>
pkgname=bakery
pkgver=0.2.3
pkgrel=1
pkgdesc="Package manager for the bread ecosystem"
arch=('x86_64')
url="https://github.com/Breadway/bread-ecosystem"
license=('MIT')
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
# causing undefined-symbol errors. Disable LTO.
options=(!lto !debug)
depends=('glibc' 'gcc-libs')
makedepends=('rust' 'cargo')
source=("${pkgname}-${pkgver}.tar.gz")
sha256sums=('SKIP')
build() {
cd "${srcdir}/${pkgname}-${pkgver}"
cargo build --release --locked -p bakery
}
package() {
cd "${srcdir}/${pkgname}-${pkgver}"
install -Dm755 target/release/bakery "${pkgdir}/usr/bin/bakery"
install -Dm644 README.md "${pkgdir}/usr/share/doc/${pkgname}/README.md"
}

View file

@ -7,6 +7,11 @@ description = "Reactive desktop automation ecosystem for Arch Linux / Hyprland"
homepage = "https://breadway.dev" homepage = "https://breadway.dev"
dl_base = "https://dl.breadway.dev" dl_base = "https://dl.breadway.dev"
[[products]]
name = "bakery"
repo = "Breadway/bread-ecosystem"
description = "Bread ecosystem package manager"
[[products]] [[products]]
name = "bread" name = "bread"
repo = "Breadway/bread" repo = "Breadway/bread"
@ -31,3 +36,8 @@ description = "Profile-aware Wi-Fi state machine with Tailscale integration"
name = "breadpad" name = "breadpad"
repo = "Breadway/breadpad" repo = "Breadway/breadpad"
description = "Quick-capture scratchpad and note viewer with AI classification" description = "Quick-capture scratchpad and note viewer with AI classification"
[[products]]
name = "breadpaper"
repo = "Breadway/breadpaper"
description = "Wallpaper manager for the bread desktop"

111
scripts/gen-index.sh Normal file → Executable file
View file

@ -1,27 +1,28 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Generate dl.breadway.dev/index.json from: # Generate dl.breadway.dev/index.json from:
# - registry/bread-ecosystem.toml (product list) # - registry/bread-ecosystem.toml (product list)
# - <repo>/bakery.toml (per-product metadata) # - <DL_DIR>/<name>/bakery.toml (per-product metadata, uploaded by release.yml)
# - /srv/breadway-dl/ (built binaries + sha256 files) # - <DL_DIR>/ (built binaries + sha256 files)
# #
# Fallback for local dev: looks for ../name/bakery.toml (sibling repo checkout).
# Run on hestia after each product build, before the dl server is refreshed. # Run on hestia after each product build, before the dl server is refreshed.
# Requires: jq, python3 (for toml parsing via tomllib), sha256sum # Requires: jq, python3 (tomllib, stdlib since 3.11), sha256sum
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SCRIPT_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
DL_DIR="${DL_DIR:-/srv/breadway-dl}" DL_DIR="${DL_DIR:-/srv/breadway-dl}"
DL_BASE="${DL_BASE:-https://dl.breadway.dev}" DL_BASE="${DL_BASE:-https://dl.breadway.dev}"
GH_BASE="https://github.com/Breadway" GH_BASE="https://github.com"
OUT="${DL_DIR}/index.json" OUT="${DL_DIR}/index.json"
# Products are read from the registry. Each line is "name repo". # Read the product list from the registry TOML instead of a hardcoded array.
products=( mapfile -t products < <(python3 -c "
"bread Breadway/bread" import tomllib, sys
"breadbar Breadway/breadbar" with open('${SCRIPT_DIR}/registry/bread-ecosystem.toml', 'rb') as f:
"breadbox Breadway/breadbox" d = tomllib.load(f)
"breadcrumbs Breadway/breadcrumbs" for p in d['products']:
"breadpad Breadway/breadpad" print(p['name'], p['repo'])
) ")
# Build a JSON package entry for one product. # Build a JSON package entry for one product.
# $1 = product name, $2 = github repo slug # $1 = product name, $2 = github repo slug
@ -33,24 +34,28 @@ build_package_json() {
local pkg_dir="${DL_DIR}/${name}" local pkg_dir="${DL_DIR}/${name}"
if [[ ! -d "${pkg_dir}" ]]; then if [[ ! -d "${pkg_dir}" ]]; then
echo " warning: no release dir for ${name} at ${pkg_dir}" >&2 echo " warning: no release dir for ${name} at ${pkg_dir}" >&2
return return 1
fi fi
# The latest symlink must point to the current version dir. # The latest symlink must point to the current version dir.
local latest_link="${pkg_dir}/latest" local latest_link="${pkg_dir}/latest"
if [[ ! -L "${latest_link}" ]]; then if [[ ! -L "${latest_link}" ]]; then
echo " warning: no 'latest' symlink for ${name}" >&2 echo " warning: no 'latest' symlink for ${name}" >&2
return return 1
fi fi
local version_dir local version_dir
version_dir="$(readlink -f "${latest_link}")" version_dir="$(readlink -f "${latest_link}")"
local version local version
version="$(basename "${version_dir}")" version="$(basename "${version_dir}")"
# Collect all binaries in the version dir (files without .sha256 extension). # Collect all binaries in the version dir (executables only; skip metadata files).
local binaries_json="[]" local binaries_json="[]"
for bin_path in "${version_dir}"/*; do for bin_path in "${version_dir}"/*; do
[[ "${bin_path}" == *.sha256 ]] && continue [[ "${bin_path}" == *.sha256 ]] && continue
[[ "${bin_path}" == *.toml ]] && continue
[[ "${bin_path}" == *.service ]] && continue
[[ "${bin_path}" == *.css ]] && continue
[[ "${bin_path}" == *.txt ]] && continue
[[ -f "${bin_path}" ]] || continue [[ -f "${bin_path}" ]] || continue
local bin_name local bin_name
bin_name="$(basename "${bin_path}")" bin_name="$(basename "${bin_path}")"
@ -72,42 +77,78 @@ build_package_json() {
binaries_json="$(jq -n --argjson arr "${binaries_json}" --argjson e "${entry}" '$arr + [$e]')" binaries_json="$(jq -n --argjson arr "${binaries_json}" --argjson e "${entry}" '$arr + [$e]')"
done done
# Read bakery.toml for this product from a co-located checkout if available, # Locate bakery.toml. The release workflow copies it into the version dir
# else use minimal defaults. # alongside the binaries (${version_dir}/bakery.toml). Fall back to a
local bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml" # sibling repo checkout for local dev use.
local description="" local bakery_toml="${version_dir}/bakery.toml"
local system_deps="[]" if [[ ! -f "${bakery_toml}" ]]; then
local bread_deps="[]" bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml"
local services="[]" fi
local config="null" if [[ ! -f "${bakery_toml}" ]]; then
local post_install="[]" echo "ERROR: bakery.toml not found for ${name} — release.yml must copy it to \${DL_DIR}/${name}/\${VERSION}/bakery.toml" >&2
return 1
fi
local description system_deps optional_system_deps bread_deps services config post_install
if [[ -f "${bakery_toml}" ]]; then
description="$(python3 -c " description="$(python3 -c "
import tomllib, sys import tomllib
with open('${bakery_toml}', 'rb') as f: with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f) d = tomllib.load(f)
print(d.get('description', '')) print(d.get('description', ''))
" 2>/dev/null || true)" " 2>/dev/null || true)"
system_deps="$(python3 -c " system_deps="$(python3 -c "
import tomllib, json, sys import tomllib, json
with open('${bakery_toml}', 'rb') as f: with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f) d = tomllib.load(f)
print(json.dumps(d.get('system_deps', []))) print(json.dumps(d.get('system_deps', [])))
" 2>/dev/null || echo "[]")" " 2>/dev/null || echo "[]")"
optional_system_deps="$(python3 -c "
import tomllib, json
with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f)
print(json.dumps(d.get('optional_system_deps', [])))
" 2>/dev/null || echo "[]")"
bread_deps="$(python3 -c " bread_deps="$(python3 -c "
import tomllib, json, sys import tomllib, json
with open('${bakery_toml}', 'rb') as f: with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f) d = tomllib.load(f)
print(json.dumps(d.get('bread_deps', []))) print(json.dumps(d.get('bread_deps', [])))
" 2>/dev/null || echo "[]")" " 2>/dev/null || echo "[]")"
# [[service]] entries → [{unit, enable}]
services="$(python3 -c "
import tomllib, json
with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f)
svcs = d.get('service', [])
print(json.dumps([{'unit': s['unit'], 'enable': s.get('enable', False)} for s in svcs]))
" 2>/dev/null || echo "[]")"
# [config] → {dir, example?} or null
config="$(python3 -c "
import tomllib, json
with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f)
cfg = d.get('config')
if cfg:
obj = {'dir': cfg['dir']}
if 'example' in cfg:
obj['example'] = cfg['example']
print(json.dumps(obj))
else:
print('null')
" 2>/dev/null || echo "null")"
post_install="$(python3 -c " post_install="$(python3 -c "
import tomllib, json, sys import tomllib, json
with open('${bakery_toml}', 'rb') as f: with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f) d = tomllib.load(f)
print(json.dumps(d.get('install', {}).get('post_install', []))) print(json.dumps(d.get('install', {}).get('post_install', [])))
" 2>/dev/null || echo "[]")" " 2>/dev/null || echo "[]")"
fi
jq -n \ jq -n \
--arg name "${name}" \ --arg name "${name}" \
@ -115,8 +156,10 @@ print(json.dumps(d.get('install', {}).get('post_install', [])))
--arg version "${version}" \ --arg version "${version}" \
--argjson binaries "${binaries_json}" \ --argjson binaries "${binaries_json}" \
--argjson system_deps "${system_deps}" \ --argjson system_deps "${system_deps}" \
--argjson optional_system_deps "${optional_system_deps}" \
--argjson bread_deps "${bread_deps}" \ --argjson bread_deps "${bread_deps}" \
--argjson services "${services}" \ --argjson services "${services}" \
--argjson config "${config}" \
--argjson post_install "${post_install}" \ --argjson post_install "${post_install}" \
'{ '{
name: $name, name: $name,
@ -124,8 +167,10 @@ print(json.dumps(d.get('install', {}).get('post_install', [])))
version: $version, version: $version,
binaries: $binaries, binaries: $binaries,
system_deps: $system_deps, system_deps: $system_deps,
optional_system_deps: $optional_system_deps,
bread_deps: $bread_deps, bread_deps: $bread_deps,
services: $services, services: $services,
config: $config,
post_install: $post_install post_install: $post_install
}' }'
} }
@ -136,8 +181,8 @@ for entry in "${products[@]}"; do
name="$(echo "${entry}" | awk '{print $1}')" name="$(echo "${entry}" | awk '{print $1}')"
repo="$(echo "${entry}" | awk '{print $2}')" repo="$(echo "${entry}" | awk '{print $2}')"
echo "processing ${name}" echo "processing ${name}"
pkg="$(build_package_json "${name}" "${repo}" 2>&1)" || { echo " skipping ${name}: ${pkg}"; continue; } pkg="$(build_package_json "${name}" "${repo}")" || { echo " skipping ${name}"; continue; }
[[ -z "${pkg}" ]] && continue [[ -z "${pkg}" ]] && { echo " skipping ${name}: no output"; continue; }
packages_json="$(jq -n --argjson m "${packages_json}" --arg k "${name}" --argjson v "${pkg}" '$m + {($k): $v}')" packages_json="$(jq -n --argjson m "${packages_json}" --arg k "${name}" --argjson v "${pkg}" '$m + {($k): $v}')"
done done

33
scripts/get.sh Normal file → Executable file
View file

@ -1,12 +1,10 @@
#!/bin/sh #!/bin/sh
# Bootstrap script: installs the `bakery` binary. # Bootstrap script: downloads and installs the `bakery` binary.
# Usage: curl https://breadway.dev/get | sh # Usage: curl https://breadway.dev/get | sh
# Or: curl -sSfL https://breadway.dev/get | sh # Or: curl -sSfL https://breadway.dev/get | sh
set -eu set -eu
BAKERY_VERSION="${BAKERY_VERSION:-latest}" BAKERY_VERSION="${BAKERY_VERSION:-latest}"
DL_PRIMARY="https://dl.breadway.dev/bakery/${BAKERY_VERSION}/bakery-x86_64"
DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/download/${BAKERY_VERSION}/bakery-x86_64"
BIN_DIR="${BAKERY_BIN_DIR:-$HOME/.local/bin}" BIN_DIR="${BAKERY_BIN_DIR:-$HOME/.local/bin}"
die() { echo "error: $*" >&2; exit 1; } die() { echo "error: $*" >&2; exit 1; }
@ -15,6 +13,20 @@ die() { echo "error: $*" >&2; exit 1; }
uname -m | grep -q x86_64 || die "bakery only supports x86_64 (got $(uname -m))" uname -m | grep -q x86_64 || die "bakery only supports x86_64 (got $(uname -m))"
uname -s | grep -q Linux || die "bakery only supports Linux (got $(uname -s))" uname -s | grep -q Linux || die "bakery only supports Linux (got $(uname -s))"
# Build download URLs. GitHub's "latest" redirect lives at a different path from
# versioned releases, so we handle them separately and always prefix tags with 'v'.
if [ "${BAKERY_VERSION}" = "latest" ]; then
DL_PRIMARY="https://dl.breadway.dev/bakery/latest/bakery-x86_64"
DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/latest/download/bakery-x86_64"
SHA256_URL="https://dl.breadway.dev/bakery/latest/bakery-x86_64.sha256"
else
# Strip a leading 'v' if the caller included it, then add it back consistently.
ver="${BAKERY_VERSION#v}"
DL_PRIMARY="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64"
DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/download/v${ver}/bakery-x86_64"
SHA256_URL="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64.sha256"
fi
# Pick a download tool. # Pick a download tool.
if command -v curl >/dev/null 2>&1; then if command -v curl >/dev/null 2>&1; then
fetch() { curl -fsSL "$1" -o "$2"; } fetch() { curl -fsSL "$1" -o "$2"; }
@ -26,13 +38,26 @@ fi
mkdir -p "${BIN_DIR}" mkdir -p "${BIN_DIR}"
TMP="$(mktemp)" TMP="$(mktemp)"
trap 'rm -f "${TMP}"' EXIT trap 'rm -f "${TMP}" "${TMP}.sha256"' EXIT
echo "downloading bakery…" echo "downloading bakery…"
if fetch "${DL_PRIMARY}" "${TMP}" 2>/dev/null; then if fetch "${DL_PRIMARY}" "${TMP}" 2>/dev/null; then
echo " from dl.breadway.dev" echo " from dl.breadway.dev"
# Verify checksum when available from primary.
if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then
expected="$(awk '{print $1}' "${TMP}.sha256")"
actual="$(sha256sum "${TMP}" | awk '{print $1}')"
if [ "${expected}" != "${actual}" ]; then
die "SHA-256 checksum mismatch (expected ${expected}, got ${actual})"
fi
echo " checksum verified"
else
echo " warning: could not fetch checksum — skipping verification"
fi
elif fetch "${DL_FALLBACK}" "${TMP}" 2>/dev/null; then elif fetch "${DL_FALLBACK}" "${TMP}" 2>/dev/null; then
echo " from GitHub (fallback)" echo " from GitHub (fallback)"
# No .sha256 on the GitHub fallback path; proceed without verification.
echo " warning: checksum not verified for GitHub fallback download"
else else
die "failed to download bakery from both primary and fallback URLs" die "failed to download bakery from both primary and fallback URLs"
fi fi

113
scripts/test-gen-index.sh Executable file
View file

@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Smoke-test gen-index.sh against a minimal fixture DL_DIR tree.
# Verifies that services, config, system_deps, optional_system_deps,
# description, and post_install are all populated correctly.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FIXTURE="$(mktemp -d)"
FAKE_REGISTRY="$(mktemp -d)"
trap 'rm -rf "${FIXTURE}" "${FAKE_REGISTRY}"' EXIT
fail() { echo "FAIL: $*" >&2; exit 1; }
# ── Build a minimal release tree for "fakepkg" ───────────────────────────────
PKG_VER_DIR="${FIXTURE}/fakepkg/0.1.0"
mkdir -p "${PKG_VER_DIR}"
printf 'fake-binary-content' > "${PKG_VER_DIR}/fakepkg-x86_64"
sha256sum "${PKG_VER_DIR}/fakepkg-x86_64" | awk '{print $1}' \
> "${PKG_VER_DIR}/fakepkg-x86_64.sha256"
printf '[Unit]\nDescription=fakepkg\n' > "${PKG_VER_DIR}/fakepkg.service"
printf '# example config\n' > "${PKG_VER_DIR}/fakepkg.example.toml"
cat > "${PKG_VER_DIR}/bakery.toml" <<'TOML'
name = "fakepkg"
description = "A fake package for testing"
binaries = ["fakepkg"]
system_deps = ["gtk4"]
optional_system_deps = ["hyprland"]
bread_deps = []
[[service]]
unit = "fakepkg.service"
enable = true
[config]
dir = "~/.config/fakepkg"
example = "fakepkg.example.toml"
[install]
post_install = ["echo installed"]
TOML
# gen-index looks for bakery.toml at ${DL_DIR}/<name>/bakery.toml (no version)
cp "${PKG_VER_DIR}/bakery.toml" "${FIXTURE}/fakepkg/bakery.toml"
ln -s "${PKG_VER_DIR}" "${FIXTURE}/fakepkg/latest"
# ── Minimal registry pointing only at fakepkg ────────────────────────────────
mkdir -p "${FAKE_REGISTRY}/registry"
cat > "${FAKE_REGISTRY}/registry/bread-ecosystem.toml" <<'TOML'
[ecosystem]
name = "test"
[[products]]
name = "fakepkg"
repo = "Test/fakepkg"
description = "A fake package"
TOML
# ── Run gen-index with overridden SCRIPT_DIR and DL_DIR ──────────────────────
OUT="${FIXTURE}/index.json"
SCRIPT_DIR="${FAKE_REGISTRY}" DL_DIR="${FIXTURE}" DL_BASE="https://dl.test" \
bash "${REPO_ROOT}/scripts/gen-index.sh" 2>&1 | sed 's/^/ [gen-index] /'
[[ -f "${OUT}" ]] || fail "index.json was not produced"
# ── Assertions ────────────────────────────────────────────────────────────────
jq -e '.packages.fakepkg' "${OUT}" > /dev/null \
|| fail "fakepkg missing from index"
check() {
local label="$1" expected="$2" actual="$3"
[[ "${actual}" == "${expected}" ]] \
|| fail "${label}: expected '${expected}', got '${actual}'"
}
check "description" \
"A fake package for testing" \
"$(jq -r '.packages.fakepkg.description' "${OUT}")"
check "system_deps" \
"gtk4" \
"$(jq -r '.packages.fakepkg.system_deps | join(",")' "${OUT}")"
check "optional_system_deps" \
"hyprland" \
"$(jq -r '.packages.fakepkg.optional_system_deps | join(",")' "${OUT}")"
check "services[0].unit" \
"fakepkg.service" \
"$(jq -r '.packages.fakepkg.services[0].unit' "${OUT}")"
check "services[0].enable" \
"true" \
"$(jq -r '.packages.fakepkg.services[0].enable' "${OUT}")"
check "config.dir" \
"~/.config/fakepkg" \
"$(jq -r '.packages.fakepkg.config.dir' "${OUT}")"
check "config.example" \
"fakepkg.example.toml" \
"$(jq -r '.packages.fakepkg.config.example' "${OUT}")"
check "binaries[0].name" \
"fakepkg-x86_64" \
"$(jq -r '.packages.fakepkg.binaries[0].name' "${OUT}")"
check "post_install[0]" \
"echo installed" \
"$(jq -r '.packages.fakepkg.post_install[0]' "${OUT}")"
echo "OK: all gen-index assertions passed"