runtime-sdk 0.4.11__tar.gz → 0.4.13__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.11
3
+ Version: 0.4.13
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
@@ -47,11 +47,16 @@ Or pass `--base-url` per command.
47
47
 
48
48
  ```bash
49
49
  # Auth
50
+ runtime # first run in a real terminal prompts for email + verification code, then creates your first computer
51
+ runtime login # interactive email + verification code flow
50
52
  runtime login you@example.com
51
53
  runtime verify 123456
52
54
  runtime whoami
53
55
  runtime logout
54
56
  runtime login --api-key rt_live_...
57
+ runtime api-keys list
58
+ runtime api-keys create "my laptop"
59
+ runtime api-keys revoke 123
55
60
 
56
61
  # Computers
57
62
  runtime create # creates a computer with the starter app already published
@@ -28,11 +28,16 @@ Or pass `--base-url` per command.
28
28
 
29
29
  ```bash
30
30
  # Auth
31
+ runtime # first run in a real terminal prompts for email + verification code, then creates your first computer
32
+ runtime login # interactive email + verification code flow
31
33
  runtime login you@example.com
32
34
  runtime verify 123456
33
35
  runtime whoami
34
36
  runtime logout
35
37
  runtime login --api-key rt_live_...
38
+ runtime api-keys list
39
+ runtime api-keys create "my laptop"
40
+ runtime api-keys revoke 123
36
41
 
37
42
  # Computers
38
43
  runtime create # creates a computer with the starter app already published
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runtime-sdk"
3
- version = "0.4.11"
3
+ version = "0.4.13"
4
4
  description = "Runtime Python SDK and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -165,6 +165,14 @@ def build_parser() -> argparse.ArgumentParser:
165
165
  subparsers.add_parser("whoami")
166
166
  subparsers.add_parser("logout")
167
167
 
168
+ api_keys_cmd = subparsers.add_parser("api-keys", help="Manage Runtime API keys")
169
+ api_keys_subparsers = api_keys_cmd.add_subparsers(dest="api_keys_action", required=True)
170
+ api_keys_subparsers.add_parser("list", aliases=["ls"], help="List API keys")
171
+ api_keys_create = api_keys_subparsers.add_parser("create", help="Create a new API key")
172
+ api_keys_create.add_argument("label", nargs="?", default=None, help="Human-friendly label for the new API key")
173
+ api_keys_revoke = api_keys_subparsers.add_parser("revoke", help="Revoke an API key")
174
+ api_keys_revoke.add_argument("id", nargs="?", default=None, help="API key id")
175
+
168
176
  create_cmd = subparsers.add_parser("create", help="Create a new computer with the starter app published by default")
169
177
  create_cmd.add_argument(
170
178
  "name",
@@ -255,11 +263,6 @@ def main(argv: list[str] | None = None) -> int:
255
263
  parser = build_parser()
256
264
  args = parser.parse_args(argv)
257
265
 
258
- # Bare `runtime` → create flow.
259
- if args.command is None:
260
- args.command = "create"
261
- args.name = None
262
-
263
266
  try:
264
267
  config = load_config()
265
268
  base_url = (
@@ -270,6 +273,13 @@ def main(argv: list[str] | None = None) -> int:
270
273
  )
271
274
  config.base_url = base_url
272
275
 
276
+ # Bare `runtime` → first-run onboarding for interactive users, create otherwise.
277
+ if args.command is None:
278
+ if _interactive() and not config.api_key:
279
+ return handle_onboarding(config)
280
+ args.command = "create"
281
+ args.name = None
282
+
273
283
  if args.command == "signup":
274
284
  return handle_signup(config, args.email, args.name)
275
285
  if args.command == "verify":
@@ -280,6 +290,14 @@ def main(argv: list[str] | None = None) -> int:
280
290
  return handle_whoami(config)
281
291
  if args.command == "logout":
282
292
  return handle_logout(config)
293
+ if args.command == "api-keys":
294
+ if args.api_keys_action in ("list", "ls"):
295
+ return handle_api_keys_list(config)
296
+ if args.api_keys_action == "create":
297
+ return handle_api_keys_create(config, args.label)
298
+ if args.api_keys_action == "revoke":
299
+ return handle_api_keys_revoke(config, args.id)
300
+ return report_error("unknown api-keys command")
283
301
  if args.command == "create":
284
302
  return handle_create(
285
303
  config,
@@ -543,7 +561,7 @@ def _spawn_proxy_agent(base_url: str, api_key: str, computer_id: str, display_na
543
561
  finally:
544
562
  log_file.close()
545
563
 
546
- deadline = time.time() + 4.0
564
+ deadline = time.time() + max(4.0, DEFAULT_WARMUP_TIMEOUT + 2.0)
547
565
  state_path = _proxy_state_path(local_port)
548
566
  while time.time() < deadline:
549
567
  if state_path.exists():
@@ -715,8 +733,8 @@ def _computer_url_label(c: dict[str, Any]) -> str:
715
733
 
716
734
 
717
735
 
718
- def _computer_created_label(c: dict[str, Any]) -> str:
719
- raw = str(c.get("created_at") or "").strip()
736
+ def _relative_time_label(value: Any) -> str:
737
+ raw = str(value or "").strip()
720
738
  if not raw:
721
739
  return ""
722
740
  try:
@@ -738,6 +756,11 @@ def _computer_created_label(c: dict[str, Any]) -> str:
738
756
 
739
757
 
740
758
 
759
+ def _computer_created_label(c: dict[str, Any]) -> str:
760
+ return _relative_time_label(c.get("created_at"))
761
+
762
+
763
+
741
764
  def _computer_status_style(status: str) -> str:
742
765
  if "cold" in status:
743
766
  return "bold cyan"
@@ -1072,6 +1095,84 @@ def _render_whoami_panel(payload: dict[str, Any]) -> None:
1072
1095
  _UI.console().print(Panel(body, title="whoami", border_style="cyan", expand=False))
1073
1096
 
1074
1097
 
1098
+
1099
+ def _api_key_status_label(key: dict[str, Any]) -> str:
1100
+ if key.get("revoked_at"):
1101
+ return "revoked"
1102
+ if key.get("last_used_at"):
1103
+ return "used"
1104
+ return "unused"
1105
+
1106
+
1107
+
1108
+ def _api_key_status_style(status: str) -> str:
1109
+ if status == "revoked":
1110
+ return "bold red"
1111
+ if status == "used":
1112
+ return "bold green"
1113
+ return "yellow"
1114
+
1115
+
1116
+
1117
+ def _render_api_keys_table(payload: dict[str, Any]) -> None:
1118
+ mods = _UI.rich()
1119
+ Table = mods["Table"]
1120
+ api_keys = payload.get("api_keys") or []
1121
+ if not isinstance(api_keys, list) or not api_keys:
1122
+ _UI.console().print("[yellow]no api keys found[/yellow]")
1123
+ return
1124
+
1125
+ table = Table(title="API keys", expand=True, box=mods["box"].SIMPLE_HEAVY)
1126
+ table.add_column("ID", style="dim", no_wrap=True)
1127
+ table.add_column("Label", style="bold white")
1128
+ table.add_column("Prefix", style="white", no_wrap=True)
1129
+ table.add_column("Created", style="dim", no_wrap=True, justify="right")
1130
+ table.add_column("Last used", style="dim", no_wrap=True, justify="right")
1131
+ table.add_column("Status", no_wrap=True)
1132
+
1133
+ for entry in api_keys:
1134
+ if not isinstance(entry, dict):
1135
+ continue
1136
+ status = _api_key_status_label(entry)
1137
+ style = _api_key_status_style(status)
1138
+ table.add_row(
1139
+ str(entry.get("id") or "—"),
1140
+ str(entry.get("label") or "—"),
1141
+ str(entry.get("key_prefix") or "—"),
1142
+ _relative_time_label(entry.get("created_at")) or "—",
1143
+ _relative_time_label(entry.get("last_used_at")) or "—",
1144
+ f"[{style}]{status}[/{style}]",
1145
+ )
1146
+
1147
+ _UI.console().print(table)
1148
+
1149
+
1150
+
1151
+ def _render_api_key_created(payload: dict[str, Any]) -> None:
1152
+ mods = _UI.rich()
1153
+ Panel, Text = mods["Panel"], mods["Text"]
1154
+ key = payload.get("key") or {}
1155
+ secret = str(payload.get("api_key") or "")
1156
+ body = Text()
1157
+ rows = [
1158
+ ("id", key.get("id")),
1159
+ ("label", key.get("label")),
1160
+ ("prefix", key.get("key_prefix")),
1161
+ ]
1162
+ for label, value in rows:
1163
+ if value is None:
1164
+ continue
1165
+ body.append(f"{label:<12}", style="dim")
1166
+ body.append(f"{value}\n")
1167
+ if secret:
1168
+ body.append("save now ", style="bold yellow")
1169
+ body.append("this secret is only shown once\n", style="yellow")
1170
+ body.append("api key ", style="dim")
1171
+ body.append(secret, style="bold white")
1172
+ body.append("\n")
1173
+ _UI.console().print(Panel(body, title="api key created", border_style="cyan", expand=False))
1174
+
1175
+
1075
1176
  # --------------------------------------------------------------------------- #
1076
1177
  # Command handlers #
1077
1178
  # --------------------------------------------------------------------------- #
@@ -1082,14 +1183,7 @@ def _looks_like_api_key(value: str | None) -> bool:
1082
1183
  return candidate.startswith("rt_live_")
1083
1184
 
1084
1185
 
1085
- def _start_verification_flow(config: RuntimeConfig, email: str | None, name: str | None) -> int:
1086
- if email is None:
1087
- if not _interactive():
1088
- return report_error("email is required")
1089
- email = _prompt_text("Email")
1090
- if not email:
1091
- return report_error("email is required")
1092
-
1186
+ def _begin_verification_flow(config: RuntimeConfig, email: str, name: str | None) -> dict[str, Any]:
1093
1187
  client = RuntimeClient(base_url=config.base_url)
1094
1188
  result = client.start_verification(email, name)
1095
1189
  config.pending_signup = PendingSignup(
@@ -1098,6 +1192,19 @@ def _start_verification_flow(config: RuntimeConfig, email: str | None, name: str
1098
1192
  expires_at=result.get("expires_at"),
1099
1193
  )
1100
1194
  save_config(config)
1195
+ return result
1196
+
1197
+
1198
+
1199
+ def _start_verification_flow(config: RuntimeConfig, email: str | None, name: str | None) -> int:
1200
+ if email is None:
1201
+ if not _interactive():
1202
+ return report_error("email is required")
1203
+ email = _prompt_text("Email")
1204
+ if not email:
1205
+ return report_error("email is required")
1206
+
1207
+ result = _begin_verification_flow(config, email, name)
1101
1208
 
1102
1209
  def render(payload: dict[str, Any]) -> None:
1103
1210
  _UI.console().print(
@@ -1110,6 +1217,33 @@ def _start_verification_flow(config: RuntimeConfig, email: str | None, name: str
1110
1217
  return report_success(result, render)
1111
1218
 
1112
1219
 
1220
+
1221
+ def _interactive_email_auth_flow(config: RuntimeConfig, email: str | None, name: str | None) -> int:
1222
+ pending = config.pending_signup
1223
+ if email is None and pending is not None and pending.email:
1224
+ _UI.console().print(
1225
+ f"[green]✓[/green] verification code already sent to [bold]{pending.email}[/bold]"
1226
+ )
1227
+ code = _prompt_text("Verification code")
1228
+ if not code:
1229
+ return report_error("verification code is required")
1230
+ return handle_verify(config, code, pending.flow_id)
1231
+
1232
+ if email is None:
1233
+ email = _prompt_text("Email")
1234
+ if not email:
1235
+ return report_error("email is required")
1236
+
1237
+ _begin_verification_flow(config, email, name)
1238
+ _UI.console().print(
1239
+ f"[green]✓[/green] verification code sent to [bold]{email}[/bold]"
1240
+ )
1241
+ code = _prompt_text("Verification code")
1242
+ if not code:
1243
+ return report_error("verification code is required")
1244
+ return handle_verify(config, code, None)
1245
+
1246
+
1113
1247
  def _login_with_api_key(config: RuntimeConfig, api_key: str | None) -> int:
1114
1248
  if api_key is None or api_key == "":
1115
1249
  if not _interactive():
@@ -1136,6 +1270,8 @@ def _login_with_api_key(config: RuntimeConfig, api_key: str | None) -> int:
1136
1270
 
1137
1271
 
1138
1272
  def handle_signup(config: RuntimeConfig, email: str | None, name: str | None) -> int:
1273
+ if _interactive():
1274
+ return _interactive_email_auth_flow(config, email, name)
1139
1275
  return _start_verification_flow(config, email, name)
1140
1276
 
1141
1277
 
@@ -1185,6 +1321,8 @@ def handle_login(
1185
1321
  if _looks_like_api_key(identifier):
1186
1322
  return _login_with_api_key(config, identifier)
1187
1323
 
1324
+ if _interactive():
1325
+ return _interactive_email_auth_flow(config, identifier, name)
1188
1326
  return _start_verification_flow(config, identifier, name)
1189
1327
 
1190
1328
 
@@ -1220,6 +1358,64 @@ def handle_logout(config: RuntimeConfig) -> int:
1220
1358
  return report_success({"message": message}, render)
1221
1359
 
1222
1360
 
1361
+
1362
+ def handle_api_keys_list(config: RuntimeConfig) -> int:
1363
+ if (err := _require_api_key(config)) is not None:
1364
+ return err
1365
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1366
+ return report_success(client.list_api_keys(), _render_api_keys_table)
1367
+
1368
+
1369
+
1370
+ def handle_api_keys_create(config: RuntimeConfig, label: str | None) -> int:
1371
+ if (err := _require_api_key(config)) is not None:
1372
+ return err
1373
+ if label is None:
1374
+ if not _interactive():
1375
+ return report_error("label is required")
1376
+ label = _prompt_text("API key label")
1377
+ if not label:
1378
+ return report_error("label is required")
1379
+
1380
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1381
+ result = client.create_api_key(label)
1382
+ return report_success(result, _render_api_key_created)
1383
+
1384
+
1385
+
1386
+ def handle_api_keys_revoke(config: RuntimeConfig, key_id: str | None) -> int:
1387
+ if (err := _require_api_key(config)) is not None:
1388
+ return err
1389
+ if key_id is None:
1390
+ if not _interactive():
1391
+ return report_error("api key id is required")
1392
+ key_id = _prompt_text("API key id")
1393
+ if not key_id:
1394
+ return report_error("api key id is required")
1395
+
1396
+ try:
1397
+ resolved_key_id = int(str(key_id).strip())
1398
+ except ValueError:
1399
+ return report_error("api key id must be a number")
1400
+
1401
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1402
+ client.revoke_api_key(resolved_key_id)
1403
+
1404
+ def render(_: dict[str, Any]) -> None:
1405
+ _UI.console().print(f"[green]✓[/green] api key {resolved_key_id} revoked")
1406
+
1407
+ return report_success({"id": resolved_key_id, "message": f"api key {resolved_key_id} revoked"}, render)
1408
+
1409
+
1410
+
1411
+ def handle_onboarding(config: RuntimeConfig) -> int:
1412
+ code = handle_login(config, None, None, None)
1413
+ if code != 0:
1414
+ return code
1415
+ return handle_create(config, None, None, None, None)
1416
+
1417
+
1418
+
1223
1419
  def handle_create(
1224
1420
  config: RuntimeConfig,
1225
1421
  name: str | None,
@@ -1633,6 +1829,10 @@ def handle_proxy_start(config: RuntimeConfig, computer_id: str | None, specs: li
1633
1829
  if started_ports:
1634
1830
  _stop_proxy_ports(started_ports, stop_all=False)
1635
1831
  return report_error(str(exc), status_code=exc.status_code)
1832
+ except Exception as exc:
1833
+ if started_ports:
1834
+ _stop_proxy_ports(started_ports, stop_all=False)
1835
+ return report_error(str(exc))
1636
1836
  started_ports.append(local_port)
1637
1837
  started.append({"computer_id": resolved_id, "computer": computer_id, "local_port": local_port, "remote_port": remote_port})
1638
1838
 
@@ -1988,7 +2188,7 @@ def _strip_terminal_noise(text: str) -> str:
1988
2188
  filtered: list[str] = []
1989
2189
  for line in lines:
1990
2190
  stripped = line.rstrip("\r\n")
1991
- if stripped == "Running bootstrap command: export PS1='\\[\\033[1;34m\\]runtime@\\h\\[\\033[0m\\]:\\w\\$ '":
2191
+ if stripped.startswith("Running bootstrap command: export PS1='\\[\\033[1;34m\\]runtime@"):
1992
2192
  continue
1993
2193
  if stripped == "Connected! Press Ctrl+] to exit.":
1994
2194
  continue
@@ -43,6 +43,15 @@ class RuntimeClient:
43
43
  def logout(self) -> dict[str, Any]:
44
44
  return self._request("POST", "/api/auth/logout", auth_required=True)
45
45
 
46
+ def list_api_keys(self) -> dict[str, Any]:
47
+ return self._request("GET", "/api/api-keys", auth_required=True)
48
+
49
+ def create_api_key(self, label: str) -> dict[str, Any]:
50
+ return self._request("POST", "/api/api-keys", json={"label": label}, auth_required=True)
51
+
52
+ def revoke_api_key(self, key_id: int) -> dict[str, Any]:
53
+ return self._request("POST", f"/api/api-keys/{key_id}/revoke", auth_required=True)
54
+
46
55
  # --- Computers ---
47
56
 
48
57
  def create_computer(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.4.11
3
+ Version: 0.4.13
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
@@ -47,11 +47,16 @@ Or pass `--base-url` per command.
47
47
 
48
48
  ```bash
49
49
  # Auth
50
+ runtime # first run in a real terminal prompts for email + verification code, then creates your first computer
51
+ runtime login # interactive email + verification code flow
50
52
  runtime login you@example.com
51
53
  runtime verify 123456
52
54
  runtime whoami
53
55
  runtime logout
54
56
  runtime login --api-key rt_live_...
57
+ runtime api-keys list
58
+ runtime api-keys create "my laptop"
59
+ runtime api-keys revoke 123
55
60
 
56
61
  # Computers
57
62
  runtime create # creates a computer with the starter app already published
File without changes