runtime-sdk 0.2.0__tar.gz → 0.3.0__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.
- {runtime_sdk-0.2.0 → runtime_sdk-0.3.0}/PKG-INFO +22 -3
- {runtime_sdk-0.2.0 → runtime_sdk-0.3.0}/README.md +21 -2
- {runtime_sdk-0.2.0 → runtime_sdk-0.3.0}/pyproject.toml +1 -1
- {runtime_sdk-0.2.0 → runtime_sdk-0.3.0}/scripts/runtime_sdk/cli.py +238 -53
- {runtime_sdk-0.2.0 → runtime_sdk-0.3.0}/scripts/runtime_sdk/client.py +31 -3
- {runtime_sdk-0.2.0 → runtime_sdk-0.3.0}/scripts/runtime_sdk.egg-info/PKG-INFO +22 -3
- {runtime_sdk-0.2.0 → runtime_sdk-0.3.0}/scripts/runtime_sdk/__init__.py +0 -0
- {runtime_sdk-0.2.0 → runtime_sdk-0.3.0}/scripts/runtime_sdk/config.py +0 -0
- {runtime_sdk-0.2.0 → runtime_sdk-0.3.0}/scripts/runtime_sdk.egg-info/SOURCES.txt +0 -0
- {runtime_sdk-0.2.0 → runtime_sdk-0.3.0}/scripts/runtime_sdk.egg-info/dependency_links.txt +0 -0
- {runtime_sdk-0.2.0 → runtime_sdk-0.3.0}/scripts/runtime_sdk.egg-info/entry_points.txt +0 -0
- {runtime_sdk-0.2.0 → runtime_sdk-0.3.0}/scripts/runtime_sdk.egg-info/requires.txt +0 -0
- {runtime_sdk-0.2.0 → runtime_sdk-0.3.0}/scripts/runtime_sdk.egg-info/top_level.txt +0 -0
- {runtime_sdk-0.2.0 → runtime_sdk-0.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runtime-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -46,16 +46,18 @@ Or pass `--base-url` per command.
|
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
48
|
# Auth
|
|
49
|
-
runtime
|
|
49
|
+
runtime login you@example.com
|
|
50
50
|
runtime verify 123456
|
|
51
51
|
runtime whoami
|
|
52
52
|
runtime logout
|
|
53
|
-
runtime login
|
|
53
|
+
runtime login --api-key rt_live_...
|
|
54
54
|
|
|
55
55
|
# Computers
|
|
56
56
|
runtime create
|
|
57
|
+
runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
|
|
57
58
|
runtime list
|
|
58
59
|
runtime info <id>
|
|
60
|
+
runtime start <id>
|
|
59
61
|
runtime run <id> "echo hello"
|
|
60
62
|
runtime run <id> "apt install -y nodejs" --uid 0
|
|
61
63
|
runtime publish <id> 3000
|
|
@@ -73,10 +75,22 @@ client = RuntimeClient(base_url="https://api.runruntime.dev", api_key="rt_live_.
|
|
|
73
75
|
computer = client.create_computer()
|
|
74
76
|
print(computer["public_url"]) # https://goldbird.runruntime.dev
|
|
75
77
|
|
|
78
|
+
# Or create one with a durable startup command.
|
|
79
|
+
# That startup config is replayed after cold restore / auto-wake.
|
|
80
|
+
app = client.create_computer(
|
|
81
|
+
slug="myapp",
|
|
82
|
+
command="python3 app.py",
|
|
83
|
+
cwd="/home/ubuntu",
|
|
84
|
+
port=3000,
|
|
85
|
+
)
|
|
86
|
+
|
|
76
87
|
# Run a command
|
|
77
88
|
result = client.run_command(computer["id"], "echo hello")
|
|
78
89
|
print(result["stdout"])
|
|
79
90
|
|
|
91
|
+
# Wake a cold computer explicitly
|
|
92
|
+
client.start_computer(app["id"])
|
|
93
|
+
|
|
80
94
|
# Pin the public URL to a local app port
|
|
81
95
|
client.publish_port(computer["id"], 3000)
|
|
82
96
|
|
|
@@ -104,6 +118,11 @@ make smoke
|
|
|
104
118
|
Use `make deploy` instead of `make sync` when migrations, env
|
|
105
119
|
files, Caddy, or systemd units changed.
|
|
106
120
|
|
|
121
|
+
Cold restore and public auto-wake replay the saved startup command only for
|
|
122
|
+
computers created with `--command` + `--port` (or the SDK `command`/`port`
|
|
123
|
+
arguments). If you start an app later via a one-off `runtime run`, the
|
|
124
|
+
filesystem is restored after going cold, but that ad-hoc process is not.
|
|
125
|
+
|
|
107
126
|
Run the SDK unit tests through the backend project environment:
|
|
108
127
|
|
|
109
128
|
```bash
|
|
@@ -28,16 +28,18 @@ Or pass `--base-url` per command.
|
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
30
|
# Auth
|
|
31
|
-
runtime
|
|
31
|
+
runtime login you@example.com
|
|
32
32
|
runtime verify 123456
|
|
33
33
|
runtime whoami
|
|
34
34
|
runtime logout
|
|
35
|
-
runtime login
|
|
35
|
+
runtime login --api-key rt_live_...
|
|
36
36
|
|
|
37
37
|
# Computers
|
|
38
38
|
runtime create
|
|
39
|
+
runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
|
|
39
40
|
runtime list
|
|
40
41
|
runtime info <id>
|
|
42
|
+
runtime start <id>
|
|
41
43
|
runtime run <id> "echo hello"
|
|
42
44
|
runtime run <id> "apt install -y nodejs" --uid 0
|
|
43
45
|
runtime publish <id> 3000
|
|
@@ -55,10 +57,22 @@ client = RuntimeClient(base_url="https://api.runruntime.dev", api_key="rt_live_.
|
|
|
55
57
|
computer = client.create_computer()
|
|
56
58
|
print(computer["public_url"]) # https://goldbird.runruntime.dev
|
|
57
59
|
|
|
60
|
+
# Or create one with a durable startup command.
|
|
61
|
+
# That startup config is replayed after cold restore / auto-wake.
|
|
62
|
+
app = client.create_computer(
|
|
63
|
+
slug="myapp",
|
|
64
|
+
command="python3 app.py",
|
|
65
|
+
cwd="/home/ubuntu",
|
|
66
|
+
port=3000,
|
|
67
|
+
)
|
|
68
|
+
|
|
58
69
|
# Run a command
|
|
59
70
|
result = client.run_command(computer["id"], "echo hello")
|
|
60
71
|
print(result["stdout"])
|
|
61
72
|
|
|
73
|
+
# Wake a cold computer explicitly
|
|
74
|
+
client.start_computer(app["id"])
|
|
75
|
+
|
|
62
76
|
# Pin the public URL to a local app port
|
|
63
77
|
client.publish_port(computer["id"], 3000)
|
|
64
78
|
|
|
@@ -86,6 +100,11 @@ make smoke
|
|
|
86
100
|
Use `make deploy` instead of `make sync` when migrations, env
|
|
87
101
|
files, Caddy, or systemd units changed.
|
|
88
102
|
|
|
103
|
+
Cold restore and public auto-wake replay the saved startup command only for
|
|
104
|
+
computers created with `--command` + `--port` (or the SDK `command`/`port`
|
|
105
|
+
arguments). If you start an app later via a one-off `runtime run`, the
|
|
106
|
+
filesystem is restored after going cold, but that ad-hoc process is not.
|
|
107
|
+
|
|
89
108
|
Run the SDK unit tests through the backend project environment:
|
|
90
109
|
|
|
91
110
|
```bash
|
|
@@ -50,6 +50,7 @@ class _UI:
|
|
|
50
50
|
_console: Any = None
|
|
51
51
|
_err_console: Any = None
|
|
52
52
|
_questionary: Any = None
|
|
53
|
+
_questionary_style: Any = None
|
|
53
54
|
_rich_mods: dict[str, Any] | None = None
|
|
54
55
|
|
|
55
56
|
@classmethod
|
|
@@ -76,6 +77,25 @@ class _UI:
|
|
|
76
77
|
cls._questionary = questionary
|
|
77
78
|
return cls._questionary
|
|
78
79
|
|
|
80
|
+
@classmethod
|
|
81
|
+
def style(cls) -> Any:
|
|
82
|
+
if cls._questionary_style is None:
|
|
83
|
+
cls._questionary_style = cls.q().Style(
|
|
84
|
+
[
|
|
85
|
+
("qmark", "fg:#22c55e bold"),
|
|
86
|
+
("question", "bold fg:#e5e7eb"),
|
|
87
|
+
("answer", "fg:#22c55e bold"),
|
|
88
|
+
("pointer", "fg:#60a5fa bold"),
|
|
89
|
+
("highlighted", "fg:#ffffff bg:#1f2937 bold"),
|
|
90
|
+
("selected", "fg:#22c55e bold"),
|
|
91
|
+
("separator", "fg:#4b5563"),
|
|
92
|
+
("instruction", "fg:#94a3b8 italic"),
|
|
93
|
+
("text", "fg:#d1d5db"),
|
|
94
|
+
("disabled", "fg:#6b7280 italic"),
|
|
95
|
+
]
|
|
96
|
+
)
|
|
97
|
+
return cls._questionary_style
|
|
98
|
+
|
|
79
99
|
@classmethod
|
|
80
100
|
def rich(cls) -> dict[str, Any]:
|
|
81
101
|
if cls._rich_mods is None:
|
|
@@ -98,16 +118,29 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
98
118
|
# Subcommand is optional: bare `runtime` defaults to `create`.
|
|
99
119
|
subparsers = parser.add_subparsers(dest="command", required=False)
|
|
100
120
|
|
|
101
|
-
signup = subparsers.add_parser("signup")
|
|
121
|
+
signup = subparsers.add_parser("signup", help="Send an email verification code")
|
|
102
122
|
signup.add_argument("email", nargs="?", default=None)
|
|
103
123
|
signup.add_argument("--name", default=None)
|
|
104
124
|
|
|
105
|
-
verify = subparsers.add_parser("verify")
|
|
125
|
+
verify = subparsers.add_parser("verify", help="Verify an emailed code and save a fresh API key")
|
|
106
126
|
verify.add_argument("code", nargs="?", default=None)
|
|
107
127
|
verify.add_argument("--flow-id", type=int)
|
|
108
128
|
|
|
109
|
-
login = subparsers.add_parser("login")
|
|
110
|
-
login.add_argument(
|
|
129
|
+
login = subparsers.add_parser("login", help="Start email login or import an API key")
|
|
130
|
+
login.add_argument(
|
|
131
|
+
"identifier",
|
|
132
|
+
nargs="?",
|
|
133
|
+
default=None,
|
|
134
|
+
help="Email address to send a code to, or an API key for backwards compatibility",
|
|
135
|
+
)
|
|
136
|
+
login.add_argument("--name", default=None, help="Optional display name for first-time email signup")
|
|
137
|
+
login.add_argument(
|
|
138
|
+
"--api-key",
|
|
139
|
+
nargs="?",
|
|
140
|
+
const="",
|
|
141
|
+
default=None,
|
|
142
|
+
help="Import an API key explicitly (prompts if omitted in interactive mode)",
|
|
143
|
+
)
|
|
111
144
|
|
|
112
145
|
subparsers.add_parser("whoami")
|
|
113
146
|
subparsers.add_parser("logout")
|
|
@@ -119,11 +152,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
119
152
|
default=None,
|
|
120
153
|
help="Subdomain name (e.g. redsox → redsox.runruntime.dev). Random if skipped.",
|
|
121
154
|
)
|
|
155
|
+
create_cmd.add_argument("--command", dest="startup_command", help="App startup command to run after create")
|
|
156
|
+
create_cmd.add_argument("--cwd", help="Working directory for the startup command")
|
|
157
|
+
create_cmd.add_argument("--port", type=int, help="App port to publish after startup")
|
|
122
158
|
subparsers.add_parser("list", aliases=["ls"], help="List computers")
|
|
123
159
|
|
|
124
160
|
info_cmd = subparsers.add_parser("info", help="Get computer details")
|
|
125
161
|
info_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
126
162
|
|
|
163
|
+
start_cmd = subparsers.add_parser("start", help="Wake a cold computer")
|
|
164
|
+
start_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
165
|
+
|
|
127
166
|
delete_cmd = subparsers.add_parser("delete", aliases=["rm"], help="Delete a computer")
|
|
128
167
|
delete_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
129
168
|
|
|
@@ -174,17 +213,25 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
174
213
|
if args.command == "verify":
|
|
175
214
|
return handle_verify(config, args.code, args.flow_id)
|
|
176
215
|
if args.command == "login":
|
|
177
|
-
return handle_login(config, args.api_key)
|
|
216
|
+
return handle_login(config, args.identifier, args.name, args.api_key)
|
|
178
217
|
if args.command == "whoami":
|
|
179
218
|
return handle_whoami(config)
|
|
180
219
|
if args.command == "logout":
|
|
181
220
|
return handle_logout(config)
|
|
182
221
|
if args.command == "create":
|
|
183
|
-
return handle_create(
|
|
222
|
+
return handle_create(
|
|
223
|
+
config,
|
|
224
|
+
args.name,
|
|
225
|
+
getattr(args, "startup_command", None),
|
|
226
|
+
getattr(args, "cwd", None),
|
|
227
|
+
getattr(args, "port", None),
|
|
228
|
+
)
|
|
184
229
|
if args.command in ("list", "ls"):
|
|
185
230
|
return handle_list(config)
|
|
186
231
|
if args.command == "info":
|
|
187
232
|
return handle_info(config, args.id)
|
|
233
|
+
if args.command == "start":
|
|
234
|
+
return handle_start(config, args.id)
|
|
188
235
|
if args.command in ("delete", "rm"):
|
|
189
236
|
return handle_delete(config, args.id)
|
|
190
237
|
if args.command == "run":
|
|
@@ -242,7 +289,7 @@ def report_success(payload: dict[str, Any], renderer: Callable[[dict[str, Any]],
|
|
|
242
289
|
def _require_api_key(config: RuntimeConfig) -> int | None:
|
|
243
290
|
if config.api_key:
|
|
244
291
|
return None
|
|
245
|
-
return report_error("missing api key; run
|
|
292
|
+
return report_error("missing api key; run login or verify first")
|
|
246
293
|
|
|
247
294
|
|
|
248
295
|
def _prompt_text(message: str, *, default: str | None = None) -> str | None:
|
|
@@ -308,7 +355,7 @@ def _computer_table_layout(computers: list[dict[str, Any]]) -> dict[str, int]:
|
|
|
308
355
|
|
|
309
356
|
|
|
310
357
|
def _format_computer_row(c: dict[str, Any], widths: dict[str, int]) -> str:
|
|
311
|
-
return "
|
|
358
|
+
return " │ ".join(
|
|
312
359
|
[
|
|
313
360
|
_fit_cell(c.get("slug") or c.get("id") or "?", widths["slug"]),
|
|
314
361
|
_fit_cell(c.get("status") or "?", widths["status"]),
|
|
@@ -320,7 +367,7 @@ def _format_computer_row(c: dict[str, Any], widths: dict[str, int]) -> str:
|
|
|
320
367
|
|
|
321
368
|
def _computer_table_prompt(prompt: str, computers: list[dict[str, Any]]) -> tuple[str, dict[str, int]]:
|
|
322
369
|
widths = _computer_table_layout(computers)
|
|
323
|
-
header = "
|
|
370
|
+
header = " │ ".join(
|
|
324
371
|
[
|
|
325
372
|
_fit_cell("slug", widths["slug"]),
|
|
326
373
|
_fit_cell("status", widths["status"]),
|
|
@@ -328,7 +375,54 @@ def _computer_table_prompt(prompt: str, computers: list[dict[str, Any]]) -> tupl
|
|
|
328
375
|
_fit_cell("created", widths["created_at"]),
|
|
329
376
|
]
|
|
330
377
|
)
|
|
331
|
-
|
|
378
|
+
divider = "─" * len(header)
|
|
379
|
+
return f"{prompt}\n{header}\n{divider}", widths
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _render_computer_summary(computers: list[dict[str, Any]]) -> None:
|
|
383
|
+
mods = _UI.rich()
|
|
384
|
+
Panel, Text = mods["Panel"], mods["Text"]
|
|
385
|
+
counts: dict[str, int] = {}
|
|
386
|
+
for computer in computers:
|
|
387
|
+
status = str(computer.get("status") or "unknown")
|
|
388
|
+
counts[status] = counts.get(status, 0) + 1
|
|
389
|
+
|
|
390
|
+
body = Text()
|
|
391
|
+
body.append(f"total {len(computers)}", style="bold white")
|
|
392
|
+
if counts:
|
|
393
|
+
body.append(" ")
|
|
394
|
+
first = True
|
|
395
|
+
status_styles = {
|
|
396
|
+
"warm": "bold green",
|
|
397
|
+
"cold": "bold cyan",
|
|
398
|
+
"running": "bold green",
|
|
399
|
+
"archiving": "bold yellow",
|
|
400
|
+
"restoring": "bold yellow",
|
|
401
|
+
"creating": "bold magenta",
|
|
402
|
+
"orphaned": "bold red",
|
|
403
|
+
}
|
|
404
|
+
for status in sorted(counts):
|
|
405
|
+
if not first:
|
|
406
|
+
body.append(" • ", style="dim")
|
|
407
|
+
first = False
|
|
408
|
+
body.append(status, style=status_styles.get(status, "bold white"))
|
|
409
|
+
body.append(f" {counts[status]}", style="white")
|
|
410
|
+
|
|
411
|
+
_UI.console().print(
|
|
412
|
+
Panel(body, title="runtime", border_style="blue", expand=False, padding=(0, 1))
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _select_prompt(message: str, choices: list[Any]) -> Any:
|
|
417
|
+
return _UI.q().select(
|
|
418
|
+
message,
|
|
419
|
+
choices=choices,
|
|
420
|
+
qmark="●",
|
|
421
|
+
pointer="❯",
|
|
422
|
+
instruction="↑/↓ move • Enter select • j/k also work",
|
|
423
|
+
use_indicator=True,
|
|
424
|
+
style=_UI.style(),
|
|
425
|
+
)
|
|
332
426
|
|
|
333
427
|
|
|
334
428
|
def _pick_computer(config: RuntimeConfig, prompt: str) -> dict[str, Any] | None:
|
|
@@ -350,11 +444,7 @@ def _pick_computer(config: RuntimeConfig, prompt: str) -> dict[str, Any] | None:
|
|
|
350
444
|
questionary.Choice(title=_format_computer_row(c, widths), value=c) for c in computers
|
|
351
445
|
]
|
|
352
446
|
choices.append(questionary.Choice(title="← cancel", value=None))
|
|
353
|
-
picked =
|
|
354
|
-
message,
|
|
355
|
-
choices=choices,
|
|
356
|
-
instruction="↑/↓ to move, Enter to select",
|
|
357
|
-
).unsafe_ask()
|
|
447
|
+
picked = _select_prompt(message, choices).unsafe_ask()
|
|
358
448
|
return picked if isinstance(picked, dict) else None
|
|
359
449
|
|
|
360
450
|
|
|
@@ -367,7 +457,7 @@ def _render_computer_panel(c: dict[str, Any], *, title: str = "computer") -> Non
|
|
|
367
457
|
mods = _UI.rich()
|
|
368
458
|
Panel, Text = mods["Panel"], mods["Text"]
|
|
369
459
|
body = Text()
|
|
370
|
-
for key in ("id", "slug", "status", "public_url", "created_at"):
|
|
460
|
+
for key in ("id", "slug", "status", "internal_status", "public_url", "created_at"):
|
|
371
461
|
if key in c and c[key] is not None:
|
|
372
462
|
body.append(f"{key:<12}", style="dim")
|
|
373
463
|
body.append(f"{c[key]}\n")
|
|
@@ -413,22 +503,21 @@ def _render_whoami_panel(payload: dict[str, Any]) -> None:
|
|
|
413
503
|
# --------------------------------------------------------------------------- #
|
|
414
504
|
|
|
415
505
|
|
|
416
|
-
def
|
|
506
|
+
def _looks_like_api_key(value: str | None) -> bool:
|
|
507
|
+
candidate = (value or "").strip()
|
|
508
|
+
return candidate.startswith("rt_live_")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _start_verification_flow(config: RuntimeConfig, email: str | None, name: str | None) -> int:
|
|
417
512
|
if email is None:
|
|
418
513
|
if not _interactive():
|
|
419
514
|
return report_error("email is required")
|
|
420
515
|
email = _prompt_text("Email")
|
|
421
516
|
if not email:
|
|
422
517
|
return report_error("email is required")
|
|
423
|
-
if name is None:
|
|
424
|
-
if not _interactive():
|
|
425
|
-
return report_error("--name is required")
|
|
426
|
-
name = _prompt_text("Your name")
|
|
427
|
-
if not name:
|
|
428
|
-
return report_error("name is required")
|
|
429
518
|
|
|
430
519
|
client = RuntimeClient(base_url=config.base_url)
|
|
431
|
-
result = client.
|
|
520
|
+
result = client.start_verification(email, name)
|
|
432
521
|
config.pending_signup = PendingSignup(
|
|
433
522
|
flow_id=int(result["flow_id"]),
|
|
434
523
|
email=email,
|
|
@@ -447,11 +536,40 @@ def handle_signup(config: RuntimeConfig, email: str | None, name: str | None) ->
|
|
|
447
536
|
return report_success(result, render)
|
|
448
537
|
|
|
449
538
|
|
|
539
|
+
def _login_with_api_key(config: RuntimeConfig, api_key: str | None) -> int:
|
|
540
|
+
if api_key is None or api_key == "":
|
|
541
|
+
if not _interactive():
|
|
542
|
+
return report_error("api key is required")
|
|
543
|
+
api_key = _prompt_password("API key")
|
|
544
|
+
if not api_key:
|
|
545
|
+
return report_error("api key is required")
|
|
546
|
+
|
|
547
|
+
client = RuntimeClient(base_url=config.base_url, api_key=api_key)
|
|
548
|
+
whoami = client.whoami()
|
|
549
|
+
config.api_key = api_key
|
|
550
|
+
config.pending_signup = None
|
|
551
|
+
save_config(config)
|
|
552
|
+
|
|
553
|
+
def render(_: dict[str, Any]) -> None:
|
|
554
|
+
owner = whoami.get("owner") or {}
|
|
555
|
+
email = owner.get("email")
|
|
556
|
+
if email:
|
|
557
|
+
_UI.console().print(f"[green]✓[/green] logged in as [bold]{email}[/bold]")
|
|
558
|
+
return
|
|
559
|
+
_UI.console().print("[green]✓[/green] api key saved")
|
|
560
|
+
|
|
561
|
+
return report_success({"message": "logged in", **whoami}, render)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def handle_signup(config: RuntimeConfig, email: str | None, name: str | None) -> int:
|
|
565
|
+
return _start_verification_flow(config, email, name)
|
|
566
|
+
|
|
567
|
+
|
|
450
568
|
def handle_verify(config: RuntimeConfig, code: str | None, flow_id: int | None) -> int:
|
|
451
569
|
pending = config.pending_signup
|
|
452
570
|
resolved_flow_id = flow_id or (pending.flow_id if pending else None)
|
|
453
571
|
if not resolved_flow_id:
|
|
454
|
-
return report_error("missing flow id; run
|
|
572
|
+
return report_error("missing flow id; run login first or pass --flow-id")
|
|
455
573
|
|
|
456
574
|
if code is None:
|
|
457
575
|
if not _interactive():
|
|
@@ -468,27 +586,32 @@ def handle_verify(config: RuntimeConfig, code: str | None, flow_id: int | None)
|
|
|
468
586
|
config.pending_signup = None
|
|
469
587
|
save_config(config)
|
|
470
588
|
|
|
471
|
-
def render(
|
|
589
|
+
def render(payload: dict[str, Any]) -> None:
|
|
590
|
+
owner = payload.get("owner") or {}
|
|
591
|
+
email = owner.get("email")
|
|
592
|
+
if email:
|
|
593
|
+
_UI.console().print(f"[green]✓[/green] verified, logged in as [bold]{email}[/bold]")
|
|
594
|
+
return
|
|
472
595
|
_UI.console().print("[green]✓[/green] verified, api key saved")
|
|
473
596
|
|
|
474
597
|
return report_success(result, render)
|
|
475
598
|
|
|
476
599
|
|
|
477
|
-
def handle_login(
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
600
|
+
def handle_login(
|
|
601
|
+
config: RuntimeConfig,
|
|
602
|
+
identifier: str | None,
|
|
603
|
+
name: str | None,
|
|
604
|
+
api_key: str | None,
|
|
605
|
+
) -> int:
|
|
606
|
+
if api_key is not None:
|
|
607
|
+
if identifier is not None:
|
|
608
|
+
return report_error("pass either an email/api key argument or --api-key, not both")
|
|
609
|
+
return _login_with_api_key(config, api_key)
|
|
487
610
|
|
|
488
|
-
|
|
489
|
-
|
|
611
|
+
if _looks_like_api_key(identifier):
|
|
612
|
+
return _login_with_api_key(config, identifier)
|
|
490
613
|
|
|
491
|
-
return
|
|
614
|
+
return _start_verification_flow(config, identifier, name)
|
|
492
615
|
|
|
493
616
|
|
|
494
617
|
def handle_whoami(config: RuntimeConfig) -> int:
|
|
@@ -500,26 +623,54 @@ def handle_whoami(config: RuntimeConfig) -> int:
|
|
|
500
623
|
|
|
501
624
|
|
|
502
625
|
def handle_logout(config: RuntimeConfig) -> int:
|
|
626
|
+
had_api_key = config.api_key is not None
|
|
627
|
+
revoked = False
|
|
628
|
+
if config.api_key:
|
|
629
|
+
try:
|
|
630
|
+
RuntimeClient(base_url=config.base_url, api_key=config.api_key).logout()
|
|
631
|
+
revoked = True
|
|
632
|
+
except RuntimeAPIError:
|
|
633
|
+
revoked = False
|
|
634
|
+
|
|
503
635
|
config.api_key = None
|
|
504
636
|
config.pending_signup = None
|
|
505
637
|
save_config(config)
|
|
506
638
|
|
|
639
|
+
message = "logged out"
|
|
640
|
+
if had_api_key and not revoked:
|
|
641
|
+
message = "logged out locally"
|
|
642
|
+
|
|
507
643
|
def render(_: dict[str, Any]) -> None:
|
|
508
|
-
_UI.console().print("[green]✓[/green]
|
|
644
|
+
_UI.console().print(f"[green]✓[/green] {message}")
|
|
509
645
|
|
|
510
|
-
return report_success({"message":
|
|
646
|
+
return report_success({"message": message}, render)
|
|
511
647
|
|
|
512
648
|
|
|
513
|
-
def handle_create(
|
|
649
|
+
def handle_create(
|
|
650
|
+
config: RuntimeConfig,
|
|
651
|
+
name: str | None,
|
|
652
|
+
startup_command: str | None,
|
|
653
|
+
cwd: str | None,
|
|
654
|
+
port: int | None,
|
|
655
|
+
) -> int:
|
|
514
656
|
if (err := _require_api_key(config)) is not None:
|
|
515
657
|
return err
|
|
516
658
|
|
|
517
659
|
if name is None and _interactive():
|
|
518
660
|
name = _prompt_text("Name your computer")
|
|
519
661
|
|
|
662
|
+
if startup_command is None and port is not None:
|
|
663
|
+
return report_error("startup command is required when port is provided")
|
|
664
|
+
if startup_command is not None and port is None:
|
|
665
|
+
return report_error("port is required when startup command is provided")
|
|
666
|
+
|
|
520
667
|
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
668
|
+
label = "creating computer…"
|
|
669
|
+
if startup_command is not None:
|
|
670
|
+
label = f"creating computer and starting app on port {port}…"
|
|
521
671
|
result = _with_spinner(
|
|
522
|
-
|
|
672
|
+
label,
|
|
673
|
+
lambda: client.create_computer(slug=name, command=startup_command, cwd=cwd, port=port),
|
|
523
674
|
)
|
|
524
675
|
|
|
525
676
|
def render(payload: dict[str, Any]) -> None:
|
|
@@ -561,6 +712,33 @@ def handle_info(config: RuntimeConfig, computer_id: str | None) -> int:
|
|
|
561
712
|
return report_success(result, lambda p: _render_computer_panel(p, title="computer"))
|
|
562
713
|
|
|
563
714
|
|
|
715
|
+
def handle_start(config: RuntimeConfig, computer_id: str | None) -> int:
|
|
716
|
+
if (err := _require_api_key(config)) is not None:
|
|
717
|
+
return err
|
|
718
|
+
|
|
719
|
+
if computer_id is None:
|
|
720
|
+
if not _interactive():
|
|
721
|
+
return report_error("computer id is required")
|
|
722
|
+
picked = _pick_computer(config, "Pick a cold computer to wake")
|
|
723
|
+
if picked is None:
|
|
724
|
+
return 0
|
|
725
|
+
computer_id = picked.get("id") or picked.get("slug")
|
|
726
|
+
if not computer_id:
|
|
727
|
+
return report_error("computer is missing an id")
|
|
728
|
+
|
|
729
|
+
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
730
|
+
result = _with_spinner(f"warming {computer_id}…", lambda: client.start_computer(computer_id))
|
|
731
|
+
|
|
732
|
+
def render(payload: dict[str, Any]) -> None:
|
|
733
|
+
_render_computer_panel(payload, title="warmed")
|
|
734
|
+
url = payload.get("public_url")
|
|
735
|
+
if url:
|
|
736
|
+
_UI.console().print(f"[bold green]→[/bold green] [cyan]{url}[/cyan]")
|
|
737
|
+
|
|
738
|
+
return report_success(result, render)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
|
|
564
742
|
def handle_delete(config: RuntimeConfig, computer_id: str | None) -> int:
|
|
565
743
|
if (err := _require_api_key(config)) is not None:
|
|
566
744
|
return err
|
|
@@ -701,6 +879,7 @@ def _interactive_list(config: RuntimeConfig) -> int:
|
|
|
701
879
|
_UI.console().print("[dim]no computers yet. create one with `runtime create`.[/dim]")
|
|
702
880
|
return 0
|
|
703
881
|
|
|
882
|
+
_render_computer_summary(computers)
|
|
704
883
|
message, widths = _computer_table_prompt("Pick a computer", computers)
|
|
705
884
|
choices = [
|
|
706
885
|
questionary.Choice(title=_format_computer_row(c, widths), value=c)
|
|
@@ -710,11 +889,7 @@ def _interactive_list(config: RuntimeConfig) -> int:
|
|
|
710
889
|
choices.append(questionary.Choice(title="↻ refresh", value="__refresh__"))
|
|
711
890
|
choices.append(questionary.Choice(title="✕ quit", value="__quit__"))
|
|
712
891
|
|
|
713
|
-
picked =
|
|
714
|
-
message,
|
|
715
|
-
choices=choices,
|
|
716
|
-
instruction="↑/↓ to move, Enter to select",
|
|
717
|
-
).unsafe_ask()
|
|
892
|
+
picked = _select_prompt(message, choices).unsafe_ask()
|
|
718
893
|
|
|
719
894
|
if picked == "__quit__":
|
|
720
895
|
return 0
|
|
@@ -738,10 +913,11 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
|
|
|
738
913
|
while True:
|
|
739
914
|
_render_computer_panel(vm, title=f"computer: {slug}")
|
|
740
915
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
choices=
|
|
744
|
-
|
|
916
|
+
choices = [questionary.Choice(title="▶ run a command", value="run")]
|
|
917
|
+
if vm.get("status") == "cold":
|
|
918
|
+
choices.append(questionary.Choice(title="☀ start / wake", value="start"))
|
|
919
|
+
choices.extend(
|
|
920
|
+
[
|
|
745
921
|
questionary.Choice(title="⤴ publish a port", value="publish"),
|
|
746
922
|
questionary.Choice(title="ℹ refresh info", value="info"),
|
|
747
923
|
questionary.Choice(title="⧉ copy public url", value="url"),
|
|
@@ -749,7 +925,12 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
|
|
|
749
925
|
questionary.Separator(),
|
|
750
926
|
questionary.Choice(title="← back", value="__back__"),
|
|
751
927
|
questionary.Choice(title="✕ quit", value="__quit__"),
|
|
752
|
-
]
|
|
928
|
+
]
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
action = _select_prompt(
|
|
932
|
+
f"What would you like to do with {slug}?",
|
|
933
|
+
choices,
|
|
753
934
|
).unsafe_ask()
|
|
754
935
|
|
|
755
936
|
if action == "__back__":
|
|
@@ -767,6 +948,10 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
|
|
|
767
948
|
_render_run_result(result)
|
|
768
949
|
continue
|
|
769
950
|
|
|
951
|
+
if action == "start":
|
|
952
|
+
vm = _with_spinner(f"warming {slug}…", lambda: client.start_computer(vm_id))
|
|
953
|
+
continue
|
|
954
|
+
|
|
770
955
|
if action == "publish":
|
|
771
956
|
port_text = _prompt_text("Port to publish", default="3000")
|
|
772
957
|
if not port_text:
|
|
@@ -22,8 +22,14 @@ class RuntimeClient:
|
|
|
22
22
|
def __post_init__(self) -> None:
|
|
23
23
|
self.base_url = self.base_url.rstrip("/")
|
|
24
24
|
|
|
25
|
-
def
|
|
26
|
-
|
|
25
|
+
def start_verification(self, email: str, name: str | None = None) -> dict[str, Any]:
|
|
26
|
+
body: dict[str, Any] = {"email": email}
|
|
27
|
+
if name is not None:
|
|
28
|
+
body["name"] = name
|
|
29
|
+
return self._request("POST", "/api/auth/start", json=body)
|
|
30
|
+
|
|
31
|
+
def signup(self, email: str, name: str | None = None) -> dict[str, Any]:
|
|
32
|
+
return self.start_verification(email, name)
|
|
27
33
|
|
|
28
34
|
def verify(self, flow_id: int, code: str) -> dict[str, Any]:
|
|
29
35
|
return self._request("POST", "/api/auth/verify", json={"flow_id": flow_id, "code": code})
|
|
@@ -31,12 +37,31 @@ class RuntimeClient:
|
|
|
31
37
|
def whoami(self) -> dict[str, Any]:
|
|
32
38
|
return self._request("GET", "/api/auth/whoami", auth_required=True)
|
|
33
39
|
|
|
40
|
+
def logout(self) -> dict[str, Any]:
|
|
41
|
+
return self._request("POST", "/api/auth/logout", auth_required=True)
|
|
42
|
+
|
|
34
43
|
# --- Computers ---
|
|
35
44
|
|
|
36
|
-
def create_computer(
|
|
45
|
+
def create_computer(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
slug: str | None = None,
|
|
49
|
+
command: str | None = None,
|
|
50
|
+
cwd: str | None = None,
|
|
51
|
+
port: int | None = None,
|
|
52
|
+
) -> dict[str, Any]:
|
|
37
53
|
body: dict[str, Any] = {}
|
|
38
54
|
if slug:
|
|
39
55
|
body["slug"] = slug
|
|
56
|
+
if command is not None or cwd is not None or port is not None:
|
|
57
|
+
start: dict[str, Any] = {}
|
|
58
|
+
if command is not None:
|
|
59
|
+
start["command"] = command
|
|
60
|
+
if cwd is not None:
|
|
61
|
+
start["cwd"] = cwd
|
|
62
|
+
if port is not None:
|
|
63
|
+
start["port"] = port
|
|
64
|
+
body["start"] = start
|
|
40
65
|
return self._request("POST", "/api/computers", json=body or None, auth_required=True)
|
|
41
66
|
|
|
42
67
|
def list_computers(self) -> dict[str, Any]:
|
|
@@ -45,6 +70,9 @@ class RuntimeClient:
|
|
|
45
70
|
def get_computer(self, computer_id: str) -> dict[str, Any]:
|
|
46
71
|
return self._request("GET", f"/api/computers/{computer_id}", auth_required=True)
|
|
47
72
|
|
|
73
|
+
def start_computer(self, computer_id: str) -> dict[str, Any]:
|
|
74
|
+
return self._request("POST", f"/api/computers/{computer_id}/start", auth_required=True)
|
|
75
|
+
|
|
48
76
|
def delete_computer(self, computer_id: str) -> dict[str, Any]:
|
|
49
77
|
return self._request("DELETE", f"/api/computers/{computer_id}", auth_required=True)
|
|
50
78
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runtime-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -46,16 +46,18 @@ Or pass `--base-url` per command.
|
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
48
|
# Auth
|
|
49
|
-
runtime
|
|
49
|
+
runtime login you@example.com
|
|
50
50
|
runtime verify 123456
|
|
51
51
|
runtime whoami
|
|
52
52
|
runtime logout
|
|
53
|
-
runtime login
|
|
53
|
+
runtime login --api-key rt_live_...
|
|
54
54
|
|
|
55
55
|
# Computers
|
|
56
56
|
runtime create
|
|
57
|
+
runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
|
|
57
58
|
runtime list
|
|
58
59
|
runtime info <id>
|
|
60
|
+
runtime start <id>
|
|
59
61
|
runtime run <id> "echo hello"
|
|
60
62
|
runtime run <id> "apt install -y nodejs" --uid 0
|
|
61
63
|
runtime publish <id> 3000
|
|
@@ -73,10 +75,22 @@ client = RuntimeClient(base_url="https://api.runruntime.dev", api_key="rt_live_.
|
|
|
73
75
|
computer = client.create_computer()
|
|
74
76
|
print(computer["public_url"]) # https://goldbird.runruntime.dev
|
|
75
77
|
|
|
78
|
+
# Or create one with a durable startup command.
|
|
79
|
+
# That startup config is replayed after cold restore / auto-wake.
|
|
80
|
+
app = client.create_computer(
|
|
81
|
+
slug="myapp",
|
|
82
|
+
command="python3 app.py",
|
|
83
|
+
cwd="/home/ubuntu",
|
|
84
|
+
port=3000,
|
|
85
|
+
)
|
|
86
|
+
|
|
76
87
|
# Run a command
|
|
77
88
|
result = client.run_command(computer["id"], "echo hello")
|
|
78
89
|
print(result["stdout"])
|
|
79
90
|
|
|
91
|
+
# Wake a cold computer explicitly
|
|
92
|
+
client.start_computer(app["id"])
|
|
93
|
+
|
|
80
94
|
# Pin the public URL to a local app port
|
|
81
95
|
client.publish_port(computer["id"], 3000)
|
|
82
96
|
|
|
@@ -104,6 +118,11 @@ make smoke
|
|
|
104
118
|
Use `make deploy` instead of `make sync` when migrations, env
|
|
105
119
|
files, Caddy, or systemd units changed.
|
|
106
120
|
|
|
121
|
+
Cold restore and public auto-wake replay the saved startup command only for
|
|
122
|
+
computers created with `--command` + `--port` (or the SDK `command`/`port`
|
|
123
|
+
arguments). If you start an app later via a one-off `runtime run`, the
|
|
124
|
+
filesystem is restored after going cold, but that ad-hoc process is not.
|
|
125
|
+
|
|
107
126
|
Run the SDK unit tests through the backend project environment:
|
|
108
127
|
|
|
109
128
|
```bash
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|