meshagent-cli 0.5.8b5__py3-none-any.whl → 0.5.12__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.

Potentially problematic release.


This version of meshagent-cli might be problematic. Click here for more details.

@@ -1,849 +0,0 @@
1
- # meshagent/cli/containers.py
2
- from __future__ import annotations
3
-
4
- import asyncio
5
- import io
6
- import os
7
- import sys
8
- import tarfile
9
- import time
10
- from pathlib import Path
11
-
12
- import typer
13
- from rich import print
14
- from typing import Annotated, Optional, List, Dict
15
-
16
- from meshagent.cli import async_typer
17
- from meshagent.cli.common_options import ProjectIdOption, ApiKeyIdOption, RoomOption
18
- from meshagent.cli.helper import (
19
- get_client,
20
- resolve_project_id,
21
- resolve_api_key,
22
- resolve_room,
23
- )
24
- from meshagent.api import (
25
- RoomClient,
26
- ParticipantToken,
27
- WebSocketClientProtocol,
28
- ApiScope,
29
- )
30
- from meshagent.api.helpers import meshagent_base_url, websocket_room_url
31
- from meshagent.api.room_server_client import (
32
- BuildSource,
33
- BuildSourceGit,
34
- BuildSourceContext,
35
- BuildSourceRoom,
36
- DockerSecret,
37
- )
38
-
39
- app = async_typer.AsyncTyper(help="Manage containers and images inside a room")
40
-
41
- # -------------------------
42
- # Helpers
43
- # -------------------------
44
-
45
-
46
- def _parse_keyvals(items: List[str]) -> Dict[str, str]:
47
- """
48
- Parse ["KEY=VAL", "FOO=BAR"] -> {"KEY":"VAL", "FOO":"BAR"}
49
- """
50
- out: Dict[str, str] = {}
51
- for s in items or []:
52
- if "=" not in s:
53
- raise typer.BadParameter(f"Expected KEY=VALUE, got: {s}")
54
- k, v = s.split("=", 1)
55
- out[k] = v
56
- return out
57
-
58
-
59
- def _parse_ports(items: List[str]) -> Dict[int, int]:
60
- """
61
- Parse ["8080:3000", "9999:9999"] as CONTAINER:HOST -> {8080:3000, 9999:9999}
62
- (Matches server's expectation: container_port -> host_port.)
63
- """
64
- out: Dict[int, int] = {}
65
- for s in items or []:
66
- if ":" not in s:
67
- raise typer.BadParameter(f"Expected CONTAINER:HOST, got: {s}")
68
- c, h = s.split(":", 1)
69
- try:
70
- cp, hp = int(c), int(h)
71
- except ValueError:
72
- raise typer.BadParameter(f"Ports must be integers, got: {s}")
73
- out[cp] = hp
74
- return out
75
-
76
-
77
- def _parse_creds(items: List[str]) -> List[DockerSecret]:
78
- """
79
- Parse creds given as:
80
- --cred username,password
81
- --cred registry,username,password
82
- """
83
- creds: List[DockerSecret] = []
84
- for s in items or []:
85
- parts = [p.strip() for p in s.split(",")]
86
- if len(parts) == 2:
87
- u, p = parts
88
- creds.append(DockerSecret(username=u, password=p))
89
- elif len(parts) == 3:
90
- r, u, p = parts
91
- creds.append(DockerSecret(registry=r, username=u, password=p))
92
- else:
93
- raise typer.BadParameter(
94
- f"Invalid --cred format: {s}. Use username,password or registry,username,password"
95
- )
96
- return creds
97
-
98
-
99
- def _tarfilter_strip_macos(ti: tarfile.TarInfo) -> tarfile.TarInfo | None:
100
- """
101
- Filter to make Linux-friendly tarballs:
102
- - Drop AppleDouble files (._*)
103
- - Reset uid/gid/uname/gname
104
- - Clear pax headers
105
- """
106
- base = os.path.basename(ti.name)
107
- if base.startswith("._"):
108
- return None
109
- ti.uid = 0
110
- ti.gid = 0
111
- ti.uname = ""
112
- ti.gname = ""
113
- ti.pax_headers = {}
114
- # Preserve mode & type; set a stable-ish mtime
115
- if ti.mtime is None:
116
- ti.mtime = int(time.time())
117
- return ti
118
-
119
-
120
- def _make_targz_from_dir(path: Path) -> bytes:
121
- buf = io.BytesIO()
122
- with tarfile.open(fileobj=buf, mode="w:gz") as tar:
123
- tar.add(path, arcname=".", filter=_tarfilter_strip_macos)
124
- return buf.getvalue()
125
-
126
-
127
- def _make_targz_with_dockerfile_text(text: str) -> bytes:
128
- b = text.encode("utf-8")
129
- buf = io.BytesIO()
130
- with tarfile.open(fileobj=buf, mode="w:gz") as tar:
131
- ti = tarfile.TarInfo("Dockerfile")
132
- ti.size = len(b)
133
- ti.mtime = int(time.time())
134
- ti.mode = 0o644
135
- tar.addfile(ti, io.BytesIO(b))
136
- return buf.getvalue()
137
-
138
-
139
- async def _drain_stream_plain(stream, *, show_progress: bool = True):
140
- async def _logs():
141
- async for line in stream.logs():
142
- # Server emits plain lines; print as-is
143
- if line is not None:
144
- print(line)
145
-
146
- async def _prog():
147
- if not show_progress:
148
- async for _ in stream.progress():
149
- pass
150
- return
151
- async for p in stream.progress():
152
- if p is None:
153
- return
154
- msg = p.message or ""
155
- # Show progress if we have numbers, else just the message.
156
- if p.current is not None and p.total:
157
- print(f"[cyan]{msg} ({p.current}/{p.total})[/cyan]")
158
- elif msg:
159
- print(f"[cyan]{msg}[/cyan]")
160
-
161
- t1 = asyncio.create_task(_logs())
162
- t2 = asyncio.create_task(_prog())
163
- try:
164
- return await stream
165
- finally:
166
- await asyncio.gather(t1, t2, return_exceptions=True)
167
-
168
-
169
- async def _drain_stream_pretty(stream):
170
- import asyncio
171
- import math
172
- from rich.table import Column
173
- from rich.live import Live
174
- from rich.panel import Panel
175
- from rich.console import Group
176
- from rich.text import Text
177
- from rich.progress import (
178
- Progress,
179
- TextColumn,
180
- BarColumn,
181
- TimeElapsedColumn,
182
- ProgressColumn,
183
- SpinnerColumn,
184
- )
185
-
186
- class MaybeMofN(ProgressColumn):
187
- def render(self, task):
188
- import math
189
- from rich.text import Text
190
-
191
- def _fmt_bytes(n):
192
- if n is None:
193
- return ""
194
- n = float(n)
195
- units = ["B", "KiB", "MiB", "GiB", "TiB"]
196
- i = 0
197
- while n >= 1024 and i < len(units) - 1:
198
- n /= 1024
199
- i += 1
200
- return f"{n:.1f} {units[i]}"
201
-
202
- if task.total == 0 or math.isinf(task.total):
203
- return Text("")
204
- return Text(f"{_fmt_bytes(task.completed)} / {_fmt_bytes(task.total)}")
205
-
206
- class MaybeBarColumn(BarColumn):
207
- def __init__(
208
- self,
209
- *,
210
- bar_width: int | None = 28,
211
- hide_when_unknown: bool = False,
212
- column_width: int | None = None,
213
- **kwargs,
214
- ):
215
- # bar_width controls the drawn bar size; None = flex
216
- super().__init__(bar_width=bar_width, **kwargs)
217
- self.hide_when_unknown = hide_when_unknown
218
- self.column_width = column_width # fix the table column if set
219
-
220
- def get_table_column(self) -> Column:
221
- if self.column_width is None:
222
- # default behavior (may flex depending on layout)
223
- return Column(no_wrap=True)
224
- return Column(
225
- width=self.column_width,
226
- min_width=self.column_width,
227
- max_width=self.column_width,
228
- no_wrap=True,
229
- overflow="crop",
230
- justify="left",
231
- )
232
-
233
- def render(self, task):
234
- if task.total is None or task.total == 0 or math.isinf(task.total):
235
- return Text("") # hide bar for indeterminate tasks
236
- return super().render(task)
237
-
238
- class MaybeETA(ProgressColumn):
239
- """Show ETA only if total is known."""
240
-
241
- _elapsed = TimeElapsedColumn()
242
-
243
- def render(self, task):
244
- # You can swap this to a TimeRemainingColumn() if you prefer,
245
- # but hide when total is unknown.
246
- if task.total == 0 or math.isinf(task.total):
247
- return Text("")
248
- return self._elapsed.render(task) # or TimeRemainingColumn().render(task)
249
-
250
- progress = Progress(
251
- SpinnerColumn(),
252
- TextColumn(
253
- "[bold]{task.description}",
254
- table_column=Column(ratio=8, no_wrap=True, overflow="ellipsis"),
255
- ),
256
- MaybeMofN(table_column=Column(ratio=2, no_wrap=True, overflow="ellipsis")),
257
- MaybeETA(table_column=Column(ratio=1, no_wrap=True, overflow="ellipsis")),
258
- MaybeBarColumn(pulse_style="cyan", bar_width=20, hide_when_unknown=True),
259
- # pulses automatically if total=None
260
- transient=False, # we’re inside Live; we’ll hide tasks ourselves
261
- expand=True,
262
- )
263
-
264
- logs_tail: list[str] = []
265
- tasks: dict[str, int] = {} # layer -> task_id
266
-
267
- def render():
268
- tail = "\n".join(logs_tail[-12:]) or "waiting…"
269
- return Group(
270
- progress,
271
- Panel(tail, title="logs", border_style="cyan", height=12),
272
- )
273
-
274
- async def _logs():
275
- async for line in stream.logs():
276
- if line:
277
- logs_tail.append(line.strip())
278
-
279
- async def _prog():
280
- async for p in stream.progress():
281
- layer = p.layer or "overall"
282
- if layer not in tasks:
283
- tasks[layer] = progress.add_task(
284
- p.message or layer, total=p.total if p.total is not None else 0
285
- )
286
- task_id = tasks[layer]
287
-
288
- updates = {}
289
- # Keep total=None for pulsing; only set if we get a real number.
290
- if p.total is not None and not math.isinf(p.total):
291
- updates["total"] = p.total
292
- if p.current is not None:
293
- updates["completed"] = p.current
294
- if p.message:
295
- updates["description"] = p.message
296
- if updates:
297
- progress.update(task_id, **updates)
298
-
299
- with Live(render(), refresh_per_second=10) as live:
300
-
301
- async def _refresh():
302
- while True:
303
- live.update(render())
304
- await asyncio.sleep(0.1)
305
-
306
- t_logs = asyncio.create_task(_logs())
307
- t_prog = asyncio.create_task(_prog())
308
- t_ui = asyncio.create_task(_refresh())
309
- try:
310
- result = await stream
311
- return result
312
- finally:
313
- # Hide any still-visible tasks (e.g., indeterminate ones with total=None)
314
- for tid in list(tasks.values()):
315
- progress.update(tid, visible=False)
316
- live.update(render())
317
-
318
- for t in (t_logs, t_prog):
319
- await t
320
-
321
- t_ui.cancel()
322
-
323
-
324
- async def _with_client(
325
- *,
326
- project_id: ProjectIdOption,
327
- room: RoomOption,
328
- api_key_id: ApiKeyIdOption,
329
- name: str,
330
- role: str,
331
- ):
332
- account_client = await get_client()
333
- try:
334
- project_id = await resolve_project_id(project_id=project_id)
335
- api_key_id = await resolve_api_key(project_id, api_key_id)
336
- room = resolve_room(room)
337
-
338
- key = (
339
- await account_client.decrypt_project_api_key(
340
- project_id=project_id, id=api_key_id
341
- )
342
- )["token"]
343
-
344
- token = ParticipantToken(
345
- name=name, project_id=project_id, api_key_id=api_key_id
346
- )
347
- token.add_api_grant(ApiScope.agent_default())
348
- token.add_role_grant(role=role)
349
- token.add_room_grant(room)
350
-
351
- print("[bold green]Connecting to room...[/bold green]", flush=True)
352
- proto = WebSocketClientProtocol(
353
- url=websocket_room_url(room_name=room, base_url=meshagent_base_url()),
354
- token=token.to_jwt(token=key),
355
- )
356
- client_cm = RoomClient(protocol=proto)
357
- await client_cm.__aenter__()
358
- return account_client, client_cm
359
- except Exception:
360
- await account_client.close()
361
- raise
362
-
363
-
364
- # -------------------------
365
- # Top-level: ps / stop / logs / run
366
- # -------------------------
367
-
368
-
369
- @app.async_command("ps")
370
- async def list_containers(
371
- *,
372
- project_id: ProjectIdOption = None,
373
- room: RoomOption = None,
374
- api_key_id: ApiKeyIdOption = None,
375
- name: Annotated[str, typer.Option(...)] = "cli",
376
- role: Annotated[str, typer.Option(...)] = "user",
377
- output: Annotated[Optional[str], typer.Option(help="json | table")] = "json",
378
- ):
379
- account_client, client = await _with_client(
380
- project_id=project_id, room=room, api_key_id=api_key_id, name=name, role=role
381
- )
382
- try:
383
- containers = await client.containers.list()
384
- if output == "table":
385
- from rich.table import Table
386
- from rich.console import Console
387
-
388
- table = Table(title="Containers")
389
- table.add_column("ID", style="cyan")
390
- table.add_column("Image")
391
- table.add_column("Status")
392
- for c in containers:
393
- table.add_row(c.id, c.image or "", c.status or "")
394
- Console().print(table)
395
- else:
396
- # default json-ish
397
- print([c.model_dump() for c in containers])
398
- finally:
399
- await client.__aexit__(None, None, None)
400
- await account_client.close()
401
-
402
-
403
- @app.async_command("stop")
404
- async def stop_container(
405
- *,
406
- project_id: ProjectIdOption = None,
407
- room: RoomOption = None,
408
- api_key_id: ApiKeyIdOption = None,
409
- id: Annotated[str, typer.Option(..., help="Container ID")],
410
- name: Annotated[str, typer.Option(...)] = "cli",
411
- role: Annotated[str, typer.Option(...)] = "user",
412
- ):
413
- account_client, client = await _with_client(
414
- project_id=project_id, room=room, api_key_id=api_key_id, name=name, role=role
415
- )
416
- try:
417
- await client.containers.stop(container_id=id)
418
- print("[green]Stopped[/green]")
419
- finally:
420
- await client.__aexit__(None, None, None)
421
- await account_client.close()
422
-
423
-
424
- @app.async_command("logs")
425
- async def container_logs(
426
- *,
427
- project_id: ProjectIdOption = None,
428
- room: RoomOption = None,
429
- api_key_id: ApiKeyIdOption = None,
430
- id: Annotated[str, typer.Option(..., help="Container ID")],
431
- follow: Annotated[bool, typer.Option("--follow/--no-follow")] = False,
432
- name: Annotated[str, typer.Option(...)] = "cli",
433
- role: Annotated[str, typer.Option(...)] = "user",
434
- ):
435
- account_client, client = await _with_client(
436
- project_id=project_id, room=room, api_key_id=api_key_id, name=name, role=role
437
- )
438
- try:
439
- stream = client.containers.logs(container_id=id, follow=follow)
440
- await _drain_stream_plain(stream)
441
- finally:
442
- await client.__aexit__(None, None, None)
443
- await account_client.close()
444
-
445
-
446
- # -------------------------
447
- # Run (detached) and run-attached
448
- # -------------------------
449
-
450
-
451
- @app.async_command("run")
452
- async def run_container(
453
- *,
454
- project_id: ProjectIdOption = None,
455
- room: RoomOption = None,
456
- api_key_id: ApiKeyIdOption = None,
457
- image: Annotated[str, typer.Option(..., help="Image to run")],
458
- command: Annotated[Optional[str], typer.Option(...)] = None,
459
- env: Annotated[List[str], typer.Option("--env", "-e", help="KEY=VALUE")] = [],
460
- port: Annotated[
461
- List[str], typer.Option("--port", "-p", help="CONTAINER:HOST")
462
- ] = [],
463
- var: Annotated[
464
- List[str],
465
- typer.Option("--var", help="Template variable KEY=VALUE (optional)"),
466
- ] = [],
467
- cred: Annotated[
468
- List[str],
469
- typer.Option(
470
- "--cred",
471
- help="Docker creds (username,password) or (registry,username,password)",
472
- ),
473
- ] = [],
474
- mount_path: Annotated[Optional[str], typer.Option()] = None,
475
- mount_subpath: Annotated[Optional[str], typer.Option()] = None,
476
- participant_name: Annotated[Optional[str], typer.Option()] = None,
477
- role: Annotated[str, typer.Option(...)] = "user",
478
- name: Annotated[str, typer.Option(...)] = "cli",
479
- ):
480
- account_client, client = await _with_client(
481
- project_id=project_id, room=room, api_key_id=api_key_id, name=name, role=role
482
- )
483
- try:
484
- creds = _parse_creds(cred)
485
- env_map = _parse_keyvals(env)
486
- ports_map = _parse_ports(port)
487
- vars_map = _parse_keyvals(var)
488
-
489
- stream = client.containers.run(
490
- image=image,
491
- command=command,
492
- env=env_map,
493
- mount_path=mount_path,
494
- mount_subpath=mount_subpath,
495
- role=role,
496
- participant_name=participant_name,
497
- ports=ports_map,
498
- credentials=creds,
499
- variables=vars_map or None,
500
- )
501
- result = await _drain_stream_plain(stream)
502
- print(result.model_dump() if hasattr(result, "model_dump") else result)
503
- finally:
504
- await client.__aexit__(None, None, None)
505
- await account_client.close()
506
-
507
-
508
- @app.async_command("run-attached")
509
- async def run_attached(
510
- *,
511
- project_id: ProjectIdOption = None,
512
- room: RoomOption = None,
513
- api_key_id: ApiKeyIdOption = None,
514
- image: Annotated[str, typer.Option(..., help="Image to run")],
515
- command: Annotated[Optional[str], typer.Option(...)] = None,
516
- tty: Annotated[bool, typer.Option("--tty/--no-tty")] = False,
517
- env: Annotated[List[str], typer.Option("--env", "-e", help="KEY=VALUE")] = [],
518
- port: Annotated[
519
- List[str], typer.Option("--port", "-p", help="CONTAINER:HOST")
520
- ] = [],
521
- send: Annotated[
522
- List[str],
523
- typer.Option(
524
- "--send",
525
- help="Optional lines to send to container stdin (each becomes a line)",
526
- ),
527
- ] = [],
528
- name: Annotated[str, typer.Option(...)] = "cli",
529
- role: Annotated[str, typer.Option(...)] = "user",
530
- ):
531
- account_client, client = await _with_client(
532
- project_id=project_id, room=room, api_key_id=api_key_id, name=name, role=role
533
- )
534
- try:
535
- env_map = _parse_keyvals(env)
536
- ports_map = _parse_ports(port)
537
-
538
- tty_obj = client.containers.run_attached(
539
- image=image,
540
- command=command,
541
- env=env_map,
542
- ports=ports_map,
543
- tty=tty,
544
- role=role,
545
- participant_name=name,
546
- )
547
-
548
- # Output reader
549
- async def _read():
550
- async for b in tty_obj.output():
551
- if not b:
552
- continue
553
- try:
554
- sys.stdout.buffer.write(b)
555
- sys.stdout.flush()
556
- except Exception:
557
- # fallback printing
558
- print(b.decode(errors="ignore"), end="")
559
-
560
- # Optional sender (from --send args)
561
- async def _preload():
562
- for line in send:
563
- await tty_obj.write(line.encode("utf-8") + b"\n")
564
-
565
- readers = asyncio.gather(_read(), _preload())
566
- status = await tty_obj.result
567
- await readers
568
- if status is not None:
569
- print(f"\n[green]Exit status:[/green] {status}")
570
- finally:
571
- await client.__aexit__(None, None, None)
572
- await account_client.close()
573
-
574
-
575
- # -------------------------
576
- # Images sub-commands
577
- # -------------------------
578
-
579
- images_app = async_typer.AsyncTyper(help="Image operations")
580
- app.add_typer(images_app, name="images")
581
-
582
-
583
- @images_app.async_command("list")
584
- async def images_list(
585
- *,
586
- project_id: ProjectIdOption = None,
587
- room: RoomOption = None,
588
- api_key_id: ApiKeyIdOption = None,
589
- name: Annotated[str, typer.Option(...)] = "cli",
590
- role: Annotated[str, typer.Option(...)] = "user",
591
- ):
592
- account_client, client = await _with_client(
593
- project_id=project_id, room=room, api_key_id=api_key_id, name=name, role=role
594
- )
595
- try:
596
- imgs = await client.containers.list_images()
597
- print([i.model_dump() for i in imgs])
598
- finally:
599
- await client.__aexit__(None, None, None)
600
- await account_client.close()
601
-
602
-
603
- @images_app.async_command("delete")
604
- async def images_delete(
605
- *,
606
- project_id: ProjectIdOption = None,
607
- room: RoomOption = None,
608
- api_key_id: ApiKeyIdOption = None,
609
- image: Annotated[str, typer.Option(..., help="Image ref/tag to delete")],
610
- name: Annotated[str, typer.Option(...)] = "cli",
611
- role: Annotated[str, typer.Option(...)] = "user",
612
- ):
613
- account_client, client = await _with_client(
614
- project_id=project_id, room=room, api_key_id=api_key_id, name=name, role=role
615
- )
616
- try:
617
- await client.containers.delete_image(image=image)
618
- print("[green]Deleted[/green]")
619
- finally:
620
- await client.__aexit__(None, None, None)
621
- await account_client.close()
622
-
623
-
624
- @images_app.async_command("pull")
625
- async def images_pull(
626
- *,
627
- project_id: ProjectIdOption = None,
628
- room: RoomOption = None,
629
- api_key_id: ApiKeyIdOption = None,
630
- tag: Annotated[str, typer.Option(..., help="Image tag/ref to pull")],
631
- cred: Annotated[
632
- List[str],
633
- typer.Option(
634
- "--cred",
635
- help="Docker creds (username,password) or (registry,username,password)",
636
- ),
637
- ] = [],
638
- name: Annotated[str, typer.Option(...)] = "cli",
639
- role: Annotated[str, typer.Option(...)] = "user",
640
- ):
641
- account_client, client = await _with_client(
642
- project_id=project_id, room=room, api_key_id=api_key_id, name=name, role=role
643
- )
644
- try:
645
- stream = client.containers.pull_image(tag=tag, credentials=_parse_creds(cred))
646
- result = await _drain_stream_plain(stream)
647
- print(result.model_dump() if hasattr(result, "model_dump") else result)
648
- finally:
649
- await client.__aexit__(None, None, None)
650
- await account_client.close()
651
-
652
-
653
- # -------------------------
654
- # Build sub-commands
655
- # -------------------------
656
-
657
- build_app = async_typer.AsyncTyper(help="Build images")
658
- app.add_typer(build_app, name="build")
659
-
660
-
661
- @build_app.async_command("git")
662
- async def build_git(
663
- *,
664
- project_id: ProjectIdOption = None,
665
- room: RoomOption = None,
666
- api_key_id: ApiKeyIdOption = None,
667
- tag: Annotated[str, typer.Option(..., help="Resulting image tag")],
668
- url: Annotated[str, typer.Option(..., help="Git URL")],
669
- ref: Annotated[str, typer.Option(..., help="Git ref/branch/tag")],
670
- cred: Annotated[
671
- List[str],
672
- typer.Option(
673
- "--cred",
674
- help="Docker creds (username,password) or (registry,username,password)",
675
- ),
676
- ] = [],
677
- name: Annotated[str, typer.Option(...)] = "cli",
678
- role: Annotated[str, typer.Option(...)] = "user",
679
- pretty: Annotated[bool, typer.Option(...)] = True,
680
- ):
681
- account_client, client = await _with_client(
682
- project_id=project_id, room=room, api_key_id=api_key_id, name=name, role=role
683
- )
684
- try:
685
- source = BuildSource(git=BuildSourceGit(url=url, ref=ref))
686
- stream = client.containers.build(
687
- tag=tag, source=source, credentials=_parse_creds(cred)
688
- )
689
- await _drain_stream_pretty(stream) if pretty else await _drain_stream_plain(
690
- stream
691
- )
692
- finally:
693
- await client.__aexit__(None, None, None)
694
- await account_client.close()
695
-
696
-
697
- @build_app.async_command("context")
698
- async def build_context(
699
- *,
700
- project_id: ProjectIdOption = None,
701
- room: RoomOption = None,
702
- api_key_id: ApiKeyIdOption = None,
703
- tag: Annotated[str, typer.Option(..., help="Resulting image tag")],
704
- from_dir: Annotated[
705
- Optional[str],
706
- typer.Option(help="Directory to tar.gz as build context"),
707
- ] = None,
708
- dockerfile: Annotated[
709
- Optional[str],
710
- typer.Option(help="Path to a Dockerfile; sends just this file as context"),
711
- ] = None,
712
- dockerfile_inline: Annotated[
713
- Optional[str],
714
- typer.Option(help="Inline Dockerfile text; sends only this as context"),
715
- ] = None,
716
- tgz: Annotated[
717
- Optional[str],
718
- typer.Option(help="Use an existing .tar.gz file as the context"),
719
- ] = None,
720
- cred: Annotated[
721
- List[str],
722
- typer.Option(
723
- "--cred",
724
- help="Docker creds (username,password) or (registry,username,password)",
725
- ),
726
- ] = [],
727
- name: Annotated[str, typer.Option(...)] = "cli",
728
- role: Annotated[str, typer.Option(...)] = "user",
729
- pretty: Annotated[bool, typer.Option(...)] = True,
730
- ):
731
- # Validate mutually exclusive inputs
732
- specified = [x for x in [from_dir, dockerfile, dockerfile_inline, tgz] if x]
733
- if len(specified) != 1:
734
- raise typer.BadParameter(
735
- "Specify exactly one of --from-dir, --dockerfile, --dockerfile-inline, or --tgz"
736
- )
737
-
738
- # Prepare context bytes
739
- if from_dir:
740
- ctx_bytes = _make_targz_from_dir(Path(from_dir).resolve())
741
- elif dockerfile_inline:
742
- ctx_bytes = _make_targz_with_dockerfile_text(dockerfile_inline)
743
- elif dockerfile:
744
- text = Path(dockerfile).read_text(encoding="utf-8")
745
- ctx_bytes = _make_targz_with_dockerfile_text(text)
746
- else:
747
- ctx_bytes = Path(tgz).read_bytes()
748
-
749
- account_client, client = await _with_client(
750
- project_id=project_id, room=room, api_key_id=api_key_id, name=name, role=role
751
- )
752
- try:
753
- source = BuildSource(context=BuildSourceContext(encoding="gzip"))
754
- stream = client.containers.build(
755
- tag=tag,
756
- source=source,
757
- context_bytes=ctx_bytes,
758
- credentials=_parse_creds(cred),
759
- )
760
- await _drain_stream_pretty(stream) if pretty else await _drain_stream_plain(
761
- stream
762
- )
763
- finally:
764
- await client.__aexit__(None, None, None)
765
- await account_client.close()
766
-
767
-
768
- @build_app.async_command("room")
769
- async def build_room(
770
- *,
771
- project_id: ProjectIdOption = None,
772
- room: RoomOption = None,
773
- api_key_id: ApiKeyIdOption = None,
774
- tag: Annotated[str, typer.Option(..., help="Resulting image tag")],
775
- path: Annotated[str, typer.Option(..., help="Room path to a .tar.gz context")],
776
- cred: Annotated[
777
- List[str],
778
- typer.Option(
779
- "--cred",
780
- help="Docker creds (username,password) or (registry,username,password)",
781
- ),
782
- ] = [],
783
- name: Annotated[str, typer.Option(...)] = "cli",
784
- role: Annotated[str, typer.Option(...)] = "user",
785
- pretty: Annotated[bool, typer.Option(...)] = True,
786
- ):
787
- account_client, client = await _with_client(
788
- project_id=project_id, room=room, api_key_id=api_key_id, name=name, role=role
789
- )
790
- try:
791
- source = BuildSource(room=BuildSourceRoom(path=path))
792
- stream = client.containers.build(
793
- tag=tag, source=source, credentials=_parse_creds(cred)
794
- )
795
- await _drain_stream_pretty(stream) if pretty else await _drain_stream_plain(
796
- stream
797
- )
798
- finally:
799
- await client.__aexit__(None, None, None)
800
- await account_client.close()
801
-
802
-
803
- # -------------------------
804
- # Build admin: list/stop
805
- # -------------------------
806
-
807
- builds_app = async_typer.AsyncTyper(help="Inspect or manage running builds")
808
- app.add_typer(builds_app, name="builds")
809
-
810
-
811
- @builds_app.async_command("list")
812
- async def list_builds(
813
- *,
814
- project_id: ProjectIdOption = None,
815
- room: RoomOption = None,
816
- api_key_id: ApiKeyIdOption = None,
817
- name: Annotated[str, typer.Option(...)] = "cli",
818
- role: Annotated[str, typer.Option(...)] = "user",
819
- ):
820
- account_client, client = await _with_client(
821
- project_id=project_id, room=room, api_key_id=api_key_id, name=name, role=role
822
- )
823
- try:
824
- builds = await client.containers.list_builds()
825
- print([b.model_dump() for b in builds])
826
- finally:
827
- await client.__aexit__(None, None, None)
828
- await account_client.close()
829
-
830
-
831
- @builds_app.async_command("stop")
832
- async def stop_build(
833
- *,
834
- project_id: ProjectIdOption = None,
835
- room: RoomOption = None,
836
- api_key_id: ApiKeyIdOption = None,
837
- request_id: Annotated[str, typer.Option(..., help="Build request_id to stop")],
838
- name: Annotated[str, typer.Option(...)] = "cli",
839
- role: Annotated[str, typer.Option(...)] = "user",
840
- ):
841
- account_client, client = await _with_client(
842
- project_id=project_id, room=room, api_key_id=api_key_id, name=name, role=role
843
- )
844
- try:
845
- await client.containers.stop_build(request_id=request_id)
846
- print("[green]Stopped[/green]")
847
- finally:
848
- await client.__aexit__(None, None, None)
849
- await account_client.close()