Skip to main content

auths_cli/commands/
init.rs

1//! One-command guided setup wizard for Auths.
2//!
3//! Applies Gather → Execute → Display for each profile, delegating all
4//! business logic to `auths-sdk`.
5
6use anyhow::{Context, Result, anyhow};
7use clap::{Args, ValueEnum};
8use dialoguer::{Confirm, Input, Select};
9use std::io::IsTerminal;
10use std::path::Path;
11use std::sync::Arc;
12
13use auths_core::PrefilledPassphraseProvider;
14use auths_core::signing::{PassphraseProvider, StorageSigner};
15use auths_core::storage::keychain::{KeyAlias, KeyStorage, get_platform_keychain};
16use auths_id::storage::attestation::AttestationSource;
17use auths_id::storage::identity::IdentityStorage;
18use auths_infra_http::HttpRegistryClient;
19use auths_sdk::ports::git_config::GitConfigProvider;
20use auths_sdk::registration::DEFAULT_REGISTRY_URL;
21use auths_sdk::result::InitializeResult;
22use auths_sdk::setup::initialize;
23use auths_sdk::types::{
24    CiEnvironment, CiIdentityConfig, CreateDeveloperIdentityConfig, GitSigningScope,
25    IdentityConfig, IdentityConflictPolicy,
26};
27use auths_storage::git::{
28    GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage,
29};
30
31use crate::adapters::git_config::SystemGitConfigProvider;
32use crate::factories::storage::build_auths_context;
33
34use super::init_helpers::{
35    check_git_version, detect_ci_environment, get_auths_repo_path, offer_shell_completions,
36    select_agent_capabilities, write_allowed_signers,
37};
38use crate::config::CliConfig;
39use crate::ux::format::Output;
40
41const DEFAULT_KEY_ALIAS: &str = "main";
42
43/// Setup profile for identity initialization.
44#[derive(Debug, Clone, Copy, ValueEnum)]
45pub enum InitProfile {
46    /// Full local development setup with keychain, identity, device linking, and git signing
47    Developer,
48    /// Ephemeral identity for CI/CD pipelines
49    Ci,
50    /// Scoped identity for AI agents with capability restrictions
51    Agent,
52}
53
54impl std::fmt::Display for InitProfile {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            InitProfile::Developer => write!(f, "developer"),
58            InitProfile::Ci => write!(f, "ci"),
59            InitProfile::Agent => write!(f, "agent"),
60        }
61    }
62}
63
64/// Initializes Auths identity with a guided setup wizard.
65///
66/// Supports three profiles (developer, ci, agent) covering the most common
67/// deployment scenarios. Interactive by default; pass `--non-interactive` for
68/// scripted or CI use.
69///
70/// Usage:
71/// ```ignore
72/// // auths init
73/// // auths init --profile developer --non-interactive
74/// // auths init --profile ci --non-interactive
75/// ```
76#[derive(Args, Debug, Clone)]
77#[command(
78    name = "init",
79    about = "Set up your cryptographic identity and Git signing"
80)]
81pub struct InitCommand {
82    /// Skip interactive prompts and use sensible defaults
83    #[clap(long)]
84    pub non_interactive: bool,
85
86    /// Preset profile: developer, ci, or agent
87    #[clap(long, value_enum)]
88    pub profile: Option<InitProfile>,
89
90    /// Key alias for the identity key (default: main)
91    #[clap(long, default_value = DEFAULT_KEY_ALIAS)]
92    pub key_alias: String,
93
94    /// Force overwrite if identity already exists
95    #[clap(long)]
96    pub force: bool,
97
98    /// Preview agent configuration without creating files or identities
99    #[clap(long)]
100    pub dry_run: bool,
101
102    /// Registry URL for automatic identity registration
103    #[clap(long, default_value = DEFAULT_REGISTRY_URL)]
104    pub registry: String,
105
106    /// Skip automatic registry registration during setup
107    #[clap(long)]
108    pub skip_registration: bool,
109}
110
111// ── Main Dispatcher ──────────────────────────────────────────────────────
112
113/// Handle the `init` command with Gather → Execute → Display pattern.
114///
115/// Args:
116/// * `cmd`: Parsed [`InitCommand`] from the CLI.
117/// * `ctx`: CLI configuration with passphrase provider and repo path.
118///
119/// Usage:
120/// ```ignore
121/// handle_init(cmd, &ctx)?;
122/// ```
123pub fn handle_init(cmd: InitCommand, ctx: &CliConfig) -> Result<()> {
124    let out = Output::new();
125    let interactive = !cmd.non_interactive && std::io::stdin().is_terminal();
126
127    let profile = match cmd.profile {
128        Some(p) => p,
129        None if !interactive => {
130            out.println("No profile specified in non-interactive mode, defaulting to developer.");
131            InitProfile::Developer
132        }
133        None => prompt_profile(&out)?,
134    };
135
136    out.print_heading(&format!("Auths Setup ({})", profile));
137    out.println("=".repeat(40).as_str());
138    out.newline();
139
140    match profile {
141        InitProfile::Developer => {
142            // GATHER
143            let (keychain, mut config) = gather_developer_config(interactive, &out, &cmd)?;
144            let registry_path = get_auths_repo_path()?;
145
146            // Bootstrap: ensure registry dir and git repo exist (CLI responsibility)
147            ensure_registry_dir(&registry_path)?;
148
149            // Resolve auths-sign path and git config provider at presentation boundary
150            let sign_binary_path = which::which("auths-sign").ok();
151            if let Some(ref path) = sign_binary_path {
152                config.sign_binary_path = Some(path.clone());
153            }
154            let git_config_provider: Option<Box<dyn GitConfigProvider>> =
155                match &config.git_signing_scope {
156                    GitSigningScope::Skip => None,
157                    GitSigningScope::Global => Some(Box::new(SystemGitConfigProvider::global())),
158                    GitSigningScope::Local { repo_path } => {
159                        Some(Box::new(SystemGitConfigProvider::local(repo_path.clone())))
160                    }
161                };
162
163            // Build SDK context with injected backends
164            let sdk_ctx = build_auths_context(&registry_path, &ctx.env_config, None)?;
165
166            // EXECUTE
167            let keychain_arc: Arc<dyn KeyStorage + Send + Sync> = Arc::from(keychain);
168            let signer = StorageSigner::new(Arc::clone(&keychain_arc));
169            let result = initialize(
170                IdentityConfig::Developer(config),
171                &sdk_ctx,
172                keychain_arc,
173                &signer,
174                ctx.passphrase_provider.as_ref(),
175                git_config_provider.as_deref(),
176            )?;
177            let result = match result {
178                InitializeResult::Developer(r) => r,
179                _ => unreachable!(),
180            };
181
182            out.print_success(&format!("Identity ready: {}", &result.identity_did));
183            out.print_success(&format!("Device linked: {}", result.device_did.as_str()));
184            out.newline();
185
186            // Post-execute: platform verification (interactive CLI concern)
187            let proof_url = if interactive && !cmd.skip_registration {
188                out.print_info("Claim your Developer Passport");
189                out.newline();
190                match prompt_platform_verification(
191                    &out,
192                    Arc::clone(&ctx.passphrase_provider),
193                    &ctx.env_config,
194                )? {
195                    Some((url, _username)) => {
196                        out.print_success(&format!("Proof anchored: {}", url));
197                        Some(url)
198                    }
199                    None => {
200                        out.println("  Continuing as anonymous identity");
201                        None
202                    }
203                }
204            } else {
205                None
206            };
207            out.newline();
208
209            offer_shell_completions(interactive, &out)?;
210            write_allowed_signers(&result.key_alias, &out)?;
211
212            // Post-execute: registration (best-effort, via SDK)
213            let registered = submit_registration(
214                &get_auths_repo_path()?,
215                &cmd.registry,
216                proof_url,
217                cmd.skip_registration,
218                &out,
219            );
220
221            // DISPLAY
222            display_developer_result(&out, &result, registered.as_deref());
223        }
224        InitProfile::Ci => {
225            // GATHER
226            let (ci_env, config, keychain, passphrase_str) = gather_ci_config(&out)?;
227            let registry_path = config.registry_path.clone();
228
229            // Bootstrap: ensure registry dir and git repo exist (CLI responsibility)
230            ensure_registry_dir(&registry_path)?;
231
232            // Build SDK context with injected backends
233            let sdk_ctx = build_auths_context(&registry_path, &ctx.env_config, None)?;
234
235            // EXECUTE
236            let keychain_arc: Arc<dyn KeyStorage + Send + Sync> = Arc::from(keychain);
237            let signer = StorageSigner::new(Arc::clone(&keychain_arc));
238            let provider = PrefilledPassphraseProvider::new(&passphrase_str);
239            let result = initialize(
240                IdentityConfig::Ci(config),
241                &sdk_ctx,
242                keychain_arc,
243                &signer,
244                &provider,
245                None,
246            )?;
247            let result = match result {
248                InitializeResult::Ci(r) => r,
249                _ => unreachable!(),
250            };
251
252            // DISPLAY
253            display_ci_result(&out, &result, ci_env.as_deref());
254        }
255        InitProfile::Agent => {
256            // GATHER
257            let (keychain, config) = gather_agent_config(interactive, &out, &cmd)?;
258            let registry_path = config.registry_path.clone();
259
260            if config.dry_run {
261                display_agent_dry_run(&out, &config);
262            } else {
263                // Bootstrap: ensure registry dir and git repo exist (CLI responsibility)
264                ensure_registry_dir(&registry_path)?;
265
266                // Build SDK context with injected backends
267                let sdk_ctx = build_auths_context(&registry_path, &ctx.env_config, None)?;
268
269                // EXECUTE
270                let keychain_arc: Arc<dyn KeyStorage + Send + Sync> = Arc::from(keychain);
271                let signer = StorageSigner::new(Arc::clone(&keychain_arc));
272                let result = initialize(
273                    IdentityConfig::Agent(config),
274                    &sdk_ctx,
275                    keychain_arc,
276                    &signer,
277                    ctx.passphrase_provider.as_ref(),
278                    None,
279                )?;
280                let result = match result {
281                    InitializeResult::Agent(r) => r,
282                    _ => unreachable!(),
283                };
284
285                // DISPLAY
286                display_agent_result(&out, &result);
287            }
288        }
289    }
290
291    Ok(())
292}
293
294// ── Gather Functions ─────────────────────────────────────────────────────
295
296fn gather_developer_config(
297    interactive: bool,
298    out: &Output,
299    cmd: &InitCommand,
300) -> Result<(
301    Box<dyn KeyStorage + Send + Sync>,
302    CreateDeveloperIdentityConfig,
303)> {
304    out.print_info("Checking prerequisites...");
305    let keychain = check_keychain_access(out)?;
306    check_git_version(out)?;
307    out.print_success("Prerequisites OK");
308    out.newline();
309
310    let registry_path = get_auths_repo_path()?;
311    let alias = prompt_for_alias(interactive, cmd)?;
312    let conflict_policy = prompt_for_conflict_policy(interactive, cmd, &registry_path, out)?;
313    let git_scope = prompt_for_git_scope(interactive)?;
314
315    let mut builder = CreateDeveloperIdentityConfig::builder(KeyAlias::new_unchecked(&alias))
316        .with_conflict_policy(conflict_policy)
317        .with_git_signing_scope(git_scope);
318
319    if !cmd.skip_registration {
320        builder = builder.with_registration(&cmd.registry);
321    }
322
323    Ok((keychain, builder.build()))
324}
325
326#[allow(clippy::type_complexity)]
327fn gather_ci_config(
328    out: &Output,
329) -> Result<(
330    Option<String>,
331    CiIdentityConfig,
332    Box<dyn KeyStorage + Send + Sync>,
333    String,
334)> {
335    out.print_info("Detecting CI environment...");
336    let ci_env = detect_ci_environment();
337    if let Some(ref vendor) = ci_env {
338        out.print_success(&format!("Detected: {}", vendor));
339    } else {
340        out.print_warn("No CI environment detected, proceeding anyway");
341    }
342    out.newline();
343
344    let registry_path = std::env::current_dir()?.join(".auths-ci");
345    let passphrase =
346        std::env::var("AUTHS_PASSPHRASE").unwrap_or_else(|_| "Ci-ephemeral-pass1!".to_string());
347
348    // SAFETY: Single-threaded CLI context; env var read immediately by get_platform_keychain.
349    unsafe {
350        std::env::set_var("AUTHS_KEYCHAIN_BACKEND", "memory");
351    }
352    let keychain =
353        get_platform_keychain().map_err(|e| anyhow!("Failed to get memory keychain: {}", e))?;
354
355    out.println(&format!("  Using keychain: {}", keychain.backend_name()));
356
357    let config = CiIdentityConfig {
358        ci_environment: map_ci_environment(&ci_env),
359        registry_path,
360    };
361
362    Ok((ci_env, config, keychain, passphrase))
363}
364
365fn gather_agent_config(
366    interactive: bool,
367    out: &Output,
368    cmd: &InitCommand,
369) -> Result<(
370    Box<dyn KeyStorage + Send + Sync>,
371    auths_sdk::types::CreateAgentIdentityConfig,
372)> {
373    out.print_info("Setting capability scope...");
374    let capabilities = select_agent_capabilities(interactive, out)?;
375    let cap_names: Vec<String> = capabilities.iter().map(|c| c.name.clone()).collect();
376    out.print_success(&format!("Capabilities: {}", cap_names.join(", ")));
377    out.newline();
378
379    let parsed_caps: Vec<auths_verifier::Capability> = cap_names
380        .into_iter()
381        .filter_map(|s| auths_verifier::Capability::parse(&s).ok())
382        .collect();
383
384    let keychain = check_keychain_access(out)?;
385    let registry_path = get_auths_repo_path()?;
386
387    let config = auths_sdk::types::CreateAgentIdentityConfig::builder(
388        KeyAlias::new_unchecked("agent"),
389        &registry_path,
390    )
391    .with_capabilities(parsed_caps)
392    .with_expiry(365 * 24 * 3600)
393    .dry_run(cmd.dry_run)
394    .build();
395
396    Ok((keychain, config))
397}
398
399// ── Prompt Functions ─────────────────────────────────────────────────────
400
401fn prompt_profile(out: &Output) -> Result<InitProfile> {
402    out.print_heading("Select Setup Profile");
403    out.newline();
404
405    let items = [
406        "Developer - Full local setup with keychain and git signing",
407        "CI - Ephemeral identity for CI/CD pipelines",
408        "Agent - Scoped identity for AI agents",
409    ];
410
411    let selection = Select::new()
412        .with_prompt("Choose your setup profile")
413        .items(items)
414        .default(0)
415        .interact()?;
416
417    Ok(match selection {
418        0 => InitProfile::Developer,
419        1 => InitProfile::Ci,
420        _ => InitProfile::Agent,
421    })
422}
423
424fn prompt_for_alias(interactive: bool, cmd: &InitCommand) -> Result<String> {
425    if interactive {
426        Ok(Input::new()
427            .with_prompt("Key alias")
428            .default(cmd.key_alias.clone())
429            .interact_text()?)
430    } else {
431        Ok(cmd.key_alias.clone())
432    }
433}
434
435fn prompt_for_conflict_policy(
436    interactive: bool,
437    cmd: &InitCommand,
438    registry_path: &Path,
439    out: &Output,
440) -> Result<IdentityConflictPolicy> {
441    if cmd.force {
442        return Ok(IdentityConflictPolicy::ForceNew);
443    }
444
445    let identity_storage = RegistryIdentityStorage::new(registry_path.to_path_buf());
446    if let Ok(existing) = identity_storage.load_identity() {
447        out.println(&format!(
448            "  Found existing identity: {}",
449            out.info(existing.controller_did.as_str())
450        ));
451
452        if !interactive {
453            return Ok(IdentityConflictPolicy::ReuseExisting);
454        }
455
456        let use_existing = Confirm::new()
457            .with_prompt("Use existing identity?")
458            .default(true)
459            .interact()?;
460        if use_existing {
461            return Ok(IdentityConflictPolicy::ReuseExisting);
462        }
463
464        let overwrite = Confirm::new()
465            .with_prompt("Create new identity? This will NOT delete the old one.")
466            .default(false)
467            .interact()?;
468        if !overwrite {
469            return Err(anyhow!("Setup cancelled by user"));
470        }
471    }
472
473    Ok(IdentityConflictPolicy::ForceNew)
474}
475
476fn prompt_for_git_scope(interactive: bool) -> Result<GitSigningScope> {
477    if !interactive {
478        return Ok(GitSigningScope::Global);
479    }
480
481    let choice = Select::new()
482        .with_prompt("Configure git signing for")
483        .items([
484            "This repository only (--local)",
485            "All repositories (--global)",
486        ])
487        .default(1)
488        .interact()?;
489
490    if choice == 0 {
491        let repo_path = std::env::current_dir()?;
492        Ok(GitSigningScope::Local { repo_path })
493    } else {
494        Ok(GitSigningScope::Global)
495    }
496}
497
498fn prompt_platform_verification(
499    out: &Output,
500    passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
501    env_config: &auths_core::config::EnvironmentConfig,
502) -> Result<Option<(String, String)>> {
503    let items = [
504        "GitHub — link your GitHub identity (recommended)",
505        "GitLab — coming soon",
506        "Anonymous — skip platform verification",
507    ];
508
509    let selection = Select::new()
510        .with_prompt("Claim your Developer Passport")
511        .items(items)
512        .default(0)
513        .interact()?;
514
515    match selection {
516        0 => {
517            use std::time::Duration;
518
519            use auths_core::ports::platform::OAuthDeviceFlowProvider;
520            use auths_core::ports::platform::PlatformProofPublisher;
521            use auths_infra_http::{HttpGistPublisher, HttpGitHubOAuthProvider};
522            use auths_sdk::workflows::platform::create_signed_platform_claim;
523
524            const GITHUB_CLIENT_ID: &str = "Ov23lio2CiTHBjM2uIL4";
525            let client_id = std::env::var("AUTHS_GITHUB_CLIENT_ID")
526                .unwrap_or_else(|_| GITHUB_CLIENT_ID.to_string());
527
528            let auths_dir = get_auths_repo_path()?;
529            let ctx = build_auths_context(&auths_dir, env_config, Some(passphrase_provider))?;
530
531            let oauth = HttpGitHubOAuthProvider::new();
532            let publisher = HttpGistPublisher::new();
533
534            let rt = tokio::runtime::Runtime::new().context("failed to create async runtime")?;
535
536            let device_code = rt
537                .block_on(oauth.request_device_code(&client_id, "read:user gist"))
538                .map_err(|e| anyhow::anyhow!("{e}"))?;
539
540            out.println(&format!(
541                "  Enter this code: {}",
542                out.bold(&device_code.user_code)
543            ));
544            out.println(&format!(
545                "  At: {}",
546                out.info(&device_code.verification_uri)
547            ));
548            if let Err(e) = open::that(&device_code.verification_uri) {
549                out.print_warn(&format!("Could not open browser automatically: {e}"));
550                out.println("  Please open the URL above manually.");
551            } else {
552                out.println("  Browser opened — waiting for authorization...");
553            }
554
555            let expires_in = Duration::from_secs(device_code.expires_in);
556            let interval = Duration::from_secs(device_code.interval);
557
558            let access_token = rt
559                .block_on(oauth.poll_for_token(
560                    &client_id,
561                    &device_code.device_code,
562                    interval,
563                    expires_in,
564                ))
565                .map_err(|e| anyhow::anyhow!("{e}"))?;
566
567            let profile = rt
568                .block_on(oauth.fetch_user_profile(&access_token))
569                .map_err(|e| anyhow::anyhow!("{e}"))?;
570
571            out.print_success(&format!("Authenticated as @{}", profile.login));
572
573            let controller_did =
574                auths_sdk::pairing::load_controller_did(ctx.identity_storage.as_ref())
575                    .map_err(|e| anyhow::anyhow!("{e}"))?;
576
577            let identity_did =
578                auths_core::storage::keychain::IdentityDID::new_unchecked(controller_did.clone());
579            let aliases = ctx
580                .key_storage
581                .list_aliases_for_identity(&identity_did)
582                .context("failed to list key aliases")?;
583            let key_alias = aliases
584                .into_iter()
585                .find(|a| !a.contains("--next-"))
586                .ok_or_else(|| anyhow::anyhow!("no signing key found for {controller_did}"))?;
587
588            let claim_json = create_signed_platform_claim(
589                "github",
590                &profile.login,
591                &controller_did,
592                &key_alias,
593                &ctx,
594                chrono::Utc::now(),
595            )
596            .map_err(|e| anyhow::anyhow!("{e}"))?;
597
598            let proof_url = rt
599                .block_on(publisher.publish_proof(&access_token, &claim_json))
600                .map_err(|e| anyhow::anyhow!("{e}"))?;
601
602            out.print_success(&format!("Published proof Gist: {}", out.info(&proof_url)));
603
604            Ok(Some((proof_url, profile.login)))
605        }
606        1 => {
607            out.print_warn("GitLab integration is coming soon. Continuing as anonymous.");
608            Ok(None)
609        }
610        _ => Ok(None),
611    }
612}
613
614// ── Display Functions ────────────────────────────────────────────────────
615
616fn display_developer_result(
617    out: &Output,
618    result: &auths_sdk::result::DeveloperIdentityResult,
619    registered: Option<&str>,
620) {
621    out.newline();
622    out.print_heading("You are on the Web of Trust!");
623    out.newline();
624    out.println(&format!("  Identity: {}", out.info(&result.identity_did)));
625    out.println(&format!("  Key alias: {}", out.info(&result.key_alias)));
626    if let Some(registry) = registered {
627        out.println(&format!("  Registry: {}", out.info(registry)));
628    }
629    let did_prefix = result
630        .identity_did
631        .strip_prefix("did:keri:")
632        .unwrap_or(&result.identity_did);
633    out.println(&format!(
634        "  Profile: {}",
635        out.info(&format!("https://auths.dev/registry/identity/{did_prefix}"))
636    ));
637    out.newline();
638    out.print_success("Your next commit will be signed with Auths!");
639    out.println("  Run `auths status` to check your identity");
640}
641
642fn display_ci_result(
643    out: &Output,
644    result: &auths_sdk::result::CiIdentityResult,
645    ci_vendor: Option<&str>,
646) {
647    out.print_success(&format!("CI identity: {}", &result.identity_did));
648    out.newline();
649
650    out.print_heading("Add these to your CI secrets:");
651    out.println("─".repeat(50).as_str());
652    for line in &result.env_block {
653        println!("{}", line);
654    }
655    out.println("─".repeat(50).as_str());
656    out.newline();
657
658    if let Some(vendor) = ci_vendor {
659        write_ci_vendor_hints(out, vendor);
660    }
661
662    out.print_success("CI setup complete!");
663    out.println("  Add the environment variables to your CI secrets");
664    out.println("  Commits made in CI will be signed with the ephemeral identity");
665}
666
667fn display_agent_result(out: &Output, result: &auths_sdk::result::AgentIdentityResult) {
668    out.print_heading("Agent Setup Complete!");
669    out.newline();
670    out.println(&format!("  Identity: {}", out.info(&result.agent_did)));
671    let cap_display: Vec<String> = result.capabilities.iter().map(|c| c.to_string()).collect();
672    out.println(&format!("  Capabilities: {}", cap_display.join(", ")));
673    out.newline();
674    out.print_success("Agent is ready to sign commits!");
675    out.println("  Start the agent: auths agent start");
676    out.println("  Check status: auths agent status");
677}
678
679fn display_agent_dry_run(out: &Output, config: &auths_sdk::types::CreateAgentIdentityConfig) {
680    out.print_heading("Dry Run — no files or identities will be created");
681    out.newline();
682    out.println(&format!("  Storage: {}", config.registry_path.display()));
683    out.println(&format!("  Capabilities: {:?}", config.capabilities));
684    if let Some(secs) = config.expires_in_secs {
685        out.println(&format!("  Expires in: {}s", secs));
686    }
687    out.newline();
688    out.print_info("TOML config that would be generated:");
689    let provisioning_config = auths_id::agent_identity::AgentProvisioningConfig {
690        agent_name: config.alias.to_string(),
691        capabilities: config.capabilities.iter().map(|c| c.to_string()).collect(),
692        expires_in_secs: config.expires_in_secs,
693        delegated_by: None,
694        storage_mode: auths_id::agent_identity::AgentStorageMode::Persistent { repo_path: None },
695    };
696    out.println(&auths_id::agent_identity::format_agent_toml(
697        "did:keri:E<pending>",
698        "agent-key",
699        &provisioning_config,
700    ));
701}
702
703// ── Post-Execute Helpers ─────────────────────────────────────────────────
704
705fn submit_registration(
706    repo_path: &Path,
707    registry_url: &str,
708    proof_url: Option<String>,
709    skip: bool,
710    out: &Output,
711) -> Option<String> {
712    if skip {
713        out.print_info("Registration skipped (--skip-registration)");
714        return None;
715    }
716
717    out.print_info("Publishing identity to Auths Registry...");
718    let rt = match tokio::runtime::Runtime::new() {
719        Ok(rt) => rt,
720        Err(e) => {
721            out.print_warn(&format!("Could not create async runtime: {e}"));
722            return None;
723        }
724    };
725
726    let backend = Arc::new(GitRegistryBackend::from_config_unchecked(
727        RegistryConfig::single_tenant(repo_path),
728    ));
729    let identity_storage: Arc<dyn IdentityStorage + Send + Sync> =
730        Arc::new(RegistryIdentityStorage::new(repo_path.to_path_buf()));
731    let attestation_store = Arc::new(RegistryAttestationStorage::new(repo_path));
732    let attestation_source: Arc<dyn AttestationSource + Send + Sync> = attestation_store;
733
734    let registry_client = HttpRegistryClient::new();
735
736    match rt.block_on(auths_sdk::registration::register_identity(
737        identity_storage,
738        backend,
739        attestation_source,
740        registry_url,
741        proof_url,
742        &registry_client,
743    )) {
744        Ok(outcome) => {
745            out.print_success(&format!("Identity registered at {}", outcome.registry));
746            Some(outcome.registry)
747        }
748        Err(auths_sdk::error::RegistrationError::AlreadyRegistered) => {
749            out.print_success("Already registered on this registry");
750            Some(registry_url.to_string())
751        }
752        Err(auths_sdk::error::RegistrationError::QuotaExceeded) => {
753            out.print_warn("Registration quota exceeded. Run `auths id register` to retry later.");
754            None
755        }
756        Err(auths_sdk::error::RegistrationError::NetworkError(_)) => {
757            out.print_warn(
758                "Could not reach the registry (offline?). Your local setup is complete.",
759            );
760            out.println("  Run `auths id register` when you're back online.");
761            None
762        }
763        Err(auths_sdk::error::RegistrationError::LocalDataError(e)) => {
764            out.print_warn(&format!("Could not prepare registration payload: {e}"));
765            out.println("  Run `auths id register` to retry.");
766            None
767        }
768        Err(e) => {
769            out.print_warn(&format!("Registration failed: {e}"));
770            None
771        }
772    }
773}
774
775fn ensure_registry_dir(registry_path: &Path) -> Result<()> {
776    if !registry_path.exists() {
777        std::fs::create_dir_all(registry_path).with_context(|| {
778            format!(
779                "Failed to create registry directory: {}",
780                registry_path.display()
781            )
782        })?;
783    }
784    if git2::Repository::open(registry_path).is_err() {
785        git2::Repository::init(registry_path).with_context(|| {
786            format!(
787                "Failed to initialize git repository: {}",
788                registry_path.display()
789            )
790        })?;
791    }
792    auths_sdk::setup::install_registry_hook(registry_path);
793    Ok(())
794}
795
796fn check_keychain_access(out: &Output) -> Result<Box<dyn KeyStorage + Send + Sync>> {
797    match get_platform_keychain() {
798        Ok(keychain) => {
799            out.println(&format!(
800                "  Keychain: {} (accessible)",
801                out.success(keychain.backend_name())
802            ));
803            Ok(keychain)
804        }
805        Err(e) => Err(anyhow!("Keychain not accessible: {}", e)),
806    }
807}
808
809fn map_ci_environment(detected: &Option<String>) -> CiEnvironment {
810    match detected.as_deref() {
811        Some("GitHub Actions") => CiEnvironment::GitHubActions,
812        Some("GitLab CI") => CiEnvironment::GitLabCi,
813        Some(name) => CiEnvironment::Custom {
814            name: name.to_string(),
815        },
816        None => CiEnvironment::Unknown,
817    }
818}
819
820fn write_ci_vendor_hints(out: &Output, vendor: &str) {
821    out.newline();
822    out.print_heading(&format!("Hints for {}", vendor));
823
824    match vendor {
825        "GitHub Actions" => {
826            out.println("Add to your workflow (.github/workflows/*.yml):");
827            out.newline();
828            out.println("  env:");
829            out.println("    AUTHS_KEYCHAIN_BACKEND: memory");
830            out.newline();
831            out.println("  steps:");
832            out.println("    - uses: actions/checkout@v4");
833            out.println("    - run: auths init --profile ci --non-interactive");
834        }
835        "GitLab CI" => {
836            out.println("Add to .gitlab-ci.yml:");
837            out.newline();
838            out.println("  variables:");
839            out.println("    AUTHS_KEYCHAIN_BACKEND: memory");
840            out.newline();
841            out.println("  before_script:");
842            out.println("    - auths init --profile ci --non-interactive");
843        }
844        _ => {
845            out.println("Set these environment variables in your CI:");
846            out.println("  AUTHS_KEYCHAIN_BACKEND=memory");
847        }
848    }
849    out.newline();
850}
851
852// ── ExecutableCommand ────────────────────────────────────────────────────
853
854impl crate::commands::executable::ExecutableCommand for InitCommand {
855    fn execute(&self, ctx: &CliConfig) -> anyhow::Result<()> {
856        handle_init(self.clone(), ctx)
857    }
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863
864    #[test]
865    fn test_setup_profile_display() {
866        assert_eq!(InitProfile::Developer.to_string(), "developer");
867        assert_eq!(InitProfile::Ci.to_string(), "ci");
868        assert_eq!(InitProfile::Agent.to_string(), "agent");
869    }
870
871    #[test]
872    fn test_setup_command_defaults() {
873        let cmd = InitCommand {
874            non_interactive: false,
875            profile: None,
876            key_alias: DEFAULT_KEY_ALIAS.to_string(),
877            force: false,
878            dry_run: false,
879            registry: DEFAULT_REGISTRY_URL.to_string(),
880            skip_registration: false,
881        };
882        assert!(!cmd.non_interactive);
883        assert!(cmd.profile.is_none());
884        assert_eq!(cmd.key_alias, "main");
885        assert!(!cmd.force);
886        assert!(!cmd.dry_run);
887        assert_eq!(cmd.registry, "https://auths-registry.fly.dev");
888        assert!(!cmd.skip_registration);
889    }
890
891    #[test]
892    fn test_setup_command_with_profile() {
893        let cmd = InitCommand {
894            non_interactive: true,
895            profile: Some(InitProfile::Ci),
896            key_alias: "ci-key".to_string(),
897            force: true,
898            dry_run: false,
899            registry: DEFAULT_REGISTRY_URL.to_string(),
900            skip_registration: false,
901        };
902        assert!(cmd.non_interactive);
903        assert!(matches!(cmd.profile, Some(InitProfile::Ci)));
904        assert_eq!(cmd.key_alias, "ci-key");
905        assert!(cmd.force);
906    }
907
908    #[test]
909    fn test_map_ci_environment() {
910        assert!(matches!(
911            map_ci_environment(&Some("GitHub Actions".into())),
912            CiEnvironment::GitHubActions
913        ));
914        assert!(matches!(
915            map_ci_environment(&Some("GitLab CI".into())),
916            CiEnvironment::GitLabCi
917        ));
918        assert!(matches!(map_ci_environment(&None), CiEnvironment::Unknown));
919    }
920}