terp-cli 0.25.0__tar.gz → 0.28.0__tar.gz

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.
Files changed (40) hide show
  1. {terp_cli-0.25.0 → terp_cli-0.28.0}/PKG-INFO +8 -8
  2. {terp_cli-0.25.0 → terp_cli-0.28.0}/pyproject.toml +8 -8
  3. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/__init__.py +110 -15
  4. terp_cli-0.28.0/src/terp/cli/_output.py +60 -0
  5. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/capabilities.py +33 -1
  6. terp_cli-0.28.0/src/terp/cli/dev.py +472 -0
  7. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/verify.py +129 -11
  8. terp_cli-0.25.0/src/terp/cli/_output.py +0 -26
  9. terp_cli-0.25.0/src/terp/cli/dev.py +0 -240
  10. {terp_cli-0.25.0 → terp_cli-0.28.0}/.gitignore +0 -0
  11. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/_appref.py +0 -0
  12. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/_engine.py +0 -0
  13. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/_subjects.py +0 -0
  14. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/access.py +0 -0
  15. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/apidocs.py +0 -0
  16. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/authz_surface.py +0 -0
  17. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/deploy_safety.py +0 -0
  18. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/docker.py +0 -0
  19. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/envfile.py +0 -0
  20. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/envschema.py +0 -0
  21. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/envseams.py +0 -0
  22. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/fmt.py +0 -0
  23. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/grants.py +0 -0
  24. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/jobs.py +0 -0
  25. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/leases.py +0 -0
  26. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/module_roles.py +0 -0
  27. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/openapi.py +0 -0
  28. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/outbox.py +0 -0
  29. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/ports.py +0 -0
  30. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/profiles.py +0 -0
  31. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/py.typed +0 -0
  32. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/routes.py +0 -0
  33. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/scaffold.py +0 -0
  34. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/schema.py +0 -0
  35. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/seed.py +0 -0
  36. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/service_accounts.py +0 -0
  37. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/smoke.py +0 -0
  38. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/users.py +0 -0
  39. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/version.py +0 -0
  40. {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/workbench.py +0 -0
@@ -1,19 +1,19 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: terp-cli
3
- Version: 0.25.0
3
+ Version: 0.28.0
4
4
  Summary: Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs.
5
5
  Project-URL: Repository, https://github.com/AITT-NL/terp-framework
6
6
  Project-URL: Changelog, https://github.com/AITT-NL/terp-framework/blob/main/CHANGELOG.md
7
7
  License-Expression: Apache-2.0
8
8
  Requires-Python: >=3.13
9
9
  Requires-Dist: pyyaml>=6.0
10
- Requires-Dist: terp-arch==0.25.0
11
- Requires-Dist: terp-core==0.25.0
12
- Requires-Dist: terp-migrations==0.25.0
10
+ Requires-Dist: terp-arch==0.28.0
11
+ Requires-Dist: terp-core==0.28.0
12
+ Requires-Dist: terp-migrations==0.28.0
13
13
  Provides-Extra: jobs
14
- Requires-Dist: terp-cap-outbox==0.25.0; extra == 'jobs'
15
- Requires-Dist: terp-cap-scheduler-apscheduler==0.25.0; extra == 'jobs'
14
+ Requires-Dist: terp-cap-outbox==0.28.0; extra == 'jobs'
15
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.28.0; extra == 'jobs'
16
16
  Provides-Extra: scheduler
17
- Requires-Dist: terp-cap-scheduler-apscheduler==0.25.0; extra == 'scheduler'
17
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.28.0; extra == 'scheduler'
18
18
  Provides-Extra: worker
19
- Requires-Dist: terp-cap-outbox==0.25.0; extra == 'worker'
19
+ Requires-Dist: terp-cap-outbox==0.28.0; extra == 'worker'
@@ -4,18 +4,18 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "terp-cli"
7
- version = "0.25.0"
7
+ version = "0.28.0"
8
8
  description = "Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs."
9
9
  requires-python = ">=3.13"
10
10
  license = "Apache-2.0"
11
11
  dependencies = [
12
- "terp-core==0.25.0",
12
+ "terp-core==0.28.0",
13
13
  # `terp migrate` delegates to terp-migrations (lazily imported), keeping Alembic
14
14
  # off the path for `terp inspect` / `terp guide` while making migrations work.
15
- "terp-migrations==0.25.0",
15
+ "terp-migrations==0.28.0",
16
16
  # `terp guide rules` projects the live rule registry; terp-arch is imported lazily
17
17
  # (only when that topic is rendered), so it stays off the common `terp guide` path.
18
- "terp-arch==0.25.0",
18
+ "terp-arch==0.28.0",
19
19
  # `terp verify --only env-seams` and `terp smoke` read the compose profiles as data
20
20
  # (anchors and `<<:` merge keys resolved), so both answer without a Docker daemon or
21
21
  # even the `docker` binary. Declared explicitly rather than leant on: every app
@@ -29,11 +29,11 @@ dependencies = [
29
29
  # or a scheduler engine into apps that only use inspect/check/migrate. Deployments can select
30
30
  # one process role, while `jobs` is the convenient complete jobs-process bundle.
31
31
  [project.optional-dependencies]
32
- worker = ["terp-cap-outbox==0.25.0"]
33
- scheduler = ["terp-cap-scheduler-apscheduler==0.25.0"]
32
+ worker = ["terp-cap-outbox==0.28.0"]
33
+ scheduler = ["terp-cap-scheduler-apscheduler==0.28.0"]
34
34
  jobs = [
35
- "terp-cap-outbox==0.25.0",
36
- "terp-cap-scheduler-apscheduler==0.25.0",
35
+ "terp-cap-outbox==0.28.0",
36
+ "terp-cap-scheduler-apscheduler==0.28.0",
37
37
  ]
38
38
 
39
39
  [project.scripts]
@@ -16,7 +16,7 @@ from terp.core import ControlPlane, CorsPolicy, ModuleSpec
16
16
  if TYPE_CHECKING: # terp-arch stays off the common `terp guide` / `terp inspect` path
17
17
  from terp.arch import ScanRoot
18
18
 
19
- from terp.cli._output import emit
19
+ from terp.cli._output import emit, use_utf8_output
20
20
  from terp.cli.access import (
21
21
  build_access_graph_for_app,
22
22
  render_access,
@@ -229,8 +229,11 @@ Adopt it by declaring the table (a project from the template already has it):
229
229
 
230
230
  [tool.deptry.per_rule_ignores]
231
231
  # `terp.*` is one PEP 420 namespace across distributions, and pydantic
232
- # re-exports through sqlmodel / pydantic-settings.
233
- DEP003 = ["terp", "pydantic"]
232
+ # re-exports through sqlmodel / pydantic-settings. fastapi, sqlmodel,
233
+ # sqlalchemy and alembic are the stack terp-core and terp-migrations bring,
234
+ # which the template's code, `terp new module` and `terp migrate make` all
235
+ # import directly: constrained by that release, not by a second pin here.
236
+ DEP003 = ["terp", "pydantic", "fastapi", "sqlmodel", "sqlalchemy", "alembic"]
234
237
 
235
238
  and installing the tool: `uv add --dev deptry`.
236
239
 
@@ -1554,7 +1557,82 @@ Using capabilities
1554
1557
  api_key, token, ...) to a string literal — or a recognizable secret-token literal
1555
1558
  anywhere — is refused by the no_hardcoded_credentials rule. Wire secrets through
1556
1559
  settings / sealed config (ADR 0055), never source.
1557
- """, "migrations": """\
1560
+ - Sending e-mail is a capability too: `import smtplib` is refused by the same
1561
+ no_raw_outbound_http rule, and terp-cap-mail is where it goes — `terp guide mail`.
1562
+ """,
1563
+ "mail": """\
1564
+ Sending e-mail (terp-cap-mail)
1565
+
1566
+ - Mail is a capability concern, never a module concern: `import smtplib` anywhere in the
1567
+ app is refused by the no_raw_outbound_http rule. Install the capability:
1568
+ uv add terp-cap-mail
1569
+ - ONE relay per application, declared ONCE in the composition root (app/main.py) from
1570
+ fixed environment variables:
1571
+ import os
1572
+ from terp.capabilities.mail import configure_mail, mail_settings_from_environment
1573
+ configure_mail(mail_settings_from_environment(os.environ))
1574
+ MAIL_FROM the sender of EVERY message: "Name <noreply@example.com>" or a bare
1575
+ address
1576
+ SMTP_HOST the relay's bare hostname (smtp.example.com)
1577
+ SMTP_PORT optional: 587 for starttls, 465 for tls
1578
+ SMTP_SECURITY starttls (the default) | tls | none
1579
+ SMTP_USERNAME / SMTP_PASSWORD set together, or neither
1580
+ The session is encrypted and the certificate verified; there is no setting that turns
1581
+ verification off. `none` exists for a local mail catcher: a production boot refuses
1582
+ it, and signing in over it is refused everywhere. With neither MAIL_FROM nor SMTP_HOST set,
1583
+ development LOGS each message instead of delivering it and a production boot is
1584
+ REFUSED. Declare the variables in environment.schema.json so a deployment renders them
1585
+ per environment: SMTP_PASSWORD with "format": "secret", SMTP_HOST "format":
1586
+ "hostname", SMTP_PORT "format": "port", and MAIL_FROM + SMTP_HOST in "required" so a
1587
+ deploy without a relay is refused before it boots.
1588
+ - Register the job the capability enqueues (control_plane/jobs.py):
1589
+ from terp.capabilities.mail import MAIL_SEND
1590
+ job_catalog = JobCatalog([MAIL_SEND])
1591
+ A job catalog needs ControlPlane(job_system_actor_id=...) in production — `terp guide
1592
+ jobs`.
1593
+ - Send from INSIDE the write the mail is about: the service's _after_write hook runs in
1594
+ that write's transaction, so the mail commits - or rolls back - with it. A send_mail
1595
+ after the write has returned is a transaction of its own, and nothing ties the two.
1596
+ from terp.core import AuditAction
1597
+ from terp.capabilities.mail import MailMessage, send_mail
1598
+ class OrderService(BaseService[Order, OrderCreate, OrderUpdate]):
1599
+ def _after_write(self, session, entity, action):
1600
+ if action is AuditAction.UPDATED and entity.status == "shipped":
1601
+ send_mail(session, MailMessage(
1602
+ to=[entity.customer_email], # plain addresses, at most MAX_RECIPIENTS
1603
+ subject="Your order has shipped", # one line: a line break is refused
1604
+ text=f"Order {entity.number} is on its way.", # plain text
1605
+ reply_to="sales@example.com", # optional: where a person's answer goes
1606
+ ))
1607
+ List the job on the sending module, ModuleSpec(jobs=[MAIL_SEND]), so boot checks that
1608
+ the catalog declares it.
1609
+ There is no per-message sender (every mail is from MAIL_FROM), no HTML body and no
1610
+ attachment. The same notice to many people is one send_mail per person, which also
1611
+ keeps every recipient's address private from the others.
1612
+ - send_mail only ENQUEUES the MAIL_SEND job. Wire the durable outbox so the mail commits
1613
+ — or rolls back — with the write, and survives a restart:
1614
+ from terp.capabilities.outbox import OutboxJobQueue
1615
+ create_app(..., job_queue=OutboxJobQueue(),
1616
+ require_durable_jobs=settings.is_production)
1617
+ and run `terp jobs worker` beside the API. A relay that is down is retried with
1618
+ backoff and ends dead-lettered: `terp outbox backlog` counts it and `terp outbox
1619
+ dead-letters` lists it. With the
1620
+ in-process default queue (no worker) the mail is sent inline, and a failing relay
1621
+ fails the request that asked for it.
1622
+ - Test without a relay:
1623
+ from terp.capabilities.mail import (
1624
+ CapturingMailTransport, MailSettings, configure_mail, reset_mail)
1625
+ sent = CapturingMailTransport()
1626
+ configure_mail(MailSettings(sender="App <noreply@example.test>",
1627
+ host="smtp.example.test"), transport=sent)
1628
+ ...exercise the feature...
1629
+ assert sent.sent[0]["Subject"] == "Your order has shipped"
1630
+ reset_mail()
1631
+ - A provider reachable only over its HTTP API is a different transport, declared in the
1632
+ same place: configure_mail(settings, transport=<a callable built on
1633
+ terp.capabilities.egress>). Call sites stay send_mail either way.
1634
+ """,
1635
+ "migrations": """\
1558
1636
  Database migrations (terp migrate)
1559
1637
 
1560
1638
  - Each table-owning package (capability or app module) owns an INDEPENDENT, linear
@@ -1684,6 +1762,10 @@ Frontend module screens (@terpjs/react-core)
1684
1762
  style={} / className / module stylesheets -> layout via Stack/DetailList; design tokens
1685
1763
  <a href="/..."> -> the router's Link (role-aware, no reload)
1686
1764
  deep imports (@terpjs/*/src, @terpjs/*/dist) -> import from the package root only
1765
+ data-terp / data-terp-* anywhere in src -> compose the component that renders it
1766
+ The data-terp markers are react-core's own: its stylesheet and the runtime layout check
1767
+ trust them. A framework screen you replace (renderTerpApp({ login })) is yours, so its
1768
+ tests find it by role and accessible name, not by the framework screen's markers.
1687
1769
  - Frontend security defaults (each its own lint rule, same error-only footing):
1688
1770
  dangerouslySetInnerHTML and DOM HTML-injection sinks (innerHTML/outerHTML/
1689
1771
  insertAdjacentHTML/document.write) are refused — render text, or Markdown from
@@ -1709,6 +1791,12 @@ Frontend module screens (@terpjs/react-core)
1709
1791
  OpenAPI export) and unwrap(...) which throws a typed ApiError carrying code/status.
1710
1792
  - The one governed opt-out is a justified `// terp-allow-<rule>: <reason>` marker whose
1711
1793
  counts must exactly match the app's checked-in escape-hatch-budget.json (a ratchet).
1794
+ - Unit tests sit beside the code as src/**/*.test.tsx and run with
1795
+ npm --prefix frontend test (vitest in jsdom; `terp verify` runs it too). jsdom has no
1796
+ <dialog> modal API, so frontend/vitest.setup.ts calls installDialogPolyfill() from
1797
+ @terpjs/react-core/testing: a test can open a ConfirmDialog and confirm it, cancel it or
1798
+ press Escape. Focus and the inert page behind a modal are not emulated there; they
1799
+ belong to the browser suite in conformance/.
1712
1800
  - Run the lint locally: npm --prefix frontend run lint (part of the gate).
1713
1801
  """,
1714
1802
  "dataview": """\
@@ -2142,11 +2230,15 @@ Compliant decision path for outbound HTTP
2142
2230
  1. Preserve the requested integration and its external contract. Removing the live
2143
2231
  call, returning static/local data, or moving the client import to an unscanned
2144
2232
  helper only to make the gate green is not a compliant fix.
2145
- 2. Use a maintained purpose-built capability when its semantics match. For example,
2146
- terp-cap-webhooks owns signed webhook POST delivery; it is not a generic GET client.
2147
- 3. The maintained Terp capability surface currently has no generic outbound-fetch
2148
- capability for arbitrary HTTP GETs. App modules therefore cannot implement a live
2149
- news/feed fetch through a sanctioned generic API today.
2233
+ 2. Use the maintained capability whose semantics match:
2234
+ - an HTTP call to a host you can name: terp-cap-egress (EgressClient, with that
2235
+ exact host on its EgressPolicy allowlist);
2236
+ - signed webhook POST delivery to subscriber URLs: terp-cap-webhooks;
2237
+ - sending e-mail: terp-cap-mail (configure_mail once in the composition root,
2238
+ send_mail per message) - never smtplib. `terp guide mail`.
2239
+ 3. There is no sanctioned path to an ARBITRARY host: EgressPolicy takes exact hostnames
2240
+ and refuses wildcards, so a feature that must fetch whatever URL a user supplies has
2241
+ no compliant implementation today.
2150
2242
  4. When no matching capability exists, stop and report the missing capability. Leave
2151
2243
  the check red until a human approves an escape hatch or the platform supplies a
2152
2244
  reviewed adapter capability. Do not create an app-local helper package merely to
@@ -3499,19 +3591,21 @@ def _build_parser() -> argparse.ArgumentParser:
3499
3591
  dev_parser.add_argument(
3500
3592
  "--port",
3501
3593
  type=int,
3502
- default=DEFAULT_API_PORT,
3594
+ default=None,
3503
3595
  help=(
3504
- f"Backend host port (default: {DEFAULT_API_PORT}) -- in the range Terp owns, "
3505
- "away from the 8000 another application on this machine is probably using"
3596
+ "Backend host port (default: the pair claimed for this checkout, the one "
3597
+ "`terp ports show` prints and a workbench and docker compose use; claimed "
3598
+ f"and published on first run; {DEFAULT_API_PORT} if none can be claimed)"
3506
3599
  ),
3507
3600
  )
3508
3601
  dev_parser.add_argument(
3509
3602
  "--web-port",
3510
3603
  type=int,
3511
- default=DEFAULT_WEB_PORT,
3604
+ default=None,
3512
3605
  help=(
3513
- f"Frontend host port (default: {DEFAULT_WEB_PORT}); passed through to the "
3514
- "frontend dev server, which would otherwise take its own 5173"
3606
+ "Frontend host port (default: the web half of the same claimed pair; "
3607
+ f"{DEFAULT_WEB_PORT} if none can be claimed); the frontend dev server refuses "
3608
+ "a taken port rather than wandering to the next one"
3515
3609
  ),
3516
3610
  )
3517
3611
  dev_parser.add_argument(
@@ -3853,6 +3947,7 @@ def _build_parser() -> argparse.ArgumentParser:
3853
3947
 
3854
3948
  def main(argv: Sequence[str] | None = None) -> None:
3855
3949
  """Console entry point."""
3950
+ use_utf8_output()
3856
3951
  parser = _build_parser()
3857
3952
  args = parser.parse_args(argv)
3858
3953
  if args.command == "inspect" and args.inspect_command == "control-plane":
@@ -0,0 +1,60 @@
1
+ """The one place ``terp``'s command dispatch writes to standard output.
2
+
3
+ A CLI's job is to print, so ``no_print`` is not a defect report against this package --
4
+ it is the reason ``packages/backend/cli`` is recorded in
5
+ ``packages/backend/UNSCANNED.json`` rather than scanned. What the record is for is the
6
+ direction of travel: a count there may fall and never rise, so the dispatcher acquiring
7
+ one more bare ``print`` per command added is the shape that makes it rise forever.
8
+
9
+ One seam fixes that and is worth having on its own terms. Everything the dispatcher
10
+ says to an operator goes through here, which is where a ``--quiet``, a machine-readable
11
+ envelope, or a decision to route diagnostics to stderr would go. Thirty-eight call sites
12
+ answering that question independently is thirty-eight places such a change has to find.
13
+
14
+ Deliberately not a logger. This is a command's ANSWER, not a diagnostic about producing
15
+ it: it belongs on stdout unconditionally, it is what a shell pipes into the next thing,
16
+ and a log level that could suppress it would make the tool lie about having run.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import codecs
22
+ import sys
23
+
24
+ __all__ = ["emit", "use_utf8_output"]
25
+
26
+
27
+ def emit(text: object = "") -> None:
28
+ """Write one line of command output to stdout, flushed.
29
+
30
+ Flushed because a pipe is block-buffered: a one-shot command loses nothing by it, but
31
+ ``terp dev`` runs until it is stopped, and a line saying it restarted the backend is only
32
+ worth reading while that is true. Unflushed, it sat in the buffer until the process ended,
33
+ and a stopped process's buffer is simply lost.
34
+ """
35
+ print(text, flush=True)
36
+
37
+
38
+ def use_utf8_output() -> None:
39
+ """Write standard output and error as UTF-8 where the stream would not.
40
+
41
+ On Windows a pipe or a file takes the ANSI code page -- cp1252 on a Western install --
42
+ and a pipe is exactly how ``terp`` is read by an agent, an editor task or a workbench,
43
+ none of which is a console. cp1252 has no ``→``, so the line ``terp dev`` prints before
44
+ it starts the servers ended the command with ``UnicodeEncodeError`` instead; and text
45
+ this CLI does not author -- a module's label, a finding quoting a source line -- can do
46
+ the same to any command. UTF-8 encodes every string, and it is the default Python itself
47
+ moves to for these streams (PEP 686).
48
+
49
+ Changed only where it is not already UTF-8, so a console (which Python already drives
50
+ through the console API as UTF-8) and every Linux or macOS terminal are left exactly as
51
+ they were. A stream that cannot be reconfigured -- none at all, or a replacement such
52
+ as ``io.StringIO`` -- is left alone. ``backslashreplace`` rather than ``strict``, so the
53
+ one thing UTF-8 cannot encode, a lone surrogate from an undecodable file name, prints
54
+ as an escape rather than ending the command.
55
+ """
56
+ for stream in (sys.stdout, sys.stderr):
57
+ reconfigure = getattr(stream, "reconfigure", None)
58
+ if reconfigure is None or codecs.lookup(stream.encoding).name == "utf-8":
59
+ continue
60
+ reconfigure(encoding="utf-8", errors="backslashreplace")
@@ -44,6 +44,17 @@ class Capability:
44
44
  guide: str | None = None
45
45
  """``terp guide`` topic covering it, when one exists."""
46
46
 
47
+ inventory: str | None = None
48
+ """The command that lists what this app holds of it, when the CLI has one.
49
+
50
+ The registry is built from packages, so it can say an app *has* a capability and
51
+ nothing about what the app has *done* with it. That gap misled a reader into
52
+ concluding an app had no machine credentials because the identity line said only
53
+ "user store": the service accounts it had issued were one command away, and nothing
54
+ on this screen named the command. Rendered for installed capabilities only — an
55
+ unadopted one has nothing to list.
56
+ """
57
+
47
58
  @property
48
59
  def distribution(self) -> str:
49
60
  """The PyPI distribution name."""
@@ -62,6 +73,7 @@ CAPABILITIES: tuple[Capability, ...] = (
62
73
  kind="routed",
63
74
  wiring="create_app(..., discover_capabilities=True)",
64
75
  guide="access",
76
+ inventory="terp grant list <subject>",
65
77
  ),
66
78
  Capability(
67
79
  name="audit",
@@ -90,6 +102,13 @@ CAPABILITIES: tuple[Capability, ...] = (
90
102
  wiring="create_app(..., discover_capabilities=True)",
91
103
  guide="files",
92
104
  ),
105
+ Capability(
106
+ name="mfa",
107
+ summary="A TOTP second factor with recovery codes, wired into login through the second_factor seam.",
108
+ kind="library",
109
+ wiring="build_login_module(..., second_factor=MfaService()) + specs=[mfa.module]",
110
+ guide="passwords",
111
+ ),
93
112
  Capability(
94
113
  name="groups",
95
114
  summary="Admin-managed user groups that bundle access-grant permissions.",
@@ -99,9 +118,11 @@ CAPABILITIES: tuple[Capability, ...] = (
99
118
  ),
100
119
  Capability(
101
120
  name="identity",
102
- summary="Persisted user store backing authentication.",
121
+ summary="Persisted user store backing authentication, and the service accounts an integration signs in with.",
103
122
  kind="library",
104
123
  wiring="create_app(..., discover_capabilities=True)",
124
+ guide="package-boundaries",
125
+ inventory="terp service-account list",
105
126
  ),
106
127
  Capability(
107
128
  name="jobs_celery",
@@ -123,6 +144,14 @@ CAPABILITIES: tuple[Capability, ...] = (
123
144
  kind="library",
124
145
  wiring="create_app(..., lease_store=DatabaseLeaseStore()) + specs=[leases.module]",
125
146
  guide="leases",
147
+ inventory="terp leases list",
148
+ ),
149
+ Capability(
150
+ name="mail",
151
+ summary="Send e-mail through one declared relay — encrypted, a fixed sender, delivered by the jobs seam so a send commits with its write.",
152
+ kind="library",
153
+ wiring="configure_mail(mail_settings_from_environment(os.environ)) + JobCatalog([MAIL_SEND])",
154
+ guide="mail",
126
155
  ),
127
156
  Capability(
128
157
  name="oidc",
@@ -371,6 +400,7 @@ def render_capabilities(*, fmt: str = "text", root: str | pathlib.Path = ".") ->
371
400
  "kind": cap.kind,
372
401
  "wiring": cap.wiring,
373
402
  "guide": cap.guide,
403
+ "inventory": cap.inventory,
374
404
  "installed": version is not None,
375
405
  "version": version,
376
406
  "seams": list(seams.get(cap.name, ())),
@@ -410,6 +440,8 @@ def render_capabilities(*, fmt: str = "text", root: str | pathlib.Path = ".") ->
410
440
  for cap, version in installed:
411
441
  lines.append(f" {cap.distribution:<32} {version:<10} {cap.kind}")
412
442
  lines.append(f" {cap.summary}")
443
+ if cap.inventory is not None:
444
+ lines.append(f" what this app has: uv run {cap.inventory}")
413
445
  missing = unwired.get(cap.name, ())
414
446
  if missing:
415
447
  lines.append(f" not used here: {', '.join(missing)}")