plexus-cli (0.0.291)

Published 2026-10-12 20:41:15 +00:00 by ivo.zilkenat

Installation

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

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 test                             # node --import tsx --test (also: `pnpm test:cli` from app/)
pnpm spec                             # regenerate spec.snapshot.json from the code (no backend)

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 gate — offer 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
catalog catalog {plan,apply} --file catalog.json · catalog export --out catalog.json — articles with the programs selling them (#1131), packages, need-based tiers and Word templates of a tenant, by stable key; a DOCX is replaced only when its SHA-256 differs; export → plan is same. Recipe playbooks/config/set-up-billing-catalog.md
applications applications {list,counts} --offer <slug> · applications show <id> · applications store <id> [--json] (the process store, every address resolved — playbooks/applications/inspect-process-store.md) · applications start-test --offer --run <key|id> --stage <key|id> (--applicant <email> | --party <id>) · applications discard-test <id> — recipe playbooks/applications/start-a-test-application.md
retention retention plan (read-only) · retention apply [--confirm --expect <n>] — a tenant's applications past their retention period, and their erasure by hand (dry run without --confirm; a count other than --expect erases nothing). Nothing erases on a schedule; recipe and the DSGVO duty: playbooks/retention/erase-due-applications.md
test-actions test-actions list --application <id> [--node] · test-actions run --application <id> --action <id> [--node] [--no-auto-submit] — the test-mode popover of a test application (scenarios, auto-fill-files, submit-form, …); recipe playbooks/test-actions/drive-a-test-application.md
migrations migrations status [--recent] — the deployed release's pending migrations; exit 1 while one is not success (recipe playbooks/worker-ops/verify-migrations.md)
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,set-email,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] [--deleted], stammdaten persons purge <partyId> [--confirm] (the only hard delete of a person, plan unless --confirm)
admission admission laeufe · assignment simulate --dir <bundle> --env-json '<json>'|--env-file <f> [--creator <label>] [--rotation-previous <slot>=<key>]… [--open <key>=<n>]… [--left <key>]… (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; add --from <id> derives a variant) · offer form env set --keys … --sample <scenario>=<json> (the $env keys the form reads, #1282) (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; section set --visible-to <key,…> restricts who on the school's side reads the section's values (#676, "" clears); field/section move --before|--after|--to-top|--to-bottom reorders. Every SmartForm text is ONE { de, en } field (#725): a German text needs its English in the same call (--label-en, --placeholder-en, --help-text-en, …) — the German is never copied into en.
offer workflow offer workflow node {list,add,set,rm,reads} · offer workflow show — author the workflow graph (splice nodes, keep progress layout/routing in sync). node add --type form --form <slug> binds a SmartForm (--new-form scaffolds + binds); the folded governedDocument node (offer/contract lifecycle) cannot be added by the CLI yet — author it in bundle JSON and gate it through offer validate / offer apply --publish; on an existing one `node set --approval release
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).

Writing commands name their environment. Every client command is classified in ONE list, plexus/writeVerbs.ts (read · write · write only with --confirm · call by its kind), and ONE guard (ClientCommand.caller) applies it: a writing run refuses without --env on the command line (a configured default or PLEXUS_ENV is not enough), and a production target needs --allow-prod — decided on the RESOLVED deployment URL, so any environment that points at the URL plexus.yml names for prod is production (plexus/writeTarget.ts, core/config.ts knownProdConvexUrls). The check runs before any network access; a plexus.yml without a prod entry makes writes refuse. Reads are unaffected (--allow-prod on a read is ignored, with a note on stderr). A --confirm command's dry-run counts as a read; a write verb's own --dry-run flag (feedback to-issue, imports trigger) does not — it still needs --env. writeVerbs.test.ts fails when a new command is not classified, so none ships unguarded; a command listed as a read that reaches a mutation or action at runtime throws, naming itself. --env local connects only to THIS machine — its admin-key bootstrap is a mutation even for a read, so the target's host must resolve to loopback or to an address of one of this machine's interfaces (rdu's Traefik host of a worktree does; a remote deployment does not, #875, #895). seed is outside the guard: it runs convex run against the current worktree's own deployment, never through plexus.yml.

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; --output writes the email,password row 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 --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/.

E-signature (signing command group)

plexus-cli signing {status,probe,list,start,show,void,resend,reconcile} — the Signaturvorgang (DocuSign or inSign) over the public signing.* functions; gated on finanzen_vertraege.contracts.manage. Recipe: playbooks/signing/README.md; setup + runbook: docs/signing/{docusign-setup,insign,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 --env <env> --tenant test notifications letters set  applicant.tasksOpen \
  --slot intro="bei {{schoolName}} warten {{taskCount}} Aufgaben auf Sie."
plexus-cli --env <env> --tenant test notifications letters set  applicant.tasksOpen --slot-file intro=./intro.txt
plexus-cli --env <env> --tenant test notifications letters reset applicant.tasksOpen --slot intro
plexus-cli --env <env> --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:

  • set merges across blocks; a block whose text equals the platform default is dropped rather than stored, so the command's deviating blocks line 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.
  • reset needs --all to drop a whole letter: naming nothing must not mean everything.
  • A letter with no blocks cannot be reworded. Today that is only the retired applicant.reentry, shown as — (not editable); the sign-in letters (category login) are rewordable, but their link must stand alone.
  • Placeholders are an allowlist per block. An undeclared {{x}} throws with every finding named; nothing is written.
  • These are tenant-scoped (--tenant) and gated on notifications._self.access (read) / notifications.mail.configure (write) — not the platform _platform.notifications.monitor that the delivery log uses.
  • The deviations are config-as-code (notificationLetters, identity templateId:locale:scopeKey; the frame is tenant.notificationChrome), so config export|set|apply promote them across environments.

Recipe: playbooks/notifications/reword-a-letter.md. Model + invariants: docs/engineering/notifications.md.

Addresses (what a node reads, and who produces it)

A node reads another node's data only through a full address in the field that reads it (#1281) — there are no data edges (wires) any more, no form implied by the node and no bare keys. Control edges (next) only route. The producer must run on every path before the reader.

Address Names
$node.<formNode>.admission[.<field>] the form instance a form node creates, or one field / export / party leaf of it
$node.<node>.document · .contract · .payment a document node's newest revision · a resolver contract · a payment
$task.<task>.<key> the value the ONE task <task> writes under store key <key>
$derived.<key> · $env.applicant.language a computed price · a fact about the application
plexus-cli offer workflow node reads --dir ./bundle --node contract [--json]   # what arrives: address, origin, config path, producer
plexus-cli offer workflow node set-input --dir ./bundle --node contract --input schulgeld --address '$derived.schulgeld'
plexus-cli offer workflow node set-reads --dir ./bundle --node contract --from form --field kind.vorname,kind.nachname
plexus-cli offer workflow node add --dir ./bundle --type setFlag ... --flag-key vertrag --target '$node.form.admission'
plexus-cli offer workflow node add --dir ./bundle --type form ... --adopt-form '$node.form.admission'

Address-taking flags: set-input --address (short form --from <formNode> --field <path>), *-reads / *-requires --address (same short form), node add --reads / --require / --person / --target / --adopt-form / --contract / --payment / engagePush --form, node set --target / --contract / --payment / --person / --form (on a resolver contract or engagePush), task --when / --reads / --documents (artifact:<own slot>, field:<address>, document:$node.<node>.document) / --family-request-fields / --family-request-from, and a record field's "suggest":{"kind":"form","address":"$node.…"}. Pricing-rule conditions and processStore[].derive.reduction have no verb: the bundle JSON carries $task.<task>.<key> identifiers ($task.geschwisterrang.geschwisterRangSchule >= 3). A malformed address or a bare key is refused with the list of addresses visible at the node (visibleAddressesAt, the rule the gate applies too); everything else is the publish gate, run before writing: [address/shape|bareIdentifier|unknown|unknownOwner|unknownKey|ownerNotWriter|taskOwnerTaken|notVisible|pathNotInTarget|typeMismatch|notFormInstance|mixedForms|envCohortBound|missing]. Only errors the edit introduces block it. node rm refuses while any address names the node (addressesNaming) and lists config path + address of each. A bundle exported before #1281 is refused ([graph/retiredField]: wires, a binding with path, adoptForm: true) — export it again. Recipe: playbooks/offer/addresses.md; model (visibility, $env vocabulary, the store behind $task): docs/engineering/workflow-engine.md.

Countersignature node („Unterschrift (intern)")

The school countersigns the contract the family signed: a countersign node after a checked resolver contract (production: resolver + responseStrategy: signedUploadChecked). Its one row „Vertrag gegenzeichnen" books the Träger on the revision its contract address names, sends the node's countersigned letter and routes countersigned.

plexus-cli offer workflow node add --dir ./bundle --type countersign \
  --id gegenzeichnung --label "Gegenzeichnung" --label-en "Countersignature" \
  --on-edge contract:signed \
  --assign-team schulverwaltung [--title "Vertrag gegenzeichnen" --title-en "Countersign the contract"] [--due-in-days 5] \
  --contract '$node.contract.contract' \
  --notification countersigned=admission.contractCountersigned

Publish rules: [countersign/assign], [countersign/sourceNotCheckedContract], [countersign/twice], [countersign/dueInDays], [address/missing] (no contract), [notification/missingBinding] (the letter is required). Recipe: playbooks/offer/countersign-node.md.

Readiness reads on a workflow node

reads: [Address] on a decision, review or document-producing governedDocument node names the SmartForm fields ($node.<formNode>.admission.<field>, one form per list) 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 contract \
  --from form --field kind.surname,kind.geburtsdatum,derivedConstellation   # replaces the list
plexus-cli offer workflow node add-reads    --dir ./bundle --node contract --address '$node.form.admission.kind.homeAddress'
plexus-cli offer workflow node remove-reads --dir ./bundle --node contract --address '$node.form.admission.kind.homeAddress'
plexus-cli offer workflow node set-reads    --dir ./bundle --node contract --clear
plexus-cli offer workflow node list --dir ./bundle      # the `reads` column

Addresses are checked offline against the form their owner node pins, by the same [address/*] / [readiness/*] rules and the same per-form-node field universe publish uses (readinessUniverse): a field the form lacks, a field inside a repeated block, a duplicate, two forms in one list, a form that does not run before the node, 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 addresses added and removed. Recipe: playbooks/offer/declare-node-reads.md.

Completeness check (documents owed before the process ends)

completenessCheck holds the process until the SmartForm fields it requires are there, by a deadline; entered with nothing missing it completes at once.

# --require: ≥ 1, repeatable · one of --deadline-days/--deadline-until ·
# --guest-role and --person: both or neither · the `requested` letter is required
plexus-cli offer workflow node add --dir ./bundle --type completenessCheck \
  --id nachreichen --label "Vollständigkeitsprüfung" --label-en "Completeness check" \
  --require '$node.form.admission.kind.masernschutz,$node.form.admission.kind.geburtsurkunde' \
  --deadline-days 14 \
  --guest-role gasthoerer --person '$node.form.admission.beneficiary' \
  --notification requested=admission.documentsRequested \
  --outcomes rejected:joinAbsage,absage:endAbsage
plexus-cli offer workflow node set-requires|add-requires|remove-requires --dir ./bundle --node nachreichen (--address '<address>,…' | --from form --field <path>,…)
plexus-cli offer workflow node set --dir ./bundle --id nachreichen [--deadline-days n | --deadline-until d] [--guest-role r --person p | --guest-role ""]

--notification <event>=<templateId> works on every node add (it writes the same binding set-notification does); it is needed in the same call whenever the type declares an event required, since node add validates before writing. Nothing is defaulted: no deadline, no person, no letter, no form — requires and the guest role's person name the form they read by address. The node's [completenessCheck/*] rules run offline (field keys against the carried form); the tenant role catalog (unknownRole, nonLeafRole) and the letter catalog only at offer apply --publish. *-requires edits behave like *-reads (only errors the edit introduces block it; removing the last address is refused). Recipe: playbooks/offer/completeness-check.md.

Tasks on a workflow node

node.tasks[] are the composed tasks a node spawns on enter (#567). task add|set|rm edits one of them by --node + --key; set changes only the flags it is given.

plexus-cli offer workflow node task add --dir ./bundle --node reviewEingereicht --key einschaetzung \
  --title "Einschätzung abgeben" [--title-en "…"] --team oeffentlichkeitsarbeit [--kind task|approval] \
  [--due-in-days 3] \
  [--blocks aufnahme:decision | --blocks <siblingKey>] \
  [--result freetext|recommendation|choice|record|pricedItem|reduction|appointment] [--option key=Label[|English]]… \
  [--writes '$task.<thisKey>.<storeKey>'] [--prices '$derived.<key>'] [--item <catalogKey>] \
  [--result-label "…" [--result-label-en "…"]] \
  [--text-required | --no-text-required] [--chat all|team:<group>|<channelKey>] \
  [--when '$node.<form>.admission.<booleanExport>' --on-condition-return respawn|stayClosed] \
  [--reads '$node.<form>.admission.a,…'] [--documents 'artifact:<slot>,field:<address>,document:<address>'] \
  [--family-request-fields '<address>,…' | --family-request-from '$node.<form>.admission'] \
  [--locks-evidence] [--description "…" [--description-en "…"]] \
  [--files | --no-files] [--optional "Keine Einschätzung abgeben" [--optional-en "No assessment"]]
plexus-cli offer workflow node task set --dir ./bundle --node <n> --key <k> [any flag above] \
  [--clear-blocks] [--clear-result] [--clear-when] [--clear-reads] [--clear-locks-evidence] [--clear-documents] [--clear-description] [--clear-family-request] [--clear-optional]
plexus-cli offer workflow node task rm  --dir ./bundle --node <n> --key <k>

--blocks <templateKey> is a sibling on the same node; <nodeId>:<templateKey> a template on a later node (stored as blocks.node only when it differs from --node). --option replaces its list. --locks-evidence (#689): once the task is decided, its evidence (--reads + the fields behind --when) takes no correction, flag or cohort move until its team reopens the task (applicationTasks.reopen). The CLI refuses malformed input in German (unknown result type, --option on a non-choice result, --text-required on free text, --chat external, …); the graph rules ([workItems/*], [taskResult/*]) are the publish validators, run before every write — only errors the edit introduces block it. rm is refused while another task's blocks (or an optional action) names the task, and lists those dependents. Output: a before/after field diff; --json → {action, node, key, before, after, changes, dropped}. Recipe: playbooks/offer/node-tasks.md.

--optional "<Knopf>" (#1213) makes a task OPTIONAL: nothing waits for it (publish refuses it together with blocks, gatesNode, locksEvidence, completableIf), its holder may close it without a result with that button, and a decision made while it is open makes it „hinfällig". --files lets a free text / recommendation / choice that writes no value (result.writes) take files — text and files become one message of the result's chat.

A result with a value (choice, pricedItem, reduction) writes it under the task's own address — --writes '$task.<thisKey>.<storeKey>' (stored as the bare result.writes; another task's address or a path is refused) — and names it with --result-label (--result-label-en; without it the German stands in). --item <catalogKey> (pricedItem) names the billing item it prices; --prices '$derived.<key>' (reduction) the derived price it lowers. A kind change drops what no longer fits and lists it under dropped.

node optional-action set|remove --dir <bundle> --node <decision> --key <k> authors one entry under „Weiteres" (a spawnTasks binding): --label [--label-en], --tasks a,b (keys of the node's tasks), --trigger <group> (who may press it), --repeatable, --description [--description-en], --clear-trigger, --clear-description. set creates it when new (--label + --tasks required); remove is refused while requiresOneOf names the key. Recipe: playbooks/offer/optional-assessment.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 freigabe --event rejected --template admission.rejected
plexus-cli offer workflow node set-notification --dir ./bundle \
  --node 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 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. The addresses a node reads 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"

Without --kind, call runs only a query: it looks the function up in the offline spec below and refuses a mutation or action by name (a write is always an explicit --kind mutation|action — the default environment may be prod). A function the spec does not know (newer than the snapshot, or a typo) needs --kind.

call also checks the arguments against the spec BEFORE the request: a missing required argument, an unknown one or a wrong type is named with the expected type (missing configKey (string)). A production deployment would answer them only with „Server Error" and a GlitchTip event (#820). A value the spec types as text keeps its text (q=2026 stays "2026"). A function the snapshot does not know (explicit --kind) is called unchecked, with a note on stderr.

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 changing a public function's arguments, return validator, kind, or adding/removing one. No backend needed; the cliSpecParity test fails until the committed file matches the code:

pnpm spec        # = tsx scripts/regen-spec.ts (imports app/convex/**, exportArgs/exportReturns of every public function → spec.snapshot.json)

The snapshot drives display and call's query check (call builds its reference directly). A stale snapshot makes call ask for --kind on a new function.

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 --env <env> offer create --slug kosmetik-bs --title "Kosmetik" --title-en "Cosmetics" --unit <unit key>
plexus-cli offer show   --slug kosmetik-bs              # title, unit, levels, runs, capacity, state
plexus-cli --env <env> offer set    --slug kosmetik-bs --title "Kosmetik" --title-en "Cosmetics" --unit <unit key>
plexus-cli offer list   [--all]                         # --all includes archived (soft-deleted)

plexus-cli --env <env> offer level  add    --slug kosmetik-bs --name "Jahr 1" --name-en "Year 1" --grade 1 [--key jahr-1]
plexus-cli --env <env> offer level  rename --slug kosmetik-bs --level jahr-1 --name "Jahrgang 1" --name-en "Year 1"
plexus-cli offer level  remove|restore --slug kosmetik-bs --level jahr-1

plexus-cli --env <env> offer run    add    --slug kosmetik-bs --name "Schuljahr 2026/27" --name-en "School year 2026/27" [--key 2026-27] --start 2026-08-01 --capacity 20
plexus-cli offer run    set|archive|restore --slug kosmetik-bs --run 2026-27

plexus-cli --env <env> offer capacity set  --slug kosmetik-bs --run 2026-27 --level jahr-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); a run's is its key too (offer run add --key), a unit's its config key. --level, --run and --unit take those keys, never a name. The bundle carries the same keys; see "Stage identity" below.

Names a reader sees are German AND English (#725). Every flag that sets one has an English twin (--title-en, --name-en, --label-en, --program-name-en); creating needs both. Changing only the English keeps the German. Changing only the German follows the one edit rule of every config text (editedLocalizedText): refused when the stored English is a real translation (it would describe the old German); an untranslated English (blank, or a copy of the German) becomes blank — the German is never copied into it. A name is written by a mutation that refuses a blank language, so for a name the CLI refuses that up front: a German rename always needs its English twin (--name-en, --title-en, --label-en).

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.

A real tenant's bundle is never committed (#482). Prod is its source of truth: export it from prod into a scratch dir OUTSIDE the repo, edit, plan, apply — recipe: edit-live-tenant-offer. Always pass --env (the default is prod) and --out (the default offers/<tenant>/<slug> is relative to the current directory). Staging gets a real tenant's bundle only by an explicit export --env prod → apply --env staging. Only the test tenant's offers are in git, and those come from the seed (app/config/templates/test/**), not from a bundle dir.

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 --env <env> 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: --publish releases whenever the draft says something the live head does not, even when your bundle changed nothing. --stage internal stages the draft as the testable candidate instead of releasing it, and offer status prints the draft / internal / live revisions plus whether a release is pending. Recipe: Release an offer bundle. 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>" --name-en "<English>" (or upgrade an existing unit with offer unit ensure-pedagogy --unit <unit key>); then offer create --unit <unit key> links it (a unit made by offer unit create has no config key yet — address it by its URL slug until config-as-code adopts 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 --env <env> config apply   --tenant <slug>|--scope platform \
                          --from <blueprint|env:<src>|snapshot:<path>> \
                          [--confirm] [--allow-destructive] \
                          [--output <file>] [--quiet] [--json]
plexus-cli --env <env> 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 --env <env> 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: aggregate created / updated / deleted / unchanged counts, 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, so updated is 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 config slug moves 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. So apply --confirm on such a tenant is not safe to run in a shared terminal or a pasted log unless you redirect them:
    • --output <file> — append each email,password to that CSV (header written when the file is new/empty). Same flag, same format, same append semantics as users provision.
    • --quiet — keep the plaintext off stdout (the row then reads password: (not echoed)); use it with --output, or the password is gone.
    • A dry run mints nothing: the flags are inert there, and --output neither creates nor touches the file. The plan instead shows ! no account <email> per user who would get one.
  • --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}], 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. Prod is the source of truth for every real tenant (#482): export from --env prod, never from a copy in the repo — no real tenant's config is committed (the former one-time snapshot fixtures are gone; a test fails the gate on any real tenant's file under app/config/). Staging gets it only by an explicit config apply --env staging --from env:prod (or a prod snapshot).

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, sizeBytes) — never inline. An export from before #1288 (fileName / size) is refused with [config/fileRefRenamed]: export again. 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 this tenant's library (a tenantFiles upload ticket, bound with uploads.registerUpload); a filename is one path segment. Direct verbs:

plexus-cli --tenant <slug> files list
plexus-cli --env <env> --tenant <slug> files put <key> <path> [--label "…" --label-en "…"] [--file-name …]   # new key: both languages
plexus-cli --env <env> --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 … --label-en …]
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. --target all is the teardown: it purges the applications, drains every target, then deletes the tenant row. Every wipe deletes the stored files only its rows held (the logo stays: sent mail links it). 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.3.2
tsx ^4.21.0
typescript ^6.0.3
typescript7 npm:typescript@^7.0.2
Details
npm
2026-10-12 20:41:15 +00:00
0
4.3 MiB
Assets (1)
Versions (320) View all
0.0.320 2026-10-22
0.0.319 2026-10-22
0.0.318 2026-10-22
0.0.317 2026-10-22
0.0.316 2026-10-21