use std::time::Duration;
use anyhow::{bail, Context, Result};
use clap::Parser;
use crate::{auth, ui};
const CLIENT_ID: &str = "portaki-cli";
const SCOPES: [&str; 5] = [
"dev:read",
"dev:deploy",
"dev:dispatch",
"dev:stay:read",
"registry:publish",
];
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const SDK_VERSION: &str = portaki_sdk::VERSION;
#[derive(Debug, Parser)]
pub struct LoginArgs {
#[arg(long)]
pub url: Option<String>,
#[arg(long)]
pub no_browser: bool,
}
#[derive(Debug, Parser)]
pub struct LogoutArgs {
#[arg(long)]
pub url: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeviceCode {
device_code: String,
user_code: String,
verification_uri: String,
#[serde(default)]
verification_uri_complete: Option<String>,
expires_in: u64,
interval: u64,
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Granted {
access_token: String,
refresh_token: String,
#[serde(default)]
scopes: Vec<String>,
}
pub async fn run(args: LoginArgs) -> Result<()> {
ui::header(
"portaki login",
"Device grant — the code below ties this terminal to your Portaki account.",
);
let base = base_url(args.url.as_deref());
let client = reqwest::Client::new();
let asking = ui::step("asking the platform for a code");
let response = client
.post(format!("{base}/api/v1/auth/device/code"))
.json(&serde_json::json!({
"clientId": CLIENT_ID,
"scopes": SCOPES,
"deviceLabel": device_label(),
"clientVersion": CLIENT_VERSION,
"sdkVersion": SDK_VERSION,
}))
.send()
.await
.map_err(|failure| {
asking.abandon();
failure
})
.context("ask the platform for a device code")?;
let started: DeviceCode = crate::api::unwrap(&response.text().await.unwrap_or_default())?;
asking.done("got a code");
present(&started, args.no_browser);
let mut interval = Duration::from_secs(started.interval.max(1));
let deadline = std::time::Instant::now() + Duration::from_secs(started.expires_in);
let waiting = ui::step("waiting for approval");
loop {
let now = std::time::Instant::now();
if now >= deadline {
waiting.abandon();
bail!("the code expired before it was approved — run `portaki login` again");
}
waiting.say(format!(
"waiting for approval — {} left",
ui::countdown(deadline - now)
));
tokio::time::sleep(interval).await;
let response = client
.post(format!("{base}/api/v1/auth/device/token"))
.json(&serde_json::json!({ "deviceCode": started.device_code }))
.send()
.await
.context("poll the platform")?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if status.is_success() {
let granted: Granted = crate::api::unwrap(&body)?;
auth::store(&granted.access_token, &granted.refresh_token)?;
waiting.done("approved");
ui::success("signed in — token stored in the system keychain");
if !granted.scopes.is_empty() {
ui::field("scopes", granted.scopes.join(" "));
}
ui::advice(
"the access token lasts minutes and renews itself — the session lives in the \
keychain until portaki logout",
);
ui::next(&[
(
"portaki dev --watch",
"build, deploy to the sandbox, redeploy on every save",
),
(
"portaki publish",
"push a release and announce it to the registry",
),
]);
ui::blank();
return Ok(());
}
let error = crate::api::error_code(&body).unwrap_or_else(|| body.clone());
match error.as_str() {
"authorization_pending" => {}
"slow_down" => interval += Duration::from_secs(5),
"access_denied" => {
waiting.abandon();
bail!("the request was denied");
}
"expired_token" => {
waiting.abandon();
bail!("the code expired — run `portaki login` again");
}
other => {
waiting.abandon();
bail!("the platform answered {other}");
}
}
}
}
fn present(started: &DeviceCode, no_browser: bool) {
let target = started
.verification_uri_complete
.as_deref()
.unwrap_or(&started.verification_uri);
ui::blank();
ui::code_block(&started.user_code);
ui::blank();
if no_browser || !ui::open_browser(target) {
ui::field("open", target);
ui::field("code", &started.user_code);
} else {
ui::success(if started.verification_uri_complete.is_some() {
"opened your browser — check the code above, then approve"
} else {
"opened your browser — paste the code above to approve"
});
ui::detail(target);
}
ui::blank();
}
pub async fn logout(args: LogoutArgs) -> Result<()> {
ui::header(
"portaki logout",
"End the session here, and on the platform.",
);
let stored = auth::refresh_token();
auth::forget()?;
ui::success("signed out here — credentials cleared");
let Some(refresh_token) = stored else {
ui::detail("no session was stored");
ui::blank();
return Ok(());
};
match revoke(&base_url(args.url.as_deref()), &refresh_token).await {
Ok(()) => ui::success("the platform revoked this session"),
Err(failure) => {
ui::warn("could not reach the platform — this session is still valid there");
ui::detail(format!("{failure:#}"));
ui::advice("run portaki logout again once you are online");
}
}
ui::blank();
Ok(())
}
async fn revoke(base: &str, refresh_token: &str) -> Result<()> {
let response = reqwest::Client::new()
.post(format!("{base}/api/v1/auth/logout"))
.json(&serde_json::json!({ "refreshToken": refresh_token }))
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.context("tell the platform to end this session")?;
let status = response.status();
if !status.is_success() {
anyhow::bail!("the platform answered {status}");
}
Ok(())
}
fn base_url(explicit: Option<&str>) -> String {
auth::api_base_url(explicit)
}
fn device_label() -> Option<String> {
if let Ok(name) = std::env::var("COMPUTERNAME") {
if let Some(name) = non_empty(name) {
return Some(name);
}
}
if let Ok(output) = std::process::Command::new("hostname").output() {
if output.status.success() {
if let Some(name) = non_empty(String::from_utf8_lossy(&output.stdout).into_owned()) {
return Some(name);
}
}
}
std::env::var("HOSTNAME")
.or_else(|_| std::env::var("HOST"))
.ok()
.and_then(non_empty)
}
fn non_empty(value: String) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_cli_never_asks_for_a_host_scope() {
assert!(!SCOPES.iter().any(|s| s.starts_with("host:")));
}
#[test]
fn sandbox_stays_are_asked_for_under_the_dev_domain() {
assert!(SCOPES.contains(&"dev:stay:read"));
assert!(!SCOPES.contains(&"stay:read"));
}
#[test]
fn a_device_label_is_trimmed_or_absent() {
assert_eq!(
non_empty(" my-laptop.local\n".to_owned()).as_deref(),
Some("my-laptop.local")
);
assert_eq!(non_empty(" ".to_owned()), None);
assert_eq!(non_empty(String::new()), None);
}
#[test]
fn a_missing_device_label_is_not_an_error() {
let label = device_label();
assert!(label
.as_deref()
.map(str::trim)
.map(|l| !l.is_empty())
.unwrap_or(true));
}
#[test]
fn the_versions_announced_are_the_ones_compiled_in() {
assert!(!CLIENT_VERSION.is_empty());
assert_eq!(SDK_VERSION, portaki_sdk::VERSION);
}
#[test]
fn an_explicit_url_wins_over_the_environment() {
std::env::set_var("PORTAKI_API_URL", "https://from-env.example");
assert_eq!(
base_url(Some("https://explicit.example/")),
"https://explicit.example"
);
std::env::remove_var("PORTAKI_API_URL");
}
#[test]
fn a_trailing_slash_never_doubles_in_the_path() {
assert_eq!(
base_url(Some("https://api.example/")),
"https://api.example"
);
}
}