Repository navigation
Expand file tree
/
Copy pathMakefile
More file actions
387 lines (320 loc) · 19.1 KB
/
Copy pathMakefile
File metadata and controls
387 lines (320 loc) · 19.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
# Grove — single source of truth for local dev tasks.
#
# All targets shell out to `uv` so the venv is implicit. CI workflows call
# the same targets, so what works locally works on every push.
#
# Canonical install for end users is `uvx grove` (see README); this Makefile
# is for contributors only.
UV ?= uv
PROJECT := grove
DIST := dist
BUILD := build
.DEFAULT_GOAL := help
.PHONY: help
help: ## Show this help message
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
# ─── environment ────────────────────────────────────────────────────────────
.PHONY: sync install dev
sync: ## Install runtime + dev deps via uv (alias: install, dev)
$(UV) sync --all-groups
install: sync
dev: sync
# ─── quality gates ──────────────────────────────────────────────────────────
.PHONY: lint format type contracts check
lint: ## Run ruff + mypy + import-linter
$(UV) run ruff check src tests
$(UV) run ruff format --check src tests
$(UV) run mypy src/$(PROJECT)
$(UV) run lint-imports
format: ## Auto-fix lint + reformat
$(UV) run ruff check --fix src tests
$(UV) run ruff format src tests
type: ## Run mypy --strict only
$(UV) run mypy src/$(PROJECT)
contracts: ## Run import-linter only
$(UV) run lint-imports
check: lint test ## Run lint + unit tests (no integration)
# ─── docs ───────────────────────────────────────────────────────────────────
.PHONY: docs-schema app-icons docs-capture docs-screenshots docs-webapp-screenshots docs-frame-webapp-screenshots docs-tour-gif docs-mockups docs-images docs docs-build
docs-schema: ## Regenerate docs/grove.schema.json from the Pydantic model
$(UV) run grove config schema --stdout > docs/grove.schema.json
# Deliberately NOT part of `docs-images`: this reads one committed SVG and
# writes eight committed files, so it belongs to a change to the artwork, not
# to a screenshot refresh that needs a live fleet and twelve minutes.
app-icons: ## Regenerate every app icon from docs/img/grove-logo.svg (needs chromium)
$(UV) run --group dev python -m tools.app_icons
# ONE seed serves both halves: `capture.py` plants the demo world once, brings
# up one sandbox daemon, then shoots the TUI and the web dashboard against it.
# Seeding is most of the cost (a year of usage history), so `docs-capture` is
# what CI runs; the two single-half targets exist for iterating on one surface.
# Needs `node` plus the webapp's Playwright install; `capture.py` preflights
# both before it plants anything.
docs-capture: webapp-build ## Regenerate every TUI + web screenshot from ONE seeded demo world
$(UV) run --group dev python -m tools.screenshots.capture --shots tui,webapp
$(MAKE) docs-frame-webapp-screenshots
$(MAKE) docs-tour-gif
docs-screenshots: ## Regenerate the framed TUI PNG screenshots only
$(UV) run --group dev python -m tools.screenshots.capture --shots tui
# The desktop shots, and only those. Framing is not idempotent — a second
# pass frames the frame — so the set is named rather than globbed, and it is
# chained onto the capture that produces it so the two cannot drift apart.
# `webapp-home-mobile.png` is deliberately absent: it feeds the phone mockup,
# which supplies its own device shell.
FRAMED_SHOTS := $(addprefix docs/img/screenshots/,webapp-home.png webapp-composer.png webapp-sessions.png webapp-workspace.png webapp-annotate.png webapp-diagram-split.png webapp-diagram.png webapp-diagram-palette.png webapp-usage.png webapp-usage-detail.png webapp-pair-device.png webapp-pair-code.png)
docs-webapp-screenshots: webapp-build ## Regenerate the web dashboard PNG screenshots only (needs webapp/.next)
$(UV) run --group dev python -m tools.screenshots.capture --shots webapp
$(MAKE) docs-frame-webapp-screenshots
$(MAKE) docs-tour-gif
docs-frame-webapp-screenshots: ## Composite the webapp screenshots onto a consistent 16:9 framed window
$(UV) run --group dev python -m tools.screenshots.frame $(FRAMED_SHOTS)
# Chained onto the framing above, never run standalone against unframed shots:
# the tour's whole compression story is that every slide shares one wallpaper.
docs-tour-gif: ## Build the looping web dashboard tour GIF from the framed shots
$(UV) run --group dev python -m tools.screenshots.slideshow
docs-mockups: ## Composite the landing-page device mockups from the latest screenshots
$(UV) run python -m tools.screenshots.mockups
docs-images: docs-capture docs-mockups ## Regenerate every doc image (TUI + web + mockups)
docs: docs-schema ## Serve the docs site locally (live reload)
$(UV) run --group docs mkdocs serve
docs-build: docs-schema ## Build the docs site (CI parity, --strict)
$(UV) run --group docs mkdocs build --strict
# ─── tests ──────────────────────────────────────────────────────────────────
.PHONY: test integration test-all
test: ## Run unit + Pilot tests (excludes integration)
$(UV) run pytest -m "not integration"
integration: ## Run real-tmux + real-git integration tests
$(UV) run pytest -m integration
test-all: test integration ## Run every test we have
# ─── build artifacts ────────────────────────────────────────────────────────
.PHONY: build clean uvx-smoke install-smoke
build: ## Build sdist + wheel into $(DIST)/ (PyPI-ready)
$(UV) build
uvx-smoke: ## Verify the package works under uvx from the local checkout
$(UV) tool run --from . --no-cache grove version
install-smoke: ## Clean-install + first-run smoke test in a fresh Ubuntu container (needs docker)
docker build -t grove-install-smoke packaging/docker
docker run --rm -v "$(CURDIR):/src:ro" -e GROVE_INSTALL_SPEC grove-install-smoke bash /src/packaging/docker/smoke.sh
clean: ## Remove build artifacts and caches
rm -rf $(DIST) $(BUILD) *.egg-info
rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov
# ─── release dry-run ────────────────────────────────────────────────────────
.PHONY: release-check
release-check: lint test build uvx-smoke ## Full pre-release gauntlet
@echo
@echo "release-check OK — wheel + sdist in $(DIST)/"
# ─── webapp (assistant-ui-native front end) ─────────────────────────────────
#
# webapp/ is a Node project with its own package.json. These targets are
# convenience wrappers — see webapp/CLAUDE.md for the full engineering
# contract. Default GROVE_DAEMON_URL points at the daemon defaults below.
# It needs Node >= 22 (Next 16), which is often newer than the shell default —
# hence its own WEBAPP_NPM_BIN rather than assuming the shell's npm resolves
# to a new enough Node.
# Auto-detect grove + npm binaries; user can override for nvm/asdf setups.
# `grove-mcp` is a separate console script from `grove`, so it gets its own
# lookup — an install with the lean `[daemon]` extra ships one and not the other.
#
# These sit ABOVE their first consumer on purpose. `WEBAPP_NODE_BIN_DIR` and
# `WEBAPP_NPM` are `:=`, which expands immediately, so a definition placed
# below them reads as empty and the recipe loses its command entirely.
GROVE_BIN ?= $(shell command -v grove 2>/dev/null)
MCP_BIN ?= $(shell command -v grove-mcp 2>/dev/null)
NPM_BIN ?= $(shell command -v npm 2>/dev/null)
NODE_BIN_DIR := $(if $(NPM_BIN),$(dir $(NPM_BIN)),)
# The npm Grove ships (`nodejs-wheel`, a base dependency) sits beside the
# interpreter in the project venv, so the webapp builds on a known Node 24
# whatever the host's nvm default is. The shell's npm is only the fallback,
# for a checkout whose venv has not been synced yet.
VENV_NPM_BIN := $(wildcard $(CURDIR)/.venv/bin/npm)
WEBAPP_DIR ?= $(CURDIR)/webapp
WEBAPP_NPM_BIN ?= $(or $(VENV_NPM_BIN),$(NPM_BIN))
WEBAPP_NODE_BIN_DIR := $(if $(WEBAPP_NPM_BIN),$(dir $(WEBAPP_NPM_BIN)),)
# Naming the npm does NOT choose the Node that runs it. Every npm is a
# `#!/usr/bin/env node` shim, so a Node 22 npm invoked from a shell whose PATH
# leads with Node 20 executes under Node 20 — `npm -v` reports 10.9.8 while
# `process.versions.node` is v20.x. That mismatch is what produced a wall of
# EBADENGINE warnings while WEBAPP_NPM_BIN looked correctly set, and it is the
# same class of bug as the stale-surface rule this file's header describes:
# the override was honoured and still had no effect. Put the chosen npm's own
# bin dir FIRST on PATH so the shim resolves its sibling node.
WEBAPP_NPM := PATH="$(WEBAPP_NODE_BIN_DIR:/=):$$PATH" $(WEBAPP_NPM_BIN)
.PHONY: webapp-install webapp-build webapp-dev webapp-test webapp-gate
webapp-install: ## Install webapp Node deps (npm ci)
cd $(WEBAPP_DIR) && $(WEBAPP_NPM) ci
webapp-build: webapp-install ## Build webapp for production (required before `systemd` w/ webapp)
cd $(WEBAPP_DIR) && $(WEBAPP_NPM) run build
webapp-dev: ## Run webapp dev server (LAN-reachable on :3000)
cd $(WEBAPP_DIR) && $(WEBAPP_NPM) run dev
webapp-test: ## Run webapp unit + component tests
cd $(WEBAPP_DIR) && $(WEBAPP_NPM) test
webapp-gate: ## Run webapp's full gate (typecheck, registry drift, styling, tests)
cd $(WEBAPP_DIR) && $(WEBAPP_NPM) run gate
# The prebuilt dashboard that ships inside the wheel, served by `grove web`.
#
# Built in a scratch copy of the COMMITTED webapp (`git ls-files`), never in
# webapp/ itself: a build rewrites `.next` in place, and that directory may be
# what a live grove-webapp is serving from this checkout. `node_modules` is
# hard-linked (Turbopack rejects a symlinked one) so the copy costs no disk.
#
# `sharp` is traced even with unoptimized images, and it is the only native
# code in the bundle: removing it is what lets one pure-JS wheel serve every
# OS. Its loader is lazy, so nothing touches it once images are unoptimized.
#
# The finished bundle is assembled beside the live one and swapped in with two
# renames, only after the build and the native-module check both pass. An
# editable install serves src/grove/_webapp directly, so deleting it first (as
# this recipe once did) left a running `grove web` with no files for the whole
# build, and a failed build left it with none at all.
WEBAPP_BUNDLE_OUT := $(CURDIR)/src/grove/_webapp
WEBAPP_BUNDLE_TMP := $(CURDIR)/build/webapp-bundle
WEBAPP_BUNDLE_NEW := $(CURDIR)/build/webapp-bundle-out
.PHONY: webapp-bundle
webapp-bundle: webapp-install ## Build the standalone dashboard into src/grove/_webapp (shipped in the wheel)
rm -rf $(WEBAPP_BUNDLE_TMP) $(WEBAPP_BUNDLE_NEW)
mkdir -p $(WEBAPP_BUNDLE_TMP)
cd $(WEBAPP_DIR) && git ls-files -z | xargs -0 cp --parents -t $(WEBAPP_BUNDLE_TMP)
cp -al $(WEBAPP_DIR)/node_modules $(WEBAPP_BUNDLE_TMP)/node_modules
cd $(WEBAPP_BUNDLE_TMP) && GROVE_WEBAPP_BUNDLE=1 $(WEBAPP_NPM) run build
cp -a $(WEBAPP_BUNDLE_TMP)/.next/standalone $(WEBAPP_BUNDLE_NEW)
cp -a $(WEBAPP_BUNDLE_TMP)/.next/static $(WEBAPP_BUNDLE_NEW)/.next/static
cp -a $(WEBAPP_BUNDLE_TMP)/public $(WEBAPP_BUNDLE_NEW)/public
rm -rf $(WEBAPP_BUNDLE_NEW)/node_modules/sharp $(WEBAPP_BUNDLE_NEW)/node_modules/@img
@if find $(WEBAPP_BUNDLE_NEW) -name '*.node' | grep -q .; then \
echo "native module left in the bundle; the wheel would be platform-specific:" >&2; \
find $(WEBAPP_BUNDLE_NEW) -name '*.node' >&2; exit 1; fi
rm -rf $(WEBAPP_BUNDLE_OUT).old
if [ -d $(WEBAPP_BUNDLE_OUT) ]; then mv $(WEBAPP_BUNDLE_OUT) $(WEBAPP_BUNDLE_OUT).old; fi
mv $(WEBAPP_BUNDLE_NEW) $(WEBAPP_BUNDLE_OUT)
rm -rf $(WEBAPP_BUNDLE_OUT).old $(WEBAPP_BUNDLE_TMP)
# ─── systemd (Linux user-scope) ─────────────────────────────────────────────
#
# Installs ~/.config/systemd/user/grove-daemon.service (always),
# grove-webapp.service (only when WITH_WEBAPP=1), and grove-mcp.service
# (only when WITH_MCP=1). Templates live in packaging/systemd/*.service.in;
# @PLACEHOLDER@ tokens are substituted by sed at install time.
#
# Quick reference:
# make systemd # daemon only
# WITH_WEBAPP=1 make systemd # daemon + webapp
# WITH_MCP=1 make systemd # daemon + MCP over Streamable HTTP
# make systemd-enable # daemon-reload + enable --now (matches WITH_WEBAPP)
# make systemd-disable # stop + disable
# make systemd-uninstall # remove unit files
# make systemd-status # systemctl --user status
#
# See packaging/systemd/README.md for the full reference.
SYSTEMD_USER_DIR ?= $(HOME)/.config/systemd/user
SYSTEMD_TEMPLATE_DIR := $(CURDIR)/packaging/systemd
# GROVE_BIN / MCP_BIN / NPM_BIN / NODE_BIN_DIR are defined with the webapp
# block above, which consumes them in immediately-expanded assignments.
# PATH baked into the daemon unit. Defaults to the invoking shell's PATH so
# pyenv/nvm/asdf-managed toolchains stay visible to init scripts under
# systemd --user, which otherwise provides a bare PATH (issue #9). Snapshot
# semantics: re-run `make systemd` after toolchain moves.
DAEMON_PATH ?= $(PATH)
DAEMON_HOST ?= 127.0.0.1
DAEMON_PORT ?= 7421
WEBAPP_HOST ?= 0.0.0.0
WEBAPP_PORT ?= 3000
# Loopback by default, deliberately unlike WEBAPP_HOST: the MCP surface grants
# workspace lifecycle control (create/kill/message), so exposing it off-host is
# an explicit operator decision, taken behind a tunnel/VPN/TLS proxy.
MCP_HOST ?= 127.0.0.1
MCP_PORT ?= 7431
DAEMON_URL := http://$(DAEMON_HOST):$(DAEMON_PORT)
# WITH_WEBAPP=1 to also install/enable/disable the webapp unit.
WITH_WEBAPP ?=
# WITH_MCP=1 to also install/enable/disable the MCP (Streamable HTTP) unit.
WITH_MCP ?=
# Internal: list a companion unit basename only when its gate is set.
_WEBAPP_UNIT_NAME := $(if $(WITH_WEBAPP),grove-webapp.service,)
_MCP_UNIT_NAME := $(if $(WITH_MCP),grove-mcp.service,)
_UNITS := grove-daemon.service $(_WEBAPP_UNIT_NAME) $(_MCP_UNIT_NAME)
# When a gate is set, `systemd` depends on that unit's install recipe too.
# These are Make-level (not shell) conditionals so the per-unit recipes stay
# single-purpose and don't hide control flow inside shell heredocs.
_WEBAPP_INSTALL_DEP := $(if $(WITH_WEBAPP),_systemd-install-webapp,)
_MCP_INSTALL_DEP := $(if $(WITH_MCP),_systemd-install-mcp,)
.PHONY: systemd systemd-enable systemd-disable systemd-uninstall systemd-status systemd-print \
_systemd-precheck _systemd-install-daemon _systemd-install-webapp _systemd-install-mcp
# Precondition: required binaries discoverable. Run before any install/enable.
_systemd-precheck:
@if [ -z "$(GROVE_BIN)" ]; then \
echo "✗ grove binary not on PATH. Install first: 'curl -fsSL https://raw.githubusercontent.com/bearlike/Grove/current/install.sh | bash'" >&2; \
exit 1; \
fi
@echo "✓ grove: $(GROVE_BIN)"
@if [ -n "$(WITH_MCP)" ]; then \
if [ -z "$(MCP_BIN)" ]; then \
echo "✗ grove-mcp not on PATH (required for WITH_MCP=1). Reinstall with the mcp extra: 'uv tool install --reinstall --force --editable .[all]'" >&2; exit 1; \
fi; \
if [ ! -f "$(HOME)/.config/grove/mcp.env" ]; then \
echo "⚠ $(HOME)/.config/grove/mcp.env not found — create it (mode 0600) with GROVE_MCP_TOKEN=<secret>; the service exits 2 without it."; \
fi; \
echo "✓ grove-mcp: $(MCP_BIN)"; \
fi
# Common sed substitution applied to a template. Uses ',' as the delimiter
# so file paths don't need escaping.
_SED_SUBST := sed \
-e 's,@GROVE_BIN@,$(GROVE_BIN),g' \
-e 's,@DAEMON_PATH@,$(DAEMON_PATH),g' \
-e 's,@DAEMON_HOST@,$(DAEMON_HOST),g' \
-e 's,@DAEMON_PORT@,$(DAEMON_PORT),g' \
-e 's,@WEBAPP_HOST@,$(WEBAPP_HOST),g' \
-e 's,@WEBAPP_PORT@,$(WEBAPP_PORT),g' \
-e 's,@MCP_BIN@,$(MCP_BIN),g' \
-e 's,@MCP_HOST@,$(MCP_HOST),g' \
-e 's,@MCP_PORT@,$(MCP_PORT),g' \
-e 's,@DAEMON_URL@,$(DAEMON_URL),g'
_systemd-install-daemon: _systemd-precheck
@mkdir -p "$(SYSTEMD_USER_DIR)"
@$(_SED_SUBST) "$(SYSTEMD_TEMPLATE_DIR)/grove-daemon.service.in" > "$(SYSTEMD_USER_DIR)/grove-daemon.service.tmp"
@mv -f "$(SYSTEMD_USER_DIR)/grove-daemon.service.tmp" "$(SYSTEMD_USER_DIR)/grove-daemon.service"
@echo "✓ wrote $(SYSTEMD_USER_DIR)/grove-daemon.service"
# Proven against the real install before the unit is written, never in the
# shared precheck: `systemd-print` is a dry run and must render for any GROVE_BIN.
_systemd-install-webapp: _systemd-precheck
@"$(GROVE_BIN)" web --check >/dev/null || { \
echo "✗ $(GROVE_BIN) cannot serve the web dashboard. Reinstall: 'uv tool install --reinstall grove-factory'." >&2; exit 1; }
@mkdir -p "$(SYSTEMD_USER_DIR)"
@$(_SED_SUBST) "$(SYSTEMD_TEMPLATE_DIR)/grove-webapp.service.in" > "$(SYSTEMD_USER_DIR)/grove-webapp.service.tmp"
@mv -f "$(SYSTEMD_USER_DIR)/grove-webapp.service.tmp" "$(SYSTEMD_USER_DIR)/grove-webapp.service"
@echo "✓ wrote $(SYSTEMD_USER_DIR)/grove-webapp.service"
_systemd-install-mcp: _systemd-precheck
@mkdir -p "$(SYSTEMD_USER_DIR)"
@$(_SED_SUBST) "$(SYSTEMD_TEMPLATE_DIR)/grove-mcp.service.in" > "$(SYSTEMD_USER_DIR)/grove-mcp.service.tmp"
@mv -f "$(SYSTEMD_USER_DIR)/grove-mcp.service.tmp" "$(SYSTEMD_USER_DIR)/grove-mcp.service"
@echo "✓ wrote $(SYSTEMD_USER_DIR)/grove-mcp.service"
systemd: _systemd-install-daemon $(_WEBAPP_INSTALL_DEP) $(_MCP_INSTALL_DEP) ## Install user systemd units (WITH_WEBAPP=1 / WITH_MCP=1 to also install those)
systemctl --user daemon-reload
@echo
@echo "next: 'make systemd-enable'$(if $(_WEBAPP_UNIT_NAME)$(_MCP_UNIT_NAME), (will enable all installed units),)"
systemd-print: _systemd-precheck ## Print the rendered unit file(s) without writing
@echo "─── grove-daemon.service ───"
@$(_SED_SUBST) "$(SYSTEMD_TEMPLATE_DIR)/grove-daemon.service.in"
@if [ -n "$(WITH_WEBAPP)" ]; then \
echo; echo "─── grove-webapp.service ───"; \
$(_SED_SUBST) "$(SYSTEMD_TEMPLATE_DIR)/grove-webapp.service.in"; \
fi
@if [ -n "$(WITH_MCP)" ]; then \
echo; echo "─── grove-mcp.service ───"; \
$(_SED_SUBST) "$(SYSTEMD_TEMPLATE_DIR)/grove-mcp.service.in"; \
fi
systemd-enable: systemd ## Enable + start now (matches WITH_WEBAPP scope)
systemctl --user enable --now $(_UNITS)
@echo
@for u in $(_UNITS); do systemctl --user --no-pager status $$u | head -3; done
systemd-disable: ## Stop + disable user units (matches WITH_WEBAPP scope)
-systemctl --user disable --now $(_UNITS)
systemd-uninstall: systemd-disable ## Stop, disable, and remove unit files
rm -f $(SYSTEMD_USER_DIR)/grove-daemon.service
@if [ -n "$(WITH_WEBAPP)" ]; then rm -f $(SYSTEMD_USER_DIR)/grove-webapp.service; fi
@if [ -n "$(WITH_MCP)" ]; then rm -f $(SYSTEMD_USER_DIR)/grove-mcp.service; fi
systemctl --user daemon-reload
@echo "✓ removed unit files"
systemd-status: ## Show status of installed grove user units
@for u in $(_UNITS); do \
echo "─── $$u ───"; \
systemctl --user --no-pager status $$u 2>&1 | head -10 || true; \
echo; \
done