terp-cli 0.20.0__tar.gz → 0.22.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {terp_cli-0.20.0 → terp_cli-0.22.0}/PKG-INFO +8 -8
- {terp_cli-0.20.0 → terp_cli-0.22.0}/pyproject.toml +8 -8
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/__init__.py +135 -2
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/verify.py +255 -3
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/version.py +131 -27
- {terp_cli-0.20.0 → terp_cli-0.22.0}/.gitignore +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/_appref.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/_subjects.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/access.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/apidocs.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/capabilities.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/deploy_safety.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/dev.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/docker.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/envfile.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/envschema.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/envseams.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/fmt.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/grants.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/jobs.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/leases.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/module_roles.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/openapi.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/outbox.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/profiles.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/py.typed +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/routes.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/scaffold.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/schema.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/seed.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/service_accounts.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/smoke.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/users.py +0 -0
- {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/workbench.py +0 -0
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: terp-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.22.0
|
|
4
4
|
Summary: Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs.
|
|
5
5
|
Project-URL: Repository, https://github.com/AITT-NL/terp-framework
|
|
6
6
|
Project-URL: Changelog, https://github.com/AITT-NL/terp-framework/blob/main/CHANGELOG.md
|
|
7
7
|
License-Expression: Apache-2.0
|
|
8
8
|
Requires-Python: >=3.13
|
|
9
9
|
Requires-Dist: pyyaml>=6.0
|
|
10
|
-
Requires-Dist: terp-arch==0.
|
|
11
|
-
Requires-Dist: terp-core==0.
|
|
12
|
-
Requires-Dist: terp-migrations==0.
|
|
10
|
+
Requires-Dist: terp-arch==0.22.0
|
|
11
|
+
Requires-Dist: terp-core==0.22.0
|
|
12
|
+
Requires-Dist: terp-migrations==0.22.0
|
|
13
13
|
Provides-Extra: jobs
|
|
14
|
-
Requires-Dist: terp-cap-outbox==0.
|
|
15
|
-
Requires-Dist: terp-cap-scheduler-apscheduler==0.
|
|
14
|
+
Requires-Dist: terp-cap-outbox==0.22.0; extra == 'jobs'
|
|
15
|
+
Requires-Dist: terp-cap-scheduler-apscheduler==0.22.0; extra == 'jobs'
|
|
16
16
|
Provides-Extra: scheduler
|
|
17
|
-
Requires-Dist: terp-cap-scheduler-apscheduler==0.
|
|
17
|
+
Requires-Dist: terp-cap-scheduler-apscheduler==0.22.0; extra == 'scheduler'
|
|
18
18
|
Provides-Extra: worker
|
|
19
|
-
Requires-Dist: terp-cap-outbox==0.
|
|
19
|
+
Requires-Dist: terp-cap-outbox==0.22.0; extra == 'worker'
|
|
@@ -4,18 +4,18 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "terp-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.22.0"
|
|
8
8
|
description = "Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs."
|
|
9
9
|
requires-python = ">=3.13"
|
|
10
10
|
license = "Apache-2.0"
|
|
11
11
|
dependencies = [
|
|
12
|
-
"terp-core==0.
|
|
12
|
+
"terp-core==0.22.0",
|
|
13
13
|
# `terp migrate` delegates to terp-migrations (lazily imported), keeping Alembic
|
|
14
14
|
# off the path for `terp inspect` / `terp guide` while making migrations work.
|
|
15
|
-
"terp-migrations==0.
|
|
15
|
+
"terp-migrations==0.22.0",
|
|
16
16
|
# `terp guide rules` projects the live rule registry; terp-arch is imported lazily
|
|
17
17
|
# (only when that topic is rendered), so it stays off the common `terp guide` path.
|
|
18
|
-
"terp-arch==0.
|
|
18
|
+
"terp-arch==0.22.0",
|
|
19
19
|
# `terp verify --only env-seams` and `terp smoke` read the compose profiles as data
|
|
20
20
|
# (anchors and `<<:` merge keys resolved), so both answer without a Docker daemon or
|
|
21
21
|
# even the `docker` binary. Declared explicitly rather than leant on: every app
|
|
@@ -29,11 +29,11 @@ dependencies = [
|
|
|
29
29
|
# or a scheduler engine into apps that only use inspect/check/migrate. Deployments can select
|
|
30
30
|
# one process role, while `jobs` is the convenient complete jobs-process bundle.
|
|
31
31
|
[project.optional-dependencies]
|
|
32
|
-
worker = ["terp-cap-outbox==0.
|
|
33
|
-
scheduler = ["terp-cap-scheduler-apscheduler==0.
|
|
32
|
+
worker = ["terp-cap-outbox==0.22.0"]
|
|
33
|
+
scheduler = ["terp-cap-scheduler-apscheduler==0.22.0"]
|
|
34
34
|
jobs = [
|
|
35
|
-
"terp-cap-outbox==0.
|
|
36
|
-
"terp-cap-scheduler-apscheduler==0.
|
|
35
|
+
"terp-cap-outbox==0.22.0",
|
|
36
|
+
"terp-cap-scheduler-apscheduler==0.22.0",
|
|
37
37
|
]
|
|
38
38
|
|
|
39
39
|
[project.scripts]
|
|
@@ -559,6 +559,107 @@ Recoverable deletes (SoftDeleteMixin)
|
|
|
559
559
|
your own would mean touching deleted_at, which the gate refuses. Today the trait
|
|
560
560
|
preserves the row and the history that cites it; bringing one back is an operator
|
|
561
561
|
action, not an app one.
|
|
562
|
+
""",
|
|
563
|
+
"references": """\
|
|
564
|
+
Stored references (Ref + OnDelete)
|
|
565
|
+
|
|
566
|
+
- A foreign key HAS a referential action whether or not you chose one, and the one
|
|
567
|
+
you get by not choosing (NO ACTION) looks exactly like the one you chose. So
|
|
568
|
+
declare it. on_delete is a required keyword, so the decision cannot be skipped:
|
|
569
|
+
from terp.core import BaseTable, OnDelete, Ref
|
|
570
|
+
class InvoiceLine(BaseTable, table=True):
|
|
571
|
+
invoice_id: uuid.UUID = Ref("invoice.id", on_delete=OnDelete.CASCADE)
|
|
572
|
+
- WHICH action is yours to pick; the platform does not prefer one, because the right
|
|
573
|
+
answer is a property of what the reference MEANS:
|
|
574
|
+
CASCADE this row is part of its parent (an invoice line, an address)
|
|
575
|
+
RESTRICT the parent must not vanish underneath this row (a ledger entry)
|
|
576
|
+
SET NULL a pointer allowed to go slack (an optional assignee) -- nullable
|
|
577
|
+
SET_DEFAULT point at the column default instead (that default must exist)
|
|
578
|
+
NO_ACTION the database takes none; something above it owns this lifecycle
|
|
579
|
+
- NO_ACTION is a real answer, not a cop-out, and it emits NO clause at all. Two
|
|
580
|
+
consequences worth knowing: adopting the declaration on an existing schema costs
|
|
581
|
+
no migration (the DDL is unchanged, only the source now says the silence was
|
|
582
|
+
chosen), and a database reports its default action as absent rather than as the
|
|
583
|
+
words NO ACTION -- which is why emitting the literal would make every
|
|
584
|
+
model-versus-database comparison report drift on that constraint forever.
|
|
585
|
+
- Ref() forwards everything else to Field(), and indexes by default: an unindexed
|
|
586
|
+
foreign key turns every parent delete and every join into a table scan.
|
|
587
|
+
owner_id: uuid.UUID | None = Ref("user.id", on_delete=OnDelete.SET_NULL,
|
|
588
|
+
default=None)
|
|
589
|
+
- SOFT-DELETE CHANGES THE ANSWER. A SoftDeleteMixin row is never DELETEd, only
|
|
590
|
+
stamped, so no action declared against it can fire at all: CASCADE never cascades,
|
|
591
|
+
SET DEFAULT never defaults, and SET NULL is the worst of them -- the children keep
|
|
592
|
+
a live, non-null pointer to a row the read scope now hides from every query, so the
|
|
593
|
+
reference reads as broken rather than absent. Those three are refused by the
|
|
594
|
+
references_declare_delete_behaviour rule. RESTRICT and NO_ACTION are accepted, and
|
|
595
|
+
the difference is not that they are passive: it is that they only ever described
|
|
596
|
+
the hard-delete path no request can reach, so they promise nothing that fails to
|
|
597
|
+
happen. Declare one of those and cascade the STAMP from the owning service, which
|
|
598
|
+
is the only layer that can see it.
|
|
599
|
+
- A service-owned cascade goes in _after_write, inside the same write unit, so every
|
|
600
|
+
removal is audited and a failure anywhere rolls the whole thing back. Drain in
|
|
601
|
+
batches until nothing is left: each pass flushes into the same transaction, so it
|
|
602
|
+
sees the previous pass's deletes, and no parent size is too large.
|
|
603
|
+
def _after_write(self, session, entity, action):
|
|
604
|
+
super()._after_write(session, entity, action)
|
|
605
|
+
if action is not AuditAction.DELETED:
|
|
606
|
+
return
|
|
607
|
+
while True:
|
|
608
|
+
lines, _total = self._lines.list(session, skip=0, limit=1_000,
|
|
609
|
+
filters={"invoice_id": entity.id})
|
|
610
|
+
if not lines:
|
|
611
|
+
break
|
|
612
|
+
for line in lines:
|
|
613
|
+
self._lines._remove(session, line)
|
|
614
|
+
(`invoice_id` has to be on the child service's `filterable` declaration for that
|
|
615
|
+
filter to be accepted -- see `terp guide service`.) The hook runs with raw session
|
|
616
|
+
writes re-forbidden, so `session.delete(...)` there fails closed on purpose -- `_save` / `_remove` re-open the scope for their own
|
|
617
|
+
audited write, and they are the only way through. Prefer this over a database
|
|
618
|
+
CASCADE whenever the removals must appear in the audit trail: the database deletes
|
|
619
|
+
silently and leaves the trail with a hole in it.
|
|
620
|
+
- AUDIT LIVE METADATA (a consumer test, or a boot check where the model set is known):
|
|
621
|
+
from terp.core import assert_references_declare_delete_behaviour
|
|
622
|
+
def test_references_declare_delete_behaviour() -> None:
|
|
623
|
+
import app.models # noqa: F401 -- populate the metadata
|
|
624
|
+
assert_references_declare_delete_behaviour()
|
|
625
|
+
It is deliberately not wired into create_app: SQLModel.metadata is process-global
|
|
626
|
+
and accumulates every table any test ever declared, so an unconditional walk at
|
|
627
|
+
boot would make one ad-hoc test model fail an unrelated later test (ADR 0133).
|
|
628
|
+
- WHAT THIS DOES NOT COVER: ON UPDATE. BaseTable mandates a UUID surrogate primary
|
|
629
|
+
key, so a key never changes and there is nothing for it to cascade.
|
|
630
|
+
""",
|
|
631
|
+
"append-only": """\
|
|
632
|
+
Rows that cannot change, and rows that cannot go (append_only)
|
|
633
|
+
|
|
634
|
+
- A ledger row, an immutable revision, a captured snapshot: the guarantee is real
|
|
635
|
+
only when it is STATED. Without this, immutability is achieved by not exposing an
|
|
636
|
+
update route, which is a guarantee made of missing code -- it holds until someone
|
|
637
|
+
adds a line, and no rule and no review step notices when they do.
|
|
638
|
+
class LedgerEntryService(BaseService[LedgerEntry, LedgerEntryCreate, Never]):
|
|
639
|
+
model = LedgerEntry
|
|
640
|
+
append_only = True
|
|
641
|
+
- The write chokepoint then refuses every non-CREATED write -- update(), delete(),
|
|
642
|
+
and any bespoke _save() -- with the uniform 409. There is nothing to remember at
|
|
643
|
+
the call sites, and no route can opt back in.
|
|
644
|
+
- It is a property of the SERVICE, so it covers the API, a worker, a job and a
|
|
645
|
+
fixture equally. It is not a database grant: a migration or a psql session can
|
|
646
|
+
still write the table. Pair it with the audit trail if you need to know that
|
|
647
|
+
nothing did.
|
|
648
|
+
- append_only is ALL OR NOTHING per table. "Editable until the invoice is posted"
|
|
649
|
+
and "set once, then fixed" are narrower questions with no built-in seam yet; they
|
|
650
|
+
live in the service's own update path today. Write the refusal as a typed
|
|
651
|
+
ConflictError so it reads like the built-in one:
|
|
652
|
+
def update(self, session, entity_id, data):
|
|
653
|
+
entity = self.get(session, entity_id)
|
|
654
|
+
if entity.posted_at is not None:
|
|
655
|
+
raise ConflictError("Een geboekte factuur kan niet meer wijzigen.")
|
|
656
|
+
return super().update(session, entity_id, data)
|
|
657
|
+
- The neighbouring guarantees, so you pick the right one:
|
|
658
|
+
append_only the ROW cannot change after insert
|
|
659
|
+
@read_only the ROUTE writes nothing, though its verb is unsafe
|
|
660
|
+
SoftDeleteMixin the row survives its own delete (see: soft-delete)
|
|
661
|
+
OnDelete.RESTRICT another row cannot be deleted while this one points at it
|
|
662
|
+
BaseUpdateSchema a concurrent writer cannot lose your edit (OCC, 409)
|
|
562
663
|
""",
|
|
563
664
|
"package-boundaries": """\
|
|
564
665
|
Boundaries for a second top-level package (an ungated worker)
|
|
@@ -1070,6 +1171,14 @@ Idempotency (the Idempotency-Key header, terp.core.idempotency)
|
|
|
1070
1171
|
CLIENT opts in per request by sending a header:
|
|
1071
1172
|
Idempotency-Key: <client-generated unique key>
|
|
1072
1173
|
A request without the header behaves exactly as it did before.
|
|
1174
|
+
- GENERATE THE KEY WITH randomUuid() from @terpjs/react-core, not crypto.randomUUID().
|
|
1175
|
+
The browser method exists only in a SECURE CONTEXT while lib.dom declares it
|
|
1176
|
+
unconditionally, so on an http origin -- which is what the compose files publish, so
|
|
1177
|
+
any deployment reached by hostname rather than localhost -- the call is a synchronous
|
|
1178
|
+
TypeError. It type-checks cleanly and it never fires on localhost, so neither tsc nor
|
|
1179
|
+
the test suite ever sees it; the retry-safety this whole feature exists for is what
|
|
1180
|
+
breaks, and only in the deployment. The seam falls back to crypto.getRandomValues,
|
|
1181
|
+
which is not secure-context-gated, for the same entropy.
|
|
1073
1182
|
- The semantics, all typed envelopes:
|
|
1074
1183
|
first call -> executes; the response is stored under the key
|
|
1075
1184
|
retry, same body -> the stored response is replayed
|
|
@@ -1419,8 +1528,32 @@ Forms (react-core primitives)
|
|
|
1419
1528
|
is the layout — never style={} / className / a module stylesheet.
|
|
1420
1529
|
- Submit through the typed client: const client = useTerpClient();
|
|
1421
1530
|
await unwrap(client.POST("/api/v1/invoices/", { body })); a failure throws ApiError
|
|
1422
|
-
({code, status, requestId}) — map codes to copy with useErrorMessage, show
|
|
1423
|
-
success/failure with useToast(), and confirm destructive actions with
|
|
1531
|
+
({code, status, requestId, fields}) — map codes to copy with useErrorMessage, show
|
|
1532
|
+
transient success/failure with useToast(), and confirm destructive actions with
|
|
1533
|
+
ConfirmDialog.
|
|
1534
|
+
- WHERE `errors.number` ABOVE COMES FROM: ApiError.fields. The backend's typed envelope
|
|
1535
|
+
carries per-reason `loc` (AppError details, and FastAPI's own 422 entries, which share
|
|
1536
|
+
the shape on purpose), and `unwrap` turns those into `fields` — a plain object keyed by
|
|
1537
|
+
dotted field path, ready to hand straight to Field's `error` prop. That is the whole
|
|
1538
|
+
loop the error envelope opens: a rejected field lights up under its own input instead of
|
|
1539
|
+
degrading to a generic toast. `Object.keys(error.fields).length > 0` is the test for
|
|
1540
|
+
"this belongs on the form".
|
|
1541
|
+
const RENDERED = ["number", "status"];
|
|
1542
|
+
try {
|
|
1543
|
+
await unwrap(client.POST("/api/v1/invoices/", { body }));
|
|
1544
|
+
} catch (error) {
|
|
1545
|
+
const { shown, leftover } = routeFieldErrors(error, RENDERED);
|
|
1546
|
+
setErrors(shown);
|
|
1547
|
+
if (leftover || Object.keys(shown).length === 0) {
|
|
1548
|
+
toast.error(message(error));
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
- USE routeFieldErrors RATHER THAN A LOOP OF YOUR OWN, and the reason is the `leftover`
|
|
1552
|
+
branch. The naive version — "if fields is non-empty, set them and return" — has a hole
|
|
1553
|
+
that is invisible until it happens: a reason naming a field this form does not render
|
|
1554
|
+
sets state nobody reads AND suppresses the toast on the way out, so the user presses
|
|
1555
|
+
Save and nothing appears. No field lights up, no message, no navigation. A failed write
|
|
1556
|
+
that reports nothing is worse than the toast it replaced.
|
|
1424
1557
|
- Updates carry the row's `version` (optimistic concurrency): send the version you
|
|
1425
1558
|
read; a 409 version_conflict means reload-and-retry, surfaced via ErrorState copy.
|
|
1426
1559
|
- Mirror the backend's input caps client-side (maxLength on Input matching the schema's
|
|
@@ -38,6 +38,7 @@ required lanes.
|
|
|
38
38
|
|
|
39
39
|
from __future__ import annotations
|
|
40
40
|
|
|
41
|
+
import importlib
|
|
41
42
|
import json
|
|
42
43
|
import os
|
|
43
44
|
import pathlib
|
|
@@ -183,6 +184,7 @@ class VerifyCheck:
|
|
|
183
184
|
# "subprocess" | "architecture" | "api-docs-drift" | "routes-drift"
|
|
184
185
|
# | "platform-install" | "env-seams" | "api-client" | "package-boundaries"
|
|
185
186
|
# | "dependency-hygiene" | "workbench" | "deploy-safety"
|
|
187
|
+
# | "production-readiness"
|
|
186
188
|
runner: str = "subprocess"
|
|
187
189
|
|
|
188
190
|
|
|
@@ -259,6 +261,20 @@ _DEPLOY_SAFETY = VerifyCheck(
|
|
|
259
261
|
runner="deploy-safety",
|
|
260
262
|
)
|
|
261
263
|
|
|
264
|
+
# Runs beside deploy-safety and for the same reason, one layer in. That check reads
|
|
265
|
+
# the deployment artifact for properties a declaration cannot excuse; this one reads
|
|
266
|
+
# the declaration itself for the states the platform's own boot refuses. Both answer
|
|
267
|
+
# "would this actually come up where it matters", which is the question a green gate
|
|
268
|
+
# is otherwise silent about — and both cost a file read or an import, so they can run
|
|
269
|
+
# in every profile including the cheapest.
|
|
270
|
+
_PRODUCTION_READINESS = VerifyCheck(
|
|
271
|
+
id="production-readiness",
|
|
272
|
+
category="architecture",
|
|
273
|
+
command="terp verify --only production-readiness",
|
|
274
|
+
scope=("control_plane/**/*.py",),
|
|
275
|
+
runner="production-readiness",
|
|
276
|
+
)
|
|
277
|
+
|
|
262
278
|
_ARCHITECTURE = VerifyCheck(
|
|
263
279
|
id="architecture",
|
|
264
280
|
category="architecture",
|
|
@@ -416,6 +432,7 @@ PROFILES: dict[str, tuple[VerifyCheck, ...]] = {
|
|
|
416
432
|
_ENV_SEAMS,
|
|
417
433
|
_WORKBENCH,
|
|
418
434
|
_DEPLOY_SAFETY,
|
|
435
|
+
_PRODUCTION_READINESS,
|
|
419
436
|
_ARCHITECTURE,
|
|
420
437
|
_PACKAGE_BOUNDARIES,
|
|
421
438
|
_FRONTEND_BOUNDARIES,
|
|
@@ -428,6 +445,7 @@ PROFILES: dict[str, tuple[VerifyCheck, ...]] = {
|
|
|
428
445
|
_ENV_SEAMS,
|
|
429
446
|
_WORKBENCH,
|
|
430
447
|
_DEPLOY_SAFETY,
|
|
448
|
+
_PRODUCTION_READINESS,
|
|
431
449
|
_ARCHITECTURE,
|
|
432
450
|
_PACKAGE_BOUNDARIES,
|
|
433
451
|
_DEPENDENCY_HYGIENE,
|
|
@@ -444,6 +462,7 @@ PROFILES: dict[str, tuple[VerifyCheck, ...]] = {
|
|
|
444
462
|
_ENV_SEAMS,
|
|
445
463
|
_WORKBENCH,
|
|
446
464
|
_DEPLOY_SAFETY,
|
|
465
|
+
_PRODUCTION_READINESS,
|
|
447
466
|
_ARCHITECTURE,
|
|
448
467
|
_PACKAGE_BOUNDARIES,
|
|
449
468
|
_DEPENDENCY_HYGIENE,
|
|
@@ -1006,6 +1025,92 @@ def _frontend_lockstep_problems(project_root: pathlib.Path, platform: str) -> li
|
|
|
1006
1025
|
return problems
|
|
1007
1026
|
|
|
1008
1027
|
|
|
1028
|
+
#: A ``terp-*`` requirement string, split into the distribution and its version
|
|
1029
|
+
#: specifier. Extras and environment markers are tolerated because the template
|
|
1030
|
+
#: writes both (``terp-core[secrets]==0.20.0``, a marker on a dev-group entry), and a
|
|
1031
|
+
#: parser that choked on them would silently read such a line as "no Terp dependency
|
|
1032
|
+
#: declared here" — a green over exactly the pin this check exists to police.
|
|
1033
|
+
_PY_REQUIREMENT = re.compile(
|
|
1034
|
+
r"^(?P<name>[A-Za-z0-9._-]+)\s*(?:\[[^\]]*\])?\s*(?P<spec>[^;]*)"
|
|
1035
|
+
)
|
|
1036
|
+
#: The one accepted specifier shape: an exact pin. Terp moves in lockstep, so a range
|
|
1037
|
+
#: is a resolver invitation rather than a pin.
|
|
1038
|
+
_EXACT_PIN = re.compile(r"^==\s*(?P<version>[A-Za-z0-9._+!-]+)$")
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _declared_python_requirements(project_root: pathlib.Path) -> list[tuple[str, str]]:
|
|
1042
|
+
"""``(where, requirement)`` for every dependency this app's manifest declares.
|
|
1043
|
+
|
|
1044
|
+
Both halves of the manifest, because a forgotten pin in the dev group is the same
|
|
1045
|
+
mixed install as one in the runtime dependencies — ``terp-arch`` lives there, and
|
|
1046
|
+
it is the gate itself.
|
|
1047
|
+
"""
|
|
1048
|
+
manifest = project_root / "pyproject.toml"
|
|
1049
|
+
try:
|
|
1050
|
+
data = tomllib.loads(manifest.read_text(encoding="utf-8"))
|
|
1051
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
1052
|
+
# Unreadable or absent: `_run_dependency_hygiene` and the package-graph check
|
|
1053
|
+
# already report a manifest nobody can parse, and a second voice saying it
|
|
1054
|
+
# would bury the one that explains it.
|
|
1055
|
+
return []
|
|
1056
|
+
found: list[tuple[str, str]] = []
|
|
1057
|
+
for requirement in data.get("project", {}).get("dependencies", []) or []:
|
|
1058
|
+
if isinstance(requirement, str):
|
|
1059
|
+
found.append(("dependencies", requirement))
|
|
1060
|
+
for group, requirements in (data.get("dependency-groups", {}) or {}).items():
|
|
1061
|
+
for requirement in requirements or []:
|
|
1062
|
+
if isinstance(requirement, str):
|
|
1063
|
+
found.append((f"dependency-groups.{group}", requirement))
|
|
1064
|
+
return found
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _backend_pin_problems(project_root: pathlib.Path, platform: str) -> list[str]:
|
|
1068
|
+
"""Every ``terp-*`` pin in ``pyproject.toml`` that disagrees with what is installed.
|
|
1069
|
+
|
|
1070
|
+
The half of the lockstep that was missing, and it was the more consequential half.
|
|
1071
|
+
This check read the *environment* for backend packages and compared it only against
|
|
1072
|
+
itself, while reading *declarations* for the frontend and comparing them against the
|
|
1073
|
+
platform. So a tree whose manifest said ``terp-core==0.20.0`` over an environment
|
|
1074
|
+
installed at 0.13.0 was internally consistent, passed here, and passed the whole
|
|
1075
|
+
profile — a verdict about code that is not the code that will run.
|
|
1076
|
+
|
|
1077
|
+
Two ways an app arrives there, and neither is exotic. Someone repins and has not run
|
|
1078
|
+
``uv sync`` yet, which is the ordinary middle of an upgrade. Or a container bakes the
|
|
1079
|
+
packages into its image and bind-mounts the source over them, so a rebuilt checkout
|
|
1080
|
+
reloads new code against old libraries and dies on an import nowhere near the cause.
|
|
1081
|
+
|
|
1082
|
+
Silent on a requirement carrying **no** specifier: that is a workspace member or an
|
|
1083
|
+
editable install (the platform's own tree declares all twenty-odd of its packages
|
|
1084
|
+
that way), where the manifest is not where the version lives. A declared specifier,
|
|
1085
|
+
however, is a claim about a version, and this holds it to the installed one.
|
|
1086
|
+
"""
|
|
1087
|
+
from terp.cli.version import _INDEPENDENTLY_VERSIONED, _PREFIX
|
|
1088
|
+
|
|
1089
|
+
problems: list[str] = []
|
|
1090
|
+
for where, requirement in _declared_python_requirements(project_root):
|
|
1091
|
+
match = _PY_REQUIREMENT.match(requirement.strip())
|
|
1092
|
+
if match is None:
|
|
1093
|
+
continue
|
|
1094
|
+
name = match.group("name").lower().replace("_", "-")
|
|
1095
|
+
if not name.startswith(_PREFIX) or name in _INDEPENDENTLY_VERSIONED:
|
|
1096
|
+
continue
|
|
1097
|
+
spec = match.group("spec").strip()
|
|
1098
|
+
if not spec:
|
|
1099
|
+
continue # a workspace source or an editable install; see the docstring
|
|
1100
|
+
pinned = _EXACT_PIN.match(spec)
|
|
1101
|
+
if pinned is None:
|
|
1102
|
+
problems.append(
|
|
1103
|
+
f"{where}: {name} is declared {spec!r} — Terp moves in lockstep, so "
|
|
1104
|
+
f"pin =={platform}"
|
|
1105
|
+
)
|
|
1106
|
+
elif pinned.group("version") != platform:
|
|
1107
|
+
problems.append(
|
|
1108
|
+
f"{where}: {name} is pinned =={pinned.group('version')} but "
|
|
1109
|
+
f"{platform} is installed"
|
|
1110
|
+
)
|
|
1111
|
+
return problems
|
|
1112
|
+
|
|
1113
|
+
|
|
1009
1114
|
def _run_platform_install(project_root: pathlib.Path) -> tuple[int, str]:
|
|
1010
1115
|
"""Fail when the installed platform disagrees with itself — either half.
|
|
1011
1116
|
|
|
@@ -1016,7 +1121,10 @@ def _run_platform_install(project_root: pathlib.Path) -> tuple[int, str]:
|
|
|
1016
1121
|
either direction, so this refuses rather than reports.
|
|
1017
1122
|
|
|
1018
1123
|
The backend half reads the live environment (never a declared list), so a
|
|
1019
|
-
capability adopted after this was written is policed too
|
|
1124
|
+
capability adopted after this was written is policed too, and then holds that
|
|
1125
|
+
environment against the pins ``pyproject.toml`` declares — a manifest and an
|
|
1126
|
+
install that disagree is the same mixed platform arriving by a third route, and
|
|
1127
|
+
the one that used to pass here green. The frontend half
|
|
1020
1128
|
reads every app manifest that declares a ``@terpjs/*`` package, plus the
|
|
1021
1129
|
installed copy under its ``node_modules`` when present — because a frontend
|
|
1022
1130
|
package left behind is the same mixed install by another route, and a check
|
|
@@ -1057,6 +1165,20 @@ def _run_platform_install(project_root: pathlib.Path) -> tuple[int, str]:
|
|
|
1057
1165
|
f" Fix: rename each to its @terpjs/* spelling pinned at ^{platform}, "
|
|
1058
1166
|
"then reinstall the node_modules of that manifest."
|
|
1059
1167
|
)
|
|
1168
|
+
backend_problems = _backend_pin_problems(project_root, platform)
|
|
1169
|
+
if backend_problems:
|
|
1170
|
+
return 1, (
|
|
1171
|
+
f"the environment is consistent at terp {platform}, but this app's "
|
|
1172
|
+
"pyproject.toml declares something else:\n"
|
|
1173
|
+
+ "".join(f" {problem}\n" for problem in backend_problems)
|
|
1174
|
+
+ "A gate run against packages the manifest does not ask for proves "
|
|
1175
|
+
"nothing about the code that will ship: the tree reloads new source "
|
|
1176
|
+
"against old libraries and fails on an import nowhere near its cause.\n"
|
|
1177
|
+
f" Fix: uv sync --refresh (or repin to =={platform} if the manifest is "
|
|
1178
|
+
"the half that is wrong).\n"
|
|
1179
|
+
"If this is a container, its image bakes the packages in — rebuild it "
|
|
1180
|
+
"rather than reloading into it (`terp docker dev` does)."
|
|
1181
|
+
)
|
|
1060
1182
|
frontend_problems = _frontend_lockstep_problems(project_root, platform)
|
|
1061
1183
|
if frontend_problems:
|
|
1062
1184
|
return 1, (
|
|
@@ -1070,13 +1192,141 @@ def _run_platform_install(project_root: pathlib.Path) -> tuple[int, str]:
|
|
|
1070
1192
|
"that declares one, then reinstall that manifest's node_modules."
|
|
1071
1193
|
)
|
|
1072
1194
|
manifest_count = len(_terp_frontend_manifests(project_root))
|
|
1195
|
+
pin_count = sum(
|
|
1196
|
+
1
|
|
1197
|
+
for _, requirement in _declared_python_requirements(project_root)
|
|
1198
|
+
if requirement.lower().replace("_", "-").startswith("terp-")
|
|
1199
|
+
)
|
|
1073
1200
|
return (
|
|
1074
1201
|
0,
|
|
1075
|
-
f"terp {platform} ({len(versions)} distributions
|
|
1076
|
-
f"{manifest_count} frontend manifest(s), consistent)",
|
|
1202
|
+
f"terp {platform} ({len(versions)} distributions, {pin_count} declared "
|
|
1203
|
+
f"pin(s) and {manifest_count} frontend manifest(s), consistent)",
|
|
1077
1204
|
)
|
|
1078
1205
|
|
|
1079
1206
|
|
|
1207
|
+
#: The reference every other Terp surface already uses for the authority aggregate:
|
|
1208
|
+
#: `terp jobs list`, `terp inspect control-plane` and the template's own composition
|
|
1209
|
+
#: root all resolve `control_plane:control_plane`. Named here rather than made an
|
|
1210
|
+
#: option, because a check that has to be told where to look is a check an app can
|
|
1211
|
+
#: leave unaimed.
|
|
1212
|
+
_CONTROL_PLANE_REF = "control_plane:control_plane"
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
#: The conventional address a job's system principal arrives at (ADR 0129). An app
|
|
1216
|
+
#: that declares this variable has said the value will be supplied, which is the
|
|
1217
|
+
#: evidence this check can actually have: the id belongs to a service principal in a
|
|
1218
|
+
#: particular database, so the gate's own environment is the last place it would be.
|
|
1219
|
+
_JOB_ACTOR_VARIABLE = "JOB_SYSTEM_ACTOR_ID"
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _job_actor_arrives_from_the_environment(project_root: pathlib.Path) -> bool:
|
|
1223
|
+
"""Whether the app *declares* the job actor as a runtime variable.
|
|
1224
|
+
|
|
1225
|
+
The declaration is the proof, never the value. `create_app` fills an unset
|
|
1226
|
+
`job_system_actor_id` from `JOB_SYSTEM_ACTOR_ID`, so an app whose principal is a
|
|
1227
|
+
deployment fact leaves the field empty on purpose — and reddening it here would
|
|
1228
|
+
punish exactly the delivery mechanism ADR 0129 added. What the app must not be
|
|
1229
|
+
able to do is stay silent: `environment.schema.json` is where a runtime variable
|
|
1230
|
+
is declared, `env-seams` already refuses a declaration the deployment does not
|
|
1231
|
+
deliver, and `terp env file` renders it. So a declared variable is a promise with
|
|
1232
|
+
a gate behind it, which an undeclared one is not.
|
|
1233
|
+
"""
|
|
1234
|
+
from terp.cli.envschema import declared_variables
|
|
1235
|
+
|
|
1236
|
+
return _JOB_ACTOR_VARIABLE in declared_variables(project_root)
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
def _run_production_readiness(project_root: pathlib.Path) -> tuple[int, str]:
|
|
1240
|
+
"""Refuse a tree whose declared control plane cannot boot in production.
|
|
1241
|
+
|
|
1242
|
+
Three of the platform's boot refusals are decided entirely by what the control
|
|
1243
|
+
plane declares — no environment, no database, no request. `create_app` raises
|
|
1244
|
+
`BootError` on each of them under `ENVIRONMENT == "production"`: an unsafe
|
|
1245
|
+
security config, a password policy with no strength floor, and background work
|
|
1246
|
+
that names no actor to stamp its writes with (ADR 0125). Outside production the
|
|
1247
|
+
same states log a warning and keep booting, on purpose, because a developer who
|
|
1248
|
+
has not wired a system principal yet should not be blocked by one.
|
|
1249
|
+
|
|
1250
|
+
Nothing gated the gap between those two behaviours. An app could declare a job,
|
|
1251
|
+
never set `job_system_actor_id`, and take a green `--profile full` all the way to
|
|
1252
|
+
a deployment that refuses to start — the gate, the machine envelope and CI all
|
|
1253
|
+
agreeing, because none of them asked. This asks, and it is the same argument
|
|
1254
|
+
`platform-install` already makes about mixed installs: a warning inside a command
|
|
1255
|
+
nobody runs before shipping is not a control, so the verdict belongs where it can
|
|
1256
|
+
fail.
|
|
1257
|
+
|
|
1258
|
+
Reads the *declared* plane and never builds the app, so it costs one module import
|
|
1259
|
+
and can sit in every profile. The audit refusal is deliberately not among the
|
|
1260
|
+
three: it turns on `create_app(audit_sink=...)`, a runtime argument this check
|
|
1261
|
+
cannot see, and a check that pretended to cover it would be worse than the gap.
|
|
1262
|
+
|
|
1263
|
+
Skips with a note for a tree with no importable control plane — the platform's own
|
|
1264
|
+
checkout, and an app whose authority surface predates the module. A plane that
|
|
1265
|
+
imports and yields no `ControlPlane`, however, is a red: every other Terp command
|
|
1266
|
+
reads the same reference, so the app has adopted the pattern and the file the
|
|
1267
|
+
tooling depends on has stopped answering.
|
|
1268
|
+
|
|
1269
|
+
The job-actor half is satisfied by a *declaration* as well as by a value, because
|
|
1270
|
+
ADR 0129 made `JOB_SYSTEM_ACTOR_ID` the conventional way the principal arrives and
|
|
1271
|
+
the gate's own environment is the last place a production principal's id would be.
|
|
1272
|
+
See `_job_actor_arrives_from_the_environment`.
|
|
1273
|
+
"""
|
|
1274
|
+
from terp.core import ControlPlane
|
|
1275
|
+
|
|
1276
|
+
if not (project_root / "control_plane").is_dir():
|
|
1277
|
+
return (
|
|
1278
|
+
0,
|
|
1279
|
+
f"{NOTE_PREFIX}no control_plane/ package - production readiness not "
|
|
1280
|
+
"applicable (the authority surface every other terp command reads)",
|
|
1281
|
+
)
|
|
1282
|
+
root = str(project_root.resolve())
|
|
1283
|
+
restore = root not in sys.path
|
|
1284
|
+
if restore:
|
|
1285
|
+
sys.path.insert(0, root)
|
|
1286
|
+
module_name, _, attr = _CONTROL_PLANE_REF.partition(":")
|
|
1287
|
+
try:
|
|
1288
|
+
importlib.invalidate_caches()
|
|
1289
|
+
module = importlib.import_module(module_name)
|
|
1290
|
+
plane = getattr(module, attr, None)
|
|
1291
|
+
except Exception as exc: # noqa: BLE001 - any import failure is the app's answer
|
|
1292
|
+
return 1, (
|
|
1293
|
+
f"{_CONTROL_PLANE_REF} could not be imported, so what this app declares "
|
|
1294
|
+
f"cannot be established: {type(exc).__name__}: {exc}"
|
|
1295
|
+
)
|
|
1296
|
+
finally:
|
|
1297
|
+
if restore and root in sys.path:
|
|
1298
|
+
sys.path.remove(root)
|
|
1299
|
+
if not isinstance(plane, ControlPlane):
|
|
1300
|
+
return 1, (
|
|
1301
|
+
f"{_CONTROL_PLANE_REF} did not resolve to a terp.core.ControlPlane "
|
|
1302
|
+
f"(found {type(plane).__name__}). Every terp command reads that reference "
|
|
1303
|
+
"- `terp jobs list`, `terp inspect control-plane` and the app's own "
|
|
1304
|
+
"composition root - so this is not a layout choice, it is a surface that "
|
|
1305
|
+
"has stopped answering."
|
|
1306
|
+
)
|
|
1307
|
+
problems = [
|
|
1308
|
+
*(f"security: {problem}" for problem in plane.security.production_problems()),
|
|
1309
|
+
*(f"passwords: {problem}" for problem in plane.passwords.production_problems()),
|
|
1310
|
+
*(
|
|
1311
|
+
f"jobs: {problem}"
|
|
1312
|
+
for problem in plane.production_problems()
|
|
1313
|
+
if not _job_actor_arrives_from_the_environment(project_root)
|
|
1314
|
+
),
|
|
1315
|
+
]
|
|
1316
|
+
if problems:
|
|
1317
|
+
return 1, (
|
|
1318
|
+
"this app's control plane refuses a production boot:\n"
|
|
1319
|
+
+ "".join(f" {problem}\n" for problem in problems)
|
|
1320
|
+
+ "Each of these raises BootError under ENVIRONMENT=production and only "
|
|
1321
|
+
"logs a warning outside it, so a green gate over this state is a gate "
|
|
1322
|
+
"that agrees with a deployment that will not start.\n"
|
|
1323
|
+
" A job actor that is a deployment fact rather than a source constant is "
|
|
1324
|
+
"declared, not hard-coded: put JOB_SYSTEM_ACTOR_ID in "
|
|
1325
|
+
"environment.schema.json and create_app will resolve it (ADR 0129)."
|
|
1326
|
+
)
|
|
1327
|
+
return 0, "the declared control plane boots in production (security, passwords, jobs)"
|
|
1328
|
+
|
|
1329
|
+
|
|
1080
1330
|
def _run_routes_drift(root: pathlib.Path) -> tuple[int, str]:
|
|
1081
1331
|
"""Refuse a committed route table that no longer matches the module manifests.
|
|
1082
1332
|
|
|
@@ -1492,6 +1742,8 @@ def run_verify_command(
|
|
|
1492
1742
|
from terp.cli.deploy_safety import run_deploy_safety_check
|
|
1493
1743
|
|
|
1494
1744
|
exit_code, output = run_deploy_safety_check(project_root)
|
|
1745
|
+
elif check.runner == "production-readiness":
|
|
1746
|
+
exit_code, output = _run_production_readiness(project_root)
|
|
1495
1747
|
else:
|
|
1496
1748
|
exit_code, output = _run_subprocess(check, project_root)
|
|
1497
1749
|
reports = _reports_in(output)
|
|
@@ -223,6 +223,7 @@ def scaffold_ref(root: pathlib.Path) -> str | None:
|
|
|
223
223
|
#: ``test_the_app_owned_scaffold_list_matches_copier`` — the same treatment the theme
|
|
224
224
|
#: bootstrap's three duplicated facts get, so this list cannot rot into a wrong answer.
|
|
225
225
|
_APP_OWNED_SCAFFOLD_FILES = (
|
|
226
|
+
"control_plane/app_operations.py",
|
|
226
227
|
"environment.schema.json",
|
|
227
228
|
"escape-hatch-budget.json",
|
|
228
229
|
"frontend/layout-contract.json",
|
|
@@ -233,7 +234,9 @@ _APP_OWNED_SCAFFOLD_FILES = (
|
|
|
233
234
|
)
|
|
234
235
|
|
|
235
236
|
|
|
236
|
-
def _scaffold_lines(
|
|
237
|
+
def _scaffold_lines(
|
|
238
|
+
root: pathlib.Path, platform: str, *, include_command: bool = True
|
|
239
|
+
) -> list[str]:
|
|
237
240
|
"""Report how far the app's *scaffolding* is behind its *packages*.
|
|
238
241
|
|
|
239
242
|
The two move independently and only one of them is gated. Package drift already fails
|
|
@@ -268,15 +271,124 @@ def _scaffold_lines(root: pathlib.Path, platform: str) -> list[str]:
|
|
|
268
271
|
f"{platform}.",
|
|
269
272
|
"A re-render rewrites EVERY file the template owns — main.tsx, index.html,",
|
|
270
273
|
"AGENTS.md, the Dockerfiles, docker-compose.yml, the CI workflows — so any fix a",
|
|
271
|
-
"release made to one of them
|
|
272
|
-
"
|
|
273
|
-
"
|
|
274
|
-
"
|
|
274
|
+
"release made to one of them may still be waiting here. WHICH of them actually",
|
|
275
|
+
"differ is not something this can say: it compares two version numbers, and a",
|
|
276
|
+
"file no release has touched since is already current. Nothing gates any of it,",
|
|
277
|
+
"so it stays green either way — and the two that cost the most when they are",
|
|
278
|
+
"behind are AGENTS.md, which briefs every agent working here, and",
|
|
279
|
+
"docker-compose.yml, which can serve a dev stack that disagrees with the",
|
|
280
|
+
"checkout the boundary lint reads.",
|
|
275
281
|
"These are seeded once and then the app's, so a re-render leaves them alone:",
|
|
276
282
|
*(f" {name}" for name in _APP_OWNED_SCAFFOLD_FILES),
|
|
283
|
+
# Suppressed when a recipe above already numbered the re-render as a step: the
|
|
284
|
+
# same command printed twice in one report reads as two different things to do,
|
|
285
|
+
# and the recipe's copy is the one with the tree-cleaning step before it.
|
|
286
|
+
*(
|
|
287
|
+
[
|
|
288
|
+
"",
|
|
289
|
+
" Re-render: copier update (or the Studio's upgrade flow, which "
|
|
290
|
+
"records the",
|
|
291
|
+
" answers file it needs).",
|
|
292
|
+
]
|
|
293
|
+
if include_command
|
|
294
|
+
else []
|
|
295
|
+
),
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _has_template_provenance(root: pathlib.Path) -> bool:
|
|
300
|
+
"""Whether ``copier update`` can run here at all.
|
|
301
|
+
|
|
302
|
+
It needs the answers file copier writes at render time; without one there is no
|
|
303
|
+
recorded template and no answers to re-render from, so the only way to move an app
|
|
304
|
+
forward is by hand. That is the whole reason the recipe below has two shapes.
|
|
305
|
+
"""
|
|
306
|
+
return scaffold_ref(root) is not None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _rerender_recipe(target: str, current: str, count: int) -> list[str]:
|
|
310
|
+
"""The upgrade for an app the template rendered: re-render first, sync once.
|
|
311
|
+
|
|
312
|
+
The order used to be the other way round and could not be followed as printed.
|
|
313
|
+
Steps 2 to 4 were "edit the pins, uv sync, npm install", and the re-render came
|
|
314
|
+
after them — but ``copier update`` refuses a dirty tree, so the recipe's own
|
|
315
|
+
earlier steps made its last step impossible, and whoever followed it had to stash
|
|
316
|
+
halfway through.
|
|
317
|
+
|
|
318
|
+
Worse, those pin edits were work the re-render does. The template owns
|
|
319
|
+
``pyproject.toml``, ``frontend/package.json`` and ``conformance/package.json``, so
|
|
320
|
+
a re-render writes every one of those pins itself. The old recipe even warned that
|
|
321
|
+
"a recipe that names only one is how the other goes stale" — which is an admission
|
|
322
|
+
that the hand-pinning step was a footgun, for a job already done one step later.
|
|
323
|
+
|
|
324
|
+
So: read, clean the tree, re-render, resolve, sync once, confirm, verify.
|
|
325
|
+
"""
|
|
326
|
+
return [
|
|
327
|
+
"",
|
|
328
|
+
f"All {count} packages can move to {target} together. Re-render FIRST — the",
|
|
329
|
+
"template owns pyproject.toml and both npm manifests, so the re-render writes",
|
|
330
|
+
"every pin itself, and it refuses to run on a tree with uncommitted changes:",
|
|
331
|
+
"",
|
|
332
|
+
f" 1. Read what changed: uvx --from terp-cli=={target} terp guide changelog",
|
|
333
|
+
f" (the {target} notes; the copy installed here ends at {current}).",
|
|
334
|
+
" 2. Commit or discard what you have. A re-render on a dirty tree is refused,",
|
|
335
|
+
" and its own diff is much easier to review on its own.",
|
|
336
|
+
" 3. copier update (or the Studio's upgrade flow, which records the",
|
|
337
|
+
" answers file it needs). This rewrites EVERY file the template owns and",
|
|
338
|
+
" writes the terp-* and @terpjs/* pins for you.",
|
|
339
|
+
" 4. Resolve what it reports. Two conflicts are structural rather than bad luck,",
|
|
340
|
+
" because the template owns the file and your app also writes to it:",
|
|
341
|
+
" pyproject.toml keep your dependencies, take the terp-* pins.",
|
|
342
|
+
" control_plane/operations.py the capability folding is the template's;",
|
|
343
|
+
" your own operations belong in",
|
|
344
|
+
" control_plane/app_operations.py, which no",
|
|
345
|
+
" re-render touches (ADR 0130).",
|
|
346
|
+
" 5. uv sync --refresh && npm --prefix frontend install",
|
|
347
|
+
" (once, now that the manifests are final — not before the re-render.)",
|
|
348
|
+
" 6. uv run terp --version (confirm the set agrees)",
|
|
349
|
+
" 7. uv run terp verify --profile full",
|
|
350
|
+
"",
|
|
351
|
+
" Running the dev stack in containers?",
|
|
352
|
+
" Rebuild it rather than reloading into it: the images bake the terp packages",
|
|
353
|
+
" in while the source is bind-mounted, so correct new code reloads against old",
|
|
354
|
+
" libraries and dies on an import nowhere near its cause. `terp docker dev`",
|
|
355
|
+
" rebuilds on a pyproject.toml change; a plain `docker compose up` does not,",
|
|
356
|
+
" and `terp verify` now refuses the skew either way.",
|
|
277
357
|
"",
|
|
278
|
-
"
|
|
279
|
-
"
|
|
358
|
+
"A green gate proves the upgrade did not break this app. It cannot prove the",
|
|
359
|
+
"release did not change something this app should adopt — step 1 is the only",
|
|
360
|
+
"thing that answers that.",
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _hand_pin_recipe(target: str, current: str, count: int) -> list[str]:
|
|
365
|
+
"""The upgrade for an app with no recorded template: every pin by hand.
|
|
366
|
+
|
|
367
|
+
No answers file means ``copier update`` has nothing to re-render from, so the pins
|
|
368
|
+
the template would have written have to be written here instead. Kept in full for
|
|
369
|
+
exactly that case and printed nowhere else — an app the template rendered is told
|
|
370
|
+
to re-render, because doing both is what produced two needless installs.
|
|
371
|
+
"""
|
|
372
|
+
return [
|
|
373
|
+
"",
|
|
374
|
+
f"All {count} packages can move to {target} together. This app records no",
|
|
375
|
+
"template answers file, so `copier update` has nothing to re-render from and",
|
|
376
|
+
"the pins have to be written by hand:",
|
|
377
|
+
"",
|
|
378
|
+
f" 1. Read what changed: uvx --from terp-cli=={target} terp guide changelog",
|
|
379
|
+
f" (the {target} notes; the copy installed here ends at {current}).",
|
|
380
|
+
f" 2. Pin every terp-* dependency to =={target} in pyproject.toml",
|
|
381
|
+
" (including the dev group — a forgotten pin is a mixed install).",
|
|
382
|
+
f" 3. Pin every @terpjs/* package to ^{target} in EVERY manifest that",
|
|
383
|
+
" declares one — frontend/package.json AND conformance/package.json",
|
|
384
|
+
" (a recipe that names only one is how the other goes stale).",
|
|
385
|
+
" 4. uv sync --refresh && npm --prefix frontend install",
|
|
386
|
+
" 5. uv run terp --version (confirm the set agrees)",
|
|
387
|
+
" 6. uv run terp verify --profile full",
|
|
388
|
+
"",
|
|
389
|
+
"A green gate proves the upgrade did not break this app. It cannot prove the",
|
|
390
|
+
"release did not change something this app should adopt — step 1 is the only",
|
|
391
|
+
"thing that answers that.",
|
|
280
392
|
]
|
|
281
393
|
|
|
282
394
|
|
|
@@ -337,24 +449,16 @@ def render_upgrade_check(root: pathlib.Path | None = None) -> str:
|
|
|
337
449
|
# to help judge. `uvx --from terp-cli==target` resolves an ephemeral CLI from
|
|
338
450
|
# the same index (terp-cli pins terp-core exactly, so the right CHANGELOG
|
|
339
451
|
# comes with it) without touching this app's environment or its pins.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
" 5. uv run terp --version (confirm the set agrees)",
|
|
353
|
-
" 6. uv run terp verify --profile full",
|
|
354
|
-
"",
|
|
355
|
-
"A green gate proves the upgrade did not break this app. It cannot prove the",
|
|
356
|
-
"release did not change something this app should adopt — step 1 is the only",
|
|
357
|
-
"thing that answers that.",
|
|
358
|
-
]
|
|
359
|
-
lines += _scaffold_lines(project_root, target)
|
|
452
|
+
# Which recipe depends on whether copier can run here at all: an app the
|
|
453
|
+
# template rendered re-renders and gets its pins written for it, and an app with
|
|
454
|
+
# no answers file writes them by hand. Printing both, or the hand-pin one to an
|
|
455
|
+
# app that could re-render, is what produced two needless installs and a stash
|
|
456
|
+
# halfway through.
|
|
457
|
+
if _has_template_provenance(project_root):
|
|
458
|
+
lines += _rerender_recipe(target, current, len(landing))
|
|
459
|
+
else:
|
|
460
|
+
lines += _hand_pin_recipe(target, current, len(landing))
|
|
461
|
+
lines += _scaffold_lines(
|
|
462
|
+
project_root, target, include_command=not _has_template_provenance(project_root)
|
|
463
|
+
)
|
|
360
464
|
return "\n".join(lines)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|