terp-cli 0.30.0__tar.gz → 0.32.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {terp_cli-0.30.0 → terp_cli-0.32.0}/PKG-INFO +8 -8
  2. {terp_cli-0.30.0 → terp_cli-0.32.0}/pyproject.toml +8 -8
  3. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/__init__.py +106 -20
  4. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/docker.py +14 -0
  5. terp_cli-0.32.0/src/terp/cli/docker_masks.py +167 -0
  6. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/verify.py +18 -0
  7. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/version.py +10 -3
  8. {terp_cli-0.30.0 → terp_cli-0.32.0}/.gitignore +0 -0
  9. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/_appref.py +0 -0
  10. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/_engine.py +0 -0
  11. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/_output.py +0 -0
  12. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/_subjects.py +0 -0
  13. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/access.py +0 -0
  14. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/apidocs.py +0 -0
  15. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/authz_surface.py +0 -0
  16. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/capabilities.py +0 -0
  17. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/deploy_safety.py +0 -0
  18. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/dev.py +0 -0
  19. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/envfile.py +0 -0
  20. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/envschema.py +0 -0
  21. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/envseams.py +0 -0
  22. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/fmt.py +0 -0
  23. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/grants.py +0 -0
  24. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/jobs.py +0 -0
  25. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/leases.py +0 -0
  26. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/module_roles.py +0 -0
  27. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/openapi.py +0 -0
  28. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/outbox.py +0 -0
  29. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/ports.py +0 -0
  30. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/profiles.py +0 -0
  31. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/py.typed +0 -0
  32. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/routes.py +0 -0
  33. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/scaffold.py +0 -0
  34. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/schema.py +0 -0
  35. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/seed.py +0 -0
  36. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/service_accounts.py +0 -0
  37. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/smoke.py +0 -0
  38. {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/users.py +0 -0
  39. {terp_cli-0.30.0 → terp_cli-0.32.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.30.0
3
+ Version: 0.32.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.30.0
11
- Requires-Dist: terp-core==0.30.0
12
- Requires-Dist: terp-migrations==0.30.0
10
+ Requires-Dist: terp-arch==0.32.0
11
+ Requires-Dist: terp-core==0.32.0
12
+ Requires-Dist: terp-migrations==0.32.0
13
13
  Provides-Extra: jobs
14
- Requires-Dist: terp-cap-outbox==0.30.0; extra == 'jobs'
15
- Requires-Dist: terp-cap-scheduler-apscheduler==0.30.0; extra == 'jobs'
14
+ Requires-Dist: terp-cap-outbox==0.32.0; extra == 'jobs'
15
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.32.0; extra == 'jobs'
16
16
  Provides-Extra: scheduler
17
- Requires-Dist: terp-cap-scheduler-apscheduler==0.30.0; extra == 'scheduler'
17
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.32.0; extra == 'scheduler'
18
18
  Provides-Extra: worker
19
- Requires-Dist: terp-cap-outbox==0.30.0; extra == 'worker'
19
+ Requires-Dist: terp-cap-outbox==0.32.0; extra == 'worker'
@@ -4,18 +4,18 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "terp-cli"
7
- version = "0.30.0"
7
+ version = "0.32.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.30.0",
12
+ "terp-core==0.32.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.30.0",
15
+ "terp-migrations==0.32.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.30.0",
18
+ "terp-arch==0.32.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.30.0"]
33
- scheduler = ["terp-cap-scheduler-apscheduler==0.30.0"]
32
+ worker = ["terp-cap-outbox==0.32.0"]
33
+ scheduler = ["terp-cap-scheduler-apscheduler==0.32.0"]
34
34
  jobs = [
35
- "terp-cap-outbox==0.30.0",
36
- "terp-cap-scheduler-apscheduler==0.30.0",
35
+ "terp-cap-outbox==0.32.0",
36
+ "terp-cap-scheduler-apscheduler==0.32.0",
37
37
  ]
38
38
 
39
39
  [project.scripts]
@@ -1797,8 +1797,9 @@ Frontend module screens (@terpjs/react-core)
1797
1797
  insertAdjacentHTML/document.write) are refused — render text, or Markdown from
1798
1798
  @terpjs/react-core for rich text; eval() / new Function() are refused; javascript:
1799
1799
  URLs in href/src are refused; a static target="_blank" link needs rel="noopener".
1800
- - Every routed view renders a page archetype (Page / OverviewPage / DetailPage / HubPage);
1801
- buildAppRouter refuses an unframed view at runtime, fail closed. An app can ratchet
1800
+ - Every routed view renders a page archetype (Page / OverviewPage / DetailPage / HubPage /
1801
+ DashboardPage / FormPage / SettingsPage / SplitPage); buildAppRouter refuses an unframed
1802
+ view at runtime, fail closed. An app can ratchet
1802
1803
  further with an opt-in slot-typed layout contract (terp guide layouts).
1803
1804
  - Route paths and params are CHECKED, from generated types (ADR 0092). The router is built
1804
1805
  at runtime from the manifests, so nothing type-checks a path or a param name until you
@@ -1940,11 +1941,16 @@ Theming and branding (design tokens, palettes, the brand mark)
1940
1941
  refuses `style={}`, `className` and module stylesheets: a module that painted itself
1941
1942
  would not follow the palette. Modules never need theme-specific code.
1942
1943
  - THE SHIPPED PALETTES, plus "system":
1943
- light dark midnight twilight contrast
1944
- `contrast` is a high-contrast light set. The active one is `data-theme` on <html>;
1945
- the shell header's theme toggle offers all five plus "system" (follow the viewer's
1946
- own platform preference) and persists the choice. `system` resolves to the dark set
1947
- when the platform asks for dark.
1944
+ midday twilight evening night contrast
1945
+ Named for the time of day they suit: midday is the light set, twilight a dimmed dark,
1946
+ evening the slate dark, night the near-black dark. `contrast` is a high-contrast light
1947
+ set. The active one is `data-theme` on <html>; the shell header's theme toggle offers all
1948
+ five plus "system" (follow the viewer's own platform preference) and persists the choice.
1949
+ `system` resolves to `night` when the platform asks for dark.
1950
+ `light`, `dark` and `midnight` are the EARLIER names of midday, evening and night. They
1951
+ are still accepted wherever a theme is named (defaultTheme, a stored choice, data-theme)
1952
+ and resolve to the new name -- but the theme toggle now writes the new names, so a
1953
+ selector in your own theme.css must use them: `[data-theme="dark"]` no longer matches.
1948
1954
  - TO CHANGE HOW YOUR APP LOOKS, redefine tokens in `frontend/src/theme.css`. It is
1949
1955
  imported last and therefore wins the cascade. Declare only what you are changing;
1950
1956
  everything else falls back to the framework's value.
@@ -1954,17 +1960,38 @@ Theming and branding (design tokens, palettes, the brand mark)
1954
1960
  depart from a house style, declare the token in theme.css; nothing takes that back.
1955
1961
 
1956
1962
  :root { --color-brand-primary: #2563eb; }
1957
- [data-theme="dark"] { --color-brand-primary: #60a5fa; }
1963
+ [data-theme="evening"] { --color-brand-primary: #60a5fa; }
1958
1964
 
1959
1965
  Per palette, use that palette's selector. A token with no palette selector is
1960
1966
  declared in `:root` and governs every palette at once — which is what you want for
1961
1967
  spacing, corners and typography, and usually not what you want for a colour.
1962
- - TO SHIP ON A PALETTE OTHER THAN light, name it in the layout declaration — never by
1968
+ - THE PAGE'S BACKGROUNDS MOVE AS A SET (ADR 0169). `--color-bg-canvas` is the page,
1969
+ `--color-bg-subtle` the containers and chrome on it — boxed cards, hub tiles, the page
1970
+ band — and `--color-bg-surface` where data is read: tables, and charts and figures as
1971
+ they land. Every shipped palette declares all three, with subtle the midpoint of the
1972
+ other two. A token in theme.css does not recompute another, so a theme that moves canvas
1973
+ or surface redeclares subtle as well, usually as their midpoint; otherwise its containers
1974
+ keep the shipped tone, between two rungs the app no longer has.
1975
+
1976
+ :root {
1977
+ --color-bg-canvas: #e8eef8;
1978
+ --color-bg-subtle: #f4f7fc;
1979
+ --color-bg-surface: #ffffff;
1980
+ }
1981
+
1982
+ The page's summary band — the figures under its title — sits on its own token,
1983
+ `--color-bg-summary`, which every palette ships at its brand soft tint (night's a step
1984
+ darker, so subtle text clears AA on it). It is a value of its own, not a reference: a theme
1985
+ that moves `--color-brand-primary-soft` for a new brand moves `--color-bg-summary` with it,
1986
+ or the band keeps the shipped blue; a theme that wants a calmer band moves this one token
1987
+ and the tint the hub tiles share stays where it is.
1988
+
1989
+ - TO SHIP ON A PALETTE OTHER THAN midday, name it in the layout declaration — never by
1963
1990
  restyling one palette to imitate another:
1964
1991
 
1965
- frontend/layout-contract.json -> { "defaultTheme": "midnight" }
1992
+ frontend/layout-contract.json -> { "defaultTheme": "night" }
1966
1993
 
1967
- Legal values are the five above plus "system". Passing `defaultTheme` as a bootstrap
1994
+ Legal values are the palettes named above, plus "system". Passing `defaultTheme` as a bootstrap
1968
1995
  option as well is refused (terp guide layouts). Your organisation's styling tool may
1969
1996
  seed this key; changing it here makes it yours and later rollouts leave it alone.
1970
1997
 
@@ -1975,7 +2002,7 @@ Theming and branding (design tokens, palettes, the brand mark)
1975
2002
  so declare it on the document as well and the app opens in its own palette with no
1976
2003
  flash either:
1977
2004
 
1978
- frontend/index.html -> <html lang="en" data-theme="midnight">
2005
+ frontend/index.html -> <html lang="en" data-theme="night">
1979
2006
 
1980
2007
  Both halves are the same fact, in the two places that can each answer at a different
1981
2008
  moment: the attribute is there before anything runs, and the script overrides it only
@@ -1997,9 +2024,18 @@ Theming and branding (design tokens, palettes, the brand mark)
1997
2024
  a palette may vary it). Spacing, corners, typography, motion and z-index are
1998
2025
  theme-INVARIANT by design — declare them once in `:root`. An app whose spacing
1999
2026
  changed when someone switched palette is not what anyone means by a theme.
2000
- - CONTRAST is measurable, so measure it: @terpjs/contract carries a WCAG contrast suite
2001
- over the shipped palettes. If you override a foreground or a background, check the
2002
- pairing rather than trusting the eye.
2027
+ - CONTRAST is measurable, so measure it. @terpjs/contract holds every pairing the shipped
2028
+ palettes paint to two models (ADR 0170): the WCAG ratio, and APCA lightness contrast at
2029
+ the level the text's reading asks for -- Lc 90 for body text, 75 for secondary text a
2030
+ reader still has to take in (a label, a hint, a link, a badge), 60 for a count or a
2031
+ position. The manifest publishes both: each of `textPairs` names its `reading`, and
2032
+ `apca.minimumLc` the floors. If you override a foreground or a background, check its
2033
+ pairings against both rather than trusting the eye: WCAG's ratio alone passes light text
2034
+ on a dark ground that reads poorly. In module code the same split is `Text`'s tone:
2035
+ `muted` for anything read, `subtle` only for what orients.
2036
+ - A CONTROL'S OUTLINE is `--color-border-strong`, held at 3:1 against the surfaces controls
2037
+ sit on. Move that token to retune the edge of every input, select and secondary button;
2038
+ `--color-neutral-300` is the scrollbar and disabled ink, not a border.
2003
2039
  """,
2004
2040
  "layouts": """\
2005
2041
  Layout contracts (slot-typed layouts, ADR 0079)
@@ -2049,15 +2085,65 @@ Layout contracts (slot-typed layouts, ADR 0079)
2049
2085
  DetailPage -> DetailList / DetailListGroup / Stack / Grid / Tabs / ModuleNav /
2050
2086
  DataView / Card / Divider / Text + the same framework states and
2051
2087
  ConfirmDialog
2052
- Grid is a DETAIL-body component and not an overview one, deliberately: an overview
2053
- body is a data collection, and a grid of summary cards is a hub — which has its own
2054
- archetype. Heading is admitted nowhere: a heading in a governed body must OWN its
2088
+ FormPage -> Stack / Grid / Card / Divider / Text + the same states and
2089
+ ConfirmDialog: a form body is a container (Stack as="form"), never
2090
+ a loose run of Fields
2091
+ SettingsPage -> Card / Stack / Divider / Text + the same states and ConfirmDialog:
2092
+ Card sections, no collection
2093
+ SplitPage -> SplitPane only: a list beside the record it selects
2094
+ DashboardPage -> Grid / Stack / Card / DataView / Stat / StatGroup / TrendChart /
2095
+ BarChart / ProportionBar / StatusHistory / Timeline / Divider / Text +
2096
+ the same states and ConfirmDialog: "how is the whole doing", its
2097
+ figures in the summary band
2098
+ Grid is not an overview-body component, deliberately: an overview body is a data
2099
+ collection, a grid of cards into each area is a hub, and sections of figures and charts
2100
+ are a dashboard — each has its own archetype. Heading is admitted nowhere: a heading in a governed body must OWN its
2055
2101
  section, and Card (boxed, or variant="plain" for no chrome) is how a section is
2056
2102
  owned; a bare heading with siblings after it is a grouping the check cannot see.
2057
- The plain Page stays unconstrained — it is the sanctioned home for a bespoke screen.
2058
- Only the slot's DIRECT children are governed: an allowed container's own subtree
2103
+ The plain Page's BODY stays unconstrained — it is the sanctioned home for a bespoke
2104
+ screen. Only the slot's DIRECT children are governed: an allowed container's own subtree
2059
2105
  (a Card's body, a Stack's rows) is yours to compose — nesting content inside an
2060
2106
  allowed component is sanctioned composition, not an escape.
2107
+ - Two rules belong to the page FRAME rather than to a body, so they hold on every page,
2108
+ the plain Page included (ADR 0169 §4):
2109
+ summary -> Stat / StatGroup / StatusHistory / Badge / Text only: the band under
2110
+ the title holds the page's own figures, nothing else
2111
+ headline -> at most ONE figure per page is the headline (the brand-filled one);
2112
+ the lint counts a page element's static JSX, the runtime the rendered
2113
+ page, and both say the fix
2114
+ - Compose a page from the shape of its data (ADR 0169). Decide what each block IS and use
2115
+ the component made for it — there are no style variants to pick, and the framework owns
2116
+ how each one looks:
2117
+ the figures the page is about -> a StatGroup in the page's `summary`
2118
+ one figure, with its context -> Stat: `delta` (the sentiment is yours to declare —
2119
+ more rejections is up and bad), `trend` (a
2120
+ sparkline), `target` (a range, drawn as a Meter)
2121
+ the figure that matters most -> Stat `headline`, once per page
2122
+ facts about one record -> DetailList
2123
+ values over time -> TrendChart: mark "line", "area" or "columns",
2124
+ `comparison` for the period before
2125
+ categories compared -> BarChart, ranked before it is passed
2126
+ a whole and its parts -> ProportionBar, each part a word with its share
2127
+ how recent runs ended -> StatusHistory, or `history` on a DataView column
2128
+ events in order -> Timeline: a record's history, newest first
2129
+ a collection -> DataView (the brightest object on the page)
2130
+ a section beside another -> Grid template="2:1" ("1:2", "3:1", or the equal
2131
+ "1:1" / "1:1:1" / "1:1:1:1"), which collapses on a
2132
+ phone where columns={4} would clip
2133
+ Contrast is that ranking — the figures in their band, containers stepped back onto
2134
+ --color-bg-subtle, the data forward on the surface — so a block that should stand out
2135
+ is a different shape of data, not a custom colour.
2136
+ - Pick the archetype by the question the page answers: a landing that only leads into the
2137
+ app's areas is a HubPage; one area's collection (how each one is doing) an OverviewPage;
2138
+ how the whole is doing a DashboardPage -- a landing that also carries the app's figures is
2139
+ one, as the hub preset scaffolds it; one record a DetailPage; entering a record a
2140
+ FormPage; settings a SettingsPage; a list beside the record it selects a SplitPage.
2141
+ - Alternate framed and unframed blocks. The frame belongs to the data a reader works with:
2142
+ a collection, a chart and a lone figure sit on the surface, the collection brightest. A
2143
+ boxed Card steps back onto --color-bg-subtle, for a group of controls or one section of
2144
+ a record. Much of a page needs no frame at all: a StatGroup's figures, a DetailList, a
2145
+ Timeline, a line of Text, a titled section as Card variant="plain". A page that boxes
2146
+ every block in a Card reads as one object repeated, however good each block is.
2061
2147
  - Enforcement (never lint-only):
2062
2148
  build time -> the terp/layout-contract ESLint rule checks the static JSX
2063
2149
  children of each governed archetype (npm --prefix frontend run lint)
@@ -29,6 +29,8 @@ import subprocess
29
29
  from collections.abc import Callable, Sequence
30
30
 
31
31
  from terp.cli import ports
32
+ from terp.cli._output import emit
33
+ from terp.cli.docker_masks import clear_stale_masks
32
34
 
33
35
  _DEFAULT_COMPOSE = "docker-compose.yml"
34
36
 
@@ -229,6 +231,18 @@ def run_docker_dev_command(
229
231
  _, note = ports.ensure_assigned(path.parent)
230
232
  if note:
231
233
  print(note)
234
+ # Before `watch` creates anything: a workbench from before 0.30.0 holds its
235
+ # node_modules mask as a volume, which Compose would reattach over the tmpfs the
236
+ # compose file now declares and keep serving old packages from
237
+ # (terp.cli.docker_masks). No docker on PATH is `watch`'s to report, in its own
238
+ # words, not this check's.
239
+ try:
240
+ cleared = clear_stale_masks(path, project_name=project_name, capture=capture or _capture)
241
+ except OSError:
242
+ cleared = ""
243
+ if cleared:
244
+ # Through the CLI's one output seam, and flushed: `watch` holds the terminal next.
245
+ emit(cleared)
232
246
  status = (runner or _run)(docker_dev_argv(path, project_name=project_name))
233
247
  message = f"docker compose watch exited with status {status}"
234
248
  if status == 0:
@@ -0,0 +1,167 @@
1
+ """A mask the compose file declares as ``tmpfs`` that a running workbench still holds as a volume.
2
+
3
+ 0.30.0 turned the frontend's ``node_modules`` mask from an anonymous volume into a
4
+ ``tmpfs``, so that a dependency bump reaches the running dev server. A workbench that
5
+ was already up before the upgrade does not get it. When Compose recreates a container
6
+ it reattaches the old container's anonymous volumes by target path, and that wins over
7
+ the ``tmpfs`` now declared at the same path. The dev server then keeps serving the
8
+ first boot's packages from that volume, with every container healthy and the page
9
+ answering 200. An upgraded-on-paper workbench and an upgraded one look identical.
10
+
11
+ Compose owns that reattachment, so the place to undo it is before ``watch`` creates
12
+ anything. For every service whose mount at a ``tmpfs`` path is still a volume, the
13
+ container is removed, and so is that volume and only that one. Compose then creates
14
+ both fresh, with the ``tmpfs``. The volume held nothing but a copy of the image's
15
+ packages: the mask exists to hide the checkout's ``node_modules``, never to keep one.
16
+ A named volume is never touched, and neither is any other volume of the container.
17
+
18
+ Best effort, like the failure diagnosis next door: a daemon that is not up, a compose
19
+ file Compose cannot read, or a ``ps`` with no containers yet means there is nothing to
20
+ undo, and ``watch`` speaks for itself.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import pathlib
27
+ from collections.abc import Callable, Sequence
28
+
29
+ #: Runs argv, returning ``(exit status, combined output)``.
30
+ Capture = Callable[[Sequence[str]], tuple[int, str]]
31
+
32
+
33
+ def _compose(compose_file: pathlib.Path, project_name: str | None) -> list[str]:
34
+ argv = ["docker", "compose", "-f", str(compose_file)]
35
+ if project_name:
36
+ argv += ["-p", project_name]
37
+ return argv
38
+
39
+
40
+ def _json_entries(output: str) -> list[dict]:
41
+ """A JSON array, one object, or newline-delimited objects, as Compose has printed each."""
42
+ stripped = output.strip()
43
+ if not stripped:
44
+ return []
45
+ try:
46
+ parsed = json.loads(stripped)
47
+ except json.JSONDecodeError:
48
+ entries = []
49
+ for line in stripped.splitlines():
50
+ try:
51
+ entries.append(json.loads(line))
52
+ except json.JSONDecodeError:
53
+ continue
54
+ return [entry for entry in entries if isinstance(entry, dict)]
55
+ items = parsed if isinstance(parsed, list) else [parsed]
56
+ return [entry for entry in items if isinstance(entry, dict)]
57
+
58
+
59
+ def tmpfs_targets(config_output: str) -> dict[str, frozenset[str]]:
60
+ """Per service, the paths ``docker compose config --format json`` declares as ``tmpfs``.
61
+
62
+ Both spellings count: a ``volumes`` entry of ``type: tmpfs``, and the service-level
63
+ ``tmpfs:`` list (whose entries may carry ``:options`` after the path).
64
+ """
65
+ try:
66
+ config = json.loads(config_output)
67
+ except json.JSONDecodeError:
68
+ return {}
69
+ services = config.get("services") if isinstance(config, dict) else None
70
+ if not isinstance(services, dict):
71
+ return {}
72
+ found: dict[str, frozenset[str]] = {}
73
+ for name, service in services.items():
74
+ if not isinstance(service, dict):
75
+ continue
76
+ targets = {
77
+ volume["target"]
78
+ for volume in service.get("volumes") or ()
79
+ if isinstance(volume, dict)
80
+ and volume.get("type") == "tmpfs"
81
+ and isinstance(volume.get("target"), str)
82
+ }
83
+ listed = service.get("tmpfs") or ()
84
+ for entry in [listed] if isinstance(listed, str) else listed:
85
+ if isinstance(entry, str) and entry:
86
+ targets.add(entry.split(":", 1)[0])
87
+ if targets:
88
+ found[name] = frozenset(targets)
89
+ return found
90
+
91
+
92
+ def containers(ps_output: str) -> dict[str, str]:
93
+ """Service name to container id, from ``docker compose ps --all --format json``."""
94
+ found: dict[str, str] = {}
95
+ for entry in _json_entries(ps_output):
96
+ service, container = entry.get("Service"), entry.get("ID")
97
+ if isinstance(service, str) and isinstance(container, str) and container:
98
+ found.setdefault(service, container)
99
+ return found
100
+
101
+
102
+ def stale_volumes(inspect_output: str, targets: frozenset[str]) -> tuple[str, ...]:
103
+ """The volumes ``docker inspect`` shows mounted at a path that should be a ``tmpfs``.
104
+
105
+ Only a volume counts, never a bind: a bind at that path is somebody's deliberate
106
+ choice, and removing it is not this command's to decide.
107
+ """
108
+ names: list[str] = []
109
+ for container in _json_entries(inspect_output):
110
+ for mount in container.get("Mounts") or ():
111
+ if (
112
+ isinstance(mount, dict)
113
+ and mount.get("Type") == "volume"
114
+ and mount.get("Destination") in targets
115
+ and isinstance(mount.get("Name"), str)
116
+ and mount["Name"] not in names
117
+ ):
118
+ names.append(mount["Name"])
119
+ return tuple(names)
120
+
121
+
122
+ def clear_stale_masks(
123
+ compose_file: pathlib.Path,
124
+ *,
125
+ project_name: str | None = None,
126
+ capture: Capture,
127
+ ) -> str:
128
+ """Undo a pre-0.30.0 ``node_modules`` volume before ``watch``; what was done, or ``""``."""
129
+ compose = _compose(compose_file, project_name)
130
+ status, config = capture([*compose, "config", "--format", "json"])
131
+ targets = tmpfs_targets(config) if status == 0 else {}
132
+ if not targets:
133
+ return ""
134
+ status, ps_output = capture([*compose, "ps", "--all", "--format", "json"])
135
+ running = containers(ps_output) if status == 0 else {}
136
+ stale: dict[str, tuple[str, ...]] = {}
137
+ for service, paths in sorted(targets.items()):
138
+ container = running.get(service)
139
+ if container is None:
140
+ continue
141
+ status, inspected = capture(["docker", "inspect", container])
142
+ volumes = stale_volumes(inspected, paths) if status == 0 else ()
143
+ if volumes:
144
+ stale[service] = volumes
145
+ if not stale:
146
+ return ""
147
+ services = sorted(stale)
148
+ volumes = [volume for service in services for volume in stale[service]]
149
+ listing = ", ".join(repr(service) for service in services)
150
+ removed, _ = capture([*compose, "rm", "--stop", "--force", *services])
151
+ if removed == 0:
152
+ removed, _ = capture(["docker", "volume", "rm", *volumes])
153
+ if removed != 0:
154
+ renew = [*compose, "up", "-d", "--no-deps", "--force-recreate", "--renew-anon-volumes"]
155
+ return (
156
+ f"terp docker dev: {listing} still mount(s) a volume where the compose file "
157
+ "declares a tmpfs (a workbench from before 0.30.0), so the dev server would keep "
158
+ "serving its old packages. Removing it failed; once, by hand:\n"
159
+ f" {' '.join([*renew, *services])}\n"
160
+ f" docker volume rm {' '.join(volumes)}"
161
+ )
162
+ return (
163
+ f"terp docker dev: {listing} still mounted a volume where the compose file declares "
164
+ "a tmpfs (a workbench from before 0.30.0), so the dev server kept serving the packages "
165
+ "it first booted with. Removed the container and that volume; this start creates both "
166
+ "fresh."
167
+ )
@@ -1043,6 +1043,24 @@ def _node_modules_problem(root: pathlib.Path, workspace: str) -> str | None:
1043
1043
  except (OSError, ValueError):
1044
1044
  return None
1045
1045
 
1046
+ # A directory with none of the lockfile's platform-neutral packages in it is not
1047
+ # a tree for another platform but no tree at all -- an install that never ran,
1048
+ # or a fresh mount point. Calling it "a different platform" sends the reader
1049
+ # looking for a second machine that does not exist. Neutral ones only: every
1050
+ # platform installs those, so not one of them present means nothing was.
1051
+ neutral = [
1052
+ name
1053
+ for name, entry in packages.items()
1054
+ if name.startswith("node_modules/")
1055
+ and isinstance(entry, dict)
1056
+ and not (entry.get("os") or entry.get("cpu") or entry.get("libc"))
1057
+ ]
1058
+ if neutral and not any((directory / name).exists() for name in neutral):
1059
+ return (
1060
+ f"{label} is missing (the directory is there, but holds none of the "
1061
+ f"lockfile's packages) — {subject} cannot run.\n Fix: {fix}"
1062
+ )
1063
+
1046
1064
  system, arch = _node_platform()
1047
1065
  libc = _node_libc(system)
1048
1066
  missing = [
@@ -236,6 +236,7 @@ _APP_OWNED_SCAFFOLD_FILES = (
236
236
  "control_plane/app_operations.py",
237
237
  "environment.schema.json",
238
238
  "escape-hatch-budget.json",
239
+ "frontend/escape-hatch-budget.json",
239
240
  "frontend/layout-contract.json",
240
241
  "frontend/src/house-style.css",
241
242
  "frontend/src/routes.gen.d.ts",
@@ -450,8 +451,11 @@ def _rerender_recipe(target: str, current: str, count: int) -> list[str]:
450
451
  " and its own diff is much easier to review on its own.",
451
452
  " 3. copier update (or the Studio's upgrade flow, which records the",
452
453
  " answers file it needs). This rewrites EVERY file the template owns and",
453
- " writes the terp-* and @terpjs/* pins for you.",
454
- " 4. Resolve what it reports. Two conflicts are structural rather than bad luck,",
454
+ " writes the terp-* and @terpjs/* pins for you. Its --pretend lists none of",
455
+ " that, so do not judge the update by a dry run: review what it did as its",
456
+ " own diff (git status, git diff) — that is why step 2 cleans the tree.",
457
+ " 4. Resolve what it reports: conflict markers in the file, or a *.rej beside it",
458
+ " when it ran with --conflict rej. Two conflicts are structural, not bad luck,",
455
459
  " because the template owns the file and your app also writes to it:",
456
460
  " pyproject.toml keep your dependencies, take the terp-* pins.",
457
461
  " control_plane/operations.py the capability folding is the template's;",
@@ -468,7 +472,10 @@ def _rerender_recipe(target: str, current: str, count: int) -> list[str]:
468
472
  " in while the source is bind-mounted, so correct new code reloads against old",
469
473
  " libraries and dies on an import nowhere near its cause. `terp docker dev`",
470
474
  " rebuilds on a pyproject.toml change; a plain `docker compose up` does not,",
471
- " and `terp verify` now refuses the skew either way.",
475
+ " and `terp verify` now refuses the skew either way. Coming from 0.29 or older,",
476
+ " a stack that is already up keeps its old node_modules volume where 0.30.0",
477
+ " declares a tmpfs. `terp docker dev` removes it before it starts; with plain",
478
+ " compose, run `up --force-recreate --renew-anon-volumes` once.",
472
479
  "",
473
480
  "A green gate proves the upgrade did not break this app. It cannot prove the",
474
481
  "release did not change something this app should adopt — step 1 is the only",
File without changes
File without changes
File without changes