Skip to main content

fission_command_release/
lib.rs

1use anyhow::{bail, Context, Result};
2use clap::Subcommand;
3use fission_command_core::{DistributionProvider, Target};
4use fission_command_package as publish;
5use fission_credentials as credentials;
6use serde::Serialize;
7use std::env;
8use std::fs;
9use std::io::{self, IsTerminal, Read};
10use std::path::{Path, PathBuf};
11use std::process::Command;
12use std::time::{SystemTime, UNIX_EPOCH};
13use toml_edit::{
14    Array as TomlEditArray, DocumentMut, Item as TomlEditItem, Table as TomlEditTable,
15    Value as TomlEditValue,
16};
17
18mod content;
19mod microsoft_store_ops;
20mod model;
21mod signing_ops;
22mod store_ops;
23mod workflow_ops;
24
25fn provider_secret(provider: DistributionProvider, env_names: &[&str]) -> Result<Option<String>> {
26    credentials::provider_secret(provider, env_names)
27}
28
29fn now_unix_seconds() -> u64 {
30    SystemTime::now()
31        .duration_since(UNIX_EPOCH)
32        .unwrap_or_default()
33        .as_secs()
34}
35
36#[derive(Subcommand, Debug)]
37pub enum ReleaseConfigCommand {
38    /// Open release configuration in an editor or the Fission terminal UI.
39    Edit {
40        #[arg(long, default_value = ".")]
41        project_dir: PathBuf,
42        #[arg(long)]
43        tui: bool,
44    },
45    /// Import provider metadata into local release files.
46    Import {
47        #[arg(long, value_enum)]
48        provider: DistributionProvider,
49        #[arg(long)]
50        locales: Option<String>,
51        #[arg(long)]
52        yes: bool,
53        #[arg(long, default_value = ".")]
54        project_dir: PathBuf,
55        #[arg(long)]
56        json: bool,
57    },
58    /// Diff local release metadata against provider state.
59    Diff {
60        #[arg(long, value_enum)]
61        provider: DistributionProvider,
62        #[arg(long, default_value = ".")]
63        project_dir: PathBuf,
64        #[arg(long)]
65        json: bool,
66    },
67    /// Validate fission.toml and referenced release files.
68    Validate {
69        #[arg(long, value_enum)]
70        provider: Option<DistributionProvider>,
71        #[arg(long, default_value = ".")]
72        project_dir: PathBuf,
73        #[arg(long)]
74        json: bool,
75    },
76    /// Push release metadata to a provider.
77    Push {
78        #[arg(long, value_enum)]
79        provider: DistributionProvider,
80        #[arg(long)]
81        locales: Option<String>,
82        #[arg(long)]
83        dry_run: bool,
84        #[arg(long)]
85        yes: bool,
86        #[arg(long, default_value = ".")]
87        project_dir: PathBuf,
88        #[arg(long)]
89        json: bool,
90    },
91    /// Set a scalar field in fission.toml.
92    Set {
93        field: String,
94        value: String,
95        #[arg(long, default_value = ".")]
96        project_dir: PathBuf,
97        #[arg(long)]
98        yes: bool,
99    },
100    /// Append a release entry to fission.toml.
101    AddRelease {
102        #[arg(long)]
103        version: String,
104        #[arg(long)]
105        build: u64,
106        #[arg(long)]
107        from: Option<String>,
108        #[arg(long, default_value = ".")]
109        project_dir: PathBuf,
110        #[arg(long)]
111        yes: bool,
112    },
113    /// Open or create a release metadata sidecar file.
114    EditFile {
115        #[arg(long)]
116        release: String,
117        #[arg(long)]
118        kind: String,
119        #[arg(long)]
120        locale: Option<String>,
121        #[arg(long, default_value = ".")]
122        project_dir: PathBuf,
123    },
124}
125
126#[derive(Subcommand, Debug)]
127pub enum ReleaseContentCommand {
128    /// Capture screenshots/videos from configured release scenarios.
129    Capture {
130        #[arg(long, value_enum)]
131        target: Target,
132        #[arg(long)]
133        set: String,
134        #[arg(long, default_value = ".")]
135        project_dir: PathBuf,
136        #[arg(long)]
137        json: bool,
138    },
139    /// Render store-ready screenshot/video assets from raw captures.
140    Render {
141        #[arg(long, value_enum)]
142        provider: DistributionProvider,
143        #[arg(long, default_value = ".")]
144        project_dir: PathBuf,
145        #[arg(long)]
146        json: bool,
147    },
148    /// Validate release-content assets and manifests.
149    Validate {
150        #[arg(long, value_enum)]
151        provider: Option<DistributionProvider>,
152        #[arg(long, default_value = ".")]
153        project_dir: PathBuf,
154        #[arg(long)]
155        json: bool,
156    },
157}
158
159#[derive(Subcommand, Debug)]
160pub enum BetaCommand {
161    /// Manage beta groups/flights/tracks.
162    Groups {
163        #[command(subcommand)]
164        command: BetaGroupsCommand,
165    },
166    /// Manage beta testers.
167    Testers {
168        #[command(subcommand)]
169        command: BetaTestersCommand,
170    },
171    /// Distribute an artifact to a beta track/group.
172    Distribute {
173        #[arg(long, value_enum)]
174        provider: DistributionProvider,
175        #[arg(long)]
176        artifact: PathBuf,
177        #[arg(long)]
178        group: Option<String>,
179        #[arg(long)]
180        track: Option<String>,
181        #[arg(long, default_value = ".")]
182        project_dir: PathBuf,
183        #[arg(long)]
184        dry_run: bool,
185        #[arg(long)]
186        json: bool,
187    },
188}
189
190#[derive(Subcommand, Debug)]
191pub enum BetaGroupsCommand {
192    List {
193        #[arg(long, value_enum)]
194        provider: DistributionProvider,
195        #[arg(long, default_value = ".")]
196        project_dir: PathBuf,
197        #[arg(long)]
198        json: bool,
199    },
200    Sync {
201        #[arg(long, value_enum)]
202        provider: DistributionProvider,
203        #[arg(long, default_value = "fission.toml")]
204        from: PathBuf,
205        #[arg(long, default_value = ".")]
206        project_dir: PathBuf,
207        #[arg(long)]
208        dry_run: bool,
209        #[arg(long)]
210        json: bool,
211    },
212}
213
214#[derive(Subcommand, Debug)]
215pub enum BetaTestersCommand {
216    Import {
217        #[arg(long, value_enum)]
218        provider: DistributionProvider,
219        #[arg(long)]
220        group: Option<String>,
221        #[arg(long)]
222        track: Option<String>,
223        #[arg(long)]
224        csv: PathBuf,
225        #[arg(long, default_value = ".")]
226        project_dir: PathBuf,
227        #[arg(long)]
228        dry_run: bool,
229        #[arg(long)]
230        json: bool,
231    },
232    Export {
233        #[arg(long, value_enum)]
234        provider: DistributionProvider,
235        #[arg(long)]
236        group: Option<String>,
237        #[arg(long)]
238        track: Option<String>,
239        #[arg(long)]
240        output: PathBuf,
241        #[arg(long, default_value = ".")]
242        project_dir: PathBuf,
243        #[arg(long)]
244        json: bool,
245    },
246}
247
248#[derive(Subcommand, Debug)]
249pub enum SigningCommand {
250    Status {
251        #[arg(long, value_enum)]
252        target: Target,
253        #[arg(long, default_value = ".")]
254        project_dir: PathBuf,
255        #[arg(long)]
256        json: bool,
257    },
258    Sync {
259        #[arg(long, value_enum)]
260        target: Target,
261        #[arg(long)]
262        readonly: bool,
263        #[arg(long, default_value = ".")]
264        project_dir: PathBuf,
265        #[arg(long)]
266        json: bool,
267    },
268    Import {
269        #[arg(long, value_enum)]
270        target: Target,
271        #[arg(long)]
272        keystore: Option<PathBuf>,
273        #[arg(long)]
274        alias: Option<String>,
275        #[arg(long, default_value = ".")]
276        project_dir: PathBuf,
277        #[arg(long)]
278        json: bool,
279    },
280}
281
282#[derive(Subcommand, Debug)]
283pub enum ReviewsCommand {
284    List {
285        #[arg(long, value_enum)]
286        provider: DistributionProvider,
287        #[arg(long)]
288        since: Option<String>,
289        #[arg(long, default_value = ".")]
290        project_dir: PathBuf,
291        #[arg(long)]
292        json: bool,
293    },
294    Reply {
295        #[arg(long, value_enum)]
296        provider: DistributionProvider,
297        #[arg(long)]
298        review: String,
299        #[arg(long)]
300        message_file: PathBuf,
301        #[arg(long, default_value = ".")]
302        project_dir: PathBuf,
303        #[arg(long)]
304        dry_run: bool,
305        #[arg(long)]
306        json: bool,
307    },
308}
309
310#[derive(Subcommand, Debug)]
311pub enum ReleaseWorkflowCommand {
312    /// List configured release workflows.
313    List {
314        #[arg(long, default_value = ".")]
315        project_dir: PathBuf,
316        #[arg(long)]
317        json: bool,
318    },
319    /// Run a named release workflow from fission.toml.
320    Run {
321        name: String,
322        #[arg(long, default_value = ".")]
323        project_dir: PathBuf,
324        #[arg(long)]
325        dry_run: bool,
326        #[arg(long)]
327        json: bool,
328    },
329}
330
331#[derive(Subcommand, Debug)]
332pub enum AuthCommand {
333    Setup {
334        #[arg(value_enum)]
335        provider: Option<DistributionProvider>,
336        #[arg(long)]
337        json: bool,
338    },
339    Login {
340        #[arg(value_enum)]
341        provider: DistributionProvider,
342    },
343    Status {
344        #[arg(value_enum)]
345        provider: Option<DistributionProvider>,
346        #[arg(long)]
347        json: bool,
348    },
349    Logout {
350        #[arg(value_enum)]
351        provider: DistributionProvider,
352        #[arg(long)]
353        yes: bool,
354    },
355    Import {
356        #[arg(value_enum)]
357        provider: DistributionProvider,
358        #[arg(long)]
359        from: String,
360        #[arg(long)]
361        yes: bool,
362    },
363    Rotate {
364        #[arg(value_enum)]
365        provider: DistributionProvider,
366    },
367    Audit {
368        #[arg(long)]
369        json: bool,
370    },
371}
372
373#[derive(Debug, Serialize)]
374struct LifecycleReport {
375    area: String,
376    status: String,
377    provider: Option<String>,
378    target: Option<String>,
379    checks: Vec<LifecycleCheck>,
380}
381
382#[derive(Debug, Serialize)]
383struct LifecycleCheck {
384    id: String,
385    status: String,
386    summary: String,
387    details: Option<String>,
388    remediation: Vec<String>,
389}
390
391pub fn release_config(command: ReleaseConfigCommand) -> Result<()> {
392    match command {
393        ReleaseConfigCommand::Edit { project_dir, tui } => edit_release_config(&project_dir, tui),
394        ReleaseConfigCommand::Validate {
395            provider,
396            project_dir,
397            json,
398        } => print_report(
399            model::validate_release_config_model(&project_dir, provider)?,
400            json,
401        ),
402        ReleaseConfigCommand::Set {
403            field,
404            value,
405            project_dir,
406            yes,
407        } => set_release_field(&project_dir, &field, &value, yes),
408        ReleaseConfigCommand::AddRelease {
409            version,
410            build,
411            from,
412            project_dir,
413            yes,
414        } => add_release(&project_dir, &version, build, from.as_deref(), yes),
415        ReleaseConfigCommand::EditFile {
416            release,
417            kind,
418            locale,
419            project_dir,
420        } => edit_release_file(&project_dir, &release, &kind, locale.as_deref()),
421        ReleaseConfigCommand::Import {
422            provider,
423            locales,
424            yes,
425            project_dir,
426            json,
427        } => store_ops::release_config_import(provider, locales, yes, &project_dir, json),
428        ReleaseConfigCommand::Diff {
429            provider,
430            project_dir,
431            json,
432        } => store_ops::release_config_diff(provider, &project_dir, json),
433        ReleaseConfigCommand::Push {
434            provider,
435            locales,
436            dry_run,
437            yes,
438            project_dir,
439            json,
440        } => store_ops::release_config_push(provider, locales, dry_run, yes, &project_dir, json),
441    }
442}
443
444pub fn release_content(command: ReleaseContentCommand) -> Result<()> {
445    match command {
446        ReleaseContentCommand::Validate {
447            provider,
448            project_dir,
449            json,
450        } => print_report(
451            content::validate_release_content_model(&project_dir, provider),
452            json,
453        ),
454        ReleaseContentCommand::Capture {
455            target,
456            set,
457            project_dir,
458            json,
459        } => print_report(
460            content::capture_release_content(&project_dir, target, &set)?,
461            json,
462        ),
463        ReleaseContentCommand::Render {
464            provider,
465            project_dir,
466            json,
467        } => print_report(
468            content::render_release_content(&project_dir, provider)?,
469            json,
470        ),
471    }
472}
473
474pub fn beta(command: BetaCommand) -> Result<()> {
475    match command {
476        BetaCommand::Groups { command } => match command {
477            BetaGroupsCommand::List {
478                provider,
479                project_dir,
480                json,
481            } => store_ops::beta_groups_list(provider, &project_dir, json),
482            BetaGroupsCommand::Sync {
483                provider,
484                from,
485                project_dir,
486                dry_run,
487                json,
488            } => store_ops::beta_groups_sync(provider, &from, &project_dir, dry_run, json),
489        },
490        BetaCommand::Testers { command } => match command {
491            BetaTestersCommand::Import {
492                provider,
493                group,
494                track,
495                csv,
496                project_dir,
497                dry_run,
498                json,
499            } => store_ops::beta_testers_import(
500                provider,
501                group.as_deref(),
502                track.as_deref(),
503                &csv,
504                &project_dir,
505                dry_run,
506                json,
507            ),
508            BetaTestersCommand::Export {
509                provider,
510                group,
511                track,
512                output,
513                project_dir,
514                json,
515            } => store_ops::beta_testers_export(
516                provider,
517                group.as_deref(),
518                track.as_deref(),
519                &output,
520                &project_dir,
521                json,
522            ),
523        },
524        BetaCommand::Distribute {
525            provider,
526            artifact,
527            group,
528            track,
529            project_dir,
530            dry_run,
531            json,
532        } => publish::distribute(publish::DistributeOptions {
533            project_dir,
534            provider,
535            action: publish::DistributeAction::Publish,
536            artifact: Some(artifact),
537            site: group.unwrap_or_else(|| "beta".to_string()),
538            deploy: None,
539            track,
540            dry_run,
541            yes: true,
542            json,
543        }),
544    }
545}
546
547pub fn signing(command: SigningCommand) -> Result<()> {
548    match command {
549        SigningCommand::Status {
550            target,
551            project_dir,
552            json,
553        } => signing_ops::status(&project_dir, target, json),
554        SigningCommand::Sync {
555            target,
556            readonly,
557            project_dir,
558            json,
559        } => signing_ops::sync(&project_dir, target, readonly, json),
560        SigningCommand::Import {
561            target,
562            keystore,
563            alias,
564            project_dir,
565            json,
566        } => signing_ops::import(&project_dir, target, keystore, alias, json),
567    }
568}
569
570pub fn reviews(command: ReviewsCommand) -> Result<()> {
571    match command {
572        ReviewsCommand::List {
573            provider,
574            since,
575            project_dir,
576            json,
577        } => store_ops::reviews_list(provider, since, &project_dir, json),
578        ReviewsCommand::Reply {
579            provider,
580            review,
581            message_file,
582            project_dir,
583            dry_run,
584            json,
585        } => store_ops::reviews_reply(
586            provider,
587            &review,
588            &message_file,
589            &project_dir,
590            dry_run,
591            json,
592        ),
593    }
594}
595
596pub fn release_workflow(command: ReleaseWorkflowCommand) -> Result<()> {
597    match command {
598        ReleaseWorkflowCommand::List { project_dir, json } => {
599            workflow_ops::list(&project_dir, json)
600        }
601        ReleaseWorkflowCommand::Run {
602            name,
603            project_dir,
604            dry_run,
605            json,
606        } => workflow_ops::run(&project_dir, &name, dry_run, json),
607    }
608}
609
610pub fn auth(command: AuthCommand) -> Result<()> {
611    match command {
612        AuthCommand::Status { provider, json } => {
613            print_report(auth_report("auth.status", provider), json)
614        }
615        AuthCommand::Setup { provider, json } => print_report(auth_setup_report(provider), json),
616        AuthCommand::Audit { json } => print_report(auth_report("auth.audit", None), json),
617        AuthCommand::Login { provider } => login_provider(provider),
618        AuthCommand::Logout { provider, yes } => {
619            if !yes {
620                bail!(
621                    "refusing to delete {} credentials without --yes",
622                    provider.as_str()
623                );
624            }
625            let path = credentials::vault_record_path(provider)?;
626            if path.exists() {
627                fs::remove_file(&path)?;
628                println!(
629                    "Removed {} credentials from {}",
630                    provider.as_str(),
631                    path.display()
632                );
633            } else {
634                println!("No stored {} credentials found", provider.as_str());
635            }
636            Ok(())
637        }
638        AuthCommand::Import {
639            provider,
640            from,
641            yes,
642        } => {
643            if !yes {
644                bail!(
645                    "refusing to import {} credentials without --yes",
646                    provider.as_str()
647                );
648            }
649            if let Some(path) = from.strip_prefix("file:") {
650                fs::metadata(path)
651                    .with_context(|| format!("credential file {path} does not exist"))?;
652            }
653            let secret = credentials::read_secret_source(&from)?;
654            credentials::store_provider_secret(provider, secret.as_bytes())?;
655            println!(
656                "Stored {} credentials in the encrypted Fission release vault",
657                provider.as_str()
658            );
659            Ok(())
660        }
661        AuthCommand::Rotate { provider } => {
662            credentials::rotate_provider_secret(provider)?;
663            println!("Rotated {} vault encryption record", provider.as_str());
664            Ok(())
665        }
666    }
667}
668
669fn login_provider(provider: DistributionProvider) -> Result<()> {
670    print_login_instructions(provider);
671    let secret = if io::stdin().is_terminal() {
672        println!("Paste the provider token, service-account JSON, API key contents, or a file:<path>/env:<name> source, then press Enter:");
673        let mut line = String::new();
674        io::stdin().read_line(&mut line)?;
675        line.trim().to_string()
676    } else {
677        let mut text = String::new();
678        io::stdin().read_to_string(&mut text)?;
679        text.trim().to_string()
680    };
681    if secret.is_empty() {
682        bail!("no credential was provided for {}", provider.as_str());
683    }
684    let resolved = if secret.starts_with("env:") || secret.starts_with("file:") {
685        credentials::read_secret_source(&secret)?
686    } else {
687        secret
688    };
689    credentials::store_provider_secret(provider, resolved.as_bytes())?;
690    println!(
691        "Stored {} credentials in the encrypted Fission release vault",
692        provider.as_str()
693    );
694    Ok(())
695}
696
697fn print_login_instructions(provider: DistributionProvider) {
698    match provider {
699        DistributionProvider::PlayStore => println!(
700            "Google Play uses an Android Publisher API service-account JSON file or a short-lived access token."
701        ),
702        DistributionProvider::AppStore => println!(
703            "App Store Connect uses an issuer id, key id, and .p8 API private key; paste the key contents or import APP_STORE_CONNECT_API_KEY_PATH separately."
704        ),
705        DistributionProvider::MicrosoftStore => println!(
706            "Microsoft Store uses Partner Center/Entra credentials; paste the client secret or pipe it from your secret manager."
707        ),
708        DistributionProvider::GithubPages => println!(
709            "GitHub Pages uses a GitHub token with repository Pages/workflow permissions when direct API access is needed."
710        ),
711        DistributionProvider::GithubReleases => println!(
712            "GitHub Releases uses the GitHub CLI. Run `gh auth login`, set GH_TOKEN/GITHUB_TOKEN, or import a token into the Fission vault."
713        ),
714        DistributionProvider::CloudflarePages => println!(
715            "Cloudflare Pages uses an API token with Pages project edit/deploy permissions."
716        ),
717        DistributionProvider::DockerRegistry => println!(
718            "Docker registry publishing uses the Docker CLI. Run `docker login <registry>` for the registry referenced by your image tags."
719        ),
720        DistributionProvider::Netlify => println!(
721            "Netlify uses a personal access token with deploy permissions for the configured site."
722        ),
723        DistributionProvider::S3 => println!(
724            "S3-compatible uploads normally use AWS_PROFILE or access-key environment variables; paste a provider credential only for local vault-backed workflows."
725        ),
726        DistributionProvider::GoogleDrive => println!(
727            "Google Drive uses an OAuth access token for the target account or service account flow you manage outside the project."
728        ),
729        DistributionProvider::OneDrive => println!(
730            "OneDrive uses a Microsoft Graph OAuth access token for the target account."
731        ),
732        DistributionProvider::Dropbox => println!(
733            "Dropbox uses an OAuth access token with files.content.write/read scopes."
734        ),
735    }
736}
737
738fn edit_release_config(project_dir: &Path, tui: bool) -> Result<()> {
739    let path = project_dir.join("fission.toml");
740    fs::metadata(&path).with_context(|| format!("{} does not exist", path.display()))?;
741    if tui {
742        return fission_command_ui::run_ui(fission_command_ui::UiOptions {
743            project_dir: project_dir.to_path_buf(),
744            screenshot: None,
745            exit_after_render: false,
746            width: None,
747            height: None,
748        });
749    }
750    let editor = env::var("VISUAL")
751        .or_else(|_| env::var("EDITOR"))
752        .unwrap_or_else(|_| "vi".to_string());
753    let status = Command::new(editor)
754        .arg(&path)
755        .status()
756        .context("failed to launch editor")?;
757    if !status.success() {
758        bail!("editor exited with {status}");
759    }
760    Ok(())
761}
762
763fn set_release_field(project_dir: &Path, field: &str, value: &str, yes: bool) -> Result<()> {
764    if !yes {
765        bail!("set rewrites fission.toml; pass --yes after reviewing the field path");
766    }
767    let path = project_dir.join("fission.toml");
768    let data =
769        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
770    let mut doc = parse_toml_edit_document(&data, &path)?;
771    set_toml_edit_path(&mut doc, field, toml_edit::value(value.to_string()))?;
772    write_toml_edit_document(&path, &doc)?;
773    Ok(())
774}
775
776fn add_release(
777    project_dir: &Path,
778    version: &str,
779    build: u64,
780    from: Option<&str>,
781    yes: bool,
782) -> Result<()> {
783    if !yes {
784        bail!("add-release appends to fission.toml; pass --yes after reviewing the release id");
785    }
786    let path = project_dir.join("fission.toml");
787    let mut text =
788        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
789    let id = format!("{version}+{build}");
790    text.push_str(&format!(
791        "\n[[releases]]\nid = \"{id}\"\nversion = \"{version}\"\nbuild = {build}\nstatus = \"candidate\"\nmetadata = \"release-content/metadata/{id}/release.toml\"\nrelease_notes = \"release-content/metadata/{id}/notes\"\nreview = \"release-content/metadata/{id}/review.toml\"\nprivacy = \"release-content/metadata/{id}/privacy.toml\"\n"
792    ));
793    if let Some(source) = from {
794        text.push_str(&format!("# copied_from = \"{source}\"\n"));
795    }
796    fs::write(&path, text).with_context(|| format!("failed to write {}", path.display()))?;
797    Ok(())
798}
799
800fn parse_toml_edit_document(text: &str, path: &Path) -> Result<DocumentMut> {
801    text.parse::<DocumentMut>()
802        .with_context(|| format!("failed to parse {}", path.display()))
803}
804
805fn write_toml_edit_document(path: &Path, doc: &DocumentMut) -> Result<()> {
806    fs::write(path, format!("{doc}\n"))
807        .with_context(|| format!("failed to write {}", path.display()))
808}
809
810fn set_toml_edit_path(root: &mut DocumentMut, path: &str, value: TomlEditItem) -> Result<()> {
811    let parts = path.split('.').collect::<Vec<_>>();
812    if parts.is_empty() || parts.iter().any(|part| part.trim().is_empty()) {
813        bail!("field path must be dot-separated and non-empty");
814    }
815    let mut current = root.as_table_mut();
816    for part in &parts[..parts.len() - 1] {
817        current = current
818            .entry(part)
819            .or_insert(TomlEditItem::Table(TomlEditTable::new()))
820            .as_table_mut()
821            .context("field path traversed through a non-table value")?;
822    }
823    current[parts[parts.len() - 1]] = value;
824    Ok(())
825}
826
827fn toml_edit_string_array(values: impl IntoIterator<Item = String>) -> TomlEditItem {
828    let mut array = TomlEditArray::default();
829    for value in values {
830        array.push(value);
831    }
832    TomlEditItem::Value(TomlEditValue::Array(array))
833}
834
835fn edit_release_file(
836    project_dir: &Path,
837    release: &str,
838    kind: &str,
839    locale: Option<&str>,
840) -> Result<()> {
841    let relative = match (kind, locale) {
842        ("notes", Some(locale)) => format!("release-content/metadata/{release}/notes/{locale}.md"),
843        ("notes", None) => format!("release-content/metadata/{release}/notes/en-US.md"),
844        ("review", _) => format!("release-content/metadata/{release}/review.toml"),
845        ("privacy", _) => format!("release-content/metadata/{release}/privacy.toml"),
846        ("metadata", _) | ("release", _) => {
847            format!("release-content/metadata/{release}/release.toml")
848        }
849        other => bail!("unsupported release file kind `{}`", other.0),
850    };
851    let path = project_dir.join(relative);
852    if let Some(parent) = path.parent() {
853        fs::create_dir_all(parent)?;
854    }
855    if !path.exists() {
856        fs::write(&path, "")?;
857    }
858    let editor = env::var("VISUAL")
859        .or_else(|_| env::var("EDITOR"))
860        .unwrap_or_else(|_| "vi".to_string());
861    let status = Command::new(editor).arg(&path).status()?;
862    if !status.success() {
863        bail!("editor exited with {status}");
864    }
865    Ok(())
866}
867
868fn auth_report(area: &str, provider: Option<DistributionProvider>) -> LifecycleReport {
869    let mut report = base_report(area, provider, None);
870    let providers = provider
871        .map(|provider| vec![provider])
872        .unwrap_or_else(auth_providers);
873    for provider in providers {
874        report.checks.push(provider_env_check(provider));
875    }
876    finalize_status(&mut report);
877    report
878}
879
880fn auth_setup_report(provider: Option<DistributionProvider>) -> LifecycleReport {
881    let mut report = base_report("auth.setup", provider, None);
882    let providers = provider
883        .map(|provider| vec![provider])
884        .unwrap_or_else(auth_providers);
885    for provider in providers {
886        let spec = provider_auth_spec(provider);
887        report.checks.push(LifecycleCheck {
888            id: format!(
889                "auth.{}.credential_kind",
890                provider.as_str().replace('-', "_")
891            ),
892            status: "passed".to_string(),
893            summary: format!("{} credential kind is documented", provider.as_str()),
894            details: Some(spec.kind.to_string()),
895            remediation: Vec::new(),
896        });
897        report.checks.push(LifecycleCheck {
898            id: format!("auth.{}.env", provider.as_str().replace('-', "_")),
899            status: "passed".to_string(),
900            summary: format!("{} accepted environment variables", provider.as_str()),
901            details: Some(spec.env.join(", ")),
902            remediation: Vec::new(),
903        });
904        report.checks.push(LifecycleCheck {
905            id: format!("auth.{}.setup", provider.as_str().replace('-', "_")),
906            status: "passed".to_string(),
907            summary: format!("{} setup command", provider.as_str()),
908            details: Some(spec.command.to_string()),
909            remediation: Vec::new(),
910        });
911        report.checks.push(LifecycleCheck {
912            id: format!("auth.{}.scopes", provider.as_str().replace('-', "_")),
913            status: "passed".to_string(),
914            summary: format!("{} required provider permissions", provider.as_str()),
915            details: Some(spec.permissions.to_string()),
916            remediation: Vec::new(),
917        });
918    }
919    finalize_status(&mut report);
920    report
921}
922
923fn auth_providers() -> Vec<DistributionProvider> {
924    vec![
925        DistributionProvider::GithubPages,
926        DistributionProvider::GithubReleases,
927        DistributionProvider::CloudflarePages,
928        DistributionProvider::DockerRegistry,
929        DistributionProvider::Netlify,
930        DistributionProvider::S3,
931        DistributionProvider::GoogleDrive,
932        DistributionProvider::OneDrive,
933        DistributionProvider::Dropbox,
934        DistributionProvider::PlayStore,
935        DistributionProvider::AppStore,
936        DistributionProvider::MicrosoftStore,
937    ]
938}
939
940struct ProviderAuthSpec {
941    kind: &'static str,
942    env: &'static [&'static str],
943    command: &'static str,
944    permissions: &'static str,
945}
946
947fn provider_auth_spec(provider: DistributionProvider) -> ProviderAuthSpec {
948    match provider {
949        DistributionProvider::GithubPages => ProviderAuthSpec {
950            kind: "GitHub token or GitHub App installation token",
951            env: &["GH_TOKEN", "GITHUB_TOKEN"],
952            command: "fission auth import github-pages --from env:GH_TOKEN --yes",
953            permissions: "repository contents/workflows/pages permissions for local API operations; Actions deployment uses repository workflow permissions",
954        },
955        DistributionProvider::GithubReleases => ProviderAuthSpec {
956            kind: "Authenticated GitHub CLI session, GitHub token, or GitHub App installation token",
957            env: &["GH_TOKEN", "GITHUB_TOKEN"],
958            command: "gh auth login",
959            permissions: "repository Contents write permission to create/update releases and upload/delete release assets",
960        },
961        DistributionProvider::CloudflarePages => ProviderAuthSpec {
962            kind: "Cloudflare API token plus Wrangler login/config for uploads",
963            env: &["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID"],
964            command: "fission auth import cloudflare-pages --from env:CLOUDFLARE_API_TOKEN --yes",
965            permissions: "Pages edit/deploy permission for the target account/project",
966        },
967        DistributionProvider::DockerRegistry => ProviderAuthSpec {
968            kind: "Authenticated Docker CLI session for the target registry",
969            env: &["DOCKER_CONFIG"],
970            command: "docker login <registry>",
971            permissions: "push permission for every image repository configured in [distribution.docker_registry.<profile>].tags",
972        },
973        DistributionProvider::Netlify => ProviderAuthSpec {
974            kind: "Netlify personal access token",
975            env: &["NETLIFY_AUTH_TOKEN"],
976            command: "fission auth import netlify --from env:NETLIFY_AUTH_TOKEN --yes",
977            permissions: "site read/deploy permissions for the configured site",
978        },
979        DistributionProvider::S3 => ProviderAuthSpec {
980            kind: "AWS/S3 profile or access key credentials",
981            env: &["AWS_PROFILE", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
982            command: "fission auth import s3 --from env:AWS_SECRET_ACCESS_KEY --yes",
983            permissions: "s3:PutObject, s3:ListBucket, and optional s3:PutObjectAcl for public artifacts",
984        },
985        DistributionProvider::GoogleDrive => ProviderAuthSpec {
986            kind: "Google OAuth access token or service-account flow managed outside fission.toml",
987            env: &["GOOGLE_DRIVE_ACCESS_TOKEN"],
988            command: "fission auth import google-drive --from env:GOOGLE_DRIVE_ACCESS_TOKEN --yes",
989            permissions: "Drive file create/update permission for the selected folder",
990        },
991        DistributionProvider::OneDrive => ProviderAuthSpec {
992            kind: "Microsoft Graph OAuth access token",
993            env: &["ONEDRIVE_ACCESS_TOKEN"],
994            command: "fission auth import onedrive --from env:ONEDRIVE_ACCESS_TOKEN --yes",
995            permissions: "Files.ReadWrite or equivalent delegated/application permission for the target drive",
996        },
997        DistributionProvider::Dropbox => ProviderAuthSpec {
998            kind: "Dropbox OAuth access token",
999            env: &["DROPBOX_ACCESS_TOKEN"],
1000            command: "fission auth import dropbox --from env:DROPBOX_ACCESS_TOKEN --yes",
1001            permissions: "files.content.write and files.metadata.read for the destination path",
1002        },
1003        DistributionProvider::PlayStore => ProviderAuthSpec {
1004            kind: "Google Play Android Publisher service-account JSON or access token",
1005            env: &["PLAY_STORE_SERVICE_ACCOUNT_JSON"],
1006            command: "fission auth import play-store --from file:play-service-account.json --yes",
1007            permissions: "Android Publisher API access to the configured package and release tracks",
1008        },
1009        DistributionProvider::AppStore => ProviderAuthSpec {
1010            kind: "App Store Connect API private key plus issuer/key ids",
1011            env: &[
1012                "APP_STORE_CONNECT_API_KEY",
1013                "APP_STORE_CONNECT_API_KEY_PATH",
1014                "APP_STORE_CONNECT_ISSUER_ID",
1015                "APP_STORE_CONNECT_KEY_ID",
1016            ],
1017            command: "fission auth import app-store --from file:AuthKey.p8 --yes",
1018            permissions: "App Manager or equivalent App Store Connect API role for metadata, uploads, TestFlight, and submissions",
1019        },
1020        DistributionProvider::MicrosoftStore => ProviderAuthSpec {
1021            kind: "Partner Center/Entra application secret or access token",
1022            env: &["MICROSOFT_STORE_TOKEN", "MICROSOFT_STORE_CLIENT_SECRET"],
1023            command: "fission auth import microsoft-store --from env:MICROSOFT_STORE_CLIENT_SECRET --yes",
1024            permissions: "Partner Center app submission permissions for the configured product",
1025        },
1026    }
1027}
1028
1029fn provider_env_check(provider: DistributionProvider) -> LifecycleCheck {
1030    let vars: &[&str] = match provider {
1031        DistributionProvider::GithubPages => &["GH_TOKEN", "GITHUB_TOKEN"],
1032        DistributionProvider::GithubReleases => &["GH_TOKEN", "GITHUB_TOKEN"],
1033        DistributionProvider::CloudflarePages => &["CLOUDFLARE_API_TOKEN"],
1034        DistributionProvider::DockerRegistry => &["DOCKER_CONFIG"],
1035        DistributionProvider::Netlify => &["NETLIFY_AUTH_TOKEN"],
1036        DistributionProvider::S3 => &["AWS_PROFILE", "AWS_ACCESS_KEY_ID"],
1037        DistributionProvider::GoogleDrive => &["GOOGLE_DRIVE_ACCESS_TOKEN"],
1038        DistributionProvider::OneDrive => &["ONEDRIVE_ACCESS_TOKEN"],
1039        DistributionProvider::Dropbox => &["DROPBOX_ACCESS_TOKEN"],
1040        DistributionProvider::PlayStore => &["PLAY_STORE_SERVICE_ACCOUNT_JSON"],
1041        DistributionProvider::AppStore => &["APP_STORE_CONNECT_API_KEY"],
1042        DistributionProvider::MicrosoftStore => &["MICROSOFT_STORE_TOKEN"],
1043    };
1044    let found = vars.iter().find(|name| env::var_os(name).is_some());
1045    let vault_path = credentials::vault_record_path(provider).ok();
1046    let vault_present = vault_path.as_ref().is_some_and(|path| path.exists());
1047    LifecycleCheck {
1048        id: format!("auth.{}.credentials", provider.as_str().replace('-', "_")),
1049        status: if found.is_some() || vault_present {
1050            "passed"
1051        } else {
1052            "missing"
1053        }
1054        .to_string(),
1055        summary: format!("{} credentials are available", provider.as_str()),
1056        details: found
1057            .map(|name| format!("using {name}"))
1058            .or_else(|| vault_path.map(|path| format!("vault: {}", path.display()))),
1059        remediation: vec![format!(
1060            "Set one of {} or use `fission auth import {} --from env:<NAME> --yes` to store credentials in the encrypted local vault.",
1061            vars.join(", "),
1062            provider.as_str()
1063        )],
1064    }
1065}
1066
1067fn set_toml_path(root: &mut toml::Value, path: &str, value: toml::Value) -> Result<()> {
1068    let mut current = root;
1069    let parts = path.split('.').collect::<Vec<_>>();
1070    if parts.is_empty() || parts.iter().any(|part| part.trim().is_empty()) {
1071        bail!("field path must be dot-separated and non-empty");
1072    }
1073    for part in &parts[..parts.len() - 1] {
1074        let table = current
1075            .as_table_mut()
1076            .context("field path traversed through a non-table value")?;
1077        current = table
1078            .entry((*part).to_string())
1079            .or_insert_with(|| toml::Value::Table(Default::default()));
1080    }
1081    let table = current
1082        .as_table_mut()
1083        .context("field path parent is not a table")?;
1084    table.insert(parts[parts.len() - 1].to_string(), value);
1085    Ok(())
1086}
1087
1088fn base_report(
1089    area: &str,
1090    provider: Option<DistributionProvider>,
1091    target: Option<Target>,
1092) -> LifecycleReport {
1093    LifecycleReport {
1094        area: area.to_string(),
1095        status: "ready".to_string(),
1096        provider: provider.map(|provider| provider.as_str().to_string()),
1097        target: target.map(|target| target.as_str().to_string()),
1098        checks: Vec::new(),
1099    }
1100}
1101
1102fn path_check(id: &str, path: PathBuf, summary: &str) -> LifecycleCheck {
1103    LifecycleCheck {
1104        id: id.to_string(),
1105        status: if path.exists() { "passed" } else { "missing" }.to_string(),
1106        summary: summary.to_string(),
1107        details: Some(path.display().to_string()),
1108        remediation: vec![
1109            "Create the file/directory or update fission.toml to point at the correct path."
1110                .to_string(),
1111        ],
1112    }
1113}
1114
1115fn value_path_check(value: &toml::Value, path: &str, id: &str, summary: &str) -> LifecycleCheck {
1116    let exists = path
1117        .split('.')
1118        .try_fold(value, |current, segment| current.get(segment))
1119        .is_some();
1120    LifecycleCheck {
1121        id: id.to_string(),
1122        status: if exists { "passed" } else { "missing" }.to_string(),
1123        summary: summary.to_string(),
1124        details: Some(path.to_string()),
1125        remediation: vec![
1126            "Add the missing release configuration or use fission release-config add-release/set."
1127                .to_string(),
1128        ],
1129    }
1130}
1131
1132fn ok_check(id: &str, details: impl Into<String>) -> LifecycleCheck {
1133    LifecycleCheck {
1134        id: id.to_string(),
1135        status: "passed".to_string(),
1136        summary: id.replace('_', " "),
1137        details: Some(details.into()),
1138        remediation: Vec::new(),
1139    }
1140}
1141
1142fn warning_check(id: &str, details: String) -> LifecycleCheck {
1143    LifecycleCheck {
1144        id: id.to_string(),
1145        status: "warning".to_string(),
1146        summary: id.replace('_', " "),
1147        details: Some(details),
1148        remediation: vec![
1149            "Wire the provider backend before using this command to mutate remote state."
1150                .to_string(),
1151        ],
1152    }
1153}
1154
1155fn failed_check(id: &str, details: String) -> LifecycleCheck {
1156    LifecycleCheck {
1157        id: id.to_string(),
1158        status: "failed".to_string(),
1159        summary: id.replace('_', " "),
1160        details: Some(details),
1161        remediation: vec!["Fix the reported error and rerun the command.".to_string()],
1162    }
1163}
1164
1165fn finalize_status(report: &mut LifecycleReport) {
1166    report.status = if report
1167        .checks
1168        .iter()
1169        .any(|check| check.status == "failed" || check.status == "missing")
1170    {
1171        "blocked"
1172    } else if report.checks.iter().any(|check| check.status == "warning") {
1173        "warning"
1174    } else {
1175        "ready"
1176    }
1177    .to_string();
1178}
1179
1180fn print_report(mut report: LifecycleReport, json: bool) -> Result<()> {
1181    finalize_status(&mut report);
1182    if json {
1183        println!("{}", serde_json::to_string_pretty(&report)?);
1184    } else {
1185        println!("{}: {}", report.area, report.status);
1186        for check in &report.checks {
1187            println!("[{}] {} - {}", check.status, check.id, check.summary);
1188            if let Some(details) = &check.details {
1189                println!("  {details}");
1190            }
1191            for remediation in &check.remediation {
1192                println!("  fix: {remediation}");
1193            }
1194        }
1195    }
1196    if report.status == "blocked" {
1197        bail!("{} is blocked", report.area);
1198    }
1199    Ok(())
1200}
1201
1202#[cfg(test)]
1203mod tests {
1204    use super::*;
1205    use std::fs;
1206
1207    #[test]
1208    fn auth_setup_documents_provider_credentials_without_secrets() {
1209        let report = auth_setup_report(Some(DistributionProvider::CloudflarePages));
1210        assert_eq!(report.status, "ready");
1211        assert!(report.checks.iter().any(|check| {
1212            check.id == "auth.cloudflare_pages.env"
1213                && check
1214                    .details
1215                    .as_deref()
1216                    .is_some_and(|details| details.contains("CLOUDFLARE_API_TOKEN"))
1217        }));
1218        assert!(report.checks.iter().any(|check| {
1219            check.id == "auth.cloudflare_pages.scopes"
1220                && check
1221                    .details
1222                    .as_deref()
1223                    .is_some_and(|details| details.contains("Pages"))
1224        }));
1225    }
1226
1227    #[test]
1228    fn release_config_set_preserves_existing_comments_and_formatting() {
1229        let dir =
1230            std::env::temp_dir().join(format!("fission-release-config-set-{}", std::process::id()));
1231        let _ = fs::remove_dir_all(&dir);
1232        fs::create_dir_all(&dir).unwrap();
1233        let path = dir.join("fission.toml");
1234        fs::write(&path, "# keep this comment\n[app]\nname = \"Todo\"\n").unwrap();
1235
1236        set_release_field(&dir, "app.version", "1.2.3", true).unwrap();
1237
1238        let text = fs::read_to_string(&path).unwrap();
1239        assert!(text.contains("# keep this comment"));
1240        assert!(text.contains("version = \"1.2.3\""));
1241        assert!(text.contains("name = \"Todo\""));
1242
1243        let _ = fs::remove_dir_all(&dir);
1244    }
1245}