Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ client/.tls/
# direnv
.direnv/

# Per-worktree port assignment written by `just worktree-adopt`
/.env

.flox

# shotput workspace-copy (image-inline clients only; not needed on OpenCode web)
Expand Down
76 changes: 67 additions & 9 deletions Justfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# deckd — common commands

# Load a gitignored ./.env if one exists. `just worktree-adopt` writes one per
# worktree holding that checkout's port assignment, so every recipe below picks
# up the right ports with no env-var juggling. Nothing else uses it, the
# primary checkout doesn't need one, and an explicit env var still wins:
# `DECKD_PORT=9000 just dev`.
set dotenv-load := true

# `just` (no args) lists available recipes.
default:
@just --list
Expand Down Expand Up @@ -71,11 +78,14 @@ run-daemon:
run-daemon-lan:
VLC_HTTP_PASSWORD=dummy deckd --bind 0.0.0.0 --layouts-dir layouts --verbose

# Default ports. Override with DECKD_PORT / VITE_PORT when running multiple
# worktrees side-by-side (each `git worktree` lives on its own checkout but
# still shares the host's port space).
# Default ports, and the knobs that let worktrees coexist. `just worktree-adopt`
# writes all four into a per-checkout ./.env (loaded above); set them by hand
# for a one-off. DECKD_E2E_PORT and DECKD_SMOKE_PORT move the two throwaway
# test daemons, so two worktrees can run `just test-all` at the same time.
DECKD_PORT := env_var_or_default("DECKD_PORT", "8765")
VITE_PORT := env_var_or_default("VITE_PORT", "5173")
DECKD_E2E_PORT := env_var_or_default("DECKD_E2E_PORT", "8975")
DECKD_SMOKE_PORT := env_var_or_default("DECKD_SMOKE_PORT", "18765")

# Kill whatever is bound to the two ports we use: the daemon (default :8765)
# and the Vite dev server (default :5173). Handy when a stale daemon still
Expand Down Expand Up @@ -309,9 +319,11 @@ test-client:
# End-to-end smoke test (boots daemon in-process, fires every action
# primitive). Uses a stable fixture layout (scripts/smoke_fixtures/)
# so shipping-layout edits can't break CI (#77). Pass --layouts-dir to
# point at shipping layouts (or anything else) instead.
# point at shipping layouts (or anything else) instead. Binds DECKD_SMOKE_PORT
# (default :18765, well away from any live daemon) so two worktrees can run
# it concurrently.
smoke:
python -u scripts/smoke.py
DECKD_SMOKE_PORT={{DECKD_SMOKE_PORT}} python -u scripts/smoke.py

# Check whether this shell can create a uinput scroll device.
check-uinput:
Expand Down Expand Up @@ -395,8 +407,14 @@ watch-focus-once:
python -u scripts/watch_focus.py --once

# Hit /health.
#
# These four all target DECKD_PORT — i.e. *this* checkout's daemon. Without
# that, deckctl's own default (:8765) would answer from whatever holds the
# default port, which on a machine running an installed deckd service is the
# prod daemon rather than the dev one you just started. To aim at another
# instance deliberately: `DECKD_PORT=8765 just status`.
status:
deckctl status
deckctl --port {{DECKD_PORT}} status

# Hit /diag (issue #70): one-shot machine-readable snapshot of the
# daemon's focus, input, layouts, sessions, and MPRIS state. Open-auth,
Expand All @@ -405,22 +423,22 @@ status:
diag:
#!/usr/bin/env bash
set -euo pipefail
deckctl diag
deckctl --port {{DECKD_PORT}} diag

# Hit /layouts (issue #70): enumeration of loaded layouts and safe
# widget summaries (no action bodies).
layouts:
#!/usr/bin/env bash
set -euo pipefail
deckctl layouts
deckctl --port {{DECKD_PORT}} layouts

# Hit /metrics (issue #71): Prometheus text-format scrape. Open-auth
# and stdlib-only on the server side; pipe into ``head`` or
# ``grep deckd_`` for a quick check.
metrics:
#!/usr/bin/env bash
set -euo pipefail
deckctl metrics
deckctl --port {{DECKD_PORT}} metrics

# Run the Nix flake checks: builds packages.deckd and the focus-watcher
# bundles, evaluates the NixOS + home-manager modules, unit-tests the
Expand All @@ -429,3 +447,43 @@ metrics:
# See docs/GUIDE.md "Nix flake, NixOS, and home-manager".
nix-check:
nix flake check -L

# --- Worktrees ------------------------------------------------------------
# Code needs nothing for `git worktree` (every path resolves from the file's
# own location). What a fresh checkout lacks is the gitignored scaffolding —
# .envrc, .venv, client/node_modules, TLS certs — plus a port assignment that
# doesn't collide with its siblings. See docs/ONBOARDING.md#worktrees-git-worktree.

# "Adopt" because the worktree usually already exists: an agent harness
# (Paseo, Cursor) or a plain `git worktree add` made it, and this claims it
# afterwards. Assigns free ports -> ./.env, wires .envrc to the primary
# checkout's flox env, copies over gitignored bits worth sharing (TLS certs),
# then runs `just setup`. Idempotent, so it's also the repair command. Pass
# --no-install to skip the slow dependency step, --force to reassign ports.
#
# Make THIS worktree dev-ready: ports, env, dependencies. [--no-install] [--force]
worktree-adopt *args:
@bash scripts/worktree.sh adopt {{args}}

# Checks port assignment (including collisions with siblings), .envrc,
# toolchain on PATH, venv, and client deps. Every failure prints its fix;
# exits non-zero if the checkout isn't ready.
#
# Why isn't this worktree working?
worktree-doctor:
@bash scripts/worktree.sh doctor

# Every worktree with its assigned ports and readiness. `*` marks this one.
worktree-list:
@bash scripts/worktree.sh list

# For harness-created worktrees, run `worktree-adopt` inside the checkout
# instead — this is only for the from-scratch case.
#
# Add a worktree at ../deckd-<branch> on a new branch off HEAD, then adopt it.
worktree-create branch *args:
#!/usr/bin/env bash
set -euo pipefail
dir="$(cd "$(git rev-parse --show-toplevel)/.." && pwd)/deckd-{{branch}}"
git worktree add -b "{{branch}}" "$dir"
cd "$dir" && just worktree-adopt {{args}}
16 changes: 11 additions & 5 deletions client/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,20 @@ import { findChromiumExe } from "./e2e/find-chromium.mjs";

const __dirname = dirname(fileURLToPath(import.meta.url));

// Port for the fixture daemon this config boots. Defaults to 8975 (not the
// daemon default 8765) so e2e runs alongside a live dev/user daemon; a
// worktree overrides it via its ./.env so two checkouts can run `just test-all`
// at the same time (see docs/ONBOARDING.md#worktrees-git-worktree).
const e2ePort = Number(process.env.DECKD_E2E_PORT ?? 8975);

export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
workers: 1,
reporter: [["list"]],
timeout: 30000,
use: {
baseURL: "http://localhost:8975",
baseURL: `http://localhost:${e2ePort}`,
trace: "retain-on-failure",
},
projects: [
Expand All @@ -27,8 +33,8 @@ export default defineConfig({
],
webServer: {
// Copy the repo layouts into a throwaway tmp dir so an e2e save cycle
// never mutates the human-owned YAML. Port 8975 (not the daemon default
// 8765) so e2e can run alongside a live dev/user daemon.
// never mutates the human-owned YAML — suffixed with the port so two
// worktrees running e2e concurrently don't stomp each other's copy.
// DECKD_BIN: ./.venv is the plain-uv layout; a flox checkout has no
// ./.venv and gets `deckd` from the activated env on PATH.
//
Expand All @@ -44,9 +50,9 @@ export default defineConfig({
// can assert the surface + chrome dot without a session bus or a
// real MPRIS player on the runner (see daemon/deckd/__main__.py).
command:
'cd .. && DECKD_BIN=.venv/bin/deckd && [ -x "$DECKD_BIN" ] || DECKD_BIN=deckd; rm -rf /tmp/deckd-e2e-layouts && mkdir /tmp/deckd-e2e-layouts && cp layouts/default.yaml layouts/editor.yaml layouts/mpris.yaml /tmp/deckd-e2e-layouts/ && rm -f client/e2e/.daemon.log && PYTHONUNBUFFERED=1 PYTHONPATH=scripts/no-evdev DECKD_FAKE_INPUT=1 DECKD_FAKE_MPRIS=client/e2e/fixtures/mpris-seed.json "$DECKD_BIN" --layouts-dir /tmp/deckd-e2e-layouts --client-dist client/dist --no-auth --no-focus --port 8975 --verbose > client/e2e/.daemon.log 2>&1',
`cd .. && DECKD_BIN=.venv/bin/deckd && [ -x "$DECKD_BIN" ] || DECKD_BIN=deckd; rm -rf /tmp/deckd-e2e-layouts-${e2ePort} && mkdir /tmp/deckd-e2e-layouts-${e2ePort} && cp layouts/default.yaml layouts/editor.yaml layouts/mpris.yaml /tmp/deckd-e2e-layouts-${e2ePort}/ && rm -f client/e2e/.daemon.log && PYTHONUNBUFFERED=1 PYTHONPATH=scripts/no-evdev DECKD_FAKE_INPUT=1 DECKD_FAKE_MPRIS=client/e2e/fixtures/mpris-seed.json "$DECKD_BIN" --layouts-dir /tmp/deckd-e2e-layouts-${e2ePort} --client-dist client/dist --no-auth --no-focus --port ${e2ePort} --verbose > client/e2e/.daemon.log 2>&1`,
cwd: __dirname,
port: 8975,
port: e2ePort,
reuseExistingServer: false,
timeout: 30000,
},
Expand Down
2 changes: 1 addition & 1 deletion docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ just build-client
just dev-daemon # listens on http://127.0.0.1:8765, auto-restarts on Python edits
```

`just dev-daemon` wraps the daemon in the `deckd-dev` supervisor so Python edits hot-reload (YAML hot-reloads either way). For a one-shot `deckd` invocation use `just run-daemon`. Running multiple `git worktree`s side-by-side? Override the default ports with `DECKD_PORT` / `VITE_PORT` (see [docs/ONBOARDING.md](ONBOARDING.md#worktrees-git-worktree)).
`just dev-daemon` wraps the daemon in the `deckd-dev` supervisor so Python edits hot-reload (YAML hot-reloads either way). For a one-shot `deckd` invocation use `just run-daemon`. Running multiple `git worktree`s side-by-side? Run `just worktree-adopt` once inside each new checkout — it assigns non-colliding ports, wires the toolchain to the primary checkout's env, and installs dependencies (see [docs/ONBOARDING.md](ONBOARDING.md#worktrees-git-worktree)).

To force a specific platform's setup (e.g. on a CI box): `just setup-linux` or `just setup-macos`.

Expand Down
176 changes: 136 additions & 40 deletions docs/ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,53 +148,149 @@ Key daemon CLI flags (in `daemon/deckd/__main__.py`):

### Worktrees (`git worktree`)

Each `git worktree add` is a fully independent checkout of the repo. Paths
inside the daemon, tests, and scripts are anchored to the file's own
location (`Path(__file__).resolve().parents[N]`), so layouts, fixtures, and
the built client all resolve correctly without any symlinks or rewrites —
no code change is required for worktree support.
Each `git worktree add` is a fully independent checkout. Paths inside the
daemon, tests, and scripts are anchored to the file's own location
(`Path(__file__).resolve().parents[N]`), so layouts, fixtures, and the built
client all resolve correctly with no symlinks or rewrites — **no code change
is required for worktree support.**

The one resource that *is* shared is the host's port space: every worktree
that runs `just dev` defaults to `:8765` (daemon) and `:5173` (Vite), so a
second worktree can't bind the same ports. Override with env vars before
launching:
What a fresh worktree *does* lack is everything git deliberately doesn't carry
across: the gitignored scaffolding (`.envrc`, `.flox/`, `.venv/`,
`client/node_modules/`, `client/.tls/`) and a port assignment that doesn't
collide with its siblings. One command fixes all of it:

```sh
# worktree 1 (defaults)
just dev
cd /path/to/the/new/worktree
just worktree-adopt
```

"Adopt", not "create", because the checkout usually already exists — an agent
harness (Paseo, Cursor) or a plain `git worktree add` made it, and this claims
it afterwards. It is idempotent, so it's also the repair command. It:

1. assigns the lowest free port offset and writes it to a gitignored `./.env`
(the primary prefers offset 0, but moves off it if those ports are taken);
2. writes an `.envrc` that resolves the **primary** checkout's flox
environment (only if the primary itself uses direnv/flox — the repo doesn't
prescribe either), and runs `direnv allow`;
3. copies gitignored-but-shareable files over, currently `client/.tls`
(host-wide certs that otherwise cost a `sudo` prompt per worktree);
4. runs `just setup` — pass `--no-install` to skip that and do it yourself.

# worktree 2 — pick free ports and keep them consistent across all recipes
DECKD_PORT=8766 VITE_PORT=5174 just dev
Then:

```sh
just worktree-doctor # why isn't this worktree working? every failure prints its fix
just worktree-list # all worktrees, their ports, and whether they're ready
just worktree-create B # git worktree add ../deckd-B on a new branch, then adopt it
```

The dev recipes read these vars and pass them to both halves:

- `DECKD_PORT` is forwarded as `deckd-dev`'s `--port`; `dev-daemon`,
`dev-daemon-lan`, `dev`, and `dev-lan` all honour it.
- `VITE_PORT` is forwarded as Vite's `--port`. When it's been overridden
the recipe drops `--strictPort` so Vite falls through to the next free
port instead of failing; it also sets `DECKD_UPSTREAM` so Vite's
`/ws`/`/health` proxy reaches the *current* worktree's daemon.
- `just kill` only tears down the *current* worktree's ports, so two
worktrees running side-by-side won't take each other down.

Caveats:

- **`just install-service` should only be run from your main checkout.**
It writes the literal `$(pwd)` into the systemd unit / launchd plist;
doing it from a feature worktree pins the service to a worktree that
will eventually be removed.
- **Live MPRIS / focus smoke tests** (`just smoke-mpris`, `just
smoke-focus`) hit the real session bus, so two worktrees can't run
them simultaneously.
#### Ports

The one genuinely shared resource is the host's port space. Every checkout
gets an offset applied to all four bases at once, so its ports stay mentally
grouped:

| offset | `DECKD_PORT` | `VITE_PORT` | `DECKD_E2E_PORT` | `DECKD_SMOKE_PORT` | who |
| --- | --- | --- | --- | --- | --- |
| 0 | 8765 | 5173 | 8975 | 18765 | the primary checkout, *if the defaults are free* |
| 1 | 8766 | 5174 | 8976 | 18766 | first adopted checkout |
| 2 | 8767 | 5175 | 8977 | 18767 | second, and so on |

The primary prefers offset 0 and normally needs no `.env` at all, so on a
machine with no installed deckd nothing about the main checkout changes. It
does **not** own offset 0 though: if something already holds `:8765` — almost
always an installed deckd service — `worktree-adopt` moves the primary to a
free offset like any other checkout, and says so. Delete its `.env` and
re-adopt to move back once the port frees up.

An offset is only free when *all four* of its ports are, so a stray process on
one port pushes the whole group along rather than producing a half-working
checkout.

`.env` is loaded automatically by every recipe (`set dotenv-load` in the
Justfile), so `just dev`, `just kill`, `just smoke`, and `just test-all` all
target the current checkout with no env-var juggling. An explicit variable
still wins for a one-off: `DECKD_PORT=9000 just dev`.

- `DECKD_PORT` becomes `deckd-dev`'s `--port` (`dev-daemon`, `dev-daemon-lan`,
`dev`, `dev-lan`) — and `deckctl`'s `--port` in `just status`, `diag`,
`layouts`, and `metrics`, so those report on *this* checkout's daemon rather
than whatever holds the default port.
- `VITE_PORT` becomes Vite's `--port`; when it differs from 5173 the recipe
drops `--strictPort` so Vite falls through if the port is busy, and sets
`DECKD_UPSTREAM` so the `/ws` + `/health` proxy reaches *this* checkout's
daemon.
- `DECKD_E2E_PORT` moves the Playwright fixture daemon and its throwaway
layouts dir; `DECKD_SMOKE_PORT` moves the in-process smoke server. Together
they let two checkouts run `just test-all` simultaneously.
- `just kill` only tears down the current checkout's ports.

#### Running dev alongside an installed deckd

A machine can run an installed deckd service (systemd/launchd/home-manager)
and any number of dev instances at once. What's isolated, and what isn't:

**Isolated, no action needed.** Layouts — the service reads
`~/.config/deckd/layouts`, dev checkouts read their own `./layouts`, so an
editor save in a dev instance can't touch the service's. Client state — each
port is a distinct browser origin, so every instance gets its own PWA storage
and service worker. The password file (`~/.config/deckd/password`) *is*
shared, which is a convenience rather than a conflict: one password opens
every instance.

**Handled by the offsets.** Ports, including the primary checkout, per above.

**Not isolated, and can't be.** These act on shared session state, so every
running daemon competes:

- **Input injection.** Each daemon opens its own uinput device (all named
`deckd`) and injects into whatever window currently has focus. Press a
button on the service's client and on a dev client and the target app
receives both.
- **MPRIS transport and `dbus:` actions.** Same story — they drive the
session's real players and services.
- **KDE focus (`org.deckd.Focus`).** On KDE the *daemon* owns the bus name,
and it requests it with `NameFlag.REPLACE_EXISTING` — so the last daemon to
start silently takes focus pushes away from every other one, including the
installed service. GNOME is unaffected: there the Shell extension owns the
name and daemons only call it, so any number coexist.

In practice: run as many daemons as you like, but only drive *one* client at a
time, and on KDE expect focus-dependent behaviour to follow the most recently
started daemon.

#### Toolchain: shared env, per-worktree venv

A worktree has no `.flox/` of its own, and direnv's stdlib `use flox` requires
a local one — so the generated `.envrc` calls `flox activate -d <primary>`
directly. flox leaves `$PWD` alone, so the toolchain resolves from the primary
while this worktree's own `.venv` is the one that gets used. That split is
deliberate and load-bearing:

- **Toolchain is shared** (python, node) — nothing to rebuild per worktree.
- **The venv is not.** `uv pip install -e .` bakes an absolute path into the
editable install, so a shared venv would silently point `deckd` at whichever
worktree installed last.

The manifest's `[profile]` hook is what exports `$PWD/.venv/bin`, but flox
only sources it for an **interactive** shell — direnv's non-interactive env
dump never carries it. So the generated `.envrc` adds `.venv/bin` to `PATH`
(and exports `VIRTUAL_ENV`) itself. Without that, `just dev-daemon` and the
`deckctl` recipes fail with `deckd-dev: command not found` in an activated
worktree, even though `.venv/bin/deckd-dev` exists. If you hand-edit `.envrc`,
keep that block.

Caveats that remain:

- **`just install-service` should only be run from your main checkout.** It
writes the literal `$(pwd)` into the systemd unit / launchd plist; from a
feature worktree it pins the service to a checkout that will be removed.
- **Live MPRIS / focus smoke tests** (`just smoke-mpris`, `just smoke-focus`)
hit the real session bus, so two worktrees can't run them at once.
- **`uv.lock` is per-repo, not per-worktree.** A `uv pip install` in one
worktree edits the lockfile that all worktrees share; if you're
intentionally diverging dependencies, isolate with a worktree-local
venv (`uv venv --python 3.11 .venv`) and commit changes deliberately.
- **Each worktree needs its own `.venv/`** (run `just setup` per
worktree); `just test-all` only prepends `./.venv/bin` when one
exists, so a worktree without one will fall back to whatever Python
is on PATH (flox's, or the active interpreter).
worktree edits the lockfile every worktree shares; if you're intentionally
diverging dependencies, commit the change deliberately.

## Verification ladder

Expand Down
Loading
Loading