Skip to content

Commit d24c6af

Browse files
committed
feat(lpb): first-boot lemonade URL prompt + host passthrough
- lpb.py: first-boot health probe of the lemonade server in --ssh/--web modes (TTY): unreachable -> prompt for the real host, then API key with default; resolved URL/key passed to the container as LPB_LEMONADE_* so the non-interactive first-boot setup (lpb-config setup) gets the right server (lemonade on a different host than 127.0.0.1) - static passthrough always: shell env LPB_/bare LEMONADE_BASE_URL & LEMONADE_API_KEY, then lpb.conf.env (LPB_ wins) - foreground Pi/shell modes: no host prompt (in-container interactive wizard handles it, pre-filled from the same env) - testharness: deterministic non-TTY stdin default (no input() hangs) - tests: test_lpb_lemonade.py (9 tests), registered in test_lpb.py - docs: README, lpb-cli (new section), env-vars, lpb-config
1 parent 8cf1136 commit d24c6af

8 files changed

Lines changed: 355 additions & 10 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,17 @@ lpb # resumes your last project (or ~)
2626
```
2727

2828
On the **first run** the container pulls the image, clones the config preset,
29-
generates `settings.json`, and installs the extensions. Before Pi starts, the
30-
**first-run setup wizard** walks you through connecting your Lemonade server:
31-
32-
1. server URL (pre-filled from `LEMONADE_BASE_URL`, health-checked)
33-
2. API key (a placeholder like `lemonade` is fine for a local server)
34-
3. default model (picked from the server's model list)
35-
4. lpb-memory configuration
36-
37-
After that Pi starts fully configured. Re-run it any time inside the
29+
generates `settings.json`, and installs the extensions. First-run setup then
30+
connects your Lemonade server:
31+
32+
- **`--ssh` / `--web`** (background): `lpb` prompts in its own terminal for
33+
the server URL (health-checked — type the real host if
34+
`127.0.0.1:13305` is unreachable) and API key, then the container finishes
35+
setup non-interactively: default model (from the server's model list) and
36+
lpb-memory configuration.
37+
- **Foreground Pi / shell**: the **first-run setup wizard** (`lpb-config
38+
setup`) walks you through it inside the container, pre-filled from
39+
`LPB_LEMONADE_BASE_URL` / `LPB_LEMONADE_API_KEY` env or `lpb.conf.env`. Re-run it any time inside the
3840
container with `lpb-config setup --reconfigure`.
3941

4042
### Common commands

‎doc/env-vars.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,12 @@ If a value from `.env` doesn't seem to apply, check shell env first:
2828
| `LPB_BROWSER_DIR` | `~/.lpb-stack/agent-browser` | Host dir for browser profiles & sessions |
2929
| `LPB_CONTAINER_NAME` | `lpb-stack` | Container name (set in `lpb.stack.env`) |
3030
| `LPB_IMAGE_CLI` / `LPB_IMAGE_WEB` | `ghcr.io/lpb-stack/devstack:dev-{cli,web}` | Last-resort fallback images (forks repoint these) |
31+
| `LPB_LEMONADE_BASE_URL` | `http://127.0.0.1:13305/v1` | Lemonade server URL — passed to the container; on a first boot (`--ssh`/`--web`) the launcher health-checks it and prompts for the real host if unreachable |
32+
| `LPB_LEMONADE_API_KEY` | `lemonade` | Lemonade API key — passed to the container for the first-boot setup wizard |
3133
| `GHCR_USERNAME` | `lpb-stack` | Registry user for pulls |
3234

35+
(The bare `LEMONADE_BASE_URL` / `LEMONADE_API_KEY` names are also accepted on the host; the `LPB_` form wins.)
36+
3337
## Runtime Defaults (baked into the image via `lpb.conf.env`)
3438

3539
| Variable | Default | Purpose |

‎doc/lpb-cli.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,29 @@ lpb /myproject -- --thinking high # pass any pi flag
7777
- **`LPB_SSH_PORT`** (default `2222`): forwarded to the container; the
7878
connect line (`ssh -p <port> lpb@<host>`) is printed when the server starts.
7979

80+
### First-boot Lemonade prompt (`--ssh` / `--web`)
81+
82+
On the **first boot of a fresh state volume** (no `~/.pi/.initialized` in
83+
`LPB_STATE_DIR`), detached-server modes run the container's setup
84+
non-interactively — so `lpb` resolves your Lemonade server on the host side
85+
first:
86+
87+
1. URL from `LPB_LEMONADE_BASE_URL` / `LEMONADE_BASE_URL` env or
88+
`lpb.conf.env` (default `http://127.0.0.1:13305` when unset).
89+
2. Health probe (`GET <url>/api/v1/models`, 3 s). Unreachable on a TTY →
90+
you're offered to type the real host (re-probed until it answers or you
91+
press Enter to keep the current one); reachable → silent.
92+
3. API key is asked with the configured value (or `lemonade`) as default.
93+
94+
The resolved URL + key are passed to the container
95+
(`LPB_LEMONADE_BASE_URL` / `LPB_LEMONADE_API_KEY`), where the first-boot
96+
hook (`lpb-config setup --non-interactive`) writes `auth.json` + default
97+
model. Foreground Pi/shell modes skip the host prompt — the in-container
98+
interactive wizard handles it, pre-filled from the same env. Non-TTY
99+
(hosts/CI) and already-initialized volumes: no prompt, static passthrough
100+
only. Reconfigure any time inside the container with
101+
`lpb-config setup --reconfigure`.
102+
80103
## Image Selection
81104

82105
Two image flavours are published to `ghcr.io/lpb-stack/devstack`:

‎doc/lpb-config.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ see [lpb-devstack reference](lpb-devstack.md).
3939
| Command | Description |
4040
|---|---|
4141
| `lpb-config setup` | Interactive first-run setup: lemonade provider (auth.json), default model (settings.json), lpb-memory config. Runs automatically on first boot (start.sh); idempotent — re-run with `--reconfigure` to change server/model |
42-
| `lpb-config setup --non-interactive` | Same, no prompts — `LEMONADE_BASE_URL` / `LEMONADE_API_KEY` env + defaults (used on TTY-less first boots) |
42+
| `lpb-config setup --non-interactive` | Same, no prompts — `LEMONADE_BASE_URL` / `LEMONADE_API_KEY` env + defaults. On TTY-less first boots the `lpb` launcher passes them in (first-boot prompt in `--ssh`/`--web` modes, see `lpb-cli.md`) |
4343

4444
### Pipeline Override
4545

‎scripts/lpb.py‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,10 @@ class Config:
343343
ssh_pubkey = ""
344344
ssh_password = ""
345345
ssh_port = os.environ.get("LPB_SSH_PORT", _conf_cfg.get("LPB_SSH_PORT", "2222"))
346+
# Lemonade model server (prompted on first boot with a TTY, otherwise from
347+
# env/lpb.conf.env) — passed to the container for the first-boot setup hook.
348+
lemonade_base_url = ""
349+
lemonade_api_key = ""
346350
pi_args = [] # args after "--" forwarded to pi inside container
347351

348352

@@ -1426,6 +1430,13 @@ def _build_run_env(mount_path: str) -> list[str]:
14261430
f"LPB_EXA_API_KEY={os.environ.get('LPB_EXA_API_KEY', os.environ.get('EXA_API_KEY', ''))}",
14271431
f"LPB_MAX_TOKENS_CONTEXT_RATIO={os.environ.get('LPB_MAX_TOKENS_CONTEXT_RATIO', _conf_cfg.get('LPB_MAX_TOKENS_CONTEXT_RATIO', '0.06'))}",
14281432
]
1433+
# Lemonade model server — resolved in cmd_run (step 6b): first-boot prompt
1434+
# on a TTY, otherwise static env/lpb.conf.env. start.sh bridges
1435+
# LPB_LEMONADE_* → LEMONADE_* for the plugin and the setup wizard.
1436+
if cfg.lemonade_base_url:
1437+
env_vars.append(f"LPB_LEMONADE_BASE_URL={cfg.lemonade_base_url}")
1438+
if cfg.lemonade_api_key:
1439+
env_vars.append(f"LPB_LEMONADE_API_KEY={cfg.lemonade_api_key}")
14291440
# GHCR token for image pulls (personal account requires auth)
14301441
ghcr_token = os.environ.get('GHCR_TOKEN') or os.environ.get('GITHUB_TOKEN') or os.environ.get('LPB_GITHUB_TOKEN', '')
14311442
ghcr_username = os.environ.get('GHCR_USERNAME', _conf_cfg.get('GHCR_USERNAME', 'lpb-stack'))
@@ -1680,6 +1691,72 @@ def _run_cli(project_dir: str, env_vars: list[str], volumes: list[str]) -> None:
16801691
# Container is removed (--rm), nothing to clean up
16811692

16821693

1694+
def _bare_lemonade_url(raw: str) -> str:
1695+
"""Normalize a lemonade URL to bare http://host:port (strip /v1, /api/v1…).
1696+
1697+
Mirrors lpb-config's _bare_base_url — the container normalizes again when
1698+
it reads LEMONADE_BASE_URL, so this exists only for the host-side probe.
1699+
"""
1700+
url = (raw or "").strip()
1701+
if not url:
1702+
return ""
1703+
if not url.startswith(("http://", "https://")):
1704+
url = "http://" + url
1705+
return re.sub(r"/(api/)?v\d+/?$", "", url)
1706+
1707+
1708+
def _url_reachable(url: str, timeout: float = 3.0) -> bool:
1709+
"""Best-effort probe of a lemonade server (GET <bare>/api/v1/models)."""
1710+
bare = _bare_lemonade_url(url)
1711+
if not bare:
1712+
return False
1713+
try:
1714+
with urllib.request.urlopen(f"{bare}/api/v1/models", timeout=timeout):
1715+
return True
1716+
except Exception:
1717+
return False
1718+
1719+
1720+
def _prompt_lemonade_first_boot(resolved_state: str) -> None:
1721+
"""Resolve lemonade server URL + API key for container env passthrough.
1722+
1723+
Static resolution always applies: shell env LPB_LEMONADE_BASE_URL /
1724+
LEMONADE_BASE_URL, then lpb.conf.env. On a first boot (no .initialized
1725+
marker in the state dir) in a detached-server mode (--ssh / --web) with
1726+
a host TTY, the resolved URL (default http://127.0.0.1:13305 when unset)
1727+
is health-probed: unreachable → the user is offered the real host, and
1728+
the API key is asked with a default. The container's non-interactive
1729+
first-boot setup (start.sh → lpb-config setup) then consumes the passed
1730+
env vars. Foreground modes skip the host prompt — the in-container
1731+
interactive wizard handles it with these values pre-filled.
1732+
"""
1733+
cfg.lemonade_base_url = (os.environ.get("LPB_LEMONADE_BASE_URL")
1734+
or os.environ.get("LEMONADE_BASE_URL")
1735+
or _conf_cfg.get("LPB_LEMONADE_BASE_URL", ""))
1736+
cfg.lemonade_api_key = (os.environ.get("LPB_LEMONADE_API_KEY")
1737+
or os.environ.get("LEMONADE_API_KEY")
1738+
or _conf_cfg.get("LPB_LEMONADE_API_KEY", ""))
1739+
detached_server = cfg.ssh_mode or cfg.web_mode
1740+
first_boot = not (Path(resolved_state) / ".pi" / ".initialized").exists()
1741+
if not (detached_server and first_boot and sys.stdin.isatty()):
1742+
return
1743+
default_url = "http://127.0.0.1:13305"
1744+
cfg.lemonade_base_url = _bare_lemonade_url(cfg.lemonade_base_url) or default_url
1745+
if not _url_reachable(cfg.lemonade_base_url):
1746+
while True:
1747+
warn(f"Lemonade server unreachable: {cfg.lemonade_base_url}")
1748+
val = input(f" Server URL [Enter = keep {cfg.lemonade_base_url}]: ").strip()
1749+
if not val:
1750+
break
1751+
cfg.lemonade_base_url = _bare_lemonade_url(val)
1752+
if _url_reachable(cfg.lemonade_base_url):
1753+
break
1754+
done(f"Lemonade server: {cfg.lemonade_base_url}")
1755+
key_default = cfg.lemonade_api_key or "lemonade"
1756+
key = input(f" API key [Enter = {key_default}]: ").strip()
1757+
cfg.lemonade_api_key = key or key_default
1758+
1759+
16831760
def cmd_run():
16841761
"""Resolve the project, decide the mode (cli/web/shell/ssh), and launch the
16851762
container. Foreground modes attach interactively; web/ssh run detached.
@@ -1709,6 +1786,9 @@ def cmd_run():
17091786
os.makedirs(resolved_state, exist_ok=True)
17101787
os.makedirs(dir_browser, exist_ok=True)
17111788

1789+
# ── 6b. Lemonade URL/key: first-boot prompt (TTY) or static passthrough ─
1790+
_prompt_lemonade_first_boot(resolved_state)
1791+
17121792
# ── 7. Shell mode: attach to existing container (falls through to
17131793
# full startup when none exists; SSH mode skips this — it always
17141794
# does a fresh detached server) ───────────────────────────────────

‎scripts/test_lpb.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
test_lpb_tags.py — image tag selection + image resolution
88
test_lpb_selfupdate.py — lpb --update (launcher self-update)
99
test_lpb_ssh.py — SSH mode (key auto-detect, password auth)
10+
test_lpb_lemonade.py — first-boot lemonade URL/key prompt + env passthrough
1011
test_lpb_regression.py — structural guards, env files, cmd_* regression
1112
tests, mutation tests
1213
@@ -25,6 +26,7 @@
2526
from test_lpb_tags import TESTS as TAG_TESTS
2627
from test_lpb_selfupdate import TESTS as SELFUPDATE_TESTS
2728
from test_lpb_ssh import TESTS as SSH_TESTS
29+
from test_lpb_lemonade import TESTS as LEMONADE_TESTS
2830
from test_lpb_regression import TESTS as REGRESSION_TESTS
2931

3032
TESTS = [
@@ -35,6 +37,7 @@
3537
*TAG_TESTS,
3638
*SELFUPDATE_TESTS,
3739
*SSH_TESTS,
40+
*LEMONADE_TESTS,
3841
]
3942

4043

0 commit comments

Comments
 (0)