cli-consumption 0.4.3__tar.gz → 0.4.5__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 (66) hide show
  1. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/CHANGELOG.md +18 -1
  2. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/PKG-INFO +12 -9
  3. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/README.md +9 -6
  4. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/pyproject.toml +5 -2
  5. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/mistral_vibe.py +18 -10
  6. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/registry.py +5 -3
  7. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/cli.py +186 -5
  8. cli_consumption-0.4.5/src/cli_consumption/frontend.py +153 -0
  9. cli_consumption-0.4.5/src/cli_consumption/web_runtime.zip +0 -0
  10. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/.gitignore +0 -0
  11. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/LICENSE +0 -0
  12. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/NOTICE +0 -0
  13. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/INTER_FONT_LICENSE.txt +0 -0
  14. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/__init__.py +0 -0
  15. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/__main__.py +0 -0
  16. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/__init__.py +0 -0
  17. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/_shared.py +0 -0
  18. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/aider.py +0 -0
  19. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/amazon_q.py +0 -0
  20. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/amp.py +0 -0
  21. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/base.py +0 -0
  22. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/claude.py +0 -0
  23. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/cline.py +0 -0
  24. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/codex.py +0 -0
  25. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/continue_cli.py +0 -0
  26. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/copilot.py +0 -0
  27. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/crush.py +0 -0
  28. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/cursor.py +0 -0
  29. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/gemini.py +0 -0
  30. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/goose.py +0 -0
  31. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/grok.py +0 -0
  32. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/kilo.py +0 -0
  33. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/kimi.py +0 -0
  34. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/opencode.py +0 -0
  35. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/openhands.py +0 -0
  36. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/pi.py +0 -0
  37. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/plandex.py +0 -0
  38. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/adapters/qwen.py +0 -0
  39. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/api.py +0 -0
  40. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/dashboard.py +0 -0
  41. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/dashboard_layouts.py +0 -0
  42. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/dashboard_react.css +0 -0
  43. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/dashboard_react.js +0 -0
  44. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/exporting.py +0 -0
  45. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/__init__.py +0 -0
  46. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/env.py +0 -0
  47. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/versions/__init__.py +0 -0
  48. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/versions/v0001_baseline.py +0 -0
  49. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/versions/v0002_minimize_subagents.py +0 -0
  50. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/versions/v0003_canonical_timestamps.py +0 -0
  51. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/versions/v0004_subagent_scope_freshness.py +0 -0
  52. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/versions/v0005_sync_receipts.py +0 -0
  53. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/versions/v0006_dashboard_layouts.py +0 -0
  54. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/migrations/versions/v0007_dashboard_layout_revision.py +0 -0
  55. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/models.py +0 -0
  56. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/py.typed +0 -0
  57. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/qualifications.py +0 -0
  58. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/reporting.py +0 -0
  59. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/reporting_api.py +0 -0
  60. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/retention.py +0 -0
  61. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/schema.py +0 -0
  62. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/snapshot_extraction.py +0 -0
  63. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/snapshot_files.py +0 -0
  64. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/storage.py +0 -0
  65. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/sync.py +0 -0
  66. {cli_consumption-0.4.3 → cli_consumption-0.4.5}/src/cli_consumption/timestamps.py +0 -0
@@ -6,6 +6,21 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.5] - 2026-09-11
10
+
11
+ ### Added
12
+
13
+ - `serve --front` now launches the FastAPI service and a bundled production Next.js
14
+ dashboard from the Python distribution, without requiring a repository checkout or
15
+ npm installation at runtime.
16
+
17
+ ## [0.4.4] - 2026-09-08
18
+
19
+ ### Fixed
20
+
21
+ - Mistral Vibe now counts each persisted assistant message as a model call while
22
+ retaining cumulative session tokens as one unsplit aggregate.
23
+
9
24
  ## [0.4.3] - 2026-09-05
10
25
 
11
26
  ### Added
@@ -194,7 +209,9 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
194
209
 
195
210
  - Refreshed the provider guide for the first minor release ([#26]).
196
211
 
197
- [Unreleased]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.3...HEAD
212
+ [Unreleased]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.5...HEAD
213
+ [0.4.5]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.4...v0.4.5
214
+ [0.4.4]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.3...v0.4.4
198
215
  [0.4.3]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.2...v0.4.3
199
216
  [0.4.2]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.1...v0.4.2
200
217
  [0.4.1]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.0...v0.4.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cli-consumption
3
- Version: 0.4.3
3
+ Version: 0.4.5
4
4
  Summary: Analyze and consolidate AI coding CLI consumption across machines.
5
5
  Project-URL: Homepage, https://github.com/Guillaume-Lombardo/cli-consumption
6
6
  Project-URL: Documentation, https://github.com/Guillaume-Lombardo/cli-consumption#readme
@@ -23,14 +23,14 @@ Classifier: Programming Language :: Python :: 3.14
23
23
  Classifier: Topic :: Software Development :: Quality Assurance
24
24
  Requires-Python: >=3.11
25
25
  Requires-Dist: alembic>=1.14
26
+ Requires-Dist: fastapi>=0.115
26
27
  Requires-Dist: pydantic>=2.10
27
28
  Requires-Dist: sqlalchemy>=2.0
28
29
  Requires-Dist: typer>=0.15
30
+ Requires-Dist: uvicorn>=0.34
29
31
  Provides-Extra: postgres
30
32
  Requires-Dist: psycopg[binary]>=3.2; extra == 'postgres'
31
33
  Provides-Extra: server
32
- Requires-Dist: fastapi>=0.115; extra == 'server'
33
- Requires-Dist: uvicorn>=0.34; extra == 'server'
34
34
  Provides-Extra: snapshots
35
35
  Requires-Dist: cryptography>=45; extra == 'snapshots'
36
36
  Provides-Extra: sync
@@ -72,16 +72,14 @@ CLI Consumption requires Python 3.11 or newer. Run it directly from PyPI with `u
72
72
  uv tool run cli-consumption providers
73
73
  ```
74
74
 
75
- The default package covers local collection, SQLite storage, and exports. Install only
76
- the optional runtime capabilities you use:
75
+ The default package covers local collection, SQLite storage, exports, and the local
76
+ HTTP server. Install only the optional runtime capabilities you use:
77
77
 
78
78
  - `cli-consumption[sync]` for the sync client;
79
- - `cli-consumption[server]` for the collector service;
80
79
  - `cli-consumption[postgres]` for PostgreSQL;
81
80
  - `cli-consumption[snapshots]` for signed, compressed offline snapshot files.
82
81
 
83
- Extras can be combined, for example `cli-consumption[server,postgres]` on a central
84
- collector.
82
+ Extras can be combined, for example `cli-consumption[postgres]` on a central collector.
85
83
 
86
84
  ## Quick start
87
85
 
@@ -100,7 +98,7 @@ tables are generated only when `--csv` is passed.
100
98
  Dashboard development lives in the locked npm workspace under `packages/`. It builds
101
99
  provider-neutral ESM analytics, shared React presentation primitives, and deterministic
102
100
  React/Tailwind browser assets that Python embeds in the wheel. The React runtime is the
103
- only offline renderer; installing or using the Python CLI does not require Node.js.
101
+ only offline renderer; collection and offline reporting do not require Node.js.
104
102
 
105
103
  The authenticated persistent dashboard lives in `apps/web/`. It reads the same
106
104
  minimized reporting contract through a server-side Next.js BFF: the browser never
@@ -186,6 +184,11 @@ Run `uv run cli-consumption COMMAND --help` for every option. The
186
184
  [usage guide](https://github.com/Guillaume-Lombardo/cli-consumption/blob/main/docs/usage.md)
187
185
  contains copy-ready examples.
188
186
 
187
+ To run the API and bundled persistent dashboard together, install Node.js 20.9 or
188
+ newer and use `uv tool run cli-consumption serve --front`. The command asks for a
189
+ dashboard password when `CLI_CONSUMPTION_DASHBOARD_PASSWORD` is not set; it does not
190
+ require a repository checkout or npm install.
191
+
189
192
  ## Documentation
190
193
 
191
194
  - [Usage and operations](https://github.com/Guillaume-Lombardo/cli-consumption/blob/main/docs/usage.md)
@@ -33,16 +33,14 @@ CLI Consumption requires Python 3.11 or newer. Run it directly from PyPI with `u
33
33
  uv tool run cli-consumption providers
34
34
  ```
35
35
 
36
- The default package covers local collection, SQLite storage, and exports. Install only
37
- the optional runtime capabilities you use:
36
+ The default package covers local collection, SQLite storage, exports, and the local
37
+ HTTP server. Install only the optional runtime capabilities you use:
38
38
 
39
39
  - `cli-consumption[sync]` for the sync client;
40
- - `cli-consumption[server]` for the collector service;
41
40
  - `cli-consumption[postgres]` for PostgreSQL;
42
41
  - `cli-consumption[snapshots]` for signed, compressed offline snapshot files.
43
42
 
44
- Extras can be combined, for example `cli-consumption[server,postgres]` on a central
45
- collector.
43
+ Extras can be combined, for example `cli-consumption[postgres]` on a central collector.
46
44
 
47
45
  ## Quick start
48
46
 
@@ -61,7 +59,7 @@ tables are generated only when `--csv` is passed.
61
59
  Dashboard development lives in the locked npm workspace under `packages/`. It builds
62
60
  provider-neutral ESM analytics, shared React presentation primitives, and deterministic
63
61
  React/Tailwind browser assets that Python embeds in the wheel. The React runtime is the
64
- only offline renderer; installing or using the Python CLI does not require Node.js.
62
+ only offline renderer; collection and offline reporting do not require Node.js.
65
63
 
66
64
  The authenticated persistent dashboard lives in `apps/web/`. It reads the same
67
65
  minimized reporting contract through a server-side Next.js BFF: the browser never
@@ -147,6 +145,11 @@ Run `uv run cli-consumption COMMAND --help` for every option. The
147
145
  [usage guide](https://github.com/Guillaume-Lombardo/cli-consumption/blob/main/docs/usage.md)
148
146
  contains copy-ready examples.
149
147
 
148
+ To run the API and bundled persistent dashboard together, install Node.js 20.9 or
149
+ newer and use `uv tool run cli-consumption serve --front`. The command asks for a
150
+ dashboard password when `CLI_CONSUMPTION_DASHBOARD_PASSWORD` is not set; it does not
151
+ require a repository checkout or npm install.
152
+
150
153
  ## Documentation
151
154
 
152
155
  - [Usage and operations](https://github.com/Guillaume-Lombardo/cli-consumption/blob/main/docs/usage.md)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cli-consumption"
7
- version = "0.4.3"
7
+ version = "0.4.5"
8
8
  description = "Analyze and consolidate AI coding CLI consumption across machines."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -25,14 +25,16 @@ classifiers = [
25
25
  ]
26
26
  dependencies = [
27
27
  "alembic>=1.14",
28
+ "fastapi>=0.115",
28
29
  "pydantic>=2.10",
29
30
  "sqlalchemy>=2.0",
30
31
  "typer>=0.15",
32
+ "uvicorn>=0.34",
31
33
  ]
32
34
 
33
35
  [project.optional-dependencies]
34
36
  postgres = ["psycopg[binary]>=3.2"]
35
- server = ["fastapi>=0.115", "uvicorn>=0.34"]
37
+ server = []
36
38
  sync = ["httpx>=0.27"]
37
39
  snapshots = [
38
40
  "cryptography>=45",
@@ -108,6 +110,7 @@ select = ["E", "F", "I", "UP", "B", "SIM", "RUF", "S"]
108
110
  "src/cli_consumption/api.py" = ["S608"]
109
111
  "src/cli_consumption/cli.py" = ["S107"]
110
112
  "src/cli_consumption/dashboard.py" = ["E501", "S608"]
113
+ "src/cli_consumption/frontend.py" = ["S603"]
111
114
  "tests/**" = ["S101"]
112
115
  "tests/smoke_minimal_install.py" = ["S603", "S607"]
113
116
  "tests/test_codex_adapter.py" = ["S608"]
@@ -84,6 +84,7 @@ class MistralVibeAdapter:
84
84
  started_at = timestamp(source.metadata.get("start_time"))
85
85
  ended_at = timestamp(source.metadata.get("end_time"))
86
86
  active_turn: dict[str, Any] | None = None
87
+ assistant_turn_ids: list[str | None] = []
87
88
  turn_number = 0
88
89
  tool_number = 0
89
90
  compaction_number = 0
@@ -125,6 +126,8 @@ class MistralVibeAdapter:
125
126
  continue
126
127
  if active_turn is not None:
127
128
  active_turn["status"] = "completed"
129
+ active_turn["model_calls"] += 1
130
+ assistant_turn_ids.append(active_turn["id"] if active_turn else None)
128
131
  raw_tool_calls = message.get("tool_calls")
129
132
  if not isinstance(raw_tool_calls, list):
130
133
  continue
@@ -160,20 +163,25 @@ class MistralVibeAdapter:
160
163
  )
161
164
  config = mapping(source.metadata.get("config"))
162
165
  model = label(config.get("active_model"), 255) or "unknown"
163
- model_calls = 0
164
166
  totals = empty_tokens()
165
- if usage["total_tokens"] > 0 or counter(stats.get("steps")) > 0:
166
- model_calls = 1
167
+ if usage["total_tokens"] > 0:
167
168
  add_tokens(totals, usage)
169
+
170
+ for sequence, turn_id in enumerate(assistant_turn_ids, 1):
171
+ call_usage = (
172
+ usage if sequence == len(assistant_turn_ids) else empty_tokens()
173
+ )
168
174
  snapshot.model_calls.append(
169
175
  {
170
- "id": f"{conversation_id}:model:aggregate",
176
+ "id": f"{conversation_id}:model:{sequence}",
171
177
  "conversation_id": conversation_id,
172
- "turn_id": None,
173
- "sequence": 1,
174
- "timestamp": iso(ended_at),
178
+ "turn_id": turn_id,
179
+ "sequence": sequence,
180
+ "timestamp": iso(ended_at)
181
+ if sequence == len(assistant_turn_ids)
182
+ else None,
175
183
  "model": model,
176
- **usage,
184
+ **call_usage,
177
185
  }
178
186
  )
179
187
 
@@ -193,9 +201,9 @@ class MistralVibeAdapter:
193
201
  "ended_at": iso(ended_at),
194
202
  "duration_seconds": _duration(started_at, ended_at),
195
203
  "source": "local-session-json",
196
- "models": [model] if model_calls else [],
204
+ "models": [model] if assistant_turn_ids else [],
197
205
  "iterations": turn_number,
198
- "model_calls": model_calls,
206
+ "model_calls": len(assistant_turn_ids),
199
207
  "tool_calls": tool_number,
200
208
  "compactions": compaction_number,
201
209
  "event_count": source.event_count,
@@ -346,10 +346,12 @@ ADAPTER_SPECS = (
346
346
  token_semantics="conversation-aggregate",
347
347
  qualification=_qualification(
348
348
  "mistral-vibe",
349
- "CLI 2.24.5",
349
+ "CLI 2.25.0",
350
350
  "session meta JSON and messages JSONL",
351
- "https://github.com/mistralai/mistral-vibe/tree/v2.24.5",
352
- "Session aggregates only; no timing or historical model attribution.",
351
+ "https://github.com/mistralai/mistral-vibe/tree/v2.25.0",
352
+ "Assistant-message call counts with session-aggregate tokens; no timing "
353
+ "or historical model attribution.",
354
+ qualified_on="2026-09-08",
353
355
  ),
354
356
  ),
355
357
  AdapterSpec(
@@ -3,11 +3,15 @@ from __future__ import annotations
3
3
  import json
4
4
  import os
5
5
  import platform
6
+ import secrets
7
+ import subprocess
6
8
  import tempfile
7
- from collections.abc import Iterator
9
+ import threading
10
+ from collections.abc import Callable, Iterator
8
11
  from datetime import UTC, datetime, timedelta
9
12
  from pathlib import Path
10
- from typing import Annotated, Never, TextIO, TypedDict
13
+ from types import ModuleType
14
+ from typing import Annotated, Never, Protocol, TextIO, TypedDict, cast
11
15
 
12
16
  import typer
13
17
  from sqlalchemy.engine import Engine
@@ -79,6 +83,12 @@ class IncrementalIngestion(TypedDict):
79
83
  batch_duplicates: int
80
84
 
81
85
 
86
+ class _ServerProcess(Protocol):
87
+ should_exit: bool
88
+
89
+ def run(self) -> None: ...
90
+
91
+
82
92
  class _BoundedStagingWriter:
83
93
  """Count UTF-8 metadata bytes before writing them to strict staging."""
84
94
 
@@ -1038,17 +1048,42 @@ def serve(
1038
1048
  help="Environment variable containing the dashboard layout mutation token."
1039
1049
  ),
1040
1050
  ] = "CLI_CONSUMPTION_LAYOUT_TOKEN",
1051
+ front: Annotated[
1052
+ bool,
1053
+ typer.Option(help="Also run the bundled persistent Next.js dashboard."),
1054
+ ] = False,
1055
+ front_host: Annotated[
1056
+ str,
1057
+ typer.Option(help="Loopback address for the bundled dashboard."),
1058
+ ] = "127.0.0.1",
1059
+ front_port: Annotated[
1060
+ int,
1061
+ typer.Option(help="Port for the bundled dashboard."),
1062
+ ] = 3000,
1063
+ front_password_env: Annotated[
1064
+ str,
1065
+ typer.Option(
1066
+ help="Environment variable containing the dashboard login password."
1067
+ ),
1068
+ ] = "CLI_CONSUMPTION_DASHBOARD_PASSWORD",
1041
1069
  ) -> None:
1042
- """Run the optional central HTTP collector."""
1070
+ """Run the central HTTP collector and optionally its dashboard."""
1043
1071
  try:
1044
1072
  import uvicorn
1045
1073
 
1046
1074
  from cli_consumption.api import create_app
1047
1075
  except ModuleNotFoundError:
1048
1076
  raise typer.BadParameter(
1049
- "serve requires optional dependencies; install cli-consumption[server]"
1077
+ "serve requires FastAPI and Uvicorn; reinstall cli-consumption"
1050
1078
  ) from None
1051
1079
 
1080
+ if front and front_host not in {"127.0.0.1", "localhost", "::1"}:
1081
+ raise typer.BadParameter("--front-host must be a loopback address.")
1082
+ if front and port == front_port:
1083
+ raise typer.BadParameter("--port and --front-port must be different.")
1084
+ if not 1 <= port <= 65535 or not 1 <= front_port <= 65535:
1085
+ raise typer.BadParameter("Server ports must be between 1 and 65535.")
1086
+
1052
1087
  token = os.environ.get(token_env)
1053
1088
  read_token = os.environ.get(read_token_env)
1054
1089
  export_token = os.environ.get(export_token_env)
@@ -1057,6 +1092,10 @@ def serve(
1057
1092
  raise typer.BadParameter(
1058
1093
  "Configured token environment variables must be non-empty."
1059
1094
  )
1095
+ if front:
1096
+ read_token = read_token or secrets.token_urlsafe(32)
1097
+ export_token = export_token or secrets.token_urlsafe(32)
1098
+ layout_token = layout_token or secrets.token_urlsafe(32)
1060
1099
  if (
1061
1100
  token is None
1062
1101
  and read_token is None
@@ -1090,11 +1129,153 @@ def serve(
1090
1129
  layout_token=layout_token,
1091
1130
  )
1092
1131
  )
1093
- uvicorn.run(application, host=host, port=port, access_log=False)
1132
+ if not front:
1133
+ uvicorn.run(application, host=host, port=port, access_log=False)
1134
+ return
1135
+ _serve_with_frontend(
1136
+ uvicorn,
1137
+ application,
1138
+ host=host,
1139
+ port=port,
1140
+ front_host=front_host,
1141
+ front_port=front_port,
1142
+ front_password_env=front_password_env,
1143
+ read_token=cast(str, read_token),
1144
+ export_token=cast(str, export_token),
1145
+ layout_token=cast(str, layout_token),
1146
+ )
1094
1147
  finally:
1095
1148
  engine.dispose()
1096
1149
 
1097
1150
 
1151
+ def _serve_with_frontend(
1152
+ uvicorn: ModuleType,
1153
+ application: object,
1154
+ *,
1155
+ host: str,
1156
+ port: int,
1157
+ front_host: str,
1158
+ front_port: int,
1159
+ front_password_env: str,
1160
+ read_token: str,
1161
+ export_token: str,
1162
+ layout_token: str,
1163
+ ) -> None:
1164
+ from cli_consumption.frontend import (
1165
+ FrontendRuntimeError,
1166
+ find_node_runtime,
1167
+ frontend_environment,
1168
+ materialize_frontend_runtime,
1169
+ start_frontend,
1170
+ stop_frontend,
1171
+ )
1172
+
1173
+ password = os.environ.get(front_password_env)
1174
+ if password is None:
1175
+ password = typer.prompt("Dashboard password", hide_input=True)
1176
+ if len(password) < 12:
1177
+ raise typer.BadParameter(
1178
+ f"{front_password_env} must contain at least 12 characters."
1179
+ )
1180
+ session_secret = os.environ.get("CLI_CONSUMPTION_SESSION_SECRET")
1181
+ if session_secret is not None and len(session_secret.encode("utf-8")) < 32:
1182
+ raise typer.BadParameter(
1183
+ "CLI_CONSUMPTION_SESSION_SECRET must contain at least 32 bytes."
1184
+ )
1185
+ session_secret = session_secret or secrets.token_urlsafe(32)
1186
+ api_host = {
1187
+ "0.0.0.0": "127.0.0.1", # noqa: S104 - converts an explicit bind address
1188
+ "::": "::1",
1189
+ }.get(host, host)
1190
+ api_origin = _http_origin(api_host, port)
1191
+ front_origin = _http_origin(front_host, front_port)
1192
+
1193
+ try:
1194
+ node = find_node_runtime()
1195
+ environment = frontend_environment(
1196
+ api_url=api_origin,
1197
+ origin=front_origin,
1198
+ host=front_host,
1199
+ port=front_port,
1200
+ password=password,
1201
+ read_token=read_token,
1202
+ export_token=export_token,
1203
+ layout_token=layout_token,
1204
+ session_secret=session_secret,
1205
+ )
1206
+ with materialize_frontend_runtime() as runtime:
1207
+ frontend = start_frontend(node, runtime, environment)
1208
+ typer.echo(f"Dashboard: {front_origin}")
1209
+ _supervise_servers(
1210
+ uvicorn,
1211
+ application,
1212
+ frontend,
1213
+ host=host,
1214
+ port=port,
1215
+ stop_frontend=stop_frontend,
1216
+ )
1217
+ except FrontendRuntimeError as error:
1218
+ messages = {
1219
+ "frontend_node_missing": "serve --front requires Node.js 20.9 or newer.",
1220
+ "frontend_node_invalid": "The Node.js runtime could not be validated.",
1221
+ "frontend_node_unsupported": (
1222
+ "serve --front requires Node.js 20.9 or newer."
1223
+ ),
1224
+ "frontend_runtime_missing": "The bundled dashboard runtime is missing.",
1225
+ "frontend_runtime_invalid": "The bundled dashboard runtime is invalid.",
1226
+ "frontend_start_failed": "The bundled dashboard could not be started.",
1227
+ "frontend_stop_failed": "The bundled dashboard could not be stopped.",
1228
+ }
1229
+ typer.echo(f"Error: {messages[str(error)]}", err=True)
1230
+ raise typer.Exit(1) from None
1231
+
1232
+
1233
+ def _supervise_servers(
1234
+ uvicorn: ModuleType,
1235
+ application: object,
1236
+ frontend: subprocess.Popen[bytes],
1237
+ *,
1238
+ host: str,
1239
+ port: int,
1240
+ stop_frontend: Callable[[subprocess.Popen[bytes]], None],
1241
+ ) -> None:
1242
+ server = uvicorn.Server(
1243
+ uvicorn.Config(application, host=host, port=port, access_log=False)
1244
+ )
1245
+ failures: list[BaseException] = []
1246
+
1247
+ def run_backend() -> None:
1248
+ try:
1249
+ server.run()
1250
+ except BaseException as error:
1251
+ failures.append(error)
1252
+
1253
+ backend = threading.Thread(target=run_backend, name="cli-consumption-api")
1254
+ backend.start()
1255
+ frontend_failed = False
1256
+ try:
1257
+ while backend.is_alive() and frontend.poll() is None:
1258
+ backend.join(timeout=0.1)
1259
+ frontend_failed = frontend.poll() is not None and backend.is_alive()
1260
+ except KeyboardInterrupt:
1261
+ pass
1262
+ finally:
1263
+ server.should_exit = True
1264
+ stop_frontend(frontend)
1265
+ backend.join(timeout=10)
1266
+ if backend.is_alive() or failures:
1267
+ typer.echo("Error: The API server stopped unexpectedly.", err=True)
1268
+ raise typer.Exit(1)
1269
+ if frontend_failed:
1270
+ typer.echo("Error: The dashboard server stopped unexpectedly.", err=True)
1271
+ raise typer.Exit(1)
1272
+
1273
+
1274
+ def _http_origin(host: str, port: int) -> str:
1275
+ rendered_host = f"[{host}]" if ":" in host else host
1276
+ return f"http://{rendered_host}:{port}"
1277
+
1278
+
1098
1279
  def _collect_incrementally(
1099
1280
  provider: str,
1100
1281
  source_values: list[str] | None,
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+ import zipfile
10
+ from collections.abc import Iterator, Mapping
11
+ from contextlib import contextmanager
12
+ from importlib.resources import files
13
+ from pathlib import Path, PurePosixPath
14
+
15
+ MAX_RUNTIME_FILES = 5_000
16
+ MAX_RUNTIME_BYTES = 128 * 1024 * 1024
17
+ MAX_RUNTIME_ARCHIVE_BYTES = 32 * 1024 * 1024
18
+ MINIMUM_NODE = (20, 9)
19
+
20
+
21
+ class FrontendRuntimeError(RuntimeError):
22
+ """A fixed, content-free dashboard runtime failure."""
23
+
24
+
25
+ def find_node_runtime() -> str:
26
+ node = shutil.which("node")
27
+ if node is None:
28
+ raise FrontendRuntimeError("frontend_node_missing")
29
+ try:
30
+ result = subprocess.run(
31
+ [node, "--version"],
32
+ check=False,
33
+ capture_output=True,
34
+ text=True,
35
+ timeout=5,
36
+ )
37
+ except (OSError, subprocess.SubprocessError):
38
+ raise FrontendRuntimeError("frontend_node_invalid") from None
39
+ match = re.fullmatch(r"v(\d+)\.(\d+)\.\d+\s*", result.stdout)
40
+ if result.returncode != 0 or match is None:
41
+ raise FrontendRuntimeError("frontend_node_invalid")
42
+ if (int(match.group(1)), int(match.group(2))) < MINIMUM_NODE:
43
+ raise FrontendRuntimeError("frontend_node_unsupported")
44
+ return node
45
+
46
+
47
+ @contextmanager
48
+ def materialize_frontend_runtime() -> Iterator[Path]:
49
+ try:
50
+ payload = files("cli_consumption").joinpath("web_runtime.zip").read_bytes()
51
+ except (FileNotFoundError, OSError):
52
+ raise FrontendRuntimeError("frontend_runtime_missing") from None
53
+
54
+ if len(payload) > MAX_RUNTIME_ARCHIVE_BYTES:
55
+ raise FrontendRuntimeError("frontend_runtime_invalid")
56
+
57
+ with tempfile.TemporaryDirectory(prefix="cli-consumption-web-") as directory:
58
+ destination = Path(directory)
59
+ try:
60
+ with zipfile.ZipFile(io.BytesIO(payload)) as archive:
61
+ members = archive.infolist()
62
+ if (
63
+ len(members) > MAX_RUNTIME_FILES
64
+ or len({member.filename for member in members}) != len(members)
65
+ or sum(member.file_size for member in members) > MAX_RUNTIME_BYTES
66
+ or any(not _safe_runtime_member(member) for member in members)
67
+ ):
68
+ raise FrontendRuntimeError("frontend_runtime_invalid")
69
+ for member in members:
70
+ target = destination.joinpath(*PurePosixPath(member.filename).parts)
71
+ target.parent.mkdir(parents=True, exist_ok=True)
72
+ with archive.open(member) as source, target.open("wb") as output:
73
+ shutil.copyfileobj(source, output)
74
+ except (OSError, zipfile.BadZipFile):
75
+ raise FrontendRuntimeError("frontend_runtime_invalid") from None
76
+ runtime = destination / "runtime" / "apps" / "web"
77
+ if not (runtime / "server.js").is_file():
78
+ raise FrontendRuntimeError("frontend_runtime_invalid")
79
+ yield runtime
80
+
81
+
82
+ def frontend_environment(
83
+ *,
84
+ api_url: str,
85
+ origin: str,
86
+ host: str,
87
+ port: int,
88
+ password: str,
89
+ read_token: str,
90
+ export_token: str,
91
+ layout_token: str,
92
+ session_secret: str,
93
+ ) -> dict[str, str]:
94
+ environment = {
95
+ name: value
96
+ for name in ("LANG", "LC_ALL", "PATH", "TMPDIR")
97
+ if (value := os.environ.get(name)) is not None
98
+ }
99
+ environment.update(
100
+ {
101
+ "CLI_CONSUMPTION_API_URL": api_url,
102
+ "CLI_CONSUMPTION_DASHBOARD_ORIGIN": origin,
103
+ "CLI_CONSUMPTION_DASHBOARD_PASSWORD": password,
104
+ "CLI_CONSUMPTION_EXPORT_TOKEN": export_token,
105
+ "CLI_CONSUMPTION_LAYOUT_TOKEN": layout_token,
106
+ "CLI_CONSUMPTION_READ_TOKEN": read_token,
107
+ "CLI_CONSUMPTION_SESSION_SECRET": session_secret,
108
+ "HOSTNAME": host,
109
+ "NEXT_TELEMETRY_DISABLED": "1",
110
+ "PORT": str(port),
111
+ }
112
+ )
113
+ return environment
114
+
115
+
116
+ def start_frontend(
117
+ node: str, runtime: Path, environment: Mapping[str, str]
118
+ ) -> subprocess.Popen[bytes]:
119
+ try:
120
+ return subprocess.Popen(
121
+ [node, "server.js"],
122
+ cwd=runtime,
123
+ env=dict(environment),
124
+ )
125
+ except OSError:
126
+ raise FrontendRuntimeError("frontend_start_failed") from None
127
+
128
+
129
+ def stop_frontend(process: subprocess.Popen[bytes]) -> None:
130
+ try:
131
+ if process.poll() is not None:
132
+ return
133
+ process.terminate()
134
+ try:
135
+ process.wait(timeout=5)
136
+ except subprocess.TimeoutExpired:
137
+ process.kill()
138
+ process.wait(timeout=5)
139
+ except (OSError, subprocess.SubprocessError):
140
+ raise FrontendRuntimeError("frontend_stop_failed") from None
141
+
142
+
143
+ def _safe_runtime_member(member: zipfile.ZipInfo) -> bool:
144
+ path = PurePosixPath(member.filename)
145
+ file_type = member.external_attr >> 16 & 0o170000
146
+ return (
147
+ not member.is_dir()
148
+ and "\\" not in member.filename
149
+ and not path.is_absolute()
150
+ and path.parts[:1] == ("runtime",)
151
+ and ".." not in path.parts
152
+ and file_type in {0, 0o100000}
153
+ )
File without changes
File without changes