@@ -6,17 +6,23 @@ use anyhow::Result;
66use clap:: Args ;
77use console:: style;
88use dialoguer:: Confirm ;
9- use opencode_cloud_core:: docker:: { CONTAINER_NAME , container_is_running} ;
9+ use futures_util:: StreamExt ;
10+ use opencode_cloud_core:: bollard:: container:: LogOutput ;
11+ use opencode_cloud_core:: bollard:: query_parameters:: LogsOptions ;
12+ use opencode_cloud_core:: docker:: { CONTAINER_NAME , DockerClient , container_is_running} ;
1013use opencode_cloud_core:: { Config , load_config_or_default, save_config} ;
1114
1215use crate :: commands:: { cmd_start, cmd_stop} ;
1316use crate :: constants:: COCKPIT_EXPOSED ;
1417use crate :: wizard:: run_wizard;
1518
19+ const IOTP_LOG_PREFIX : & str = "INITIAL ONE-TIME PASSWORD (IOTP): " ;
20+ const IOTP_FALLBACK_COMMAND : & str = "occ logs | grep -F \" INITIAL ONE-TIME PASSWORD (IOTP): \" | tail -n1 | sed 's/.*INITIAL ONE-TIME PASSWORD (IOTP): //'" ;
21+
1622/// Arguments for the setup command
1723#[ derive( Args ) ]
1824pub struct SetupArgs {
19- /// Skip wizard if auth credentials are already configured
25+ /// Skip wizard if configuration already exists
2026 #[ arg( long, short) ]
2127 pub yes : bool ,
2228
@@ -45,20 +51,21 @@ pub async fn cmd_setup(args: &SetupArgs, quiet: bool) -> Result<()> {
4551
4652 // Handle --yes flag for non-interactive mode
4753 if args. yes {
48- if let Some ( ref config ) = existing_config
49- && config. has_required_auth ( )
50- {
54+ let config_exists =
55+ opencode_cloud_core :: config:: paths :: get_config_path ( ) . is_some_and ( |path| path . exists ( ) ) ;
56+ if config_exists && existing_config . is_some ( ) {
5157 if !quiet {
5258 println ! ( "{}" , style( "Configuration already set" ) . green( ) ) ;
5359 }
5460 return Ok ( ( ) ) ;
5561 }
5662
5763 anyhow:: bail!(
58- "Non-interactive mode requires auth credentials to be pre-set .\n \n \
64+ "Non-interactive mode requires an existing configuration .\n \n \
5965 Use:\n \
60- occ config set username <user>\n \
61- occ config set password"
66+ occ setup\n \n \
67+ Or for automated environments:\n \
68+ occ setup --bootstrap"
6269 ) ;
6370 }
6471
@@ -132,6 +139,7 @@ pub async fn cmd_setup(args: &SetupArgs, quiet: bool) -> Result<()> {
132139 ..Default :: default ( )
133140 } ;
134141 cmd_start ( & start_args, target_host. as_deref ( ) , quiet, 0 ) . await ?;
142+ maybe_print_iotp_info ( & client, host_name. as_deref ( ) , & new_config) . await ;
135143
136144 Ok ( ( ) )
137145}
@@ -219,6 +227,9 @@ async fn start_or_restart_after_setup(
219227 ..Default :: default ( )
220228 } ;
221229 cmd_start ( & start_args, target_host, quiet || non_interactive, 0 ) . await ?;
230+ if !quiet {
231+ maybe_print_iotp_info ( & client, host_name. as_deref ( ) , new_config) . await ;
232+ }
222233 Ok ( ( ) )
223234}
224235
@@ -258,3 +269,133 @@ fn show_running_status(config: &Config, host: Option<&str>) {
258269 style( format!( "http://{}:{}" , bind_addr, config. opencode_web_port) ) . cyan( )
259270 ) ;
260271}
272+
273+ async fn maybe_print_iotp_info ( client : & DockerClient , host : Option < & str > , config : & Config ) {
274+ if !config. users . is_empty ( ) {
275+ return ;
276+ }
277+
278+ println ! ( ) ;
279+ let headline = crate :: format_host_message ( host, "First-time onboarding" ) ;
280+ println ! ( "{}" , style( headline) . cyan( ) . bold( ) ) ;
281+
282+ if let Some ( iotp) = find_iotp_in_recent_logs ( client, 250 ) . await {
283+ println ! (
284+ "Initial One-Time Password (IOTP): {}" ,
285+ style( iotp) . green( ) . bold( )
286+ ) ;
287+ println ! (
288+ "Enter this in the web login first-time setup panel, then enroll a passkey for {}." ,
289+ style( "opencoder" ) . cyan( )
290+ ) ;
291+ return ;
292+ }
293+
294+ for ( idx, line) in build_iotp_fallback_message ( config. allow_unauthenticated_network )
295+ . lines ( )
296+ . enumerate ( )
297+ {
298+ if idx == 0 {
299+ println ! ( "{}" , style( line) . yellow( ) ) ;
300+ } else {
301+ println ! ( "{line}" ) ;
302+ }
303+ }
304+ }
305+
306+ async fn find_iotp_in_recent_logs ( client : & DockerClient , lines : usize ) -> Option < String > {
307+ let options = LogsOptions {
308+ stdout : true ,
309+ stderr : true ,
310+ tail : lines. to_string ( ) ,
311+ ..Default :: default ( )
312+ } ;
313+
314+ let mut stream = client. inner ( ) . logs ( CONTAINER_NAME , Some ( options) ) ;
315+ let mut latest: Option < String > = None ;
316+
317+ while let Some ( entry) = stream. next ( ) . await {
318+ let output = match entry {
319+ Ok ( output) => output,
320+ Err ( _) => break ,
321+ } ;
322+
323+ let message = match output {
324+ LogOutput :: StdOut { message } | LogOutput :: StdErr { message } => {
325+ String :: from_utf8_lossy ( & message) . to_string ( )
326+ }
327+ _ => continue ,
328+ } ;
329+
330+ for line in message. lines ( ) {
331+ if let Some ( iotp) = extract_iotp_from_line ( line) {
332+ latest = Some ( iotp) ;
333+ }
334+ }
335+ }
336+
337+ latest
338+ }
339+
340+ fn extract_iotp_from_line ( line : & str ) -> Option < String > {
341+ let ( _, remainder) = line. split_once ( IOTP_LOG_PREFIX ) ?;
342+ let token = remainder. split_whitespace ( ) . next ( ) ?. trim ( ) ;
343+ if token. is_empty ( ) {
344+ return None ;
345+ }
346+ Some ( token. to_string ( ) )
347+ }
348+
349+ fn build_iotp_fallback_message ( allow_unauthenticated_network : bool ) -> String {
350+ let mut message = format ! (
351+ "Could not auto-detect the Initial One-Time Password (IOTP) from recent logs.\n \
352+ Fetch it manually with:\n {IOTP_FALLBACK_COMMAND}"
353+ ) ;
354+
355+ if allow_unauthenticated_network {
356+ message. push_str (
357+ "\n Note: allow_unauthenticated_network=true may skip IOTP generation by design." ,
358+ ) ;
359+ }
360+
361+ message
362+ }
363+
364+ #[ cfg( test) ]
365+ mod tests {
366+ use super :: * ;
367+
368+ #[ test]
369+ fn test_extract_iotp_from_line_valid ( ) {
370+ let line = "2026-02-08T00:00:00Z INITIAL ONE-TIME PASSWORD (IOTP): abc123XYZ" ;
371+ assert_eq ! ( extract_iotp_from_line( line) , Some ( "abc123XYZ" . to_string( ) ) ) ;
372+ }
373+
374+ #[ test]
375+ fn test_extract_iotp_from_line_ignores_unrelated ( ) {
376+ assert_eq ! ( extract_iotp_from_line( "normal startup line" ) , None ) ;
377+ }
378+
379+ #[ test]
380+ fn test_extract_iotp_from_line_rejects_malformed ( ) {
381+ assert_eq ! (
382+ extract_iotp_from_line( "INITIAL ONE-TIME PASSWORD (IOTP): " ) ,
383+ None
384+ ) ;
385+ }
386+
387+ #[ test]
388+ fn test_build_iotp_fallback_message_default ( ) {
389+ let message = build_iotp_fallback_message ( false ) ;
390+ assert ! ( message. contains( "Could not auto-detect" ) ) ;
391+ assert ! ( message. contains( IOTP_FALLBACK_COMMAND ) ) ;
392+ assert ! ( !message. contains( "allow_unauthenticated_network" ) ) ;
393+ }
394+
395+ #[ test]
396+ fn test_build_iotp_fallback_message_with_unauth_hint ( ) {
397+ let message = build_iotp_fallback_message ( true ) ;
398+ assert ! ( message. contains( IOTP_FALLBACK_COMMAND ) ) ;
399+ assert ! ( message. contains( "allow_unauthenticated_network=true" ) ) ;
400+ }
401+ }
0 commit comments