vrex-flow-engine 0.2.0__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,84 @@
1
+ """Standalone WebSocket server on :9223 for the Chrome extension bridge.
2
+
3
+ Kept separate from the FastAPI :8101 app to match flowkit's pattern — the
4
+ extension's `background.js` connects to `ws://127.0.0.1:9223` only.
5
+
6
+ Multi-instance protocol: the extension MUST send a ``register_instance``
7
+ message as its FIRST message before any other traffic. The server registers
8
+ (or retrieves) the PoolInstance for that account_id and binds the WS to it.
9
+ All subsequent messages are routed to that instance's FlowClient.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import logging
16
+
17
+ import websockets
18
+
19
+ from flow_engine.config import EXTENSION_WS_PORT, WS_HOST
20
+ from flow_engine.pool import pool
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ async def _handler(websocket) -> None:
26
+ # First message must be register_instance; close 1008 on protocol violation.
27
+ try:
28
+ raw = await asyncio.wait_for(websocket.recv(), timeout=10.0)
29
+ data = json.loads(raw)
30
+ except Exception:
31
+ await websocket.close(1008, "expected register_instance as first message")
32
+ return
33
+
34
+ if data.get("type") != "register_instance":
35
+ await websocket.close(1008, "first message must be register_instance")
36
+ return
37
+
38
+ account_id = data.get("accountId")
39
+ if not isinstance(account_id, str) or not account_id:
40
+ await websocket.close(1008, "accountId required in register_instance")
41
+ return
42
+
43
+ instance = pool.register(account_id)
44
+ instance.client.set_extension(websocket)
45
+ logger.info(
46
+ "instance %s connected from %s",
47
+ account_id,
48
+ getattr(websocket, "remote_address", "?"),
49
+ )
50
+
51
+ # Hand the extension the per-instance callback secret.
52
+ try:
53
+ await websocket.send(
54
+ json.dumps(
55
+ {"type": "callback_secret", "secret": instance.client.callback_secret}
56
+ )
57
+ )
58
+ except Exception: # noqa: BLE001
59
+ logger.exception("failed to send callback_secret to %s", account_id)
60
+
61
+ try:
62
+ async for raw in websocket:
63
+ try:
64
+ msg = json.loads(raw)
65
+ except json.JSONDecodeError:
66
+ logger.warning("invalid JSON from extension %s", account_id)
67
+ continue
68
+ try:
69
+ await instance.client.handle_message(msg)
70
+ except Exception: # noqa: BLE001
71
+ logger.exception("error handling message from %s", account_id)
72
+ except websockets.ConnectionClosed:
73
+ pass
74
+ finally:
75
+ instance.client.clear_extension()
76
+ logger.info("instance %s disconnected", account_id)
77
+
78
+
79
+ async def run_ws_server() -> None:
80
+ async with websockets.serve(_handler, WS_HOST, EXTENSION_WS_PORT):
81
+ logger.info(
82
+ "WebSocket server listening on ws://%s:%d", WS_HOST, EXTENSION_WS_PORT
83
+ )
84
+ await asyncio.Future() # run forever
flow_engine/catalog.py ADDED
@@ -0,0 +1,332 @@
1
+ """Dynamic model catalog, sourced from Flow's ``flow.projectInitialData``.
2
+
3
+ Instead of hardcoding model keys (which Google rotates), flow_engine fetches the
4
+ live ``modelConfig`` once and resolves the exact ``videoModelKey`` for a given
5
+ (family, mode, aspect, duration) against the user's service tier — picking the
6
+ variant whose ``creditMapping[tier]`` is actually a number (available), not
7
+ ``"UNAVAILABLE"``.
8
+
9
+ Mode is read from each usage's ``requirements`` (data-driven), not the key
10
+ string:
11
+ REFERENCES → r2v · END_IMAGE → interpolation · START_IMAGE → i2v ·
12
+ EXTENSION → extension · UPSAMPLE* → upsample · VIDEO_EDIT → edit · else t2v
13
+
14
+ Cached process-wide; call ``refresh()`` (or hit /v1/models?refresh=1) to reload.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import logging
21
+ import time
22
+ from pathlib import Path
23
+ from typing import Any, Optional
24
+
25
+ from flow_engine.bridge.flow_sdk import (
26
+ VIDEO_EDIT_URL,
27
+ VIDEO_EXTEND_URL,
28
+ VIDEO_I2V_URL,
29
+ VIDEO_R2V_URL,
30
+ VIDEO_T2V_URL,
31
+ FlowSDK,
32
+ )
33
+ from flow_engine.config import CATALOG_TTL_S, STORAGE_DIR
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ _CACHE_FILE = STORAGE_DIR / "catalog.json"
38
+
39
+ # Endpoints we can actually dispatch today (body shapes verified). interpolation
40
+ # / upsample still need a captured request before we wire them — resolve()
41
+ # returns endpoint=None for those so callers 400 clearly.
42
+ _MODE_ENDPOINTS: dict[str, str] = {
43
+ "t2v": VIDEO_T2V_URL,
44
+ "i2v": VIDEO_I2V_URL,
45
+ "r2v": VIDEO_R2V_URL,
46
+ "extension": VIDEO_EXTEND_URL,
47
+ "edit": VIDEO_EDIT_URL,
48
+ }
49
+
50
+
51
+ class _Catalog:
52
+ def __init__(self) -> None:
53
+ self._loaded_at: Optional[float] = None
54
+ self._raw_model_config: dict[str, Any] = {}
55
+ self._video_families: list[dict[str, Any]] = []
56
+ self._image_families: list[dict[str, Any]] = []
57
+ self._audio_voices: list[dict[str, Any]] = []
58
+ self._service_tier: Optional[str] = None
59
+ self._lock = asyncio.Lock()
60
+
61
+ @property
62
+ def loaded(self) -> bool:
63
+ return self._loaded_at is not None
64
+
65
+ def _fresh(self) -> bool:
66
+ return (
67
+ self._loaded_at is not None
68
+ and (time.time() - self._loaded_at) < CATALOG_TTL_S
69
+ )
70
+
71
+ @property
72
+ def service_tier(self) -> Optional[str]:
73
+ return self._service_tier
74
+
75
+ async def ensure_loaded(
76
+ self, sdk: FlowSDK, project_id: str, *, force: bool = False
77
+ ) -> Optional[str]:
78
+ """Load the catalog if the in-memory copy is missing/stale. Tries the
79
+ on-disk cache before hitting Flow's heavy projectInitialData call;
80
+ refreshes only once per ``CATALOG_TTL_S`` (default 1 day). Returns an
81
+ error string on failure, else None."""
82
+ if self._fresh() and not force:
83
+ return None
84
+ async with self._lock:
85
+ if self._fresh() and not force:
86
+ return None
87
+ # On-disk cache (survives restarts) — use if still within TTL.
88
+ if not force and self._load_from_disk():
89
+ return None
90
+ result = await sdk.get_project_initial_data(project_id)
91
+ if result.get("error"):
92
+ return str(result["error"])[:200]
93
+ self._apply(result.get("data") or {})
94
+ self._loaded_at = time.time()
95
+ self._save_to_disk()
96
+ logger.info(
97
+ "catalog fetched from Flow: %d video families, %d image "
98
+ "families, %d voices, tier=%s",
99
+ len(self._video_families), len(self._image_families),
100
+ len(self._audio_voices), self._service_tier,
101
+ )
102
+ return None
103
+
104
+ # Families exposed via /v1/models and accepted at dispatch time.
105
+ # Requests for any other family are rejected at the handler level (HTTP 400).
106
+ _ALLOWED_VIDEO_FAMILIES: frozenset[str] = frozenset({"veo_3_1_lite_low_priority"})
107
+ _ALLOWED_IMAGE_FAMILIES: frozenset[str] = frozenset({"NANO_BANANA_2"})
108
+
109
+ def _apply(self, data: dict[str, Any]) -> None:
110
+ mc = data.get("modelConfig") or {}
111
+ self._raw_model_config = mc
112
+ # Filter to pinned families immediately so /v1/models only surfaces them.
113
+ all_video = mc.get("videoModelFamilies") or []
114
+ all_image = mc.get("imageModelFamilies") or []
115
+ self._video_families = [
116
+ f for f in all_video if f.get("id") in self._ALLOWED_VIDEO_FAMILIES
117
+ ]
118
+ self._image_families = [
119
+ f for f in all_image if f.get("id") in self._ALLOWED_IMAGE_FAMILIES
120
+ ]
121
+ self._service_tier = (data.get("userData") or {}).get("serviceTier")
122
+ contents = data.get("projectContents") or {}
123
+ self._audio_voices = [
124
+ {
125
+ "id": v.get("mediaId"),
126
+ "name": (v.get("media") or {}).get("audio", {})
127
+ .get("generatedAudio", {}).get("name")
128
+ or v.get("workflowDisplayName"),
129
+ "description": (v.get("media") or {}).get("audio", {})
130
+ .get("generatedAudio", {}).get("description"),
131
+ }
132
+ for v in (contents.get("externalReferenceMedia") or [])
133
+ if v.get("mediaType") == "AUDIO" and v.get("mediaId")
134
+ ]
135
+
136
+ def _load_from_disk(self) -> bool:
137
+ """Populate from the on-disk cache when present and within TTL."""
138
+ try:
139
+ blob = json.loads(_CACHE_FILE.read_text())
140
+ except (OSError, ValueError):
141
+ return False
142
+ fetched_at = blob.get("fetched_at")
143
+ if not isinstance(fetched_at, (int, float)):
144
+ return False
145
+ if (time.time() - fetched_at) >= CATALOG_TTL_S:
146
+ return False
147
+ all_video = blob.get("video_families") or []
148
+ all_image = blob.get("image_families") or []
149
+ self._video_families = [
150
+ f for f in all_video if f.get("id") in self._ALLOWED_VIDEO_FAMILIES
151
+ ]
152
+ self._image_families = [
153
+ f for f in all_image if f.get("id") in self._ALLOWED_IMAGE_FAMILIES
154
+ ]
155
+ self._audio_voices = blob.get("audio_voices") or []
156
+ self._service_tier = blob.get("service_tier")
157
+ self._loaded_at = fetched_at
158
+ logger.info(
159
+ "catalog loaded from disk cache (age %ds): %d video families",
160
+ int(time.time() - fetched_at), len(self._video_families),
161
+ )
162
+ return True
163
+
164
+ def _save_to_disk(self) -> None:
165
+ blob = {
166
+ "fetched_at": self._loaded_at,
167
+ "service_tier": self._service_tier,
168
+ "video_families": self._video_families,
169
+ "image_families": self._image_families,
170
+ "audio_voices": self._audio_voices,
171
+ }
172
+ try:
173
+ _CACHE_FILE.write_text(json.dumps(blob))
174
+ except OSError as exc:
175
+ logger.warning("could not persist catalog cache: %s", exc)
176
+
177
+ # ── resolution ─────────────────────────────────────────────────────────
178
+ @staticmethod
179
+ def _usage_mode(usage: dict[str, Any]) -> str:
180
+ reqs: set[str] = set()
181
+ for combo in usage.get("requirements") or []:
182
+ for r in combo:
183
+ reqs.add(r)
184
+ if any("UPSAMPLE" in r for r in reqs):
185
+ return "upsample"
186
+ if "VIDEO_REQUIREMENT_VIDEO_EDIT" in reqs:
187
+ return "edit"
188
+ if "VIDEO_REQUIREMENT_EXTENSION" in reqs:
189
+ return "extension"
190
+ if "VIDEO_REQUIREMENT_REFERENCES" in reqs:
191
+ return "r2v"
192
+ if "VIDEO_REQUIREMENT_END_IMAGE" in reqs:
193
+ return "interpolation"
194
+ if "VIDEO_REQUIREMENT_START_IMAGE" in reqs:
195
+ return "i2v"
196
+ if "VIDEO_REQUIREMENT_TEXT" in reqs:
197
+ return "t2v"
198
+ return "unknown"
199
+
200
+ @staticmethod
201
+ def _cost(usage: dict[str, Any], tier: Optional[str]) -> Optional[int]:
202
+ cm = usage.get("creditMapping") or {}
203
+ entry = cm.get(tier or "") or {}
204
+ cost = entry.get("cost")
205
+ return cost if isinstance(cost, int) else None
206
+
207
+ @staticmethod
208
+ def _aspect_short(aspect: str) -> str:
209
+ return "PORTRAIT" if "PORTRAIT" in (aspect or "") else "LANDSCAPE"
210
+
211
+ def _family(self, family_id: str) -> Optional[dict[str, Any]]:
212
+ for fam in self._video_families:
213
+ if fam.get("id") == family_id:
214
+ return fam
215
+ return None
216
+
217
+ def resolve_video(
218
+ self,
219
+ family_id: str,
220
+ mode: str,
221
+ aspect: str,
222
+ duration: Optional[int],
223
+ service_tier: Optional[str] = None,
224
+ ) -> dict[str, Any]:
225
+ """Resolve a concrete dispatch config for the selection.
226
+
227
+ Returns ``{key, endpoint, mode, video_length, max_image_inputs,
228
+ max_audio_references, outputs_audio, credits}`` or ``{error}``.
229
+ """
230
+ tier = service_tier or self._service_tier
231
+ fam = self._family(family_id)
232
+ if fam is None:
233
+ return {"error": f"unknown_video_model_family_{family_id}"}
234
+ aspect_short = self._aspect_short(aspect)
235
+
236
+ cands = [
237
+ u for u in (fam.get("usages") or [])
238
+ if self._usage_mode(u) == mode
239
+ and aspect_short in (u.get("supportedAspectRatios") or [])
240
+ ]
241
+ if not cands:
242
+ return {
243
+ "error": f"{family_id}_has_no_{mode}_usage_for_{aspect_short}"
244
+ }
245
+ if duration is not None:
246
+ dur = [u for u in cands if u.get("videoLengthSeconds") == duration]
247
+ cands = dur or cands
248
+ # Prefer the variant available to this tier (cost is a number).
249
+ avail = [u for u in cands if self._cost(u, tier) is not None]
250
+ pick = (avail or cands)[0]
251
+
252
+ endpoint = _MODE_ENDPOINTS.get(mode)
253
+ if endpoint is None:
254
+ return {
255
+ "error": (
256
+ f"mode_{mode}_not_dispatchable_yet "
257
+ f"(resolved key {pick.get('key')!r}; endpoint not captured)"
258
+ ),
259
+ "key": pick.get("key"),
260
+ "mode": mode,
261
+ }
262
+ spec = pick.get("inputSpec") or {}
263
+ return {
264
+ "key": pick.get("key"),
265
+ "endpoint": endpoint,
266
+ "mode": mode,
267
+ "video_length": pick.get("videoLengthSeconds"),
268
+ "max_image_inputs": pick.get("maxImageInputs"),
269
+ "max_audio_references": spec.get("maxAudioReferences"),
270
+ "outputs_audio": pick.get("outputsAudio"),
271
+ "credits": self._cost(pick, tier),
272
+ }
273
+
274
+ # ── catalog views (for /v1/models) ─────────────────────────────────────
275
+ def video_models_api(self, service_tier: Optional[str] = None) -> list[dict]:
276
+ tier = service_tier or self._service_tier
277
+ out: list[dict[str, Any]] = []
278
+ for fam in self._video_families:
279
+ usages = fam.get("usages") or []
280
+ modes: dict[str, dict[str, Any]] = {}
281
+ for u in usages:
282
+ mode = self._usage_mode(u)
283
+ cost = self._cost(u, tier)
284
+ if cost is None:
285
+ continue # not available to this tier
286
+ m = modes.setdefault(
287
+ mode,
288
+ {"mode": mode, "durations": set(), "aspects": set(),
289
+ "dispatchable": mode in _MODE_ENDPOINTS, "credits": cost},
290
+ )
291
+ if u.get("videoLengthSeconds"):
292
+ m["durations"].add(u["videoLengthSeconds"])
293
+ for a in u.get("supportedAspectRatios") or []:
294
+ m["aspects"].add(a)
295
+ m["credits"] = min(m["credits"], cost)
296
+ if not modes:
297
+ continue
298
+ out.append({
299
+ "id": fam.get("id"),
300
+ "object": "model",
301
+ "kind": "video",
302
+ "display_name": fam.get("displayName"),
303
+ "owned_by": "google-flow",
304
+ "modes": [
305
+ {**v, "durations": sorted(v["durations"]),
306
+ "aspects": sorted(v["aspects"])}
307
+ for v in modes.values()
308
+ ],
309
+ })
310
+ return out
311
+
312
+ def image_models_api(self, service_tier: Optional[str] = None) -> list[dict]:
313
+ tier = service_tier or self._service_tier
314
+ out: list[dict[str, Any]] = []
315
+ for fam in self._image_families:
316
+ usages = fam.get("usages") or []
317
+ keys = [u.get("key") for u in usages if u.get("key")]
318
+ out.append({
319
+ "id": fam.get("id"),
320
+ "object": "model",
321
+ "kind": "image",
322
+ "display_name": fam.get("displayName"),
323
+ "owned_by": "google-flow",
324
+ "keys": keys,
325
+ })
326
+ return out
327
+
328
+ def audio_voices(self) -> list[dict[str, Any]]:
329
+ return list(self._audio_voices)
330
+
331
+
332
+ catalog = _Catalog()
@@ -0,0 +1 @@
1
+ """flow-engine launcher CLI: setup wizard + supervisor + live dashboard."""
flow_engine/cli/app.py ADDED
@@ -0,0 +1,149 @@
1
+ """flow-engine CLI entrypoint.
2
+
3
+ Commands:
4
+ start supervise the engine + Cloudflare tunnel with a live dashboard (the one-liner)
5
+ serve run just the engine (foreground uvicorn) — for systemd / a custom supervisor
6
+ setup interactive wizard to store the engine key + tunnel token (~/.vrex-flow)
7
+ doctor check prerequisites and probe a running engine
8
+
9
+ Distributed Python-native: `uvx flow-engine start` (once published) or, from the
10
+ monorepo, `uvx --from 'git+ssh://…/multimodal.git#subdirectory=flow-engine' flow-engine start`.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import shutil
16
+ import sys
17
+
18
+ from rich.console import Console
19
+ from rich.prompt import Prompt
20
+
21
+ from . import health
22
+ from .config import (
23
+ CONFIG_FILE,
24
+ DEFAULT_PORT,
25
+ DEFAULT_PUBLIC_BASE_URL,
26
+ FlowConfig,
27
+ load_config,
28
+ mask,
29
+ save_config,
30
+ )
31
+
32
+ console = Console()
33
+
34
+
35
+ def _run_wizard(cfg: FlowConfig) -> FlowConfig:
36
+ """Prompt for the secrets/ports, keeping existing values as defaults."""
37
+ console.print("[bold]Vrex Flow Engine — setup[/] (stored at ~/.vrex-flow/config.json, chmod 600)\n")
38
+ key = Prompt.ask(
39
+ "Engine API key" + (f" [dim]({mask(cfg.engine_api_key)})[/]" if cfg.has_engine_key else ""),
40
+ password=True, default=cfg.engine_api_key, show_default=False,
41
+ )
42
+ token = Prompt.ask(
43
+ "Cloudflare tunnel token" + (f" [dim]({mask(cfg.tunnel_token)})[/]" if cfg.has_tunnel_token else ""),
44
+ password=True, default=cfg.tunnel_token, show_default=False,
45
+ )
46
+ port = Prompt.ask("HTTP port", default=str(cfg.http_port or DEFAULT_PORT))
47
+ base = Prompt.ask("Public base URL", default=cfg.public_base_url or DEFAULT_PUBLIC_BASE_URL)
48
+ cfg.engine_api_key = key.strip()
49
+ cfg.tunnel_token = token.strip()
50
+ cfg.http_port = int(port)
51
+ cfg.public_base_url = base.strip()
52
+ return cfg
53
+
54
+
55
+ def _cmd_setup(_: argparse.Namespace) -> int:
56
+ cfg = _run_wizard(load_config())
57
+ path = save_config(cfg)
58
+ console.print(f"\n[green]✓[/] saved to [bold]{path}[/]")
59
+ return 0
60
+
61
+
62
+ def _cmd_start(args: argparse.Namespace) -> int:
63
+ from .supervisor import Supervisor, preflight
64
+
65
+ cfg = load_config()
66
+ # CLI flags override stored/env values (non-interactive hosts).
67
+ if args.engine_key:
68
+ cfg.engine_api_key = args.engine_key
69
+ if args.tunnel_token:
70
+ cfg.tunnel_token = args.tunnel_token
71
+ if args.port:
72
+ cfg.http_port = args.port
73
+ if args.public_url:
74
+ cfg.public_base_url = args.public_url
75
+
76
+ with_tunnel = not args.no_tunnel
77
+ # Fill gaps interactively unless told not to; then persist for next time.
78
+ needs = not cfg.has_engine_key or (with_tunnel and not cfg.has_tunnel_token)
79
+ if needs and not args.non_interactive:
80
+ cfg = _run_wizard(cfg)
81
+ save_config(cfg)
82
+
83
+ if not preflight(cfg, with_tunnel):
84
+ return 1
85
+ return Supervisor(cfg, with_tunnel=with_tunnel).run()
86
+
87
+
88
+ def _cmd_serve(_: argparse.Namespace) -> int:
89
+ # Raw engine only (what `start` spawns as a child, and what systemd would use).
90
+ from flow_engine.main import main as serve_main
91
+
92
+ serve_main()
93
+ return 0
94
+
95
+
96
+ def _cmd_doctor(_: argparse.Namespace) -> int:
97
+ cfg = load_config()
98
+ ok = True
99
+
100
+ def line(good: bool, label: str, detail: str = "") -> None:
101
+ nonlocal ok
102
+ ok = ok and good
103
+ mark = "[green]✓[/]" if good else "[red]✗[/]"
104
+ console.print(f"{mark} {label}" + (f" [dim]{detail}[/]" if detail else ""))
105
+
106
+ line(sys.version_info >= (3, 10), "python ≥ 3.10", sys.version.split()[0])
107
+ line(bool(shutil.which("cloudflared")), "cloudflared installed", shutil.which("cloudflared") or "missing")
108
+ line(CONFIG_FILE.exists(), "config present", str(CONFIG_FILE))
109
+ line(cfg.has_engine_key, "engine API key set", mask(cfg.engine_api_key))
110
+ line(cfg.has_tunnel_token, "tunnel token set", mask(cfg.tunnel_token))
111
+ h = health.probe_engine(cfg.http_port)
112
+ if h:
113
+ line(True, f"engine responding on :{cfg.http_port}",
114
+ f"extension_connected={h.get('extension_connected')} instances={h.get('instances')}")
115
+ else:
116
+ console.print(f"[dim]·[/] engine not running on :{cfg.http_port} (start it with [bold]flow-engine start[/])")
117
+ return 0 if ok else 1
118
+
119
+
120
+ def build_parser() -> argparse.ArgumentParser:
121
+ p = argparse.ArgumentParser(prog="flow-engine", description="Vrex Flow Engine launcher + supervisor")
122
+ sub = p.add_subparsers(dest="command")
123
+
124
+ ps = sub.add_parser("start", help="run engine + tunnel with a live dashboard")
125
+ ps.add_argument("--engine-key", help="engine API key (overrides stored/env)")
126
+ ps.add_argument("--tunnel-token", help="Cloudflare tunnel token (overrides stored/env)")
127
+ ps.add_argument("--port", type=int, help=f"HTTP port (default {DEFAULT_PORT})")
128
+ ps.add_argument("--public-url", help="public base URL")
129
+ ps.add_argument("--no-tunnel", action="store_true", help="run the engine only (skip cloudflared)")
130
+ ps.add_argument("--non-interactive", action="store_true", help="fail instead of prompting for missing secrets")
131
+ ps.set_defaults(func=_cmd_start)
132
+
133
+ sub.add_parser("serve", help="run just the engine (foreground uvicorn)").set_defaults(func=_cmd_serve)
134
+ sub.add_parser("setup", help="store engine key + tunnel token").set_defaults(func=_cmd_setup)
135
+ sub.add_parser("doctor", help="check prerequisites + probe a running engine").set_defaults(func=_cmd_doctor)
136
+ return p
137
+
138
+
139
+ def main() -> None:
140
+ parser = build_parser()
141
+ args = parser.parse_args()
142
+ if not getattr(args, "command", None):
143
+ parser.print_help()
144
+ raise SystemExit(0)
145
+ raise SystemExit(args.func(args))
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main()
@@ -0,0 +1,81 @@
1
+ """Persistent CLI config for the flow-engine supervisor.
2
+
3
+ Stores the engine API key + Cloudflare tunnel token (both secrets) plus the
4
+ HTTP port and public base URL in ``~/.vrex-flow/config.json`` (chmod 0600).
5
+ The running engine itself does NOT read this file — the supervisor injects the
6
+ key into the child process env — so this is purely the launcher's own state.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import stat
13
+ from dataclasses import dataclass, fields
14
+ from pathlib import Path
15
+
16
+ CONFIG_DIR = Path(os.getenv("VREX_FLOW_HOME") or (Path.home() / ".vrex-flow"))
17
+ CONFIG_FILE = CONFIG_DIR / "config.json"
18
+ LOGS_DIR = CONFIG_DIR / "logs"
19
+
20
+ DEFAULT_PORT = 8101
21
+ DEFAULT_PUBLIC_BASE_URL = "https://flow.getvrex.com"
22
+
23
+
24
+ @dataclass
25
+ class FlowConfig:
26
+ """Launcher state. `engine_api_key` + `tunnel_token` are secrets."""
27
+
28
+ engine_api_key: str = ""
29
+ tunnel_token: str = ""
30
+ http_port: int = DEFAULT_PORT
31
+ public_base_url: str = DEFAULT_PUBLIC_BASE_URL
32
+
33
+ @property
34
+ def has_engine_key(self) -> bool:
35
+ return bool(self.engine_api_key.strip())
36
+
37
+ @property
38
+ def has_tunnel_token(self) -> bool:
39
+ return bool(self.tunnel_token.strip())
40
+
41
+
42
+ def load_config() -> FlowConfig:
43
+ """Read the on-disk config, tolerating a missing file or unknown keys.
44
+ Env vars FLOW_ENGINE_API_KEY / CLOUDFLARE_TUNNEL_TOKEN override the file so a
45
+ non-interactive host can inject secrets without writing them to disk."""
46
+ cfg = FlowConfig()
47
+ if CONFIG_FILE.exists():
48
+ known = {f.name for f in fields(FlowConfig)}
49
+ data = json.loads(CONFIG_FILE.read_text())
50
+ cfg = FlowConfig(**{k: v for k, v in data.items() if k in known})
51
+ env_key = os.getenv("FLOW_ENGINE_API_KEY")
52
+ env_token = os.getenv("CLOUDFLARE_TUNNEL_TOKEN") or os.getenv("TUNNEL_TOKEN")
53
+ if env_key:
54
+ cfg.engine_api_key = env_key
55
+ if env_token:
56
+ cfg.tunnel_token = env_token
57
+ return cfg
58
+
59
+
60
+ def save_config(cfg: FlowConfig) -> Path:
61
+ """Persist the config as 0600 (it holds secrets). Returns the file path."""
62
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
63
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
64
+ payload = {
65
+ "engine_api_key": cfg.engine_api_key,
66
+ "tunnel_token": cfg.tunnel_token,
67
+ "http_port": cfg.http_port,
68
+ "public_base_url": cfg.public_base_url,
69
+ }
70
+ # Write then tighten perms — never leave a world-readable window on secrets.
71
+ CONFIG_FILE.write_text(json.dumps(payload, indent=2) + "\n")
72
+ os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) # 0600
73
+ return CONFIG_FILE
74
+
75
+
76
+ def mask(secret: str) -> str:
77
+ """Render a secret for display: keep a short prefix, hide the rest."""
78
+ s = (secret or "").strip()
79
+ if not s:
80
+ return "(unset)"
81
+ return f"{s[:6]}…{s[-2:]}" if len(s) > 10 else "•" * len(s)