Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Unreleased

* feat: Many more commands support `--json` and `--quiet`.

# v0.2.1

* feat: icp-cli will now inform you if a new version is released. This can be disabled with `icp settings update-check`
Expand Down
102 changes: 76 additions & 26 deletions crates/icp-cli/src/commands/canister/call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ use icp::manifest::InitArgsFormat;
use icp::parsers::CyclesAmount;
use icp::prelude::*;
use icp_canister_interfaces::proxy::{ProxyArgs, ProxyResult};
use serde::Serialize;
use std::io::{self, Write};
use tracing::warn;
use tracing::{error, warn};

use crate::{commands::args, operations::misc::fetch_canister_metadata};

Expand Down Expand Up @@ -79,6 +80,10 @@ pub(crate) struct CallArgs {
/// How to interpret and display the response.
#[arg(long, short, default_value = "auto")]
pub(crate) output: CallOutputMode,

/// Output command results as JSON
#[arg(long)]
pub(crate) json: bool,
}

pub(crate) async fn exec(ctx: &Context, args: &CallArgs) -> Result<(), anyhow::Error> {
Expand Down Expand Up @@ -246,41 +251,86 @@ pub(crate) async fn exec(ctx: &Context, args: &CallArgs) -> Result<(), anyhow::E

let mut term = Term::buffered_stdout();
let res_hex = || format!("response (hex): {}", hex::encode(&res));
let mut json_response = JsonCallResponse {
response_bytes: hex::encode(&res),
response_text: None,
response_candid: None,
};

match args.output {
CallOutputMode::Auto => {
if let Ok(ret) = try_decode_candid(&res, declared_method.as_ref()) {
print_candid_for_term(&mut term, &ret)
.context("failed to print candid return value")?;
} else if let Ok(s) = std::str::from_utf8(&res) {
writeln!(term, "{s}")?;
term.flush()?;
} else {
// catch errors, because the json result should be printed regardless of errors
let res = (|| {
match args.output {
CallOutputMode::Auto => {
if let Ok(ret) = try_decode_candid(&res, declared_method.as_ref()) {
if args.json {
json_response.response_candid = Some(format!("{ret}"));
} else {
print_candid_for_term(&mut term, &ret)
.context("failed to print candid return value")?;
}
} else if let Ok(s) = std::str::from_utf8(&res) {
if args.json {
json_response.response_text = Some(s.to_string());
} else {
writeln!(term, "{s}")?;
term.flush()?;
}
} else if !args.json {
writeln!(term, "{}", hex::encode(&res))?;
term.flush()?;
}
}
CallOutputMode::Candid => {
let ret =
try_decode_candid(&res, declared_method.as_ref()).with_context(res_hex)?;
if args.json {
json_response.response_candid = Some(format!("{ret}"));
} else {
print_candid_for_term(&mut term, &ret)
.context("failed to print candid return value")?;
}
}
CallOutputMode::Text => {
let s = std::str::from_utf8(&res)
.with_context(res_hex)
.context("response is not valid UTF-8")?;
if args.json {
json_response.response_text = Some(s.to_string());
} else {
writeln!(term, "{s}")?;
term.flush()?;
}
}
CallOutputMode::Hex => {
writeln!(term, "{}", hex::encode(&res))?;
term.flush()?;
}
}
CallOutputMode::Candid => {
let ret = try_decode_candid(&res, declared_method.as_ref()).with_context(res_hex)?;
print_candid_for_term(&mut term, &ret)
.context("failed to print candid return value")?;
}
CallOutputMode::Text => {
let s = std::str::from_utf8(&res)
.with_context(res_hex)
.context("response is not valid UTF-8")?;
writeln!(term, "{s}")?;
term.flush()?;
}
CallOutputMode::Hex => {
writeln!(term, "{}", hex::encode(&res))?;
term.flush()?;
};
anyhow::Ok(())
})();
if args.json {
let write_result = serde_json::to_writer(term, &json_response);
if let Err(write_err) = write_result {
if let Err(decode_err) = res {
error!("failed to write JSON response: {write_err}");
return Err(decode_err);
} else {
return Err(write_err).context("failed to write JSON response");
}
}
}
res?;

Ok(())
}

#[derive(Serialize)]
struct JsonCallResponse {
response_bytes: String,
response_text: Option<String>,
response_candid: Option<String>,
}

/// Tries to decode the response as Candid. Returns `None` if decoding fails.
fn try_decode_candid(
res: &[u8],
Expand Down
29 changes: 29 additions & 0 deletions crates/icp-cli/src/commands/canister/create.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
use std::io::stdout;

use anyhow::anyhow;
use candid::{Nat, Principal};
use clap::{ArgGroup, Args, Parser};
use icp::context::Context;
use icp::parsers::{CyclesAmount, DurationAmount, MemoryAmount};
use icp::{Canister, context::CanisterSelection, prelude::*};
use icp_canister_interfaces::management_canister::CanisterSettingsArg;
use serde::Serialize;
use tracing::info;

use crate::{commands::args, operations::create::CreateOperation};
Expand Down Expand Up @@ -91,6 +94,10 @@ pub(crate) struct CreateArgs {
required_unless_present = "canister"
)]
pub detached: bool,

/// Output command results as JSON
#[arg(long, conflicts_with = "quiet")]
pub(crate) json: bool,
}

impl CreateArgs {
Expand Down Expand Up @@ -193,6 +200,14 @@ async fn create_canister(ctx: &Context, args: &CreateArgs) -> Result<(), anyhow:

if args.quiet {
println!("{id}");
} else if args.json {
serde_json::to_writer(
stdout(),
&JsonCreate {
canister_id: id,
canister_name: None,
},
)?;
} else {
println!("Created canister with ID {id}");
}
Expand Down Expand Up @@ -249,9 +264,23 @@ async fn create_project_canister(ctx: &Context, args: &CreateArgs) -> Result<(),

if args.quiet {
println!("{id}");
} else if args.json {
serde_json::to_writer(
stdout(),
&JsonCreate {
canister_id: id,
canister_name: Some(canister.clone()),
},
)?;
} else {
println!("Created canister {canister} with ID {id}");
}

Ok(())
}

#[derive(Serialize)]
struct JsonCreate {
canister_id: Principal,
canister_name: Option<String>,
}
20 changes: 16 additions & 4 deletions crates/icp-cli/src/commands/canister/list.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
use std::io::stdout;

use clap::Args;
use icp::context::Context;
use serde::Serialize;

use crate::options::EnvironmentOpt;

Expand All @@ -8,15 +11,24 @@ use crate::options::EnvironmentOpt;
pub(crate) struct ListArgs {
#[command(flatten)]
pub(crate) environment: EnvironmentOpt,
/// Output command results as JSON
#[arg(long)]
pub(crate) json: bool,
}

pub(crate) async fn exec(ctx: &Context, args: &ListArgs) -> Result<(), anyhow::Error> {
let environment_selection = args.environment.clone().into();
let env = ctx.get_environment(&environment_selection).await?;

for c in env.canisters.keys() {
println!("{c}");
let canisters = env.canisters.keys().cloned().collect();
if args.json {
serde_json::to_writer(stdout(), &JsonList { canisters })?;
} else {
println!("{}", canisters.join("\n"));
}

Ok(())
}

#[derive(Serialize)]
struct JsonList {
canisters: Vec<String>,
}
67 changes: 60 additions & 7 deletions crates/icp-cli/src/commands/canister/logs.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::io::stdout;

use anyhow::{Context as _, anyhow};
use clap::Args;
use ic_utils::interfaces::ManagementCanister;
Expand All @@ -6,6 +8,8 @@ use ic_utils::interfaces::management_canister::{
};
use icp::context::Context;
use icp::signal::stop_signal;
use itertools::Itertools;
use serde::Serialize;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tokio::select;

Expand Down Expand Up @@ -42,6 +46,10 @@ pub(crate) struct LogsArgs {
/// Show logs before this log index (exclusive). Cannot be used with --follow
#[arg(long, value_name = "INDEX", conflicts_with_all = ["follow", "since", "until"])]
pub(crate) until_index: Option<u64>,

/// Output command results as JSON
#[arg(long)]
pub(crate) json: bool,
}

fn parse_timestamp(s: &str) -> Result<u64, String> {
Expand Down Expand Up @@ -99,13 +107,24 @@ pub(crate) async fn exec(ctx: &Context, args: &LogsArgs) -> Result<(), anyhow::E

if args.follow {
// Follow mode: continuously fetch and display new logs
follow_logs(&mgmt, &canister_id, args.interval).await
follow_logs(args, &mgmt, &canister_id, args.interval).await
} else {
// Single fetch mode: fetch all logs once
fetch_and_display_logs(&mgmt, &canister_id, build_filter(args)?).await
fetch_and_display_logs(args, &mgmt, &canister_id, build_filter(args)?).await
}
}

#[derive(Serialize)]
struct JsonFollowRecord {
timestamp: u64,
index: u64,
content: String,
}
#[derive(Serialize)]
struct JsonListRecord {
log_records: Vec<JsonFollowRecord>,
}

fn build_filter(args: &LogsArgs) -> Result<Option<CanisterLogFilter>, anyhow::Error> {
if args.since_index.is_some() || args.until_index.is_some() {
let start = args.since_index.unwrap_or(0);
Expand Down Expand Up @@ -141,6 +160,7 @@ fn build_filter(args: &LogsArgs) -> Result<Option<CanisterLogFilter>, anyhow::Er
}

async fn fetch_and_display_logs(
args: &LogsArgs,
mgmt: &ManagementCanister<'_>,
canister_id: &candid::Principal,
filter: Option<CanisterLogFilter>,
Expand All @@ -154,9 +174,30 @@ async fn fetch_and_display_logs(
.await
.context("Failed to fetch canister logs")?;

for log in result.canister_log_records {
let formatted = format_log(&log);
println!("{formatted}");
if args.json {
println!(
"{}",
result
.canister_log_records
.iter()
.map(format_log)
.format("\n")
);
} else {
serde_json::to_writer(
stdout(),
&JsonListRecord {
log_records: result
.canister_log_records
.iter()
.map(|log| JsonFollowRecord {
timestamp: log.timestamp_nanos,
index: log.idx,
content: String::from_utf8_lossy(&log.content).into_owned(),
})
.collect(),
},
)?;
}

Ok(())
Expand All @@ -165,6 +206,7 @@ async fn fetch_and_display_logs(
const FOLLOW_LOOKBACK_NANOS: u64 = 60 * 60 * 1_000_000_000; // 1 hour

async fn follow_logs(
args: &LogsArgs,
mgmt: &ManagementCanister<'_>,
canister_id: &candid::Principal,
interval_seconds: u64,
Expand Down Expand Up @@ -204,8 +246,18 @@ async fn follow_logs(

if !new_logs.is_empty() {
for log in &new_logs {
let formatted = format_log(log);
println!("{formatted}");
if args.json {
serde_json::to_writer(
stdout(),
&JsonFollowRecord {
timestamp: log.timestamp_nanos,
index: log.idx,
content: String::from_utf8_lossy(&log.content).into_owned(),
},
)?;
} else {
println!("{}", format_log(log));
}
}
// Update last_idx to the highest idx we've displayed
if let Some(last_log) = new_logs.last() {
Expand Down Expand Up @@ -387,6 +439,7 @@ mod tests {
until,
since_index,
until_index,
json: false,
}
}

Expand Down
Loading
Loading