ivo.zilkenat
  • Joined on 2025-10-29

plexus-cli (0.0.52)

Published 2026-08-20 15:07:16 +00:00 by ivo.zilkenat

Installation

registry=https://gitea.i.ivo-zilkenat.de/api/packages/ivo.zilkenat/npm/
npm install plexus-cli@0.0.52
"plexus-cli": "0.0.52"

About this package

Unified Plexus admin + admission-offer config CLI (Convex backend).

plexus-cli — the unified Plexus admin + config CLI

One pure-TypeScript CLI for operating and configuring Plexus against any environment (local / staging / prod) — runtime ops, introspection, and admission-offer config-as-code from a single binary. The system command is plexus-cli.

Install (system tool)

Published to the Gitea npm registry; install it globally and run from anywhere:

# one-time: point the registry at Gitea (in ~/.npmrc), then install
npm config set registry https://gitea.i.ivo-zilkenat.de/api/packages/ivo.zilkenat/npm
npm i -g plexus-cli

plexus-cli --help              # all commands, grouped by category
plexus-cli <command> --help    # per-command help (clipanion auto-generates it)

Prefer to keep your default registry on public npm? Install with an explicit --registry https://gitea.i.ivo-zilkenat.de/api/packages/ivo.zilkenat/npm instead.

Or link the working tree as the global binary (dev machines)

Instead of the published package you can point the global plexus-cli at your local build — from the primary checkout only (~/dev/plexus):

cd ~/dev/plexus && pnpm --dir app/tools/plexus-cli link:global

This builds dist/cli.mjs and symlinks ~/.local/bin/plexus-cli at it. The symlink tracks the checkout, so a later pnpm build refreshes the binary. link:global refuses to run inside a worktree — a worktree's dist/ is deleted on teardown, which would leave a dead symlink (the recurring "No such file" breakage).

Develop in-repo

The CLI lives in app/tools/plexus-cli/. To run the working tree (not the published build) against a backend — this is also how you test CLI code changes against a specific worktree's backend (run it from inside that worktree; --env local follows your cwd), without touching the global binary:

cd app/tools/plexus-cli
pnpm install                          # one-time: tool deps (clipanion, esbuild, tsx…)
pnpm dev -- <command> [...flags]      # = node --import tsx cli.ts
pnpm build                            # → dist/cli.mjs (the published bundle)
pnpm link:global                      # (primary checkout only) build + symlink the global binary
pnpm typecheck                        # tsc against the live backend (build-from-truth gate)
pnpm spec                             # regenerate spec.snapshot.json (functions/describe)

How it's built — in-repo source, bundled to ship

The CLI imports the backend's real types, the generated api, and the publish-gate validators directly from app/convex/**. The publish workflow type-checks against that live tree and bundles it (esbuild) into a standalone artifact — so the benefits are baked in at build time:

  1. Typed api, zero drift — every call's args + returns are inferred end-to-end from the live function definitions; the published bundle is the real code, type-checked and versioned, never a hand-maintained re-derivation.
  2. no-explicit-any everywhere — the call surface is fully typed; the only unavoidable casts are as unknown as T (admin auth, the generic call).
  3. Offline validation against the real publish gateoffer validate runs the same runWorkflowValidators / runFormValidators the server runs, inlined into the bundle (works on any machine, no repo, no deployment).

The only caveats: the installed CLI is a point-in-time release (rebuild/republish to pick up backend changes), and --env local resolves the deployment from your current worktree (run it from inside a checkout) — the staging/prod token path works anywhere.

Auth & environments

Connection config resolves in this order: --env/--tenant flags → env-vars → ~/.config/agent-tools/plexus.yml.

# ~/.config/agent-tools/plexus.yml
plexus:
  default_env: local
  default_tenant: test
  environments:
    local:   {}   # DYNAMIC — follows your current rdu worktree (see below); no values needed
    staging: { convex_url: "https://staging.db.plexus.swop.schule", site_url: "https://staging.actions.db.plexus.swop.schule", token: "plx_…" }
    prod:    { convex_url: "https://db.plexus.swop.schule",         site_url: "https://actions.db.plexus.swop.schule",         token: "plx_…" }

Env-var fallback (no YAML): PLEXUS_ENV, PLEXUS_CONVEX_URL, PLEXUS_SITE_URL, PLEXUS_TOKEN, PLEXUS_TENANT.

Token path (faithful): a plx_… API token (mint one in the app under Settings → API-Token) is exchanged at {site_url}/cli/token for a short-lived RS256 JWT, cached at ~/.config/agent-tools/plexus-jwt-<env>.json. Every call lands as the real authenticated user through the public API + its gates.

Local path (--env local): zero config — the CLI follows whichever rdu worktree you run it from. It resolves that deployment's URL from the worktree's .env files and bootstraps a deterministic platform-admin token with the rdu admin key (apiTokens:devSeedToken, DEV_MODE-only), then takes the same token→JWT path. The cwd decides the target deployment, not where the binary was installed. Run plexus-cli --env local config show to see the resolved deployment + authMode (local-bootstrap). Full workflow + gotchas: docs/engineering/plexus-cli.md §"Local dev across rdu worktrees".

Commands

Group Commands
local config, functions, describe, offer validate
introspection functions [--kind] [--grep], describe <fn>, call <fn> [--kind] key=value…
identity whoami, tenants, units
tokens tokens list, tokens create <name>, tokens revoke <id>
users users {list,show,add,remove,set-group,unset-group,groups,set-platform-admin,set-platform-permission,create,provision,provision-platform-admin,provision-platform-user,set-password,delete,doctor,relink-auth}
platform platform {tenants,users,workers}
stammdaten stammdaten persons [-q] [--limit] [--all]
admission admission laeufe
imports imports {status,trigger,trigger-all}
feedback feedback {list,to-issue,sync,set-status}
offer offer {create,show,set,delete,restore,list,status} · offer run {add,set,archive,restore} · offer level {add,rename,move,remove,restore} · offer capacity set · offer unit {create,ensure-pedagogy} (make a school applyable: create unit + pedagogy bridge) · offer {export,validate,plan,diff,apply}
offer form offer form {list,show,add,set,rm} · offer form section {add,set,move,rm} · offer form field {add,set,move,rm} · offer form outcome {add,set,rm} · offer form effect {add,set,rm} · offer form repeated {add,set,rm} · offer form computed {add,set,rm} (offline bundle authoring; every mutator runs the real publish-gate validators before writing). field set covers all editable props (bucket/placeholder/help-text/enum/enum-ref/enum-list-key/multiple/required-for/docRef/consent + validation expressions; enum/enum-ref/enum-list-key are mutually exclusive — --enum-list-key binds a tenant custom_field_lists key, --multiple makes it multi-select); effect set is per-kind (bucket/identity/orgKind/from/to/relKind/target/map); section set --parent reparents; field/section move --before|--after|--to-top|--to-bottom reorders.
offer workflow offer workflow node {list,add,set,rm} · offer workflow show — author the workflow graph (splice nodes, keep progress layout/wiring in sync). node add --type form --form <slug> binds a SmartForm (--new-form scaffolds + binds); node add --type contractCreate/contractSign add the mockup contract pair (contractSign auto-binds the upstream contractCreate carry). node set edits a node in place — --label, approval --approvers/--approval-mode, form --form rebind, --outcomes routing rewrite (--type is not editable → rm+add).
offer manifest offer manifest set (applicant-mode/signature-level/window/capacity/selection) · offer role {add,set,rm,list} (roleInstances) · offer leistung {add,rm} (offline bundle authoring; enum values validated client-side fail-loud, since the manifest gate checks structure not enum values).

Every command supports --json (raw output; default is a table). Network commands take --env / --tenant. functions / describe / offer validate / config work offline (no deployment).

Provisioning admins

Two distinct shapes for a tenant-less platform operator:

  • users create <email> --platform-admin — mints the bare identity + the _platform.* row but no credential (the user can't log in until they self-sign-up / reset).
  • users provision-platform-admin <email> --name "…" — the credentialed, ready-to-log-in full-admin path (creates the Better Auth credential too and grants every _platform.* permission).
  • users provision-platform-user <email> --name "…" --permission _platform.schema.read — the credentialed limited-platform path. It creates the Better Auth credential and grants exactly the repeated --permission values.

Both platform provisioning commands need _platform.users.manage (your own platform-admin token, no Convex admin key) and refuse an email that already has tenant access (the _platform.* XOR tenant-access invariant). users provision does the same credentialed onboarding for a tenant user (--tenant <slug> --group tenant_admin).

Step-by-step recipes (exact commands + verification + gotchas): playbooks/users/add-platform-admin.md · playbooks/users/add-platform-user.md · playbooks/users/add-tenant-admin.md. For any common CLI task, see playbooks/.

Generic escape hatch

plexus-cli call parties:listForMainPage client=test q="Müller" --kind query

functions / describe read a committed snapshot (spec.snapshot.json). Regenerate it with the tool's in-repo spec script (run from app/tools/plexus-cli/) after adding/removing public functions:

pnpm spec        # = node --import tsx scripts/regen-spec.ts (convex function-spec → public filter → spec.snapshot.json)

The snapshot drives display only (call builds its reference directly), so staleness is cosmetic, not a correctness hazard.

Offer / admission CRUD

Model. An offer's identity = name + unit. The title is the bare program name ("Kosmetik"); the school/location is the pedagogic-unit link (the namespace). Runs (Schuljahre) and levels (Klassenstufen) are settings of the offer. A smartform and a workflow are optional later stages that make the offer applyable — a draft offer needs neither.

Lifecycle: create (name + unit) → add runs / levels / capacity → [smartform → workflow → applyable].

Imperative CRUD

plexus-cli offer create --slug kosmetik-bs --title "Kosmetik" --unit "Morgenstern Schulen Braunschweig"
plexus-cli offer show   --slug kosmetik-bs              # title, unit, levels, runs, capacity, state
plexus-cli offer set    --slug kosmetik-bs --title "Kosmetik" --unit "<unit>"
plexus-cli offer list   [--all]                         # --all includes archived (soft-deleted)

plexus-cli offer level  add    --slug kosmetik-bs --name "Jahr 1" --grade 1
plexus-cli offer level  rename --slug kosmetik-bs --level "Jahr 1" --name "Jahrgang 1"
plexus-cli offer level  remove|restore --slug kosmetik-bs --level "Jahrgang 1"

plexus-cli offer run    add    --slug kosmetik-bs --name "2026/27" --start 2026-08-01 --capacity 20
plexus-cli offer run    set|archive|restore --slug kosmetik-bs --run "2026/27"

plexus-cli offer capacity set  --slug kosmetik-bs --run "2026/27" --level "Jahrgang 1" --capacity 15 [--open|--close]

plexus-cli offer delete|restore --slug kosmetik-bs       # soft-delete (archive) / restore

Deletes are soft (archive) — restorable, never orphaning. offer delete, offer run archive, and offer level remove set an archive marker: the row stays and keeps resolving for anything that references it (offering cells, applications), but the item is hidden from active lists and is not applyable in the portal. restore clears the marker. There is no hard delete.

Declarative config-as-code (bundles, for promotion)

Offers are also versioned bundles on disk — use this for cross-env promotion or a reviewable whole-offer change:

offers/<tenant>/<offerSlug>/
  bundle.yaml      # index — module, tenant, slugs, label, links, program, pinned
  manifest.json    # == saveManifestDraft.data  (incl. progressLayout)
  smartform.json   # OPTIONAL — == saveSmartFormDraft.definition (absent for a draft offer)
  workflow.json    # OPTIONAL — { graph, progressWiring } (absent for a draft offer)
plexus-cli offer export --tenant test --slug grundschule-havelland --out offers/test/gymnasium
# edit bundle.yaml (slugs/label/unit/stages/runs) + the JSON blobs
plexus-cli offer validate --dir offers/test/gymnasium   # offline, real publish gate
plexus-cli offer plan     --dir offers/test/gymnasium   # remote↔local diff (smartform/workflow = ∅ when absent)
plexus-cli offer apply [--publish] --dir offers/test/gymnasium
plexus-cli offer status --tenant test --slug gymnasium-havelland   # applyable?

A workflow-less draft exports/applies a manifest-only bundle (no smartform.json / workflow.json). Apply order mirrors the seed (init/seed/admission/seedAdmissionPortal.ts): manifest → scaffold (pedagogy-unit link

  • program spine) → smartform (if present) → workflow (if present). Publish happens only on a canonical-JSON diff (byte-equal blob = no-op). The imperative commands and offer apply go through the same backend mutations — one write path, no duplication.

New schools: create a brand-new school + its pedagogy_units bridge with offer unit create --name "<School>" (or upgrade an existing unit with offer unit ensure-pedagogy --unit "<Name>"); then offer create --unit "<School>" links it (the link path also ensures the bridge). A unit must still belong to a tenant legal entity (auto-resolved from the tenant's first legal entity).

Tenant config-as-code (config command group)

The config group drives the config-as-code engine (a tenant's Stammdaten shape: custom fields, lists, display groups, roles + role-field bindings, foundation, and search config) against any environment. Tenant config is declarative + DB-authoritative — no reseeds to change live config. The model/architecture lives in docs/engineering/config-as-code/; the copy-paste task recipes live in playbooks/config/. This section is the command reference.

All commands take --env <local|staging|prod> and --json. They call the public, platform-admin-gated surface (convex/config/api.ts), so the token owner must hold _platform.config.read (read/dry-run) or _platform.config.manage (commit). The local rdu admin key does not satisfy the gate — use a platform-admin token even locally (see Auth).

plexus-cli config show                                          # resolved connection for the env (no network)
plexus-cli config targets --tenant <slug> [--json]              # the registry + live per-tenant counts
plexus-cli config apply   --tenant <slug>|--scope platform \
                          --from <blueprint|env:<src>|snapshot:<path>> \
                          [--confirm] [--allow-destructive] [--json]
plexus-cli config set     --tenant <slug> --target <id> --field <path> --value <v> [--confirm] [--json]
plexus-cli config export  --tenant <slug> [--out <path>]        # live → app/config/.dev/<slug>/config.json
plexus-cli config wipe    --tenant <slug> --target <id|all> [--cascade] [--confirm] [--allow-destructive] [--json]

Two granularities, one gate. A surgical single-field set and a whole-tenant apply are the same engine — same validators (tenantConfig/lib/validate.ts), same diff/patch primitive. set patches one field of one existing row; it cannot delete a row (use export → edit → apply for removals).

apply — the core verb

  • Dry-run unless --confirm. The dry-run output is the plan: aggregate created / updated / deleted / unchanged counts, plus:
    • - delete <target> <key> — each config row the reconciler prunes because it is absent from the source (search targets/filters/options, result icons, role-fields, target requirements, template placeholders). These need no --allow-destructive.
    • - orphan <target>/<table> <key> — rows in the five upsert-only tables (display-groups, lists, custom_fields, roles, permission_groups) the reconciler will not auto-delete; labelled would destroy and removed only with --confirm --allow-destructive.
  • --from blueprint — the repo template (only test today).
  • --from env:<src>cross-env promote: opens a second client for <src>, exports its live config, and applies it inline to --env (re-plans against the target's live state).
  • --from snapshot:<path> — apply a .dev JSON snapshot.
  • --scope platform — platform-scope config (a stub today; no platform targets yet).

JSON shape: { tenant, dryRun, counts: {created,updated,unchanged,deleted}, orphans: [{target,table,key,id}], deletions: [{target,key}], destroyed? }.

set — surgical edit

--target is a setField locator (display-groups, fields, lists, roles, units, permission-groups, legal-entities, search-filters, party-result-icons) — these differ from the config targets registry ids. --field is a dot-path (label.de). The whole resulting config is validated before the inline apply, so an invalid edit fails loud and writes nothing.

export — cross-env snapshot producer

Reconstructs a full TenantConfigDefinition from live rows and writes app/config/.dev/<tenant>/config.json (gitignored). The round-trip is exact — feeding the export back through apply --from snapshot: yields all-unchanged — so it is how a real tenant's config moves between environments. A committed one-time snapshot of each former real-tenant TS config also lives at app/convex/init/seed/tenantConfig/__fixtures__/<key>.config.json and is a valid --from snapshot: source.

wipe

Clear-only by default (--confirm required); --cascade also clears the dependent data target; the heavy tier (the masterdata data target, the foundation config target, or --target all) additionally needs --allow-destructive. The config↔data interlock blocks a config wipe with dependent live data unless --cascade. See playbooks/config/wipe-tenant-config.md.

Migrations are not a config verb

Renames / field-type changes — anything that changes the shape of existing rows — run through the standard migrations lane (convex/migrations.ts + migrations:runPending), deployment-globally, before bootstrap/apply. apply only moves config values into the shape that already exists. For a manual local run: npx convex run migrations:runPending.

Deploy protocol (local + steady state)

  • Local dev: pnpm dev on a fresh DB runs config/bootstrap, which idempotently applies the template tenant(s) + demo. Develop against the test template; change it with config apply --tenant test --env local --confirm (no wipe).
  • Staging / prod (every deploy): CI runs, after migrations:runPending, npx convex run config/bootstrap:bootstrap '{"demo":<true on staging|false on prod>}'. bootstrap is idempotent and non-destructive — it applies --scope platform + the template tenants (TEMPLATE_TENANT_KEYS = ["test"]) + (staging) demo. Real tenants are never touched by CI; establish/update them manually (see the playbooks).
  • Setup mode (platform-admin right-rail tab) is the in-app data-plane half of the onboarding loop: edit config (CLI) → load N sampled rows → test → wipe the subset → adjust → reload.

Auth

Config commands need a platform-admin JWT; point your ~/.config/agent-tools/plexus.yml <env> entry at it. Locally, mint one for a seeded platform admin (DEV_MODE):

npx convex run --url <convex-url> --admin-key <key> apiTokens:devSeedToken \
  '{"email":"admin@plexus.swop.schule","name":"config-cli","plaintext":"plx_..."}'

After adding new _platform.* perms, re-sync the admin's row (locally: pnpm seed; staging/prod: call init/seed/seedPlatformAdmins:seedPlatformAdminMut per admin). For automation that legitimately bypasses the user gate (CI, deploy bootstrap), call the internal engine functions with the admin key rather than the public config/api:*.

Architecture

core/      # GENERIC, brand-neutral, zero backend-type imports
  transport.ts  auth.ts  jwt-cache.ts  config.ts  output.ts  spec.ts  command.ts
plexus/    # BRANDED — imports backend types + validators
  branding.ts  client.ts  command.ts  commands/*  modules/admission.ts  offer/*
cli.ts     # clipanion bootstrap — registers every command group

See CLAUDE.md in this directory for the design rules (the thin-client / "where does a change belong" principle).

Dependencies

Development Dependencies

ID Version
@types/js-yaml ^4.0.9
@types/node ^22.10.0
clipanion 4.0.0-rc.4
convex ^1.38.0
esbuild 0.27.0
js-yaml ^4.2.0
tsx ^4.21.0
typescript ^6.0.3
Details
npm
2026-08-20 15:07:16 +00:00
0
1.4 MiB
Assets (1)
Versions (58) View all
0.0.58 2026-08-25
0.0.57 2026-08-25
0.0.56 2026-08-24
0.0.55 2026-08-21
0.0.54 2026-08-20