An extension that needed to remember anything had to add a model to core AND an
Alembic migration for it — which is why extension tables like
ext_serverkit_minecraft_* sit in the panel's own migration history. That is a
steep tax on a small extension and it puts plugin schema in the panel's upgrade
path. A plugin now writes to a key/value store instead:
mine = store.for_plugin('serverkit-minecraft')
mine.set('world:1:seed', 8675309)
Values are JSON, so dicts and lists round-trip without hand-serialising, and
rows are namespaced by slug so two plugins cannot collide on a key. Reach for a
real table when you need to query BY the data — this is a key/value store, not
a database, and the docstring says so.
Because the table is shared by every installed plugin, three things are refused
rather than trusted: a value that isn't JSON-serializable, a value over 256 KB,
and an empty or overlong key. Validation happens at set() rather than at commit
— by commit time the error is opaque and the session is already poisoned, so
the next unrelated write fails too.
has() exists because get() cannot distinguish a stored None from a missing key,
and a plugin storing None deliberately would otherwise have no way to tell.
Concurrent writers race on insert; the unique constraint makes that safe and
the loser applies its write to the winner's row instead of failing the caller.
Store rows follow the same rule as extension-declared tables: an ordinary
uninstall leaves them, so a reinstall finds its state where it left it, and
"uninstall and delete data" removes them. The purge runs after the uninstall
lifecycle hook, since a hook may legitimately read its own store on the way out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suite pointed every run at one fixed file, %TEMP%/serverkit_test.db,
deleted once at conftest import — and the delete swallowed OSError. On Windows
that is not a rare edge: a finished pytest whose app threads outlive it still
holds the file, the delete fails, the error is discarded, and the next run
quietly inherits the previous run's rows. create_all then collides with the
already-seeded settings row, and tests error at fixture setup — in a different
place every run, never in the test that caused it. Two suites running at once
share one database and truncate each other's tables mid-test, which is the same
symptom from the other direction.
That is the "flaky tests" folklore in this repo, and it is why a full-suite run
reported four errors that all passed when re-run alone.
Each process now uses its own serverkit_test_<pid>.db and sweeps stale ones at
startup, so a lingering process can no longer poison the next run and two
suites can run side by side. Session finish disposes the engine and removes the
file; if a thread still holds it, the next run's sweep gets it.
Verified by running two suites concurrently — both green, where the shared file
previously corrupted whichever one started second.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The omnisearch fan-out was a hardcoded sequence of core entity blocks, so an
extension's objects were absent from the command palette — the one place the
UI tells you to look. A plugin now registers a provider and its rows appear
alongside services, servers and domains, with no frontend change: the palette
already renders unknown types, bucketing them under "Results".
Scoping stays the provider's job, because search invents no ACL — every core
block scopes its own query and the provider is handed the user and workspace
to do the same. The shape, though, is not trusted:
- rows are always tagged with the registered type, so a plugin cannot mint a
row that claims to be a 'service' or a 'vault' (which are trusted to have
been scoped by core);
- the per-type cap is re-applied host-side rather than trusted from the
provider, so one contributed type can't drown the core ones;
- a path must be an in-app router path. The palette hands it straight to
navigate(), so a protocol-relative '//host' would walk the user off the
panel entirely.
A malformed row is dropped on its own, a broken provider degrades to "no rows
of that type" — the same isolation each core block already has.
The lazy accessor in plugins_sdk now carries a table of surfaces rather than a
single special case for deploys, since the other SDK surfaces land the same way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Promoting between WordPress environments ran in the request thread: no
console, no log stream, no retry, and a progress bar the pipeline page had
to keep alive itself. It now runs as a registered deployment kind, so it
inherits the Deploy Console, live logs, retry and a row in Deploy Activity.
POST /promote returns 202 with a job_id instead of blocking, and the UI
opens the console with it.
PromotionJob is kept, not replaced. It carries pre_promotion_snapshot_id,
the rollback pointer, which is WordPress domain a generic DeploymentJob has
no business holding — so the deployment records which promotion rows it
produced instead. They are collected by id watermark rather than from the
service's return value, because promote_* only reports the job on success
and a failed promotion is exactly when someone needs the snapshot.
A full promotion runs two five-step halves and restarts its own numbering
at 1 for the second, so the progress bridge carries an offset across the
reset — otherwise the console's step strip walks backwards halfway through.
Two gaps this exposed in the SDK, both fixed here:
- Plugin kinds never closed their log stream, so they got no per-step
timings, no failure tail and no hint, and no terminal status push. The
handler and the runner also built separate streams, so the timings one
recorded were dropped by the other's close; they now share one per job.
- A handler that reported failure by return value logged nothing, leaving
a failed run with no reason visible in the console.
The console now titles a run from plan.title, so a contributed kind names
itself without a branch per kind in the frontend.
A locked environment is refused by the endpoint rather than by the job:
queueing first would send the user to a console that fails a second later.
The pipeline service still re-checks, since a lock can be taken in between.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DeploymentJobService.run_job dispatched on a hardcoded switch over three
core kinds, so a plugin could not own work that behaves like a
deployment. Its only option was the generic job system, which gives no
step plan, no streaming console and no place in Deploy Activity — the
reason promoting a WordPress environment, which is deploy-shaped in every
respect, is invisible in the one page you open to see what ran.
plugins_sdk.deploys lets a plugin register a kind and start one. The job
is a real DeploymentJob, so it inherits the Deploy Console, live logs,
retry and a row in the feed with no frontend work at all — those surfaces
key off the job, not off any particular kind, and the feed already
renders an unknown kind by its id.
Three things the SDK guarantees, each pinned by a test:
- Core kinds cannot be hijacked. A plugin registering app_deploy would
silently take over real deployments.
- A handler that raises still ends the job visibly failed. Third-party
code must not be able to leave a job spinning at 'running' with no
explanation — the same promise _reconcile_interrupted_jobs makes for a
lost process, extended to a lost handler.
- A handler that finalises the job itself keeps that outcome, even if its
return value contradicts it.
deploys.steps() advances the job through its plan as the handler works,
so the console's pipeline strip and the feed's tick bar fill in, and a
failure records which step failed rather than only a stack trace.
The implementation is deploys_sdk.py, not deploys.py: a submodule of that
name is bound as a package attribute on import and would shadow the lazy
accessor, handing callers the module instead of the SDK.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The single-container image shipped its own build toolchain and an optional
antivirus in every running panel. Measured on linux/amd64:
before 1.07 GB
multi-stage venv 553 MB
+ venv prune 501 MB (-53%)
Four changes:
1. Split a python-builder stage. gcc and the *-dev headers build wheels into
/opt/venv; the runtime stage copies the venv and leaves the toolchain behind.
The apt layer goes from 284 MB to 5 MB. No runtime -dev packages are needed:
the xmlsec, lxml and cryptography wheels are self-contained, verified by
importing all of them in the built image.
2. ClamAV behind ARG INSTALL_CLAMAV=false (153 MB). Safe because
malware_scan_service already treats clamscan as best-effort -- it reports
clamav_available and falls back to the builtin YARA matcher -- which is what
the old Dockerfile comment claimed but did not do. Build with
--build-arg INSTALL_CLAMAV=true to bundle it; that path is verified too.
3. COPY --chown instead of a trailing `chown -R /app`, which rewrote every
file's metadata and duplicated the whole tree into a 22 MB layer.
4. Prune the venv (~50 MB): third-party bundled test suites, and botocore's API
models for 400+ AWS services the panel never talks to. ServerKit constructs
exactly three clients -- s3, route53, ses -- plus sts for signing.
pip stays in the runtime image on purpose: PluginService installs an extension's
Python requirements with `sys.executable -m pip install -r`, so PATH points at
the venv and the venv is owned by the serverkit user.
The botocore prune fails open in dev and closed in the image -- a new
boto3.client('sns') would work locally and raise UnknownServiceError only in the
built image, when a user hits the feature. test_botocore_prune_contract.py
parses ARG BOTOCORE_KEEP and every call site and fails the suite on drift.
Verified in the built image: 14 optional deps import, all four boto3 clients
construct, sns is gone, no gcc, no __pycache__ or venv leaked into /app,
/api/v1/system/health and the SPA index both return 200. Suite: 2546 passed,
47 skipped. Idle RSS is unchanged at ~208 MB -- image size and resident memory
are separate problems.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`docker build .` at the repo root failed outright:
error from sender: open backend\.venv-wsl\lib64:
The file cannot be accessed by the system
With no .dockerignore the daemon walked the entire working tree -- ~750 MB of
node_modules (283), .venv-wsl (366) and .git (82) -- and died on that venv's
lib64 symlink, which Windows cannot stat. Only CI could build the image.
Exclude everything the Dockerfile does not COPY. Context drops to ~21 MB, the
build works from a clean checkout, and unrelated edits stop busting layer cache.
backend/tests is excluded too: release.yml builds and pushes the image, it never
runs pytest inside it, and nothing in backend/app imports from tests/ -- the only
references are source comments.
Deliberately no `!exception` patterns. A single negation makes Docker descend
into every excluded directory in case a nested file is re-included, which both
slows the walk and brings the unreadable .venv-wsl symlink back. That is why the
file lists directories rather than carving out keeps.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WordPressProjects hand-rolled a card grid, an inline <svg> WordPress
glyph, and a wp-project-card SCSS family (88 lines) that restated what
ResourceListPage already does — while missing everything that page has
gained since: search, status filters with counts, the table/cards switch,
and a shaped loading skeleton.
It now passes columns and a card renderer like Services does, so those
come for free and there is one list implementation to change instead of
two. The dead SCSS is deleted; wp-env-status and wp-site-icon stay, they
are still used by the environment cards.
Note this page is NOT merged into Deploy Activity, despite the similar
name: it answers "which projects have an environment pipeline", while
Deploy Activity answers "what ran, when, and did it work". Both are worth
having — what they should share is the run history, which is the next
commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shared sheet sets padding and a gap on the panel itself, but
.sk-drawer lays out three full-height regions inside it — head, body,
foot — each managing its own padding. The panel's padding therefore inset
all of them: the header's divider stopped short of both edges and the
footer floated above a band of dead space instead of sitting on the
bottom edge.
Zero the panel's padding and gap, and give scrolling to __body alone —
with the panel itself scrollable the footer could not stay put either.
Fixed at .sk-drawer rather than per page, so every drawer gets it: the
template deploy and filter drawers had the same gap.
Measured after the slide-in settles: 0px below the footer, and head/foot
now span the full panel width.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Create WordPress Site" was a centred modal while services and templates
deploy from a right-hand drawer, and its own styling was broken: an icon
stranded on its own line, an unspaced list running straight into the
first field, and a footer that floated over the form.
Two causes, both structural. It rendered legacy .modal-body and
.modal-footer *inside* the shared Modal, which already supplies a body
and takes a footer prop — the chrome was nested twice. And its
.install-warning callout is styled under `.wordpress-page`, while the
dialog renders in a portal, so it is not a descendant of the page that
opened it and none of those rules ever applied.
So the drawer styling moves out of _templates.scss into the design system
as .sk-formdrawer, shared by both pages. That is the fix for the class of
bug, not just this instance: a portalled surface cannot rely on
page-scoped SCSS, so its styles belong somewhere unscoped. The block also
now covers bare <select> and checkbox rows, which otherwise render with
the browser's own white chrome on a dark panel, and the callout.
Verified in a browser: drawer settles at the right edge at its declared
560px, callout renders with its background and spacing, selects pick up
the surface colour, and no .modal-body/.modal-footer/.install-warning
remain in the tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clicking a repo-kind template like agentsite navigated to
/services/new?template=<id> — a five-step wizard — while every other
template opened a drawer in place. Same catalog, same button, two
completely different experiences depending on a kind the card barely
advertises.
The wizard exists to work out what an unknown repo needs: detect
manifests, pick a build method, guess a port. A curated template already
declares all of that (repo.url, branch, app_type, build_method, port), so
for templates those steps had nothing left to ask. Repo templates now
deploy from the same drawer, submitting createAppFromRepository with the
template's own declarations, plus a Branch field — the one thing a
template cannot decide for you.
The wizard's duplicate "Deploy Template" source mode goes with it: the
gallery, its manifest fetch, the selection state and the submit branch.
It keeps its four real sources (GitHub, other Git remote, local, upload)
for repos that genuinely need detecting — including the quick-start cards
on /templates, which link into it.
Old /services/new?template=<id> links forward to /templates?install=<id>
rather than breaking, and ?install= now covers repo templates too.
Verified in a browser: wizard shows four sources and still advances to
Connect; the legacy link lands on the drawer with the branch field.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
json.dump emits no trailing newline, but every other writer of these same
tracked files — scripts/new-extension.mjs and
scripts/sync-builtin-frontends.mjs — appends one. The two disagreed, so
each plugin install and manifest regeneration rewrote
frontend/src/plugins/**/plugin.json and plugins-manifest.json with the
newline stripped.
The result was a working tree that went dirty on its own during a normal
backend boot, showing five modified files whose diff contained no actual
change. Easy to dismiss as line-ending noise; it is a writer disagreement,
and it would have kept coming back.
111 plugin/extension tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dev.sh started Vite on 127.0.0.1 — WSL's own loopback, not the one the
browser on Windows talks to. That only ever worked because WSL2 forwards
localhost, and when that forwarding silently stops the app becomes
unreachable while the backend, already bound to 0.0.0.0, keeps answering
on the WSL IP. Half the stack responding and half refusing, with no error
anywhere, is a miserable thing to debug.
Under WSL the frontend now binds all interfaces, so the WSL IP always
works regardless of forwarding, and the banner prints that URL instead of
a localhost one that may not resolve. Both loopback spellings stay in
CORS so localhost keeps working whenever forwarding is up, and non-WSL
setups still bind 127.0.0.1 exactly as before. SERVERKIT_FRONTEND_BIND
overrides the choice.
WSL networking is NAT'd behind the Windows host, so this does not put the
dev server on the LAN.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ResourceListPage returned its loading state before the wrapper the
loaded page renders into, and several pages early-return theirs the same
way. The skeleton therefore spanned edge to edge with no side or top
padding, and everything jumped inward when data arrived — precisely the
flash a skeleton exists to prevent.
ResourceListPage now wraps its loading branch in the same container as
its content. For the early-return pages, a shaped skeleton reasserts page
padding itself, dropped again when a real container is already supplying
it, so neither case doubles up.
Measured on /services with the request stalled: the skeleton now sits
25px in from both sides of the content area, matching the loaded table.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clicking a template card opened a detail modal while its Deploy button
opened the deploy drawer — two different surfaces for one object, and the
modal was rendering blank above its footer, so the visible result was a
popup cut in half.
The design prototype has no detail view at all: a card click deploys.
That is now what both the card and its button do, so a click anywhere on
a card has one predictable outcome. The modal is removed along with the
state, imports and RepoTemplateDetailBody that only it used;
handleViewTemplate stays, narrowed to the ?install=<id> deep link that
still needs the full template record.
Removed with it: the repo-template manifest preview (detected manifests
and env), which had no other entry point. Repo templates deploy through
the New Service wizard, which does its own repo inspection — say if you
want that preview back as a drawer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
StyleGuide demonstrates the raw elements and legacy classes the
no-restricted-syntax rules exist to discourage — showing a bare <button>
beside the shared <Button> is the whole point of the page. Un-suppressed
it contributed 165 of ~950 such warnings, one file masking the real drift
in every other file behind it.
Suppressing it drops the lint total from 1185 to 1019, so what remains is
drift worth acting on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pages had drifted into three unrelated interactions for the same job —
the shared FilterDrawer on three pages, SegControl on seven, and a raw
<select> on ten more, with several using two of the three at once. The
split was never written down, so each page picked by whoever wrote it.
FilterDrawer now documents the rule: a segmented control for <= 4
mutually-exclusive options in one dimension, this drawer for anything
wider or multi-dimensional, and a plain <select> only for genuinely
unbounded lists (servers, groups, versions). A raw select is never right
for a fixed set of states; a segmented control is never right for two
dimensions at once.
DeliveryLog was the clearest violation — two dimensions, one of them
seven channels wide, as bare selects — and moves to the drawer.
Most remaining raw selects are form fields or unbounded pickers, i.e.
correct as they stand; Telemetry (source/type/severity) is the one real
conversion still outstanding.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
210 run_privileged() call sites passed no timeout. With output captured
and no terminal attached, a command that never returns takes its caller
with it — silently and forever. That is exactly how the metadata guard's
iptables probe wedged backend startup; the sudo -n fix removed that one
trigger, but the class of bug was still one hung command away.
run_privileged now applies a default timeout when the caller does not
name one, so the fix lands on all 210 sites at once rather than being
reapplied by hand and forgotten on the 211th. TimeoutExpired is a far
better outcome than a wedged worker, and callers that genuinely need
longer already pass their own; `timeout=None` opts out deliberately.
The four direct read-only probes in this module (dpkg -s, rpm -q,
systemctl is-active/is-enabled) bypass that helper, so they get their own
much tighter ceiling — they answer immediately or not at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three places had reimplemented chrome the design system already owns.
Servers' agent-setup wizard and Templates' detail view built their own
.modal-overlay div. Both are now the shared Modal (Radix dialog), which
brings focus trapping, escape handling and scroll locking that every
other dialog in the app already had — an accessibility gap, not just a
visual one. A .sk-modal__subtitle style covers the lead paragraph the
Servers wizard shows under its title.
Workspaces hand-copied ResourceListPage's .wp-list toolbar, bulk bar and
table markup. It had already silently missed the list/cards switch added
with the Services card view, and every future change to the shared list
chrome would have had to be made twice. It now passes columns to
ResourceListPage like Services does, and delegates its loading state
too — so it picks up the table skeleton for free.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The capture tool shipped with two targets and had evidently never been
run past them — every one of them failed for reasons the tool could not
report.
Three bugs, in the order they bit:
- login filled the first `input[type="text"]` on the page, which is a 2FA
digit box rendered above the real form. The credential form stayed
empty, so every target then timed out waiting for content on a page
that was still /login. Target #email/#password, and fail loudly rather
than silently proceeding unauthenticated.
- The API client clears both tokens from localStorage whenever any
request 401s, and one admin-only endpoint doing so was enough to bounce
every later target back to /login. The captured tokens are now re-seeded
before app code runs on each navigation, with a re-auth-and-retry as
backstop.
- A bare "waitForSelector timeout" hid all of the above. Failures now say
which cause applies: bounced to /login, root selector absent, or the
page rendered its empty state.
Targets grew to services and domains alongside ssl and wordpress-list.
/templates is deliberately excluded: its catalog measures ~975 bones over
5700px — a skeleton nobody scrolls, at a node count costing more to
animate than the page costs to load. A MAX_BONES cap now rejects that
class of capture outright, pointing at the PageSkeleton archetypes, and
the target list documents that a page only qualifies once the dev
database reliably has rows for it — bones measured from an empty page
would predict that nothing is coming.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every one of the ~40 loaders went through EmptyState's single hardcoded
shape — an avatar, three cards, three lines — whether the page was a
table, a console, a form or a chart. A skeleton exists to predict the
layout about to arrive so that arrival is a fill-in rather than a jump;
one shape reused everywhere mispredicts on all but the page it was drawn
for, which is worse than honest, because it promises a layout that never
comes.
PageSkeleton provides ten layout archetypes — panel, table, feed, cards,
detail, console, form, chart, tree, split — driven by SCSS so widths stay
responsive instead of being guessed in pixels. Repeated lines use a
deterministic ragged width, so they read as text and don't reshuffle each
render the way Math.random() would.
EmptyState takes `loadingVariant` (defaulting to the old `panel`, so
nothing regresses) and 41 call sites now name their real shape.
ResourceListPage picks table or cards from its own view state.
All ten are on the style guide under Feedback & Loading, and captured as
one screenshot so a regression in any of them is visible in one shot.
Captured bones (npm run capture:skeletons) remain the pixel-exact option
for individual regions; these archetypes are the broad-coverage answer
for the pages that will never be worth capturing one by one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recaptured against the ported design, plus new shots for the templates
catalog, its filter and deploy drawers, and the Services card view.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three inconsistencies the section top bars had drifted into.
Tab groups repeated the section name as a heading beside their tabs. The
title block is gone when no title is passed (what TabGroupLayout, Git,
Email and Terminal now do) and the tabs lead the bar from the left edge
— the active tab already names the section, as does the lit sidebar
item. Entity pages (ServiceDetail, WordPressDetail, ProjectDetail,
CloudflareZoneSettings) keep their title on purpose: there it says
*which* entity you are on, which nothing else in the bar says.
Search position was per-page: Templates, Marketplace and Jobs all put it
before their action buttons. Rather than reorder call sites and hope
future pages comply, the top bar pins it:
.sk-topbar__actions > .sk-searchfield { order: 1 }
The bar also gains its own surface, so it reads as chrome rather than as
the first row of the content.
The shared FilterDrawer grew optional per-option counts and a result
count on its confirm button ("Show 45 templates"). Its active-filter
subtitle can now be driven by the host's own count, which fixes a drawer
reading "1 active" when nothing was filtered — the button excluded a
default sort from its badge while the drawer recounted and included it.
Templates drops its quick category chips: filtering lived in two
competing surfaces, and the drawer is the one that scales.
Finally, the AI bubble is hidden while a modal is open. It is fixed at
bottom-right above Radix's dialog layer, so it floated over every drawer
in the app and covered the primary action in drawer footers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings the Services tab group in line with the design prototype.
Deploy Activity is a feed rather than a table: runs group into Queued /
Today / Earlier, each row showing what was deployed (commit, image, or
template), why (push, webhook, manual), a per-step pipeline bar, target,
duration, and the error inline on failures. Step states are derived
client-side from current_step/total_steps/status, so the feed stays one
request. Status filters carry live counts via the shared SegControl.
Deploy Console trades the vertical step rail for a horizontal pipeline
strip above the log, each segment's bar sized to its share of the run —
so where the time went is visible at a glance, and the log pane gets the
full page width. A mono run-identity line sits between them.
Templates leads with the two routes no template covers (import a repo,
drop a ZIP), gains a Featured / A-Z sort, and deploys through a drawer
that shows the subdomain the service will be published at and a server
picker listing each target's CPU and memory — placing a service on a
second box is now a visible choice rather than a hidden default. That
needs capacity in the payload, so get_available_servers() now reports
cpu_cores/total_memory/total_disk (remote from the Server row, local via
a psutil probe).
Services gains an opt-in card view; ResourceListPage grew renderCard /
viewStorageKey props, so table-only pages are untouched.
Not ported: the mock's Resources card (CPU/memory limits, auto-restart,
backups). installTemplate does not carry limits through to
Application.cpu_limit/memory_limit, so the card would have claimed
limits the deploy never applies.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
privileged_cmd prepended a bare `sudo`. Nothing here runs attached to a
terminal and run_privileged captures output, so on a non-root host a
password prompt is both invisible and unanswerable — sudo blocks forever
and takes its caller with it. This hung backend startup outright: the
metadata guard's `iptables -C DOCKER-USER` probe sat unanswered and the
server never bound its port.
Emit `sudo -n` so the call fails immediately with a non-zero exit, which
every caller already handles. Production runs as root (_needs_sudo() is
false) which is why this stayed hidden, but any non-root install without
passwordless sudo would hang on boot.
An explicitly-sudo command passed in by a caller is still forwarded
untouched; the regression test pins non-interactive sudo across all four
invocation shapes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dev.ps1 called bare wsl.exe, so it ran in whatever distro happened to be
the default. On a machine with more than one, a distro without a Linux
node silently falls through WSL's PATH interop to the Windows node shim
under /mnt/c, which fails with messages that read like a WSL problem
("WSL 1 is not supported", "Could not determine Node.js install
directory") rather than "node isn't installed here".
Pick the first distro that resolves node to a real Linux path instead,
overridable with SERVERKIT_WSL_DISTRO, and name the chosen distro in the
launch banner. Single-distro machines are unaffected (no -d is passed).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/deployments still reserved a second grid column for the detail pane plan 51
removed — the table sat at half width with its Started column clipped by
overflow:hidden. Single full-width panel now; pruned ~150 lines of dead
detail-pane SCSS; status badge + progress colors move from inline styles to
modifier classes; kinds humanized; skeleton loading rows; removed the topbar
New Service button duplicating the adjacent tab. Console: fills the viewport
(calc-based min-height), wider step rail (248px), flexible log search, denser
toolbar. Adds deploy-console screenshots (4 sections, mock-data captures).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/deployments gains a topbar 'Test deployment' action (shown only when the
backend demo gate is on) with a scenario-picker modal; the console titles
demo_deploy jobs 'Test deployment — <scenario>' and shows a dashed Simulated
pill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dev-gated (DEMO_DEPLOYS_ENABLED) scripted DeploymentJobs — success/fail-build/
long/ansi/slow scenarios driven through RunLogStream so the Deploy Console is
fully exercisable locally with no Docker or server. GET/POST
/api/v1/deployment-jobs/simulate; retry + unified-job wiring; 12 proving tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop docs/screenshots/themes/ from the panel repo; THEMING.md and the
README roadmap now link to the registry's gallery instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One dashboard capture per theme (mock-data rig, capture-themes.mjs) under
docs/screenshots/themes/ with a grouped gallery README, linked from
THEMING.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- ThemeContext: fetch pre-auth panel default (/public/active) and follow it
until a user picks a skin; ThemeSync bridge loads installed themes after auth
- Theme Gallery admin actions: set panel default, remove installed themes
- Browse modal: install community themes from the registry (offline-tolerant)
- Import theme.json (admin, paste/upload)
- Theme Studio: grouped color editor over the LIVE panel, start-from any
installed theme, Export theme.json, Save-to-panel, and Submit = a prefilled
GitHub new-file PR against serverkit-themes
- docs/THEMING.md (token reference + rules + submit flow); README docs table +
ROADMAP "Recently Completed"
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review feedback + follow-up on the review-stamps feature:
- The 409 trust gate now distinguishes reason 'unreviewed' (community
code not vetted) from 'unverified' (no pinned checksum, possible even
for first_party), each with an accurate message; the risk dialog
carries the reason and matches its title/body to the actual case
instead of always claiming 'unreviewed community extension'
- Unreviewed registry entries are now developer-stage content: hidden
from the Marketplace catalog and refused at the install endpoint
(404) unless the panel runs in a development context (Flask debug /
testing config, or the dev_mode setting). Hidden means not
installable; the 409 acknowledgment flow only exists where they list
- dev_mode setting description mentions the Marketplace effect
- tests: reason assertions on both 409 cases; catalog visibility and
404-on-hidden-install coverage (38 passing)