55 lpb [/path/to/project] Start Pi CLI session at project (foreground)
66 lpb /path -- <pi-args...> Pass args through to pi (e.g. -p, --session)
77 lpb --shell [/path/to/project] Start interactive bash shell in container
8- lpb --ssh [pubkey|path] [/path] Start sshd server (background) for remote login
9- (key auto-detected from ~/.ssh when omitted)
8+ lpb --ssh [pubkey|path] [project] Start sshd server (background) for remote login
9+ (key auto-detected from ~/.ssh when omitted;
10+ a project dir in place of a key is also accepted)
1011 lpb --ssh --ssh-password [pw] SSH password login (no pw: random, shown once)
1112 lpb --web [/path/to/project] Start VSCodium at project (background)
1213 lpb --stop Stop the container
@@ -410,6 +411,35 @@ def _discover_ssh_pubkeys(ssh_dir: Path | None = None) -> list[Path]:
410411 return sorted (p for p in d .glob ("*.pub" ) if p .is_file ())
411412
412413
414+ _SSH_KEY_PREFIXES = ("ssh-rsa" , "ssh-dss" , "ssh-ed25519" , "ecdsa-sha2-" ,
415+ "sk-ecdsa-" , "sk-ssh-" )
416+
417+
418+ def _looks_like_pubkey (value : str ) -> bool :
419+ """A public key is '<type> <base64> [comment]'; type starts with a known prefix."""
420+ parts = value .split ()
421+ return len (parts ) >= 2 and parts [0 ].startswith (_SSH_KEY_PREFIXES )
422+
423+
424+ def _classify_ssh_key_arg (value : str ) -> tuple [str , object ]:
425+ """Classify the optional --ssh value:
426+
427+ ("key", str) inline public key
428+ ("file", Path) existing file (to read as a public key)
429+ ("project", str) existing directory — a project path was passed
430+ ("invalid", str) none of the above
431+ """
432+ raw = value .strip ()
433+ p = Path (raw ).expanduser ()
434+ if p .is_file ():
435+ return "file" , p
436+ if _looks_like_pubkey (raw ):
437+ return "key" , raw
438+ if p .is_dir ():
439+ return "project" , str (p )
440+ return "invalid" , raw
441+
442+
413443# ── Output helpers (stdout) ───────────────────────────────────────────────────
414444
415445# Engine file path — resolved from __file__, NOT sys.argv[0]: the bash wrapper
@@ -858,7 +888,7 @@ def apply_overrides(project_dir: str | None = None, project_name: str | None = N
858888 " lpb [/path/to/project] Start Pi CLI session at project\n "
859889 " lpb /path -- <pi-args...> Pass flags through to pi (-p, --session, etc.)\n "
860890 " lpb --shell [/path/to/project] Interactive bash shell in container\n "
861- " lpb --ssh [pubkey|path] Start sshd server in background (key auto-detected from ~/.ssh)\n "
891+ " lpb --ssh [pubkey|path] [project] Start sshd server in background (key auto-detected from ~/.ssh)\n "
862892 " lpb --ssh --ssh-password [pw] SSH password login (no pw: random, shown once)\n "
863893 " lpb --web [/path/to/project] Start VSCodium (background)\n "
864894 " lpb --stop Stop the container\n "
@@ -1030,9 +1060,31 @@ def parse_cli(args: list[str]) -> None:
10301060 if known .ssh is not None or known .ssh_password is not None :
10311061 cfg .ssh_mode = cfg .shell_mode = True
10321062 if known .ssh :
1033- p = Path (known .ssh )
1034- cfg .ssh_pubkey = p .read_text (encoding = "utf-8" ).strip () if p .is_file () else known .ssh .strip ()
1035- elif known .ssh_password is None :
1063+ kind , value = _classify_ssh_key_arg (known .ssh )
1064+ if kind == "file" :
1065+ key_text = value .read_text (encoding = "utf-8" ).strip ()
1066+ if not _looks_like_pubkey (key_text ):
1067+ err (f"{ value } does not contain a valid public key" ,
1068+ "Pass a .pub file, an inline key (lpb --ssh <key>), or run 'lpb --ssh' to pick from ~/.ssh" )
1069+ raise DevstackError
1070+ cfg .ssh_pubkey = key_text
1071+ elif kind == "key" :
1072+ cfg .ssh_pubkey = value
1073+ elif kind == "project" :
1074+ # `lpb --ssh /path/to/project`: a directory where a key was
1075+ # expected — use it as the project dir and fall through to
1076+ # the profile-key discovery below (the selection prompt shows).
1077+ # An explicit positional project (parsed later) still wins.
1078+ if not cfg .project_dir :
1079+ cfg .project_dir = value
1080+ info (f"Project directory from --ssh argument: { value } " )
1081+ known .ssh = ""
1082+ else :
1083+ err (f"'{ known .ssh } ' is not a valid pub key, a pub key file, or a project directory" ,
1084+ "Key: lpb --ssh <pubkey|path-to-pub-file> — or 'lpb --ssh' alone to pick from ~/.ssh\n "
1085+ "Project: pass it as a positional: lpb --dev --ssh <key> <project>" )
1086+ raise DevstackError
1087+ if not cfg .ssh_pubkey and known .ssh == "" and known .ssh_password is None :
10361088 # No explicit key — fall back to the user's profile keys.
10371089 keys = _discover_ssh_pubkeys ()
10381090 if not keys :
@@ -1942,16 +1994,19 @@ def _port_in_use(port) -> bool:
19421994 s .close ()
19431995
19441996
1945- def _wait_ssh_port (port , timeout : int = 20 ) -> bool :
1997+ def _wait_ssh_port (port , timeout : int = 30 ) -> bool :
19461998 """Wait until the sshd port accepts TCP connections.
19471999
19482000 start.sh launches sshd asynchronously after the container starts, so
19492001 connecting right after 'run' returns is too early. The SSH banner is
19502002 verified — a bare connect cannot tell sshd apart from whatever else
19512003 holds the host port, and claiming 'ready' for a non-sshd holder would
1952- repeat the silent-failure bug."""
2004+ repeat the silent-failure bug. Prints a progress note every 30s so a
2005+ long first-run wait is not mistaken for a hang."""
19532006 port = int (port )
1954- deadline = time .time () + timeout
2007+ start = time .time ()
2008+ deadline = start + timeout
2009+ next_note = 30
19552010 while time .time () < deadline :
19562011 try :
19572012 s = socket .create_connection (("127.0.0.1" , port ), timeout = 2 )
@@ -1967,6 +2022,9 @@ def _wait_ssh_port(port, timeout: int = 20) -> bool:
19672022 s .close ()
19682023 if banner .startswith (b"SSH-" ):
19692024 return True
2025+ if time .time () - start >= next_note :
2026+ info (f" ... still waiting for sshd ({ int (time .time () - start )} s elapsed)" )
2027+ next_note += 30
19702028 time .sleep (1 )
19712029 return False
19722030
@@ -2008,15 +2066,32 @@ def _run_ssh(c: ContainerClient, project_dir: str, env_vars: list[str],
20082066 port = cfg .ssh_port
20092067 user = "lpb" # container user (uid 1000) is lpb
20102068
2069+ # First-run detection: the host state dir IS the container's /home/lpb/.pi
2070+ # mount, and start.sh creates <state>/.initialized when the bootstrap
2071+ # finishes. On the first boot the bootstrap (config-repo clone, volume
2072+ # chown, provider setup) runs BEFORE sshd starts and can take several
2073+ # minutes — a short wait here produced a false 'SSH server not ready'
2074+ # failure while the container was simply still bootstrapping.
2075+ first_run = not (Path (resolve_path (cfg .state_dir )) / ".initialized" ).exists ()
2076+ if first_run :
2077+ info ("First run — container is bootstrapping (config clone, provider setup)..." )
2078+ info (" sshd starts automatically after the bootstrap; this can take a few minutes." )
2079+ wait_timeout = 300 if first_run else 30
2080+
20112081 # Wait for sshd to actually listen before claiming success — the SSH
20122082 # banner probe is the only ground truth (the host cannot see start.sh's
20132083 # log in detached mode).
2014- if not _wait_ssh_port (port ):
2084+ if not _wait_ssh_port (port , wait_timeout ):
20152085 warn (f"Container started but SSH port { port } is NOT accepting the SSH protocol." )
20162086 warn (" sshd failed to start inside the container — last container log lines:" )
20172087 _show_log_tail (c , 15 )
2018- err ("SSH server not ready (container is still running)" ,
2019- "Fix the cause above, then: lpb --stop && lpb --ssh (or --ssh-port <port>)" )
2088+ if first_run :
2089+ err ("SSH server not ready — the first-run bootstrap may still be running (container is still alive)" ,
2090+ "Watch it finish: lpb --logs (sshd starts once 'First run bootstrap complete' appears)\n "
2091+ "Start over: lpb --stop && lpb --ssh (or --ssh-port <port>)" )
2092+ else :
2093+ err ("SSH server not ready (container is still running)" ,
2094+ "Fix the cause above, then: lpb --stop && lpb --ssh (or --ssh-port <port>)" )
20202095 raise DevstackError
20212096
20222097 done (f"\u2713 SSH server ready (background) — listening on port { port } " )
0 commit comments