From f54525ae5c8191f4e888273e87fec6884816153c Mon Sep 17 00:00:00 2001 From: Pavel Korolev Date: Sun, 26 Jul 2026 17:55:28 +0300 Subject: [PATCH 1/2] feat(extensions): select installed platform extensions - add repeatable CLI-only --extension selector with validation\n- preserve direct platform names and ordering when forwarding to ibcmd\n- document source-set and installed-extension selection workflows --- SKILL/SKILL.md | 4 +- SKILL/references/project-workflows.md | 4 ++ docs/CAPABILITIES.md | 9 ++-- src/cli/args.rs | 43 ++++++++++++++- src/cli/execute.rs | 75 ++++++++++++++++++++++++--- src/use_cases/configure_extensions.rs | 50 ++++++++++-------- src/use_cases/request.rs | 38 +++++++++++++- tests/cli_extensions.rs | 54 +++++++++++++++++++ 8 files changed, 239 insertions(+), 38 deletions(-) diff --git a/SKILL/SKILL.md b/SKILL/SKILL.md index 2b0e4c5..27be940 100644 --- a/SKILL/SKILL.md +++ b/SKILL/SKILL.md @@ -80,7 +80,9 @@ v8-runner init `--sources` on `yaxunit` or `client-mcp` to download `.cfe` artifacts when `builder=DESIGNER`. - Vanessa Automation debugging or scenario authoring: use `v8-runner launch mcp va --wait-ready ...` to start the client MCP server with VA loaded and verify the VA MCP tools before driving `.feature` workflows. -- Extension properties need synchronization: use `v8-runner extensions` or `extensions --name `. +- Extension properties need synchronization: use `v8-runner extensions`, `extensions --name `, + or CLI-only `extensions --extension ` for an installed extension that is not selected + through a configured source-set. Do not combine `--name` and `--extension`. - Infobase changes need to become Git-visible files: check `git status`, then run the relevant `v8-runner dump ...` command. - Source files need conversion between Designer and EDT: use `v8-runner convert`; this is CLI-only and does not use the infobase. - Existing `.cf` or `.cfe` artifacts need to be applied to an infobase: use `v8-runner load ...`. diff --git a/SKILL/references/project-workflows.md b/SKILL/references/project-workflows.md index a9db7c8..737d248 100644 --- a/SKILL/references/project-workflows.md +++ b/SKILL/references/project-workflows.md @@ -117,8 +117,12 @@ Do not replace extension-specific synchronization with a full rebuild unless the ```bash v8-runner extensions v8-runner extensions --name +v8-runner extensions --extension ``` +`--name` selects configured extension source-sets. CLI-only `--extension` selects installed +platform extensions by their exact names and can be repeated; it cannot be combined with `--name`. + ## Launch Prefer runner launch commands over raw `1cv8` command construction: diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 6483c06..8b2add7 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -157,11 +157,14 @@ v8-runner tools download client-mcp [--sources] [--force] ### `extensions` ```bash -v8-runner extensions [--name ...] +v8-runner extensions [--name ... | --extension ...] ``` -- Работает только с `source-set`, у которых `type=EXTENSION`. -- Без `--name` обрабатывает все extension `source-set` из конфига. +- Без прямого selector работает только с `source-set`, у которых `type=EXTENSION`. +- Без селектора обрабатывает все extension `source-set` из конфига. +- `--name` выбирает configured extension `source-set`; `--extension` выбирает установленное в ИБ + platform extension по его точному имени, без lookup в конфиге. Эти селекторы взаимоисключающие; + `--extension` можно повторять, а пустые, control-character и повторяющиеся значения отклоняются. - Возвращает пошаговый результат по каждому целевому расширению. ### `build` diff --git a/src/cli/args.rs b/src/cli/args.rs index 666c767..ac9e598 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -236,8 +236,12 @@ pub struct LoadArgs { #[command(next_help_heading = "Command options")] pub struct ExtensionsArgs { /// Extension source-set name to update. Repeat to target multiple extensions. - #[arg(long = "name")] + #[arg(long = "name", conflicts_with = "extensions")] pub names: Vec, + + /// Installed platform extension name to update. Repeat to target multiple extensions. + #[arg(long = "extension", conflicts_with = "names")] + pub extensions: Vec, } #[derive(Args, Debug)] @@ -626,13 +630,48 @@ mod tests { .expect("parse"); match cli.command { - Command::Extensions(ExtensionsArgs { names }) => { + Command::Extensions(ExtensionsArgs { names, .. }) => { assert_eq!(names, vec!["client_mcp", "tests"]); } _ => panic!("unexpected command"), } } + #[test] + fn parses_extensions_command_with_platform_extension_names_in_requested_order() { + let cli = Cli::try_parse_from([ + "v8-runner", + "extensions", + "--extension", + "SalesAddon", + "--extension", + "TestsAddon", + ]) + .expect("parse"); + + match cli.command { + Command::Extensions(ExtensionsArgs { extensions, .. }) => { + assert_eq!(extensions, vec!["SalesAddon", "TestsAddon"]); + } + _ => panic!("unexpected command"), + } + } + + #[test] + fn extensions_command_rejects_mixing_source_set_and_platform_extension_selectors() { + let error = Cli::try_parse_from([ + "v8-runner", + "extensions", + "--name", + "client_mcp", + "--extension", + "SalesAddon", + ]) + .expect_err("selectors must conflict"); + + assert!(error.to_string().contains("cannot be used with")); + } + #[test] fn parses_load_command_with_default_mode() { let cli = Cli::try_parse_from(["v8-runner", "load", "--path", "dist/main.cf"]) diff --git a/src/cli/execute.rs b/src/cli/execute.rs index 927e7b2..f715962 100644 --- a/src/cli/execute.rs +++ b/src/cli/execute.rs @@ -287,7 +287,7 @@ fn execute_extensions( clean_before_execution: bool, cancellation: CancellationToken, ) -> Result<(), UseCaseError> { - let request = map_extensions_request(args); + let request = map_extensions_request(args)?; let context = cli_context(config, CommandName::Extensions, cancellation); with_cli_workspace_lock( config, @@ -833,10 +833,25 @@ fn map_build_request(args: &BuildArgs) -> BuildRequest { } } -fn map_extensions_request(args: &ExtensionsArgs) -> ConfigureExtensionsRequest { - ConfigureExtensionsRequest { - names: args.names.clone(), +fn map_extensions_request( + args: &ExtensionsArgs, +) -> Result { + if !args.names.is_empty() && !args.extensions.is_empty() { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + "--name cannot be used with --extension", + )); } + + let selector = if !args.extensions.is_empty() { + crate::use_cases::request::ExtensionSelector::platform_names(args.extensions.clone())? + } else if !args.names.is_empty() { + crate::use_cases::request::ExtensionSelector::SourceSets(args.names.clone()) + } else { + crate::use_cases::request::ExtensionSelector::ConfiguredAll + }; + + Ok(ConfigureExtensionsRequest { selector }) } fn map_tools_download_target(args: &ToolsDownloadArgs) -> ToolDownloadTarget { @@ -2628,9 +2643,11 @@ mod tests { assert_eq!( map_extensions_request(&ExtensionsArgs { names: vec!["client_mcp".to_owned()], + extensions: vec![], }) - .names, - vec!["client_mcp"] + .expect("request") + .selector, + crate::use_cases::request::ExtensionSelector::SourceSets(vec!["client_mcp".to_owned()]) ); assert_eq!( map_dump_request(&DumpArgs { @@ -2772,6 +2789,47 @@ mod tests { assert_eq!(artifacts.extension.as_deref(), Some("SalesAddon")); } + #[test] + fn maps_extensions_request_to_direct_platform_selector_without_normalizing_name() { + let request = map_extensions_request(&ExtensionsArgs { + names: vec![], + extensions: vec![" SalesAddon ".to_owned(), "TestsAddon".to_owned()], + }) + .expect("valid direct selector"); + + assert!(matches!( + request.selector, + crate::use_cases::request::ExtensionSelector::PlatformNames(names) + if names == [" SalesAddon ", "TestsAddon"] + )); + } + + #[test] + fn maps_extensions_request_rejects_duplicate_direct_platform_names() { + let error = map_extensions_request(&ExtensionsArgs { + names: vec![], + extensions: vec!["SalesAddon".to_owned(), "SalesAddon".to_owned()], + }) + .expect_err("duplicate direct selector"); + + assert_eq!(error.kind(), UseCaseErrorKind::Validation); + assert!(error.message().contains("duplicate --extension")); + } + + #[test] + fn maps_extensions_request_rejects_blank_or_control_character_direct_platform_names() { + for name in [" ", "Sales\nAddon"] { + let error = map_extensions_request(&ExtensionsArgs { + names: vec![], + extensions: vec![name.to_owned()], + }) + .expect_err("invalid direct selector"); + + assert_eq!(error.kind(), UseCaseErrorKind::Validation); + assert!(error.message().contains("invalid --extension")); + } + } + #[test] fn maps_artifacts_request_keeps_blank_extension_in_cfe_mode() { let artifacts = map_artifacts_request_with_config( @@ -2907,7 +2965,10 @@ mod tests { fn resolves_command_name() { assert_eq!(command_name(&Command::Init), CommandName::Init); assert_eq!( - command_name(&Command::Extensions(ExtensionsArgs { names: vec![] })), + command_name(&Command::Extensions(ExtensionsArgs { + names: vec![], + extensions: vec![], + })), CommandName::Extensions ); assert_eq!( diff --git a/src/use_cases/configure_extensions.rs b/src/use_cases/configure_extensions.rs index 5747e80..53f3201 100644 --- a/src/use_cases/configure_extensions.rs +++ b/src/use_cases/configure_extensions.rs @@ -11,7 +11,7 @@ use crate::use_cases::extension_identity::platform_extension_name; use crate::use_cases::ibcmd_diagnostics::format_ibcmd_failure_details; use crate::use_cases::interruption; use crate::use_cases::progress::log_live_stage; -use crate::use_cases::request::ConfigureExtensionsRequest; +use crate::use_cases::request::{ConfigureExtensionsRequest, ExtensionSelector}; use crate::use_cases::result::{UseCaseFailure, UseCaseResult}; use tracing::{debug, info}; @@ -230,23 +230,27 @@ fn resolve_targets( }) .collect::>(); - if args.names.is_empty() { - return Ok(available.into_iter().map(|(_, name)| name).collect()); - } - - let mut targets = Vec::new(); - for requested in &args.names { - let Some((_, resolved)) = available - .iter() - .find(|(name, _)| *name == requested.as_str()) - else { - return Err(AppError::Validation(format!( - "unknown extension source-set '{requested}'" - ))); - }; - targets.push(resolved.clone()); + match &args.selector { + ExtensionSelector::ConfiguredAll => { + Ok(available.into_iter().map(|(_, name)| name).collect()) + } + ExtensionSelector::SourceSets(requested_names) => { + let mut targets = Vec::new(); + for requested in requested_names { + let Some((_, resolved)) = available + .iter() + .find(|(name, _)| *name == requested.as_str()) + else { + return Err(AppError::Validation(format!( + "unknown extension source-set '{requested}'" + ))); + }; + targets.push(resolved.clone()); + } + Ok(targets) + } + ExtensionSelector::PlatformNames(names) => Ok(names.clone()), } - Ok(targets) } #[cfg(test)] @@ -328,8 +332,8 @@ mod tests { .expect("project file"); let config = sample_config(dir.path(), dir.path(), Path::new("/tmp/ibcmd")); - let targets = resolve_targets(&config, &ConfigureExtensionsRequest { names: vec![] }) - .expect("targets"); + let targets = + resolve_targets(&config, &ConfigureExtensionsRequest::default()).expect("targets"); assert_eq!(targets, vec!["client_mcp"]); } @@ -377,7 +381,7 @@ mod tests { let result = execute( &ExecutionContext::cli(CommandName::Extensions), &config, - &ConfigureExtensionsRequest { names: vec![] }, + &ConfigureExtensionsRequest::default(), ) .expect("execute"); @@ -416,7 +420,7 @@ mod tests { let result = execute( &ExecutionContext::cli(CommandName::Extensions), &config, - &ConfigureExtensionsRequest { names: vec![] }, + &ConfigureExtensionsRequest::default(), ) .expect("execute"); @@ -448,7 +452,7 @@ mod tests { let failure = execute( &ExecutionContext::cli(CommandName::Extensions), &config, - &ConfigureExtensionsRequest { names: vec![] }, + &ConfigureExtensionsRequest::default(), ) .expect_err("failure"); @@ -486,7 +490,7 @@ mod tests { let failure = execute( &ExecutionContext::cli(CommandName::Extensions).with_cancellation(cancellation), &config, - &ConfigureExtensionsRequest { names: vec![] }, + &ConfigureExtensionsRequest::default(), ) .expect_err("interrupted execution"); let payload = failure.payload.expect("payload"); diff --git a/src/use_cases/request.rs b/src/use_cases/request.rs index bee6132..d7c746b 100644 --- a/src/use_cases/request.rs +++ b/src/use_cases/request.rs @@ -640,11 +640,45 @@ pub struct LaunchRequest { #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct InitRequest; +/// Explicit extension target selection for extension property updates. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum ExtensionSelector { + /// Updates all configured extension source-sets. + #[default] + ConfiguredAll, + /// Updates extension source-sets selected by their configuration names. + SourceSets(Vec), + /// Updates installed platform extensions selected by their platform names. + PlatformNames(Vec), +} + +impl ExtensionSelector { + /// Builds a direct platform-name selector without altering user-provided names. + pub fn platform_names(names: Vec) -> Result { + let mut seen = std::collections::HashSet::new(); + for name in &names { + if name.trim().is_empty() || name.chars().any(char::is_control) { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + "invalid --extension value: platform extension name must be nonblank and contain no control characters", + )); + } + if !seen.insert(name.as_str()) { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + format!("duplicate --extension value '{name}'"), + )); + } + } + Ok(Self::PlatformNames(names)) + } +} + /// Transport-neutral request for extension property updates. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ConfigureExtensionsRequest { - /// Optional source-set names to update. Empty means all extension source-sets. - pub names: Vec, + /// Requested extension target selection. + pub selector: ExtensionSelector, } #[cfg(test)] diff --git a/tests/cli_extensions.rs b/tests/cli_extensions.rs index d6d6a64..503ba45 100644 --- a/tests/cli_extensions.rs +++ b/tests/cli_extensions.rs @@ -215,6 +215,60 @@ fn extensions_command_filters_by_requested_source_set_names() { assert!(!calls.contains("--name tests")); } +#[test] +fn extensions_command_forwards_requested_platform_extension_names_in_order() { + let (_dir, config_path, calls_log, _ibcmd_path) = setup_extensions_project(); + + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--no-color", + "extensions", + "--extension", + "SalesAddon", + "--extension", + "TestsAddon", + ]) + .output() + .expect("run command"); + + assert!(output.status.success()); + let calls = fs::read_to_string(calls_log).expect("calls"); + let calls = calls.lines().collect::>(); + assert_eq!(calls.len(), 2); + assert!(calls[0].contains("--name SalesAddon")); + assert!(calls[1].contains("--name TestsAddon")); +} + +#[test] +fn extensions_command_forwards_direct_platform_extension_name_without_trimming() { + let (_dir, config_path, calls_log, ibcmd_path) = setup_extensions_project(); + write_script( + &ibcmd_path, + &format!( + "printf '<%s>\\n' \"$@\" > '{}'\nexit 0", + calls_log.display() + ), + ); + + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--no-color", + "extensions", + "--extension", + " SalesAddon ", + ]) + .output() + .expect("run command"); + + assert!(output.status.success()); + let calls = fs::read_to_string(calls_log).expect("calls"); + assert!(calls.contains("< SalesAddon >")); +} + #[test] fn extensions_command_json_failure_reports_operation_target_and_exit_code() { let (_dir, config_path, _calls_log, ibcmd_path) = setup_extensions_project(); From cf9f6e63339a5e37af04dda7d89e2e909bf80e6d Mon Sep 17 00:00:00 2001 From: Pavel Korolev Date: Sun, 26 Jul 2026 18:00:39 +0300 Subject: [PATCH 2/2] fix(extensions): render selector validation errors - render direct extension selector failures before dispatch\n- cover text and JSON validation output without ibcmd execution\n- document installed extension selection in skill references --- SKILL/references/command-selection.md | 8 ++++ SKILL/references/config-and-backends.md | 4 +- src/cli/execute.rs | 3 +- tests/cli_extensions.rs | 59 +++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/SKILL/references/command-selection.md b/SKILL/references/command-selection.md index 00abafc..85edb89 100644 --- a/SKILL/references/command-selection.md +++ b/SKILL/references/command-selection.md @@ -111,6 +111,14 @@ Update selected extension source-sets: v8-runner extensions --name ``` +Update installed extensions that are not selected through `v8project.yaml`: + +```bash +v8-runner extensions --extension +``` + +`--extension` is CLI-only, can be repeated, and cannot be combined with `--name`. + ## Dump, Convert, Load, And Artifacts Bring infobase changes back into Git-visible files: diff --git a/SKILL/references/config-and-backends.md b/SKILL/references/config-and-backends.md index 13f6ead..e388bfe 100644 --- a/SKILL/references/config-and-backends.md +++ b/SKILL/references/config-and-backends.md @@ -24,7 +24,9 @@ settings before CLI overrides. - `format=DESIGNER`, `builder=IBCMD`: supports init, build, extensions, and dump for file infobases and server infobases with `infobase.dbms`. - `format=EDT`, `builder=DESIGNER`: supports init, build through EDT export to Designer files, EDT syntax checks, extensions, and tests. - `format=EDT`, `builder=IBCMD`: supports init and build through EDT export to Designer files followed by IBCMD import/apply; requires a file infobase. -- `extensions` supports Designer and EDT projects, but only extension `source-set` entries are actionable. +- `extensions` supports Designer and EDT projects. Without a selector, it updates configured extension + `source-set` entries; CLI-only `--extension ` updates an installed platform extension + directly and cannot be combined with `--name`. - `syntax designer-config` and `syntax designer-modules` require Designer format with Designer backend. - `syntax edt` requires EDT format with Designer backend. - IBCMD dump uses project-local standalone-server data under `workPath/ibcmd-data`. diff --git a/src/cli/execute.rs b/src/cli/execute.rs index f715962..69ca77a 100644 --- a/src/cli/execute.rs +++ b/src/cli/execute.rs @@ -287,7 +287,8 @@ fn execute_extensions( clean_before_execution: bool, cancellation: CancellationToken, ) -> Result<(), UseCaseError> { - let request = map_extensions_request(args)?; + let request = map_extensions_request(args) + .map_err(|error| render_pre_dispatch_error(presenter, CommandName::Extensions, error))?; let context = cli_context(config, CommandName::Extensions, cancellation); with_cli_workspace_lock( config, diff --git a/tests/cli_extensions.rs b/tests/cli_extensions.rs index 503ba45..4ce401a 100644 --- a/tests/cli_extensions.rs +++ b/tests/cli_extensions.rs @@ -269,6 +269,65 @@ fn extensions_command_forwards_direct_platform_extension_name_without_trimming() assert!(calls.contains("< SalesAddon >")); } +#[test] +fn extensions_command_text_validation_renders_duplicate_direct_extension_error() { + let (_dir, config_path, calls_log, _ibcmd_path) = setup_extensions_project(); + + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--no-color", + "extensions", + "--extension", + "SalesAddon", + "--extension", + "SalesAddon", + ]) + .output() + .expect("run command"); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&output.stderr).contains("duplicate --extension")); + assert!( + !calls_log.exists(), + "ibcmd must not run after validation failure" + ); +} + +#[test] +fn extensions_command_json_validation_renders_blank_direct_extension_envelope() { + let (_dir, config_path, calls_log, _ibcmd_path) = setup_extensions_project(); + + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "extensions", + "--extension", + " ", + ]) + .output() + .expect("run command"); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(2)); + let payload: serde_json::Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(payload["ok"], false); + assert_eq!(payload["command"], "extensions"); + assert_eq!(payload["error"]["code"], "invalid_argument"); + assert!(payload["data"]["message"] + .as_str() + .expect("message") + .contains("invalid --extension")); + assert!( + !calls_log.exists(), + "ibcmd must not run after validation failure" + ); +} + #[test] fn extensions_command_json_failure_reports_operation_target_and_exit_code() { let (_dir, config_path, _calls_log, ibcmd_path) = setup_extensions_project();