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", ®ion)?;
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;