Replace the bland version + four plain buttons with a composed layout: a current-
version hero (mono version, channel + source), tactile bump tiles showing the
current → target transition with a Beta-tag badge (continue-beta highlighted
accent), styled running/last-pushed states, and a vertical pipeline rail
(Gitea → GitHub-privat → CI → Staging). Tokens-only, responsive 375/768/1280,
German, no emoji; the R5 confirm flow + component contract are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- serve-request.test.sh: the push-fail case pointed origin at a dead https remote,
so _precheck's fetch failed first and the rollback path in _do_stage was never
reached (assertions passed trivially). Use a fetch-OK / push-rejecting remote
(pre-receive exit 1) so the commit+tag are created and _rollback genuinely runs;
also assert the tree is restored clean.
- clusev-release.sh: |\| true on the version extraction (defensive under pipefail).
- install.sh: make the release-unit install best-effort (if/then/warn) like the WG
block so a systemctl failure cannot abort the installer.
- .gitignore: ignore runtime run/ contents, keep the dir via run/.gitkeep.
- docker/release/README.md: dev-watcher runbook (the manual end-to-end gate).
- ReleaseBridgeTest: assert a rejected target writes no request file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shipped Release UI references release.confirm_title, confirm_body,
confirm_action and staged_label, but neither lang/de/release.php nor
lang/en/release.php defined them, so the R5 confirm modal and the staged
success banner leaked raw keys (R9/R17). Add the four keys to both locales.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Register the /release route only when config('clusev.release_controls') is
true — the first of the spec's three gating layers (route + component mount +
sidebar). The route file is re-included per request when uncached, so the flag
is honoured; mount() abort_unless(...) still covers the cached-route case.
The gating test re-evaluates routes/web.php after toggling the flag (the file
is read once at boot, so a runtime config()->set() alone cannot register the
route), mirroring a fresh request.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the dev-only Release page: a flag-gated route (config('clusev.release_controls'),
enforced by the component mount() guard) and a flag-gated sidebar item, plus the
Release Livewire component. Deploy-to-Staging is irreversible, so it goes through the
shared R5 wire-elements/modal confirm — confirmDeployStaging opens the modal and
applyDeployStaging consumes the sealed ConfirmToken and runs the deployStaging executor
(the host bridge writes the request, betaN is computed host-side). Registers the
releaseStaged confirm action in the ConfirmToken allow-list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First sub-project of Phase B: a dev-only Release page with a Deploy-to-Staging
button that cuts a beta (bump config version, commit, tag vX.Y.Z-betaN, push to
Gitea) via a host release bridge — same isolation as the WireGuard bridge, so the
container never holds git/token credentials. Gated behind CLUSEV_RELEASE_CONTROLS.
B2 (public promotion via GitHub API + vault token) is a separate later spec.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The earlier leak guard grepped the published tree for private URL strings, but
promote.sh / promote.test.sh contain those very strings (patterns + fixture), so
it tripped on its own source and would also ship them. Switch the guard to
internal-doc presence (CLAUDE.md/rules.md/handoff.md/kickoff-prompt.md/docs) — a
reliable pre-fix-tree signal that embeds no private identifier. Genericise the
dev VM IP in .env.example (APP_URL/REVERB_HOST) to localhost.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The actual file changes for the leak-safety hardening described in a94efb0
(that commit only captured the art/ asset move):
- promote.sh: leak guard refusing to publish a tree carrying a private identifier
(git.bave.dev / boban/clusev / clusev-staging) or an internal doc; negative test.
- .gitattributes: export-ignore kickoff-prompt.md (leaked the VM IP).
- README: reference the moved art/ SVGs (docs/ is export-ignored).
- .env.example: comment out CLUSEV_REPOSITORY so the public-repo config default
applies (a present-but-empty value would disable the update check).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ReleaseChecker: mix a short hash of clusev.repository into the tag + changelog
cache keys so a cached value can never be reused across a host/repo change (the
promotion pipeline re-points CLUSEV_REPOSITORY between the private mirror and the
public repo).
- Strengthen the GitHub User-Agent test to assert the value (Clusev-Panel), not
mere presence — Guzzle always sets some UA, so the old check was a tautology.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- promote.sh: add a leak guard that refuses to publish a tree still carrying a
private identifier (git.bave.dev / boban/clusev / clusev-staging) or an internal
doc (CLAUDE.md, rules.md, handoff.md, kickoff-prompt.md, docs/). This makes
promotion safe even for a tag cut BEFORE the export-ignore fixes — such a tree is
refused instead of leaking the dev/staging repos. Negative test added.
- .gitattributes: export-ignore kickoff-prompt.md (internal bootstrap doc; leaked
the VM IP + infra topology).
- README: move the banner/architecture SVGs out of the export-ignored docs/ into
art/ so the public README images are actually published.
- .env.example: comment out CLUSEV_REPOSITORY — a present-but-empty value defeats
the config default (env() returns ''), which would silently disable the public
update check. Left unset, the public-repo default applies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The release-pipeline + one-time-wiring section named the private staging runner
label and described internal setup — not public-facing. Move it to
docs/release-pipeline.md (export-ignore, never published) so the public README
stays clean and no private repo name leaks via the published tree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Default update source, README (badge/clone/home), the console + source-view
branding, and the CHANGELOG compare links now reference the public repo. Private
URLs remain only in the gitignored .env (install.sh derives CLUSEV_REPOSITORY
from the clone origin). Bump 0.9.58.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
promote.sh copies a release tree with git archive, which honours export-ignore.
Mark CLAUDE.md, rules.md, handoff.md and docs/ as export-ignore so internal dev
notes (and the Gitea URL they contain) never reach the public repo; drop the
stock CHANGELOG.md export-ignore so the public Versions page can fetch it. Extend
promote.test.sh to assert internal files are excluded and CHANGELOG.md is kept.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ReleaseChecker + the changelog fetch were hardwired to the Gitea API path, so a
GitHub-hosted public repo would never resolve a release. Centralise the repo-URL
parsing and branch by host: GitHub uses api.github.com for tags and
raw.githubusercontent.com for CHANGELOG.md (with the User-Agent GitHub requires);
Gitea keeps its /api/v1 paths. The changelog fetch moves into ReleaseChecker so
all host logic lives in one place.
Make the update-check tests hermetic: they now set clusev.repository themselves
(neutral host) instead of depending on the ambient .env — so they pass in CI with
an empty .env.example — and add GitHub-host coverage for tags + changelog.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- promote.sh: fail fast before mutating the public tree if the tag was already
promoted (avoids a half-commit left on the runner on re-dispatch).
- ci-staging.yml: pass STAGING_PROJECT_DIR via env + :? guard instead of a
${{ }} expression in run: (clears the template-injection lint, fails loudly
on misconfig).
- README: PUBLIC_REPO_URL was never read by any workflow — keep only the slug.
- set-repository-url.test.sh: chmod +x for mode parity with promote.test.sh.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Promotion workflows for the release pipeline. promote-public publishes a tag to
the public repo's beta channel; promote-stable re-tags a finalised beta as a
stable vX.Y.Z. Both bound to the 'production' environment (required reviewer),
reading PUBLIC_REPO_SLUG/PUBLIC_REPO_TOKEN. Tree copy via scripts/promote.sh
excludes .git/.github so private CI never reaches the public repo.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stable re-tags the same source commit as the last beta, so the extracted tree
is byte-identical and `git commit` would abort on "nothing to commit" under
set -euo pipefail, leaving the stable tag uncreated. Use --allow-empty and add
a promote.test.sh case that promotes a second identical-tree tag into the same
pub repo to guard the regression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restore the plan's if-grep / replace / else-append skeleton in the
CLUSEV_REPOSITORY write-back. The replace branch deletes existing
line(s) then appends once so duplicates collapse to a single entry,
keeping scripts/set-repository-url.test.sh green (the plan's verbatim
in-place sed leaves two identical lines and fails the duplicate-line
assertion).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capture the maintainer's release workflow: dev-dashboard buttons drive the
pipeline (Deploy to Staging / Deploy to Public / Promote to Stable). Beta =
vX.Y.Z-betaN on staging, Stable = vX.Y.Z on public; public serves both channels.
Public promotion triggered via the GitHub API with a deploy token in the
encrypted vault. Decomposed: Phase A = git/CI/CD + config-URL infra (buildable
now, URL-agnostic); Phase B = the dashboard release-control UI (own spec, after
A + the repos exist).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per the maintainer: dev/staging may hold any URL (in their gitignored .env);
the public repo's tracked files carry only the public URL. So config/clusev.php
defaults to the public URL, private (Gitea/GitHub-private) URLs live solely in
gitignored .env, and install.sh derives CLUSEV_REPOSITORY from the clone origin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Design for promoting Clusev dev → staging → public across three repos with
GitHub Actions CI, a manual gate before public, and a clean public history.
Core: no repository URL in tracked files — derived from the clone origin, kept
in gitignored .env per environment, so the public repo never exposes the
private/Gitea URLs and the URLs plug in later when the GitHub repos exist.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- A friendly HTML comment in the page source (both layouts, before <head>) —
ASCII "CLUSEV", what it is, and the project link — for anyone who View-Sources.
- A styled console banner printed on load (every page, incl. login): the CLUSEV
wordmark, a one-liner, the repo link, AND a self-XSS security warning. The
warning earns its place: a Linux-server control panel is a prime target for
"paste this into your console" social engineering, so the notice sits exactly
where that attack happens. console styling is best-effort and never throws.
Verified: /login 200, the console logs the brand + warning + link, the source
carries the comment, no page errors; 374 tests pass; Vite build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- File manager: on tablet widths the lg table grid squeezed the name column
(minmax(0,1fr)) to near-zero behind the perms/size/modified columns + wide
action buttons, so the filename was unreadable/overlapped. The rows now render
as a stacked card below xl — name on its own full-width line, a folded
perms·size·modified line and the actions beneath — and only switch to the
table grid at xl (tightened columns). Mobile tidied up to match.
- Restore the pointer cursor: Tailwind v4's preflight makes buttons use the
default cursor, so clickable controls (folder names, every button) didn't show
a hand. Added a base rule giving enabled button/[role=button]/summary/label
the pointer cursor.
Verified the row at desktop / tablet / mobile widths (name readable at every
breakpoint, table only when wide). 374 tests pass (the Livewire test renders the
blade), Tailwind build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening a binary / non-UTF-8 file in the file manager threw "undefined is not
valid JSON". The editor bound the raw file bytes to its public $content
property; Livewire JSON-encodes the component snapshot, and invalid UTF-8 makes
PHP's json_encode return false, so the client received an empty response and
JSON.parse("undefined") failed (the trace in mergeNewSnapshot/deepClone).
Fixed at both layers: FleetService::readFile blanks the content when it detects
binary/non-UTF-8 (returns the binary flag instead), and FileEditor::load only
binds content for non-binary, non-oversize files. Binary/oversize files show
their existing notice; the snapshot is always valid UTF-8.
Tests: load blanks binary content (keeps the snapshot valid) and keeps valid
text content. 374 pass, Pint clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lists rendered loop rows without a wire:key. On re-render Livewire's morph
matched rows by position and cloned them, dropping the inline Alpine x-data
(modalTrigger) scope on the buttons — the console threw "Alpine Expression
Error: pending is not defined" (and a follow-on "undefined is not valid JSON"
as the snapshot merge broke). Reported in the file manager; the same pattern
existed across the app.
Added a stable wire:key to every loop whose rows carry inline Alpine / x-modal-
trigger state: files (entries + breadcrumbs), services, the release-channel
selector, settings (users, webauthn keys, login-protection bans) and the server
detail page (hardening, firewall rules, SSH keys, fail2ban exceptions). Keys use
a stable identifier (id / fingerprint / md5 of the natural key), not the loop
index.
372 tests pass (the Livewire tests render these blades, so a broken key would
fail them); /system, /settings, /files load 200 with no console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The /audit page already rendered human-readable action labels (v0.9.48), but the
Dashboard's "recent events" panel still showed the raw codes (wg.set-endpoint,
deploy.update_request). It now uses the same AuditEvent::action_label + is_error
styling, so failures show red with an alert icon — consistent with the audit page.
Test: dashboard recent events render the readable label, not the raw code. 372
pass, Pint clean, dashboard loads 200 with no console errors and no raw codes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two fixes the operator asked for:
- The "what's new" preview was empty. availableChangelog fetched CHANGELOG.md at
ref="0.9.50", but release tags are "v0.9.50" — the raw fetch 404'd and the
panel silently stayed empty. Use the v-prefixed tag as the ref. The existing
test masked it with a wildcard fake; tightened with Http::assertSent.
- The sidebar now shows a "1" badge on the Version item when an update is
available, so an update is visible on every page, not only the Versions page.
Driven by a new ReleaseChecker service: updateAvailable() is a pure cache read
(no network in the sidebar); a scheduled clusev:check-update (every 30 min,
anonymous/read-only) keeps the cache warm so the badge is accurate even before
the operator opens the Versions page.
Refactor: the remote tag lookup (fetch + newest-version) moved out of the
Versions component into ReleaseChecker; the component delegates. nav-item gains
an optional badge prop.
Tests: ReleaseChecker (cache-read update-available, refresh caches, command warms
cache, sidebar badge renders only when available) + the v-prefixed-ref assertion.
371 pass, Pint clean, /versions loads 200 with no console errors; badge + the
"What's new in v0.9.50" panel verified in the browser. Lang parity kept.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The audit log loaded the latest 50 entries and filtered them in memory, so it
couldn't grow and search only spanned those 50. Now it is paginated (25/page)
with the page deep-linked in the URL via Livewire WithPagination (?page=), and
the search runs in SQL across the whole history — matching actor / action code /
target / server name AND the readable labels (a term is translated to the action
codes whose localized label contains it, so "Endpoint geändert" still finds
wg.set-endpoint). Pagination control mirrors the changelog's windowed pager.
Tests: 25/page with page 2 overflow, search resets to page 1, search filters in
SQL across all pages (a match on page 2 surfaces when filtered). 365 pass, Pint
clean, /audit loads 200 with no console errors, audit lang parity 62/62.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Versions page only rendered the INSTALLED CHANGELOG.md, so the operator
couldn't see what an available update contained until after applying it. Now,
when behind a published release, a "What's new in vX.Y.Z" panel shows the
changelog of the available version(s) — fetched from the remote CHANGELOG.md at
the latest tag (Gitea raw API, anonymous/read-only, briefly cached), parsed with
the shared changelog parser and filtered to versions newer than installed.
Degrades gracefully: a failed fetch yields an empty preview and never blocks the
update button. Refactored releases() to a reusable parseChangelog().
Tests: preview lists only newer versions + renders their entries; no preview
when current. 362 pass, Pint clean, /versions loads 200 with no console errors,
versions lang parity 51/51.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The audit log showed raw machine codes ("Administrator · wg.set-endpoint").
Now each entry renders a localized human-readable label ("WireGuard endpoint
changed"), via an AuditEvent::action_label accessor backed by an audit.actions
lang map (DE/EN); unmapped/dynamic codes (e.g. harden.*.on|off) fall back to a
tidied form, never a bare code. Search also matches the readable label.
Failures are now visible: AuditEvent::is_error flags failed/security events
(failed sign-in, IP ban, failed 2FA, wg.action-failed) which render in red with
an alert icon. And failed WireGuard dashboard actions are now written to the
audit log with the host error message (not just a transient toast), so the
operator can read later what went wrong.
Tests: label mapping + fallback, is_error, page renders label not code, search
by label, WG failure auditing. 360 pass, Pint clean, /audit loads 200 with no
console errors, audit lang parity 59/59.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The server config showed a "Port" and an "Endpoint" that repeated the same
:51820, so it looked like the port had to be entered twice. It never did (since
0.9.43 a bare host gets the listen port appended) — but the UI didn't say so.
Now the field is labelled "Public endpoint (host)", the placeholder shows only
the host (port stripped via Str::before), and the hint explains the port is
taken from the listen port automatically; a different port is only needed for
port-forwarding (host:port). Display/copy only — no behaviour change.
Pint clean, /wireguard server tab loads 200 with no console errors, lang
parity 89/89, 354 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>