@smartkomm/plexus-cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +826 -2
- package/dist/cli.mjs +483 -0
- package/dist/skill/.managed-by-plexus-cli +1 -0
- package/dist/skill/SKILL.md +185 -0
- package/dist/skill/references/accounts/email-changes.md +46 -0
- package/dist/skill/references/applications/inspect-process-store.md +61 -0
- package/dist/skill/references/applications/start-a-test-application.md +66 -0
- package/dist/skill/references/audit/answer-access-request.md +115 -0
- package/dist/skill/references/config/attach-a-template.md +117 -0
- package/dist/skill/references/config/init-tenant.md +121 -0
- package/dist/skill/references/config/onboard-tenant.md +71 -0
- package/dist/skill/references/config/rename-program-stage.md +99 -0
- package/dist/skill/references/config/set-tenant-branding.md +150 -0
- package/dist/skill/references/config/set-tenant-terms.md +50 -0
- package/dist/skill/references/config/set-up-billing-catalog.md +73 -0
- package/dist/skill/references/config/update-tenant-config.md +170 -0
- package/dist/skill/references/config/wipe-tenant-config.md +107 -0
- package/dist/skill/references/export/create-named-export.md +47 -0
- package/dist/skill/references/feedback/create-and-triage-feedback.md +69 -0
- package/dist/skill/references/feedback/manage-feedback-tags.md +57 -0
- package/dist/skill/references/finance/change-contract-grant.md +36 -0
- package/dist/skill/references/finance/record-submission-date.md +33 -0
- package/dist/skill/references/interessenten/list-interessenten.md +54 -0
- package/dist/skill/references/notifications/debug-mail-delivery.md +77 -0
- package/dist/skill/references/notifications/mail-level.md +90 -0
- package/dist/skill/references/notifications/reword-a-letter.md +155 -0
- package/dist/skill/references/notifications/trace-a-login.md +50 -0
- package/dist/skill/references/offer/add-workflow-node.md +268 -0
- package/dist/skill/references/offer/addresses.md +129 -0
- package/dist/skill/references/offer/appointment-task.md +79 -0
- package/dist/skill/references/offer/assignment-rule.md +195 -0
- package/dist/skill/references/offer/attach-form-to-workflow.md +79 -0
- package/dist/skill/references/offer/author-form-scenarios.md +97 -0
- package/dist/skill/references/offer/author-manifest-meta.md +94 -0
- package/dist/skill/references/offer/author-smartform.md +102 -0
- package/dist/skill/references/offer/bind-node-input.md +76 -0
- package/dist/skill/references/offer/completeness-check.md +137 -0
- package/dist/skill/references/offer/countersign-node.md +91 -0
- package/dist/skill/references/offer/create-pedagogy-unit.md +55 -0
- package/dist/skill/references/offer/declare-node-reads.md +99 -0
- package/dist/skill/references/offer/edit-live-tenant-offer.md +144 -0
- package/dist/skill/references/offer/edit-smartform.md +121 -0
- package/dist/skill/references/offer/edit-workflow-node.md +126 -0
- package/dist/skill/references/offer/engage-push-node.md +147 -0
- package/dist/skill/references/offer/insert-and-rewire-node.md +67 -0
- package/dist/skill/references/offer/node-tasks.md +162 -0
- package/dist/skill/references/offer/optional-assessment.md +65 -0
- package/dist/skill/references/offer/publish-offer-bundle.md +117 -0
- package/dist/skill/references/offer/set-offer-capacities.md +78 -0
- package/dist/skill/references/offer/staff-input-if-node.md +112 -0
- package/dist/skill/references/offer/task-appointment-result.md +69 -0
- package/dist/skill/references/offer/task-family-request.md +69 -0
- package/dist/skill/references/offer/task-result-files.md +52 -0
- package/dist/skill/references/offer/template-document-node.md +113 -0
- package/dist/skill/references/offer/workflow-default-channels.md +69 -0
- package/dist/skill/references/resolver/manage-contract-resolver.md +159 -0
- package/dist/skill/references/retention/erase-due-applications.md +99 -0
- package/dist/skill/references/retention/erase-due-parties.md +107 -0
- package/dist/skill/references/roles/migrate-parent-assignments.md +31 -0
- package/dist/skill/references/signing/README.md +113 -0
- package/dist/skill/references/signing/connect-a-school.md +90 -0
- package/dist/skill/references/signing/rotate-the-key.md +41 -0
- package/dist/skill/references/stammdaten/purge-deleted-person.md +68 -0
- package/dist/skill/references/stripe/connect-a-school.md +107 -0
- package/dist/skill/references/stripe/request-and-verify-a-payment.md +78 -0
- package/dist/skill/references/test-actions/drive-a-test-application.md +59 -0
- package/dist/skill/references/users/add-platform-admin.md +59 -0
- package/dist/skill/references/users/add-platform-user.md +59 -0
- package/dist/skill/references/users/add-tenant-admin.md +75 -0
- package/dist/skill/references/users/change-login-email.md +55 -0
- package/dist/skill/references/users/cli-access-for-tenant-admin.md +117 -0
- package/dist/skill/references/users/delete-permission-group.md +76 -0
- package/dist/skill/references/users/grant-cross-tenant-access.md +57 -0
- package/dist/skill/references/users/grant-feedback-assignable.md +43 -0
- package/dist/skill/references/users/set-password.md +57 -0
- package/dist/skill/references/users/use-test-account.md +79 -0
- package/dist/skill/references/worker-ops/debug-worker-jobs.md +90 -0
- package/dist/skill/references/worker-ops/verify-migrations.md +51 -0
- package/dist/spec.snapshot.json +145815 -0
- package/package.json +53 -4
package/README.md
CHANGED
|
@@ -1,3 +1,827 @@
|
|
|
1
|
-
#
|
|
1
|
+
# plexus-cli — the unified Plexus admin + config CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One **pure-TypeScript** CLI for operating and configuring Plexus against any
|
|
4
|
+
environment (local / staging / prod) — runtime ops, introspection, and
|
|
5
|
+
admission-offer config-as-code from a single binary. The system command is
|
|
6
|
+
`plexus-cli`.
|
|
7
|
+
|
|
8
|
+
## Install (system tool)
|
|
9
|
+
|
|
10
|
+
Published publicly on npmjs.org as `@smartkomm/plexus-cli` (Node.js 22 or newer);
|
|
11
|
+
install it globally and run from anywhere:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm i -g @smartkomm/plexus-cli
|
|
15
|
+
|
|
16
|
+
plexus-cli --help # all commands, grouped by category
|
|
17
|
+
plexus-cli <command> --help # per-command help (clipanion auto-generates it)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
> The command is `plexus-cli`; only the package name is scoped (`plexus-cli` on
|
|
21
|
+
> npm belongs to someone else). The package holds no credential — every call needs
|
|
22
|
+
> your own `plx_…` token.
|
|
23
|
+
|
|
24
|
+
### The agent skill
|
|
25
|
+
|
|
26
|
+
On every start the installed CLI keeps an agent skill current for Claude Code
|
|
27
|
+
(`~/.claude/skills/plexus-cli/`) and Codex (`~/.codex/skills/plexus-cli/`) — whichever
|
|
28
|
+
of the two exists: `SKILL.md` plus every playbook as `references/`, each marked with the
|
|
29
|
+
right it needs (tenant token or platform operator). A directory of that name the CLI did
|
|
30
|
+
not write is left alone (one warning); remove it to get the bundled skill. A failed write
|
|
31
|
+
warns once and never fails the command. The installed directory belongs to the CLI: an
|
|
32
|
+
edit inside it is replaced by the next release. `PLEXUS_CLI_NO_SKILL=1` switches the
|
|
33
|
+
installation off (a development build, a CI machine).
|
|
34
|
+
|
|
35
|
+
### Or link the working tree as the global binary (dev machines)
|
|
36
|
+
|
|
37
|
+
Instead of the published package you can point the global `plexus-cli` at your local
|
|
38
|
+
build — **from the primary checkout only** (never a worktree):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
cd <primary checkout> && pnpm --dir app/tools/plexus-cli link:global
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
This builds `dist/cli.mjs` and symlinks `~/.local/bin/plexus-cli` at it. The symlink
|
|
45
|
+
tracks the checkout, so a later `pnpm build` refreshes the binary. `link:global`
|
|
46
|
+
**refuses to run inside a worktree** — a worktree's `dist/` is deleted on teardown,
|
|
47
|
+
which would leave a dead symlink (the recurring "No such file" breakage).
|
|
48
|
+
|
|
49
|
+
## Develop in-repo
|
|
50
|
+
|
|
51
|
+
The CLI lives in `app/tools/plexus-cli/`. To run the working tree (not the published
|
|
52
|
+
build) against a backend — this is also how you test **CLI code changes against a
|
|
53
|
+
specific worktree's backend** (run it from inside that worktree; `--env local` follows
|
|
54
|
+
your cwd), without touching the global binary:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
cd app/tools/plexus-cli
|
|
58
|
+
pnpm install # one-time: tool deps (clipanion, esbuild, tsx…)
|
|
59
|
+
pnpm dev -- <command> [...flags] # = node --import tsx cli.ts
|
|
60
|
+
pnpm build # → dist/cli.mjs (the published bundle)
|
|
61
|
+
pnpm link:global # (primary checkout only) build + symlink the global binary
|
|
62
|
+
pnpm typecheck # tsc against the live backend (build-from-truth gate)
|
|
63
|
+
pnpm test # node --import tsx --test (also: `pnpm test:cli` from app/)
|
|
64
|
+
pnpm spec # regenerate spec.snapshot.json from the code (no backend)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## How it's built — in-repo source, bundled to ship
|
|
68
|
+
|
|
69
|
+
The CLI imports the backend's **real types, the generated `api`, and the publish-gate
|
|
70
|
+
validators directly** from `app/convex/**`. The publish workflow type-checks against
|
|
71
|
+
that live tree and **bundles it** (esbuild) into a standalone artifact — so the
|
|
72
|
+
benefits are baked in at build time:
|
|
73
|
+
|
|
74
|
+
1. **Typed `api`, zero drift** — every call's args + returns are inferred end-to-end
|
|
75
|
+
from the live function definitions; the published bundle is the real code,
|
|
76
|
+
type-checked and *versioned*, never a hand-maintained re-derivation.
|
|
77
|
+
2. **`no-explicit-any` everywhere** — the call surface is fully typed; the only
|
|
78
|
+
unavoidable casts are `as unknown as T` (admin auth, the generic `call`).
|
|
79
|
+
3. **Offline validation against the *real* publish gate** — `offer validate` runs the
|
|
80
|
+
same `runWorkflowValidators` / `runFormValidators` the server runs, inlined into
|
|
81
|
+
the bundle (works on any machine, no repo, no deployment).
|
|
82
|
+
|
|
83
|
+
The only caveats: the installed CLI is a point-in-time release (rebuild/republish to
|
|
84
|
+
pick up backend changes), and `--env local` resolves the deployment from your current
|
|
85
|
+
worktree (run it from inside a checkout) — the staging/prod token path works anywhere.
|
|
86
|
+
|
|
87
|
+
## Auth & environments
|
|
88
|
+
|
|
89
|
+
Connection config resolves in this order: **`--env`/`--tenant` flags → env-vars →
|
|
90
|
+
`~/.config/agent-tools/plexus.yml`**.
|
|
91
|
+
|
|
92
|
+
```yaml
|
|
93
|
+
# ~/.config/agent-tools/plexus.yml
|
|
94
|
+
plexus:
|
|
95
|
+
default_env: local
|
|
96
|
+
default_tenant: test
|
|
97
|
+
environments:
|
|
98
|
+
local: {} # DYNAMIC — follows your current rdu worktree (see below); no values needed
|
|
99
|
+
staging: { convex_url: "<staging Convex URL>", site_url: "<staging site URL>", token: "plx_…" } # from the platform operator
|
|
100
|
+
prod: { convex_url: "https://db.plexus.swop.schule", site_url: "https://actions.db.plexus.swop.schule", token: "plx_…" }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Env-var fallback (no YAML): `PLEXUS_ENV`, `PLEXUS_CONVEX_URL`, `PLEXUS_SITE_URL`,
|
|
104
|
+
`PLEXUS_TOKEN`, `PLEXUS_TENANT`.
|
|
105
|
+
|
|
106
|
+
**Token path (faithful):** a `plx_…` API token (mint one in the app under
|
|
107
|
+
**Settings → API-Token**) is exchanged at `{site_url}/cli/token` for a short-lived
|
|
108
|
+
RS256 JWT, cached at `~/.config/agent-tools/plexus-jwt-<env>.json`. Every call
|
|
109
|
+
lands as the real authenticated user through the public API + its gates.
|
|
110
|
+
|
|
111
|
+
**Local path (`--env local`):** zero config — the CLI follows whichever rdu worktree
|
|
112
|
+
you run it from. It resolves that deployment's URL from the worktree's `.env` files
|
|
113
|
+
and bootstraps a deterministic platform-admin token with the rdu admin key
|
|
114
|
+
(`apiTokens:devSeedToken`, DEV_MODE-only), then takes the same token→JWT path. The
|
|
115
|
+
**cwd** decides the target deployment, not where the binary was installed. Run
|
|
116
|
+
`plexus-cli --env local config show` to see the resolved `deployment` + `authMode`
|
|
117
|
+
(`local-bootstrap`). Full workflow + gotchas:
|
|
118
|
+
`docs/engineering/plexus-cli.md` §"Local dev across rdu worktrees".
|
|
119
|
+
|
|
120
|
+
## Commands
|
|
121
|
+
|
|
122
|
+
| Group | Commands |
|
|
123
|
+
|---|---|
|
|
124
|
+
| **local** | `config`, `functions`, `describe`, `offer validate` |
|
|
125
|
+
| **introspection** | `functions [--kind] [--grep]`, `describe <fn>`, `call <fn> [--kind] key=value…` |
|
|
126
|
+
| **identity** | `whoami`, `tenants`, `units` |
|
|
127
|
+
| **catalog** | `catalog {plan,apply} --file catalog.json` · `catalog export --out catalog.json` — articles with the programs selling them, 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](./playbooks/config/set-up-billing-catalog.md) |
|
|
128
|
+
| **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](./playbooks/applications/inspect-process-store.md)) · `applications start-test --offer --run <key\|id> --stage <key\|id> (--applicant <email> \| --party <id>) [--staff-input <key>=true\|false …]` (every staff input the offer declares, once each) · `applications discard-test <id>` — recipe [playbooks/applications/start-a-test-application.md](./playbooks/applications/start-a-test-application.md) |
|
|
129
|
+
| **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](./playbooks/retention/erase-due-applications.md). `retention parties plan` · `retention parties apply [--confirm --expect <n>]` — the same for persons past the tenant's deletion rule (hard delete with history and files; blocked persons are listed and skipped, `--expect` counts the deletable ones): [playbooks/retention/erase-due-parties.md](./playbooks/retention/erase-due-parties.md) |
|
|
130
|
+
| **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](./playbooks/test-actions/drive-a-test-application.md) |
|
|
131
|
+
| **migrations** | `migrations status [--recent]` — the deployed release's pending migrations; exit 1 while one is not `success` (recipe [playbooks/worker-ops/verify-migrations.md](./playbooks/worker-ops/verify-migrations.md)) |
|
|
132
|
+
| **tokens** | `tokens list`, `tokens create <name>`, `tokens revoke <id>` — minting needs `_platform.apiTokens.manage` (opt-in) or the tenant right `administration.apiTokens.manage` (every `tenant_admin` holds it); list and revoke your own always work. `tokens create <name> --for <email> [--output <csv>] [--quiet]` mints one FOR a tenant administrator to hand over (`_platform.users.manage` AND `_platform.apiTokens.manage`; the target must hold `administration.apiTokens.manage` in a tenant) — recipe [playbooks/users/cli-access-for-tenant-admin.md](./playbooks/users/cli-access-for-tenant-admin.md) |
|
|
133
|
+
| **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,login-trail,set-email,delete,doctor,relink-auth}` — `login-trail <email> [--since 24h]` lists refusals, letters with the relay's answer, sessions and the last password change; `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 |
|
|
134
|
+
| **platform** | `platform {tenants,users,workers}` |
|
|
135
|
+
| **stammdaten** | `stammdaten persons [-q] [--limit] [--all] [--deleted]`, `stammdaten persons purge <partyId> [--confirm]` (the only hard delete of a person, plan unless `--confirm`) |
|
|
136
|
+
| **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`) |
|
|
137
|
+
| **generator** | Contract generators (`finanzen_vertraege.contracts.manage`): `generator {list,show,usages,runs}` · `generator {create,update,clone,seal,archive,restore,delete-draft}` · `generator source {export,save}` · `generator scenarios {test,approve}` · `generator {export,apply}` (one-file round trip) · `generator template {list,upload}` · `generator setup {plan,apply}` (offer setup by catalog keys). **Combined generators**: `generator combined export <key@rev> [--out]` writes the declaration (mask, parts pinned as `key@revision`, names) · `generator combined save <file>` writes it back — onto the exported revision (guarded by `expectedSourceRevision`; an unedited export writes nothing) or, without `id`, as the first draft of a new key · `generator combined plan <key@rev> --input <json>` (sandbox: which parts run, what each misses) · `generator combined create --key … --name … --name-en …` (empty draft); seal/clone/archive with the ordinary verbs. The server's `[generator/…]` refusals print verbatim. Recipe [playbooks/resolver/manage-contract-resolver.md](./playbooks/resolver/manage-contract-resolver.md). |
|
|
138
|
+
| **imports** | `imports {status,trigger,trigger-all}` |
|
|
139
|
+
| **feedback** | `feedback {list,screenshot,to-issue,sync,set-status}` |
|
|
140
|
+
| **notifications** | Operator's log (`_platform.notifications.monitor`): `notifications {list,show,retry,sweeper-status,self-test}`; `list` filters by `--status`, `--template`, `--to`, `--since 24h` and shows the relay's answer (`smtp`). Why a sign-in failed: `users login-trail <email> --since 24h` (recipe [playbooks/notifications/trace-a-login.md](./playbooks/notifications/trace-a-login.md)). 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"](#the-words-a-school-sends-notifications-letters--chrome). |
|
|
141
|
+
| **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}` |
|
|
142
|
+
| **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) (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 (`""` clears); `field/section move --before\|--after\|--to-top\|--to-bottom` reorders. Every SmartForm text is ONE `{ de, en }` field: a German text needs its English in the same call (`--label-en`, `--placeholder-en`, `--help-text-en`, …) — the German is never copied into `en`. |
|
|
143
|
+
| **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|none` switches the Freigabe before the document goes out (`none` drops its team + title, `release` takes `--approve-title`, default „<Dokument> freigeben“) and `--confirm-issue true|false` makes „Bereitstellen“ ask before the family sees the document (approval `none` only); on a signing node `--confirm-terms true|false` switches the confirmation of the contract terms before the signed copy's upload (default on) and `--terms-question-de/-en` rewords it — the same edits as the IDE's Knotendetails → Einstellungen. `node add --type review [--reads '<address>,…']` adds a „Prüfung" that parks, and whose outcomes wait until the named fields are ticked; `--on-edge <from>:<outcome>` splices any new node onto exactly that edge and `--progress <event>=<state>` puts its board event on an existing state (recipe [playbooks/offer/insert-and-rewire-node.md](./playbooks/offer/insert-and-rewire-node.md)). Every flag that reads another node's data takes a full ADDRESS naming its producer (`'$node.form.admission.kind.vorname'`, `'$task.reducedFee.reducedFee'`, `'$derived.schulgeld'`, `'$env.applicant.language'` — single quotes) — a bare key is refused with the addresses visible at the node, nothing is bound implicitly; `node reads --node <id>` lists what arrives at a node; see ["Addresses"](#addresses-what-a-node-reads-and-who-produces-it). `node add --type countersign` adds „Unterschrift (intern)" — see ["Countersignature node"](#countersignature-node-unterschrift-intern). `node add --type if --condition '<expr>'` adds a branch on one DSL expression over `$env` (outcomes `true`/`false`, route with `--outcomes`; `node set --condition` replaces it), and `staff-input {list,set,remove}` declares the staff inputs it reads as `$env.staffInput.<key>` — see ["Staff inputs and the if node"](#staff-inputs-and-the-if-node). `node set-input --dir <bundle> --node <id> --input <key> (--address '<address>' | --from <formNode> --field <path> | --literal <json> | --clear)` sets one input cell — e.g. a template's language field to `$env.applicant.language` (recipe [playbooks/offer/bind-node-input.md](./playbooks/offer/bind-node-input.md)). `node set` edits a node in place — `--label`, decision `--assign-decision`/`--comment-required`, form `--form` rebind (a slug on a form node; the instance address on a resolver contract / engagePush), setFlag `--target`, countersign/engagePush `--contract`, engagePush `--payment`, if `--condition`, `--person`, `--outcomes` routing rewrite (`--type` is not editable → rm+add). `node rm` is refused while an address elsewhere names the node, listing every such place. `node {set,clear}-notification` binds/unbinds the LETTER a node sends at one of its declared events — see ["Notifications on a workflow node"](#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"](#readiness-reads-on-a-workflow-node). `node add --type completenessCheck` + `node {set,add,remove}-requires` author the gate that waits for outstanding documents — see ["Completeness check"](#completeness-check-documents-owed-before-the-process-ends). `node task {add,set,rm}` edits ONE composed task of a node — result, cross-node `blocks`, condition, evidence, description, family request, optional — and `node optional-action {set,remove}` one entry under „Weiteres" — see ["Tasks on a workflow node"](#tasks-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](./playbooks/offer/workflow-default-channels.md). |
|
|
144
|
+
| **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). |
|
|
145
|
+
|
|
146
|
+
**Writing commands name their environment.** Every client command is classified in ONE
|
|
147
|
+
list, `plexus/writeVerbs.ts` (read · write · write only with `--confirm` · `call` by its
|
|
148
|
+
kind), and ONE guard (`ClientCommand.caller`) applies it: a writing run refuses without
|
|
149
|
+
`--env` on the command line (a configured default or `PLEXUS_ENV` is not enough), and a
|
|
150
|
+
production target needs `--allow-prod` — decided on the RESOLVED deployment URL, so any
|
|
151
|
+
environment that points at the URL `plexus.yml` names for `prod` is production
|
|
152
|
+
(`plexus/writeTarget.ts`, `core/config.ts` `knownProdConvexUrls`). The check runs before any
|
|
153
|
+
network access; a `plexus.yml` without a `prod` entry makes writes refuse. Reads are
|
|
154
|
+
unaffected (`--allow-prod` on a read is ignored, with a note on stderr). A `--confirm`
|
|
155
|
+
command's dry-run counts as a read; a write verb's own `--dry-run` flag (`feedback to-issue`,
|
|
156
|
+
`imports trigger`) does not — it still needs `--env`. `writeVerbs.test.ts` fails when a new
|
|
157
|
+
command is not classified, so none ships unguarded; a command listed as a read that reaches a
|
|
158
|
+
mutation or action at runtime throws, naming itself. `--env local` connects only to THIS machine — its admin-key bootstrap is a mutation even for
|
|
159
|
+
a read, so the target's host must resolve to loopback or to an address of one of this machine's
|
|
160
|
+
interfaces (rdu's Traefik host of a worktree does; a remote deployment does not). `seed` is outside the guard: it runs
|
|
161
|
+
`convex run` against the current worktree's own deployment, never through `plexus.yml`.
|
|
162
|
+
|
|
163
|
+
`seed --if-needed` is the local startup path: it pushes current code, initializes
|
|
164
|
+
an empty database once, joins an active seed, or preserves a completed seed.
|
|
165
|
+
Failed/incomplete seeds and untracked populated databases require an explicit
|
|
166
|
+
rebuild. Without `--if-needed`, `seed` is destructive. `--applicants none` and
|
|
167
|
+
`--finance-showcase none` select the lean fixtures; switching local profiles
|
|
168
|
+
preserves existing data until an explicit rebuild. Recipe:
|
|
169
|
+
[Initialize or rebuild local demo data](playbooks/worker-ops/rebuild-local-data.md).
|
|
170
|
+
|
|
171
|
+
Every command supports `--json` (raw output; default is a table). Network commands
|
|
172
|
+
take `--env` / `--tenant`. `functions` / `describe` / `offer validate` / `config`
|
|
173
|
+
work offline (no deployment).
|
|
174
|
+
|
|
175
|
+
### Provisioning admins
|
|
176
|
+
|
|
177
|
+
Two distinct shapes for a tenant-less platform operator:
|
|
178
|
+
|
|
179
|
+
- `users create <email> --platform-admin` — mints the identity + the `_platform.*`
|
|
180
|
+
row **with** a generated credential (printed once; `--output` writes the
|
|
181
|
+
`email,password` row to a CSV). It takes no display name, so the account carries
|
|
182
|
+
none. No verb produces an identity that cannot log in.
|
|
183
|
+
- `users provision-platform-admin <email> --name "…"` — the **credentialed,
|
|
184
|
+
ready-to-log-in** full-admin path (creates the Better Auth credential too and
|
|
185
|
+
grants every `_platform.*` permission).
|
|
186
|
+
- `users provision-platform-user <email> --name "…" --permission _platform.schema.read`
|
|
187
|
+
— the credentialed limited-platform path. It creates the Better Auth credential
|
|
188
|
+
and grants exactly the repeated `--permission` values.
|
|
189
|
+
|
|
190
|
+
Both platform provisioning commands need `_platform.users.manage` (your own
|
|
191
|
+
platform-admin token, no Convex admin key) and refuse an email that already has
|
|
192
|
+
tenant access (the `_platform.* XOR tenant-access` invariant). `users provision` does
|
|
193
|
+
the same credentialed onboarding for a *tenant* user (`--tenant <slug> --group
|
|
194
|
+
tenant_admin`).
|
|
195
|
+
|
|
196
|
+
Step-by-step recipes (exact commands + verification + gotchas):
|
|
197
|
+
[playbooks/users/add-platform-admin.md](./playbooks/users/add-platform-admin.md) ·
|
|
198
|
+
[playbooks/users/add-platform-user.md](./playbooks/users/add-platform-user.md) ·
|
|
199
|
+
[playbooks/users/add-tenant-admin.md](./playbooks/users/add-tenant-admin.md). For any
|
|
200
|
+
common CLI task, see [playbooks/](./playbooks/README.md).
|
|
201
|
+
|
|
202
|
+
### E-signature (`signing` command group)
|
|
203
|
+
|
|
204
|
+
`plexus-cli signing {list,start,show,void,resend,reconcile}` — the Signaturvorgang
|
|
205
|
+
(DocuSign or inSign) over the public `signing.*` functions; gated on
|
|
206
|
+
`finanzen_vertraege.contracts.manage`. `start` goes out through the tenant's ACTIVE
|
|
207
|
+
connection — there is no provider to pick.
|
|
208
|
+
|
|
209
|
+
`plexus-cli signing connection {show,test,set,activate,remove,reseal}` and `plexus-cli signing status` (key slots: what is sealed under which key; `reseal` after a key rotation, both platform-wide under `_platform.config.manage` — docs/signing/operations.md → "Rotating the key") — the school's own
|
|
210
|
+
account at a provider (`signingConnections.*`). `show` and `test` need
|
|
211
|
+
`finanzen_vertraege.contracts.manage`; `set`, `activate` and `remove` the platform right
|
|
212
|
+
`_platform.config.manage`. A secret (DocuSign's Connect HMAC key, inSign's API password)
|
|
213
|
+
enters only through `--secret-stdin` or `--secret-file` and is never printed — `show`
|
|
214
|
+
lists its name and fingerprint. Recipes: `playbooks/signing/README.md`,
|
|
215
|
+
`playbooks/signing/connect-a-school.md`; setup + runbook:
|
|
216
|
+
`docs/signing/{README,docusign-setup,insign,operations}.md`.
|
|
217
|
+
|
|
218
|
+
### The words a school sends (`notifications letters` / `chrome`)
|
|
219
|
+
|
|
220
|
+
A **letter** is code: named **blocks** with the text Plexus wrote. A school may
|
|
221
|
+
rewrite a block, and what is stored is **only that deviation** — every block it
|
|
222
|
+
leaves alone keeps following the platform text, so a later improvement still
|
|
223
|
+
reaches it.
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
plexus-cli --tenant test notifications letters list # catalog + who deviates
|
|
227
|
+
plexus-cli --tenant test notifications letters show applicant.tasksOpen # the letter's placeholders + each block's text
|
|
228
|
+
plexus-cli --env <env> --tenant test notifications letters set applicant.tasksOpen \
|
|
229
|
+
--slot intro="bei {{schoolName}} warten {{taskCount}} Aufgaben auf Sie."
|
|
230
|
+
plexus-cli --env <env> --tenant test notifications letters set applicant.tasksOpen --slot-file intro=./intro.txt
|
|
231
|
+
plexus-cli --env <env> --tenant test notifications letters reset applicant.tasksOpen --slot intro
|
|
232
|
+
plexus-cli --env <env> --tenant test notifications letters reset applicant.tasksOpen --all
|
|
233
|
+
plexus-cli --tenant test notifications chrome {show,set} # greeting / sign-off / sender name
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Semantics worth knowing before using them:
|
|
237
|
+
|
|
238
|
+
- **`set` merges** across blocks; a block whose text equals the platform default
|
|
239
|
+
is **dropped rather than stored**, so the command's `deviating blocks` line can
|
|
240
|
+
be shorter than what you submitted. That is the backend's answer, not a CLI
|
|
241
|
+
filter — the CLI never resolves a default, checks a placeholder, or merges a
|
|
242
|
+
letter.
|
|
243
|
+
- **`reset` needs `--all`** to drop a whole letter: naming nothing must not mean
|
|
244
|
+
everything.
|
|
245
|
+
- **A letter with no blocks cannot be reworded.** Today that is only the
|
|
246
|
+
retired `applicant.reentry`, shown as `— (not editable)`; the sign-in letters
|
|
247
|
+
(category `login`) are rewordable, but their link must stand alone.
|
|
248
|
+
- **Placeholders are an allowlist per block.** An undeclared `{{x}}` throws with
|
|
249
|
+
every finding named; nothing is written.
|
|
250
|
+
- These are tenant-scoped (`--tenant`) and gated on `notifications._self.access`
|
|
251
|
+
(read) / `notifications.mail.configure` (write) — **not** the platform
|
|
252
|
+
`_platform.notifications.monitor` that the delivery log uses.
|
|
253
|
+
- The deviations are **config-as-code** (`notificationLetters`, identity
|
|
254
|
+
`templateId:locale:scopeKey`; the frame is `tenant.notificationChrome`), so
|
|
255
|
+
`config export|set|apply` promote them across environments.
|
|
256
|
+
|
|
257
|
+
Recipe: [playbooks/notifications/reword-a-letter.md](./playbooks/notifications/reword-a-letter.md).
|
|
258
|
+
Model + invariants: `docs/engineering/notifications.md`.
|
|
259
|
+
|
|
260
|
+
### Addresses (what a node reads, and who produces it)
|
|
261
|
+
|
|
262
|
+
A node reads another node's data only through a full **address** in the field
|
|
263
|
+
that reads it — there are no data edges (`wires`) any more, no form
|
|
264
|
+
implied by the node and no bare keys. Control edges (`next`) only route. The
|
|
265
|
+
producer must run on every path before the reader.
|
|
266
|
+
|
|
267
|
+
| Address | Names |
|
|
268
|
+
|---|---|
|
|
269
|
+
| `$node.<formNode>.admission[.<field>]` | the form instance a form node creates, or one field / export / party leaf of it |
|
|
270
|
+
| `$node.<node>.document` · `.contract` · `.payment` | a document node's newest revision · a resolver contract · a payment |
|
|
271
|
+
| `$task.<task>.<key>` | the value the ONE task `<task>` writes under store key `<key>` |
|
|
272
|
+
| `$derived.<key>` · `$env.applicant.language` | a computed price · a fact about the application |
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
plexus-cli offer workflow node reads --dir ./bundle --node contract [--json] # what arrives: address, origin, config path, producer
|
|
276
|
+
plexus-cli offer workflow node set-input --dir ./bundle --node contract --input schulgeld --address '$derived.schulgeld'
|
|
277
|
+
plexus-cli offer workflow node set-reads --dir ./bundle --node contract --from form --field kind.vorname,kind.nachname
|
|
278
|
+
plexus-cli offer workflow node add --dir ./bundle --type setFlag ... --flag-key vertrag --target '$node.form.admission'
|
|
279
|
+
plexus-cli offer workflow node add --dir ./bundle --type form ... --adopt-form '$node.form.admission'
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Address-taking flags: `set-input --address` (short form `--from <formNode>
|
|
283
|
+
--field <path>`), `*-reads` / `*-requires --address` (same short form), `node
|
|
284
|
+
add --reads` / `--require` / `--person` / `--target` / `--adopt-form` /
|
|
285
|
+
`--contract` / `--payment` / engagePush `--form`, `node set --target` /
|
|
286
|
+
`--contract` / `--payment` / `--person` / `--form` (on a resolver contract or
|
|
287
|
+
engagePush), task `--when` / `--reads` / `--documents` (`artifact:<own slot>`,
|
|
288
|
+
`field:<address>`, `document:$node.<node>.document`) /
|
|
289
|
+
`--family-request-fields` / `--family-request-from`, and a record field's
|
|
290
|
+
`"suggest":{"kind":"form","address":"$node.…"}`. Pricing-rule conditions and
|
|
291
|
+
`processStore[].derive.reduction` have no verb: the bundle JSON carries
|
|
292
|
+
`$task.<task>.<key>` identifiers (`$task.geschwisterrang.geschwisterRangSchule >= 3`).
|
|
293
|
+
A malformed address or a bare key is refused with the list of addresses visible
|
|
294
|
+
at the node (`visibleAddressesAt`, the rule the gate applies too); everything
|
|
295
|
+
else is the publish gate, run before writing: `[address/shape|bareIdentifier|unknown|unknownOwner|unknownKey|ownerNotWriter|taskOwnerTaken|notVisible|pathNotInTarget|typeMismatch|notFormInstance|mixedForms|envCohortBound|missing]`.
|
|
296
|
+
Only errors the edit introduces block it. `node rm` refuses while any address
|
|
297
|
+
names the node (`addressesNaming`) and lists config path + address of each.
|
|
298
|
+
A bundle exported before addresses is refused (`[graph/retiredField]`: `wires`, a
|
|
299
|
+
binding with `path`, `adoptForm: true`) — export it again. Recipe:
|
|
300
|
+
[playbooks/offer/addresses.md](./playbooks/offer/addresses.md); model (visibility, `$env`
|
|
301
|
+
vocabulary, the store behind `$task`): `docs/engineering/workflow-engine.md`.
|
|
302
|
+
|
|
303
|
+
### Countersignature node („Unterschrift (intern)")
|
|
304
|
+
|
|
305
|
+
The school countersigns the contract the family signed: a `countersign` node
|
|
306
|
+
after a checked resolver contract (`production: resolver` +
|
|
307
|
+
`responseStrategy: signedUploadChecked`). Its one row „Vertrag gegenzeichnen"
|
|
308
|
+
books the Träger on the revision its `contract` address names, sends the
|
|
309
|
+
node's `countersigned` letter and routes `countersigned`.
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
plexus-cli offer workflow node add --dir ./bundle --type countersign \
|
|
313
|
+
--id gegenzeichnung --label "Gegenzeichnung" --label-en "Countersignature" \
|
|
314
|
+
--on-edge contract:signed \
|
|
315
|
+
--assign-team schulverwaltung [--title "Vertrag gegenzeichnen" --title-en "Countersign the contract"] [--due-in-days 5] \
|
|
316
|
+
--contract '$node.contract.contract' \
|
|
317
|
+
--notification countersigned=admission.contractCountersigned
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Publish rules: `[countersign/assign]`, `[countersign/sourceNotCheckedContract]`,
|
|
321
|
+
`[countersign/twice]`, `[countersign/dueInDays]`, `[address/missing]` (no
|
|
322
|
+
`contract`), `[notification/missingBinding]` (the letter is required). Recipe:
|
|
323
|
+
[playbooks/offer/countersign-node.md](./playbooks/offer/countersign-node.md).
|
|
324
|
+
|
|
325
|
+
### Staff inputs and the if node
|
|
326
|
+
|
|
327
|
+
Values staff set when they start an application: `graph.staffInputs`
|
|
328
|
+
declares them (v1: `boolean`, a checkbox in „+ Bewerbung"), an `if` node reads
|
|
329
|
+
one in its condition as `$env.staffInput.<key>` and routes `true` / `false`
|
|
330
|
+
(never parks). Every staff start — cockpit, lead conversion,
|
|
331
|
+
`applications start-test --staff-input <key>=true|false` — must set every
|
|
332
|
+
declared input; a self-service start records `false`.
|
|
333
|
+
|
|
334
|
+
```bash
|
|
335
|
+
plexus-cli offer workflow staff-input set staffApplication --dir ./bundle \
|
|
336
|
+
--label "Mitarbeiterbewerbung" --label-en "Staff application" # --label-de is an alias of --label
|
|
337
|
+
plexus-cli offer workflow staff-input list --dir ./bundle
|
|
338
|
+
plexus-cli offer workflow staff-input remove staffApplication --dir ./bundle
|
|
339
|
+
plexus-cli offer workflow node add --dir ./bundle --type if \
|
|
340
|
+
--id staffCheck --label "Mitarbeiterbewerbung?" --label-en "Staff application?" \
|
|
341
|
+
--on-edge form:standard --condition '$env.staffInput.staffApplication' \
|
|
342
|
+
--outcomes true:review,false:fee # a staff child skips the fee
|
|
343
|
+
plexus-cli offer workflow node set --dir ./bundle --id staffCheck --condition '<expr>'
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Publish rules: `[staffInput/*]`, `[if/condition]`, `[if/unroutedOutcome]`; a
|
|
347
|
+
SmartForm, document or rule cannot read a staff input
|
|
348
|
+
(`[address/staffInputConditionOnly]`). Recipe:
|
|
349
|
+
[playbooks/offer/staff-input-if-node.md](./playbooks/offer/staff-input-if-node.md).
|
|
350
|
+
|
|
351
|
+
### Readiness reads on a workflow node
|
|
352
|
+
|
|
353
|
+
`reads: [Address]` on a `decision`, `review` or document-producing `governedDocument`
|
|
354
|
+
node names the SmartForm fields (`$node.<formNode>.admission.<field>`, one form
|
|
355
|
+
per list) a human reads before the node acts — for an
|
|
356
|
+
**uploaded** contract the fields staff copy into it, since an upload
|
|
357
|
+
binds no field. The readiness gate holds the node's actions while one of them has
|
|
358
|
+
an open correction or is empty where the form requires it; once the document is
|
|
359
|
+
issued they are **consumed** (a correction is refused until it is rescinded).
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
plexus-cli offer workflow node set-reads --dir ./bundle --node contract \
|
|
363
|
+
--from form --field kind.surname,kind.geburtsdatum,derivedConstellation # replaces the list
|
|
364
|
+
plexus-cli offer workflow node add-reads --dir ./bundle --node contract --address '$node.form.admission.kind.homeAddress'
|
|
365
|
+
plexus-cli offer workflow node remove-reads --dir ./bundle --node contract --address '$node.form.admission.kind.homeAddress'
|
|
366
|
+
plexus-cli offer workflow node set-reads --dir ./bundle --node contract --clear
|
|
367
|
+
plexus-cli offer workflow node list --dir ./bundle # the `reads` column
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Addresses are checked **offline** against the form their owner node pins, by the
|
|
371
|
+
same `[address/*]` / `[readiness/*]` rules and the same per-form-node field
|
|
372
|
+
universe publish uses (`readinessUniverse`): a field the form lacks, a field
|
|
373
|
+
inside a repeated block, a duplicate, two forms in one list, a form that does not
|
|
374
|
+
run before the node, or `reads` on a node the gate does not govern refuses the write. Only
|
|
375
|
+
errors the edit *introduces* block it; errors the bundle already carried are
|
|
376
|
+
printed as warnings. `offer validate` runs the same rules (it used to skip them
|
|
377
|
+
offline), and `offer plan` prints one `~reads:<node>` row per node with the
|
|
378
|
+
addresses added and removed. Recipe: [playbooks/offer/declare-node-reads.md](./playbooks/offer/declare-node-reads.md).
|
|
379
|
+
|
|
380
|
+
### Completeness check (documents owed before the process ends)
|
|
381
|
+
|
|
382
|
+
`completenessCheck` holds the process until the SmartForm fields it `requires`
|
|
383
|
+
are there, by a deadline; entered with nothing missing it completes at once.
|
|
384
|
+
|
|
385
|
+
```bash
|
|
386
|
+
# --require: ≥ 1, repeatable · one of --deadline-days/--deadline-until ·
|
|
387
|
+
# --guest-role and --person: both or neither · the `requested` letter is required
|
|
388
|
+
plexus-cli offer workflow node add --dir ./bundle --type completenessCheck \
|
|
389
|
+
--id nachreichen --label "Vollständigkeitsprüfung" --label-en "Completeness check" \
|
|
390
|
+
--require '$node.form.admission.kind.masernschutz,$node.form.admission.kind.geburtsurkunde' \
|
|
391
|
+
--deadline-days 14 \
|
|
392
|
+
--guest-role gasthoerer --person '$node.form.admission.beneficiary' \
|
|
393
|
+
--notification requested=admission.documentsRequested \
|
|
394
|
+
--outcomes rejected:joinAbsage,absage:endAbsage
|
|
395
|
+
plexus-cli offer workflow node set-requires|add-requires|remove-requires --dir ./bundle --node nachreichen (--address '<address>,…' | --from form --field <path>,…)
|
|
396
|
+
plexus-cli offer workflow node set --dir ./bundle --id nachreichen [--deadline-days n | --deadline-until d] [--guest-role r --person p | --guest-role ""]
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
`--notification <event>=<templateId>` works on every `node add` (it writes the
|
|
400
|
+
same binding `set-notification` does); it is needed in the same call whenever the
|
|
401
|
+
type declares an event **required**, since `node add` validates before writing.
|
|
402
|
+
Nothing is defaulted: no deadline, no person, no letter, no form — `requires` and
|
|
403
|
+
the guest role's person name the form they read by address. The node's
|
|
404
|
+
`[completenessCheck/*]` rules run offline (field keys against the carried form);
|
|
405
|
+
the tenant role catalog (`unknownRole`, `nonLeafRole`) and the letter catalog only
|
|
406
|
+
at `offer apply --publish`. `*-requires` edits behave like `*-reads` (only errors
|
|
407
|
+
the edit introduces block it; removing the last address is refused). Recipe:
|
|
408
|
+
[playbooks/offer/completeness-check.md](./playbooks/offer/completeness-check.md).
|
|
409
|
+
|
|
410
|
+
### Tasks on a workflow node
|
|
411
|
+
|
|
412
|
+
`node.tasks[]` are the composed tasks a node spawns on enter. `task add|set|rm`
|
|
413
|
+
edits one of them by `--node` + `--key`; `set` changes only the flags it is given.
|
|
414
|
+
|
|
415
|
+
```bash
|
|
416
|
+
plexus-cli offer workflow node task add --dir ./bundle --node reviewEingereicht --key einschaetzung \
|
|
417
|
+
--title "Einschätzung abgeben" [--title-en "…"] --team oeffentlichkeitsarbeit [--kind task|approval] \
|
|
418
|
+
[--due-in-days 3] \
|
|
419
|
+
[--blocks aufnahme:decision | --blocks <siblingKey>] \
|
|
420
|
+
[--result freetext|recommendation|choice|record|pricedItem|reduction|appointment] [--option key=Label[|English]]… \
|
|
421
|
+
[--writes '$task.<thisKey>.<storeKey>'] [--prices '$derived.<key>'] [--item <catalogKey>] \
|
|
422
|
+
[--result-label "…" [--result-label-en "…"]] \
|
|
423
|
+
[--text-required | --no-text-required] [--chat all|team:<group>|<channelKey>] \
|
|
424
|
+
[--when '$node.<form>.admission.<booleanExport>' --on-condition-return respawn|stayClosed] \
|
|
425
|
+
[--reads '$node.<form>.admission.a,…'] [--documents 'artifact:<slot>,field:<address>,document:<address>'] \
|
|
426
|
+
[--family-request-fields '<address>,…' | --family-request-from '$node.<form>.admission'] \
|
|
427
|
+
[--locks-evidence] [--description "…" [--description-en "…"]] \
|
|
428
|
+
[--files | --no-files] [--optional "Keine Einschätzung abgeben" [--optional-en "No assessment"]]
|
|
429
|
+
plexus-cli offer workflow node task set --dir ./bundle --node <n> --key <k> [any flag above] \
|
|
430
|
+
[--clear-blocks] [--clear-result] [--clear-when] [--clear-reads] [--clear-locks-evidence] [--clear-documents] [--clear-description] [--clear-family-request] [--clear-optional]
|
|
431
|
+
plexus-cli offer workflow node task rm --dir ./bundle --node <n> --key <k>
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
`--blocks <templateKey>` is a sibling on the same node; `<nodeId>:<templateKey>` a
|
|
435
|
+
template on a later node (stored as `blocks.node` only when it differs from `--node`).
|
|
436
|
+
`--option` replaces its list. `--locks-evidence`: once the task is
|
|
437
|
+
decided, its evidence (`--reads` + the fields behind `--when`) takes no correction, flag
|
|
438
|
+
or cohort move until its team reopens the task (`applicationTasks.reopen`). The CLI refuses malformed input in German
|
|
439
|
+
(unknown result type, `--option` on a non-choice result, `--text-required` on free text,
|
|
440
|
+
`--chat external`, …); the graph rules
|
|
441
|
+
(`[workItems/*]`, `[taskResult/*]`) are the publish validators, run before every write —
|
|
442
|
+
only errors the edit introduces block it. `rm` is refused while another task's `blocks`
|
|
443
|
+
(or an optional action) names the task, and lists those dependents. Output: a
|
|
444
|
+
`before`/`after` field diff; `--json` → `{action, node, key, before, after, changes,
|
|
445
|
+
dropped}`. Recipe: [playbooks/offer/node-tasks.md](./playbooks/offer/node-tasks.md).
|
|
446
|
+
|
|
447
|
+
`--optional "<Knopf>"` makes a task OPTIONAL: nothing waits for it (publish
|
|
448
|
+
refuses it together with `blocks`, `gatesNode`, `locksEvidence`, `completableIf`), its
|
|
449
|
+
holder may close it without a result with that button, and a decision made while it is
|
|
450
|
+
open makes it „hinfällig". `--files` lets a free text / recommendation / choice that writes
|
|
451
|
+
no value (`result.writes`) take files — text and files become one message of the result's chat.
|
|
452
|
+
|
|
453
|
+
A result with a value (`choice`, `pricedItem`, `reduction`) writes it under the task's own
|
|
454
|
+
address — `--writes '$task.<thisKey>.<storeKey>'` (stored as the bare `result.writes`;
|
|
455
|
+
another task's address or a path is refused) — and names it with `--result-label`
|
|
456
|
+
(`--result-label-en`; without it the German stands in). `--item <catalogKey>` (pricedItem)
|
|
457
|
+
names the billing item it prices; `--prices '$derived.<key>'` (reduction) the derived price
|
|
458
|
+
it lowers. A kind change drops what no longer fits and lists it under `dropped`.
|
|
459
|
+
|
|
460
|
+
`node optional-action set|remove --dir <bundle> --node <decision> --key <k>` authors one
|
|
461
|
+
entry under „Weiteres" (a `spawnTasks` binding): `--label [--label-en]`, `--tasks a,b`
|
|
462
|
+
(keys of the node's tasks), `--trigger <group>` (who may press it), `--repeatable`,
|
|
463
|
+
`--description [--description-en]`, `--clear-trigger`, `--clear-description`. `set`
|
|
464
|
+
creates it when new (`--label` + `--tasks` required); `remove` is refused while
|
|
465
|
+
`requiresOneOf` names the key. Recipe:
|
|
466
|
+
[playbooks/offer/optional-assessment.md](./playbooks/offer/optional-assessment.md).
|
|
467
|
+
|
|
468
|
+
### Notifications on a workflow node
|
|
469
|
+
|
|
470
|
+
A node **type** declares at which **events** it can write to a family and with
|
|
471
|
+
which typed arguments; the authored **node** binds a letter to one of them.
|
|
472
|
+
Neither half can invent the other — a school that says nothing on a rejection
|
|
473
|
+
simply binds nothing.
|
|
474
|
+
|
|
475
|
+
```bash
|
|
476
|
+
plexus-cli offer workflow node set-notification --dir ./bundle \
|
|
477
|
+
--node freigabe --event rejected --template admission.rejected
|
|
478
|
+
plexus-cli offer workflow node set-notification --dir ./bundle \
|
|
479
|
+
--node contract --event issued --template admission.documentIssued
|
|
480
|
+
# --literal amount=12000 # a fixed value
|
|
481
|
+
# --disabled # authored, switched off
|
|
482
|
+
plexus-cli offer workflow node clear-notification --dir ./bundle --node freigabe --event rejected
|
|
483
|
+
plexus-cli offer workflow node list --dir ./bundle # the `letters` column
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
An argument **binds itself** when exactly one of the event's arguments (or an
|
|
487
|
+
ambient one such as `tenantSlug`) carries its name and a compatible type;
|
|
488
|
+
`--literal` exists for what the event cannot supply. The addresses a node reads
|
|
489
|
+
are not a source — no executor hands them to the send, so the gate does not offer
|
|
490
|
+
them. Like every other node mutator,
|
|
491
|
+
both commands run the real publish-gate validators before writing — so an event
|
|
492
|
+
the node type does not declare fails here. What offline validation cannot see is
|
|
493
|
+
the letter **catalog** (`analysis/validators.ts` must stay free of the
|
|
494
|
+
notification registry so the browser IDE and this CLI can import it), so an
|
|
495
|
+
unknown `--template` id and an unresolvable argument are caught server-side at
|
|
496
|
+
`offer apply --publish`. Run it.
|
|
497
|
+
|
|
498
|
+
### Generic escape hatch
|
|
499
|
+
|
|
500
|
+
```bash
|
|
501
|
+
plexus-cli call parties:listForMainPage client=test q="Müller"
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
Without `--kind`, `call` runs only a query: it looks the function up in the offline spec
|
|
505
|
+
below and refuses a mutation or action by name (a write is always an explicit
|
|
506
|
+
`--kind mutation|action` — the default environment may be prod). A function the spec does
|
|
507
|
+
not know (newer than the snapshot, or a typo) needs `--kind`.
|
|
508
|
+
|
|
509
|
+
`call` also checks the arguments against the spec BEFORE the request: a missing required
|
|
510
|
+
argument, an unknown one or a wrong type is named with the expected type
|
|
511
|
+
(`missing configKey (string)`). A production deployment would answer them only with
|
|
512
|
+
„Server Error" and a GlitchTip event. A value the spec types as text keeps its text
|
|
513
|
+
(`q=2026` stays `"2026"`). A function the snapshot does not know (explicit `--kind`) is
|
|
514
|
+
called unchecked, with a note on stderr.
|
|
515
|
+
|
|
516
|
+
`functions` / `describe` read a committed snapshot (`spec.snapshot.json`).
|
|
517
|
+
Regenerate it with the tool's in-repo `spec` script (run from
|
|
518
|
+
`app/tools/plexus-cli/`) after changing a public function's arguments, return
|
|
519
|
+
validator, kind, or adding/removing one. No backend needed; the `cliSpecParity`
|
|
520
|
+
test fails until the committed file matches the code:
|
|
521
|
+
|
|
522
|
+
```bash
|
|
523
|
+
pnpm spec # = tsx scripts/regen-spec.ts (imports app/convex/**, exportArgs/exportReturns of every public function → spec.snapshot.json)
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
The snapshot drives display and `call`'s query check (`call` builds its reference
|
|
527
|
+
directly). A stale snapshot makes `call` ask for `--kind` on a new function.
|
|
528
|
+
|
|
529
|
+
## Offer / admission CRUD
|
|
530
|
+
|
|
531
|
+
**Model.** An offer's identity = **name + unit**. The title is the bare program name
|
|
532
|
+
(`"Kosmetik"`); the school/location is the **pedagogic-unit link** (the namespace).
|
|
533
|
+
**Runs** (Schuljahre) and **levels** (Klassenstufen) are settings of the offer. A
|
|
534
|
+
**smartform** and a **workflow** are optional later stages that make the offer
|
|
535
|
+
*applyable* — a draft offer needs neither.
|
|
536
|
+
|
|
537
|
+
**Lifecycle:** `create (name + unit) → add runs / levels / capacity → [smartform → workflow → applyable]`.
|
|
538
|
+
|
|
539
|
+
### Imperative CRUD
|
|
540
|
+
|
|
541
|
+
```bash
|
|
542
|
+
plexus-cli --env <env> offer create --slug kosmetik-bs --title "Kosmetik" --title-en "Cosmetics" --unit <unit key>
|
|
543
|
+
plexus-cli offer show --slug kosmetik-bs # title, unit, levels, runs, capacity, state
|
|
544
|
+
plexus-cli --env <env> offer set --slug kosmetik-bs --title "Kosmetik" --title-en "Cosmetics" --unit <unit key>
|
|
545
|
+
plexus-cli offer list [--all] # --all includes archived (soft-deleted)
|
|
546
|
+
|
|
547
|
+
plexus-cli --env <env> offer level add --slug kosmetik-bs --name "Jahr 1" --name-en "Year 1" --grade 1 [--key jahr-1]
|
|
548
|
+
plexus-cli --env <env> offer level rename --slug kosmetik-bs --level jahr-1 --name "Jahrgang 1" --name-en "Year 1"
|
|
549
|
+
plexus-cli offer level remove|restore --slug kosmetik-bs --level jahr-1
|
|
550
|
+
|
|
551
|
+
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
|
|
552
|
+
plexus-cli offer run set|archive|restore --slug kosmetik-bs --run 2026-27
|
|
553
|
+
|
|
554
|
+
plexus-cli --env <env> offer capacity set --slug kosmetik-bs --run 2026-27 --level jahr-1 --capacity 15|unlimited [--open|--close]
|
|
555
|
+
|
|
556
|
+
plexus-cli offer delete|restore --slug kosmetik-bs # soft-delete (archive) / restore
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
**Deletes are soft (archive) — restorable, never orphaning.** `offer delete`,
|
|
560
|
+
`offer run archive`, and `offer level remove` set an archive marker: the row stays and
|
|
561
|
+
keeps resolving for anything that references it (offering cells, applications), but the
|
|
562
|
+
item is hidden from active lists and is **not applyable** in the portal. `restore`
|
|
563
|
+
clears the marker. There is **no hard delete**. `offer level remove` is **refused**
|
|
564
|
+
(`[stage/hasActiveApplications]`) while an active application sits on the level —
|
|
565
|
+
rename it instead; open leads on it only print a warning.
|
|
566
|
+
|
|
567
|
+
A level's identity is its **key** (`offer level add --key`, or derived from `--name`
|
|
568
|
+
when omitted — and it keeps that value after a rename); a run's is its key too
|
|
569
|
+
(`offer run add --key`), a unit's its config key. `--level`, `--run` and `--unit`
|
|
570
|
+
take those keys, never a name. The bundle carries the same keys; see "Stage
|
|
571
|
+
identity" below.
|
|
572
|
+
|
|
573
|
+
**Names a reader sees are German AND English.** Every flag that sets one
|
|
574
|
+
has an English twin (`--title-en`, `--name-en`, `--label-en`, `--program-name-en`);
|
|
575
|
+
creating needs both. Changing only the English keeps the German. Changing only
|
|
576
|
+
the German follows the one edit rule of every config text (`editedLocalizedText`):
|
|
577
|
+
refused when the stored English is a real translation (it would describe the old
|
|
578
|
+
German); an untranslated English (blank, or a copy of the German) becomes blank —
|
|
579
|
+
the German is never copied into it. A name is written by a mutation that refuses
|
|
580
|
+
a blank language, so for a name the CLI refuses that up front: a German rename
|
|
581
|
+
always needs its English twin (`--name-en`, `--title-en`, `--label-en`).
|
|
582
|
+
|
|
583
|
+
### Declarative config-as-code (bundles, for promotion)
|
|
584
|
+
|
|
585
|
+
Offers are also versioned bundles on disk — use this for cross-env promotion or a
|
|
586
|
+
reviewable whole-offer change.
|
|
587
|
+
|
|
588
|
+
**A real tenant's bundle is never committed.** Prod is its source of truth: export
|
|
589
|
+
it from prod into a scratch dir OUTSIDE the repo, edit, `plan`, `apply` — recipe:
|
|
590
|
+
[edit-live-tenant-offer](playbooks/offer/edit-live-tenant-offer.md). Always pass `--env`
|
|
591
|
+
(the default is prod) and `--out` (the default `offers/<tenant>/<slug>` is relative to the
|
|
592
|
+
current directory). Staging gets a real tenant's bundle only by an explicit
|
|
593
|
+
`export --env prod` → `apply --env staging`. Only the `test` tenant's offers are in git,
|
|
594
|
+
and those come from the seed (`app/config/templates/test/**`), not from a bundle dir.
|
|
595
|
+
|
|
596
|
+
```
|
|
597
|
+
offers/<tenant>/<offerSlug>/
|
|
598
|
+
bundle.yaml # index — module, tenant, slugs, label, links, program, pinned
|
|
599
|
+
manifest.json # == saveManifestDraft.data (incl. progressLayout)
|
|
600
|
+
smartform.json # OPTIONAL — == saveSmartFormDraft.definition (absent for a draft offer)
|
|
601
|
+
workflow.json # OPTIONAL — { graph, progressWiring } (absent for a draft offer)
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
```bash
|
|
605
|
+
plexus-cli offer export --tenant test --slug grundschule-havelland --out offers/test/gymnasium
|
|
606
|
+
# edit bundle.yaml (slugs/label/unit/stages/runs) + the JSON blobs
|
|
607
|
+
plexus-cli offer validate --dir offers/test/gymnasium # offline, real publish gate
|
|
608
|
+
plexus-cli offer plan --dir offers/test/gymnasium # remote↔local diff (smartform/workflow = ∅ when absent)
|
|
609
|
+
plexus-cli --env <env> offer apply [--publish] --dir offers/test/gymnasium
|
|
610
|
+
plexus-cli offer status --tenant test --slug gymnasium-havelland # applyable?
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
**Stage identity.** `program.stages[]` entries are `{ key, name, gradeLevel? }`; `key`
|
|
614
|
+
is required (`offer validate`). `offer apply` matches levels **by key**: a changed name
|
|
615
|
+
renames the same row, a new key adds a level, a removed key archives it (refused while
|
|
616
|
+
an active application sits on it), a re-listed archived key restores it, and the array
|
|
617
|
+
order is the display order. `offer plan` lists each change as `~stage` / `+stage` /
|
|
618
|
+
`-stage`, and marks a `-stage` the apply would refuse (`REFUSED: n active
|
|
619
|
+
application(s)`). Rules: `docs/engineering/config-as-code/README.md` → "Stage identity";
|
|
620
|
+
recipe: [Rename a program stage](./playbooks/config/rename-program-stage.md).
|
|
621
|
+
|
|
622
|
+
**Capacities.** Each `program.runs[]` entry carries its cells as
|
|
623
|
+
`capacities: { <stageKey>: { isOpen, capacity } }` (`capacity`: whole number,
|
|
624
|
+
`unlimited`, or `null` = unconfigured). Export writes every cell; `offer plan` shows each
|
|
625
|
+
changed cell as `~cap <run>×<stage> from → to`; `offer apply` writes only those (listed
|
|
626
|
+
cells are authoritative, unlisted ones untouched). Recipe:
|
|
627
|
+
[Set an offer's seat capacities](./playbooks/offer/set-offer-capacities.md).
|
|
628
|
+
|
|
629
|
+
A **workflow-less draft** exports/applies a **manifest-only** bundle (no
|
|
630
|
+
`smartform.json` / `workflow.json`). Apply order mirrors the seed
|
|
631
|
+
(`init/seed/admission/seedAdmissionPortal.ts`): manifest → scaffold (pedagogy-unit link
|
|
632
|
+
+ program spine) → smartform (if present) → workflow (if present). Drafts are written
|
|
633
|
+
**only on a canonical-JSON diff** (byte-equal blob = no-op), but the **offer flip is
|
|
634
|
+
decided against the LIVE generation**: `--publish` releases whenever the draft says
|
|
635
|
+
something the live head does not, even when your bundle changed nothing. `--stage
|
|
636
|
+
internal` stages the draft as the testable candidate instead of releasing it, and
|
|
637
|
+
`offer status` prints the draft / internal / live revisions plus whether a release is
|
|
638
|
+
pending. Recipe: [Release an offer bundle](./playbooks/offer/publish-offer-bundle.md).
|
|
639
|
+
The imperative commands and `offer apply` go through the **same** backend mutations —
|
|
640
|
+
one write path, no duplication.
|
|
641
|
+
|
|
642
|
+
> **New schools:** create a brand-new school + its `pedagogy_units` bridge with
|
|
643
|
+
> `offer unit create --name "<School>" --name-en "<English>"` (or upgrade an existing unit with
|
|
644
|
+
> `offer unit ensure-pedagogy --unit <unit key>`); then `offer create --unit <unit key>`
|
|
645
|
+
> links it (a unit made by `offer unit create` has no config key yet — address it by
|
|
646
|
+
> its URL slug until config-as-code adopts it) (the link path also ensures the bridge). A unit must still belong to a
|
|
647
|
+
> tenant legal entity (auto-resolved from the tenant's first legal entity).
|
|
648
|
+
|
|
649
|
+
## Tenant config-as-code (`config` command group)
|
|
650
|
+
|
|
651
|
+
The `config` group drives the config-as-code engine (a tenant's **Stammdaten shape**:
|
|
652
|
+
custom fields, lists, display groups, roles + role-field bindings, foundation, and search
|
|
653
|
+
config) against any environment. Tenant config is **declarative + DB-authoritative** — no
|
|
654
|
+
reseeds to change live config. The model/architecture lives in
|
|
655
|
+
[`docs/engineering/config-as-code/`](../../../docs/engineering/config-as-code/README.md);
|
|
656
|
+
the copy-paste task recipes live in [`playbooks/config/`](./playbooks/config). This section
|
|
657
|
+
is the **command reference**.
|
|
658
|
+
|
|
659
|
+
All commands take `--env <local|staging|prod>` and `--json`. They call the **public,
|
|
660
|
+
gated** surface (`convex/config/api.ts`, one gate `convex/config/access.ts`), so the token
|
|
661
|
+
owner must hold `_platform.config.read` (read/dry-run) or `_platform.config.manage` (commit).
|
|
662
|
+
The local rdu admin key does **not** satisfy the gate — use a token even locally (see Auth).
|
|
663
|
+
|
|
664
|
+
**A school's own administrator.** The tenant rights `administration.config.read` /
|
|
665
|
+
`.manage` (part of the derived `tenant_admin`; held tenant-wide) open `config export`, `config apply`,
|
|
666
|
+
`config set` and `config targets --tenant` for **that tenant only**. A tenant-scoped apply
|
|
667
|
+
is refused (`FORBIDDEN`, naming the field) when it would create a tenant, use
|
|
668
|
+
`--from blueprint`, pass `--allow-destructive`, change a platform-owned setting — every
|
|
669
|
+
`tenant.*` key except `branding`, `brandingAssets`, `notificationChrome` (modules,
|
|
670
|
+
`defaultApplicantGroupKey`, `mailLevel`, `magicLinkLogin`, terms, …) or the import `source`
|
|
671
|
+
— name a `foundation.users` address that is not an active member with a login, or put a
|
|
672
|
+
`_platform.*` right in a group; changing groups or `foundation.users` additionally needs
|
|
673
|
+
`administration.permissions.manage` / `administration.users.manage`. `wipe`, `prune-offers`, `--scope platform` and setup mode
|
|
674
|
+
stay platform-only. Recipe:
|
|
675
|
+
[playbooks/users/cli-access-for-tenant-admin.md](./playbooks/users/cli-access-for-tenant-admin.md).
|
|
676
|
+
|
|
677
|
+
```
|
|
678
|
+
plexus-cli config show # resolved connection for the env (no network)
|
|
679
|
+
plexus-cli config targets --tenant <slug> [--json] # the registry + live per-tenant counts
|
|
680
|
+
plexus-cli --env <env> config apply --tenant <slug>|--scope platform \
|
|
681
|
+
--from <blueprint|env:<src>|snapshot:<path>> \
|
|
682
|
+
[--confirm] [--allow-destructive] \
|
|
683
|
+
[--output <file>] [--quiet] [--json]
|
|
684
|
+
plexus-cli --env <env> config set --tenant <slug> --target <id> --field <path> --value <v> [--confirm] [--json]
|
|
685
|
+
plexus-cli config export --tenant <slug> [--out <path>] # live → app/config/.dev/<slug>/config.json
|
|
686
|
+
plexus-cli --env <env> config wipe --tenant <slug> --target <id|all> [--cascade] [--confirm] [--allow-destructive] [--json]
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
Assignment rules are **not** a tenant config target: the rule is part of each offer's
|
|
690
|
+
workflow (`graph.assignmentRules` in the bundle's `workflow.json`) and travels with
|
|
691
|
+
`offer export` / `offer apply`. See `playbooks/offer/assignment-rule.md`.
|
|
692
|
+
|
|
693
|
+
**Two granularities, one gate.** A surgical single-field `set` and a whole-tenant `apply` are
|
|
694
|
+
the same engine — same validators (`tenantConfig/lib/validate.ts`), same diff/patch primitive.
|
|
695
|
+
`set` patches one field of one existing row; it **cannot delete a row** (use export → edit →
|
|
696
|
+
apply for removals).
|
|
697
|
+
|
|
698
|
+
### `apply` — the core verb
|
|
699
|
+
- **Dry-run unless `--confirm`.** The dry-run output *is* the plan: aggregate
|
|
700
|
+
`created / updated / deleted / unchanged` counts, plus:
|
|
701
|
+
- `~ update <target> <key>` — each config row the apply **patches** (admission custom
|
|
702
|
+
fields / derived attributes included, dry run too); an idempotent row counts as
|
|
703
|
+
unchanged, so `updated` is exactly the number of these rows.
|
|
704
|
+
- `- delete <target> <key>` — each config row the reconciler **prunes** because it is absent
|
|
705
|
+
from the source (search targets/filters/options, result icons, role-fields, target
|
|
706
|
+
requirements, template placeholders). These need **no** `--allow-destructive`.
|
|
707
|
+
- `- orphan <target>/<table> <key>` — rows in the five upsert-only tables (display-groups,
|
|
708
|
+
lists, custom_fields, roles, permission_groups) the reconciler will **not** auto-delete;
|
|
709
|
+
labelled *would destroy* and removed only with `--confirm --allow-destructive`.
|
|
710
|
+
- `~ slug units/<key> <from> → <to>` — an existing unit whose config `slug` moves its URL.
|
|
711
|
+
The old `/e/units/<from>` (and `?unit=<from>`) no longer reaches this unit — there is no
|
|
712
|
+
redirect, and a unit created later may take `<from>`.
|
|
713
|
+
A slug another unit holds fails the apply with `[unit/slugTaken]`.
|
|
714
|
+
- **A committing apply can return secrets.** If the config carries `foundation.users`,
|
|
715
|
+
every listed user that has no login account yet gets one **minted here**, and its
|
|
716
|
+
generated password is printed as a `+ account <email> password: …` row — once, because
|
|
717
|
+
the server keeps only the hash. So `apply --confirm` on such a tenant is not safe to run
|
|
718
|
+
in a shared terminal or a pasted log unless you redirect them:
|
|
719
|
+
- `--output <file>` — append each `email,password` to that CSV (header written when the
|
|
720
|
+
file is new/empty). Same flag, same format, same append semantics as `users provision`.
|
|
721
|
+
- `--quiet` — keep the plaintext off stdout (the row then reads `password: (not echoed)`);
|
|
722
|
+
use it **with** `--output`, or the password is gone.
|
|
723
|
+
- A **dry run mints nothing**: the flags are inert there, and `--output` neither creates
|
|
724
|
+
nor touches the file. The plan instead shows `! no account <email>` per user who *would*
|
|
725
|
+
get one.
|
|
726
|
+
- `--from blueprint` — the repo template (only `test` today).
|
|
727
|
+
- `--from env:<src>` — **cross-env promote**: opens a second client for `<src>`, exports its
|
|
728
|
+
live config, and applies it inline to `--env` (re-plans against the target's live state).
|
|
729
|
+
- `--from snapshot:<path>` — apply a `.dev` JSON snapshot.
|
|
730
|
+
- `--scope platform` — platform-scope config (a stub today; no platform targets yet).
|
|
731
|
+
|
|
732
|
+
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? }`.
|
|
733
|
+
Note that `--json` prints `accounts.minted` verbatim — `--quiet` governs the table only,
|
|
734
|
+
exactly as on `users provision`.
|
|
735
|
+
|
|
736
|
+
### `set` — surgical edit
|
|
737
|
+
`--target` is a setField locator (`display-groups`, `fields`, `lists`, `roles`, `units`,
|
|
738
|
+
`permission-groups`, `legal-entities`, `search-filters`, `party-result-icons`) — these differ
|
|
739
|
+
from the `config targets` registry ids. `--field` is a dot-path (`label.de`). The whole
|
|
740
|
+
resulting config is validated before the inline apply, so an invalid edit fails loud and writes
|
|
741
|
+
nothing.
|
|
742
|
+
|
|
743
|
+
### `export` — cross-env snapshot producer
|
|
744
|
+
Reconstructs a full `TenantConfigDefinition` from live rows and writes
|
|
745
|
+
`app/config/.dev/<tenant>/config.json` (gitignored). The round-trip is **exact** — feeding the
|
|
746
|
+
export back through `apply --from snapshot:` yields all-unchanged — so it is how a real tenant's
|
|
747
|
+
config moves between environments. **Prod is the source of truth for every real tenant**:
|
|
748
|
+
export from `--env prod`, never from a copy in the repo — no real tenant's config is
|
|
749
|
+
committed (the former one-time snapshot fixtures are gone; a test fails the gate on any real
|
|
750
|
+
tenant's file under `app/config/`). Staging gets it only by an explicit
|
|
751
|
+
`config apply --env staging --from env:prod` (or a prod snapshot).
|
|
752
|
+
|
|
753
|
+
### `files` — the tenant file library (blank templates)
|
|
754
|
+
A school's Vorlagen (`tenant_files`) ride the config as `files[]`: `key`, `label`,
|
|
755
|
+
`filename`, `mimeType`, and the content **by hash** (`sha256`, `sizeBytes`) — never inline.
|
|
756
|
+
An export from before file references (`fileName` / `size`) is refused with `[config/fileRefRenamed]`:
|
|
757
|
+
export again. `export` also downloads each file to `files/<key>/<filename>` beside `config.json` and
|
|
758
|
+
writes its `path`; on `apply --from snapshot:` the file on disk decides the hash. `apply`
|
|
759
|
+
uploads only the files whose hash the target does not hold (a dry run uploads nothing and
|
|
760
|
+
lists them), `--from env:` downloads them from the source env, and the `test` blueprint's
|
|
761
|
+
bytes are bundled server-side. A stored file absent from `files` is reported as an **orphan**
|
|
762
|
+
and removed only with `--allow-destructive` — then still refused with `[file/inUse]` while any
|
|
763
|
+
form version names it. Library writes accept only uploads staged for this tenant's library
|
|
764
|
+
(a `tenantFiles` upload ticket, bound with `uploads.registerUpload`); a `filename` is one path segment. Direct verbs:
|
|
765
|
+
|
|
766
|
+
```bash
|
|
767
|
+
plexus-cli --tenant <slug> files list
|
|
768
|
+
plexus-cli --env <env> --tenant <slug> files put <key> <path> [--label "…" --label-en "…"] [--file-name …] # new key: both languages
|
|
769
|
+
plexus-cli --env <env> --tenant <slug> files remove <key> # alias: files rm
|
|
770
|
+
plexus-cli offer form field attach --dir <bundle> --key <field> --file-key <key> [--render download|inlineLink] [--label … --label-en …]
|
|
771
|
+
plexus-cli offer form field detach --dir <bundle> --key <field> --file-key <key>
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
Recipe: [`playbooks/config/attach-a-template.md`](./playbooks/config/attach-a-template.md).
|
|
775
|
+
|
|
776
|
+
### `wipe`
|
|
777
|
+
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;
|
|
778
|
+
the heavy tier (the `masterdata` and `contract-setup` data targets, the `foundation` config target, or `--target all`)
|
|
779
|
+
additionally needs `--allow-destructive`. The config↔data interlock blocks a config wipe with
|
|
780
|
+
dependent live data unless `--cascade`. `--target all` is the teardown: it purges the applications,
|
|
781
|
+
drains every target, then deletes the tenant row. Every wipe deletes the stored files only its rows
|
|
782
|
+
held (the logo stays: sent mail links it). See [`playbooks/config/wipe-tenant-config.md`](./playbooks/config/wipe-tenant-config.md).
|
|
783
|
+
|
|
784
|
+
### Migrations are not a `config` verb
|
|
785
|
+
Renames / field-type changes — anything that changes the **shape** of existing rows — run
|
|
786
|
+
through the standard migrations lane (`convex/migrations.ts` + `migrations:runPending`),
|
|
787
|
+
deployment-globally, **before** `bootstrap`/`apply`. `apply` only moves config *values* into the
|
|
788
|
+
shape that already exists. For a manual local run: `npx convex run migrations:runPending`.
|
|
789
|
+
|
|
790
|
+
### Deploy protocol (local + steady state)
|
|
791
|
+
- **Local dev:** `pnpm dev` on a fresh DB runs `config/bootstrap`, which idempotently applies the
|
|
792
|
+
template tenant(s) + demo. Develop against the `test` template; change it with
|
|
793
|
+
`config apply --tenant test --env local --confirm` (no wipe).
|
|
794
|
+
- **Staging / prod (every deploy):** CI runs, after `migrations:runPending`,
|
|
795
|
+
`npx convex run config/bootstrap:bootstrap '{"demo":<true on staging|false on prod>}'`.
|
|
796
|
+
`bootstrap` is **idempotent and non-destructive** — it applies `--scope platform` + the
|
|
797
|
+
template tenants (`TEMPLATE_TENANT_KEYS = ["test"]`) + (staging) demo. **Real tenants are never
|
|
798
|
+
touched** by CI; establish/update them manually (see the playbooks).
|
|
799
|
+
- **Setup mode** (platform-admin right-rail tab) is the in-app data-plane half of the onboarding
|
|
800
|
+
loop: edit config (CLI) → load N sampled rows → test → wipe the subset → adjust → reload.
|
|
801
|
+
|
|
802
|
+
### Auth
|
|
803
|
+
Config commands need a platform-admin **JWT**; point your `~/.config/agent-tools/plexus.yml`
|
|
804
|
+
`<env>` entry at it. Locally, mint one for a seeded platform admin (`DEV_MODE`):
|
|
805
|
+
|
|
806
|
+
```bash
|
|
807
|
+
npx convex run --url <convex-url> --admin-key <key> apiTokens:devSeedToken \
|
|
808
|
+
'{"email":"admin@plexus.swop.schule","name":"config-cli","plaintext":"plx_..."}'
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
After adding new `_platform.*` perms, re-sync the admin's row (locally: `pnpm seed`; staging/prod:
|
|
812
|
+
call `init/seed/seedPlatformAdmins:seedPlatformAdminMut` per admin). For automation that
|
|
813
|
+
legitimately bypasses the user gate (CI, deploy bootstrap), call the **internal** engine
|
|
814
|
+
functions with the admin key rather than the public `config/api:*`.
|
|
815
|
+
|
|
816
|
+
## Architecture
|
|
817
|
+
|
|
818
|
+
```
|
|
819
|
+
core/ # GENERIC, brand-neutral, zero backend-type imports
|
|
820
|
+
transport.ts auth.ts jwt-cache.ts config.ts output.ts spec.ts command.ts
|
|
821
|
+
plexus/ # BRANDED — imports backend types + validators
|
|
822
|
+
branding.ts client.ts command.ts commands/* modules/admission.ts offer/*
|
|
823
|
+
cli.ts # clipanion bootstrap — registers every command group
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
See `CLAUDE.md` in this directory for the design rules (the thin-client /
|
|
827
|
+
"where does a change belong" principle).
|