mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
feat(install): support non-interactive github app setup
This commit is contained in:
parent
f4bae6e9bc
commit
f04e59f83f
3 changed files with 326 additions and 31 deletions
|
|
@ -1297,6 +1297,9 @@ pub(crate) struct InstallNonInteractiveArgs {
|
|||
#[arg(long, hide = true)]
|
||||
pub(crate) github_strategy: Option<InstallGitHubStrategyArg>,
|
||||
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) github_owner: Option<String>,
|
||||
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) github_username: Option<String>,
|
||||
|
||||
|
|
|
|||
|
|
@ -267,6 +267,7 @@ impl InstallNonInteractiveArgs {
|
|||
|| self.llm_api_key_stdin
|
||||
|| self.llm_api_key_env.is_some()
|
||||
|| self.github_strategy.is_some()
|
||||
|| self.github_owner.is_some()
|
||||
|| self.github_username.is_some()
|
||||
|| self.overwrite_settings
|
||||
|| self.keep_existing_settings
|
||||
|
|
@ -282,6 +283,8 @@ impl InstallNonInteractiveArgs {
|
|||
Some("--llm-api-key-env")
|
||||
} else if self.github_strategy.is_some() {
|
||||
Some("--github-strategy")
|
||||
} else if self.github_owner.is_some() {
|
||||
Some("--github-owner")
|
||||
} else if self.github_username.is_some() {
|
||||
Some("--github-username")
|
||||
} else if self.overwrite_settings {
|
||||
|
|
@ -312,11 +315,18 @@ Non-interactive usage:
|
|||
--github-strategy gh_cli \
|
||||
--github-username brynary
|
||||
|
||||
fabro install --non-interactive \
|
||||
--llm-provider anthropic \
|
||||
--llm-api-key-env ANTHROPIC_API_KEY \
|
||||
--github-strategy app \
|
||||
--github-owner personal
|
||||
|
||||
Hidden non-interactive flags:
|
||||
--llm-provider <PROVIDER>
|
||||
--llm-api-key-stdin
|
||||
--llm-api-key-env <ENV_VAR>
|
||||
--github-strategy <gh_cli|app>
|
||||
--github-owner <personal|org:SLUG>
|
||||
--github-username <USERNAME>
|
||||
--overwrite-settings
|
||||
--keep-existing-settings
|
||||
|
|
@ -324,7 +334,7 @@ Hidden non-interactive flags:
|
|||
|
||||
Notes:
|
||||
- Only one API-key-based LLM provider is supported in non-interactive mode.
|
||||
- GitHub CLI is supported in non-interactive mode; GitHub App setup is not."#
|
||||
- GitHub App setup prints a local handoff URL and waits for the browser callback."#
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -352,6 +362,48 @@ enum ServerConfigSelection {
|
|||
Write,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum GitHubAppHandoffMode {
|
||||
Interactive,
|
||||
Manual,
|
||||
}
|
||||
|
||||
fn install_json_event_line(value: &serde_json::Value) -> Result<String> {
|
||||
serde_json::to_string(&value).context("failed to serialize install JSON event")
|
||||
}
|
||||
|
||||
fn emit_install_json_event(value: &serde_json::Value) -> Result<()> {
|
||||
let line = install_json_event_line(value)?;
|
||||
#[allow(clippy::print_stdout)]
|
||||
{
|
||||
println!("{line}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_complete_event() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"event": "install_complete",
|
||||
"status": "success",
|
||||
})
|
||||
}
|
||||
|
||||
fn install_error_event(message: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"event": "install_error",
|
||||
"status": "error",
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
fn install_github_app_handoff_event(url: &str, owner: &GitHubAppOwner) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"event": "github_app_handoff",
|
||||
"url": url,
|
||||
"owner": owner.scripted_value(),
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
trait InstallInputSource {
|
||||
async fn choose_graphviz_install(&self, dot_missing: bool) -> Result<bool>;
|
||||
|
|
@ -557,9 +609,21 @@ impl NonInteractiveInstallInputSource {
|
|||
);
|
||||
|
||||
match self.args.github_strategy {
|
||||
Some(InstallGitHubStrategyArg::GhCli) => {}
|
||||
Some(InstallGitHubStrategyArg::GhCli) => {
|
||||
anyhow::ensure!(
|
||||
self.args.github_owner.is_none(),
|
||||
"--github-owner is only supported with --github-strategy app"
|
||||
);
|
||||
}
|
||||
Some(InstallGitHubStrategyArg::App) => {
|
||||
bail!("GitHub App setup is not supported with --non-interactive")
|
||||
let owner = self.args.github_owner.as_deref().context(
|
||||
"non-interactive install requires --github-owner for --github-strategy app",
|
||||
)?;
|
||||
GitHubAppOwner::parse_scripted(owner)?;
|
||||
anyhow::ensure!(
|
||||
self.args.github_username.is_none(),
|
||||
"--github-username is only supported with --github-strategy gh_cli"
|
||||
);
|
||||
}
|
||||
None => bail!("non-interactive install requires --github-strategy"),
|
||||
}
|
||||
|
|
@ -575,10 +639,15 @@ impl NonInteractiveInstallInputSource {
|
|||
}
|
||||
}
|
||||
|
||||
anyhow::ensure!(
|
||||
self.args.github_username.is_some(),
|
||||
"non-interactive install requires --github-username"
|
||||
);
|
||||
if matches!(
|
||||
self.args.github_strategy,
|
||||
Some(InstallGitHubStrategyArg::GhCli)
|
||||
) {
|
||||
anyhow::ensure!(
|
||||
self.args.github_username.is_some(),
|
||||
"non-interactive install requires --github-username for --github-strategy gh_cli"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -627,9 +696,14 @@ impl InstallInputSource for NonInteractiveInstallInputSource {
|
|||
) -> Result<GitHubInstallSelection> {
|
||||
match self.args.github_strategy {
|
||||
Some(InstallGitHubStrategyArg::GhCli) => Ok(GitHubInstallSelection::GhCli),
|
||||
Some(InstallGitHubStrategyArg::App) => {
|
||||
bail!("GitHub App setup is not supported with --non-interactive")
|
||||
}
|
||||
Some(InstallGitHubStrategyArg::App) => Ok(GitHubInstallSelection::App {
|
||||
owner: GitHubAppOwner::parse_scripted(
|
||||
self.args.github_owner.as_deref().context(
|
||||
"non-interactive install requires --github-owner for --github-strategy app",
|
||||
)?,
|
||||
)?,
|
||||
username: best_effort_github_username().await,
|
||||
}),
|
||||
None => bail!("non-interactive install requires --github-strategy"),
|
||||
}
|
||||
}
|
||||
|
|
@ -657,13 +731,28 @@ impl InstallInputSource for NonInteractiveInstallInputSource {
|
|||
// GitHub App owner selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum GitHubAppOwner {
|
||||
Personal,
|
||||
Organization(String),
|
||||
}
|
||||
|
||||
impl GitHubAppOwner {
|
||||
fn parse_scripted(value: &str) -> Result<Self> {
|
||||
if value == "personal" {
|
||||
return Ok(Self::Personal);
|
||||
}
|
||||
|
||||
let Some(org) = value.strip_prefix("org:") else {
|
||||
bail!("--github-owner must be 'personal' or 'org:<slug>'");
|
||||
};
|
||||
anyhow::ensure!(
|
||||
!org.trim().is_empty(),
|
||||
"--github-owner organization slug cannot be empty"
|
||||
);
|
||||
Ok(Self::Organization(org.to_string()))
|
||||
}
|
||||
|
||||
fn manifest_form_action(&self) -> String {
|
||||
match self {
|
||||
Self::Personal => "https://github.com/settings/apps/new".to_string(),
|
||||
|
|
@ -673,6 +762,13 @@ impl GitHubAppOwner {
|
|||
}
|
||||
}
|
||||
|
||||
fn scripted_value(&self) -> String {
|
||||
match self {
|
||||
Self::Personal => "personal".to_string(),
|
||||
Self::Organization(org) => format!("org:{org}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn app_name(&self, username: Option<&str>) -> String {
|
||||
match self {
|
||||
Self::Organization(org) => format!("{org}-fabro"),
|
||||
|
|
@ -693,6 +789,11 @@ impl GitHubAppOwner {
|
|||
}
|
||||
}
|
||||
|
||||
async fn best_effort_github_username() -> Option<String> {
|
||||
let gh = GhCli::detect().await?;
|
||||
gh.authenticated_user().await
|
||||
}
|
||||
|
||||
/// Ask the user where to create the GitHub App.
|
||||
///
|
||||
/// Uses the `gh` CLI to discover the username and admin orgs. If `gh` is
|
||||
|
|
@ -785,6 +886,8 @@ async fn setup_github_app(
|
|||
web_url: &str,
|
||||
owner: &GitHubAppOwner,
|
||||
username: Option<&str>,
|
||||
handoff_mode: GitHubAppHandoffMode,
|
||||
json_output: bool,
|
||||
printer: Printer,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
let app_name = owner.app_name(username);
|
||||
|
|
@ -875,19 +978,34 @@ async fn setup_github_app(
|
|||
.ok();
|
||||
});
|
||||
|
||||
// Open browser
|
||||
let url = format!("http://127.0.0.1:{port}/");
|
||||
fabro_util::printerr!(printer, " {}", s.dim.apply_to("Opening browser..."));
|
||||
if let Err(e) = open::that(&url) {
|
||||
fabro_util::printerr!(printer, " Could not open browser automatically: {e}");
|
||||
fabro_util::printerr!(printer, " Please open this URL manually: {url}");
|
||||
if json_output {
|
||||
emit_install_json_event(&install_github_app_handoff_event(&url, owner))?;
|
||||
}
|
||||
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" {}",
|
||||
s.dim.apply_to("Waiting for GitHub... (Ctrl+C to cancel)")
|
||||
);
|
||||
match handoff_mode {
|
||||
GitHubAppHandoffMode::Interactive => {
|
||||
fabro_util::printerr!(printer, " {}", s.dim.apply_to("Opening browser..."));
|
||||
if let Err(e) = open::that(&url) {
|
||||
fabro_util::printerr!(printer, " Could not open browser automatically: {e}");
|
||||
fabro_util::printerr!(printer, " Please open this URL manually: {url}");
|
||||
}
|
||||
}
|
||||
GitHubAppHandoffMode::Manual => {
|
||||
if !json_output {
|
||||
fabro_util::printerr!(printer, " Open this URL manually to continue setup:");
|
||||
fabro_util::printerr!(printer, " {url}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !json_output {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" {}",
|
||||
s.dim.apply_to("Waiting for GitHub... (Ctrl+C to cancel)")
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for the code
|
||||
let code = code_rx
|
||||
|
|
@ -1076,7 +1194,29 @@ pub(crate) async fn run_install(
|
|||
globals: &GlobalArgs,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
globals.require_no_json()?;
|
||||
if globals.json && !args.non_interactive {
|
||||
bail!("--json is only supported for install with --non-interactive");
|
||||
}
|
||||
|
||||
let result = Box::pin(run_install_inner(args, globals, printer)).await;
|
||||
if globals.json {
|
||||
let emit_result = match &result {
|
||||
Ok(()) => emit_install_json_event(&install_complete_event()),
|
||||
Err(err) => emit_install_json_event(&install_error_event(&err.to_string())),
|
||||
};
|
||||
if result.is_ok() {
|
||||
emit_result?;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn run_install_inner(
|
||||
args: &InstallArgs,
|
||||
globals: &GlobalArgs,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let web_url = &args.web_url;
|
||||
let s = Styles::detect_stderr();
|
||||
let emoji = console::Emoji("⚒️ ", "");
|
||||
|
|
@ -1218,6 +1358,12 @@ pub(crate) async fn run_install(
|
|||
web_url,
|
||||
&owner,
|
||||
username.as_deref(),
|
||||
if args.non_interactive {
|
||||
GitHubAppHandoffMode::Manual
|
||||
} else {
|
||||
GitHubAppHandoffMode::Interactive
|
||||
},
|
||||
globals.json,
|
||||
printer,
|
||||
)
|
||||
.await?;
|
||||
|
|
@ -1653,6 +1799,31 @@ client_id = "client-id"
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_app_owner_parses_personal_scripted_value() {
|
||||
assert_eq!(
|
||||
GitHubAppOwner::parse_scripted("personal").unwrap(),
|
||||
GitHubAppOwner::Personal
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_app_owner_parses_org_scripted_value() {
|
||||
assert_eq!(
|
||||
GitHubAppOwner::parse_scripted("org:acme").unwrap(),
|
||||
GitHubAppOwner::Organization("acme".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_app_owner_rejects_invalid_scripted_value() {
|
||||
let err = GitHubAppOwner::parse_scripted("acme").unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("--github-owner must be 'personal' or 'org:<slug>'")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_app_owner_app_name_with_org() {
|
||||
let owner = GitHubAppOwner::Organization("acme-corp".to_string());
|
||||
|
|
@ -1673,6 +1844,25 @@ client_id = "client-id"
|
|||
assert_eq!(name.len(), 12); // "Fabro-" (6) + 6 hex chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_json_event_line_serializes_handoff_event() {
|
||||
let event =
|
||||
install_github_app_handoff_event("http://127.0.0.1:1234/", &GitHubAppOwner::Personal);
|
||||
let line = install_json_event_line(&event).unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(value["event"], "github_app_handoff");
|
||||
assert_eq!(value["url"], "http://127.0.0.1:1234/");
|
||||
assert_eq!(value["owner"], "personal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_error_event_contains_message() {
|
||||
let value = install_error_event("boom");
|
||||
assert_eq!(value["event"], "install_error");
|
||||
assert_eq!(value["status"], "error");
|
||||
assert_eq!(value["message"], "boom");
|
||||
}
|
||||
|
||||
// -- GitHub App manifest --
|
||||
|
||||
#[test]
|
||||
|
|
@ -1852,10 +2042,9 @@ client_id = "client-id"
|
|||
};
|
||||
|
||||
let err = source.validate(false).unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("non-interactive install requires --github-username")
|
||||
);
|
||||
assert!(err.to_string().contains(
|
||||
"non-interactive install requires --github-username for --github-strategy gh_cli"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1873,13 +2062,33 @@ client_id = "client-id"
|
|||
source.validate(true).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_interactive_source_rejects_github_app_setup() {
|
||||
#[test]
|
||||
fn non_interactive_source_rejects_missing_github_owner_for_app() {
|
||||
let source = NonInteractiveInstallInputSource {
|
||||
args: InstallNonInteractiveArgs {
|
||||
llm_provider: Some(Provider::Anthropic),
|
||||
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
|
||||
github_strategy: Some(InstallGitHubStrategyArg::App),
|
||||
..InstallNonInteractiveArgs::default()
|
||||
},
|
||||
};
|
||||
|
||||
let err = source.validate(false).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains(
|
||||
"non-interactive install requires --github-owner for --github-strategy app"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_source_rejects_github_owner_for_gh_cli() {
|
||||
let source = NonInteractiveInstallInputSource {
|
||||
args: InstallNonInteractiveArgs {
|
||||
llm_provider: Some(Provider::Anthropic),
|
||||
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
|
||||
github_strategy: Some(InstallGitHubStrategyArg::GhCli),
|
||||
github_owner: Some("personal".to_string()),
|
||||
github_username: Some("brynary".to_string()),
|
||||
..InstallNonInteractiveArgs::default()
|
||||
},
|
||||
|
|
@ -1888,10 +2097,45 @@ client_id = "client-id"
|
|||
let err = source.validate(false).unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("GitHub App setup is not supported with --non-interactive")
|
||||
.contains("--github-owner is only supported with --github-strategy app")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_source_rejects_github_username_for_app() {
|
||||
let source = NonInteractiveInstallInputSource {
|
||||
args: InstallNonInteractiveArgs {
|
||||
llm_provider: Some(Provider::Anthropic),
|
||||
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
|
||||
github_strategy: Some(InstallGitHubStrategyArg::App),
|
||||
github_owner: Some("personal".to_string()),
|
||||
github_username: Some("brynary".to_string()),
|
||||
..InstallNonInteractiveArgs::default()
|
||||
},
|
||||
};
|
||||
|
||||
let err = source.validate(false).unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("--github-username is only supported with --github-strategy gh_cli")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_source_allows_github_app_setup() {
|
||||
let source = NonInteractiveInstallInputSource {
|
||||
args: InstallNonInteractiveArgs {
|
||||
llm_provider: Some(Provider::Anthropic),
|
||||
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
|
||||
github_strategy: Some(InstallGitHubStrategyArg::App),
|
||||
github_owner: Some("personal".to_string()),
|
||||
..InstallNonInteractiveArgs::default()
|
||||
},
|
||||
};
|
||||
|
||||
source.validate(false).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_interactive_source_requires_config_choice_when_settings_exist() {
|
||||
let source = NonInteractiveInstallInputSource {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ fn help() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn install_rejects_json() {
|
||||
fn install_json_requires_non_interactive() {
|
||||
let context = test_context!();
|
||||
let output = context
|
||||
.command()
|
||||
|
|
@ -38,7 +38,55 @@ fn install_rejects_json() {
|
|||
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(stderr.contains("--json is not supported for this command"));
|
||||
assert!(stderr.contains("--json is only supported for install with --non-interactive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_json_non_interactive_is_not_rejected_as_unsupported() {
|
||||
let context = test_context!();
|
||||
let output = context
|
||||
.command()
|
||||
.args(["--json", "install", "--non-interactive"])
|
||||
.output()
|
||||
.expect("command should run");
|
||||
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(stderr.contains("Non-interactive install requires additional flags"));
|
||||
assert!(!stderr.contains("--json is not supported for this command"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_json_non_interactive_allows_github_app_strategy() {
|
||||
let context = test_context!();
|
||||
let output = context
|
||||
.command()
|
||||
.env_remove("MISSING_ANTHROPIC_API_KEY")
|
||||
.args([
|
||||
"--json",
|
||||
"install",
|
||||
"--non-interactive",
|
||||
"--llm-provider",
|
||||
"anthropic",
|
||||
"--llm-api-key-env",
|
||||
"MISSING_ANTHROPIC_API_KEY",
|
||||
"--github-strategy",
|
||||
"app",
|
||||
"--github-owner",
|
||||
"personal",
|
||||
])
|
||||
.output()
|
||||
.expect("command should run");
|
||||
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(!stderr.contains("GitHub App setup is not supported with --non-interactive"));
|
||||
assert!(!stderr.contains("requires --github-username"));
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(stdout.trim()).expect("install JSON error should parse");
|
||||
assert_eq!(value["event"], "install_error");
|
||||
assert_eq!(value["status"], "error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue