1#[cfg(feature = "server")]
6use crate::output::server;
7use crate::{
8 output::{deeplink, fml_cli},
9 protocol::StartAppProtocol,
10 sources::ManifestSource,
11 value_utils::{
12 self, prepare_experiment, prepare_rollout, try_find_branches_from_experiment,
13 try_find_features_from_branch, CliUtils,
14 },
15 AppCommand, AppOpenArgs, ExperimentListSource, ExperimentSource, LaunchableApp, NimbusApp,
16};
17use anyhow::{bail, Result};
18use nimbus_fml::intermediate_representation::FeatureManifest;
19use serde_json::{json, Value};
20use std::io::Write;
21use std::path::PathBuf;
22use std::process::Command;
23use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
24
25pub(crate) fn process_cmd(cmd: &AppCommand) -> Result<bool> {
26 let status = match cmd {
27 AppCommand::ApplyFile {
28 app,
29 open,
30 list,
31 preserve_nimbus_db,
32 } => app.apply_list(open, list, preserve_nimbus_db)?,
33 AppCommand::CaptureLogs { app, file } => app.capture_logs(file)?,
34 AppCommand::Defaults {
35 manifest,
36 feature_id,
37 output,
38 } => manifest.print_defaults(feature_id.as_ref(), output.as_ref())?,
39 AppCommand::Enroll {
40 app,
41 params,
42 experiment,
43 rollouts,
44 branch,
45 preserve_targeting,
46 preserve_bucketing,
47 preserve_nimbus_db,
48 open,
49 ..
50 } => app.enroll(
51 params,
52 experiment,
53 rollouts,
54 branch,
55 preserve_targeting,
56 preserve_bucketing,
57 preserve_nimbus_db,
58 open,
59 )?,
60 AppCommand::ExtractFeatures {
61 experiment,
62 branch,
63 manifest,
64 feature_id,
65 validate,
66 multi,
67 output,
68 } => experiment.print_features(
69 branch,
70 manifest,
71 feature_id.as_ref(),
72 *validate,
73 *multi,
74 output.as_ref(),
75 )?,
76
77 AppCommand::FetchList { list, file } => list.fetch_list(file.as_ref())?,
78 AppCommand::FmlPassthrough { args, cwd } => fml_cli(args, cwd)?,
79 AppCommand::Info { experiment, output } => experiment.print_info(output.as_ref())?,
80 AppCommand::Kill { app } => app.kill_app()?,
81 AppCommand::List { list, .. } => list.print_list()?,
82 AppCommand::LogState { app, open } => app.log_state(open)?,
83 AppCommand::NoOp => true,
84 AppCommand::Open {
85 app, open: args, ..
86 } => app.open(args)?,
87 AppCommand::Reset { app } => app.reset_app()?,
88 #[cfg(feature = "server")]
89 AppCommand::StartServer => server::start_server()?,
90 AppCommand::TailLogs { app } => app.tail_logs()?,
91 AppCommand::Unenroll { app, open } => app.unenroll_all(open)?,
92 AppCommand::ValidateExperiment {
93 params,
94 manifest,
95 experiment,
96 } => params.validate_experiment(manifest, experiment)?,
97 };
98
99 Ok(status)
100}
101
102fn prompt(stream: &mut StandardStream, command: &str) -> Result<()> {
103 stream.set_color(ColorSpec::new().set_fg(Some(Color::Cyan)))?;
104 write!(stream, "$ ")?;
105 stream.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))?;
106 writeln!(stream, "{}", command)?;
107 stream.reset()?;
108
109 Ok(())
110}
111
112fn output_ok(stream: &mut StandardStream, title: &str) -> Result<()> {
113 write!(stream, "✅ ")?;
114 stream.set_color(ColorSpec::new().set_fg(Some(Color::Green)))?;
115 writeln!(stream, "{title}")?;
116 stream.reset()?;
117
118 Ok(())
119}
120
121fn output_err(stream: &mut StandardStream, title: &str, detail: &str) -> Result<()> {
122 write!(stream, "❎ ")?;
123 stream.set_color(ColorSpec::new().set_fg(Some(Color::Red)))?;
124 write!(stream, "{title}")?;
125 stream.reset()?;
126 writeln!(stream, ": {detail}")?;
127
128 Ok(())
129}
130
131impl LaunchableApp {
132 #[cfg(feature = "server")]
133 fn platform(&self) -> &str {
134 match self {
135 Self::Android { .. } => "android",
136 Self::Ios { .. } => "ios",
137 }
138 }
139
140 fn exe(&self) -> Result<Command> {
141 Ok(match self {
142 Self::Android { device_id, .. } => {
143 let adb_name = if std::env::consts::OS != "windows" {
144 "adb"
145 } else {
146 "adb.exe"
147 };
148 let adb = std::env::var("ADB_PATH").unwrap_or_else(|_| adb_name.to_string());
149 let mut cmd = Command::new(adb);
150 if let Some(id) = device_id {
151 cmd.args(["-s", id]);
152 }
153 cmd
154 }
155 Self::Ios { .. } => {
156 if std::env::consts::OS != "macos" {
157 panic!("Cannot run commands for iOS on anything except macOS");
158 }
159 let xcrun = std::env::var("XCRUN_PATH").unwrap_or_else(|_| "xcrun".to_string());
160 let mut cmd = Command::new(xcrun);
161 cmd.arg("simctl");
162 cmd
163 }
164 })
165 }
166
167 fn kill_app(&self) -> Result<bool> {
168 Ok(match self {
169 Self::Android { package_name, .. } => self
170 .exe()?
171 .arg("shell")
172 .arg(format!("am force-stop {}", package_name))
173 .spawn()?
174 .wait()?
175 .success(),
176 Self::Ios {
177 app_id, device_id, ..
178 } => {
179 let _ = self
180 .exe()?
181 .args(["terminate", device_id, app_id])
182 .output()?;
183 true
184 }
185 })
186 }
187
188 fn unenroll_all(&self, open: &AppOpenArgs) -> Result<bool> {
189 let payload = TryFrom::try_from(&ExperimentListSource::Empty)?;
190 let protocol = StartAppProtocol {
191 log_state: true,
192 experiments: Some(&payload),
193 ..Default::default()
194 };
195 self.start_app(protocol, open)
196 }
197
198 fn reset_app(&self) -> Result<bool> {
199 Ok(match self {
200 Self::Android { package_name, .. } => self
201 .exe()?
202 .arg("shell")
203 .arg(format!("pm clear {}", package_name))
204 .spawn()?
205 .wait()?
206 .success(),
207 Self::Ios {
208 app_id, device_id, ..
209 } => {
210 self.exe()?
211 .args(["privacy", device_id, "reset", "all", app_id])
212 .status()?;
213 let data = self.ios_app_container("data")?;
214 let groups = self.ios_app_container("groups")?;
215 self.ios_reset(data, groups)?;
216 true
217 }
218 })
219 }
220
221 fn tail_logs(&self) -> Result<bool> {
222 let mut stdout = StandardStream::stdout(ColorChoice::Auto);
223
224 Ok(match self {
225 Self::Android { .. } => {
226 let mut args = logcat_args();
227 args.append(&mut vec!["-v", "color"]);
228 prompt(&mut stdout, &format!("adb {}", args.join(" ")))?;
229 self.exe()?.args(args).spawn()?.wait()?.success()
230 }
231 Self::Ios { .. } => {
232 prompt(
233 &mut stdout,
234 &format!("{} | xargs tail -f", self.ios_log_file_command()),
235 )?;
236 let log = self.ios_log_file()?;
237
238 Command::new("tail")
239 .arg("-f")
240 .arg(log.as_path().to_str().unwrap())
241 .spawn()?
242 .wait()?
243 .success()
244 }
245 })
246 }
247
248 fn capture_logs(&self, file: &PathBuf) -> Result<bool> {
249 let mut stdout = StandardStream::stdout(ColorChoice::Auto);
250
251 Ok(match self {
252 Self::Android { .. } => {
253 let mut args = logcat_args();
254 args.append(&mut vec!["-d"]);
255 prompt(
256 &mut stdout,
257 &format!(
258 "adb {} > {}",
259 args.join(" "),
260 file.as_path().to_str().unwrap()
261 ),
262 )?;
263 let output = self.exe()?.args(args).output()?;
264 std::fs::write(file, String::from_utf8_lossy(&output.stdout).to_string())?;
265 true
266 }
267
268 Self::Ios { .. } => {
269 let log = self.ios_log_file()?;
270 prompt(
271 &mut stdout,
272 &format!(
273 "{} | xargs -J %log_file% cp %log_file% {}",
274 self.ios_log_file_command(),
275 file.as_path().to_str().unwrap()
276 ),
277 )?;
278 std::fs::copy(log, file)?;
279 true
280 }
281 })
282 }
283
284 fn ios_log_file(&self) -> Result<PathBuf> {
285 let data = self.ios_app_container("data")?;
286 let mut files = glob::glob(&format!("{}/**/*.log", data))?;
287 let log = files.next();
288 Ok(log.ok_or_else(|| {
289 anyhow::Error::msg(
290 "Logs are not available before the app is started for the first time",
291 )
292 })??)
293 }
294
295 fn ios_log_file_command(&self) -> String {
296 if let Self::Ios {
297 device_id, app_id, ..
298 } = self
299 {
300 format!(
301 "find $(xcrun simctl get_app_container {0} {1} data) -name \\*.log",
302 device_id, app_id
303 )
304 } else {
305 unreachable!()
306 }
307 }
308
309 fn log_state(&self, open: &AppOpenArgs) -> Result<bool> {
310 let protocol = StartAppProtocol {
311 log_state: true,
312 ..Default::default()
313 };
314 self.start_app(protocol, open)
315 }
316
317 #[allow(clippy::too_many_arguments)]
318 fn enroll(
319 &self,
320 params: &NimbusApp,
321 experiment: &ExperimentSource,
322 rollouts: &Vec<ExperimentSource>,
323 branch: &str,
324 preserve_targeting: &bool,
325 preserve_bucketing: &bool,
326 preserve_nimbus_db: &bool,
327 open: &AppOpenArgs,
328 ) -> Result<bool> {
329 let mut stdout = StandardStream::stdout(ColorChoice::Auto);
330
331 let experiment = Value::try_from(experiment)?;
332 let slug = experiment.get_str("slug")?.to_string();
333
334 let mut recipes = vec![prepare_experiment(
335 &experiment,
336 params,
337 branch,
338 *preserve_targeting,
339 *preserve_bucketing,
340 )?];
341 prompt(
342 &mut stdout,
343 &format!("# Enrolling in the '{0}' branch of '{1}'", branch, &slug),
344 )?;
345
346 for r in rollouts {
347 let rollout = Value::try_from(r)?;
348 let slug = rollout.get_str("slug")?.to_string();
349 recipes.push(prepare_rollout(
350 &rollout,
351 params,
352 *preserve_targeting,
353 *preserve_bucketing,
354 )?);
355 prompt(
356 &mut stdout,
357 &format!("# Enrolling into the '{0}' rollout", &slug),
358 )?;
359 }
360
361 let payload = json! {{ "data": recipes }};
362 let protocol = StartAppProtocol {
363 reset_db: !preserve_nimbus_db,
364 experiments: Some(&payload),
365 log_state: true,
366 };
367 self.start_app(protocol, open)
368 }
369
370 fn apply_list(
371 &self,
372 open: &AppOpenArgs,
373 list: &ExperimentListSource,
374 preserve_nimbus_db: &bool,
375 ) -> Result<bool> {
376 let value: Value = list.try_into()?;
377
378 let protocol = StartAppProtocol {
379 reset_db: !preserve_nimbus_db,
380 experiments: Some(&value),
381 log_state: true,
382 };
383 self.start_app(protocol, open)
384 }
385
386 fn ios_app_container(&self, container: &str) -> Result<String> {
387 if let Self::Ios {
388 app_id, device_id, ..
389 } = self
390 {
391 let output = self
393 .exe()?
394 .args(["get_app_container", device_id, app_id, container])
395 .output()
396 .expect("Expected an app-container from the simulator");
397 let string = String::from_utf8_lossy(&output.stdout).to_string();
398 Ok(string.trim().to_string())
399 } else {
400 unreachable!()
401 }
402 }
403
404 fn ios_reset(&self, data_dir: String, groups_string: String) -> Result<bool> {
405 let mut stdout = StandardStream::stdout(ColorChoice::Auto);
406 prompt(&mut stdout, "# Resetting the app")?;
407 if !data_dir.is_empty() {
408 prompt(&mut stdout, &format!("rm -Rf {}/* 2>/dev/null", data_dir))?;
409 let _ = std::fs::remove_dir_all(&data_dir);
410 let _ = std::fs::create_dir_all(&data_dir);
411 }
412 let lines = groups_string.split('\n');
413
414 for line in lines {
415 let words = line.splitn(2, '\t').collect::<Vec<_>>();
416 if let [_, dir] = words.as_slice() {
417 if !dir.is_empty() {
418 prompt(&mut stdout, &format!("rm -Rf {}/* 2>/dev/null", dir))?;
419 let _ = std::fs::remove_dir_all(dir);
420 let _ = std::fs::create_dir_all(dir);
421 }
422 }
423 }
424 Ok(true)
425 }
426
427 fn open(&self, open: &AppOpenArgs) -> Result<bool> {
428 self.start_app(Default::default(), open)
429 }
430
431 fn start_app(&self, app_protocol: StartAppProtocol, open: &AppOpenArgs) -> Result<bool> {
432 let mut stdout = StandardStream::stdout(ColorChoice::Auto);
433 if open.pbcopy {
434 let len = self.copy_to_clipboard(&app_protocol, open)?;
435 prompt(
436 &mut stdout,
437 &format!("# Copied a deeplink URL ({len} characters) in to the clipboard"),
438 )?;
439 }
440 #[cfg(feature = "server")]
441 if open.pbpaste {
442 let url = self.longform_url(&app_protocol, open)?;
443 let addr = server::get_address()?;
444 match server::post_deeplink(self.platform(), &url, app_protocol.experiments) {
445 Err(_) => output_err(
446 &mut stdout,
447 "Cannot post to the server",
448 "Start the server with `nimbus-cli start-server`",
449 )?,
450 _ => output_ok(&mut stdout, &format!("Posted to server at http://{addr}"))?,
451 };
452 }
453 if let Some(file) = &open.output {
454 let ex = app_protocol.experiments;
455 if let Some(contents) = ex {
456 value_utils::write_to_file_or_print(Some(file), contents)?;
457 output_ok(
458 &mut stdout,
459 &format!(
460 "Written to JSON to file {}",
461 file.to_str().unwrap_or_default()
462 ),
463 )?;
464 } else {
465 output_err(
466 &mut stdout,
467 "No content",
468 &format!("File {} not written", file.to_str().unwrap_or_default()),
469 )?;
470 }
471 }
472 if open.pbcopy || open.pbpaste || open.output.is_some() {
473 return Ok(true);
474 }
475
476 Ok(match self {
477 Self::Android { .. } => self
478 .android_start(app_protocol, open)?
479 .spawn()?
480 .wait()?
481 .success(),
482 Self::Ios { .. } => self
483 .ios_start(app_protocol, open)?
484 .spawn()?
485 .wait()?
486 .success(),
487 })
488 }
489
490 fn android_start(&self, app_protocol: StartAppProtocol, open: &AppOpenArgs) -> Result<Command> {
491 if let Self::Android {
492 package_name,
493 activity_name,
494 ..
495 } = self
496 {
497 let mut args: Vec<String> = Vec::new();
498
499 let (start_args, ending_args) = open.args();
500 args.extend_from_slice(start_args);
501
502 if let Some(deeplink) = self.deeplink(open)? {
503 args.extend([
504 "-a android.intent.action.VIEW".to_string(),
505 "-c android.intent.category.DEFAULT".to_string(),
506 "-c android.intent.category.BROWSABLE".to_string(),
507 format!("-d {}", deeplink),
508 ]);
509 } else {
510 args.extend([
511 format!("-n {}/{}", package_name, activity_name),
512 "-a android.intent.action.MAIN".to_string(),
513 "-c android.intent.category.LAUNCHER".to_string(),
514 ]);
515 }
516
517 let StartAppProtocol {
518 reset_db,
519 experiments,
520 log_state,
521 } = app_protocol;
522
523 if log_state || experiments.is_some() || reset_db {
524 args.extend(["--esn nimbus-cli".to_string(), "--ei version 1".to_string()]);
525 }
526
527 if reset_db {
528 args.push("--ez reset-db true".to_string());
529 }
530 if let Some(s) = experiments {
531 let json = s.to_string().replace('\'', "'");
532 args.push(format!("--es experiments '{}'", json))
533 }
534 if log_state {
535 args.push("--ez log-state true".to_string());
536 };
537 args.extend_from_slice(ending_args);
538
539 let sh = format!(r#"am start {}"#, args.join(" \\\n "),);
540 let mut stdout = StandardStream::stdout(ColorChoice::Auto);
541 prompt(&mut stdout, &format!("adb shell \"{}\"", sh))?;
542 let mut cmd = self.exe()?;
543 cmd.arg("shell").arg(&sh);
544 Ok(cmd)
545 } else {
546 unreachable!();
547 }
548 }
549
550 fn ios_start(&self, app_protocol: StartAppProtocol, open: &AppOpenArgs) -> Result<Command> {
551 if let Self::Ios {
552 app_id, device_id, ..
553 } = self
554 {
555 let mut args: Vec<String> = Vec::new();
556
557 let (starting_args, ending_args) = open.args();
558
559 if let Some(deeplink) = self.deeplink(open)? {
560 let deeplink = deeplink::longform_deeplink_url(&deeplink, &app_protocol)?;
561 if deeplink.len() >= 2047 {
562 anyhow::bail!("Deeplink is too long for xcrun simctl openurl. Use --pbcopy to copy the URL to the clipboard")
563 }
564 args.push("openurl".to_string());
565 args.extend_from_slice(starting_args);
566 args.extend([device_id.to_string(), deeplink]);
567 } else {
568 args.push("launch".to_string());
569 args.extend_from_slice(starting_args);
570 args.extend([device_id.to_string(), app_id.to_string()]);
571
572 let StartAppProtocol {
573 log_state,
574 experiments,
575 reset_db,
576 } = app_protocol;
577
578 if log_state || experiments.is_some() || reset_db {
579 args.extend([
580 "--nimbus-cli".to_string(),
581 "--version".to_string(),
582 "1".to_string(),
583 ]);
584 }
585
586 if reset_db {
587 args.push("--reset-db".to_string());
590 }
591 if let Some(s) = experiments {
592 args.extend([
593 "--experiments".to_string(),
594 s.to_string().replace('\'', "'"),
595 ]);
596 }
597 if log_state {
598 args.push("--log-state".to_string());
599 }
600 }
601 args.extend_from_slice(ending_args);
602
603 let mut cmd = self.exe()?;
604 cmd.args(args.clone());
605
606 let sh = format!(r#"xcrun simctl {}"#, args.join(" \\\n "),);
607 let mut stdout = StandardStream::stdout(ColorChoice::Auto);
608 prompt(&mut stdout, &sh)?;
609 Ok(cmd)
610 } else {
611 unreachable!()
612 }
613 }
614}
615
616fn logcat_args<'a>() -> Vec<&'a str> {
617 vec!["logcat", "-b", "main"]
618}
619
620impl NimbusApp {
621 fn validate_experiment(
622 &self,
623 manifest_source: &ManifestSource,
624 experiment: &ExperimentSource,
625 ) -> Result<bool> {
626 let mut stdout = StandardStream::stdout(ColorChoice::Auto);
627 let value: Value = experiment.try_into()?;
628
629 let manifest = match TryInto::<FeatureManifest>::try_into(manifest_source) {
630 Ok(manifest) => {
631 output_ok(
632 &mut stdout,
633 &format!("Loaded manifest from {manifest_source}"),
634 )?;
635 manifest
636 }
637 Err(err) => {
638 output_err(
639 &mut stdout,
640 &format!("Problem with manifest from {manifest_source}"),
641 &err.to_string(),
642 )?;
643 bail!("Error when loading and validating the manifest");
644 }
645 };
646
647 let mut is_valid = true;
648 for b in try_find_branches_from_experiment(&value)? {
649 let branch = b.get_str("slug")?;
650 for f in try_find_features_from_branch(&b)? {
651 let id = f.get_str("featureId")?;
652 let value = f
653 .get("value")
654 .unwrap_or_else(|| panic!("Branch {branch} feature {id} has no value"));
655 let res = manifest.validate_feature_config(id, value.clone());
656 match res {
657 Ok(_) => output_ok(&mut stdout, &format!("{branch: <15} {id}"))?,
658 Err(err) => {
659 is_valid = false;
660 output_err(
661 &mut stdout,
662 &format!("{branch: <15} {id}"),
663 &err.to_string(),
664 )?
665 }
666 }
667 }
668 }
669 if !is_valid {
670 bail!("At least one error detected");
671 }
672 Ok(true)
673 }
674}