Skip to content

Commit 71d3c96

Browse files
committed
feat(setup): switch wizard to IOTP-first onboarding
1 parent 7ede00a commit 71d3c96

10 files changed

Lines changed: 237 additions & 310 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,14 @@ Quick IOTP extraction from logs:
164164
occ logs | grep -F "INITIAL ONE-TIME PASSWORD (IOTP): " | tail -n1 | sed 's/.*INITIAL ONE-TIME PASSWORD (IOTP): //'
165165
```
166166

167+
You can also run the setup wizard:
168+
169+
```bash
170+
occ setup
171+
```
172+
173+
The wizard now configures runtime settings (image source, bind/port, mounts), keeps authentication on IOTP + passkey onboarding, and attempts to auto-detect the IOTP from logs after start.
174+
167175
### From source (install locally)
168176

169177
```bash
@@ -353,6 +361,7 @@ opencode-cloud uses **PAM (Pluggable Authentication Modules)** for authenticatio
353361
First boot path:
354362
- If no users are configured, startup logs print an Initial One-Time Password (IOTP).
355363
- Extract only the IOTP quickly: `occ logs | grep -F "INITIAL ONE-TIME PASSWORD (IOTP): " | tail -n1 | sed 's/.*INITIAL ONE-TIME PASSWORD (IOTP): //'`
364+
- `occ setup` attempts to auto-detect and print the IOTP after starting/restarting the service.
356365
- Enter that IOTP in the web login page first-time setup panel.
357366
- Enroll a passkey for the default `opencoder` account.
358367
- The IOTP is deleted after successful passkey enrollment.

docs/deploy/digitalocean-droplet.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ Extract just the IOTP value (optional):
8484
occ logs | grep -F "INITIAL ONE-TIME PASSWORD (IOTP): " | tail -n1 | sed 's/.*INITIAL ONE-TIME PASSWORD (IOTP): //'
8585
```
8686

87+
If you used `occ setup`, it will also try to auto-detect and print the IOTP after starting/restarting the service.
88+
8789
Open the web login page through your SSH tunnel and use the first-time setup panel:
8890
- Enter the IOTP from logs
8991
- Continue to passkey setup

packages/cli-rust/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,9 @@ packages/cli-rust/
7171
│ │ └── urls.rs # URL formatting helpers
7272
│ ├── wizard/
7373
│ │ ├── mod.rs # Interactive setup wizard
74-
│ │ ├── auth.rs # Auth prompts
75-
│ │ └── network.rs # Network prompts
74+
│ │ ├── network.rs # Network prompts
75+
│ │ ├── summary.rs # Wizard summary output
76+
│ │ └── prechecks.rs # Environment prechecks
7677
│ └── lib.rs # Shared CLI implementation
7778
├── Cargo.toml
7879
└── README.md # This file

packages/cli-rust/src/commands/setup.rs

Lines changed: 149 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,23 @@ use anyhow::Result;
66
use clap::Args;
77
use console::style;
88
use 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};
1013
use opencode_cloud_core::{Config, load_config_or_default, save_config};
1114

1215
use crate::commands::{cmd_start, cmd_stop};
1316
use crate::constants::COCKPIT_EXPOSED;
1417
use 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)]
1824
pub 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+
"\nNote: 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+
}

packages/cli-rust/src/commands/start.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -587,7 +587,7 @@ fn display_bootstrap_mode_warning(bind_addr: &str) {
587587
style(bind_addr).cyan()
588588
);
589589
eprintln!("Open the login page and use the first-time setup panel with that IOTP.");
590-
eprintln!("The IOTP is invalidated immediately after the first successful signup.");
590+
eprintln!("The IOTP is invalidated immediately after the first successful passkey enrollment.");
591591
eprintln!();
592592
eprintln!(
593593
"Admin alternative: {}",

0 commit comments

Comments
 (0)