studio-console 1.3.4__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.
@@ -0,0 +1,3001 @@
1
+ # studio_console/commands.py
2
+ """Command functions, submenus, and config menu."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from .constants import (
14
+ COMPONENT_TO_IMAGE,
15
+ COMPONENT_TO_PROFILE,
16
+ ENV_SECTIONS,
17
+ IMAGE_BUILD_CONFIG,
18
+ SCALE_PROFILES,
19
+ SCALE_VARS,
20
+ THIRD_PARTY_IMAGES,
21
+ )
22
+ from . import major_version as mv
23
+ from .env import (
24
+ _find_repo_root,
25
+ _get_running_services,
26
+ _images_from_env,
27
+ _validate_env,
28
+ backup_root,
29
+ compose_cmd,
30
+ derive_app_db_url,
31
+ detect_context,
32
+ env_path,
33
+ fatal,
34
+ mask_value,
35
+ read_env,
36
+ run,
37
+ run_quiet,
38
+ set_env_value,
39
+ unset_env_values,
40
+ storage_root,
41
+ validate_password,
42
+ write_env,
43
+ )
44
+ from .tui import (
45
+ NavBack,
46
+ NavExit,
47
+ _bold,
48
+ _cyan,
49
+ _dim,
50
+ _green,
51
+ _interactive_single,
52
+ _interactive_yn,
53
+ _prompt,
54
+ _prompt_password,
55
+ _red,
56
+ _yellow,
57
+ error,
58
+ heading,
59
+ info,
60
+ ok,
61
+ warn,
62
+ warn_header,
63
+ )
64
+ from .wizard import (
65
+ SetupState,
66
+ _section_api_ui_scaling,
67
+ _write_override_and_nginx,
68
+ wizard,
69
+ )
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Shared helpers
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ def _apply_scale_flags(up_cmd: list[str], env_data: dict) -> list[str]:
77
+ """Append --scale flags for any *active* worker with a count != 1.
78
+
79
+ Only scales services whose profile is in COMPOSE_PROFILES — compose errors
80
+ 'no such service: X: disabled' if you --scale a profile that isn't active.
81
+ """
82
+ active = {p for p in env_data.get("COMPOSE_PROFILES", "").split(",") if p}
83
+ for var, service in SCALE_PROFILES.items():
84
+ if service not in active:
85
+ continue
86
+ count = env_data.get(var, "")
87
+ if count and count != "1":
88
+ up_cmd += ["--scale", f"{service}={count}"]
89
+ return up_cmd
90
+
91
+
92
+ def _stop_api_ui_containers() -> None:
93
+ """Remove all studio-api-*, studio-ui-*, studio-nginx-* containers directly.
94
+
95
+ Uses 'ps -a' so Created/Exited containers (not just running ones) are caught —
96
+ otherwise a leftover 'studio-api-2' in Created state collides with compose on
97
+ recreate ('container name already in use'). 'docker rm -f' force-removes
98
+ running ones too; '-s' is NOT a valid rm flag.
99
+ """
100
+ ctrs: list[str] = []
101
+ for pattern in ("studio-api-", "studio-ui-", "studio-nginx-"):
102
+ _, out = run_quiet(["docker", "ps", "-aq", "--filter", f"name={pattern}"])
103
+ ctrs.extend(c.strip() for c in out.strip().splitlines() if c.strip())
104
+ ctrs = list(dict.fromkeys(ctrs))
105
+ if ctrs:
106
+ info(f"Stopping {len(ctrs)} API/UI/nginx container(s)...")
107
+ run_quiet(["docker", "rm", "-f"] + ctrs, timeout=30)
108
+
109
+
110
+ def _log_picker(context: str, env_file: Path, follow: bool) -> None:
111
+ """Interactive loop to pick a service and show/stream its logs."""
112
+ prompt = "Stream logs for" if follow else "View logs for"
113
+ while True:
114
+ services = _get_running_services(context, env_file)
115
+ labels, targets = _build_log_options(services)
116
+ try:
117
+ pick = _interactive_single(prompt, labels, default=0)
118
+ except NavBack:
119
+ break
120
+ cmd_logs(context, env_file, targets[pick], follow=follow)
121
+
122
+
123
+ def _missing_images(env_file: Path) -> list[str]:
124
+ """Return configured image names that are not present locally."""
125
+ env_data = read_env(env_file)
126
+ tag = env_data.get("SHS_STUDIO_VERSION", "latest")
127
+ missing: list[str] = []
128
+ for name in _images_from_env(env_file):
129
+ found = any(
130
+ run_quiet(["docker", "image", "inspect", f"{prefix}/{name}:{tag}"])[0] == 0
131
+ for prefix in ("ghcr.io/selfhosthub", "selfhosthub")
132
+ )
133
+ if not found:
134
+ missing.append(name)
135
+ return missing
136
+
137
+
138
+ def _pull_images(names: list[str], tag: str) -> bool:
139
+ """Pull the given studio images from the registry. Returns True iff all succeed.
140
+
141
+ Tries the canonical ghcr.io/selfhosthub prefix first, then the bare
142
+ selfhosthub fallback (mirrors _missing_images' lookup order).
143
+ """
144
+ all_ok = True
145
+ for name in names:
146
+ pulled = False
147
+ for prefix in ("ghcr.io/selfhosthub", "selfhosthub"):
148
+ ref = f"{prefix}/{name}:{tag}"
149
+ rc, _ = run_quiet(["docker", "pull", ref], timeout=300)
150
+ if rc == 0:
151
+ ok(f"{ref}")
152
+ pulled = True
153
+ break
154
+ if not pulled:
155
+ all_ok = False
156
+ return all_ok
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Config menu (main interactive menu when .env exists)
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ def config_menu(context: str, env_file: Path) -> None:
165
+ """Interactive config menu when .env already exists."""
166
+ heading("Studio")
167
+
168
+ menu_options = [
169
+ f"Services {_dim('start · stop · restart · health · logs · links')}",
170
+ f"Setup {_dim('wizard — components · secrets · domain · cloudflare')}",
171
+ f"Images {_dim('build · upgrade · rollback')}",
172
+ f"Advanced {_dim('scale API/UI · per-service ops · cloudflare')}",
173
+ f"Backup {_dim('backup · restore')}",
174
+ f"Update console {_dim('upgrade studio-console to latest')}",
175
+ f"Exit",
176
+ ]
177
+
178
+ while True:
179
+ # Quick status check before showing menu
180
+ env_data = read_env(env_file)
181
+ nginx_port = env_data.get("SHS_NGINX_PORT", "80")
182
+ health_url = f"http://localhost:{nginx_port}/health"
183
+ rc, _ = run_quiet(["curl", "-sf", health_url])
184
+ if rc == 0:
185
+ public_url = env_data.get("SHS_PUBLIC_BASE_URL", "")
186
+ local = (
187
+ f"http://localhost:{nginx_port}"
188
+ if nginx_port != "80"
189
+ else "http://localhost"
190
+ )
191
+ print(f" Status: {_green('running')}")
192
+ print(f" Local URL: {local}")
193
+ if public_url.startswith("https://"):
194
+ print(f" Public URL: {public_url}")
195
+ else:
196
+ rc2, ps_out = run_quiet(
197
+ compose_cmd(env_file) + ["ps", "--format", "json"], timeout=10
198
+ )
199
+ running = []
200
+ if rc2 == 0 and ps_out.strip():
201
+ import json as _json
202
+
203
+ for line in ps_out.strip().splitlines():
204
+ try:
205
+ svc = _json.loads(line)
206
+ state = svc.get("State", "")
207
+ if state == "running":
208
+ running.append(svc.get("Service", ""))
209
+ except _json.JSONDecodeError:
210
+ pass
211
+ if not running:
212
+ print(f" Status: {_cyan('configured')} | Services → Start all")
213
+ elif mv.scrape_guardrail_failure(env_file, context):
214
+ print(
215
+ f" Status: {_red('blocked')} | major-version boundary — see Services → Health"
216
+ )
217
+ else:
218
+ print(f" Status: {_yellow('starting...')}")
219
+ print()
220
+
221
+ idx = _interactive_single("Studio", menu_options, default=0, nav=False)
222
+
223
+ try:
224
+ if idx == 0:
225
+ _submenu_services(context, env_file)
226
+ elif idx == 1:
227
+ _submenu_setup(context, env_file)
228
+ elif idx == 2:
229
+ _submenu_images(context, env_file)
230
+ elif idx == 3:
231
+ _submenu_advanced(context, env_file)
232
+ elif idx == 4:
233
+ _submenu_backup(context, env_file)
234
+ elif idx == 5:
235
+ cmd_self_update(context)
236
+ elif idx == 6:
237
+ return
238
+ except (NavBack, NavExit):
239
+ pass # return to main menu
240
+
241
+ print()
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # Restart helper
246
+ # ---------------------------------------------------------------------------
247
+
248
+
249
+ def _restart_for_setup(
250
+ context: str,
251
+ env_file: Path,
252
+ include_api: bool = False,
253
+ old_profiles: str = "",
254
+ ) -> None:
255
+ """Restart services after a settings change.
256
+
257
+ Stops containers using the *old* profile set (captured before wizard wrote
258
+ the new .env) so we catch workers that were just deselected and would
259
+ otherwise become orphans. Then starts using the *new* profile set.
260
+
261
+ When include_api=False only worker containers are touched; API/UI/nginx
262
+ are left running throughout.
263
+ """
264
+ env_data = read_env(env_file)
265
+ base = compose_cmd(env_file)
266
+ new_profiles = {p for p in env_data.get("COMPOSE_PROFILES", "").split(",") if p}
267
+
268
+ # Resolve missing images BEFORE stopping anything — a missing image must not
269
+ # leave a running stack torn down. In source mode we build; in registry mode
270
+ # we pull. Only if a pull fails do we bail, with everything still up.
271
+ missing = _missing_images(env_file)
272
+ if missing:
273
+ if _find_repo_root(env_file):
274
+ info(f"Building missing images: {', '.join(missing)}")
275
+ cmd_build(env_file, missing, confirm=False)
276
+ else:
277
+ tag = env_data.get("SHS_STUDIO_VERSION", "latest")
278
+ info(f"Pulling missing images: {', '.join(missing)}")
279
+ if not _pull_images(missing, tag):
280
+ error(f"Failed to pull images: {', '.join(missing)}")
281
+ warn("Check the tag (SHS_STUDIO_VERSION) and registry access.")
282
+ warn("No changes applied — services left as they were.")
283
+ return
284
+
285
+ # Stop workers directly — catches orphans whose profiles were removed from .env.
286
+ # 'ps -a' so Created/Exited workers are removed too (avoids name collisions on
287
+ # recreate); 'rm -f' force-removes running ones ('-s' is not a valid rm flag).
288
+ _, ps_out = run_quiet(["docker", "ps", "-aq", "--filter", "name=studio-worker-"])
289
+ all_worker_ctrs = [c.strip() for c in ps_out.strip().splitlines() if c.strip()]
290
+ if all_worker_ctrs:
291
+ info(f"Stopping {len(all_worker_ctrs)} worker container(s)...")
292
+ run_quiet(["docker", "rm", "-f"] + all_worker_ctrs, timeout=30)
293
+
294
+ if include_api:
295
+ _stop_api_ui_containers()
296
+
297
+ info("Starting services...")
298
+ try:
299
+ if include_api:
300
+ run(
301
+ _apply_scale_flags(base + ["up", "-d", "--remove-orphans"], env_data),
302
+ timeout=120,
303
+ )
304
+ else:
305
+ # Workers only — explicit service names so API/UI/nginx are never touched
306
+ worker_services = [
307
+ svc for svc in SCALE_PROFILES.values() if svc in new_profiles
308
+ ]
309
+ if worker_services:
310
+ run(
311
+ _apply_scale_flags(
312
+ base + ["up", "-d"] + list(dict.fromkeys(worker_services)),
313
+ env_data,
314
+ ),
315
+ timeout=120,
316
+ )
317
+ else:
318
+ info("No active worker profiles — nothing to start.")
319
+ ok("Done")
320
+ except Exception as e:
321
+ error(f"Docker Compose failed: {e}")
322
+
323
+
324
+ # ---------------------------------------------------------------------------
325
+ # Submenus
326
+ # ---------------------------------------------------------------------------
327
+
328
+
329
+ def _submenu_setup(context: str, env_file: Path) -> None:
330
+ """Setup submenu — runs the wizard then offers Apply now / Skip."""
331
+ old_profiles = (
332
+ read_env(env_file).get("COMPOSE_PROFILES", "") if env_file.exists() else ""
333
+ )
334
+ if wizard(context, env_file):
335
+ apply_options = [
336
+ f"Apply now {_dim('restarts changed services — brief downtime possible')}",
337
+ f"Skip {_dim('apply on next manual restart')}",
338
+ ]
339
+ pick = _interactive_single(
340
+ "Apply changes?", apply_options, default=0, nav=False
341
+ )
342
+ if pick == 0:
343
+ _restart_for_setup(
344
+ context, env_file, include_api=True, old_profiles=old_profiles
345
+ )
346
+
347
+
348
+ def _cmd_scale_api_ui(env_file: Path) -> None:
349
+ """Interactively change API/UI replica counts and regenerate nginx config."""
350
+ state = SetupState(env_file)
351
+ _section_api_ui_scaling(state)
352
+
353
+ env_data = read_env(env_file)
354
+ old_api = int(env_data.get("CONSOLE_API_REPLICAS", "1"))
355
+ old_ui = int(env_data.get("CONSOLE_UI_REPLICAS", "1"))
356
+ if state.api_replicas == old_api and state.ui_replicas == old_ui:
357
+ info("No changes")
358
+ return
359
+
360
+ set_env_value(env_file, "CONSOLE_API_REPLICAS", str(state.api_replicas))
361
+ set_env_value(env_file, "CONSOLE_UI_REPLICAS", str(state.ui_replicas))
362
+ set_env_value(env_file, "SHS_NGINX_PORT", str(state.nginx_port))
363
+ _write_override_and_nginx(state)
364
+ ok("Scaling updated")
365
+ if not _interactive_yn("Restart API + UI to apply?", default=True):
366
+ return
367
+ warn_header("This will restart API, UI, and nginx — workers will keep running")
368
+ _stop_api_ui_containers()
369
+ env_data = read_env(env_file)
370
+ run(
371
+ _apply_scale_flags(
372
+ compose_cmd(env_file) + ["up", "-d", "--remove-orphans"], env_data
373
+ ),
374
+ timeout=120,
375
+ )
376
+ ok("Done")
377
+
378
+
379
+ def _submenu_services(context: str, env_file: Path) -> None:
380
+ """Services submenu — daily ops: start/stop/restart all, health, logs, links."""
381
+ options = [
382
+ f"Start all {_dim('pull/build if needed, then start')}",
383
+ f"Stop all {_dim('stop all containers')}",
384
+ f"Restart all {_dim('restart every service')}",
385
+ f"Health {_dim('API + worker status')}",
386
+ f"View logs {_dim('recent logs')}",
387
+ f"Stream logs {_dim('follow live (Ctrl-C to stop)')}",
388
+ f"Links {_dim('UI, API, docs URLs')}",
389
+ ]
390
+ idx = _interactive_single("Services", options, default=0)
391
+ if idx == 0:
392
+ cmd_start(context, env_file)
393
+ elif idx == 1:
394
+ cmd_stop(context, env_file)
395
+ elif idx == 2:
396
+ cmd_restart(context, env_file, None)
397
+ elif idx == 3:
398
+ cmd_health(context, env_file)
399
+ elif idx == 4:
400
+ _log_picker(context, env_file, follow=False)
401
+ elif idx == 5:
402
+ _log_picker(context, env_file, follow=True)
403
+ elif idx == 6:
404
+ cmd_links(context, env_file)
405
+
406
+
407
+ def _build_log_options(
408
+ services: list[str],
409
+ ) -> tuple[list[str], list[str | list[str] | None]]:
410
+ """Build log menu labels and corresponding service targets.
411
+
412
+ When multiple api-N or ui-N instances are running, injects group entries
413
+ ("All API", "All UI") so the user can tail all replicas at once.
414
+
415
+ Returns (labels, targets) where each target is:
416
+ None → all services
417
+ str → single service name
418
+ list[str] → group of service names passed together to docker compose logs
419
+ """
420
+ labels: list[str] = ["All services"]
421
+ targets: list[str | list[str] | None] = [None]
422
+
423
+ api_replicas = [
424
+ s for s in services if s == "api" or (s.startswith("api-") and s[4:].isdigit())
425
+ ]
426
+ ui_replicas = [
427
+ s for s in services if s == "ui" or (s.startswith("ui-") and s[3:].isdigit())
428
+ ]
429
+
430
+ if len(api_replicas) > 1:
431
+ labels.append(f"All API {_dim(f'{len(api_replicas)} replicas')}")
432
+ targets.append(api_replicas)
433
+ if len(ui_replicas) > 1:
434
+ labels.append(f"All UI {_dim(f'{len(ui_replicas)} replicas')}")
435
+ targets.append(ui_replicas)
436
+
437
+ for svc in services:
438
+ labels.append(svc)
439
+ targets.append(svc)
440
+
441
+ return labels, targets
442
+
443
+
444
+ def _submenu_images(context: str, env_file: Path) -> None:
445
+ """Images submenu - build, upgrade, rollback."""
446
+ options = [
447
+ f"Build {_dim('build configured images + pull postgres')}",
448
+ f"Upgrade {_dim('pull newer version from registry')}",
449
+ f"Rollback {_dim('switch to an older version')}",
450
+ ]
451
+ idx = _interactive_single("Images", options, default=0)
452
+ if idx == 0:
453
+ cmd_build(env_file, None)
454
+ elif idx in (1, 2):
455
+ cmd_upgrade(context, env_file)
456
+
457
+
458
+ def _owns_postgres(env_data: dict) -> bool:
459
+ """True if SHS_DATABASE_URL points at a postgres instance we manage.
460
+
461
+ External DBs (CloudSQL, RDS, etc., or a CloudSQL Auth Proxy sidecar) own
462
+ their own backup tooling — console refuses backup/restore in those cases
463
+ rather than producing dumps that can't be restored back to the source.
464
+ """
465
+ url = env_data.get("SHS_DATABASE_URL", "")
466
+ m = re.search(r"@([^:/]+)", url)
467
+ if not m:
468
+ return False
469
+ host = m.group(1).lower()
470
+ return host in ("postgres", "localhost", "127.0.0.1")
471
+
472
+
473
+ def _submenu_backup(context: str, env_file: Path) -> None:
474
+ """Archive submenu - backup and restore."""
475
+ if not _owns_postgres(read_env(env_file)):
476
+ warn("This Studio uses an external database (CloudSQL, RDS, etc.).")
477
+ info("Use your database provider's backup and restore tooling.")
478
+ info(
479
+ _dim(
480
+ "(Org file backup/restore is not yet supported for external DB setups.)"
481
+ )
482
+ )
483
+ return
484
+
485
+ options = [
486
+ f"Backup all {_dim('database + .env + org files (potentially large files)')}",
487
+ f"Backup DB {_dim('pg_dump + .env')}",
488
+ f"Backup orgs {_dim('organization files only')}",
489
+ f"Restore DB {_dim('restore database from backup')}",
490
+ f"Restore orgs {_dim('restore organization files from backup')}",
491
+ ]
492
+ br_idx = _interactive_single("Backup", options, default=0)
493
+ if br_idx == 0:
494
+ cmd_backup(context, env_file, what="all")
495
+ elif br_idx == 1:
496
+ cmd_backup(context, env_file, what="db")
497
+ elif br_idx == 2:
498
+ cmd_backup(context, env_file, what="orgs")
499
+ elif br_idx == 3:
500
+ db_file = _pick_db_file(context, env_file)
501
+ if db_file:
502
+ cmd_restore_db(context, env_file, db_file)
503
+ elif br_idx == 4:
504
+ path = _prompt("Backup path (enter for latest)", "")
505
+ if _interactive_yn(
506
+ "Restore org files? This will overwrite current files.", default=False
507
+ ):
508
+ cmd_restore(context, env_file, path or None, what="orgs")
509
+
510
+
511
+ def cmd_db_role(context: str, env_file: Path) -> None:
512
+ """Show/enable the restricted runtime DB role (shs_app cutover).
513
+
514
+ Console only writes SHS_DATABASE_APP_URL — the API's bootstrap provisions
515
+ the role, grants, and RLS posture from it on every boot. SHS_DATABASE_URL
516
+ stays privileged; console's own psql/dump/restore tooling keeps using it.
517
+ """
518
+ env_data = read_env(env_file)
519
+
520
+ if _app_db_url(env_data):
521
+ ok("Restricted DB role is configured (SHS_DATABASE_APP_URL is set).")
522
+ info("The API provisions the role and re-applies RLS policies on every boot.")
523
+ info("Check Services → Health for the live posture.")
524
+ return
525
+
526
+ db_url = env_data.get("SHS_DATABASE_URL", "") or os.environ.get(
527
+ "SHS_DATABASE_URL", ""
528
+ )
529
+ if not db_url:
530
+ error("No SHS_DATABASE_URL found — configure the database first.")
531
+ return
532
+
533
+ warn_header("The API currently connects as the privileged DB role — RLS is inert")
534
+ info("Enabling writes SHS_DATABASE_APP_URL (role shs_app) next to SHS_DATABASE_URL.")
535
+ info("The API provisions the role itself on next boot; console runs no SQL.")
536
+ info(_dim("Requires a studio image with restricted-role support; older images ignore it."))
537
+ if not _owns_postgres(env_data):
538
+ warn(
539
+ "External database: the SHS_DATABASE_URL role must have CREATEROLE, "
540
+ "or the API's boot fails closed while provisioning shs_app."
541
+ )
542
+ print()
543
+
544
+ if not _interactive_yn("Enable the restricted DB role?", default=False):
545
+ info("Skipped — no changes made.")
546
+ return
547
+
548
+ set_env_value(env_file, "SHS_DATABASE_APP_URL", derive_app_db_url(db_url))
549
+ ok("SHS_DATABASE_APP_URL written to .env")
550
+
551
+ if context == "host":
552
+ env_data = read_env(env_file)
553
+ if _interactive_yn(
554
+ "Apply now? Restarts services so the API boots on the restricted role.",
555
+ default=True,
556
+ ):
557
+ run(
558
+ _apply_scale_flags(
559
+ compose_cmd(env_file) + ["up", "-d", "--remove-orphans"], env_data
560
+ ),
561
+ timeout=120,
562
+ )
563
+ ok("Services restarted — check Services → Health for the RLS posture.")
564
+ else:
565
+ info("Takes effect on the next restart of the API.")
566
+ else:
567
+ warn(
568
+ "Restart this container from the host to apply — provisioning runs in "
569
+ "the container entrypoint, not under supervisord."
570
+ )
571
+ print(f" {_bold('docker restart <container>')} {_dim('(or stop/start the pod on RunPod)')}")
572
+
573
+
574
+ def _submenu_advanced(context: str, env_file: Path) -> None:
575
+ """Advanced submenu — scale API/UI, per-service ops, Cloudflare ops."""
576
+ options = [
577
+ f"Scale API/UI {_dim('set replica count, enable/disable nginx LB')}",
578
+ f"Start one {_dim('start a stopped service')}",
579
+ f"Stop one {_dim('stop a running service')}",
580
+ f"Restart one {_dim('restart a single service')}",
581
+ f"Show .env {_dim('current configuration values')}",
582
+ f"DB role {_dim('restricted runtime role (RLS) — status · enable')}",
583
+ f"Cloudflare {_dim('tunnel · routes · IP rules · Access')}",
584
+ ]
585
+ idx = _interactive_single("Advanced", options, default=0)
586
+ if idx == 0:
587
+ _cmd_scale_api_ui(env_file)
588
+ elif idx in (1, 2, 3):
589
+ # Service names (not container names): each action targets a whole
590
+ # service type, restarting/stopping all its replicas at once.
591
+ services = _get_running_services(context, env_file)
592
+ if not services:
593
+ warn("No services found")
594
+ else:
595
+ action = {1: "Start", 2: "Stop", 3: "Restart"}[idx]
596
+ pick = _interactive_single(f"{action} which service?", services, default=0)
597
+ svc = services[pick]
598
+ if idx == 1:
599
+ run(compose_cmd(env_file) + ["start", svc], timeout=60)
600
+ elif idx == 2:
601
+ run(compose_cmd(env_file) + ["stop", svc], timeout=60)
602
+ else:
603
+ cmd_restart(context, env_file, svc)
604
+ elif idx == 4:
605
+ cmd_show_config(context, env_file)
606
+ elif idx == 5:
607
+ cmd_db_role(context, env_file)
608
+ elif idx == 6:
609
+ _submenu_cloudflare(context, env_file)
610
+
611
+
612
+ def _submenu_cloudflare(context: str, env_file: Path) -> None:
613
+ """Cloudflare ops submenu."""
614
+ from .cloudflare.cf_wizard import cf_full_setup, update_domain, update_ip_rules
615
+
616
+ env_data = read_env(env_file)
617
+ tunnel_token = env_data.get("CLOUDFLARE_TUNNEL_TOKEN", "")
618
+ api_token = env_data.get("CLOUDFLARE_API_TOKEN", "")
619
+ domain = env_data.get("SHS_PUBLIC_BASE_URL", "")
620
+ api_domain = env_data.get("CONSOLE_PUBLIC_API_BASE_URL", "")
621
+ ip_mode = env_data.get("CONSOLE_IP_RESTRICT_MODE", "none")
622
+ profiles = env_data.get("COMPOSE_PROFILES", "")
623
+ is_docker_mode = bool(tunnel_token) or "cloudflared" in profiles
624
+ is_split = api_domain.startswith("https://") and api_domain != domain
625
+
626
+ options = [
627
+ f"Full setup (API) {_dim('create tunnel + routes + Access app + IP policy')}",
628
+ f"Status {_dim('token, domain, tunnel running?')}",
629
+ f"Test {_dim('curl public URL health endpoint')}",
630
+ f"Update IP rules {_dim('add/remove IPs from bypass policy')}",
631
+ f"Update token {_dim('change tunnel token (manual)')}",
632
+ f"Update domain {_dim('change public domain + cleanup stale DNS')}",
633
+ f"Start tunnel {_dim('compose up cloudflared')}",
634
+ f"Stop tunnel {_dim('compose stop cloudflared')}",
635
+ f"Tunnel logs {_dim('follow cloudflared container logs')}",
636
+ ]
637
+ idx = _interactive_single("Cloudflare", options, default=0)
638
+
639
+ if idx == 0:
640
+ cf_full_setup(env_file)
641
+
642
+ elif idx == 1:
643
+ print()
644
+ print(
645
+ f" {_bold('API token:')} "
646
+ f"{mask_value('CLOUDFLARE_API_TOKEN', api_token) if api_token else _dim('not set')}"
647
+ )
648
+ print(
649
+ f" {_bold('Tunnel token:')} "
650
+ f"{mask_value('CLOUDFLARE_TUNNEL_TOKEN', tunnel_token) if tunnel_token else _dim('not set')}"
651
+ )
652
+ print(
653
+ f" {_bold('Tunnel ID:')} {env_data.get('CLOUDFLARE_TUNNEL_ID', _dim('not set'))}"
654
+ )
655
+ if is_split:
656
+ print(f" {_bold('UI domain:')} {domain}")
657
+ print(f" {_bold('API domain:')} {api_domain}")
658
+ print(
659
+ f" {_bold('UI app:')} {env_data.get('CLOUDFLARE_ACCESS_APP_ID', _dim('not set'))}"
660
+ )
661
+ print(
662
+ f" {_bold('API app:')} {env_data.get('CLOUDFLARE_ACCESS_API_APP_ID', _dim('not set'))}"
663
+ )
664
+ else:
665
+ print(f" {_bold('Domain:')} {domain if domain else _dim('not set')}")
666
+ print(
667
+ f" {_bold('Access app:')} {env_data.get('CLOUDFLARE_ACCESS_APP_ID', _dim('not set'))}"
668
+ )
669
+ print(f" {_bold('IP restrict:')} {ip_mode}")
670
+ if is_docker_mode:
671
+ rc, out = run_quiet(
672
+ compose_cmd(env_file) + ["ps", "cloudflared", "--format", "{{.State}}"],
673
+ timeout=10,
674
+ )
675
+ if rc == 0 and "running" in out.lower():
676
+ tunnel_status = _green("running")
677
+ else:
678
+ tunnel_status = _yellow(
679
+ "not running (use Start tunnel to bring it up)"
680
+ )
681
+ print(f" {_bold('cloudflared:')} {tunnel_status}")
682
+ else:
683
+ print(f" {_bold('cloudflared:')} {_dim('not configured')}")
684
+ print()
685
+
686
+ elif idx == 2:
687
+ if not domain or not domain.startswith("https://"):
688
+ warn("No public domain configured")
689
+ return
690
+ base = domain.rstrip("/")
691
+ routes = [
692
+ ("UI health (api)", f"{base}/health"),
693
+ ("UI WebSocket", f"{base}/ws"),
694
+ ("UI", base),
695
+ ]
696
+ if is_split:
697
+ api_base = api_domain.rstrip("/")
698
+ routes.extend(
699
+ [
700
+ ("API health", f"{api_base}/health"),
701
+ ("API WebSocket", f"{api_base}/ws"),
702
+ ]
703
+ )
704
+ for name, url in routes:
705
+ curl_cmd = [
706
+ "curl",
707
+ "-sf",
708
+ "--max-time",
709
+ "10",
710
+ "-o",
711
+ "/dev/null",
712
+ "-w",
713
+ "%{http_code}",
714
+ url,
715
+ ]
716
+ rc, code = run_quiet(curl_cmd, timeout=15)
717
+ if rc == 0 and code and code[0] in ("2", "3"):
718
+ ok(f"{name:14s} {url} → {code}")
719
+ else:
720
+ warn(f"{name:14s} {url} → failed ({code or 'no response'})")
721
+
722
+ elif idx == 3:
723
+ update_ip_rules(env_file)
724
+
725
+ elif idx == 4:
726
+ import getpass
727
+
728
+ try:
729
+ new_token = getpass.getpass(f"▸ New tunnel token: ").strip()
730
+ except (EOFError, KeyboardInterrupt):
731
+ print()
732
+ return
733
+ if new_token:
734
+ set_env_value(env_file, "CLOUDFLARE_TUNNEL_TOKEN", new_token)
735
+ if "cloudflared" not in profiles:
736
+ new_profiles = (profiles + ",cloudflared").strip(",")
737
+ set_env_value(env_file, "COMPOSE_PROFILES", new_profiles)
738
+ ok("Token updated")
739
+ if _interactive_yn("Restart tunnel to apply?", default=True):
740
+ base_cmd = compose_cmd(env_file)
741
+ run_quiet(base_cmd + ["rm", "-sf", "cloudflared"], timeout=30)
742
+ run(base_cmd + ["up", "-d", "cloudflared"], timeout=60)
743
+ else:
744
+ warn("No token entered")
745
+
746
+ elif idx == 5:
747
+ if api_token:
748
+ update_domain(env_file)
749
+ else:
750
+ new_domain = _prompt(
751
+ "New public domain (e.g. https://app.yourdomain.com)", domain
752
+ )
753
+ while new_domain and not new_domain.startswith("https://"):
754
+ warn("Must start with https://")
755
+ new_domain = _prompt("New public domain", domain)
756
+ if new_domain:
757
+ set_env_value(env_file, "SHS_PUBLIC_BASE_URL", new_domain.rstrip("/"))
758
+ from .commands_container import _sync_derived_urls
759
+
760
+ _sync_derived_urls(env_file)
761
+ ok(f"Domain updated to {new_domain.rstrip('/')}")
762
+ warn("API token not set — DNS records and Access app not updated")
763
+ else:
764
+ warn("No domain entered")
765
+
766
+ elif idx == 6:
767
+ if not tunnel_token:
768
+ warn("No tunnel token set. Run 'Full setup (API)' first.")
769
+ return
770
+ if "cloudflared" not in profiles:
771
+ new_profiles = (profiles + ",cloudflared").strip(",")
772
+ set_env_value(env_file, "COMPOSE_PROFILES", new_profiles)
773
+ info("Starting cloudflared...")
774
+ run(compose_cmd(env_file) + ["up", "-d", "cloudflared"], timeout=60)
775
+ ok("Tunnel started")
776
+
777
+ elif idx == 7:
778
+ info("Stopping cloudflared...")
779
+ run_quiet(compose_cmd(env_file) + ["stop", "cloudflared"], timeout=30)
780
+ ok("Tunnel stopped")
781
+
782
+ elif idx == 8:
783
+ info("Streaming cloudflared logs (Ctrl-C to stop)")
784
+ try:
785
+ run(
786
+ compose_cmd(env_file) + ["logs", "-f", "cloudflared"],
787
+ timeout=None,
788
+ check=False,
789
+ )
790
+ except KeyboardInterrupt:
791
+ print()
792
+
793
+
794
+ # ---------------------------------------------------------------------------
795
+ # Subcommands
796
+ # ---------------------------------------------------------------------------
797
+
798
+
799
+ def cmd_build(env_file: Path, images: list[str] | None, confirm: bool = True) -> None:
800
+ """Build Docker images from local source + pull third-party."""
801
+ repo = _find_repo_root(env_file)
802
+ if repo is None:
803
+ warn("Building from source requires CONSOLE_REPO_ROOT to be set.")
804
+ warn("Add it to ~/.studio/.env or export it in your shell:")
805
+ warn(" CONSOLE_REPO_ROOT=/path/to/studio")
806
+ warn("In a standard install, Studio images are pulled from the registry.")
807
+ return
808
+
809
+ env_data = read_env(env_file) if env_file.exists() else {}
810
+ tag = env_data.get("SHS_STUDIO_VERSION", "latest")
811
+ prefix = "ghcr.io/selfhosthub"
812
+
813
+ # Pull third-party images (postgres etc.)
814
+ for img in THIRD_PARTY_IMAGES:
815
+ rc, _ = run_quiet(["docker", "image", "inspect", img])
816
+ if rc != 0:
817
+ info(f"Pulling {img}...")
818
+ run(["docker", "pull", img], timeout=120)
819
+ ok(f"{img}")
820
+ else:
821
+ ok(f"{img} (already local)")
822
+
823
+ # Determine which images to build
824
+ if images:
825
+ # Normalize: accept "api" or "studio-api", commas or spaces
826
+ raw: list[str] = []
827
+ for img in images:
828
+ raw.extend(part.strip() for part in img.split(",") if part.strip())
829
+ targets: list[str] = []
830
+ for img in raw:
831
+ canonical = img if img.startswith("studio-") else f"studio-{img}"
832
+ if canonical not in IMAGE_BUILD_CONFIG:
833
+ fatal(
834
+ f"Unknown image: {img}. Available: {', '.join(IMAGE_BUILD_CONFIG)}"
835
+ )
836
+ targets.append(canonical)
837
+ else:
838
+ # Build only what was configured
839
+ targets = _images_from_env(env_file)
840
+
841
+ total = len(targets)
842
+
843
+ if confirm:
844
+ info(f"Images to build ({total}): {', '.join(targets)} [tag: {tag}]")
845
+ info(f"Source: {repo}")
846
+ warn("This may take several minutes.")
847
+ if not _interactive_yn("Build now?", default=True, nav=False):
848
+ info("Cancelled.")
849
+ return
850
+
851
+ info(f"Building {total} image(s), tag={tag}")
852
+ print()
853
+
854
+ for i, name in enumerate(targets, 1):
855
+ dockerfile, context_dir = IMAGE_BUILD_CONFIG[name]
856
+ full_tag = f"{prefix}/{name}:{tag}"
857
+ print(f" [{i}/{total}] {_bold(full_tag)}")
858
+ env_data = read_env(env_file)
859
+ build_args = []
860
+ if name == "ui":
861
+ public_api = env_data.get("SHS_PUBLIC_API_URL", "") or env_data.get(
862
+ "SHS_API_BASE_URL", ""
863
+ )
864
+ for var in (
865
+ "SHS_API_BASE_URL",
866
+ "SHS_WS_URL",
867
+ "NEXT_PUBLIC_WS_URL",
868
+ ):
869
+ val = env_data.get(var, "")
870
+ if val:
871
+ build_args += ["--build-arg", f"{var}={val}"]
872
+ if public_api:
873
+ build_args += ["--build-arg", f"NEXT_PUBLIC_API_URL={public_api}"]
874
+ try:
875
+ run(
876
+ [
877
+ "docker",
878
+ "build",
879
+ "--build-context",
880
+ f"contracts={repo / 'contracts'}",
881
+ "-f",
882
+ str(repo / dockerfile),
883
+ "-t",
884
+ full_tag,
885
+ *build_args,
886
+ str(repo / context_dir),
887
+ ],
888
+ timeout=600,
889
+ )
890
+ except Exception as e:
891
+ error(f"Build failed for {name}: {e}")
892
+ return
893
+ ok(f" {full_tag}")
894
+
895
+ print()
896
+ ok(f"All {total} image(s) built")
897
+
898
+ # Offer to restart running services so they pick up the new images
899
+ base = compose_cmd(env_file)
900
+ rc, out = run_quiet(base + ["ps", "--format", "{{.Names}}"])
901
+ if rc == 0 and out.strip():
902
+ running = [line.strip() for line in out.strip().splitlines() if line.strip()]
903
+ if running:
904
+ warn_header(
905
+ "Restart required to apply new images — Studio will be briefly unavailable"
906
+ )
907
+ info(f"Running services: {', '.join(running)}")
908
+ if _interactive_yn("Restart now?", default=True, nav=False):
909
+ run(base + ["up", "-d", "--force-recreate"], timeout=120)
910
+ ok("Services restarted")
911
+ else:
912
+ info("Images built. Restart manually when ready: Services → Restart")
913
+ else:
914
+ info("No running services to restart")
915
+
916
+
917
+ def _get_repo_version(env_file: Path | None = None) -> str | None:
918
+ """Read SHS_STUDIO_VERSION from the repo's deploy/.env.example."""
919
+ repo = _find_repo_root(env_file)
920
+ if not repo:
921
+ return None
922
+ example = repo / "deploy" / ".env.example"
923
+ if not example.exists():
924
+ return None
925
+ for line in example.read_text().splitlines():
926
+ line = line.strip()
927
+ if line.startswith("SHS_STUDIO_VERSION="):
928
+ return line.partition("=")[2].strip()
929
+ return None
930
+
931
+
932
+ def _get_latest_registry_version() -> str | None:
933
+ """Get the latest semver tag from GHCR."""
934
+ versions = _fetch_available_versions(limit=1)
935
+ return versions[0] if versions else None
936
+
937
+
938
+ def cmd_start(context: str, env_file: Path) -> None:
939
+ """Start services. Builds missing images automatically."""
940
+ if context == "host":
941
+ _validate_env(env_file)
942
+ env_data = read_env(env_file)
943
+
944
+ target_tag = env_data.get("SHS_STUDIO_VERSION", "")
945
+ if mv.check_and_block(
946
+ env_file, mv.parse_target_major(target_tag), "install", context, target_tag
947
+ ):
948
+ return False
949
+
950
+ missing = _missing_images(env_file)
951
+ if missing:
952
+ if _find_repo_root(env_file):
953
+ info(f"Building missing images: {', '.join(missing)}")
954
+ cmd_build(env_file, missing, confirm=False)
955
+ else:
956
+ version = env_data.get("SHS_STUDIO_VERSION", "")
957
+ info(
958
+ f"Pulling missing images from registry"
959
+ + (f" (v{version})" if version else "")
960
+ + f": {', '.join(missing)}"
961
+ )
962
+ try:
963
+ run(compose_cmd(env_file) + ["pull"], timeout=600)
964
+ except Exception:
965
+ error("The download was interrupted before it finished.")
966
+ warn("Run Start again — it should pick up where it left off.")
967
+ return False
968
+ ok("Images pulled")
969
+
970
+ # Ensure nginx:alpine is available (always used)
971
+ rc_ng, _ = run_quiet(["docker", "image", "inspect", "nginx:alpine"])
972
+ if rc_ng != 0:
973
+ info("Pulling nginx:alpine...")
974
+ run(["docker", "pull", "nginx:alpine"], timeout=120)
975
+ ok("nginx:alpine")
976
+
977
+ info("Starting Studio via Docker Compose...")
978
+ up_cmd = _apply_scale_flags(
979
+ compose_cmd(env_file) + ["up", "-d", "--remove-orphans"], env_data
980
+ )
981
+
982
+ try:
983
+ run(up_cmd, timeout=120)
984
+ except Exception as e:
985
+ error(f"Docker Compose failed: {e}")
986
+ warn(
987
+ "If images are missing, use Images → Upgrade to pull from the registry."
988
+ )
989
+ warn("If running from source, use Images → Build to build locally.")
990
+ return False
991
+
992
+ # Health check always via nginx
993
+ import time
994
+
995
+ nginx_port = env_data.get("SHS_NGINX_PORT", "80")
996
+ health_url = f"http://localhost:{nginx_port}/health"
997
+
998
+ info("Waiting for API...")
999
+ healthy = False
1000
+ for _ in range(60):
1001
+ rc, _ = run_quiet(["curl", "-sf", health_url])
1002
+ if rc == 0:
1003
+ healthy = True
1004
+ break
1005
+ time.sleep(2)
1006
+
1007
+ if healthy:
1008
+ ok("Studio started")
1009
+ else:
1010
+ warn(
1011
+ "API not responding yet - check logs: studio-console → Configure → View logs"
1012
+ )
1013
+
1014
+ # First boot: create super admin account directly in Postgres
1015
+ if healthy:
1016
+ _bootstrap_first_admin(env_file)
1017
+
1018
+ return healthy
1019
+ else:
1020
+ info("Starting all services via supervisorctl...")
1021
+ run(["supervisorctl", "start", "all"], timeout=30)
1022
+ ok("All services started")
1023
+ return True
1024
+
1025
+
1026
+ def cmd_stop(context: str, env_file: Path) -> None:
1027
+ """Stop services."""
1028
+ if context == "host":
1029
+ _validate_env(env_file)
1030
+ info("Stopping Studio...")
1031
+ run(compose_cmd(env_file) + ["stop"], timeout=60)
1032
+ ok("Studio stopped")
1033
+ else:
1034
+ info("Stopping all services...")
1035
+ run(["supervisorctl", "stop", "all"], timeout=30)
1036
+ ok("All services stopped")
1037
+
1038
+
1039
+ def _api_service_name(env_data: dict) -> str:
1040
+ """Return the running API service name: api-1 in multi-replica mode, api otherwise."""
1041
+ return "api-1" if int(env_data.get("CONSOLE_API_REPLICAS", "1")) > 1 else "api"
1042
+
1043
+
1044
+ class _BootstrapPlan:
1045
+ """Exec plumbing for admin bootstrap, per deployment shape."""
1046
+
1047
+ def __init__(self, base, api_svc, pg_svc, api_base, exec_flags):
1048
+ self.base = base
1049
+ self.api_svc = api_svc
1050
+ self.pg_svc = pg_svc
1051
+ self.api_base = api_base
1052
+ self.exec_flags = exec_flags # compose needs -T; docker exec rejects it
1053
+
1054
+
1055
+ def _split_plan(env_file: Path, env_data: dict) -> "_BootstrapPlan":
1056
+ return _BootstrapPlan(
1057
+ base=compose_cmd(env_file),
1058
+ api_svc=_api_service_name(env_data),
1059
+ pg_svc="postgres",
1060
+ api_base=f"http://localhost:{env_data.get('SHS_NGINX_PORT', '80')}/api/v1",
1061
+ exec_flags=["-T"],
1062
+ )
1063
+
1064
+
1065
+ def _full_plan(container: str) -> "_BootstrapPlan":
1066
+ # base omits "exec"; helpers append it.
1067
+ return _BootstrapPlan(
1068
+ base=["docker"],
1069
+ api_svc=container,
1070
+ pg_svc=container,
1071
+ api_base="http://localhost:8000/api/v1",
1072
+ exec_flags=[],
1073
+ )
1074
+
1075
+
1076
+ def _core_plan(
1077
+ container: str, pg_container: str, nginx_port: int | str = 80
1078
+ ) -> "_BootstrapPlan":
1079
+ # Core's API runs in `container`, but Postgres is an external sidecar
1080
+ # (`pg_container`) — so password hashing execs into the API container while
1081
+ # every psql runs against the sidecar. base omits "exec"; helpers append it.
1082
+ # Core launches with publish_internal=False, so port 8000 is not published:
1083
+ # reach the API through the front door.
1084
+ return _BootstrapPlan(
1085
+ base=["docker"],
1086
+ api_svc=container,
1087
+ pg_svc=pg_container,
1088
+ api_base=f"http://localhost:{nginx_port}/api/v1",
1089
+ exec_flags=[],
1090
+ )
1091
+
1092
+
1093
+ def _read_env_for_bootstrap(env_file: Path, plan: "_BootstrapPlan | None") -> dict:
1094
+ """Read .env for the bootstrap flow, from the container for exec plans.
1095
+
1096
+ full/core write .env root-owned 0600 *inside* the container, so a host user
1097
+ (non-root on Ubuntu/runpod/vast; UID-namespaced on Mac/Win) can't read it.
1098
+ docker-exec plans (base == ["docker"]) cat it from the API container; split
1099
+ reads the user-owned host file directly.
1100
+ """
1101
+ if plan is not None and plan.base == ["docker"]:
1102
+ rc, out = run_quiet(
1103
+ ["docker", "exec", plan.api_svc, "cat", "/workspace/.env"], timeout=10
1104
+ )
1105
+ if rc != 0:
1106
+ return {}
1107
+ result: dict[str, str] = {}
1108
+ for line in out.splitlines():
1109
+ line = line.strip()
1110
+ if not line or line.startswith("#") or "=" not in line:
1111
+ continue
1112
+ key, _, value = line.partition("=")
1113
+ result[key.strip()] = value.strip()
1114
+ return result
1115
+ return read_env(env_file)
1116
+
1117
+
1118
+ def _unset_env_for_bootstrap(
1119
+ env_file: Path, keys: list[str], plan: "_BootstrapPlan | None"
1120
+ ) -> None:
1121
+ """Strip keys from .env, inside the container for exec plans (root-owned file)."""
1122
+ if plan is not None and plan.base == ["docker"]:
1123
+ pattern = "|".join(k for k in keys)
1124
+ run_quiet(
1125
+ [
1126
+ "docker", "exec", plan.api_svc, "sh", "-c",
1127
+ f"grep -Ev '^({pattern})=' /workspace/.env > /workspace/.env.tmp "
1128
+ f"&& mv /workspace/.env.tmp /workspace/.env && chmod 0600 /workspace/.env",
1129
+ ],
1130
+ timeout=10,
1131
+ )
1132
+ return
1133
+ unset_env_values(env_file, keys)
1134
+
1135
+
1136
+ def _super_admin_exists(plan: "_BootstrapPlan", env_data: dict) -> bool:
1137
+ """True if a super_admin user is already in the DB — the bootstrap gate."""
1138
+ pg_user = env_data.get("POSTGRES_USER", "postgres")
1139
+ rc, out = run_quiet(
1140
+ plan.base
1141
+ + [
1142
+ "exec",
1143
+ *plan.exec_flags,
1144
+ plan.pg_svc,
1145
+ "psql",
1146
+ "-U",
1147
+ pg_user,
1148
+ "-d",
1149
+ "selfhost_studio",
1150
+ "-tA",
1151
+ "-c",
1152
+ "SELECT 1 FROM users WHERE role = 'super_admin' LIMIT 1",
1153
+ ],
1154
+ timeout=10,
1155
+ )
1156
+ return rc == 0 and out.strip() == "1"
1157
+
1158
+
1159
+ def _bootstrap_first_admin(
1160
+ env_file: Path, plan: "_BootstrapPlan | None" = None
1161
+ ) -> bool:
1162
+ """First-boot super admin + default org admin. Idempotent."""
1163
+ # Skip if a super_admin already exists.
1164
+ env_data = _read_env_for_bootstrap(env_file, plan)
1165
+ check_plan = plan or _split_plan(env_file, env_data)
1166
+ if _super_admin_exists(check_plan, env_data):
1167
+ return True
1168
+
1169
+ print()
1170
+ info("First boot - create your super admin account (username: super_admin)")
1171
+ admin_email = os.environ.get("SHS_ADMIN_EMAIL", "")
1172
+ admin_password = os.environ.get("SHS_ADMIN_PASSWORD", "")
1173
+ if not admin_email:
1174
+ while not admin_email or "@" not in admin_email:
1175
+ admin_email = _prompt("super_admin email", "super-admin@example.com")
1176
+ if not admin_email or "@" not in admin_email:
1177
+ warn("Enter a valid email address")
1178
+ if not admin_password:
1179
+ admin_password = _prompt_password("super_admin password")
1180
+
1181
+ print()
1182
+ info("Create the default org's admin account (username: admin)")
1183
+ default_admin_email = os.environ.get("CONSOLE_DEFAULT_ADMIN_EMAIL", "")
1184
+ default_admin_password = os.environ.get("CONSOLE_DEFAULT_ADMIN_PASSWORD", "")
1185
+ default_email_default = (
1186
+ "" if admin_email == "admin@example.com" else "admin@example.com"
1187
+ )
1188
+ if not default_admin_email:
1189
+ while True:
1190
+ default_admin_email = _prompt("admin email", default_email_default)
1191
+ if not default_admin_email or "@" not in default_admin_email:
1192
+ warn("Enter a valid email address")
1193
+ continue
1194
+ if default_admin_email == admin_email:
1195
+ warn("Org admin email must differ from the super admin email")
1196
+ continue
1197
+ break
1198
+ elif default_admin_email == admin_email:
1199
+ error(
1200
+ "CONSOLE_DEFAULT_ADMIN_EMAIL must differ from SHS_ADMIN_EMAIL "
1201
+ f"({admin_email})"
1202
+ )
1203
+ return False
1204
+ if not default_admin_password:
1205
+ default_admin_password = _prompt_password("admin password")
1206
+
1207
+ # Prompt for entitlement token; _create_admin_direct reads it from env.
1208
+ if not os.environ.get("SHS_ENTITLEMENT_TOKEN") and not _read_env_for_bootstrap(
1209
+ env_file, plan
1210
+ ).get("SHS_ENTITLEMENT_TOKEN"):
1211
+ print()
1212
+ info("Entitlement token (enables the Plus catalog; leave blank to skip)")
1213
+ token = _prompt("Entitlement token", "").strip()
1214
+ if token:
1215
+ os.environ["SHS_ENTITLEMENT_TOKEN"] = token
1216
+
1217
+ info("Creating super admin account...")
1218
+ super_admin_ok = False
1219
+ try:
1220
+ _create_admin_direct(env_file, admin_email, admin_password, plan)
1221
+ ok(f"Super admin account created: {admin_email} (username: super_admin)")
1222
+ super_admin_ok = True
1223
+ except Exception as e:
1224
+ error(str(e))
1225
+ error("Try: Settings → Reset password")
1226
+
1227
+ info("Creating default org + admin account...")
1228
+ org_admin_ok = False
1229
+ try:
1230
+ _create_default_org_admin(
1231
+ env_file, default_admin_email, default_admin_password, plan
1232
+ )
1233
+ ok(
1234
+ f"Admin account created: {default_admin_email} (username: admin, org: default)"
1235
+ )
1236
+ org_admin_ok = True
1237
+ except Exception as e:
1238
+ error(str(e))
1239
+
1240
+ if super_admin_ok and org_admin_ok:
1241
+ # Strip transient bootstrap inputs from .env.
1242
+ _unset_env_for_bootstrap(
1243
+ env_file,
1244
+ [
1245
+ "SHS_ADMIN_EMAIL",
1246
+ "SHS_ADMIN_PASSWORD",
1247
+ "CONSOLE_DEFAULT_ADMIN_EMAIL",
1248
+ "CONSOLE_DEFAULT_ADMIN_PASSWORD",
1249
+ "SHS_ENTITLEMENT_TOKEN",
1250
+ ],
1251
+ plan,
1252
+ )
1253
+ return True
1254
+ warn("Bootstrap incomplete — will retry account creation on next start.")
1255
+ return False
1256
+
1257
+
1258
+ def _create_admin_direct(
1259
+ env_file: Path, email: str, password: str, plan: "_BootstrapPlan | None" = None
1260
+ ) -> None:
1261
+ """Create super admin: hash via API container, insert via psql."""
1262
+ import uuid
1263
+
1264
+ env_data = _read_env_for_bootstrap(env_file, plan)
1265
+ plan = plan or _split_plan(env_file, env_data)
1266
+ base = plan.base
1267
+ pg_user = env_data.get("POSTGRES_USER", "postgres")
1268
+ api_svc = plan.api_svc
1269
+
1270
+ # 1. Hash password via bcrypt in the API container
1271
+ rc, hashed = run_quiet(
1272
+ base
1273
+ + [
1274
+ "exec",
1275
+ *plan.exec_flags,
1276
+ "-e",
1277
+ f"_PW={password}",
1278
+ api_svc,
1279
+ "python",
1280
+ "-c",
1281
+ "import bcrypt,os; pw=os.environ['_PW'].encode(); "
1282
+ "print(bcrypt.hashpw(pw,bcrypt.gensalt()).decode())",
1283
+ ],
1284
+ timeout=15,
1285
+ )
1286
+ if rc != 0 or not hashed.strip():
1287
+ raise RuntimeError(f"Failed to hash password (rc={rc}): {hashed}")
1288
+ hashed_pw = hashed.strip()
1289
+
1290
+ # 2. Get system org ID
1291
+ rc, org_out = run_quiet(
1292
+ base
1293
+ + [
1294
+ "exec",
1295
+ *plan.exec_flags,
1296
+ plan.pg_svc,
1297
+ "psql",
1298
+ "-U",
1299
+ pg_user,
1300
+ "-d",
1301
+ "selfhost_studio",
1302
+ "-t",
1303
+ "-A",
1304
+ "-c",
1305
+ "SELECT id FROM organizations WHERE slug = 'system' LIMIT 1",
1306
+ ],
1307
+ timeout=10,
1308
+ )
1309
+ if rc != 0 or not org_out.strip():
1310
+ raise RuntimeError(f"System org not found (rc={rc}): {org_out}")
1311
+ org_id = org_out.strip()
1312
+
1313
+ # 2b. Ensure system org is flagged as staging
1314
+ rc, upd_out = run_quiet(
1315
+ base
1316
+ + [
1317
+ "exec",
1318
+ *plan.exec_flags,
1319
+ plan.pg_svc,
1320
+ "psql",
1321
+ "-U",
1322
+ pg_user,
1323
+ "-d",
1324
+ "selfhost_studio",
1325
+ "-c",
1326
+ "UPDATE organizations SET is_staging = TRUE WHERE slug = 'system'",
1327
+ ],
1328
+ timeout=10,
1329
+ )
1330
+ if rc != 0:
1331
+ raise RuntimeError(
1332
+ f"Failed to set is_staging on system org (rc={rc}): {upd_out}. "
1333
+ "The Studio schema may be missing the is_staging column; upgrade Studio."
1334
+ )
1335
+
1336
+ # 3. Insert admin user
1337
+ admin_id = str(uuid.uuid4())
1338
+ sql = (
1339
+ "INSERT INTO users "
1340
+ "(id, username, email, hashed_password, role, "
1341
+ "is_active, is_public, first_name, last_name, "
1342
+ "organization_id, created_at, updated_at) "
1343
+ "VALUES ("
1344
+ f"'{admin_id}', 'super_admin', '{email}', "
1345
+ f"$hash${hashed_pw}$hash$, 'super_admin', true, false, "
1346
+ f"'System', 'Administrator', '{org_id}', NOW(), NOW()"
1347
+ ") ON CONFLICT (username) DO NOTHING"
1348
+ )
1349
+ rc, out = run_quiet(
1350
+ base
1351
+ + [
1352
+ "exec",
1353
+ *plan.exec_flags,
1354
+ plan.pg_svc,
1355
+ "psql",
1356
+ "-U",
1357
+ pg_user,
1358
+ "-d",
1359
+ "selfhost_studio",
1360
+ "-c",
1361
+ sql,
1362
+ ],
1363
+ timeout=10,
1364
+ )
1365
+ if rc != 0:
1366
+ raise RuntimeError(f"INSERT failed (rc={rc}): {out}")
1367
+
1368
+ # 4. Create ENTITLEMENT_TOKEN secret via API (encrypted correctly by repository)
1369
+ api_base = plan.api_base
1370
+ entitlement_token = os.environ.get("SHS_ENTITLEMENT_TOKEN") or env_data.get(
1371
+ "SHS_ENTITLEMENT_TOKEN", ""
1372
+ )
1373
+
1374
+ rc, login_out = run_quiet(
1375
+ [
1376
+ "curl",
1377
+ "-sf",
1378
+ "-X",
1379
+ "POST",
1380
+ f"{api_base}/auth/token",
1381
+ "-d",
1382
+ f"username={email}&password={password}",
1383
+ ],
1384
+ timeout=15,
1385
+ )
1386
+ if rc != 0 or not login_out.strip():
1387
+ warn(
1388
+ "Could not log in to create ENTITLEMENT_TOKEN secret - add it manually via Settings → Secrets"
1389
+ )
1390
+ return
1391
+
1392
+ try:
1393
+ access_token = json.loads(login_out).get("access_token", "")
1394
+ except Exception:
1395
+ access_token = ""
1396
+
1397
+ if not access_token:
1398
+ warn(
1399
+ "Login response missing access_token - ENTITLEMENT_TOKEN secret not created"
1400
+ )
1401
+ return
1402
+
1403
+ secret_data: dict = {"token": entitlement_token} if entitlement_token else {}
1404
+ payload = json.dumps(
1405
+ {
1406
+ "name": "ENTITLEMENT_TOKEN",
1407
+ "secret_type": "bearer",
1408
+ "secret_data": secret_data,
1409
+ "is_active": bool(entitlement_token),
1410
+ }
1411
+ )
1412
+ rc, secret_out = run_quiet(
1413
+ [
1414
+ "curl",
1415
+ "-sf",
1416
+ "-X",
1417
+ "POST",
1418
+ f"{api_base}/organizations/secrets",
1419
+ "-H",
1420
+ "Content-Type: application/json",
1421
+ "-H",
1422
+ f"Authorization: Bearer {access_token}",
1423
+ "-d",
1424
+ payload,
1425
+ ],
1426
+ timeout=15,
1427
+ )
1428
+ if rc != 0:
1429
+ warn(
1430
+ "Failed to create ENTITLEMENT_TOKEN secret - add it manually via Settings → Secrets"
1431
+ )
1432
+
1433
+
1434
+ def _create_default_org_admin(
1435
+ env_file: Path, email: str, password: str, plan: "_BootstrapPlan | None" = None
1436
+ ) -> None:
1437
+ """Create the 'default' org (is_staging=TRUE) and its immutable 'admin' user."""
1438
+ import uuid
1439
+
1440
+ env_data = _read_env_for_bootstrap(env_file, plan)
1441
+ plan = plan or _split_plan(env_file, env_data)
1442
+ base = plan.base
1443
+ pg_user = env_data.get("POSTGRES_USER", "postgres")
1444
+ api_svc = plan.api_svc
1445
+
1446
+ # 1. Hash password via bcrypt in the API container
1447
+ rc, hashed = run_quiet(
1448
+ base
1449
+ + [
1450
+ "exec",
1451
+ *plan.exec_flags,
1452
+ "-e",
1453
+ f"_PW={password}",
1454
+ api_svc,
1455
+ "python",
1456
+ "-c",
1457
+ "import bcrypt,os; pw=os.environ['_PW'].encode(); "
1458
+ "print(bcrypt.hashpw(pw,bcrypt.gensalt()).decode())",
1459
+ ],
1460
+ timeout=15,
1461
+ )
1462
+ if rc != 0 or not hashed.strip():
1463
+ raise RuntimeError(f"Failed to hash password (rc={rc}): {hashed}")
1464
+ hashed_pw = hashed.strip()
1465
+
1466
+ # 2. Insert 'default' org with is_staging=TRUE (idempotent)
1467
+ org_id = str(uuid.uuid4())
1468
+ org_sql = (
1469
+ "INSERT INTO organizations "
1470
+ "(id, name, slug, is_active, is_staging, status, settings, created_at, updated_at) "
1471
+ f"VALUES ('{org_id}', 'Default', 'default', TRUE, TRUE, 'active', '{{}}', NOW(), NOW()) "
1472
+ "ON CONFLICT (slug) DO UPDATE SET is_staging = TRUE "
1473
+ "RETURNING id"
1474
+ )
1475
+ rc, org_out = run_quiet(
1476
+ base
1477
+ + [
1478
+ "exec",
1479
+ *plan.exec_flags,
1480
+ plan.pg_svc,
1481
+ "psql",
1482
+ "-U",
1483
+ pg_user,
1484
+ "-d",
1485
+ "selfhost_studio",
1486
+ "-t",
1487
+ "-A",
1488
+ "-c",
1489
+ org_sql,
1490
+ ],
1491
+ timeout=10,
1492
+ )
1493
+ if rc != 0 or not org_out.strip():
1494
+ raise RuntimeError(
1495
+ f"Default org INSERT failed (rc={rc}): {org_out}. "
1496
+ "The organizations schema may differ from what console expects; "
1497
+ "upgrade Studio."
1498
+ )
1499
+ org_id = org_out.strip().splitlines()[0]
1500
+
1501
+ # 3. Insert admin user
1502
+ admin_id = str(uuid.uuid4())
1503
+ sql = (
1504
+ "INSERT INTO users "
1505
+ "(id, username, email, hashed_password, role, "
1506
+ "is_active, is_public, first_name, last_name, "
1507
+ "organization_id, created_at, updated_at) "
1508
+ "VALUES ("
1509
+ f"'{admin_id}', 'admin', '{email}', "
1510
+ f"$hash${hashed_pw}$hash$, 'admin', true, false, "
1511
+ f"'Default', 'Administrator', '{org_id}', NOW(), NOW()"
1512
+ ") ON CONFLICT (username) DO NOTHING"
1513
+ )
1514
+ rc, out = run_quiet(
1515
+ base
1516
+ + [
1517
+ "exec",
1518
+ *plan.exec_flags,
1519
+ plan.pg_svc,
1520
+ "psql",
1521
+ "-U",
1522
+ pg_user,
1523
+ "-d",
1524
+ "selfhost_studio",
1525
+ "-c",
1526
+ sql,
1527
+ ],
1528
+ timeout=10,
1529
+ )
1530
+ if rc != 0:
1531
+ raise RuntimeError(f"admin user INSERT failed (rc={rc}): {out}")
1532
+
1533
+
1534
+ def cmd_restart(context: str, env_file: Path, service: str | None) -> None:
1535
+ """Restart a service or all."""
1536
+ if context == "host":
1537
+ _validate_env(env_file)
1538
+ if service:
1539
+ info(f"Restarting {service}...")
1540
+ run(compose_cmd(env_file) + ["restart", service], timeout=60)
1541
+ ok(f"{service} restarted")
1542
+ else:
1543
+ # Full restart = full apply: use 'up -d --remove-orphans' (not bare
1544
+ # 'restart') so deselected profiles are cleaned up and replica/scale
1545
+ # counts are honored. Scale flags must be reapplied or workers/API
1546
+ # collapse to 1 replica.
1547
+ info("Restarting all services...")
1548
+ env_data = read_env(env_file)
1549
+ run(
1550
+ _apply_scale_flags(
1551
+ compose_cmd(env_file) + ["up", "-d", "--remove-orphans"],
1552
+ env_data,
1553
+ ),
1554
+ timeout=120,
1555
+ )
1556
+ ok("All services restarted")
1557
+ else:
1558
+ if service:
1559
+ info(f"Restarting {service}...")
1560
+ run(["supervisorctl", "restart", service], timeout=30)
1561
+ ok(f"{service} restarted")
1562
+ else:
1563
+ info("Restarting all services...")
1564
+ run(["supervisorctl", "restart", "all"], timeout=30)
1565
+ ok("All services restarted")
1566
+
1567
+
1568
+ def _app_db_url(env_data: dict) -> str:
1569
+ """The restricted-role URL, from .env or process env (process env wins)."""
1570
+ return os.environ.get("SHS_DATABASE_APP_URL", "") or env_data.get(
1571
+ "SHS_DATABASE_APP_URL", ""
1572
+ )
1573
+
1574
+
1575
+ def _print_db_role_posture(env_data: dict, api_up: bool) -> None:
1576
+ """One-line RLS posture. A healthy API with the app URL set proves
1577
+ restricted mode — boot is fail-closed on an RLS-inert role."""
1578
+ if _app_db_url(env_data):
1579
+ if api_up:
1580
+ print(
1581
+ f" {'DB role':24s} {_green('restricted')} "
1582
+ f"{_dim('RLS enforced (shs_app)')}"
1583
+ )
1584
+ else:
1585
+ print(
1586
+ f" {'DB role':24s} {_yellow('restricted (configured)')} "
1587
+ f"{_dim('API down — an RlsInertError in its logs means SHS_DATABASE_APP_URL is misconfigured')}"
1588
+ )
1589
+ else:
1590
+ print(
1591
+ f" {'DB role':24s} {_yellow('privileged')} "
1592
+ f"{_dim('RLS inert, app-layer checks only — enable via the DB role menu')}"
1593
+ )
1594
+
1595
+
1596
+ def cmd_health(context: str, env_file: Path) -> None:
1597
+ """Check health of API and workers."""
1598
+ env_data = read_env(env_file)
1599
+
1600
+ # Container mode: no nginx, API binds 8000 directly. Host mode: nginx fronts API.
1601
+ if context == "host":
1602
+ _validate_env(env_file) # friendly fatal beats a misleading all-DOWN report
1603
+ nginx_port = env_data.get("SHS_NGINX_PORT", "80")
1604
+ api_base = f"http://localhost:{nginx_port}"
1605
+ print(f"\n{_bold('Health:')}")
1606
+ rc, _ = run_quiet(["curl", "-sf", f"{api_base}/health"])
1607
+ print(f" {'API (via nginx)':24s} {_green('UP') if rc == 0 else _red('DOWN')}")
1608
+ rc_ng, _ = run_quiet(
1609
+ ["curl", "-sf", f"http://localhost:{nginx_port}/nginx-health"]
1610
+ )
1611
+ print(f" {'nginx':24s} {_green('UP') if rc_ng == 0 else _red('DOWN')}")
1612
+ _print_db_role_posture(env_data, api_up=rc == 0)
1613
+
1614
+ # If the API is down, distinguish "guardrail tripped" from "unknown" so
1615
+ # the operator isn't told to "just check logs" when the cause is known.
1616
+ if rc != 0:
1617
+ target_tag = env_data.get("SHS_STUDIO_VERSION", "")
1618
+ target_major = mv.parse_target_major(target_tag)
1619
+ mv_result, mv_info = mv.classify_db(env_file, target_major, context)
1620
+ if mv_result in ("prior_major", "unknown_future"):
1621
+ mv.render_block(mv_result, mv_info, "run")
1622
+ elif mv.scrape_guardrail_failure(env_file, context):
1623
+ warn(
1624
+ "API container logs show a major-version guardrail FATAL. "
1625
+ "The database schema does not match the running image. "
1626
+ "Check SHS_STUDIO_VERSION in ~/.studio/.env."
1627
+ )
1628
+ else:
1629
+ api_base = "http://localhost:8000"
1630
+ print(f"\n{_bold('Health:')}")
1631
+ rc, _ = run_quiet(["curl", "-sf", f"{api_base}/health"])
1632
+ print(f" {'API':24s} {_green('UP') if rc == 0 else _red('DOWN')}")
1633
+ _print_db_role_posture(env_data, api_up=rc == 0)
1634
+
1635
+ # Container/supervisor status
1636
+ if context == "host":
1637
+ rc2, out = run_quiet(
1638
+ compose_cmd(env_file) + ["ps", "--format", "json"],
1639
+ timeout=15,
1640
+ )
1641
+ if rc2 == 0 and out:
1642
+ try:
1643
+ # docker compose ps --format json can return one JSON per line
1644
+ for line in out.strip().splitlines():
1645
+ svc = json.loads(line)
1646
+ name = svc.get("Name", svc.get("Service", "?"))
1647
+ state = svc.get("State", "unknown")
1648
+ color = _green if state == "running" else _red
1649
+ print(f" {name:24s} {color(state)}")
1650
+ except json.JSONDecodeError:
1651
+ pass
1652
+ else:
1653
+ rc2, out = run_quiet(["supervisorctl", "status"], timeout=10)
1654
+ if rc2 == 0 or out:
1655
+ for line in out.splitlines():
1656
+ parts = line.split()
1657
+ if len(parts) >= 2:
1658
+ name, state = parts[0], parts[1]
1659
+ color = _green if state == "RUNNING" else _red
1660
+ print(f" {name:30s} {color(state)}")
1661
+
1662
+ # Worker health via API
1663
+ rc3, wout = run_quiet(
1664
+ ["curl", "-sf", f"{api_base}/api/v1/infrastructure/health/workers"],
1665
+ )
1666
+ if rc3 == 0 and wout:
1667
+ try:
1668
+ data = json.loads(wout)
1669
+ for w in data.get("workers", []):
1670
+ wtype = w.get("type", "?")
1671
+ healthy = w.get("healthy", False)
1672
+ pid = w.get("pid", "?")
1673
+ color = _green if healthy else _red
1674
+ status = "UP" if healthy else "DOWN"
1675
+ print(f" {wtype:20s} {color(status)} (pid {pid})")
1676
+ except json.JSONDecodeError:
1677
+ pass
1678
+ print()
1679
+
1680
+
1681
+ def cmd_show_config(context: str, env_file: Path) -> None:
1682
+ """Show .env grouped by section, mask secrets."""
1683
+ if not env_file.exists():
1684
+ fatal(f"No .env found at {env_file}. Run studio-console to create one.")
1685
+
1686
+ data = read_env(env_file)
1687
+ shown_keys: set[str] = set()
1688
+
1689
+ for section, keys in ENV_SECTIONS.items():
1690
+ section_data = {k: data[k] for k in keys if k in data}
1691
+ if not section_data:
1692
+ continue
1693
+ print(f"\n{_bold(section)}")
1694
+ for k, v in section_data.items():
1695
+ print(f" {k:40s} {mask_value(k, v)}")
1696
+ shown_keys.add(k)
1697
+
1698
+ # Show any keys not in known sections
1699
+ extra = {k: v for k, v in data.items() if k not in shown_keys}
1700
+ if extra:
1701
+ print(f"\n{_bold('Other')}")
1702
+ for k, v in extra.items():
1703
+ print(f" {k:40s} {mask_value(k, v)}")
1704
+
1705
+
1706
+ def cmd_config_set(env_file: Path, key: str, value: str) -> None:
1707
+ """Set a config value in .env."""
1708
+ if not env_file.exists():
1709
+ fatal(f"No .env found at {env_file}. Run studio-console first.")
1710
+ set_env_value(env_file, key, value)
1711
+ ok(f"Set {key}={mask_value(key, value)}")
1712
+
1713
+
1714
+ def cmd_config_unset(env_file: Path, key: str) -> None:
1715
+ """Remove a config value from .env."""
1716
+ if not env_file.exists():
1717
+ fatal(f"No .env found at {env_file}. Run studio-console first.")
1718
+ if key not in read_env(env_file):
1719
+ warn(f"{key} not set; nothing to remove.")
1720
+ return
1721
+ unset_env_values(env_file, [key])
1722
+ ok(f"Removed {key}")
1723
+
1724
+
1725
+ def cmd_logs(
1726
+ context: str,
1727
+ env_file: Path,
1728
+ services: str | list[str] | None,
1729
+ follow: bool = True,
1730
+ ) -> None:
1731
+ """View logs. follow=True streams live, follow=False shows recent.
1732
+
1733
+ *services* may be None (all), a single service name string, or a list of
1734
+ service names (e.g. ["api-1", "api-2"] for the "All API" group).
1735
+ """
1736
+ if follow:
1737
+ print(_dim(" Press Ctrl-C to stop streaming\n"))
1738
+ if context == "host":
1739
+ _validate_env(env_file)
1740
+ cmd = compose_cmd(env_file) + ["logs", "--tail=200"]
1741
+ if follow:
1742
+ cmd.append("-f")
1743
+ if isinstance(services, list):
1744
+ cmd.extend(services)
1745
+ elif services:
1746
+ cmd.append(services)
1747
+ try:
1748
+ run(cmd, timeout=None, check=False)
1749
+ except KeyboardInterrupt:
1750
+ pass
1751
+ print()
1752
+ else:
1753
+ # supervisorctl only supports a single service name for tail -f
1754
+ target: str | None = None
1755
+ if isinstance(services, list):
1756
+ target = services[0] if services else None
1757
+ else:
1758
+ target = services
1759
+ try:
1760
+ tail_cmd = ["supervisorctl", "tail"]
1761
+ if follow:
1762
+ tail_cmd.append("-f")
1763
+ tail_cmd.append(target if target else "all")
1764
+ run(tail_cmd, timeout=None, check=False)
1765
+ except KeyboardInterrupt:
1766
+ pass
1767
+ print()
1768
+
1769
+
1770
+ def cmd_workers(context: str, env_file: Path) -> None:
1771
+ """List workers - configured profiles and running state."""
1772
+ env_data = read_env(env_file)
1773
+
1774
+ # Show configured profiles (source of truth)
1775
+ profiles = env_data.get("COMPOSE_PROFILES", "")
1776
+ if profiles:
1777
+ print(f"\n{_bold('Configured (COMPOSE_PROFILES):')}")
1778
+ for p in profiles.split(","):
1779
+ print(f" {p.strip()}")
1780
+ else:
1781
+ print(f"\n{_bold('Configured (COMPOSE_PROFILES):')}")
1782
+ print(f" {_dim('none')}")
1783
+
1784
+ # Check running worker containers
1785
+ if context == "host":
1786
+ running = _get_running_services(context, env_file)
1787
+ worker_containers = [
1788
+ s for s in running if "worker" in s.lower() or s.startswith("w-")
1789
+ ]
1790
+
1791
+ if worker_containers:
1792
+ print(f"\n{_bold('Running workers:')}")
1793
+ for w in worker_containers:
1794
+ print(f" {w:30s} {_green('running')}")
1795
+ else:
1796
+ print(f"\n{_bold('Running workers:')}")
1797
+ print(f" {_dim('none')}")
1798
+
1799
+ # Also check API's worker registry
1800
+ workers_base = f"http://localhost:{env_data.get('SHS_NGINX_PORT', '80')}"
1801
+ rc, wout = run_quiet(
1802
+ [
1803
+ "curl",
1804
+ "-sf",
1805
+ f"{workers_base}/api/v1/infrastructure/health/workers",
1806
+ ],
1807
+ )
1808
+ if rc == 0 and wout:
1809
+ try:
1810
+ data = json.loads(wout)
1811
+ workers = data.get("workers", [])
1812
+ if workers:
1813
+ print(f"\n{_bold('Registered with API:')}")
1814
+ for w in workers:
1815
+ wtype = w.get("type", "?")
1816
+ healthy = w.get("healthy", False)
1817
+ color = _green if healthy else _red
1818
+ status = "UP" if healthy else "DOWN"
1819
+ print(f" {wtype:20s} {color(status)}")
1820
+ except json.JSONDecodeError:
1821
+ pass
1822
+ else:
1823
+ # Container/runpod - check supervisorctl
1824
+ rc, out = run_quiet(["supervisorctl", "status"], timeout=10)
1825
+ if out:
1826
+ worker_lines = [l for l in out.splitlines() if "worker" in l.lower()]
1827
+ if worker_lines:
1828
+ print(f"\n{_bold('Running workers:')}")
1829
+ for line in worker_lines:
1830
+ parts = line.split()
1831
+ if len(parts) >= 2:
1832
+ name, state = parts[0], parts[1]
1833
+ color = _green if state == "RUNNING" else _red
1834
+ print(f" {name:30s} {color(state)}")
1835
+ else:
1836
+ print(f"\n{_bold('Running workers:')}")
1837
+ print(f" {_dim('none')}")
1838
+
1839
+ # Only show scaling config if workers are configured
1840
+ components = env_data.get("CONSOLE_COMPONENTS", "")
1841
+ has_workers = "worker" in components.lower()
1842
+ if has_workers or context != "host":
1843
+ general = env_data.get("SHS_GENERAL_WORKERS", "1")
1844
+ transfer = env_data.get("SHS_TRANSFER_WORKERS", "1")
1845
+ print(f"\n{_bold('Scaling (supervisord):')}")
1846
+ print(
1847
+ f" {_dim(f'SHS_GENERAL_WORKERS={general} SHS_TRANSFER_WORKERS={transfer}')}"
1848
+ )
1849
+ print()
1850
+
1851
+
1852
+ def cmd_reset_password(context: str, env_file: Path) -> None:
1853
+ """Reset super admin password. Prompts locally, passes to container."""
1854
+ new_password = _prompt_password("New super admin password")
1855
+
1856
+ if context == "host":
1857
+ env_data = read_env(env_file)
1858
+ health_url = f"http://localhost:{env_data.get('SHS_NGINX_PORT', '80')}/health"
1859
+ rc, _ = run_quiet(["curl", "-sf", health_url])
1860
+ if rc != 0:
1861
+ error("API is not running. Start services first.")
1862
+ return
1863
+ api_svc = _api_service_name(env_data)
1864
+ info("Resetting admin password...")
1865
+ try:
1866
+ # SHS_ names are the app script's env contract (scripts/reset_admin_password.py
1867
+ # reads SHS_ADMIN_PASSWORD / SHS_FORCE_PRODUCTION) — keep them, do NOT rename
1868
+ # to CONSOLE_. Transient injection only; never written to .env.
1869
+ run(
1870
+ compose_cmd(env_file)
1871
+ + [
1872
+ "exec",
1873
+ "-e",
1874
+ "SHS_FORCE_PRODUCTION=true",
1875
+ "-e",
1876
+ f"SHS_ADMIN_PASSWORD={new_password}",
1877
+ api_svc,
1878
+ "python",
1879
+ "scripts/reset_admin_password.py",
1880
+ ],
1881
+ timeout=60,
1882
+ )
1883
+ except subprocess.CalledProcessError:
1884
+ error("Failed to reset password. Check that the API container is healthy.")
1885
+ else:
1886
+ info("Resetting admin password...")
1887
+ # SHS_ names are the app script's env contract — keep, do NOT rename to CONSOLE_.
1888
+ os.environ["SHS_ADMIN_PASSWORD"] = new_password
1889
+ os.environ["SHS_FORCE_PRODUCTION"] = "true"
1890
+ try:
1891
+ run(
1892
+ ["python3", "/app/api/scripts/reset_admin_password.py"],
1893
+ timeout=60,
1894
+ )
1895
+ except subprocess.CalledProcessError:
1896
+ error("Failed to reset password.")
1897
+
1898
+
1899
+ # Backup format is pinned: pg_dump plain-text + --inserts, with a leading
1900
+ # "-- studio-console" comment block recording image tag and digest. Both
1901
+ # _read_revision_from_dump and _parse_dump_header depend on this format.
1902
+ # Do not switch to -Fc / -Fd or remove --inserts without updating both.
1903
+ BACKUP_HEADER_VERSION = 1
1904
+ PG_DUMP_FLAGS = ["--inserts"]
1905
+
1906
+ _DUMP_HEADER_RE = re.compile(r"^-- studio-console:([a-z_]+)=(.*)$")
1907
+ _ALEMBIC_INSERT_RE = re.compile(
1908
+ r"^INSERT INTO (?:public\.)?alembic_version\s*(?:\([^)]*\)\s*)?VALUES\s*\('([^']+)'\)",
1909
+ re.MULTILINE,
1910
+ )
1911
+
1912
+
1913
+ def _api_image_digest() -> str | None:
1914
+ """Return the image digest of a running studio-api container, or None."""
1915
+ rc, out = run_quiet(
1916
+ ["docker", "ps", "--filter", "name=studio-api", "--format", "{{.ID}}"],
1917
+ timeout=5,
1918
+ )
1919
+ if rc != 0 or not out.strip():
1920
+ return None
1921
+ cid = out.strip().splitlines()[0].strip()
1922
+ rc, out = run_quiet(["docker", "inspect", "--format", "{{.Image}}", cid], timeout=5)
1923
+ if rc != 0:
1924
+ return None
1925
+ return out.strip() or None
1926
+
1927
+
1928
+ def _encryption_key_fingerprint(env_data: dict) -> str:
1929
+ """sha256 of SHS_CREDENTIAL_ENCRYPTION_KEY, or "" if unset. Stored in the dump header so restore can verify the key without storing it."""
1930
+ import hashlib
1931
+
1932
+ key = (env_data.get("SHS_CREDENTIAL_ENCRYPTION_KEY") or "").strip()
1933
+ if not key:
1934
+ return ""
1935
+ return hashlib.sha256(key.encode("utf-8")).hexdigest()
1936
+
1937
+
1938
+ def _build_dump_header(env_data: dict, timestamp_iso: str) -> str:
1939
+ """Build the leading SQL comment block prepended to every dump."""
1940
+ fields = {
1941
+ "header_version": str(BACKUP_HEADER_VERSION),
1942
+ "created_at": timestamp_iso,
1943
+ "studio_image_tag": env_data.get("SHS_STUDIO_VERSION") or "",
1944
+ "studio_image_digest": _api_image_digest() or "",
1945
+ "encryption_key_fp": _encryption_key_fingerprint(env_data),
1946
+ }
1947
+ lines = ["-- studio-console backup"]
1948
+ lines += [f"-- studio-console:{k}={v}" for k, v in fields.items()]
1949
+ lines.append("--")
1950
+ return "\n".join(lines) + "\n"
1951
+
1952
+
1953
+ def _parse_dump_header(db_file: str) -> dict:
1954
+ """Read studio-console metadata from the dump's leading comment block.
1955
+
1956
+ Returns {} for legacy dumps with no header. Stops scanning at the first
1957
+ non-comment line so we never read past the preamble.
1958
+ """
1959
+ meta: dict = {}
1960
+ try:
1961
+ with open(db_file, "r", encoding="utf-8", errors="replace") as fh:
1962
+ for line in fh:
1963
+ if not line.startswith("--"):
1964
+ break
1965
+ m = _DUMP_HEADER_RE.match(line.rstrip())
1966
+ if m:
1967
+ meta[m.group(1)] = m.group(2)
1968
+ except OSError:
1969
+ pass
1970
+ return meta
1971
+
1972
+
1973
+ def _read_revision_from_dump(db_file: str) -> str | None:
1974
+ """Extract the alembic revision from a pg_dump --inserts plain-text dump."""
1975
+ try:
1976
+ text = Path(db_file).read_text(errors="replace")
1977
+ except OSError:
1978
+ return None
1979
+ m = _ALEMBIC_INSERT_RE.search(text)
1980
+ return m.group(1) if m else None
1981
+
1982
+
1983
+ def _read_current_db_revision(context: str, env_file: Path) -> str | None:
1984
+ """Query the running DB's alembic_version. Returns None if unreachable or empty."""
1985
+ env_data = read_env(env_file)
1986
+ pg_user = env_data.get("POSTGRES_USER", "postgres")
1987
+ sql = "SELECT version_num FROM alembic_version LIMIT 1;"
1988
+ if context == "host":
1989
+ cmd = compose_cmd(env_file) + [
1990
+ "exec",
1991
+ "-T",
1992
+ "postgres",
1993
+ "psql",
1994
+ "-U",
1995
+ pg_user,
1996
+ "-d",
1997
+ "selfhost_studio",
1998
+ "-tAc",
1999
+ sql,
2000
+ ]
2001
+ else:
2002
+ cmd = ["psql", "-U", pg_user, "-d", "selfhost_studio", "-tAc", sql]
2003
+ rc, out = run_quiet(cmd, timeout=10)
2004
+ if rc != 0:
2005
+ return None
2006
+ rev = out.strip()
2007
+ return rev or None
2008
+
2009
+
2010
+ def _format_local_time(iso_string: str) -> tuple[str, str]:
2011
+ """Return (absolute, relative) labels for a header timestamp.
2012
+
2013
+ Header timestamps are ISO 8601 with offset (e.g. 2026-05-07T08:56:17-07:00).
2014
+ We re-render in the operator's local zone so 11pm-incident reading is unambiguous.
2015
+ """
2016
+ import datetime
2017
+
2018
+ try:
2019
+ dt = datetime.datetime.fromisoformat(iso_string)
2020
+ except ValueError:
2021
+ return iso_string, ""
2022
+ local = dt.astimezone()
2023
+ abs_label = local.strftime("%b %-d, %Y %-I:%M %p %Z").strip()
2024
+ delta = datetime.datetime.now(local.tzinfo) - local
2025
+ secs = int(delta.total_seconds())
2026
+ if secs < 60:
2027
+ rel = "just now"
2028
+ elif secs < 3600:
2029
+ rel = f"{secs // 60} min ago"
2030
+ elif secs < 86400:
2031
+ rel = f"{secs // 3600} hr ago"
2032
+ elif secs < 86400 * 30:
2033
+ rel = f"{secs // 86400} day{'s' if secs // 86400 != 1 else ''} ago"
2034
+ else:
2035
+ rel = local.strftime("%b %Y")
2036
+ return abs_label, rel
2037
+
2038
+
2039
+ def _verify_post_restore(context: str, env_file: Path, db_file: str) -> None:
2040
+ """After restore, confirm the live DB's alembic_version matches the dump's.
2041
+
2042
+ Mismatch means the apply succeeded but landed on the wrong schema — surface
2043
+ it loudly so the operator doesn't trust a silent success.
2044
+ """
2045
+ expected = _read_revision_from_dump(db_file)
2046
+ if not expected:
2047
+ return # Nothing to verify against (legacy backup with no parseable rev).
2048
+ actual = _read_current_db_revision(context, env_file)
2049
+ if actual is None:
2050
+ warn("Could not verify post-restore schema (alembic_version unreadable).")
2051
+ return
2052
+ if actual != expected:
2053
+ error(
2054
+ f"Post-restore verification FAILED: expected schema {expected}, "
2055
+ f"got {actual}. The restore may be incomplete or corrupted."
2056
+ )
2057
+ error("Inspect the database before starting the API.")
2058
+ return
2059
+ ok(f"Schema verified: {actual}")
2060
+
2061
+
2062
+ def _offer_key_recovery(env_file: Path, backup_key_fp: str) -> bool:
2063
+ """Paste/verify the backup's key against its fingerprint; persist to .env on match."""
2064
+ import hashlib
2065
+
2066
+ print()
2067
+ info(_cyan("If you have the key this backup was taken under, paste it now to"))
2068
+ info(_cyan("verify and use it. Leave blank to cancel."))
2069
+ for _ in range(3):
2070
+ pasted = _prompt("Encryption key for this backup").strip()
2071
+ if not pasted:
2072
+ return False
2073
+ if hashlib.sha256(pasted.encode("utf-8")).hexdigest() == backup_key_fp:
2074
+ data = read_env(env_file)
2075
+ data["SHS_CREDENTIAL_ENCRYPTION_KEY"] = pasted
2076
+ write_env(env_file, data)
2077
+ ok("Encryption key verified and saved to .env")
2078
+ return True
2079
+ warn(_yellow("That key does not match this backup. Try again or leave blank."))
2080
+ warn(_yellow("No matching key entered."))
2081
+ return False
2082
+
2083
+
2084
+ def _restore_preflight(context: str, env_file: Path, db_file: str) -> bool:
2085
+ """Show pre-flight summary, run schema check, prompt appropriately.
2086
+
2087
+ Returns True if the operator confirms and the restore should proceed.
2088
+ Tiers:
2089
+ - prior_major / unknown_future → hard block (no override)
2090
+ - encryption key fingerprint differs → paste matching key or abort
2091
+ - schemas match → single y/N
2092
+ - schemas differ (both known) → require typing RESTORE
2093
+ - no header / unverifiable → require typing RESTORE
2094
+ """
2095
+ backup_dir = os.path.dirname(db_file)
2096
+ backup_name = os.path.basename(backup_dir)
2097
+ header = _parse_dump_header(db_file)
2098
+ backup_rev = _read_revision_from_dump(db_file)
2099
+ current_rev = _read_current_db_revision(context, env_file)
2100
+
2101
+ # Major-version boundary check: if we restore this backup, the DB ends up at
2102
+ # backup_rev. Is backup_rev compatible with the running image's major?
2103
+ env_data = read_env(env_file)
2104
+ target_tag = env_data.get("SHS_STUDIO_VERSION", "")
2105
+ target_major = mv.parse_target_major(target_tag)
2106
+ mv_result, mv_info = mv.classify_revision(backup_rev, target_major)
2107
+ if mv_result in ("prior_major", "unknown_future"):
2108
+ mv.render_block(mv_result, mv_info, "restore")
2109
+ info("Aborted (incompatible backup).")
2110
+ return False
2111
+
2112
+ # Encryption-key check: the dump's encrypted columns are only readable with
2113
+ # the key they were written under. We never store that key in the backup, so
2114
+ # we compare fingerprints — live key vs. the fp recorded in the header.
2115
+ backup_key_fp = header.get("encryption_key_fp", "")
2116
+ live_key_fp = _encryption_key_fingerprint(env_data)
2117
+ key_mismatch = bool(backup_key_fp and live_key_fp and backup_key_fp != live_key_fp)
2118
+
2119
+ has_header = bool(header)
2120
+ created_iso = header.get("created_at", "")
2121
+ abs_time, rel_time = (
2122
+ _format_local_time(created_iso) if created_iso else ("unknown", "")
2123
+ )
2124
+ studio_tag = header.get("studio_image_tag") or "unknown"
2125
+
2126
+ if not has_header and not backup_rev:
2127
+ tier = "unverifiable"
2128
+ elif backup_rev and current_rev and backup_rev == current_rev:
2129
+ tier = "match"
2130
+ elif backup_rev and current_rev and backup_rev != current_rev:
2131
+ tier = "mismatch"
2132
+ else:
2133
+ tier = "unverifiable"
2134
+
2135
+ if tier == "match":
2136
+ schema_line = _green("✓ matches your current database")
2137
+ elif tier == "mismatch":
2138
+ schema_line = _yellow("⚠ DIFFERENT from your current database")
2139
+ else:
2140
+ schema_line = _yellow("⚠ unverifiable (legacy backup, no header)")
2141
+
2142
+ print()
2143
+ print(_bold("About to restore database"))
2144
+ print("─" * 40)
2145
+ print(f" Backup: {backup_name}")
2146
+ if has_header:
2147
+ time_label = f"{abs_time}" + (f" ({rel_time})" if rel_time else "")
2148
+ print(f" Created: {time_label}")
2149
+ print(f" Studio version: {studio_tag}")
2150
+ if backup_rev:
2151
+ print(f" Backup schema: {_dim(backup_rev)}")
2152
+ if current_rev:
2153
+ print(f" Current schema: {_dim(current_rev)}")
2154
+ print(f" Schema check: {schema_line}")
2155
+ if key_mismatch:
2156
+ print(f" Encryption key: {_yellow('⚠ does NOT match this backup')}")
2157
+ print()
2158
+ print("This will:")
2159
+ print(f" • Take a pre-restore snapshot to {_backups_root(context, env_file)}")
2160
+ print(" • Replace the database with the backup")
2161
+ print(" • Keep your current .env (the encryption key is not changed)")
2162
+ print()
2163
+
2164
+ if key_mismatch:
2165
+ warn(
2166
+ _yellow("Your live SHS_CREDENTIAL_ENCRYPTION_KEY differs from the one this backup was")
2167
+ )
2168
+ warn(
2169
+ _yellow(
2170
+ "written under. Restoring will leave the encrypted data UNREADABLE."
2171
+ )
2172
+ )
2173
+ if _offer_key_recovery(env_file, backup_key_fp):
2174
+ return True # key now matches; fall through to restore
2175
+ info(_cyan("Restore needs the matching key — the data is unreadable without it."))
2176
+ info("Aborted (no valid encryption key).")
2177
+ return False
2178
+
2179
+ if tier == "match":
2180
+ return _interactive_yn("Restore this database?", default=False)
2181
+
2182
+ if tier == "mismatch":
2183
+ warn(
2184
+ _yellow(
2185
+ "This backup was taken with a different schema than what's installed."
2186
+ )
2187
+ )
2188
+ warn(_yellow("Restoring it may corrupt data, crash the API, or both."))
2189
+ info(
2190
+ _cyan(
2191
+ "If unsure, cancel and check which Studio version produced this backup."
2192
+ )
2193
+ )
2194
+ else: # unverifiable
2195
+ warn(
2196
+ _yellow(
2197
+ "This backup has no version metadata, so schema compatibility "
2198
+ "can't be verified."
2199
+ )
2200
+ )
2201
+ info(
2202
+ _cyan(
2203
+ "If unsure, cancel and check which Studio version produced this backup."
2204
+ )
2205
+ )
2206
+
2207
+ print()
2208
+ typed = _prompt("Type RESTORE to proceed")
2209
+ if typed != "RESTORE":
2210
+ info("Aborted.")
2211
+ return False
2212
+ return True
2213
+
2214
+
2215
+ def cmd_backup(
2216
+ context: str,
2217
+ env_file: Path,
2218
+ what: str = "all",
2219
+ name_prefix: str = "studio",
2220
+ ) -> str:
2221
+ """Backup database and/or workspace files. Returns the backup directory path."""
2222
+ import datetime
2223
+
2224
+ env_data = read_env(env_file)
2225
+ now = datetime.datetime.now()
2226
+ timestamp = now.strftime("%Y%m%d_%H%M%S")
2227
+ timestamp_iso = now.astimezone().isoformat(timespec="seconds")
2228
+ backup_name = f"{name_prefix}-{timestamp}"
2229
+
2230
+ backup_dir = str(backup_root(context, env_file) / backup_name)
2231
+ os.makedirs(backup_dir, exist_ok=True)
2232
+
2233
+ if what in ("all", "db"):
2234
+ info("Backing up database...")
2235
+ db_file = os.path.join(backup_dir, "database.sql")
2236
+ pg_user = env_data.get("POSTGRES_USER", "postgres")
2237
+ dump_args = ["pg_dump", *PG_DUMP_FLAGS, "-U", pg_user, "selfhost_studio"]
2238
+ if context == "host":
2239
+ cmd = compose_cmd(env_file) + ["exec", "-T", "postgres", *dump_args]
2240
+ else:
2241
+ cmd = dump_args
2242
+ result = run(cmd, capture=True, timeout=120)
2243
+ header = _build_dump_header(env_data, timestamp_iso)
2244
+ Path(db_file).write_text(header + result.stdout)
2245
+ ok(f"Database: {db_file}")
2246
+
2247
+ if what in ("all", "orgs"):
2248
+ orgs_dir = str(storage_root(context, env_file) / "orgs")
2249
+ if os.path.isdir(orgs_dir):
2250
+ info("Archiving org files...")
2251
+ archive = os.path.join(backup_dir, "orgs.tar.gz")
2252
+ run(
2253
+ ["tar", "czf", archive, "-C", os.path.dirname(orgs_dir), "orgs"],
2254
+ timeout=300,
2255
+ )
2256
+ ok(f"Orgs: {archive}")
2257
+ else:
2258
+ warn("No orgs directory found")
2259
+
2260
+ ok(f"Backup complete: {backup_dir}")
2261
+
2262
+ # The encryption key is deliberately NOT stored in the backup — co-locating
2263
+ # it with the ciphertext would let anyone holding the backup decrypt it.
2264
+ # Skip the notice for internal pre-restore snapshots (not operator-facing).
2265
+ if what in ("all", "db") and name_prefix != "pre-restore":
2266
+ _print_encryption_key_notice(env_data)
2267
+
2268
+ return backup_dir
2269
+
2270
+
2271
+ def _print_encryption_key_notice(env_data: dict) -> None:
2272
+ """Remind the operator to store SHS_CREDENTIAL_ENCRYPTION_KEY separately from the backup."""
2273
+ key = (env_data.get("SHS_CREDENTIAL_ENCRYPTION_KEY") or "").strip()
2274
+ print()
2275
+ print(_bold("⚠ Record your encryption key separately"))
2276
+ print("─" * 40)
2277
+ if key:
2278
+ print("This backup does NOT contain your encryption key — by design, so a")
2279
+ print("stolen backup can't be decrypted. The dump is unrecoverable without it.")
2280
+ print()
2281
+ print("Copy SHS_CREDENTIAL_ENCRYPTION_KEY from your .env into a password manager or")
2282
+ print("secrets vault, kept apart from these backup files:")
2283
+ print()
2284
+ print(f" SHS_CREDENTIAL_ENCRYPTION_KEY={key}")
2285
+ else:
2286
+ print(_yellow("No SHS_CREDENTIAL_ENCRYPTION_KEY is set in .env — nothing to record."))
2287
+ print()
2288
+
2289
+
2290
+ def _backups_root(context: str, env_file: Path) -> str:
2291
+ """Return the directory where backups live for the current context."""
2292
+ return str(backup_root(context, env_file))
2293
+
2294
+
2295
+ def _check_snapshot_space(context: str, env_file: Path) -> None:
2296
+ """Abort if the backup volume can't hold a pre-restore snapshot.
2297
+
2298
+ Heuristic: require max(1.5 * pg_database_size, db_size + 100 MB).
2299
+ pg_dump --inserts is verbose for small DBs (often >1× on-disk size) and
2300
+ leaner than the live DB for large DBs (no indexes/bloat). The 1.5×
2301
+ factor is a safe upper bound for typical Studio installs.
2302
+ """
2303
+ import shutil
2304
+
2305
+ env_data = read_env(env_file)
2306
+ pg_user = env_data.get("POSTGRES_USER", "postgres")
2307
+ sql = "SELECT pg_database_size('selfhost_studio')"
2308
+ if context == "host":
2309
+ cmd = compose_cmd(env_file) + [
2310
+ "exec",
2311
+ "-T",
2312
+ "postgres",
2313
+ "psql",
2314
+ "-U",
2315
+ pg_user,
2316
+ "-d",
2317
+ "selfhost_studio",
2318
+ "-tAc",
2319
+ sql,
2320
+ ]
2321
+ else:
2322
+ cmd = ["psql", "-U", pg_user, "-d", "selfhost_studio", "-tAc", sql]
2323
+ rc, out = run_quiet(cmd, timeout=10)
2324
+ if rc != 0 or not out.strip().isdigit():
2325
+ return # Can't size the DB; skip the precheck rather than block.
2326
+ db_bytes = int(out.strip())
2327
+ required = max(int(db_bytes * 1.5), db_bytes + 100 * 1024 * 1024)
2328
+
2329
+ backups_root = _backups_root(context, env_file)
2330
+ os.makedirs(backups_root, exist_ok=True)
2331
+ free = shutil.disk_usage(backups_root).free
2332
+ if free < required:
2333
+ fatal(
2334
+ f"Pre-restore snapshot needs ~{required // (1024 * 1024)} MB, "
2335
+ f"only {free // (1024 * 1024)} MB free on {backups_root}.\n"
2336
+ f"Free up space, or set CONSOLE_SKIP_PRE_RESTORE_SNAPSHOT=1 to skip "
2337
+ f"(not recommended)."
2338
+ )
2339
+
2340
+
2341
+ def _take_pre_restore_snapshot(context: str, env_file: Path) -> str | None:
2342
+ """Take a forced backup of current DB before restore. Returns dir path or None if skipped."""
2343
+ if os.environ.get("CONSOLE_SKIP_PRE_RESTORE_SNAPSHOT", "").strip() in (
2344
+ "1",
2345
+ "true",
2346
+ "yes",
2347
+ ):
2348
+ warn("Pre-restore snapshot skipped (CONSOLE_SKIP_PRE_RESTORE_SNAPSHOT set)")
2349
+ return None
2350
+ _check_snapshot_space(context, env_file)
2351
+ info("Taking pre-restore snapshot of current database...")
2352
+ return cmd_backup(context, env_file, what="db", name_prefix="pre-restore")
2353
+
2354
+
2355
+ def _pick_db_file(context: str, env_file: Path) -> str | None:
2356
+ """Prompt for a directory then let the user pick a .sql file from it."""
2357
+ default_dir = str(backup_root(context, env_file))
2358
+ raw = _prompt(f"Backup directory [{default_dir}]", "").strip()
2359
+ search_dir = os.path.expanduser(raw) if raw else default_dir
2360
+
2361
+ if not os.path.isdir(search_dir):
2362
+ error(f"Directory not found: {search_dir}")
2363
+ return None
2364
+
2365
+ import glob
2366
+
2367
+ sql_files = sorted(
2368
+ glob.glob(os.path.join(search_dir, "**", "*.sql"), recursive=True)
2369
+ )
2370
+ if not sql_files:
2371
+ error(f"No .sql files found in {search_dir}")
2372
+ return None
2373
+
2374
+ labels = []
2375
+ for f in sql_files:
2376
+ rel = os.path.relpath(f, search_dir)
2377
+ meta = _parse_dump_header(f)
2378
+ rev = _read_revision_from_dump(f)
2379
+ if meta or rev:
2380
+ rev_label = (rev or "no-rev")[:12]
2381
+ tag = meta.get("studio_image_tag") or "no-tag"
2382
+ labels.append(f"{rel} {_dim(f'rev={rev_label} tag={tag}')}")
2383
+ else:
2384
+ labels.append(f"{rel} {_dim('(legacy, no header)')}")
2385
+ idx = _interactive_single("Select database file to restore", labels, default=0)
2386
+ return sql_files[idx]
2387
+
2388
+
2389
+ def cmd_restore_db(
2390
+ context: str, env_file: Path, db_file: str, confirm: bool = True
2391
+ ) -> bool:
2392
+ """Restore a single .sql file into the database. Returns True on success."""
2393
+ import time
2394
+
2395
+ env_data = read_env(env_file)
2396
+ pg_user = env_data.get("POSTGRES_USER", "postgres")
2397
+ db_name = "selfhost_studio"
2398
+
2399
+ if confirm:
2400
+ if not _restore_preflight(context, env_file, db_file):
2401
+ return False
2402
+
2403
+ _take_pre_restore_snapshot(context, env_file)
2404
+
2405
+ if context == "host":
2406
+ info("Waiting for postgres...")
2407
+ base = compose_cmd(env_file) + ["exec", "-T", "postgres"]
2408
+ for _ in range(15):
2409
+ rc, _ = run_quiet(base + ["pg_isready", "-U", pg_user])
2410
+ if rc == 0:
2411
+ break
2412
+ time.sleep(2)
2413
+ else:
2414
+ error("Postgres not ready after 30s")
2415
+ return False
2416
+
2417
+ try:
2418
+ if context == "host":
2419
+ # Stop API/UI before dropping the schema — a live API reconnects
2420
+ # to postgres mid-restore (pg_terminate_backend only kills current
2421
+ # connections) and reads/writes a half-restored schema. The
2422
+ # pre-restore snapshot above is the recovery net if this fails.
2423
+ info("Stopping API/UI for exclusive DB access...")
2424
+ _stop_api_ui_containers()
2425
+ base = compose_cmd(env_file) + ["exec", "-T", "postgres"]
2426
+ run_quiet(
2427
+ base
2428
+ + [
2429
+ "psql",
2430
+ "-q",
2431
+ "-U",
2432
+ pg_user,
2433
+ "-c",
2434
+ f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
2435
+ f"WHERE datname = '{db_name}' AND pid <> pg_backend_pid();",
2436
+ ],
2437
+ timeout=30,
2438
+ )
2439
+ run_quiet(
2440
+ base
2441
+ + [
2442
+ "psql",
2443
+ "-q",
2444
+ "-U",
2445
+ pg_user,
2446
+ "-c",
2447
+ "DROP SCHEMA public CASCADE; CREATE SCHEMA public;",
2448
+ db_name,
2449
+ ],
2450
+ timeout=30,
2451
+ )
2452
+ info("Applying dump...")
2453
+ with open(db_file) as f:
2454
+ subprocess.run(
2455
+ compose_cmd(env_file)
2456
+ + ["exec", "-T", "postgres", "psql", "-q", "-U", pg_user, db_name],
2457
+ stdin=f,
2458
+ stdout=subprocess.DEVNULL,
2459
+ stderr=subprocess.PIPE,
2460
+ timeout=300,
2461
+ check=True,
2462
+ )
2463
+ else:
2464
+ run_quiet(
2465
+ [
2466
+ "psql",
2467
+ "-q",
2468
+ "-U",
2469
+ pg_user,
2470
+ "-c",
2471
+ f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
2472
+ f"WHERE datname = '{db_name}' AND pid <> pg_backend_pid();",
2473
+ ],
2474
+ timeout=30,
2475
+ )
2476
+ run_quiet(
2477
+ [
2478
+ "psql",
2479
+ "-q",
2480
+ "-U",
2481
+ pg_user,
2482
+ "-c",
2483
+ "DROP SCHEMA public CASCADE; CREATE SCHEMA public;",
2484
+ db_name,
2485
+ ],
2486
+ timeout=30,
2487
+ )
2488
+ info("Applying dump...")
2489
+ with open(db_file) as f:
2490
+ subprocess.run(
2491
+ ["psql", "-q", "-U", pg_user, db_name],
2492
+ stdin=f,
2493
+ stdout=subprocess.DEVNULL,
2494
+ stderr=subprocess.PIPE,
2495
+ timeout=300,
2496
+ check=True,
2497
+ )
2498
+ except subprocess.CalledProcessError as e:
2499
+ error(f"Database restore failed: {e}")
2500
+ if e.stderr:
2501
+ error(
2502
+ e.stderr.decode("utf-8", errors="replace")
2503
+ if isinstance(e.stderr, bytes)
2504
+ else e.stderr
2505
+ )
2506
+ error("Is postgres running? Try: studio-console start")
2507
+ return False
2508
+ except Exception as e:
2509
+ error(f"Database restore failed: {e}")
2510
+ error("Is postgres running? Try: studio-console start")
2511
+ return False
2512
+
2513
+ ok("Database restored")
2514
+ _verify_post_restore(context, env_file, db_file)
2515
+ # .env (and its encryption key) is intentionally left untouched — the key
2516
+ # is never stored in the backup. The preflight already gated key mismatch.
2517
+
2518
+ # API/UI were stopped for exclusive DB access — bring them back up.
2519
+ if context == "host":
2520
+ info("Restarting API/UI...")
2521
+ env_data2 = read_env(env_file)
2522
+ run(
2523
+ _apply_scale_flags(
2524
+ compose_cmd(env_file) + ["up", "-d", "--remove-orphans"], env_data2
2525
+ ),
2526
+ timeout=120,
2527
+ )
2528
+ ok("API/UI restarted")
2529
+ if _app_db_url(env_data2):
2530
+ info(
2531
+ "Grants and RLS policies for the restricted role re-apply during "
2532
+ "boot; requests may fail with sanitized 500s until it finishes."
2533
+ )
2534
+ else:
2535
+ # Provisioning/grants/RLS re-apply in the container ENTRYPOINT, so the
2536
+ # restore flow ends with a container restart — supervisorctl restart
2537
+ # is not enough. Until then the restricted role authenticates but every
2538
+ # table access is permission-denied → sanitized 500s (fail-closed).
2539
+ warn("Restart this container from the host now to finish the restore.")
2540
+ print(f" {_bold('docker restart <container>')} {_dim('(or stop/start the pod on RunPod)')}")
2541
+ if _app_db_url(env_data):
2542
+ warn(
2543
+ "Until the restart, API requests fail with sanitized 500s — "
2544
+ "the restore dropped the restricted role's grants; boot re-applies them."
2545
+ )
2546
+
2547
+ return True
2548
+
2549
+
2550
+ def cmd_restore(
2551
+ context: str, env_file: Path, path: str | None, what: str = "all"
2552
+ ) -> None:
2553
+ """Restore database and/or org files from backup."""
2554
+ env_data = read_env(env_file)
2555
+
2556
+ if path:
2557
+ restore_dir = path
2558
+ else:
2559
+ backup_base = str(backup_root(context, env_file))
2560
+ if not os.path.isdir(backup_base):
2561
+ fatal("No backups found")
2562
+ entries = sorted(os.listdir(backup_base), reverse=True)
2563
+ if not entries:
2564
+ fatal("No backups found")
2565
+ restore_dir = os.path.join(backup_base, entries[0])
2566
+
2567
+ info(f"Restoring from: {restore_dir}")
2568
+
2569
+ if what in ("all", "db"):
2570
+ db_file = os.path.join(restore_dir, "database.sql")
2571
+ if os.path.isfile(db_file):
2572
+ print()
2573
+ if not _restore_preflight(context, env_file, db_file):
2574
+ return
2575
+
2576
+ _take_pre_restore_snapshot(context, env_file)
2577
+
2578
+ pg_user = env_data.get("POSTGRES_USER", "postgres")
2579
+ db_name = "selfhost_studio"
2580
+
2581
+ if context == "host":
2582
+ # Stop API/UI before dropping the schema — see cmd_restore_db.
2583
+ # A live API reconnects mid-restore and reads a half-restored
2584
+ # schema; the pre-restore snapshot above is the recovery net.
2585
+ info("Stopping API/UI for exclusive DB access...")
2586
+ _stop_api_ui_containers()
2587
+ base = compose_cmd(env_file) + ["exec", "-T", "postgres"]
2588
+ run_quiet(
2589
+ base
2590
+ + [
2591
+ "psql",
2592
+ "-q",
2593
+ "-U",
2594
+ pg_user,
2595
+ "-c",
2596
+ f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
2597
+ f"WHERE datname = '{db_name}' AND pid <> pg_backend_pid();",
2598
+ ],
2599
+ timeout=30,
2600
+ )
2601
+ run_quiet(
2602
+ base
2603
+ + [
2604
+ "psql",
2605
+ "-q",
2606
+ "-U",
2607
+ pg_user,
2608
+ "-c",
2609
+ "DROP SCHEMA public CASCADE; CREATE SCHEMA public;",
2610
+ db_name,
2611
+ ],
2612
+ timeout=30,
2613
+ )
2614
+ info("Applying dump...")
2615
+ with open(db_file) as f:
2616
+ subprocess.run(
2617
+ compose_cmd(env_file)
2618
+ + [
2619
+ "exec",
2620
+ "-T",
2621
+ "postgres",
2622
+ "psql",
2623
+ "-q",
2624
+ "-U",
2625
+ pg_user,
2626
+ db_name,
2627
+ ],
2628
+ stdin=f,
2629
+ stdout=subprocess.DEVNULL,
2630
+ stderr=subprocess.PIPE,
2631
+ timeout=300,
2632
+ check=True,
2633
+ )
2634
+ else:
2635
+ run_quiet(
2636
+ [
2637
+ "psql",
2638
+ "-q",
2639
+ "-U",
2640
+ pg_user,
2641
+ "-c",
2642
+ f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
2643
+ f"WHERE datname = '{db_name}' AND pid <> pg_backend_pid();",
2644
+ ],
2645
+ timeout=30,
2646
+ )
2647
+ run_quiet(
2648
+ [
2649
+ "psql",
2650
+ "-q",
2651
+ "-U",
2652
+ pg_user,
2653
+ "-c",
2654
+ "DROP SCHEMA public CASCADE; CREATE SCHEMA public;",
2655
+ db_name,
2656
+ ],
2657
+ timeout=30,
2658
+ )
2659
+ info("Applying dump...")
2660
+ with open(db_file) as f:
2661
+ subprocess.run(
2662
+ ["psql", "-q", "-U", pg_user, db_name],
2663
+ stdin=f,
2664
+ stdout=subprocess.DEVNULL,
2665
+ stderr=subprocess.PIPE,
2666
+ timeout=300,
2667
+ check=True,
2668
+ )
2669
+ ok("Database restored")
2670
+ _verify_post_restore(context, env_file, db_file)
2671
+ # .env (and its encryption key) is intentionally left untouched — the
2672
+ # key is never stored in the backup. Preflight gated key mismatch.
2673
+ else:
2674
+ warn(f"No database.sql in {restore_dir}")
2675
+
2676
+ if what in ("all", "orgs"):
2677
+ orgs_archive = os.path.join(restore_dir, "orgs.tar.gz")
2678
+ if os.path.isfile(orgs_archive):
2679
+ # Archive root is `orgs/...` so extract into shared/ (the parent of orgs/).
2680
+ target = str(storage_root(context, env_file))
2681
+ info("Restoring organization files...")
2682
+ run(["tar", "xzf", orgs_archive, "-C", target], timeout=300)
2683
+ ok("Org files restored")
2684
+ else:
2685
+ warn(f"No orgs.tar.gz in {restore_dir}")
2686
+
2687
+ # API/UI were stopped for exclusive DB access — bring them back up.
2688
+ if context == "host":
2689
+ print()
2690
+ info("Restarting API/UI...")
2691
+ run(
2692
+ _apply_scale_flags(
2693
+ compose_cmd(env_file) + ["up", "-d", "--remove-orphans"], env_data
2694
+ ),
2695
+ timeout=120,
2696
+ )
2697
+ ok("API/UI restarted")
2698
+
2699
+
2700
+ def _fetch_available_versions(limit: int = 10) -> list[str]:
2701
+ """Fetch available version tags from GHCR."""
2702
+ import urllib.request
2703
+ import urllib.error
2704
+
2705
+ # GHCR requires a token even for public repos
2706
+ token_url = "https://ghcr.io/token?scope=repository:selfhosthub/studio-api:pull"
2707
+ try:
2708
+ with urllib.request.urlopen(token_url, timeout=10) as resp:
2709
+ token = json.loads(resp.read().decode()).get("token", "")
2710
+ except (urllib.error.URLError, OSError, json.JSONDecodeError):
2711
+ return []
2712
+
2713
+ url = "https://ghcr.io/v2/selfhosthub/studio-api/tags/list"
2714
+ try:
2715
+ req = urllib.request.Request(
2716
+ url,
2717
+ headers={
2718
+ "Accept": "application/json",
2719
+ "Authorization": f"Bearer {token}",
2720
+ },
2721
+ )
2722
+ with urllib.request.urlopen(req, timeout=10) as resp:
2723
+ data = json.loads(resp.read().decode())
2724
+ except (urllib.error.URLError, OSError, json.JSONDecodeError):
2725
+ return []
2726
+
2727
+ # Filter to semver tags (X.Y.Z), skip "latest" and digests
2728
+ versions: list[str] = []
2729
+ for name in data.get("tags", []):
2730
+ if re.match(r"^\d+\.\d+\.\d+$", name):
2731
+ versions.append(name)
2732
+
2733
+ # Sort descending by semver
2734
+ versions.sort(key=lambda v: tuple(int(x) for x in v.split(".")), reverse=True)
2735
+ return versions[:limit]
2736
+
2737
+
2738
+ def cmd_upgrade(context: str, env_file: Path) -> None:
2739
+ """Upgrade or rollback Studio to a specific version."""
2740
+ env_data = read_env(env_file)
2741
+ current = env_data.get("SHS_STUDIO_VERSION", "unknown")
2742
+
2743
+ if context != "host":
2744
+ warn("In-container upgrades are not supported.")
2745
+ print(f"\n Current version: {_bold(current)}")
2746
+ print()
2747
+ if context == "runpod":
2748
+ print(" To upgrade:")
2749
+ print(" 1. Update the pod template to use the new image tag")
2750
+ print(" 2. Stop and restart the pod")
2751
+ print(" Your data is safe on the network volume.")
2752
+ else:
2753
+ from .env import detect_shape
2754
+
2755
+ shape = detect_shape(env_file) or "core"
2756
+ image = "studio-full" if shape == "full" else "studio-core"
2757
+ print(" To upgrade:")
2758
+ print(
2759
+ f" 1. Pull the new image: docker pull ghcr.io/selfhosthub/{image}:<tag>"
2760
+ )
2761
+ print(" 2. Stop the current container")
2762
+ print(" 3. Start a new container with the updated image")
2763
+ return
2764
+
2765
+ print(f"\n Current version: {_bold(current)}")
2766
+
2767
+ info("Checking available versions...")
2768
+ versions = _fetch_available_versions()
2769
+
2770
+ if not versions:
2771
+ warn("Could not fetch versions from Docker Hub")
2772
+ target = _prompt("Enter version to install (e.g. 1.1.0)")
2773
+ else:
2774
+ print()
2775
+ for i, v in enumerate(versions, 1):
2776
+ marker = " ← current" if v == current else ""
2777
+ print(f" {_bold(str(i))}. {v}{marker}")
2778
+ print(f" {_bold(str(len(versions) + 1))}. Enter manually")
2779
+
2780
+ while True:
2781
+ raw = _prompt("Select version", "1")
2782
+ try:
2783
+ pick = int(raw.strip())
2784
+ if 1 <= pick <= len(versions):
2785
+ target = versions[pick - 1]
2786
+ break
2787
+ elif pick == len(versions) + 1:
2788
+ target = _prompt("Enter version (e.g. 1.1.0)")
2789
+ break
2790
+ except ValueError:
2791
+ pass
2792
+ print(f" {_red('✗')} Enter a number between 1 and {len(versions) + 1}")
2793
+
2794
+ if target == current:
2795
+ info(f"Already on {current}, nothing to do")
2796
+ return
2797
+
2798
+ if mv.check_and_block(
2799
+ env_file, mv.parse_target_major(target), "upgrade to", context, target
2800
+ ):
2801
+ return
2802
+
2803
+ direction = "Upgrading" if target > current else "Rolling back"
2804
+ info(f"{direction}: {current} → {_bold(target)}")
2805
+
2806
+ # Update version in .env
2807
+ set_env_value(env_file, "SHS_STUDIO_VERSION", target)
2808
+ ok(f"SHS_STUDIO_VERSION set to {target}")
2809
+
2810
+ # Pull new images
2811
+ info("Pulling images...")
2812
+ run(compose_cmd(env_file) + ["pull"], timeout=300)
2813
+ ok("Images pulled")
2814
+
2815
+ # Restart — reapply scale flags + --remove-orphans so replica counts survive
2816
+ # the upgrade and stale containers from the old version are cleaned up.
2817
+ info("Restarting services...")
2818
+ env_data = read_env(env_file)
2819
+ run(
2820
+ _apply_scale_flags(
2821
+ compose_cmd(env_file) + ["up", "-d", "--remove-orphans"], env_data
2822
+ ),
2823
+ timeout=120,
2824
+ )
2825
+ ok(f"Studio {direction.lower()} to {target}")
2826
+
2827
+
2828
+ def cmd_links(context: str, env_file: Path) -> None:
2829
+ """Print service URLs."""
2830
+ env_data = read_env(env_file)
2831
+ _print_links(env_data, context)
2832
+
2833
+
2834
+ def _print_links(env_data: dict[str, str], context: str) -> None:
2835
+ """Print service URLs from env data."""
2836
+ public_url = env_data.get("SHS_PUBLIC_BASE_URL", "")
2837
+ api_url = env_data.get("CONSOLE_PUBLIC_API_BASE_URL", "")
2838
+ nginx_port = env_data.get("SHS_NGINX_PORT", "80")
2839
+ has_public = public_url.startswith("https://")
2840
+
2841
+ print(f"\n{_bold('Links:')}")
2842
+ print(f" Studio: http://localhost:{nginx_port}")
2843
+ print(f" API: http://localhost:{nginx_port}/api")
2844
+ print(f" Health: http://localhost:{nginx_port}/health")
2845
+ print(f" Nginx: http://localhost:{nginx_port}/nginx-health")
2846
+
2847
+ if has_public:
2848
+ print()
2849
+ print(f" Public: {public_url}")
2850
+ if api_url.startswith("https://"):
2851
+ print(f" Public API: {api_url}")
2852
+
2853
+ if context in ("container", "runpod"):
2854
+ print(f" Supervisor: http://localhost:9001")
2855
+
2856
+ print()
2857
+
2858
+
2859
+ def _detect_install_method() -> str:
2860
+ """Detect how studio-console was installed.
2861
+
2862
+ Returns one of: "uv", "pip", "brew", "curl", "dev"
2863
+ """
2864
+ from pathlib import Path
2865
+
2866
+ pkg_dir = Path(__file__).resolve().parent.parent
2867
+
2868
+ # Marker written by install.sh
2869
+ marker = pkg_dir / ".install-method"
2870
+ if marker.exists():
2871
+ return marker.read_text().strip()
2872
+
2873
+ # uv tool install — path contains uv/tools, no pip module available
2874
+ if "uv/tools" in str(pkg_dir).replace("\\", "/"):
2875
+ return "uv"
2876
+
2877
+ # pip install puts the package inside site-packages
2878
+ if "site-packages" in str(pkg_dir):
2879
+ return "pip"
2880
+
2881
+ # Homebrew installs under Cellar
2882
+ if "Cellar" in str(pkg_dir) or "homebrew" in str(pkg_dir).lower():
2883
+ return "brew"
2884
+
2885
+ # Inside a git repo — dev mode
2886
+ if (pkg_dir / ".git").exists() or (pkg_dir.parent / ".git").exists():
2887
+ return "dev"
2888
+
2889
+ return "curl"
2890
+
2891
+
2892
+ def cmd_self_update(context: str) -> None:
2893
+ """Update studio-console — delegates to the correct mechanism for the install method."""
2894
+ import json
2895
+ import os
2896
+ import shutil
2897
+ import subprocess
2898
+ import tarfile
2899
+ import tempfile
2900
+ import urllib.error
2901
+ import urllib.request
2902
+ from pathlib import Path
2903
+
2904
+ from . import __version__
2905
+
2906
+ if context in ("container", "runpod"):
2907
+ warn("studio-console is baked into the container image.")
2908
+ warn("Update by pulling a new image tag — not via self-update.")
2909
+ return
2910
+
2911
+ method = _detect_install_method()
2912
+ info(f"Current version: {__version__} (installed via {method})")
2913
+
2914
+ if method == "dev":
2915
+ warn("Running from source — use git pull to update.")
2916
+ return
2917
+
2918
+ if method == "brew":
2919
+ info("Updating via Homebrew...")
2920
+ run(["brew", "upgrade", "selfhosthub/studio/studio-console"], timeout=120)
2921
+ ok("Updated")
2922
+ return
2923
+
2924
+ if method == "pip":
2925
+ info("Updating via pip...")
2926
+ run(
2927
+ [os.sys.executable, "-m", "pip", "install", "--upgrade", "studio-console"],
2928
+ timeout=120,
2929
+ )
2930
+ ok("Updated — restart studio-console to use the new version")
2931
+ return
2932
+
2933
+ # uv and curl installs both resolve the latest GitHub release first
2934
+ info("Checking for updates...")
2935
+ api_url = "https://api.github.com/repos/selfhosthub/studio-console/releases/latest"
2936
+ try:
2937
+ req = urllib.request.Request(
2938
+ api_url, headers={"Accept": "application/vnd.github+json"}
2939
+ )
2940
+ with urllib.request.urlopen(req, timeout=10) as resp:
2941
+ data = json.loads(resp.read().decode())
2942
+ except (urllib.error.URLError, OSError, json.JSONDecodeError) as e:
2943
+ fatal(f"Could not reach GitHub: {e}")
2944
+
2945
+ latest_tag = data.get("tag_name", "")
2946
+ latest_version = latest_tag.lstrip("v")
2947
+ if not latest_version:
2948
+ fatal("Could not determine latest version from GitHub response")
2949
+
2950
+ if latest_version == __version__:
2951
+ ok(f"Already up to date ({__version__})")
2952
+ return
2953
+
2954
+ info(f"Updating {__version__} → {latest_version}")
2955
+
2956
+ if method == "uv":
2957
+ wheel_url = (
2958
+ f"https://github.com/selfhosthub/studio-console/releases/download/"
2959
+ f"{latest_tag}/studio_console-{latest_version}-py3-none-any.whl"
2960
+ )
2961
+ info("Updating via uv tool...")
2962
+ run(["uv", "tool", "install", "--force", wheel_url], timeout=120)
2963
+ ok("Updated — restart studio-console to use the new version")
2964
+ return
2965
+
2966
+ tarball_url = f"https://github.com/selfhosthub/studio-console/archive/refs/tags/{latest_tag}.tar.gz"
2967
+ install_dir = Path(__file__).resolve().parent.parent
2968
+
2969
+ tmp = tempfile.mkdtemp()
2970
+ try:
2971
+ tarball_path = os.path.join(tmp, "release.tar.gz")
2972
+ info("Downloading...")
2973
+ urllib.request.urlretrieve(tarball_url, tarball_path)
2974
+
2975
+ extract_dir = os.path.join(tmp, "extracted")
2976
+ os.makedirs(extract_dir)
2977
+ with tarfile.open(tarball_path, "r:gz") as tf:
2978
+ members = tf.getmembers()
2979
+ prefix = members[0].name.split("/")[0] + "/" if members else ""
2980
+ for member in members:
2981
+ if member.name.startswith(prefix):
2982
+ member.name = member.name[len(prefix) :]
2983
+ if member.name:
2984
+ tf.extract(member, extract_dir)
2985
+
2986
+ for item in os.listdir(extract_dir):
2987
+ src = os.path.join(extract_dir, item)
2988
+ dst = os.path.join(str(install_dir), item)
2989
+ if os.path.isdir(src):
2990
+ if os.path.exists(dst):
2991
+ shutil.rmtree(dst)
2992
+ shutil.copytree(src, dst)
2993
+ else:
2994
+ shutil.copy2(src, dst)
2995
+
2996
+ # Preserve the install method marker
2997
+ (install_dir / ".install-method").write_text("curl")
2998
+ finally:
2999
+ shutil.rmtree(tmp, ignore_errors=True)
3000
+
3001
+ ok(f"Updated to {latest_version}")