mod api;
mod auth;
mod commands;
mod manifest;
mod oci;
mod oidc;
mod ui;
use anyhow::Result;
use clap::builder::styling::{AnsiColor, Effects, Styles};
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
use tracing_subscriber::EnvFilter;
const HELP_STYLES: Styles = Styles::styled()
.header(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
.usage(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
.literal(AnsiColor::White.on_default().effects(Effects::BOLD))
.placeholder(AnsiColor::Cyan.on_default())
.error(AnsiColor::Red.on_default().effects(Effects::BOLD))
.invalid(AnsiColor::Yellow.on_default());
#[derive(Debug, Parser)]
#[command(
name = "portaki",
version,
about = "Portaki module SDK CLI",
styles = HELP_STYLES,
arg_required_else_help = true
)]
struct Cli {
#[arg(long, global = true)]
no_color: bool,
#[arg(long, short, global = true)]
verbose: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Init(commands::init::InitArgs),
Login(commands::login::LoginArgs),
Logout,
Dev(commands::dev::DevArgs),
Build(commands::build::BuildArgs),
Lint(commands::lint::LintArgs),
Test(commands::test::TestArgs),
Publish(commands::publish::PublishArgs),
Docs(commands::docs::DocsArgs),
Catalog(commands::catalog::CatalogArgs),
Inspect(commands::inspect::InspectArgs),
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let cli = parse();
ui::init(cli.no_color, cli.verbose);
if let Err(failure) = dispatch(cli.command).await {
ui::report(&failure);
std::process::exit(1);
}
}
fn parse() -> Cli {
let wants_color = !std::env::args().any(|argument| argument == "--no-color");
ui::set_colors(wants_color);
let command = Cli::command()
.before_help(ui::banner())
.before_long_help(ui::banner())
.after_help(ui::legal())
.after_long_help(ui::legal())
.long_version(Box::leak(ui::long_version().into_boxed_str()) as &'static str);
let matches = match command.clone().try_get_matches() {
Ok(matches) => matches,
Err(refusal) => refuse(refusal, &command),
};
Cli::from_arg_matches(&matches).unwrap_or_else(|failure| failure.exit())
}
fn refuse(refusal: clap::Error, command: &clap::Command) -> ! {
use clap::error::ErrorKind;
if is_a_screen(refusal.kind()) {
refusal.exit();
}
let rendered = refusal.to_string();
ui::blank();
ui::failure(headline(&rendered));
for tip in rendered
.lines()
.filter_map(|line| line.trim().strip_prefix("tip: "))
{
ui::detail(tip);
}
if matches!(
refusal.kind(),
ErrorKind::InvalidSubcommand | ErrorKind::MissingSubcommand
) {
let commands: Vec<(String, String)> = command
.get_subcommands()
.filter(|sub| !sub.is_hide_set())
.map(|sub| {
(
sub.get_name().to_string(),
sub.get_about().map(ToString::to_string).unwrap_or_default(),
)
})
.collect();
let rows: Vec<(&str, &str)> = commands
.iter()
.map(|(name, about)| (name.as_str(), about.as_str()))
.collect();
ui::list("commands", &rows);
}
let help = match invoked_command(command) {
Some(name) => format!("portaki {name} --help"),
None => "portaki --help".to_string(),
};
ui::next(&[(&help, "every flag this command takes")]);
ui::blank();
std::process::exit(2);
}
fn is_a_screen(kind: clap::error::ErrorKind) -> bool {
use clap::error::ErrorKind;
matches!(
kind,
ErrorKind::DisplayHelp
| ErrorKind::DisplayVersion
| ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
)
}
fn headline(rendered: &str) -> String {
rendered
.lines()
.find(|line| !line.trim().is_empty())
.map(|line| line.trim_start_matches("error: ").to_string())
.unwrap_or_else(|| "invalid arguments".to_string())
}
fn invoked_command(command: &clap::Command) -> Option<String> {
let known: Vec<&str> = command
.get_subcommands()
.map(|sub| sub.get_name())
.collect();
std::env::args()
.skip(1)
.find(|argument| known.contains(&argument.as_str()))
}
async fn dispatch(command: Command) -> Result<()> {
match command {
Command::Init(args) => commands::init::run(args),
Command::Login(args) => commands::login::run(args).await,
Command::Logout => commands::login::logout(),
Command::Dev(args) => commands::dev::run(args).await,
Command::Build(args) => commands::build::run(args).await,
Command::Lint(args) => commands::lint::run(args),
Command::Test(args) => commands::test::run(args),
Command::Publish(args) => commands::publish::run(args).await,
Command::Docs(args) => commands::docs::run(args),
Command::Catalog(args) => commands::catalog::run(args),
Command::Inspect(args) => commands::inspect::run(args).await,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_headline_drops_the_prefix_clap_adds() {
assert_eq!(
headline("error: unrecognized subcommand 'buidl'\n\n tip: ..."),
"unrecognized subcommand 'buidl'"
);
}
#[test]
fn a_help_screen_is_never_taken_for_a_refusal() {
use clap::error::ErrorKind;
assert!(is_a_screen(
ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
));
assert!(is_a_screen(ErrorKind::DisplayHelp));
assert!(is_a_screen(ErrorKind::DisplayVersion));
assert!(!is_a_screen(ErrorKind::InvalidSubcommand));
assert!(!is_a_screen(ErrorKind::UnknownArgument));
}
#[test]
fn an_unreadable_refusal_still_says_something() {
assert_eq!(headline(" \n\n"), "invalid arguments");
}
}