feat(init): Add the new/init subcommands (#85)
This commit is contained in:
parent
e3b246ef91
commit
918da22952
19 changed files with 765 additions and 152 deletions
|
|
@ -40,6 +40,12 @@ fn main() {
|
|||
#[cfg(feature = "login")]
|
||||
CommandArgs::Login(mut command) => command.run(),
|
||||
|
||||
#[cfg(feature = "init")]
|
||||
CommandArgs::New(mut command) => command.run(),
|
||||
|
||||
#[cfg(feature = "init")]
|
||||
CommandArgs::Init(mut command) => command.run(),
|
||||
|
||||
#[cfg(feature = "iso")]
|
||||
CommandArgs::GenerateIso(mut command) => command.run(),
|
||||
|
||||
|
|
|
|||
|
|
@ -13,16 +13,16 @@ pub mod completions;
|
|||
pub mod generate;
|
||||
#[cfg(feature = "iso")]
|
||||
pub mod generate_iso;
|
||||
#[cfg(feature = "login")]
|
||||
pub mod login;
|
||||
#[cfg(feature = "validate")]
|
||||
pub mod validate;
|
||||
// #[cfg(feature = "init")]
|
||||
// pub mod init;
|
||||
#[cfg(feature = "init")]
|
||||
pub mod init;
|
||||
#[cfg(not(feature = "switch"))]
|
||||
pub mod local;
|
||||
#[cfg(feature = "login")]
|
||||
pub mod login;
|
||||
#[cfg(feature = "switch")]
|
||||
pub mod switch;
|
||||
#[cfg(feature = "validate")]
|
||||
pub mod validate;
|
||||
|
||||
pub trait BlueBuildCommand {
|
||||
/// Runs the command and returns a result
|
||||
|
|
@ -117,6 +117,14 @@ pub enum CommandArgs {
|
|||
#[cfg(feature = "login")]
|
||||
Login(login::LoginCommand),
|
||||
|
||||
/// Create a new bluebuild project.
|
||||
#[cfg(feature = "init")]
|
||||
New(init::NewCommand),
|
||||
|
||||
/// Create a new bluebuild project.
|
||||
#[cfg(feature = "init")]
|
||||
Init(init::InitCommand),
|
||||
|
||||
/// Validate your recipe file and display
|
||||
/// errors to help fix problems.
|
||||
#[cfg(feature = "validate")]
|
||||
|
|
|
|||
|
|
@ -1,68 +1,142 @@
|
|||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process,
|
||||
env,
|
||||
fmt::{Display, Write as FmtWrite},
|
||||
fs::{self, OpenOptions},
|
||||
io::{BufWriter, Write as IoWrite},
|
||||
path::PathBuf,
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args;
|
||||
use log::error;
|
||||
use typed_builder::TypedBuilder;
|
||||
use blue_build_process_management::drivers::{
|
||||
opts::GenerateKeyPairOpts, CiDriver, Driver, DriverArgs, GitlabDriver, SigningDriver,
|
||||
};
|
||||
use blue_build_template::{GitlabCiTemplate, InitReadmeTemplate, Template};
|
||||
use blue_build_utils::{
|
||||
cmd,
|
||||
constants::{COSIGN_PUB_PATH, RECIPE_FILE, RECIPE_PATH, TEMPLATE_REPO_URL},
|
||||
};
|
||||
use bon::Builder;
|
||||
use clap::{crate_version, Args, ValueEnum};
|
||||
use log::{debug, info, trace};
|
||||
use miette::{bail, miette, Context, IntoDiagnostic, Report, Result};
|
||||
use requestty::{questions, Answer, Answers, OnEsc};
|
||||
use semver::Version;
|
||||
|
||||
use super::BlueBuildCommand;
|
||||
use crate::commands::BlueBuildCommand;
|
||||
|
||||
const GITLAB_CI_FILE: &'static str = include_str!("../../templates/init/gitlab-ci.yml.tera");
|
||||
const RECIPE_FILE: &'static str = include_str!("../../templates/init/recipe.yml.tera");
|
||||
const LICENSE_FILE: &'static str = include_str!("../../LICENSE");
|
||||
|
||||
#[derive(Debug, Clone, Default, Args, TypedBuilder)]
|
||||
pub struct NewInitCommon {
|
||||
#[builder(default)]
|
||||
no_git: bool,
|
||||
#[derive(Debug, Default, Clone, Copy, ValueEnum)]
|
||||
pub enum CiProvider {
|
||||
#[default]
|
||||
Github,
|
||||
Gitlab,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args, TypedBuilder)]
|
||||
pub struct InitCommand {
|
||||
/// The directory to extract the files into. Defaults to the current directory
|
||||
#[arg()]
|
||||
#[builder(setter(strip_option, into), default)]
|
||||
dir: Option<PathBuf>,
|
||||
impl CiProvider {
|
||||
fn default_ci_file_path(self) -> std::path::PathBuf {
|
||||
match self {
|
||||
Self::Gitlab => GitlabDriver::default_ci_file_path(),
|
||||
Self::None | Self::Github => unimplemented!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_file(self) -> Result<String> {
|
||||
match self {
|
||||
Self::Gitlab => GitlabCiTemplate::builder()
|
||||
.version({
|
||||
let version = crate_version!();
|
||||
let version: Version = version.parse().into_diagnostic()?;
|
||||
|
||||
format!("v{}.{}", version.major, version.minor)
|
||||
})
|
||||
.build()
|
||||
.render()
|
||||
.into_diagnostic(),
|
||||
Self::None | Self::Github => unimplemented!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for CiProvider {
|
||||
type Error = Report;
|
||||
|
||||
fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
|
||||
Ok(match value {
|
||||
"Gitlab" => Self::Gitlab,
|
||||
"Github" => Self::Github,
|
||||
"None" => Self::None,
|
||||
_ => bail!("Unable to parse for CiProvider"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&String> for CiProvider {
|
||||
type Error = Report;
|
||||
|
||||
fn try_from(value: &String) -> std::result::Result<Self, Self::Error> {
|
||||
Self::try_from(value.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for CiProvider {
|
||||
type Err = Report;
|
||||
|
||||
fn from_str(s: &str) -> std::prelude::v1::Result<Self, Self::Err> {
|
||||
Self::try_from(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for CiProvider {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match *self {
|
||||
Self::Github => "Github",
|
||||
Self::Gitlab => "Gitlab",
|
||||
Self::None => "None",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Args, Builder)]
|
||||
#[builder(on(String, into))]
|
||||
pub struct NewInitCommon {
|
||||
/// The name of the image for the recipe.
|
||||
#[arg(long)]
|
||||
image_name: Option<String>,
|
||||
|
||||
/// The name of the org where your repo will be located.
|
||||
/// This could end up being your username.
|
||||
#[arg(long)]
|
||||
org_name: Option<String>,
|
||||
|
||||
/// Optional description for the GitHub repository.
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
|
||||
/// The registry to store the image.
|
||||
#[arg(long)]
|
||||
registry: Option<String>,
|
||||
|
||||
/// The CI provider that will be building the image.
|
||||
///
|
||||
/// GitHub Actions and Gitlab CI are currently the
|
||||
/// officially supported CI providers.
|
||||
#[arg(long, short)]
|
||||
ci_provider: Option<CiProvider>,
|
||||
|
||||
/// Disable setting up git.
|
||||
#[arg(long)]
|
||||
no_git: bool,
|
||||
|
||||
#[clap(flatten)]
|
||||
#[builder(default)]
|
||||
common: NewInitCommon,
|
||||
drivers: DriverArgs,
|
||||
}
|
||||
|
||||
impl BlueBuildCommand for InitCommand {
|
||||
fn try_run(&mut self) -> Result<()> {
|
||||
let base_dir = match self.dir.as_ref() {
|
||||
Some(dir) => dir,
|
||||
None => std::path::Path::new("./"),
|
||||
};
|
||||
|
||||
self.initialize_directory(base_dir);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl InitCommand {
|
||||
fn initialize_directory(&self, base_dir: &Path) {
|
||||
let recipe_path = base_dir.join("recipe.yml");
|
||||
|
||||
let gitlab_ci_path = base_dir.join(".gitlab-ci.yml");
|
||||
|
||||
let readme_path = base_dir.join("README.md");
|
||||
|
||||
let license_path = base_dir.join("LICENSE");
|
||||
|
||||
let scripts_dir = base_dir.join("scripts/");
|
||||
|
||||
let pre_scripts_dir = scripts_dir.join("pre/");
|
||||
|
||||
let post_scripts_dir = scripts_dir.join("post/");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args, TypedBuilder)]
|
||||
#[derive(Debug, Clone, Args, Builder)]
|
||||
pub struct NewCommand {
|
||||
#[arg()]
|
||||
dir: PathBuf,
|
||||
|
|
@ -80,3 +154,383 @@ impl BlueBuildCommand for NewCommand {
|
|||
.try_run()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args, Builder)]
|
||||
pub struct InitCommand {
|
||||
#[clap(skip)]
|
||||
#[builder(into)]
|
||||
dir: Option<PathBuf>,
|
||||
|
||||
#[clap(flatten)]
|
||||
common: NewInitCommon,
|
||||
}
|
||||
|
||||
impl BlueBuildCommand for InitCommand {
|
||||
fn try_run(&mut self) -> Result<()> {
|
||||
Driver::init(self.common.drivers);
|
||||
|
||||
let base_dir = self
|
||||
.dir
|
||||
.get_or_insert(env::current_dir().into_diagnostic()?);
|
||||
|
||||
if base_dir.exists() && fs::read_dir(base_dir).is_ok_and(|dir| dir.count() != 0) {
|
||||
bail!("Must be in an empty directory!");
|
||||
}
|
||||
|
||||
self.start(&self.questions()?)
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! when {
|
||||
($check:expr) => {
|
||||
|_answers: &::requestty::Answers| $check
|
||||
};
|
||||
}
|
||||
|
||||
impl InitCommand {
|
||||
const CI_PROVIDER: &str = "ci_provider";
|
||||
const REGISTRY: &str = "registry";
|
||||
const IMAGE_NAME: &str = "image_name";
|
||||
const ORG_NAME: &str = "org_name";
|
||||
const DESCRIPTION: &str = "description";
|
||||
|
||||
fn questions(&self) -> Result<Answers> {
|
||||
let questions = questions![
|
||||
Input {
|
||||
name: Self::IMAGE_NAME,
|
||||
message: "What would you like to name your image?",
|
||||
when: when!(self.common.image_name.is_none()),
|
||||
on_esc: OnEsc::Terminate,
|
||||
},
|
||||
Input {
|
||||
name: Self::REGISTRY,
|
||||
message:
|
||||
"What is the registry for the image? (e.g. ghcr.io or registry.gitlab.com)",
|
||||
when: when!(self.common.registry.is_none()),
|
||||
on_esc: OnEsc::Terminate,
|
||||
},
|
||||
Input {
|
||||
name: Self::ORG_NAME,
|
||||
message: "What is the name of your org/username?",
|
||||
when: when!(self.common.org_name.is_none()),
|
||||
on_esc: OnEsc::Terminate,
|
||||
},
|
||||
Input {
|
||||
name: Self::DESCRIPTION,
|
||||
message: "Write a short description of your image:",
|
||||
when: when!(self.common.description.is_none()),
|
||||
on_esc: OnEsc::Terminate,
|
||||
},
|
||||
Select {
|
||||
name: Self::CI_PROVIDER,
|
||||
message: "Are you building on Github or Gitlab?",
|
||||
when: when!(!self.common.no_git && self.common.ci_provider.is_none()),
|
||||
on_esc: OnEsc::Terminate,
|
||||
choices: vec!["Github", "Gitlab", "None"],
|
||||
}
|
||||
];
|
||||
|
||||
requestty::prompt(questions).into_diagnostic()
|
||||
}
|
||||
|
||||
fn start(&self, answers: &Answers) -> Result<()> {
|
||||
self.clone_repository()?;
|
||||
self.remove_git_directory()?;
|
||||
self.template_readme(answers)?;
|
||||
self.template_ci_file(answers)?;
|
||||
self.update_recipe_file(answers)?;
|
||||
self.generate_signing_files()?;
|
||||
|
||||
if !self.common.no_git {
|
||||
self.initialize_git()?;
|
||||
self.add_files()?;
|
||||
self.initial_commit()?;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Created new BlueBuild project in {}",
|
||||
self.dir.as_ref().unwrap().display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clone_repository(&self) -> Result<()> {
|
||||
let dir = self.dir.as_ref().unwrap();
|
||||
trace!("clone_repository()");
|
||||
|
||||
let mut command = cmd!("git", "clone", "-q", TEMPLATE_REPO_URL, dir);
|
||||
trace!("{command:?}");
|
||||
|
||||
let status = command
|
||||
.status()
|
||||
.into_diagnostic()
|
||||
.context("Failed to execute git clone")?;
|
||||
|
||||
if !status.success() {
|
||||
bail!("Failed to clone template repo");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_git_directory(&self) -> Result<()> {
|
||||
trace!("remove_git_directory()");
|
||||
|
||||
let dir = self.dir.as_ref().unwrap();
|
||||
let git_path = dir.join(".git");
|
||||
|
||||
if git_path.exists() {
|
||||
fs::remove_dir_all(&git_path)
|
||||
.into_diagnostic()
|
||||
.context("Failed to remove .git directory")?;
|
||||
debug!(".git directory removed.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn initialize_git(&self) -> Result<()> {
|
||||
trace!("initialize_git()");
|
||||
|
||||
let dir = self.dir.as_ref().unwrap();
|
||||
|
||||
let mut command = cmd!("git", "init", "-q", "-b", "main", dir);
|
||||
trace!("{command:?}");
|
||||
|
||||
let status = command
|
||||
.status()
|
||||
.into_diagnostic()
|
||||
.context("Failed to execute git init")?;
|
||||
|
||||
if !status.success() {
|
||||
bail!("Error initializing git");
|
||||
}
|
||||
|
||||
debug!("Initialized git in {}", dir.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn initial_commit(&self) -> Result<()> {
|
||||
trace!("initial_commit()");
|
||||
|
||||
let dir = self.dir.as_ref().unwrap();
|
||||
|
||||
let mut command = cmd!(
|
||||
"git",
|
||||
"commit",
|
||||
"-a",
|
||||
"-m",
|
||||
"chore: Initial Commit",
|
||||
current_dir = dir,
|
||||
);
|
||||
trace!("{command:?}");
|
||||
|
||||
let status = command
|
||||
.status()
|
||||
.into_diagnostic()
|
||||
.context("Failed to run git commit")?;
|
||||
|
||||
if !status.success() {
|
||||
bail!("Failed to commit initial changes");
|
||||
}
|
||||
|
||||
debug!("Created initial commit");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_files(&self) -> Result<()> {
|
||||
trace!("add_files()");
|
||||
|
||||
let dir = self.dir.as_ref().unwrap();
|
||||
|
||||
let mut command = cmd!("git", "add", ".", current_dir = dir,);
|
||||
trace!("{command:?}");
|
||||
|
||||
let status = command
|
||||
.status()
|
||||
.into_diagnostic()
|
||||
.context("Failed to run git add")?;
|
||||
|
||||
if !status.success() {
|
||||
bail!("Failed to add files to initial commit");
|
||||
}
|
||||
|
||||
debug!("Added files for initial commit");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn template_readme(&self, answers: &Answers) -> Result<()> {
|
||||
trace!("template_readme()");
|
||||
|
||||
let readme_path = self.dir.as_ref().unwrap().join("README.md");
|
||||
|
||||
let readme = InitReadmeTemplate::builder()
|
||||
.repo_name(
|
||||
self.common
|
||||
.org_name
|
||||
.as_deref()
|
||||
.or_else(|| answers.get(Self::ORG_NAME).and_then(Answer::as_string))
|
||||
.ok_or_else(|| miette!("Failed to get organization name"))?,
|
||||
)
|
||||
.image_name(
|
||||
self.common
|
||||
.image_name
|
||||
.as_deref()
|
||||
.or_else(|| answers.get(Self::IMAGE_NAME).and_then(Answer::as_string))
|
||||
.ok_or_else(|| miette!("Failed to get image name"))?,
|
||||
)
|
||||
.registry(
|
||||
self.common
|
||||
.registry
|
||||
.as_deref()
|
||||
.or_else(|| answers.get(Self::REGISTRY).and_then(Answer::as_string))
|
||||
.ok_or_else(|| miette!("Failed to get registry"))?,
|
||||
)
|
||||
.build();
|
||||
|
||||
debug!("Templating README");
|
||||
let readme = readme.render().into_diagnostic()?;
|
||||
|
||||
debug!("Writing README to {}", readme_path.display());
|
||||
fs::write(readme_path, readme).into_diagnostic()
|
||||
}
|
||||
|
||||
fn template_ci_file(&self, answers: &Answers) -> Result<()> {
|
||||
trace!("template_ci_file()");
|
||||
|
||||
let ci_provider = self
|
||||
.common
|
||||
.ci_provider
|
||||
.ok_or("CLI Arg not set")
|
||||
.or_else(|e| {
|
||||
answers
|
||||
.get(Self::CI_PROVIDER)
|
||||
.and_then(Answer::as_list_item)
|
||||
.map(|li| &li.text)
|
||||
.ok_or_else(|| miette!("Failed to get CI Provider answer:\n{e}"))
|
||||
.and_then(CiProvider::try_from)
|
||||
})?;
|
||||
|
||||
if matches!(ci_provider, CiProvider::Github) {
|
||||
fs::remove_file(self.dir.as_ref().unwrap().join(".github/CODEOWNERS"))
|
||||
.into_diagnostic()?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
fs::remove_dir_all(self.dir.as_ref().unwrap().join(".github")).into_diagnostic()?;
|
||||
|
||||
// Never run for None
|
||||
if matches!(ci_provider, CiProvider::None) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let ci_file_path = self
|
||||
.dir
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.join(ci_provider.default_ci_file_path());
|
||||
let parent_path = ci_file_path
|
||||
.parent()
|
||||
.ok_or_else(|| miette!("Couldn't get parent directory from {ci_file_path:?}"))?;
|
||||
fs::create_dir_all(parent_path)
|
||||
.into_diagnostic()
|
||||
.with_context(|| format!("Couldn't create directory path {parent_path:?}"))?;
|
||||
|
||||
let file = &mut BufWriter::new(
|
||||
OpenOptions::new()
|
||||
.truncate(true)
|
||||
.create(true)
|
||||
.write(true)
|
||||
.open(&ci_file_path)
|
||||
.into_diagnostic()
|
||||
.with_context(|| format!("Failed to open file at {ci_file_path:?}"))?,
|
||||
);
|
||||
|
||||
let template = ci_provider.render_file()?;
|
||||
|
||||
writeln!(file, "{template}")
|
||||
.into_diagnostic()
|
||||
.with_context(|| format!("Failed to write CI file {ci_file_path:?}"))
|
||||
}
|
||||
|
||||
fn update_recipe_file(&self, answers: &Answers) -> Result<()> {
|
||||
trace!("update_recipe_file()");
|
||||
|
||||
let recipe_path = self
|
||||
.dir
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.join(RECIPE_PATH)
|
||||
.join(RECIPE_FILE);
|
||||
|
||||
debug!("Reading {recipe_path:?}");
|
||||
let file = fs::read_to_string(&recipe_path)
|
||||
.into_diagnostic()
|
||||
.with_context(|| format!("Failed to read {recipe_path:?}"))?;
|
||||
|
||||
let description = self
|
||||
.common
|
||||
.description
|
||||
.as_deref()
|
||||
.ok_or("Description arg not set")
|
||||
.or_else(|e| {
|
||||
answers
|
||||
.get(Self::DESCRIPTION)
|
||||
.and_then(Answer::as_string)
|
||||
.ok_or_else(|| miette!("Failed to get description:\n{e}"))
|
||||
})?;
|
||||
let name = self
|
||||
.common
|
||||
.image_name
|
||||
.as_deref()
|
||||
.ok_or("Description arg not set")
|
||||
.or_else(|e| {
|
||||
answers
|
||||
.get(Self::IMAGE_NAME)
|
||||
.and_then(Answer::as_string)
|
||||
.ok_or_else(|| miette!("Failed to get description:\n{e}"))
|
||||
})?;
|
||||
|
||||
let mut new_file_str = String::with_capacity(file.capacity());
|
||||
|
||||
for line in file.lines() {
|
||||
if line.starts_with("description:") {
|
||||
writeln!(&mut new_file_str, "description: {description}").into_diagnostic()?;
|
||||
} else if line.starts_with("name: ") {
|
||||
writeln!(&mut new_file_str, "name: {name}").into_diagnostic()?;
|
||||
} else {
|
||||
writeln!(&mut new_file_str, "{line}").into_diagnostic()?;
|
||||
}
|
||||
}
|
||||
|
||||
let file = &mut BufWriter::new(
|
||||
OpenOptions::new()
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&recipe_path)
|
||||
.into_diagnostic()
|
||||
.with_context(|| format!("Failed to open {recipe_path:?}"))?,
|
||||
);
|
||||
write!(file, "{new_file_str}")
|
||||
.into_diagnostic()
|
||||
.with_context(|| format!("Failed to write to file {recipe_path:?}"))
|
||||
}
|
||||
|
||||
fn generate_signing_files(&self) -> Result<()> {
|
||||
trace!("generate_signing_files()");
|
||||
|
||||
debug!("Removing old cosign files {COSIGN_PUB_PATH}");
|
||||
fs::remove_file(self.dir.as_ref().unwrap().join(COSIGN_PUB_PATH))
|
||||
.into_diagnostic()
|
||||
.with_context(|| format!("Failed to delete old public file {COSIGN_PUB_PATH}"))?;
|
||||
|
||||
Driver::generate_key_pair(
|
||||
&GenerateKeyPairOpts::builder()
|
||||
.maybe_dir(self.dir.as_ref())
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue