runtime-sdk 0.4.14__tar.gz → 0.4.16__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.4.14
3
+ Version: 0.4.16
4
4
  Summary: Runtime Python SDK and CLI
5
5
  Project-URL: Repository, https://github.com/The-Money-Company-Limited/runtimevm
6
6
  Project-URL: Issues, https://github.com/The-Money-Company-Limited/runtimevm/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runtime-sdk"
3
- version = "0.4.14"
3
+ version = "0.4.16"
4
4
  description = "Runtime Python SDK and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -15,6 +15,7 @@ import termios
15
15
  import threading
16
16
  import time
17
17
  import tty
18
+ import webbrowser
18
19
  from datetime import datetime, timezone
19
20
  from pathlib import Path
20
21
  from typing import Any, Callable
@@ -173,6 +174,28 @@ def build_parser() -> argparse.ArgumentParser:
173
174
  api_keys_revoke = api_keys_subparsers.add_parser("revoke", help="Revoke an API key")
174
175
  api_keys_revoke.add_argument("id", nargs="?", default=None, help="API key id")
175
176
 
177
+ secrets_cmd = subparsers.add_parser("secrets", help="Manage account-level secret sets")
178
+ secrets_subparsers = secrets_cmd.add_subparsers(dest="secrets_action", required=True)
179
+ secrets_subparsers.add_parser("list", aliases=["ls"], help="List secret sets")
180
+ secrets_show = secrets_subparsers.add_parser("show", help="Show one secret set")
181
+ secrets_show.add_argument("slug", nargs="?", default=None, help="Secret set slug")
182
+ secrets_set = secrets_subparsers.add_parser("set", help="Create or replace a secret set from KEY=VALUE pairs")
183
+ secrets_set.add_argument("slug", nargs="?", default=None, help="Secret set slug")
184
+ secrets_set.add_argument("pairs", nargs="*", help="KEY=VALUE pairs")
185
+ secrets_import = secrets_subparsers.add_parser("import", help="Create or replace a secret set from a .env file")
186
+ secrets_import.add_argument("slug", nargs="?", default=None, help="Secret set slug")
187
+ secrets_import.add_argument("--env-file", required=True, help="Path to .env file")
188
+ secrets_delete = secrets_subparsers.add_parser("delete", aliases=["rm"], help="Delete a secret set")
189
+ secrets_delete.add_argument("slug", nargs="?", default=None, help="Secret set slug")
190
+
191
+ integrations_cmd = subparsers.add_parser("integrations", help="Connect account-level integrations")
192
+ integrations_subparsers = integrations_cmd.add_subparsers(dest="integrations_action", required=True)
193
+ integrations_connect = integrations_subparsers.add_parser("connect", help="Connect an integration")
194
+ integrations_connect.add_argument("provider", choices=["github"])
195
+ integrations_connect.add_argument("--slug", default="github", help="Integration slug to create (default: github)")
196
+ integrations_connect.add_argument("--owner", default=None, help="GitHub username or org where the app is installed")
197
+ integrations_connect.add_argument("--no-open", action="store_true", help="Print the install URL instead of opening a browser")
198
+
176
199
  create_cmd = subparsers.add_parser("create", help="Create a new computer with the starter app published by default")
177
200
  create_cmd.add_argument(
178
201
  "name",
@@ -297,6 +320,22 @@ def main(argv: list[str] | None = None) -> int:
297
320
  if args.api_keys_action == "revoke":
298
321
  return handle_api_keys_revoke(config, args.id)
299
322
  return report_error("unknown api-keys command")
323
+ if args.command == "secrets":
324
+ if args.secrets_action in ("list", "ls"):
325
+ return handle_secrets_list(config)
326
+ if args.secrets_action == "show":
327
+ return handle_secrets_show(config, args.slug)
328
+ if args.secrets_action == "set":
329
+ return handle_secrets_set(config, args.slug, args.pairs)
330
+ if args.secrets_action == "import":
331
+ return handle_secrets_import(config, args.slug, args.env_file)
332
+ if args.secrets_action in ("delete", "rm"):
333
+ return handle_secrets_delete(config, args.slug)
334
+ return report_error("unknown secrets command")
335
+ if args.command == "integrations":
336
+ if args.integrations_action == "connect":
337
+ return handle_integrations_connect(config, args.provider, args.slug, args.owner, no_open=args.no_open)
338
+ return report_error("unknown integrations command")
300
339
  if args.command == "create":
301
340
  return handle_create(
302
341
  config,
@@ -1172,6 +1211,45 @@ def _render_api_key_created(payload: dict[str, Any]) -> None:
1172
1211
  _UI.console().print(Panel(body, title="api key created", border_style="cyan", expand=False))
1173
1212
 
1174
1213
 
1214
+
1215
+ def _render_secret_sets_table(payload: dict[str, Any]) -> None:
1216
+ mods = _UI.rich()
1217
+ Table = mods["Table"]
1218
+ secret_sets = payload.get("secret_sets") or []
1219
+ if not isinstance(secret_sets, list) or not secret_sets:
1220
+ _UI.console().print("[yellow]no secret sets found[/yellow]")
1221
+ return
1222
+
1223
+ table = Table(title="Secret sets", expand=True, box=mods["box"].SIMPLE_HEAVY)
1224
+ table.add_column("Slug", style="bold white")
1225
+ table.add_column("Keys", style="white")
1226
+ table.add_column("Updated", style="dim", no_wrap=True, justify="right")
1227
+
1228
+ for entry in secret_sets:
1229
+ if not isinstance(entry, dict):
1230
+ continue
1231
+ keys = entry.get("keys") or []
1232
+ table.add_row(
1233
+ str(entry.get("slug") or "—"),
1234
+ ", ".join(str(key) for key in keys) or "—",
1235
+ _relative_time_label(entry.get("updated_at")) or "—",
1236
+ )
1237
+
1238
+ _UI.console().print(table)
1239
+
1240
+
1241
+
1242
+ def _render_secret_set_panel(payload: dict[str, Any]) -> None:
1243
+ mods = _UI.rich()
1244
+ Panel, Text = mods["Panel"], mods["Text"]
1245
+ body = Text()
1246
+ body.append(f"{'slug':<12}", style="dim")
1247
+ body.append(f"{payload.get('slug') or '—'}\n")
1248
+ body.append(f"{'keys':<12}", style="dim")
1249
+ body.append(", ".join(str(key) for key in (payload.get("keys") or [])) or "—")
1250
+ _UI.console().print(Panel(body, title="secret set", border_style="cyan", expand=False))
1251
+
1252
+
1175
1253
  # --------------------------------------------------------------------------- #
1176
1254
  # Command handlers #
1177
1255
  # --------------------------------------------------------------------------- #
@@ -1407,6 +1485,200 @@ def handle_api_keys_revoke(config: RuntimeConfig, key_id: str | None) -> int:
1407
1485
 
1408
1486
 
1409
1487
 
1488
+ def _coerce_secret_set_slug(slug: str | None) -> str | None:
1489
+ value = str(slug or "").strip()
1490
+ return value or None
1491
+
1492
+
1493
+
1494
+ def _parse_secret_pairs(pairs: list[str] | None) -> dict[str, str]:
1495
+ values: dict[str, str] = {}
1496
+ for pair in pairs or []:
1497
+ if "=" not in pair:
1498
+ raise RuntimeConfigError(f"invalid secret pair: {pair}")
1499
+ key, value = pair.split("=", 1)
1500
+ key = key.strip()
1501
+ if not key:
1502
+ raise RuntimeConfigError(f"invalid secret pair: {pair}")
1503
+ values[key] = value
1504
+ return values
1505
+
1506
+
1507
+
1508
+ def _parse_env_file(path: str) -> dict[str, str]:
1509
+ try:
1510
+ lines = Path(path).read_text(encoding="utf-8").splitlines()
1511
+ except OSError as exc:
1512
+ raise RuntimeConfigError(f"failed to read env file: {exc}") from exc
1513
+ values: dict[str, str] = {}
1514
+ for raw_line in lines:
1515
+ line = raw_line.strip()
1516
+ if not line or line.startswith("#"):
1517
+ continue
1518
+ if line.startswith("export "):
1519
+ line = line[len("export "):].strip()
1520
+ if "=" not in line:
1521
+ raise RuntimeConfigError(f"invalid env line: {raw_line}")
1522
+ key, value = line.split("=", 1)
1523
+ key = key.strip()
1524
+ if not key:
1525
+ raise RuntimeConfigError(f"invalid env line: {raw_line}")
1526
+ value = value.strip()
1527
+ if value and value[0] == value[-1] and value[0] in {'\"', "'"}:
1528
+ value = value[1:-1]
1529
+ values[key] = value
1530
+ return values
1531
+
1532
+
1533
+
1534
+ def handle_secrets_list(config: RuntimeConfig) -> int:
1535
+ if (err := _require_api_key(config)) is not None:
1536
+ return err
1537
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1538
+ return report_success(client.list_secret_sets(), _render_secret_sets_table)
1539
+
1540
+
1541
+
1542
+ def handle_secrets_show(config: RuntimeConfig, slug: str | None) -> int:
1543
+ if (err := _require_api_key(config)) is not None:
1544
+ return err
1545
+ resolved_slug = _coerce_secret_set_slug(slug)
1546
+ if resolved_slug is None:
1547
+ if not _interactive():
1548
+ return report_error("secret set slug is required")
1549
+ resolved_slug = _prompt_text("Secret set slug")
1550
+ if not resolved_slug:
1551
+ return report_error("secret set slug is required")
1552
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1553
+ return report_success(client.get_secret_set(resolved_slug), _render_secret_set_panel)
1554
+
1555
+
1556
+
1557
+ def handle_secrets_set(config: RuntimeConfig, slug: str | None, pairs: list[str] | None) -> int:
1558
+ if (err := _require_api_key(config)) is not None:
1559
+ return err
1560
+ resolved_slug = _coerce_secret_set_slug(slug)
1561
+ if resolved_slug is None:
1562
+ if not _interactive():
1563
+ return report_error("secret set slug is required")
1564
+ resolved_slug = _prompt_text("Secret set slug")
1565
+ if not resolved_slug:
1566
+ return report_error("secret set slug is required")
1567
+ values = _parse_secret_pairs(pairs)
1568
+ if not values:
1569
+ return report_error("at least one KEY=VALUE pair is required")
1570
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1571
+ secret_set = client.put_secret_set(resolved_slug, values)
1572
+ return report_success(
1573
+ {"secret_set": secret_set},
1574
+ lambda payload: _UI.console().print(f"[green]✓[/green] saved [bold]{payload['secret_set'].get('slug', resolved_slug)}[/bold]"),
1575
+ )
1576
+
1577
+
1578
+
1579
+ def handle_secrets_import(config: RuntimeConfig, slug: str | None, env_file: str) -> int:
1580
+ if (err := _require_api_key(config)) is not None:
1581
+ return err
1582
+ resolved_slug = _coerce_secret_set_slug(slug)
1583
+ if resolved_slug is None:
1584
+ if not _interactive():
1585
+ return report_error("secret set slug is required")
1586
+ resolved_slug = _prompt_text("Secret set slug")
1587
+ if not resolved_slug:
1588
+ return report_error("secret set slug is required")
1589
+ values = _parse_env_file(env_file)
1590
+ if not values:
1591
+ return report_error("env file did not contain any secrets")
1592
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1593
+ secret_set = client.put_secret_set(resolved_slug, values)
1594
+ return report_success(
1595
+ {"secret_set": secret_set},
1596
+ lambda payload: _UI.console().print(f"[green]✓[/green] saved [bold]{payload['secret_set'].get('slug', resolved_slug)}[/bold] from [bold]{env_file}[/bold]"),
1597
+ )
1598
+
1599
+
1600
+
1601
+ def handle_secrets_delete(config: RuntimeConfig, slug: str | None) -> int:
1602
+ if (err := _require_api_key(config)) is not None:
1603
+ return err
1604
+ resolved_slug = _coerce_secret_set_slug(slug)
1605
+ if resolved_slug is None:
1606
+ if not _interactive():
1607
+ return report_error("secret set slug is required")
1608
+ resolved_slug = _prompt_text("Secret set slug")
1609
+ if not resolved_slug:
1610
+ return report_error("secret set slug is required")
1611
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1612
+ client.delete_secret_set(resolved_slug)
1613
+ return report_success(
1614
+ {"slug": resolved_slug, "deleted": True},
1615
+ lambda payload: _UI.console().print(f"[green]✓[/green] deleted [bold]{payload['slug']}[/bold]"),
1616
+ )
1617
+
1618
+
1619
+
1620
+ def handle_integrations_connect(
1621
+ config: RuntimeConfig,
1622
+ provider: str,
1623
+ slug: str | None,
1624
+ owner: str | None,
1625
+ *,
1626
+ no_open: bool = False,
1627
+ ) -> int:
1628
+ if (err := _require_api_key(config)) is not None:
1629
+ return err
1630
+ if provider != "github":
1631
+ return report_error(f"unsupported integration provider: {provider}")
1632
+
1633
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1634
+ payload = _with_spinner("getting GitHub install link…", lambda: client.start_github_connect(slug=slug))
1635
+ install_url = str(payload.get("install_url") or "").strip()
1636
+ resolved_slug = str(payload.get("slug") or slug or "github").strip() or "github"
1637
+ if not install_url:
1638
+ return report_error("server did not return a GitHub install URL")
1639
+
1640
+ opened = False
1641
+ if not no_open:
1642
+ with contextlib.suppress(Exception):
1643
+ opened = bool(webbrowser.open(install_url))
1644
+
1645
+ if _interactive():
1646
+ action = "opened in your browser" if opened else "copy this URL into your browser"
1647
+ _UI.console().print(f"[cyan]→[/cyan] {action}: [link={install_url}]{install_url}[/link]")
1648
+ _UI.console().print("[dim]Install the RuntimeVM GitHub App, then come back here.[/dim]")
1649
+ input("Press Enter after the GitHub App is installed... ")
1650
+ if not owner:
1651
+ owner = _prompt_text("GitHub owner (username or org)")
1652
+ elif not owner:
1653
+ return report_error("--owner is required without a TTY")
1654
+
1655
+ resolved_owner = str(owner or "").strip()
1656
+ if not resolved_owner:
1657
+ return report_error("GitHub owner is required")
1658
+
1659
+ integration = _with_spinner(
1660
+ "connecting GitHub…",
1661
+ lambda: client.create_integration(
1662
+ slug=resolved_slug,
1663
+ provider="github",
1664
+ transport="scoped_credential",
1665
+ backend="native",
1666
+ credential_source="external_account",
1667
+ credential_ref=resolved_owner,
1668
+ config={},
1669
+ ),
1670
+ )
1671
+
1672
+ def render(result: dict[str, Any]) -> None:
1673
+ connected = result.get("integration") or {}
1674
+ _UI.console().print(
1675
+ f"[green]✓[/green] connected [bold]{connected.get('slug', resolved_slug)}[/bold] → [bold]{connected.get('credential_ref', resolved_owner)}[/bold]"
1676
+ )
1677
+
1678
+ return report_success({"integration": integration, "install_url": install_url}, render)
1679
+
1680
+
1681
+
1410
1682
  def handle_onboarding(config: RuntimeConfig) -> int:
1411
1683
  code = handle_login(config, None, None, None)
1412
1684
  if code != 0:
@@ -52,6 +52,48 @@ class RuntimeClient:
52
52
  def revoke_api_key(self, key_id: int) -> dict[str, Any]:
53
53
  return self._request("POST", f"/api/api-keys/{key_id}/revoke", auth_required=True)
54
54
 
55
+ # --- Integrations ---
56
+
57
+ def start_github_connect(self, *, slug: str | None = None) -> dict[str, Any]:
58
+ body: dict[str, Any] = {}
59
+ if slug:
60
+ body["slug"] = slug
61
+ return self._request("POST", "/api/integrations/github/connect", json=body, auth_required=True)
62
+
63
+ def create_integration(
64
+ self,
65
+ *,
66
+ slug: str,
67
+ provider: str,
68
+ transport: str,
69
+ backend: str,
70
+ credential_source: str,
71
+ credential_ref: str,
72
+ config: dict[str, Any] | None = None,
73
+ ) -> dict[str, Any]:
74
+ body: dict[str, Any] = {
75
+ "slug": slug,
76
+ "provider": provider,
77
+ "transport": transport,
78
+ "backend": backend,
79
+ "credential_source": credential_source,
80
+ "credential_ref": credential_ref,
81
+ "config": config or {},
82
+ }
83
+ return self._request("POST", "/api/integrations", json=body, auth_required=True)
84
+
85
+ def list_secret_sets(self) -> dict[str, Any]:
86
+ return self._request("GET", "/api/secrets", auth_required=True)
87
+
88
+ def get_secret_set(self, slug: str) -> dict[str, Any]:
89
+ return self._request("GET", f"/api/secrets/{slug}", auth_required=True)
90
+
91
+ def put_secret_set(self, slug: str, values: dict[str, str]) -> dict[str, Any]:
92
+ return self._request("PUT", f"/api/secrets/{slug}", json={"values": values}, auth_required=True)
93
+
94
+ def delete_secret_set(self, slug: str) -> dict[str, Any]:
95
+ return self._request("DELETE", f"/api/secrets/{slug}", auth_required=True)
96
+
55
97
  # --- Computers ---
56
98
 
57
99
  def create_computer(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.4.14
3
+ Version: 0.4.16
4
4
  Summary: Runtime Python SDK and CLI
5
5
  Project-URL: Repository, https://github.com/The-Money-Company-Limited/runtimevm
6
6
  Project-URL: Issues, https://github.com/The-Money-Company-Limited/runtimevm/issues
File without changes
File without changes