Repository navigation
Expand file tree
/
Copy pathMakefile
More file actions
680 lines (599 loc) · 40.5 KB
/
Copy pathMakefile
File metadata and controls
680 lines (599 loc) · 40.5 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
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
# Every artifact below is derived from these two pins. Never "latest".
ADMINER_VERSION = 6.1.1
FRANKENPHP_VERSION = 1.13.3
ADMINER_URL = https://github.com/vrana/adminer/releases/download/v$(ADMINER_VERSION)
FRANKEN_URL = https://github.com/php/frankenphp/releases/download/v$(FRANKENPHP_VERSION)
# Which frankenphp build to fetch. Defaults to this machine; CI overrides it per runner.
# Windows is the odd one: it is the only asset shipped as a zip rather than a bare binary.
UNAME_S := $(shell uname -s)
UNAME_M := $(shell uname -m)
ifeq ($(UNAME_S),Darwin)
FRANKEN_ASSET ?= frankenphp-mac-$(if $(filter arm64,$(UNAME_M)),arm64,x86_64)
EXE =
else ifeq ($(UNAME_S),Linux)
FRANKEN_ASSET ?= frankenphp-linux-$(if $(filter aarch64 arm64,$(UNAME_M)),aarch64,x86_64)
EXE =
else
FRANKEN_ASSET ?= frankenphp-windows-x86_64.zip
EXE = .exe
endif
.PHONY: help install linux-deps fetch verify qa go-test phpstan dresscode dresscode-hook golangci oxc security check check-app e2e e2e-visual e2e-packaged i18n i18n-check mcp-bridge-data bench build run dev editor debug demo down destroy kill-orphans bundle zip dist tarball windows deb logs serve clean checksums
.DEFAULT_GOAL := help
# Self-documenting: every target with a `## ` comment lists itself here, so this
# stays in step with the Makefile instead of being a second list to maintain.
# A `##@ ` line opens a group, and targets list under whichever one they follow —
# so the grouping is the file's own order, not a second list to keep in step either.
##@ General
help: ## Show this help
@awk 'BEGIN { FS = ":.*## " } \
/^##@ / { printf "\n\033[1m%s\033[0m\n", substr($$0, 5); next } \
/^[a-z][a-zA-Z0-9-]*:.*## / { printf " \033[1m%-12s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
##@ Setup
# mise owns the dev toolchain (node, go, composer + npm deps, the WebKit e2e browser) —
# one entry point so its version pins stay in one place. On Linux the webview also needs
# GTK/WebKit dev headers, which are a distro package rather than a mise tool, so install
# runs linux-deps there too. Toolchain first, then `fetch` for the downloads (adminer +
# designs), so one command sets a fresh checkout up completely.
install: ## Install everything: dev toolchain (mise) + Linux webview deps + downloads
ifeq ($(UNAME_S),Linux)
$(MAKE) linux-deps
endif
mise run install
$(MAKE) fetch
# The one build dependency mise cannot pin: webview is cgo, and on Linux that means the
# system GTK-3 and WebKit2GTK dev headers — distro packages, not a toolchain. webview_go
# also hardcodes `pkg-config webkit2gtk-4.0`, which Ubuntu 24.04 dropped for 4.1 (same API
# over libsoup3), so a shim .pc that just requires 4.1 keeps the build on a current distro.
# Debian/Ubuntu (apt) is the only Linux supported right now — and the path CI and the .deb
# build on — so gate on apt up front and fail clearly on Arch/Fedora/others rather than
# half-installing. The pkg-config guard then makes a re-run a no-op that never re-sudos.
linux-deps: ## Install the Linux webview build deps (GTK/WebKit dev headers, Debian/Ubuntu only)
@command -v apt-get >/dev/null 2>&1 || { echo "linux-deps: Debian/Ubuntu (apt) is the only supported Linux distribution right now. On Arch, Fedora and others, install the GTK-3 and WebKit2GTK 4.1 dev headers with your own package manager."; exit 1; }
@pkg-config --exists gtk+-3.0 webkit2gtk-4.1 2>/dev/null && echo "gtk/webkit dev headers present" || { \
sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev; }
@pkg-config --exists webkit2gtk-4.0 2>/dev/null || printf 'Name: webkit2gtk-4.0\nDescription: Shim onto webkit2gtk-4.1\nVersion: 2.44.0\nRequires: webkit2gtk-4.1\n' \
| sudo tee /usr/lib/$(UNAME_M)-linux-gnu/pkgconfig/webkit2gtk-4.0.pc >/dev/null
fetch: app/adminer.php app/editor.php app/src/Settings/Plugins/available app/src/Settings/Theme/designs/README.md bin/frankenphp$(EXE) ## Download frankenphp, build adminer from its release sources + patches/ (pinned, checksum-verified)
# Compiled from the release zip's sources with patches/*.patch applied, so a fix upstream has
# not released yet can ship. With no patches the output is the release's own two files byte
# for byte. The patches/ folder is a prerequisite too: deleting a patch changes only its mtime.
ADMINER_SRC = .cache/adminer-src
ADMINER_BUILD = .cache/adminer-build
$(ADMINER_BUILD): $(ADMINER_SRC) patches $(wildcard patches/*.patch) cli/compile-adminer.sh bin/frankenphp$(EXE)
./cli/compile-adminer.sh $(ADMINER_SRC) $@ bin/frankenphp$(EXE) $(wildcard patches/*.patch)
app/adminer.php: $(ADMINER_BUILD)
@mkdir -p app
cp $(ADMINER_BUILD)/adminer-$(ADMINER_VERSION).php $@
app/editor.php: $(ADMINER_BUILD)
@mkdir -p app
cp $(ADMINER_BUILD)/editor-$(ADMINER_VERSION).php $@
# The release zip is the full source tree: adminer.php and editor.php are compiled from it, and
# plugins/ and designs/ copied out of it. Same zip, same pinned tag — plugins can never drift from the core.
.cache/adminer-src.zip:
@mkdir -p .cache
curl -fsSL --retry 3 --retry-delay 2 -o $@ $(ADMINER_URL)/adminer-$(ADMINER_VERSION).zip
# Extracted whole, once. Selecting with a pattern like 'designs/*' is not portable:
# macOS and linux unzip let * match a slash and recurse, the windows one does not, so
# only the file directly inside designs/ came out and every design silently vanished.
.cache/adminer-src: .cache/adminer-src.zip
rm -rf $@ .cache/src-tmp
unzip -qo $< -d .cache/src-tmp
mv .cache/src-tmp/adminer-$(ADMINER_VERSION) $@
rm -rf .cache/src-tmp
# unzip restores the mtimes stored in the archive and mv keeps them, so without this the
# extracted tree is older than the zip it came from and every `make fetch` re-extracts it.
@touch $@
# The whole upstream set, shipped but not loaded: Settings\Plugins\PluginList picks the
# ones that make sense here by name and instantiates those. It must not land anywhere
# adminer globs — a directory called adminer-plugins/ at the document root is auto-included
# and auto-enabled by it (include/plugins.inc.php:17-19), which is why there is not one.
app/src/Settings/Plugins/available: .cache/adminer-src
@mkdir -p app/src/Settings/Plugins
rm -rf $@ && cp -R .cache/adminer-src/plugins $@
# Left over from when enabling a plugin meant symlinking it in here. A checkout that
# still has it would have adminer load and enable whatever is inside, behind the app's back.
@rm -rf app/adminer-plugins
# Marked by the README inside rather than by the directory itself, because adminer-desktop/
# is our own theme and is committed in there: the directory exists in a fresh checkout, make
# calls it up to date and no gallery design is ever copied. That is how CI shipped a designs
# list of one and `make check` failed on a design it had been offered. Same reason there is
# no rm -rf here, and why it copies the contents (designs/.) rather than the folder — the
# folder is half ours.
app/src/Settings/Theme/designs/README.md: .cache/adminer-src
@mkdir -p app/src/Settings/Theme/designs
cp -R .cache/adminer-src/designs/. app/src/Settings/Theme/designs/
bin/frankenphp$(EXE):
@mkdir -p bin .cache
ifeq ($(suffix $(FRANKEN_ASSET)),.zip)
curl -fsSL --retry 3 --retry-delay 2 -o .cache/frankenphp.zip $(FRANKEN_URL)/$(FRANKEN_ASSET)
# The whole tree, not just the exe: the windows build is a real php install, with
# ~30 DLLs beside the binary and ext/ and lib/ next to it. Taking only frankenphp.exe
# got 0xC0000135, STATUS_DLL_NOT_FOUND, the moment it ran.
unzip -qo .cache/frankenphp.zip -d bin
else
curl -fsSL --retry 3 --retry-delay 2 -o $@ $(FRANKEN_URL)/$(FRANKEN_ASSET)
endif
chmod +x $@
# macOS ships shasum and no sha256sum; the windows runner's git bash ships sha256sum and
# no shasum. Both write the same "hash file" format, so one checksums.txt serves all.
SHA256 := $(shell command -v sha256sum >/dev/null 2>&1 && echo sha256sum || echo "shasum -a 256")
# What upstream signed is the asset as released, which is not always the file we end up
# running: mac and linux ship the bare binary, windows ships a zip and we keep the exe
# out of it. Verify the thing that was attested, not the thing that was extracted.
FRANKEN_DL = $(if $(filter .zip,$(suffix $(FRANKEN_ASSET))),.cache/frankenphp.zip,bin/frankenphp)
# Hard fail on mismatch: means the release was re-uploaded or the download was tampered
# with. Two artifacts, two mechanisms, because upstream offers different things.
#
# Adminer publishes no signatures, so checksums.txt is a hash we recorded ourselves of the
# adminer.php and editor.php built at a version we chose, with patches/ applied — it proves the
# sources, the patches and the compile give the same bytes as then, and nothing more.
#
# frankenphp used to be skipped here, on the grounds that four per-OS checksum files were
# more to keep in step than the check was worth. It never needed one: every release asset
# carries GitHub build provenance, so one `gh attestation verify` covers all four and
# proves *which workflow built it*, which a hash we wrote down ourselves cannot.
#
# gh is a hard requirement, not a nicety skipped when missing — a verify step that quietly
# does nothing on the machine that lacks the tool is worse than no verify step, because it
# still reports success. mise pins it and `mise run install` refuses to finish without it
# authenticated, which is what the check needs: it reads the attestation over the API, so
# an installed-but-logged-out gh fails here too.
verify: fetch ## Verify the downloads: adminer checksums, frankenphp build provenance
$(SHA256) -c checksums.txt
gh attestation verify $(FRANKEN_DL) --repo php/frankenphp
# Regenerate after a deliberate version bump or a change in patches/. Review the diff.
checksums:
$(SHA256) app/adminer.php app/editor.php > checksums.txt
##@ Quality
# Analysis tools are pinned like everything else, so a green build stays green for a
# reason rather than because a linter happened not to ship a new rule today.
GOLANGCI_VERSION = v2.12.2
COMPOSER_VERSION = 2.10.2
.cache/composer.phar:
@mkdir -p .cache
curl -fsSL --retry 3 --retry-delay 2 -o $@ https://getcomposer.org/download/$(COMPOSER_VERSION)/composer.phar
# Composer reaches packagist over https, and the windows frankenphp is the official PHP
# build for Windows: openssl is a DLL sitting unloaded in ext/, because that build reads no
# php.ini and nothing here writes one. Without it composer refuses to run at all ("the
# openssl extension is required for SSL/TLS protection"). The mac and linux binaries are
# static with openssl compiled in, so the flags are windows-only -- and composer is the only
# thing here that goes over the network through PHP.
# Handed over as an ini file and not as `-d extension=...`: frankenphp's php-cli takes its
# first argument as the script to run, so a -d in front of composer.phar becomes a filename
# it fails to open ("Failed opening required '-d'"). PHP_INI_SCAN_DIR is read by PHP itself
# at startup, before any argument parsing, so it lands whatever the SAPI does with argv.
WIN_INI = .cache/php-ini
COMPOSER = $(if $(EXE),PHP_INI_SCAN_DIR=$(WIN_INI)) ./bin/frankenphp$(EXE) php-cli .cache/composer.phar
# The build tooling under app/cli/: `$(CLI) <command>`, the commands listed in run.php.
CLI = ./bin/frankenphp$(EXE) php-cli app/cli/run.php
# Both paths are relative to the directory make runs in, which is the repo root; composer's
# --working-dir only chdirs later, long after PHP has loaded its extensions.
$(WIN_INI)/openssl.ini:
@mkdir -p $(dir $@)
printf 'extension_dir=bin/ext\nextension=php_openssl.dll\n' > $@
# The windows frankenphp keeps gd as an extension too, and the icon build resizes with it.
$(WIN_INI)/gd.ini:
@mkdir -p $(dir $@)
printf 'extension_dir=bin/ext\nextension=php_gd.dll\n' > $@
# The app's own PHP deps: latte renders our markup and tracy stands behind -debug. qa
# needs them as much as the app does -- phpstan resolves those classes through vendor/,
# and the template linter is one of the packages. composer.json lives in app/ so vendor/
# lands beside the code it autoloads (and out of Go's way at the module root), hence
# --working-dir.
app/vendor: app/composer.json app/composer.lock bin/frankenphp$(EXE) .cache/composer.phar $(if $(EXE),$(WIN_INI)/openssl.ini)
$(COMPOSER) install --no-interaction --working-dir=app
@touch app/vendor
# --debug is not for debugging: phpstan's parallel workers shell out to a `php` binary,
# and there deliberately is none here -- we run it through the frankenphp we download.
# 2G because adminer.php is 500 KB of minified source on very long lines.
# A composer dev dependency, not a download: the strict rules are a phpstan extension and
# have to match its version, and composer.lock pins the pair the way upstream adminer does.
# phpstan.restarted: phpstan restarts itself through PHP_BINARY to load its native extension
# or retune OPcache, and under frankenphp's php-cli that is empty, so the restart fails and
# takes the run with it. The value is what its own restart sets, so it skips it. An ini file
# for the reason WIN_INI is one.
PHPSTAN_INI = .cache/phpstan-ini
$(PHPSTAN_INI)/phpstan.ini:
@mkdir -p $(dir $@)
printf 'phpstan.restarted=1\n' > $@
phpstan: fetch app/vendor $(PHPSTAN_INI)/phpstan.ini
PHP_INI_SCAN_DIR=$(PHPSTAN_INI) ./bin/frankenphp$(EXE) php-cli app/vendor/phpstan/phpstan/phpstan.phar analyse -c phpstan.neon \
--no-progress --debug --memory-limit=2G
# The conventions phpstan does not judge, listed in app/dresscode.neon, which also excludes adminer's
# own downloaded files. --jobs 1 because its workers start through PHP_BINARY, and under
# frankenphp's php-cli that is frankenphp itself, which takes `check` for a command of its own.
# fetch because typeAnalysis reads phpstan.neon, and without the files it names only warns.
dresscode: fetch app/vendor
./cli/dresscode.sh ./bin/frankenphp$(EXE)
# What .claude/settings.json runs after an agent's edit, the edit's JSON on stdin. Silent and with no
# prerequisites: what it prints goes to the agent, and an edit must not start a composer install.
dresscode-hook:
@test -x bin/frankenphp$(EXE) && test -d app/vendor/dresscode || exit 0; ./bin/frankenphp$(EXE) php-cli cli/dresscode-hook.php
# A cache per checkout. The default one is per user and keeps absolute paths, so an agent's
# worktree linted second got the first one's findings at that worktree's paths; once it was
# deleted, the //nolint comments there could not be read and the lint failed on them.
golangci: mcp-bridge-data # the bridge embeds it: go cannot even type-check the package without it
GOLANGCI_LINT_CACHE=$(CURDIR)/.cache/golangci-lint go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION) run ./...
# Format-check CSS and JS with oxfmt and lint JS with oxlint; the file list is package.json's.
# Its --ignore-path=.gitignore is load-bearing: without it oxfmt also reads the main checkout's
# .gitignore, which ignores .claude/worktrees/, so in an agent's worktree it checks nothing.
# Run `mise run install` once to fetch them. Prefer npm directly (it needs only node on PATH);
# fall back to mise, which puts node on PATH, when node is not there itself; skip with a note
# if neither is set up, so `make qa` is not blocked on a machine without the JS tooling.
# Bare `mise` is not assumed to be on PATH — in a plain make shell it often is not.
oxc:
@if [ -x node_modules/.bin/oxfmt ] && command -v npm >/dev/null 2>&1; then \
npm run --silent lint ; \
elif command -v mise >/dev/null 2>&1; then \
mise run lint ; \
else \
echo "oxfmt skipped (run 'mise run install', or put node on PATH)" ; \
fi
# Security scan, for a local run — CI calls the shared semgrep workflow in
# landsman/config instead (.github/workflows/build.yml), and this keeps the same
# packs and excludes. Docker rather than an install, and skipped rather than failed
# when docker is not running, so `make security` is safe to chain locally.
# Not semgrep's own Docker Hub image — that rate-limits anonymous pulls. It comes from
# the public GHCR mirror published by github.com/landsman/config, which every repo of
# mine shares, so no repo mirrors it for itself.
#
# The version is the one that repo pins, read from its Dockerfile on each run, so the
# bump happens once, centrally — Dependabot proposed it there, a week-old cooldown
# passed, the pull request was merged. Not `latest`: docker never re-pulls a tag it
# already holds, so a laptop would keep scanning with the first one it ever pulled.
# What it costs, plainly: a merge over there can turn a build red here with no
# commit of ours to point at. `git log` in landsman/config is where that lives.
SEMGREP_VERSION = $(shell curl -fsSL https://raw.githubusercontent.com/landsman/config/main/bin/semgrep/Dockerfile | sed -n 's|^FROM semgrep/semgrep:\([^@ ]*\).*|\1|p')
SEMGREP_IMAGE = ghcr.io/landsman/semgrep-mirror:$(SEMGREP_VERSION)
# p/php, p/golang and p/secrets read the code we wrote. p/ci reads what runs it — the
# workflows and .github/dependabot.yml — which is the half that had nothing looking at
# it, and is what would have caught a dependabot config with no cooldown on it.
#
# The one rule we turn off: it wants every `uses:` pinned to a commit sha rather than
# @v5. That trades a readable diff for a 40-character hash on eight lines, and the
# threat it answers — GitHub's own actions org repointing a release tag at malicious
# code — is not one worth reading hashes over. Revisit for a third-party action.
SEMGREP_SKIP = yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
security:
@docker info >/dev/null 2>&1 || { echo "semgrep skipped (docker not running)"; exit 0; }; \
[ -n "$(SEMGREP_VERSION)" ] || { echo "no semgrep version from landsman/config (offline?)"; exit 1; }; \
docker run --rm -v "$$PWD:/src" -w /src $(SEMGREP_IMAGE) semgrep \
--config=p/php --config=p/golang --config=p/secrets --config=p/ci \
--exclude-rule=$(SEMGREP_SKIP) \
--exclude=adminer.php --exclude=editor.php --exclude=available \
--exclude=designs --metrics=off --error
# Static checks, every one from a tool we already have: the php is the frankenphp we
# download, the rest ship with macOS or the go toolchain. Nothing to install.
qa: fetch app/vendor i18n mcp-bridge-data ## Run every static check (php, go, js lint + formatting)
./bin/frankenphp$(EXE) php-cli cli/lint.php
@# No database and no browser: it replays adminer's own parser over a dump.
./bin/frankenphp$(EXE) php-cli tests/postgres/copy-import/run.php
@# Every step a .feature uses has a definition, read off a dry run: no docker, no browser. Not
@# --strict, which fails every step a dry run skips; the exit code still catches a broken config.
@out=$$(./bin/frankenphp$(EXE) php-cli app/vendor/bin/behat -c tests/e2e/behat.php --dry-run --format=progress --no-colors --snippets-for='Desktop\Tests\DesktopContext' 2>&1) \
&& ! echo "$$out" | grep -q undefined && echo "e2e steps ok" || { echo "$$out"; exit 1; }
@gofmt -l . | grep . && { echo "gofmt: files above need formatting"; exit 1; } || echo "gofmt ok"
go vet ./...
@# The MCP gate decides what reaches the window's session, so its refusals are tested here,
@# with no server; the bridge's offline answers and forwarding likewise (launcher/mcpbridge).
$(MAKE) go-test
@# Every darwin-only function needs a non-darwin definition, or the build breaks on
@# linux and windows only -- a CI round trip away rather than a compile away. That is
@# either a shared stub in menu_other.go, or a platform split (a real *_linux.go and a
@# *_other.go stub for windows), the way installDownloads is done, or the reverse (a real
@# *_windows.go and a *_linux.go stub), the way installMenu is.
@for f in $$(grep -oE '^func [a-zA-Z]+' launcher/menu_darwin.go | cut -d' ' -f2); do \
grep -q "$$f(" launcher/main.go || continue; \
grep -q "func $$f(" launcher/menu_other.go && continue; \
{ grep -lq "func $$f(" launcher/*_linux.go && grep -lq "func $$f(" launcher/*_other.go launcher/*_windows.go; } \
|| { echo "launcher: no non-darwin definition of $$f() (menu_other.go stub, or *_linux.go + *_other.go or *_windows.go split)"; exit 1; }; \
done && echo "platform stubs ok"
@command -v shellcheck >/dev/null \
&& { shellcheck check.sh cli/pick-os.sh tests/cli/pick-os-test.sh cli/compile-adminer.sh cli/dresscode.sh cli/kill-orphans.sh cli/demo-mysql.sh tests/cli/dresscode-test.sh tests/cli/dresscode-hook-test.sh tests/cli/kill-orphans-test.sh tests/launcher/downloads.sh && echo "shellcheck ok"; } \
|| { sh -n check.sh && sh -n cli/pick-os.sh && sh -n tests/cli/pick-os-test.sh && sh -n cli/compile-adminer.sh && sh -n cli/dresscode.sh && sh -n cli/kill-orphans.sh && sh -n cli/demo-mysql.sh && echo "sh ok (shellcheck not installed)"; }
@# Native-shell strings: the `i18n` prerequisite already generated the real files (which is
@# also what makes i18n_gen.h exist before `go vet` compiles the cgo launcher above). Assert the
@# generator's output quality and lint the emitted plists. `make i18n-check` reports coverage.
./bin/frankenphp$(EXE) php-cli tests/i18n/GeneratorTest.php
./bin/frankenphp$(EXE) php-cli tests/version/AppVersionTest.php
./bin/frankenphp$(EXE) php-cli tests/shortcuts/ShortcutsTest.php
./bin/frankenphp$(EXE) php-cli tests/cli/WindowsIcoTest.php
./bin/frankenphp$(EXE) php-cli tests/cli/I18nTest.php
@sh tests/cli/pick-os-test.sh
@sh tests/cli/kill-orphans-test.sh
@sh tests/cli/dresscode-test.sh
@sh tests/cli/dresscode-hook-test.sh
@# The launcher's own download code against the real webview, every way adminer starts one.
@sh tests/launcher/downloads.sh
@command -v plutil >/dev/null && plutil -lint Info.plist.in lproj/*/Localizable.strings >/dev/null && echo "i18n + plists ok" || echo "i18n ok (plutil skipped, macOS only)"
@$(MAKE) --no-print-directory phpstan
@$(MAKE) --no-print-directory dresscode
@$(MAKE) --no-print-directory golangci && echo "golangci-lint ok"
@$(MAKE) --no-print-directory oxc && echo "oxfmt + oxlint ok"
# Boot the app and assert the desktop plugin's before-login behaviour — prefill, refresh
# shortcut, design switch, plugin toggle — against the real login page. app/vendor is a
# prerequisite because the app does not boot without the autoloader Latte renders through;
# it used to be there only because `qa` happened to have run first.
check: fetch app/vendor ## Boot the app, assert before-login behaviour (prefill, design, plugins)
./check.sh
# The end-to-end scenarios: Behat driving a real browser through the app, against a throwaway
# postgres and mysql in docker. app/vendor because behat and playwright live there. Kept out of
# `qa` because it is slow and needs docker; run it on its own. A failed step leaves the page it
# failed on in tests/e2e/artifacts/, which the workflow uploads.
e2e: fetch app/vendor mcp-bridge-data ## Every scenario, against both drivers (needs docker)
mise run e2e
@# The MCP scenarios again, through the Go bridge an installed app's agents spawn.
mise run e2e-bridge-go
# The same, in a browser you can watch: this is how a scenario is written, and how a failing one
# is understood. `make e2e-visual SUITE=mysql` or `ARGS='--name "Sorting"'` narrows it down.
e2e-visual: fetch app/vendor ## The same, in a browser you can watch (needs docker)
mise run e2e-visual
# The same scenarios against app/ as it ships: production deps, templates precompiled, and read-only,
# as a signed bundle or a .deb under /usr is. A template the build did not compile is then a fatal
# page rather than a slow first one, which `e2e` against the checkout never shows. Nightly in CI.
E2E_APP = build.noindex/e2e-app
e2e-packaged: fetch app/vendor ## Every scenario, against the packaged app/ (needs docker)
# u+w first: a killed run leaves the copy read-only, and rm cannot clear it then.
chmod -R u+w $(E2E_APP) 2>/dev/null; rm -rf $(E2E_APP) && mkdir -p $(dir $(E2E_APP)) && cp -R app $(E2E_APP)
$(call package-app,$(E2E_APP))
chmod -R a-w $(E2E_APP)
ADMINER_DESKTOP_E2E_APP=$(CURDIR)/$(E2E_APP) mise run e2e; s=$$?; chmod -R u+w $(E2E_APP); exit $$s
##@ Build & run
# About reads these, so it can never disagree with what is actually bundled.
VERSION = $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS = -X main.version=$(VERSION) \
-X main.adminerVersion=$(ADMINER_VERSION) \
-X main.frankenphpVersion=$(FRANKENPHP_VERSION)
# Unbundled, macOS titles the app menu from the executable's name, which setProcessName no longer
# changes; a plist linked into the binary names it (launcher/unbundled-Info.plist says why only that).
ifeq ($(UNAME_S),Darwin)
LDFLAGS += -extldflags '-sectcreate __TEXT __info_plist $(CURDIR)/launcher/unbundled-Info.plist'
endif
# One source for every string the native shell shows (menu, dialogs, downloads): the per-language
# files under app/src/I18n/Locale/, generated by Desktop\I18n\Generator into each platform's own
# format (macOS .strings, a C table the Linux/Windows launcher #includes) so nothing gains a runtime
# i18n dependency. The outputs are gitignored and rebuilt here before every build; the language
# files are the committed source. The PHP plugin UI localises itself, via AdminerDesktop::$translations.
i18n: bin/frankenphp$(EXE) app/vendor ## Generate the native-shell translations from app/src/I18n/Locale/*.php
$(CLI) i18n
# The MCP bridge's copy of what PHP owns — the tool catalogue for both settings, the offline
# sentences per locale — so the Go bridge describes tools from Server::catalogue() rather than a
# hand copy. Gitignored and rebuilt before every build, like the native-shell translations; the
# PHP source is the committed definition.
mcp-bridge-data: bin/frankenphp$(EXE) app/vendor ## Generate the Go MCP bridge's catalogue and sentences from PHP
./bin/frankenphp$(EXE) php-cli cli/mcp-bridge.php
go-test: fetch mcp-bridge-data ## Test the MCP gate and bridge, no server needed
go test ./launcher/mcpgate/ ./launcher/mcpbridge/
# What the MCP bridge costs a call, the record included, against a stub app: run it before and
# after a change to the bridge and compare. Six runs, so a difference can be told from noise.
bench: mcp-bridge-data ## Benchmark the MCP bridge's own cost per call
go test -run '^$$' -bench . -benchmem -count 6 ./launcher/mcpbridge/
# Which strings each locale is still missing. Exits non-zero if any are, so CI can gate on it.
i18n-check: bin/frankenphp$(EXE) app/vendor ## Report native-shell translation coverage (missing strings per locale)
@$(CLI) i18n-check
# The exe's icon on Windows, from the same logo.png as the mac and linux ones. go build links any
# .syso beside the sources, and windres comes with the gcc cgo already needs.
WIN_ICON = $(if $(EXE),launcher/icon_windows_amd64.syso)
launcher/icon_windows_amd64.syso: launcher/icon.rc build.noindex/logo.ico
windres -O coff -i $< -o $@
build.noindex/logo.ico: assets/logo.png app/cli/Command/WindowsIco.php app/vendor $(if $(EXE),$(WIN_INI)/gd.ini)
@mkdir -p $(dir $@)
$(if $(EXE),PHP_INI_SCAN_DIR=$(WIN_INI)) $(CLI) windows-ico
build: fetch i18n mcp-bridge-data $(WIN_ICON) ## Build the launcher binary
go build -ldflags "$(LDFLAGS)" -o build.noindex/adminer-desktop$(EXE) ./launcher
# NSLocalizedString reads the .lproj folders beside an unbundled binary as it does a bundle's
# Resources: without them `make run` showed the menu's keys (menu.edit) for its titles.
ifeq ($(UNAME_S),Darwin)
cp -R lproj/*.lproj build.noindex/
endif
# The app itself: opens a window.
run: build ## Build and open the app window
./build.noindex/adminer-desktop$(EXE)
# Like run, but reloads the window whenever a file under app/ changes — edit PHP or CSS
# and see it without a rebuild (frankenphp serves the tree live; the window just reloads).
dev: build ## Run, reloading the window on any change under app/
./build.noindex/adminer-desktop$(EXE) -dev
editor: build ## Run in editor mode
./build.noindex/adminer-desktop$(EXE) -editor
# Turns on Safari's Web Inspector against the app's page: Develop > this machine >
# Adminer Desktop. There is no console in the app otherwise, which is how a confirm()
# that never fired stayed invisible for as long as it did.
#
# Brings up the e2e's seeded mysql beside it, so there is a database to point it at; no
# docker skips it rather than refusing to debug.
debug: build ## Run with Safari's Web Inspector attached, and a seeded mysql (docker)
@os=$$(./cli/pick-os.sh); \
./cli/demo-mysql.sh $(DEMO_MYSQL) || echo "no mysql: docker is not running"; \
ADMINER_DESKTOP_SIMULATE_OS=$$os ./build.noindex/adminer-desktop$(EXE) -debug
# The app in dev mode against seeded demo data, for clicking around by hand. Brings up
# (or reuses) the same throwaway postgres the e2e uses, reseeds it, and opens the app
# logged straight into it — ADMINER_DESKTOP_DEMO carries the throwaway connection, which
# desktop.php hands to demo-login.js to fill and submit. Only `make demo` ever sets it, so
# a shipped build never auto-logs-in. `make down` kills the container when you are done.
# vendor/ because dev serves app/ and Latte renders from it. The seed drops and recreates,
# so re-running just refreshes the data.
# The postgres one is shared with the e2e (tests/e2e/harness/fixture.php names it), so `make demo`
# and a run reuse each other's database rather than each leaving one behind. DEMO_MYSQL is shared
# the same way, with the e2e and `make debug`.
DEMO_PG = adminer-demo-pg
DEMO_MYSQL = adminer-demo-mysql
demo: build app/vendor ## Run against seeded demo data, opened logged in (needs docker)
@docker start $(DEMO_PG) >/dev/null 2>&1 || docker run -d --name $(DEMO_PG) \
-e POSTGRES_PASSWORD=demo -e POSTGRES_DB=demo -p 127.0.0.1:55432:5432 postgres:18-alpine >/dev/null
@echo "waiting for postgres ..." && until docker exec $(DEMO_PG) pg_isready -U postgres >/dev/null 2>&1; do sleep 1; done
@docker exec -i $(DEMO_PG) psql -U postgres -d demo -v ON_ERROR_STOP=1 < tests/e2e/seed/pgsql.sql >/dev/null
@echo "demo data ready on 127.0.0.1:55432 (postgres / demo / demo)"
ADMINER_DESKTOP_DEMO='pgsql 127.0.0.1:55432 postgres demo demo' ./build.noindex/adminer-desktop$(EXE) -dev
# Kill the database containers `make demo` and the e2e left running.
down: ## Stop the demo database containers
-docker rm -f $(DEMO_PG) $(DEMO_MYSQL)
# The same, plus the anonymous volumes the databases keep their data in — which plain `rm -f`
# leaves behind, dangling and named after nothing. Rarely needed now that the fixture reseeds on
# every run: an edited seed reaches the database without this. Reach for it when a container is
# wedged, or to get the disk back.
#
# `rm -v` and not `volume prune`, which would take every other project's anonymous volumes on
# this machine with it — this removes the ones attached to our containers and nothing else.
destroy: ## Remove the demo database containers and their data volumes
-docker rm -fv $(DEMO_PG) $(DEMO_MYSQL)
# A window that crashed or was killed leaves its server running; the SessionEnd hook in
# .claude/settings.json runs the same at the end of every agent session.
kill-orphans: ## Stop the app servers whose window is gone (the installed app's too)
@sh cli/kill-orphans.sh
# Same startup path as `run`, minus the window — so it works over ssh and in CI.
check-app: build
./build.noindex/adminer-desktop$(EXE) -headless
##@ Package
APP = build.noindex/Adminer Desktop.app
ICON = build.noindex/AdminerDesktop.icns
# sips and iconutil ship with macOS, so the icon needs no image tooling installed.
# logo.png is rendered from logo.svg, a vector redraw of adminer's 57px pictogram, so the
# 1024px sizes are sharp. After editing the SVG: swift cli/render-logo.swift assets/logo.svg assets/logo.png 1024
$(ICON): assets/logo.png
@mkdir -p build.noindex/icon.iconset
@for s in 16 32 64 128 256 512 1024; do \
sips -z $$s $$s $< --out build.noindex/icon.iconset/icon_$${s}x$${s}.png >/dev/null; \
done
@cd build.noindex/icon.iconset && for s in 16 32 128 256 512; do \
d=$$((s * 2)); cp icon_$${d}x$${d}.png icon_$${s}x$${s}@2x.png; \
done && rm -f icon_64x64.png icon_1024x1024.png
iconutil -c icns build.noindex/icon.iconset -o $@
@rm -rf build.noindex/icon.iconset
# A .app is just a directory, which is why none of this needs go:embed or a static
# single-binary build: the runtime and app/ are simply files inside it.
bundle: build app/vendor $(ICON) ## Build the macOS .app bundle
rm -rf "$(APP)"
mkdir -p "$(APP)"/Contents/MacOS "$(APP)"/Contents/Resources
sed 's|@ADMINER_VERSION@|$(ADMINER_VERSION)|g' Info.plist.in > "$(APP)"/Contents/Info.plist
cp build.noindex/adminer-desktop "$(APP)"/Contents/MacOS/
cp bin/frankenphp "$(APP)"/Contents/MacOS/
# The whole app/ tree in one copy — vendor/ lives in app/ now, so it (and the autoloader
# Latte needs) travels along too.
rsync -a app/ "$(APP)"/Contents/Resources/app/
# Strip the dev tooling the shared app/vendor target installs for qa (dresscode,
# playwright: nothing the shipped app runs). composer.json travels with app/, so this
# reconciles the copied tree down to the production deps in place.
$(call package-app,"$(APP)"/Contents/Resources/app)
# NSLocalizedString resolves against the main bundle, so the .lproj folders have to
# sit directly in Resources. macOS then picks the language itself.
cp -R lproj/*.lproj "$(APP)"/Contents/Resources/
cp $(ICON) "$(APP)"/Contents/Resources/
# Last, once nothing in the bundle changes. The binaries carry only the linker's own
# signature, which seals no Info.plist and no resources, so a downloaded copy fails
# Gatekeeper as "damaged" with no way past it. An ad-hoc signature (`-`, no account)
# seals the whole bundle; Gatekeeper then says "not verified" and offers Open Anyway.
codesign --force --deep --sign - "$(APP)"
@echo "built "$(APP)" -- $$(du -sh "$(APP)" | cut -f1)"
# One name shape for every artifact: adminer-desktop_<version>_<os>-<arch>.<ext>. A
# release listing then sorts by version and each download says what it runs on, which
# matters most on Linux, where the same version ships a tarball and a .deb and will grow
# arm64 and a flatpak beside them. The .deb is the one exception, and dpkg dictates it:
# <package>_<version>_<arch>.deb, no os segment, because that is what apt and every repo
# tool parse.
#
# Debian versions must begin with a digit and reserve '-' as the upstream/revision
# separator, so git's description is trimmed to its first digit and its dashes turned to
# '~'. Every package shares that string, so the .deb and the tarball of one build can
# never disagree about which version they are.
#
# amd64/arm64 rather than uname's x86_64/aarch64: it is dpkg's vocabulary and Go's, so the
# .deb, the tarball and GOARCH all say the same word.
PKG_VERSION = $(shell printf '%s' '$(VERSION)' | sed -E 's/^[^0-9]*//; s/-/~/g')
PKG_ARCH = $(if $(filter aarch64 arm64,$(UNAME_M)),arm64,amd64)
PKG_OS = $(if $(filter Darwin,$(UNAME_S)),macos,$(if $(filter Linux,$(UNAME_S)),linux,windows))
PKG = adminer-desktop_$(PKG_VERSION)_$(PKG_OS)-$(PKG_ARCH)
# Ad-hoc signed, not notarized: a first launch elsewhere is refused, then allowed from
# System Settings > Privacy & Security > Open Anyway. Notarizing needs a paid account; that is M4.
# The bundle inside keeps its display name, "Adminer Desktop.app"; only the archive is
# renamed, so the download is greppable and the thing in /Applications still reads right.
zip: bundle ## Zip the macOS .app bundle
rm -f build.noindex/$(PKG).zip && cd build.noindex && zip -qry $(PKG).zip "Adminer Desktop.app"
@echo "built build.noindex/$(PKG).zip -- $$(du -sh "build.noindex/$(PKG).zip" | cut -f1)"
# Linux and Windows get a plain directory rather than a bundle or an installer: the
# layout resolve() looks for is "runtime and app/ next to the binary", which a folder
# already satisfies. AppImage, .deb and an MSI are all packaging opinions we do not need
# before anyone has asked to install this.
# Staged under build.noindex/pkg/ so the folder name inside the archive can still be
# adminer-desktop without colliding with the binary of that name in build.noindex/.
DIST = build.noindex/pkg/adminer-desktop
# Turns a copy of app/ into what ships; bundle, dist and e2e-packaged share it, so the nightly tests
# the same tree a release carries. composer strips the dev tooling app/vendor carries for qa
# (dresscode, playwright: nothing the shipped app runs), reconciling the copy down to
# production deps in place. No precompile on windows: Latte locks even a precompiled template
# there, so app/ would have to be writable. app/cli/ is build tooling, so it does not ship: the
# precompile goes first, through the copy's own run.php, because it has to load the copy's classes.
define package-app
$(if $(EXE),,./bin/frankenphp php-cli $(1)/cli/run.php latte-precompile)
rm -rf $(1)/cli
$(COMPOSER) install --no-dev --no-interaction --working-dir=$(1)
rm -f $(1)/*.neon
endef
dist: build app/vendor ## Stage the Linux/Windows folder layout
rm -rf $(DIST) && mkdir -p $(DIST)
cp build.noindex/adminer-desktop$(EXE) $(DIST)/
# The window icon, beside the binary so iconPath() finds it at runtime (the launcher
# sets the GTK window icon from it; the .deb also points its .desktop at a copy).
cp assets/logo.png $(DIST)/
# All of bin/, because on windows that is the php runtime's DLLs and ext/ as well as
# the exe. cp rather than rsync: git bash on the windows runner has no rsync.
cp -R bin/. $(DIST)/
cp -R app $(DIST)/app # includes app/vendor, the autoloader Latte renders through
$(call package-app,$(DIST)/app)
@echo "built $(DIST) -- $$(du -sh $(DIST) | cut -f1)"
# tar preserves the executable bit; zip on windows does not need it.
tarball: dist ## Package the Linux tarball
cd build.noindex/pkg && tar czf ../$(PKG).tar.gz adminer-desktop
@echo "built build.noindex/$(PKG).tar.gz -- $$(du -sh build.noindex/$(PKG).tar.gz | cut -f1)"
# The one platform whose own package cannot be made with the tool named after it: git bash
# on windows ships no zip(1), which is the only thing the whole build was ever missing there.
# 7-Zip is on the runner and on most windows machines, and writes the same archive.
windows: dist ## Package the Windows zip
rm -f build.noindex/$(PKG).zip
cd build.noindex/pkg && { command -v zip >/dev/null 2>&1 \
&& zip -qry ../$(PKG).zip adminer-desktop \
|| 7z a -tzip -bso0 ../$(PKG).zip adminer-desktop; }
@echo "built build.noindex/$(PKG).zip -- $$(du -sh build.noindex/$(PKG).zip | cut -f1)"
# A .deb is the same flat layout dist stages, just rooted at /usr/lib instead of a folder:
# resolve() reads os.Executable(), which follows the /usr/bin symlink back to the real
# binary, so frankenphp and app/ beside it are found exactly as in the tarball. Arch comes
# from dpkg rather than PKG_ARCH -- it is the authority on the machine that will install
# the thing, and the go build is never cross-compiled here anyway.
# --root-owner-group ships the files as root:root without needing fakeroot.
DEB = build.noindex/deb
DEB_ARCH = $(shell dpkg --print-architecture 2>/dev/null || echo amd64)
DEB_FILE = build.noindex/adminer-desktop_$(PKG_VERSION)_linux-$(DEB_ARCH)-deb.deb
deb: dist ## Package a Debian .deb (Linux)
rm -rf $(DEB)
mkdir -p $(DEB)/DEBIAN $(DEB)/usr/lib $(DEB)/usr/bin $(DEB)/usr/share/applications $(DEB)/usr/share/pixmaps
cp -R build.noindex/pkg/adminer-desktop $(DEB)/usr/lib/adminer-desktop
ln -sf ../lib/adminer-desktop/adminer-desktop $(DEB)/usr/bin/adminer-desktop
cp assets/logo.png $(DEB)/usr/share/pixmaps/adminer-desktop.png
# StartupWMClass = the GtkWindow's app_id (webview's prgname, the binary basename), so
# Plasma matches the running window to this entry and shows its icon in the taskbar —
# the way a Wayland session gets the icon, where the runtime GtkWindow icon does not reach.
printf '%s\n' \
'[Desktop Entry]' 'Type=Application' 'Name=Adminer Desktop' \
'Comment=Adminer as a desktop app' 'Exec=adminer-desktop' 'Icon=adminer-desktop' \
'Terminal=false' 'Categories=Development;Database;' 'StartupWMClass=adminer-desktop' \
> $(DEB)/usr/share/applications/adminer-desktop.desktop
printf '%s\n' \
'Package: adminer-desktop' 'Version: $(PKG_VERSION)' 'Architecture: $(DEB_ARCH)' \
'Maintainer: Michal Landsman <landsman@insuit.cz>' \
'Section: database' 'Priority: optional' \
'Depends: libgtk-3-0, libwebkit2gtk-4.1-0' \
'Homepage: https://github.com/landsman/adminer-desktop' \
'Description: Adminer as a desktop app' \
' No PHP install, no web server, no browser tab.' \
> $(DEB)/DEBIAN/control
dpkg-deb --build --root-owner-group $(DEB) $(DEB_FILE)
@echo "built $(DEB_FILE) -- $$(du -sh $(DEB_FILE) | cut -f1)"
##@ Misc
# PHP errors, adminer warnings and caddy's access log all land in one file, in the
# place macOS users and Console.app already look.
logs: ## Open the log folder (macOS)
open ~/Library/Logs/"Adminer Desktop"
# Just the server, no window. Handy for poking at it with curl.
serve: fetch ## Serve app/ with no window, for poking at with curl
AD_PORT=18000 AD_ROOT=app AD_ASSET_CACHE=no-cache ./bin/frankenphp$(EXE) run --config launcher/Caddyfile --adapter caddyfile
clean: ## Remove downloaded and built files (app/, bin/, .cache/)
rm -rf app bin .cache