1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
//! Mecha10 CLI
//!
//! Command-line interface for managing Mecha10 robot projects.
//!
//! # Commands
//!
//! - `mecha10 init` - Initialize a new robot project
//! - `mecha10 dev` - Start development mode with hot-reload
//! - `mecha10 run` - Run the robot in production mode
//! - `mecha10 build` - Build the project
//! - `mecha10 train` - Submit a training job
//! - `mecha10 logs` - View logs from nodes, framework, and services (watch mode by default)
//! - `mecha10 sim` - Generate robot/environment simulation scenes
//! - `mecha10 target` - Show or update which platform(s) a target builds for
#![allow(
clippy::print_stdout,
clippy::print_stderr,
reason = "this crate IS the CLI's terminal UI -- println!/eprintln! to the user's terminal is its intended output mechanism, not debug leftovers"
)]
mod commands;
mod component_catalog;
mod config_validation;
mod context;
mod dev;
mod handlers;
mod infrastructure;
mod init;
mod paths;
mod run;
mod services;
mod sim;
mod templates;
mod types;
mod ui;
use anyhow::Result;
use clap::Parser;
use context::{CliContext, CliContextBuilder};
use types::{Cli, Commands};
#[tokio::main]
async fn main() -> Result<()> {
let _ = dotenvy::dotenv();
let sentry_dsn = std::env::var("SENTRY_DSN_CLI").unwrap_or_else(|_| {
"https://23908dad56709a668ae2f899e2c19924@o4506107376631808.ingest.us.sentry.io/4512334108098560".to_string()
});
let mut sentry_opts = sentry::ClientOptions::default();
sentry_opts.dsn = sentry_dsn.parse().ok();
sentry_opts.release = sentry::release_name!();
// `sentry::init` applies Sentry's default integrations (`ClientOptions::default_integrations`
// defaults to `true` and is left untouched here), which includes `sentry-panic`'s panic hook.
// That hook captures a Sentry event the instant a panic occurs, synchronously, independent of
// this guard's `Drop`. An uncaught panic then unwinds normally out of `main` (Rust's runtime
// wraps the user `main` in `catch_unwind`), so `_sentry_guard`'s `Drop` impl still runs during
// that unwind and flushes the already-captured event -- panics are covered with no extra code.
//
// The `Err(...)` path from `run()` below needs explicit capture+flush instead, because:
// (a) a bare `anyhow::Error` returned via `?` is never seen by `sentry-tracing`'s
// `ErrorLayer` unless something logged it via `tracing::error!` first, so nothing would
// ever be queued for it otherwise, and
// (b) `main` returning `Err` makes Rust's `Termination` impl print the error and call
// `std::process::exit(1)`, which does NOT run destructors -- so `_sentry_guard`'s
// `Drop`-based flush would never fire even if something had been queued.
let _sentry_guard = sentry::init(sentry_opts);
let cli = Cli::parse();
let log_level = match cli.log_level.as_str() {
"debug" => tracing::Level::DEBUG,
"info" => tracing::Level::INFO,
"warn" => tracing::Level::WARN,
"error" => tracing::Level::ERROR,
_ => tracing::Level::INFO,
};
let _file_log_guard = if matches!(cli.command, Commands::Dev(_)) {
let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let logs_dir = working_dir.join(paths::project::LOGS_DIR);
let _ = std::fs::create_dir_all(&logs_dir);
let file_appender = tracing_appender::rolling::never(&logs_dir, "mecha10-dev.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
// Capped at WARN regardless of `--log-level`: `dev` runs long-lived, chatty node
// processes, and every `info!` would otherwise print twice (once here, once in the
// file layer below). The file layer keeps the user's requested verbosity so nothing
// is actually lost, just moved off the terminal.
let terminal_level = log_level.min(tracing::Level::WARN);
use tracing_subscriber::prelude::*;
tracing_subscriber::registry()
.with(
tracing_subscriber::fmt::layer()
.with_target(false)
.with_filter(tracing_subscriber::filter::LevelFilter::from_level(terminal_level)),
)
.with(
tracing_subscriber::fmt::layer()
.with_target(false)
.with_ansi(false)
.with_writer(non_blocking)
.with_filter(tracing_subscriber::filter::LevelFilter::from_level(log_level)),
)
// `sentry_tracing::layer()` (the legacy combined layer) is deprecated in favor of
// individual layers -- see `sentry_tracing::SentryLayer`'s migration docs. This crate
// doesn't enable sentry-tracing's `logs` feature, so `LogLayer` isn't available; these
// three layers reproduce the legacy layer's own default (non-`logs`) behavior:
// `ERROR`/`WARN`/`INFO` spans become Sentry spans/transactions, `WARN`/`INFO` events
// become breadcrumbs attached to subsequent errors, and `ERROR` events become Sentry
// error events.
.with(sentry_tracing::SpanLayer::new().with_filter(tracing_subscriber::filter::LevelFilter::INFO))
.with(
sentry_tracing::BreadcrumbLayer::new().with_filter(tracing_subscriber::filter::filter_fn(|metadata| {
matches!(*metadata.level(), tracing::Level::WARN | tracing::Level::INFO)
})),
)
.with(sentry_tracing::ErrorLayer::new().with_filter(tracing_subscriber::filter::LevelFilter::ERROR))
.init();
Some(guard)
} else {
tracing_subscriber::fmt()
.with_max_level(log_level)
.with_target(false)
.init();
None
};
let mut ctx = CliContextBuilder::new()
.config_path(cli.config.clone())
.log_level(log_level)
.build()?;
if let Err(err) = run(&cli, &mut ctx).await {
// Explicitly capture the error to Sentry -- see the comment on `sentry::init` above for
// why neither `sentry-tracing`'s layers nor the guard's `Drop` can be relied on here.
sentry::integrations::anyhow::capture_anyhow(&err);
// Flush synchronously *before* returning, since returning `Err` from `main` exits via
// `std::process::exit`, which skips `_sentry_guard`'s `Drop` (and therefore its flush).
_sentry_guard.flush(Some(std::time::Duration::from_secs(2)));
return Err(err);
}
Ok(())
}
/// Dispatches to the handler for the parsed CLI command.
///
/// Split out of `main` so that `main` can intercept the `Result` and explicitly capture+flush to
/// Sentry on failure before the process exits -- see the comments in `main` for why that can't
/// just be a bare `?` on the match block.
async fn run(cli: &Cli, ctx: &mut CliContext) -> Result<()> {
match &cli.command {
Commands::Add { command } => {
handlers::add::handle_add(ctx, command).await?;
}
Commands::Auth { command } => {
use commands::auth::AuthCommand;
match command {
AuthCommand::Login(args) => {
handlers::auth::handle_login(args).await?;
}
AuthCommand::Logout => {
handlers::auth::handle_logout().await?;
}
AuthCommand::Whoami(args) => {
handlers::auth::handle_whoami(args).await?;
}
}
}
Commands::Build { command } => {
handlers::build::handle_build(ctx, command).await?;
}
Commands::BuildWorker(build_worker_args) => {
handlers::build_worker::handle_build_worker(ctx, build_worker_args).await?;
}
Commands::Cache { command } => {
use commands::cache::CacheCommand;
match command {
CacheCommand::Clean => {
handlers::cache::handle_cache_clean().await?;
}
}
}
Commands::Config { command } => {
use commands::config::ConfigCommand;
match command {
ConfigCommand::Push(args) => {
handlers::config::handle_config_push(ctx, args).await?;
}
ConfigCommand::Pull(args) => {
handlers::config::handle_config_pull(ctx, args).await?;
}
ConfigCommand::List(args) => {
handlers::config::handle_config_list(ctx, args).await?;
}
ConfigCommand::Show => {
handlers::config::handle_config_show(ctx).await?;
}
ConfigCommand::Validate(args) => {
handlers::config::handle_config_validate(ctx, args).await?;
}
ConfigCommand::Edit => {
handlers::config::handle_config_edit(ctx).await?;
}
ConfigCommand::Sync => {
handlers::config::handle_config_sync(ctx).await?;
}
}
}
Commands::Dev(dev_args) => {
handlers::dev::handle_dev(ctx, dev_args).await?;
}
Commands::Diagnostics(args) => {
handlers::diagnostics::handle_diagnostics(ctx, args).await?;
}
Commands::Format(format_args) => {
use handlers::format::FormatArgs;
let args = FormatArgs {
check: format_args.check,
};
handlers::format::handle_format(ctx, &args).await?;
}
Commands::Init(init_args) => {
handlers::init::handle_init(ctx, init_args).await?;
}
Commands::Lint(lint_args) => {
use handlers::lint::LintArgs;
let args = LintArgs { fix: lint_args.fix };
handlers::lint::handle_lint(ctx, &args).await?;
}
Commands::Logs { command } => {
handlers::logs::handle_logs_command(ctx, command).await?;
}
Commands::Model { command } => {
handlers::model::handle_model(ctx, command).await?;
}
Commands::Ps { command } => {
handlers::ps::handle_ps(ctx, command).await?;
}
Commands::Robot { command } => {
handlers::robot::handle_robot(ctx, command).await?;
}
Commands::Run { command } => {
handlers::run::handle_run(ctx, command).await?;
}
Commands::Setup(setup_args) => {
use handlers::setup::SetupArgs;
let args = SetupArgs {
check_only: setup_args.check_only,
install_deps: !setup_args.non_interactive,
quiet: false,
};
handlers::setup::handle_setup(ctx, &args).await?;
}
Commands::Sim { command } => {
handlers::sim::handle_sim(ctx, command).await?;
}
Commands::Stop { command } => {
handlers::stop::handle_stop(ctx, command).await?;
}
Commands::Target { command } => {
handlers::target::handle_target(ctx, command.as_ref()).await?;
}
Commands::Topics { command } => {
use commands::TopicsCommand;
use handlers::topics::TopicsListArgs;
match command {
TopicsCommand::List {
pattern,
verbose,
grouped,
} => {
let args = TopicsListArgs {
pattern: pattern.clone(),
verbose: *verbose,
grouped: *grouped,
};
handlers::topics::handle_topics_list(ctx, &args).await?;
}
}
}
Commands::Topology(topology_args) => {
handlers::topology::handle_topology(ctx, topology_args).await?;
}
Commands::Train(train_args) => {
handlers::train::handle_train(ctx, train_args).await?;
}
Commands::Upload(upload_args) => {
handlers::upload::handle_upload(ctx, upload_args).await?;
}
Commands::Validate(args) => {
handlers::config::handle_config_validate(ctx, args).await?;
}
}
Ok(())
}
#[cfg(test)]
mod sentry_tracing_layer_tests {
// Regression test for the `sentry_tracing::layer()` deprecation (migrated to the individual
// `SpanLayer`/`BreadcrumbLayer`/`ErrorLayer` layers in `main`'s dev-mode tracing setup, since
// this crate doesn't enable sentry-tracing's `logs` feature for `LogLayer`). Unlike the
// legacy combined layer, these individual layers are documented as safe to compose together
// (only mixing them *with* the legacy layer is unsupported/can panic), so this exercises that
// composition end-to-end -- building the registry, installing it as the default subscriber
// for the test's scope, and emitting a span/events through it -- to guard against a future
// edit reintroducing the deprecated call or an incompatible layer combination.
use tracing_subscriber::prelude::*;
#[test]
fn sentry_layers_compose_and_handle_events_without_panicking() {
let subscriber = tracing_subscriber::registry()
.with(sentry_tracing::SpanLayer::new().with_filter(tracing_subscriber::filter::LevelFilter::INFO))
.with(
sentry_tracing::BreadcrumbLayer::new().with_filter(tracing_subscriber::filter::filter_fn(|metadata| {
matches!(*metadata.level(), tracing::Level::WARN | tracing::Level::INFO)
})),
)
.with(sentry_tracing::ErrorLayer::new().with_filter(tracing_subscriber::filter::LevelFilter::ERROR));
tracing::subscriber::with_default(subscriber, || {
let _span = tracing::info_span!("test span").entered();
tracing::warn!("test warning breadcrumb");
tracing::error!("test error event");
});
}
}
#[cfg(test)]
mod sentry_error_capture_tests {
// Regression test for the bug where a command failing via a bare `?` out of `main` never
// reached Sentry at all: `sentry-tracing`'s `ErrorLayer` only turns `tracing::error!`
// log/span events into Sentry events, so it never sees a plain `anyhow::Error` propagated by
// `?` unless something along the way separately logged it. `main` now explicitly calls
// `sentry::integrations::anyhow::capture_anyhow` on `run()`'s `Err`, so this test exercises
// that exact call -- using `sentry::test::with_captured_events` (the `test`-feature dev-only
// transport, see Cargo.toml) to assert an event is actually produced, without a live network
// call -- rather than the flush/exit-code wiring around it, which isn't observable this way.
use anyhow::anyhow;
#[test]
fn capture_anyhow_on_run_error_produces_a_sentry_event() {
let err = anyhow!("boom: something in a command handler failed");
let events = sentry::test::with_captured_events(|| {
sentry::integrations::anyhow::capture_anyhow(&err);
});
assert_eq!(events.len(), 1, "capture_anyhow should queue exactly one event");
let message = events[0]
.exception
.iter()
.find_map(|exc| exc.value.as_deref())
.unwrap_or_default();
assert!(
message.contains("boom: something in a command handler failed"),
"captured event should carry the anyhow error's message, got: {message:?}"
);
}
}