runtime-sdk 0.4.49__py3-none-win_amd64.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,578 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import difflib
5
+ import sys
6
+ import time
7
+ from typing import Any, Callable
8
+ from urllib.parse import urlparse
9
+
10
+ from runtime_sdk import arguments, output as cli_output, terminal
11
+ from runtime_sdk.auth import authenticated_client
12
+ from runtime_sdk.client import RuntimeAPIError, RuntimeClient
13
+ from runtime_sdk.config import RuntimeConfig
14
+
15
+
16
+ def close_matches(ref: str, candidates: list[str], limit: int = 3) -> list[str]:
17
+ """Return up to `limit` candidates that look similar to ref."""
18
+ ref = (ref or "").strip()
19
+ if not ref:
20
+ return []
21
+ pool = [c for c in candidates if c]
22
+ return difflib.get_close_matches(ref, pool, n=limit, cutoff=0.4)
23
+
24
+
25
+ def computer_power_state(c: dict[str, Any]) -> str:
26
+ power = c.get("power_state")
27
+ if power is not None:
28
+ return str(power or "?")
29
+ status = str(c.get("status") or "?")
30
+ if status in ("warm", "cold"):
31
+ return status
32
+ if status == "deleted":
33
+ return "cold"
34
+ return "warm"
35
+
36
+
37
+ def computer_internal_status(c: dict[str, Any]) -> str:
38
+ internal = c.get("internal_status")
39
+ if internal is not None:
40
+ return str(internal or "?")
41
+ return str(c.get("status") or "?")
42
+
43
+
44
+ def computer_status_label(c: dict[str, Any]) -> str:
45
+ power = computer_power_state(c)
46
+ internal = computer_internal_status(c)
47
+ if power == internal:
48
+ return power
49
+ if power == "warm" and internal == "running":
50
+ return power
51
+ if internal in {"creating", "archiving", "restoring", "orphaned"}:
52
+ return internal
53
+ return power
54
+
55
+
56
+ def _computer_matches_ref(c: dict[str, Any], ref: str) -> bool:
57
+ ref = ref.strip()
58
+ if not ref:
59
+ return False
60
+
61
+ candidates = {
62
+ str(c.get("id") or "").strip(),
63
+ str(c.get("slug") or "").strip(),
64
+ str(c.get("hostname") or "").strip(),
65
+ str(c.get("public_url") or "").strip(),
66
+ }
67
+
68
+ public_url = str(c.get("public_url") or "").strip()
69
+ if public_url:
70
+ parsed = urlparse(public_url)
71
+ if parsed.netloc:
72
+ candidates.add(parsed.netloc)
73
+ first_label = parsed.netloc.split(".", 1)[0].strip()
74
+ if first_label:
75
+ candidates.add(first_label)
76
+
77
+ return ref in {value for value in candidates if value}
78
+
79
+
80
+ def resolve_computer_ref(client: RuntimeClient, ref: str) -> str:
81
+ ref = ref.strip()
82
+ if not ref:
83
+ raise RuntimeAPIError("computer id is required")
84
+ if ref.startswith("cmp_"):
85
+ return ref
86
+
87
+ try:
88
+ payload = client.list_computers()
89
+ except RuntimeAPIError:
90
+ return ref
91
+
92
+ computers = payload.get("computers") or []
93
+ if not isinstance(computers, list):
94
+ computers = []
95
+
96
+ matches = [
97
+ c for c in computers if isinstance(c, dict) and _computer_matches_ref(c, ref)
98
+ ]
99
+ if len(matches) == 1:
100
+ resolved = str(matches[0].get("id") or "").strip()
101
+ return resolved or ref
102
+ if len(matches) > 1:
103
+ raise RuntimeAPIError(
104
+ f"multiple computers matched {ref!r}; use the exact computer id"
105
+ )
106
+
107
+ slugs = [str(c.get("slug") or "").strip() for c in computers if isinstance(c, dict)]
108
+ suggestions = close_matches(ref, [s for s in slugs if s])
109
+ if suggestions:
110
+ hint = ", ".join(repr(s) for s in suggestions)
111
+ raise RuntimeAPIError(f"computer {ref!r} not found. Did you mean {hint}?")
112
+ raise RuntimeAPIError(f"computer {ref!r} not found")
113
+
114
+
115
+ def _computer_name(c: dict[str, Any]) -> str:
116
+ return str(c.get("slug") or c.get("id") or "?")
117
+
118
+
119
+ def _computer_email_address(c: dict[str, Any]) -> str:
120
+ explicit = str(c.get("email_address") or "").strip()
121
+ if explicit:
122
+ return explicit
123
+ slug = str(c.get("slug") or "").strip()
124
+ public_url = str(c.get("public_url") or "").strip()
125
+ if not slug or not public_url:
126
+ return ""
127
+ host = urlparse(public_url).netloc
128
+ if not host:
129
+ return ""
130
+ suffix = host.removeprefix(slug + ".")
131
+ if suffix == host or not suffix:
132
+ return ""
133
+ return f"{slug}@mail.{suffix}"
134
+
135
+
136
+ def with_computer_email(payload: dict[str, Any]) -> dict[str, Any]:
137
+ if payload.get("email_address"):
138
+ return payload
139
+ email_address = _computer_email_address(payload)
140
+ if not email_address:
141
+ return payload
142
+ enriched = dict(payload)
143
+ enriched["email_address"] = email_address
144
+ return enriched
145
+
146
+
147
+ def _computer_sort_key(c: dict[str, Any]) -> tuple[int, str, str]:
148
+ status = computer_status_label(c)
149
+ priority = 1
150
+ if "warm" in status or "running" in status:
151
+ priority = 0
152
+ elif "cold" in status:
153
+ priority = 1
154
+ elif "restoring" in status or "creating" in status:
155
+ priority = 2
156
+ else:
157
+ priority = 3
158
+ return (priority, _computer_name(c).lower(), str(c.get("id") or ""))
159
+
160
+
161
+ def pick_computer(
162
+ config: RuntimeConfig,
163
+ prompt: str,
164
+ predicate: Callable[[dict[str, Any]], bool] | None = None,
165
+ ) -> dict[str, Any] | None:
166
+ client = authenticated_client(config)
167
+ payload = client.list_computers()
168
+ computers = payload.get("computers") or []
169
+ if not isinstance(computers, list):
170
+ computers = []
171
+ if predicate is not None:
172
+ computers = [c for c in computers if isinstance(c, dict) and predicate(c)]
173
+ computers = sorted(computers, key=_computer_sort_key)
174
+ if not computers:
175
+ print("no matching computers found", file=sys.stderr)
176
+ return None
177
+
178
+ for index, computer in enumerate(computers, start=1):
179
+ print(
180
+ f"{index}. {_computer_name(computer)} ({computer_status_label(computer)})"
181
+ )
182
+ selection = cli_output.prompt_text(f"{prompt} [1-{len(computers)}]")
183
+ if selection is None:
184
+ return None
185
+ try:
186
+ index = int(selection) - 1
187
+ except ValueError:
188
+ print("selection must be a number", file=sys.stderr)
189
+ return None
190
+ if index < 0 or index >= len(computers):
191
+ print("selection is out of range", file=sys.stderr)
192
+ return None
193
+ return computers[index]
194
+
195
+
196
+ def required_computer_id(config: RuntimeConfig, computer_id: str | None) -> str | int:
197
+ if computer_id is not None:
198
+ return computer_id
199
+ if not cli_output.interactive():
200
+ return cli_output.report_error("computer id is required")
201
+ picked = pick_computer(config, "Pick a computer")
202
+ if picked is None:
203
+ return 0
204
+ value = str(picked.get("id") or picked.get("slug") or "").strip()
205
+ if not value:
206
+ return cli_output.report_error("computer is missing an id")
207
+ return value
208
+
209
+
210
+ def handle_create(
211
+ config: RuntimeConfig,
212
+ name: str | None,
213
+ startup_command: str | None,
214
+ cwd: str | None,
215
+ port: int | None,
216
+ network_mode: str | None = None,
217
+ allowed_hosts: list[str] | None = None,
218
+ ) -> int:
219
+ if (err := cli_output.require_auth(config)) is not None:
220
+ return err
221
+
222
+ if name is None and cli_output.interactive():
223
+ name = cli_output.prompt_text("Name your computer")
224
+
225
+ if startup_command is None and port is not None:
226
+ return cli_output.report_error(
227
+ "startup command is required when port is provided"
228
+ )
229
+ if startup_command is not None and port is None:
230
+ return cli_output.report_error(
231
+ "port is required when startup command is provided"
232
+ )
233
+ allowed_hosts = allowed_hosts or []
234
+ network_error = _validate_network_policy(
235
+ network_mode, allowed_hosts, allow_default=True
236
+ )
237
+ if network_error:
238
+ return cli_output.report_error(network_error)
239
+
240
+ client = authenticated_client(config)
241
+ try:
242
+ started_at = time.perf_counter()
243
+ create_args: dict[str, Any] = {
244
+ "slug": name,
245
+ "command": startup_command,
246
+ "cwd": cwd,
247
+ "port": port,
248
+ }
249
+ if network_mode is not None:
250
+ create_args["network_mode"] = network_mode
251
+ create_args["allowed_hosts"] = allowed_hosts
252
+ result = client.create_computer(**create_args)
253
+ result["creation_duration_ms"] = int(
254
+ round((time.perf_counter() - started_at) * 1000)
255
+ )
256
+ except RuntimeAPIError as exc:
257
+ if (
258
+ exc.status_code == 409
259
+ and (
260
+ "slug already in use" in str(exc)
261
+ or "computer name or email address already in use" in str(exc)
262
+ )
263
+ and name
264
+ ):
265
+ return cli_output.report_error(
266
+ f"computer name {name!r} is unavailable. Names are public URL slugs and VM email addresses; an active/cold computer or reserved email address already owns it.",
267
+ status_code=exc.status_code,
268
+ )
269
+ raise
270
+
271
+ result = with_computer_email(result)
272
+ code = cli_output.report_success(result)
273
+ if code != 0 or not cli_output.interactive():
274
+ return code
275
+
276
+ computer_id = result.get("id") or result.get("slug")
277
+ if not computer_id:
278
+ return code
279
+ return terminal.enter_computer(config, str(computer_id))
280
+
281
+
282
+ def handle_network(config: RuntimeConfig, args: argparse.Namespace) -> int:
283
+ if (err := cli_output.require_auth(config)) is not None:
284
+ return err
285
+ client = authenticated_client(config)
286
+ if args.network_action == "default":
287
+ action = args.network_default_action
288
+ if action == "show":
289
+ return cli_output.report_success(
290
+ {"network_policy": client.get_default_network()}
291
+ )
292
+ hosts = args.hosts if action == "allowlist" else []
293
+ return cli_output.report_success(
294
+ {"network_policy": client.set_default_network(action, hosts)}
295
+ )
296
+ if args.network_action == "show":
297
+ computer_id = required_computer_id(config, args.id)
298
+ if isinstance(computer_id, int):
299
+ return computer_id
300
+ computer_id = resolve_computer_ref(client, computer_id)
301
+ return cli_output.report_success(client.get_computer_network(computer_id))
302
+ if args.network_action == "denials":
303
+ computer_id = required_computer_id(config, args.id)
304
+ if isinstance(computer_id, int):
305
+ return computer_id
306
+ computer_id = resolve_computer_ref(client, computer_id)
307
+ return cli_output.report_success(client.list_network_denials(computer_id))
308
+ if args.network_action == "set":
309
+ error = _validate_network_policy(args.mode, args.hosts)
310
+ if error:
311
+ return cli_output.report_error(error)
312
+ computer_id = required_computer_id(config, args.id)
313
+ if isinstance(computer_id, int):
314
+ return computer_id
315
+ computer_id = resolve_computer_ref(client, computer_id)
316
+ return cli_output.report_success(
317
+ client.set_computer_network(computer_id, args.mode, args.hosts)
318
+ )
319
+ return cli_output.report_error("unknown network command")
320
+
321
+
322
+ def _validate_network_policy(
323
+ mode: str | None, hosts: list[str], *, allow_default: bool = False
324
+ ) -> str | None:
325
+ if mode is None:
326
+ if hosts:
327
+ return "--allow-host requires --network allowlist"
328
+ return None if allow_default else "network mode is required"
329
+ if mode == "allowlist" and not hosts:
330
+ return "allowlist mode requires at least one host"
331
+ if mode != "allowlist" and hosts:
332
+ return "hosts can only be used with allowlist mode"
333
+ return None
334
+
335
+
336
+ def handle_info(config: RuntimeConfig, computer_id: str | None) -> int:
337
+ if (err := cli_output.require_auth(config)) is not None:
338
+ return err
339
+
340
+ if computer_id is None:
341
+ if not cli_output.interactive():
342
+ return cli_output.report_error("computer id is required")
343
+ picked = pick_computer(config, "Pick a computer")
344
+ if picked is None:
345
+ return 0
346
+ computer_id = picked.get("id") or picked.get("slug")
347
+ if not computer_id:
348
+ return cli_output.report_error("computer is missing an id")
349
+
350
+ client = authenticated_client(config)
351
+ computer_id = resolve_computer_ref(client, computer_id)
352
+ result = client.get_computer(computer_id)
353
+ return cli_output.report_success(result)
354
+
355
+
356
+ def handle_url(config: RuntimeConfig, computer_id: str | None) -> int:
357
+ if (err := cli_output.require_auth(config)) is not None:
358
+ return err
359
+
360
+ if computer_id is None:
361
+ if not cli_output.interactive():
362
+ return cli_output.report_error("computer id is required")
363
+ picked = pick_computer(config, "Pick a computer")
364
+ if picked is None:
365
+ return 0
366
+ computer_id = picked.get("id") or picked.get("slug")
367
+ if not computer_id:
368
+ return cli_output.report_error("computer is missing an id")
369
+
370
+ client = authenticated_client(config)
371
+ computer_id = resolve_computer_ref(client, computer_id)
372
+ result = client.get_computer_network(computer_id)
373
+ return cli_output.report_success(result)
374
+
375
+
376
+ def handle_share(
377
+ config: RuntimeConfig, visibility: str | None, computer_id: str | None
378
+ ) -> int:
379
+ if (err := cli_output.require_auth(config)) is not None:
380
+ return err
381
+ if visibility not in ("public", "private"):
382
+ return cli_output.report_error("share target must be public or private")
383
+
384
+ if computer_id is None:
385
+ if not cli_output.interactive():
386
+ return cli_output.report_error("computer id is required")
387
+ picked = pick_computer(config, "Pick a computer")
388
+ if picked is None:
389
+ return 0
390
+ computer_id = picked.get("id") or picked.get("slug")
391
+ if not computer_id:
392
+ return cli_output.report_error("computer is missing an id")
393
+
394
+ client = authenticated_client(config)
395
+ computer_id = resolve_computer_ref(client, computer_id)
396
+ result = client.set_visibility(computer_id, visibility)
397
+
398
+ return cli_output.report_success(result)
399
+
400
+
401
+ def handle_start(config: RuntimeConfig, computer_id: str | None) -> int:
402
+ if (err := cli_output.require_auth(config)) is not None:
403
+ return err
404
+
405
+ if computer_id is None:
406
+ if not cli_output.interactive():
407
+ return cli_output.report_error("computer id is required")
408
+ picked = pick_computer(
409
+ config,
410
+ "Pick a cold computer to wake",
411
+ lambda c: computer_power_state(c) == "cold",
412
+ )
413
+ if picked is None:
414
+ return 0
415
+ computer_id = picked.get("id") or picked.get("slug")
416
+ if not computer_id:
417
+ return cli_output.report_error("computer is missing an id")
418
+
419
+ client = authenticated_client(config)
420
+ computer_id = resolve_computer_ref(client, computer_id)
421
+ result = client.start_computer(computer_id)
422
+
423
+ return cli_output.report_success(result)
424
+
425
+
426
+ def handle_enter(
427
+ config: RuntimeConfig,
428
+ computer_id: str | None,
429
+ command_parts: list[str] | None = None,
430
+ ) -> int:
431
+ if (err := cli_output.require_auth(config)) is not None:
432
+ return err
433
+ if not cli_output.interactive():
434
+ return cli_output.report_error("enter requires an interactive terminal")
435
+
436
+ command = arguments.command_from_remainder(command_parts)
437
+
438
+ if computer_id is None:
439
+ picked = pick_computer(
440
+ config,
441
+ "Pick a computer to enter",
442
+ lambda c: computer_internal_status(c) in ("running", "cold"),
443
+ )
444
+ if picked is None:
445
+ return 0
446
+ computer_id = picked.get("id") or picked.get("slug")
447
+ if not computer_id:
448
+ return cli_output.report_error("computer is missing an id")
449
+
450
+ client = authenticated_client(config)
451
+ computer_id = resolve_computer_ref(client, str(computer_id))
452
+ if command:
453
+ return terminal.enter_computer(config, str(computer_id), command=command)
454
+ return terminal.enter_computer(config, str(computer_id))
455
+
456
+
457
+ def handle_delete(
458
+ config: RuntimeConfig, computer_id: str | None, *, force: bool = False
459
+ ) -> int:
460
+ if (err := cli_output.require_auth(config)) is not None:
461
+ return err
462
+
463
+ if computer_id is None:
464
+ if not cli_output.interactive():
465
+ return cli_output.report_error("computer id is required")
466
+ picked = pick_computer(config, "Pick a computer to delete")
467
+ if picked is None:
468
+ return 0
469
+ computer_id = picked.get("id") or picked.get("slug")
470
+ if not computer_id:
471
+ return cli_output.report_error("computer is missing an id")
472
+
473
+ # In TTY mode, require the user to retype the slug as confirmation
474
+ # unless --force was passed. Non-TTY callers stay script-friendly.
475
+ if cli_output.interactive() and not force:
476
+ if not cli_output.prompt_typed_confirm(
477
+ f"Delete {computer_id}? This permanently destroys filesystem, service, and public URL.",
478
+ str(computer_id),
479
+ ):
480
+ print("cancelled")
481
+ return 0
482
+
483
+ client = authenticated_client(config)
484
+ display_ref = computer_id
485
+ computer_id = resolve_computer_ref(client, computer_id)
486
+ client.delete_computer(computer_id)
487
+
488
+ return cli_output.report_success({"message": f"computer {display_ref} deleted"})
489
+
490
+
491
+ def handle_run(
492
+ config: RuntimeConfig,
493
+ computer_id: str | None,
494
+ command: str | None,
495
+ uid: int | None,
496
+ gid: int | None,
497
+ cwd: str | None,
498
+ shell: str | None,
499
+ ) -> int:
500
+ if (err := cli_output.require_auth(config)) is not None:
501
+ return err
502
+
503
+ if computer_id is None:
504
+ if not cli_output.interactive():
505
+ return cli_output.report_error("computer id is required")
506
+ picked = pick_computer(
507
+ config,
508
+ "Pick a computer",
509
+ lambda c: computer_internal_status(c) == "running",
510
+ )
511
+ if picked is None:
512
+ return 0
513
+ computer_id = picked.get("id") or picked.get("slug")
514
+ if not computer_id:
515
+ return cli_output.report_error("computer is missing an id")
516
+
517
+ if command is None:
518
+ if not cli_output.interactive():
519
+ return cli_output.report_error("command is required")
520
+ command = cli_output.prompt_text("Command to run")
521
+ if not command:
522
+ return cli_output.report_error("command is required")
523
+
524
+ client = authenticated_client(config)
525
+ computer_id = resolve_computer_ref(client, computer_id)
526
+ result = client.run_command(
527
+ computer_id, command, uid=uid, gid=gid, cwd=cwd, shell=shell
528
+ )
529
+ return cli_output.report_success(result)
530
+
531
+
532
+ def handle_publish(
533
+ config: RuntimeConfig,
534
+ computer_id: str | None,
535
+ port: int | None,
536
+ command_parts: list[str] | None = None,
537
+ ) -> int:
538
+ if (err := cli_output.require_auth(config)) is not None:
539
+ return err
540
+
541
+ if computer_id is None:
542
+ if not cli_output.interactive():
543
+ return cli_output.report_error("computer id is required")
544
+ picked = pick_computer(
545
+ config,
546
+ "Pick a computer",
547
+ lambda c: computer_internal_status(c) == "running",
548
+ )
549
+ if picked is None:
550
+ return 0
551
+ computer_id = picked.get("id") or picked.get("slug")
552
+ if not computer_id:
553
+ return cli_output.report_error("computer is missing an id")
554
+
555
+ if port is None:
556
+ if not cli_output.interactive():
557
+ return cli_output.report_error("port is required")
558
+ port_text = cli_output.prompt_text("Port to publish", default="3000")
559
+ if not port_text:
560
+ return cli_output.report_error("port is required")
561
+ try:
562
+ port = int(port_text)
563
+ except ValueError:
564
+ return cli_output.report_error("port must be a number")
565
+
566
+ if port <= 0 or port > 65535:
567
+ return cli_output.report_error("port must be between 1 and 65535")
568
+
569
+ command = arguments.command_from_remainder(command_parts)
570
+ if not command:
571
+ return cli_output.report_error(
572
+ f"publish requires -- <command...>, for example: runtime publish {computer_id} {port} -- npm run dev -- --host 0.0.0.0"
573
+ )
574
+
575
+ client = authenticated_client(config)
576
+ computer_id = resolve_computer_ref(client, computer_id)
577
+ result = client.publish_port(computer_id, port, command=command)
578
+ return cli_output.report_success(result)