From 767eda992c93519a961ec8fe4d3c069b7daa07cd Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:31:41 +0530 Subject: [PATCH] refactor: share test and demo project command setup --- cli/bash/commands/basectl/subcommands/demo.sh | 147 +++------------ .../subcommands/project_command_helpers.sh | 174 ++++++++++++++++++ cli/bash/commands/basectl/subcommands/test.sh | 150 +++------------ .../tests/project-command-helpers.bats | 55 ++++++ 4 files changed, 278 insertions(+), 248 deletions(-) diff --git a/cli/bash/commands/basectl/subcommands/demo.sh b/cli/bash/commands/basectl/subcommands/demo.sh index 51976a57..8fd32e10 100644 --- a/cli/bash/commands/basectl/subcommands/demo.sh +++ b/cli/bash/commands/basectl/subcommands/demo.sh @@ -34,130 +34,29 @@ base_demo_usage_error() { } base_demo_subcommand_main() { - local project="" explicit_project="" wrapper resolve_output resolved_name project_root manifest_path demo_script command_runner - local quoted_demo_script command_to_run display_command - local dry_run=0 - local args=() extra_args=() project_args=() parser_args=() - local route_venv_dir uses_uv_manager trust_required - local positional_count=0 - # shellcheck disable=SC2034 # base_arg_parse receives caller-owned arrays by name. - local -a option_specs=( - "debug|flag|-v" - "workspace|value|--workspace" - "project|value|--project" - "dry_run|flag|--dry-run" - ) - local -a positionals=() - local -A parsed_options=() - - while (($#)); do - case "$1" in - --) - shift - extra_args=("$@") - break - ;; - -h|--help|help) - base_demo_subcommand_usage - return 0 - ;; - -v) - parser_args+=("$1") - args+=(--debug) - shift - ;; - --workspace) - [[ -n "${2:-}" ]] || { - base_demo_usage_error "Option '--workspace' requires an argument." - return $? - } - parser_args+=("--workspace=$2") - args+=(--workspace "$2") - shift 2 - ;; - --workspace=*) - parser_args+=("$1") - args+=("$1") - shift - ;; - --project) - [[ -n "${2:-}" ]] || { - base_demo_usage_error "Option '--project' requires an argument." - return $? - } - [[ -z "$explicit_project" ]] || { - base_demo_usage_error "Option '--project' may be specified only once." - return $? - } - explicit_project="$2" - parser_args+=("--project=$2") - shift 2 - ;; - --dry-run) - parser_args+=("$1") - shift - ;; - -*) - base_demo_usage_error "Unknown demo option '$1'." - return $? - ;; - *) - positional_count=$((positional_count + 1)) - if ((positional_count > 1)); then - base_demo_usage_error "The 'demo' command accepts one project name." - return $? - fi - parser_args+=("$1") - shift - ;; - esac - done - - if ! base_arg_parse parsed_options positionals option_specs -- "${parser_args[@]}"; then - base_demo_usage_error "Could not parse demo arguments." - return $? - fi - - if ((${#positionals[@]} > 1)); then - base_demo_usage_error "The 'demo' command accepts one project name." - return $? - fi - if ((${#positionals[@]} == 1)); then - project="${positionals[0]}" - fi - - explicit_project="${parsed_options[project]:-}" - dry_run="${parsed_options[dry_run]:-0}" - [[ -z "$explicit_project" || -z "$project" ]] || { - base_demo_usage_error "The 'demo' command does not accept a positional project with --project." - return $? - } - - wrapper="$BASE_HOME/bin/base-wrapper" - [[ -x "$wrapper" ]] || base_std_fatal_error "Base Python wrapper '$wrapper' is missing or is not executable." - - if [[ -n "$explicit_project" ]]; then - project_args+=(--project "$explicit_project") - elif [[ -n "$project" ]]; then - project_args+=("$project") - fi - resolve_output="$("$wrapper" --project base base_projects demo-script "${project_args[@]}" "${args[@]}" --format command-protocol)" || return $? - base_command_protocol_decode_one demo "$resolve_output" || { - base_std_fatal_error "Unable to resolve demo script for project '${project:-current project}'." - } - resolved_name="${BASE_COMMAND_PROTOCOL_FIELDS[project_name]}" - project_root="${BASE_COMMAND_PROTOCOL_FIELDS[project_root]}" - manifest_path="${BASE_COMMAND_PROTOCOL_FIELDS[manifest_path]}" - route_venv_dir="${BASE_COMMAND_PROTOCOL_FIELDS[project_venv_dir]}" - uses_uv_manager="${BASE_COMMAND_PROTOCOL_FIELDS[uses_uv_manager]}" - trust_required="${BASE_COMMAND_PROTOCOL_FIELDS[manifest_command_trust_required]}" - base_project_set_history_context "$resolved_name" "$project_root" "$manifest_path" - demo_script="${BASE_COMMAND_PROTOCOL_FIELDS[demo_script]}" - command_runner="${BASE_COMMAND_PROTOCOL_FIELDS[runner]}" - - [[ -n "$resolved_name" && -n "$project_root" && -n "$manifest_path" && -n "$demo_script" ]] || { - base_std_fatal_error "Unable to resolve demo script for project '${project:-current project}'." - } + local project="" demo_script command_runner + local quoted_demo_script command_to_run display_command dry_run + local extra_args=() + local resolved_name project_root manifest_path route_venv_dir uses_uv_manager trust_required + + base_project_command_parse_args \ + demo base_demo_subcommand_usage base_demo_usage_error \ + "The 'demo' command accepts one project name." "$@" || return $? + [[ "$BASE_PROJECT_COMMAND_HELP_SHOWN" == 1 ]] && return 0 + project="$BASE_PROJECT_COMMAND_PROJECT" + dry_run="$BASE_PROJECT_COMMAND_DRY_RUN" + extra_args=("${BASE_PROJECT_COMMAND_EXTRA_ARGS[@]}") + + base_project_command_resolve_context \ + demo-script demo demo_script "demo script" "${project:-current project}" || return $? + resolved_name="$BASE_PROJECT_COMMAND_RESOLVED_NAME" + project_root="$BASE_PROJECT_COMMAND_RESOLVED_ROOT" + manifest_path="$BASE_PROJECT_COMMAND_RESOLVED_MANIFEST" + route_venv_dir="$BASE_PROJECT_COMMAND_ROUTE_VENV" + uses_uv_manager="$BASE_PROJECT_COMMAND_USES_UV" + trust_required="$BASE_PROJECT_COMMAND_TRUST_REQUIRED" + demo_script="$BASE_PROJECT_COMMAND_RESOLVED_ACTION" + command_runner="$BASE_PROJECT_COMMAND_RUNNER" command_runner="${command_runner:-}" printf -v quoted_demo_script '%q' "$demo_script" diff --git a/cli/bash/commands/basectl/subcommands/project_command_helpers.sh b/cli/bash/commands/basectl/subcommands/project_command_helpers.sh index 7bae0125..51917af1 100644 --- a/cli/bash/commands/basectl/subcommands/project_command_helpers.sh +++ b/cli/bash/commands/basectl/subcommands/project_command_helpers.sh @@ -77,6 +77,180 @@ base_project_require_manifest_command_trust() { "$wrapper" --project base base_trust require "$project" --manifest "$manifest_path" } +base_project_command_parse_args() { + local command_name="$1" + local usage_function="$2" + local usage_error_function="$3" + local positional_error="$4" + shift 4 + + local project="" explicit_project="" dry_run=0 + local positional_count=0 + local args=() extra_args=() parser_args=() positionals=() + # shellcheck disable=SC2034 # base_arg_parse reads the caller-owned option specification by name. + local -a option_specs=( + "debug|flag|-v" + "workspace|value|--workspace" + "project|value|--project" + "dry_run|flag|--dry-run" + ) + local -A parsed_options=() + + # shellcheck disable=SC2034 # The selected project is consumed by test.sh and demo.sh. + BASE_PROJECT_COMMAND_PROJECT="" + # shellcheck disable=SC2034 # These shared parse outputs are consumed by test.sh and demo.sh. + BASE_PROJECT_COMMAND_DRY_RUN=0 + # shellcheck disable=SC2034 # The parser returns help to its caller without resolving a project. + BASE_PROJECT_COMMAND_HELP_SHOWN=0 + BASE_PROJECT_COMMAND_ARGUMENTS=() + # shellcheck disable=SC2034 # Passthrough arguments are consumed by test.sh and demo.sh. + BASE_PROJECT_COMMAND_EXTRA_ARGS=() + BASE_PROJECT_COMMAND_SELECTION_ARGS=() + + while (($#)); do + case "$1" in + --) + shift + extra_args=("$@") + break + ;; + -h|--help|help) + # shellcheck disable=SC2034 # The caller must skip resolution after help is printed. + BASE_PROJECT_COMMAND_HELP_SHOWN=1 + "$usage_function" + return 0 + ;; + -v) + parser_args+=("$1") + args+=(--debug) + shift + ;; + --workspace) + [[ -n "${2:-}" ]] || { + "$usage_error_function" "Option '--workspace' requires an argument." + return $? + } + parser_args+=("--workspace=$2") + args+=(--workspace "$2") + shift 2 + ;; + --workspace=*) + parser_args+=("$1") + args+=("$1") + shift + ;; + --project) + [[ -n "${2:-}" ]] || { + "$usage_error_function" "Option '--project' requires an argument." + return $? + } + [[ -z "$explicit_project" ]] || { + "$usage_error_function" "Option '--project' may be specified only once." + return $? + } + explicit_project="$2" + parser_args+=("--project=$2") + shift 2 + ;; + --dry-run) + parser_args+=("$1") + shift + ;; + -*) + "$usage_error_function" "Unknown $command_name option '$1'." + return $? + ;; + *) + positional_count=$((positional_count + 1)) + if ((positional_count > 1)); then + "$usage_error_function" "$positional_error" + return $? + fi + parser_args+=("$1") + shift + ;; + esac + done + + if ! base_arg_parse parsed_options positionals option_specs -- "${parser_args[@]}"; then + "$usage_error_function" "Could not parse $command_name arguments." + return $? + fi + + if ((${#positionals[@]} > 1)); then + "$usage_error_function" "$positional_error" + return $? + fi + if ((${#positionals[@]} == 1)); then + project="${positionals[0]}" + fi + + explicit_project="${parsed_options[project]:-}" + dry_run="${parsed_options[dry_run]:-0}" + [[ -z "$explicit_project" || -z "$project" ]] || { + "$usage_error_function" "The '$command_name' command does not accept a positional project with --project." + return $? + } + + # shellcheck disable=SC2034 # The selected project is consumed by test.sh and demo.sh. + BASE_PROJECT_COMMAND_PROJECT="$project" + # shellcheck disable=SC2034 # Dry-run mode is consumed by test.sh and demo.sh. + BASE_PROJECT_COMMAND_DRY_RUN="$dry_run" + BASE_PROJECT_COMMAND_ARGUMENTS=("${args[@]}") + # shellcheck disable=SC2034 # Passthrough arguments are consumed by test.sh and demo.sh. + BASE_PROJECT_COMMAND_EXTRA_ARGS=("${extra_args[@]}") + if [[ -n "$explicit_project" ]]; then + BASE_PROJECT_COMMAND_SELECTION_ARGS=(--project "$explicit_project") + elif [[ -n "$project" ]]; then + BASE_PROJECT_COMMAND_SELECTION_ARGS=("$project") + fi + return 0 +} + +base_project_command_resolve_context() { + local resolver_command="$1" + local protocol_type="$2" + local command_field="$3" + local command_description="$4" + local display_project="$5" + local wrapper="$BASE_HOME/bin/base-wrapper" + local resolve_output + + [[ -x "$wrapper" ]] || base_std_fatal_error "Base Python wrapper '$wrapper' is missing or is not executable." + + resolve_output="$("$wrapper" --project base base_projects "$resolver_command" \ + "${BASE_PROJECT_COMMAND_SELECTION_ARGS[@]}" "${BASE_PROJECT_COMMAND_ARGUMENTS[@]}" \ + --format command-protocol)" || return $? + base_command_protocol_decode_one "$protocol_type" "$resolve_output" || { + base_std_fatal_error "Unable to resolve $command_description for project '$display_project'." + } + + BASE_PROJECT_COMMAND_RESOLVED_NAME="${BASE_COMMAND_PROTOCOL_FIELDS[project_name]}" + BASE_PROJECT_COMMAND_RESOLVED_ROOT="${BASE_COMMAND_PROTOCOL_FIELDS[project_root]}" + BASE_PROJECT_COMMAND_RESOLVED_MANIFEST="${BASE_COMMAND_PROTOCOL_FIELDS[manifest_path]}" + # shellcheck disable=SC2034 # Venv route metadata is consumed by test.sh and demo.sh. + BASE_PROJECT_COMMAND_ROUTE_VENV="${BASE_COMMAND_PROTOCOL_FIELDS[project_venv_dir]}" + # shellcheck disable=SC2034 # Manager route metadata is consumed by test.sh and demo.sh. + BASE_PROJECT_COMMAND_USES_UV="${BASE_COMMAND_PROTOCOL_FIELDS[uses_uv_manager]}" + # shellcheck disable=SC2034 # Trust metadata is consumed by test.sh and demo.sh. + BASE_PROJECT_COMMAND_TRUST_REQUIRED="${BASE_COMMAND_PROTOCOL_FIELDS[manifest_command_trust_required]}" + BASE_PROJECT_COMMAND_RESOLVED_ACTION="${BASE_COMMAND_PROTOCOL_FIELDS[$command_field]}" + # shellcheck disable=SC2034 # The runner is consumed by test.sh and demo.sh. + BASE_PROJECT_COMMAND_RUNNER="${BASE_COMMAND_PROTOCOL_FIELDS[runner]}" + + [[ -n "$BASE_PROJECT_COMMAND_RESOLVED_NAME" && \ + -n "$BASE_PROJECT_COMMAND_RESOLVED_ROOT" && \ + -n "$BASE_PROJECT_COMMAND_RESOLVED_MANIFEST" && \ + -n "$BASE_PROJECT_COMMAND_RESOLVED_ACTION" ]] || { + base_std_fatal_error "Unable to resolve $command_description for project '$display_project'." + } + + base_project_set_history_context \ + "$BASE_PROJECT_COMMAND_RESOLVED_NAME" \ + "$BASE_PROJECT_COMMAND_RESOLVED_ROOT" \ + "$BASE_PROJECT_COMMAND_RESOLVED_MANIFEST" +} + base_project_activate_environment() { local project="$1" local project_root="$2" diff --git a/cli/bash/commands/basectl/subcommands/test.sh b/cli/bash/commands/basectl/subcommands/test.sh index 599a5369..fc08c399 100644 --- a/cli/bash/commands/basectl/subcommands/test.sh +++ b/cli/bash/commands/basectl/subcommands/test.sh @@ -34,131 +34,30 @@ base_test_usage_error() { } base_test_subcommand_main() { - local project="" explicit_project="" wrapper resolve_output resolved_name project_root manifest_path test_command command_runner - local command_to_run display_command - local dry_run=0 - local args=() extra_args=() parser_args=() + local project="" wrapper resolve_output resolved_name project_root manifest_path test_command command_runner + local command_to_run display_command dry_run + local args=() extra_args=() local route_venv_dir uses_uv_manager trust_required - local positional_count=0 - # shellcheck disable=SC2034 # base_arg_parse receives caller-owned arrays by name. - local -a option_specs=( - "debug|flag|-v" - "workspace|value|--workspace" - "project|value|--project" - "dry_run|flag|--dry-run" - ) - local -a positionals=() - local -A parsed_options=() - while (($#)); do - case "$1" in - --) - shift - extra_args=("$@") - break - ;; - -h|--help|help) - base_test_subcommand_usage - return 0 - ;; - -v) - parser_args+=("$1") - args+=(--debug) - shift - ;; - --workspace) - [[ -n "${2:-}" ]] || { - base_test_usage_error "Option '--workspace' requires an argument." - return $? - } - parser_args+=("--workspace=$2") - args+=(--workspace "$2") - shift 2 - ;; - --workspace=*) - parser_args+=("$1") - args+=("$1") - shift - ;; - --project) - [[ -n "${2:-}" ]] || { - base_test_usage_error "Option '--project' requires an argument." - return $? - } - [[ -z "$explicit_project" ]] || { - base_test_usage_error "Option '--project' may be specified only once." - return $? - } - explicit_project="$2" - parser_args+=("--project=$2") - shift 2 - ;; - --dry-run) - parser_args+=("$1") - shift - ;; - -*) - base_test_usage_error "Unknown test option '$1'." - return $? - ;; - *) - positional_count=$((positional_count + 1)) - if ((positional_count > 1)); then - base_test_usage_error "The 'test' command accepts exactly one project name." - return $? - fi - parser_args+=("$1") - shift - ;; - esac - done - - if ! base_arg_parse parsed_options positionals option_specs -- "${parser_args[@]}"; then - base_test_usage_error "Could not parse test arguments." - return $? - fi - - if ((${#positionals[@]} > 1)); then - base_test_usage_error "The 'test' command accepts exactly one project name." - return $? - fi - if ((${#positionals[@]} == 1)); then - project="${positionals[0]}" - fi - - explicit_project="${parsed_options[project]:-}" - dry_run="${parsed_options[dry_run]:-0}" - [[ -z "$explicit_project" || -z "$project" ]] || { - base_test_usage_error "The 'test' command does not accept a positional project with --project." - return $? - } - - wrapper="$BASE_HOME/bin/base-wrapper" - [[ -x "$wrapper" ]] || base_std_fatal_error "Base Python wrapper '$wrapper' is missing or is not executable." - - local command_args=(test-command) - if [[ -n "$explicit_project" ]]; then - command_args+=(--project "$explicit_project") - elif [[ -n "$project" ]]; then - command_args+=("$project") - fi - resolve_output="$("$wrapper" --project base base_projects "${command_args[@]}" "${args[@]}" --format command-protocol)" || return $? - base_command_protocol_decode_one project-command "$resolve_output" || { - base_std_fatal_error "Unable to resolve test command for project '$project'." - } - resolved_name="${BASE_COMMAND_PROTOCOL_FIELDS[project_name]}" - project_root="${BASE_COMMAND_PROTOCOL_FIELDS[project_root]}" - manifest_path="${BASE_COMMAND_PROTOCOL_FIELDS[manifest_path]}" - route_venv_dir="${BASE_COMMAND_PROTOCOL_FIELDS[project_venv_dir]}" - uses_uv_manager="${BASE_COMMAND_PROTOCOL_FIELDS[uses_uv_manager]}" - trust_required="${BASE_COMMAND_PROTOCOL_FIELDS[manifest_command_trust_required]}" - base_project_set_history_context "$resolved_name" "$project_root" "$manifest_path" - test_command="${BASE_COMMAND_PROTOCOL_FIELDS[command]}" - command_runner="${BASE_COMMAND_PROTOCOL_FIELDS[runner]}" - - [[ -n "$resolved_name" && -n "$project_root" && -n "$manifest_path" && -n "$test_command" ]] || { - base_std_fatal_error "Unable to resolve test command for project '$project'." - } + base_project_command_parse_args \ + test base_test_subcommand_usage base_test_usage_error \ + "The 'test' command accepts exactly one project name." "$@" || return $? + [[ "$BASE_PROJECT_COMMAND_HELP_SHOWN" == 1 ]] && return 0 + project="$BASE_PROJECT_COMMAND_PROJECT" + dry_run="$BASE_PROJECT_COMMAND_DRY_RUN" + args=("${BASE_PROJECT_COMMAND_ARGUMENTS[@]}") + extra_args=("${BASE_PROJECT_COMMAND_EXTRA_ARGS[@]}") + + base_project_command_resolve_context \ + test-command project-command command "test command" "$project" || return $? + resolved_name="$BASE_PROJECT_COMMAND_RESOLVED_NAME" + project_root="$BASE_PROJECT_COMMAND_RESOLVED_ROOT" + manifest_path="$BASE_PROJECT_COMMAND_RESOLVED_MANIFEST" + route_venv_dir="$BASE_PROJECT_COMMAND_ROUTE_VENV" + uses_uv_manager="$BASE_PROJECT_COMMAND_USES_UV" + trust_required="$BASE_PROJECT_COMMAND_TRUST_REQUIRED" + test_command="$BASE_PROJECT_COMMAND_RESOLVED_ACTION" + command_runner="$BASE_PROJECT_COMMAND_RUNNER" command_runner="${command_runner:-}" command_to_run="$(base_command_with_runner "$command_runner" "$test_command" "${extra_args[@]}")" || return $? @@ -170,7 +69,10 @@ base_test_subcommand_main() { fi base_project_require_manifest_command_trust "$resolved_name" "$manifest_path" "$trust_required" || return $? - resolve_output="$($wrapper --project base base_projects "${command_args[@]}" "${args[@]}" --format command-protocol --test-preflight)" || return $? + wrapper="$BASE_HOME/bin/base-wrapper" + resolve_output="$("$wrapper" --project base base_projects test-command \ + "${BASE_PROJECT_COMMAND_SELECTION_ARGS[@]}" "${args[@]}" \ + --format command-protocol --test-preflight)" || return $? base_command_protocol_decode_one project-command "$resolve_output" || { base_std_fatal_error "Unable to resolve test command for project '$project'." } diff --git a/cli/bash/commands/basectl/tests/project-command-helpers.bats b/cli/bash/commands/basectl/tests/project-command-helpers.bats index 3034f33f..2c9b991a 100644 --- a/cli/bash/commands/basectl/tests/project-command-helpers.bats +++ b/cli/bash/commands/basectl/tests/project-command-helpers.bats @@ -6,6 +6,61 @@ source_project_command_helpers() { source "$BASE_REPO_ROOT/cli/bash/commands/basectl/subcommands/project_command_helpers.sh" } +@test "project command parser preserves workspace and passthrough argument boundaries" { + local workspace="$TEST_TMPDIR/work space" + local control_arg=$'first line\nsecond\tline' + + run env \ + BASE_HOME="$BASE_REPO_ROOT" \ + BASE_BASH_LIBS_DIR="${BASE_BASH_LIBS_DIR:-}" \ + bash -c ' + workspace="$1" + control_arg="$2" + shift 2 + source "$BASE_HOME/base_init.sh" + import_base_lib arg/lib_arg.sh + source "$BASE_HOME/cli/bash/commands/basectl/subcommands/project_command_helpers.sh" + base_command_usage() { :; } + base_command_usage_error() { printf "ERROR:%s\\n" "$*" >&2; return 2; } + base_project_command_parse_args test base_command_usage base_command_usage_error \ + "The test command accepts one project." "$@" || exit $? + [[ "$BASE_PROJECT_COMMAND_PROJECT" == "" ]] + [[ "$BASE_PROJECT_COMMAND_DRY_RUN" == 1 ]] + [[ "${#BASE_PROJECT_COMMAND_ARGUMENTS[@]}" -eq 2 ]] + [[ "${BASE_PROJECT_COMMAND_ARGUMENTS[0]}" == "--workspace=$workspace" ]] + [[ "${BASE_PROJECT_COMMAND_ARGUMENTS[1]}" == --debug ]] + [[ "${#BASE_PROJECT_COMMAND_SELECTION_ARGS[@]}" -eq 2 ]] + [[ "${BASE_PROJECT_COMMAND_SELECTION_ARGS[0]}" == --project ]] + [[ "${BASE_PROJECT_COMMAND_SELECTION_ARGS[1]}" == demo ]] + [[ "${#BASE_PROJECT_COMMAND_EXTRA_ARGS[@]}" -eq 2 ]] + [[ "${BASE_PROJECT_COMMAND_EXTRA_ARGS[0]}" == "name with spaces" ]] + [[ "${BASE_PROJECT_COMMAND_EXTRA_ARGS[1]}" == "$control_arg" ]] + printf "argument boundaries preserved\\n" + ' bash "$workspace" "$control_arg" \ + "--workspace=$workspace" --project demo --dry-run -v -- "name with spaces" "$control_arg" + + [ "$status" -eq 0 ] + [[ "$output" == *"argument boundaries preserved"* ]] +} + +@test "project command parser rejects duplicate explicit project options" { + run env \ + BASE_HOME="$BASE_REPO_ROOT" \ + BASE_BASH_LIBS_DIR="${BASE_BASH_LIBS_DIR:-}" \ + bash -c ' + source "$BASE_HOME/base_init.sh" + import_base_lib arg/lib_arg.sh + source "$BASE_HOME/cli/bash/commands/basectl/subcommands/project_command_helpers.sh" + base_command_usage() { :; } + base_command_usage_error() { printf "ERROR:%s\\n" "$*" >&2; return 2; } + base_project_command_parse_args demo base_command_usage base_command_usage_error \ + "The demo command accepts one project." "$@" + ' bash --project first --project second + + [ "$status" -eq 2 ] + [[ "$output" == *"ERROR:Option '--project' may be specified only once."* ]] +} + @test "project command helper resolves project venv directories" { source_project_command_helpers