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/seed.py ADDED
@@ -0,0 +1,67 @@
1
+ """``terp seed`` — run the app's declared seed routine (idempotent demo / bootstrap data).
2
+
3
+ Populates a fresh database so the app is immediately usable — the missing half of
4
+ ``terp migrate upgrade`` for a dev / demo environment. The seed itself lives in the app
5
+ (default ``app.seed:seed``), a callable taking a :class:`~sqlmodel.Session`, so each app
6
+ decides what "seeded" means. The example's seed provisions users and a little content
7
+ through the real audited services, so seeding dogfoods audit, events, actor-stamping,
8
+ ownership, and tenancy.
9
+
10
+ Fail-closed: it refuses to run when ``ENVIRONMENT=production`` — seed data must never touch
11
+ a production store; a real deployment bootstraps its first admin with ``terp user create``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import importlib
18
+ import pathlib
19
+ from collections.abc import Callable
20
+
21
+ from sqlmodel import Session
22
+
23
+ from terp.cli._appref import load_app, push_app_root
24
+ from terp.core import settings
25
+ from terp.core.db import get_session
26
+
27
+ SeedFn = Callable[[Session], "str | None"]
28
+
29
+
30
+ def load_seed(dotted: str) -> SeedFn:
31
+ """Resolve a ``module:attribute`` reference to a ``seed(session)`` callable."""
32
+ module_name, _, attr = dotted.partition(":")
33
+ if not module_name:
34
+ raise SystemExit(f"{dotted!r} is not a valid 'module:attribute' reference")
35
+ module = importlib.import_module(module_name)
36
+ candidate = getattr(module, attr or "seed", None)
37
+ if not callable(candidate):
38
+ raise SystemExit(f"{dotted!r} did not resolve to a callable seed(session)")
39
+ return candidate
40
+
41
+
42
+ def run_seed_command(
43
+ *,
44
+ app_ref: str = "app.main:app",
45
+ app_root: str | pathlib.Path = ".",
46
+ seed_ref: str = "app.seed:seed",
47
+ production: bool | None = None,
48
+ ) -> str:
49
+ """Build *app_ref*, resolve *seed_ref*, and run it in one write-guarded session.
50
+
51
+ *production* defaults to the app's environment; when true the command fails closed with a
52
+ clean CLI error (seed data is dev / demo only). Injecting it keeps the guard unit-testable.
53
+ """
54
+ if production is None:
55
+ production = settings.is_production
56
+ if production:
57
+ raise SystemExit(
58
+ "terp seed refuses to run when ENVIRONMENT=production; seed data is dev/demo only "
59
+ "(bootstrap a real first admin with `terp user create`)"
60
+ )
61
+ push_app_root(app_root)
62
+ load_app(app_ref)
63
+ seed = load_seed(seed_ref)
64
+ with contextlib.closing(get_session()) as gen:
65
+ session = next(gen)
66
+ summary = seed(session)
67
+ return summary or "seeded"
terp/cli/users.py ADDED
@@ -0,0 +1,94 @@
1
+ """``terp user create`` — provision (or confirm) a user straight against the app's store.
2
+
3
+ Closes the first-administrator bootstrap: the admin-only ``/users`` API cannot mint the
4
+ *first* administrator (no admin exists yet to authorize the call), so an operator needs an
5
+ out-of-band seam. This builds the app (so ``create_app`` has configured the engine and the
6
+ durable audit sink), opens a session on the live database, and provisions through the
7
+ audited :class:`~terp.capabilities.users.UsersService` chokepoint — so even the bootstrap
8
+ admin lands an audit row and is held to the app's ``PasswordPolicy``.
9
+
10
+ The password is read from an environment variable (default ``TERP_USER_PASSWORD``) or an
11
+ interactive prompt — never a command-line argument, so it cannot leak into shell history or
12
+ the process table. Re-running for an existing email is a no-op (idempotent).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import getpass
19
+ import os
20
+ import pathlib
21
+ from collections.abc import Callable
22
+
23
+ from terp.cli._appref import load_app, push_app_root
24
+ from terp.core import Roles
25
+ from terp.core.db import get_session
26
+
27
+ _ROLE_ALIASES = {
28
+ "viewer": int(Roles.VIEWER),
29
+ "editor": int(Roles.EDITOR),
30
+ "admin": int(Roles.ADMIN),
31
+ }
32
+
33
+
34
+ def resolve_role(value: str) -> int:
35
+ """Resolve a ``--role`` value: a name (viewer / editor / admin) or an integer rank."""
36
+ key = value.strip().lower()
37
+ if key in _ROLE_ALIASES:
38
+ return _ROLE_ALIASES[key]
39
+ try:
40
+ rank = int(value)
41
+ except ValueError:
42
+ raise SystemExit(
43
+ f"--role must be viewer / editor / admin or an integer rank, not {value!r}"
44
+ ) from None
45
+ if rank < 0:
46
+ raise SystemExit("--role rank must be >= 0")
47
+ return rank
48
+
49
+
50
+ def read_password(env_var: str) -> str:
51
+ """Read the new password from *env_var*, falling back to an interactive prompt."""
52
+ password = os.environ.get(env_var)
53
+ if password:
54
+ return password
55
+ return getpass.getpass("New user password: ")
56
+
57
+
58
+ def create_user_command(
59
+ email: str,
60
+ *,
61
+ role: str = "admin",
62
+ app_ref: str = "app.main:app",
63
+ app_root: str | pathlib.Path = ".",
64
+ password_env: str = "TERP_USER_PASSWORD", # noqa: S107 - the env var *name*, not a secret
65
+ password_reader: Callable[[str], str] = read_password,
66
+ ) -> str:
67
+ """Build *app_ref*, then create (or confirm) user *email* with *role*, audited.
68
+
69
+ Idempotent: an already-present email is reported and left unchanged. The password comes
70
+ from *password_env* or an interactive prompt via *password_reader* (injected in tests),
71
+ and its strength is enforced by the ``UsersService`` write chokepoint — a weak password
72
+ fails closed as a clean CLI error.
73
+ """
74
+ push_app_root(app_root)
75
+ load_app(app_ref)
76
+
77
+ from terp.capabilities.users import UserProvision, UsersService
78
+ from terp.core import AppError
79
+
80
+ rank = resolve_role(role)
81
+ service = UsersService()
82
+ with contextlib.closing(get_session()) as gen:
83
+ session = next(gen)
84
+ existing = service.get_by_email(session, email)
85
+ if existing is not None:
86
+ return f"user {email!r} already exists (id {existing.id}); left unchanged"
87
+ password = password_reader(password_env)
88
+ try:
89
+ user = service.create(
90
+ session, UserProvision(email=email, password=password, role=rank)
91
+ )
92
+ except AppError as exc:
93
+ raise SystemExit(f"could not create user {email!r}: {exc}") from exc
94
+ return f"created user {email!r} (id {user.id}, role rank {rank})"
terp/cli/verify.py ADDED
@@ -0,0 +1,469 @@
1
+ """``terp verify`` — the project's whole verification profile as one command.
2
+
3
+ The single source of truth for "what does green mean" (the gate a human, an
4
+ agent, CI, and a driving tool such as Terp Studio all run): a **profile** is a
5
+ named list of checks (id, category, command, input scope), declared here as
6
+ data and executed sequentially in the project root. Three profiles ratchet up:
7
+
8
+ * ``quick`` — static enforcement only (architecture gate, frontend boundary
9
+ lint, frontend typecheck): cheap enough to run after every agent turn.
10
+ * ``full`` — the merge bar: quick plus the backend test suite, the delegated
11
+ generic AppSec baseline (ruff ``S``, ADR 0085), and the production frontend
12
+ build. This is exactly the template CI's blocking surface.
13
+ * ``release`` — full plus the dependency audits (pip-audit / npm audit — the
14
+ spec's required ``dependency-audit`` assurance lane), the contract-drift
15
+ checks and the black-box conformance suite (which needs the Docker workbench
16
+ running; see the check's ``requires`` note in the manifest).
17
+
18
+ ``--list`` prints the manifest without running anything — the seam a driving
19
+ tool reads so its gate DEFINITION comes from the project's own pinned
20
+ toolchain instead of a hardcoded copy. ``--only <id>`` runs a subset (the
21
+ change-scoped rerun seam). ``--format json`` emits the ``terp_verify``
22
+ envelope: per-check verdicts plus every Terp Standard check report
23
+ (``terp_check_report`` document, ``app-check-report.schema.json``) and legacy
24
+ findings envelope (``terp_findings``) the checks published on stdout — parsed
25
+ out and carried structurally, never re-derived by the consumer.
26
+ ``--format assurance`` (release profile only) emits the spec's release-
27
+ assurance claim instead (``assurance-profile.schema.json``): the run's checks
28
+ composed into the normative evidence lanes, with the exit code following the
29
+ required lanes.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import os
36
+ import pathlib
37
+ import shlex
38
+ import shutil
39
+ import subprocess
40
+ import sys
41
+ from dataclasses import dataclass
42
+
43
+ #: How much of a failing check's combined output the envelope keeps (fail-closed
44
+ #: on unbounded output; enough to show the actual errors).
45
+ _OUTPUT_TAIL_CHARS = 20_000
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class VerifyCheck:
50
+ """One named check of a profile: a command tagged with an issue category.
51
+
52
+ ``scope`` lists the ``/``-separated path globs that can affect the check's
53
+ verdict (``dir/**`` = the whole subtree) — the input claim a change-aware
54
+ driving tool uses to prove a rerun unnecessary. ``requires`` is a
55
+ human-readable precondition (e.g. a running workbench) surfaced in the
56
+ manifest, never checked here: the check itself fails visibly when unmet.
57
+ """
58
+
59
+ id: str
60
+ category: str
61
+ command: str
62
+ scope: tuple[str, ...] = ()
63
+ requires: str = ""
64
+ #: In-process checks (the architecture gate) run as a callable instead of a
65
+ #: subprocess — same verdict surface, no interpreter round-trip.
66
+ runner: str = "subprocess" # "subprocess" | "architecture" | "api-docs-drift"
67
+
68
+
69
+ _ARCHITECTURE = VerifyCheck(
70
+ id="architecture",
71
+ category="architecture",
72
+ command="terp check --format check-report --budget escape-hatch-budget.json",
73
+ scope=("app/**", "control_plane/**", "escape-hatch-budget.json"),
74
+ runner="architecture",
75
+ )
76
+
77
+ _FRONTEND_BOUNDARIES = VerifyCheck(
78
+ id="frontend-boundaries",
79
+ category="frontend-boundaries",
80
+ command="npm --prefix frontend run lint -- --format check-report",
81
+ scope=("frontend/**", "escape-hatch-budget.json"),
82
+ )
83
+
84
+ _FRONTEND_TYPECHECK = VerifyCheck(
85
+ id="frontend-typecheck",
86
+ category="build",
87
+ command="npm --prefix frontend run typecheck",
88
+ scope=("frontend/**", "app/**"),
89
+ )
90
+
91
+ _BACKEND_TESTS = VerifyCheck(
92
+ id="backend-tests",
93
+ category="backend-tests",
94
+ command="uv run pytest",
95
+ scope=("app/**", "control_plane/**", "tests/**", "conformance/**"),
96
+ )
97
+
98
+ _APPSEC_BASELINE = VerifyCheck(
99
+ id="appsec-baseline",
100
+ category="architecture",
101
+ command="uv run ruff check .",
102
+ scope=("app/**", "control_plane/**", "tests/**"),
103
+ )
104
+
105
+ _FRONTEND_BUILD = VerifyCheck(
106
+ id="frontend-build",
107
+ category="build",
108
+ command="npm --prefix frontend run build",
109
+ scope=("frontend/**", "app/**"),
110
+ )
111
+
112
+ _API_DOCS_DRIFT = VerifyCheck(
113
+ id="api-docs-drift",
114
+ category="build",
115
+ command="terp api-docs --out docs && git diff --exit-code -- docs",
116
+ scope=("app/**", "docs/**"),
117
+ runner="api-docs-drift",
118
+ )
119
+
120
+ # The dependency-audit assurance lane (the spec's required generic evidence):
121
+ # both dependency trees against known-vulnerability databases. Release-profile
122
+ # checks (not the merge bar): advisory databases move independently of the
123
+ # code, so a red here means "do not ship", not "this change broke something".
124
+ _DEPENDENCY_AUDIT_PYTHON = VerifyCheck(
125
+ id="dependency-audit-python",
126
+ category="architecture",
127
+ command="uv run --with pip-audit pip-audit --progress-spinner off",
128
+ scope=("pyproject.toml", "uv.lock"),
129
+ requires="network access to the advisory databases",
130
+ )
131
+
132
+ _DEPENDENCY_AUDIT_NPM = VerifyCheck(
133
+ id="dependency-audit-npm",
134
+ category="architecture",
135
+ command="npm --prefix frontend audit --audit-level=high",
136
+ scope=("frontend/package.json", "frontend/package-lock.json"),
137
+ requires="network access to the advisory databases",
138
+ )
139
+
140
+ _CONFORMANCE = VerifyCheck(
141
+ id="conformance",
142
+ category="conformance",
143
+ command="npm --prefix conformance test",
144
+ scope=("app/**", "frontend/**", "conformance/**"),
145
+ requires="the Docker workbench running (docker compose up -d --wait api web seed)",
146
+ )
147
+
148
+ #: The profiles, cheapest first; each is a superset of the previous.
149
+ PROFILES: dict[str, tuple[VerifyCheck, ...]] = {
150
+ "quick": (_ARCHITECTURE, _FRONTEND_BOUNDARIES, _FRONTEND_TYPECHECK),
151
+ "full": (
152
+ _ARCHITECTURE,
153
+ _BACKEND_TESTS,
154
+ _APPSEC_BASELINE,
155
+ _FRONTEND_BOUNDARIES,
156
+ _FRONTEND_TYPECHECK,
157
+ _FRONTEND_BUILD,
158
+ ),
159
+ "release": (
160
+ _ARCHITECTURE,
161
+ _BACKEND_TESTS,
162
+ _APPSEC_BASELINE,
163
+ _DEPENDENCY_AUDIT_PYTHON,
164
+ _DEPENDENCY_AUDIT_NPM,
165
+ _FRONTEND_BOUNDARIES,
166
+ _FRONTEND_TYPECHECK,
167
+ _FRONTEND_BUILD,
168
+ _API_DOCS_DRIFT,
169
+ _CONFORMANCE,
170
+ ),
171
+ }
172
+
173
+ #: The Terp Standard's assurance-lane vocabulary → (requirement, composing
174
+ #: release-profile check ids). The vocabulary and each lane's requirement
175
+ #: level are NORMATIVE in the spec (assurance-profile.schema.json + the
176
+ #: README's "Assurance profile" table) — these constants mirror them, held to
177
+ #: the pinned spec's schema by the framework gate. ``a11y`` is declared but
178
+ #: not realised by this toolchain yet: it is emitted ``not-run`` (a lane is
179
+ #: never dropped and never counted as passed without evidence).
180
+ ASSURANCE_LANES: tuple[tuple[str, str, tuple[str, ...]], ...] = (
181
+ ("terp-standard", "required", ("architecture", "frontend-boundaries")),
182
+ ("appsec-baseline", "required", ("appsec-baseline",)),
183
+ (
184
+ "dependency-audit",
185
+ "required",
186
+ ("dependency-audit-python", "dependency-audit-npm"),
187
+ ),
188
+ ("a11y", "recommended", ()),
189
+ ("blackbox-conformance", "recommended", ("conformance",)),
190
+ )
191
+
192
+
193
+ def profile_ids() -> tuple[str, ...]:
194
+ """The declared profile names (the ``--profile`` choices)."""
195
+ return tuple(PROFILES)
196
+
197
+
198
+ def verify_manifest(profile: str) -> dict[str, object]:
199
+ """The profile's check manifest as data (the ``--list --format json`` body).
200
+
201
+ A driving tool configures its gate FROM this — the project's own pinned
202
+ toolchain states what green means — instead of hardcoding a copy that
203
+ drifts. ``command`` is the exact invocation ``terp verify`` itself runs.
204
+ """
205
+ checks = PROFILES.get(profile)
206
+ if checks is None:
207
+ raise SystemExit(f"unknown profile {profile!r}; expected one of {profile_ids()}")
208
+ return {
209
+ "terp_verify_manifest": 1,
210
+ "profile": profile,
211
+ "checks": [
212
+ {
213
+ "id": check.id,
214
+ "category": check.category,
215
+ "command": check.command,
216
+ "scope": list(check.scope),
217
+ **({"requires": check.requires} if check.requires else {}),
218
+ }
219
+ for check in checks
220
+ ],
221
+ }
222
+
223
+
224
+ def _json_documents(stdout: str) -> list[dict]:
225
+ """Every top-level JSON object embedded in *stdout*, tolerantly.
226
+
227
+ A check's stdout may interleave prose with one or more JSON documents (the
228
+ single-line ``terp_findings`` envelope, the indented ``terp_check_report``).
229
+ Anything unparseable is skipped — the consumer falls back to the raw tail.
230
+ """
231
+ documents: list[dict] = []
232
+ decoder = json.JSONDecoder()
233
+ index = 0
234
+ while True:
235
+ start = stdout.find("{", index)
236
+ if start == -1:
237
+ return documents
238
+ try:
239
+ payload, end = decoder.raw_decode(stdout[start:])
240
+ except ValueError:
241
+ index = start + 1
242
+ continue
243
+ if isinstance(payload, dict):
244
+ documents.append(payload)
245
+ index = start + max(end, 1)
246
+
247
+
248
+ def _reports_in(stdout: str) -> list[dict]:
249
+ """The machine documents a check published: check reports + legacy envelopes."""
250
+ return [
251
+ document
252
+ for document in _json_documents(stdout)
253
+ if document.get("terp_check_report") is not None
254
+ or document.get("terp_findings") is not None
255
+ ]
256
+
257
+
258
+ def _run_subprocess(check: VerifyCheck, root: pathlib.Path) -> tuple[int, str]:
259
+ """Run one manifest command (shell-less; ``&&`` composites never land here)."""
260
+ argv = shlex.split(check.command)
261
+ executable = shutil.which(argv[0]) or argv[0]
262
+ try:
263
+ completed = subprocess.run( # noqa: S603 - fixed manifest argv, shell=False
264
+ [executable, *argv[1:]],
265
+ cwd=root,
266
+ capture_output=True,
267
+ text=True,
268
+ encoding="utf-8",
269
+ errors="replace",
270
+ check=False,
271
+ )
272
+ except FileNotFoundError:
273
+ return 127, f"{argv[0]}: executable not found on PATH"
274
+ return completed.returncode, completed.stdout + ("\n" + completed.stderr if completed.stderr else "")
275
+
276
+
277
+ def _run_architecture(root: pathlib.Path) -> tuple[int, str, list[dict]]:
278
+ """The architecture gate in-process: the check report without a subprocess."""
279
+ from terp.cli import check_report_envelope # lazy: the package imports this module
280
+
281
+ budget = root / "escape-hatch-budget.json"
282
+ envelope = check_report_envelope(
283
+ str(root), budget_path=str(budget) if budget.is_file() else None
284
+ )
285
+ ok = bool(envelope["ok"])
286
+ summary = json.dumps(envelope, indent=2)
287
+ return (0 if ok else 1), summary, [envelope]
288
+
289
+
290
+ def _run_api_docs_drift(root: pathlib.Path) -> tuple[int, str]:
291
+ """Regenerate the API reference and fail on drift from the committed copy.
292
+
293
+ A no-op success until the project commits ``docs/`` (the template CI pair
294
+ behaves identically: the diff of an untracked directory is empty).
295
+ """
296
+ from terp.cli import api_docs
297
+
298
+ docs = root / "docs"
299
+ if not docs.is_dir():
300
+ return 0, "docs/ not committed - drift check skipped (commit docs/ to enable)"
301
+ previous = pathlib.Path.cwd()
302
+ try:
303
+ # api_docs writes relative to cwd through the live kernel import.
304
+ os.chdir(root)
305
+ written = [str(path) for path in api_docs(str(docs))]
306
+ finally:
307
+ os.chdir(previous)
308
+ git = shutil.which("git") or "git"
309
+ completed = subprocess.run( # noqa: S603 - fixed argv, shell=False
310
+ [git, "diff", "--exit-code", "--", "docs"],
311
+ cwd=root,
312
+ capture_output=True,
313
+ text=True,
314
+ encoding="utf-8",
315
+ errors="replace",
316
+ check=False,
317
+ )
318
+ output = "\n".join(["\n".join(f"wrote {path}" for path in written), completed.stdout])
319
+ if completed.returncode != 0:
320
+ output += "\napi docs drifted from the committed copy - commit the regenerated docs/"
321
+ return completed.returncode, output
322
+
323
+
324
+ def assurance_document(results: list[dict[str, object]]) -> dict[str, object]:
325
+ """The release-assurance claim (``assurance-profile.schema.json``) from a
326
+ release-profile run's per-check *results*.
327
+
328
+ Lane verdicts compose from the named checks' verdicts: every composing
329
+ check green ⇒ ``passed``, otherwise ``failed``; a lane this toolchain does
330
+ not realise (``a11y``) is ``not-run``, never dropped. The claim (``ok``)
331
+ follows the REQUIRED lanes only — the requirement mapping is the spec's,
332
+ mirrored in :data:`ASSURANCE_LANES` — so a red recommended lane informs
333
+ the reader without carrying the claim.
334
+ """
335
+ import importlib.metadata
336
+
337
+ from terp.arch import SPEC_VERSION # lazy: the package imports this module
338
+
339
+ verdicts = {str(result["id"]): bool(result["ok"]) for result in results}
340
+ lanes: list[dict[str, object]] = []
341
+ ok = True
342
+ for lane_id, requirement, check_ids in ASSURANCE_LANES:
343
+ if not check_ids:
344
+ status = "not-run"
345
+ elif all(verdicts.get(check_id, False) for check_id in check_ids):
346
+ status = "passed"
347
+ else:
348
+ status = "failed"
349
+ if requirement == "required" and status != "passed":
350
+ ok = False
351
+ lanes.append({"id": lane_id, "status": status, "checks": list(check_ids)})
352
+ try:
353
+ version = importlib.metadata.version("terp-cli")
354
+ except importlib.metadata.PackageNotFoundError: # a source checkout (the platform repo)
355
+ version = "0"
356
+ return {
357
+ "terp_assurance": 1,
358
+ "spec_version": SPEC_VERSION,
359
+ "toolchain": {"tool": "terp-verify", "version": version},
360
+ "profile": "release",
361
+ "ok": ok,
362
+ "lanes": lanes,
363
+ }
364
+
365
+
366
+ def run_verify_command(
367
+ *,
368
+ profile: str,
369
+ root: str = ".",
370
+ only: list[str] | None = None,
371
+ list_only: bool = False,
372
+ fmt: str = "text",
373
+ ) -> int:
374
+ """Run (or list) the profile; returns the process exit code.
375
+
376
+ Human progress goes to stderr so ``--format json`` keeps stdout as one
377
+ machine document (the same stdout/stderr split as ``terp-boundaries-lint``).
378
+ ``--format assurance`` emits the release-assurance claim instead
379
+ (``assurance-profile.schema.json``) and its exit code follows the claim:
380
+ every REQUIRED lane passed = 0 — a red recommended lane does not fail the
381
+ emission (the strict every-check gate remains ``--format text``/``json``).
382
+ Assurance is only meaningful over the whole release profile, so it refuses
383
+ any other profile, ``--only`` subsets, and ``--list`` (fail closed: a
384
+ partial run can never quietly become a release claim).
385
+ """
386
+ if fmt == "assurance" and (profile != "release" or only or list_only):
387
+ raise SystemExit(
388
+ "--format assurance emits the release-assurance claim: it requires "
389
+ "--profile release and refuses --only/--list — a partial run can "
390
+ "never become a release claim"
391
+ )
392
+ manifest = verify_manifest(profile)
393
+ checks = list(PROFILES[profile])
394
+ selected = [name for name in (only or []) if name]
395
+ if selected:
396
+ known = {check.id for check in checks}
397
+ unknown = sorted(set(selected) - known)
398
+ if unknown:
399
+ raise SystemExit(
400
+ f"--only names no check of profile {profile!r}: {', '.join(unknown)} "
401
+ f"(known: {', '.join(sorted(known))})"
402
+ )
403
+ checks = [check for check in checks if check.id in selected]
404
+
405
+ if list_only:
406
+ if fmt == "json":
407
+ print(json.dumps(manifest, indent=2))
408
+ else:
409
+ print(f"profile {profile}:")
410
+ for check in PROFILES[profile]:
411
+ requires = f" [requires {check.requires}]" if check.requires else ""
412
+ print(f" {check.id:<20} {check.command}{requires}")
413
+ return 0
414
+
415
+ project_root = pathlib.Path(root).resolve()
416
+ results: list[dict[str, object]] = []
417
+ all_ok = True
418
+ for check in checks:
419
+ print(f"verify: {check.id} ({check.command})", file=sys.stderr)
420
+ reports: list[dict] = []
421
+ if check.runner == "architecture":
422
+ exit_code, output, reports = _run_architecture(project_root)
423
+ elif check.runner == "api-docs-drift":
424
+ exit_code, output = _run_api_docs_drift(project_root)
425
+ else:
426
+ exit_code, output = _run_subprocess(check, project_root)
427
+ reports = _reports_in(output)
428
+ ok = exit_code == 0
429
+ all_ok = all_ok and ok
430
+ print(
431
+ f"verify: {check.id} {'ok' if ok else f'FAILED (exit {exit_code})'}",
432
+ file=sys.stderr,
433
+ )
434
+ if not ok and fmt == "text":
435
+ print(output[-_OUTPUT_TAIL_CHARS:], file=sys.stderr)
436
+ results.append(
437
+ {
438
+ "id": check.id,
439
+ "category": check.category,
440
+ "command": check.command,
441
+ "scope": list(check.scope),
442
+ "ok": ok,
443
+ "exit_code": exit_code,
444
+ "output_tail": output[-_OUTPUT_TAIL_CHARS:],
445
+ "reports": reports,
446
+ }
447
+ )
448
+
449
+ if fmt == "json":
450
+ print(
451
+ json.dumps(
452
+ {
453
+ "terp_verify": 1,
454
+ "profile": profile,
455
+ "ok": all_ok,
456
+ "checks": results,
457
+ }
458
+ )
459
+ )
460
+ elif fmt == "assurance":
461
+ document = assurance_document(results)
462
+ print(json.dumps(document, indent=2))
463
+ verdict = "holds" if document["ok"] else "does NOT hold"
464
+ print(f"verify: the release-assurance claim {verdict}", file=sys.stderr)
465
+ return 0 if document["ok"] else 1
466
+ else:
467
+ verdict = "green" if all_ok else "RED"
468
+ print(f"verify: profile {profile} is {verdict}", file=sys.stderr)
469
+ return 0 if all_ok else 1
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: terp-cli
3
+ Version: 0.1.0
4
+ Summary: Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: terp-arch==0.1.0
8
+ Requires-Dist: terp-core==0.1.0
9
+ Requires-Dist: terp-migrations==0.1.0
10
+ Provides-Extra: jobs
11
+ Requires-Dist: terp-cap-outbox==0.1.0; extra == 'jobs'
12
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.1.0; extra == 'jobs'
13
+ Provides-Extra: scheduler
14
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.1.0; extra == 'scheduler'
15
+ Provides-Extra: worker
16
+ Requires-Dist: terp-cap-outbox==0.1.0; extra == 'worker'
@@ -0,0 +1,19 @@
1
+ terp/cli/__init__.py,sha256=qPWL2PqaDFpzw2KckGfnJuqhWqk6nYqD8_6PLDhCjD4,77130
2
+ terp/cli/_appref.py,sha256=SMlPG4QK8x-slL4c1M9DRJGMwhoAI8EWMDHyoWdqKJQ,1700
3
+ terp/cli/access.py,sha256=_vFqxOBnS1rvwN1dVV9OHNeHL7hjdtQuHfwaBXEI0Ow,19700
4
+ terp/cli/apidocs.py,sha256=eCsezMcloj0ms5s536N3SqmBToe5SXU1EtQY-wjTUdo,5001
5
+ terp/cli/dev.py,sha256=7u0oj-BbHrMpV3Xp7K6iyIsU3kTre2YfZHq75Wr5okw,5196
6
+ terp/cli/docker.py,sha256=fraHor8rdkU_aEJ_jU8K_q45yeR9bb5Y571DadpnZQg,2287
7
+ terp/cli/jobs.py,sha256=FtFf-AH-6FwssXCG5qAHZEb4yd_sAqy09oPZ6dQ8J_U,10098
8
+ terp/cli/openapi.py,sha256=6l0hDXvqs8fL2qhbNYQR7d_5eQhg6fghTJCfoOHti8I,2569
9
+ terp/cli/profiles.py,sha256=x8Wvtq53PKTFBMy2mQyM4ZIlKDDCBAl-ne6NUCAIbq8,6169
10
+ terp/cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ terp/cli/scaffold.py,sha256=KHgE1gZSHspkN0oc-1_fyt-1aFubbe-DBBoyZz0M5EY,12320
12
+ terp/cli/schema.py,sha256=wFKVt4KAyAWfxID2ih4A_STazPmH0KO3OBuCZ27wBc0,13436
13
+ terp/cli/seed.py,sha256=3EmX_52dD-JZ_ncZE9N5TNKmy7ekwg75oIh1G2w5bQ0,2571
14
+ terp/cli/users.py,sha256=blTCeCBb03erMQauAHlRjW_qYpg97OaS2tFSB5UPhk8,3576
15
+ terp/cli/verify.py,sha256=a6CGu_Lcv33sPyCP8fBEECxKtcIEWnWyE_fq4rb4-kE,17901
16
+ terp_cli-0.1.0.dist-info/METADATA,sha256=2jYvdwSVWER_0E_KT79fJOHXh6CYiQ_f_jCm5ohjjX8,624
17
+ terp_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
18
+ terp_cli-0.1.0.dist-info/entry_points.txt,sha256=mHl3XEA4OW7XsxfBe0dOOXcLo97wSfoMx22w4sY4huA,39
19
+ terp_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ terp = terp.cli:main