terp-cli 0.1.0__py3-none-any.whl
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.
- terp/cli/__init__.py +1633 -0
- terp/cli/_appref.py +43 -0
- terp/cli/access.py +482 -0
- terp/cli/apidocs.py +132 -0
- terp/cli/dev.py +145 -0
- terp/cli/docker.py +57 -0
- terp/cli/jobs.py +238 -0
- terp/cli/openapi.py +67 -0
- terp/cli/profiles.py +148 -0
- terp/cli/py.typed +0 -0
- terp/cli/scaffold.py +343 -0
- terp/cli/schema.py +317 -0
- terp/cli/seed.py +67 -0
- terp/cli/users.py +94 -0
- terp/cli/verify.py +469 -0
- terp_cli-0.1.0.dist-info/METADATA +16 -0
- terp_cli-0.1.0.dist-info/RECORD +19 -0
- terp_cli-0.1.0.dist-info/WHEEL +4 -0
- terp_cli-0.1.0.dist-info/entry_points.txt +2 -0
terp/cli/__init__.py
ADDED
|
@@ -0,0 +1,1633 @@
|
|
|
1
|
+
"""terp.cli — the ``terp`` command-line tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib
|
|
7
|
+
import json
|
|
8
|
+
import pathlib
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
|
|
13
|
+
from terp.core import ControlPlane, CorsPolicy, ModuleSpec
|
|
14
|
+
|
|
15
|
+
from terp.cli.access import (
|
|
16
|
+
build_access_graph_for_app,
|
|
17
|
+
render_access,
|
|
18
|
+
render_access_graph,
|
|
19
|
+
)
|
|
20
|
+
from terp.cli.apidocs import api_docs
|
|
21
|
+
from terp.cli.dev import dev_plan, run_dev_command
|
|
22
|
+
from terp.cli.docker import run_docker_dev_command
|
|
23
|
+
from terp.cli.jobs import (
|
|
24
|
+
render_jobs,
|
|
25
|
+
run_job_command,
|
|
26
|
+
run_scheduler_command,
|
|
27
|
+
run_worker_command,
|
|
28
|
+
)
|
|
29
|
+
from terp.cli.openapi import _load_app, export_openapi
|
|
30
|
+
from terp.cli.profiles import DEFAULT_PROFILE, profile_names
|
|
31
|
+
from terp.cli.scaffold import new_module, new_module_message
|
|
32
|
+
from terp.cli.schema import (
|
|
33
|
+
build_schema_graph,
|
|
34
|
+
import_declared_models,
|
|
35
|
+
render_schema_graph,
|
|
36
|
+
scan_declared_table_models,
|
|
37
|
+
)
|
|
38
|
+
from terp.cli.seed import run_seed_command
|
|
39
|
+
from terp.cli.users import create_user_command
|
|
40
|
+
from terp.cli.verify import profile_ids, run_verify_command, verify_manifest
|
|
41
|
+
|
|
42
|
+
_GUIDE_TOPICS: dict[str, str] = {
|
|
43
|
+
"module": """\
|
|
44
|
+
Add a module (the "10-minute module")
|
|
45
|
+
|
|
46
|
+
1) models.py table model (inherit BaseTable; never redeclare id/created_at/version)
|
|
47
|
+
from terp.core import BaseTable
|
|
48
|
+
from sqlmodel import Field
|
|
49
|
+
class Invoice(BaseTable, table=True):
|
|
50
|
+
number: str = Field(max_length=50, index=True, unique=True)
|
|
51
|
+
amount_cents: int
|
|
52
|
+
|
|
53
|
+
2) schemas.py DTOs (cap every input string; the Read DTO is what the API returns)
|
|
54
|
+
from terp.core import BaseSchema, BaseUpdateSchema
|
|
55
|
+
class InvoiceCreate(BaseSchema):
|
|
56
|
+
number: str = Field(max_length=50)
|
|
57
|
+
amount_cents: int
|
|
58
|
+
class InvoiceUpdate(BaseUpdateSchema): # `version` is required (optimistic concurrency)
|
|
59
|
+
amount_cents: int | None = None
|
|
60
|
+
class InvoiceRead(BaseSchema): # NOT the table model
|
|
61
|
+
id: uuid.UUID; number: str; amount_cents: int; version: int
|
|
62
|
+
|
|
63
|
+
3) service.py business logic (CRUD is inherited and audited)
|
|
64
|
+
from terp.core import BaseService
|
|
65
|
+
class InvoiceService(BaseService[Invoice, InvoiceCreate, InvoiceUpdate]):
|
|
66
|
+
model = Invoice
|
|
67
|
+
|
|
68
|
+
4) router.py thin HTTP layer (convert rows to the Read DTO)
|
|
69
|
+
@router.post("/", response_model=InvoiceRead, status_code=201)
|
|
70
|
+
def create_invoice(payload: InvoiceCreate, session: SessionDep) -> InvoiceRead:
|
|
71
|
+
return InvoiceRead.model_validate(_service.create(session, payload))
|
|
72
|
+
|
|
73
|
+
5) module.py the manifest
|
|
74
|
+
module = ModuleSpec(name="invoices", router=router, policy=Policy.default())
|
|
75
|
+
|
|
76
|
+
Then run `terp check`. Policy.default() = authenticated; read VIEWER, write EDITOR.
|
|
77
|
+
""",
|
|
78
|
+
"service": """\
|
|
79
|
+
Services (BaseService)
|
|
80
|
+
|
|
81
|
+
- Subclass BaseService[Model, Create, Update] and set `model`. You get
|
|
82
|
+
create/update/delete/get/list — all audited, OCC-checked and scope-honored.
|
|
83
|
+
- A bespoke mutation must route through self._save(...) / self._remove(...) (never a
|
|
84
|
+
raw session write), so it stays audited.
|
|
85
|
+
- Add an always-on read filter by overriding business_filters() — it returns
|
|
86
|
+
conditions and CANNOT drop soft-delete / tenant scope:
|
|
87
|
+
def business_filters(self):
|
|
88
|
+
return (Invoice.status == "open",)
|
|
89
|
+
- A per-call filter goes in a custom list() built on base_query():
|
|
90
|
+
def list(self, session, *, skip, limit, status=None):
|
|
91
|
+
q = self.base_query()
|
|
92
|
+
if status is not None:
|
|
93
|
+
q = q.where(Invoice.status == status)
|
|
94
|
+
return self._paginate(session, q, skip=skip, limit=limit)
|
|
95
|
+
- On a large table, prefer keyset pagination: a route takes CursorPaginationDep and
|
|
96
|
+
returns CursorPage[ReadDTO] from self.list_by_cursor(session, pagination=...) —
|
|
97
|
+
no OFFSET scan, and the exact COUNT runs only when the caller asks
|
|
98
|
+
(include_total=true).
|
|
99
|
+
- EVERY read (search, "my items", reports) builds on base_query() — never a raw
|
|
100
|
+
select(Model) and never session.get(Model, id). A scope-trait model
|
|
101
|
+
(SoftDeleteMixin/TenantScopedMixin) read via a bare select() or a primary-key
|
|
102
|
+
get() drops soft-delete/tenant scope (cross-tenant leak); the gate's
|
|
103
|
+
reads_use_base_query rule forbids both, and the request session re-scopes the
|
|
104
|
+
user-facing read methods (exec/scalars/scalar/get) as a backstop. Read a single
|
|
105
|
+
row with self.get(session, id) — NOT session.get(Model, id).
|
|
106
|
+
- Soft-delete: mix SoftDeleteMixin into the model; delete() soft-deletes and reads
|
|
107
|
+
exclude it automatically. Never write deleted_at by hand. Never override base_query.
|
|
108
|
+
- Provenance is automatic: compose ActorStampedMixin and BaseService fills
|
|
109
|
+
created_by_id / modified_by_id from the request actor on every write — never set them
|
|
110
|
+
by hand (the no_manual_actor_stamping rule forbids it). A Read DTO may still expose
|
|
111
|
+
them to surface "who created / last changed this".
|
|
112
|
+
- Dropping to raw SQL? Keep the text(...) argument a STATIC literal and pass data
|
|
113
|
+
through bound parameters — a dynamically built statement (f-string, concatenation,
|
|
114
|
+
.format, %, or a variable) is refused by the no_dynamic_sql rule (SQL injection):
|
|
115
|
+
session.exec(text("SELECT ... WHERE status = :status"), {"status": status})
|
|
116
|
+
""",
|
|
117
|
+
"policy": """\
|
|
118
|
+
Authorization (Policy)
|
|
119
|
+
|
|
120
|
+
- Every ModuleSpec carries a Policy (deny-by-default):
|
|
121
|
+
Policy.default() authenticated; read VIEWER, write EDITOR
|
|
122
|
+
Policy(read=VIEWER, write=ADMIN) typed roles (terp.core: VIEWER/EDITOR/ADMIN)
|
|
123
|
+
Policy.public(reason="health probe") the ONLY way to drop authentication
|
|
124
|
+
- Fine-grained permissions need a per-subject GRANT, not just a role:
|
|
125
|
+
Policy(write=Permission("invoices.approve", min_role=EDITOR))
|
|
126
|
+
Wire create_app(..., permission_enforcer=terp.capabilities.access.enforce_permission)
|
|
127
|
+
or boot fails closed. Grant via the access capability; the caller must clear the
|
|
128
|
+
min_role floor AND hold the grant.
|
|
129
|
+
- Route-level extra check: dependencies=[Depends(require_permission("invoices.approve"))].
|
|
130
|
+
- Authority is always a typed object (Role / Permission), never a bare string.
|
|
131
|
+
""",
|
|
132
|
+
"access": """\
|
|
133
|
+
The access model (three layers) — profiles + the access graph
|
|
134
|
+
|
|
135
|
+
- Effective access is exactly three composable layers, each an existing primitive:
|
|
136
|
+
1. module access ModuleSpec.policy — may this principal enter the module?
|
|
137
|
+
2. endpoint access the route's read/write requirement (mutating verb => write),
|
|
138
|
+
plus route-level require_permission(...) dependencies
|
|
139
|
+
3. data visibility model traits — which rows are readable / mutable?
|
|
140
|
+
OwnedMixin (write gate), TenantScopedMixin (read filter +
|
|
141
|
+
stamped writes), register_scope_predicate / object-authz
|
|
142
|
+
- Pick a PERMISSION PROFILE instead of hand-assembling the layers:
|
|
143
|
+
terp new module invoices --profile <name>
|
|
144
|
+
shared read VIEWER, write EDITOR (Policy.default())
|
|
145
|
+
role-gated read VIEWER, write ADMIN
|
|
146
|
+
owner-private + OwnedMixin: only a row's owner may update/delete
|
|
147
|
+
tenant-private + TenantScopedMixin + TenantScopedService: rows isolated per tenant
|
|
148
|
+
tenant-owner tenant isolation + the per-row owner write gate
|
|
149
|
+
A profile is a preset, never a mechanism: it only decides which primitives the
|
|
150
|
+
scaffold composes, so the output is ordinary gate-checked Terp code you own.
|
|
151
|
+
- SEE the whole graph — who can reach which module, endpoint, and rows:
|
|
152
|
+
uv run terp inspect access --app app.main:build --app-root . --format json
|
|
153
|
+
The --app form reports the WHOLE composed surface — client modules AND every
|
|
154
|
+
discovered capability router (users / groups / audit / files / …) plus the kernel
|
|
155
|
+
health routes — reconciled against app.openapi() so a mounted route can never hide
|
|
156
|
+
(any that is not covered is listed under omitted_routes, fail-visible). Use
|
|
157
|
+
--object/--module instead to inspect a focused, hand-passed subset.
|
|
158
|
+
One document: roles, permissions, every endpoint's method/path/requirement, each
|
|
159
|
+
declared service's model traits (owned / tenant-scoped / soft-delete), read scope,
|
|
160
|
+
write authority, and warnings (e.g. OwnedMixin gates writes only). `--format json`
|
|
161
|
+
is the stable Studio contract; declare services=(InvoiceService,) on the ModuleSpec
|
|
162
|
+
so the data layer is visualizable — an undeclared data layer is a warning.
|
|
163
|
+
""",
|
|
164
|
+
"ownership": """\
|
|
165
|
+
Object-level (per-row) authorization (OwnedMixin)
|
|
166
|
+
|
|
167
|
+
- A Policy gates a whole route (every editor may edit every row). To restrict a write
|
|
168
|
+
to the row's OWNER, compose OwnedMixin into the model — never hand-roll an owner_id
|
|
169
|
+
check (the no_manual_ownership_checks rule forbids it):
|
|
170
|
+
from terp.core import BaseTable, OwnedMixin
|
|
171
|
+
class Journal(BaseTable, OwnedMixin, table=True):
|
|
172
|
+
title: str = Field(max_length=200)
|
|
173
|
+
- BaseService stamps owner_id to the request actor on create, then authorizes every
|
|
174
|
+
update / delete of that row at the audited chokepoint: a non-owner write fails closed
|
|
175
|
+
with 403, with no code in your service. owner_id is stripped from inbound payloads, so
|
|
176
|
+
a client can never seize ownership through the request body.
|
|
177
|
+
- For a richer policy than "owner only" (team membership, a shared-with ACL), register
|
|
178
|
+
an object-authz predicate — the write-side seam — so a capability contributes per-row
|
|
179
|
+
authority without the kernel importing it (predicates compose fail-closed, AND):
|
|
180
|
+
from terp.core import register_object_authz_predicate
|
|
181
|
+
register_object_authz_predicate(my_predicate) # (model, entity, actor, action) -> bool
|
|
182
|
+
- Ownership is the WRITE gate only; read visibility is the separate register_scope_predicate
|
|
183
|
+
seam (ADR 0017) — an OwnedMixin row stays readable by a non-owner unless you also restrict
|
|
184
|
+
reads. An owner-keyed read filter necessarily references the managed owner_id, so (like the
|
|
185
|
+
tenancy capability's tenant filter) it belongs in a governed predicate carrying a justified
|
|
186
|
+
`# arch-allow-no_manual_ownership_checks`; a built-in owner-read filter is planned sugar.
|
|
187
|
+
Endpoint authority (Policy), row-read visibility (register_scope_predicate) and row-write
|
|
188
|
+
authority (OwnedMixin) are the three composable layers.
|
|
189
|
+
""",
|
|
190
|
+
"tenancy": """\
|
|
191
|
+
Multi-tenant rows (tenancy capability)
|
|
192
|
+
|
|
193
|
+
- Mix TenantScopedMixin into the model and give it a TenantScopedService. Importing
|
|
194
|
+
the mixin registers the tenant row predicate, so EVERY read of that model is
|
|
195
|
+
filtered to the current tenant automatically and create stamps tenant_id; a missing
|
|
196
|
+
tenant context fails closed (reads empty, writes raise).
|
|
197
|
+
class Doc(BaseTable, TenantScopedMixin, table=True): ...
|
|
198
|
+
class DocService(TenantScopedService[Doc, DocCreate, DocUpdate]): model = Doc
|
|
199
|
+
- Never filter tenant_id by hand — the framework owns the predicate (the gate forbids it).
|
|
200
|
+
- The current tenant comes from the request (TenantMiddleware binds the JWT `tenant`
|
|
201
|
+
claim); in tests use tenant_context(tenant_id).
|
|
202
|
+
- Wire it through the create_app middleware seam — never add_middleware (the gate
|
|
203
|
+
forbids it):
|
|
204
|
+
from starlette.middleware import Middleware
|
|
205
|
+
from terp.capabilities.auth import tenant_from_bearer
|
|
206
|
+
create_app(specs, principal_provider=get_principal,
|
|
207
|
+
middleware=[Middleware(TenantMiddleware, resolve_tenant=tenant_from_bearer)])
|
|
208
|
+
Sign the tenant into the token at login with
|
|
209
|
+
build_login_module(authenticate, tenant_resolver=...).
|
|
210
|
+
""",
|
|
211
|
+
"passwords": """\
|
|
212
|
+
Password strength (PasswordPolicy, Tier-B)
|
|
213
|
+
|
|
214
|
+
- Provisioning and resets enforce the app's PasswordPolicy at the users-service
|
|
215
|
+
credential boundary: a weak password is refused with a typed 422 (code weak_password,
|
|
216
|
+
the uniform envelope), the max_length cap stays the separate DoS guard.
|
|
217
|
+
- The safe default is 12+ chars, 2+ character classes, and a common-password denylist
|
|
218
|
+
(length over forced complexity, NIST-aligned). Tier-B: override the VALUES, not shape:
|
|
219
|
+
from terp.core import PasswordPolicy, ControlPlane
|
|
220
|
+
control_plane = ControlPlane(passwords=PasswordPolicy(min_length=16, min_character_classes=3))
|
|
221
|
+
- Relaxing strength is an explicit, justified opt-out and is refused at production boot:
|
|
222
|
+
PasswordPolicy.relaxed(reason="legacy bulk import")
|
|
223
|
+
- No terp.arch check applies (no module code shape to police) — enforcement is the
|
|
224
|
+
service chokepoint plus the create_app production fail-fast.
|
|
225
|
+
""",
|
|
226
|
+
"events": """\
|
|
227
|
+
Domain events (eventbus capability)
|
|
228
|
+
|
|
229
|
+
- Declare typed events in your control plane (never bare strings):
|
|
230
|
+
NOTE_CREATED = EventDefinition("note.created", payload_schema=NoteCreatedPayload)
|
|
231
|
+
event_catalog = EventCatalog([NOTE_CREATED])
|
|
232
|
+
- Emit declaratively from a service (atomic with the write):
|
|
233
|
+
class NoteService(EventEmittingService[Note, NoteCreate, NoteUpdate]):
|
|
234
|
+
model = Note
|
|
235
|
+
event_map = LifecycleEventMap(created=NOTE_CREATED)
|
|
236
|
+
- Subscribe with @subscribe(NOTE_CREATED). Reference catalog constants only (the gate
|
|
237
|
+
enforces no-drift). Wire create_app(..., event_dispatcher=dispatch_in_process).
|
|
238
|
+
""",
|
|
239
|
+
"jobs": """\
|
|
240
|
+
Background jobs (terp.core.enqueue + JobCatalog)
|
|
241
|
+
|
|
242
|
+
- Declare typed jobs in your control plane (never bare strings), with a payload SCHEMA
|
|
243
|
+
(cap its strings) and a handler resolved BY NAME:
|
|
244
|
+
class SyncPullPayload(BaseSchema):
|
|
245
|
+
source: str = Field(max_length=100)
|
|
246
|
+
def pull(ctx: JobContext, payload: SyncPullPayload) -> None:
|
|
247
|
+
MyService().create(ctx.session, ...) # writes are audited + actor/tenant-stamped
|
|
248
|
+
SYNC_PULL = JobDefinition(name="sync.customers.pull",
|
|
249
|
+
payload_schema=SyncPullPayload, handler=pull)
|
|
250
|
+
job_catalog = JobCatalog([SYNC_PULL]) # rejects duplicate names
|
|
251
|
+
Put the catalog on the control plane (ControlPlane(jobs=job_catalog)) and list it on the
|
|
252
|
+
module (ModuleSpec(jobs=[SYNC_PULL])) so boot validates it. Reference catalog constants
|
|
253
|
+
only - the jobs_reference_catalog rule forbids a bare string or inline JobDefinition(...).
|
|
254
|
+
- Enqueue through the typed chokepoint (never a raw queue), which rejects an unregistered
|
|
255
|
+
or shadowed job:
|
|
256
|
+
enqueue(session, job=SYNC_PULL, payload=SyncPullPayload(source="crm"),
|
|
257
|
+
idempotency_key="customers-2026-06-29")
|
|
258
|
+
A handler chains follow-up work the same way: enqueue(ctx.session, job=..., payload=...).
|
|
259
|
+
- Pass IDS, not entities - the payload must round-trip JSON (model_dump(mode="json")).
|
|
260
|
+
Delivery is at-least-once, so make handlers idempotent (the idempotency_key + your own
|
|
261
|
+
unique keys). Never read ambient request state in a handler - there is none in a worker;
|
|
262
|
+
use ctx.session / ctx.actor_id / ctx.tenant_id, all re-bound from the envelope.
|
|
263
|
+
- The default InProcessJobQueue runs the handler inline in its own audited unit (dev /
|
|
264
|
+
single-process). A user-less job runs as the control-plane system actor
|
|
265
|
+
(ControlPlane(job_system_actor_id=...)), so its writes are never unstamped. For real
|
|
266
|
+
off-request execution + durability, wire a durable adapter and require it at boot:
|
|
267
|
+
create_app(specs, ..., job_queue=<durable>, require_durable_jobs=settings.is_production)
|
|
268
|
+
- The system actor CANNOT update or delete a user's OwnedMixin row. It remains a
|
|
269
|
+
different actor from the owner. The built-in owner gate and registered object-authz
|
|
270
|
+
predicates compose fail-closed (AND). Predicates can narrow authority but never grant
|
|
271
|
+
an override. Cross-owner maintenance requires a reviewed maintenance-authority
|
|
272
|
+
capability. If none is installed, stop and report the missing capability — never
|
|
273
|
+
remove OwnedMixin or author a destructive owner-column migration.
|
|
274
|
+
- Trigger a scheduled job from any cron / k8s CronJob / systemd or cloud timer:
|
|
275
|
+
terp jobs run sync.customers.pull --payload '{"source": "crm"}'
|
|
276
|
+
Inspect the declared jobs: terp jobs list / terp inspect jobs.
|
|
277
|
+
- Declare a schedule (ScheduleDefinition: a cron + a catalog JobDefinition) on the control
|
|
278
|
+
plane (ControlPlane(schedules=ScheduleCatalog([...]))); boot validates each schedule's job
|
|
279
|
+
against the JobCatalog. Run schedules in-process with `terp jobs scheduler` (APScheduler;
|
|
280
|
+
needs terp-cap-scheduler-apscheduler) or via Celery beat — each cron tick enqueues through
|
|
281
|
+
the same typed seam, so a scheduled job stays audited + system-actor stamped.
|
|
282
|
+
""",
|
|
283
|
+
"files": """\
|
|
284
|
+
File objects (files capability, ADR 0056/0057)
|
|
285
|
+
|
|
286
|
+
- Upload/download/list/rename/delete ride the admin-only discovered router at
|
|
287
|
+
/api/v1/files; File composes OwnedMixin, so rename/delete are owner-gated centrally.
|
|
288
|
+
- Bytes live behind the StorageBackend port (put/open/delete, streamed via file-like
|
|
289
|
+
objects) in a NAMED-PROFILE registry;
|
|
290
|
+
metadata (name, type, size, sha256, storage_key, storage_profile) lives in the platform
|
|
291
|
+
DB. storage_key/storage_profile never leave the boundary (FileRead omits both).
|
|
292
|
+
- Any provider is an adapter subclass (local ships; S3/Azure/NAS are each a
|
|
293
|
+
StorageBackend). Register each store once at the composition root:
|
|
294
|
+
register_storage_backend("azure-invoices", AzureBlobStorage(container="invoices"))
|
|
295
|
+
register_storage_backend("azure-hr", AzureBlobStorage(container="hr"))
|
|
296
|
+
Keep credentials in settings / sealed config — never in module code.
|
|
297
|
+
- Pick the store per module (subclass default) or per call — never from a client:
|
|
298
|
+
class InvoiceFileService(FileService):
|
|
299
|
+
storage_profile = "azure-invoices"
|
|
300
|
+
service.store(session, filename=..., content_type=..., source=..., profile="azure-hr")
|
|
301
|
+
Resolution is FAIL-CLOSED: an unknown profile raises (UnknownStorageProfileError)
|
|
302
|
+
before any byte lands; load/remove always resolve the store the ROW itself names.
|
|
303
|
+
- Uploads stream (never fully buffered) under a 25 MiB default cap; the files spec
|
|
304
|
+
declares its own request-body allowance so the kernel's global max_request_bytes
|
|
305
|
+
(1 MiB default) is lifted for /api/v1/files ONLY (ADR 0067). Retune per deployment
|
|
306
|
+
with two composition-root lines (the request allowance must exceed the stored cap
|
|
307
|
+
by multipart framing headroom):
|
|
308
|
+
configure_upload_limit(100 * 1024 * 1024)
|
|
309
|
+
create_app(..., request_size_overrides={"files": 100 * 1024 * 1024 + 65536})
|
|
310
|
+
- Content types are allowed by default (descriptive metadata); a deployment narrows
|
|
311
|
+
uploads to an allowlist with one composition-root line — enforced in the service
|
|
312
|
+
chokepoint (typed 415, before any byte lands), so no upload path can bypass it:
|
|
313
|
+
configure_allowed_content_types(["application/pdf", "image/*"])
|
|
314
|
+
- Referencing a file from your own model? Declare it — never a bare uuid column (the
|
|
315
|
+
no_raw_file_references rule enforces this on table models):
|
|
316
|
+
class Invoice(BaseTable, table=True):
|
|
317
|
+
attachment_file_id: uuid.UUID | None = FileRef()
|
|
318
|
+
Serve it THROUGH your own already-authorized row (serve-through delegation): load the
|
|
319
|
+
invoice via your own service (its policy + row scope decide visibility), then
|
|
320
|
+
row, data = FileService().load_for(session, invoice, "attachment_file_id")
|
|
321
|
+
load_for fail-closes on an undeclared reference; /api/v1/files itself stays ADMIN-only.
|
|
322
|
+
""",
|
|
323
|
+
"capability": """\
|
|
324
|
+
Using capabilities
|
|
325
|
+
|
|
326
|
+
- Capabilities are opt-in packages (terp-cap-*); the base profile is auth + access +
|
|
327
|
+
identity + users (+ projects). Install the ones you need.
|
|
328
|
+
- A routed capability self-registers: create_app(specs, discover_capabilities=True)
|
|
329
|
+
mounts it at /api/v1/<name> via its entry point — no composition-root edit.
|
|
330
|
+
- A library capability (tenancy, eventbus) ships no router; you import and wire it
|
|
331
|
+
(a mixin/service, a dispatcher) where needed.
|
|
332
|
+
- Compose the app once:
|
|
333
|
+
create_app(specs, principal_provider=..., control_plane=...,
|
|
334
|
+
audit_sink=persist_audit, event_dispatcher=dispatch_in_process,
|
|
335
|
+
permission_enforcer=enforce_permission, discover_capabilities=True)
|
|
336
|
+
- You can always drop to native FastAPI/SQLModel — the same gate rules still apply.
|
|
337
|
+
- Outbound HTTP is a capability concern, never a module concern: importing httpx /
|
|
338
|
+
requests / urllib.request / urllib3 / aiohttp in a module is refused by the
|
|
339
|
+
no_raw_outbound_http rule — SSRF protection, egress allowlists and timeout policy
|
|
340
|
+
belong behind one declared capability, not scattered per call site.
|
|
341
|
+
- Credentials never live in module source: a credential-shaped assignment (password,
|
|
342
|
+
api_key, token, ...) to a string literal — or a recognizable secret-token literal
|
|
343
|
+
anywhere — is refused by the no_hardcoded_credentials rule. Wire secrets through
|
|
344
|
+
settings / sealed config (ADR 0055), never source.
|
|
345
|
+
""", "migrations": """\
|
|
346
|
+
Database migrations (terp migrate)
|
|
347
|
+
|
|
348
|
+
- Each table-owning package (capability or app module) owns an INDEPENDENT, linear
|
|
349
|
+
Alembic history with its own alembic_version_<label> table - no shared graph and no
|
|
350
|
+
CROSS-package merges. Terp discovers them; you never hand-write env.py.
|
|
351
|
+
- Author a revision after changing a model (autogenerated, scoped to that package so
|
|
352
|
+
it never proposes another package's tables):
|
|
353
|
+
terp migrate make <label> -m "add invoice.status" # <label> e.g. invoices
|
|
354
|
+
Cross-module / cross-package foreign keys just work: every package's models are
|
|
355
|
+
imported so an FK target (a sibling module, or identity_user) resolves at make time,
|
|
356
|
+
and upgrade is ordered by FK dependencies so a referenced table is always created
|
|
357
|
+
before the table that references it - regardless of label ordering. (A cross-package
|
|
358
|
+
FK *cycle* cannot be ordered and fails closed; break it with a nullable FK populated
|
|
359
|
+
in a later migration.)
|
|
360
|
+
- Apply / inspect / roll back across every package:
|
|
361
|
+
terp migrate upgrade # each package to head (run on deploy)
|
|
362
|
+
terp migrate upgrade --sql > release.sql # render DBA-reviewable offline SQL
|
|
363
|
+
# instead (nothing connects; flat layout)
|
|
364
|
+
terp migrate status # current-vs-head per package
|
|
365
|
+
terp migrate downgrade # every package back to base (or -N)
|
|
366
|
+
terp migrate downgrade --label notes --revision <rev> # one package only
|
|
367
|
+
A concrete revision is package-specific, so the all-package downgrade takes only
|
|
368
|
+
base or a relative -N; pass --label to roll one package to any of its own revisions.
|
|
369
|
+
- Two developers branched the same package? Resolve the within-package divergence:
|
|
370
|
+
terp migrate heads # more than one head = diverged
|
|
371
|
+
terp migrate merge <label> -m "merge"
|
|
372
|
+
- Destructive DDL (drop table/column or alter-column type changes) is refused by
|
|
373
|
+
`terp check` unless the operation carries `# arch-allow-no-destructive-migrations:
|
|
374
|
+
<reason>` on (or immediately above) its line, budgeted by the escape-hatch ratchet.
|
|
375
|
+
- Adopt Terp on an EXISTING database (built by create_all or by hand) without dropping
|
|
376
|
+
data - baseline each history at head, then only genuinely new migrations apply:
|
|
377
|
+
terp migrate stamp # records head, runs no DDL
|
|
378
|
+
- Want physical per-module separation on PostgreSQL (each package's tables in its own
|
|
379
|
+
schema, the groundwork for per-schema GRANTs)? Set DB_SCHEMA_LAYOUT=per-module for a
|
|
380
|
+
fresh database, or move an existing flat one in place (idempotent, data moves with
|
|
381
|
+
the tables, version tables stay put):
|
|
382
|
+
terp migrate adopt-schemas # one-time; ADR 0070
|
|
383
|
+
- Least-privilege runtime (ADR 0071): migrate as the owning role, run the app as a
|
|
384
|
+
separate login that holds ONLY DML - the database itself then refuses DDL and
|
|
385
|
+
(per-module) any tampering with migration state. Provision the login yourself, then:
|
|
386
|
+
terp migrate grant-runtime <role> # idempotent; run after upgrade/adopt
|
|
387
|
+
Run it as the role that runs `terp migrate` - or pass --owner-role <role> so the
|
|
388
|
+
ALTER DEFAULT PRIVILEGES it emits covers tables that future upgrades create.
|
|
389
|
+
Module-to-module DML is deliberately NOT database-blocked (one runtime role spans
|
|
390
|
+
every write schema; audit/outbox ride the business write's single session).
|
|
391
|
+
- Operate safely: run `terp migrate upgrade` ONCE per deploy (e.g. a release job), not
|
|
392
|
+
on every replica - it takes no lock, so concurrent runs race. The boot guard below is
|
|
393
|
+
read-only and safe on every replica. The migration engine is built from DATABASE_URL,
|
|
394
|
+
so put URL-expressible options (e.g. sslmode) there.
|
|
395
|
+
- Run from your app root so app/ is importable (app modules ship their history in
|
|
396
|
+
app/modules/<name>/migrations/; a capability declares a terp.migrations entry point).
|
|
397
|
+
- Make upgrading non-optional: wire the fail-closed boot guard so the app refuses to
|
|
398
|
+
start against a stale schema (a deploy that skipped the upgrade fails loudly). Pass an
|
|
399
|
+
app_root so it guards your app modules too, not only capabilities:
|
|
400
|
+
from functools import partial
|
|
401
|
+
from pathlib import Path
|
|
402
|
+
from terp.migrations import assert_migrations_current
|
|
403
|
+
create_app(specs, ..., migration_check=partial(
|
|
404
|
+
assert_migrations_current, app_root=Path(__file__).parent))
|
|
405
|
+
Gate it on production if local dev builds the schema with create_all / SQLite.
|
|
406
|
+
- Test the REAL migration path (not only create_all) so a model change with no
|
|
407
|
+
migration fails CI, not production:
|
|
408
|
+
from terp.migrations import upgrade, assert_migrations_match_models
|
|
409
|
+
upgrade(db_url, app_root); assert_migrations_match_models(db_url, app_root)
|
|
410
|
+
""",
|
|
411
|
+
"frontend": """\
|
|
412
|
+
Frontend module screens (@terp/react-core)
|
|
413
|
+
|
|
414
|
+
- A module's frontend slot is frontend/src/modules/<name>/ with a module.tsx manifest;
|
|
415
|
+
everything composes the token-styled @terp/react-core surface. The full catalog (with
|
|
416
|
+
per-export "Use" guidance) is the @terp/react-core README; each export also carries
|
|
417
|
+
JSDoc, so your editor shows the same guidance inline.
|
|
418
|
+
- The boundary lint (@terp/eslint-boundaries) refuses, fail-closed:
|
|
419
|
+
raw <button>/<input>/<select>/<textarea> -> Button / Input / Select / Textarea
|
|
420
|
+
raw <table> -> DataView (terp guide dataview)
|
|
421
|
+
raw <dialog> -> ConfirmDialog
|
|
422
|
+
raw <form> -> Stack as="form" (terp guide forms)
|
|
423
|
+
raw fetch / XMLHttpRequest -> useTerpClient() + unwrap (typed client)
|
|
424
|
+
WebSocket / EventSource / sendBeacon -> the generated client (one egress path)
|
|
425
|
+
style={} / className / module stylesheets -> layout via Stack/DetailList; design tokens
|
|
426
|
+
<a href="/..."> -> the router's Link (role-aware, no reload)
|
|
427
|
+
deep imports (@terp/*/src, @terp/*/dist) -> import from the package root only
|
|
428
|
+
- Frontend security defaults (each its own lint rule, same error-only footing):
|
|
429
|
+
dangerouslySetInnerHTML and DOM HTML-injection sinks (innerHTML/outerHTML/
|
|
430
|
+
insertAdjacentHTML/document.write) are refused — render text, or Markdown from
|
|
431
|
+
@terp/react-core for rich text; eval() / new Function() are refused; javascript:
|
|
432
|
+
URLs in href/src are refused; a static target="_blank" link needs rel="noopener".
|
|
433
|
+
- Every routed view renders a page archetype (Page / OverviewPage / DetailPage / HubPage);
|
|
434
|
+
buildAppRouter refuses an unframed view at runtime, fail closed. An app can ratchet
|
|
435
|
+
further with an opt-in slot-typed layout contract (terp guide layouts).
|
|
436
|
+
- User-facing text props are UiText (a plain string, or {id, message} for localization).
|
|
437
|
+
- Data always flows through the generated client: useTerpClient() (typed from the backend
|
|
438
|
+
OpenAPI export) and unwrap(...) which throws a typed ApiError carrying code/status.
|
|
439
|
+
- The one governed opt-out is a justified `// terp-allow-<rule>: <reason>` marker whose
|
|
440
|
+
counts must exactly match the app's checked-in escape-hatch-budget.json (a ratchet).
|
|
441
|
+
- Run the lint locally: npm --prefix frontend run lint (part of the gate).
|
|
442
|
+
""",
|
|
443
|
+
"dataview": """\
|
|
444
|
+
Data collections (DataView)
|
|
445
|
+
|
|
446
|
+
- DataView is the single sanctioned surface for data collections — a raw <table> is
|
|
447
|
+
refused by the boundary lint. It gives search, sorting, pagination, column management,
|
|
448
|
+
selection + batch actions, row actions, expandable rows, and persisted view
|
|
449
|
+
preferences, driven by a repository port.
|
|
450
|
+
- Client-side (small collections — rows already in memory):
|
|
451
|
+
const repo = useMemo(() => new InMemoryDataViewRepository(rows), [rows]);
|
|
452
|
+
<DataView repository={repo} columns={columns} keyField="id" />
|
|
453
|
+
- Server-side (large collections — let the backend paginate/sort/filter):
|
|
454
|
+
const repo = useMemo(() => new HttpDataViewRepository({...}), [client]);
|
|
455
|
+
and keep query state in the URL with useServerDataView.
|
|
456
|
+
- Columns declare {key, header, render?}; header text is UiText. Row actions and batch
|
|
457
|
+
actions are declared as data (the component renders the token-styled controls).
|
|
458
|
+
- Persist per-user view preferences via the ViewStateRepository seam
|
|
459
|
+
(LocalStorageViewStateRepository for the browser; InMemoryViewStateRepository in tests).
|
|
460
|
+
- For a simple titled CRUD list (no tables), ResourceList over useResource is the
|
|
461
|
+
lighter standard screen; reach for DataView when the collection needs table powers.
|
|
462
|
+
""",
|
|
463
|
+
"forms": """\
|
|
464
|
+
Forms (react-core primitives)
|
|
465
|
+
|
|
466
|
+
- Raw <form>/<input>/<select>/<textarea>/<button> are refused by the boundary lint;
|
|
467
|
+
compose the token-styled primitives instead:
|
|
468
|
+
<Stack as="form" onSubmit={submit}>
|
|
469
|
+
<Field label="Number" error={errors.number}>
|
|
470
|
+
<Input value={number} onChange={...} maxLength={50} />
|
|
471
|
+
</Field>
|
|
472
|
+
<Field label="Status">
|
|
473
|
+
<Select value={status} onChange={...}>...</Select>
|
|
474
|
+
</Field>
|
|
475
|
+
<Button type="submit" variant="primary">Save</Button>
|
|
476
|
+
</Stack>
|
|
477
|
+
- Field wraps label + control + hint/error for one field; Stack (vertical by default)
|
|
478
|
+
is the layout — never style={} / className / a module stylesheet.
|
|
479
|
+
- Submit through the typed client: const client = useTerpClient();
|
|
480
|
+
await unwrap(client.POST("/api/v1/invoices/", { body })); a failure throws ApiError
|
|
481
|
+
({code, status, requestId}) — map codes to copy with useErrorMessage, show transient
|
|
482
|
+
success/failure with useToast(), and confirm destructive actions with ConfirmDialog.
|
|
483
|
+
- Updates carry the row's `version` (optimistic concurrency): send the version you
|
|
484
|
+
read; a 409 version_conflict means reload-and-retry, surfaced via ErrorState copy.
|
|
485
|
+
- Mirror the backend's input caps client-side (maxLength on Input matching the schema's
|
|
486
|
+
Field(max_length=...)) so users see the limit before the 422 does.
|
|
487
|
+
""",
|
|
488
|
+
"layouts": """\
|
|
489
|
+
Layout contracts (slot-typed layouts, ADR 0079)
|
|
490
|
+
|
|
491
|
+
- A layout contract is an OPT-IN ratchet above the page archetypes: not just "every
|
|
492
|
+
routed view is framed", but "this archetype's body holds only these components".
|
|
493
|
+
It is enforced two-layer and fail-closed, and every failure message tells you the
|
|
494
|
+
contract, the slot, what was found, what is allowed, and the concrete fix — let it
|
|
495
|
+
guide you.
|
|
496
|
+
- Opt in (both halves; keep them in sync — the template generates both):
|
|
497
|
+
frontend/layout-contract.json -> { "contract": "standard" } (lint half)
|
|
498
|
+
renderTerpApp({ layoutContract: "standard", ... }) (runtime half)
|
|
499
|
+
No config = no checks (fully backwards compatible; an existing app can switch later
|
|
500
|
+
and fix screens by following the enforcement messages).
|
|
501
|
+
- The "standard" contract governs the body slot of each archetype:
|
|
502
|
+
HubPage -> HubCard only (a card grid landing)
|
|
503
|
+
OverviewPage -> DataView / ResourceList / ModuleNav / Stack + the framework
|
|
504
|
+
states (EmptyState / ErrorState / LoadingState / Alert) and
|
|
505
|
+
ConfirmDialog
|
|
506
|
+
DetailPage -> DetailList / Stack / Tabs / ModuleNav / DataView + the same
|
|
507
|
+
framework states and ConfirmDialog
|
|
508
|
+
The plain Page stays unconstrained — it is the sanctioned home for a bespoke screen.
|
|
509
|
+
- Enforcement (never lint-only):
|
|
510
|
+
build time -> the terp/layout-contract ESLint rule checks the static JSX
|
|
511
|
+
children of each governed archetype (npm --prefix frontend run lint)
|
|
512
|
+
runtime -> the archetypes verify the rendered DOM children (each sanctioned
|
|
513
|
+
component stamps a data-terp marker) and refuse the view, fail
|
|
514
|
+
closed — so dynamic children a linter cannot see are still governed.
|
|
515
|
+
- The one opt-out is the governed escape hatch: a justified
|
|
516
|
+
`// terp-allow-layout-contract: <reason>` marker on the violating line, counted
|
|
517
|
+
against the app's checked-in escape-hatch-budget.json (a ratchet). A recurring
|
|
518
|
+
legitimate need should become a contract allowance, not an opt-out.
|
|
519
|
+
""",}
|
|
520
|
+
|
|
521
|
+
# Topics whose body is generated from a live registry (not a static recipe above).
|
|
522
|
+
_GENERATED_TOPICS: tuple[str, ...] = ("rules",)
|
|
523
|
+
|
|
524
|
+
_RULE_GUIDE_DETAILS: dict[str, str] = {
|
|
525
|
+
"no_raw_outbound_http": """\
|
|
526
|
+
Compliant decision path for outbound HTTP
|
|
527
|
+
|
|
528
|
+
1. Preserve the requested integration and its external contract. Removing the live
|
|
529
|
+
call, returning static/local data, or moving the client import to an unscanned
|
|
530
|
+
helper only to make the gate green is not a compliant fix.
|
|
531
|
+
2. Use a maintained purpose-built capability when its semantics match. For example,
|
|
532
|
+
terp-cap-webhooks owns signed webhook POST delivery; it is not a generic GET client.
|
|
533
|
+
3. The maintained Terp capability surface currently has no generic outbound-fetch
|
|
534
|
+
capability for arbitrary HTTP GETs. App modules therefore cannot implement a live
|
|
535
|
+
news/feed fetch through a sanctioned generic API today.
|
|
536
|
+
4. When no matching capability exists, stop and report the missing capability. Leave
|
|
537
|
+
the check red until a human approves an escape hatch or the platform supplies a
|
|
538
|
+
reviewed adapter capability. Do not create an app-local helper package merely to
|
|
539
|
+
move the raw client outside the scanner.
|
|
540
|
+
5. A new adapter capability must expose a narrow domain API and centrally enforce a
|
|
541
|
+
fixed destination allowlist, HTTPS, SSRF-safe DNS/IP handling, redirect policy,
|
|
542
|
+
bounded timeouts and response sizes, credentials from settings, and egress audit.
|
|
543
|
+
App modules import only that declared capability's public domain seam.
|
|
544
|
+
""",
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def guide_topics() -> tuple[str, ...]:
|
|
549
|
+
"""Every ``terp guide`` topic, sorted: the static recipes + the generated ones.
|
|
550
|
+
|
|
551
|
+
The single source of truth for the CLI topic ``choices`` *and* the docs-parity
|
|
552
|
+
test, which derives its per-topic coverage from this rather than re-listing topics.
|
|
553
|
+
"""
|
|
554
|
+
return tuple(sorted([*_GUIDE_TOPICS, *_GENERATED_TOPICS]))
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def guide_choices() -> tuple[str, ...]:
|
|
558
|
+
"""Every accepted focused guide name: broad topics plus exact architecture rules."""
|
|
559
|
+
from terp.arch.rules import GUIDE_TOPIC_BY_RULE
|
|
560
|
+
|
|
561
|
+
return tuple(sorted({*guide_topics(), *GUIDE_TOPIC_BY_RULE}))
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
_TOPIC_NAMES = ", ".join(guide_topics())
|
|
565
|
+
|
|
566
|
+
_GUIDE_OVERVIEW = f"""\
|
|
567
|
+
Terp — secure-by-default application platform (authoring guide)
|
|
568
|
+
|
|
569
|
+
You write small modules; the framework enforces auth, audit, optimistic concurrency,
|
|
570
|
+
pagination, input caps and row scoping for you. A green gate (`terp check` /
|
|
571
|
+
`uv run pytest`) means your code is compliant — the architecture rules fail closed with
|
|
572
|
+
precise, fixable messages, so let them guide you.
|
|
573
|
+
|
|
574
|
+
Canonical module shape (modules/<name>/):
|
|
575
|
+
models.py table models (inherit BaseTable)
|
|
576
|
+
schemas.py request/response DTOs (BaseSchema / BaseUpdateSchema)
|
|
577
|
+
service.py business logic (subclass BaseService)
|
|
578
|
+
router.py thin HTTP layer (APIRouter over the service + SessionDep)
|
|
579
|
+
module.py the ModuleSpec manifest (name + router + Policy)
|
|
580
|
+
|
|
581
|
+
Golden rules (the gate enforces these — follow them and it stays green):
|
|
582
|
+
1. Table models inherit BaseTable; never redeclare id/created_at/updated_at/version.
|
|
583
|
+
2. Services subclass BaseService; CRUD is inherited. Add read filters via
|
|
584
|
+
business_filters(); never override base_query (it would drop soft-delete/tenant scope).
|
|
585
|
+
3. Every write goes through the service (create/update/delete, or self._save/_remove);
|
|
586
|
+
never call session.add/commit/execute yourself — the audit trail is automatic.
|
|
587
|
+
4. Every module declares a ModuleSpec with a Policy (deny-by-default); a truly public
|
|
588
|
+
route opts in with Policy.public(reason="...").
|
|
589
|
+
5. Routes set response_model to a Read DTO (never the table model); paginate lists (Page[T]).
|
|
590
|
+
6. Cap every input string: Field(max_length=...).
|
|
591
|
+
7. Import only the terp.core public surface + your declared capabilities — never
|
|
592
|
+
terp.core._internal, never a sibling module.
|
|
593
|
+
|
|
594
|
+
More: terp guide <topic> (topics: {_TOPIC_NAMES})
|
|
595
|
+
terp guide <rule> (the exact rule's remediation and related pattern)
|
|
596
|
+
terp guide rules (every architecture rule the gate enforces, generated)
|
|
597
|
+
terp inspect control-plane (your roles / permissions / module authority map)
|
|
598
|
+
terp inspect access (the full access graph: modules, endpoints, data traits)
|
|
599
|
+
terp check (run the full architecture gate locally)
|
|
600
|
+
"""
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _clean_doc(text: str) -> str:
|
|
604
|
+
"""Strip RST inline markup (``literals`` and ``:role:`targets```) for plain output."""
|
|
605
|
+
text = re.sub(r":[a-zA-Z]+:`~?(?:[\w.]+\.)?(\w+)`", r"\1", text)
|
|
606
|
+
return text.replace("``", "")
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _rule_headline(rule: Callable[..., object]) -> str:
|
|
610
|
+
"""The first line of *rule*'s docstring (its one-line summary), RST-normalized."""
|
|
611
|
+
return _clean_doc(rule.__doc__.strip().splitlines()[0]).strip()
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _render_rules_topic() -> str:
|
|
615
|
+
"""Generate the enforced-rules list from the live ``terp.arch`` registry.
|
|
616
|
+
|
|
617
|
+
Introspected from ``terp.arch.rules._ALL_RULES`` (each rule's name + its docstring
|
|
618
|
+
headline), so a newly added rule surfaces here automatically — there is no second,
|
|
619
|
+
hand-maintained rule list to drift (ADR 0030). The harness is imported lazily, so
|
|
620
|
+
plain ``terp guide`` / ``terp inspect`` need not load it.
|
|
621
|
+
"""
|
|
622
|
+
from terp.arch.rules import _ALL_RULES
|
|
623
|
+
|
|
624
|
+
lines = [
|
|
625
|
+
"Architecture rules the gate enforces",
|
|
626
|
+
"",
|
|
627
|
+
"Generated from the live terp-arch registry, so this list is always complete and",
|
|
628
|
+
"current. Each rule is checked by `terp check` / `uv run pytest` and fails closed",
|
|
629
|
+
"with a precise, fixable message naming the file, line, and fix.",
|
|
630
|
+
"",
|
|
631
|
+
]
|
|
632
|
+
for rule in sorted(_ALL_RULES, key=lambda item: item.__name__):
|
|
633
|
+
lines.append(f" - {rule.__name__.removeprefix('check_')}")
|
|
634
|
+
lines.append(f" {_rule_headline(rule)}")
|
|
635
|
+
return "\n".join(lines) + "\n"
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _render_rule_guide(rule_name: str) -> str:
|
|
639
|
+
"""Render one rule's exact remediation followed by its broader authoring pattern."""
|
|
640
|
+
from terp.arch.rules import GUIDE_TOPIC_BY_RULE, _ALL_RULES
|
|
641
|
+
|
|
642
|
+
topic = GUIDE_TOPIC_BY_RULE[rule_name]
|
|
643
|
+
rules = {
|
|
644
|
+
rule.__name__.removeprefix("check_"): rule
|
|
645
|
+
for rule in _ALL_RULES
|
|
646
|
+
}
|
|
647
|
+
checker = rules.get(rule_name)
|
|
648
|
+
explanation = (
|
|
649
|
+
_clean_doc(checker.__doc__.strip()).strip()
|
|
650
|
+
if checker is not None and checker.__doc__
|
|
651
|
+
else rule_name
|
|
652
|
+
)
|
|
653
|
+
detail = _RULE_GUIDE_DETAILS.get(
|
|
654
|
+
rule_name,
|
|
655
|
+
"Apply the sanctioned construct in the related authoring pattern below at "
|
|
656
|
+
"the exact file and line from the finding. Preserve existing behavior and "
|
|
657
|
+
"rerun the failing check; do not add an opt-out merely to turn it green.",
|
|
658
|
+
)
|
|
659
|
+
return (
|
|
660
|
+
f"Rule: {rule_name}\n"
|
|
661
|
+
f"{explanation}\n\n"
|
|
662
|
+
f"{detail.rstrip()}\n\n"
|
|
663
|
+
f"Related authoring pattern ({topic})\n\n"
|
|
664
|
+
f"{guide(topic)}"
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def guide(topic: str | None = None) -> str:
|
|
669
|
+
"""Return the Terp authoring guide, or a focused recipe for *topic*.
|
|
670
|
+
|
|
671
|
+
The deterministic, in-terminal instruction surface for agents (and humans): an
|
|
672
|
+
agent can run ``terp guide`` without reading the installed package, learn the
|
|
673
|
+
canonical module shape + the golden rules the architecture gate enforces, then
|
|
674
|
+
``terp guide <topic>`` for a copy-pasteable recipe. The ``rules`` topic is generated
|
|
675
|
+
from the live ``terp.arch`` registry (ADR 0030), so it never drifts.
|
|
676
|
+
"""
|
|
677
|
+
if topic is None:
|
|
678
|
+
return _GUIDE_OVERVIEW
|
|
679
|
+
if topic == "rules":
|
|
680
|
+
return _render_rules_topic()
|
|
681
|
+
if topic in _GUIDE_TOPICS:
|
|
682
|
+
return _GUIDE_TOPICS[topic]
|
|
683
|
+
return _render_rule_guide(topic)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def check_report(
|
|
687
|
+
root: str = ".", *, package: str = "app", budget_path: str | None = None
|
|
688
|
+
) -> dict[str, object]:
|
|
689
|
+
"""The architecture gate as a structured report (the ``terp check --format json`` body).
|
|
690
|
+
|
|
691
|
+
Machine-readable so an agent (or the Studio) never has to parse a prose wall:
|
|
692
|
+
every violation carries its rule, file, line, message, the ``terp guide`` topic
|
|
693
|
+
that teaches the compliant pattern, and a copy-pasteable ``fix`` command. An
|
|
694
|
+
ungoverned ``# arch-allow-*`` marker (the condition ``assert_app_clean`` fails
|
|
695
|
+
closed on) is reported in-band as an ``ungoverned_escape_hatch`` violation.
|
|
696
|
+
|
|
697
|
+
``rules`` is the evaluated-rule inventory: every rule id this run actually held
|
|
698
|
+
the app to. That is the live registry plus the escape-hatch governance half that
|
|
699
|
+
matches the execution mode: with a *budget_path* the budget ratchet ran (and an
|
|
700
|
+
unbudgeted marker is reported as its drift, subsuming the ungoverned condition);
|
|
701
|
+
without one only the ungoverned-marker condition ran — the ratchet is then left
|
|
702
|
+
OUT of the inventory, so a consumer joining verdicts to the Terp Standard catalog
|
|
703
|
+
can never claim ``escape_hatch_budget`` passed on a run that never enforced it
|
|
704
|
+
(fail closed under version skew and configuration alike).
|
|
705
|
+
"""
|
|
706
|
+
from terp.arch import check_app, guide_topic_for, ungoverned_marker_violations
|
|
707
|
+
from terp.arch.rules import GUIDE_TOPIC_BY_RULE
|
|
708
|
+
|
|
709
|
+
violations = list(check_app(root, package=package, budget_path=budget_path))
|
|
710
|
+
if budget_path is None:
|
|
711
|
+
violations.extend(ungoverned_marker_violations(root, package=package))
|
|
712
|
+
violations.sort(key=lambda violation: (violation.path, violation.line, violation.rule))
|
|
713
|
+
rules = set(GUIDE_TOPIC_BY_RULE)
|
|
714
|
+
if budget_path is None:
|
|
715
|
+
rules.discard("escape_hatch_budget")
|
|
716
|
+
return {
|
|
717
|
+
"ok": not violations,
|
|
718
|
+
"rules": sorted(rules),
|
|
719
|
+
"violation_count": len(violations),
|
|
720
|
+
"violations": [
|
|
721
|
+
{
|
|
722
|
+
"rule": violation.rule,
|
|
723
|
+
# Separator-stable ('/') on every OS: the report is a machine
|
|
724
|
+
# contract consumed by agents and the Studio, not display text.
|
|
725
|
+
"path": violation.path.replace("\\", "/"),
|
|
726
|
+
"line": violation.line,
|
|
727
|
+
"message": violation.message,
|
|
728
|
+
"guide_topic": guide_topic_for(violation.rule),
|
|
729
|
+
"fix": f"terp guide {violation.rule}",
|
|
730
|
+
}
|
|
731
|
+
for violation in violations
|
|
732
|
+
],
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def check_report_envelope(
|
|
737
|
+
root: str = ".", *, package: str = "app", budget_path: str | None = None
|
|
738
|
+
) -> dict[str, object]:
|
|
739
|
+
"""The architecture gate as a Terp Standard **check report** (``terp check
|
|
740
|
+
--format check-report``).
|
|
741
|
+
|
|
742
|
+
The spec's ``app-check-report.schema.json`` shape: one self-describing document a
|
|
743
|
+
consumer joins to the catalog without knowing this toolchain — ``spec_version``
|
|
744
|
+
(the standard the rule ids resolve against), the checker identity, the run
|
|
745
|
+
verdict, the evaluated-rule inventory as **catalog ids** (``backend/<rule>``),
|
|
746
|
+
and findings in the finding format's shape (``fix_hint`` = the ``terp guide``
|
|
747
|
+
recipe). The legacy ``--format json`` report keeps its published shape for
|
|
748
|
+
existing consumers; this is the successor surface driving tools migrate to.
|
|
749
|
+
"""
|
|
750
|
+
import importlib.metadata
|
|
751
|
+
|
|
752
|
+
from terp.arch import SPEC_VERSION
|
|
753
|
+
|
|
754
|
+
report = check_report(root, package=package, budget_path=budget_path)
|
|
755
|
+
try:
|
|
756
|
+
version = importlib.metadata.version("terp-arch")
|
|
757
|
+
except importlib.metadata.PackageNotFoundError: # a source checkout (the platform repo)
|
|
758
|
+
version = "0"
|
|
759
|
+
findings: list[dict[str, object]] = []
|
|
760
|
+
for violation in report["violations"]: # type: ignore[union-attr]
|
|
761
|
+
finding: dict[str, object] = {
|
|
762
|
+
"rule": f"backend/{violation['rule']}",
|
|
763
|
+
"path": violation["path"],
|
|
764
|
+
"message": violation["message"],
|
|
765
|
+
"fix_hint": violation["fix"],
|
|
766
|
+
}
|
|
767
|
+
# The spec's line is optional and 1-based ("when the checker can locate
|
|
768
|
+
# it") — a whole-tree condition (budget drift) carries line 0 internally.
|
|
769
|
+
if int(violation["line"]) >= 1:
|
|
770
|
+
finding["line"] = violation["line"]
|
|
771
|
+
findings.append(finding)
|
|
772
|
+
return {
|
|
773
|
+
"terp_check_report": 1,
|
|
774
|
+
"spec_version": SPEC_VERSION,
|
|
775
|
+
"checker": {"tool": "terp-arch", "version": version},
|
|
776
|
+
"ok": report["ok"],
|
|
777
|
+
"rules": [f"backend/{rule}" for rule in report["rules"]], # type: ignore[union-attr]
|
|
778
|
+
"findings": findings,
|
|
779
|
+
"unattributed": [],
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _mermaid_id(prefix: str, name: str) -> str:
|
|
784
|
+
"""A Mermaid-safe node id (``prefix_`` + non-alphanumerics collapsed to ``_``)."""
|
|
785
|
+
return f"{prefix}_{re.sub(r'[^0-9A-Za-z_]', '_', name)}"
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _load_control_plane(dotted: str) -> ControlPlane:
|
|
790
|
+
module_name, _, attr = dotted.partition(":")
|
|
791
|
+
module = importlib.import_module(module_name)
|
|
792
|
+
candidate = getattr(module, attr or "control_plane")
|
|
793
|
+
if not isinstance(candidate, ControlPlane):
|
|
794
|
+
raise SystemExit(
|
|
795
|
+
f"{dotted!r} did not resolve to a terp.core.ControlPlane instance"
|
|
796
|
+
)
|
|
797
|
+
return candidate
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _load_module_spec(dotted: str) -> ModuleSpec:
|
|
801
|
+
module_name, _, attr = dotted.partition(":")
|
|
802
|
+
module = importlib.import_module(module_name)
|
|
803
|
+
candidate = getattr(module, attr or "module")
|
|
804
|
+
if not isinstance(candidate, ModuleSpec):
|
|
805
|
+
raise SystemExit(f"{dotted!r} did not resolve to a terp.core.ModuleSpec instance")
|
|
806
|
+
return candidate
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def inspect_access(
|
|
810
|
+
dotted: str = "control_plane:control_plane",
|
|
811
|
+
*,
|
|
812
|
+
modules: Sequence[str] = (),
|
|
813
|
+
app: str | None = None,
|
|
814
|
+
app_root: str = ".",
|
|
815
|
+
fmt: str = "text",
|
|
816
|
+
) -> str:
|
|
817
|
+
"""Return the access graph (text or json).
|
|
818
|
+
|
|
819
|
+
With ``app`` (a FastAPI instance or zero-arg factory, e.g. ``app.main:build``) the
|
|
820
|
+
graph covers the WHOLE composed surface — every discovered capability router and the
|
|
821
|
+
kernel routes — reconciled against ``app.openapi()`` so no mounted route can hide.
|
|
822
|
+
Without it, the focused form reports just the hand-passed ``modules``.
|
|
823
|
+
|
|
824
|
+
The three-layer view — module policy, per-endpoint requirement, and the data
|
|
825
|
+
layer's row-visibility / write-authority traits — is JSON-first for Studio
|
|
826
|
+
(``terp inspect access --app app.main:build --format json``).
|
|
827
|
+
"""
|
|
828
|
+
if app is not None:
|
|
829
|
+
root = str(pathlib.Path(app_root).resolve())
|
|
830
|
+
if root not in sys.path:
|
|
831
|
+
sys.path.insert(0, root)
|
|
832
|
+
return render_access_graph(build_access_graph_for_app(_load_app(app)), fmt)
|
|
833
|
+
plane = _load_control_plane(dotted)
|
|
834
|
+
specs = [_load_module_spec(module) for module in modules]
|
|
835
|
+
return render_access(plane, specs, fmt=fmt)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def inspect_schema(
|
|
839
|
+
*,
|
|
840
|
+
app_root: str = ".",
|
|
841
|
+
package: str = "app",
|
|
842
|
+
fmt: str = "text",
|
|
843
|
+
) -> str:
|
|
844
|
+
"""Return the schema graph for the app at *app_root* (text or json).
|
|
845
|
+
|
|
846
|
+
Loads every declared migration tree's models module (exactly how ``terp
|
|
847
|
+
migrate`` discovers models), projects the shared metadata as attributed
|
|
848
|
+
tables + kernel traits, and reconciles it against an AST source scan so a
|
|
849
|
+
model can never be silently skipped: unowned / non-canonical / unmapped /
|
|
850
|
+
unimported entries are alarmed, never dropped (JSON-first for Studio).
|
|
851
|
+
"""
|
|
852
|
+
root = str(pathlib.Path(app_root).resolve())
|
|
853
|
+
if root not in sys.path:
|
|
854
|
+
sys.path.insert(0, root)
|
|
855
|
+
# Migration-tree discovery expects the app PACKAGE directory (it scans
|
|
856
|
+
# <package>/modules/<name>), mirroring how `terp migrate` is invoked.
|
|
857
|
+
package_dir = pathlib.Path(app_root) / package
|
|
858
|
+
trees = import_declared_models(
|
|
859
|
+
package_dir if package_dir.is_dir() else None, package=package
|
|
860
|
+
)
|
|
861
|
+
graph = build_schema_graph(
|
|
862
|
+
trees, source_models=scan_declared_table_models(app_root, package=package)
|
|
863
|
+
)
|
|
864
|
+
return render_schema_graph(graph, fmt)
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def inspect_control_plane(
|
|
868
|
+
dotted: str = "control_plane:control_plane",
|
|
869
|
+
*,
|
|
870
|
+
modules: Sequence[str] = (),
|
|
871
|
+
fmt: str = "text",
|
|
872
|
+
) -> str:
|
|
873
|
+
"""Return an authority map for *dotted* control plane (text or mermaid)."""
|
|
874
|
+
plane = _load_control_plane(dotted)
|
|
875
|
+
specs = [_load_module_spec(module) for module in modules]
|
|
876
|
+
if fmt == "mermaid":
|
|
877
|
+
return _render_mermaid(plane, specs)
|
|
878
|
+
if fmt == "json":
|
|
879
|
+
return _render_json(plane, specs)
|
|
880
|
+
return _render_text(plane, specs)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _render_json(plane: ControlPlane, specs: Sequence[ModuleSpec]) -> str:
|
|
884
|
+
"""Render the authority map as JSON — the structured introspection seam for
|
|
885
|
+
external tooling (e.g. Terp Studio) that must not import ``terp.*``."""
|
|
886
|
+
payload = {
|
|
887
|
+
"roles": [
|
|
888
|
+
{"name": role.name, "rank": role.rank}
|
|
889
|
+
for role in sorted(plane.permissions.roles, key=lambda item: item.rank)
|
|
890
|
+
],
|
|
891
|
+
"permissions": [
|
|
892
|
+
{"name": permission.name, "min_role": permission.min_role.name}
|
|
893
|
+
for permission in sorted(
|
|
894
|
+
plane.permissions.permissions, key=lambda item: item.name
|
|
895
|
+
)
|
|
896
|
+
],
|
|
897
|
+
"modules": [_module_json(spec) for spec in sorted(specs, key=lambda item: item.name)],
|
|
898
|
+
"events": [
|
|
899
|
+
{
|
|
900
|
+
"name": event.name,
|
|
901
|
+
"visibility": event.visibility.value,
|
|
902
|
+
"payload_schema": event.payload_schema.__name__,
|
|
903
|
+
}
|
|
904
|
+
for event in sorted(plane.events.events, key=lambda item: item.name)
|
|
905
|
+
],
|
|
906
|
+
"jobs": [
|
|
907
|
+
{
|
|
908
|
+
"name": job.name,
|
|
909
|
+
"queue": job.queue,
|
|
910
|
+
"visibility": job.visibility.value,
|
|
911
|
+
"max_attempts": job.retry.max_attempts,
|
|
912
|
+
}
|
|
913
|
+
for job in sorted(plane.jobs.jobs, key=lambda item: item.name)
|
|
914
|
+
],
|
|
915
|
+
# Platform policies: the rest of the ControlPlane aggregate. The redact
|
|
916
|
+
# keys are substring markers (never secret values) and the denylist is
|
|
917
|
+
# summarised as a count (its entries are noise, not policy shape).
|
|
918
|
+
"audit": {
|
|
919
|
+
"enabled": plane.audit.enabled,
|
|
920
|
+
"disabled_reason": plane.audit.disabled_reason,
|
|
921
|
+
"retention_days": plane.audit.retention_days,
|
|
922
|
+
"redact_keys": list(plane.audit.redact_keys),
|
|
923
|
+
},
|
|
924
|
+
"passwords": {
|
|
925
|
+
"min_length": plane.passwords.min_length,
|
|
926
|
+
"min_character_classes": plane.passwords.min_character_classes,
|
|
927
|
+
"denylist_size": len(plane.passwords.denylist),
|
|
928
|
+
"relaxed_reason": plane.passwords.relaxed_reason,
|
|
929
|
+
},
|
|
930
|
+
"security": {
|
|
931
|
+
"cors": _cors_json(plane.security.cors),
|
|
932
|
+
"rate_limit": {
|
|
933
|
+
"enabled": plane.security.rate_limit.enabled,
|
|
934
|
+
"requests": plane.security.rate_limit.requests,
|
|
935
|
+
"window_seconds": plane.security.rate_limit.window_seconds,
|
|
936
|
+
},
|
|
937
|
+
"max_request_bytes": plane.security.max_request_bytes,
|
|
938
|
+
"trusted_proxy_hops": plane.security.trusted_proxy_hops,
|
|
939
|
+
"request_id_header": plane.security.request_id_header,
|
|
940
|
+
},
|
|
941
|
+
"schedules": [
|
|
942
|
+
{"name": schedule.name, "cron": schedule.cron, "job": schedule.job.name}
|
|
943
|
+
for schedule in sorted(
|
|
944
|
+
plane.schedules.schedules, key=lambda item: item.name
|
|
945
|
+
)
|
|
946
|
+
],
|
|
947
|
+
"job_system_actor": plane.job_system_actor_id is not None,
|
|
948
|
+
}
|
|
949
|
+
return json.dumps(payload, indent=2)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _cors_json(cors: CorsPolicy) -> dict[str, object]:
|
|
953
|
+
"""The CORS declaration as one of three explicit modes (never raw fields)."""
|
|
954
|
+
if cors.disabled_reason is not None:
|
|
955
|
+
return {"mode": "disabled", "reason": cors.disabled_reason}
|
|
956
|
+
if cors.allow_origins:
|
|
957
|
+
return {
|
|
958
|
+
"mode": "allow",
|
|
959
|
+
"origins": list(cors.allow_origins),
|
|
960
|
+
"allow_credentials": cors.allow_credentials,
|
|
961
|
+
}
|
|
962
|
+
return {"mode": "deny-all", "configured": cors.configured}
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _module_json(spec: ModuleSpec) -> dict[str, object]:
|
|
966
|
+
policy: dict[str, object] | None = None
|
|
967
|
+
if spec.policy is not None:
|
|
968
|
+
if spec.policy.is_public:
|
|
969
|
+
policy = {"public": True, "public_reason": spec.policy.public_reason}
|
|
970
|
+
else:
|
|
971
|
+
policy = {
|
|
972
|
+
"public": False,
|
|
973
|
+
"read": spec.policy.read_requirement.label,
|
|
974
|
+
"write": spec.policy.write_requirement.label,
|
|
975
|
+
}
|
|
976
|
+
return {
|
|
977
|
+
"name": spec.name,
|
|
978
|
+
"policy": policy,
|
|
979
|
+
"emits": [event.name for event in spec.emits],
|
|
980
|
+
"subscribes": [event.name for event in spec.subscribes],
|
|
981
|
+
"jobs": [job.name for job in spec.jobs],
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def _render_text(plane: ControlPlane, specs: Sequence[ModuleSpec]) -> str:
|
|
986
|
+
lines = ["Roles"]
|
|
987
|
+
for role in sorted(plane.permissions.roles, key=lambda item: item.rank):
|
|
988
|
+
lines.append(f" {role.name} ({role.rank})")
|
|
989
|
+
lines.append("")
|
|
990
|
+
lines.append("Permissions")
|
|
991
|
+
if not plane.permissions.permissions:
|
|
992
|
+
lines.append(" <none declared>")
|
|
993
|
+
for permission in sorted(plane.permissions.permissions, key=lambda item: item.name):
|
|
994
|
+
lines.append(f" {permission.name} {permission.min_role.name}+")
|
|
995
|
+
lines.append("")
|
|
996
|
+
lines.append("Modules")
|
|
997
|
+
if not specs:
|
|
998
|
+
lines.append(" <none provided>")
|
|
999
|
+
for spec in sorted(specs, key=lambda item: item.name):
|
|
1000
|
+
lines.append(f" {spec.name} {_policy_label(spec)}")
|
|
1001
|
+
lines.append("")
|
|
1002
|
+
lines.append("Audit")
|
|
1003
|
+
if plane.audit.enabled:
|
|
1004
|
+
retention = (
|
|
1005
|
+
f"{plane.audit.retention_days} days"
|
|
1006
|
+
if plane.audit.retention_days is not None
|
|
1007
|
+
else "unlimited"
|
|
1008
|
+
)
|
|
1009
|
+
lines.append(
|
|
1010
|
+
f" enabled retention={retention} "
|
|
1011
|
+
f"redact_keys={len(plane.audit.redact_keys)}"
|
|
1012
|
+
)
|
|
1013
|
+
else:
|
|
1014
|
+
lines.append(f" DISABLED ({plane.audit.disabled_reason})")
|
|
1015
|
+
lines.append("")
|
|
1016
|
+
lines.append("Passwords")
|
|
1017
|
+
password_line = (
|
|
1018
|
+
f" min_length={plane.passwords.min_length} "
|
|
1019
|
+
f"min_character_classes={plane.passwords.min_character_classes} "
|
|
1020
|
+
f"denylist={len(plane.passwords.denylist)} entries"
|
|
1021
|
+
)
|
|
1022
|
+
if plane.passwords.relaxed_reason is not None:
|
|
1023
|
+
password_line += f" RELAXED ({plane.passwords.relaxed_reason})"
|
|
1024
|
+
lines.append(password_line)
|
|
1025
|
+
lines.append("")
|
|
1026
|
+
lines.append("Security")
|
|
1027
|
+
lines.append(f" cors {_cors_label(plane.security.cors)}")
|
|
1028
|
+
rate_limit = plane.security.rate_limit
|
|
1029
|
+
lines.append(
|
|
1030
|
+
f" rate_limit "
|
|
1031
|
+
+ (
|
|
1032
|
+
f"{rate_limit.requests}/{rate_limit.window_seconds}s"
|
|
1033
|
+
if rate_limit.enabled
|
|
1034
|
+
else "DISABLED"
|
|
1035
|
+
)
|
|
1036
|
+
)
|
|
1037
|
+
lines.append(
|
|
1038
|
+
f" max_request_bytes={plane.security.max_request_bytes} "
|
|
1039
|
+
f"trusted_proxy_hops={plane.security.trusted_proxy_hops}"
|
|
1040
|
+
)
|
|
1041
|
+
lines.append("")
|
|
1042
|
+
lines.append("Schedules")
|
|
1043
|
+
if not plane.schedules.schedules:
|
|
1044
|
+
lines.append(" <none declared>")
|
|
1045
|
+
for schedule in sorted(plane.schedules.schedules, key=lambda item: item.name):
|
|
1046
|
+
lines.append(f" {schedule.name} {schedule.cron} -> {schedule.job.name}")
|
|
1047
|
+
return "\n".join(lines)
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _cors_label(cors: CorsPolicy) -> str:
|
|
1051
|
+
if cors.disabled_reason is not None:
|
|
1052
|
+
return f"disabled ({cors.disabled_reason})"
|
|
1053
|
+
if cors.allow_origins:
|
|
1054
|
+
return "allow " + ", ".join(cors.allow_origins)
|
|
1055
|
+
return "deny-all" + ("" if cors.configured else " (unconfigured)")
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _policy_label(spec: ModuleSpec) -> str:
|
|
1059
|
+
if spec.policy is None:
|
|
1060
|
+
return "policy=<missing>"
|
|
1061
|
+
if spec.policy.is_public:
|
|
1062
|
+
return f"public ({spec.policy.public_reason})"
|
|
1063
|
+
return (
|
|
1064
|
+
f"read={spec.policy.read_requirement.label} "
|
|
1065
|
+
f"write={spec.policy.write_requirement.label}"
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _render_mermaid(plane: ControlPlane, specs: Sequence[ModuleSpec]) -> str:
|
|
1070
|
+
"""Render the authority map as a Mermaid ``flowchart`` for visualization.
|
|
1071
|
+
|
|
1072
|
+
Node ids are sanitized to ``[0-9A-Za-z_]`` and every label is quoted, so
|
|
1073
|
+
permission names containing ``.`` / ``:`` (e.g. ``billing.read``) stay valid
|
|
1074
|
+
Mermaid rather than breaking the diagram.
|
|
1075
|
+
"""
|
|
1076
|
+
lines = ["flowchart LR"]
|
|
1077
|
+
ladder = sorted(plane.permissions.roles, key=lambda item: item.rank)
|
|
1078
|
+
lines.append(" subgraph Roles")
|
|
1079
|
+
for lower, higher in zip(ladder, ladder[1:]):
|
|
1080
|
+
lines.append(
|
|
1081
|
+
f' {_mermaid_id("role", lower.name)}["{lower.name}"]'
|
|
1082
|
+
f' --> {_mermaid_id("role", higher.name)}["{higher.name}"]'
|
|
1083
|
+
)
|
|
1084
|
+
if len(ladder) == 1:
|
|
1085
|
+
only = ladder[0]
|
|
1086
|
+
lines.append(f' {_mermaid_id("role", only.name)}["{only.name}"]')
|
|
1087
|
+
lines.append(" end")
|
|
1088
|
+
for spec in sorted(specs, key=lambda item: item.name):
|
|
1089
|
+
module_id = _mermaid_id("module", spec.name)
|
|
1090
|
+
lines.append(f' {module_id}(["{spec.name}"])')
|
|
1091
|
+
if spec.policy is not None and not spec.policy.is_public:
|
|
1092
|
+
for verb, requirement in (
|
|
1093
|
+
("read", spec.policy.read_requirement),
|
|
1094
|
+
("write", spec.policy.write_requirement),
|
|
1095
|
+
):
|
|
1096
|
+
authz_id = _mermaid_id("authz", requirement.label)
|
|
1097
|
+
lines.append(
|
|
1098
|
+
f' {module_id} -- "{verb}:{requirement.name}" '
|
|
1099
|
+
f'--> {authz_id}["{requirement.label}"]'
|
|
1100
|
+
)
|
|
1101
|
+
return "\n".join(lines)
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
1105
|
+
parser = argparse.ArgumentParser(prog="terp")
|
|
1106
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
1107
|
+
|
|
1108
|
+
inspect_parser = subcommands.add_parser("inspect")
|
|
1109
|
+
inspect_subcommands = inspect_parser.add_subparsers(
|
|
1110
|
+
dest="inspect_command",
|
|
1111
|
+
required=True,
|
|
1112
|
+
)
|
|
1113
|
+
control_plane_parser = inspect_subcommands.add_parser("control-plane")
|
|
1114
|
+
control_plane_parser.add_argument(
|
|
1115
|
+
"--object",
|
|
1116
|
+
default="control_plane:control_plane",
|
|
1117
|
+
help="Dotted object to inspect (default: control_plane:control_plane)",
|
|
1118
|
+
)
|
|
1119
|
+
control_plane_parser.add_argument(
|
|
1120
|
+
"--module",
|
|
1121
|
+
action="append",
|
|
1122
|
+
default=[],
|
|
1123
|
+
help="Dotted ModuleSpec to include (may be repeated)",
|
|
1124
|
+
)
|
|
1125
|
+
control_plane_parser.add_argument(
|
|
1126
|
+
"--format",
|
|
1127
|
+
choices=("text", "mermaid", "json"),
|
|
1128
|
+
default="text",
|
|
1129
|
+
help="Output format (default: text)",
|
|
1130
|
+
)
|
|
1131
|
+
jobs_inspect_parser = inspect_subcommands.add_parser("jobs")
|
|
1132
|
+
jobs_inspect_parser.add_argument(
|
|
1133
|
+
"--object",
|
|
1134
|
+
default="control_plane:control_plane",
|
|
1135
|
+
help="Dotted ControlPlane to inspect (default: control_plane:control_plane)",
|
|
1136
|
+
)
|
|
1137
|
+
access_parser = inspect_subcommands.add_parser(
|
|
1138
|
+
"access",
|
|
1139
|
+
help="The access graph: module policies, per-endpoint requirements, data traits",
|
|
1140
|
+
)
|
|
1141
|
+
access_parser.add_argument(
|
|
1142
|
+
"--object",
|
|
1143
|
+
default="control_plane:control_plane",
|
|
1144
|
+
help="Dotted object to inspect (default: control_plane:control_plane)",
|
|
1145
|
+
)
|
|
1146
|
+
access_parser.add_argument(
|
|
1147
|
+
"--module",
|
|
1148
|
+
action="append",
|
|
1149
|
+
default=[],
|
|
1150
|
+
help="Dotted ModuleSpec to include (may be repeated)",
|
|
1151
|
+
)
|
|
1152
|
+
access_parser.add_argument(
|
|
1153
|
+
"--app",
|
|
1154
|
+
default=None,
|
|
1155
|
+
help="Composed FastAPI app or factory (e.g. app.main:build): report the WHOLE "
|
|
1156
|
+
"mounted surface incl. discovered capabilities, reconciled against app.openapi()",
|
|
1157
|
+
)
|
|
1158
|
+
access_parser.add_argument(
|
|
1159
|
+
"--app-root",
|
|
1160
|
+
default=".",
|
|
1161
|
+
help="Directory placed first on sys.path so --app imports (default: .)",
|
|
1162
|
+
)
|
|
1163
|
+
access_parser.add_argument(
|
|
1164
|
+
"--format",
|
|
1165
|
+
choices=("text", "json"),
|
|
1166
|
+
default="text",
|
|
1167
|
+
help="Output format: text (human) or json (structured, for Studio; default: text)",
|
|
1168
|
+
)
|
|
1169
|
+
schema_parser = inspect_subcommands.add_parser(
|
|
1170
|
+
"schema",
|
|
1171
|
+
help="The schema graph: every table with ownership, traits, and fail-visible "
|
|
1172
|
+
"alarms for models the framework cannot account for",
|
|
1173
|
+
)
|
|
1174
|
+
schema_parser.add_argument(
|
|
1175
|
+
"--app-root",
|
|
1176
|
+
default=".",
|
|
1177
|
+
help="Project root put first on sys.path; its modules' migration trees load "
|
|
1178
|
+
"(default: .)",
|
|
1179
|
+
)
|
|
1180
|
+
schema_parser.add_argument(
|
|
1181
|
+
"--package",
|
|
1182
|
+
default="app",
|
|
1183
|
+
help="The app package owning app/modules/<name> trees (default: app)",
|
|
1184
|
+
)
|
|
1185
|
+
schema_parser.add_argument(
|
|
1186
|
+
"--format",
|
|
1187
|
+
choices=("text", "json"),
|
|
1188
|
+
default="text",
|
|
1189
|
+
help="Output format: text (human) or json (structured, for Studio; default: text)",
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
guide_parser = subcommands.add_parser(
|
|
1193
|
+
"guide", help="Print the Terp authoring guide (or a recipe for a topic)"
|
|
1194
|
+
)
|
|
1195
|
+
guide_parser.add_argument(
|
|
1196
|
+
"topic",
|
|
1197
|
+
nargs="?",
|
|
1198
|
+
default=None,
|
|
1199
|
+
help="Optional topic or exact architecture rule for a focused recipe "
|
|
1200
|
+
"(validated on dispatch, so the rule registry stays off the common CLI path)",
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
migrate_parser = subcommands.add_parser(
|
|
1204
|
+
"migrate",
|
|
1205
|
+
help="Run database migrations (upgrade / downgrade / make / status / check)",
|
|
1206
|
+
)
|
|
1207
|
+
migrate_parser.add_argument(
|
|
1208
|
+
"migrate_args",
|
|
1209
|
+
nargs=argparse.REMAINDER,
|
|
1210
|
+
help="Arguments forwarded to the migration runner (e.g. upgrade --database-url ...)",
|
|
1211
|
+
)
|
|
1212
|
+
|
|
1213
|
+
jobs_parser = subcommands.add_parser(
|
|
1214
|
+
"jobs", help="Run a background job or list the declared jobs (ADR 0043)"
|
|
1215
|
+
)
|
|
1216
|
+
jobs_subcommands = jobs_parser.add_subparsers(dest="jobs_command", required=True)
|
|
1217
|
+
jobs_run_parser = jobs_subcommands.add_parser(
|
|
1218
|
+
"run", help="Enqueue/run one job by name (the external-scheduler trigger)"
|
|
1219
|
+
)
|
|
1220
|
+
jobs_run_parser.add_argument("name", help="Job name (e.g. sync.customers.pull)")
|
|
1221
|
+
jobs_run_parser.add_argument(
|
|
1222
|
+
"--payload", default="{}", help="JSON payload for the job (default: {})"
|
|
1223
|
+
)
|
|
1224
|
+
jobs_run_parser.add_argument(
|
|
1225
|
+
"--app",
|
|
1226
|
+
default="app.main:app",
|
|
1227
|
+
help="Dotted module:attribute of the FastAPI app or factory (default: app.main:app)",
|
|
1228
|
+
)
|
|
1229
|
+
jobs_run_parser.add_argument(
|
|
1230
|
+
"--app-root", default=".", help="App root placed first on sys.path (default: .)"
|
|
1231
|
+
)
|
|
1232
|
+
jobs_list_parser = jobs_subcommands.add_parser(
|
|
1233
|
+
"list", help="List the jobs the control plane declares"
|
|
1234
|
+
)
|
|
1235
|
+
jobs_list_parser.add_argument(
|
|
1236
|
+
"--object",
|
|
1237
|
+
default="control_plane:control_plane",
|
|
1238
|
+
help="Dotted ControlPlane to read (default: control_plane:control_plane)",
|
|
1239
|
+
)
|
|
1240
|
+
jobs_worker_parser = jobs_subcommands.add_parser(
|
|
1241
|
+
"worker", help="Drain the durable outbox: run due jobs/events, retry, dead-letter (ADR 0044)"
|
|
1242
|
+
)
|
|
1243
|
+
jobs_worker_parser.add_argument(
|
|
1244
|
+
"--app",
|
|
1245
|
+
default="app.main:app",
|
|
1246
|
+
help="Dotted module:attribute of the FastAPI app or factory (default: app.main:app)",
|
|
1247
|
+
)
|
|
1248
|
+
jobs_worker_parser.add_argument(
|
|
1249
|
+
"--app-root", default=".", help="App root placed first on sys.path (default: .)"
|
|
1250
|
+
)
|
|
1251
|
+
jobs_worker_parser.add_argument(
|
|
1252
|
+
"--max-cycles",
|
|
1253
|
+
type=int,
|
|
1254
|
+
default=None,
|
|
1255
|
+
help="Drain at most this many batches, else until the outbox is empty (default: until empty)",
|
|
1256
|
+
)
|
|
1257
|
+
jobs_worker_parser.add_argument(
|
|
1258
|
+
"--batch-size", type=int, default=10, help="Rows leased per claim (default: 10)"
|
|
1259
|
+
)
|
|
1260
|
+
jobs_worker_parser.add_argument(
|
|
1261
|
+
"--lease-seconds",
|
|
1262
|
+
type=float,
|
|
1263
|
+
default=30.0,
|
|
1264
|
+
help="Lease duration before a stalled row may be reclaimed (default: 30)",
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
jobs_scheduler_parser = jobs_subcommands.add_parser(
|
|
1268
|
+
"scheduler",
|
|
1269
|
+
help="Run the in-process scheduler: fire declared schedules on their cron (ADR 0047/0048)",
|
|
1270
|
+
)
|
|
1271
|
+
jobs_scheduler_parser.add_argument(
|
|
1272
|
+
"--app",
|
|
1273
|
+
default="app.main:app",
|
|
1274
|
+
help="Dotted module:attribute of the FastAPI app or factory (default: app.main:app)",
|
|
1275
|
+
)
|
|
1276
|
+
jobs_scheduler_parser.add_argument(
|
|
1277
|
+
"--app-root", default=".", help="App root placed first on sys.path (default: .)"
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
new_parser = subcommands.add_parser("new", help="Scaffold a canonical module")
|
|
1281
|
+
new_subcommands = new_parser.add_subparsers(dest="new_command", required=True)
|
|
1282
|
+
module_parser = new_subcommands.add_parser(
|
|
1283
|
+
"module", help="Scaffold a full-stack module (backend slots + frontend slot)"
|
|
1284
|
+
)
|
|
1285
|
+
module_parser.add_argument("name", help="Module name (lowercase identifier, e.g. invoices)")
|
|
1286
|
+
module_parser.add_argument("--root", default=".", help="App root to scaffold into (default: .)")
|
|
1287
|
+
module_parser.add_argument("--package", default="app", help="Module package root (default: app)")
|
|
1288
|
+
module_parser.add_argument(
|
|
1289
|
+
"--no-frontend",
|
|
1290
|
+
action="store_true",
|
|
1291
|
+
help="Skip the frontend slot even when a frontend app is present",
|
|
1292
|
+
)
|
|
1293
|
+
module_parser.add_argument(
|
|
1294
|
+
"--profile",
|
|
1295
|
+
default=DEFAULT_PROFILE,
|
|
1296
|
+
choices=profile_names(),
|
|
1297
|
+
help="Permission profile the slots compile to (default: %(default)s; "
|
|
1298
|
+
"see 'terp guide access')",
|
|
1299
|
+
)
|
|
1300
|
+
|
|
1301
|
+
apidocs_parser = subcommands.add_parser(
|
|
1302
|
+
"api-docs", help="Generate the public-API reference + .pyi from the live kernel"
|
|
1303
|
+
)
|
|
1304
|
+
apidocs_parser.add_argument("--out", default="docs", help="Output directory (default: docs)")
|
|
1305
|
+
|
|
1306
|
+
openapi_parser = subcommands.add_parser(
|
|
1307
|
+
"openapi", help="Export the app's OpenAPI document (the frontend-contract source)"
|
|
1308
|
+
)
|
|
1309
|
+
openapi_parser.add_argument(
|
|
1310
|
+
"--app",
|
|
1311
|
+
default="app.main:app",
|
|
1312
|
+
help="Dotted module:attribute of the FastAPI app or factory (default: app.main:app)",
|
|
1313
|
+
)
|
|
1314
|
+
openapi_parser.add_argument(
|
|
1315
|
+
"--out", default="openapi.json", help="Output file (default: openapi.json)"
|
|
1316
|
+
)
|
|
1317
|
+
openapi_parser.add_argument(
|
|
1318
|
+
"--app-root", default=".", help="App root placed first on sys.path (default: .)"
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
dev_parser = subcommands.add_parser(
|
|
1322
|
+
"dev",
|
|
1323
|
+
help="Run the backend + frontend dev servers together (with an OpenAPI preflight)",
|
|
1324
|
+
)
|
|
1325
|
+
dev_parser.add_argument(
|
|
1326
|
+
"--app",
|
|
1327
|
+
default="app.main:app",
|
|
1328
|
+
help="Dotted module:attribute of the FastAPI app (default: app.main:app)",
|
|
1329
|
+
)
|
|
1330
|
+
dev_parser.add_argument(
|
|
1331
|
+
"--app-root", default=".", help="Project root placed first on sys.path (default: .)"
|
|
1332
|
+
)
|
|
1333
|
+
dev_parser.add_argument(
|
|
1334
|
+
"--frontend-dir", default="frontend", help="Frontend app directory (default: frontend)"
|
|
1335
|
+
)
|
|
1336
|
+
dev_parser.add_argument(
|
|
1337
|
+
"--host", default="127.0.0.1", help="Backend host (default: 127.0.0.1)"
|
|
1338
|
+
)
|
|
1339
|
+
dev_parser.add_argument(
|
|
1340
|
+
"--port", type=int, default=8000, help="Backend port (default: 8000)"
|
|
1341
|
+
)
|
|
1342
|
+
dev_parser.add_argument(
|
|
1343
|
+
"--openapi-out",
|
|
1344
|
+
default="openapi.json",
|
|
1345
|
+
help="Preflight OpenAPI output path, relative to root (default: openapi.json)",
|
|
1346
|
+
)
|
|
1347
|
+
dev_parser.add_argument(
|
|
1348
|
+
"--no-preflight", action="store_true", help="Skip the OpenAPI preflight export"
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
check_parser = subcommands.add_parser("check", help="Run the architecture gate locally")
|
|
1352
|
+
check_parser.add_argument("--root", default=".", help="App root (default: .)")
|
|
1353
|
+
check_parser.add_argument("--package", default="app", help="App package (default: app)")
|
|
1354
|
+
check_parser.add_argument(
|
|
1355
|
+
"--budget", default=None, help="Escape-hatch budget JSON (governs # arch-allow markers)"
|
|
1356
|
+
)
|
|
1357
|
+
check_parser.add_argument(
|
|
1358
|
+
"--format",
|
|
1359
|
+
choices=("text", "json", "check-report"),
|
|
1360
|
+
default="text",
|
|
1361
|
+
help="Output format: text (human), json (the legacy structured report), or "
|
|
1362
|
+
"check-report (the Terp Standard app-check-report envelope; default: text)",
|
|
1363
|
+
)
|
|
1364
|
+
|
|
1365
|
+
verify_parser = subcommands.add_parser(
|
|
1366
|
+
"verify",
|
|
1367
|
+
help="Run the project's whole verification profile (the one-command gate)",
|
|
1368
|
+
)
|
|
1369
|
+
verify_parser.add_argument(
|
|
1370
|
+
"--profile",
|
|
1371
|
+
choices=profile_ids(),
|
|
1372
|
+
default="quick",
|
|
1373
|
+
help="Which checks run: quick (static enforcement), full (+ tests, AppSec "
|
|
1374
|
+
"baseline, build), release (+ docs drift, conformance; default: quick)",
|
|
1375
|
+
)
|
|
1376
|
+
verify_parser.add_argument("--root", default=".", help="Project root (default: .)")
|
|
1377
|
+
verify_parser.add_argument(
|
|
1378
|
+
"--only",
|
|
1379
|
+
action="append",
|
|
1380
|
+
default=[],
|
|
1381
|
+
metavar="CHECK",
|
|
1382
|
+
help="Run only the named check(s) of the profile (repeatable) — the seam a "
|
|
1383
|
+
"driving tool uses for change-scoped reruns",
|
|
1384
|
+
)
|
|
1385
|
+
verify_parser.add_argument(
|
|
1386
|
+
"--list",
|
|
1387
|
+
action="store_true",
|
|
1388
|
+
help="Print the profile's check manifest without running anything",
|
|
1389
|
+
)
|
|
1390
|
+
verify_parser.add_argument(
|
|
1391
|
+
"--format",
|
|
1392
|
+
choices=("text", "json", "assurance"),
|
|
1393
|
+
default="text",
|
|
1394
|
+
help="Output format: text (human), json (the terp_verify envelope), or "
|
|
1395
|
+
"assurance (the release-assurance claim, assurance-profile.schema.json; "
|
|
1396
|
+
"requires --profile release; default: text)",
|
|
1397
|
+
)
|
|
1398
|
+
|
|
1399
|
+
user_parser = subcommands.add_parser(
|
|
1400
|
+
"user", help="Manage users (e.g. bootstrap the first administrator)"
|
|
1401
|
+
)
|
|
1402
|
+
user_subcommands = user_parser.add_subparsers(dest="user_command", required=True)
|
|
1403
|
+
user_create_parser = user_subcommands.add_parser(
|
|
1404
|
+
"create", help="Create (or confirm) a user directly against the app's store"
|
|
1405
|
+
)
|
|
1406
|
+
user_create_parser.add_argument("email", help="The user's email address")
|
|
1407
|
+
user_create_parser.add_argument(
|
|
1408
|
+
"--role",
|
|
1409
|
+
default="admin",
|
|
1410
|
+
help="viewer / editor / admin or an integer rank (default: admin)",
|
|
1411
|
+
)
|
|
1412
|
+
user_create_parser.add_argument(
|
|
1413
|
+
"--app",
|
|
1414
|
+
default="app.main:app",
|
|
1415
|
+
help="Dotted module:attribute of the FastAPI app (default: app.main:app)",
|
|
1416
|
+
)
|
|
1417
|
+
user_create_parser.add_argument(
|
|
1418
|
+
"--app-root", default=".", help="App root placed first on sys.path (default: .)"
|
|
1419
|
+
)
|
|
1420
|
+
user_create_parser.add_argument(
|
|
1421
|
+
"--password-env",
|
|
1422
|
+
default="TERP_USER_PASSWORD",
|
|
1423
|
+
help="Env var holding the new password; prompts if unset (default: TERP_USER_PASSWORD)",
|
|
1424
|
+
)
|
|
1425
|
+
|
|
1426
|
+
seed_parser = subcommands.add_parser(
|
|
1427
|
+
"seed", help="Run the app's seed routine (idempotent demo / bootstrap data; dev only)"
|
|
1428
|
+
)
|
|
1429
|
+
seed_parser.add_argument(
|
|
1430
|
+
"--app",
|
|
1431
|
+
default="app.main:app",
|
|
1432
|
+
help="Dotted module:attribute of the FastAPI app (default: app.main:app)",
|
|
1433
|
+
)
|
|
1434
|
+
seed_parser.add_argument(
|
|
1435
|
+
"--app-root", default=".", help="App root placed first on sys.path (default: .)"
|
|
1436
|
+
)
|
|
1437
|
+
seed_parser.add_argument(
|
|
1438
|
+
"--seed",
|
|
1439
|
+
default="app.seed:seed",
|
|
1440
|
+
help="Dotted module:attribute of the seed callable (default: app.seed:seed)",
|
|
1441
|
+
)
|
|
1442
|
+
|
|
1443
|
+
docker_parser = subcommands.add_parser(
|
|
1444
|
+
"docker", help="Docker workflows (the Compose dev workbench)"
|
|
1445
|
+
)
|
|
1446
|
+
docker_subcommands = docker_parser.add_subparsers(dest="docker_command", required=True)
|
|
1447
|
+
docker_dev_parser = docker_subcommands.add_parser(
|
|
1448
|
+
"dev", help="Run the full-stack workbench via `docker compose watch` (db + api + web)"
|
|
1449
|
+
)
|
|
1450
|
+
docker_dev_parser.add_argument(
|
|
1451
|
+
"--compose-file",
|
|
1452
|
+
default="docker-compose.yml",
|
|
1453
|
+
help="Compose file, resolved under --root (default: docker-compose.yml)",
|
|
1454
|
+
)
|
|
1455
|
+
docker_dev_parser.add_argument(
|
|
1456
|
+
"--root", default=".", help="Directory the compose file is resolved against (default: .)"
|
|
1457
|
+
)
|
|
1458
|
+
docker_dev_parser.add_argument(
|
|
1459
|
+
"--project-name", default=None, help="Compose project name (default: Compose's own)"
|
|
1460
|
+
)
|
|
1461
|
+
return parser
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
1465
|
+
"""Console entry point."""
|
|
1466
|
+
parser = _build_parser()
|
|
1467
|
+
args = parser.parse_args(argv)
|
|
1468
|
+
if args.command == "inspect" and args.inspect_command == "control-plane":
|
|
1469
|
+
print(inspect_control_plane(args.object, modules=args.module, fmt=args.format))
|
|
1470
|
+
return
|
|
1471
|
+
if args.command == "inspect" and args.inspect_command == "jobs":
|
|
1472
|
+
print(render_jobs(args.object))
|
|
1473
|
+
return
|
|
1474
|
+
if args.command == "inspect" and args.inspect_command == "access":
|
|
1475
|
+
print(
|
|
1476
|
+
inspect_access(
|
|
1477
|
+
args.object,
|
|
1478
|
+
modules=args.module,
|
|
1479
|
+
app=args.app,
|
|
1480
|
+
app_root=args.app_root,
|
|
1481
|
+
fmt=args.format,
|
|
1482
|
+
)
|
|
1483
|
+
)
|
|
1484
|
+
return
|
|
1485
|
+
if args.command == "inspect" and args.inspect_command == "schema":
|
|
1486
|
+
print(inspect_schema(app_root=args.app_root, package=args.package, fmt=args.format))
|
|
1487
|
+
return
|
|
1488
|
+
if args.command == "guide":
|
|
1489
|
+
if args.topic is not None and args.topic not in guide_choices():
|
|
1490
|
+
raise SystemExit(
|
|
1491
|
+
f"terp guide: unknown topic or rule {args.topic!r}; run `terp guide` "
|
|
1492
|
+
"for the topic list or `terp guide rules` for every rule name"
|
|
1493
|
+
)
|
|
1494
|
+
print(guide(args.topic))
|
|
1495
|
+
return
|
|
1496
|
+
if args.command == "migrate":
|
|
1497
|
+
from terp.migrations import migrate_main
|
|
1498
|
+
|
|
1499
|
+
migrate_main(args.migrate_args)
|
|
1500
|
+
return
|
|
1501
|
+
if args.command == "jobs" and args.jobs_command == "run":
|
|
1502
|
+
print(
|
|
1503
|
+
run_job_command(
|
|
1504
|
+
args.name, payload=args.payload, app_ref=args.app, app_root=args.app_root
|
|
1505
|
+
)
|
|
1506
|
+
)
|
|
1507
|
+
return
|
|
1508
|
+
if args.command == "jobs" and args.jobs_command == "list":
|
|
1509
|
+
print(render_jobs(args.object))
|
|
1510
|
+
return
|
|
1511
|
+
if args.command == "jobs" and args.jobs_command == "worker":
|
|
1512
|
+
print(
|
|
1513
|
+
run_worker_command(
|
|
1514
|
+
app_ref=args.app,
|
|
1515
|
+
app_root=args.app_root,
|
|
1516
|
+
max_cycles=args.max_cycles,
|
|
1517
|
+
batch_size=args.batch_size,
|
|
1518
|
+
lease_seconds=args.lease_seconds,
|
|
1519
|
+
)
|
|
1520
|
+
)
|
|
1521
|
+
return
|
|
1522
|
+
if args.command == "jobs" and args.jobs_command == "scheduler":
|
|
1523
|
+
print(run_scheduler_command(app_ref=args.app, app_root=args.app_root))
|
|
1524
|
+
return
|
|
1525
|
+
if args.command == "new" and args.new_command == "module":
|
|
1526
|
+
paths = new_module(
|
|
1527
|
+
args.name,
|
|
1528
|
+
root=args.root,
|
|
1529
|
+
package=args.package,
|
|
1530
|
+
frontend=not args.no_frontend,
|
|
1531
|
+
profile=args.profile,
|
|
1532
|
+
)
|
|
1533
|
+
print(new_module_message(args.name, paths, profile=args.profile))
|
|
1534
|
+
return
|
|
1535
|
+
if args.command == "api-docs":
|
|
1536
|
+
for path in api_docs(args.out):
|
|
1537
|
+
print(f"wrote {path}")
|
|
1538
|
+
return
|
|
1539
|
+
if args.command == "openapi":
|
|
1540
|
+
print(f"wrote {export_openapi(args.app, out=args.out, app_root=args.app_root)}")
|
|
1541
|
+
return
|
|
1542
|
+
if args.command == "dev":
|
|
1543
|
+
print(
|
|
1544
|
+
run_dev_command(
|
|
1545
|
+
app_ref=args.app,
|
|
1546
|
+
root=args.app_root,
|
|
1547
|
+
frontend_dir=args.frontend_dir,
|
|
1548
|
+
host=args.host,
|
|
1549
|
+
port=args.port,
|
|
1550
|
+
openapi_out=args.openapi_out,
|
|
1551
|
+
preflight=not args.no_preflight,
|
|
1552
|
+
)
|
|
1553
|
+
)
|
|
1554
|
+
return
|
|
1555
|
+
if args.command == "check":
|
|
1556
|
+
if args.format == "check-report":
|
|
1557
|
+
payload = check_report_envelope(
|
|
1558
|
+
args.root, package=args.package, budget_path=args.budget
|
|
1559
|
+
)
|
|
1560
|
+
print(json.dumps(payload, indent=2))
|
|
1561
|
+
if not payload["ok"]:
|
|
1562
|
+
raise SystemExit(1)
|
|
1563
|
+
return
|
|
1564
|
+
if args.format == "json":
|
|
1565
|
+
payload = check_report(args.root, package=args.package, budget_path=args.budget)
|
|
1566
|
+
print(json.dumps(payload, indent=2))
|
|
1567
|
+
if not payload["ok"]:
|
|
1568
|
+
raise SystemExit(1)
|
|
1569
|
+
return
|
|
1570
|
+
from terp.arch import assert_app_clean
|
|
1571
|
+
|
|
1572
|
+
assert_app_clean(args.root, package=args.package, budget_path=args.budget)
|
|
1573
|
+
print("terp.arch: app is clean")
|
|
1574
|
+
return
|
|
1575
|
+
if args.command == "verify":
|
|
1576
|
+
raise SystemExit(
|
|
1577
|
+
run_verify_command(
|
|
1578
|
+
profile=args.profile,
|
|
1579
|
+
root=args.root,
|
|
1580
|
+
only=args.only,
|
|
1581
|
+
list_only=args.list,
|
|
1582
|
+
fmt=args.format,
|
|
1583
|
+
)
|
|
1584
|
+
)
|
|
1585
|
+
if args.command == "user" and args.user_command == "create":
|
|
1586
|
+
print(
|
|
1587
|
+
create_user_command(
|
|
1588
|
+
args.email,
|
|
1589
|
+
role=args.role,
|
|
1590
|
+
app_ref=args.app,
|
|
1591
|
+
app_root=args.app_root,
|
|
1592
|
+
password_env=args.password_env,
|
|
1593
|
+
)
|
|
1594
|
+
)
|
|
1595
|
+
return
|
|
1596
|
+
if args.command == "seed":
|
|
1597
|
+
print(run_seed_command(app_ref=args.app, app_root=args.app_root, seed_ref=args.seed))
|
|
1598
|
+
return
|
|
1599
|
+
if args.command == "docker" and args.docker_command == "dev":
|
|
1600
|
+
print(
|
|
1601
|
+
run_docker_dev_command(
|
|
1602
|
+
compose_file=args.compose_file, root=args.root, project_name=args.project_name
|
|
1603
|
+
)
|
|
1604
|
+
)
|
|
1605
|
+
return
|
|
1606
|
+
parser.error("unknown command") # pragma: no cover - argparse guards this
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
__all__ = [
|
|
1610
|
+
"api_docs",
|
|
1611
|
+
"check_report",
|
|
1612
|
+
"check_report_envelope",
|
|
1613
|
+
"create_user_command",
|
|
1614
|
+
"dev_plan",
|
|
1615
|
+
"export_openapi",
|
|
1616
|
+
"guide",
|
|
1617
|
+
"guide_topics",
|
|
1618
|
+
"guide_choices",
|
|
1619
|
+
"inspect_access",
|
|
1620
|
+
"inspect_control_plane",
|
|
1621
|
+
"main",
|
|
1622
|
+
"new_module",
|
|
1623
|
+
"profile_ids",
|
|
1624
|
+
"render_jobs",
|
|
1625
|
+
"run_dev_command",
|
|
1626
|
+
"run_docker_dev_command",
|
|
1627
|
+
"run_job_command",
|
|
1628
|
+
"run_scheduler_command",
|
|
1629
|
+
"run_seed_command",
|
|
1630
|
+
"run_verify_command",
|
|
1631
|
+
"run_worker_command",
|
|
1632
|
+
"verify_manifest",
|
|
1633
|
+
]
|