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