plexus-cli (0.0.99)
Installation
registry=https://gitea.i.ivo-zilkenat.de/api/packages/ivo.zilkenat/npm/npm install plexus-cli@0.0.99"plexus-cli": "0.0.99"About this package
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/npminstead.
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 test # node --import tsx --test (also: `pnpm test:cli` from app/)
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:
- 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. no-explicit-anyeverywhere — the call surface is fully typed; the only unavoidable casts areas unknown as T(admin auth, the genericcall).- Offline validation against the real publish gate —
offer validateruns the samerunWorkflowValidators/runFormValidatorsthe 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} — remove / unset-group / delete are refused while a wip or published workflow's assignment rule names the person (replace them first: „Person ersetzen" in the workflow editor, then publish); --replace-with <configKey> hands their active applications over (required while they are responsible for any) and never rewrites a rule |
| platform | platform {tenants,users,workers} |
| stammdaten | stammdaten persons [-q] [--limit] [--all] |
| admission | admission laeufe · assignment simulate --dir <bundle> --context '<json>'|--context-file <f> [--creator <label>] (evaluate an offer bundle's assignment rule — graph.assignmentRules in its workflow.json, every slot, the matched row per slot — offline; recipe playbooks/offer/assignment-rule.md) |
| imports | imports {status,trigger,trigger-all} |
| feedback | feedback {list,to-issue,sync,set-status} |
| notifications | Operator's log (_platform.notifications.monitor): notifications {list,show,retry,sweeper-status,self-test}. School's wording (tenant-scoped, notifications._self.access / .mail.configure): notifications letters {list,show,set,reset} · notifications chrome {show,set} — see "The words a school sends". |
| 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} · offer form scenario {add,set,rm} (test-mode prefills; --prefill k=v typed by field) · offer form context set --keys … --sample <scenario>=<json> (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. English twins (--label-en, --placeholder-en, --help-text-en, …) are written only when given — the German is never copied into en. |
| 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); the folded governedDocument node (offer/contract lifecycle) is not CLI-authorable yet — author it in bundle JSON and gate it through offer validate / offer apply --publish. node set edits a node in place — --label, approval --approvers/--approval-mode, form --form rebind, --outcomes routing rewrite (--type is not editable → rm+add). node {set,clear}-notification binds/unbinds the LETTER a node sends at one of its declared events — see "Notifications on a workflow node". node {set,add,remove}-reads declares which form fields a decision/document node reads before acting — see "Readiness reads on a workflow node". channels {list,add,rm} authors the default chat channels every application of the version starts with (graph.channels; automatic per-team channels are listed read-only) — recipe playbooks/offer/workflow-default-channels.md. |
| 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 identity + the_platform.*row with a generated credential (printed once;--outputwrites theemail,passwordrow to a CSV). It takes no display name, so the account carries none. Since #225 no verb produces an identity that cannot log in.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--permissionvalues.
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/.
E-signature (signing command group)
plexus-cli signing {status,probe,list,start,show,void,resend,reconcile} — the
Signaturvorgang (DocuSign) over the public signing.* functions; gated on
finanzen_vertraege.contracts.manage. Recipe: playbooks/signing/README.md;
setup + runbook: docs/signing/{setup,operations}.md.
The words a school sends (notifications letters / chrome)
A letter is code: named blocks with the text Plexus wrote. A school may rewrite a block, and what is stored is only that deviation — every block it leaves alone keeps following the platform text, so a later improvement still reaches it.
plexus-cli --tenant test notifications letters list # catalog + who deviates
plexus-cli --tenant test notifications letters show applicant.tasksOpen # the letter's placeholders + each block's text
plexus-cli --tenant test notifications letters set applicant.tasksOpen \
--slot intro="bei {{schoolName}} warten {{taskCount}} Aufgaben auf Sie."
plexus-cli --tenant test notifications letters set applicant.tasksOpen --slot-file intro=./intro.txt
plexus-cli --tenant test notifications letters reset applicant.tasksOpen --slot intro
plexus-cli --tenant test notifications letters reset applicant.tasksOpen --all
plexus-cli --tenant test notifications chrome {show,set} # greeting / sign-off / sender name
Semantics worth knowing before using them:
setmerges across blocks; a block whose text equals the platform default is dropped rather than stored, so the command'sdeviating blocksline can be shorter than what you submitted. That is the backend's answer, not a CLI filter — the CLI never resolves a default, checks a placeholder, or merges a letter.resetneeds--allto drop a whole letter: naming nothing must not mean everything.- A letter with no blocks cannot be reworded. The two sign-in letters are
authCritical(the mail that carries a login link is nobody's to reword) and show— (not editable). - Placeholders are an allowlist per block. An undeclared
{{x}}throws with every finding named; nothing is written. - These are tenant-scoped (
--tenant) and gated onnotifications._self.access(read) /notifications.mail.configure(write) — not the platform_platform.notifications.monitorthat the delivery log uses. - The deviations are config-as-code (
notificationLetters, identitytemplateId:locale:scopeKey; the frame istenant.notificationChrome), soconfig export|set|applypromote them across environments.
Recipe: playbooks/notifications/reword-a-letter.md.
Model + invariants: docs/engineering/notifications.md.
Readiness reads on a workflow node
reads: [fieldKey] on a decision or document-producing governedDocument
node names the SmartForm fields a human reads before the node acts — for an
uploaded contract (LIS, Villa) the fields staff copy into it, since an upload
binds no field. The readiness gate holds the node's actions while one of them has
an open correction or is empty where the form requires it; once the document is
issued they are consumed (a correction is refused until it is rescinded).
plexus-cli offer workflow node set-reads --dir ./bundle --node n.contract \
--field kind.surname,kind.geburtsdatum --field derivedConstellation # replaces the list
plexus-cli offer workflow node add-reads --dir ./bundle --node n.contract --field kind.homeAddress
plexus-cli offer workflow node remove-reads --dir ./bundle --node n.contract --field kind.homeAddress
plexus-cli offer workflow node set-reads --dir ./bundle --node n.contract --clear
plexus-cli offer workflow node list --dir ./bundle # the `reads` column
Keys are checked offline against the forms the bundle's form nodes pin, by
the same [readiness/*] rules and the same field universe publish uses
(readinessUniverse): an unknown key, a field inside a repeated block, a
duplicate, or reads on a node the gate does not govern refuses the write. Only
errors the edit introduces block it; errors the bundle already carried are
printed as warnings. offer validate runs the same rules (it used to skip them
offline), and offer plan prints one ~reads:<node> row per node with the keys
added and removed. Recipe: playbooks/offer/declare-node-reads.md.
Notifications on a workflow node
A node type declares at which events it can write to a family and with which typed arguments; the authored node binds a letter to one of them. Neither half can invent the other — a school that says nothing on a rejection simply binds nothing.
plexus-cli offer workflow node set-notification --dir ./bundle \
--node n.freigabe --event rejected --template admission.rejected
plexus-cli offer workflow node set-notification --dir ./bundle \
--node n.contract --event issued --template admission.documentIssued
# --literal amount=12000 # a fixed value
# --disabled # authored, switched off
plexus-cli offer workflow node clear-notification --dir ./bundle --node n.freigabe --event rejected
plexus-cli offer workflow node list --dir ./bundle # the `letters` column
An argument binds itself when exactly one of the event's arguments (or an
ambient one such as tenantSlug) carries its name and a compatible type;
--literal exists for what the event cannot supply. A node's incoming carries
are not a source — no executor hands them to the send, so the gate does not
offer them. Like every other node mutator,
both commands run the real publish-gate validators before writing — so an event
the node type does not declare fails here. What offline validation cannot see is
the letter catalog (analysis/validators.ts must stay free of the
notification registry so the browser IDE and this CLI can import it), so an
unknown --template id and an unresolvable argument are caught server-side at
offer apply --publish. Run it.
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 [--key jahr-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|unlimited [--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. offer level remove is refused
([stage/hasActiveApplications]) while an active application sits on the level —
rename it instead; open leads on it only print a warning.
A level's identity is its key (offer level add --key, or derived from --name
when omitted — and it keeps that value after a rename). The bundle carries the same
key; see "Stage identity" below.
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?
Stage identity. program.stages[] entries are { key, name, gradeLevel? }; key
is required (offer validate). offer apply matches levels by key: a changed name
renames the same row, a new key adds a level, a removed key archives it (refused while
an active application sits on it), a re-listed archived key restores it, and the array
order is the display order. offer plan lists each change as ~stage / +stage /
-stage, and marks a -stage the apply would refuse (REFUSED: n active application(s)). Rules: docs/engineering/config-as-code/README.md → "Stage identity";
recipe: Rename a program stage.
Capacities. Each program.runs[] entry carries its cells as
capacities: { <stageKey>: { isOpen, capacity } } (capacity: whole number,
unlimited, or null = unconfigured). Export writes every cell; offer plan shows each
changed cell as ~cap <run>×<stage> from → to; offer apply writes only those (listed
cells are authoritative, unlisted ones untouched). Recipe:
Set an offer's seat capacities.
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). Drafts are written
only on a canonical-JSON diff (byte-equal blob = no-op), but the offer flip is
decided against the LIVE generation:
--publishreleases whenever the draft says something the live head does not, even when your bundle changed nothing.--stage internalstages the draft as the testable candidate instead of releasing it, andoffer statusprints the draft / internal / live revisions plus whether a release is pending. Recipe: Release an offer bundle. The imperative commands andoffer applygo through the same backend mutations — one write path, no duplication.
New schools: create a brand-new school + its
pedagogy_unitsbridge withoffer unit create --name "<School>"(or upgrade an existing unit withoffer unit ensure-pedagogy --unit "<Name>"); thenoffer 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] \
[--output <file>] [--quiet] [--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]
Assignment rules are not a tenant config target: the rule is part of each offer's
workflow (graph.assignmentRules in the bundle's workflow.json) and travels with
offer export / offer apply. See playbooks/offer/assignment-rule.md.
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: aggregatecreated / updated / deleted / unchangedcounts, plus:~ update <target> <key>— each config row the apply patches (admission custom fields / derived attributes included, dry run too); an idempotent row counts as unchanged, soupdatedis exactly the number of these rows.- 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.~ slug units/<key> <from> → <to>— an existing unit whose configslugmoves its URL. The old/e/units/<from>(and?unit=<from>) no longer reaches this unit — there is no redirect, and a unit created later may take<from>. A slug another unit holds fails the apply with[unit/slugTaken].
- A committing apply can return secrets. If the config carries
foundation.users, every listed user that has no login account yet gets one minted here, and its generated password is printed as a+ account <email> password: …row — once, because the server keeps only the hash. Soapply --confirmon such a tenant is not safe to run in a shared terminal or a pasted log unless you redirect them:--output <file>— append eachemail,passwordto that CSV (header written when the file is new/empty). Same flag, same format, same append semantics asusers provision.--quiet— keep the plaintext off stdout (the row then readspassword: (not echoed)); use it with--output, or the password is gone.- A dry run mints nothing: the flags are inert there, and
--outputneither creates nor touches the file. The plan instead shows! no account <email>per user who would get one.
--from blueprint— the repo template (onlytesttoday).--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.devJSON 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}], updates: [{target,key}], slugChanges: [{unitKey,from,to}], accounts: {created, minted: [{email,password}], pending: [email]}, destroyed? }.
Note that --json prints accounts.minted verbatim — --quiet governs the table only,
exactly as on users provision.
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.
files — the tenant file library (blank templates)
A school's Vorlagen (tenant_files) ride the config as files[]: key, label,
fileName, mimeType, and the content by hash (sha256, size) — never inline.
export also downloads each file to files/<key>/<fileName> beside config.json and
writes its path; on apply --from snapshot: the file on disk decides the hash. apply
uploads only the files whose hash the target does not hold (a dry run uploads nothing and
lists them), --from env: downloads them from the source env, and the test blueprint's
bytes are bundled server-side. A stored file absent from files is reported as an orphan
and removed only with --allow-destructive — then still refused with [file/inUse] while any
form version names it. Library writes accept only uploads staged for the library
(uploads.registerUpload, surface tenantFile); a fileName is one path segment. Direct verbs:
plexus-cli --tenant <slug> files list
plexus-cli --tenant <slug> files put <key> <path> [--label "…"] [--file-name …]
plexus-cli --tenant <slug> files remove <key> # alias: files rm
plexus-cli offer form field attach --dir <bundle> --key <field> --file-key <key> [--render download|inlineLink] [--label …]
plexus-cli offer form field detach --dir <bundle> --key <field> --file-key <key>
Recipe: playbooks/config/attach-a-template.md.
wipe
Without --confirm it is a dry run that prints per table what it would clear (and any block) and deletes nothing; --cascade also clears the dependent data target;
the heavy tier (the masterdata and contract-setup data targets, 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 devon a fresh DB runsconfig/bootstrap, which idempotently applies the template tenant(s) + demo. Develop against thetesttemplate; change it withconfig 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>}'.bootstrapis 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 |
| typescript7 | npm:typescript@^7.0.2 |