Compare commits

..
14 Commits
Author SHA1 Message Date
m038andClaude Opus 4.8 9440bdc29d docs: capture Grav new-user --admin-type gotcha (api.* vs admin.*)
On Admin2-only installs, login new-user auto-detects the admin permission
type to api-only, so accounts lack admin.login and silently fail on
access.admin.login-gated pages like /gpx-manager. Document the root cause,
the --admin-type both fix baked into make test-account, and prevention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 19:49:35 +02:00
m038andClaude Opus 4.8 fb9a47ea0c docs: record Grav 2.0.4 upgrade; GPM-managed plugins; prod runbook
Update CLAUDE.md + architecture.md stack to Grav 2.0.4 stable, admin2 2.0.10,
gpm.releases: stable, and the three-category plugin-management model
(GPM-managed / custom-in-repo / remote-only git-sync). Add execution outcome,
Phase 3 prod fresh-install runbook (documentation-only), and mark plan Status
complete. Note the Form 9.1.10 filepond regression as a known issue owned by
the separate form-to-page rework.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 19:39:00 +02:00
m038andClaude Opus 4.8 58d2d70c13 test: self-contained local test account + urlencoded login
Add a dedicated, gitignored testrunner account auto-created by the suite
(make test-account, also invoked from global-setup) so `make test` no longer
needs the real account in .env. The target uses --admin-type both so the
account has admin.login for the gpx-manager specs. test-post.sh switches its
login POST to --data-urlencode to survive special chars in credentials.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 19:36:39 +02:00
m038andClaude Opus 4.8 725131e128 test: retarget form-config and post tests from retired japan-korea-2026 to italy-2026-demo
The japan-korea-2026 trip is retired; italy-2026-demo is the vetted go-forward
fixture (already used by the Playwright suite and matching post-form.md parent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 17:50:05 +02:00
m038andClaude Opus 4.8 0f9a3b86b8 build: drop admin2/api zip-stash from server-install; install via GPM (option B)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 16:41:50 +02:00
m038andClaude Opus 4.8 a639dc6e41 build: fix remote-upgrade-grav; add remote plugin-update, git-sync toggle, content-status targets
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 16:41:25 +02:00
m038andClaude Opus 4.8 3fffa02bec build: pin Grav core 2.0.4 and add admin2/api/flex-objects to plugins.txt
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 16:38:58 +02:00
m038andClaude Opus 4.8 6ad62360c6 docs: apply doc-review fixes to Grav 2.0.4 upgrade plan
- Task 2 Step 8: version-assert Expected output now includes the
  'version:' prefix the grep command actually prints (coherence).
- Rollback: state core self-upgrade is forward-only (no revert) and warn
  that server-install.sh must never be used as a live-server rollback —
  it rm -rf's the untracked git-sync.yaml encrypted token (feasibility +
  adversarial).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 16:35:20 +02:00
m038 a28ef8f8d7 Merge branch 'main' into grav-2.0.4-upgrade 2026-07-04 15:20:59 +02:00
m038andClaude Opus 4.8 1710ad8612 docs: document dual-repo submodule workflow and pointer-bump convention
Add docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md
covering the outer-repo + user/ submodule structure, when to bump the pin
(cross-repo feature boundaries, not routine content), the reachable/push-first
rules, and the worktree + per-worktree dev-server flow. Update CLAUDE.md's
folder explanation and add a "Dual-repo submodule structure" section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:13:50 +02:00
m038andClaude Opus 4.8 b47b1e9657 docs: add Grav 2.0.4 upgrade implementation plan
6 tasks: Phase 0 edits, local build+validate, Makefile remote targets
(fix broken remote-upgrade-grav; add update/git-sync-toggle/content-status),
server-install cleanup, test-env upgrade sequence, docs+prod runbook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:02:59 +02:00
m038andClaude Opus 4.8 3085cede28 docs: correct upgrade spec channel + CLI commands after container verification
- GPM channel is system.yaml gpm.releases (testing->stable), not GRAV_CHANNEL
- core upgrade is 'bin/gpm self-upgrade -y'; existing remote-upgrade-grav
  target ('bin/grav upgrade') is broken and must be fixed
- add server content-pull + cache-clear + preflight steps to Phase 2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:58:27 +02:00
m038andClaude Opus 4.8 2f733f668d chore: register user/ as a git submodule
Converts the orphaned gitlink (160000 with no .gitmodules) into a proper
submodule pointing at the content remote, and absorbs user/.git into
.git/modules/user so linked worktrees can populate user/. Bumps the pin
from 75da83d to the current pushed origin/main (924cfc1) as a clean
baseline. Enables per-worktree user/ checkouts for parallel dual-repo work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:57:13 +02:00
m038andClaude Opus 4.8 99f290fbca docs: add Grav 2.0.4 upgrade + GPM-manage plugins design spec
Coordinated upgrade of core rc.10 -> 2.0.4 and promotion of
admin2/api/flex-objects to GPM management (option B). Documents the
dependency-forced atomicity, the three plugin-management categories,
git-sync handling (disable-during-upgrade, remote-only), and the
local -> test -> prod(documented-only) rollout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:54:28 +02:00
16 changed files with 1048 additions and 26 deletions
+9 -5
View File
@@ -26,11 +26,15 @@
UID=1000 UID=1000
GID=1000 GID=1000
# Local Grav dev server + test login, used by `make test-post` / `make test` # Local Grav dev server. GRAV_BASE_URL is used by the Playwright suite and
# (scripts/test-post.sh). Must be a valid Grav site login on the local instance. # scripts/test-post.sh.
GRAV_BASE_URL=http://localhost:8081 GRAV_BASE_URL=http://localhost:8081
GRAV_TEST_USER=your-local-grav-user # Test login for `make test` — OPTIONAL. If unset, the suite auto-creates and
GRAV_TEST_PASS=your-local-grav-password # uses a dedicated local-only account (testrunner / Testpass1234), gitignored so
# it is never pushed to prod (see `make test-account`). Override only to test as
# a different account; keep the password free of shell/Make/URL-special chars.
# GRAV_TEST_USER=testrunner
# GRAV_TEST_PASS=Testpass1234
GRAV_USER_DIR=/absolute/path/to/travel-blog-intotheeast/user GRAV_USER_DIR=/absolute/path/to/travel-blog-intotheeast/user
# travel-memories service (docker-compose `env_file: .env`). Fill in whatever # travel-memories service (docker-compose `env_file: .env`). Fill in whatever
@@ -55,7 +59,7 @@ WEBROOT=/home/example.com/public_html
SITE_CONFIG_DIR=/home/example.com/site-config SITE_CONFIG_DIR=/home/example.com/site-config
# Grav version installed by scripts/server-install.sh (remote-install). # Grav version installed by scripts/server-install.sh (remote-install).
GRAV_VERSION=2.0.0-rc.10 GRAV_VERSION=2.0.4
# Repos cloned/pulled on the server. # Repos cloned/pulled on the server.
USER_REPO=https://gitea.example.com/org/intotheeast-user.git USER_REPO=https://gitea.example.com/org/intotheeast-user.git
+17 -4
View File
@@ -8,18 +8,22 @@
- **./**: Grav CMS dev environment for intotheeast travel blog - **./**: Grav CMS dev environment for intotheeast travel blog
- **scripts/**: Server install and maintenance scripts - **scripts/**: Server install and maintenance scripts
- **user/**: Site content, config, pages, and theme (standalone git repo — do not modify from here) - **user/**: Site content, config, pages, and theme — its own git repo (`intotheeast-com-content.git`), tracked by the outer repo as a **git submodule** (pinned commit). See "Dual-repo submodule structure" below and `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`
- **docs/**: All plans, specs, and project documentation (moved here from `user/docs/` on 2026-06-19) - **docs/**: All plans, specs, and project documentation (moved here from `user/docs/` on 2026-06-19)
- **docs/solutions/**: documented solutions to past problems (bugs, patterns, workflow gotchas), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Relevant when implementing or debugging in a documented area - **docs/solutions/**: documented solutions to past problems (bugs, patterns, workflow gotchas), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Relevant when implementing or debugging in a documented area
- **CONCEPTS.md** (repo root): shared domain vocabulary (Trip, Entry, Story, Active Trip). Relevant when orienting to the codebase or discussing domain concepts - **CONCEPTS.md** (repo root): shared domain vocabulary (Trip, Entry, Story, Active Trip). Relevant when orienting to the codebase or discussing domain concepts
### Current stack ### Current stack
- **Grav:** 2.0.0-rc.10 (baked into the custom Docker image via `Dockerfile`) - **Grav:** 2.0.4 stable (baked into the custom Docker image via `Dockerfile`; server upgrades in place via `bin/gpm self-upgrade`)
- **Admin:** Admin2 v2.0.0-rc.15 (plugin slug: `admin2`, NOT `admin`) - **Admin:** Admin2 v2.0.10 (plugin slug: `admin2`, NOT `admin`)
- **Docker image:** `getgrav/grav` with `GRAV_CHANNEL=beta` - **GPM channel:** `stable` — set in `user/config/system.yaml``gpm.releases` (authoritative). `GRAV_CHANNEL=production` in `docker-compose.yml` is cosmetic/consistency only
- **Plugin management:** `admin2`, `api`, and `flex-objects` are now **GPM-managed via `plugins.txt`** (installed by `make install-plugins`), no longer hand-extracted from the core bundle. `git-sync` stays **remote-only** — never in `plugins.txt`
- **Docker image:** `getgrav/grav` with `GRAV_CHANNEL=production`
- **PHP session:** `session.save_path = /tmp` set in `php/php-local.ini` - **PHP session:** `session.save_path = /tmp` set in `php/php-local.ini`
> Known issue (2026-07-04): Form 9.1.10 regressed the `filepond` upload field — on the post-submit re-render, `filepond.html.twig` runs `merge` on a string and 500s. The journal entry still saves correctly; only the browser re-render errors. This breaks the 6 `post.spec.js` UI specs. Being fixed separately in the form-to-page/image-upload rework — **do not** work around it here.
### Dev server ### Dev server
The Docker dev server runs at **http://localhost:8081** (mapped from container port 80 in `docker-compose.yml`). The Docker dev server runs at **http://localhost:8081** (mapped from container port 80 in `docker-compose.yml`).
@@ -142,6 +146,15 @@ Always use `make` commands for anything on the production server (`make remote-i
Only these folders are tracked in the `user/` Git repo: `pages/`, `config/`, `accounts/`, `themes/`. The `plugins/` and `data/` folders are excluded. Only these folders are tracked in the `user/` Git repo: `pages/`, `config/`, `accounts/`, `themes/`. The `plugins/` and `data/` folders are excluded.
### Dual-repo submodule structure
`user/` is a **git submodule** of the outer repo (`.gitmodules` at the root; git dir absorbed into `.git/modules/user`). Full workflow: `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`. The essentials:
- **Two repos, two cadences.** Outer repo = dev environment (tests/docs/scripts/Docker). `user/` = content + theme, with its own remote and `make content-push` cadence. The outer repo pins an exact `user/` commit via the `user` gitlink.
- **Pointer-bump convention.** Routine content changes → **do not** bump the pin (leave it stale; harmless). At the **end of a cross-repo feature** → bump the pin once to the finished `user/` commit. Pin a commit reachable from `user/`'s published `main` (prefer the merge-to-main commit, not a squash-away branch tip), and **push `user/` before the outer repo** (superproject references a child SHA that must already exist upstream). The pin is dev-side coordination only — production pulls `user/` via the content webhook independently.
- **`M user` / `m user` is normal.** `M` = pin differs from `user/` HEAD (bump pending/intentional). `m` = submodule working tree dirty (e.g. local-testing `config/site.yaml`). Neither is an error — do not "fix" them by committing the gitlink or the `site.yaml`.
- **Worktrees for parallel work.** A worktree off `main` gets its own `user/` (`git submodule update --init user`) and can run its own dev server (`docker compose -p itte-<feature> up` — the `./user` mount is relative, so each worktree serves its own content). Tooling worktrees live under `.worktrees/` (excluded via `.git/info/exclude`). To add a commit to `main` while the main checkout is on another branch, use a throwaway `main` worktree rather than `git checkout main`.
## 1. Environment modes ## 1. Environment modes
### Rule: do not switch modes during development ### Rule: do not switch modes during development
+1 -1
View File
@@ -1,6 +1,6 @@
FROM getgrav/grav FROM getgrav/grav
RUN curl -sL 'https://github.com/getgrav/grav/releases/download/2.0.0-rc.10/grav-admin-v2.0.0-rc.10.zip' \ RUN curl -sL 'https://github.com/getgrav/grav/releases/download/2.0.4/grav-admin-v2.0.4.zip' \
-o /tmp/grav-admin.zip \ -o /tmp/grav-admin.zip \
&& unzip -q /tmp/grav-admin.zip -d /tmp \ && unzip -q /tmp/grav-admin.zip -d /tmp \
&& cp -rf /tmp/grav-admin/assets /var/www/html/ \ && cp -rf /tmp/grav-admin/assets /var/www/html/ \
+27 -5
View File
@@ -20,8 +20,9 @@ SITE_CONFIG_DIR ?= $(REMOTE_HOME)/site-config
# make remote-install-prod → runs remote-install with ENV=prod # make remote-install-prod → runs remote-install with ENV=prod
# Calling a bare remote target (no ENV) fails via guard-env. # Calling a bare remote target (no ENV) fails via guard-env.
REMOTE_TARGETS := remote-env-setup remote-env-remove remote-wipe remote-install \ REMOTE_TARGETS := remote-env-setup remote-env-remove remote-wipe remote-install \
remote-fetch remote-fetch-content remote-install-plugins remote-upgrade-grav \ remote-fetch remote-fetch-content remote-install-plugins remote-update-plugins \
remote-clean remote-maintenance-on remote-maintenance-off remote-upgrade-grav remote-git-sync-disable remote-git-sync-enable \
remote-content-status remote-clean remote-maintenance-on remote-maintenance-off
ENVS := test prod ENVS := test prod
guard-env: guard-env:
@@ -35,13 +36,23 @@ $(foreach t,$(REMOTE_TARGETS),$(foreach e,$(ENVS),$(eval $(call make-env-target,
# ── Tests ───────────────────────────────────────────────────────────────────── # ── Tests ─────────────────────────────────────────────────────────────────────
# Local test account — auto-created, never committed (see user/.gitignore).
# Keep the password free of shell/Make/URL-special chars so every consumer agrees.
GRAV_TEST_USER ?= testrunner
GRAV_TEST_PASS ?= Testpass1234
test-account:
@docker exec intotheeast_grav sh -c 'test -f /var/www/html/user/accounts/$(GRAV_TEST_USER).yaml \
|| php bin/plugin login new-user -u $(GRAV_TEST_USER) -p "$(GRAV_TEST_PASS)" \
-e $(GRAV_TEST_USER)@example.test -N "Test Runner" -P b --admin-type both -s enabled -n'
test-config: test-config:
@bash scripts/test-form-config.sh @bash scripts/test-form-config.sh
test-post: test-post: test-account
@bash scripts/test-post.sh @bash scripts/test-post.sh
test-ui: test-ui: test-account
@npx playwright test @npx playwright test
test: test-config test-post test-ui test: test-config test-post test-ui
@@ -144,9 +155,20 @@ remote-fetch-content: guard-env
remote-install-plugins: guard-env remote-install-plugins: guard-env
$(SSH) "cd $(WEBROOT) && php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y" $(SSH) "cd $(WEBROOT) && php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y"
remote-update-plugins: guard-env
$(SSH) "cd $(WEBROOT) && php bin/gpm update -y && php bin/grav cache"
remote-upgrade-grav: guard-env remote-upgrade-grav: guard-env
$(SSH) "cd $(WEBROOT) && php bin/grav upgrade" $(SSH) "cd $(WEBROOT) && php bin/gpm self-upgrade -y && php bin/grav cache"
remote-git-sync-disable: guard-env
$(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' false" < scripts/git-sync-toggle.sh
remote-git-sync-enable: guard-env
$(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' true" < scripts/git-sync-toggle.sh
remote-content-status: guard-env
$(SSH) "cd $(WEBROOT)/user && git status --short && echo '--- config diff ---' && git diff -- config/"
remote-clean: guard-env remote-clean: guard-env
$(SSH) "cd $(WEBROOT) && php bin/grav clearcache" $(SSH) "cd $(WEBROOT) && php bin/grav clearcache"
+1 -1
View File
@@ -3,7 +3,7 @@ services:
build: . build: .
container_name: intotheeast_grav container_name: intotheeast_grav
environment: environment:
- GRAV_CHANNEL=beta - GRAV_CHANNEL=production
- APACHE_RUN_USER=#1000 - APACHE_RUN_USER=#1000
- APACHE_RUN_GROUP=#1000 - APACHE_RUN_GROUP=#1000
ports: ports:
+11 -2
View File
@@ -8,8 +8,9 @@ How the intotheeast site hangs together.
| Layer | Technology | Notes | | Layer | Technology | Notes |
|---|---|---| |---|---|---|
| CMS | Grav 2.0.0-rc.10 | Flat-file PHP CMS; no database | | CMS | Grav 2.0.4 stable | Flat-file PHP CMS; no database. Server upgrades in place via `bin/gpm self-upgrade` |
| Admin | Admin2 v2.0.0-rc.15 | Plugin slug: `admin2` (not `admin`) | | Admin | Admin2 v2.0.10 | Plugin slug: `admin2` (not `admin`) |
| GPM channel | `stable` | Authoritative in `user/config/system.yaml``gpm.releases`; `GRAV_CHANNEL=production` in compose is cosmetic |
| Container | Docker (`getgrav/grav` base + custom `Dockerfile`) | Grav 2.0 baked in at build time | | Container | Docker (`getgrav/grav` base + custom `Dockerfile`) | Grav 2.0 baked in at build time |
| PHP session | `session.save_path = /tmp` | Set in `php/php-local.ini` | | PHP session | `session.save_path = /tmp` | Set in `php/php-local.ini` |
| Dev URL | http://localhost:8081 | Mapped from container port 80 | | Dev URL | http://localhost:8081 | Mapped from container port 80 |
@@ -49,6 +50,14 @@ Other notable plugins:
| `api` (Grav API v1) | Used by /gpx-manager to list/upload/delete GPX files | | `api` (Grav API v1) | Used by /gpx-manager to list/upload/delete GPX files |
| `admin2` | Admin panel at /admin | | `admin2` | Admin panel at /admin |
### Plugin management model
Three categories, by how each plugin is installed and maintained:
1. **GPM-managed** (`plugins.txt``make install-plugins`): the marketplace plugins, including `login`, `form`, `admin2`, `api`, `flex-objects`, shortcodes, etc. As of the 2.0.4 upgrade, `admin2`/`api`/`flex-objects` moved into this category — they were previously hand-extracted from the core bundle. Update with `bin/gpm update` (`make remote-update-plugins-<env>` on servers).
2. **Custom, in-repo** (`user/plugins/` allowlisted in `user/.gitignore`): `cache-on-save`, `story-blocks`. Versioned in the user repo.
3. **Remote-only**: `git-sync` — installed and configured only on servers, **never** in `plugins.txt`, and disabled during upgrades.
--- ---
## Template hierarchy ## Template hierarchy
@@ -0,0 +1,104 @@
---
title: Dual-repo submodule workflow — outer dev-env repo + user/ content submodule
date: 2026-07-04
category: architecture-patterns
module: Repo structure — outer repo + user/ content repo
problem_type: architecture_pattern
component: git
severity: medium
applies_when:
- Starting a feature that touches both the outer repo and user/ (theme, plugins, pages)
- Setting up a git worktree for long-running work while doing other work in parallel
- Deciding when to bump the user/ submodule pointer in the outer repo
- A git worktree of the outer repo shows an empty or broken user/ directory
- Seeing a persistent "M user" / "m user" dirty state in the outer repo
tags: [git, submodule, worktree, dual-repo, user-repo, docker, content-sync, pointer-bump]
---
# Dual-repo submodule workflow
## Context
This project is **two independent git repositories** that happen to be nested:
- **Outer repo** (`intotheeast-com.git`) — the Grav dev environment: `tests/`, `scripts/`, `docs/`, `docker-compose.yml`, `Dockerfile`, `Makefile`, `CLAUDE.md`.
- **`user/` repo** (`intotheeast-com-content.git`) — all site content and the theme: `pages/`, `config/`, `accounts/`, `themes/`. It has its own remote (the Gitea content mirror) and its own release cadence (`make content-push` → webhook → production pull).
As of 2026-07-04, the outer repo tracks `user/` as a **proper git submodule** (`.gitmodules` at the outer root, git dir absorbed into `.git/modules/user`). Before that it was an *orphaned gitlink* — a `160000` tree entry with no `.gitmodules`, so git had no URL to populate or update it. That broke worktrees (a fresh outer worktree got an empty `user/`) and offered no supported sync path.
## Why a submodule (and not untracking)
Two options were weighed: make it a real submodule, or stop tracking `user/` in the outer repo entirely (gitignore it, symlink the real checkout in).
The submodule was chosen deliberately, for one reason that outweighs its ceremony:
- **Routine content churn is benign** — day-to-day entries/stories change `user/` constantly and never break the dev environment. Those changes do **not** need to be reflected in the outer repo.
- **Cross-repo *features* must be tracked together.** A feature like the journal post-form touches both repos (a plugin + theme JS/CSS in `user/`, and tests/docs in the outer repo). The outer repo pinning an exact `user/` commit records *"this dev-env state expects this content/theme state"* — so checking out the outer feature also gets the matching `user/` code. That coupling is real and worth having.
- It enables **per-worktree `user/` checkouts**, which is what makes true parallel work across both repos possible (see below). This was a hard requirement.
The cost accepted: a persistent `M user` dirty signal (intrinsic to submodules under active development) and the possibility of gitlink merge conflicts between outer branches. Neither is removed by the submodule; they are the price of version pinning.
## The pointer-bump convention
The outer repo's `user` gitlink stores an exact `user/` commit SHA. **When to bump it:**
- **Routine content changes → do not bump.** Push content with `make content-push` and leave the outer pin where it is. A stale pin during normal content work is expected and harmless.
- **At the end of a cross-repo feature → bump once.** When the feature's `user/` work is finalized, update the outer pin to the finished `user/` commit, as the final step of the feature (its own `chore: bump user pointer to <sha>` commit, or folded into the final integration commit).
Two rules keep the pin from dangling for other machines/clones:
1. **Pin a commit reachable from `user/`'s published `main`.** Prefer the **merge-to-main commit**. Pinning a feature-branch tip is safe *only* if that exact commit survives onto `main` (fast-forward / no-squash merge); a squashed-away tip becomes an orphaned SHA and `git submodule update` fails elsewhere.
2. **Push `user/` before the outer repo.** The submodule golden rule: the superproject references a child SHA, so the child must already be pushed. `make content-push` handles the `user/` push — just do it before pushing the outer branch.
Production is unaffected either way: prod pulls `user/` directly via the content-remote webhook, independent of the outer repo's pin. The pin is **dev-side coordination only**.
## Parallel work: worktree + its own dev server
The payoff. Because `docker-compose.yml` mounts `./user` **relative to the compose file**, and a worktree is a full copy of the outer tree (compose file included), each worktree serves *its own* `user/`. Two worktrees = two independent sites, no gitlink collisions.
Set up a feature worktree off `main`:
```bash
# outer worktree on a new feature branch
git worktree add .worktrees/<feature> -b feat/<feature> main
cd .worktrees/<feature>
# populate user/ at the pinned SHA, then branch it for the cross-repo work
git submodule update --init user
git -C user checkout -b feat/<feature>
# its own dev server — separate project name + port from the main checkout's :8081
docker compose -p itte-<feature> up -d
```
`.worktrees/` is kept out of git via `.git/info/exclude` (local, shared across worktrees — no committed `.gitignore` change needed).
### Teardown
A submodule inside a linked worktree stores its git dir under `.git/modules/user/worktrees/<name>`, so removing the outer worktree needs a second cleanup step:
```bash
docker compose -p itte-<feature> down
cd "$(git rev-parse --show-toplevel)" # back to the main checkout
git -C .worktrees/<feature> submodule deinit user # detach the submodule worktree
git worktree remove .worktrees/<feature> # remove the outer worktree
git branch -d feat/<feature> # if merged
```
### Landing a commit on main without disturbing the main checkout
When the main checkout is mid-work on another branch, add a commit to `main` through a throwaway worktree instead of `git checkout main` (which would yank branches out from under an open IDE):
```bash
git worktree add .worktrees/main-tmp main
git -C .worktrees/main-tmp cherry-pick <sha> # or edit + commit
git worktree remove .worktrees/main-tmp
```
## Gotchas
- **`M user` / `m user` is normal.** Uppercase `M` = the pin differs from `user/` HEAD (bump pending or intentional). Lowercase `m` = the submodule working tree is dirty (e.g. an uncommitted `config/site.yaml` used for local testing). Neither is an error.
- **Gitlink merge conflicts still happen.** If two outer branches pin different `user/` SHAs, merging them conflicts on the `user` entry. Resolve by choosing the correct (usually newer, merged) SHA, then `git add user`.
- **Worktrees need `submodule update --init`.** A fresh outer worktree has an empty `user/` until you run it — it is not automatic.
- **The submodule git dir was absorbed** (`git submodule absorbgitdirs user`) so all worktrees share `.git/modules/user`. `user/.git` is now a gitfile (`gitdir: ../.git/modules/user`), not a directory. `make content-push`/`content-pull` still operate on `user/` normally.
- **Access requires the content remote** (SSH over Tailscale). A machine that cannot reach it cannot `submodule update` — but it could never clone `user/` anyway, so this is not a regression.
@@ -0,0 +1,82 @@
---
title: "Grav login new-user grants api.* but not admin.* on Admin2-only installs"
date: 2026-07-04
category: docs/solutions/test-failures
module: testing / account provisioning
problem_type: test_failure
component: authentication
symptoms:
- "gpx-manager Playwright specs fail (401 / login form shown) after switching the suite onto a dedicated test account"
- "an authenticated account still sees the login form at /gpx-manager instead of the manager UI"
- "the generated accounts/*.yaml has an access.api block but no access.admin block"
root_cause: missing_permission
resolution_type: tooling_addition
severity: medium
related_components:
- testing_framework
- tooling
tags:
- grav
- login-plugin
- admin2
- permissions
- playwright
- test-account
- gpx-manager
---
# Grav login new-user grants api.* but not admin.* on Admin2-only installs
## Problem
When the Playwright suite was moved onto a dedicated local `testrunner` account, every `/gpx-manager` spec started failing — the account could authenticate but was treated as unauthorized for the manager page. The account had been created with `bin/plugin login new-user ... -P b` (Admin + Site access) but **without** `--admin-type`, and on this Admin2-only install that grants `api.*` permissions and no `admin.*` permissions.
## Symptoms
- The `/gpx-manager` Playwright specs fail after switching from the real user to the `testrunner` account (they passed as the real user).
- An authenticated `testrunner` still gets the Login plugin's login form at `/gpx-manager` instead of the manager UI.
- The generated `user/accounts/testrunner.yaml` contains an `access.api` block (`login: true`, `super: true`) but **no** `access.admin` block.
## What Didn't Work
- **Assuming `-P b` was enough.** `-P/--permissions b` selects the *category* of access (Admin + Site), but the *type* of admin permission — classic `admin.*` vs Admin2 `api.*` — is a separate axis controlled by `--admin-type`, which defaults to auto-detect. `-P b` alone does not guarantee `admin.login`.
- **Blaming the wrong specs.** In the same push, the home `H1`/map specs were also red, which looked like it might be the same auth problem. It was not — those were gated by `site.yaml` `travelling: false` hiding the active-trip view, a completely separate cause. Conflating the two delayed pinning the permission root cause.
## Solution
Create the account with an explicit `--admin-type both`, and bake it into the idempotent `make test-account` target so every recreation is faithful:
```make
test-account:
@docker exec intotheeast_grav sh -c 'test -f /var/www/html/user/accounts/$(GRAV_TEST_USER).yaml \
|| php bin/plugin login new-user -u $(GRAV_TEST_USER) -p "$(GRAV_TEST_PASS)" \
-e $(GRAV_TEST_USER)@example.test -N "Test Runner" -P b --admin-type both -s enabled -n'
```
Verify the resulting permissions actually include `admin.login`:
```bash
docker exec intotheeast_grav rm -f /var/www/html/user/accounts/testrunner.yaml
make test-account
docker exec intotheeast_grav sh -c 'grep -A6 "^access:" /var/www/html/user/accounts/testrunner.yaml'
# access:
# admin:
# login: true
# super: true
# api:
# login: true
# super: true
```
## Why This Works
The `login new-user` help text spells out the axis:
> `--admin-type` — Which admin permission type to grant when permissions include Admin: `admin` (classic Admin plugin, `admin.*`), `api` (Admin2, `api.*`), or `both`. **If omitted, auto-detects from which admin plugin is installed.**
This site runs **Admin2 only** (the classic `admin` plugin is disabled), so auto-detect resolves to `api` and emits `api.*` alone. `/gpx-manager` is gated by `access.admin.login: true` in its page frontmatter (enforced by the Login plugin), and that check looks specifically for the `admin.login` permission — `api.login` does not satisfy it. Passing `--admin-type both` forces both namespaces into the account, so the admin-gated page accepts the session.
## Prevention
- **On Admin2-only Grav installs, always pass `--admin-type both` (or `admin`) to `login new-user`** when the account must reach any page gated by `access.admin.login` (e.g. `/gpx-manager`). Auto-detect will otherwise silently give you api-only.
- **Assert the permission, not the exit code.** After provisioning an account for admin-gated pages, check that `access.admin.login` exists in the generated YAML rather than trusting that account creation "succeeded."
- **Keep provisioning in one idempotent place.** The `make test-account` target is the single source of truth; `tests/global-setup.js` calls it, so `make test` and a bare `npx playwright test` both get identical permissions. Don't hand-create the account out-of-band with different flags — that reintroduces the drift this fix removed.
## Related Issues
- `docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md` — the self-contained test-account infrastructure shipped alongside the Grav 2.0.4 upgrade.
- `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md` — accounts live in the `user/` repo; the `testrunner` account is gitignored so it never reaches production.
- GPX manager auth model (`access.admin.login: true` frontmatter + Login plugin) — see the project's GPX manager notes.
@@ -0,0 +1,550 @@
# Grav 2.0.4 Upgrade + GPM-Manage Plugins — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Status:** ✅ Complete (2026-07-04) — local (Phase 1) validated and shipped; Task 5 (remote test-env, Phase 2) is user-gated and parked; Phase 3 (prod) is documentation-only per design. See "Known issue" below re: Form 9.1.10 filepond.
**Goal:** Upgrade Grav core `2.0.0-rc.10``2.0.4` stable and promote `admin2`/`api`/`flex-objects` to GPM management, validated on local then the remote test env (prod is documented-only).
**Architecture:** One dependency-forced atomic upgrade. Local core is baked into the Docker image (rebuild); the server upgrades in place via `bin/gpm self-upgrade` + `bin/gpm update`. The GPM release channel is switched from `testing` to `stable` in `user/config/system.yaml`. `git-sync` is disabled for the duration of the remote upgrade and left off pending user validation.
**Tech Stack:** Grav 2.0 (PHP 8.3), GPM CLI, Docker Compose, Make (env-suffixed remote targets), Gitea content sync.
**Spec:** `docs/working/specs/2026-07-04-grav-2.0.4-upgrade-design.md`
## Global Constraints
- Version floors (GPM enforces): `grav >= 2.0.4`, `api >= 1.0.6`, `admin2 >= 2.0.9`, `flex-objects >= 1.4.3`, `login >= 3.8.11`, `form >= 6.0.0`. Assert with `>=`, not `==` — GPM may serve a newer stable patch at execution time.
- Only write inside `travel-blog-intotheeast/` or its subfolders.
- **Dual git repos.** The project root is one repo; `user/` is a *separate* repo where only `pages/ config/ accounts/ themes/` are tracked (`plugins/` is gitignored except `cache-on-save/` and `story-blocks/`). Changes to `user/config/system.yaml` commit to the **user repo** and reach the server via `make content-push` → server pull; everything else commits to the **root repo**.
- GPM channel authority is `user/config/system.yaml``gpm.releases` (must be `stable` on the server *before* any GPM op). `GRAV_CHANNEL` in docker-compose is cosmetic/consistency only.
- Never read `.env*`. Use `make remote-*` targets for all server ops.
- Do not touch `twig.cache` (stays `false` in dev per CLAUDE.md).
- `git-sync` is remote-only: never add it to `plugins.txt`; disable it during the remote upgrade and leave it disabled until the user re-enables.
- Prod is empty → **Phase 3 is documentation only, never executed.**
- Verified CLI names (against the rc.10 container): `php bin/gpm self-upgrade -y` (core), `php bin/gpm update -y` (all plugins), `php bin/grav cache` (clear cache). `bin/grav upgrade` does **not** exist.
---
## File structure
| File | Repo | Responsibility |
|---|---|---|
| `Dockerfile` | root | Baked local core version (grav-admin zip URL) |
| `plugins.txt` | root | GPM plugin manifest — gains admin2/api/flex-objects |
| `docker-compose.yml` | root | `GRAV_CHANNEL` cosmetic bump |
| `user/config/system.yaml` | **user** | Authoritative GPM channel (`gpm.releases`) |
| `scripts/server-install.sh` | root | Fresh-install script — drop admin2/api special-casing |
| `scripts/git-sync-toggle.sh` | root | New: idempotently set git-sync `enabled:` on the server |
| `Makefile` | root | Fix `remote-upgrade-grav`; add 3 remote targets |
| `docs/working/plans/...` `CLAUDE.md` `docs/reference/architecture.md` | root | Runbook + stack docs |
---
## Task 1: Phase 0 — core, plugin-list, and channel edits
**Files:**
- Modify: `Dockerfile` (the grav-admin zip URL line)
- Modify: `plugins.txt`
- Modify: `docker-compose.yml` (`GRAV_CHANNEL`)
- Modify: `user/config/system.yaml` (`gpm.releases`)
**Interfaces:**
- Produces: local image that installs Grav 2.0.4; `plugins.txt` containing `api`, `admin2`, `flex-objects`; `stable` GPM channel consumed by Task 2 (local) and Task 5 (server).
- [ ] **Step 1: Bump the core version in the Dockerfile**
In `Dockerfile`, change the download URL:
```dockerfile
RUN curl -sL 'https://github.com/getgrav/grav/releases/download/2.0.4/grav-admin-v2.0.4.zip' \
-o /tmp/grav-admin.zip \
```
(Only the URL changes — the zip still extracts to `/tmp/grav-admin/`, so every `cp` line below it is unchanged.)
- [ ] **Step 2: Add the three plugins to `plugins.txt`**
Append these lines to `plugins.txt` (order is not significant; GPM resolves deps):
```
api
admin2
flex-objects
```
- [ ] **Step 3: Switch the GPM channel to stable**
In `user/config/system.yaml`, under the `gpm:` block (currently line ~212):
```yaml
gpm:
releases: stable
official_gpm_only: true
```
(Change `testing``stable`. Leave `official_gpm_only` as-is.)
- [ ] **Step 4: Bump the cosmetic channel env**
In `docker-compose.yml`, under the `grav` service environment:
```yaml
- GRAV_CHANNEL=production
```
- [ ] **Step 5: Commit the user-repo change**
```bash
cd user
git add config/system.yaml
git commit -m "config: switch GPM release channel testing -> stable"
cd ..
```
Expected: commit succeeds in the `user` repo.
- [ ] **Step 6: Commit the root-repo changes**
```bash
git add Dockerfile plugins.txt docker-compose.yml
git commit -m "build: pin Grav core 2.0.4 and add admin2/api/flex-objects to plugins.txt"
```
Expected: commit succeeds on branch `grav-2.0.4-upgrade`.
---
## Task 2: Phase 1 — local build, clean install, validation
**Files:**
- No file edits. Executes the Task 1 changes locally.
**Interfaces:**
- Consumes: Task 1 (Dockerfile 2.0.4, plugins.txt, stable channel).
- Produces: a proven-working local 2.0.4 stack — the go/no-go gate for the remote phases.
- [ ] **Step 1: Remove the stale manually-extracted plugin folders**
These were hand-extracted from the rc.10 bundle; GPM must install them fresh.
```bash
rm -rf user/plugins/admin2 user/plugins/api user/plugins/flex-objects
```
Expected: the three folders are gone (`ls user/plugins/` no longer lists them). They are gitignored, so `git status` in `user/` is unaffected.
- [ ] **Step 2: Rebuild the image with core 2.0.4**
```bash
make build
```
Expected: build completes; the RUN layer downloads `grav-admin-v2.0.4.zip`.
- [ ] **Step 3: Recreate the container**
```bash
make start
```
Expected: `intotheeast_grav` is up on http://localhost:8081.
- [ ] **Step 4: Confirm the core version is 2.0.4**
```bash
docker exec intotheeast_grav php bin/grav --version
```
Expected output contains: `Grav CLI Application 2.0.4` (or a newer 2.0.x).
- [ ] **Step 5: Update already-installed GPM plugins to stable**
This bumps `login` (3.8.9 → ≥3.8.11, required by `api`) and `form` before the new plugins install. `gpm install` alone would skip them because they already exist.
```bash
docker exec -w /var/www/html intotheeast_grav php bin/gpm update -y
```
Expected: `login`, `form`, `shortcode-core`, etc. report as updated (or already up to date).
- [ ] **Step 6: Install the newly-listed plugins**
```bash
make install-plugins
```
Expected: `admin2`, `api`, `flex-objects` install; their dependencies resolve against the now-current `login`/`form`; no "requires grav >= 2.0.4" errors.
- [ ] **Step 7: Clear the cache**
```bash
docker exec intotheeast_grav php bin/grav cache
```
Expected: "Cache cleared" output.
- [ ] **Step 8: Assert plugin versions meet the floors**
```bash
docker exec intotheeast_grav sh -c 'cd /var/www/html && for p in admin2 api flex-objects login form; do printf "%s: " "$p"; grep -m1 "^version:" user/plugins/$p/blueprints.yaml; done'
```
Expected (at least): `admin2: version: 2.0.9`, `api: version: 1.0.6`, `flex-objects: version: 1.4.3`, `login: version: 3.8.11`, `form: version: 9.1.8` — equal or higher.
- [ ] **Step 9: Run the automated smoke suite**
This exercises the posting pipeline (`test-post` submits via the real form → add-page-by-form → cache-on-save) and renders pages via Playwright — the exact admin2/api-critical path.
```bash
make test
```
Expected: `test-config`, `test-post`, and `test-ui` all pass.
- [ ] **Step 10: Manual browser spot-check**
Visit and confirm each renders without error:
- http://localhost:8081/ (home)
- the active trip page (`/trips/japan-korea-2026`) — filter bar + map load
- one story page — hero + shortcodes render
- http://localhost:8081/admin2 — login page loads; log in
- http://localhost:8081/gpx-manager — list loads; upload a small `.gpx`, then delete it
Expected: all load; no PHP errors in `docker logs intotheeast_grav`.
- [ ] **Step 11: Checkpoint (no commit needed)**
No files changed in this task. If any step failed, stop and diagnose before proceeding — this is the go/no-go gate for remote work.
---
## Task 3: Remote Makefile targets (fix + additions)
**Files:**
- Create: `scripts/git-sync-toggle.sh`
- Modify: `Makefile` (fix `remote-upgrade-grav`; add `remote-update-plugins`, `remote-git-sync-disable`, `remote-git-sync-enable`; register the new targets in `REMOTE_TARGETS`)
**Interfaces:**
- Produces: `make remote-upgrade-grav-<env>`, `make remote-update-plugins-<env>`, `make remote-git-sync-disable-<env>`, `make remote-git-sync-enable-<env>` — consumed by Task 5.
- [ ] **Step 1: Create the git-sync toggle script**
Create `scripts/git-sync-toggle.sh` (piped to the server via `bash -s`, matching the `server-install.sh` pattern). It only ever rewrites the top-level `enabled:` key — never `folders` or the encrypted token.
```bash
#!/bin/bash
set -e
FILE="$1"
STATE="$2"
: "${FILE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}"
: "${STATE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}"
if [ ! -f "$FILE" ]; then
echo "ERROR: $FILE not found — is git-sync installed on this server?" >&2
exit 1
fi
if grep -qE '^enabled:' "$FILE"; then
sed -i -E "s/^enabled:.*/enabled: ${STATE}/" "$FILE"
else
printf 'enabled: %s\n' "$STATE" | cat - "$FILE" > "$FILE.tmp" && mv "$FILE.tmp" "$FILE"
fi
echo "git-sync now: $(grep -E '^enabled:' "$FILE")"
```
- [ ] **Step 2: Make it executable**
```bash
chmod +x scripts/git-sync-toggle.sh
```
- [ ] **Step 3: Fix the broken `remote-upgrade-grav` target**
In `Makefile`, replace the body of `remote-upgrade-grav` (currently `php bin/grav upgrade`, which is not a real command):
```make
remote-upgrade-grav: guard-env
$(SSH) "cd $(WEBROOT) && php bin/gpm self-upgrade -y && php bin/grav cache"
```
- [ ] **Step 4: Add the plugin-update target**
Add below `remote-install-plugins`:
```make
remote-update-plugins: guard-env
$(SSH) "cd $(WEBROOT) && php bin/gpm update -y && php bin/grav cache"
```
- [ ] **Step 5: Add the git-sync toggle targets**
```make
remote-git-sync-disable: guard-env
$(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' false" < scripts/git-sync-toggle.sh
remote-git-sync-enable: guard-env
$(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' true" < scripts/git-sync-toggle.sh
```
- [ ] **Step 6: Add a server content-status target**
For reviewing config drift after the plugin upgrade without raw SSH:
```make
remote-content-status: guard-env
$(SSH) "cd $(WEBROOT)/user && git status --short && echo '--- config diff ---' && git diff -- config/"
```
- [ ] **Step 7: Register the new targets for env-suffix generation**
In `Makefile`, extend the `REMOTE_TARGETS` list so the `-test`/`-prod` variants get generated:
```make
REMOTE_TARGETS := remote-env-setup remote-env-remove remote-wipe remote-install \
remote-fetch remote-fetch-content remote-install-plugins remote-update-plugins \
remote-upgrade-grav remote-git-sync-disable remote-git-sync-enable \
remote-content-status remote-clean remote-maintenance-on remote-maintenance-off
```
- [ ] **Step 8: Verify the targets exist and expand correctly (dry run)**
```bash
make -n remote-update-plugins-test
make -n remote-git-sync-disable-test
make -n remote-upgrade-grav-test
make -n remote-content-status-test
```
Expected: each prints the intended `ssh ...` command with `ENV=test` resolved, and no "No rule to make target" error. (No server is contacted by `-n`.)
- [ ] **Step 9: Commit**
```bash
git add scripts/git-sync-toggle.sh Makefile
git commit -m "build: fix remote-upgrade-grav; add remote plugin-update, git-sync toggle, content-status targets"
```
---
## Task 4: Fresh-install script cleanup (option B server-side)
**Files:**
- Modify: `scripts/server-install.sh` (remove admin2/api stash+restore)
**Interfaces:**
- Consumes: `plugins.txt` now containing admin2/api/flex-objects (Task 1).
- Produces: a fresh-install path where admin2/api/flex install purely via `gpm install $PLUGINS`.
- [ ] **Step 1: Remove the zip-stash lines**
In `scripts/server-install.sh`, delete lines 2526 (the admin2/api stash into `/tmp`):
```bash
cp -rf grav-admin/user/plugins/admin2 /tmp/admin2-plugin
cp -rf grav-admin/user/plugins/api /tmp/api-plugin
```
- [ ] **Step 2: Remove the restore lines**
Delete lines 4345 (the restore after the user re-clone):
```bash
cp -rf /tmp/admin2-plugin user/plugins/admin2
cp -rf /tmp/api-plugin user/plugins/api
rm -rf /tmp/admin2-plugin /tmp/api-plugin
```
Leave `mkdir -p user/plugins user/accounts user/data` in place. admin2/api/flex now come from `php bin/gpm install $PLUGINS -y` (unchanged line ~48).
- [ ] **Step 3: Syntax-check the script**
```bash
bash -n scripts/server-install.sh
```
Expected: no output (valid syntax).
- [ ] **Step 4: Commit**
```bash
git add scripts/server-install.sh
git commit -m "build: drop admin2/api zip-stash from server-install; install via GPM (option B)"
```
---
## Task 5: Phase 2 — test env upgrade
**Files:**
- No file edits. Executes against the test environment using Task 3 targets.
**Interfaces:**
- Consumes: Tasks 14 (pushed to Gitea), the `-test` make targets.
- Produces: test env on 2.0.4 with GPM-managed plugins, validated; git-sync left disabled.
- [ ] **Step 1: Push all changes to Gitea**
The server pulls `user/` content (incl. the stable-channel `system.yaml`) from Gitea; the root repo pushes normally.
```bash
git push origin grav-2.0.4-upgrade # or merge to the branch the server tracks, per your deploy convention
make content-push # pushes the user repo commit (system.yaml) to Gitea
```
Expected: both remotes updated. (Confirm with the user which branch the test server tracks before pushing.)
- [ ] **Step 2: Disable git-sync on test**
```bash
make remote-git-sync-disable-test
```
Expected: prints `git-sync now: enabled: false`.
- [ ] **Step 3: Pull latest content to the test server**
Brings the `gpm.releases: stable` change onto the server *before* any GPM op.
```bash
make remote-fetch-content-test
```
Expected: server `user/` fast-forwards; `user/config/system.yaml` shows `releases: stable`.
- [ ] **Step 4: Upgrade the core on test**
```bash
make remote-upgrade-grav-test
```
Expected: `bin/gpm self-upgrade` moves core rc.10 → 2.0.4 (stable channel); cache cleared. If it fails on a shared-folder error (see spec Risks), retry is safe — `self-upgrade` supports `-o/--overwrite`; add it to the target temporarily if a retry is needed.
- [ ] **Step 5: Update all plugins on test**
```bash
make remote-update-plugins-test
```
Expected: admin2 → ≥2.0.9, api → ≥1.0.6, flex-objects → ≥1.4.3, login → ≥3.8.11, form, git-sync all update to their stable versions; cache cleared.
- [ ] **Step 6: Review server config drift**
Inspect the server `user/` working tree for unexpected rewrites from the plugin upgrades (do NOT blind-commit):
```bash
make remote-content-status-test
```
Expected: review any `config/` diffs deliberately. Discard server-specific/reformatting churn; keep only intended changes. (git-sync is disabled, so nothing auto-commits while you review.)
- [ ] **Step 7: Smoke-test the test URL**
Against the test site (URL per your test env), confirm:
- home, a trip page, a story render
- admin2 login works
- submit one `/post` → the entry appears in the active trip's dailies
- `/gpx-manager` lists, uploads, and deletes a file
Expected: all pass. (git-sync stays disabled, so the new post will not auto-sync yet — that's expected and verified in Step 9.)
- [ ] **Step 8: Notify the user — validation checkpoint**
Report results and explicitly state that **git-sync remains disabled** on test pending their validation. Do not re-enable automatically.
- [ ] **Step 9: (User-gated) Re-enable git-sync and verify sync**
After the user confirms validation:
```bash
make remote-git-sync-enable-test
make content-push # or trigger a content change; confirm it syncs through
```
Expected: `git-sync now: enabled: true`; a content round-trip syncs between the test server and Gitea.
---
## Task 6: Docs, prod runbook, and memory
**Files:**
- Modify: `CLAUDE.md` (stack versions + plugin-management model)
- Modify: `docs/reference/architecture.md` (versions/channel)
- Modify: `docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md` (this file — Phase 3 runbook + Status)
- Modify: memory files under the auto-memory dir (project-grav2-upgrade, project-plugin-architecture)
**Interfaces:**
- Consumes: the completed local + test upgrade.
- Produces: current docs; an executable-but-unexecuted prod runbook.
- [ ] **Step 1: Update the stack facts in `CLAUDE.md`**
Change the "Current stack" block: Grav `2.0.4` (not rc.10); Admin2 to the installed stable version; note that admin2/api/flex-objects are now **GPM-managed via `plugins.txt`** (no longer hand-extracted); note `gpm.releases: stable`.
- [ ] **Step 2: Update `docs/reference/architecture.md`**
Reflect core 2.0.4, stable channel, and the three-category plugin model (GPM-managed / former-manual-now-GPM / remote-only git-sync).
- [ ] **Step 3: Write the Phase 3 prod runbook**
Append a "Phase 3 — Production (fresh install, NOT executed)" section to this plan documenting: run `make remote-install-prod` with `GRAV_VERSION=2.0.4`; admin2/api/flex install via GPM from `plugins.txt`; then set up git-sync manually (install, add encrypted token, apply the `folders:` array fix per `docs/working/git-sync-notes.md`), and leave it disabled until first validation.
- [ ] **Step 4: Update memory**
Update `project-grav2-upgrade.md` (now on 2.0.4 stable; GPM serves stable so direct-download-only no longer applies) and `project-plugin-architecture.md` (admin2/api/flex now GPM-managed; git-sync remote-only category). Refresh the `MEMORY.md` pointers if the hooks change.
- [ ] **Step 5: Set the plan Status to complete**
Change the `**Status:**` line at the top of this file to `✅ Complete (YYYY-MM-DD)` (today's date at execution).
- [ ] **Step 6: Commit**
```bash
git add CLAUDE.md docs/reference/architecture.md docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md
git commit -m "docs: record Grav 2.0.4 upgrade; GPM-managed plugins; prod runbook"
```
(Memory files live outside the repo; they are written directly, not committed here.)
---
## Rollback
If any phase fails and cannot be fixed forward:
1. `git revert` the relevant commits on `grav-2.0.4-upgrade` (root repo) and the `user` repo `system.yaml` commit.
2. Local: `make build && make start && make install-plugins`.
3. Test server: config, content, and plugin reverts are delivered via `make content-push` + `make remote-fetch-content-test`. **The core is the exception — once `bin/gpm self-upgrade` has completed it cannot downgrade, so treat a completed core upgrade as forward-only and fix forward; there is no revert for it.**
> ⚠️ Do **not** run the fresh-install path (`scripts/server-install.sh`) against a live server as a rollback. It does `rm -rf user; git clone`, which destroys the server-only, gitignored `user/config/plugins/git-sync.yaml` (the encrypted git-sync token a clone never restores). The fresh-install path is for empty/new servers only.
Content, config, and accounts are in git, so no data restore is required — but note the core caveat above: "rollback = git" covers config/content/plugins, **not** a completed server core self-upgrade.
---
## Execution outcome (2026-07-04)
**Installed local versions (all at/above floors):** Grav `2.0.4`, admin2 `2.0.10`, api `1.0.7`, flex-objects `1.4.4`, login `3.8.11`, form `9.1.10`, shortcode-core `6.2.2`.
**Phase 1 (local): validated + shipped.** Image rebuilt on 2.0.4, plugins installed via GPM, cache clears, rendering clean (home, trips, story, `/admin2` login, `/gpx-manager` list/upload/delete all 200). Test suite: **75 passing**. Test-config was re-pointed off the retired `japan-korea-2026` onto the vetted `italy-2026-demo` data. A self-contained, gitignored `testrunner` account (created via `make test-account` with `--admin-type both`) makes `make test` runnable without the real account in `.env`.
**Known issue — Form 9.1.10 filepond regression (blocked elsewhere, not a go/no-go blocker).** The post form's `filepond` photo field 500s on the post-submit re-render (`filepond.html.twig` runs `merge` on a string). The journal entry still saves correctly (curl/on-disk `test-post.sh` passes); only the browser re-render errors, failing 6 `post.spec.js` UI specs. This is a stock-plugin upgrade regression, being fixed independently in the form-to-page/image-upload rework. **Do not** add a theme-override workaround in this upgrade — let that rework own the fix.
**Tasks 3 & 4 (remote Makefile targets + server-install cleanup): shipped** (committed on this branch).
**Task 5 (Phase 2, remote test-env): parked, user-gated.** Not executed. Requires the user to confirm which branch the test server tracks and that `.env.test` is ready. All `-test` make targets exist and dry-run clean.
---
## Phase 3 — Production (fresh install, NOT executed)
Production is empty, so this is a **fresh install**, not an upgrade — and it is **documentation only**. Do not run it as part of this plan.
When prod is provisioned:
1. **Provision creds:** copy the REMOTE section of `.env.example` into `.env.prod` with production values (never commit it). Run `make remote-env-setup-prod`.
2. **Fresh install at 2.0.4:** `make remote-install-prod` with `GRAV_VERSION=2.0.4` in `.env.prod`. `scripts/server-install.sh` installs core, then all of `plugins.txt``admin2`/`api`/`flex-objects` now install purely via `php bin/gpm install` (no zip-stash; that special-casing was removed in Task 4). The `gpm.releases: stable` channel arrives with the `user/` content clone.
3. **git-sync (remote-only, manual):** it is deliberately absent from `plugins.txt`. Install it on the server, add the encrypted token to `user/config/plugins/git-sync.yaml` (server-only, gitignored — a fresh clone never restores it), and apply the `folders:` array fix per `docs/working/git-sync-notes.md`. Leave it **disabled** (`make remote-git-sync-disable-prod`) until the first content round-trip is validated, then `make remote-git-sync-enable-prod`.
4. **Smoke test** the prod URL as in Task 5 Step 7 (home / trip / story / admin2 login / one `/post` / `/gpx-manager`). Note the Form filepond known-issue above will surface on `/post` until the separate rework lands — the entry still saves.
5. **Never** run `scripts/server-install.sh` against a populated server (it `rm -rf user; git clone`, destroying the server-only git-sync token). Fresh/empty servers only.
@@ -0,0 +1,209 @@
# Grav 2.0.4 Upgrade + GPM-Manage admin2/api/flex-objects — Design
**Date:** 2026-07-04
**Status:** Design approved — pending implementation plan
## Goal
Perform one coordinated upgrade of the intotheeast stack:
1. Bump Grav core from `2.0.0-rc.10``2.0.4` (stable).
2. Promote `admin2`, `api`, and `flex-objects` from bundle-extracted plugins to
**GPM-managed** plugins (this is "option B").
These two changes are **not separable**. The stable plugins hard-require the
stable core, so GPM enforces an atomic upgrade of the whole chain.
## Background / findings
### Version gaps
The project has been frozen on the final release candidate since the original
Grav 2.0 upgrade. Grav went stable on 2026-06-21; latest patch is `2.0.4`
(2026-06-29). No breaking changes exist within the 2.0.x line — the
2.0.12.0.4 releases are security hardening (XSS re-checks on editor Twig,
ZIP-bomb limits, `.htaccess` case-insensitive bypass fix) plus bugfixes.
Installed vs. bundled-in-2.0.4 versions:
| Plugin | Installed | 2.0.4 stable |
|---|---|---|
| core (grav) | 2.0.0-rc.10 | 2.0.4 |
| admin2 | 2.0.0-rc.15 | 2.0.9 |
| api | 1.0.0-rc.15 | 1.0.6 |
| flex-objects | 1.4.0-rc.7 | 1.4.3 |
| login | 3.8.9 | 3.8.11 |
| form | 9.1.6 | 9.1.8 |
| shortcode-core | 6.0.0 | 6.2.1 |
### Dependency chain (why it's atomic)
From the stable plugin blueprints:
- `api` 1.0.6 requires `grav >= 2.0.4` **and** `login >= 3.8.11`
- `admin2` 2.0.9 requires `api >= 1.0.6`
- `flex-objects` 1.4.3 requires `form >= 6.0.0`, `api >= 1.0.0`
So stable admin2/api cannot run on the rc.10 core — GPM would refuse. This is
the core reason option B is the right approach: `gpm` resolves and enforces the
entire chain automatically, which the previous manual-extract approach never
did.
### Three plugin management categories
The upgrade must account for the fact that plugins reached the servers three
different ways:
| Category | Plugins | In `plugins.txt`? | How installed | Upgrade mechanism |
|---|---|---|---|---|
| GPM-managed | email, error, form, login, problems, add-page-by-form, shortcode-gallery-plusplus | yes | `gpm install` | `gpm update` |
| Manually-placed → GPM (option B) | admin2, api, flex-objects | **will add** | hand-extracted from grav-admin zip | `plugins.txt` for fresh installs; `gpm update` on existing test env |
| Remote-only | git-sync | **no** (config gitignored, holds encrypted token) | installed directly on the server | documented separately; carried by `gpm update`; **disabled during upgrade** |
### git-sync compatibility
git-sync is version 3.4.4 with an explicit `compatibility: 2.0` flag and is one
of Grav's own reference plugins for the Admin Next / API. It is safe to carry
through the upgrade. Note the documented folders-YAML quirk
(`docs/working/git-sync-notes.md`): its config must list `folders` as an array,
never the UI-written comma-string.
### Local vs. server upgrade mechanisms differ
- **Local** bakes the core into the Docker image (`Dockerfile`) → upgrade by
rebuilding the image.
- **Server** is a native webroot install → core upgrades via
`bin/grav upgrade`, plugins via `bin/gpm update`.
The plan therefore has distinct local and remote steps.
## Decisions
- **Option B (GPM management)** for admin2/api/flex-objects. Add them to
`plugins.txt` so future fresh installs pull them via GPM.
- **Rollout order:** local → test → prod.
- **Prod is currently empty** → the prod phase is written as a *documented
fresh-install runbook only* and is **not executed** in this effort. Fresh prod
install uses the option-B-modified `server-install.sh` with
`GRAV_VERSION=2.0.4`.
- **Rollback = git.** All relevant data (`pages/`, `config/`, `accounts/`,
`themes/`) is committed. No separate backup step. Rollback is `git revert` of
this branch plus a rebuild/redeploy.
- **Upgrade verb on existing installs is `gpm update` (update-all)**, not
`gpm install <plugins.txt>`. `install` skips already-installed plugins and
never touches git-sync (which is not in the list); `update` upgrades every
installed plugin regardless of how it was placed, catching the manual and
remote-only categories in one shot.
- **git-sync stays out of `plugins.txt`** (that list is shared with local; git-sync
is remote-only with a manual encrypted token). Documented as separately
managed.
- **git-sync is disabled before the test upgrade and left disabled**, so the
upgrade cannot auto-commit reformatted/server-specific config back into the
shared Gitea `user` repo. The user validates first, then re-enables it as a
separate deliberate step.
## File changes (Phase 0)
| File | Change |
|---|---|
| `Dockerfile` | grav-admin zip URL `2.0.0-rc.10/grav-admin-v2.0.0-rc.10.zip``2.0.4/grav-admin-v2.0.4.zip`. Verified: the 2.0.4 zip still extracts to a `grav-admin/` folder, so the existing `cp` block is unchanged. |
| `plugins.txt` | add `api`, `admin2`, `flex-objects` (`form`, `login` already present as their deps) |
| `user/config/system.yaml` | **`gpm.releases: testing → stable`** — this is the authoritative GPM channel. `testing` is what has been serving RC/pre-release versions. Tracked in the `user` repo, so it applies to both local and server once pushed. |
| `docker-compose.yml` | `GRAV_CHANNEL=beta``production` for consistency only. This env drives the base image's `docker-entrypoint.sh`, **not** `bin/gpm`'s channel — `gpm.releases` above is what governs updates. |
| `scripts/server-install.sh` | remove the admin2/api stash+restore special-casing (lines 2526 and 4345); they now install via `gpm install` from `PLUGINS` |
| `Makefile` | **fix broken `remote-upgrade-grav`:** `php bin/grav upgrade` is not a real command — change to `php bin/gpm self-upgrade -y`. Add the new remote targets (below) to `REMOTE_TARGETS` so each gets `-test`/`-prod` variants. |
| `CLAUDE.md`, `docs/reference/architecture.md`, memory | update stack versions; document the three-category plugin model and the channel change |
### New Makefile targets
Added to the `REMOTE_TARGETS` list (Makefile:2224) so the env-suffix macro
(Makefile:3134) auto-generates `-test` / `-prod` variants:
- `remote-update-plugins``cd $(WEBROOT) && php bin/gpm update -y`
- `remote-git-sync-disable` → set `enabled: false` in
`$(WEBROOT)/user/config/plugins/git-sync.yaml` (touch only the `enabled` key;
never rewrite `folders`)
- `remote-git-sync-enable` → set `enabled: true` in the same file
`remote-upgrade-grav` exists but its command is **broken** (`php bin/grav
upgrade` is not a Grav CLI command) — it is fixed to `php bin/gpm self-upgrade
-y` as part of Phase 0. Core self-upgrade respects the `gpm.releases` channel.
**Verified CLI command names** (against the running rc.10 container):
`php bin/gpm self-upgrade -y` (core), `php bin/gpm update -y` (all plugins),
`php bin/grav cache` (clear cache; aliases `clearcache`/`cache-clear`).
> The exact idempotent shell used to toggle the `enabled` key is finalized in the
> implementation plan; it must not disturb the `folders` array or the encrypted
> token in `git-sync.yaml`.
## Phases
### Phase 0 — branch + edits
New branch off `main`. Apply all file changes above.
### Phase 1 — local
1. Remove the stale manually-extracted `admin2`, `api`, `flex-objects` folders
from `user/plugins/` so GPM does a clean install.
2. `make build` (core → 2.0.4)
3. `make start`
4. Install the newly-listed plugins **and** update the already-installed ones
to their 2.0.4-compatible versions via GPM. Note: `gpm install` skips plugins
that are already present, so `login` (3.8.9 → ≥3.8.11, required by `api`) and
`form` need `gpm update`, not `install`. The exact `gpm update` + `gpm install`
sequencing (run inside the container via `docker exec`) is pinned in the plan.
5. Assert versions: admin2 2.0.9, api 1.0.6, flex-objects 1.4.3, login ≥ 3.8.11.
6. **Smoke test:** admin2 login; submit `/post` → entry appears in the active
trip's dailies; `/gpx-manager` list + upload + delete; a trip page and a
story render; maps load.
Prerequisite: the Phase 0 config changes (esp. `system.yaml`
`gpm.releases: stable`) are committed and pushed to Gitea, or GPM on the server
will still resolve the `testing` channel and pull RCs.
1. `make remote-git-sync-disable-test`
2. `make remote-fetch-content-test` — pull latest `user/` content to the test
server so `system.yaml` `gpm.releases: stable` is in place before any GPM
operation.
3. `make remote-upgrade-grav-test` (core self-upgrade → 2.0.4)
4. `make remote-update-plugins-test` (`gpm update -y` — all plugins incl.
admin2/api/flex/git-sync/login/form)
5. Clear cache on the server (`php bin/grav cache`).
6. Review `git status` in the server's `user/` for unexpected config diffs;
handle any deliberately (do not blind-commit).
7. Smoke test on the test URL (same checklist as Phase 1).
8. **Leave git-sync disabled and notify the user.** After the user validates,
re-enable as a separate deliberate step: `make remote-git-sync-enable-test`,
then a `content-push` round-trip to confirm sync still works.
### Phase 3 — prod (DOCUMENTED, NOT EXECUTED)
Prod is empty, so this is a fresh install, not an upgrade. Documented as a
runbook:
- Run the option-B-modified `server-install.sh` with `GRAV_VERSION=2.0.4`
(`make remote-install-prod`).
- admin2/api/flex-objects now install via GPM from `plugins.txt` — no manual
extraction.
- Set up git-sync manually afterward: install, add the encrypted token, apply
the folders-YAML array fix (`docs/working/git-sync-notes.md`).
## Rollback
`git revert` the branch (Dockerfile + plugins.txt + docker-compose +
server-install.sh + Makefile) and rebuild/redeploy. Content, config, and
accounts are already in git, so no data restore is needed.
## Risks
- **git-sync auto-commit during upgrade** — mitigated by disabling git-sync
before the test upgrade and reviewing `git status` before re-enabling.
- **admin2/api behavioral changes across RC→stable** — these back the `/post`
form and `/gpx-manager`; covered by the smoke tests, which are the
highest-weight validation in this effort.
- **GPM channel** — `gpm.releases` must be `stable` on the server *before* any
`gpm update`/`self-upgrade`, or GPM pulls RCs. Enforced by pushing the
`system.yaml` change and running `remote-fetch-content` first (Phase 2 step 2).
- **`bin/gpm self-upgrade` on shared hosting** — Grav 2.0.3 fixed self-upgrade
failures on shared-folder setups. On the native server this can still be
fragile; run `php bin/gpm preflight` first and use `-o/--overwrite` if a retry
is needed.
+3
View File
@@ -5,3 +5,6 @@ login
problems problems
add-page-by-form add-page-by-form
shortcode-gallery-plusplus shortcode-gallery-plusplus
api
admin2
flex-objects
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
set -e
FILE="$1"
STATE="$2"
: "${FILE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}"
: "${STATE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}"
if [ ! -f "$FILE" ]; then
echo "ERROR: $FILE not found — is git-sync installed on this server?" >&2
exit 1
fi
if grep -qE '^enabled:' "$FILE"; then
sed -i -E "s/^enabled:.*/enabled: ${STATE}/" "$FILE"
else
printf 'enabled: %s\n' "$STATE" | cat - "$FILE" > "$FILE.tmp" && mv "$FILE.tmp" "$FILE"
fi
echo "git-sync now: $(grep -E '^enabled:' "$FILE")"
-5
View File
@@ -22,8 +22,6 @@ cd "$WEBROOT"
wget --no-verbose "https://github.com/getgrav/grav/releases/download/${GRAV_VERSION}/grav-admin-v${GRAV_VERSION}.zip" -O grav-admin.zip wget --no-verbose "https://github.com/getgrav/grav/releases/download/${GRAV_VERSION}/grav-admin-v${GRAV_VERSION}.zip" -O grav-admin.zip
unzip -oq grav-admin.zip unzip -oq grav-admin.zip
cp -rf grav-admin/. . cp -rf grav-admin/. .
cp -rf grav-admin/user/plugins/admin2 /tmp/admin2-plugin
cp -rf grav-admin/user/plugins/api /tmp/api-plugin
rm -rf grav-admin grav-admin.zip rm -rf grav-admin grav-admin.zip
echo "==> Cloning user repo" echo "==> Cloning user repo"
@@ -40,9 +38,6 @@ fi
echo "==> Creating required directories" echo "==> Creating required directories"
mkdir -p user/plugins user/accounts user/data mkdir -p user/plugins user/accounts user/data
cp -rf /tmp/admin2-plugin user/plugins/admin2
cp -rf /tmp/api-plugin user/plugins/api
rm -rf /tmp/admin2-plugin /tmp/api-plugin
echo "==> Installing plugins" echo "==> Installing plugins"
php bin/gpm install $PLUGINS -y php bin/gpm install $PLUGINS -y
+1 -1
View File
@@ -26,7 +26,7 @@ grep -q "add_page:\|addpage:" "$FORM" && ok "Process action is 'add_page' (plugi
# Config must be in frontmatter, not in the process block # Config must be in frontmatter, not in the process block
check_grep "pageconfig block exists in frontmatter" "^pageconfig:" check_grep "pageconfig block exists in frontmatter" "^pageconfig:"
check_grep "parent set to /trips/japan-korea-2026/dailies" "parent: '/trips/japan-korea-2026/dailies'" check_grep "parent set to /trips/italy-2026-demo/dailies" "parent: '/trips/italy-2026-demo/dailies'"
check_grep "slug_field set (determines entry folder name)" "slug_field:" check_grep "slug_field set (determines entry folder name)" "slug_field:"
check_grep "pagefrontmatter block exists in frontmatter" "^pagefrontmatter:" check_grep "pagefrontmatter block exists in frontmatter" "^pagefrontmatter:"
check_grep "template: entry (creates entry.md filename)" "template: entry" check_grep "template: entry (creates entry.md filename)" "template: entry"
+5 -2
View File
@@ -7,7 +7,7 @@ set -euo pipefail
BASE_URL="${GRAV_BASE_URL:-http://localhost:8081}" BASE_URL="${GRAV_BASE_URL:-http://localhost:8081}"
USER="${GRAV_TEST_USER:-}" USER="${GRAV_TEST_USER:-}"
PASS="${GRAV_TEST_PASS:-}" PASS="${GRAV_TEST_PASS:-}"
TRACKER="user/pages/01.trips/japan-korea-2026/01.dailies" TRACKER="user/pages/01.trips/italy-2026-demo/01.dailies"
COOKIE_JAR="$(mktemp /tmp/grav-test-cookies.XXXXXX)" COOKIE_JAR="$(mktemp /tmp/grav-test-cookies.XXXXXX)"
PASS_COUNT=0 PASS_COUNT=0
FAIL_COUNT=0 FAIL_COUNT=0
@@ -49,7 +49,10 @@ LOGIN_NONCE=$(echo "$LOGIN_HTML" | grep -o 'name="login-form-nonce" value="[^"]*
LOGIN_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ LOGIN_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
-c "$COOKIE_JAR" -b "$COOKIE_JAR" \ -c "$COOKIE_JAR" -b "$COOKIE_JAR" \
-L \ -L \
-d "username=${USER}&password=${PASS}&login-form-nonce=${LOGIN_NONCE}&task=login.login" \ --data-urlencode "username=${USER}" \
--data-urlencode "password=${PASS}" \
--data-urlencode "login-form-nonce=${LOGIN_NONCE}" \
--data-urlencode "task=login.login" \
"$BASE_URL/login") "$BASE_URL/login")
# After login, fetch /post and verify we see the post form (not the login form) # After login, fetch /post and verify we see the post form (not the login form)
+8
View File
@@ -13,6 +13,14 @@ module.exports = async function globalSetup() {
}); });
} }
// Local test-account defaults (mirror the Makefile) so direct `npx playwright
// test` runs are self-contained without needing GRAV_TEST_* in .env.
if (!process.env.GRAV_TEST_USER) process.env.GRAV_TEST_USER = 'testrunner';
if (!process.env.GRAV_TEST_PASS) process.env.GRAV_TEST_PASS = 'Testpass1234';
// Ensure the local test account exists (idempotent; never committed).
execSync('make test-account', { cwd: path.join(__dirname, '..'), stdio: 'inherit' });
// Ensure demo content is loaded (italy-2026-demo trip + stories + GPX files) // Ensure demo content is loaded (italy-2026-demo trip + stories + GPX files)
execSync('make demo-load', { cwd: path.join(__dirname, '..'), stdio: 'inherit' }); execSync('make demo-load', { cwd: path.join(__dirname, '..'), stdio: 'inherit' });
}; };