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        json: false,
1252    })?;
1253    println!("Artifact manifest: {}", artifact.display());
1254    cx.artifact = Some(artifact);
1255    if pause_after {
1256        pause("Package step finished. Press Enter to continue.");
1257    }
1258    Ok(())
1259}
1260
1261fn offer_build_bump_if_needed(cx: &mut PublishContext, hooks: PublishShellHooks) -> Result<()> {
1262    if cx.provider != DistributionProvider::PlayStore {
1263        return Ok(());
1264    }
1265    let checks = (hooks.release_checks)(workflow_request(cx, None, true, true, false))?;
1266    let Some(check) = checks.iter().find(|check| {
1267        check.id == "release.play_store.version_code_available"
1268            && check.status == CheckStatus::Failed
1269    }) else {
1270        return Ok(());
1271    };
1272    println!("\nGoogle Play version/build issue");
1273    println!("{}", check.summary);
1274    if let Some(details) = &check.details {
1275        println!("{details}");
1276    }
1277    for fix in &check.remediation {
1278        println!("fix: {fix}");
1279    }
1280    if confirm(
1281        "Increment the Android build/version code before packaging?",
1282        true,
1283    )? {
1284        let output = (hooks.bump_build)(workflow_request(cx, None, true, true, false))?;
1285        if !output.trim().is_empty() {
1286            println!("{output}");
1287        }
1288        cx.artifact = None;
1289    }
1290    Ok(())
1291}
1292
1293fn dry_run_publish(
1294    cx: &PublishContext,
1295    options: &PublishShellOptions,
1296    hooks: PublishShellHooks,
1297    pause_after: bool,
1298) -> Result<()> {
1299    let output = (hooks.publish)(workflow_request(
1300        cx,
1301        options.deploy.clone(),
1302        true,
1303        true,
1304        false,
1305    ))?;
1306    if !output.trim().is_empty() {
1307        println!("{output}");
1308    }
1309    if pause_after {
1310        pause("Dry run finished. Press Enter to continue.");
1311    }
1312    Ok(())
1313}
1314
1315fn publish_artifact_interactive(
1316    cx: &PublishContext,
1317    options: &PublishShellOptions,
1318    hooks: PublishShellHooks,
1319) -> Result<()> {
1320    println!("\nFinal publish confirmation");
1321    println!("Target: {} / {}", cx.target.as_str(), cx.provider.as_str());
1322    println!("Track: {}", cx.track.as_deref().unwrap_or("not set"));
1323    println!("Artifact: {}", cx.artifact_manifest_path().display());
1324    println!("Type the package/app id to publish: {}", cx.app_id);
1325    let typed = prompt("Confirm package/app id")?;
1326    if typed.trim() != cx.app_id {
1327        bail!(
1328            "confirmation did not match {}; publish cancelled",
1329            cx.app_id
1330        );
1331    }
1332    publish_artifact(cx, options, hooks)
1333}
1334
1335fn publish_artifact(
1336    cx: &PublishContext,
1337    options: &PublishShellOptions,
1338    hooks: PublishShellHooks,
1339) -> Result<()> {
1340    let output = (hooks.publish)(workflow_request(
1341        cx,
1342        options.deploy.clone(),
1343        false,
1344        true,
1345        false,
1346    ))?;
1347    if !output.trim().is_empty() {
1348        println!("{output}");
1349    }
1350    Ok(())
1351}
1352
1353fn publish_command_args_from_request(request: PublishShellWorkflowRequest) -> Vec<String> {
1354    let mut args = vec![
1355        "publish".to_string(),
1356        "--provider".to_string(),
1357        request.provider.as_str().to_string(),
1358        "--site".to_string(),
1359        request.site,
1360        "--project-dir".to_string(),
1361        request.project_dir.display().to_string(),
1362    ];
1363    if request.yes {
1364        args.push("--yes".to_string());
1365    }
1366    if let Some(target) = request.target {
1367        args.push("--target".to_string());
1368        args.push(target.as_str().to_string());
1369    }
1370    if let Some(format) = request.format {
1371        args.push("--format".to_string());
1372        args.push(format.as_str().to_string());
1373    }
1374    if let Some(artifact) = request.artifact {
1375        args.push("--artifact".to_string());
1376        args.push(artifact.display().to_string());
1377    }
1378    if let Some(track) = request
1379        .track
1380        .as_ref()
1381        .filter(|value| !value.trim().is_empty())
1382    {
1383        args.push("--track".to_string());
1384        args.push(track.clone());
1385    }
1386    for locale in request.locales {
1387        args.push("--locale".to_string());
1388        args.push(locale);
1389    }
1390    if let Some(deploy) = request
1391        .deploy
1392        .as_ref()
1393        .filter(|value| !value.trim().is_empty())
1394    {
1395        args.push("--deploy".to_string());
1396        args.push(deploy.clone());
1397    }
1398    if request.dry_run {
1399        args.push("--dry-run".to_string());
1400    }
1401    if request.overwrite_remote {
1402        args.push("--overwrite-remote".to_string());
1403    }
1404    if request.json {
1405        args.push("--json".to_string());
1406    }
1407    args
1408}
1409
1410fn run_current_fission_logged(args: Vec<String>, label: &str) -> Result<()> {
1411    let exe = env::current_exe().context("failed to resolve current fission executable")?;
1412    let mut command = Command::new(exe);
1413    command.args(args);
1414    run_logged_command(&mut command, label)
1415}
1416
1417fn run_logged_command(command: &mut Command, label: &str) -> Result<()> {
1418    command
1419        .stdout(Stdio::piped())
1420        .stderr(Stdio::piped())
1421        .stdin(Stdio::null());
1422    let mut child = command
1423        .spawn()
1424        .with_context(|| format!("failed to start {label}"))?;
1425    let stdout = child.stdout.take().context("failed to capture stdout")?;
1426    let stderr = child.stderr.take().context("failed to capture stderr")?;
1427    let (tx, rx) = mpsc::channel::<String>();
1428    spawn_log_reader(stdout, tx.clone());
1429    spawn_log_reader(stderr, tx);
1430
1431    let interactive = io::stdout().is_terminal();
1432    let started = Instant::now();
1433    let mut tick = 0usize;
1434    let mut rendered = false;
1435    let mut tail = VecDeque::new();
1436
1437    loop {
1438        while let Ok(line) = rx.try_recv() {
1439            record_log_line(&mut tail, line, interactive);
1440        }
1441
1442        if let Some(status) = child.try_wait()? {
1443            while let Ok(line) = rx.try_recv() {
1444                record_log_line(&mut tail, line, interactive);
1445            }
1446            if interactive {
1447                render_log_window(
1448                    label,
1449                    &tail,
1450                    started,
1451                    tick,
1452                    Some(status.success()),
1453                    &mut rendered,
1454                )?;
1455            }
1456            if status.success() {
1457                if !interactive {
1458                    println!("{label}: done");
1459                }
1460                return Ok(());
1461            }
1462            let detail = tail.iter().cloned().collect::<Vec<_>>().join("\n");
1463            if detail.trim().is_empty() {
1464                bail!("{label} failed with {status}");
1465            }
1466            bail!("{label} failed with {status}\n{detail}");
1467        }
1468
1469        match rx.recv_timeout(Duration::from_millis(120)) {
1470            Ok(line) => record_log_line(&mut tail, line, interactive),
1471            Err(mpsc::RecvTimeoutError::Timeout) => {}
1472            Err(mpsc::RecvTimeoutError::Disconnected) => {}
1473        }
1474
1475        if interactive {
1476            render_log_window(label, &tail, started, tick, None, &mut rendered)?;
1477        }
1478        tick = tick.wrapping_add(1);
1479    }
1480}
1481
1482fn spawn_log_reader<R>(reader: R, tx: mpsc::Sender<String>)
1483where
1484    R: Read + Send + 'static,
1485{
1486    thread::spawn(move || {
1487        for line in BufReader::new(reader).lines() {
1488            let line = line.unwrap_or_else(|err| format!("failed to read process output: {err}"));
1489            let _ = tx.send(line.replace('\r', ""));
1490        }
1491    });
1492}
1493
1494fn push_log_line(tail: &mut VecDeque<String>, line: String) {
1495    tail.push_back(line);
1496    while tail.len() > GUIDED_LOG_LINES - 1 {
1497        tail.pop_front();
1498    }
1499}
1500
1501fn record_log_line(tail: &mut VecDeque<String>, line: String, interactive: bool) {
1502    let line = super::redact_sensitive_text(&line);
1503    if !interactive {
1504        println!("{line}");
1505    }
1506    push_log_line(tail, line);
1507}
1508
1509fn render_log_window(
1510    label: &str,
1511    tail: &VecDeque<String>,
1512    started: Instant,
1513    tick: usize,
1514    done: Option<bool>,
1515    rendered: &mut bool,
1516) -> Result<()> {
1517    let mut stdout = io::stdout();
1518    if *rendered {
1519        write!(stdout, "\x1b[{GUIDED_LOG_LINES}F")?;
1520    }
1521    let status = match done {
1522        Some(true) => "done",
1523        Some(false) => "failed",
1524        None => "running",
1525    };
1526    let spinner = match done {
1527        Some(true) => "OK",
1528        Some(false) => "XX",
1529        None => ["|", "/", "-", "\\"][tick % 4],
1530    };
1531    let header = format!(
1532        "{spinner} {label} {} {status} {}s",
1533        progress_bar(tick, done),
1534        started.elapsed().as_secs()
1535    );
1536    let mut lines = Vec::with_capacity(GUIDED_LOG_LINES);
1537    lines.push(header);
1538    lines.extend(tail.iter().cloned());
1539    while lines.len() < GUIDED_LOG_LINES {
1540        lines.push(String::new());
1541    }
1542    let width = terminal_width();
1543    for line in lines.into_iter().take(GUIDED_LOG_LINES) {
1544        writeln!(stdout, "\x1b[K{}", fit_terminal_line(&line, width))?;
1545    }
1546    stdout.flush()?;
1547    *rendered = true;
1548    Ok(())
1549}
1550
1551fn progress_bar(tick: usize, done: Option<bool>) -> String {
1552    let width = 18usize;
1553    if let Some(true) = done {
1554        return format!("[{}]", "=".repeat(width));
1555    }
1556    if let Some(false) = done {
1557        return format!("[{}]", "!".repeat(width));
1558    }
1559    let mut cells = vec![' '; width];
1560    let start = tick % (width - 3);
1561    for cell in cells.iter_mut().skip(start).take(3) {
1562        *cell = '=';
1563    }
1564    format!("[{}]", cells.into_iter().collect::<String>())
1565}
1566
1567fn terminal_width() -> usize {
1568    env::var("COLUMNS")
1569        .ok()
1570        .and_then(|value| value.parse::<usize>().ok())
1571        .filter(|value| *value > 24)
1572        .unwrap_or(120)
1573}
1574
1575fn fit_terminal_line(line: &str, width: usize) -> String {
1576    let max = width.saturating_sub(1).max(20);
1577    let mut out = String::new();
1578    for (idx, ch) in line.chars().enumerate() {
1579        if idx + 3 >= max {
1580            out.push_str("...");
1581            return out;
1582        }
1583        out.push(ch);
1584    }
1585    out
1586}
1587
1588fn export_ci_checklist(cx: &PublishContext) {
1589    println!(
1590        "\nCI checklist for {} / {}",
1591        cx.target.as_str(),
1592        cx.provider.as_str()
1593    );
1594    println!("Set non-secret config in fission.toml. Store these as CI secrets or provider variables; do not commit values.");
1595    match cx.provider {
1596        DistributionProvider::PlayStore => {
1597            println!(
1598                "ANDROID_KEYSTORE_BASE64=$(base64 -i ~/.fission/{}/upload-key.jks)",
1599                sanitize_workspace_name(&cx.app_name)
1600            );
1601            println!("ANDROID_KEYSTORE_PASSWORD=<secret>");
1602            println!("ANDROID_KEYSTORE_ALIAS={}", default_android_alias(cx));
1603            println!("ANDROID_KEY_PASSWORD=<secret>");
1604            println!(
1605                "PLAY_STORE_SERVICE_ACCOUNT_JSON_BASE64=$(base64 -i ~/.fission/{}/play-service-account.json)",
1606                sanitize_workspace_name(&cx.app_name)
1607            );
1608        }
1609        DistributionProvider::AppStore => {
1610            println!("APP_STORE_CONNECT_API_KEY_BASE64=<base64 .p8 key>");
1611            println!("APP_STORE_CONNECT_KEY_ID=<key id>");
1612            println!("APP_STORE_CONNECT_ISSUER_ID=<issuer id>");
1613        }
1614        DistributionProvider::MicrosoftStore => {
1615            println!("WINDOWS_CERTIFICATE_BASE64=<base64 pfx>");
1616            println!("WINDOWS_CERTIFICATE_PASSWORD=<secret>");
1617            println!("AZURE_TENANT_ID=<tenant id>");
1618            println!("AZURE_CLIENT_ID=<client id>");
1619            println!("MICROSOFT_STORE_CLIENT_SECRET=<secret>");
1620        }
1621        DistributionProvider::S3 => {
1622            println!("AWS_PROFILE=<local only, or omit in CI>");
1623            println!("AWS_REGION=<region>");
1624            println!("AWS_WEB_IDENTITY_TOKEN_FILE=<oidc token file> # preferred CI mode");
1625            println!("AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY=<fallback secrets>");
1626        }
1627        _ => println!(
1628            "Use provider-specific env vars shown by fission auth setup --provider {}",
1629            cx.provider.as_str()
1630        ),
1631    }
1632    println!(
1633        "Track/channel: {}",
1634        cx.track.as_deref().unwrap_or("not set")
1635    );
1636    println!(
1637        "Locales: {}",
1638        if cx.locales.is_empty() {
1639            "from fission.toml".to_string()
1640        } else {
1641            cx.locales.join(",")
1642        }
1643    );
1644    println!("\nRecommended non-interactive CI commands:");
1645    println!(
1646        "fission readiness release --project-dir . --target {} --format {} --provider {}{} --json",
1647        cx.target.as_str(),
1648        cx.format.as_str(),
1649        cx.provider.as_str(),
1650        ci_track_locale_args(cx)
1651    );
1652    println!(
1653        "fission package --project-dir . --target {} --format {} --release --json",
1654        cx.target.as_str(),
1655        cx.format.as_str()
1656    );
1657    println!(
1658        "fission publish --project-dir . --provider {} --artifact target/fission/release/{}/{}/artifact-manifest.json{} --yes --json",
1659        cx.provider.as_str(),
1660        cx.target.as_str(),
1661        cx.format.as_str(),
1662        ci_track_locale_args(cx)
1663    );
1664    pause("Press Enter to continue.");
1665}
1666
1667fn ci_track_locale_args(cx: &PublishContext) -> String {
1668    let mut args = String::new();
1669    if let Some(track) = cx.track.as_deref().filter(|track| !track.trim().is_empty()) {
1670        args.push_str(" --track ");
1671        args.push_str(track);
1672    }
1673    for locale in &cx.locales {
1674        args.push_str(" --locale ");
1675        args.push_str(locale);
1676    }
1677    args
1678}
1679
1680fn select_file(start: &Path, extension: Option<&str>) -> Result<PathBuf> {
1681    let base = if start.exists() {
1682        if start.is_file() {
1683            start.parent().unwrap_or(Path::new("/")).to_path_buf()
1684        } else {
1685            start.to_path_buf()
1686        }
1687    } else {
1688        env::current_dir()?
1689    };
1690    loop {
1691        let expected = extension.map(|ext| format!(".{ext} ")).unwrap_or_default();
1692        println!(
1693            "\nEnter {}file path. Relative paths are resolved from the current directory first, then {}.",
1694            expected,
1695            base.display()
1696        );
1697        let choice = prompt("Path (q to cancel)")?;
1698        let choice = trim_path_input(&choice);
1699        if choice.eq_ignore_ascii_case("q") {
1700            bail!("file selection cancelled");
1701        }
1702        if choice.trim().is_empty() {
1703            println!("Path is required.");
1704            continue;
1705        }
1706        let path = resolve_input_file_path(choice, &base);
1707        if path.is_file() {
1708            validate_extension(&path, extension)?;
1709            return Ok(path);
1710        }
1711        println!("Not a file: {}", path.display());
1712    }
1713}
1714
1715fn trim_path_input(input: &str) -> &str {
1716    let input = input.trim();
1717    if input.len() >= 2 {
1718        let bytes = input.as_bytes();
1719        if (bytes[0] == b'"' && bytes[input.len() - 1] == b'"')
1720            || (bytes[0] == b'\'' && bytes[input.len() - 1] == b'\'')
1721        {
1722            return &input[1..input.len() - 1];
1723        }
1724    }
1725    input
1726}
1727
1728fn resolve_input_file_path(input: &str, base: &Path) -> PathBuf {
1729    let path = expand_home_path(input);
1730    if path.is_absolute() {
1731        return path;
1732    }
1733    let cwd_path = env::current_dir()
1734        .unwrap_or_else(|_| PathBuf::from("."))
1735        .join(&path);
1736    if cwd_path.is_file() {
1737        return cwd_path;
1738    }
1739    let base_path = base.join(path);
1740    if base_path.is_file() {
1741        return base_path;
1742    }
1743    cwd_path
1744}
1745
1746fn expand_home_path(input: &str) -> PathBuf {
1747    if input == "~" {
1748        return env::var_os("HOME")
1749            .map(PathBuf::from)
1750            .unwrap_or_else(|| PathBuf::from(input));
1751    }
1752    if let Some(rest) = input.strip_prefix("~/") {
1753        if let Some(home) = env::var_os("HOME") {
1754            return PathBuf::from(home).join(rest);
1755        }
1756    }
1757    PathBuf::from(input)
1758}
1759
1760fn validate_extension(path: &Path, extension: Option<&str>) -> Result<()> {
1761    if let Some(extension) = extension {
1762        let actual = path.extension().and_then(OsStr::to_str).unwrap_or_default();
1763        if !actual.eq_ignore_ascii_case(extension) {
1764            println!(
1765                "Warning: expected a .{extension} file, selected {}",
1766                path.display()
1767            );
1768            if !confirm("Use it anyway?", false)? {
1769                bail!("file selection cancelled");
1770            }
1771        }
1772    }
1773    Ok(())
1774}
1775
1776fn handle_selected_file(
1777    selected: &Path,
1778    workspace: &Path,
1779    default_name: &str,
1780    project_dir: &Path,
1781) -> Result<PathBuf> {
1782    println!("Selected: {}", selected.display());
1783    println!("  1  Copy to {} (recommended)", workspace.display());
1784    println!("  2  Move to {}", workspace.display());
1785    println!("  3  Reference original path");
1786    match prompt_default("Action", "1")?.trim() {
1787        "2" => {
1788            fs::create_dir_all(workspace)?;
1789            set_private_dir_permissions(workspace)?;
1790            let dest = workspace.join(default_name);
1791            fs::rename(selected, &dest).with_context(|| {
1792                format!(
1793                    "failed to move {} to {}",
1794                    selected.display(),
1795                    dest.display()
1796                )
1797            })?;
1798            set_private_file_permissions(&dest)?;
1799            Ok(dest)
1800        }
1801        "3" => {
1802            if path_is_inside_project(selected, project_dir) {
1803                bail!(
1804                    "refusing to reference a secret file inside the project tree: {}; copy or move it to {} instead",
1805                    selected.display(),
1806                    workspace.display()
1807                );
1808            }
1809            Ok(selected.to_path_buf())
1810        }
1811        _ => {
1812            fs::create_dir_all(workspace)?;
1813            set_private_dir_permissions(workspace)?;
1814            let dest = workspace.join(default_name);
1815            fs::copy(selected, &dest).with_context(|| {
1816                format!(
1817                    "failed to copy {} to {}",
1818                    selected.display(),
1819                    dest.display()
1820                )
1821            })?;
1822            set_private_file_permissions(&dest)?;
1823            Ok(dest)
1824        }
1825    }
1826}
1827
1828fn path_is_inside_project(path: &Path, project_dir: &Path) -> bool {
1829    let Ok(path) = fs::canonicalize(path) else {
1830        return false;
1831    };
1832    let Ok(project_dir) = fs::canonicalize(project_dir) else {
1833        return false;
1834    };
1835    path.starts_with(project_dir)
1836}
1837
1838fn load_local_env(path: &Path) -> Result<()> {
1839    if !path.exists() {
1840        return Ok(());
1841    }
1842    let text =
1843        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
1844    for line in text.lines() {
1845        let line = line.trim();
1846        if line.is_empty() || line.starts_with('#') {
1847            continue;
1848        }
1849        let line = line.strip_prefix("export ").unwrap_or(line);
1850        if let Some((key, value)) = line.split_once('=') {
1851            let value = unquote_env_value(value.trim());
1852            env::set_var(key.trim(), value);
1853        }
1854    }
1855    Ok(())
1856}
1857
1858fn prompt(label: &str) -> Result<String> {
1859    print!("{label}: ");
1860    io::stdout().flush()?;
1861    let mut input = String::new();
1862    io::stdin().read_line(&mut input)?;
1863    Ok(input.trim_end().to_string())
1864}
1865
1866fn prompt_default(label: &str, default: &str) -> Result<String> {
1867    print!("{label} [{default}]: ");
1868    io::stdout().flush()?;
1869    let mut input = String::new();
1870    io::stdin().read_line(&mut input)?;
1871    let input = input.trim_end();
1872    if input.is_empty() {
1873        Ok(default.to_string())
1874    } else {
1875        Ok(input.to_string())
1876    }
1877}
1878
1879fn prompt_optional(label: &str) -> Result<Option<String>> {
1880    let value = prompt(&format!("{label} (leave blank to skip)"))?;
1881    Ok((!value.trim().is_empty()).then_some(value))
1882}
1883
1884fn confirm(label: &str, default: bool) -> Result<bool> {
1885    let suffix = if default { "Y/n" } else { "y/N" };
1886    let value = prompt(&format!("{label} [{suffix}]"))?;
1887    if value.trim().is_empty() {
1888        return Ok(default);
1889    }
1890    Ok(matches!(
1891        value.trim().to_ascii_lowercase().as_str(),
1892        "y" | "yes"
1893    ))
1894}
1895
1896fn prompt_password_confirmed(label: &str) -> Result<String> {
1897    print!("{label}: ");
1898    io::stdout().flush()?;
1899    let first = read_password()?;
1900    print!("Confirm {label}: ");
1901    io::stdout().flush()?;
1902    let second = read_password()?;
1903    if first != second {
1904        bail!("passwords did not match");
1905    }
1906    if first.is_empty() {
1907        bail!("password cannot be empty");
1908    }
1909    Ok(first)
1910}
1911
1912fn pause(message: &str) {
1913    if io::stdin().is_terminal() {
1914        let _ = prompt(message);
1915    }
1916}
1917
1918#[cfg(test)]
1919#[path = "publish_shell_tests.rs"]
1920mod tests;