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.
- {terp_cli-0.30.0 → terp_cli-0.32.0}/PKG-INFO +8 -8
- {terp_cli-0.30.0 → terp_cli-0.32.0}/pyproject.toml +8 -8
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/__init__.py +106 -20
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/docker.py +14 -0
- terp_cli-0.32.0/src/terp/cli/docker_masks.py +167 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/verify.py +18 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/version.py +10 -3
- {terp_cli-0.30.0 → terp_cli-0.32.0}/.gitignore +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/_appref.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/_engine.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/_output.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/_subjects.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/access.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/apidocs.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/authz_surface.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/capabilities.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/deploy_safety.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/dev.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/envfile.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/envschema.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/envseams.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/fmt.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/grants.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/jobs.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/leases.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/module_roles.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/openapi.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/outbox.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/ports.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/profiles.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/py.typed +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/routes.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/scaffold.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/schema.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/seed.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/service_accounts.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/smoke.py +0 -0
- {terp_cli-0.30.0 → terp_cli-0.32.0}/src/terp/cli/users.py +0 -0
- {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.
|
|
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.
|
|
11
|
-
Requires-Dist: terp-core==0.
|
|
12
|
-
Requires-Dist: terp-migrations==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.
|
|
15
|
-
Requires-Dist: terp-cap-scheduler-apscheduler==0.
|
|
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.
|
|
17
|
+
Requires-Dist: terp-cap-scheduler-apscheduler==0.32.0; extra == 'scheduler'
|
|
18
18
|
Provides-Extra: worker
|
|
19
|
-
Requires-Dist: terp-cap-outbox==0.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
33
|
-
scheduler = ["terp-cap-scheduler-apscheduler==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.
|
|
36
|
-
"terp-cap-scheduler-apscheduler==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
|
-
|
|
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
|
-
|
|
1944
|
-
|
|
1945
|
-
the
|
|
1946
|
-
|
|
1947
|
-
|
|
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="
|
|
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
|
-
-
|
|
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": "
|
|
1992
|
+
frontend/layout-contract.json -> { "defaultTheme": "night" }
|
|
1966
1993
|
|
|
1967
|
-
Legal values are the
|
|
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="
|
|
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
|
|
2001
|
-
|
|
2002
|
-
|
|
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
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|