Skip to content

Commit 5224f7d

Browse files
authored
feat(pop-cli): add --json support for new, clean, completion, upgrade, and install commands (#994)
* feat(pop-cli): add --json support for clean, completion, upgrade, and install commands Extends the global --json flag to the four remaining commands that didn't support it yet. Each command emits a CliResponse envelope with structured data and returns PromptRequiredError when interactive flags are missing. * fix: cfg-gate imports and restore dead_code allows for feature compat The CI builds with --no-default-features, --features contract, and --features chain. The cli::Cli import is only used in feature-gated blocks, and JsonCli infrastructure types need unconditional allow(dead_code) since the compiler cannot trace trait dispatch. * feat(pop-cli): add --json support for the `new` command Wire OutputMode through `new chain`, `new pallet`, and `new contract` subcommands so that `pop new --json` produces machine-readable output. JSON mode behaviour: - `pop new --json --list` emits a TemplateListOutput envelope with all non-deprecated chain and contract templates. - Each subcommand's `--list` emits a SubcommandTemplateListOutput. - Generation emits a NewOutput envelope with kind, name, path, and optional template fields. - Missing required arguments (name, template, bare --with-frontend) return a PROMPT_REQUIRED error instead of launching interactive prompts. - Human-mode behaviour is completely unchanged. Closes #972 * fix: return PROMPT_REQUIRED for json new command prompt paths address review feedback: guard prompt-driven JSON paths\n\nAddressed comments:\n- [new/pallet.rs] reject interactive advanced mode in --json unless advanced flags are provided\n- [new/{pallet,contract,chain}.rs] fail early with PromptRequiredError when destination path already exists\n- [new/{pallet,contract,chain}.rs tests] add JSON regression tests for existing-path and advanced prompt cases
1 parent 1620a63 commit 5224f7d

File tree

9 files changed

+605
-16
lines changed

9 files changed

+605
-16
lines changed

crates/pop-cli/src/cli.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,9 @@ impl traits::Cli for JsonCli {
465465
}
466466
}
467467

468+
#[allow(dead_code)]
469+
const JSON_PROMPT_ERR: &str = "interactive prompt required but --json mode is active";
470+
468471
#[allow(dead_code)]
469472
struct JsonConfirm;
470473
impl traits::Confirm for JsonConfirm {

crates/pop-cli/src/commands/build/mod.rs

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,8 @@ impl Command {
554554
return Err(BuildCommandError::new("Build failed").with_details(details).into());
555555
}
556556

557-
let artifact_path = profile.target_directory(path);
557+
let root = pop_common::find_workspace_root(path).unwrap_or_else(|| path.to_path_buf());
558+
let artifact_path = profile.target_directory(&root);
558559
Ok(BuildOutput {
559560
artifact_path: artifact_path.display().to_string(),
560561
profile: profile.to_string(),
@@ -1136,6 +1137,63 @@ mod tests {
11361137
Ok(())
11371138
}
11381139

1140+
#[test]
1141+
fn build_json_workspace_member_uses_workspace_root_target() -> anyhow::Result<()> {
1142+
let temp_dir = tempfile::tempdir()?;
1143+
let ws = temp_dir.path();
1144+
1145+
// Create workspace Cargo.toml
1146+
std::fs::write(ws.join("Cargo.toml"), "[workspace]\nmembers = [\"member\"]\n")?;
1147+
1148+
// Create a member crate
1149+
cmd("cargo", ["new", "member", "--bin"]).dir(ws).run()?;
1150+
let member_path = ws.join("member");
1151+
std::fs::write(
1152+
member_path.join("src/main.rs"),
1153+
"//! test binary\nfn main() { println!(\"ok\"); }\n",
1154+
)?;
1155+
let output = Command::build_json(
1156+
&BuildArgs {
1157+
#[cfg(feature = "chain")]
1158+
command: None,
1159+
path: Some(member_path.clone()),
1160+
path_pos: None,
1161+
package: None,
1162+
release: false,
1163+
profile: Some(Profile::Debug),
1164+
features: None,
1165+
#[cfg(feature = "chain")]
1166+
benchmark: false,
1167+
#[cfg(feature = "chain")]
1168+
try_runtime: false,
1169+
#[cfg(feature = "chain")]
1170+
deterministic: false,
1171+
#[cfg(feature = "chain")]
1172+
tag: None,
1173+
#[cfg(feature = "chain")]
1174+
only_runtime: false,
1175+
#[cfg(feature = "contract")]
1176+
metadata: None,
1177+
#[cfg(feature = "contract")]
1178+
verifiable: false,
1179+
#[cfg(feature = "contract")]
1180+
image: None,
1181+
},
1182+
&member_path,
1183+
)?;
1184+
1185+
// artifact_path must point to the workspace root target, not the member's
1186+
let artifact = PathBuf::from(&output.artifact_path);
1187+
let ws_canonical = ws.canonicalize()?;
1188+
assert!(
1189+
artifact.starts_with(&ws_canonical),
1190+
"expected artifact under workspace root {ws_canonical:?}, got {artifact:?}"
1191+
);
1192+
assert!(!artifact.starts_with(ws_canonical.join("member")));
1193+
assert!(artifact.exists());
1194+
Ok(())
1195+
}
1196+
11391197
#[test]
11401198
fn build_json_failure_returns_build_command_error() -> anyhow::Result<()> {
11411199
let name = "json_build_fail";

crates/pop-cli/src/commands/mod.rs

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
use crate::cli::Cli;
55
#[cfg(any(feature = "chain", feature = "contract"))]
66
use crate::cli::traits::Cli as _;
7+
#[cfg(any(feature = "chain", feature = "contract"))]
8+
use crate::output::PromptRequiredError;
79
use crate::{
810
cache,
911
output::{CliResponse, OutputMode, reject_unsupported_json},
@@ -129,6 +131,8 @@ impl Command {
129131
Self::Fork(_) => true,
130132
#[cfg(any(feature = "chain", feature = "contract"))]
131133
Self::Install(_) => true,
134+
#[cfg(any(feature = "chain", feature = "contract"))]
135+
Self::New(_) => true,
132136
#[cfg(feature = "chain")]
133137
Self::Bench(_) => true,
134138
#[cfg(any(feature = "chain", feature = "contract"))]
@@ -151,6 +155,48 @@ impl Command {
151155
Self::New(args) => {
152156
env_logger::init();
153157

158+
if output_mode == OutputMode::Json {
159+
if args.list {
160+
let output = new::TemplateListOutput {
161+
#[cfg(feature = "chain")]
162+
chain_templates: pop_chains::ChainTemplate::templates()
163+
.iter()
164+
.filter(|t| !t.is_deprecated())
165+
.map(|t| new::TemplateInfo {
166+
name: t.name().to_string(),
167+
description: t.description().to_string(),
168+
})
169+
.collect(),
170+
#[cfg(feature = "contract")]
171+
contract_templates: pop_contracts::Contract::templates()
172+
.iter()
173+
.filter(|t| !t.is_deprecated())
174+
.map(|t| new::TemplateInfo {
175+
name: t.name().to_string(),
176+
description: t.description().to_string(),
177+
})
178+
.collect(),
179+
};
180+
CliResponse::ok(output).print_json();
181+
return Ok(());
182+
}
183+
184+
return match &mut args.command {
185+
None => Err(PromptRequiredError(
186+
"--json mode requires a subcommand (chain, pallet, or contract)".into(),
187+
)
188+
.into()),
189+
Some(cmd) => match cmd {
190+
#[cfg(feature = "chain")]
191+
new::Command::Chain(cmd) => cmd.execute(output_mode).await,
192+
#[cfg(feature = "chain")]
193+
new::Command::Pallet(cmd) => cmd.execute(output_mode).await,
194+
#[cfg(feature = "contract")]
195+
new::Command::Contract(cmd) => cmd.execute(output_mode).await,
196+
},
197+
};
198+
}
199+
154200
if args.list {
155201
Cli.intro("Available templates")?;
156202
#[cfg(feature = "chain")]
@@ -190,11 +236,11 @@ impl Command {
190236

191237
match command {
192238
#[cfg(feature = "chain")]
193-
new::Command::Chain(cmd) => cmd.execute().await,
239+
new::Command::Chain(cmd) => cmd.execute(output_mode).await,
194240
#[cfg(feature = "chain")]
195-
new::Command::Pallet(cmd) => cmd.execute().await,
241+
new::Command::Pallet(cmd) => cmd.execute(output_mode).await,
196242
#[cfg(feature = "contract")]
197-
new::Command::Contract(cmd) => cmd.execute().await,
243+
new::Command::Contract(cmd) => cmd.execute(output_mode).await,
198244
}
199245
},
200246
#[cfg(feature = "chain")]

crates/pop-cli/src/commands/new/chain.rs

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
use crate::{
44
cli::{self, traits::*},
55
common::helpers::check_destination_path,
6-
new::frontend::{PackageManager, create_frontend, prompt_frontend_template},
6+
new::{
7+
NewOutput, SubcommandTemplateListOutput, TemplateInfo,
8+
frontend::{PackageManager, create_frontend, prompt_frontend_template},
9+
},
10+
output::{CliResponse, OutputMode, PromptRequiredError},
711
};
812
use anyhow::Result;
913
use clap::{
@@ -85,7 +89,10 @@ pub struct NewChainCommand {
8589

8690
impl NewChainCommand {
8791
/// Executes the command.
88-
pub(crate) async fn execute(&self) -> Result<()> {
92+
pub(crate) async fn execute(&self, output_mode: OutputMode) -> Result<()> {
93+
if output_mode == OutputMode::Json {
94+
return self.execute_json().await;
95+
}
8996
if self.list {
9097
let mut cli = cli::Cli;
9198
cli.intro("Available templates")?;
@@ -151,6 +158,93 @@ impl NewChainCommand {
151158
Ok(())
152159
}
153160

161+
/// Executes the command in JSON mode.
162+
async fn execute_json(&self) -> Result<()> {
163+
// --list: return structured template listing.
164+
if self.list {
165+
let templates: Vec<TemplateInfo> = ChainTemplate::templates()
166+
.iter()
167+
.filter(|t| !t.is_deprecated())
168+
.map(|t| TemplateInfo {
169+
name: t.name().to_string(),
170+
description: t.description().to_string(),
171+
})
172+
.collect();
173+
CliResponse::ok(SubcommandTemplateListOutput { templates }).print_json();
174+
return Ok(());
175+
}
176+
177+
// Name is required in JSON mode.
178+
let name = self.name.as_ref().ok_or_else(|| {
179+
PromptRequiredError(
180+
"--json mode requires the chain name as a positional argument".into(),
181+
)
182+
})?;
183+
184+
let destination_path = Path::new(name);
185+
if destination_path.exists() {
186+
return Err(PromptRequiredError(format!(
187+
"--json mode cannot confirm deleting existing path \"{}\". Remove it first or choose a different name.",
188+
destination_path.display()
189+
))
190+
.into());
191+
}
192+
193+
// Bare --with-frontend (empty string = needs prompt) is not allowed.
194+
if let Some(frontend_arg) = &self.with_frontend &&
195+
frontend_arg.is_empty()
196+
{
197+
return Err(PromptRequiredError(
198+
"--json mode requires --with-frontend=<TEMPLATE_NAME> (e.g. create-dot-app)".into(),
199+
)
200+
.into());
201+
}
202+
203+
// Generate the chain using JsonCli (suppresses interactive output).
204+
let template = self.template.clone().unwrap_or_default();
205+
let config = get_customization_value(
206+
&template,
207+
self.symbol.clone(),
208+
self.decimals,
209+
self.initial_endowment.clone(),
210+
&mut cli::JsonCli,
211+
)?;
212+
let tag_version = self.release_tag.clone();
213+
214+
let frontend_template: Option<FrontendTemplate> = self
215+
.with_frontend
216+
.as_ref()
217+
.map(|arg| {
218+
FrontendTemplate::from_str(arg)
219+
.map_err(|_| anyhow::anyhow!("Invalid frontend template: {}", arg))
220+
})
221+
.transpose()?;
222+
223+
generate_parachain_from_template(
224+
name,
225+
&template,
226+
tag_version,
227+
config,
228+
self.verify,
229+
frontend_template,
230+
self.package_manager,
231+
&mut cli::JsonCli,
232+
)
233+
.await?;
234+
235+
let path = std::path::PathBuf::from(name).canonicalize().unwrap_or_else(|_| name.into());
236+
237+
CliResponse::ok(NewOutput {
238+
kind: "chain".into(),
239+
name: name.clone(),
240+
path: path.display().to_string(),
241+
template: Some(template.name().to_string()),
242+
})
243+
.print_json();
244+
245+
Ok(())
246+
}
247+
154248
fn display(&self) -> String {
155249
let mut full_message = "pop new chain".to_string();
156250
if let Some(name) = &self.name {
@@ -514,6 +608,7 @@ fn prompt_customizable_options(cli: &mut impl Cli) -> Result<Config> {
514608
mod tests {
515609
use super::*;
516610
use cli::MockCli;
611+
use tempfile::tempdir;
517612

518613
#[test]
519614
fn test_new_chain_command_display() {
@@ -714,7 +809,37 @@ mod tests {
714809
#[tokio::test]
715810
async fn test_new_chain_list_templates() -> Result<()> {
716811
let command = NewChainCommand { list: true, ..Default::default() };
717-
command.execute().await?;
812+
command.execute(OutputMode::Human).await?;
813+
Ok(())
814+
}
815+
816+
#[tokio::test]
817+
async fn execute_json_missing_name_returns_prompt_required() {
818+
let cmd = NewChainCommand { ..Default::default() };
819+
let err = cmd.execute(OutputMode::Json).await.unwrap_err();
820+
assert!(err.downcast_ref::<PromptRequiredError>().is_some());
821+
}
822+
823+
#[tokio::test]
824+
async fn execute_json_bare_with_frontend_returns_prompt_required() {
825+
let cmd = NewChainCommand {
826+
name: Some("test-chain".into()),
827+
with_frontend: Some(String::new()),
828+
..Default::default()
829+
};
830+
let err = cmd.execute(OutputMode::Json).await.unwrap_err();
831+
assert!(err.downcast_ref::<PromptRequiredError>().is_some());
832+
}
833+
834+
#[tokio::test]
835+
async fn execute_json_existing_path_returns_prompt_required() -> Result<()> {
836+
let dir = tempdir()?;
837+
let chain_path = dir.path().join("my-chain");
838+
std::fs::create_dir_all(&chain_path)?;
839+
let cmd =
840+
NewChainCommand { name: Some(chain_path.display().to_string()), ..Default::default() };
841+
let err = cmd.execute(OutputMode::Json).await.unwrap_err();
842+
assert!(err.downcast_ref::<PromptRequiredError>().is_some());
718843
Ok(())
719844
}
720845
}

0 commit comments

Comments
 (0)