mod api;
mod auth;
mod commands;
mod dev_session;
mod manifest;
mod oci;
mod oidc;
mod ui;
mod update;
mod watch_lock;
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,
#[arg(long, global = true)]
plain: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Init(commands::init::InitArgs),
Login(commands::login::LoginArgs),
Logout(commands::login::LogoutArgs),
Dev(commands::dev::DevArgs),
Build(commands::build::BuildArgs),
Ci(commands::ci::CiArgs),
Lint(commands::lint::LintArgs),
Test(commands::test::TestArgs),
Sdk(commands::sdk::SdkArgs),
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, cli.plain);
if let Err(failure) = dispatch(cli.command).await {
ui::report(&failure);
std::process::exit(1);
}
update::notify().await;
}
fn parse() -> Cli {
let raw: Vec<String> = std::env::args().collect();
let bare = raw.iter().any(|argument| argument == "--plain");
ui::set_plain(bare);
ui::set_colors(!bare && !raw.iter().any(|argument| argument == "--no-color"));
let mut command = Cli::command().long_version(
Box::leak(ui::long_version().into_boxed_str()) as &'static str,
);
if !bare {
command = command
.before_help(ui::banner())
.before_long_help(ui::banner())
.after_help(ui::legal())
.after_long_help(ui::legal());
}
let command = command;
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()) {
if wants_room() && !ui::plain() {
if refusal.use_stderr() {
eprintln!();
} else {
println!();
}
}
refusal.exit();
}
let rendered = refusal.to_string();
if std::env::var_os("PORTAKI_RAW_REFUSAL").is_some() {
eprintln!("RAW>>>\n{rendered}\n<<<END");
}
ui::blank();
let (headline, precisions) = refusal_lines(&rendered);
ui::failure(headline);
for precision in precisions {
ui::detail(precision);
}
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 arguments: Vec<String> = std::env::args().skip(1).collect();
let (_, reached) = descend(command, &arguments);
let commands: Vec<(String, String)> = reached
.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 wants_room() -> bool {
room_for(std::env::args().skip(1))
}
fn room_for(mut arguments: impl Iterator<Item = String>) -> bool {
!arguments.any(|argument| argument == "-V")
}
fn refusal_lines(rendered: &str) -> (String, Vec<String>) {
let mut paragraph = rendered
.lines()
.skip_while(|line| line.trim().is_empty())
.take_while(|line| !line.trim().is_empty())
.map(|line| line.trim().to_string());
let headline = paragraph
.next()
.map(|line| line.trim_start_matches("error: ").to_string())
.unwrap_or_else(|| "invalid arguments".to_string());
(headline, paragraph.collect())
}
fn invoked_command(command: &clap::Command) -> Option<String> {
let arguments: Vec<String> = std::env::args().skip(1).collect();
let (path, _) = descend(command, &arguments);
(!path.is_empty()).then(|| path.join(" "))
}
fn descend<'a>(root: &'a clap::Command, arguments: &[String]) -> (Vec<String>, &'a clap::Command) {
let mut node = root;
let mut path = Vec::new();
for argument in arguments {
if let Some(next) = node
.get_subcommands()
.find(|sub| sub.get_name() == argument.as_str())
{
path.push(next.get_name().to_string());
node = next;
}
}
(path, node)
}
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(args) => commands::login::logout(args).await,
Command::Dev(args) => commands::dev::run(args).await,
Command::Build(args) => commands::build::run(args).await,
Command::Ci(args) => commands::ci::run(args).await,
Command::Lint(args) => commands::lint::run(args),
Command::Test(args) => commands::test::run(args),
Command::Sdk(args) => commands::sdk::run(args).await,
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() {
let (headline, _) = refusal_lines("error: unrecognized subcommand 'buidl'\n\n tip: ...");
assert_eq!(headline, "unrecognized subcommand 'buidl'");
}
#[test]
fn the_precisions_that_name_the_argument_survive() {
let rendered = concat!(
"error: the following required arguments were not provided:\n",
" --outcome <OUTCOME>\n",
"\n",
"Usage: portaki ci report --outcome <OUTCOME>\n",
"\n",
"For more information, try '--help'.\n"
);
let (headline, precisions) = refusal_lines(rendered);
assert_eq!(
headline,
"the following required arguments were not provided:"
);
assert_eq!(precisions, vec!["--outcome <OUTCOME>"]);
}
#[test]
fn what_follows_the_blank_line_is_left_to_clap() {
let (_, precisions) = refusal_lines("error: nope\n\nUsage: portaki\n");
assert!(precisions.is_empty());
}
#[test]
fn the_help_pointer_reaches_the_deepest_command() {
let root = Cli::command();
let args = |raw: &[&str]| raw.iter().map(ToString::to_string).collect::<Vec<_>>();
let (path, reached) = descend(&root, &args(&["ci", "report"]));
assert_eq!(path, vec!["ci", "report"]);
assert_eq!(reached.get_name(), "report");
let (path, _) = descend(&root, &args(&["--plain", "ci", "modules"]));
assert_eq!(path, vec!["ci", "modules"]);
let (path, reached) = descend(&root, &args(&["ci", "nope"]));
assert_eq!(path, vec!["ci"]);
assert_eq!(reached.get_name(), "ci");
let (path, reached) = descend(&root, &args(&["buidl"]));
assert!(path.is_empty());
assert_eq!(reached.get_name(), "portaki");
}
fn args(raw: &[&str]) -> impl Iterator<Item = String> + use<> {
raw.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.into_iter()
}
#[test]
fn the_short_version_stays_a_single_parseable_line() {
assert!(!room_for(args(&["-V"])));
assert!(room_for(args(&["--version"])));
assert!(room_for(args(&["--help"])));
assert!(room_for(args(&[])));
}
#[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() {
let (headline, precisions) = refusal_lines(" \n\n");
assert_eq!(headline, "invalid arguments");
assert!(precisions.is_empty());
}
}