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