lumilake-cli 0.1.0.dev1__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.
File without changes
lumilake_cli/cli.py ADDED
@@ -0,0 +1,54 @@
1
+ """Lumilake CLI entrypoint.
2
+
3
+ Installs a typer-aware handler on the shared ``Lumilake`` logger root so
4
+ records from every workspace package render with the CLI colour palette.
5
+ """
6
+
7
+ import logging
8
+
9
+ import typer
10
+ from lumilake.log import ColorFormatter, get_default_logger
11
+
12
+ from .commands import register
13
+ from .core.typer import get_typer
14
+
15
+ root_app = get_typer(help="Lumilake command line interface.")
16
+
17
+
18
+ class _TyperHandler(logging.Handler):
19
+ """Routes records through ``typer.echo``: stdout for INFO/DEBUG,
20
+ stderr for WARNING/ERROR/CRITICAL. Honours typer's color detection.
21
+ Coloring is left to ``ColorFormatter``.
22
+ """
23
+
24
+ def emit(self, record: logging.LogRecord) -> None:
25
+ try:
26
+ message = self.format(record)
27
+ except Exception:
28
+ self.handleError(record)
29
+ return
30
+ typer.echo(message, err=record.levelno >= logging.WARNING)
31
+
32
+
33
+ def _install_handler() -> None:
34
+ root = get_default_logger()
35
+ if any(isinstance(h, _TyperHandler) for h in root.handlers):
36
+ return
37
+ # Drop get_default_logger's StreamHandler so records aren't doubled.
38
+ for existing in list(root.handlers):
39
+ root.removeHandler(existing)
40
+ handler = _TyperHandler()
41
+ handler.setFormatter(ColorFormatter())
42
+ root.addHandler(handler)
43
+ root.propagate = False
44
+
45
+
46
+ def main() -> None:
47
+ """Attach command groups and dispatch to Typer."""
48
+ _install_handler()
49
+ register(root_app)
50
+ root_app()
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
@@ -0,0 +1,69 @@
1
+ """Command modules for the Lumilake CLI."""
2
+
3
+ from importlib.util import find_spec
4
+
5
+ import typer
6
+
7
+ from .base import app as base_app
8
+ from .job import app as job_app
9
+ from .trace import app as trace_app
10
+ from .worker import app as worker_app
11
+
12
+ # The ``[deploy]`` extra pulls ``lumilake-deploy`` and
13
+ # ``flowmesh-cli-stack``; gate on both up front so real ImportError
14
+ # from inside .deploy still bubbles up loudly.
15
+ _DEPLOY_AVAILABLE = (
16
+ find_spec("lumilake_deploy") is not None
17
+ and find_spec("flowmesh_cli_stack") is not None
18
+ )
19
+
20
+ if _DEPLOY_AVAILABLE:
21
+ from .deploy import app as deploy_app
22
+ else:
23
+ _DEPLOY_INSTALL_HINT = (
24
+ "lumilake deploy requires the `deploy` extra. Install with "
25
+ "`pip install 'lumilake[cli]'` or "
26
+ "`pip install 'lumilake-cli[deploy]'`."
27
+ )
28
+ deploy_app = typer.Typer(
29
+ help=(
30
+ "Deploy and manage the Lumilake stack. Install with "
31
+ "`pip install 'lumilake[cli]'` or "
32
+ "`pip install 'lumilake-cli[deploy]'` to enable."
33
+ )
34
+ )
35
+
36
+ def _deploy_hint() -> None:
37
+ typer.echo(_DEPLOY_INSTALL_HINT, err=True)
38
+ raise typer.Exit(code=2)
39
+
40
+ @deploy_app.callback(invoke_without_command=True)
41
+ def _deploy_no_subcommand(ctx: typer.Context) -> None:
42
+ if ctx.resilient_parsing or ctx.invoked_subcommand is not None:
43
+ return
44
+ _deploy_hint()
45
+
46
+ for _name in (
47
+ "init",
48
+ "doctor",
49
+ "build",
50
+ "pull",
51
+ "up",
52
+ "down",
53
+ "clean",
54
+ "reset",
55
+ "status",
56
+ "restart",
57
+ "logs",
58
+ "update-flowmesh",
59
+ ):
60
+ deploy_app.command(_name)(_deploy_hint)
61
+
62
+
63
+ def register(app: typer.Typer) -> None:
64
+ """Register command groups on the root app."""
65
+ app.add_typer(base_app)
66
+ app.add_typer(deploy_app, name="deploy")
67
+ app.add_typer(job_app, name="job")
68
+ app.add_typer(worker_app, name="worker")
69
+ app.add_typer(trace_app, name="trace")
@@ -0,0 +1,85 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import typer
5
+
6
+ from ..core import logging
7
+ from ..core.config import DEFAULT_CONFIG_PATH, LumilakeConfig, save_config
8
+ from ..core.http import HttpClient, HttpError, client_from_config
9
+ from ..core.typer import get_typer
10
+
11
+ app = get_typer()
12
+
13
+
14
+ @app.command()
15
+ def login(
16
+ url: str = typer.Argument(
17
+ ..., help="Lumilake server URL (e.g., http://localhost:9000)"
18
+ ),
19
+ config_path: Path = typer.Option(
20
+ DEFAULT_CONFIG_PATH, "--config", help="Path to save configuration file"
21
+ ),
22
+ ) -> None:
23
+ """Configure connection to a Lumilake server and save it locally."""
24
+ client = HttpClient(base_url=url)
25
+ try:
26
+ client.get("/healthz")
27
+ except HttpError as exc:
28
+ logging.error(str(exc))
29
+ raise typer.Exit(code=1)
30
+ save_config(LumilakeConfig(base_url=url), path=config_path)
31
+ logging.success(f"Login successful. Saved config to {config_path}")
32
+
33
+
34
+ @app.command()
35
+ def logout(
36
+ config_path: Path = typer.Option(
37
+ DEFAULT_CONFIG_PATH, "--config", help="Path to configuration file to remove"
38
+ ),
39
+ ) -> None:
40
+ """Delete saved configuration."""
41
+ config_path.unlink(missing_ok=True)
42
+ logging.success(f"Logout successful. Removed config {config_path}")
43
+
44
+
45
+ @app.command()
46
+ def config(
47
+ refresh: bool = typer.Option(
48
+ False, "--refresh", "-r", help="Reload and show the latest configuration"
49
+ ),
50
+ config_path: Path = typer.Option(
51
+ DEFAULT_CONFIG_PATH, "--config", help="Path to configuration file"
52
+ ),
53
+ ) -> None:
54
+ """Show the current Lumilake CLI configuration."""
55
+ from ..core.http import _require_config
56
+
57
+ cfg = _require_config(config_path)
58
+ if refresh:
59
+ client = HttpClient(base_url=cfg.base_url)
60
+ try:
61
+ client.get("/healthz")
62
+ except HttpError as exc:
63
+ logging.error(str(exc))
64
+ raise typer.Exit(code=1)
65
+ save_config(cfg, path=config_path)
66
+ logging.success(f"Configuration refreshed. Saved to {config_path}")
67
+ logging.log(json.dumps(cfg.to_mapping(), indent=2))
68
+
69
+
70
+ @app.command()
71
+ def info() -> None:
72
+ """Query server health status."""
73
+ client = client_from_config()
74
+ try:
75
+ response = client.get("/healthz")
76
+ except HttpError as exc:
77
+ logging.error(str(exc))
78
+ raise typer.Exit(code=1)
79
+ logging.log(json.dumps(response.json(), indent=2))
80
+
81
+
82
+ @app.command()
83
+ def health() -> None:
84
+ """Check if the Lumilake server is reachable and healthy."""
85
+ info()
@@ -0,0 +1,462 @@
1
+ """Deploy commands: init, doctor, up, down, clean, restart, reset, logs."""
2
+
3
+ import datetime as dt
4
+ import re
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from flowmesh_cli_stack.stack import stack_env_example
10
+ from lumilake_deploy import docker_client
11
+ from lumilake_deploy import setup as setup_mod
12
+ from lumilake_deploy import stop as stop_mod
13
+ from lumilake_deploy import update_flowmesh as update_fm_mod
14
+ from lumilake_deploy.assets import env_example_path
15
+ from lumilake_deploy.containers import SERVICE_NAMES, container_names
16
+ from lumilake_deploy.doctor import DoctorFinding, run_doctor
17
+ from lumilake_deploy.env import (
18
+ ENV_FILE_NAME,
19
+ FLOWMESH_ENV_FILE_NAME,
20
+ patch_env_value,
21
+ )
22
+ from lumilake_deploy.errors import DeployError
23
+ from lumilake_deploy.setup import (
24
+ SetupOptions,
25
+ build_server_image,
26
+ pull_server_image,
27
+ )
28
+
29
+ from ..core import logging
30
+ from ..core.typer import get_typer
31
+
32
+ app = get_typer(
33
+ help=(
34
+ "Deploy and manage the Lumilake stack. Reads .env (and optionally "
35
+ ".env.flowmesh) from --project-dir (or the current working "
36
+ "directory). The packaged compose file and server image are "
37
+ "resolved from the installed lumilake-deploy package."
38
+ )
39
+ )
40
+
41
+
42
+ @app.callback()
43
+ def _deploy_callback(
44
+ ctx: typer.Context,
45
+ project_dir: Path = typer.Option(
46
+ None,
47
+ "--project-dir",
48
+ "-C",
49
+ envvar="LUMILAKE_DEPLOY_DIR",
50
+ help=(
51
+ "Directory holding .env / .env.flowmesh and where compose "
52
+ "stores runtime state. Defaults to the current working directory."
53
+ ),
54
+ ),
55
+ ) -> None:
56
+ ctx.obj = project_dir.resolve() if project_dir else Path.cwd()
57
+
58
+
59
+ def _project_dir(ctx: typer.Context) -> Path:
60
+ if isinstance(ctx.obj, Path):
61
+ return ctx.obj
62
+ return Path.cwd()
63
+
64
+
65
+ def _run_setup(
66
+ root: Path,
67
+ *,
68
+ background: bool = True,
69
+ reset: bool = False,
70
+ no_server: bool = False,
71
+ ) -> None:
72
+ try:
73
+ setup_mod.run_setup(
74
+ root,
75
+ SetupOptions(
76
+ reset=reset,
77
+ no_server=no_server,
78
+ background=background,
79
+ ),
80
+ )
81
+ except DeployError as exc:
82
+ logging.error(str(exc))
83
+ raise typer.Exit(code=1) from exc
84
+
85
+
86
+ def _confirm_overwrite(target: Path, *, force: bool) -> bool:
87
+ """Return True when the target may be written, False to skip."""
88
+ if not target.exists() or force:
89
+ return True
90
+ if typer.confirm(f"{target} already exists. Overwrite?", default=False):
91
+ return True
92
+ logging.info(f"Keeping existing {target}.")
93
+ return False
94
+
95
+
96
+ def _flowmesh_env_value(env_path: Path, key: str) -> str:
97
+ """Read a single line from ``.env.flowmesh`` (no quoting strip pattern)."""
98
+ if not env_path.is_file():
99
+ return ""
100
+ prefix = f"{key}="
101
+ for raw in env_path.read_text().splitlines():
102
+ line = raw.strip()
103
+ if not line.startswith(prefix):
104
+ continue
105
+ value = line[len(prefix) :]
106
+ if value.startswith('"') and value.endswith('"'):
107
+ value = value[1:-1]
108
+ return value
109
+ return ""
110
+
111
+
112
+ _FLOWMESH_LOCAL_OVERRIDES: tuple[tuple[str, str], ...] = (
113
+ ("FLOWMESH_STACK_SUFFIX", "lumilake"),
114
+ ("SERVER_GRPC_TLS_CA_FILE", ""),
115
+ ("SERVER_GRPC_TLS_CERT_FILE", ""),
116
+ ("SERVER_GRPC_TLS_KEY_FILE", ""),
117
+ ("REDIS_TLS_CA_FILE", ""),
118
+ ("REDIS_TLS_CERT_FILE", ""),
119
+ ("REDIS_TLS_KEY_FILE", ""),
120
+ )
121
+
122
+
123
+ def _patch_flowmesh_env(env_path: Path) -> None:
124
+ """Disable TLS bind mounts on the bundled FlowMesh template."""
125
+ for key, value in _FLOWMESH_LOCAL_OVERRIDES:
126
+ patch_env_value(env_path, key, value)
127
+
128
+
129
+ def _cross_populate_runtime(env_path: Path, fm_env_path: Path) -> None:
130
+ """After ``init --flowmesh`` writes both files, point ``.env``'s
131
+ ``LUMILAKE_RUNTIME_ORCHESTRATOR_URL`` at the FlowMesh server's port."""
132
+ server_port = _flowmesh_env_value(fm_env_path, "SERVER_HTTP_PORT") or "18000"
133
+ patch_env_value(
134
+ env_path,
135
+ "LUMILAKE_RUNTIME_ORCHESTRATOR_URL",
136
+ f"http://127.0.0.1:{server_port}",
137
+ )
138
+
139
+
140
+ @app.command("init")
141
+ def init(
142
+ ctx: typer.Context,
143
+ flowmesh: bool = typer.Option(
144
+ False,
145
+ "--flowmesh",
146
+ help="Also generate ``.env.flowmesh`` from FlowMesh's stack template.",
147
+ ),
148
+ force: bool = typer.Option(
149
+ False,
150
+ "--force",
151
+ "-f",
152
+ help="Overwrite existing env files without prompting.",
153
+ ),
154
+ ) -> None:
155
+ """Initialize ``.env`` from the bundled ``.env.example`` template."""
156
+ root = _project_dir(ctx)
157
+ template = env_example_path()
158
+ target = root / ENV_FILE_NAME
159
+ wrote_env = False
160
+ if _confirm_overwrite(target, force=force):
161
+ target.write_text(template.read_text())
162
+ logging.success(f"Wrote {target} from packaged {template.name}.")
163
+ wrote_env = True
164
+
165
+ if flowmesh:
166
+ fm_target = root / FLOWMESH_ENV_FILE_NAME
167
+ wrote_flowmesh_env = False
168
+ if _confirm_overwrite(fm_target, force=force):
169
+ example = stack_env_example()
170
+ if not example.exists():
171
+ logging.error(f"FlowMesh env example not found: {example}")
172
+ raise typer.Exit(code=1)
173
+ fm_target.write_text(example.read_text())
174
+ logging.success(f"Wrote {fm_target} from {example.name}.")
175
+ _patch_flowmesh_env(fm_target)
176
+ wrote_flowmesh_env = True
177
+ if wrote_env:
178
+ _cross_populate_runtime(target, fm_target)
179
+ elif wrote_flowmesh_env:
180
+ logging.info(
181
+ f"Keeping existing {target}; not patching runtime URL from "
182
+ f"{fm_target}."
183
+ )
184
+
185
+
186
+ @app.command()
187
+ def doctor(
188
+ ctx: typer.Context,
189
+ flowmesh: bool = typer.Option(
190
+ False,
191
+ "--flowmesh",
192
+ help="Also validate that ``.env.flowmesh`` is present.",
193
+ ),
194
+ ) -> None:
195
+ """Validate ``.env`` (and optionally ``.env.flowmesh``)."""
196
+ root = _project_dir(ctx)
197
+
198
+ def _emit(finding: DoctorFinding) -> None:
199
+ if finding.level == "error":
200
+ logging.error(finding.message)
201
+ elif finding.level == "warning":
202
+ logging.warning(finding.message)
203
+ else:
204
+ logging.info(finding.message)
205
+
206
+ report = run_doctor(
207
+ root / ENV_FILE_NAME,
208
+ flowmesh_env_path=(root / FLOWMESH_ENV_FILE_NAME) if flowmesh else None,
209
+ callback=_emit,
210
+ )
211
+ if report.errors:
212
+ logging.error(f"Doctor found {len(report.errors)} issue(s).")
213
+ raise typer.Exit(code=1)
214
+ logging.success("Doctor checks passed.")
215
+
216
+
217
+ @app.command()
218
+ def build(ctx: typer.Context) -> None:
219
+ """Build the lumilake server Docker image from source."""
220
+ root = _project_dir(ctx)
221
+ setup_mod.load_project_env(root)
222
+ image_tag = setup_mod.resolve_image_tag()
223
+ try:
224
+ build_server_image(root, image_tag)
225
+ except DeployError as exc:
226
+ logging.error(str(exc))
227
+ raise typer.Exit(code=1) from exc
228
+
229
+
230
+ @app.command()
231
+ def pull(ctx: typer.Context) -> None:
232
+ """Pull the published lumilake server image from the registry."""
233
+ root = _project_dir(ctx)
234
+ setup_mod.load_project_env(root)
235
+ image_tag = setup_mod.resolve_image_tag()
236
+ try:
237
+ pull_server_image(image_tag)
238
+ except DeployError as exc:
239
+ logging.error(str(exc))
240
+ raise typer.Exit(code=1) from exc
241
+
242
+
243
+ @app.command()
244
+ def up(ctx: typer.Context) -> None:
245
+ """Start the full Lumilake stack (Docker-backed).
246
+
247
+ The server image must already be present locally — run
248
+ ``lumilake deploy pull`` or ``lumilake deploy build`` first.
249
+ """
250
+ _run_setup(_project_dir(ctx), background=True)
251
+
252
+
253
+ @app.command()
254
+ def down(
255
+ ctx: typer.Context,
256
+ wipe_archive: bool = typer.Option(
257
+ False,
258
+ "--wipe-archive",
259
+ help=(
260
+ "Also wipe runtime state that accumulates across "
261
+ "deploy cycles (FlowMesh postgres + redis). "
262
+ "Fixes the 'Duplicate key' / silent-retry-loop failure mode "
263
+ "after a killed prior run. Use this between experiment runs "
264
+ "if you don't want a full "
265
+ "``deploy reset`` + re-stage."
266
+ ),
267
+ ),
268
+ ) -> None:
269
+ """Stop all services (keep data)."""
270
+ try:
271
+ stop_mod.run_stop(
272
+ _project_dir(ctx),
273
+ purge=False,
274
+ wipe_archive=wipe_archive,
275
+ )
276
+ except DeployError as exc:
277
+ logging.error(str(exc))
278
+ raise typer.Exit(code=1) from exc
279
+
280
+
281
+ @app.command()
282
+ def clean(ctx: typer.Context) -> None:
283
+ """Stop all services and delete volumes."""
284
+ try:
285
+ stop_mod.run_stop(_project_dir(ctx), purge=True)
286
+ except DeployError as exc:
287
+ logging.error(str(exc))
288
+ raise typer.Exit(code=1) from exc
289
+
290
+
291
+ @app.command()
292
+ def status(ctx: typer.Context) -> None:
293
+ """Show the running state of every Lumilake stack container."""
294
+ rows: list[tuple[str, str, str, str]] = []
295
+ for service, container in container_names(_project_dir(ctx)).items():
296
+ state = docker_client.container_status(container)
297
+ health = (
298
+ docker_client.container_health_status(container)
299
+ if state == "running"
300
+ else ""
301
+ )
302
+ rows.append((service, container, state, health))
303
+
304
+ svc_w = max(len(r[0]) for r in rows)
305
+ cnt_w = max(len(r[1]) for r in rows)
306
+ state_w = max(len(r[2]) for r in rows)
307
+ for service, container, state, health in rows:
308
+ line = f"{service:<{svc_w}} {container:<{cnt_w}} {state:<{state_w}}"
309
+ if health:
310
+ line += f" ({health})"
311
+ logging.info(line)
312
+
313
+
314
+ @app.command()
315
+ def restart(
316
+ ctx: typer.Context,
317
+ service: str | None = typer.Argument(
318
+ None,
319
+ help=(
320
+ "Single service to restart. Choose from: "
321
+ f"{', '.join(SERVICE_NAMES)}. When omitted, restarts the "
322
+ "entire stack (re-runs setup, rebuilds images)."
323
+ ),
324
+ ),
325
+ ) -> None:
326
+ """Restart deployment services.
327
+
328
+ Without ``service``: stops the entire stack and re-runs setup
329
+ (rebuilds images if code changed). With ``service``: restarts just
330
+ that container in place.
331
+ """
332
+ root = _project_dir(ctx)
333
+ if service is not None:
334
+ container = container_names(root).get(service)
335
+ if container is None:
336
+ logging.error(
337
+ f"Unknown service '{service}'. "
338
+ f"Choose from: {', '.join(SERVICE_NAMES)}"
339
+ )
340
+ raise typer.Exit(code=1)
341
+ try:
342
+ docker_client.container_restart(container)
343
+ except DeployError as exc:
344
+ logging.error(str(exc))
345
+ raise typer.Exit(code=1) from exc
346
+ return
347
+
348
+ try:
349
+ stop_mod.run_stop(root, purge=False)
350
+ except DeployError as exc:
351
+ logging.error(str(exc))
352
+ raise typer.Exit(code=1) from exc
353
+ _run_setup(root, background=True)
354
+
355
+
356
+ @app.command()
357
+ def reset(ctx: typer.Context) -> None:
358
+ """Clean reset (stop + purge + up; deletes all data)."""
359
+ root = _project_dir(ctx)
360
+ try:
361
+ stop_mod.run_stop(root, purge=True)
362
+ except DeployError as exc:
363
+ logging.error(str(exc))
364
+ raise typer.Exit(code=1) from exc
365
+ _run_setup(root, background=True, reset=True)
366
+
367
+
368
+ _SINCE_RE = re.compile(r"^(\d+)([smhd])$")
369
+
370
+
371
+ def _parse_since(value: str | None) -> dt.datetime | None:
372
+ """Parse ``10m`` / ``2h`` / ``1d`` / ``30s`` into a past ``datetime``.
373
+
374
+ Returns ``None`` when ``value`` is ``None``. Exits the CLI on an
375
+ unparsable value.
376
+ """
377
+ if value is None:
378
+ return None
379
+ match = _SINCE_RE.match(value.strip())
380
+ if not match:
381
+ logging.error(
382
+ f"--since must be one of <N>s/m/h/d (e.g. 30s, 10m, 2h, 1d); got {value!r}"
383
+ )
384
+ raise typer.Exit(code=1)
385
+ amount = int(match.group(1))
386
+ unit = match.group(2)
387
+ delta = {
388
+ "s": dt.timedelta(seconds=amount),
389
+ "m": dt.timedelta(minutes=amount),
390
+ "h": dt.timedelta(hours=amount),
391
+ "d": dt.timedelta(days=amount),
392
+ }[unit]
393
+ return dt.datetime.now(dt.UTC) - delta
394
+
395
+
396
+ @app.command()
397
+ def logs(
398
+ ctx: typer.Context,
399
+ service: str = typer.Argument(
400
+ "server",
401
+ help=f"Service name: {', '.join(SERVICE_NAMES)}",
402
+ ),
403
+ tail: int | None = typer.Option(
404
+ None, "--tail", "-n", help="Show last N lines and exit (default: follow live)."
405
+ ),
406
+ since: str | None = typer.Option(
407
+ None,
408
+ "--since",
409
+ help=(
410
+ "Only show logs newer than this duration: "
411
+ "<N>s, <N>m, <N>h, <N>d (e.g. 30s, 10m, 2h, 1d)."
412
+ ),
413
+ ),
414
+ timestamps: bool = typer.Option(
415
+ False,
416
+ "--timestamps",
417
+ "-t",
418
+ help="Prepend an RFC3339 timestamp to each log line.",
419
+ ),
420
+ ) -> None:
421
+ """Show logs for a Lumilake service.
422
+
423
+ Follows by default; use ``-n`` to show the last N lines and exit.
424
+ ``--since`` filters to entries newer than the given relative duration.
425
+ """
426
+ container = container_names(_project_dir(ctx)).get(service)
427
+ if container is None:
428
+ logging.error(
429
+ f"Unknown service '{service}'. Choose from: {', '.join(SERVICE_NAMES)}"
430
+ )
431
+ raise typer.Exit(code=1)
432
+ since_dt = _parse_since(since)
433
+ try:
434
+ if tail is not None:
435
+ output = docker_client.container_logs_tail(
436
+ container, tail=tail, since=since_dt, timestamps=timestamps
437
+ )
438
+ sys.stdout.write(output)
439
+ if output and not output.endswith("\n"):
440
+ sys.stdout.write("\n")
441
+ sys.stdout.flush()
442
+ else:
443
+ for chunk in docker_client.container_logs_stream(
444
+ container, since=since_dt, timestamps=timestamps
445
+ ):
446
+ sys.stdout.buffer.write(chunk)
447
+ sys.stdout.buffer.flush()
448
+ except DeployError as exc:
449
+ logging.error(str(exc))
450
+ raise typer.Exit(code=1) from exc
451
+ except KeyboardInterrupt:
452
+ return
453
+
454
+
455
+ @app.command("update-flowmesh")
456
+ def update_flowmesh_cmd(ctx: typer.Context) -> None:
457
+ """Re-lock and install the latest FlowMesh packages."""
458
+ try:
459
+ update_fm_mod.run_update(_project_dir(ctx))
460
+ except DeployError as exc:
461
+ logging.error(str(exc))
462
+ raise typer.Exit(code=1) from exc