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