lr-lumentest-cli 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,245 @@
1
+ Metadata-Version: 2.4
2
+ Name: lr-lumentest-cli
3
+ Version: 0.5.1
4
+ Summary: CLI client for the LumenRadio LumenTest production test station: import artifact packages, run test sequences, and read results over HTTP.
5
+ Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://gitlab.com/lumenradio/production/lumentest
8
+ Project-URL: Changelog, https://gitlab.com/lumenradio/production/lumentest/-/blob/main/CHANGELOG.md
9
+ Keywords: LumenRadio,LumenTest,production test,test station,CLI
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Environment :: Console
14
+ Classifier: Topic :: Software Development :: Testing
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: typer>=0.12
19
+ Requires-Dist: rich>=13.7
20
+ Requires-Dist: httpx>=0.27
21
+ Requires-Dist: pydantic>=2.8
22
+ Requires-Dist: mnemonic>=0.21
23
+ Provides-Extra: dev
24
+ Requires-Dist: build>=1.2.1; extra == "dev"
25
+ Requires-Dist: twine>=6.1; extra == "dev"
26
+ Requires-Dist: pytest>=8.4; extra == "dev"
27
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
28
+ Requires-Dist: black>=25.9; extra == "dev"
29
+ Provides-Extra: package
30
+ Requires-Dist: pyinstaller==6.21.0; extra == "package"
31
+ Dynamic: license-file
32
+
33
+ # lr-lumentest-cli
34
+
35
+ CLI for the LumenRadio LumenTest production test station server.
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ pip install lr-lumentest-cli
41
+ ```
42
+
43
+ ## Quick Start
44
+
45
+ ```bash
46
+ # Point the CLI at your station server and identify yourself by scanning
47
+ # your badge (the handheld scanner types the badge id at the prompt)
48
+ lumentest login --base-url http://10.0.0.1:17080 --badge-id LOP0000001
49
+
50
+ # Check server health and login state
51
+ lumentest status
52
+
53
+ # Scan and list imported products
54
+ lumentest product scan
55
+ lumentest product list
56
+
57
+ # Run a full test sequence interactively (prompts on ALERT steps)
58
+ lumentest test start --serial SN-00001
59
+
60
+ # Run a single step (for development / debugging)
61
+ lumentest test step production-test flash-firmware --serial SN-00001
62
+ ```
63
+
64
+ ## Command Reference
65
+
66
+ ```
67
+ lumentest login [--base-url URL] --badge-id ID
68
+ # ID is the scanned QR badge; the server resolves it to your operator id.
69
+ lumentest logout
70
+ lumentest status [--output rich|json]
71
+ lumentest --version
72
+
73
+ lumentest product list [--output rich|json]
74
+ # Table includes a Build Words column — see doc/build-words.md. The manifest
75
+ # digest is not shown: read it from --output json when you need the digest.
76
+ lumentest product active
77
+ lumentest product set ARTICLE [REVISION]
78
+ lumentest product remove ARTICLE [REVISION] [--yes]
79
+ lumentest product scan [--rescan] [--output rich|json]
80
+ # Scans the station's artifact_cache/, registers every valid package, and
81
+ # reports each package's declared env variables (from parameters.toml) —
82
+ # the value a run will see, where it came from (config.toml's [env], the
83
+ # station's own environment, or the package default) and which are still
84
+ # unconfigured — alongside its prerequisites (the external tools its
85
+ # sequences need) against the station itself. Each unconfigured variable's
86
+ # line carries the exact `lumentest config set env.NAME <VALUE>` command to
87
+ # fix it; a set value applies to the running station immediately, so
88
+ # re-running scan is enough to confirm it, for a variable and for a tool
89
+ # newly installed on PATH alike — no restart needed either way. --output
90
+ # json emits the raw payload.
91
+ lumentest product import PATH [--update] [--bundle] [--output rich|json]
92
+ # PATH: directory or .zip/.tar.gz archive.
93
+ # --update: recompute SHA-256 checksums in artifact-manifest.json before
94
+ # uploading — useful during step development to skip manual hashing.
95
+ # --bundle: pack PATH into <PATH>.tar.gz (manifest + declared files only)
96
+ # next to it instead of uploading — for email/USB delivery;
97
+ # import the archive later with the same command. Requires a
98
+ # directory; passing an archive file just warns and exits.
99
+ # Prints the package's Build Words (doc/build-words.md) so a
100
+ # technician can read them off a locally built bundle without a
101
+ # running server; --output json carries them as "build_words".
102
+ # Reports the package's declared env variables the same way `product scan`
103
+ # does: the value a run will see, where it came from, and which still need
104
+ # a value. Fix an unconfigured one with the `lumentest config set` command
105
+ # printed on its line, then re-run `product scan` to confirm — same as
106
+ # confirming a newly installed prerequisite tool, neither needs a restart.
107
+ # Only artifact-manifest.json's declared files (and declared extra
108
+ # sequences) are packed and uploaded — the manifest is the contract for
109
+ # what the package needs. A package that needs a file its manifest does
110
+ # not declare must have that file added to the manifest, not bundled some
111
+ # other way.
112
+ # A directory import first checks with the station which files it already
113
+ # has (by digest, including across every other cached revision) — a
114
+ # retry after an interrupted upload resumes instead of re-sending
115
+ # everything. Only what's actually missing is uploaded, with a progress
116
+ # bar over just those bytes; the command then reports how many files were
117
+ # reused, e.g. "12 of 24 files already on the station (302 MB reused)".
118
+ # An existing .zip/.tar.gz archive still uploads as a single one-shot
119
+ # transfer. --output json moves all of the above to stderr so stdout
120
+ # carries nothing but the final JSON — see "Machine-readable output".
121
+
122
+ lumentest sequence list [--article ARTICLE] [--output rich|json]
123
+ lumentest sequence steps ARTICLE SEQUENCE
124
+ lumentest sequence selection get ARTICLE SEQUENCE
125
+ lumentest sequence selection set ARTICLE SEQUENCE --steps 1,2,5
126
+ lumentest sequence selection clear ARTICLE SEQUENCE
127
+
128
+ lumentest test start [--serial SN] [--article ARTICLE] [--sequence NAME]
129
+ # Blocks with an interactive Rich UI; handles ALERT prompts; exits with
130
+ # the run's terminal status: 0 PASS, 1 FAIL, 2 ABORTED, 3 ERROR (outcome
131
+ # undetermined — station unreachable or run gone). The operator comes
132
+ # from the bearer token established by `lumentest login`, not a flag.
133
+ # The run header shows the package's Build Words when the station reports
134
+ # them.
135
+ lumentest test step SEQUENCE STEP [--article ARTICLE] [--serial SN]
136
+ # Runs one step via the same interactive UI. STEP is a name or 0-based index.
137
+ lumentest test status [--output rich|json]
138
+ # One-shot snapshot of the currently active test run.
139
+
140
+ lumentest result list [--limit N] [--serial SN] [--output rich|json]
141
+ # --serial SN filters to runs for one device (exact dut_id match); the station
142
+ # filters server-side so high-throughput stations don't ship the full history.
143
+ lumentest result show [RUN_ID]
144
+ # RUN_ID is optional: omit it to show the most recent run.
145
+ lumentest result logs [RUN_ID] [--step IDX] [--stream stdout|stderr|traceback]
146
+ [--download DIR] [--output rich|json]
147
+ # RUN_ID is optional: omit it to fetch the most recent run's logs.
148
+ # Without --step: fetches the complete log bundle via GET /logs/all — run error,
149
+ # structured journal, and every step's stdout/stderr/traceback. --output json
150
+ # emits the raw JSON bundle; --download DIR writes the tree to DIR/<run_id>/
151
+ # (error.txt, log.jsonl, steps/<name>/{stdout,stderr,traceback}.txt, omitting
152
+ # empty files). --output json takes precedence when both flags are given.
153
+ # With --step IDX: shows that step's stdout, stderr, and traceback (or a single
154
+ # stream via --stream).
155
+ lumentest result export [RUN_ID] [--all] [--serial SN] [--limit N]
156
+ [--out DIR] [--output rich|json]
157
+ # Downloads one .tar.gz station snapshot via GET /api/results/export: the
158
+ # selected run(s) verbatim under results/ (run.json, qrm-payload.json, the
159
+ # journal, every step's captured streams) plus the station's own log,
160
+ # state.json, operator_stats.json and effective config under station/ —
161
+ # laid out exactly as the station stores them on disk. RUN_ID / --all /
162
+ # --serial / --limit govern results/ only; the station context is always
163
+ # included. Bare `result export` packs only the most recent run; --all
164
+ # opts into every retained run. RUN_ID is mutually exclusive with --all,
165
+ # --serial and --limit.
166
+ # --out DIR: directory to write the archive into (default: the current
167
+ # directory). The filename is stamped by the station itself from its own
168
+ # [station].id and a UTC timestamp, so an export stays identifiable once
169
+ # it is off the station.
170
+ # artifact_cache/ package binaries are never packed — represented instead by
171
+ # an inventory (article, revision, size, file count); Arena is the system
172
+ # of record for firmware. Re-send a run to QRM by hand with
173
+ # `qrm result upload <run>/qrm-payload.json` against the extracted tree.
174
+ # An export contains the station's QRM service password, session token
175
+ # hashes, operator identities, and whatever a step or tool printed. Treat
176
+ # an archive as a secret — see doc/troubleshooting.md § Collecting a
177
+ # station snapshot for support.
178
+
179
+ lumentest config list [--output rich|json]
180
+ # Flattens config.toml to dotted keys (station.id, env.NRF_TOOLS,
181
+ # timeouts.default_step_s, ...) so a technician can scan the whole
182
+ # effective configuration in one table. Secrets (qrm.password,
183
+ # serial.client_secret, user password hashes) show as "<redacted>" and are
184
+ # marked in the Notes column, alongside keys that need a station restart
185
+ # to take effect (station.host, station.port, debug.results_dir).
186
+ lumentest config get KEY [--output rich|json]
187
+ lumentest config set KEY VALUE [--json] [--output rich|json]
188
+ # VALUE is sent as a raw string and coerced through the server's pydantic
189
+ # validation (e.g. "90" -> the int 90); pass --json to send VALUE parsed
190
+ # as JSON instead, for list/dict/explicitly-typed values. This is how a
191
+ # newly required package env variable gets set without restarting the
192
+ # station: `lumentest config set env.SOME_VAR value` then
193
+ # `lumentest product scan`. A key flagged restart-required still saves,
194
+ # but a visible warning says it only takes effect after the next restart.
195
+ # Setting a secret back to its own masked "<redacted>" value is rejected,
196
+ # so a `config get` / `config set` round trip can never silently
197
+ # overwrite a credential.
198
+ lumentest config unset KEY
199
+ # Resets KEY to its model default; for the free-form [env] and
200
+ # [test].fake_tools maps this removes the entry instead. A required key
201
+ # with no default (e.g. station.id) is rejected. A restart-required key
202
+ # carries the same warning `config set` gives — the reset is saved, but
203
+ # the running station keeps the old value until it restarts.
204
+
205
+ lumentest operator whoami [--output rich|json]
206
+ # Resolve the operator behind the stored token via GET /api/auth/whoami.
207
+ # Exits 1 if the token is missing, unknown, or expired.
208
+
209
+ lumentest operator stats [OPERATOR_ID] [--stations URL1,URL2,...] [--output rich|json]
210
+ # Per-operator statistics aggregated across stations (counters add,
211
+ # last_login = max). Omit OPERATOR_ID to list every operator seen.
212
+ # --stations defaults to the logged-in station.
213
+ ```
214
+
215
+ ## Machine-readable output
216
+
217
+ Every command above that takes `--output json` writes **exactly one JSON document
218
+ to stdout and nothing else**, so a caller can pipe it straight into a parser:
219
+
220
+ ```bash
221
+ lumentest product import --bundle --output json ./400-1011_A | jq -r .build_words
222
+ ```
223
+
224
+ Everything meant for a human — progress bars, warnings, "generated the
225
+ manifest" — goes to **stderr** in JSON mode, and errors always do. Redirect it
226
+ away with `2>/dev/null`, or keep it to watch a long import while still capturing
227
+ the result.
228
+
229
+ A command that finds nothing to report emits `null` rather than staying silent,
230
+ so stdout parses unconditionally.
231
+
232
+ `result logs --step IDX` is the one exception: it has no JSON form, so `--output`
233
+ is inert there and the step's captured streams go to stdout either way. Piping
234
+ that output works with the flag or without it.
235
+
236
+ In `--output rich` (the default) the human output goes to stdout as usual.
237
+
238
+ ## Server URL
239
+
240
+ The CLI reads the server URL from (in order of priority):
241
+
242
+ 1. `--base-url` flag on `lumentest login`
243
+ 2. `LUMENTEST_URL` environment variable
244
+ 3. Stored value in `~/.config/lumentest/login.json`
245
+ 4. Default: `http://127.0.0.1:17080`
@@ -0,0 +1,26 @@
1
+ lr_lumentest_cli-0.5.1.dist-info/licenses/LICENSE,sha256=cEnG3HeMPa3GKtuENkytOQaNJS9rwMMU_KWHObdWbOY,1070
2
+ lumentest/__init__.py,sha256=IyOHnTq79cSXeELA5QCGzS164PZFaUqUPv5JuefIitg,253
3
+ lumentest/__main__.py,sha256=VmwcYSnsDe9MR_GCkhw1HR4AFENhvtSYub9mZG1M7Wo,37
4
+ lumentest/_version.py,sha256=et8oVdAxXtM6it836Cx8r9oFEtQha0JynHTCzNzn8eQ,520
5
+ lumentest/auth.py,sha256=qBvjLuNG0ictE6tSo6n_8l5yktbwOvArQbdECIda8t0,5527
6
+ lumentest/build_words.py,sha256=zACXpNEW0qvOqM6BLVLHRk5tQgkhQxM8PQcGxTZUWP4,4033
7
+ lumentest/cli.py,sha256=p7a36ri_WESeUhW9nZRv8XcpiweCz8j9hncmaCP8OBo,1625
8
+ lumentest/client.py,sha256=T1wai5oLmePzabQUt5ctNBRinChVrqQ9XGBNvPITtz0,46675
9
+ lumentest/commands_shared.py,sha256=j5ZeBqyupbVr4l6Awo57ufXrvjcqBRVebQk0rxVk0jQ,10347
10
+ lumentest/config.py,sha256=Heo9ABHK13QXcTYrT5mlgwtBKD_0QH5IgoQPgIa6-Hk,2382
11
+ lumentest/env_report.py,sha256=PtLu_I9kadXynMUw1brEDoLtF-c_i11FmQMJ-MCv3og,8614
12
+ lumentest/manifest_gen.py,sha256=wuQVVMY5NgAuoM592RW8GbINOTkBWRaSgeMn6hBH07E,9321
13
+ lumentest/prerequisites_report.py,sha256=hs5yxmTZKaeQ--3a9KJX0d8L95exnNJp9gKcpj7AZQs,8964
14
+ lumentest/commands/__init__.py,sha256=sa5StNEFUUs_HJQXF1S_hA0IsCMxia3uAkBBAOj9CV8,46
15
+ lumentest/commands/config.py,sha256=Byt4iowPSZy7Kz0sishMWZnAIKwte08KRlve1dtWKQw,7279
16
+ lumentest/commands/operator.py,sha256=ri0ljoifMVh8jCgGEu0KRhMRwyjrSXx_iNeWnZhUHAw,7148
17
+ lumentest/commands/product.py,sha256=If4iI1EzApHCn0TnzI97kEFDAwPK0qEDNrHUrQnaCO4,26011
18
+ lumentest/commands/result.py,sha256=YYbEIUu0dsIOwyFOYom4zCKc-h-kWF56hBldeMRzT1o,25188
19
+ lumentest/commands/sequence.py,sha256=B8LAXzLKR8BNLeejbwtkd6U5hn_rO7ZFY3G4_mZgMXA,11065
20
+ lumentest/commands/test.py,sha256=GM33JcQTtMN7d3HrKcjlV112x2t35D5EzDQWfXgb6ug,9824
21
+ lumentest/commands/test_ui.py,sha256=yNbci0PtqlNymETOPaqZZvfi7TKxUGvJV6QcrM3p2jk,34196
22
+ lr_lumentest_cli-0.5.1.dist-info/METADATA,sha256=SvW5y-5AkaHwY_DlVaSZzyyOAAxSwhaGUitrOX4aunU,12541
23
+ lr_lumentest_cli-0.5.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
24
+ lr_lumentest_cli-0.5.1.dist-info/entry_points.txt,sha256=TzyC9nHlZLbcBYRILLqCp2uRYcve8chHGY16t_pmLW8,48
25
+ lr_lumentest_cli-0.5.1.dist-info/top_level.txt,sha256=lQModdT5-g8DctLm-3TBrsSwOav0Aj8lq05feMUICoY,10
26
+ lr_lumentest_cli-0.5.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lumentest = lumentest.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LumenRadio AB
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ lumentest
lumentest/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """lr-lumentest-cli — CLI for the LumenRadio LumenTest test station server."""
2
+
3
+ try:
4
+ from lumentest._version import __version__
5
+ except ImportError: # pragma: no cover - _version.py is generated by setuptools_scm
6
+ __version__ = "0.0.0+unknown"
lumentest/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from lumentest.cli import app
2
+
3
+ app()
lumentest/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.5.1'
22
+ __version_tuple__ = version_tuple = (0, 5, 1)
23
+
24
+ __commit_id__ = commit_id = None
lumentest/auth.py ADDED
@@ -0,0 +1,185 @@
1
+ """Authentication commands: login, logout, status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.table import Table
9
+
10
+ from .client import LumenTestClient, LumenTestClientError, LoginState
11
+ from .config import DEFAULT_CONFIG_PATH, save_login_state
12
+ from .commands_shared import (
13
+ DEFAULT_BASE_URL,
14
+ OutputFormat,
15
+ console,
16
+ emit,
17
+ handle_error,
18
+ load_state_or_exit,
19
+ output_option,
20
+ )
21
+
22
+ auth_app = typer.Typer(help="Manage server login state.")
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # login
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ @auth_app.command()
31
+ def login(
32
+ base_url: str = typer.Option(
33
+ DEFAULT_BASE_URL,
34
+ "--base-url",
35
+ "-b",
36
+ help="Base URL of the lumentest-station server.",
37
+ envvar="LUMENTEST_URL",
38
+ ),
39
+ badge_id: str = typer.Option(
40
+ ...,
41
+ "--badge-id",
42
+ "-o",
43
+ prompt="Scan badge",
44
+ help="Scanned QR badge identifier (the handheld scanner types it for you).",
45
+ envvar="LUMENTEST_BADGE_ID",
46
+ ),
47
+ config_path: Path = typer.Option(
48
+ None,
49
+ "--config-path",
50
+ help="Override the location of the login.json file.",
51
+ ),
52
+ ) -> None:
53
+ """Identify the operator by scanning their badge and store login state.
54
+
55
+ Contacts ``GET /api/health`` to confirm the server is up, then
56
+ ``POST /api/auth/login`` with the scanned badge. The server resolves the
57
+ operator id and issues a token, which is written to
58
+ ``~/.config/lumentest/login.json``. An unrecognised badge fails with a
59
+ non-zero exit code.
60
+ """
61
+ destination = config_path or DEFAULT_CONFIG_PATH
62
+
63
+ client = LumenTestClient(base_url=base_url)
64
+ try:
65
+ client.health()
66
+ token, operator_id = client.auth_login(badge_id.strip())
67
+ except LumenTestClientError as exc:
68
+ typer.secho(
69
+ f"Login failed against {base_url}: {exc}",
70
+ fg=typer.colors.RED,
71
+ err=True,
72
+ )
73
+ raise typer.Exit(code=1) from exc
74
+ finally:
75
+ client.close()
76
+
77
+ state = LoginState(base_url=base_url, operator_id=operator_id, token=token)
78
+ save_login_state(state, destination)
79
+
80
+ console.print(
81
+ f"[green]Logged in as [bold]{operator_id}[/bold] "
82
+ f"→ {base_url} (saved to {destination})[/green]"
83
+ )
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # logout
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ @auth_app.command()
92
+ def logout(
93
+ config_path: Path = typer.Option(
94
+ None,
95
+ "--config-path",
96
+ help="Override the location of the login.json file.",
97
+ ),
98
+ ) -> None:
99
+ """Invalidate the server session and remove the stored login state."""
100
+ destination = config_path or DEFAULT_CONFIG_PATH
101
+ if not destination.exists():
102
+ typer.secho("Not logged in — nothing to remove.", fg=typer.colors.YELLOW)
103
+ return
104
+
105
+ # Best-effort: notify the server; ignore failures (token may already be gone).
106
+ state = load_state_or_exit(destination)
107
+ client = LumenTestClient(base_url=state.base_url, token=state.token)
108
+ try:
109
+ client.auth_logout()
110
+ except LumenTestClientError:
111
+ pass # server unreachable or stub returned an error — proceed anyway
112
+ finally:
113
+ client.close()
114
+
115
+ destination.unlink()
116
+ console.print(f"[green]Logged out (removed {destination})[/green]")
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # status
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ @auth_app.command()
125
+ def status(
126
+ config_path: Path = typer.Option(
127
+ None,
128
+ "--config-path",
129
+ help="Override the location of the login.json file.",
130
+ ),
131
+ output: OutputFormat = output_option(),
132
+ ) -> None:
133
+ """Show login state and confirm the server is reachable.
134
+
135
+ Reads ``login.json``, contacts ``GET /api/health``, and renders a
136
+ summary table with ``server_ok``, ``base_url``, and ``operator_id``.
137
+ """
138
+ state = load_state_or_exit(config_path)
139
+
140
+ client = LumenTestClient(base_url=state.base_url, token=state.token)
141
+ server_ok = False
142
+ server_version: str | None = None
143
+ error_msg: str | None = None
144
+
145
+ try:
146
+ health = client.health()
147
+ server_ok = health.get("status") == "ok"
148
+ server_version = health.get("version")
149
+ except LumenTestClientError as exc:
150
+ error_msg = str(exc)
151
+ finally:
152
+ client.close()
153
+
154
+ payload = {
155
+ "server_ok": server_ok,
156
+ "base_url": state.base_url,
157
+ "operator_id": state.operator_id,
158
+ "server_version": server_version or error_msg or "unknown",
159
+ }
160
+
161
+ if output is OutputFormat.json:
162
+ emit(payload, output)
163
+ return
164
+
165
+ # Rich rendering
166
+ table = Table(show_header=True, header_style="bold")
167
+ table.add_column("Field", style="cyan", no_wrap=True)
168
+ table.add_column("Value", style="green")
169
+
170
+ server_ok_str = "[green]yes[/green]" if server_ok else "[red]no[/red]"
171
+ table.add_row("server_ok", server_ok_str)
172
+ table.add_row("base_url", state.base_url)
173
+ table.add_row("operator_id", state.operator_id)
174
+ if server_version:
175
+ table.add_row("server_version", server_version)
176
+ if error_msg:
177
+ table.add_row("error", f"[red]{error_msg}[/red]")
178
+
179
+ console.print(table)
180
+
181
+ if not server_ok:
182
+ raise typer.Exit(code=1)
183
+
184
+
185
+ __all__ = ["auth_app"]
@@ -0,0 +1,94 @@
1
+ """Build Words: a two-word, speakable fingerprint of an artifact package's
2
+ content files. Algorithm reference: doc/build-words.md.
3
+
4
+ This is the client-side twin of the station's
5
+ ``lumentest_station.build_words``. It computes identical words from a
6
+ manifest's ``files[]`` array so a technician can read them off a locally
7
+ built bundle without a running server. The station computes over validated
8
+ ``ManifestFile`` models; here the same fields are read straight from
9
+ ``artifact-manifest.json``, so each entry is reduced to exactly the fields the
10
+ ``ManifestFile`` schema declares, at their effective value after defaults
11
+ (``title``/``sha256`` default null, ``os`` defaults ``"any"``). Any field the
12
+ schema does not declare is dropped, matching the station's ``model_dump`` —
13
+ the station would reject a manifest carrying extra fields at load time.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ from typing import Any, Iterable
21
+
22
+ from mnemonic import Mnemonic
23
+
24
+ BUILD_WORDS_COUNT = 2 # words rendered
25
+ _BITS_PER_WORD = 11 # 2048-word BIP39 list -> 11 bits per word
26
+ _TOTAL_BITS = BUILD_WORDS_COUNT * _BITS_PER_WORD # 22
27
+
28
+ # The fields the ManifestFile schema declares, with their effective value
29
+ # after defaults. Kept in lockstep with
30
+ # service/src/lumentest_station/manifest.py::ManifestFile so the two
31
+ # implementations produce identical words. See doc/build-words.md §2.
32
+ _FIELD_DEFAULTS: dict[str, Any] = {
33
+ "path": None,
34
+ "role": None,
35
+ "title": None,
36
+ "sha256": None,
37
+ "os": "any",
38
+ }
39
+
40
+ _WORDLIST: tuple[str, ...] = tuple(Mnemonic("english").wordlist)
41
+
42
+ if len(_WORDLIST) != 2048:
43
+ # Every index below assumes exactly 2048 entries; a resized list would
44
+ # silently map to different words instead of failing.
45
+ raise RuntimeError(f"expected 2048-word BIP39 wordlist, got {len(_WORDLIST)}")
46
+
47
+ __all__ = ["BUILD_WORDS_COUNT", "content_digest", "build_words"]
48
+
49
+
50
+ def _canonical_entry(entry: dict[str, Any]) -> dict[str, Any]:
51
+ """Reduce a raw ``files[]`` entry to the canonical ManifestFile fields.
52
+
53
+ Only the fields the schema declares survive, each at its effective value
54
+ after defaults, so the JSON serialised below matches the station's
55
+ ``ManifestFile.model_dump(mode="json")``.
56
+ """
57
+ return {key: entry.get(key, default) for key, default in _FIELD_DEFAULTS.items()}
58
+
59
+
60
+ def content_digest(files: Iterable[dict[str, Any]]) -> str:
61
+ """Master SHA-256 hex digest over the canonicalised ``files[]`` entries."""
62
+ # Sorting by path (Unicode code-point order) is what makes a re-export
63
+ # that reorders files[] produce identical Build Words.
64
+ entries = sorted((_canonical_entry(f) for f in files), key=lambda f: f["path"])
65
+ digest = hashlib.sha256()
66
+ for obj in entries:
67
+ # No host-OS filtering: every entry is hashed whatever its os value.
68
+ # Filtering would make one package yield different Build Words on a
69
+ # Linux station and a Windows station, defeating the point of an
70
+ # operator reading them back.
71
+ blob = json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
72
+ "utf-8"
73
+ )
74
+ # The newline separator is unambiguous because JSON escapes newlines
75
+ # inside strings.
76
+ digest.update(blob + b"\n")
77
+ return digest.hexdigest()
78
+
79
+
80
+ def build_words(files: Iterable[dict[str, Any]]) -> str:
81
+ """Two BIP39 words derived from :func:`content_digest`.
82
+
83
+ Top-level manifest fields (article, revision, name, sns_article) are not
84
+ hashed: identical content ships under different parts and must
85
+ fingerprint identically. Only files[] is content.
86
+ """
87
+ digest = content_digest(files)
88
+ # Most-significant _TOTAL_BITS bits of the digest, MSB-first, matching
89
+ # BIP39's bit convention. No checksum -- every bit is entropy.
90
+ head = bytes.fromhex(digest)[:3]
91
+ value = int.from_bytes(head, "big") >> (8 * len(head) - _TOTAL_BITS)
92
+ first = value >> _BITS_PER_WORD
93
+ second = value & 0x7FF
94
+ return f"{_WORDLIST[first]} {_WORDLIST[second]}"