mod completions;
mod lsp;
mod mcp;
mod render;
#[cfg(feature = "tui")]
use std::io::IsTerminal;
use std::process::ExitCode;
use clap::Parser;
use ignition_core::actions;
use ignition_core::client::GatewayApi as _;
use ignition_core::config::{self, Config, Credential};
use ignition_core::error::CoreError;
use ignition_core::session::Session;
use crate::render::{RenderMode, render_error, render_log_entry_line, render_ok};
use ignition_cli::cli;
use ignition_cli::cli::{
ApiArgs, ApiCommand, BackupArgs, BackupCommand, BundleCommand, Cli, Commands, DiagnosticsArgs,
DiagnosticsCommand, EamArgs, EamCommand, EamTaskCommand, EditArgs, GanArgs, GanCommand,
LicenseArgs, LicenseCommand, LintArgs, LogLevel, LoggersCmd, LogsArgs, LogsCmd, ProfileArgs,
ProfileCmd, ProjectArgs, ProjectCommand, RedundancyArgs, RedundancyCommand, ResourceArgs,
ResourceCommand, RigArgs, RigCommand, ScheduleMode, ScriptArgs, ScriptCommand, SessionsArgs,
SessionsCmd, TagsAlarmsCommand, TagsArgs, TagsCommand, TagsConfigCommand, TagsHistoryCommand,
TagsProviderCommand, TagsUdtCommand, TestingArgs, TestingCommand, WaitArgs, WaitCmd,
WebdevArgs, WebdevCommand, WorkspaceArgs, WorkspaceCommand,
};
enum ActionOutput {
Version(actions::version::VersionResult),
Status(actions::inspect::StatusResult),
Modules(actions::inspect::ModulesResult),
Metrics(actions::inspect::MetricsResult),
Sessions(actions::sessions::SessionsResult),
SessionsTerminate(actions::sessions::TerminateResult),
Connections(actions::connections::ConnectionsResult),
LogsList(actions::logs::LogPage),
LogsTail(actions::logs::TailResult),
LogsDownload(actions::logs::DownloadResult),
LoggersList(actions::logs::LoggersEnvelope),
LoggerSet(actions::logs::SetLevelResult),
LoggerReset(actions::logs::ResetResult),
Restart(actions::restart::RestartResult),
RestartWait(actions::restart::RestartWaitResult),
Wait(actions::restart::WaitResult),
Doctor(actions::doctor::DoctorResult),
Adopt(actions::adopt::AdoptResult),
SessionLogin(actions::login::SessionLoginResult),
E2eDoctor(actions::e2e::E2eDoctorResult),
E2eInit(actions::e2e::E2eInitResult),
Completions {
shell: clap_complete::aot::Shell,
},
ProfileAdd(actions::profile::ProfileAddResult),
ProfileList(actions::profile::ProfileListResult),
ProfileUse(actions::profile::ProfileUseResult),
ProjectsList(actions::projects::ProjectsResult),
ProjectNew(actions::projects::ProjectSummary),
ProjectCopy(actions::projects::ProjectCopyResult),
ProjectRename(actions::projects::ProjectRenameResult),
ProjectSet(actions::projects::ProjectSetResult),
ProjectDelete(actions::projects::ProjectDeleteResult),
ProjectExport(actions::projects::ExportResult),
ProjectExportDecoded(actions::projects::ExportDecodedResult),
ProjectImport(actions::projects::ImportResult),
ProjectDiff(actions::projects::ProjectDiffResult),
ProjectSync(actions::projects::ProjectSyncResult),
ResourcesList(actions::resources::ResourcesResult),
ResourceGet(actions::resources::ResourceGetResult),
ResourcePut(actions::resources::ResourcePutResult),
ResourceDelete(actions::resources::ResourceDeleteResult),
RigUp(actions::rig::RigUpResult),
RigDown(actions::rig::RigDownResult),
RigReset(actions::rig::RigResetResult),
RigStatus(actions::rig::RigStatusResult),
RigLogs(actions::rig::RigLogsResult),
RigSnapshot(actions::rig::SnapshotResult),
RigRestore(actions::rig::RestoreResult),
RigModuleUninstall(actions::rig::ModuleUninstallResult),
BackupDownload(actions::backup::BackupDownloadResult),
BackupRestore(actions::backup::BackupRestoreResult),
EamHistory(actions::eam::EamHistoryResult),
EamTasks(actions::eam::EamTasksResult),
EamTaskDetail(actions::eam::EamTaskDetailResult),
EamTaskCreate(actions::eam::EamTaskCreateResult),
EamTaskForce(actions::eam::EamTaskForceResult),
EamTaskLifecycle(actions::eam::EamLifecycleResult),
EamTaskModify(actions::eam::EamModifyResult),
EamTaskDelete(actions::eam::EamDeleteResult),
ScriptRun(actions::script::ScriptRunResult),
TestingRun(actions::testing::TestingRunResult),
Lint(actions::lint::LintResult),
ApiCall(actions::apicall::ApiCallOutcome),
LicenseStatus(actions::license::LicenseStatusResult),
RedundancyStatus(actions::redundancy::RedundancyStatusResult),
GanStatus(actions::gan::GanStatusResult),
BundleGenerate(actions::diagnostics::BundleStatusWire),
BundleStatus(actions::diagnostics::BundleStatusWire),
BundleWait(actions::diagnostics::BundleStatusWire),
BundleDownload(actions::diagnostics::BundleDownloadResult),
RigTrialStatus(actions::rig::TrialStatusResult),
RigTrialReset(actions::rig::TrialResetResult),
WebdevDeploy(actions::webdev::WebdevDeployResult),
WebdevStatus(actions::webdev::WebdevStatusResult),
TagProviders(actions::tags::TagProvidersResult),
TagProviderCreate(actions::tags::TagProviderCreateResult),
TagProviderDelete(actions::tags::TagProviderDeleteResult),
TagsBrowse(actions::tags::TagsBrowseResult),
TagsBrowseFromExport(actions::tags::TagBrowseFromExportResult),
TagsRead(actions::tags::TagsReadResult),
TagsWrite(actions::tags::TagsWriteResult),
TagsConfigGet(actions::tags::TagsConfigGetResult),
TagsConfigCreate(actions::tags::TagsConfigWriteResult),
TagsConfigEdit(actions::tags::TagsConfigWriteResult),
TagsConfigDelete(actions::tags::TagsConfigDeleteResult),
TagsUdtTypes(actions::tags::TagsUdtTypesResult),
TagsUdtDef(actions::tags::TagsUdtDefResult),
TagsExport(actions::tags::TagsExportResult),
TagsImport(actions::tags::TagsImportResult),
TagsAlarmsActive(actions::tags::TagsAlarmsActiveResult),
TagsAlarmsHistory(actions::tags::TagsAlarmsHistoryResult),
TagsAlarmsAck(actions::tags::TagsAlarmsAckResult),
TagsHistoryQuery(actions::tags::TagsHistoryQueryResult),
WorkspaceCheckout(actions::workspace::CheckoutOutcome),
WorkspaceStatus(actions::workspace::WorkspaceStatus),
WorkspacePush(actions::workspace::PushOutcome),
#[cfg(feature = "tui")]
TuiExited,
}
impl ActionOutput {
pub(crate) fn render_json(&self, profile: Option<&str>, compact: bool) -> String {
use ignition_core::output::render_success;
match self {
ActionOutput::Version(result) => render_success(profile, result, compact),
ActionOutput::Status(result) => render_success(profile, result, compact),
ActionOutput::Modules(result) => render_success(profile, result, compact),
ActionOutput::Metrics(result) => render_success(profile, result, compact),
ActionOutput::Sessions(result) => render_success(profile, result, compact),
ActionOutput::SessionsTerminate(result) => render_success(profile, result, compact),
ActionOutput::Connections(result) => render_success(profile, result, compact),
ActionOutput::LogsList(result) => render_success(profile, result, compact),
ActionOutput::LogsTail(result) => render_success(profile, result, compact),
ActionOutput::LogsDownload(result) => render_success(profile, result, compact),
ActionOutput::LoggersList(result) => render_success(profile, result, compact),
ActionOutput::LoggerSet(result) => render_success(profile, result, compact),
ActionOutput::LoggerReset(result) => render_success(profile, result, compact),
ActionOutput::Restart(result) => render_success(profile, result, compact),
ActionOutput::RestartWait(result) => render_success(profile, result, compact),
ActionOutput::Wait(result) => render_success(profile, result, compact),
ActionOutput::Doctor(result) => render_success(profile, result, compact),
ActionOutput::Adopt(result) => render_success(profile, result, compact),
ActionOutput::SessionLogin(result) => render_success(profile, result, compact),
ActionOutput::E2eDoctor(result) => render_success(profile, result, compact),
ActionOutput::E2eInit(result) => render_success(profile, result, compact),
ActionOutput::Completions { shell } => crate::completions::completions(*shell),
ActionOutput::ProfileAdd(result) => render_success(profile, result, compact),
ActionOutput::ProfileList(result) => render_success(profile, result, compact),
ActionOutput::ProfileUse(result) => render_success(profile, result, compact),
ActionOutput::ProjectsList(result) => render_success(profile, result, compact),
ActionOutput::ProjectNew(result) => render_success(profile, result, compact),
ActionOutput::ProjectCopy(result) => render_success(profile, result, compact),
ActionOutput::ProjectRename(result) => render_success(profile, result, compact),
ActionOutput::ProjectSet(result) => render_success(profile, result, compact),
ActionOutput::ProjectDelete(result) => render_success(profile, result, compact),
ActionOutput::ProjectExport(result) => render_success(profile, result, compact),
ActionOutput::ProjectExportDecoded(result) => render_success(profile, result, compact),
ActionOutput::ProjectImport(result) => render_success(profile, result, compact),
ActionOutput::ProjectDiff(result) => render_success(profile, result, compact),
ActionOutput::ProjectSync(result) => render_success(profile, result, compact),
ActionOutput::ResourcesList(result) => render_success(profile, result, compact),
ActionOutput::ResourceGet(result) => render_success(profile, result, compact),
ActionOutput::ResourcePut(result) => render_success(profile, result, compact),
ActionOutput::ResourceDelete(result) => render_success(profile, result, compact),
ActionOutput::RigUp(result) => render_success(profile, result, compact),
ActionOutput::RigDown(result) => render_success(profile, result, compact),
ActionOutput::RigReset(result) => render_success(profile, result, compact),
ActionOutput::RigStatus(result) => render_success(profile, result, compact),
ActionOutput::RigLogs(result) => render_success(profile, result, compact),
ActionOutput::RigSnapshot(result) => render_success(profile, result, compact),
ActionOutput::RigRestore(result) => render_success(profile, result, compact),
ActionOutput::RigModuleUninstall(result) => render_success(profile, result, compact),
ActionOutput::BackupDownload(result) => render_success(profile, result, compact),
ActionOutput::BackupRestore(result) => render_success(profile, result, compact),
ActionOutput::EamHistory(result) => render_success(profile, result, compact),
ActionOutput::EamTasks(result) => render_success(profile, result, compact),
ActionOutput::EamTaskDetail(result) => render_success(profile, result, compact),
ActionOutput::EamTaskCreate(result) => render_success(profile, result, compact),
ActionOutput::EamTaskForce(result) => render_success(profile, result, compact),
ActionOutput::EamTaskLifecycle(result) => render_success(profile, result, compact),
ActionOutput::EamTaskModify(result) => render_success(profile, result, compact),
ActionOutput::EamTaskDelete(result) => render_success(profile, result, compact),
ActionOutput::ScriptRun(result) => render_success(profile, result, compact),
ActionOutput::TestingRun(result) => render_success(profile, result, compact),
ActionOutput::Lint(result) => render_success(profile, result, compact),
ActionOutput::ApiCall(result) => render_success(profile, result, compact),
ActionOutput::LicenseStatus(result) => render_success(profile, result, compact),
ActionOutput::RedundancyStatus(result) => render_success(profile, result, compact),
ActionOutput::GanStatus(result) => render_success(profile, result, compact),
ActionOutput::BundleGenerate(result) => render_success(profile, result, compact),
ActionOutput::BundleStatus(result) => render_success(profile, result, compact),
ActionOutput::BundleWait(result) => render_success(profile, result, compact),
ActionOutput::BundleDownload(result) => render_success(profile, result, compact),
ActionOutput::RigTrialStatus(result) => render_success(profile, result, compact),
ActionOutput::RigTrialReset(result) => render_success(profile, result, compact),
ActionOutput::WebdevDeploy(result) => render_success(profile, result, compact),
ActionOutput::WebdevStatus(result) => render_success(profile, result, compact),
ActionOutput::TagProviders(result) => render_success(profile, result, compact),
ActionOutput::TagProviderCreate(result) => render_success(profile, result, compact),
ActionOutput::TagProviderDelete(result) => render_success(profile, result, compact),
ActionOutput::TagsBrowse(result) => render_success(profile, result, compact),
ActionOutput::TagsBrowseFromExport(result) => render_success(profile, result, compact),
ActionOutput::TagsRead(result) => render_success(profile, result, compact),
ActionOutput::TagsWrite(result) => render_success(profile, result, compact),
ActionOutput::TagsConfigGet(result) => render_success(profile, result, compact),
ActionOutput::TagsConfigCreate(result) => render_success(profile, result, compact),
ActionOutput::TagsConfigEdit(result) => render_success(profile, result, compact),
ActionOutput::TagsConfigDelete(result) => render_success(profile, result, compact),
ActionOutput::TagsUdtTypes(result) => render_success(profile, result, compact),
ActionOutput::TagsUdtDef(result) => render_success(profile, result, compact),
ActionOutput::TagsExport(result) => render_success(profile, result, compact),
ActionOutput::TagsImport(result) => render_success(profile, result, compact),
ActionOutput::TagsAlarmsActive(result) => render_success(profile, result, compact),
ActionOutput::TagsAlarmsHistory(result) => render_success(profile, result, compact),
ActionOutput::TagsAlarmsAck(result) => render_success(profile, result, compact),
ActionOutput::TagsHistoryQuery(result) => render_success(profile, result, compact),
ActionOutput::WorkspaceCheckout(result) => render_success(profile, result, compact),
ActionOutput::WorkspaceStatus(result) => render_success(profile, result, compact),
ActionOutput::WorkspacePush(result) => render_success(profile, result, compact),
#[cfg(feature = "tui")]
ActionOutput::TuiExited => String::new(),
}
}
}
fn main() -> ExitCode {
let mut cli = match Cli::try_parse() {
Ok(c) => c,
Err(e) => e.exit(),
};
apply_env_defaults(&mut cli);
init_tracing(cli.verbose);
let mode = RenderMode::resolve(cli.json, cli.compact);
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("failed to build async runtime");
if let Commands::Edit(args) = cli.command {
let profile_flag = cli.profile.clone();
let yes = cli.yes;
return runtime.block_on(dispatch_edit(args, profile_flag.as_deref(), yes, mode));
}
if let Commands::Mcp(args) = cli.command {
let profile_flag = cli.profile.clone();
return runtime.block_on(mcp::serve(args, profile_flag.as_deref()));
}
if let Commands::Lsp = cli.command {
let profile_flag = cli.profile.clone();
return lsp::serve(profile_flag.as_deref());
}
let (profile, result) = runtime.block_on(dispatch(cli, mode));
match result {
Ok(out) => {
render_ok(&out, profile.as_deref(), mode);
if let ActionOutput::Lint(lint) = &out
&& let Some(code) = lint.strict_exit_code()
{
return ExitCode::from(code);
}
if let ActionOutput::TestingRun(testing) = &out
&& let Some(code) = testing.failure_exit_code()
{
return ExitCode::from(code);
}
ExitCode::SUCCESS
}
Err(err) => {
render_error(&err, profile.as_deref(), mode);
ExitCode::from(err.exit_code())
}
}
}
async fn dispatch(cli: Cli, mode: RenderMode) -> (Option<String>, Result<ActionOutput, CoreError>) {
if let Commands::Completions { shell } = cli.command {
return (None, Ok(ActionOutput::Completions { shell }));
}
let path = config::config_path();
let mut config = match config::load(&path) {
Ok(config) => config,
Err(err) => return (None, Err(err)),
};
match cli.command {
Commands::Version => match Session::resolve_degraded(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let result =
actions::version::version(Some(session.api()), env!("CARGO_PKG_VERSION")).await;
(Some(name), result.map(ActionOutput::Version))
}
Err(CoreError::NoActiveProfile) => {
let result = actions::version::version(None, env!("CARGO_PKG_VERSION")).await;
(None, result.map(ActionOutput::Version))
}
Err(err) => (error_profile(&err), Err(err)),
},
Commands::Status => run_inspection(cli.profile.as_deref(), Inspection::Status).await,
Commands::Modules { quarantined } => {
run_inspection(cli.profile.as_deref(), Inspection::Modules(quarantined)).await
}
Commands::Metrics { history } => {
run_inspection(cli.profile.as_deref(), Inspection::Metrics(history)).await
}
Commands::Sessions(SessionsArgs { r#type, command }) => match command {
None => {
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::sessions::sessions(&*api, r#type.map(Into::into))
.await
.map(ActionOutput::Sessions),
Err(err) => Err(err),
};
(name, result)
}
Some(SessionsCmd::Terminate {
r#type,
id,
message,
}) => {
if let Err(err) = require_confirmation(cli.yes, "sessions terminate") {
return (None, Err(err));
}
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::sessions::terminate_session(
&*api,
r#type.into(),
&id,
message.as_deref(),
)
.await
.map(ActionOutput::SessionsTerminate),
Err(err) => Err(err),
};
(name, result)
}
},
Commands::Connections { r#type } => {
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::connections::connections(&*api, r#type.map(Into::into))
.await
.map(ActionOutput::Connections),
Err(err) => Err(err),
};
(name, result)
}
Commands::Logs(LogsArgs {
logger,
min_level,
since,
limit,
follow,
interval,
timeout,
command,
}) => match command {
None => {
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) if follow => {
let min_level = min_level.map(LogLevel::wire);
let header = name.clone();
let mut first = true;
let sink: &mut (dyn FnMut(&ignition_core::client::logs::LogEntry) + Send) =
&mut |entry| match mode {
RenderMode::Human => {
if first {
if let Some(name) = &header {
println!("[profile: {name}]");
}
first = false;
}
println!("{}", render_log_entry_line(entry));
}
RenderMode::PrettyJson | RenderMode::CompactJson => {
println!(
"{}",
serde_json::to_string(entry)
.expect("a log entry serializes")
);
}
};
actions::logs::tail(
&*api,
logger.as_deref(),
min_level,
since,
std::time::Duration::from_secs(interval),
timeout.map(std::time::Duration::from_secs),
sink,
)
.await
.map(ActionOutput::LogsTail)
}
Ok(api) => {
let min_level = min_level.map(LogLevel::wire);
actions::logs::list_logs(&*api, logger.as_deref(), min_level, since, limit)
.await
.map(ActionOutput::LogsList)
}
Err(err) => Err(err),
};
(name, result)
}
Some(LogsCmd::Download { output }) => {
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => {
let stem = name.as_deref().unwrap_or("gateway");
actions::logs::download(&*api, output.as_deref(), stem)
.await
.map(ActionOutput::LogsDownload)
}
Err(err) => Err(err),
};
(name, result)
}
Some(LogsCmd::Loggers(loggers_args)) => match loggers_args.command {
None => {
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::logs::loggers(&*api, loggers_args.search.as_deref())
.await
.map(ActionOutput::LoggersList),
Err(err) => Err(err),
};
(name, result)
}
Some(LoggersCmd::Set {
name: logger,
level,
}) => {
if let Err(err) = require_confirmation(cli.yes, "logs loggers set") {
return (None, Err(err));
}
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::logs::set_logger_level(&*api, &logger, level.wire())
.await
.map(ActionOutput::LoggerSet),
Err(err) => Err(err),
};
(name, result)
}
Some(LoggersCmd::Reset) => {
if let Err(err) = require_confirmation(cli.yes, "logs loggers reset") {
return (None, Err(err));
}
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::logs::reset_logger_levels(&*api)
.await
.map(ActionOutput::LoggerReset),
Err(err) => Err(err),
};
(name, result)
}
},
},
Commands::Restart {
wait,
timeout,
interval,
} => {
if let Err(err) = require_confirmation(cli.yes, "restart") {
return (None, Err(err));
}
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => {
let interval = interval.map_or(
actions::restart::DEFAULT_INTERVAL,
std::time::Duration::from_secs,
);
let timeout = timeout.map_or(
actions::restart::RESTART_TIMEOUT,
std::time::Duration::from_secs,
);
if wait {
actions::restart::restart_and_wait(
&*api,
interval,
timeout,
actions::restart::RESTART_FLOOR,
)
.await
.map(ActionOutput::RestartWait)
} else {
actions::restart::restart(&*api)
.await
.map(ActionOutput::Restart)
}
}
Err(err) => Err(err),
};
(name, result)
}
Commands::Wait(WaitArgs { command }) => match command {
WaitCmd::Gateway { interval, timeout } => {
let (name, api) = resolve_headerless_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::restart::wait_gateway(
&*api,
std::time::Duration::from_secs(interval),
std::time::Duration::from_secs(timeout),
)
.await
.map(ActionOutput::Wait),
Err(err) => Err(err),
};
(name, result)
}
WaitCmd::Restart { interval, timeout } => {
let (name, api) = resolve_headerless_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::restart::wait_restart(
&*api,
std::time::Duration::from_secs(interval),
std::time::Duration::from_secs(timeout),
actions::restart::RESTART_FLOOR,
)
.await
.map(ActionOutput::Wait),
Err(err) => Err(err),
};
(name, result)
}
WaitCmd::Module {
id,
interval,
timeout,
} => {
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::restart::wait_module(
&*api,
&id,
std::time::Duration::from_secs(interval),
std::time::Duration::from_secs(timeout),
)
.await
.map(ActionOutput::Wait),
Err(err) => Err(err),
};
(name, result)
}
},
Commands::Doctor {
check_write,
webdev_route,
} => match Session::resolve_degraded(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let opts = actions::doctor::DoctorOptions {
check_write,
webdev_route,
};
let result = actions::doctor::doctor(
&*session,
session.profile_url().as_str(),
session.credential_present(),
&opts,
)
.await;
(Some(name), Ok(ActionOutput::Doctor(result)))
}
Err(err) => (error_profile(&err), Err(err)),
},
Commands::Adopt {
user,
key_name,
level,
project,
testing,
checkout,
bake,
} => match Session::resolve_degraded(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let url = session.profile_url().to_string();
let Some(password) = env_non_empty("IGNITION_PASSWORD") else {
return (
error_profile(&CoreError::PasswordUnavailable {
profile: name.clone(),
}),
Err(CoreError::PasswordUnavailable { profile: name }),
);
};
let compose_credential =
match Session::resolve_credential_opt(cli.profile.as_deref()) {
Ok(credential) => credential,
Err(err) => return (error_profile(&err), Err(err)),
};
let opts = actions::adopt::AdoptOptions {
username: user.unwrap_or_else(|| "admin".into()),
key_name: key_name
.unwrap_or_else(|| actions::adopt::DEFAULT_KEY_NAME.to_string()),
level: level
.map(|path| path.split('/').map(str::to_string).collect::<Vec<_>>())
.unwrap_or_else(|| {
actions::adopt::DEFAULT_LEVEL
.iter()
.map(|segment| segment.to_string())
.collect()
}),
project,
testing,
checkout: checkout.map(std::path::PathBuf::from),
bake: bake.map(std::path::PathBuf::from),
};
let result = actions::adopt::adopt(
&url,
&name,
&ignition_core::config::config_path(),
&config::Secret::new(password),
compose_credential.as_ref(),
&opts,
)
.await;
(Some(name), result.map(ActionOutput::Adopt))
}
Err(err) => (error_profile(&err), Err(err)),
},
Commands::Session(cli::SessionArgs { command }) => match command {
cli::SessionCmd::Login { user } => {
match Session::resolve_degraded(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let url = session.profile_url().to_string();
let Some(password) = env_non_empty("IGNITION_PASSWORD") else {
return (
error_profile(&CoreError::PasswordUnavailable {
profile: name.clone(),
}),
Err(CoreError::PasswordUnavailable { profile: name }),
);
};
let username = user
.or_else(|| env_non_empty("IGNITION_USER"))
.unwrap_or_else(|| "admin".into());
let opts = actions::login::SessionLoginOptions { username };
let result = actions::login::session_login(
&url,
&opts,
&config::Secret::new(password),
)
.await;
(Some(name), result.map(ActionOutput::SessionLogin))
}
Err(err) => (error_profile(&err), Err(err)),
}
}
},
Commands::Project(ProjectArgs { command }) => match command {
ProjectCommand::List => {
let (profile, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::projects::projects(&*api)
.await
.map(ActionOutput::ProjectsList),
Err(err) => Err(err),
};
(profile, result)
}
ProjectCommand::New {
name: project_name,
title,
description,
parent,
inheritable,
disabled,
} => {
let (profile, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => {
let opts = actions::projects::NewOptions {
enabled: !disabled,
title,
description,
parent,
inheritable: inheritable.then_some(true),
};
actions::projects::project_new(&*api, &project_name, &opts)
.await
.map(ActionOutput::ProjectNew)
}
Err(err) => Err(err),
};
(profile, result)
}
ProjectCommand::Copy { src, dst } => {
let (profile, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::projects::project_copy(&*api, &src, &dst)
.await
.map(ActionOutput::ProjectCopy),
Err(err) => Err(err),
};
(profile, result)
}
ProjectCommand::Rename { old_name, new_name } => {
let (profile, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::projects::project_rename(&*api, &old_name, &new_name)
.await
.map(ActionOutput::ProjectRename),
Err(err) => Err(err),
};
(profile, result)
}
ProjectCommand::Set {
name: project_name,
title,
description,
parent,
set_enabled,
disabled,
inheritable,
} => {
let (profile, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => {
let enabled = if set_enabled {
Some(true)
} else if disabled {
Some(false)
} else {
None
};
let opts = actions::projects::SetOptions {
title,
description,
parent,
enabled,
inheritable,
};
actions::projects::project_set(&*api, &project_name, &opts)
.await
.map(ActionOutput::ProjectSet)
}
Err(err) => Err(err),
};
(profile, result)
}
ProjectCommand::Delete { name: project_name } => {
if let Err(err) = require_confirmation(cli.yes, "project delete") {
return (None, Err(err));
}
let (profile, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::projects::project_delete(&*api, &project_name)
.await
.map(ActionOutput::ProjectDelete),
Err(err) => Err(err),
};
(profile, result)
}
ProjectCommand::Export {
name: project_name,
output,
decode_scripts,
} => {
if mode == RenderMode::Human {
eprintln!("exporting {project_name} …");
}
let (profile, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => {
if decode_scripts {
actions::projects::project_export_decoded(
&*api,
&project_name,
output.as_deref(),
)
.await
.map(ActionOutput::ProjectExportDecoded)
} else {
actions::projects::project_export(
&*api,
&project_name,
output.as_deref(),
)
.await
.map(ActionOutput::ProjectExport)
}
}
Err(err) => Err(err),
};
(profile, result)
}
ProjectCommand::Import {
name: project_name,
file,
collision_policy,
encode_scripts,
} => {
if matches!(collision_policy, cli::CollisionPolicy::Overwrite)
&& let Err(err) =
require_confirmation(cli.yes, "project import --collision-policy overwrite")
{
return (None, Err(err));
}
let zip = if encode_scripts {
if file == "-" {
return (
None,
Err(CoreError::InvalidInput {
reason: "--encode-scripts needs the decoded export \
DIRECTORY via --file (stdin cannot carry one)"
.to_string(),
}),
);
}
match ignition_core::client::scripts_codec::encode_export_tree(
std::path::Path::new(&file),
) {
Ok(zip) => zip,
Err(err) => return (None, Err(err)),
}
} else if file == "-" {
use tokio::io::AsyncReadExt;
let mut buffer = Vec::new();
match tokio::io::stdin().read_to_end(&mut buffer).await {
Ok(_) => buffer,
Err(err) => {
return (
None,
Err(CoreError::InvalidImportFile {
reason: format!("cannot read stdin: {err}"),
}),
);
}
}
} else {
match std::fs::read(&file) {
Ok(bytes) => bytes,
Err(err) => {
return (
None,
Err(CoreError::InvalidImportFile {
reason: format!("cannot read {file}: {err}"),
}),
);
}
}
};
let (profile, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::projects::project_import(
&*api,
&project_name,
zip,
collision_policy.into(),
)
.await
.map(ActionOutput::ProjectImport),
Err(err) => Err(err),
};
(profile, result)
}
ProjectCommand::Diff {
profile_a,
profile_b,
project: project_name,
} => {
let (active, sides) = resolve_two_clients(
&mut config,
cli.profile.as_deref(),
&profile_a,
&profile_b,
);
let result = match sides {
Ok((api_a, api_b)) => actions::projects::project_diff(
&*api_a,
&*api_b,
&project_name,
&profile_a,
&profile_b,
)
.await
.map(ActionOutput::ProjectDiff),
Err(err) => Err(err),
};
(active, result)
}
ProjectCommand::Sync {
profile_a,
profile_b,
project: project_name,
resource,
all_changed,
delete,
} => {
if resource.is_empty() && !all_changed {
return (
None,
Err(CoreError::InvalidInput {
reason: "sync needs a selection — pass --resource PATH \
(repeatable) and/or --all-changed"
.to_string(),
}),
);
}
if let Err(err) = require_confirmation(
cli.yes,
&format!(
"project sync (overwrite-import the whole project on {profile_b} — \
replaces concurrent Designer edits)"
),
) {
return (None, Err(err));
}
let (active, sides) = resolve_two_clients(
&mut config,
cli.profile.as_deref(),
&profile_a,
&profile_b,
);
let result = match sides {
Ok((api_a, api_b)) => {
let selection = actions::projects::SyncSelection {
resources: resource,
all_changed,
};
actions::projects::project_sync(
&*api_a,
&*api_b,
&project_name,
&selection,
delete,
&profile_a,
&profile_b,
)
.await
.map(ActionOutput::ProjectSync)
}
Err(err) => Err(err),
};
(active, result)
}
},
Commands::Workspace(WorkspaceArgs { command }) => match command {
WorkspaceCommand::Checkout {
project,
target,
decode_scripts,
} => match Session::resolve(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let result = actions::workspace::workspace_checkout(
&*session,
&project,
&target,
&name,
decode_scripts,
)
.await
.map(ActionOutput::WorkspaceCheckout);
(Some(name), result)
}
Err(err) => (error_profile(&err), Err(err)),
},
WorkspaceCommand::Status { path } => {
let manifest = match actions::workspace::read_manifest(&path) {
Ok(manifest) => manifest,
Err(err) => return (None, Err(err)),
};
match Session::resolve(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let result = actions::workspace::workspace_status(
&path,
&*session,
&manifest.project,
)
.await
.map(ActionOutput::WorkspaceStatus);
(Some(name), result)
}
Err(err) => (error_profile(&err), Err(err)),
}
}
WorkspaceCommand::Push { path, delete } => {
let manifest = match actions::workspace::read_manifest(&path) {
Ok(manifest) => manifest,
Err(err) => return (None, Err(err)),
};
match Session::resolve(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let result = actions::workspace::workspace_push(
&path,
&*session,
&manifest.project,
cli.yes,
delete,
)
.await
.map(ActionOutput::WorkspacePush);
(Some(name), result)
}
Err(err) => (error_profile(&err), Err(err)),
}
}
},
Commands::Resource(ResourceArgs { command }) => match command {
ResourceCommand::List { project, prefix } => {
let (profile, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => {
actions::resources::resources_list(&*api, &project, prefix.as_deref())
.await
.map(ActionOutput::ResourcesList)
}
Err(err) => Err(err),
};
(profile, result)
}
ResourceCommand::Get { project, path } => {
let (profile, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::resources::resource_get(&*api, &project, &path)
.await
.map(ActionOutput::ResourceGet),
Err(err) => Err(err),
};
(profile, result)
}
ResourceCommand::Put {
project,
path,
file,
} => {
let input = if file == "-" {
use tokio::io::AsyncReadExt;
let mut buffer = Vec::new();
match tokio::io::stdin().read_to_end(&mut buffer).await {
Ok(_) => buffer,
Err(err) => {
return (
None,
Err(CoreError::InvalidInput {
reason: format!("cannot read stdin: {err}"),
}),
);
}
}
} else {
match std::fs::read(&file) {
Ok(bytes) => bytes,
Err(err) => {
return (
None,
Err(CoreError::InvalidInput {
reason: format!("cannot read {file}: {err}"),
}),
);
}
}
};
if let Err(err) = require_confirmation(
cli.yes,
"resource put (re-imports the project; concurrent Designer edits are replaced)",
) {
return (None, Err(err));
}
let (profile, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::resources::resource_put(&*api, &project, &path, input)
.await
.map(ActionOutput::ResourcePut),
Err(err) => Err(err),
};
(profile, result)
}
ResourceCommand::Delete { project, path } => {
if let Err(err) = require_confirmation(
cli.yes,
"resource delete (re-imports the project; concurrent Designer edits are replaced)",
) {
return (None, Err(err));
}
let (profile, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::resources::resource_delete(&*api, &project, &path)
.await
.map(ActionOutput::ResourceDelete),
Err(err) => Err(err),
};
(profile, result)
}
},
Commands::Webdev(WebdevArgs { command }) => {
match Session::resolve(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let stored_secret = config
.profiles
.get(&name)
.and_then(|profile| profile.webdev_secret.clone());
let result = match command {
WebdevCommand::Deploy {
project,
with_script_exec,
rotate_secret,
with_testing,
} => {
if mode == RenderMode::Human {
eprintln!("deploying webdev routes to {project} …");
}
actions::webdev::webdev_deploy(
&*session,
&project,
with_script_exec,
rotate_secret,
&path,
&name,
with_testing,
)
.await
.map(ActionOutput::WebdevDeploy)
}
WebdevCommand::Status { project } => actions::webdev::webdev_status(
&*session,
&project,
stored_secret.as_deref(),
)
.await
.map(ActionOutput::WebdevStatus),
};
(Some(name), result)
}
Err(err) => (error_profile(&err), Err(err)),
}
}
Commands::Tags(TagsArgs { command }) => {
if let TagsCommand::Browse {
from_export: Some(export_path),
filter,
include_properties,
..
} = &command
{
let result = actions::tags::browse_rows_from_export(
export_path,
*include_properties,
filter.as_deref(),
)
.map(ActionOutput::TagsBrowseFromExport);
return (None, result);
}
let guard_operation = match &command {
TagsCommand::Provider(TagsProviderCommand::Delete { .. }) => {
Some("tags provider delete")
}
TagsCommand::Config(TagsConfigCommand::Delete { .. }) => Some("tags config delete"),
TagsCommand::Import {
collision_policy: cli::CollisionPolicy::Overwrite,
..
} => Some("tags import --collision-policy overwrite"),
_ => None,
};
if let Some(operation) = guard_operation
&& let Err(err) = require_confirmation(cli.yes, operation)
{
return (None, Err(err));
}
let write_value = match &command {
TagsCommand::Write { value, .. } => match parse_write_scalar(value) {
Ok(parsed) => Some(parsed),
Err(err) => return (None, Err(err)),
},
_ => None,
};
let json_input = match &command {
TagsCommand::Config(TagsConfigCommand::Create { file, .. })
| TagsCommand::Config(TagsConfigCommand::Edit { file, .. }) => {
match read_json_input(file).await {
Ok(parsed) => Some(parsed),
Err(err) => return (None, Err(err)),
}
}
_ => None,
};
let (import_payload, import_loss): (
Option<Vec<u8>>,
Option<actions::tags::LossReport>,
) = match &command {
TagsCommand::Import { file, format, .. } => {
if matches!(format, cli::TransferFormat::Json) {
match read_json_input(file).await {
Ok(parsed) => (
Some(serde_json::to_vec(&parsed).expect("parsed Value serializes")),
None,
),
Err(err) => return (None, Err(err)),
}
} else {
let bytes = match read_input_bytes(file).await {
Ok(bytes) => bytes,
Err(err) => return (None, Err(err)),
};
match loss_gate(&bytes, *format, cli.yes) {
Ok(loss) => (Some(bytes), loss),
Err(err) => return (None, Err(err)),
}
}
}
_ => (None, None),
};
let export_out = match &command {
TagsCommand::Export {
paths,
output,
format,
..
} => Some(match output {
Some(path) if path == std::path::Path::new("-") => None,
Some(path) => Some(path.clone()),
None => Some(default_export_path(paths, *format)),
}),
_ => None,
};
let time_args = match &command {
TagsCommand::Alarms(TagsAlarmsCommand::History { start, end, .. })
| TagsCommand::History(TagsHistoryCommand::Query { start, end, .. }) => {
match (
actions::tags::parse_time_ms(start),
actions::tags::parse_time_ms(end),
) {
(Ok(start_ms), Ok(end_ms)) => Some((start_ms, end_ms)),
(Err(err), _) | (_, Err(err)) => return (None, Err(err)),
}
}
_ => None,
};
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match (&command, api) {
(TagsCommand::Provider(TagsProviderCommand::List), Ok(api)) => {
actions::tags::tag_provider_list(&*api)
.await
.map(ActionOutput::TagProviders)
}
(
TagsCommand::Provider(TagsProviderCommand::Create { name: provider }),
Ok(api),
) => actions::tags::tag_provider_create(&*api, provider)
.await
.map(ActionOutput::TagProviderCreate),
(
TagsCommand::Provider(TagsProviderCommand::Delete { name: provider }),
Ok(api),
) => actions::tags::tag_provider_delete(&*api, provider)
.await
.map(ActionOutput::TagProviderDelete),
(
TagsCommand::Browse {
path,
filter,
include_properties,
project,
..
},
Ok(api),
) => actions::tags::tags_browse(
&*api,
project,
path.as_deref().unwrap_or(""),
filter.as_deref(),
*include_properties,
)
.await
.map(ActionOutput::TagsBrowse),
(TagsCommand::Read { paths, project }, Ok(api)) => {
actions::tags::tags_read(&*api, project, paths)
.await
.map(ActionOutput::TagsRead)
}
(TagsCommand::Write { path, project, .. }, Ok(api)) => {
actions::tags::tags_write(&*api, project, path, write_value.expect("parsed"))
.await
.map(ActionOutput::TagsWrite)
}
(TagsCommand::Config(TagsConfigCommand::Get { path, project }), Ok(api)) => {
actions::tags::tags_config_get(&*api, project, path)
.await
.map(ActionOutput::TagsConfigGet)
}
(TagsCommand::Config(TagsConfigCommand::Create { path, project, .. }), Ok(api)) => {
actions::tags::tags_config_create(
&*api,
project,
path,
json_input.as_ref().expect("parsed pre-resolution"),
)
.await
.map(ActionOutput::TagsConfigCreate)
}
(TagsCommand::Config(TagsConfigCommand::Edit { path, project, .. }), Ok(api)) => {
actions::tags::tags_config_edit(
&*api,
project,
path,
json_input.as_ref().expect("parsed pre-resolution"),
)
.await
.map(ActionOutput::TagsConfigEdit)
}
(TagsCommand::Config(TagsConfigCommand::Delete { paths, project }), Ok(api)) => {
actions::tags::tags_config_delete(&*api, project, paths)
.await
.map(ActionOutput::TagsConfigDelete)
}
(TagsCommand::Udt(TagsUdtCommand::Types { provider, project }), Ok(api)) => {
actions::tags::tags_udt_types(&*api, project, provider)
.await
.map(ActionOutput::TagsUdtTypes)
}
(
TagsCommand::Udt(TagsUdtCommand::Def {
name: udt_name,
provider,
project,
}),
Ok(api),
) => actions::tags::tags_udt_def(&*api, project, provider, udt_name)
.await
.map(ActionOutput::TagsUdtDef),
(
TagsCommand::Export {
paths,
project,
format,
..
},
Ok(api),
) => match format {
cli::TransferFormat::Json => actions::tags::tags_export(
&*api,
project,
paths,
export_out
.as_ref()
.expect("resolved pre-resolution")
.as_deref(),
actions::tags::ExportFormat::Json,
)
.await
.map(ActionOutput::TagsExport),
cli::TransferFormat::Xml => actions::tags::tags_export(
&*api,
project,
paths,
export_out
.as_ref()
.expect("resolved pre-resolution")
.as_deref(),
actions::tags::ExportFormat::Xml,
)
.await
.map(ActionOutput::TagsExport),
cli::TransferFormat::Csv => export_csv_arm(
&*api,
project,
paths,
export_out
.as_ref()
.expect("resolved pre-resolution")
.as_deref(),
)
.await
.map(ActionOutput::TagsExport),
},
(
TagsCommand::Import {
provider,
project,
collision_policy,
format,
..
},
Ok(api),
) => {
let bytes = import_payload.as_ref().expect("parsed pre-resolution");
let import_format = match format {
cli::TransferFormat::Json => actions::tags::ImportFormat::Json,
cli::TransferFormat::Xml => actions::tags::ImportFormat::Xml,
cli::TransferFormat::Csv => actions::tags::ImportFormat::Csv,
};
actions::tags::tags_import(
&*api,
project,
provider,
bytes,
(*collision_policy).into(),
import_format,
)
.await
.map(|mut result| {
result.loss_report = import_loss.clone();
ActionOutput::TagsImport(result)
})
}
(
TagsCommand::Alarms(TagsAlarmsCommand::Active {
source,
priority,
state,
project,
}),
Ok(api),
) => actions::tags::tags_alarms_active(
&*api,
project,
source.as_deref(),
priority.as_deref(),
state.as_deref(),
)
.await
.map(ActionOutput::TagsAlarmsActive),
(TagsCommand::Alarms(TagsAlarmsCommand::History { project, .. }), Ok(api)) => {
let (start_ms, end_ms) = time_args.expect("parsed pre-resolution");
actions::tags::tags_alarms_history(&*api, project, start_ms, end_ms)
.await
.map(ActionOutput::TagsAlarmsHistory)
}
(
TagsCommand::Alarms(TagsAlarmsCommand::Ack {
ids,
note,
username,
project,
}),
Ok(api),
) => actions::tags::tags_alarms_ack(
&*api,
project,
ids,
note.as_deref().unwrap_or(""),
username,
)
.await
.map(ActionOutput::TagsAlarmsAck),
(
TagsCommand::History(TagsHistoryCommand::Query {
paths,
return_size,
aggregation,
project,
..
}),
Ok(api),
) => {
let (start_ms, end_ms) = time_args.expect("parsed pre-resolution");
actions::tags::tags_history_query(
&*api,
project,
paths,
start_ms,
end_ms,
*return_size,
aggregation.as_deref(),
)
.await
.map(ActionOutput::TagsHistoryQuery)
}
(_, Err(err)) => Err(err),
};
(name, result)
}
Commands::Rig(RigArgs { rig, command }) => {
let guarded_operation = match &command {
RigCommand::Reset { .. } => Some("rig reset"),
RigCommand::Restore { .. } => Some("rig restore"),
RigCommand::Trial(trial_args) => match trial_args.command {
cli::TrialCommand::Reset { .. } => Some("rig trial reset"),
cli::TrialCommand::Status => None,
},
RigCommand::Module(module_args) => match module_args.command {
cli::ModuleCommand::Uninstall { .. } => Some("rig module uninstall"),
},
_ => None,
};
if let Some(operation) = guarded_operation
&& let Err(err) = require_confirmation(cli.yes, operation)
{
return (None, Err(err));
}
let with_module_flags: &[String] = match &command {
RigCommand::Up { with_module, .. } | RigCommand::Reset { with_module, .. } => {
with_module
}
_ => &[],
};
if !with_module_flags.is_empty()
&& let Err(err) =
ignition_core::rig::modules::preflight_with_module_flags(with_module_flags)
{
return (None, Err(err));
}
let runner = ignition_core::rig::DockerCompose;
let selection = match rig {
Some(name) => ignition_core::rig::RigSelection::Named(name),
None => ignition_core::rig::RigSelection::Auto,
};
let gateway_verb_echo = config.active.clone();
let is_gateway_verb = matches!(
command,
RigCommand::Trial(_)
| RigCommand::Snapshot { .. }
| RigCommand::Restore { .. }
| RigCommand::Module(_)
);
let result = match ignition_core::rig::resolve_plan(&runner, selection, &config).await {
Ok(plan) => match command {
RigCommand::Up { .. } | RigCommand::Reset { .. } => {
let probe = commissioned_probe(&plan);
let probe_dyn: Option<&dyn ignition_core::client::GatewayApi> = probe
.as_ref()
.map(|session| session.api() as &dyn ignition_core::client::GatewayApi);
let merged_modules = match ignition_core::rig::modules::merge_declarations(
&plan.modules,
with_module_flags,
) {
Ok(merged) => merged,
Err(err) => return (None, Err(err)),
};
let provisioning = if merged_modules.is_empty() {
match ignition_core::rig::undeclared_provisioning(&plan) {
Ok(provisioning) => provisioning,
Err(err) => return (None, Err(err)),
}
} else {
let feed = match ignition_core::module::fetch::ModuleFeed::github() {
Ok(feed) => feed,
Err(err) => return (None, Err(err)),
};
let cache_root = ignition_core::module::cache_root();
let (refresh, accept_upstream_change) = match &command {
RigCommand::Up {
refresh,
accept_upstream_change,
..
}
| RigCommand::Reset {
refresh,
accept_upstream_change,
..
} => (*refresh, *accept_upstream_change),
_ => unreachable!("guarded by the outer match arm"),
};
let policy = module_fetch_policy(refresh, accept_upstream_change);
match ignition_core::rig::modules::provision_modules(
&feed,
&plan,
&merged_modules,
&cache_root,
policy,
)
.await
{
Ok(provisioning) => provisioning,
Err(err) => return (None, Err(err)),
}
};
match command {
RigCommand::Up { timeout, .. } => actions::rig::rig_up(
&runner,
&plan,
timeout,
probe_dyn,
&provisioning,
)
.await
.map(ActionOutput::RigUp),
RigCommand::Reset { timeout, .. } => actions::rig::rig_reset(
&runner,
&plan,
timeout,
probe_dyn,
&provisioning,
)
.await
.map(ActionOutput::RigReset),
_ => unreachable!("guarded by the outer match arm"),
}
}
RigCommand::Down => actions::rig::rig_down(&runner, &plan)
.await
.map(ActionOutput::RigDown),
RigCommand::Status => actions::rig::rig_status(&runner, &plan)
.await
.map(ActionOutput::RigStatus),
RigCommand::Logs {
tail,
follow,
service,
} => {
let mut sink = |line: String| println!("{line}");
actions::rig::rig_logs(
&runner,
&plan,
tail,
follow,
service.as_deref(),
&mut sink,
)
.await
.map(ActionOutput::RigLogs)
}
RigCommand::Trial(ref trial_args) => match trial_args.command.clone() {
cli::TrialCommand::Status => match rig_gateway_client(&plan, None) {
Some(api) => actions::rig::trial_status(&*api)
.await
.map(ActionOutput::RigTrialStatus),
None => Err(trial_no_gateway(&plan)),
},
cli::TrialCommand::Reset { user } => {
let token = std::env::var("IGNITION_TOKEN")
.ok()
.filter(|value| !value.is_empty());
let username = user.clone().or_else(|| env_non_empty("IGNITION_USER"));
let password = env_non_empty("IGNITION_PASSWORD");
let basic = username.zip(password).map(|(user, password)| {
(user, ignition_core::config::Secret::new(password))
});
if token.is_none() && basic.is_none() {
return (
gateway_verb_echo,
Err(CoreError::SecretUnavailable {
profile: plan.name.clone(),
}),
);
}
let credential =
token.map(|token| Credential::Token(config::Secret::new(token)));
let token_available = credential.is_some();
match rig_gateway_client(&plan, credential) {
Some(api) => {
let rig_url = actions::rig::gateway_url_from(&plan)
.expect("rig_gateway_client derived it or returned None");
let basic_ref = basic
.as_ref()
.map(|(user, password)| (user.as_str(), password));
actions::rig::trial_reset(
&*api,
&rig_url,
token_available,
basic_ref,
)
.await
.map(ActionOutput::RigTrialReset)
}
None => Err(trial_no_gateway(&plan)),
}
}
},
RigCommand::Snapshot { output } => {
let Some(token) = env_non_empty("IGNITION_TOKEN") else {
return (
gateway_verb_echo,
Err(CoreError::SecretUnavailable {
profile: plan.name.clone(),
}),
);
};
let credential = Some(Credential::Token(config::Secret::new(token)));
match rig_gateway_client(&plan, credential) {
Some(api) => {
actions::rig::rig_snapshot(&*api, &plan.name, output.as_deref())
.await
.map(ActionOutput::RigSnapshot)
}
None => Err(trial_no_gateway(&plan)),
}
}
RigCommand::Restore { file, timeout } => {
let Some(token) = env_non_empty("IGNITION_TOKEN") else {
return (
gateway_verb_echo,
Err(CoreError::SecretUnavailable {
profile: plan.name.clone(),
}),
);
};
let credential = Some(Credential::Token(config::Secret::new(token)));
match rig_gateway_client(&plan, credential) {
Some(api) => {
let rig_url = actions::rig::gateway_url_from(&plan)
.expect("rig_gateway_client derived it or returned None");
actions::rig::rig_restore(&*api, &rig_url, &file, timeout)
.await
.map(ActionOutput::RigRestore)
}
None => Err(trial_no_gateway(&plan)),
}
}
RigCommand::Module(ref module_args) => match module_args.command.clone() {
cli::ModuleCommand::Uninstall { id } => {
let spec = match ignition_core::module::spec_for(&id) {
Some(spec) => spec,
None => {
let known = ignition_core::module::MODULES
.iter()
.map(|spec| spec.id.to_string())
.collect();
return (
gateway_verb_echo,
Err(CoreError::ModuleNotRegistered { id, known }),
);
}
};
if plan.modules.contains_key(&id) {
return (
gateway_verb_echo,
Err(actions::rig::module_still_declared_error(&id, &plan.name)),
);
}
let Some(token) = env_non_empty("IGNITION_TOKEN") else {
return (
gateway_verb_echo,
Err(CoreError::SecretUnavailable {
profile: plan.name.clone(),
}),
);
};
let credential = Some(Credential::Token(config::Secret::new(token)));
match rig_gateway_client(&plan, credential) {
Some(api) => actions::rig::rig_module_uninstall(
&*api,
&plan.name,
spec,
&plan.modules,
)
.await
.map(ActionOutput::RigModuleUninstall),
None => Err(trial_no_gateway(&plan)),
}
}
},
},
Err(err) => Err(err),
};
let echo = if is_gateway_verb {
gateway_verb_echo
} else {
None
};
(echo, result)
}
Commands::Backup(BackupArgs { command }) => match command {
BackupCommand::Download { output, r#type } => {
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => {
let stem = name.as_deref().unwrap_or("gateway");
actions::backup::backup_download(
&*api,
output.as_deref(),
stem,
r#type.into(),
)
.await
.map(ActionOutput::BackupDownload)
}
Err(err) => Err(err),
};
(name, result)
}
BackupCommand::Restore { file } => {
if let Err(err) = require_confirmation(
cli.yes,
"backup restore (overwrites this gateway's state from the gwbk — \
gateway restarts and blocks ~minutes)",
) {
return (None, Err(err));
}
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::backup::backup_restore(&*api, &file)
.await
.map(ActionOutput::BackupRestore),
Err(err) => Err(err),
};
(name, result)
}
},
Commands::Eam(EamArgs { command }) => match command {
EamCommand::History { limit, search } => {
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::eam::eam_history(&*api, limit, search.as_deref())
.await
.map(ActionOutput::EamHistory),
Err(err) => Err(err),
};
(name, result)
}
EamCommand::Tasks { name: task_name } => {
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => match task_name {
Some(task_name) => actions::eam::eam_task_detail(&*api, &task_name)
.await
.map(ActionOutput::EamTaskDetail),
None => actions::eam::eam_tasks(&*api)
.await
.map(ActionOutput::EamTasks),
},
Err(err) => Err(err),
};
(name, result)
}
EamCommand::Task(task_command) => match task_command {
EamTaskCommand::New {
name: task_name,
r#type,
target,
setting,
definition,
schedule_mode,
} => {
use actions::eam::TaskCreateVerdict;
match actions::eam::task_create_guard(&r#type, schedule_mode.wire()) {
TaskCreateVerdict::Refused => {
return (
None,
Err(CoreError::EamTaskTypeRefused {
task_type: r#type.clone(),
}),
);
}
TaskCreateVerdict::NeedsYes => {
let operation = if schedule_mode != ScheduleMode::OnDemand {
format!(
"eam task new (scheduleMode {} arms autonomous \
gateway actions)",
schedule_mode.wire()
)
} else {
format!(
"eam task new ({} mutates the agent \
targets it dispatches to)",
r#type
)
};
if let Err(err) = require_confirmation(cli.yes, &operation) {
return (None, Err(err));
}
}
TaskCreateVerdict::Unguarded => {}
}
let definition_value = match definition.as_deref() {
Some(path) => match read_json_input(path).await {
Ok(value) => Some(value),
Err(err) => return (None, Err(err)),
},
None => None,
};
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => actions::eam::eam_task_create(
&*api,
&task_name,
&r#type,
&target,
&setting,
definition_value.as_ref(),
schedule_mode.wire(),
)
.await
.map(ActionOutput::EamTaskCreate),
Err(err) => Err(err),
};
(name, result)
}
EamTaskCommand::Force { name: task_name } => {
if let Err(err) = actions::eam::lifecycle_precheck("force", &task_name) {
return (None, Err(err));
}
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => {
match preview_then_confirm(&*api, "force", &task_name, cli.yes).await {
Ok(()) => actions::eam::eam_task_force(&*api, &task_name)
.await
.map(ActionOutput::EamTaskForce),
Err(err) => Err(err),
}
}
Err(err) => Err(err),
};
(name, result)
}
EamTaskCommand::Suspend { name: task_name } => {
if let Err(err) = actions::eam::lifecycle_precheck("suspend", &task_name) {
return (None, Err(err));
}
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => {
match preview_then_confirm(&*api, "suspend", &task_name, cli.yes).await
{
Ok(()) => actions::eam::eam_task_suspend(&*api, &task_name)
.await
.map(ActionOutput::EamTaskLifecycle),
Err(err) => Err(err),
}
}
Err(err) => Err(err),
};
(name, result)
}
EamTaskCommand::Resume { name: task_name } => {
if let Err(err) = actions::eam::lifecycle_precheck("resume", &task_name) {
return (None, Err(err));
}
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => {
match preview_then_confirm(&*api, "resume", &task_name, cli.yes).await {
Ok(()) => actions::eam::eam_task_resume(&*api, &task_name)
.await
.map(ActionOutput::EamTaskLifecycle),
Err(err) => Err(err),
}
}
Err(err) => Err(err),
};
(name, result)
}
EamTaskCommand::Cancel { name: task_name } => {
if let Err(err) = actions::eam::lifecycle_precheck("cancel", &task_name) {
return (None, Err(err));
}
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => {
match preview_then_confirm(&*api, "cancel", &task_name, cli.yes).await {
Ok(()) => actions::eam::eam_task_cancel(&*api, &task_name)
.await
.map(ActionOutput::EamTaskLifecycle),
Err(err) => Err(err),
}
}
Err(err) => Err(err),
};
(name, result)
}
EamTaskCommand::Modify {
name: task_name,
enable,
disable,
schedule_mode,
setting,
description,
} => {
let mut overlay = serde_json::Map::new();
for raw in &setting {
match actions::eam::parse_setting(raw) {
Ok((key, value)) => {
overlay.insert(key, value);
}
Err(err) => return (None, Err(err)),
}
}
let settings_overlay = if overlay.is_empty() {
None
} else {
Some(serde_json::Value::Object(overlay))
};
let change = actions::eam::TaskChange {
enabled: if enable {
Some(true)
} else if disable {
Some(false)
} else {
None
},
description,
schedule_mode,
settings_overlay,
};
if change.enabled.is_none()
&& change.description.is_none()
&& change.schedule_mode.is_none()
&& change.settings_overlay.is_none()
{
return (
None,
Err(CoreError::InvalidInput {
reason: format!(
"eam task modify {task_name:?}: no targeted keys — a modify \
must change something (enabled / description / schedule-mode \
/ settings overlay)"
),
}),
);
}
if let Err(err) = actions::eam::lifecycle_precheck("modify", &task_name) {
return (None, Err(err));
}
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => {
match preview_then_confirm(&*api, "modify", &task_name, cli.yes).await {
Ok(()) => actions::eam::eam_task_modify(&*api, &task_name, change)
.await
.map(ActionOutput::EamTaskModify),
Err(err) => Err(err),
}
}
Err(err) => Err(err),
};
(name, result)
}
EamTaskCommand::Delete { name: task_name } => {
if let Err(err) = actions::eam::lifecycle_precheck("delete", &task_name) {
return (None, Err(err));
}
let (name, api) = resolve_gateway_api(cli.profile.as_deref());
let result = match api {
Ok(api) => {
match preview_then_confirm(&*api, "delete", &task_name, cli.yes).await {
Ok(()) => actions::eam::eam_task_delete(&*api, &task_name)
.await
.map(ActionOutput::EamTaskDelete),
Err(err) => Err(err),
}
}
Err(err) => Err(err),
};
(name, result)
}
},
},
Commands::Script(ScriptArgs { command }) => match command {
ScriptCommand::Run {
code,
file,
project,
} => {
let script =
match actions::script::read_script_input(code.as_deref(), file.as_deref()) {
Ok(script) => script,
Err(err) => return (None, Err(err)),
};
match Session::resolve(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let result = actions::script::script_run(
&*session, &config, &name, &project, &script,
)
.await
.map(ActionOutput::ScriptRun);
(Some(name), result)
}
Err(err) => (error_profile(&err), Err(err)),
}
}
},
Commands::Testing(TestingArgs { command }) => match command {
TestingCommand::Run {
project,
discover,
module,
package,
format,
} => {
let opts = actions::testing::TestingRunOptions {
discover,
module,
package,
format: format.into(),
};
match Session::resolve(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let result = actions::testing::testing_run(&session, &project, &opts)
.await
.map(ActionOutput::TestingRun);
(Some(name), result)
}
Err(err) => (error_profile(&err), Err(err)),
}
}
},
Commands::E2e(cli::E2eArgs { command }) => match command {
cli::E2eCmd::Doctor { dir, project } => {
let env = actions::e2e::E2eEnv::from_process();
let opts = actions::e2e::E2eDoctorOptions {
dir: dir.unwrap_or_else(|| std::path::PathBuf::from("./e2e")),
project,
};
match Session::resolve_degraded(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let result = actions::e2e::e2e_doctor(Some(&session), &env, &opts).await;
(Some(name), result.map(ActionOutput::E2eDoctor))
}
Err(_) => {
let result = actions::e2e::e2e_doctor(None, &env, &opts).await;
(None, result.map(ActionOutput::E2eDoctor))
}
}
}
cli::E2eCmd::Init {
dir,
project,
run_project,
browsers,
} => {
let env = actions::e2e::E2eEnv::from_process();
let (profile, gateway_url) = match Session::resolve_degraded(cli.profile.as_deref())
{
Ok(session) => (
Some(session.profile_name().to_string()),
session.profile_url().to_string(),
),
Err(_) => (None, actions::e2e::DEFAULT_GATEWAY_URL.to_string()),
};
let project = project.unwrap_or_else(|| "ign-cli".to_string());
let opts = actions::e2e::E2eInitOptions {
dir: dir.unwrap_or_else(|| std::path::PathBuf::from("./e2e")),
run_project: run_project.unwrap_or_else(|| project.clone()),
project,
gateway_url,
browsers,
};
let preview = match actions::e2e::e2e_init_preview(&env, &opts) {
Ok(preview) => preview,
Err(err) => return (profile, Err(err)),
};
if let Err(err) = require_confirmation(cli.yes, &preview) {
return (profile, Err(err));
}
let result = actions::e2e::e2e_init(&env, &opts).await;
(profile, result.map(ActionOutput::E2eInit))
}
},
Commands::Lint(LintArgs {
paths,
strict,
passthrough,
}) => {
let result = actions::lint::lint_run(&paths, strict, &passthrough)
.await
.map(ActionOutput::Lint);
(None, result)
}
Commands::Api(ApiArgs {
command: ApiCommand::Call(args),
}) => {
let call = match build_api_call_request(&args) {
Ok(call) => call,
Err(err) => return (None, Err(err)),
};
if let Err(err) = ignition_core::client::apicall::refuse_auth_headers(&call.headers) {
return (None, Err(err));
}
if let Err(err) = ignition_core::client::apicall::validate_path(&call.path) {
return (None, Err(err));
}
match Session::resolve(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let result = actions::apicall::api_call(&*session, call)
.await
.map(ActionOutput::ApiCall);
(Some(name), result)
}
Err(err) => (error_profile(&err), Err(err)),
}
}
Commands::License(LicenseArgs {
command: LicenseCommand::Status,
}) => match Session::resolve(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let result = actions::license::license_status(&*session)
.await
.map(ActionOutput::LicenseStatus);
(Some(name), result)
}
Err(err) => (error_profile(&err), Err(err)),
},
Commands::Redundancy(RedundancyArgs {
command: RedundancyCommand::Status,
}) => match Session::resolve(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let result = actions::redundancy::redundancy_status(&*session)
.await
.map(ActionOutput::RedundancyStatus);
(Some(name), result)
}
Err(err) => (error_profile(&err), Err(err)),
},
Commands::Gan(GanArgs {
command: GanCommand::Status,
}) => match Session::resolve(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let result = actions::gan::gan_status(&*session)
.await
.map(ActionOutput::GanStatus);
(Some(name), result)
}
Err(err) => (error_profile(&err), Err(err)),
},
Commands::Diagnostics(DiagnosticsArgs {
command: DiagnosticsCommand::Bundle(command),
}) => match command {
BundleCommand::Generate => match Session::resolve(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let result = actions::diagnostics::bundle_generate(&*session)
.await
.map(ActionOutput::BundleGenerate);
(Some(name), result)
}
Err(err) => (error_profile(&err), Err(err)),
},
BundleCommand::Status => match Session::resolve(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let result = actions::diagnostics::bundle_status(&*session)
.await
.map(ActionOutput::BundleStatus);
(Some(name), result)
}
Err(err) => (error_profile(&err), Err(err)),
},
BundleCommand::Download { output } => match Session::resolve(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let result =
actions::diagnostics::bundle_download(&*session, output.as_deref())
.await
.map(ActionOutput::BundleDownload);
(Some(name), result)
}
Err(err) => (error_profile(&err), Err(err)),
},
BundleCommand::Wait { interval, timeout } => {
match Session::resolve(cli.profile.as_deref()) {
Ok(session) => {
let name = session.profile_name().to_string();
let result = actions::diagnostics::bundle_wait(
&*session,
std::time::Duration::from_secs(interval),
std::time::Duration::from_secs(timeout),
)
.await
.map(ActionOutput::BundleWait);
(Some(name), result)
}
Err(err) => (error_profile(&err), Err(err)),
}
}
},
Commands::Profile(ProfileArgs { command }) => match command {
ProfileCmd::List => {
match resolve_profile_context(&mut config, cli.profile.as_deref()) {
Ok(selection) => (
selection.as_ref().map(|(name, _)| name.clone()),
Ok(ActionOutput::ProfileList(actions::profile::list(&config))),
),
Err(err) => (None, Err(err)),
}
}
ProfileCmd::Add {
name,
url,
label,
token_env,
keyring,
user_env,
password_env,
active,
} => {
let prior_active = config.active.clone();
let result = actions::profile::add(
&path,
&name,
&url,
label.as_deref(),
auth_ref_from_flags(token_env, keyring, user_env, password_env),
active,
);
match result {
Ok(result) => {
let envelope_profile = if result.active {
Some(result.name.clone())
} else {
prior_active
};
(envelope_profile, Ok(ActionOutput::ProfileAdd(result)))
}
Err(err) => (None, Err(err)),
}
}
ProfileCmd::Use { name } => match actions::profile::use_profile(&path, &name) {
Ok(result) => (
Some(result.active.clone()),
Ok(ActionOutput::ProfileUse(result)),
),
Err(err) => (None, Err(err)),
},
},
Commands::Edit(_) => {
unreachable!("edit handled by dispatch_edit before the chassis")
}
Commands::Mcp(_) => {
unreachable!("mcp handled by mcp::serve before the chassis")
}
Commands::Lsp => {
unreachable!("lsp handled by lsp::serve before the chassis")
}
Commands::Completions { .. } => {
unreachable!("completions handled before config load")
}
#[cfg(feature = "tui")]
Commands::Tui => {
if !std::io::stdout().is_terminal() {
return (None, Err(CoreError::tui_tty_refusal()));
}
match ignition_tui::run(cli.profile.clone()).await {
Ok(()) => (None, Ok(ActionOutput::TuiExited)),
Err(err) => (None, Err(err)),
}
}
}
}
async fn dispatch_edit(
args: EditArgs,
profile_flag: Option<&str>,
yes: bool,
mode: RenderMode,
) -> ExitCode {
use actions::edit::{EditStatus, TokioEditor};
if let Err(err) = TokioEditor::resolve_command() {
render_error(&err, None, mode);
return ExitCode::from(err.exit_code());
}
let session = match Session::resolve(profile_flag) {
Ok(session) => session,
Err(err) => {
render_error(&err, error_profile(&err).as_deref(), mode);
return ExitCode::from(err.exit_code());
}
};
let profile_name = session.profile_name().to_string();
let staged = match actions::edit::edit_pipeline(
&*session,
&TokioEditor,
&args.project,
Some(&args.resource_path),
)
.await
{
Ok(staged) => staged,
Err(err) => {
render_error(&err, Some(&profile_name), mode);
return ExitCode::from(err.exit_code());
}
};
match staged.status {
EditStatus::NoOp => {
eprintln!("edit: no changes — nothing pushed");
ExitCode::SUCCESS
}
EditStatus::Ready { changed } => {
let zip = staged
.import_zip
.expect("Ready always carries the staged import zip");
let summary = render_edit_summary(&args.project, &changed);
if let Err(err) = require_confirmation(yes, &summary) {
render_error(&err, Some(&profile_name), mode);
return ExitCode::from(err.exit_code());
}
eprintln!("{summary}");
match session.project_import(&args.project, zip, true).await {
Ok(_) => {
eprintln!(
"edit: pushed {} member(s) to {}",
changed.len(),
args.project
);
ExitCode::SUCCESS
}
Err(err) => {
render_error(&err, Some(&profile_name), mode);
ExitCode::from(err.exit_code())
}
}
}
}
}
fn render_edit_summary(project: &str, changed: &[String]) -> String {
let mut summary = format!("edit would write {} member(s) to {project}", changed.len());
for path in changed {
summary.push_str(&format!("\n write: {path}"));
}
summary
}
fn resolve_profile_context(
config: &mut Config,
flag: Option<&str>,
) -> Result<Option<(String, config::Profile)>, CoreError> {
let overlay_target = flag.map(str::to_string).or_else(|| config.active.clone());
config::apply_env_overlay(config, overlay_target.as_deref());
config::resolve_selection(config, flag)
}
enum Inspection {
Status,
Modules(bool),
Metrics(bool),
}
async fn run_inspection(
flag: Option<&str>,
inspection: Inspection,
) -> (Option<String>, Result<ActionOutput, CoreError>) {
let (name, api) = resolve_gateway_api(flag);
let result = match api {
Ok(api) => match inspection {
Inspection::Status => actions::inspect::status(&*api)
.await
.map(ActionOutput::Status),
Inspection::Modules(quarantined) => actions::inspect::modules(&*api, quarantined)
.await
.map(ActionOutput::Modules),
Inspection::Metrics(history) => actions::inspect::metrics(&*api, history)
.await
.map(ActionOutput::Metrics),
},
Err(err) => Err(err),
};
(name, result)
}
fn resolve_gateway_api(flag: Option<&str>) -> (Option<String>, Result<Session, CoreError>) {
match Session::resolve(flag) {
Ok(session) => (Some(session.profile_name().to_string()), Ok(session)),
Err(err) => (error_profile(&err), Err(err)),
}
}
fn named_profile_client(config: &mut Config, name: &str) -> Result<Session, CoreError> {
Session::resolve_side(config, name)
}
fn resolve_two_clients(
config: &mut Config,
flag: Option<&str>,
name_a: &str,
name_b: &str,
) -> (Option<String>, Result<(Session, Session), CoreError>) {
match resolve_profile_context(config, flag) {
Ok(None) => (None, Err(CoreError::NoActiveProfile)),
Ok(Some((active, _))) => {
let sides = named_profile_client(config, name_a)
.and_then(|api_a| named_profile_client(config, name_b).map(|api_b| (api_a, api_b)));
(Some(active), sides)
}
Err(err) => (None, Err(err)),
}
}
fn resolve_headerless_api(flag: Option<&str>) -> (Option<String>, Result<Session, CoreError>) {
match Session::resolve_degraded(flag) {
Ok(session) => (Some(session.profile_name().to_string()), Ok(session)),
Err(err) => (error_profile(&err), Err(err)),
}
}
fn commissioned_probe(plan: &ignition_core::rig::RigPlan) -> Option<Session> {
rig_gateway_client(plan, None)
}
fn module_fetch_policy(
refresh: bool,
accept_upstream_change: bool,
) -> ignition_core::module::fetch::FetchPolicy {
if accept_upstream_change {
ignition_core::module::fetch::FetchPolicy::AcceptUpstreamChange
} else if refresh {
ignition_core::module::fetch::FetchPolicy::Refresh
} else {
ignition_core::module::fetch::FetchPolicy::default()
}
}
fn rig_gateway_client(
plan: &ignition_core::rig::RigPlan,
credential: Option<Credential>,
) -> Option<Session> {
actions::rig::gateway_url_from(plan)
.and_then(|url| Session::for_url(url.parse().ok()?, credential, false).ok())
}
fn trial_no_gateway(plan: &ignition_core::rig::RigPlan) -> CoreError {
CoreError::Rig(format!(
"rig {} publishes no gateway port (target 8088/443) — trial \
commands address the rig's gateway",
plan.name
))
}
fn error_profile(err: &CoreError) -> Option<String> {
match err {
CoreError::SecretUnavailable { profile } => Some(profile.clone()),
_ => None,
}
}
fn env_non_empty(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
fn auth_ref_from_flags(
token_env: Option<String>,
keyring: Option<String>,
user_env: Option<String>,
password_env: Option<String>,
) -> config::AuthRef {
if let Some(token_env) = token_env {
config::AuthRef::TokenEnv { token_env }
} else if let Some(keyring) = keyring {
config::AuthRef::Keyring { keyring }
} else if let (Some(user_env), Some(password_env)) = (user_env, password_env) {
config::AuthRef::Basic {
user_env,
password_env,
}
} else {
config::AuthRef::default()
}
}
fn apply_env_defaults(cli: &mut Cli) {
if cli.profile.is_none()
&& let Ok(profile) = std::env::var("IGNITION_PROFILE")
&& !profile.is_empty()
{
cli.profile = Some(profile);
}
if !cli.json && std::env::var("IGNITION_JSON").is_ok_and(|v| v == "1") {
cli.json = true;
}
if !cli.yes && std::env::var("IGNITION_YES").is_ok_and(|v| v == "1") {
cli.yes = true;
}
if let Commands::Rig(rig_args) = &mut cli.command
&& rig_args.rig.is_none()
&& let Ok(rig) = std::env::var("IGNITION_RIG")
&& !rig.is_empty()
{
rig_args.rig = Some(rig);
}
}
fn require_confirmation(yes: bool, operation: &str) -> Result<(), CoreError> {
if yes {
Ok(())
} else {
Err(CoreError::ConfirmationRequired {
operation: operation.to_string(),
})
}
}
pub(crate) const GUARDED_OPS: &[(&str, &str)] = &[
("sessions terminate", "sessions terminate"),
("logs loggers set", "logs loggers set"),
("logs loggers reset", "logs loggers reset"),
("restart", "restart"),
("project delete", "project delete"),
(
"project import",
"project import --collision-policy overwrite",
),
(
"project sync",
"project sync (overwrite-import the whole project on <profile-b> — \
replaces concurrent Designer edits)",
),
(
"resource put",
"resource put (re-imports the project; concurrent Designer edits are replaced)",
),
(
"resource delete",
"resource delete (re-imports the project; concurrent Designer edits are replaced)",
),
("tags provider delete", "tags provider delete"),
("tags config delete", "tags config delete"),
("tags import", "tags import --collision-policy overwrite"),
("rig reset", "rig reset"),
("rig restore", "rig restore"),
("rig trial reset", "rig trial reset"),
("rig module uninstall", "rig module uninstall"),
(
"backup restore",
"backup restore (overwrites this gateway's state from the gwbk — gateway \
restarts and blocks ~minutes)",
),
(
"eam task new",
"eam task new (scheduleMode <mode> arms autonomous gateway actions)",
),
(
"eam task force",
"eam task force <task> <blast-radius preview line>",
),
(
"eam task suspend",
"eam task suspend <task> <blast-radius preview line>",
),
(
"eam task resume",
"eam task resume <task> <blast-radius preview line>",
),
(
"eam task cancel",
"eam task cancel <task> <blast-radius preview line>",
),
(
"eam task modify",
"eam task modify <task> <blast-radius preview line>",
),
(
"eam task delete",
"eam task delete <task> <blast-radius preview line>",
),
("edit", "edit would write <N> member(s) to <project>"),
(
"workspace push",
"workspace push would write <N> member(s) and delete <M> member(s)",
),
(
"e2e init",
"e2e init <target, member list with statuses, and the npm/npx command lines>",
),
];
async fn preview_then_confirm(
api: &dyn ignition_core::client::GatewayApi,
verb: &str,
task_name: &str,
yes: bool,
) -> Result<(), CoreError> {
let preview = actions::eam::build_blast_radius(api, verb, task_name).await?;
let operation = actions::eam::render_preview_line(&preview);
require_confirmation(yes, &operation)
}
fn parse_write_scalar(raw: &str) -> Result<serde_json::Value, CoreError> {
match serde_json::from_str::<serde_json::Value>(raw) {
Ok(value) if !value.is_array() && !value.is_object() => Ok(value),
Ok(_) => Err(CoreError::InvalidInput {
reason: format!(
"--value must be a JSON scalar (number, bool, null, or string) — \
arrays/objects cannot ride the tag write slot: {raw:?}"
),
}),
Err(_) => Ok(serde_json::Value::String(raw.to_string())),
}
}
async fn read_input_bytes(file: &std::path::Path) -> Result<Vec<u8>, CoreError> {
let label = if file == std::path::Path::new("-") {
"stdin".to_string()
} else {
file.display().to_string()
};
if file == std::path::Path::new("-") {
use tokio::io::AsyncReadExt;
let mut buffer = Vec::new();
tokio::io::stdin()
.read_to_end(&mut buffer)
.await
.map_err(|err| CoreError::InvalidInput {
reason: format!("cannot read stdin: {err}"),
})?;
Ok(buffer)
} else {
std::fs::read(file).map_err(|err| CoreError::InvalidInput {
reason: format!("cannot read {label}: {err}"),
})
}
}
async fn read_json_input(file: &std::path::Path) -> Result<serde_json::Value, CoreError> {
let bytes = read_input_bytes(file).await?;
let label = if file == std::path::Path::new("-") {
"stdin".to_string()
} else {
file.display().to_string()
};
serde_json::from_slice(&bytes).map_err(|err| CoreError::InvalidInput {
reason: format!("{label} is not valid JSON: {err}"),
})
}
fn loss_gate(
bytes: &[u8],
format: cli::TransferFormat,
yes: bool,
) -> Result<Option<actions::tags::LossReport>, CoreError> {
let (label, scan_names, facts) = match format {
cli::TransferFormat::Json => return Ok(None),
cli::TransferFormat::Xml => {
let scan = actions::tag_loss::scan_xml(bytes);
("xml", scan.top_level_names, scan.facts)
}
cli::TransferFormat::Csv => {
let scan = actions::tag_loss::scan_csv(bytes);
("csv", scan.top_level_names, scan.facts)
}
};
if facts.is_empty() {
return Ok(None);
}
let prose = render_loss_prose(label, &facts, &scan_names);
if !yes {
return Err(CoreError::InvalidInput {
reason: format!("{prose}re-run with --yes to import anyway"),
});
}
Ok(Some(actions::tags::LossReport {
format: if matches!(format, cli::TransferFormat::Xml) {
"xml"
} else {
"csv"
},
facts,
top_level_names: scan_names,
dropped_keys: Vec::new(),
coerced: Vec::new(),
rows: 0,
}))
}
fn render_loss_prose(
label: &str,
facts: &[actions::tag_loss::LossFact],
names: &[String],
) -> String {
let mut out = format!(
"loss report ({label}): the scan reports {} finding(s) before the import:\n",
facts.len()
);
for fact in facts {
out.push_str(&format!(" - [{}] {}\n", fact.code, fact.detail));
}
if !names.is_empty() {
out.push_str(&format!("top-level tag(s): {}\n", names.join(", ")));
}
out
}
fn default_export_path(paths: &[String], format: cli::TransferFormat) -> std::path::PathBuf {
let json_name = actions::tags::default_export_file_name(paths);
let stem = json_name.strip_suffix(".json").unwrap_or(&json_name);
let ext = match format {
cli::TransferFormat::Json => "json",
cli::TransferFormat::Xml => "xml",
cli::TransferFormat::Csv => "csv",
};
std::path::PathBuf::from(format!("{stem}.{ext}"))
}
async fn export_csv_arm(
api: &dyn ignition_core::client::GatewayApi,
project: &str,
paths: &[String],
out: Option<&std::path::Path>,
) -> Result<actions::tags::TagsExportResult, CoreError> {
let json_result =
actions::tags::tags_export(api, project, paths, None, actions::tags::ExportFormat::Json)
.await?;
let pretty = json_result
.payload
.as_deref()
.expect("stdout-mode export carries the pretty payload");
let subtrees: Vec<serde_json::Value> = serde_json::from_str(pretty).map_err(|err| {
CoreError::Internal(format!("csv export lost the parsed interchange: {err}"))
})?;
let report = actions::tags::generate_legacy_csv(&subtrees)?;
let loss_report = actions::tags::LossReport {
format: "csv",
facts: Vec::new(),
top_level_names: Vec::new(),
dropped_keys: report.dropped_keys.clone(),
coerced: report.coerced.clone(),
rows: report.rows,
};
match out {
Some(path) => {
std::fs::write(path, &report.csv).map_err(|err| {
CoreError::Internal(format!("cannot write {}: {err}", path.display()))
})?;
Ok(actions::tags::TagsExportResult {
project: project.to_string(),
paths: paths.to_vec(),
file: Some(path.display().to_string()),
stdout: false,
tag_count: report.rows,
format: "csv",
payload: None,
raw: None,
loss_report: Some(loss_report),
})
}
None => {
let text = String::from_utf8(report.csv).map_err(|_| {
CoreError::Internal("generated csv bytes are not UTF-8".to_string())
})?;
Ok(actions::tags::TagsExportResult {
project: project.to_string(),
paths: paths.to_vec(),
file: None,
stdout: true,
tag_count: report.rows,
format: "csv",
payload: None,
raw: Some(text),
loss_report: Some(loss_report),
})
}
}
}
fn build_api_call_request(
args: &ignition_cli::cli::ApiCallArgs,
) -> Result<ignition_core::client::apicall::ApiCallRequest, CoreError> {
let mut headers = Vec::with_capacity(args.header.len());
for raw in &args.header {
let Some((name, value)) = raw.split_once(':') else {
return Err(CoreError::InvalidInput {
reason: format!(
"--header expects \"Name: Value\" (split on the first colon): {raw:?}"
),
});
};
if name.trim().is_empty() {
return Err(CoreError::InvalidInput {
reason: format!("--header needs a non-empty header name: {raw:?}"),
});
}
headers.push((name.trim().to_string(), value.trim().to_string()));
}
let mut query = Vec::with_capacity(args.query.len());
for raw in &args.query {
let Some((key, value)) = raw.split_once('=') else {
return Err(CoreError::InvalidInput {
reason: format!("--query expects \"k=v\" (split on the first '='): {raw:?}"),
});
};
if key.trim().is_empty() {
return Err(CoreError::InvalidInput {
reason: format!("--query needs a non-empty key: {raw:?}"),
});
}
query.push((key.trim().to_string(), value.trim().to_string()));
}
Ok(ignition_core::client::apicall::ApiCallRequest {
method: args.method.trim().to_uppercase(),
path: args.path.clone(),
body: args.data.clone(),
headers,
query,
})
}
fn init_tracing(verbosity: u8) {
use tracing_subscriber::EnvFilter;
let filter = match std::env::var("IGNITION_LOG") {
Ok(spec) if !spec.is_empty() => EnvFilter::new(spec),
_ => EnvFilter::new(match verbosity {
0 => "warn",
1 => "info",
2 => "debug",
_ => "trace",
}),
};
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.init();
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::{GUARDED_OPS, module_fetch_policy, require_confirmation};
#[test]
fn confirmation_guard_refuses_without_yes() {
let err = require_confirmation(false, "project delete").expect_err("refuses without --yes");
assert_eq!(err.exit_code(), 2, "usage class — it names a missing flag");
assert_eq!(err.code(), "confirmation_required");
let hint = err.hint().expect("hint required");
assert!(
hint.contains("--yes") && hint.contains("IGNITION_YES"),
"hint names the flag and the env escape hatch: {hint}"
);
assert!(
err.to_string().contains("project delete"),
"message names the operation: {err}"
);
require_confirmation(true, "project delete").expect("--yes confirms");
}
#[test]
fn module_fetch_policy_flags_map_independently() {
assert_eq!(
module_fetch_policy(false, false),
ignition_core::module::fetch::FetchPolicy::default(),
"neither flag: the default policy"
);
assert_eq!(
module_fetch_policy(true, false),
ignition_core::module::fetch::FetchPolicy::Refresh,
"--refresh alone must map to Refresh, never Accept"
);
assert_eq!(
module_fetch_policy(false, true),
ignition_core::module::fetch::FetchPolicy::AcceptUpstreamChange,
"--accept-upstream-change alone must map to AcceptUpstreamChange"
);
}
fn second_call_argument(call_inner: &str) -> Option<&str> {
let mut depth = 0usize;
let mut in_string = false;
let mut escaped = false;
for (i, b) in call_inner.bytes().enumerate() {
if in_string {
if escaped {
escaped = false;
} else if b == b'\\' {
escaped = true;
} else if b == b'"' {
in_string = false;
}
continue;
}
match b {
b'"' => in_string = true,
b'(' => depth += 1,
b')' => return None,
b',' if depth == 0 => return Some(call_inner[i + 1..].trim()),
_ => {}
}
}
None
}
fn unquote_rust_string(token: &str) -> String {
let inner = &token[1..token.len() - 1];
let mut out = String::new();
let mut chars = inner.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\\' => match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('"') => out.push('"'),
Some('\\') => out.push('\\'),
Some('\n') => {
while chars.peek().is_some_and(|next| next.is_whitespace()) {
chars.next();
}
}
Some(other) => {
out.push('\\');
out.push(other);
}
None => {}
},
other => out.push(other),
}
}
out
}
fn static_guard_literals(src: &str) -> Vec<String> {
const NEEDLE: &str = "require_confirmation(";
let mut literals = Vec::new();
let mut from = 0usize;
while let Some(found) = src[from..].find(NEEDLE) {
let start = from + found + NEEDLE.len();
let mut depth = 1usize;
let mut in_string = false;
let mut escaped = false;
let mut end = None;
for (i, b) in src[start..].bytes().enumerate() {
if in_string {
if escaped {
escaped = false;
} else if b == b'\\' {
escaped = true;
} else if b == b'"' {
in_string = false;
}
continue;
}
match b {
b'"' => in_string = true,
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
end = Some(i);
break;
}
}
_ => {}
}
}
let Some(end) = end else { break };
let inner = &src[start..start + end];
if let Some(second) = second_call_argument(inner)
&& second.starts_with('"')
&& second.ends_with('"')
{
literals.push(unquote_rust_string(second));
}
from = start + end;
}
literals
}
fn guarded_site_markers(src: &str) -> Vec<String> {
let mut out = Vec::new();
for line in src.lines() {
let trimmed = line.trim_start();
let Some(rest) = trimmed.strip_prefix("// guarded:") else {
continue;
};
let path = rest.trim();
if !path.is_empty()
&& path
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b' ')
{
out.push(path.to_string());
}
}
out
}
#[test]
fn guarded_ops_registry_tracks_every_dispatch_site() {
let src = include_str!("main.rs");
let literals = static_guard_literals(src);
assert!(
!literals.is_empty(),
"the literal scan found the guarded sites"
);
for literal in &literals {
let hits: Vec<_> = GUARDED_OPS
.iter()
.filter(|(_, prose)| *prose == literal)
.collect();
assert_eq!(
hits.len(),
1,
"static refusal literal {literal:?} must be the prose column of \
exactly one GUARDED_OPS entry"
);
}
let markers = guarded_site_markers(src);
let marker_set: BTreeSet<&str> = markers.iter().map(String::as_str).collect();
let path_set: BTreeSet<&str> = GUARDED_OPS.iter().map(|(path, _)| *path).collect();
let unregistered: Vec<_> = marker_set.difference(&path_set).collect();
assert!(
unregistered.is_empty(),
"dispatch-site marker with no GUARDED_OPS entry (register it): {unregistered:?}"
);
let unmarked: Vec<_> = path_set.difference(&marker_set).collect();
assert!(
unmarked.is_empty(),
"GUARDED_OPS entry with no dispatch-site marker (mark the site): {unmarked:?}"
);
assert_eq!(
marker_set.len(),
markers.len(),
"duplicate markers at dispatch sites are forbidden"
);
assert_eq!(
path_set.len(),
GUARDED_OPS.len(),
"duplicate GUARDED_OPS paths are forbidden"
);
}
}