Skip to main content

fission_command_package/
publish_shell.rs

1#[path = "publish_shell_env.rs"]
2mod env_file;
3
4use super::{
5    readiness_distribute, readiness_package, report_status, CheckStatus, PackageFormat,
6    ReadinessCheck, ARTIFACT_MANIFEST,
7};
8use anyhow::{bail, Context, Result};
9use env_file::{
10    set_private_dir_permissions, set_private_file_permissions, unquote_env_value, upsert_env,
11    write_env_file,
12};
13use fission_command_core::{read_project_config, DistributionProvider, Target};
14use rpassword::read_password;
15use serde::Deserialize;
16use std::collections::VecDeque;
17use std::env;
18use std::ffi::OsStr;
19use std::fs;
20use std::io::{self, BufRead, BufReader, IsTerminal, Read, Write};
21use std::path::{Path, PathBuf};
22use std::process::{Command, Stdio};
23use std::sync::mpsc;
24use std::thread;
25use std::time::{Duration, Instant};
26
27const GUIDED_LOG_LINES: usize = 10;
28
29#[derive(Clone, Debug)]
30pub struct PublishShellOptions {
31    pub project_dir: PathBuf,
32    pub provider: DistributionProvider,
33    pub target: Option<Target>,
34    pub format: Option<PackageFormat>,
35    pub artifact: Option<PathBuf>,
36    pub site: String,
37    pub deploy: Option<String>,
38    pub track: Option<String>,
39    pub locales: Vec<String>,
40    pub overwrite_remote: bool,
41    pub dry_run: bool,
42    pub yes: bool,
43    pub json: bool,
44    pub app: bool,
45}
46
47#[derive(Clone, Debug)]
48pub struct PublishShellWorkflowRequest {
49    pub project_dir: PathBuf,
50    pub provider: DistributionProvider,
51    pub target: Option<Target>,
52    pub format: Option<PackageFormat>,
53    pub artifact: Option<PathBuf>,
54    pub site: String,
55    pub deploy: Option<String>,
56    pub track: Option<String>,
57    pub locales: Vec<String>,
58    pub overwrite_remote: bool,
59    pub dry_run: bool,
60    pub yes: bool,
61    pub json: bool,
62}
63
64#[derive(Clone, Copy)]
65pub struct PublishShellHooks {
66    pub release_plan: fn(PublishShellWorkflowRequest) -> Result<PublishShellReleasePlan>,
67    pub release_checks: fn(PublishShellWorkflowRequest) -> Result<Vec<ReadinessCheck>>,
68    pub publish: fn(PublishShellWorkflowRequest) -> Result<String>,
69    pub skip_requirement: fn(PublishShellWorkflowRequest, String) -> Result<String>,
70    pub bump_build: fn(PublishShellWorkflowRequest) -> Result<String>,
71}
72
73#[derive(Clone, Debug)]
74pub struct PublishFlowSnapshot {
75    pub project_dir: PathBuf,
76    pub app_name: String,
77    pub app_id: String,
78    pub provider: DistributionProvider,
79    pub target: Target,
80    pub format: PackageFormat,
81    pub site: String,
82    pub track: Option<String>,
83    pub locales: Vec<String>,
84    pub workspace: PathBuf,
85    pub artifact_manifest: PathBuf,
86    pub package_checks: Vec<ReadinessCheck>,
87    pub distribution_checks: Vec<ReadinessCheck>,
88    pub release_checks: Vec<ReadinessCheck>,
89}
90
91#[derive(Clone, Debug)]
92pub struct PublishShellReleasePlan {
93    pub status: String,
94    pub provider: String,
95    pub target: Option<String>,
96    pub format: Option<String>,
97    pub track: Option<String>,
98    pub locales: Vec<String>,
99    pub steps: Vec<PublishShellReleaseStep>,
100    pub capabilities: Vec<PublishShellProviderCapability>,
101    pub requirements: Vec<ReadinessCheck>,
102}
103
104#[derive(Clone, Debug)]
105pub struct PublishShellReleaseStep {
106    pub id: String,
107    pub title: String,
108    pub status: String,
109}
110
111#[derive(Clone, Debug)]
112pub struct PublishShellProviderCapability {
113    pub id: String,
114    pub status: String,
115    pub summary: String,
116}
117
118#[derive(Debug, Deserialize)]
119struct ReleasePlanJson {
120    #[serde(default)]
121    status: String,
122    #[serde(default)]
123    provider: String,
124    #[serde(default)]
125    target: Option<String>,
126    #[serde(default)]
127    format: Option<String>,
128    #[serde(default)]
129    track: Option<String>,
130    #[serde(default)]
131    locales: Vec<String>,
132    #[serde(default)]
133    steps: Vec<ReleaseStepJson>,
134    #[serde(default)]
135    capabilities: Vec<ProviderCapabilityJson>,
136    #[serde(default)]
137    requirements: Vec<ReleaseRequirementJson>,
138}
139
140#[derive(Debug, Deserialize)]
141struct ReleaseStepJson {
142    id: String,
143    title: String,
144    status: String,
145}
146
147#[derive(Debug, Deserialize)]
148struct ProviderCapabilityJson {
149    id: String,
150    status: String,
151    summary: String,
152}
153
154#[derive(Debug, Deserialize)]
155struct ReleaseRequirementJson {
156    id: String,
157    level: String,
158    status: String,
159    summary: String,
160    details: Option<String>,
161    #[serde(default)]
162    remediation: Vec<String>,
163}
164
165#[derive(Clone, Debug)]
166pub(crate) struct PublishContext {
167    pub(crate) project_dir: PathBuf,
168    pub(crate) app_name: String,
169    pub(crate) app_id: String,
170    pub(crate) provider: DistributionProvider,
171    pub(crate) target: Target,
172    pub(crate) format: PackageFormat,
173    pub(crate) site: String,
174    pub(crate) track: Option<String>,
175    pub(crate) locales: Vec<String>,
176    pub(crate) overwrite_remote: bool,
177    pub(crate) workspace: PathBuf,
178    pub(crate) artifact: Option<PathBuf>,
179}
180
181pub fn run_publish_shell(options: PublishShellOptions) -> Result<()> {
182    run_publish_shell_with_hooks(options, current_fission_hooks())
183}
184
185pub fn run_publish_shell_with_hooks(
186    options: PublishShellOptions,
187    hooks: PublishShellHooks,
188) -> Result<()> {
189    if options.json {
190        bail!("interactive publish does not support --json; use fission readiness/package/distribute for machine-readable CI flows");
191    }
192
193    let mut cx = PublishContext::load(&options)?;
194    ensure_local_workspace_files(&cx)?;
195    load_local_env(&cx.workspace.join("release.env"))?;
196
197    if options.app {
198        println!("Fission publish app mode");
199        println!("Windowed publish will use the same local workspace and checks as terminal mode.");
200        println!("Opening terminal flow in this build so the release can be completed safely.\n");
201    }
202
203    if options.dry_run {
204        render_dashboard(&cx, hooks)?;
205        dry_run_publish(&cx, &options, hooks, false)?;
206        return Ok(());
207    }
208
209    if options.yes {
210        render_dashboard(&cx, hooks)?;
211        package_artifact(&mut cx, hooks, false, false)?;
212        dry_run_publish(&cx, &options, hooks, false)?;
213        publish_artifact(&cx, &options, hooks)?;
214        return Ok(());
215    }
216
217    if !io::stdin().is_terminal() {
218        render_dashboard(&cx, hooks)?;
219        bail!("interactive publish requires a terminal; pass --dry-run/--yes or use package/distribute directly in CI");
220    }
221
222    loop {
223        render_dashboard(&cx, hooks)?;
224        println!();
225        println!("Actions");
226        println!("  1  Create/update local workspace");
227        println!("  2  Configure provider credentials and signing");
228        println!("  3  Select track/locales/artifact options");
229        println!("  4  Review release metadata, screenshots, and skips");
230        println!("  5  Build/package artifact");
231        println!("  6  Dry-run publish");
232        println!("  7  Publish");
233        println!("  8  Export CI checklist");
234        println!("  r  Refresh");
235        println!("  q  Quit");
236        match prompt("Choose action")?.trim() {
237            "1" => ensure_local_workspace(&cx)?,
238            "2" => configure_provider_and_signing(&mut cx)?,
239            "3" => configure_release_options(&mut cx)?,
240            "4" => review_release_metadata_content(&cx, hooks)?,
241            "5" => package_artifact(&mut cx, hooks, true, true)?,
242            "6" => dry_run_publish(&cx, &options, hooks, true)?,
243            "7" => publish_artifact_interactive(&cx, &options, hooks)?,
244            "8" => export_ci_checklist(&cx),
245            "r" | "R" => load_local_env(&cx.workspace.join("release.env"))?,
246            "q" | "Q" => break,
247            _ => pause("Unknown action."),
248        }
249    }
250    Ok(())
251}
252
253pub fn publish_flow_snapshot(options: &PublishShellOptions) -> Result<PublishFlowSnapshot> {
254    let cx = PublishContext::load(options)?;
255    ensure_local_workspace_files(&cx)?;
256    load_local_env(&cx.workspace.join("release.env"))?;
257    let package_checks = readiness_package(&cx.project_dir, Some(cx.target), Some(cx.format), true)
258        .unwrap_or_else(|err| readiness_error("release.package.readiness_failed", err));
259    let distribution_checks = match super::load_publish_manifest(&cx.project_dir) {
260        Ok(config) => readiness_distribute(
261            &cx.project_dir,
262            cx.provider,
263            &cx.site,
264            cx.track.as_deref(),
265            Some(cx.format),
266            Some(&cx.artifact_manifest_path()),
267            &config,
268        )
269        .unwrap_or_else(|err| readiness_error("release.distribution.readiness_failed", err)),
270        Err(err) => readiness_error("release.distribution.config_failed", err),
271    };
272    let artifact_manifest = cx.artifact_manifest_path();
273    let release_checks = Vec::new();
274    Ok(PublishFlowSnapshot {
275        project_dir: cx.project_dir,
276        app_name: cx.app_name,
277        app_id: cx.app_id,
278        provider: cx.provider,
279        target: cx.target,
280        format: cx.format,
281        site: cx.site,
282        track: cx.track,
283        locales: cx.locales,
284        workspace: cx.workspace,
285        artifact_manifest,
286        package_checks,
287        distribution_checks,
288        release_checks,
289    })
290}
291
292pub fn ensure_publish_workspace(options: &PublishShellOptions) -> Result<PathBuf> {
293    let cx = PublishContext::load(options)?;
294    ensure_local_workspace_files(&cx)?;
295    Ok(cx.workspace)
296}
297
298impl PublishContext {
299    fn load(options: &PublishShellOptions) -> Result<Self> {
300        let project = read_project_config(&options.project_dir).with_context(|| {
301            format!(
302                "failed to read {}",
303                options.project_dir.join("fission.toml").display()
304            )
305        })?;
306        let target = options
307            .target
308            .unwrap_or_else(|| default_target_for_provider(options.provider));
309        let format = options
310            .format
311            .unwrap_or_else(|| default_format_for_target_provider(target, options.provider));
312        let workspace = local_workspace_for_app(&project.app.name)?;
313        let track = options
314            .track
315            .clone()
316            .or_else(|| default_track_for_provider(options.provider).map(str::to_string));
317        let locales = if options.locales.is_empty() {
318            release_default_locales(&options.project_dir).unwrap_or_default()
319        } else {
320            options.locales.clone()
321        };
322        Ok(Self {
323            project_dir: options.project_dir.clone(),
324            app_name: project.app.name,
325            app_id: project.app.app_id,
326            provider: options.provider,
327            target,
328            format,
329            site: options.site.clone(),
330            track,
331            locales,
332            overwrite_remote: options.overwrite_remote,
333            workspace,
334            artifact: options.artifact.clone(),
335        })
336    }
337
338    fn artifact_manifest_path(&self) -> PathBuf {
339        self.artifact
340            .clone()
341            .unwrap_or_else(|| self.default_artifact_manifest_path())
342    }
343
344    fn default_artifact_manifest_path(&self) -> PathBuf {
345        self.project_dir
346            .join("target/fission/release")
347            .join(self.target.as_str())
348            .join(self.format.as_str())
349            .join(ARTIFACT_MANIFEST)
350    }
351}
352
353pub fn default_target_for_provider(provider: DistributionProvider) -> Target {
354    match provider {
355        DistributionProvider::PlayStore => Target::Android,
356        DistributionProvider::AppStore => Target::Ios,
357        DistributionProvider::MicrosoftStore => Target::Windows,
358        DistributionProvider::S3
359        | DistributionProvider::GithubPages
360        | DistributionProvider::CloudflarePages
361        | DistributionProvider::Netlify => Target::Site,
362        DistributionProvider::DockerRegistry => Target::Server,
363        DistributionProvider::GithubReleases
364        | DistributionProvider::GoogleDrive
365        | DistributionProvider::OneDrive
366        | DistributionProvider::Dropbox => Target::Linux,
367    }
368}
369
370pub fn default_format_for_target_provider(
371    target: Target,
372    provider: DistributionProvider,
373) -> PackageFormat {
374    match (target, provider) {
375        (Target::Android, _) => PackageFormat::Aab,
376        (Target::Ios, _) => PackageFormat::Ipa,
377        (Target::Windows, DistributionProvider::MicrosoftStore) => PackageFormat::Msix,
378        (Target::Windows, _) => PackageFormat::Exe,
379        (Target::Linux | Target::Terminal, _) => PackageFormat::Run,
380        (Target::Macos, _) => PackageFormat::App,
381        (Target::Server, _) => PackageFormat::DockerImage,
382        (Target::Site | Target::Web, _) => PackageFormat::Static,
383    }
384}
385
386pub fn default_track_for_provider(provider: DistributionProvider) -> Option<&'static str> {
387    match provider {
388        DistributionProvider::PlayStore => Some("internal"),
389        DistributionProvider::AppStore => Some("testflight"),
390        DistributionProvider::MicrosoftStore => Some("private"),
391        _ => None,
392    }
393}
394
395fn release_default_locales(project_dir: &Path) -> Result<Vec<String>> {
396    let text = fs::read_to_string(project_dir.join("fission.toml"))?;
397    let value: toml::Value = toml::from_str(&text)?;
398    Ok(value
399        .get("release")
400        .and_then(|release| release.get("default_locales"))
401        .and_then(toml::Value::as_array)
402        .map(|items| {
403            items
404                .iter()
405                .filter_map(toml::Value::as_str)
406                .map(str::to_string)
407                .collect::<Vec<_>>()
408        })
409        .unwrap_or_default())
410}
411
412fn local_workspace_for_app(app_name: &str) -> Result<PathBuf> {
413    let home = env::var_os("HOME").context("HOME is not set; cannot resolve ~/.fission")?;
414    Ok(PathBuf::from(home)
415        .join(".fission")
416        .join(sanitize_workspace_name(app_name)))
417}
418
419fn sanitize_workspace_name(value: &str) -> String {
420    let out = value
421        .chars()
422        .map(|ch| match ch {
423            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' => ch.to_ascii_lowercase(),
424            _ => '-',
425        })
426        .collect::<String>()
427        .trim_matches(['-', '.', '_'])
428        .to_string();
429    if out.is_empty() {
430        "app".to_string()
431    } else {
432        out
433    }
434}
435
436fn render_dashboard(cx: &PublishContext, hooks: PublishShellHooks) -> Result<()> {
437    println!("\x1b[2J\x1b[H");
438    println!("Fission Local Publish");
439    println!("─────────────────────");
440    println!("App        {} ({})", cx.app_name, cx.app_id);
441    println!(
442        "Target     {} / {}",
443        cx.target.as_str(),
444        cx.provider.as_str()
445    );
446    println!("Format     {}", cx.format.as_str());
447    println!("Track      {}", cx.track.as_deref().unwrap_or("not set"));
448    println!(
449        "Locales    {}",
450        if cx.locales.is_empty() {
451            "not set".to_string()
452        } else {
453            cx.locales.join(", ")
454        }
455    );
456    println!("Workspace  {}", cx.workspace.display());
457    println!("Artifact   {}", cx.artifact_manifest_path().display());
458    println!("\nSecrets never written to fission.toml. Local release files belong under ~/.fission/<app-name>.");
459    println!();
460
461    let package = readiness_package(&cx.project_dir, Some(cx.target), Some(cx.format), true)
462        .unwrap_or_else(|err| readiness_error("release.package.readiness_failed", err));
463    let distribute_checks = match super::load_publish_manifest(&cx.project_dir) {
464        Ok(config) => readiness_distribute(
465            &cx.project_dir,
466            cx.provider,
467            &cx.site,
468            cx.track.as_deref(),
469            Some(cx.format),
470            Some(&cx.artifact_manifest_path()),
471            &config,
472        )
473        .unwrap_or_else(|err| readiness_error("release.distribution.readiness_failed", err)),
474        Err(err) => readiness_error("release.distribution.config_failed", err),
475    };
476    render_check_group("Project/package preflight", &package);
477    render_check_group("Provider/distribution preflight", &distribute_checks);
478    let release_plan = (hooks.release_checks)(workflow_request(cx, None, true, true, true))
479        .unwrap_or_else(|err| readiness_error("release.plan.readiness_failed", err));
480    render_check_group("Release plan", &release_plan);
481    if let Ok(plan) = (hooks.release_plan)(workflow_request(cx, None, true, true, true)) {
482        render_release_plan_summary(&plan);
483    }
484    println!(
485        "Overall   package={} provider={} release={}",
486        report_status(&package),
487        report_status(&distribute_checks),
488        report_status(&release_plan)
489    );
490    Ok(())
491}
492
493fn release_plan_checks_via_current_fission_request(
494    request: PublishShellWorkflowRequest,
495) -> Result<Vec<ReadinessCheck>> {
496    let plan = release_plan_json_via_current_fission_request(request)?;
497    Ok(plan
498        .requirements
499        .into_iter()
500        .map(release_requirement_check)
501        .collect())
502}
503
504fn release_plan_via_current_fission_request(
505    request: PublishShellWorkflowRequest,
506) -> Result<PublishShellReleasePlan> {
507    release_plan_json_via_current_fission_request(request).map(publish_shell_release_plan)
508}
509
510fn release_plan_json_via_current_fission_request(
511    request: PublishShellWorkflowRequest,
512) -> Result<ReleasePlanJson> {
513    let exe = env::current_exe().context("failed to resolve current fission executable")?;
514    let mut args = vec![
515        "readiness".to_string(),
516        "release".to_string(),
517        "--target".to_string(),
518        request
519            .target
520            .context("release readiness requires a target")?
521            .as_str()
522            .to_string(),
523        "--format".to_string(),
524        request
525            .format
526            .context("release readiness requires a package format")?
527            .as_str()
528            .to_string(),
529        "--provider".to_string(),
530        request.provider.as_str().to_string(),
531        "--site".to_string(),
532        request.site,
533        "--project-dir".to_string(),
534        request.project_dir.display().to_string(),
535        "--json".to_string(),
536    ];
537    if let Some(track) = request
538        .track
539        .as_ref()
540        .filter(|value| !value.trim().is_empty())
541    {
542        args.push("--track".to_string());
543        args.push(track.clone());
544    }
545    if let Some(artifact) = request.artifact.as_ref() {
546        args.push("--artifact".to_string());
547        args.push(artifact.display().to_string());
548    }
549
550    let output = Command::new(exe)
551        .args(args)
552        .stdin(Stdio::null())
553        .stdout(Stdio::piped())
554        .stderr(Stdio::piped())
555        .output()
556        .context("failed to run release readiness")?;
557    let stdout = String::from_utf8_lossy(&output.stdout);
558    if stdout.trim().is_empty() {
559        let stderr = String::from_utf8_lossy(&output.stderr);
560        bail!("release readiness produced no JSON output: {stderr}");
561    }
562    let plan: ReleasePlanJson = serde_json::from_str(&stdout)
563        .with_context(|| format!("failed to parse release readiness JSON: {stdout}"))?;
564    Ok(plan)
565}
566
567fn workflow_request(
568    cx: &PublishContext,
569    deploy: Option<String>,
570    dry_run: bool,
571    yes: bool,
572    json: bool,
573) -> PublishShellWorkflowRequest {
574    PublishShellWorkflowRequest {
575        project_dir: cx.project_dir.clone(),
576        provider: cx.provider,
577        target: Some(cx.target),
578        format: Some(cx.format),
579        artifact: cx.artifact.clone(),
580        site: cx.site.clone(),
581        deploy,
582        track: cx.track.clone(),
583        locales: cx.locales.clone(),
584        overwrite_remote: cx.overwrite_remote,
585        dry_run,
586        yes,
587        json,
588    }
589}
590
591fn release_requirement_check(requirement: ReleaseRequirementJson) -> ReadinessCheck {
592    ReadinessCheck {
593        id: requirement.id,
594        severity: match requirement.level.as_str() {
595            "provider-required" => super::CheckSeverity::Error,
596            "fission-recommended" => super::CheckSeverity::Warning,
597            _ => super::CheckSeverity::Info,
598        },
599        status: match requirement.status.as_str() {
600            "passed" => CheckStatus::Passed,
601            "missing" => CheckStatus::Missing,
602            "failed" => CheckStatus::Failed,
603            "warning" => CheckStatus::Warning,
604            "skipped" => CheckStatus::Skipped,
605            _ => CheckStatus::Warning,
606        },
607        summary: requirement.summary,
608        details: requirement.details,
609        remediation: requirement.remediation,
610    }
611}
612
613fn publish_shell_release_plan(plan: ReleasePlanJson) -> PublishShellReleasePlan {
614    PublishShellReleasePlan {
615        status: plan.status,
616        provider: plan.provider,
617        target: plan.target,
618        format: plan.format,
619        track: plan.track,
620        locales: plan.locales,
621        steps: plan
622            .steps
623            .into_iter()
624            .map(|step| PublishShellReleaseStep {
625                id: step.id,
626                title: step.title,
627                status: step.status,
628            })
629            .collect(),
630        capabilities: plan
631            .capabilities
632            .into_iter()
633            .map(|capability| PublishShellProviderCapability {
634                id: capability.id,
635                status: capability.status,
636                summary: capability.summary,
637            })
638            .collect(),
639        requirements: plan
640            .requirements
641            .into_iter()
642            .map(release_requirement_check)
643            .collect(),
644    }
645}
646
647fn render_release_plan_summary(plan: &PublishShellReleasePlan) {
648    println!();
649    println!("Release plan details");
650    println!("  status    {}", empty_dash(&plan.status));
651    println!("  provider  {}", empty_dash(&plan.provider));
652    println!(
653        "  target    {} / {}",
654        plan.target.as_deref().unwrap_or("-"),
655        plan.format.as_deref().unwrap_or("-")
656    );
657    println!("  track     {}", plan.track.as_deref().unwrap_or("-"));
658    println!(
659        "  locales   {}",
660        if plan.locales.is_empty() {
661            "-".to_string()
662        } else {
663            plan.locales.join(", ")
664        }
665    );
666    println!("  steps");
667    for step in plan.steps.iter().take(7) {
668        println!("    {:<18} {:<10} {}", step.id, step.status, step.title);
669    }
670    if !plan.capabilities.is_empty() {
671        println!("  capabilities");
672        for capability in plan.capabilities.iter().take(5) {
673            println!(
674                "    {:<18} {:<14} {}",
675                capability.id, capability.status, capability.summary
676            );
677        }
678    }
679}
680
681fn empty_dash(value: &str) -> &str {
682    if value.trim().is_empty() {
683        "-"
684    } else {
685        value
686    }
687}
688
689fn current_fission_hooks() -> PublishShellHooks {
690    PublishShellHooks {
691        release_plan: release_plan_via_current_fission_request,
692        release_checks: release_plan_checks_via_current_fission_request,
693        publish: publish_via_current_fission_request,
694        skip_requirement: skip_requirement_via_current_fission_request,
695        bump_build: bump_build_via_current_fission_request,
696    }
697}
698
699fn publish_via_current_fission_request(request: PublishShellWorkflowRequest) -> Result<String> {
700    let label = if request.dry_run {
701        "Running publish dry-run"
702    } else {
703        "Publishing artifact"
704    };
705    run_current_fission_logged(publish_command_args_from_request(request), label)?;
706    Ok("publish command completed".to_string())
707}
708
709fn skip_requirement_via_current_fission_request(
710    request: PublishShellWorkflowRequest,
711    id: String,
712) -> Result<String> {
713    let args = vec![
714        "release-config".to_string(),
715        "skip-requirement".to_string(),
716        "--project-dir".to_string(),
717        request.project_dir.display().to_string(),
718        "--id".to_string(),
719        id.clone(),
720        "--yes".to_string(),
721    ];
722    run_current_fission_logged(args, "Recording release skip")?;
723    Ok(format!("recorded explicit skip for {id}"))
724}
725
726fn bump_build_via_current_fission_request(request: PublishShellWorkflowRequest) -> Result<String> {
727    let mut args = vec![
728        "release-config".to_string(),
729        "bump-build".to_string(),
730        "--project-dir".to_string(),
731        request.project_dir.display().to_string(),
732        "--yes".to_string(),
733    ];
734    if let Some(target) = request.target {
735        args.push("--target".to_string());
736        args.push(target.as_str().to_string());
737    }
738    run_current_fission_logged(args, "Bumping release build")?;
739    Ok("bumped release build".to_string())
740}
741
742fn readiness_error(id: &str, err: anyhow::Error) -> Vec<ReadinessCheck> {
743    vec![ReadinessCheck {
744        id: id.to_string(),
745        severity: super::CheckSeverity::Error,
746        status: CheckStatus::Failed,
747        summary: "readiness check could not complete".to_string(),
748        details: Some(err.to_string()),
749        remediation: vec![
750            "Fix the configuration error above, then refresh this publish flow.".to_string(),
751        ],
752    }]
753}
754
755fn render_check_group(title: &str, checks: &[ReadinessCheck]) {
756    println!("{title}");
757    for check in checks {
758        let mark = match check.status {
759            CheckStatus::Passed => "✓",
760            CheckStatus::Warning => "!",
761            CheckStatus::Missing | CheckStatus::Failed => "✕",
762            CheckStatus::Skipped => "·",
763        };
764        let detail = check
765            .details
766            .as_deref()
767            .map(|value| format!(" — {value}"))
768            .unwrap_or_default();
769        println!("  {mark} {}{}", check.summary, detail);
770        if check.status != CheckStatus::Passed {
771            for fix in check.remediation.iter().take(1) {
772                println!("    fix: {fix}");
773            }
774        }
775    }
776    println!();
777}
778
779fn ensure_local_workspace(cx: &PublishContext) -> Result<()> {
780    ensure_local_workspace_files(cx)?;
781    println!("Local workspace ready: {}", cx.workspace.display());
782    println!(
783        "Local env file: {}",
784        cx.workspace.join("release.env").display()
785    );
786    pause("Press Enter to continue.");
787    Ok(())
788}
789
790fn ensure_local_workspace_files(cx: &PublishContext) -> Result<()> {
791    if let Some(parent) = cx.workspace.parent() {
792        fs::create_dir_all(parent)
793            .with_context(|| format!("failed to create {}", parent.display()))?;
794        set_private_dir_permissions(parent)?;
795    }
796    fs::create_dir_all(&cx.workspace)
797        .with_context(|| format!("failed to create {}", cx.workspace.display()))?;
798    set_private_dir_permissions(&cx.workspace)?;
799    let env_path = cx.workspace.join("release.env");
800    if !env_path.exists() {
801        write_env_file(&env_path, &[])?;
802    } else {
803        set_private_file_permissions(&env_path)?;
804    }
805    Ok(())
806}
807
808fn configure_provider_and_signing(cx: &mut PublishContext) -> Result<()> {
809    ensure_local_workspace(cx)?;
810    match cx.provider {
811        DistributionProvider::PlayStore => configure_android_play(cx),
812        DistributionProvider::AppStore => configure_ios_app_store(cx),
813        DistributionProvider::MicrosoftStore => configure_windows_store(cx),
814        DistributionProvider::S3 => configure_s3(cx),
815        _ => configure_generic_provider(cx),
816    }
817}
818
819fn configure_android_play(cx: &mut PublishContext) -> Result<()> {
820    println!("\nConnect Google Play Console");
821    println!("1. Open Google Play Console -> Setup -> API access");
822    println!("2. Link or create a Google Cloud project");
823    println!("3. Enable the Android Publisher API");
824    println!("4. Create a service account");
825    println!("5. Grant app access for package {}", cx.app_id);
826    println!("6. Download the JSON key and keep it outside the repository");
827    println!("Accepted local env: GOOGLE_APPLICATION_CREDENTIALS");
828    println!("Accepted CI env: PLAY_STORE_SERVICE_ACCOUNT_JSON_BASE64");
829    if confirm("Enter path to Play service-account JSON now?", true)? {
830        let selected = select_file(&cx.project_dir, Some("json"))?;
831        let chosen = handle_selected_file(
832            &selected,
833            &cx.workspace,
834            "play-service-account.json",
835            &cx.project_dir,
836        )?;
837        upsert_env(
838            &cx.workspace.join("release.env"),
839            "GOOGLE_APPLICATION_CREDENTIALS",
840            &chosen.display().to_string(),
841        )?;
842        env::set_var("GOOGLE_APPLICATION_CREDENTIALS", &chosen);
843    }
844
845    println!("\nAndroid upload signing");
846    println!("Use an existing JKS or generate a new upload key. Fission only asks for the keystore password.");
847    println!("  1  Use existing JKS");
848    println!("  2  Generate upload key");
849    println!("  s  Skip");
850    match prompt("Choose signing action")?.trim() {
851        "1" => {
852            let selected = select_file(&cx.project_dir, Some("jks"))?;
853            let chosen =
854                handle_selected_file(&selected, &cx.workspace, "upload-key.jks", &cx.project_dir)?;
855            let alias = prompt_default("Keystore alias", &default_android_alias(cx))?;
856            upsert_env(
857                &cx.workspace.join("release.env"),
858                "ANDROID_KEYSTORE",
859                &chosen.display().to_string(),
860            )?;
861            upsert_env(
862                &cx.workspace.join("release.env"),
863                "ANDROID_KEYSTORE_ALIAS",
864                &alias,
865            )?;
866            env::set_var("ANDROID_KEYSTORE", &chosen);
867            env::set_var("ANDROID_KEYSTORE_ALIAS", &alias);
868            configure_android_passwords(cx)?;
869        }
870        "2" => generate_android_jks(cx)?,
871        _ => {}
872    }
873    Ok(())
874}
875
876fn configure_android_passwords(cx: &PublishContext) -> Result<()> {
877    let password = prompt_password_confirmed("Android keystore password")?;
878    let env_path = cx.workspace.join("release.env");
879    upsert_env(&env_path, "ANDROID_KEYSTORE_PASSWORD", &password)?;
880    upsert_env(&env_path, "ANDROID_KEY_PASSWORD", &password)?;
881    env::set_var("ANDROID_KEYSTORE_PASSWORD", &password);
882    env::set_var("ANDROID_KEY_PASSWORD", &password);
883    Ok(())
884}
885
886fn generate_android_jks(cx: &PublishContext) -> Result<()> {
887    let destination = cx.workspace.join("upload-key.jks");
888    if destination.exists() && !confirm("upload-key.jks already exists. Replace it?", false)? {
889        return Ok(());
890    }
891    let password = prompt_password_confirmed("New Android upload-key password")?;
892    let alias = default_android_alias(cx);
893    let mut command = Command::new("keytool");
894    command
895        .arg("-genkeypair")
896        .arg("-v")
897        .arg("-keystore")
898        .arg(&destination)
899        .arg("-storepass")
900        .arg(&password)
901        .arg("-keypass")
902        .arg(&password)
903        .arg("-alias")
904        .arg(&alias)
905        .arg("-keyalg")
906        .arg("RSA")
907        .arg("-keysize")
908        .arg("2048")
909        .arg("-validity")
910        .arg("9125")
911        .arg("-dname")
912        .arg(format!(
913            "CN={}, OU=Fission Local Publish, O=Fission, L=Local, ST=Local, C=US",
914            cx.app_name
915        ));
916    run_logged_command(
917        &mut command,
918        &format!("Generating Android upload key at {}", destination.display()),
919    )
920    .context("failed to run keytool; install a JDK to generate Android upload keys")?;
921    set_private_file_permissions(&destination)?;
922    let env_path = cx.workspace.join("release.env");
923    upsert_env(
924        &env_path,
925        "ANDROID_KEYSTORE",
926        &destination.display().to_string(),
927    )?;
928    upsert_env(&env_path, "ANDROID_KEYSTORE_ALIAS", &alias)?;
929    upsert_env(&env_path, "ANDROID_KEYSTORE_PASSWORD", &password)?;
930    upsert_env(&env_path, "ANDROID_KEY_PASSWORD", &password)?;
931    env::set_var("ANDROID_KEYSTORE", &destination);
932    env::set_var("ANDROID_KEYSTORE_ALIAS", &alias);
933    env::set_var("ANDROID_KEYSTORE_PASSWORD", &password);
934    env::set_var("ANDROID_KEY_PASSWORD", &password);
935    println!("Generated {}", destination.display());
936    pause("Press Enter to continue.");
937    Ok(())
938}
939
940fn default_android_alias(cx: &PublishContext) -> String {
941    sanitize_workspace_name(&cx.app_name).replace('.', "-")
942}
943
944fn configure_ios_app_store(cx: &PublishContext) -> Result<()> {
945    println!("\nConnect App Store Connect");
946    println!("1. App Store Connect -> Users and Access -> Integrations -> App Store Connect API");
947    println!("2. Create an API key with App Manager or equivalent role");
948    println!("3. Download the .p8 key once");
949    println!("4. Capture Key ID and Issuer ID");
950    println!("Accepted local env: APP_STORE_CONNECT_API_KEY_PATH, APP_STORE_CONNECT_KEY_ID, APP_STORE_CONNECT_ISSUER_ID");
951    println!("Accepted CI env: APP_STORE_CONNECT_API_KEY_BASE64");
952    if confirm("Enter path to App Store Connect .p8 key now?", true)? {
953        let selected = select_file(&cx.project_dir, Some("p8"))?;
954        let selected_name = selected
955            .file_name()
956            .and_then(OsStr::to_str)
957            .unwrap_or("AuthKey.p8")
958            .to_string();
959        let chosen = handle_selected_file(
960            &selected,
961            &cx.workspace.join("ios"),
962            &selected_name,
963            &cx.project_dir,
964        )?;
965        upsert_env(
966            &cx.workspace.join("release.env"),
967            "APP_STORE_CONNECT_API_KEY_PATH",
968            &chosen.display().to_string(),
969        )?;
970        env::set_var("APP_STORE_CONNECT_API_KEY_PATH", &chosen);
971    }
972    let key_id = prompt_optional("App Store Connect key id")?;
973    let issuer = prompt_optional("App Store Connect issuer id")?;
974    if let Some(value) = key_id {
975        upsert_env(
976            &cx.workspace.join("release.env"),
977            "APP_STORE_CONNECT_KEY_ID",
978            &value,
979        )?;
980        env::set_var("APP_STORE_CONNECT_KEY_ID", value);
981    }
982    if let Some(value) = issuer {
983        upsert_env(
984            &cx.workspace.join("release.env"),
985            "APP_STORE_CONNECT_ISSUER_ID",
986            &value,
987        )?;
988        env::set_var("APP_STORE_CONNECT_ISSUER_ID", value);
989    }
990    Ok(())
991}
992
993fn configure_windows_store(cx: &PublishContext) -> Result<()> {
994    println!("\nMicrosoft Store / Partner Center credentials");
995    println!("MSIX publishing uses the Microsoft Store Developer CLI (`msstore`).");
996    println!(
997        "Run `msstore reconfigure` or the equivalent msstore sign-in flow before publishing MSIX."
998    );
999    println!("EXE/MSI submissions use Partner Center API credentials instead.");
1000    println!(
1001        "Accepted EXE/MSI env: AZURE_TENANT_ID, AZURE_CLIENT_ID, MICROSOFT_STORE_CLIENT_SECRET"
1002    );
1003    println!("Signing accepts WINDOWS_CERTIFICATE, WINDOWS_CERTIFICATE_BASE64, WINDOWS_CERTIFICATE_PASSWORD, WINDOWS_CERTIFICATE_THUMBPRINT");
1004    if confirm("Enter path to a PFX signing certificate now?", false)? {
1005        let selected = select_file(&cx.project_dir, Some("pfx"))?;
1006        let selected_name = selected
1007            .file_name()
1008            .and_then(OsStr::to_str)
1009            .unwrap_or("signing.pfx")
1010            .to_string();
1011        let chosen = handle_selected_file(
1012            &selected,
1013            &cx.workspace.join("windows"),
1014            &selected_name,
1015            &cx.project_dir,
1016        )?;
1017        upsert_env(
1018            &cx.workspace.join("release.env"),
1019            "WINDOWS_CERTIFICATE",
1020            &chosen.display().to_string(),
1021        )?;
1022        env::set_var("WINDOWS_CERTIFICATE", &chosen);
1023        let password = prompt_password_confirmed("Windows certificate password")?;
1024        upsert_env(
1025            &cx.workspace.join("release.env"),
1026            "WINDOWS_CERTIFICATE_PASSWORD",
1027            &password,
1028        )?;
1029        env::set_var("WINDOWS_CERTIFICATE_PASSWORD", password);
1030    }
1031    if cx.format != PackageFormat::Msix
1032        && confirm(
1033            "Configure Partner Center API credentials for EXE/MSI submission?",
1034            false,
1035        )?
1036    {
1037        for key in ["AZURE_TENANT_ID", "AZURE_CLIENT_ID"] {
1038            if let Some(value) = prompt_optional(key)? {
1039                upsert_env(&cx.workspace.join("release.env"), key, &value)?;
1040                env::set_var(key, value);
1041            }
1042        }
1043        if confirm("Enter Partner Center client secret now?", false)? {
1044            let secret = prompt_password_confirmed("Partner Center client secret")?;
1045            upsert_env(
1046                &cx.workspace.join("release.env"),
1047                "MICROSOFT_STORE_CLIENT_SECRET",
1048                &secret,
1049            )?;
1050            env::set_var("MICROSOFT_STORE_CLIENT_SECRET", secret);
1051        }
1052    }
1053    Ok(())
1054}
1055
1056fn configure_s3(cx: &PublishContext) -> Result<()> {
1057    println!("\nS3 credential mode");
1058    println!("Use AWS_PROFILE for local development where possible. CI should use OIDC/web identity or secret env vars.");
1059    println!("Accepted env: AWS_PROFILE, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, AWS_WEB_IDENTITY_TOKEN_FILE, AWS_REGION, AWS_ENDPOINT_URL_S3");
1060    if let Some(profile) = prompt_optional("AWS_PROFILE")? {
1061        upsert_env(&cx.workspace.join("release.env"), "AWS_PROFILE", &profile)?;
1062        env::set_var("AWS_PROFILE", profile);
1063    }
1064    if let Some(region) = prompt_optional("AWS_REGION")? {
1065        upsert_env(&cx.workspace.join("release.env"), "AWS_REGION", &region)?;
1066        env::set_var("AWS_REGION", region);
1067    }
1068    if let Some(endpoint) = prompt_optional("AWS_ENDPOINT_URL_S3 for S3-compatible storage")? {
1069        upsert_env(
1070            &cx.workspace.join("release.env"),
1071            "AWS_ENDPOINT_URL_S3",
1072            &endpoint,
1073        )?;
1074        env::set_var("AWS_ENDPOINT_URL_S3", endpoint);
1075    }
1076    if confirm(
1077        "Enter static AWS access keys for this local workspace?",
1078        false,
1079    )? {
1080        if let Some(access_key) = prompt_optional("AWS_ACCESS_KEY_ID")? {
1081            upsert_env(
1082                &cx.workspace.join("release.env"),
1083                "AWS_ACCESS_KEY_ID",
1084                &access_key,
1085            )?;
1086            env::set_var("AWS_ACCESS_KEY_ID", access_key);
1087        }
1088        let secret_key = prompt_password_confirmed("AWS_SECRET_ACCESS_KEY")?;
1089        upsert_env(
1090            &cx.workspace.join("release.env"),
1091            "AWS_SECRET_ACCESS_KEY",
1092            &secret_key,
1093        )?;
1094        env::set_var("AWS_SECRET_ACCESS_KEY", secret_key);
1095        if let Some(session) = prompt_optional("AWS_SESSION_TOKEN")? {
1096            upsert_env(
1097                &cx.workspace.join("release.env"),
1098                "AWS_SESSION_TOKEN",
1099                &session,
1100            )?;
1101            env::set_var("AWS_SESSION_TOKEN", session);
1102        }
1103    }
1104    Ok(())
1105}
1106
1107fn configure_generic_provider(cx: &PublishContext) -> Result<()> {
1108    println!(
1109        "Provider-specific interactive setup is not specialized yet for {}.",
1110        cx.provider.as_str()
1111    );
1112    println!("Use the readiness fixes shown above; Fission will still use env/provider-owned credentials only.");
1113    pause("Press Enter to continue.");
1114    Ok(())
1115}
1116
1117fn configure_release_options(cx: &mut PublishContext) -> Result<()> {
1118    if let Some(default) = default_track_for_provider(cx.provider) {
1119        let value = prompt_default("Track/channel", cx.track.as_deref().unwrap_or(default))?;
1120        cx.track = (!value.trim().is_empty()).then_some(value);
1121    }
1122    let locale_default = if cx.locales.is_empty() {
1123        "pl-PL,en-US".to_string()
1124    } else {
1125        cx.locales.join(",")
1126    };
1127    let locales = prompt_default("Locales, comma-separated", &locale_default)?;
1128    cx.locales = locales
1129        .split(',')
1130        .map(str::trim)
1131        .filter(|value| !value.is_empty())
1132        .map(str::to_string)
1133        .collect();
1134    if confirm(
1135        "Reuse an existing artifact manifest instead of building a new one?",
1136        false,
1137    )? {
1138        let selected = select_file(&cx.project_dir.join("target/fission"), Some("json"))?;
1139        cx.artifact = Some(selected);
1140    }
1141    Ok(())
1142}
1143
1144fn review_release_metadata_content(cx: &PublishContext, hooks: PublishShellHooks) -> Result<()> {
1145    println!("\nRelease metadata and content");
1146    println!("Provider: {}", cx.provider.as_str());
1147    println!("Track: {}", cx.track.as_deref().unwrap_or("not set"));
1148    println!(
1149        "Locales: {}",
1150        if cx.locales.is_empty() {
1151            "not set".to_string()
1152        } else {
1153            cx.locales.join(", ")
1154        }
1155    );
1156    println!();
1157
1158    let checks = (hooks.release_checks)(workflow_request(cx, None, true, true, false))?;
1159    let relevant = checks
1160        .iter()
1161        .filter(|check| {
1162            check.id.starts_with("release_config.") || check.id.starts_with("release_content.")
1163        })
1164        .cloned()
1165        .collect::<Vec<_>>();
1166    if relevant.is_empty() {
1167        println!("No release metadata/content checks are available for this provider yet.");
1168        pause("Press Enter to continue.");
1169        return Ok(());
1170    }
1171
1172    render_check_group("Release metadata/content checks", &relevant);
1173    println!("Helpful commands");
1174    println!(
1175        "  fission release-config edit-file --project-dir {} --release <release-id> --kind notes --locale <locale>",
1176        cx.project_dir.display()
1177    );
1178    println!(
1179        "  fission release-content capture --project-dir {} --target {} --set {}",
1180        cx.project_dir.display(),
1181        cx.target.as_str(),
1182        cx.provider.as_str()
1183    );
1184    println!(
1185        "  fission release-content render --project-dir {} --provider {}",
1186        cx.project_dir.display(),
1187        cx.provider.as_str()
1188    );
1189    println!(
1190        "  fission release-content validate --project-dir {} --provider {}",
1191        cx.project_dir.display(),
1192        cx.provider.as_str()
1193    );
1194    println!();
1195
1196    let skippable = relevant
1197        .iter()
1198        .filter(|check| {
1199            matches!(
1200                check.status,
1201                CheckStatus::Missing | CheckStatus::Failed | CheckStatus::Warning
1202            ) && check.severity != super::CheckSeverity::Error
1203        })
1204        .collect::<Vec<_>>();
1205    if skippable.is_empty() {
1206        pause("Release metadata/content checks do not have skippable warnings. Press Enter to continue.");
1207        return Ok(());
1208    }
1209
1210    println!(
1211        "Provider-required items are not skippable. Recommended items can be explicitly skipped."
1212    );
1213    for check in skippable {
1214        println!();
1215        println!("{} — {}", check.id, check.summary);
1216        if let Some(details) = &check.details {
1217            println!("  {details}");
1218        }
1219        for fix in &check.remediation {
1220            println!("  fix: {fix}");
1221        }
1222        if confirm("Add this id to [release].skip_requirements?", false)? {
1223            let output = (hooks.skip_requirement)(
1224                workflow_request(cx, None, true, true, false),
1225                check.id.clone(),
1226            )?;
1227            if !output.trim().is_empty() {
1228                println!("{output}");
1229            }
1230        }
1231    }
1232    pause("Release metadata/content review finished. Press Enter to continue.");
1233    Ok(())
1234}
1235
1236fn package_artifact(
1237    cx: &mut PublishContext,
1238    hooks: PublishShellHooks,
1239    pause_after: bool,
1240    offer_interactive_fixes: bool,
1241) -> Result<()> {
1242    if offer_interactive_fixes {
1243        offer_build_bump_if_needed(cx, hooks)?;
1244    }
1245    println!("Building release package...");
1246    let artifact = super::package_silent(super::PackageOptions {
1247        project_dir: cx.project_dir.clone(),
1248        target: cx.target,
1249        format: cx.format,
1250        release: true,
1251        variant: None,
1252        json: false,
1253    })?;
1254    println!("Artifact manifest: {}", artifact.display());
1255    cx.artifact = Some(artifact);
1256    if pause_after {
1257        pause("Package step finished. Press Enter to continue.");
1258    }
1259    Ok(())
1260}
1261
1262fn offer_build_bump_if_needed(cx: &mut PublishContext, hooks: PublishShellHooks) -> Result<()> {
1263    if cx.provider != DistributionProvider::PlayStore {
1264        return Ok(());
1265    }
1266    let checks = (hooks.release_checks)(workflow_request(cx, None, true, true, false))?;
1267    let Some(check) = checks.iter().find(|check| {
1268        check.id == "release.play_store.version_code_available"
1269            && check.status == CheckStatus::Failed
1270    }) else {
1271        return Ok(());
1272    };
1273    println!("\nGoogle Play version/build issue");
1274    println!("{}", check.summary);
1275    if let Some(details) = &check.details {
1276        println!("{details}");
1277    }
1278    for fix in &check.remediation {
1279        println!("fix: {fix}");
1280    }
1281    if confirm(
1282        "Increment the Android build/version code before packaging?",
1283        true,
1284    )? {
1285        let output = (hooks.bump_build)(workflow_request(cx, None, true, true, false))?;
1286        if !output.trim().is_empty() {
1287            println!("{output}");
1288        }
1289        cx.artifact = None;
1290    }
1291    Ok(())
1292}
1293
1294fn dry_run_publish(
1295    cx: &PublishContext,
1296    options: &PublishShellOptions,
1297    hooks: PublishShellHooks,
1298    pause_after: bool,
1299) -> Result<()> {
1300    let output = (hooks.publish)(workflow_request(
1301        cx,
1302        options.deploy.clone(),
1303        true,
1304        true,
1305        false,
1306    ))?;
1307    if !output.trim().is_empty() {
1308        println!("{output}");
1309    }
1310    if pause_after {
1311        pause("Dry run finished. Press Enter to continue.");
1312    }
1313    Ok(())
1314}
1315
1316fn publish_artifact_interactive(
1317    cx: &PublishContext,
1318    options: &PublishShellOptions,
1319    hooks: PublishShellHooks,
1320) -> Result<()> {
1321    println!("\nFinal publish confirmation");
1322    println!("Target: {} / {}", cx.target.as_str(), cx.provider.as_str());
1323    println!("Track: {}", cx.track.as_deref().unwrap_or("not set"));
1324    println!("Artifact: {}", cx.artifact_manifest_path().display());
1325    println!("Type the package/app id to publish: {}", cx.app_id);
1326    let typed = prompt("Confirm package/app id")?;
1327    if typed.trim() != cx.app_id {
1328        bail!(
1329            "confirmation did not match {}; publish cancelled",
1330            cx.app_id
1331        );
1332    }
1333    publish_artifact(cx, options, hooks)
1334}
1335
1336fn publish_artifact(
1337    cx: &PublishContext,
1338    options: &PublishShellOptions,
1339    hooks: PublishShellHooks,
1340) -> Result<()> {
1341    let output = (hooks.publish)(workflow_request(
1342        cx,
1343        options.deploy.clone(),
1344        false,
1345        true,
1346        false,
1347    ))?;
1348    if !output.trim().is_empty() {
1349        println!("{output}");
1350    }
1351    Ok(())
1352}
1353
1354fn publish_command_args_from_request(request: PublishShellWorkflowRequest) -> Vec<String> {
1355    let mut args = vec![
1356        "publish".to_string(),
1357        "--provider".to_string(),
1358        request.provider.as_str().to_string(),
1359        "--site".to_string(),
1360        request.site,
1361        "--project-dir".to_string(),
1362        request.project_dir.display().to_string(),
1363    ];
1364    if request.yes {
1365        args.push("--yes".to_string());
1366    }
1367    if let Some(target) = request.target {
1368        args.push("--target".to_string());
1369        args.push(target.as_str().to_string());
1370    }
1371    if let Some(format) = request.format {
1372        args.push("--format".to_string());
1373        args.push(format.as_str().to_string());
1374    }
1375    if let Some(artifact) = request.artifact {
1376        args.push("--artifact".to_string());
1377        args.push(artifact.display().to_string());
1378    }
1379    if let Some(track) = request
1380        .track
1381        .as_ref()
1382        .filter(|value| !value.trim().is_empty())
1383    {
1384        args.push("--track".to_string());
1385        args.push(track.clone());
1386    }
1387    for locale in request.locales {
1388        args.push("--locale".to_string());
1389        args.push(locale);
1390    }
1391    if let Some(deploy) = request
1392        .deploy
1393        .as_ref()
1394        .filter(|value| !value.trim().is_empty())
1395    {
1396        args.push("--deploy".to_string());
1397        args.push(deploy.clone());
1398    }
1399    if request.dry_run {
1400        args.push("--dry-run".to_string());
1401    }
1402    if request.overwrite_remote {
1403        args.push("--overwrite-remote".to_string());
1404    }
1405    if request.json {
1406        args.push("--json".to_string());
1407    }
1408    args
1409}
1410
1411fn run_current_fission_logged(args: Vec<String>, label: &str) -> Result<()> {
1412    let exe = env::current_exe().context("failed to resolve current fission executable")?;
1413    let mut command = Command::new(exe);
1414    command.args(args);
1415    run_logged_command(&mut command, label)
1416}
1417
1418fn run_logged_command(command: &mut Command, label: &str) -> Result<()> {
1419    command
1420        .stdout(Stdio::piped())
1421        .stderr(Stdio::piped())
1422        .stdin(Stdio::null());
1423    let mut child = command
1424        .spawn()
1425        .with_context(|| format!("failed to start {label}"))?;
1426    let stdout = child.stdout.take().context("failed to capture stdout")?;
1427    let stderr = child.stderr.take().context("failed to capture stderr")?;
1428    let (tx, rx) = mpsc::channel::<String>();
1429    spawn_log_reader(stdout, tx.clone());
1430    spawn_log_reader(stderr, tx);
1431
1432    let interactive = io::stdout().is_terminal();
1433    let started = Instant::now();
1434    let mut tick = 0usize;
1435    let mut rendered = false;
1436    let mut tail = VecDeque::new();
1437
1438    loop {
1439        while let Ok(line) = rx.try_recv() {
1440            record_log_line(&mut tail, line, interactive);
1441        }
1442
1443        if let Some(status) = child.try_wait()? {
1444            while let Ok(line) = rx.try_recv() {
1445                record_log_line(&mut tail, line, interactive);
1446            }
1447            if interactive {
1448                render_log_window(
1449                    label,
1450                    &tail,
1451                    started,
1452                    tick,
1453                    Some(status.success()),
1454                    &mut rendered,
1455                )?;
1456            }
1457            if status.success() {
1458                if !interactive {
1459                    println!("{label}: done");
1460                }
1461                return Ok(());
1462            }
1463            let detail = tail.iter().cloned().collect::<Vec<_>>().join("\n");
1464            if detail.trim().is_empty() {
1465                bail!("{label} failed with {status}");
1466            }
1467            bail!("{label} failed with {status}\n{detail}");
1468        }
1469
1470        match rx.recv_timeout(Duration::from_millis(120)) {
1471            Ok(line) => record_log_line(&mut tail, line, interactive),
1472            Err(mpsc::RecvTimeoutError::Timeout) => {}
1473            Err(mpsc::RecvTimeoutError::Disconnected) => {}
1474        }
1475
1476        if interactive {
1477            render_log_window(label, &tail, started, tick, None, &mut rendered)?;
1478        }
1479        tick = tick.wrapping_add(1);
1480    }
1481}
1482
1483fn spawn_log_reader<R>(reader: R, tx: mpsc::Sender<String>)
1484where
1485    R: Read + Send + 'static,
1486{
1487    thread::spawn(move || {
1488        for line in BufReader::new(reader).lines() {
1489            let line = line.unwrap_or_else(|err| format!("failed to read process output: {err}"));
1490            let _ = tx.send(line.replace('\r', ""));
1491        }
1492    });
1493}
1494
1495fn push_log_line(tail: &mut VecDeque<String>, line: String) {
1496    tail.push_back(line);
1497    while tail.len() > GUIDED_LOG_LINES - 1 {
1498        tail.pop_front();
1499    }
1500}
1501
1502fn record_log_line(tail: &mut VecDeque<String>, line: String, interactive: bool) {
1503    let line = super::redact_sensitive_text(&line);
1504    if !interactive {
1505        println!("{line}");
1506    }
1507    push_log_line(tail, line);
1508}
1509
1510fn render_log_window(
1511    label: &str,
1512    tail: &VecDeque<String>,
1513    started: Instant,
1514    tick: usize,
1515    done: Option<bool>,
1516    rendered: &mut bool,
1517) -> Result<()> {
1518    let mut stdout = io::stdout();
1519    if *rendered {
1520        write!(stdout, "\x1b[{GUIDED_LOG_LINES}F")?;
1521    }
1522    let status = match done {
1523        Some(true) => "done",
1524        Some(false) => "failed",
1525        None => "running",
1526    };
1527    let spinner = match done {
1528        Some(true) => "OK",
1529        Some(false) => "XX",
1530        None => ["|", "/", "-", "\\"][tick % 4],
1531    };
1532    let header = format!(
1533        "{spinner} {label} {} {status} {}s",
1534        progress_bar(tick, done),
1535        started.elapsed().as_secs()
1536    );
1537    let mut lines = Vec::with_capacity(GUIDED_LOG_LINES);
1538    lines.push(header);
1539    lines.extend(tail.iter().cloned());
1540    while lines.len() < GUIDED_LOG_LINES {
1541        lines.push(String::new());
1542    }
1543    let width = terminal_width();
1544    for line in lines.into_iter().take(GUIDED_LOG_LINES) {
1545        writeln!(stdout, "\x1b[K{}", fit_terminal_line(&line, width))?;
1546    }
1547    stdout.flush()?;
1548    *rendered = true;
1549    Ok(())
1550}
1551
1552fn progress_bar(tick: usize, done: Option<bool>) -> String {
1553    let width = 18usize;
1554    if let Some(true) = done {
1555        return format!("[{}]", "=".repeat(width));
1556    }
1557    if let Some(false) = done {
1558        return format!("[{}]", "!".repeat(width));
1559    }
1560    let mut cells = vec![' '; width];
1561    let start = tick % (width - 3);
1562    for cell in cells.iter_mut().skip(start).take(3) {
1563        *cell = '=';
1564    }
1565    format!("[{}]", cells.into_iter().collect::<String>())
1566}
1567
1568fn terminal_width() -> usize {
1569    env::var("COLUMNS")
1570        .ok()
1571        .and_then(|value| value.parse::<usize>().ok())
1572        .filter(|value| *value > 24)
1573        .unwrap_or(120)
1574}
1575
1576fn fit_terminal_line(line: &str, width: usize) -> String {
1577    let max = width.saturating_sub(1).max(20);
1578    let mut out = String::new();
1579    for (idx, ch) in line.chars().enumerate() {
1580        if idx + 3 >= max {
1581            out.push_str("...");
1582            return out;
1583        }
1584        out.push(ch);
1585    }
1586    out
1587}
1588
1589fn export_ci_checklist(cx: &PublishContext) {
1590    println!(
1591        "\nCI checklist for {} / {}",
1592        cx.target.as_str(),
1593        cx.provider.as_str()
1594    );
1595    println!("Set non-secret config in fission.toml. Store these as CI secrets or provider variables; do not commit values.");
1596    match cx.provider {
1597        DistributionProvider::PlayStore => {
1598            println!(
1599                "ANDROID_KEYSTORE_BASE64=$(base64 -i ~/.fission/{}/upload-key.jks)",
1600                sanitize_workspace_name(&cx.app_name)
1601            );
1602            println!("ANDROID_KEYSTORE_PASSWORD=<secret>");
1603            println!("ANDROID_KEYSTORE_ALIAS={}", default_android_alias(cx));
1604            println!("ANDROID_KEY_PASSWORD=<secret>");
1605            println!(
1606                "PLAY_STORE_SERVICE_ACCOUNT_JSON_BASE64=$(base64 -i ~/.fission/{}/play-service-account.json)",
1607                sanitize_workspace_name(&cx.app_name)
1608            );
1609        }
1610        DistributionProvider::AppStore => {
1611            println!("APP_STORE_CONNECT_API_KEY_BASE64=<base64 .p8 key>");
1612            println!("APP_STORE_CONNECT_KEY_ID=<key id>");
1613            println!("APP_STORE_CONNECT_ISSUER_ID=<issuer id>");
1614        }
1615        DistributionProvider::MicrosoftStore => {
1616            println!("WINDOWS_CERTIFICATE_BASE64=<base64 pfx>");
1617            println!("WINDOWS_CERTIFICATE_PASSWORD=<secret>");
1618            println!("AZURE_TENANT_ID=<tenant id>");
1619            println!("AZURE_CLIENT_ID=<client id>");
1620            println!("MICROSOFT_STORE_CLIENT_SECRET=<secret>");
1621        }
1622        DistributionProvider::S3 => {
1623            println!("AWS_PROFILE=<local only, or omit in CI>");
1624            println!("AWS_REGION=<region>");
1625            println!("AWS_WEB_IDENTITY_TOKEN_FILE=<oidc token file> # preferred CI mode");
1626            println!("AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY=<fallback secrets>");
1627        }
1628        _ => println!(
1629            "Use provider-specific env vars shown by fission auth setup --provider {}",
1630            cx.provider.as_str()
1631        ),
1632    }
1633    println!(
1634        "Track/channel: {}",
1635        cx.track.as_deref().unwrap_or("not set")
1636    );
1637    println!(
1638        "Locales: {}",
1639        if cx.locales.is_empty() {
1640            "from fission.toml".to_string()
1641        } else {
1642            cx.locales.join(",")
1643        }
1644    );
1645    println!("\nRecommended non-interactive CI commands:");
1646    println!(
1647        "fission readiness release --project-dir . --target {} --format {} --provider {}{} --json",
1648        cx.target.as_str(),
1649        cx.format.as_str(),
1650        cx.provider.as_str(),
1651        ci_track_locale_args(cx)
1652    );
1653    println!(
1654        "fission package --project-dir . --target {} --format {} --release --json",
1655        cx.target.as_str(),
1656        cx.format.as_str()
1657    );
1658    println!(
1659        "fission publish --project-dir . --provider {} --artifact target/fission/release/{}/{}/artifact-manifest.json{} --yes --json",
1660        cx.provider.as_str(),
1661        cx.target.as_str(),
1662        cx.format.as_str(),
1663        ci_track_locale_args(cx)
1664    );
1665    pause("Press Enter to continue.");
1666}
1667
1668fn ci_track_locale_args(cx: &PublishContext) -> String {
1669    let mut args = String::new();
1670    if let Some(track) = cx.track.as_deref().filter(|track| !track.trim().is_empty()) {
1671        args.push_str(" --track ");
1672        args.push_str(track);
1673    }
1674    for locale in &cx.locales {
1675        args.push_str(" --locale ");
1676        args.push_str(locale);
1677    }
1678    args
1679}
1680
1681fn select_file(start: &Path, extension: Option<&str>) -> Result<PathBuf> {
1682    let base = if start.exists() {
1683        if start.is_file() {
1684            start.parent().unwrap_or(Path::new("/")).to_path_buf()
1685        } else {
1686            start.to_path_buf()
1687        }
1688    } else {
1689        env::current_dir()?
1690    };
1691    loop {
1692        let expected = extension.map(|ext| format!(".{ext} ")).unwrap_or_default();
1693        println!(
1694            "\nEnter {}file path. Relative paths are resolved from the current directory first, then {}.",
1695            expected,
1696            base.display()
1697        );
1698        let choice = prompt("Path (q to cancel)")?;
1699        let choice = trim_path_input(&choice);
1700        if choice.eq_ignore_ascii_case("q") {
1701            bail!("file selection cancelled");
1702        }
1703        if choice.trim().is_empty() {
1704            println!("Path is required.");
1705            continue;
1706        }
1707        let path = resolve_input_file_path(choice, &base);
1708        if path.is_file() {
1709            validate_extension(&path, extension)?;
1710            return Ok(path);
1711        }
1712        println!("Not a file: {}", path.display());
1713    }
1714}
1715
1716fn trim_path_input(input: &str) -> &str {
1717    let input = input.trim();
1718    if input.len() >= 2 {
1719        let bytes = input.as_bytes();
1720        if (bytes[0] == b'"' && bytes[input.len() - 1] == b'"')
1721            || (bytes[0] == b'\'' && bytes[input.len() - 1] == b'\'')
1722        {
1723            return &input[1..input.len() - 1];
1724        }
1725    }
1726    input
1727}
1728
1729fn resolve_input_file_path(input: &str, base: &Path) -> PathBuf {
1730    let path = expand_home_path(input);
1731    if path.is_absolute() {
1732        return path;
1733    }
1734    let cwd_path = env::current_dir()
1735        .unwrap_or_else(|_| PathBuf::from("."))
1736        .join(&path);
1737    if cwd_path.is_file() {
1738        return cwd_path;
1739    }
1740    let base_path = base.join(path);
1741    if base_path.is_file() {
1742        return base_path;
1743    }
1744    cwd_path
1745}
1746
1747fn expand_home_path(input: &str) -> PathBuf {
1748    if input == "~" {
1749        return env::var_os("HOME")
1750            .map(PathBuf::from)
1751            .unwrap_or_else(|| PathBuf::from(input));
1752    }
1753    if let Some(rest) = input.strip_prefix("~/") {
1754        if let Some(home) = env::var_os("HOME") {
1755            return PathBuf::from(home).join(rest);
1756        }
1757    }
1758    PathBuf::from(input)
1759}
1760
1761fn validate_extension(path: &Path, extension: Option<&str>) -> Result<()> {
1762    if let Some(extension) = extension {
1763        let actual = path.extension().and_then(OsStr::to_str).unwrap_or_default();
1764        if !actual.eq_ignore_ascii_case(extension) {
1765            println!(
1766                "Warning: expected a .{extension} file, selected {}",
1767                path.display()
1768            );
1769            if !confirm("Use it anyway?", false)? {
1770                bail!("file selection cancelled");
1771            }
1772        }
1773    }
1774    Ok(())
1775}
1776
1777fn handle_selected_file(
1778    selected: &Path,
1779    workspace: &Path,
1780    default_name: &str,
1781    project_dir: &Path,
1782) -> Result<PathBuf> {
1783    println!("Selected: {}", selected.display());
1784    println!("  1  Copy to {} (recommended)", workspace.display());
1785    println!("  2  Move to {}", workspace.display());
1786    println!("  3  Reference original path");
1787    match prompt_default("Action", "1")?.trim() {
1788        "2" => {
1789            fs::create_dir_all(workspace)?;
1790            set_private_dir_permissions(workspace)?;
1791            let dest = workspace.join(default_name);
1792            fs::rename(selected, &dest).with_context(|| {
1793                format!(
1794                    "failed to move {} to {}",
1795                    selected.display(),
1796                    dest.display()
1797                )
1798            })?;
1799            set_private_file_permissions(&dest)?;
1800            Ok(dest)
1801        }
1802        "3" => {
1803            if path_is_inside_project(selected, project_dir) {
1804                bail!(
1805                    "refusing to reference a secret file inside the project tree: {}; copy or move it to {} instead",
1806                    selected.display(),
1807                    workspace.display()
1808                );
1809            }
1810            Ok(selected.to_path_buf())
1811        }
1812        _ => {
1813            fs::create_dir_all(workspace)?;
1814            set_private_dir_permissions(workspace)?;
1815            let dest = workspace.join(default_name);
1816            fs::copy(selected, &dest).with_context(|| {
1817                format!(
1818                    "failed to copy {} to {}",
1819                    selected.display(),
1820                    dest.display()
1821                )
1822            })?;
1823            set_private_file_permissions(&dest)?;
1824            Ok(dest)
1825        }
1826    }
1827}
1828
1829fn path_is_inside_project(path: &Path, project_dir: &Path) -> bool {
1830    let Ok(path) = fs::canonicalize(path) else {
1831        return false;
1832    };
1833    let Ok(project_dir) = fs::canonicalize(project_dir) else {
1834        return false;
1835    };
1836    path.starts_with(project_dir)
1837}
1838
1839fn load_local_env(path: &Path) -> Result<()> {
1840    if !path.exists() {
1841        return Ok(());
1842    }
1843    let text =
1844        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
1845    for line in text.lines() {
1846        let line = line.trim();
1847        if line.is_empty() || line.starts_with('#') {
1848            continue;
1849        }
1850        let line = line.strip_prefix("export ").unwrap_or(line);
1851        if let Some((key, value)) = line.split_once('=') {
1852            let value = unquote_env_value(value.trim());
1853            env::set_var(key.trim(), value);
1854        }
1855    }
1856    Ok(())
1857}
1858
1859fn prompt(label: &str) -> Result<String> {
1860    print!("{label}: ");
1861    io::stdout().flush()?;
1862    let mut input = String::new();
1863    io::stdin().read_line(&mut input)?;
1864    Ok(input.trim_end().to_string())
1865}
1866
1867fn prompt_default(label: &str, default: &str) -> Result<String> {
1868    print!("{label} [{default}]: ");
1869    io::stdout().flush()?;
1870    let mut input = String::new();
1871    io::stdin().read_line(&mut input)?;
1872    let input = input.trim_end();
1873    if input.is_empty() {
1874        Ok(default.to_string())
1875    } else {
1876        Ok(input.to_string())
1877    }
1878}
1879
1880fn prompt_optional(label: &str) -> Result<Option<String>> {
1881    let value = prompt(&format!("{label} (leave blank to skip)"))?;
1882    Ok((!value.trim().is_empty()).then_some(value))
1883}
1884
1885fn confirm(label: &str, default: bool) -> Result<bool> {
1886    let suffix = if default { "Y/n" } else { "y/N" };
1887    let value = prompt(&format!("{label} [{suffix}]"))?;
1888    if value.trim().is_empty() {
1889        return Ok(default);
1890    }
1891    Ok(matches!(
1892        value.trim().to_ascii_lowercase().as_str(),
1893        "y" | "yes"
1894    ))
1895}
1896
1897fn prompt_password_confirmed(label: &str) -> Result<String> {
1898    print!("{label}: ");
1899    io::stdout().flush()?;
1900    let first = read_password()?;
1901    print!("Confirm {label}: ");
1902    io::stdout().flush()?;
1903    let second = read_password()?;
1904    if first != second {
1905        bail!("passwords did not match");
1906    }
1907    if first.is_empty() {
1908        bail!("password cannot be empty");
1909    }
1910    Ok(first)
1911}
1912
1913fn pause(message: &str) {
1914    if io::stdin().is_terminal() {
1915        let _ = prompt(message);
1916    }
1917}
1918
1919#[cfg(test)]
1920#[path = "publish_shell_tests.rs"]
1921mod tests;