Commit Graph
1438 Commits
Author SHA1 Message Date
Juan DenisandClaude Opus 5 caa511064d feat(plugins): give extensions a place to keep their own state
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>
2026-07-25 13:51:11 -04:00
Juan DenisandClaude Opus 5 4a5c90df62 fix(tests): give each pytest process its own database
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>
2026-07-25 13:50:53 -04:00
Juan DenisandClaude Opus 5 2a0b4ca029 feat(plugins): let extensions put their objects in global search
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>
2026-07-25 13:40:00 -04:00
Juan DenisandClaude Opus 5 3c84d152ea feat(wordpress): run environment promotions as watchable deployments
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>
2026-07-25 13:29:12 -04:00
Juan DenisandClaude Opus 5 6be60249f0 feat(plugins): let plugins own deployment-shaped work
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>
2026-07-25 12:46:59 -04:00
Juan DenisandClaude Opus 5 9feaaed278 build(docker): halve the panel image, 1.07 GB -> 501 MB
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>
2026-07-25 12:38:56 -04:00
Juan DenisandClaude Opus 5 0f440b9d98 build(docker): add a .dockerignore so the image can build at all
`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>
2026-07-25 12:38:39 -04:00
Juan DenisandClaude Opus 5 5e2986cf12 refactor(wordpress): put the pipelines list on the shared list page
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>
2026-07-25 12:35:05 -04:00
Juan DenisandClaude Opus 5 374404e43d fix(ui): let drawer footers reach the bottom edge
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>
2026-07-25 12:12:59 -04:00
Juan DenisandClaude Opus 5 5b26e427a1 fix(wordpress): create a site from the same drawer as everything else
"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>
2026-07-25 12:07:06 -04:00
Juan DenisandClaude Opus 5 29c926ef95 feat(templates): deploy repo templates from the drawer too
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>
2026-07-25 11:57:57 -04:00
Juan DenisandClaude Opus 5 dddf0906e7 fix(plugins): write manifests with a trailing newline
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>
2026-07-25 11:48:15 -04:00
Juan DenisandClaude Opus 5 1c700776f0 fix(dev): bind the dev frontend so Windows can reach it from WSL
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>
2026-07-25 11:39:02 -04:00
Juan DenisandClaude Opus 5 6508c15ddb docs: screenshot the template card-click deploy drawer
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 11:35:05 -04:00
Juan DenisandClaude Opus 5 2cfc1796e7 fix(ui): keep loading skeletons inside the page's padded container
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>
2026-07-25 11:35:04 -04:00
Juan DenisandClaude Opus 5 8f45cf4aa4 fix(templates): one outcome per card, and drop the broken detail modal
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>
2026-07-25 11:35:04 -04:00
Juan DenisandClaude Opus 5 40cef81b3c docs: capture the loading-skeleton archetypes and refresh shots
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 05:41:15 -04:00
Juan DenisandClaude Opus 5 c1de60e5c1 chore(lint): scope the style guide's design-system exception
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>
2026-07-25 05:41:15 -04:00
Juan DenisandClaude Opus 5 4b271e3886 refactor(ui): state the filter-control rule, and apply it to DeliveryLog
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>
2026-07-25 05:41:15 -04:00
Juan DenisandClaude Opus 5 54a9b2d391 fix(system): put a ceiling on every privileged subprocess call
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>
2026-07-25 05:41:00 -04:00
Juan DenisandClaude Opus 5 5cd411b20a refactor(ui): move the last hand-rolled list and modals onto the shared parts
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>
2026-07-25 05:11:42 -04:00
Juan DenisandClaude Opus 5 a8e9f605e4 fix(skeletons): make bone capture reliable, and cap what it will bake
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>
2026-07-25 05:05:39 -04:00
Juan DenisandClaude Opus 5 5b1403dfaa feat(ui): shape loading skeletons to the page that is loading
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>
2026-07-25 04:46:16 -04:00
Juan DenisandClaude Opus 5 1254dda17f docs: refresh Services-group screenshots + changelog
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>
2026-07-25 04:40:35 -04:00
Juan DenisandClaude Opus 5 1ae447f0fd refactor(ui): make top-bar chrome consistent across sections
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>
2026-07-25 04:40:28 -04:00
Juan DenisandClaude Opus 5 0d768ca377 feat(services): port the deploy/services/templates design
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>
2026-07-25 04:40:10 -04:00
Juan DenisandClaude Opus 5 3fb4242736 fix(system): make generated sudo invocations non-interactive
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>
2026-07-25 04:39:55 -04:00
Juan DenisandClaude Opus 5 da8efcebf0 fix(dev): pin the WSL distro dev.ps1 launches into
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>
2026-07-25 04:39:45 -04:00
Juan DenisandClaude Fable 5 6db987f49b docs(changelog): test deployments dev tool + deployment activity layout fixes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:15:48 -04:00
Juan DenisandClaude Fable 5 f2cf89563a fix(deployments): full-width jobs table + console polish (plan 51.5 UI pass)
/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>
2026-07-23 17:11:55 -04:00
Juan DenisandClaude Fable 5 429eb7911a feat(deployments): dev-only Test deployment trigger + simulated console tag
/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>
2026-07-23 17:01:55 -04:00
Juan DenisandClaude Fable 5 3cc708a1e2 feat(deployments): simulated demo deploys through the real console pipeline
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>
2026-07-23 16:58:46 -04:00
github-actions[bot] 2febb54ecd chore: bump version to 1.7.59 [skip ci] 2026-07-23 16:20:43 +00:00
Juan DenisandClaude Fable 5 1783fb004f docs(readme): roadmap tracks use linked bold titles + read-more links
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:19:24 -04:00
Juan Denis 90f28fd160 Merge branch 'dev' of https://github.com/jhd3197/ServerKit into dev 2026-07-23 12:17:22 -04:00
Juan DenisandClaude Fable 5 31d13c6370 docs: theme screenshots live only in the serverkit-themes registry
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>
2026-07-23 12:16:40 -04:00
Juan DenisandClaude Fable 5 66d57af129 docs(readme): add Themes to the Roadmap development tracks
Links the in-repo theme gallery and the serverkit-themes catalog beside
the extensions catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:15:20 -04:00
Juan DenisandClaude Fable 5 24ee204294 docs(themes): add live screenshots of all 19 themes + gallery page
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>
2026-07-23 10:42:14 -04:00
github-actions[bot] af50e2098d chore: bump version to 1.7.58 [skip ci] 2026-07-23 14:32:36 +00:00
Juan Denis 09fa65cdb8 Merge branch 'dev' of https://github.com/jhd3197/ServerKit into dev 2026-07-23 10:32:12 -04:00
Juan DenisandClaude Fable 5 7d4543312c feat(themes): add 11 bundled Venezuelan seed themes
Turpial, Araguaney, Orquídea, Apamate, Ceiba, Azulejo, Guacamaya, Cacao,
Papelón, Onoto (dual dark+light) and Tepuy (radius override) — named after
Venezuelan national symbols and nature. Authored in
frontend/src/data/themes/, synced to backend via sync-bundled-themes.mjs
(17 bundled themes total). check-theme-tokens + backend theme suites pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 10:22:23 -04:00
github-actions[bot] ab836482a3 chore: bump version to 1.7.57 [skip ci] 2026-07-23 13:30:13 +00:00
Juan Denis ad96423966 feat(themes): hardening — preview validation, registry caching, token wiring (plan 61)
- Validate preview swatches as colors server-side (closes url() smuggling
  via POST /import); import tolerates unknown metadata (license, image, modes)
- theme_registry_service: negative caching on fetch failure (TTL-stamped
  last-good + 60s failure backoff — no more 15s gallery stalls offline);
  drop dead base_url cache key; refresh stale comment
- .env.example: document SERVERKIT_THEMES_REGISTRY_URL/_TTL
- SCSS: route $success/$warning/$danger/$info through --green/--amber/
  --red/--cyan (raw twins for color-function call sites), $font-main/$font-mono
  through --sans/--mono, scrollbar track through --scrollbar-track
- Fix 5 undefined CSS var consumers (--radius-md/--card-bg/--danger/
  --accent-subtle/--accent-soft -> existing tokens)
- Nits: drop dead ThemeStudioTrigger export, dedupe lucide import,
  run check-theme-tokens in pre-commit hook
- docs/plans/61_THEMES_HARDENING_PLAN.md

23 backend theme tests pass; check-theme-tokens, lint (0 errors), build green
2026-07-23 09:25:01 -04:00
Juan Denis 8180ff4474 docs(mcp): recommend Faro Agent Bridge + faro-cli as primary server access 2026-07-23 08:55:57 -04:00
Juan DenisandClaude Opus 4.8 e09a6c2948 feat(themes): registry browse, import, panel default, and Theme Studio (plan 60 Phase 2-4)
- 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>
2026-07-23 00:24:10 -04:00
Juan DenisandClaude Opus 4.8 c20a9f14f2 feat(themes): backend model, API, and registry pipeline (plan 60 Phase 2-3)
Themes are data, not code — so the backend is a simple validated-JSON store,
no zips/checksums/permissions.

- Theme model + migration 078_themes
- theme_tokens.py: server-side mirror of the token whitelist + validators
  (the gate: a malicious/malformed theme never reaches the DB)
- theme_service: bundled-seed loading, import/list/delete, panel default,
  pre-auth public/active
- /api/v1/themes: /installed, /import (admin), DELETE (admin), /default
  get+set (admin), unauthenticated /public/active for login/setup, plus
  /registry + /registry/<slug>/install
- theme_registry_service: offline-tolerant registry fetch (TTL + last-good +
  bundled fallback), mirrors the extension registry pattern
- bundled seed themes synced to app/data/themes + themes_index.json
- 18 proving tests (server-side validation sanitizes url()/;/} smuggling,
  admin gate, panel-default → public/active, offline fallback, install flow)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:24:00 -04:00
Juan DenisandClaude Opus 4.8 0016b1ff22 feat(themes): theme gallery + runtime skins (plan 60 Phase 1)
A ServerKit theme is data, not code: a validated map of CSS custom-property
tokens. This lands the client-side foundation.

- themeTokens.js: canonical token whitelist, legacy-alias table, and strict
  value validators (reject url()/@/;/}/expression, type-check per group)
- applySkin.js: runtime skin applier via setProperty (injection-safe); dark/
  light resolution with stock-light fallback for dark-only themes
- ThemeContext: per-user skin selection (localStorage), live hover preview,
  and accent precedence workspace > user > skin > default
- 6 bundled seed themes (default/paper/nord-deep/gruvbox/phosphor/high-contrast)
- Theme Gallery in Settings > Appearance (instant apply, 4-swatch cards) +
  command-palette index entry
- hardcoded-hex audit: 3 exact token-value leaks fixed to var(--token)
- check-theme-tokens.mjs proving guard wired into the lint chain

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:55:19 -04:00
github-actions[bot] 74421c254e chore: bump version to 1.7.56 [skip ci] 2026-07-23 03:26:21 +00:00
Juan Denis fbadc7d7df Merge branch 'dev' of https://github.com/jhd3197/ServerKit into dev 2026-07-22 23:26:02 -04:00
Juan Denis e8d84297f2 Marketplace trust: split 409 reasons, hide unreviewed outside dev mode
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)
2026-07-22 23:18:33 -04:00