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,684 @@
1
+ # studio_console/commands_launch.py
2
+ """Host-side launcher for the single-image `full` and `core` shapes."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ from .env import run, run_quiet
11
+ from .tui import (
12
+ _bold,
13
+ _cyan,
14
+ _dim,
15
+ _interactive_single,
16
+ _prompt,
17
+ _prompt_password,
18
+ error,
19
+ heading,
20
+ info,
21
+ ok,
22
+ warn,
23
+ )
24
+
25
+ MANIFEST_PATH_IN_IMAGE = "/app/contracts/launch-manifest.json"
26
+ DEFAULT_IMAGE = "ghcr.io/selfhosthub/studio-full"
27
+ DEFAULT_TAG = "latest"
28
+ CONTAINER_NAME = "studio-full"
29
+ STATE_DIR = Path.home() / ".studio-full"
30
+ STATE_FILE = STATE_DIR / ".console-state"
31
+
32
+ # Core (external-DB) launcher constants. Core needs a Postgres it does not
33
+ # provide; the launcher owns the optional sidecar and its lifecycle.
34
+ CORE_IMAGE = "ghcr.io/selfhosthub/studio-core"
35
+ CORE_CONTAINER_NAME = "studio-core"
36
+ CORE_STATE_DIR = Path.home() / ".studio-core"
37
+ CORE_STATE_FILE = CORE_STATE_DIR / ".console-state"
38
+ CORE_NETWORK = "studio-core-net"
39
+ CORE_PG_CONTAINER = "studio-core-pg"
40
+ CORE_PG_DB = "selfhost_studio"
41
+ CORE_PG_USER = "postgres"
42
+
43
+
44
+ def _read_manifest(image_ref: str, shape: str = "full") -> dict | None:
45
+ """Read the launch manifest for *shape* from inside the image."""
46
+ rc, out = run_quiet(
47
+ ["docker", "run", "--rm", "--entrypoint", "cat", image_ref, MANIFEST_PATH_IN_IMAGE],
48
+ timeout=60,
49
+ )
50
+ if rc != 0:
51
+ error(f"Could not read launch manifest from {image_ref}")
52
+ if out:
53
+ print(f" {_dim(out)}")
54
+ return None
55
+ try:
56
+ parsed = json.loads(out)
57
+ except json.JSONDecodeError as e:
58
+ error(f"Launch manifest malformed: {e}")
59
+ return None
60
+ manifest = parsed.get("shapes", {}).get(shape)
61
+ if manifest is None:
62
+ error(f"Launch manifest has no '{shape}' shape — image predates {shape} support.")
63
+ return None
64
+ # engine.image (pgvector pin) lives at top level; carry it for the sidecar.
65
+ manifest["_engine"] = parsed.get("engine", {})
66
+ return manifest
67
+
68
+
69
+ def _load_state(state_file: Path = STATE_FILE) -> dict:
70
+ """Read persisted creds (KEY=VALUE lines). Empty if absent."""
71
+ if not state_file.exists():
72
+ return {}
73
+ state: dict[str, str] = {}
74
+ for line in state_file.read_text().splitlines():
75
+ if "=" in line and not line.startswith("#"):
76
+ k, _, v = line.partition("=")
77
+ state[k.strip()] = v.strip()
78
+ return state
79
+
80
+
81
+ def _save_state(state: dict, state_dir: Path = STATE_DIR, state_file: Path = STATE_FILE) -> None:
82
+ """Persist creds atomically at 0600."""
83
+ state_dir.mkdir(mode=0o700, exist_ok=True)
84
+ content = "\n".join(f"{k}={v}" for k, v in state.items()) + "\n"
85
+ fd, tmp = tempfile.mkstemp(dir=str(state_dir), suffix=".tmp")
86
+ try:
87
+ os.chmod(fd, 0o600)
88
+ os.write(fd, content.encode())
89
+ os.close(fd)
90
+ os.replace(tmp, str(state_file))
91
+ if os.stat(state_file).st_mode & 0o777 != 0o600:
92
+ os.chmod(state_file, 0o600)
93
+ except BaseException:
94
+ if os.path.exists(tmp):
95
+ os.unlink(tmp)
96
+ raise
97
+
98
+
99
+ def _ensure_creds(
100
+ manifest: dict,
101
+ state_dir: Path = STATE_DIR,
102
+ state_file: Path = STATE_FILE,
103
+ only: list[str] | None = None,
104
+ ) -> dict:
105
+ """Return required_env creds, prompting once and persisting if not yet stored.
106
+
107
+ *only* restricts which required_env names are handled here (the rest are
108
+ owned by a caller-specific flow, e.g. core's DB provisioning). Returns just
109
+ the handled keys.
110
+ """
111
+ entries = [e for e in manifest.get("required_env", []) if only is None or e["name"] in only]
112
+ required = [e["name"] for e in entries]
113
+ state = _load_state(state_file)
114
+ if all(k in state and state[k] for k in required):
115
+ return {k: state[k] for k in required}
116
+
117
+ info("Supervisor credentials are required on every boot and are not stored in the")
118
+ info("container — console keeps them so you don't re-enter them each launch.")
119
+ print()
120
+ for entry in entries:
121
+ name = entry["name"]
122
+ if state.get(name):
123
+ continue
124
+ env_val = os.environ.get(name, "").strip()
125
+ if env_val:
126
+ state[name] = env_val
127
+ elif entry.get("secret"):
128
+ state[name] = _prompt_password(entry.get("description", name).split(".")[0])
129
+ else:
130
+ state[name] = _prompt(f"{name}")
131
+ _save_state(state, state_dir, state_file)
132
+ return {k: state[k] for k in required}
133
+
134
+
135
+ def _ensure_workspace(workspace: Path) -> None:
136
+ """Create the bind-mount root 0711 regardless of umask.
137
+
138
+ Docker auto-creates a missing bind-mount root root-owned with the daemon
139
+ umask (0700 under a 077 umask), which the container's non-root initdb can't
140
+ traverse. mkdir's mode is umask-masked, so chmod explicitly.
141
+ """
142
+ workspace.mkdir(parents=True, exist_ok=True)
143
+ os.chmod(workspace, 0o711)
144
+
145
+
146
+ # Non-secret config injected from host env via -e on first boot only; not
147
+ # re-injected on relaunch, so operator-tuned values are not clobbered.
148
+ _FIRST_BOOT_SEED_VARS = [
149
+ "SHS_GENERAL_WORKERS",
150
+ "SHS_TRANSFER_WORKERS",
151
+ ]
152
+
153
+ # Container path the entrypoint reads the tunnel token from (consumed_secret_files
154
+ # in the launch manifest). Must match Studio's entrypoint exactly.
155
+ CF_TOKEN_MOUNT = "/run/secrets/cf-token"
156
+
157
+
158
+ def _seed_first_boot_vars(workspace: Path, creds: dict) -> None:
159
+ """Inject worker counts and derived public-URL vars from host env, first boot only."""
160
+ if (workspace / ".env").exists():
161
+ for name in _FIRST_BOOT_SEED_VARS:
162
+ if os.environ.get(name, "").strip():
163
+ info(f"{name}: kept existing.")
164
+ if os.environ.get("SHS_PUBLIC_BASE_URL", "").strip():
165
+ info("public URLs: kept existing.")
166
+ return
167
+ for name in _FIRST_BOOT_SEED_VARS:
168
+ val = os.environ.get(name, "").strip()
169
+ if val:
170
+ creds[name] = val
171
+ info(f"seeded {name} (first boot).")
172
+ public_url = os.environ.get("SHS_PUBLIC_BASE_URL", "").strip()
173
+ if public_url:
174
+ from .env import derive_url_vars
175
+
176
+ # Only the browser bundle vars; SHS_API_BASE_URL/CORS are excluded so SSR
177
+ # stays on the in-container API and nginx single-origin CORS is untouched.
178
+ urls = derive_url_vars(
179
+ public_url,
180
+ os.environ.get("CONSOLE_PUBLIC_API_BASE_URL", ""),
181
+ os.environ.get("SHS_NGINX_PORT", "80"),
182
+ )
183
+ creds["SHS_PUBLIC_BASE_URL"] = public_url
184
+ for key in ("SHS_PUBLIC_API_URL", "SHS_WS_URL", "SHS_FRONTEND_URL"):
185
+ creds[key] = urls[key]
186
+ info("seeded public URLs (first boot).")
187
+
188
+
189
+ def _cf_token_mount(workspace: Path, state_dir: Path) -> Path | None:
190
+ """Write the token to a 0600 host file, return its path; None if no token or on relaunch."""
191
+ token_file = state_dir / ".cf-token"
192
+ token_file.unlink(missing_ok=True)
193
+ token = os.environ.get("CLOUDFLARE_TUNNEL_TOKEN", "").strip()
194
+ if not token:
195
+ return None
196
+ if (workspace / ".env").exists():
197
+ info("CLOUDFLARE_TUNNEL_TOKEN: kept existing.")
198
+ return None
199
+ state_dir.mkdir(mode=0o700, exist_ok=True)
200
+ fd = os.open(token_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
201
+ os.write(fd, token.encode())
202
+ os.close(fd)
203
+ info("cf-token: dropped (reason=first-boot).")
204
+ return token_file
205
+
206
+
207
+ def _build_run_cmd(
208
+ manifest: dict,
209
+ image_ref: str,
210
+ creds: dict,
211
+ workspace: Path,
212
+ container_name: str = CONTAINER_NAME,
213
+ network: str | None = None,
214
+ publish_internal: bool = True,
215
+ mounts: list[str] | None = None,
216
+ ) -> list[str]:
217
+ """Assemble `docker run` from the manifest.
218
+
219
+ *publish_internal* False publishes only ports without ``internal: true``
220
+ (core: expose nginx :80, keep api/ui/supervisor off the host).
221
+ *mounts* are extra ``-v`` specs (e.g. the consumed cf-token secret file).
222
+ """
223
+ cmd = ["docker", "run", "-d", "--name", container_name]
224
+ if network:
225
+ cmd += ["--network", network]
226
+ for port in manifest.get("ports", []):
227
+ if not publish_internal and port.get("internal"):
228
+ continue
229
+ c = port["container"]
230
+ cmd += ["-p", f"{c}:{c}"]
231
+ vol = manifest["volumes"][0]["container_path"]
232
+ cmd += ["-v", f"{workspace}:{vol}"]
233
+ for spec in mounts or []:
234
+ cmd += ["-v", spec]
235
+ for name, value in creds.items():
236
+ cmd += ["-e", f"{name}={value}"]
237
+ cmd.append(image_ref)
238
+ return cmd
239
+
240
+
241
+ def cmd_launch_full(context: str, tag: str | None = None, workspace: Path | None = None) -> bool:
242
+ """Launch the full single-image deployment and exec into its console."""
243
+ if context != "host":
244
+ error("launch-full runs on the host — you appear to be inside a container.")
245
+ return False
246
+ if run_quiet(["docker", "version"])[0] != 0:
247
+ error("Docker is not available. Install Docker and retry.")
248
+ return False
249
+
250
+ heading("Launch Studio (full)")
251
+ tag = tag or DEFAULT_TAG
252
+ image_ref = f"{DEFAULT_IMAGE}:{tag}"
253
+
254
+ existing = run_quiet(["docker", "ps", "-aq", "--filter", f"name=^{CONTAINER_NAME}$"])[1]
255
+ if existing:
256
+ warn(f"Container '{CONTAINER_NAME}' already exists.")
257
+ print(f" {_dim('Connect:')} {_bold(f'docker exec -it {CONTAINER_NAME} studio-console')}")
258
+ return False
259
+
260
+ manifest = _read_manifest(image_ref)
261
+ if manifest is None:
262
+ return False
263
+
264
+ workspace = workspace or (Path.home() / ".studio")
265
+ _ensure_workspace(workspace)
266
+
267
+ creds = _ensure_creds(manifest)
268
+ _seed_first_boot_vars(workspace, creds)
269
+ cf_token = _cf_token_mount(workspace, STATE_DIR)
270
+ mounts = [f"{cf_token}:{CF_TOKEN_MOUNT}:ro"] if cf_token else None
271
+ cmd = _build_run_cmd(manifest, image_ref, creds, workspace, mounts=mounts)
272
+
273
+ info(f"Starting {image_ref} (data: {workspace}) ...")
274
+ if run(cmd, check=False).returncode != 0:
275
+ error("docker run failed.")
276
+ return False
277
+
278
+ ok(f"{CONTAINER_NAME} started.")
279
+ ui_port = next((p["container"] for p in manifest["ports"] if p.get("name") == "ui"), 3000)
280
+ print(f" {_dim('UI:')} {_cyan(f'http://localhost:{ui_port}')}")
281
+ print(f" {_dim('Console:')} {_bold(f'docker exec -it {CONTAINER_NAME} studio-console')}")
282
+ print()
283
+
284
+ api_healthy = _wait_healthy(manifest)
285
+
286
+ if api_healthy:
287
+ from .commands import _full_plan
288
+
289
+ _bootstrap_admin(workspace, _full_plan(CONTAINER_NAME))
290
+
291
+ info("Opening in-container console ...")
292
+ run(["docker", "exec", "-it", CONTAINER_NAME, "studio-console"], check=False, timeout=None)
293
+ return True
294
+
295
+
296
+ # --- core (external-DB) launcher -----------------------------------------
297
+
298
+
299
+ def _ensure_core_network() -> bool:
300
+ """Create the core docker network if absent. Named to avoid split collision."""
301
+ exists = run_quiet(
302
+ ["docker", "network", "ls", "-q", "--filter", f"name=^{CORE_NETWORK}$"]
303
+ )[1]
304
+ if exists:
305
+ return True
306
+ return run_quiet(["docker", "network", "create", CORE_NETWORK])[0] == 0
307
+
308
+
309
+ def _sidecar_running() -> bool:
310
+ return bool(run_quiet(["docker", "ps", "-aq", "--filter", f"name=^{CORE_PG_CONTAINER}$"])[1])
311
+
312
+
313
+ def _start_sidecar(engine_image: str, workspace: Path) -> str | None:
314
+ """Start (or reuse) the pinned pgvector sidecar; return its SHS_DATABASE_URL.
315
+
316
+ The sidecar owns its data volume and outlives core — never torn down on
317
+ core removal. Password is generated once and persisted in console state so
318
+ the derived URL is stable across launches.
319
+ """
320
+ if not engine_image:
321
+ error("Manifest has no engine.image pin — cannot start a trusted Postgres sidecar.")
322
+ return None
323
+ if not _ensure_core_network():
324
+ error(f"Could not create docker network '{CORE_NETWORK}'.")
325
+ return None
326
+
327
+ state = _load_state(CORE_STATE_FILE)
328
+ password = state.get("_SIDECAR_PG_PASSWORD")
329
+ if not password:
330
+ password = os.urandom(24).hex()
331
+ state["_SIDECAR_PG_PASSWORD"] = password
332
+ _save_state(state, CORE_STATE_DIR, CORE_STATE_FILE)
333
+
334
+ url = (
335
+ f"postgresql+asyncpg://{CORE_PG_USER}:{password}"
336
+ f"@{CORE_PG_CONTAINER}:5432/{CORE_PG_DB}"
337
+ )
338
+
339
+ if _sidecar_running():
340
+ info(f"Reusing existing Postgres sidecar '{CORE_PG_CONTAINER}'.")
341
+ run_quiet(["docker", "start", CORE_PG_CONTAINER])
342
+ return url
343
+
344
+ db_dir = workspace / "db"
345
+ db_dir.mkdir(parents=True, exist_ok=True)
346
+ info(f"Starting Postgres sidecar '{CORE_PG_CONTAINER}' ({engine_image}) ...")
347
+ # PG 18+ images store data in a major-version subdir and require the mount
348
+ # at /var/lib/postgresql (not .../data) — mounting .../data exits non-zero.
349
+ cmd = [
350
+ "docker", "run", "-d", "--name", CORE_PG_CONTAINER,
351
+ "--network", CORE_NETWORK,
352
+ "-e", f"POSTGRES_USER={CORE_PG_USER}",
353
+ "-e", f"POSTGRES_PASSWORD={password}",
354
+ "-e", f"POSTGRES_DB={CORE_PG_DB}",
355
+ "-v", f"{db_dir}:/var/lib/postgresql",
356
+ engine_image,
357
+ ]
358
+ if run(cmd, check=False).returncode != 0:
359
+ error("Failed to start Postgres sidecar.")
360
+ return None
361
+ if not _wait_pg_ready():
362
+ warn("Postgres sidecar did not report ready — core bootstrap may fail on first boot.")
363
+ return url
364
+
365
+
366
+ def _wait_pg_ready(attempts: int = 30, interval: int = 2) -> bool:
367
+ """Poll pg_isready inside the sidecar until it accepts connections."""
368
+ import time
369
+
370
+ for _ in range(attempts):
371
+ rc, _ = run_quiet(
372
+ ["docker", "exec", CORE_PG_CONTAINER, "pg_isready", "-U", CORE_PG_USER],
373
+ )
374
+ if rc == 0:
375
+ ok("Postgres sidecar ready.")
376
+ return True
377
+ time.sleep(interval)
378
+ return False
379
+
380
+
381
+ def _resolve_core_db_url(engine_image: str, workspace: Path) -> str | None:
382
+ """Return SHS_DATABASE_URL for core, provisioning as chosen.
383
+
384
+ Precedence: a persisted URL, then SHS_DATABASE_URL from the environment (so a
385
+ cloud Postgres can be scripted, same as the supervisor creds above), then a
386
+ three-way prompt: enter a URL, spin up the sidecar, or defer.
387
+ Returns None to mean "defer, do not launch core yet".
388
+ """
389
+ state = _load_state(CORE_STATE_FILE)
390
+ if state.get("SHS_DATABASE_URL"):
391
+ return state["SHS_DATABASE_URL"]
392
+
393
+ env_url = os.environ.get("SHS_DATABASE_URL", "").strip()
394
+ if env_url:
395
+ state["SHS_DATABASE_URL"] = env_url
396
+ _save_state(state, CORE_STATE_DIR, CORE_STATE_FILE)
397
+ return env_url
398
+
399
+ info("Core uses an external PostgreSQL. Choose how to supply it:")
400
+ print()
401
+ choices = [
402
+ "Enter a connection URL (local · docker · cloud Postgres)",
403
+ "Spin up a Postgres sidecar (pinned pgvector, local, opt-in)",
404
+ "Configure later (don't launch yet; add the URL from the console)",
405
+ ]
406
+ idx = _interactive_single("Database", choices, default=0, nav=False)
407
+
408
+ if idx == 0:
409
+ info(_dim(
410
+ "The URL's role must have CREATEROLE — the API provisions the "
411
+ "restricted shs_app runtime role from it on boot."
412
+ ))
413
+ url = _prompt_password("SHS_DATABASE_URL (postgresql+asyncpg://user:pass@host:5432/db)")
414
+ if not url.strip():
415
+ error("No URL entered — aborting.")
416
+ return None
417
+ url = url.strip()
418
+ elif idx == 1:
419
+ url = _start_sidecar(engine_image, workspace)
420
+ if url is None:
421
+ return None
422
+ else:
423
+ _defer_core_db()
424
+ return None
425
+
426
+ state["SHS_DATABASE_URL"] = url
427
+ _save_state(state, CORE_STATE_DIR, CORE_STATE_FILE)
428
+ return url
429
+
430
+
431
+ def _ensure_core_app_db_url(db_url: str) -> str:
432
+ """Derive/persist core's restricted-role URL matching *db_url*'s DSN.
433
+
434
+ Password is generated once and persisted; if the operator repoints the
435
+ privileged URL at a different host/port/db, the DSN is re-derived keeping
436
+ the same password (the API's bootstrap refreshes the role from it on boot).
437
+ """
438
+ from urllib.parse import urlsplit
439
+
440
+ from .env import derive_app_db_url
441
+
442
+ state = _load_state(CORE_STATE_FILE)
443
+ existing = state.get("SHS_DATABASE_APP_URL", "")
444
+ new_parts = urlsplit(db_url)
445
+ if existing:
446
+ old = urlsplit(existing)
447
+ if (old.hostname, old.port, old.path) == (
448
+ new_parts.hostname,
449
+ new_parts.port,
450
+ new_parts.path,
451
+ ):
452
+ return existing
453
+ password = urlsplit(existing).password if existing else None
454
+ app_url = derive_app_db_url(db_url, password=password)
455
+ state["SHS_DATABASE_APP_URL"] = app_url
456
+ _save_state(state, CORE_STATE_DIR, CORE_STATE_FILE)
457
+ return app_url
458
+
459
+
460
+ def _print_byo_provision_help(db_url: str, app_url: str) -> None:
461
+ """Fail-closed diagnosis for BYO Postgres: shs_app provisioning failed.
462
+
463
+ Two supported fixes (7cc4942ce): grant CREATEROLE for automatic mode, or
464
+ pre-create the role as a DB admin (manual mode — a correctly-attributed
465
+ role provisions fully every boot; the operator owns its password).
466
+ """
467
+ from urllib.parse import unquote, urlsplit
468
+
469
+ priv_role = urlsplit(db_url).username or "<your role>"
470
+ app = urlsplit(app_url)
471
+ app_role = app.username or "shs_app"
472
+ app_password = unquote(app.password or "")
473
+ error("The API did not become healthy. On boot it provisions the restricted")
474
+ error(f"{app_role} role via SHS_DATABASE_URL and fails closed if it can't.")
475
+ create_sql = (
476
+ f"CREATE ROLE {app_role} LOGIN PASSWORD '{app_password}' "
477
+ "NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE NOINHERIT;"
478
+ )
479
+ print(f" {_dim('Fix on your Postgres (either one), then restart the container:')}")
480
+ print(f" {_bold(f'ALTER ROLE {priv_role} CREATEROLE;')} {_dim('(automatic mode)')}")
481
+ print(f" {_bold(create_sql)} {_dim('(manual mode)')}")
482
+ print(f" {_dim('Diagnose:')} {_bold(f'docker logs {CORE_CONTAINER_NAME} --tail 30')}")
483
+
484
+
485
+ def _defer_core_db() -> None:
486
+ """Record the deferral and tell the operator how to finish later."""
487
+ CORE_STATE_DIR.mkdir(mode=0o700, exist_ok=True)
488
+ warn("Core not launched — no database configured yet.")
489
+ print(f" {_dim('Add a database later, then launch:')}")
490
+ print(f" {_bold('studio-console launch-core')} {_dim('(re-run and pick a DB)')}")
491
+ print(f" {_dim('Or set the URL directly in console state, then re-run launch-core.')}")
492
+
493
+
494
+ def cmd_launch_core(context: str, tag: str | None = None, workspace: Path | None = None) -> bool:
495
+ """Launch the core single-image deployment against an external Postgres."""
496
+ if context != "host":
497
+ error("launch-core runs on the host — you appear to be inside a container.")
498
+ return False
499
+ if run_quiet(["docker", "version"])[0] != 0:
500
+ error("Docker is not available. Install Docker and retry.")
501
+ return False
502
+
503
+ heading("Launch Studio (core)")
504
+ tag = tag or DEFAULT_TAG
505
+ image_ref = f"{CORE_IMAGE}:{tag}"
506
+
507
+ existing = run_quiet(["docker", "ps", "-aq", "--filter", f"name=^{CORE_CONTAINER_NAME}$"])[1]
508
+ if existing:
509
+ warn(f"Container '{CORE_CONTAINER_NAME}' already exists.")
510
+ print(f" {_dim('Connect:')} {_bold(f'docker exec -it {CORE_CONTAINER_NAME} studio-console')}")
511
+ return False
512
+
513
+ manifest = _read_manifest(image_ref, shape="core")
514
+ if manifest is None:
515
+ return False
516
+
517
+ workspace = workspace or (Path.home() / ".studio-core")
518
+ _ensure_workspace(workspace)
519
+
520
+ # Supervisor creds via the shared flow; DB via the dedicated 3-way prompt.
521
+ creds = _ensure_creds(
522
+ manifest,
523
+ CORE_STATE_DIR,
524
+ CORE_STATE_FILE,
525
+ only=["SHS_SUPERVISOR_USER", "SHS_SUPERVISOR_PASSWORD"],
526
+ )
527
+
528
+ db_url = _resolve_core_db_url(manifest.get("_engine", {}).get("image", ""), workspace)
529
+ if db_url is None:
530
+ return False # deferred or aborted
531
+ creds["SHS_DATABASE_URL"] = db_url
532
+ # Restricted runtime role: the API provisions shs_app from this URL on
533
+ # boot and fails closed if it can't. Older images ignore the var.
534
+ creds["SHS_DATABASE_APP_URL"] = _ensure_core_app_db_url(db_url)
535
+
536
+ _seed_first_boot_vars(workspace, creds)
537
+ cf_token = _cf_token_mount(workspace, CORE_STATE_DIR)
538
+ mounts = [f"{cf_token}:{CF_TOKEN_MOUNT}:ro"] if cf_token else None
539
+
540
+ # If the sidecar is on the core network, core must join it to reach the DB.
541
+ network = CORE_NETWORK if _sidecar_running() else None
542
+ cmd = _build_run_cmd(
543
+ manifest, image_ref, creds, workspace,
544
+ container_name=CORE_CONTAINER_NAME,
545
+ network=network,
546
+ publish_internal=False,
547
+ mounts=mounts,
548
+ )
549
+
550
+ info(f"Starting {image_ref} (data: {workspace}) ...")
551
+ if run(cmd, check=False).returncode != 0:
552
+ error("docker run failed.")
553
+ return False
554
+
555
+ ok(f"{CORE_CONTAINER_NAME} started.")
556
+ nginx_port = next((p["container"] for p in manifest["ports"] if p.get("name") == "nginx"), 80)
557
+ print(f" {_dim('UI:')} {_cyan(f'http://localhost:{nginx_port}')}")
558
+ print(f" {_dim('Console:')} {_bold(f'docker exec -it {CORE_CONTAINER_NAME} studio-console')}")
559
+ print()
560
+
561
+ api_healthy = _wait_healthy(manifest, container_name=CORE_CONTAINER_NAME)
562
+ if api_healthy:
563
+ from .commands import _core_plan
564
+
565
+ _bootstrap_admin(
566
+ workspace, _core_plan(CORE_CONTAINER_NAME, CORE_PG_CONTAINER, nginx_port)
567
+ )
568
+ elif CORE_PG_CONTAINER not in db_url:
569
+ _print_byo_provision_help(db_url, creds["SHS_DATABASE_APP_URL"])
570
+
571
+ info("Opening in-container console ...")
572
+ run(["docker", "exec", "-it", CORE_CONTAINER_NAME, "studio-console"], check=False, timeout=None)
573
+ return True
574
+
575
+
576
+ def cmd_set_core_db_url(context: str, url: str | None = None) -> bool:
577
+ """Set/replace core's SHS_DATABASE_URL in host console state.
578
+
579
+ Core reads the DB URL from process env at launch (never from the workspace
580
+ .env), and the launcher owns that persistence — so this writes to console
581
+ state, and the next `launch-core` injects it via `docker run -e`.
582
+ """
583
+ if context != "host":
584
+ error("core-db-url runs on the host — you appear to be inside a container.")
585
+ return False
586
+
587
+ url = (url or "").strip() or _prompt_password(
588
+ "SHS_DATABASE_URL (postgresql+asyncpg://user:pass@host:5432/db)"
589
+ ).strip()
590
+ if not url:
591
+ error("No URL provided.")
592
+ return False
593
+
594
+ state = _load_state(CORE_STATE_FILE)
595
+ state["SHS_DATABASE_URL"] = url
596
+ _save_state(state, CORE_STATE_DIR, CORE_STATE_FILE)
597
+ _ensure_core_app_db_url(url) # keep the derived shs_app URL on the same DSN
598
+ ok("Saved core database URL.")
599
+
600
+ if run_quiet(["docker", "ps", "-q", "--filter", f"name=^{CORE_CONTAINER_NAME}$"])[1]:
601
+ warn("A core container is running — recreate it to apply:")
602
+ print(f" {_bold(f'docker rm -f {CORE_CONTAINER_NAME} && studio-console launch-core')}")
603
+ else:
604
+ print(f" {_dim('Launch:')} {_bold('studio-console launch-core')}")
605
+ return True
606
+
607
+
608
+ def _bootstrap_admin(workspace: Path, plan: "object") -> None:
609
+ """First-boot super admin creation, using the given shape's exec plan.
610
+
611
+ *plan* is a _BootstrapPlan: full execs one container for both API and psql;
612
+ core execs the API container for hashing but psql against the DB sidecar.
613
+ """
614
+ env_file = workspace / ".env"
615
+ if not env_file.exists():
616
+ warn("Workspace .env not found — skipping admin bootstrap.")
617
+ return
618
+
619
+ from .commands import _bootstrap_first_admin
620
+
621
+ _bootstrap_first_admin(env_file, plan)
622
+
623
+
624
+ def _curl_status(url: str) -> int:
625
+ """Return HTTP status code for url, or 0 if unreachable."""
626
+ rc, out = run_quiet(["curl", "-sf", "-o", "/dev/null", "-w", "%{http_code}", url], timeout=5)
627
+ try:
628
+ return int(out.strip())
629
+ except ValueError:
630
+ return 0
631
+
632
+
633
+ def _wait_healthy(
634
+ manifest: dict,
635
+ attempts: int = 30,
636
+ interval: int = 3,
637
+ container_name: str = CONTAINER_NAME,
638
+ ) -> bool:
639
+ """Poll API + UI on first boot; flag a non-200 UI. Returns API health.
640
+
641
+ When api/ui are host-published (full), poll them directly. When they are
642
+ ``internal: true`` (core), poll through the nginx front door instead —
643
+ ``/health`` (served by nginx) and ``/`` — since those ports aren't
644
+ reachable from the host.
645
+ """
646
+ import time
647
+
648
+ api_p = next((p for p in manifest["ports"] if p.get("name") == "api"), {})
649
+ ui_p = next((p for p in manifest["ports"] if p.get("name") == "ui"), {})
650
+ nginx_port = next((p["container"] for p in manifest["ports"] if p.get("name") == "nginx"), 80)
651
+
652
+ if api_p.get("internal") or ui_p.get("internal"):
653
+ # nginx serves `location = /health` directly; /api/* proxies to the API.
654
+ api_url = f"http://localhost:{nginx_port}/health"
655
+ ui_url = f"http://localhost:{nginx_port}/"
656
+ api_label = ui_label = f":{nginx_port}"
657
+ else:
658
+ api_port = api_p.get("container", 8000)
659
+ ui_port = ui_p.get("container", 3000)
660
+ api_url = f"http://localhost:{api_port}/health"
661
+ ui_url = f"http://localhost:{ui_port}"
662
+ api_label, ui_label = f":{api_port}", f":{ui_port}"
663
+
664
+ info("Waiting for the stack to come up (first boot runs bootstrap) ...")
665
+ api_ok = ui_status = 0
666
+ for _ in range(attempts):
667
+ if api_ok != 200:
668
+ api_ok = _curl_status(api_url)
669
+ ui_status = _curl_status(ui_url)
670
+ if api_ok == 200 and ui_status == 200:
671
+ ok("API and UI healthy.")
672
+ return True
673
+ time.sleep(interval)
674
+
675
+ if api_ok == 200:
676
+ ok(f"API healthy ({api_label}).")
677
+ else:
678
+ warn(f"API not healthy ({api_label}) — check: docker exec {container_name} supervisorctl status")
679
+ if ui_status == 200:
680
+ ok(f"UI healthy ({ui_label}).")
681
+ else:
682
+ warn(f"UI returned {ui_status or 'no response'} ({ui_label}) — SSR may be failing.")
683
+ print(f" {_dim('Logs:')} {_bold(f'docker exec {container_name} supervisorctl tail ui stderr')}")
684
+ return api_ok == 200