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::Netlify => println!(
718            "Netlify uses a personal access token with deploy permissions for the configured site."
719        ),
720        DistributionProvider::S3 => println!(
721            "S3-compatible uploads normally use AWS_PROFILE or access-key environment variables; paste a provider credential only for local vault-backed workflows."
722        ),
723        DistributionProvider::GoogleDrive => println!(
724            "Google Drive uses an OAuth access token for the target account or service account flow you manage outside the project."
725        ),
726        DistributionProvider::OneDrive => println!(
727            "OneDrive uses a Microsoft Graph OAuth access token for the target account."
728        ),
729        DistributionProvider::Dropbox => println!(
730            "Dropbox uses an OAuth access token with files.content.write/read scopes."
731        ),
732    }
733}
734
735fn edit_release_config(project_dir: &Path, tui: bool) -> Result<()> {
736    let path = project_dir.join("fission.toml");
737    fs::metadata(&path).with_context(|| format!("{} does not exist", path.display()))?;
738    if tui {
739        return fission_command_ui::run_ui(fission_command_ui::UiOptions {
740            project_dir: project_dir.to_path_buf(),
741            screenshot: None,
742            exit_after_render: false,
743            width: None,
744            height: None,
745        });
746    }
747    let editor = env::var("VISUAL")
748        .or_else(|_| env::var("EDITOR"))
749        .unwrap_or_else(|_| "vi".to_string());
750    let status = Command::new(editor)
751        .arg(&path)
752        .status()
753        .context("failed to launch editor")?;
754    if !status.success() {
755        bail!("editor exited with {status}");
756    }
757    Ok(())
758}
759
760fn set_release_field(project_dir: &Path, field: &str, value: &str, yes: bool) -> Result<()> {
761    if !yes {
762        bail!("set rewrites fission.toml; pass --yes after reviewing the field path");
763    }
764    let path = project_dir.join("fission.toml");
765    let data =
766        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
767    let mut doc = parse_toml_edit_document(&data, &path)?;
768    set_toml_edit_path(&mut doc, field, toml_edit::value(value.to_string()))?;
769    write_toml_edit_document(&path, &doc)?;
770    Ok(())
771}
772
773fn add_release(
774    project_dir: &Path,
775    version: &str,
776    build: u64,
777    from: Option<&str>,
778    yes: bool,
779) -> Result<()> {
780    if !yes {
781        bail!("add-release appends to fission.toml; pass --yes after reviewing the release id");
782    }
783    let path = project_dir.join("fission.toml");
784    let mut text =
785        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
786    let id = format!("{version}+{build}");
787    text.push_str(&format!(
788        "\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"
789    ));
790    if let Some(source) = from {
791        text.push_str(&format!("# copied_from = \"{source}\"\n"));
792    }
793    fs::write(&path, text).with_context(|| format!("failed to write {}", path.display()))?;
794    Ok(())
795}
796
797fn parse_toml_edit_document(text: &str, path: &Path) -> Result<DocumentMut> {
798    text.parse::<DocumentMut>()
799        .with_context(|| format!("failed to parse {}", path.display()))
800}
801
802fn write_toml_edit_document(path: &Path, doc: &DocumentMut) -> Result<()> {
803    fs::write(path, format!("{doc}\n"))
804        .with_context(|| format!("failed to write {}", path.display()))
805}
806
807fn set_toml_edit_path(root: &mut DocumentMut, path: &str, value: TomlEditItem) -> Result<()> {
808    let parts = path.split('.').collect::<Vec<_>>();
809    if parts.is_empty() || parts.iter().any(|part| part.trim().is_empty()) {
810        bail!("field path must be dot-separated and non-empty");
811    }
812    let mut current = root.as_table_mut();
813    for part in &parts[..parts.len() - 1] {
814        current = current
815            .entry(part)
816            .or_insert(TomlEditItem::Table(TomlEditTable::new()))
817            .as_table_mut()
818            .context("field path traversed through a non-table value")?;
819    }
820    current[parts[parts.len() - 1]] = value;
821    Ok(())
822}
823
824fn toml_edit_string_array(values: impl IntoIterator<Item = String>) -> TomlEditItem {
825    let mut array = TomlEditArray::default();
826    for value in values {
827        array.push(value);
828    }
829    TomlEditItem::Value(TomlEditValue::Array(array))
830}
831
832fn edit_release_file(
833    project_dir: &Path,
834    release: &str,
835    kind: &str,
836    locale: Option<&str>,
837) -> Result<()> {
838    let relative = match (kind, locale) {
839        ("notes", Some(locale)) => format!("release-content/metadata/{release}/notes/{locale}.md"),
840        ("notes", None) => format!("release-content/metadata/{release}/notes/en-US.md"),
841        ("review", _) => format!("release-content/metadata/{release}/review.toml"),
842        ("privacy", _) => format!("release-content/metadata/{release}/privacy.toml"),
843        ("metadata", _) | ("release", _) => {
844            format!("release-content/metadata/{release}/release.toml")
845        }
846        other => bail!("unsupported release file kind `{}`", other.0),
847    };
848    let path = project_dir.join(relative);
849    if let Some(parent) = path.parent() {
850        fs::create_dir_all(parent)?;
851    }
852    if !path.exists() {
853        fs::write(&path, "")?;
854    }
855    let editor = env::var("VISUAL")
856        .or_else(|_| env::var("EDITOR"))
857        .unwrap_or_else(|_| "vi".to_string());
858    let status = Command::new(editor).arg(&path).status()?;
859    if !status.success() {
860        bail!("editor exited with {status}");
861    }
862    Ok(())
863}
864
865fn auth_report(area: &str, provider: Option<DistributionProvider>) -> LifecycleReport {
866    let mut report = base_report(area, provider, None);
867    let providers = provider
868        .map(|provider| vec![provider])
869        .unwrap_or_else(auth_providers);
870    for provider in providers {
871        report.checks.push(provider_env_check(provider));
872    }
873    finalize_status(&mut report);
874    report
875}
876
877fn auth_setup_report(provider: Option<DistributionProvider>) -> LifecycleReport {
878    let mut report = base_report("auth.setup", provider, None);
879    let providers = provider
880        .map(|provider| vec![provider])
881        .unwrap_or_else(auth_providers);
882    for provider in providers {
883        let spec = provider_auth_spec(provider);
884        report.checks.push(LifecycleCheck {
885            id: format!(
886                "auth.{}.credential_kind",
887                provider.as_str().replace('-', "_")
888            ),
889            status: "passed".to_string(),
890            summary: format!("{} credential kind is documented", provider.as_str()),
891            details: Some(spec.kind.to_string()),
892            remediation: Vec::new(),
893        });
894        report.checks.push(LifecycleCheck {
895            id: format!("auth.{}.env", provider.as_str().replace('-', "_")),
896            status: "passed".to_string(),
897            summary: format!("{} accepted environment variables", provider.as_str()),
898            details: Some(spec.env.join(", ")),
899            remediation: Vec::new(),
900        });
901        report.checks.push(LifecycleCheck {
902            id: format!("auth.{}.setup", provider.as_str().replace('-', "_")),
903            status: "passed".to_string(),
904            summary: format!("{} setup command", provider.as_str()),
905            details: Some(spec.command.to_string()),
906            remediation: Vec::new(),
907        });
908        report.checks.push(LifecycleCheck {
909            id: format!("auth.{}.scopes", provider.as_str().replace('-', "_")),
910            status: "passed".to_string(),
911            summary: format!("{} required provider permissions", provider.as_str()),
912            details: Some(spec.permissions.to_string()),
913            remediation: Vec::new(),
914        });
915    }
916    finalize_status(&mut report);
917    report
918}
919
920fn auth_providers() -> Vec<DistributionProvider> {
921    vec![
922        DistributionProvider::GithubPages,
923        DistributionProvider::GithubReleases,
924        DistributionProvider::CloudflarePages,
925        DistributionProvider::Netlify,
926        DistributionProvider::S3,
927        DistributionProvider::GoogleDrive,
928        DistributionProvider::OneDrive,
929        DistributionProvider::Dropbox,
930        DistributionProvider::PlayStore,
931        DistributionProvider::AppStore,
932        DistributionProvider::MicrosoftStore,
933    ]
934}
935
936struct ProviderAuthSpec {
937    kind: &'static str,
938    env: &'static [&'static str],
939    command: &'static str,
940    permissions: &'static str,
941}
942
943fn provider_auth_spec(provider: DistributionProvider) -> ProviderAuthSpec {
944    match provider {
945        DistributionProvider::GithubPages => ProviderAuthSpec {
946            kind: "GitHub token or GitHub App installation token",
947            env: &["GH_TOKEN", "GITHUB_TOKEN"],
948            command: "fission auth import github-pages --from env:GH_TOKEN --yes",
949            permissions: "repository contents/workflows/pages permissions for local API operations; Actions deployment uses repository workflow permissions",
950        },
951        DistributionProvider::GithubReleases => ProviderAuthSpec {
952            kind: "Authenticated GitHub CLI session, GitHub token, or GitHub App installation token",
953            env: &["GH_TOKEN", "GITHUB_TOKEN"],
954            command: "gh auth login",
955            permissions: "repository Contents write permission to create/update releases and upload/delete release assets",
956        },
957        DistributionProvider::CloudflarePages => ProviderAuthSpec {
958            kind: "Cloudflare API token plus Wrangler login/config for uploads",
959            env: &["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID"],
960            command: "fission auth import cloudflare-pages --from env:CLOUDFLARE_API_TOKEN --yes",
961            permissions: "Pages edit/deploy permission for the target account/project",
962        },
963        DistributionProvider::Netlify => ProviderAuthSpec {
964            kind: "Netlify personal access token",
965            env: &["NETLIFY_AUTH_TOKEN"],
966            command: "fission auth import netlify --from env:NETLIFY_AUTH_TOKEN --yes",
967            permissions: "site read/deploy permissions for the configured site",
968        },
969        DistributionProvider::S3 => ProviderAuthSpec {
970            kind: "AWS/S3 profile or access key credentials",
971            env: &["AWS_PROFILE", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
972            command: "fission auth import s3 --from env:AWS_SECRET_ACCESS_KEY --yes",
973            permissions: "s3:PutObject, s3:ListBucket, and optional s3:PutObjectAcl for public artifacts",
974        },
975        DistributionProvider::GoogleDrive => ProviderAuthSpec {
976            kind: "Google OAuth access token or service-account flow managed outside fission.toml",
977            env: &["GOOGLE_DRIVE_ACCESS_TOKEN"],
978            command: "fission auth import google-drive --from env:GOOGLE_DRIVE_ACCESS_TOKEN --yes",
979            permissions: "Drive file create/update permission for the selected folder",
980        },
981        DistributionProvider::OneDrive => ProviderAuthSpec {
982            kind: "Microsoft Graph OAuth access token",
983            env: &["ONEDRIVE_ACCESS_TOKEN"],
984            command: "fission auth import onedrive --from env:ONEDRIVE_ACCESS_TOKEN --yes",
985            permissions: "Files.ReadWrite or equivalent delegated/application permission for the target drive",
986        },
987        DistributionProvider::Dropbox => ProviderAuthSpec {
988            kind: "Dropbox OAuth access token",
989            env: &["DROPBOX_ACCESS_TOKEN"],
990            command: "fission auth import dropbox --from env:DROPBOX_ACCESS_TOKEN --yes",
991            permissions: "files.content.write and files.metadata.read for the destination path",
992        },
993        DistributionProvider::PlayStore => ProviderAuthSpec {
994            kind: "Google Play Android Publisher service-account JSON or access token",
995            env: &["PLAY_STORE_SERVICE_ACCOUNT_JSON"],
996            command: "fission auth import play-store --from file:play-service-account.json --yes",
997            permissions: "Android Publisher API access to the configured package and release tracks",
998        },
999        DistributionProvider::AppStore => ProviderAuthSpec {
1000            kind: "App Store Connect API private key plus issuer/key ids",
1001            env: &[
1002                "APP_STORE_CONNECT_API_KEY",
1003                "APP_STORE_CONNECT_API_KEY_PATH",
1004                "APP_STORE_CONNECT_ISSUER_ID",
1005                "APP_STORE_CONNECT_KEY_ID",
1006            ],
1007            command: "fission auth import app-store --from file:AuthKey.p8 --yes",
1008            permissions: "App Manager or equivalent App Store Connect API role for metadata, uploads, TestFlight, and submissions",
1009        },
1010        DistributionProvider::MicrosoftStore => ProviderAuthSpec {
1011            kind: "Partner Center/Entra application secret or access token",
1012            env: &["MICROSOFT_STORE_TOKEN", "MICROSOFT_STORE_CLIENT_SECRET"],
1013            command: "fission auth import microsoft-store --from env:MICROSOFT_STORE_CLIENT_SECRET --yes",
1014            permissions: "Partner Center app submission permissions for the configured product",
1015        },
1016    }
1017}
1018
1019fn provider_env_check(provider: DistributionProvider) -> LifecycleCheck {
1020    let vars: &[&str] = match provider {
1021        DistributionProvider::GithubPages => &["GH_TOKEN", "GITHUB_TOKEN"],
1022        DistributionProvider::GithubReleases => &["GH_TOKEN", "GITHUB_TOKEN"],
1023        DistributionProvider::CloudflarePages => &["CLOUDFLARE_API_TOKEN"],
1024        DistributionProvider::Netlify => &["NETLIFY_AUTH_TOKEN"],
1025        DistributionProvider::S3 => &["AWS_PROFILE", "AWS_ACCESS_KEY_ID"],
1026        DistributionProvider::GoogleDrive => &["GOOGLE_DRIVE_ACCESS_TOKEN"],
1027        DistributionProvider::OneDrive => &["ONEDRIVE_ACCESS_TOKEN"],
1028        DistributionProvider::Dropbox => &["DROPBOX_ACCESS_TOKEN"],
1029        DistributionProvider::PlayStore => &["PLAY_STORE_SERVICE_ACCOUNT_JSON"],
1030        DistributionProvider::AppStore => &["APP_STORE_CONNECT_API_KEY"],
1031        DistributionProvider::MicrosoftStore => &["MICROSOFT_STORE_TOKEN"],
1032    };
1033    let found = vars.iter().find(|name| env::var_os(name).is_some());
1034    let vault_path = credentials::vault_record_path(provider).ok();
1035    let vault_present = vault_path.as_ref().is_some_and(|path| path.exists());
1036    LifecycleCheck {
1037        id: format!("auth.{}.credentials", provider.as_str().replace('-', "_")),
1038        status: if found.is_some() || vault_present {
1039            "passed"
1040        } else {
1041            "missing"
1042        }
1043        .to_string(),
1044        summary: format!("{} credentials are available", provider.as_str()),
1045        details: found
1046            .map(|name| format!("using {name}"))
1047            .or_else(|| vault_path.map(|path| format!("vault: {}", path.display()))),
1048        remediation: vec![format!(
1049            "Set one of {} or use `fission auth import {} --from env:<NAME> --yes` to store credentials in the encrypted local vault.",
1050            vars.join(", "),
1051            provider.as_str()
1052        )],
1053    }
1054}
1055
1056fn set_toml_path(root: &mut toml::Value, path: &str, value: toml::Value) -> Result<()> {
1057    let mut current = root;
1058    let parts = path.split('.').collect::<Vec<_>>();
1059    if parts.is_empty() || parts.iter().any(|part| part.trim().is_empty()) {
1060        bail!("field path must be dot-separated and non-empty");
1061    }
1062    for part in &parts[..parts.len() - 1] {
1063        let table = current
1064            .as_table_mut()
1065            .context("field path traversed through a non-table value")?;
1066        current = table
1067            .entry((*part).to_string())
1068            .or_insert_with(|| toml::Value::Table(Default::default()));
1069    }
1070    let table = current
1071        .as_table_mut()
1072        .context("field path parent is not a table")?;
1073    table.insert(parts[parts.len() - 1].to_string(), value);
1074    Ok(())
1075}
1076
1077fn base_report(
1078    area: &str,
1079    provider: Option<DistributionProvider>,
1080    target: Option<Target>,
1081) -> LifecycleReport {
1082    LifecycleReport {
1083        area: area.to_string(),
1084        status: "ready".to_string(),
1085        provider: provider.map(|provider| provider.as_str().to_string()),
1086        target: target.map(|target| target.as_str().to_string()),
1087        checks: Vec::new(),
1088    }
1089}
1090
1091fn path_check(id: &str, path: PathBuf, summary: &str) -> LifecycleCheck {
1092    LifecycleCheck {
1093        id: id.to_string(),
1094        status: if path.exists() { "passed" } else { "missing" }.to_string(),
1095        summary: summary.to_string(),
1096        details: Some(path.display().to_string()),
1097        remediation: vec![
1098            "Create the file/directory or update fission.toml to point at the correct path."
1099                .to_string(),
1100        ],
1101    }
1102}
1103
1104fn value_path_check(value: &toml::Value, path: &str, id: &str, summary: &str) -> LifecycleCheck {
1105    let exists = path
1106        .split('.')
1107        .try_fold(value, |current, segment| current.get(segment))
1108        .is_some();
1109    LifecycleCheck {
1110        id: id.to_string(),
1111        status: if exists { "passed" } else { "missing" }.to_string(),
1112        summary: summary.to_string(),
1113        details: Some(path.to_string()),
1114        remediation: vec![
1115            "Add the missing release configuration or use fission release-config add-release/set."
1116                .to_string(),
1117        ],
1118    }
1119}
1120
1121fn ok_check(id: &str, details: impl Into<String>) -> LifecycleCheck {
1122    LifecycleCheck {
1123        id: id.to_string(),
1124        status: "passed".to_string(),
1125        summary: id.replace('_', " "),
1126        details: Some(details.into()),
1127        remediation: Vec::new(),
1128    }
1129}
1130
1131fn warning_check(id: &str, details: String) -> LifecycleCheck {
1132    LifecycleCheck {
1133        id: id.to_string(),
1134        status: "warning".to_string(),
1135        summary: id.replace('_', " "),
1136        details: Some(details),
1137        remediation: vec![
1138            "Wire the provider backend before using this command to mutate remote state."
1139                .to_string(),
1140        ],
1141    }
1142}
1143
1144fn failed_check(id: &str, details: String) -> LifecycleCheck {
1145    LifecycleCheck {
1146        id: id.to_string(),
1147        status: "failed".to_string(),
1148        summary: id.replace('_', " "),
1149        details: Some(details),
1150        remediation: vec!["Fix the reported error and rerun the command.".to_string()],
1151    }
1152}
1153
1154fn finalize_status(report: &mut LifecycleReport) {
1155    report.status = if report
1156        .checks
1157        .iter()
1158        .any(|check| check.status == "failed" || check.status == "missing")
1159    {
1160        "blocked"
1161    } else if report.checks.iter().any(|check| check.status == "warning") {
1162        "warning"
1163    } else {
1164        "ready"
1165    }
1166    .to_string();
1167}
1168
1169fn print_report(mut report: LifecycleReport, json: bool) -> Result<()> {
1170    finalize_status(&mut report);
1171    if json {
1172        println!("{}", serde_json::to_string_pretty(&report)?);
1173    } else {
1174        println!("{}: {}", report.area, report.status);
1175        for check in &report.checks {
1176            println!("[{}] {} - {}", check.status, check.id, check.summary);
1177            if let Some(details) = &check.details {
1178                println!("  {details}");
1179            }
1180            for remediation in &check.remediation {
1181                println!("  fix: {remediation}");
1182            }
1183        }
1184    }
1185    if report.status == "blocked" {
1186        bail!("{} is blocked", report.area);
1187    }
1188    Ok(())
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193    use super::*;
1194    use std::fs;
1195
1196    #[test]
1197    fn auth_setup_documents_provider_credentials_without_secrets() {
1198        let report = auth_setup_report(Some(DistributionProvider::CloudflarePages));
1199        assert_eq!(report.status, "ready");
1200        assert!(report.checks.iter().any(|check| {
1201            check.id == "auth.cloudflare_pages.env"
1202                && check
1203                    .details
1204                    .as_deref()
1205                    .is_some_and(|details| details.contains("CLOUDFLARE_API_TOKEN"))
1206        }));
1207        assert!(report.checks.iter().any(|check| {
1208            check.id == "auth.cloudflare_pages.scopes"
1209                && check
1210                    .details
1211                    .as_deref()
1212                    .is_some_and(|details| details.contains("Pages"))
1213        }));
1214    }
1215
1216    #[test]
1217    fn release_config_set_preserves_existing_comments_and_formatting() {
1218        let dir =
1219            std::env::temp_dir().join(format!("fission-release-config-set-{}", std::process::id()));
1220        let _ = fs::remove_dir_all(&dir);
1221        fs::create_dir_all(&dir).unwrap();
1222        let path = dir.join("fission.toml");
1223        fs::write(&path, "# keep this comment\n[app]\nname = \"Todo\"\n").unwrap();
1224
1225        set_release_field(&dir, "app.version", "1.2.3", true).unwrap();
1226
1227        let text = fs::read_to_string(&path).unwrap();
1228        assert!(text.contains("# keep this comment"));
1229        assert!(text.contains("version = \"1.2.3\""));
1230        assert!(text.contains("name = \"Todo\""));
1231
1232        let _ = fs::remove_dir_all(&dir);
1233    }
1234}