github-app-forge 0.1.1

Declarative GitHub App lifecycle management via Manifest flow
Documentation
//! github-app-forge — declarative GitHub App lifecycle management.
//!
//! Replaces the manual UI form-filling that creating a GitHub App normally
//! requires with a typed YAML manifest and a one-click confirmation in the
//! browser. After confirmation the tool exchanges the temporary code for the
//! permanent app credentials, optionally walks the operator through one
//! installation click, and lands the credentials in a configurable backend
//! (SOPS-encrypted file, plaintext file, stdout, future: Akeyless).
//!
//! See `docs/flow.md` for the manifest-flow protocol spec.

use anyhow::Result;
use clap::{Parser, Subcommand};
use colored::Colorize;
use std::path::PathBuf;

use github_app_forge::{client, flow, manifest, sink};

#[derive(Parser)]
#[command(
    name = "github-app-forge",
    version,
    about = "Declarative GitHub App lifecycle management via Manifest flow"
)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Validate a manifest file (schema check only, no network calls)
    Validate {
        #[arg(value_name = "MANIFEST")]
        manifest: PathBuf,
    },
    /// Create a GitHub App from a manifest. Opens browser → operator confirms →
    /// tool captures credentials and writes to the configured sink.
    Create {
        #[arg(value_name = "MANIFEST")]
        manifest: PathBuf,

        /// Override the sink declared in the manifest (e.g. "stdout" for dry-run).
        #[arg(long)]
        sink: Option<String>,

        /// Skip the post-creation install step. Use when the app should only be
        /// installed later via `github-app-forge install`.
        #[arg(long)]
        no_install: bool,
    },
    /// Install an existing app on a list of repos (uses app JWT — no UI step).
    Install {
        /// App slug as shown in the GitHub URL (e.g. `pleme-arc-rio`)
        #[arg(long)]
        app: String,

        /// Org or user the app is installed under
        #[arg(long)]
        owner: String,

        /// Repos to grant the installation, comma-separated (`repo1,repo2`)
        #[arg(long, value_delimiter = ',')]
        repos: Vec<String>,

        /// Path to the credentials file written by `create` — needed for the JWT
        #[arg(long, value_name = "PATH")]
        credentials: PathBuf,
    },
    /// Rotate the App's private key. Issues a new key via the GitHub API,
    /// rewrites the configured sink, then deletes the old key. Safe to re-run
    /// after partial failure.
    Rotate {
        /// Path to the existing credentials file (plaintext or pre-decrypted).
        #[arg(long, value_name = "PATH")]
        credentials: PathBuf,

        /// Sink to write the rotated credentials to. Same shape as `create`'s
        /// sink config, but provided as a one-line "kind=value" pair on the CLI:
        ///   --sink stdout
        ///   --sink file:./creds.yaml
        ///   --sink sops:./secret.yaml,name=arc-github-app-secret,namespace=actions-runner-controller
        #[arg(long)]
        sink: String,
    },
    /// Emit a HelmRelease values stub for `pleme-arc-controller` consuming
    /// these credentials. Reads the credentials file and prints YAML to stdout.
    Values {
        #[arg(long, value_name = "PATH")]
        credentials: PathBuf,
    },
}

#[tokio::main]
async fn main() -> Result<()> {
    let cli = Cli::parse();
    match cli.command {
        Command::Validate { manifest } => {
            let m = manifest::load(&manifest)?;
            println!("{} {}", "OK".green(), format_args!("manifest valid: {}", m.name));
            // Also dump the JSON GitHub would receive — useful for debugging
            // schema rejections. The redirect_url is a placeholder.
            let json = m.manifest_json("http://localhost:0/cb")?;
            let pretty = serde_json::to_string_pretty(&serde_json::from_str::<serde_json::Value>(&json)?)?;
            println!("--- GitHub-bound JSON (preview) ---\n{pretty}");
            Ok(())
        }
        Command::Create { manifest, sink, no_install } => {
            let m = manifest::load(&manifest)?;
            let sink_override = sink.as_deref();
            flow::run(&m, sink_override, no_install).await
        }
        Command::Install { app, owner, repos, credentials } => {
            let creds = sink::load_credentials(&credentials)?;
            client::install_on_repos(&creds, &owner, &app, &repos).await
        }
        Command::Rotate { credentials, sink } => {
            let creds = sink::load_credentials(&credentials)?;
            let sink_cfg = sink::parse_cli_sink(&sink)?;
            client::rotate_private_key(&creds, &sink_cfg).await
        }
        Command::Values { credentials } => {
            let creds = sink::load_credentials(&credentials)?;
            sink::emit_helmrelease_values(&creds);
            Ok(())
        }
    }
}