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