From aab600b5b20cdfe306d839c7f9b4b47b50902302 Mon Sep 17 00:00:00 2001 From: Alexandre Balmes Date: Wed, 24 Jun 2026 22:08:23 +0200 Subject: [PATCH] feat(cli): standardize version conventions (F110) - `.gitignore`: add Tower workspace dir exclusion rules - `README.md`: update command table for `@version` syntax, `upgrade [version]`, `--version` flag, and breaking change notice - `docs/getting-started/installation.md`: replace `awf version` with `awf --version` and show full output format - `docs/getting-started/quickstart.md`: remove `-v` shorthand from `--verbose` flag table - `docs/user-guide/commands.md`: update `plugin install`, `workflow install`, `upgrade`, and `--version` entries; remove `--version` flag from install commands - `docs/user-guide/plugins.md`: update install syntax to `owner/repo[@version]`; remove `--version` flag; add exact SemVer note - `docs/user-guide/tracing.md`: replace `-v` with `--verbose` - `docs/user-guide/upgrade.md`: replace `--version ` with positional arg; add exact SemVer note; remove `--version` flag entry - `docs/user-guide/workflow-packs.md`: update install examples; remove range constraint examples; add exact SemVer note - `internal/interfaces/cli/doc.go`: update doc comment for version display mechanism - `internal/interfaces/cli/plugin_cmd.go`: replace `--version` flag with `owner/repo@version` inline syntax; introduce `pluginInstallSource` and `parsePluginInstallSource`; simplify `selectRelease` - `internal/interfaces/cli/plugin_cmd_unit_test.go`: rewrite tests for new install source parsing and simplified release selection - `internal/interfaces/cli/release_target.go`: add `exactReleaseTarget`, `parseExactReleaseTarget`, `parseInstallReleaseTarget`, and `selectExactRelease` - `internal/interfaces/cli/release_target_test.go`: comprehensive unit tests for release target parsing and selection - `internal/interfaces/cli/root.go`: replace `version` subcommand with cobra `Version` field and `--version` flag; remove `-v` shorthand from `--verbose` - `internal/interfaces/cli/root_nohints_test.go`: update tests to use `--version` instead of `version` subcommand; drop stale comments - `internal/interfaces/cli/root_test.go`: rewrite version tests for `--version` flag; add tests asserting `version` subcommand is removed and `-v` shorthand is gone - `internal/interfaces/cli/upgrade.go`: replace `--version` flag with positional `[version]` arg; remove `selectTargetRelease`; delegate to `selectExactRelease` - `internal/interfaces/cli/upgrade_test.go`: rewrite upgrade tests for positional version arg and exact SemVer validation - `internal/interfaces/cli/workflow_cmd.go`: replace `--version` flag with `owner/repo@version` inline syntax - `internal/interfaces/cli/workflow_cmd_test.go`: update workflow install tests for new syntax - `tests/integration/cli/plugin_install_test.go`: expand integration tests for `@version` install syntax - `tests/integration/cli/release_target_syntax_test.go`: new integration tests for release target syntax parsing - `tests/integration/cli/workflow_install_test.go`: expand integration tests for workflow `@version` install syntax - `tests/integration/features/cli_test.go`: update CLI feature tests for convention changes Closes #378 --- .gitignore | 7 + README.md | 10 +- docs/getting-started/installation.md | 6 +- docs/getting-started/quickstart.md | 2 +- docs/user-guide/commands.md | 46 ++- docs/user-guide/plugins.md | 11 +- docs/user-guide/tracing.md | 2 +- docs/user-guide/upgrade.md | 7 +- docs/user-guide/workflow-packs.md | 7 +- internal/interfaces/cli/doc.go | 2 +- internal/interfaces/cli/plugin_cmd.go | 66 ++-- .../interfaces/cli/plugin_cmd_unit_test.go | 251 ++++++++++-- internal/interfaces/cli/release_target.go | 93 +++++ .../interfaces/cli/release_target_test.go | 229 +++++++++++ internal/interfaces/cli/root.go | 20 +- internal/interfaces/cli/root_nohints_test.go | 45 +-- internal/interfaces/cli/root_test.go | 216 ++++++++-- internal/interfaces/cli/upgrade.go | 46 +-- internal/interfaces/cli/upgrade_test.go | 205 +++++++++- internal/interfaces/cli/workflow_cmd.go | 71 +--- internal/interfaces/cli/workflow_cmd_test.go | 26 +- tests/integration/cli/plugin_install_test.go | 181 +++++++-- .../cli/release_target_syntax_test.go | 163 ++++++++ .../integration/cli/workflow_install_test.go | 368 +++++++++++++----- tests/integration/features/cli_test.go | 12 +- 25 files changed, 1671 insertions(+), 421 deletions(-) create mode 100644 internal/interfaces/cli/release_target.go create mode 100644 internal/interfaces/cli/release_target_test.go create mode 100644 tests/integration/cli/release_target_syntax_test.go diff --git a/.gitignore b/.gitignore index 20e08181..a7c437a7 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,13 @@ site/node_modules/ .gemini/ .codex/ +# tower workspace dir: the sled index (db/) is regenerated by the scan and the +# extension binaries (extensions/) are built/deployed from the `extensions/` crates — +# both are machine-specific and stay out of git. The hand-authored project config is +# shared, so it is the one tracked file. +.tower/* +!.tower/config.toml + # Built plugin binaries (compiled artifacts of examples/plugins/*) examples/plugins/*/awf-plugin-* !examples/plugins/*/awf-plugin-*.go diff --git a/README.md b/README.md index cad55086..6a8ab7e4 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ AWF is a powerful orchestration tool that grants AI agents and workflows direct | `awf status ` | Check workflow status | | `awf config show` | Display project configuration | | `awf plugin list` | List installed plugins | -| `awf plugin install ` | Install a plugin from GitHub Releases | +| `awf plugin install ` | Install a plugin from GitHub Releases | | `awf plugin update [name]` | Update an installed plugin | | `awf plugin verify [name]` | Verify plugin binary integrity (check/update SHA-256 checksums) | | `awf plugin remove ` | Remove an installed plugin | @@ -137,18 +137,20 @@ AWF is a powerful orchestration tool that grants AI agents and workflows direct | `awf plugin disable ` | Disable a plugin | | `awf workflow list` | List installed workflow packs | | `awf workflow info ` | Display detailed pack information | -| `awf workflow install ` | Install a workflow pack from GitHub Releases | +| `awf workflow install ` | Install a workflow pack from GitHub Releases | | `awf workflow update [name]` | Update an installed workflow pack | | `awf workflow remove ` | Remove an installed workflow pack | | `awf workflow search [query]` | Search for workflow packs on GitHub | | `awf serve` | Start HTTP API server for remote workflow execution and monitoring | | `awf tui` | Launch the interactive terminal UI | -| `awf upgrade` | Upgrade AWF to the latest version | -| `awf version` | Show version information | +| `awf upgrade [version]` | Upgrade AWF to the latest version or an exact SemVer release | +| `awf --version` | Show binary version information | | `awf completion ` | Generate shell autocompletion | See [Command Reference](docs/user-guide/commands.md) for all options. +**BREAKING:** Use `--verbose` instead of `-v`. Version selection now follows standard CLI conventions: `awf --version`, `awf upgrade [version]`, `awf plugin install owner/repo[@version]`, and `awf workflow install owner/repo[@version]`. + ## Example Workflow ```yaml diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 657b331d..47523846 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -35,7 +35,7 @@ curl -fsSL https://raw.githubusercontent.com/awf-project/cli/main/scripts/instal To install a specific version: ```bash -AWF_VERSION=v1.0.0 curl -fsSL https://raw.githubusercontent.com/awf-project/cli/main/scripts/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/awf-project/cli/main/scripts/install.sh | AWF_VERSION=v1.0.0 sh ``` The script detects your OS and architecture, downloads the appropriate binary, verifies its SHA256 checksum, and installs it to `/usr/local/bin`. @@ -71,12 +71,14 @@ The binary will be available at `./bin/awf` after building. ## Verify Installation ```bash -awf version +awf --version ``` Expected output: ``` awf version X.Y.Z +commit: +built: ``` ## Shell Completion diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 1b6ac4ca..b3157c43 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -159,7 +159,7 @@ This will store state files in `/custom/path/states/` and history in `/custom/pa |------|-------------| | `--input, -i` | Pass input values (key=value) | | `--output, -o` | Output mode: silent, streaming, buffered | -| `--verbose, -v` | Enable verbose output | +| `--verbose` | Enable verbose output | | `--quiet, -q` | Suppress non-error output | | `--dry-run` | Show execution plan without running | | `--interactive` | Step-by-step execution with prompts | diff --git a/docs/user-guide/commands.md b/docs/user-guide/commands.md index 20e7ebd7..1a853c91 100644 --- a/docs/user-guide/commands.md +++ b/docs/user-guide/commands.md @@ -20,7 +20,7 @@ title: "CLI Commands" | `awf error [code]` | Look up error code documentation | | `awf history` | Show workflow execution history | | `awf plugin list` | List installed plugins | -| `awf plugin install ` | Install a plugin from GitHub releases | +| `awf plugin install ` | Install a plugin from GitHub releases | | `awf plugin update [name]` | Update an installed plugin | | `awf plugin verify [name]` | Verify plugin binary integrity (check/update SHA-256 checksums) | | `awf plugin remove ` | Remove an installed plugin | @@ -29,16 +29,15 @@ title: "CLI Commands" | `awf plugin disable ` | Disable a plugin | | `awf workflow list` | List installed workflow packs | | `awf workflow info ` | Display detailed pack information | -| `awf workflow install ` | Install a workflow pack from GitHub Releases | +| `awf workflow install ` | Install a workflow pack from GitHub Releases | | `awf workflow update [name]` | Update an installed workflow pack | | `awf workflow remove ` | Remove an installed workflow pack | | `awf workflow search [query]` | Search for workflow packs on GitHub | | `awf config show` | Display project configuration | | `awf serve` | Start HTTP API server for remote execution and monitoring | -| `awf upgrade` | Upgrade AWF to the latest version | +| `awf upgrade [version]` | Upgrade AWF to the latest version or an exact SemVer release | | `awf upgrade --check` | Check for available updates without installing | -| `awf upgrade --version ` | Install a specific version | -| `awf version` | Show version info | +| `awf --version` | Show binary version info | | `awf completion ` | Generate shell autocompletion | ## Global Flags @@ -47,7 +46,7 @@ These flags work with all commands: | Flag | Description | |------|-------------| -| `--verbose, -v` | Enable verbose output (displays tool-use markers during agent execution) | +| `--verbose` | Enable verbose output (displays tool-use markers during agent execution) | | `--quiet, -q` | Suppress non-error output | | `--no-color` | Disable colored output | | `--no-hints` | Disable error hint suggestions | @@ -171,7 +170,7 @@ awf run [flags] | `streaming` | Real-time output with [OUT]/[ERR] prefixes; for agent steps, displays human-readable text (or raw NDJSON if `output_format: json`) | | `buffered` | Show output after each step completes; for agent steps, displays filtered text in post-execution summary (or raw NDJSON if `output_format: json`) | -**Note:** For agent steps, the `output_format` field controls display filtering: `text` or omitted (default) shows human-readable output; `json` shows raw NDJSON. See [Output Formatting](agent-steps.md#streaming-output-display) for details. +**Note:** For agent steps, the `output_format` field controls display filtering: `text` or omitted (default) shows human-readable output; `json` shows raw NDJSON. See [Output Formatting](agent-steps.md#output-formatting) for details. ### Execution Summary @@ -810,7 +809,7 @@ awf validate [flags] awf validate deploy # Validate with verbose output -awf validate deploy -v +awf validate deploy --verbose # Skip plugin validators awf validate deploy --skip-plugins @@ -1096,20 +1095,19 @@ awf plugin list --details Install a plugin from a GitHub repository. ```bash -awf plugin install [flags] +awf plugin install [flags] ``` ### Arguments | Argument | Description | |----------|-------------| -| `owner/repo` | GitHub repository in `owner/repo` format (not a URL) | +| `owner/repo[@version]` | GitHub repository in `owner/repo` format with optional exact SemVer version suffix | ### Flags | Flag | Description | |------|-------------| -| `--version` | Version constraint (e.g. `">=1.0.0 <2.0.0"`) | | `--pre-release` | Include pre-release versions (alpha, beta, rc) | | `--force` | Overwrite existing installation | @@ -1117,6 +1115,8 @@ awf plugin install [flags] Downloads the latest compatible release from the GitHub repository, verifies the SHA-256 checksum, extracts the `.tar.gz` archive, validates the plugin manifest, and installs atomically. The plugin is enabled automatically after installation. +Explicit versions use `owner/repo@version` syntax and must be exact SemVer values. Both `1.2.3` and `v1.2.3` are accepted; ranges such as `>=1.0.0` are rejected. + Release assets must follow the naming convention: `awf-plugin-__.tar.gz` with a corresponding `checksums.txt` file. ### Examples @@ -1125,8 +1125,8 @@ Release assets must follow the naming convention: `awf-plugin-__ # Install a plugin awf plugin install myorg/awf-plugin-jira -# Install with version constraint -awf plugin install myorg/awf-plugin-jira --version ">=1.0.0 <2.0.0" +# Install an exact version +awf plugin install myorg/awf-plugin-jira@v1.2.3 # Include pre-release versions awf plugin install myorg/awf-plugin-jira --pre-release @@ -1573,20 +1573,19 @@ No installed workflow packs to update. Install a workflow pack from a GitHub repository. ```bash -awf workflow install [flags] +awf workflow install [flags] ``` ### Arguments | Argument | Description | |----------|-------------| -| `owner/repo` | GitHub repository in `owner/repo` format (not a URL) | +| `owner/repo[@version]` | GitHub repository in `owner/repo` format with optional exact SemVer version suffix | ### Flags | Flag | Description | |------|-------------| -| `--version` | Version constraint (e.g., `">=1.0.0 <2.0.0"` or `"1.2.0"` for exact version) | | `--global` | Install to global user-level directory (`~/.local/share/awf/workflow-packs/`) instead of local project | | `--force` | Overwrite existing installation | @@ -1594,6 +1593,8 @@ awf workflow install [flags] Downloads the latest compatible release from the GitHub repository, verifies the SHA-256 checksum, extracts the `.tar.gz` archive, validates the `manifest.yaml`, checks AWF version compatibility, and installs atomically. The pack directory structure is created with source metadata. +Explicit versions use `owner/repo@version` syntax and must be exact SemVer values. Both `1.2.3` and `v1.2.3` are accepted; ranges such as `>=1.0.0` are rejected. + Release assets must include a single `.tar.gz` archive (e.g., `awf-workflow-_.tar.gz`) with a corresponding `checksums.txt` file. Workflow packs are platform-independent — no OS/architecture suffix is needed. **Pack manifest validation:** @@ -1615,11 +1616,8 @@ If the manifest declares required plugins via the `plugins:` field, warnings are # Install a workflow pack (latest version) awf workflow install myorg/awf-workflow-speckit -# Install with specific version -awf workflow install myorg/awf-workflow-speckit --version "1.2.0" - -# Install with version constraint -awf workflow install myorg/awf-workflow-speckit --version ">=1.0.0 <2.0.0" +# Install an exact version +awf workflow install myorg/awf-workflow-speckit@v1.2.0 # Install globally (available to all projects) awf workflow install myorg/awf-workflow-speckit --global @@ -1794,12 +1792,12 @@ Project Configuration (.awf/config.yaml) --- -## awf version +## awf --version -Show version information. +Show binary version information. ```bash -awf version +awf --version ``` --- diff --git a/docs/user-guide/plugins.md b/docs/user-guide/plugins.md index b70dc979..8f4f8fde 100644 --- a/docs/user-guide/plugins.md +++ b/docs/user-guide/plugins.md @@ -330,16 +330,17 @@ The `SOURCE` column shows the GitHub `owner/repo` for plugins installed via `awf Install an external plugin from GitHub Releases: ```bash -awf plugin install owner/repo +awf plugin install owner/repo[@version] ``` AWF downloads the latest release, verifies the SHA-256 checksum, extracts the archive, validates the manifest, and installs atomically. +Explicit versions use `owner/repo@version` syntax and must be exact SemVer values. Both `1.2.3` and `v1.2.3` are accepted; ranges such as `>=1.0.0` are rejected. + **Flags:** | Flag | Description | |------|-------------| -| `--version` | Version constraint (e.g. `">=1.0.0 <2.0.0"`) | | `--pre-release` | Include alpha/beta/rc versions in resolution | | `--force` | Overwrite an existing installation | @@ -349,8 +350,8 @@ AWF downloads the latest release, verifies the SHA-256 checksum, extracts the ar # Install latest stable release awf plugin install myorg/awf-plugin-jira -# Install with version constraint -awf plugin install myorg/awf-plugin-jira --version ">=1.0.0 <2.0.0" +# Install an exact version +awf plugin install myorg/awf-plugin-jira@v1.2.3 # Include pre-release versions awf plugin install myorg/awf-plugin-jira --pre-release @@ -359,7 +360,7 @@ awf plugin install myorg/awf-plugin-jira --pre-release awf plugin install myorg/awf-plugin-jira --force ``` -The `owner/repo` argument must be a GitHub repository path (not a URL). The repository must contain GitHub Releases with `.tar.gz` assets matching the AWF naming convention (see [Release Asset Naming](#release-asset-naming)). +The `owner/repo[@version]` argument must be a GitHub repository path (not a URL). The repository must contain GitHub Releases with `.tar.gz` assets matching the AWF naming convention (see [Release Asset Naming](#release-asset-naming)). #### Update a Plugin diff --git a/docs/user-guide/tracing.md b/docs/user-guide/tracing.md index d45f0d5a..f96e9f96 100644 --- a/docs/user-guide/tracing.md +++ b/docs/user-guide/tracing.md @@ -434,7 +434,7 @@ workflow.run [data-pipeline] 2. **Check endpoint configuration:** ```bash export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 - awf run my-workflow --otel-exporter=otlp -v + awf run my-workflow --otel-exporter=otlp --verbose ``` 3. **Verify service name:** diff --git a/docs/user-guide/upgrade.md b/docs/user-guide/upgrade.md index 5617f574..72729336 100644 --- a/docs/user-guide/upgrade.md +++ b/docs/user-guide/upgrade.md @@ -23,10 +23,10 @@ Downloads the latest stable release, verifies its SHA256 checksum, and replaces ## Install a Specific Version ```bash -awf upgrade --version v0.5.0 +awf upgrade v0.5.0 ``` -Installs the specified version, allowing both upgrades and downgrades. +Installs the specified exact SemVer release, allowing both upgrades and downgrades. Both `0.5.0` and `v0.5.0` are accepted; ranges such as `>=0.5.0` are rejected. ## Force Upgrade @@ -35,7 +35,7 @@ awf upgrade --force ``` Skips version comparison and package manager detection. Required when: -- Running a development build (`awf version` shows "dev") +- Running a development build (`awf --version` shows "dev") - Binary is installed via a package manager (homebrew, snap, nix) - You want to reinstall the same version @@ -56,7 +56,6 @@ AWF also tries `gh auth token` automatically if the GitHub CLI is installed. |------|-------------| | `--check` | Check for updates without installing | | `--force` | Force upgrade (skip version/package manager checks) | -| `--version` | Install a specific version (e.g., `v0.5.0`) | ## Troubleshooting diff --git a/docs/user-guide/workflow-packs.md b/docs/user-guide/workflow-packs.md index 1417b7dc..f264f7ac 100644 --- a/docs/user-guide/workflow-packs.md +++ b/docs/user-guide/workflow-packs.md @@ -59,10 +59,7 @@ Install packs from GitHub Releases: awf workflow install myorg/awf-workflow-speckit # Install specific version -awf workflow install myorg/awf-workflow-speckit@1.2.0 - -# Install with version constraint -awf workflow install myorg/awf-workflow-speckit --version ">=1.0.0 <2.0.0" +awf workflow install myorg/awf-workflow-speckit@v1.2.0 # Install globally (available to all projects) awf workflow install myorg/awf-workflow-speckit --global @@ -73,6 +70,8 @@ awf workflow install myorg/awf-workflow-speckit --force AWF downloads the release archive, verifies the SHA-256 checksum, validates the manifest (including AWF version compatibility), and installs atomically. +Explicit versions use `owner/repo@version` syntax and must be exact SemVer values. Both `1.2.0` and `v1.2.0` are accepted; ranges such as `>=1.0.0` are rejected. + ### Installation Locations | Scope | Directory | Flag | diff --git a/internal/interfaces/cli/doc.go b/internal/interfaces/cli/doc.go index 9940b8e5..f6bdfc53 100644 --- a/internal/interfaces/cli/doc.go +++ b/internal/interfaces/cli/doc.go @@ -26,7 +26,7 @@ // - App: Dependency injection container with Config and Formatter // - NewApp: Creates application with loaded configuration // - NewRootCommand: Builds cobra command tree with global flags -// - newVersionCommand: Displays AWF version, commit, and build date +// - --version: Displays AWF version, commit, and build date // // Global flags: // - --no-color: Disable colorized output diff --git a/internal/interfaces/cli/plugin_cmd.go b/internal/interfaces/cli/plugin_cmd.go index 72355e47..0fb9fd64 100644 --- a/internal/interfaces/cli/plugin_cmd.go +++ b/internal/interfaces/cli/plugin_cmd.go @@ -396,16 +396,20 @@ func initPluginSystemReadOnly(ctx context.Context, cfg *Config) (*PluginSystemRe } type installOptions struct { - version string preRelease bool force bool } +type pluginInstallSource struct { + Repository string + Target exactReleaseTarget +} + func newPluginInstallCommand(cfg *Config) *cobra.Command { var opts installOptions cmd := &cobra.Command{ - Use: "install ", + Use: "install ", Short: "Install a plugin from GitHub releases", Long: `Install a plugin from a GitHub repository using the owner/repo format. @@ -414,7 +418,7 @@ atomically. The plugin is enabled automatically after installation. Examples: awf plugin install myorg/awf-plugin-jira - awf plugin install myorg/awf-plugin-jira --version ">=1.0.0 <2.0.0" + awf plugin install myorg/awf-plugin-jira@1.2.3 awf plugin install myorg/awf-plugin-jira --pre-release awf plugin install myorg/awf-plugin-jira --force`, Args: cobra.ExactArgs(1), @@ -423,18 +427,30 @@ Examples: }, } - cmd.Flags().StringVar(&opts.version, "version", "", "version constraint (e.g. \">=1.0.0 <2.0.0\")") cmd.Flags().BoolVar(&opts.preRelease, "pre-release", false, "include pre-release versions") cmd.Flags().BoolVar(&opts.force, "force", false, "overwrite existing installation") return cmd } +func parsePluginInstallSource(source string) (pluginInstallSource, error) { + repository, target, err := parseInstallReleaseTarget(source) + if err != nil { + return pluginInstallSource{}, err + } + + return pluginInstallSource{Repository: repository, Target: target}, nil +} + func runPluginInstall(cmd *cobra.Command, cfg *Config, source string, opts installOptions) error { - if err := registry.ValidateOwnerRepo(source); err != nil { + parsedSource, parseErr := parsePluginInstallSource(source) + if parseErr != nil { + return parseErr + } + if err := registry.ValidateOwnerRepo(parsedSource.Repository); err != nil { return err } - owner, repo, _ := strings.Cut(source, "/") + owner, repo, _ := strings.Cut(parsedSource.Repository, "/") pluginName := extractPluginName(repo) pluginPaths := getPluginSearchPaths(cfg) @@ -471,7 +487,7 @@ func runPluginInstall(cmd *cobra.Command, cfg *Config, source string, opts insta return fmt.Errorf("no releases found for %s", ownerRepo) } - release, err := selectRelease(releases, opts.version, opts.preRelease) + release, err := selectExactRelease(releases, parsedSource.Target, opts.preRelease) if err != nil { return fmt.Errorf("failed to resolve version for %s: %w", ownerRepo, err) } @@ -539,42 +555,14 @@ func runPluginInstall(cmd *cobra.Command, cfg *Config, source string, opts insta return nil } -// selectRelease picks the best matching release from a list. -// When versionConstraint is empty, returns the first non-prerelease release (latest stable). -// When includePrerelease is true, also considers prerelease releases. -// -// versionConstraint may be a bare version ("1.0.0"), a v-prefixed version ("v1.0.0"), -// or a semver range expression (">=1.0.0 <2.0.0"). -func selectRelease(releases []registry.Release, versionConstraint string, includePrerelease bool) (registry.Release, error) { - var constraints registry.Constraints - if versionConstraint != "" { - // Normalize a bare v-prefixed version like "v1.0.0" → "1.0.0" so ParseConstraints - // can handle it. Range expressions such as ">=1.0.0" are left as-is. - normalized := registry.NormalizeTag(versionConstraint) - var err error - constraints, err = registry.ParseConstraints(normalized) - if err != nil { - return registry.Release{}, fmt.Errorf("invalid version constraint: %w", err) - } - } - +func selectRelease(releases []registry.Release, includePrerelease bool) (registry.Release, error) { for _, r := range releases { if r.Prerelease && !includePrerelease { continue } - if versionConstraint == "" { - return r, nil - } - versionStr := registry.NormalizeTag(r.TagName) - v, err := registry.ParseVersion(versionStr) - if err != nil { - continue - } - if constraints.Check(v) { - return r, nil - } + return r, nil } - return registry.Release{}, fmt.Errorf("no release matches constraint %q (includePrerelease=%v)", versionConstraint, includePrerelease) + return registry.Release{}, fmt.Errorf("no eligible releases found (includePrerelease=%v)", includePrerelease) } // findChecksumURL locates the checksum file URL among release assets. @@ -697,7 +685,7 @@ func updatePlugin(cmd *cobra.Command, cfg *Config, name string) error { } // Select latest stable release (no version constraint, no pre-release). - release, err := selectRelease(releases, "", false) + release, err := selectRelease(releases, false) if err != nil { return fmt.Errorf("failed to resolve latest version for %s: %w", source.Repository, err) } diff --git a/internal/interfaces/cli/plugin_cmd_unit_test.go b/internal/interfaces/cli/plugin_cmd_unit_test.go index 37339712..e0a8599e 100644 --- a/internal/interfaces/cli/plugin_cmd_unit_test.go +++ b/internal/interfaces/cli/plugin_cmd_unit_test.go @@ -1,7 +1,12 @@ package cli import ( + "bytes" + "encoding/json" "net/http" + "net/http/httptest" + "os" + "path/filepath" "testing" "time" @@ -10,27 +15,230 @@ import ( "github.com/stretchr/testify/require" ) -func TestSelectRelease_NoConstraint_ReturnsFirstNonPrerelease(t *testing.T) { - releases := []registry.Release{ - {TagName: "v1.0.0-alpha", Prerelease: true}, - {TagName: "v1.0.0", Prerelease: false}, - {TagName: "v1.1.0", Prerelease: false}, +func TestPluginInstallCommand_UsesSyntaxEquivalentToInstallOwnerRepoAtVersion(t *testing.T) { + cmd := newPluginInstallCommand(&Config{}) + + assert.Equal(t, "install ", cmd.Use) + assert.Nil(t, cmd.Flags().Lookup("version")) + assert.NotNil(t, cmd.Flags().Lookup("pre-release")) + assert.NotNil(t, cmd.Flags().Lookup("force")) +} + +func TestParsePluginInstallSource(t *testing.T) { + tests := []struct { + name string + source string + wantRepo string + wantVersion bool + wantTag string + wantErr string + }{ + { + name: "unversioned", + source: "owner/repo", + wantRepo: "owner/repo", + }, + { + name: "bare semver", + source: "owner/repo@1.2.3", + wantRepo: "owner/repo", + wantVersion: true, + wantTag: "1.2.3", + }, + { + name: "v-prefixed semver", + source: "owner/repo@v1.2.3", + wantRepo: "owner/repo", + wantVersion: true, + wantTag: "1.2.3", + }, + { + name: "colon version syntax", + source: "owner/repo:1.2.3", + wantErr: "owner/repo:version syntax is not supported; use owner/repo@version", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parsePluginInstallSource(tt.source) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantRepo, got.Repository) + assert.Equal(t, tt.wantVersion, got.Target.HasVersion) + assert.Equal(t, tt.wantTag, got.Target.Tag) + }) } +} - result, err := selectRelease(releases, "", false) +func TestPluginInstallCommand_VersionFlagFailsDuringCobraFlagParsing(t *testing.T) { + var releaseHits int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + releaseHits++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + t.Setenv("GITHUB_API_URL", server.URL) - require.NoError(t, err) - assert.Equal(t, "v1.0.0", result.TagName) + cmd := newPluginInstallCommand(&Config{StoragePath: t.TempDir()}) + cmd.SetArgs([]string{"owner/repo", "--version", "1.2.3"}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown flag: --version") + assert.Zero(t, releaseHits) } -func TestSelectRelease_WithConstraint_MatchesVersion(t *testing.T) { +func TestPluginInstallCommand_RejectsInvalidVersionTargetsBeforeReleaseLookup(t *testing.T) { + tests := []struct { + name string + source string + wantErr string + }{ + { + name: "latest", + source: "owner/repo@latest", + wantErr: `invalid release version "latest": version: invalid format "latest"`, + }, + { + name: "range", + source: "owner/repo@>=1.0.0", + wantErr: `invalid release version ">=1.0.0": version: invalid format ">=1.0.0"`, + }, + { + name: "empty", + source: "owner/repo@", + wantErr: `invalid release version "": version: empty string`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var releaseHits int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + releaseHits++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + t.Setenv("GITHUB_API_URL", server.URL) + + cfg := &Config{StoragePath: t.TempDir()} + cmd := newPluginInstallCommand(cfg) + cmd.SetArgs([]string{tt.source}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + assert.Zero(t, releaseHits) + }) + } +} + +func TestPluginInstallCommand_ExistingOwnerRepoValidationRemainsInForce(t *testing.T) { + tests := []struct { + name string + source string + wantErr string + }{ + { + name: "missing slash separator", + source: "owneronly", + wantErr: "invalid owner/repo format: missing slash separator", + }, + { + name: "empty owner segment", + source: "/repo", + wantErr: "invalid owner/repo format: empty owner segment", + }, + { + name: "multiple slashes", + source: "owner/repo/extra", + wantErr: "invalid owner/repo format: multiple slashes not allowed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var releaseHits int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + releaseHits++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + t.Setenv("GITHUB_API_URL", server.URL) + + cmd := newPluginInstallCommand(&Config{StoragePath: t.TempDir()}) + cmd.SetArgs([]string{tt.source}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + assert.Zero(t, releaseHits) + }) + } +} + +func TestPluginInstallCommand_ValidSemverAbsentFromReleaseListReturnsNotFound(t *testing.T) { + tmpDir := t.TempDir() + pluginsDir := filepath.Join(tmpDir, "plugins") + require.NoError(t, os.MkdirAll(pluginsDir, 0o755)) + t.Setenv("AWF_PLUGINS_PATH", pluginsDir) + + var releaseHits int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + releaseHits++ + releases := []map[string]any{ + {"tag_name": "v2.0.0"}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(releases) //nolint:errcheck // test fixture response + })) + defer server.Close() + t.Setenv("GITHUB_API_URL", server.URL) + + cmd := newPluginInstallCommand(&Config{StoragePath: tmpDir}) + cmd.SetArgs([]string{"owner/repo@1.2.3"}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to resolve version for owner/repo: release version 1.2.3 not found") + assert.Equal(t, 1, releaseHits) +} + +func TestSelectRelease_NoConstraint_ReturnsFirstNonPrerelease(t *testing.T) { releases := []registry.Release{ - {TagName: "v0.9.0", Prerelease: false}, + {TagName: "v1.0.0-alpha", Prerelease: true}, {TagName: "v1.0.0", Prerelease: false}, {TagName: "v1.1.0", Prerelease: false}, } - result, err := selectRelease(releases, ">=1.0.0", false) + result, err := selectRelease(releases, false) require.NoError(t, err) assert.Equal(t, "v1.0.0", result.TagName) @@ -42,32 +250,21 @@ func TestSelectRelease_IncludePrerelease_MatchesPrerelease(t *testing.T) { {TagName: "v1.1.0", Prerelease: false}, } - result, err := selectRelease(releases, "", true) + result, err := selectRelease(releases, true) require.NoError(t, err) assert.Equal(t, "v1.0.0-alpha", result.TagName) } -func TestSelectRelease_InvalidConstraint_ReturnsError(t *testing.T) { +func TestSelectRelease_NoEligibleRelease_ReturnsError(t *testing.T) { releases := []registry.Release{ - {TagName: "v1.0.0", Prerelease: false}, - } - - _, err := selectRelease(releases, "invalid constraint", false) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid version constraint") -} - -func TestSelectRelease_NoMatch_ReturnsError(t *testing.T) { - releases := []registry.Release{ - {TagName: "v0.5.0", Prerelease: false}, + {TagName: "v1.0.0-alpha", Prerelease: true}, } - _, err := selectRelease(releases, ">=1.0.0", false) + _, err := selectRelease(releases, false) assert.Error(t, err) - assert.Contains(t, err.Error(), "no release matches constraint") + assert.Contains(t, err.Error(), "no eligible releases found") } func TestFindChecksumURL_ChecksumsFileExists(t *testing.T) { diff --git a/internal/interfaces/cli/release_target.go b/internal/interfaces/cli/release_target.go new file mode 100644 index 00000000..dbaecc45 --- /dev/null +++ b/internal/interfaces/cli/release_target.go @@ -0,0 +1,93 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/awf-project/cli/pkg/registry" +) + +type exactReleaseTarget struct { + HasVersion bool + Version registry.Version + Tag string +} + +func parseExactReleaseTarget(rawTarget string, optional bool) (exactReleaseTarget, error) { + if rawTarget == "" && optional { + return exactReleaseTarget{}, nil + } + + version, err := registry.ParseVersion(rawTarget) + if err != nil { + return exactReleaseTarget{}, fmt.Errorf("invalid release version %q: %w", rawTarget, err) + } + + return exactReleaseTarget{ + HasVersion: true, + Version: version, + Tag: registry.NormalizeTag(version.String()), + }, nil +} + +func parseInstallReleaseTarget(source string) (string, exactReleaseTarget, error) { + repository := source + rawTarget := "" + hasVersionTarget := false + + if at := strings.LastIndex(source, "@"); at >= 0 { + repository = source[:at] + rawTarget = source[at+1:] + hasVersionTarget = true + } else if strings.Contains(source, ":") { + return "", exactReleaseTarget{}, fmt.Errorf("owner/repo:version syntax is not supported; use owner/repo@version") + } + + target, err := parseExactReleaseTarget(rawTarget, !hasVersionTarget) + if err != nil { + return "", exactReleaseTarget{}, err + } + + return repository, target, nil +} + +func selectExactRelease( + releases []registry.Release, + target exactReleaseTarget, + includePrerelease bool, +) (registry.Release, error) { + if target.HasVersion { + for _, release := range releases { + if registry.NormalizeTag(release.TagName) == target.Tag { + return release, nil + } + } + + return registry.Release{}, fmt.Errorf("release version %s not found", target.Tag) + } + + selected, found := registry.Release{}, false + var selectedVersion registry.Version + for _, release := range releases { + if release.Prerelease && !includePrerelease { + continue + } + + version, err := registry.ParseVersion(registry.NormalizeTag(release.TagName)) + if err != nil { + continue + } + + if !found || version.Compare(selectedVersion) > 0 { + selected = release + selectedVersion = version + found = true + } + } + + if !found { + return registry.Release{}, fmt.Errorf("no stable releases found") + } + + return selected, nil +} diff --git a/internal/interfaces/cli/release_target_test.go b/internal/interfaces/cli/release_target_test.go new file mode 100644 index 00000000..8a9c09cf --- /dev/null +++ b/internal/interfaces/cli/release_target_test.go @@ -0,0 +1,229 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/awf-project/cli/pkg/registry" +) + +func TestParseExactReleaseTarget_AcceptsEmptyRawTargetOnlyWhenOptional(t *testing.T) { + target, err := parseExactReleaseTarget("", true) + require.NoError(t, err) + + assert.False(t, target.HasVersion) + assert.Equal(t, registry.Version{}, target.Version) + assert.Empty(t, target.Tag) +} + +func TestParseExactReleaseTarget_RejectsEmptyRawTargetWhenExplicitVersionRequired(t *testing.T) { + _, err := parseExactReleaseTarget("", false) + require.Error(t, err) + assert.Contains(t, err.Error(), `invalid release version "": version: empty string`) +} + +func TestParseExactReleaseTarget_AcceptsExactSemVerTargets(t *testing.T) { + tests := []struct { + name string + raw string + }{ + { + name: "parseExactReleaseTarget 1.2.3 returns exact release target", + raw: "1.2.3", + }, + { + name: "parseExactReleaseTarget v1.2.3 accepts v prefix", + raw: "v1.2.3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target, err := parseExactReleaseTarget(tt.raw, false) + require.NoError(t, err) + + assert.True(t, target.HasVersion) + assert.Equal(t, registry.Version{Major: 1, Minor: 2, Patch: 3}, target.Version) + assert.Equal(t, registry.NormalizeTag("v1.2.3"), target.Tag) + }) + } +} + +func TestParseExactReleaseTarget_RejectsNonExactTargetsBeforeReleaseLookup(t *testing.T) { + tests := []struct { + name string + raw string + wantMessage string + }{ + { + name: "parseExactReleaseTarget latest returns invalid format", + raw: "latest", + wantMessage: `invalid release version "latest": version: invalid format "latest"`, + }, + { + name: "parseExactReleaseTarget range returns invalid format", + raw: ">=1.0.0", + wantMessage: `invalid release version ">=1.0.0": version: invalid format ">=1.0.0"`, + }, + { + name: "parseExactReleaseTarget partial version returns invalid format", + raw: "1.2", + wantMessage: `invalid release version "1.2": version: invalid format "1.2"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseExactReleaseTarget(tt.raw, false) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantMessage) + }) + } +} + +func TestParseInstallReleaseTarget(t *testing.T) { + tests := []struct { + name string + source string + wantRepo string + wantVersion bool + wantTag string + wantErr string + }{ + { + name: "unversioned owner repo", + source: "owner/repo", + wantRepo: "owner/repo", + }, + { + name: "exact version suffix", + source: "owner/repo@1.2.3", + wantRepo: "owner/repo", + wantVersion: true, + wantTag: "1.2.3", + }, + { + name: "v-prefixed exact version suffix", + source: "owner/repo@v1.2.3", + wantRepo: "owner/repo", + wantVersion: true, + wantTag: "1.2.3", + }, + { + name: "colon version syntax", + source: "owner/repo:1.2.3", + wantErr: "owner/repo:version syntax is not supported; use owner/repo@version", + }, + { + name: "empty version suffix", + source: "owner/repo@", + wantErr: `invalid release version "": version: empty string`, + }, + { + name: "range version suffix", + source: "owner/repo@>=1.0.0", + wantErr: `invalid release version ">=1.0.0": version: invalid format ">=1.0.0"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRepo, gotTarget, err := parseInstallReleaseTarget(tt.source) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantRepo, gotRepo) + assert.Equal(t, tt.wantVersion, gotTarget.HasVersion) + assert.Equal(t, tt.wantTag, gotTarget.Tag) + }) + } +} + +func TestSelectExactRelease_NoVersionSelection(t *testing.T) { + releases := []registry.Release{ + {TagName: "v1.3.0-beta.1", Prerelease: true}, + {TagName: "v1.2.3", Prerelease: false}, + {TagName: "v1.1.0", Prerelease: false}, + } + + t.Run("selectExactRelease returns latest stable release when prereleases are excluded", func(t *testing.T) { + target, err := parseExactReleaseTarget("", true) + require.NoError(t, err) + + release, err := selectExactRelease(releases, target, false) + require.NoError(t, err) + assert.Equal(t, "v1.2.3", release.TagName) + }) + + t.Run("selectExactRelease includes prereleases only when caller allows prereleases", func(t *testing.T) { + target, err := parseExactReleaseTarget("", true) + require.NoError(t, err) + + release, err := selectExactRelease(releases, target, true) + require.NoError(t, err) + assert.Equal(t, "v1.3.0-beta.1", release.TagName) + }) +} + +func TestSelectExactRelease_ReturnsExactMatchingReleaseRegardlessOfVPrefix(t *testing.T) { + tests := []struct { + name string + rawTarget string + releaseTag string + }{ + { + name: "selectExactRelease matches bare target to v-prefixed release tag", + rawTarget: "1.2.3", + releaseTag: "v1.2.3", + }, + { + name: "selectExactRelease matches v-prefixed target to bare release tag", + rawTarget: "v1.2.3", + releaseTag: "1.2.3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target, err := parseExactReleaseTarget(tt.rawTarget, false) + require.NoError(t, err) + + release, err := selectExactRelease([]registry.Release{ + {TagName: "v1.4.0", Prerelease: false}, + {TagName: tt.releaseTag, Prerelease: false}, + }, target, false) + require.NoError(t, err) + assert.Equal(t, tt.releaseTag, release.TagName) + }) + } +} + +func TestSelectExactRelease_ReturnsExactNotFoundError(t *testing.T) { + target, err := parseExactReleaseTarget("1.2.3", false) + require.NoError(t, err) + + _, err = selectExactRelease([]registry.Release{ + {TagName: "v1.4.0", Prerelease: false}, + }, target, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "release version 1.2.3 not found") +} + +func TestSelectExactRelease_ReturnsNoStableReleasesFound(t *testing.T) { + target, err := parseExactReleaseTarget("", true) + require.NoError(t, err) + + _, err = selectExactRelease([]registry.Release{ + {TagName: "v1.3.0-beta.1", Prerelease: true}, + {TagName: "v1.2.3-rc.1", Prerelease: true}, + }, target, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "no stable releases found") +} diff --git a/internal/interfaces/cli/root.go b/internal/interfaces/cli/root.go index f0d19abd..047bdb7b 100644 --- a/internal/interfaces/cli/root.go +++ b/internal/interfaces/cli/root.go @@ -60,6 +60,7 @@ Examples: awf validate my-workflow awf list awf status abc123`, + Version: Version, SilenceUsage: true, SilenceErrors: true, PersistentPreRun: func(cmd *cobra.Command, args []string) { @@ -70,9 +71,12 @@ Examples: }, } + cmd.SetVersionTemplate("awf version {{.Version}}\ncommit: " + Commit + "\nbuilt: " + BuildDate + "\n") + cmd.Flags().Bool("version", false, "version for awf") + // Global flags pf := cmd.PersistentFlags() - pf.BoolVarP(&cfg.Verbose, "verbose", "v", false, "Enable verbose output") + pf.BoolVar(&cfg.Verbose, "verbose", false, "Enable verbose output") pf.BoolVarP(&cfg.Quiet, "quiet", "q", false, "Suppress non-error output") pf.BoolVar(&cfg.NoColor, "no-color", false, "Disable colored output") pf.BoolVar(&cfg.NoHints, "no-hints", false, "Disable error hint suggestions") @@ -117,7 +121,6 @@ Examples: } // Subcommands - cmd.AddCommand(newVersionCommand()) cmd.AddCommand(newInitCommand(cfg)) cmd.AddCommand(newListCommand(cfg)) cmd.AddCommand(newRunCommand(cfg)) @@ -143,16 +146,3 @@ Examples: } } } - -func newVersionCommand() *cobra.Command { - return &cobra.Command{ - Use: "version", - Short: "Print version information", - Long: "Display the version, commit hash, and build date of awf.", - Run: func(cmd *cobra.Command, args []string) { - cmd.Printf("awf version %s\n", Version) - cmd.Printf("commit: %s\n", Commit) - cmd.Printf("built: %s\n", BuildDate) - }, - } -} diff --git a/internal/interfaces/cli/root_nohints_test.go b/internal/interfaces/cli/root_nohints_test.go index 12fedd56..1b0bf25f 100644 --- a/internal/interfaces/cli/root_nohints_test.go +++ b/internal/interfaces/cli/root_nohints_test.go @@ -8,8 +8,6 @@ import ( "github.com/awf-project/cli/internal/interfaces/cli" ) -// Component T012: Tests for --no-hints persistent flag registration - func TestRootCommand_HasNoHintsFlag(t *testing.T) { cmd := cli.NewRootCommand() @@ -27,7 +25,6 @@ func TestRootCommand_NoHintsFlagType(t *testing.T) { t.Fatal("expected --no-hints persistent flag to exist") } - // Verify it's a boolean flag if flag.Value.Type() != "bool" { t.Errorf("expected --no-hints to be bool type, got: %s", flag.Value.Type()) } @@ -41,7 +38,6 @@ func TestRootCommand_NoHintsFlagDefaultValue(t *testing.T) { t.Fatal("expected --no-hints persistent flag to exist") } - // Default value should be "false" (hints enabled by default) if flag.DefValue != "false" { t.Errorf("expected --no-hints default value 'false', got: %s", flag.DefValue) } @@ -55,12 +51,10 @@ func TestRootCommand_NoHintsFlagDescription(t *testing.T) { t.Fatal("expected --no-hints persistent flag to exist") } - // Verify usage description is meaningful if flag.Usage == "" { t.Error("expected --no-hints to have usage description") } - // Description should mention hints or suggestions usage := strings.ToLower(flag.Usage) if !strings.Contains(usage, "hint") && !strings.Contains(usage, "suggestion") { t.Errorf("expected --no-hints usage to mention hints/suggestions, got: %s", flag.Usage) @@ -70,7 +64,6 @@ func TestRootCommand_NoHintsFlagDescription(t *testing.T) { func TestRootCommand_NoHintsFlagPersistence(t *testing.T) { cmd := cli.NewRootCommand() - // Verify flag is persistent (available to all subcommands) persistentFlag := cmd.PersistentFlags().Lookup("no-hints") localFlag := cmd.Flags().Lookup("no-hints") @@ -85,8 +78,7 @@ func TestRootCommand_NoHintsFlagPersistence(t *testing.T) { func TestRootCommand_NoHintsFlagAvailableToSubcommands(t *testing.T) { cmd := cli.NewRootCommand() - // Test that flag is inherited by subcommands - subcommandTests := []string{"version", "list", "run", "validate", "error"} + subcommandTests := []string{"list", "run", "validate", "error"} for _, subName := range subcommandTests { found := false @@ -102,8 +94,6 @@ func TestRootCommand_NoHintsFlagAvailableToSubcommands(t *testing.T) { continue } - // Inherited flags are accessible via root command's PersistentFlags - // but not directly in subcommand's own flags if cmd.PersistentFlags().Lookup("no-hints") == nil { t.Errorf("expected --no-hints to be inherited by '%s' subcommand", subName) } @@ -125,12 +115,10 @@ func TestRootCommand_NoHintsInHelpOutput(t *testing.T) { output := buf.String() - // Verify --no-hints appears in help output if !strings.Contains(output, "--no-hints") { t.Error("expected --no-hints to appear in help output") } - // Verify description is present if !strings.Contains(strings.ToLower(output), "hint") { t.Error("expected --no-hints description to mention hints in help output") } @@ -142,7 +130,7 @@ func TestRootCommand_NoHintsFlagAcceptsTrue(t *testing.T) { buf := new(bytes.Buffer) cmd.SetOut(buf) cmd.SetErr(buf) - cmd.SetArgs([]string{"--no-hints", "version"}) + cmd.SetArgs([]string{"--no-hints", "--version"}) err := cmd.Execute() if err != nil { @@ -158,17 +146,17 @@ func TestRootCommand_NoHintsFlagAcceptsExplicitValue(t *testing.T) { }{ { name: "explicit true", - args: []string{"--no-hints=true", "version"}, + args: []string{"--no-hints=true", "--version"}, wantError: false, }, { name: "explicit false", - args: []string{"--no-hints=false", "version"}, + args: []string{"--no-hints=false", "--version"}, wantError: false, }, { name: "implicit true (flag present)", - args: []string{"--no-hints", "version"}, + args: []string{"--no-hints", "--version"}, wantError: false, }, } @@ -194,18 +182,17 @@ func TestRootCommand_NoHintsFlagAcceptsExplicitValue(t *testing.T) { } func TestRootCommand_NoHintsFlagOrdering(t *testing.T) { - // Test that --no-hints can appear before or after subcommand tests := []struct { name string args []string }{ { name: "flag before subcommand", - args: []string{"--no-hints", "version"}, + args: []string{"--no-hints", "help"}, }, { name: "flag after subcommand", - args: []string{"version", "--no-hints"}, + args: []string{"help", "--no-hints"}, }, } @@ -227,13 +214,12 @@ func TestRootCommand_NoHintsFlagOrdering(t *testing.T) { } func TestRootCommand_NoHintsFlagWithOtherFlags(t *testing.T) { - // Test that --no-hints works alongside other global flags cmd := cli.NewRootCommand() buf := new(bytes.Buffer) cmd.SetOut(buf) cmd.SetErr(buf) - cmd.SetArgs([]string{"--no-hints", "--no-color", "--quiet", "version"}) + cmd.SetArgs([]string{"--no-hints", "--no-color", "--quiet", "--version"}) err := cmd.Execute() if err != nil { @@ -242,7 +228,6 @@ func TestRootCommand_NoHintsFlagWithOtherFlags(t *testing.T) { } func TestRootCommand_GlobalFlagsIncludesNoHints(t *testing.T) { - // Verify --no-hints is in the list of expected global flags cmd := cli.NewRootCommand() expectedFlags := []string{"verbose", "quiet", "no-color", "no-hints", "log-level", "config", "storage"} @@ -256,7 +241,6 @@ func TestRootCommand_GlobalFlagsIncludesNoHints(t *testing.T) { } func TestRootCommand_NoHintsFlagConsistentWithNoColor(t *testing.T) { - // Verify --no-hints follows the same pattern as --no-color cmd := cli.NewRootCommand() noHintsFlag := cmd.PersistentFlags().Lookup("no-hints") @@ -266,24 +250,20 @@ func TestRootCommand_NoHintsFlagConsistentWithNoColor(t *testing.T) { t.Fatal("expected both --no-hints and --no-color to exist") } - // Both should be boolean flags if noHintsFlag.Value.Type() != noColorFlag.Value.Type() { t.Error("expected --no-hints to have same type as --no-color (bool)") } - // Both should default to false (features enabled by default) if noHintsFlag.DefValue != noColorFlag.DefValue { t.Error("expected --no-hints and --no-color to have same default value (false)") } - // Both should be persistent flags if noHintsFlag.Name == "" || noColorFlag.Name == "" { t.Error("expected both flags to be persistent") } } func TestRootCommand_NoHintsFlagInvalidValue(t *testing.T) { - // Test that invalid boolean values are rejected cmd := cli.NewRootCommand() buf := new(bytes.Buffer) @@ -297,20 +277,18 @@ func TestRootCommand_NoHintsFlagInvalidValue(t *testing.T) { t.Error("expected invalid boolean value to cause error") } - // Error should be returned (either as err or in errBuf) if err == nil && errBuf.String() == "" { t.Error("expected error for invalid boolean value") } } func TestRootCommand_NoHintsFlagEmptyValue(t *testing.T) { - // Test that --no-hints without value defaults to true (standard flag behavior) cmd := cli.NewRootCommand() buf := new(bytes.Buffer) cmd.SetOut(buf) cmd.SetErr(buf) - cmd.SetArgs([]string{"--no-hints", "version"}) + cmd.SetArgs([]string{"--no-hints", "--version"}) err := cmd.Execute() if err != nil { @@ -319,24 +297,20 @@ func TestRootCommand_NoHintsFlagEmptyValue(t *testing.T) { } func TestConfig_NoHintsField(t *testing.T) { - // Verify Config struct has NoHints field cfg := cli.DefaultConfig() if cfg == nil { t.Fatal("DefaultConfig() should return non-nil config") } - // Access the NoHints field (compilation check) noHints := cfg.NoHints - // Default should be false (hints enabled by default) if noHints != false { t.Errorf("expected Config.NoHints default to be false, got: %v", noHints) } } func TestConfig_NoHintsFieldMutable(t *testing.T) { - // Verify NoHints field can be set cfg := cli.DefaultConfig() cfg.NoHints = true @@ -351,7 +325,6 @@ func TestConfig_NoHintsFieldMutable(t *testing.T) { } func TestDefaultConfig_NoHintsDefault(t *testing.T) { - // Verify DefaultConfig initializes NoHints to false cfg := cli.DefaultConfig() if cfg.NoHints != false { diff --git a/internal/interfaces/cli/root_test.go b/internal/interfaces/cli/root_test.go index 63057038..56304f1f 100644 --- a/internal/interfaces/cli/root_test.go +++ b/internal/interfaces/cli/root_test.go @@ -2,6 +2,8 @@ package cli_test import ( "bytes" + "os" + "path/filepath" "slices" "strings" "testing" @@ -43,13 +45,22 @@ func TestRootCommandHelp(t *testing.T) { } } -func TestVersionCommand(t *testing.T) { +func TestVersion_NewRootCommandExposesRootVersionOutputUsingVersionCommitAndBuildDate(t *testing.T) { + t.Cleanup(func() { + cli.Version = "dev" + cli.Commit = "unknown" + cli.BuildDate = "unknown" + }) + cli.Version = "1.2.3" + cli.Commit = "abc123" + cli.BuildDate = "2026-06-24" + cmd := cli.NewRootCommand() buf := new(bytes.Buffer) cmd.SetOut(buf) cmd.SetErr(buf) - cmd.SetArgs([]string{"version"}) + cmd.SetArgs([]string{"--version"}) err := cmd.Execute() if err != nil { @@ -57,13 +68,19 @@ func TestVersionCommand(t *testing.T) { } output := buf.String() - if !strings.Contains(output, "awf version") { - t.Errorf("expected version output, got: %s", output) + for _, expected := range []string{"awf version 1.2.3", "commit: abc123", "built: 2026-06-24"} { + if !strings.Contains(output, expected) { + t.Errorf("expected version output to contain %q, got: %s", expected, output) + } } } -func TestVersionCommandFlags(t *testing.T) { - // Set version info +func TestVersion_PrintsExactlyThreeMetadataLinesInRequiredStructure(t *testing.T) { + t.Cleanup(func() { + cli.Version = "dev" + cli.Commit = "unknown" + cli.BuildDate = "unknown" + }) cli.Version = "1.0.0" cli.Commit = "abc123" cli.BuildDate = "2024-01-01" @@ -72,32 +89,109 @@ func TestVersionCommandFlags(t *testing.T) { buf := new(bytes.Buffer) cmd.SetOut(buf) - cmd.SetArgs([]string{"version"}) + cmd.SetErr(buf) + cmd.SetArgs([]string{"--version"}) - _ = cmd.Execute() - output := buf.String() + err := cmd.Execute() + if err != nil { + t.Errorf("unexpected error: %v", err) + } - if !strings.Contains(output, "1.0.0") { - t.Errorf("expected version '1.0.0' in output: %s", output) + got := strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n") + want := []string{ + "awf version 1.0.0", + "commit: abc123", + "built: 2024-01-01", } - if !strings.Contains(output, "abc123") { - t.Errorf("expected commit 'abc123' in output: %s", output) + if !slices.Equal(got, want) { + t.Errorf("expected exact version lines %q, got %q", want, got) } } -func TestRootCommandHasVersionSubcommand(t *testing.T) { +func TestVersion_WorksWithPlaceholderDevelopmentMetadataAndKeepsThreeLineStructure(t *testing.T) { + t.Cleanup(func() { + cli.Version = "dev" + cli.Commit = "unknown" + cli.BuildDate = "unknown" + }) + cli.Version = "dev" + cli.Commit = "unknown" + cli.BuildDate = "unknown" + + cmd := cli.NewRootCommand() + + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"--version"}) + + err := cmd.Execute() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + got := strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n") + want := []string{ + "awf version dev", + "commit: unknown", + "built: unknown", + } + if !slices.Equal(got, want) { + t.Errorf("expected placeholder version lines %q, got %q", want, got) + } +} + +func TestVersion_DoesNotExecuteNormalPersistentPreRunBehaviorInitializeProjectStateOrPrintUnrelatedOutput(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", filepath.Join(tmpDir, "home")) + t.Setenv("XDG_DATA_HOME", filepath.Join(tmpDir, "data")) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "config")) + + historyDB := filepath.Join(tmpDir, "data", "awf", "history.db") + cmd, cleanup := cli.NewRootCommandAutoFacade() + defer cleanup() + + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"--version"}) + + err := cmd.Execute() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if _, statErr := os.Stat(historyDB); !os.IsNotExist(statErr) { + t.Fatalf("expected --version to skip facade-backed project state initialization, history db stat error: %v", statErr) + } + if strings.Contains(buf.String(), "NOTICE:") || strings.Contains(buf.String(), "Error:") { + t.Errorf("expected --version to avoid unrelated output, got: %s", buf.String()) + } +} + +func TestVersion_NewVersionCommandIsRemovedAndNoLongerRegistered(t *testing.T) { cmd := cli.NewRootCommand() - found := false for _, sub := range cmd.Commands() { if sub.Name() == "version" { - found = true - break + t.Fatal("expected root command not to register a 'version' subcommand") } } +} + +func TestVersion_AWFVersionIsNoLongerARegisteredSubcommand(t *testing.T) { + cmd := cli.NewRootCommand() - if !found { - t.Error("expected root command to have 'version' subcommand") + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"version"}) + + err := cmd.Execute() + if err == nil { + t.Fatalf("expected 'awf version' to be rejected, got output: %s", buf.String()) + } + if !strings.Contains(err.Error(), `unknown command "version"`) { + t.Fatalf("expected 'awf version' to fail as an unknown command, got error: %v", err) } } @@ -126,7 +220,7 @@ func TestRootRegistersACPServeCommand(t *testing.T) { func TestRootCommand_HasAllSubcommands(t *testing.T) { cmd := cli.NewRootCommand() - expectedCommands := []string{"version", "list", "run", "status", "validate", "diagram", "error"} + expectedCommands := []string{"list", "run", "status", "validate", "diagram", "error"} for _, expected := range expectedCommands { found := false @@ -211,7 +305,7 @@ func TestRootCommand_DiagramHelpAccessible(t *testing.T) { func TestRootCommand_GlobalFlags(t *testing.T) { cmd := cli.NewRootCommand() - flags := []string{"verbose", "quiet", "no-color", "log-level", "config", "storage"} + flags := []string{"verbose", "quiet", "no-color", "no-hints", "log-level", "config", "storage", "format"} for _, flag := range flags { if cmd.PersistentFlags().Lookup(flag) == nil { @@ -220,12 +314,84 @@ func TestRootCommand_GlobalFlags(t *testing.T) { } } -func TestRootCommand_VerboseShortFlag(t *testing.T) { +func TestRootCommand_VerboseFlagRemainsAvailableAsLongForm(t *testing.T) { cmd := cli.NewRootCommand() - flag := cmd.PersistentFlags().ShorthandLookup("v") + flag := cmd.PersistentFlags().Lookup("verbose") if flag == nil { - t.Error("expected -v shorthand for --verbose") + t.Error("expected --verbose flag to exist") + } +} + +func TestRootCommand_VerboseFlagNoLongerHasShorthandV(t *testing.T) { + cmd := cli.NewRootCommand() + + if flag := cmd.PersistentFlags().ShorthandLookup("v"); flag != nil { + t.Errorf("expected -v shorthand to be unassigned, got --%s", flag.Name) + } +} + +func TestRootCommand_VerboseHelpOutputNoLongerAdvertisesVerboseShorthand(t *testing.T) { + cmd := cli.NewRootCommand() + + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"--help"}) + + err := cmd.Execute() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if strings.Contains(buf.String(), "-v, --verbose") { + t.Errorf("expected help output not to advertise '-v, --verbose', got:\n%s", buf.String()) + } +} + +func TestRootCommand_VerboseRunningWithVDoesNotEnableVerboseModeAndIsRejectedOrReservedByCobra(t *testing.T) { + cmd := cli.NewRootCommand() + + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"-v", "--version"}) + + err := cmd.Execute() + output := buf.String() + if err == nil && !strings.Contains(output, "awf version") { + t.Fatalf("expected -v to be rejected or reserved by Cobra, got output: %s", output) + } + if strings.Contains(output, "Enable verbose output") { + t.Fatalf("expected -v not to enable or describe verbose mode, got output: %s", output) + } +} + +func TestRootCommand_GlobalFlagsOtherThanVerboseShorthandContinueToBeRegisteredAsBefore(t *testing.T) { + cmd := cli.NewRootCommand() + + tests := []struct { + name string + shorthand string + }{ + {name: "quiet", shorthand: "q"}, + {name: "format", shorthand: "f"}, + {name: "no-color"}, + {name: "no-hints"}, + {name: "log-level"}, + {name: "config"}, + {name: "storage"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flag := cmd.PersistentFlags().Lookup(tt.name) + if flag == nil { + t.Fatalf("expected global flag --%s to exist", tt.name) + } + if flag.Shorthand != tt.shorthand { + t.Errorf("expected --%s shorthand %q, got %q", tt.name, tt.shorthand, flag.Shorthand) + } + }) } } @@ -238,8 +404,6 @@ func TestRootCommand_QuietShortFlag(t *testing.T) { } } -// RED Phase: Test stubs for NewApp entry point - func TestNewApp_ReturnsNonNil(t *testing.T) { cfg := cli.DefaultConfig() app := cli.NewApp(cfg) diff --git a/internal/interfaces/cli/upgrade.go b/internal/interfaces/cli/upgrade.go index 39201b6f..f1b0c00c 100644 --- a/internal/interfaces/cli/upgrade.go +++ b/internal/interfaces/cli/upgrade.go @@ -16,16 +16,16 @@ import ( const upgradeOwnerRepo = "awf-project/cli" type upgradeOptions struct { - check bool - force bool - version string + check bool + force bool + targetVersion string } func newUpgradeCommand(cfg *Config) *cobra.Command { var opts upgradeOptions cmd := &cobra.Command{ - Use: "upgrade", + Use: "upgrade [version]", Short: "Upgrade AWF to the latest version", Long: `Check for and install AWF updates from GitHub releases. @@ -35,17 +35,19 @@ checksum, and replaces the current binary atomically. Examples: awf upgrade # Upgrade to latest version awf upgrade --check # Check without installing - awf upgrade --version v0.5.0 # Install specific version + awf upgrade v0.5.0 # Install specific version awf upgrade --force # Force upgrade (skip version check)`, - Args: cobra.NoArgs, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 { + opts.targetVersion = args[0] + } return runUpgrade(cmd, cfg, opts) }, } cmd.Flags().BoolVar(&opts.check, "check", false, "check for updates without installing") cmd.Flags().BoolVar(&opts.force, "force", false, "force upgrade even if already on latest") - cmd.Flags().StringVar(&opts.version, "version", "", "install a specific version (e.g. v0.5.0)") return cmd } @@ -57,6 +59,11 @@ func runUpgrade(cmd *cobra.Command, _ *Config, opts upgradeOptions) error { return fmt.Errorf("cannot determine current version (dev build); use --force to upgrade anyway") } + target, err := parseExactReleaseTarget(opts.targetVersion, true) + if err != nil { + return err + } + ctx := context.Background() doer := registry.NewGitHubAPIDoer(os.Getenv("GITHUB_API_URL"), http.DefaultClient) @@ -70,7 +77,7 @@ func runUpgrade(cmd *cobra.Command, _ *Config, opts upgradeOptions) error { return fmt.Errorf("no releases found for %s", upgradeOwnerRepo) } - release, err := selectTargetRelease(releases, opts.version) + release, err := selectExactRelease(releases, target, false) if err != nil { return err } @@ -92,7 +99,7 @@ func runUpgrade(cmd *cobra.Command, _ *Config, opts upgradeOptions) error { // isAlreadyUpToDate checks if the current version matches or exceeds the target. // Returns true with a message if no upgrade is needed, false otherwise. func isAlreadyUpToDate(cmd *cobra.Command, targetVersion, tagName string, opts upgradeOptions, isDevBuild bool) (upToDate bool, message string) { - if opts.force || opts.version != "" || isDevBuild { + if opts.force || opts.targetVersion != "" || isDevBuild { return false, "" } @@ -145,7 +152,7 @@ func downloadAndInstall(ctx context.Context, cmd *cobra.Command, release registr return fmt.Errorf("checksum for %q not found in checksum file", asset.Name) } - fmt.Fprintf(cmd.OutOrStdout(), "Downloading %s...\n", asset.Name) + fmt.Fprintf(cmd.OutOrStdout(), "Downloading %s\n", asset.Name) archiveData, err := registry.Download(ctx, http.DefaultClient, asset.DownloadURL) if err != nil { return fmt.Errorf("failed to download release: %w", err) @@ -216,25 +223,6 @@ func replaceBinaryAtExecPath(cmd *cobra.Command, newBinary []byte, tagName strin return nil } -func selectTargetRelease(releases []registry.Release, targetVersion string) (registry.Release, error) { - if targetVersion != "" { - normalized := registry.NormalizeTag(targetVersion) - for _, r := range releases { - if registry.NormalizeTag(r.TagName) == normalized { - return r, nil - } - } - return registry.Release{}, fmt.Errorf("version %s not found", targetVersion) - } - - for _, r := range releases { - if !r.Prerelease { - return r, nil - } - } - return registry.Release{}, fmt.Errorf("no stable releases found") -} - func checkWritePermission(dir string) error { tmpFile, err := os.CreateTemp(dir, ".awf-perm-check-*") if err != nil { diff --git a/internal/interfaces/cli/upgrade_test.go b/internal/interfaces/cli/upgrade_test.go index 6badbba9..2876924f 100644 --- a/internal/interfaces/cli/upgrade_test.go +++ b/internal/interfaces/cli/upgrade_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "os" + "sync/atomic" "testing" "github.com/awf-project/cli/pkg/registry" @@ -14,48 +15,216 @@ import ( "github.com/stretchr/testify/require" ) -func TestSelectTargetRelease_NoVersion_ReturnsFirstStable(t *testing.T) { +// Component: update_upgrade_version +// Acceptance source: .specify/implementation/F110/tasks/T060.md, "Acceptance". +// These cases cover the F110 upgrade grammar change from `awf upgrade --version ` +// to `awf upgrade [version]`, including red-state checks for removed flag parsing +// and exact SemVer validation before any release lookup. +func TestNewUpgradeCommand_UsesSyntaxEquivalentToAwfUpgradeVersionAndAcceptsZeroOrOnePositionalVersionArgument(t *testing.T) { + cmd := newUpgradeCommand(nil) + + assert.Equal(t, "upgrade [version]", cmd.Use) + require.NoError(t, cmd.Args(cmd, nil)) + require.NoError(t, cmd.Args(cmd, []string{"0.5.0"})) +} + +func TestNewUpgradeCommand_RejectsMoreThanOnePositionalArgumentThroughCobraMaximumNArgs(t *testing.T) { + cmd := newUpgradeCommand(nil) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"0.5.0", "0.6.0"}) + + err := cmd.Execute() + + require.Error(t, err) + assert.EqualError(t, err, "accepts at most 1 arg(s), received 2") +} + +func TestNewUpgradeCommand_NoLongerRegistersLocalVersionFlag(t *testing.T) { + cmd := newUpgradeCommand(nil) + + assert.Nil(t, cmd.Flags().Lookup("version")) +} + +func TestAwfUpgradeVersionFlagFailsDuringCobraFlagParsingWithoutReleaseResolution(t *testing.T) { + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + t.Setenv("GITHUB_API_URL", srv.URL) + + cmd := newUpgradeCommand(nil) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--version", "0.5.0"}) + + err := cmd.Execute() + + require.Error(t, err) + assert.EqualError(t, err, "unknown flag: --version") + assert.Equal(t, int32(0), requests.Load(), "removed --version flag must fail during Cobra parsing before release lookup") +} + +func TestRunUpgrade_SelectsLatestStableReleaseWhenNoPositionalVersionIsProvided(t *testing.T) { releases := []registry.Release{ {TagName: "v1.1.0-beta", Prerelease: true}, {TagName: "v1.0.0", Prerelease: false}, {TagName: "v0.9.0", Prerelease: false}, } - result, err := selectTargetRelease(releases, "") + target, err := parseExactReleaseTarget("", true) + require.NoError(t, err) + + result, err := selectExactRelease(releases, target, false) require.NoError(t, err) assert.Equal(t, "v1.0.0", result.TagName) } -func TestSelectTargetRelease_WithVersion_ReturnsMatchingRelease(t *testing.T) { +func TestRunUpgrade_AcceptsPositional050ValidatesExactSemVerAndSelectsExactlyReleaseV050(t *testing.T) { + origVersion := Version + Version = "0.4.0" + t.Cleanup(func() { Version = origVersion }) + + releases := []map[string]any{ + {"tag_name": "v0.6.0", "prerelease": false, "assets": []any{}}, + {"tag_name": "v0.5.0", "prerelease": false, "assets": []any{}}, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(releases) //nolint:errcheck // controlled test response + })) + defer srv.Close() + t.Setenv("GITHUB_API_URL", srv.URL) + + cmd := newUpgradeCommand(nil) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(os.Stderr) + + err := runUpgrade(cmd, nil, upgradeOptions{check: true, targetVersion: "0.5.0"}) + + require.NoError(t, err) + assert.Contains(t, out.String(), "v0.5.0") + assert.NotContains(t, out.String(), "v0.6.0") +} + +func TestRunUpgrade_AcceptsPositionalV050NormalizesItAndSelectsExactlyTheMatchingRelease(t *testing.T) { releases := []registry.Release{ {TagName: "v1.0.0", Prerelease: false}, - {TagName: "v0.9.0", Prerelease: false}, + {TagName: "v0.5.0", Prerelease: false}, } - result, err := selectTargetRelease(releases, "v0.9.0") + target, err := parseExactReleaseTarget("v0.5.0", true) + require.NoError(t, err) + + result, err := selectExactRelease(releases, target, false) require.NoError(t, err) - assert.Equal(t, "v0.9.0", result.TagName) + assert.Equal(t, "v0.5.0", result.TagName) } -func TestSelectTargetRelease_VersionNotFound_ReturnsError(t *testing.T) { +func TestUpgradeCommand_CheckModeAcceptsPositionalV050AndSelectsExactRelease(t *testing.T) { + origVersion := Version + Version = "0.4.0" + t.Cleanup(func() { Version = origVersion }) + + releases := []map[string]any{ + {"tag_name": "v0.6.0", "prerelease": false, "assets": []any{}}, + {"tag_name": "v0.5.0", "prerelease": false, "assets": []any{}}, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(releases) //nolint:errcheck // controlled test response + })) + defer srv.Close() + t.Setenv("GITHUB_API_URL", srv.URL) + + cmd := newUpgradeCommand(nil) + var out bytes.Buffer + var errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs([]string{"--check", "v0.5.0"}) + + err := cmd.Execute() + + require.NoError(t, err) + assert.Contains(t, out.String(), "v0.5.0") + assert.NotContains(t, out.String(), "v0.6.0") +} + +func TestRunUpgrade_RejectsPositionalLatestBeforeReleaseLookup(t *testing.T) { + origVersion := Version + Version = "0.4.0" + t.Cleanup(func() { Version = origVersion }) + + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + t.Setenv("GITHUB_API_URL", srv.URL) + + cmd := newUpgradeCommand(nil) + + err := runUpgrade(cmd, nil, upgradeOptions{targetVersion: "latest"}) + + require.Error(t, err) + assert.EqualError(t, err, `invalid release version "latest": version: invalid format "latest"`) + assert.Equal(t, int32(0), requests.Load(), "invalid explicit version must fail before release lookup") +} + +func TestRunUpgrade_RejectsPositionalRangeBeforeReleaseLookup(t *testing.T) { + origVersion := Version + Version = "0.4.0" + t.Cleanup(func() { Version = origVersion }) + + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + t.Setenv("GITHUB_API_URL", srv.URL) + + cmd := newUpgradeCommand(nil) + + err := runUpgrade(cmd, nil, upgradeOptions{targetVersion: ">=0.5.0"}) + + require.Error(t, err) + assert.EqualError(t, err, `invalid release version ">=0.5.0": version: invalid format ">=0.5.0"`) + assert.Equal(t, int32(0), requests.Load(), "range constraints must fail before release lookup") +} + +func TestRunUpgrade_ReturnsReleaseVersion050NotFoundWhenValidSemVerIsAbsentFromReleaseList(t *testing.T) { releases := []registry.Release{ {TagName: "v1.0.0", Prerelease: false}, } - _, err := selectTargetRelease(releases, "v2.0.0") + target, err := parseExactReleaseTarget("0.5.0", true) + require.NoError(t, err) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not found") + _, err = selectExactRelease(releases, target, false) + + require.Error(t, err) + assert.Contains(t, err.Error(), "release version 0.5.0 not found") } -func TestSelectTargetRelease_NoStableReleases_ReturnsError(t *testing.T) { +func TestSelectExactRelease_NoStableReleases_ReturnsError(t *testing.T) { releases := []registry.Release{ {TagName: "v1.0.0-beta", Prerelease: true}, } - _, err := selectTargetRelease(releases, "") + target, err := parseExactReleaseTarget("", true) + require.NoError(t, err) + + _, err = selectExactRelease(releases, target, false) assert.Error(t, err) assert.Contains(t, err.Error(), "no stable releases") @@ -182,8 +351,7 @@ func TestRunUpgrade_VersionInstall_DownloadsAndInstalls(t *testing.T) { cmd.SetOut(&out) cmd.SetErr(&errOut) - // Test with --version flag to select a specific version - err := runUpgrade(cmd, nil, upgradeOptions{version: "v0.9.0"}) + err := runUpgrade(cmd, nil, upgradeOptions{targetVersion: "v0.9.0"}) // We expect an error due to invalid tar.gz, but it should have attempted the install assert.Error(t, err) @@ -394,7 +562,7 @@ func TestRunUpgrade_NoCompatibleAsset_ReturnsError(t *testing.T) { assert.Contains(t, err.Error(), "no compatible binary found") } -func TestSelectTargetRelease_MultipleStableReleases_ReturnsLatestStable(t *testing.T) { +func TestSelectExactRelease_MultipleStableReleases_ReturnsLatestStable(t *testing.T) { releases := []registry.Release{ {TagName: "v0.5.0", Prerelease: false}, {TagName: "v1.0.0", Prerelease: false}, @@ -402,10 +570,13 @@ func TestSelectTargetRelease_MultipleStableReleases_ReturnsLatestStable(t *testi {TagName: "v2.0.0-beta", Prerelease: true}, } - result, err := selectTargetRelease(releases, "") + target, err := parseExactReleaseTarget("", true) + require.NoError(t, err) + + result, err := selectExactRelease(releases, target, false) require.NoError(t, err) - assert.Equal(t, "v0.5.0", result.TagName) + assert.Equal(t, "v1.0.0", result.TagName) } func TestCheckWritePermission_TempFileCleanup(t *testing.T) { diff --git a/internal/interfaces/cli/workflow_cmd.go b/internal/interfaces/cli/workflow_cmd.go index bd51ec55..f64ef470 100644 --- a/internal/interfaces/cli/workflow_cmd.go +++ b/internal/interfaces/cli/workflow_cmd.go @@ -286,16 +286,14 @@ func runWorkflowInfo(cmd *cobra.Command, _ *Config, packName string) error { } } - // Emit plugin dependency warnings (non-blocking) emitPluginWarnings(cmd, packDir, nil) return nil } type workflowInstallFlags struct { - version string - global bool - force bool + global bool + force bool } func newWorkflowInstallCommand(cfg *Config) *cobra.Command { @@ -320,26 +318,12 @@ Examples: }, } - cmd.Flags().StringVar(&flags.version, "version", "", "version constraint (e.g. \">=1.0.0 <2.0.0\")") cmd.Flags().BoolVar(&flags.global, "global", false, "install to global XDG data directory") cmd.Flags().BoolVar(&flags.force, "force", false, "overwrite existing installation") return cmd } -// parseOwnerRepoAndVersion parses owner/repo[@ version] format. -func parseOwnerRepoAndVersion(source, versionFlag string) (ownerRepo, version string) { - ownerRepo = source - version = versionFlag - - if at := strings.LastIndex(source, "@"); at >= 0 { - ownerRepo = source[:at] - version = source[at+1:] - } - - return ownerRepo, version -} - // extractPackName extracts pack name from repository name (removes awf-workflow- prefix). func extractPackName(ownerRepo string) string { packName := ownerRepo @@ -349,11 +333,11 @@ func extractPackName(ownerRepo string) string { return strings.TrimPrefix(packName, "awf-workflow-") } -// effectiveCLIVersion returns Version, substituting a valid semver for "dev" builds +// effectiveCLIVersion returns Version, substituting a valid semver for "dev" and dirty builds // so version constraint checks (packs, plugins) work correctly outside production releases. // Also handles non-parseable git describe output (e.g., "v0.8.1-3-gabcdef-dirty"). func effectiveCLIVersion() string { - if strings.HasPrefix(Version, "dev") { + if strings.HasPrefix(Version, "dev") || strings.Contains(Version, "dirty") { return "999.0.0" } if _, err := registry.ParseVersion(Version); err != nil { @@ -401,15 +385,17 @@ func findChecksumInRelease(ctx context.Context, assets []registry.Asset, assetNa } func runWorkflowInstall(cmd *cobra.Command, _ *Config, source string, flags workflowInstallFlags) error { - // Parse owner/repo and optional @version - ownerRepo, versionConstraint := parseOwnerRepoAndVersion(source, flags.version) - - if err := registry.ValidateOwnerRepo(ownerRepo); err != nil { + ownerRepo, target, err := parseInstallReleaseTarget(source) + if err != nil { return err } + if validateErr := registry.ValidateOwnerRepo(ownerRepo); validateErr != nil { + return validateErr + } + if !flags.global { - if _, err := os.Stat(".awf"); os.IsNotExist(err) { + if _, statErr := os.Stat(".awf"); os.IsNotExist(statErr) { return fmt.Errorf("not in an awf project (run awf init first)") } } @@ -418,48 +404,31 @@ func runWorkflowInstall(cmd *cobra.Command, _ *Config, source string, flags work ctx := context.Background() fmt.Fprintf(cmd.OutOrStdout(), "Fetching releases for %s...\n", ownerRepo) - resolvedVersion, err := githubClient.ResolveVersion(ctx, ownerRepo, versionConstraint, false) - if err != nil { - return fmt.Errorf("resolve version: %w", err) - } - fmt.Fprintf(cmd.OutOrStdout(), "Resolved version: %s\n", resolvedVersion.String()) - - // List releases to find the matching one releases, err := githubClient.ListReleases(ctx, ownerRepo) if err != nil { return fmt.Errorf("list releases: %w", err) } - var matchingRelease *registry.Release - for _, rel := range releases { - tagVersion := registry.NormalizeTag(rel.TagName) - v, parseErr := registry.ParseVersion(tagVersion) - if parseErr != nil { - continue - } - if v.Compare(resolvedVersion) == 0 { - matchingRelease = &rel - break - } + matchingRelease, err := selectExactRelease(releases, target, false) + if err != nil { + return fmt.Errorf("resolve version: %w", err) } - - if matchingRelease == nil { - return fmt.Errorf("release for version %s not found", resolvedVersion.String()) + resolvedVersion, err := registry.ParseVersion(registry.NormalizeTag(matchingRelease.TagName)) + if err != nil { + return fmt.Errorf("parse resolved version: %w", err) } + fmt.Fprintf(cmd.OutOrStdout(), "Resolved version: %s\n", resolvedVersion.String()) - // Find the pack archive asset (workflow packs are platform-independent — single .tar.gz) asset, err := findPackAsset(matchingRelease.Assets) if err != nil { return err } - // Get checksum for the asset checksum, err := findChecksumInRelease(ctx, matchingRelease.Assets, asset.Name) if err != nil { return err } - // Determine target pack name and directory packName := extractPackName(ownerRepo) var targetDir string if flags.global { @@ -470,7 +439,6 @@ func runWorkflowInstall(cmd *cobra.Command, _ *Config, source string, flags work installer := workflowpkg.NewPackInstaller(effectiveCLIVersion()) - // Install the pack packSource := workflowpkg.PackSource{ Repository: ownerRepo, Version: resolvedVersion.String(), @@ -481,7 +449,6 @@ func runWorkflowInstall(cmd *cobra.Command, _ *Config, source string, flags work return fmt.Errorf("install workflow pack: %w", err) } - // Emit plugin dependency warnings (non-blocking) emitPluginWarnings(cmd, targetDir, nil) fmt.Fprintf(cmd.OutOrStdout(), "Installed workflow pack %q v%s to %s\n", packName, resolvedVersion.String(), targetDir) @@ -666,7 +633,6 @@ func updateSinglePack(ctx context.Context, cmd *cobra.Command, githubClient *reg return err } - // Get checksum for the asset checksum, err := findChecksumInRelease(ctx, newerRelease.Assets, asset.Name) if err != nil { return err @@ -687,7 +653,6 @@ func updateSinglePack(ctx context.Context, cmd *cobra.Command, githubClient *reg return fmt.Errorf("update workflow pack: %w", err) } - // Emit plugin dependency warnings (non-blocking) emitPluginWarnings(cmd, packDir, nil) fmt.Fprintf(cmd.OutOrStdout(), "Updated %s from %s to %s\n", packName, source.Version, newerVersion.String()) diff --git a/internal/interfaces/cli/workflow_cmd_test.go b/internal/interfaces/cli/workflow_cmd_test.go index 66b37892..14cc69a4 100644 --- a/internal/interfaces/cli/workflow_cmd_test.go +++ b/internal/interfaces/cli/workflow_cmd_test.go @@ -75,9 +75,9 @@ func TestWorkflowInstall_ValidRepoWithVersion(t *testing.T) { t.Setenv("GITHUB_API_URL", server.URL) cfg := &Config{} - flags := workflowInstallFlags{version: "1.2.0", global: false, force: false} + flags := workflowInstallFlags{global: false, force: false} - err = runWorkflowInstall(&cobra.Command{}, cfg, "org/awf-workflow-speckit", flags) + err = runWorkflowInstall(&cobra.Command{}, cfg, "org/awf-workflow-speckit@1.2.0", flags) require.NoError(t, err, "should install workflow pack successfully") } @@ -92,9 +92,9 @@ func TestWorkflowInstall_GlobalFlag(t *testing.T) { }) cfg := &Config{} - flags := workflowInstallFlags{version: "1.0.0", global: true, force: false} + flags := workflowInstallFlags{global: true, force: false} - err := runWorkflowInstall(&cobra.Command{}, cfg, "org/awf-workflow-speckit", flags) + err := runWorkflowInstall(&cobra.Command{}, cfg, "org/awf-workflow-speckit@1.0.0", flags) // Global flag prevents .awf/ project check — error comes from GitHub client, not project validation. require.Error(t, err) @@ -112,9 +112,9 @@ func TestWorkflowInstall_MissingProjectDir(t *testing.T) { require.NoError(t, os.Chdir(tmpDir)) cfg := &Config{} - flags := workflowInstallFlags{version: "1.0.0", global: false, force: false} + flags := workflowInstallFlags{global: false, force: false} - err = runWorkflowInstall(&cobra.Command{}, cfg, "org/awf-workflow-speckit", flags) + err = runWorkflowInstall(&cobra.Command{}, cfg, "org/awf-workflow-speckit@1.0.0", flags) require.Error(t, err, "should error when .awf/ missing and not global") } @@ -133,7 +133,7 @@ func TestWorkflowInstall_InvalidRepoFormat(t *testing.T) { require.NoError(t, os.Chdir(projDir)) cfg := &Config{} - flags := workflowInstallFlags{version: "", global: false, force: false} + flags := workflowInstallFlags{global: false, force: false} err = runWorkflowInstall(&cobra.Command{}, cfg, "invalid-format", flags) @@ -158,9 +158,9 @@ func TestWorkflowInstall_ForceReplaces(t *testing.T) { require.NoError(t, os.Chdir(projDir)) cfg := &Config{} - flags := workflowInstallFlags{version: "1.0.0", global: false, force: true} + flags := workflowInstallFlags{global: false, force: true} - err = runWorkflowInstall(&cobra.Command{}, cfg, "org/awf-workflow-speckit", flags) + err = runWorkflowInstall(&cobra.Command{}, cfg, "org/awf-workflow-speckit@1.0.0", flags) // With --force, the pack directory check is bypassed and the error comes from // the GitHub client (no mock server), not from the existing pack directory. @@ -768,7 +768,7 @@ func TestFindPackDir_ShortAndLongName(t *testing.T) { } // TestEffectiveCLIVersion_DevPrefix verifies effectiveCLIVersion returns "999.0.0" -// for any version string starting with "dev" and the verbatim version otherwise. +// for dev/dirty builds and the verbatim version otherwise. func TestEffectiveCLIVersion_DevPrefix(t *testing.T) { tests := []struct { name string @@ -781,7 +781,7 @@ func TestEffectiveCLIVersion_DevPrefix(t *testing.T) { {"real version", "1.2.3", "1.2.3"}, {"pre-release", "1.0.0-rc1", "1.0.0-rc1"}, {"v-prefixed version", "v0.8.1", "v0.8.1"}, - {"v-prefixed dirty", "v0.8.1-dirty", "v0.8.1-dirty"}, + {"v-prefixed dirty", "v0.8.1-dirty", "999.0.0"}, {"git describe with commits", "v0.8.1-3-gabcdef", "999.0.0"}, {"git describe dirty with commits", "v0.8.1-3-gabcdef-dirty", "999.0.0"}, } @@ -956,9 +956,9 @@ func TestRunWorkflowInstall_ProgressMessages(t *testing.T) { cmd.SetErr(&bytes.Buffer{}) cfg := &Config{} - flags := workflowInstallFlags{version: "1.0.0", global: false, force: false} + flags := workflowInstallFlags{global: false, force: false} - err = runWorkflowInstall(cmd, cfg, "org/awf-workflow-msgpack", flags) + err = runWorkflowInstall(cmd, cfg, "org/awf-workflow-msgpack@1.0.0", flags) require.NoError(t, err) output := outBuf.String() diff --git a/tests/integration/cli/plugin_install_test.go b/tests/integration/cli/plugin_install_test.go index 04c3745c..3b75fd79 100644 --- a/tests/integration/cli/plugin_install_test.go +++ b/tests/integration/cli/plugin_install_test.go @@ -16,6 +16,7 @@ import ( "strings" "testing" + infrastructurePlugin "github.com/awf-project/cli/internal/infrastructure/pluginmgr" "github.com/awf-project/cli/internal/interfaces/cli" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -388,6 +389,16 @@ func seedPluginSourceData(t *testing.T, storagePath, pluginName, ownerRepo, vers )) } +func readPluginSourceData(t *testing.T, storagePath, pluginName string) map[string]any { + t.Helper() + + store := infrastructurePlugin.NewJSONPluginStateStore(filepath.Join(storagePath, "plugins")) + require.NoError(t, store.Load(t.Context())) + data := store.GetSourceData(pluginName) + require.NotNil(t, data, "source metadata should be persisted for installed plugin") + return data +} + // TestPluginUpdate_SuccessfulUpdate validates complete update workflow. // Covers: Fetches newer version, verifies checksum, atomically replaces. func TestPluginUpdate_SuccessfulUpdate(t *testing.T) { @@ -532,25 +543,25 @@ func TestPluginSearch_SuccessfulSearch(t *testing.T) { assert.Contains(t, output, "github", "results should include github plugin") } -// TestPluginInstall_VersionConstraints validates --version flag version resolution. -func TestPluginInstall_VersionConstraints(t *testing.T) { +// TestPluginInstall_Version validates positional exact version resolution. +func TestPluginInstall_Version(t *testing.T) { tests := []struct { - name string - constraint string - releaseTag string - shouldPass bool + name string + source string + releaseTag string + wantVersion string }{ { - name: "exact version match", - constraint: "v1.0.0", - releaseTag: "v1.0.0", - shouldPass: true, + name: "bare exact version suffix", + source: "testorg/awf-plugin-test-plugin@1.0.0", + releaseTag: "v1.0.0", + wantVersion: "v1.0.0", }, { - name: "semver range", - constraint: ">=1.0.0 <2.0.0", - releaseTag: "v1.5.0", - shouldPass: true, + name: "v-prefixed exact version suffix", + source: "testorg/awf-plugin-test-plugin@v1.5.0", + releaseTag: "v1.5.0", + wantVersion: "v1.5.0", }, } @@ -558,10 +569,10 @@ func TestPluginInstall_VersionConstraints(t *testing.T) { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() basePluginsDir := filepath.Join(tmpDir, "plugins") + storagePath := filepath.Join(tmpDir, "storage") require.NoError(t, os.MkdirAll(basePluginsDir, 0o755)) t.Setenv("AWF_PLUGINS_PATH", basePluginsDir) - // Derive version string (strip leading "v" for asset filename) versionStr := strings.TrimPrefix(tt.releaseTag, "v") assetName := platformAssetName("test-plugin", versionStr) tarball := createTestPluginTarball(t, "test-plugin") @@ -606,13 +617,8 @@ func TestPluginInstall_VersionConstraints(t *testing.T) { t.Setenv("GITHUB_API_URL", server.URL) - args := []string{"plugin", "install", "testorg/awf-plugin-test-plugin"} - if tt.constraint != "" { - args = append(args, "--version", tt.constraint) - } - cmd := cli.NewRootCommand() - cmd.SetArgs(args) + cmd.SetArgs([]string{"--storage", storagePath, "plugin", "install", tt.source}) var out bytes.Buffer cmd.SetOut(&out) @@ -620,16 +626,137 @@ func TestPluginInstall_VersionConstraints(t *testing.T) { err := cmd.Execute() - if tt.shouldPass { - assert.NoError(t, err, "install with version constraint should succeed") - pluginDir := filepath.Join(basePluginsDir, "test-plugin") - _, err := os.Stat(pluginDir) - assert.NoError(t, err, "plugin directory should exist") - } + assert.NoError(t, err, "install with exact version suffix should succeed") + assert.Contains(t, out.String(), tt.releaseTag, "output should show selected release tag") + pluginDir := filepath.Join(basePluginsDir, "test-plugin") + _, err = os.Stat(pluginDir) + assert.NoError(t, err, "plugin directory should exist") + + sourceData := readPluginSourceData(t, storagePath, "test-plugin") + assert.Equal(t, "testorg/awf-plugin-test-plugin", sourceData["repository"]) + assert.Equal(t, tt.wantVersion, sourceData["version"]) }) } } +func TestPluginInstall_UnversionedInstallPersistsLatestStableWhenPrereleaseExists(t *testing.T) { + tmpDir := t.TempDir() + basePluginsDir := filepath.Join(tmpDir, "plugins") + storagePath := filepath.Join(tmpDir, "storage") + require.NoError(t, os.MkdirAll(basePluginsDir, 0o755)) + t.Setenv("AWF_PLUGINS_PATH", basePluginsDir) + + const stableVersion = "1.9.0" + const prereleaseVersion = "2.0.0-beta.1" + stableAssetName := platformAssetName("test-plugin", stableVersion) + prereleaseAssetName := platformAssetName("test-plugin", prereleaseVersion) + tarball := createTestPluginTarball(t, "test-plugin") + checksum := createTestSHA256File(t, tarball, prereleaseAssetName) + "\n" + + createTestSHA256File(t, tarball, stableAssetName) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/releases") { + releases := []map[string]interface{}{ + { + "tag_name": "v" + prereleaseVersion, + "prerelease": true, + "assets": []map[string]interface{}{ + { + "name": prereleaseAssetName, + "browser_download_url": "http://" + r.Host + "/downloads/" + prereleaseAssetName, + }, + { + "name": "checksums.txt", + "browser_download_url": "http://" + r.Host + "/downloads/checksums.txt", + }, + }, + }, + { + "tag_name": "v" + stableVersion, + "assets": []map[string]interface{}{ + { + "name": stableAssetName, + "browser_download_url": "http://" + r.Host + "/downloads/" + stableAssetName, + }, + { + "name": "checksums.txt", + "browser_download_url": "http://" + r.Host + "/downloads/checksums.txt", + }, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(releases) //nolint:errcheck // test fixture response + return + } + + if strings.Contains(r.URL.Path, "/downloads/"+stableAssetName) || strings.Contains(r.URL.Path, "/downloads/"+prereleaseAssetName) { + w.Header().Set("Content-Type", "application/gzip") + w.Write(tarball) //nolint:errcheck // test fixture response + return + } + + if strings.Contains(r.URL.Path, "/downloads/checksums.txt") { + w.Write([]byte(checksum)) //nolint:errcheck // test fixture response + return + } + + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + t.Setenv("GITHUB_API_URL", server.URL) + + cmd := cli.NewRootCommand() + cmd.SetArgs([]string{"--storage", storagePath, "plugin", "install", "testorg/awf-plugin-test-plugin"}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + err := cmd.Execute() + + assert.NoError(t, err, "unversioned install should select latest stable release without --pre-release") + assert.Contains(t, out.String(), "v"+stableVersion, "output should show selected stable release") + assert.NotContains(t, out.String(), prereleaseVersion, "output should not show skipped prerelease") + + pluginDir := filepath.Join(basePluginsDir, "test-plugin") + _, err = os.Stat(pluginDir) + assert.NoError(t, err, "plugin directory should exist after stable install") + + sourceData := readPluginSourceData(t, storagePath, "test-plugin") + assert.Equal(t, "testorg/awf-plugin-test-plugin", sourceData["repository"]) + assert.Equal(t, "v"+stableVersion, sourceData["version"]) +} + +func TestPluginInstall_VersionFlagRejectedBeforeNetworkResolution(t *testing.T) { + tmpDir := t.TempDir() + basePluginsDir := filepath.Join(tmpDir, "plugins") + require.NoError(t, os.MkdirAll(basePluginsDir, 0o755)) + t.Setenv("AWF_PLUGINS_PATH", basePluginsDir) + + var releaseHits int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + releaseHits++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + t.Setenv("GITHUB_API_URL", server.URL) + + cmd := cli.NewRootCommand() + cmd.SetArgs([]string{"plugin", "install", "testorg/awf-plugin-test-plugin", "--version", "1.2.3"}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown flag: --version") + assert.Zero(t, releaseHits) +} + // TestPluginInstall_PrereleaseFlag validates --pre-release flag includes alpha/beta/rc versions. func TestPluginInstall_PrereleaseFlag(t *testing.T) { tmpDir := t.TempDir() diff --git a/tests/integration/cli/release_target_syntax_test.go b/tests/integration/cli/release_target_syntax_test.go new file mode 100644 index 00000000..b2f8fe6e --- /dev/null +++ b/tests/integration/cli/release_target_syntax_test.go @@ -0,0 +1,163 @@ +//go:build integration + +package cli_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/awf-project/cli/internal/interfaces/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Feature: F110 + +func TestRootVersionOutputAndLegacyVersionCommand_Integration(t *testing.T) { + cmd := cli.NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--version"}) + + err := cmd.Execute() + require.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + require.Len(t, lines, 3) + assert.Regexp(t, `^awf version .+`, lines[0]) + assert.Regexp(t, `^commit: .+`, lines[1]) + assert.Regexp(t, `^built: .+`, lines[2]) + + cmd = cli.NewRootCommand() + out.Reset() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"version"}) + + err = cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), `unknown command "version"`) +} + +func TestVerboseShorthandIsRejected_Integration(t *testing.T) { + cmd := cli.NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"-v", "--version"}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown shorthand flag: 'v'") +} + +func TestUpgradePositionalVersionCheck_Integration(t *testing.T) { + originalVersion := cli.Version + cli.Version = "0.4.0" + t.Cleanup(func() { cli.Version = originalVersion }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Contains(t, r.URL.Path, "/releases") + releases := []map[string]any{ + {"tag_name": "v1.0.0", "prerelease": false}, + {"tag_name": "v0.5.0", "prerelease": false}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(releases) //nolint:errcheck // controlled test response + })) + defer server.Close() + + t.Setenv("GITHUB_API_URL", server.URL) + + cmd := cli.NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"upgrade", "0.5.0", "--check"}) + + err := cmd.Execute() + require.NoError(t, err) + assert.Contains(t, out.String(), "Update available: v0.5.0") + assert.NotContains(t, out.String(), "v1.0.0") +} + +func TestUpgradeRemovedVersionFlagDoesNotResolveReleases_Integration(t *testing.T) { + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + t.Setenv("GITHUB_API_URL", server.URL) + + cmd := cli.NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"upgrade", "--version", "0.5.0"}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown flag: --version") + assert.Zero(t, hits.Load()) +} + +func TestInstallCommandsRejectInvalidVersionSuffixBeforeReleaseLookup_Integration(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "plugin latest", + args: []string{"plugin", "install", "testorg/awf-plugin-test-plugin@latest"}, + wantErr: `invalid release version "latest": version: invalid format "latest"`, + }, + { + name: "plugin range", + args: []string{"plugin", "install", "testorg/awf-plugin-test-plugin@>=1.0.0"}, + wantErr: `invalid release version ">=1.0.0": version: invalid format ">=1.0.0"`, + }, + { + name: "workflow empty suffix", + args: []string{"workflow", "install", "testorg/awf-workflow-speckit@"}, + wantErr: `invalid release version "": version: empty string`, + }, + { + name: "workflow range", + args: []string{"workflow", "install", "testorg/awf-workflow-speckit@>=1.0.0"}, + wantErr: `invalid release version ">=1.0.0": version: invalid format ">=1.0.0"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + t.Setenv("GITHUB_API_URL", server.URL) + + cmd := cli.NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(tt.args) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + assert.Zero(t, hits.Load()) + }) + } +} diff --git a/tests/integration/cli/workflow_install_test.go b/tests/integration/cli/workflow_install_test.go index 67361e0c..59477724 100644 --- a/tests/integration/cli/workflow_install_test.go +++ b/tests/integration/cli/workflow_install_test.go @@ -13,8 +13,10 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "testing" + "github.com/awf-project/cli/internal/infrastructure/workflowpkg" "github.com/awf-project/cli/internal/interfaces/cli" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -39,6 +41,20 @@ func createTestWorkflowSHA256File(t *testing.T, tarballContent []byte, assetName return fmt.Sprintf("%s %s", hexHash, assetName) } +func readWorkflowPackSource(t *testing.T, packDir string) *workflowpkg.PackSource { + t.Helper() + + stateData, err := os.ReadFile(filepath.Join(packDir, "state.json")) + require.NoError(t, err) + + var state workflowpkg.PackState + require.NoError(t, json.Unmarshal(stateData, &state)) + + source, err := workflowpkg.PackSourceFromSourceData(state.SourceData) + require.NoError(t, err) + return source +} + // TestWorkflowSubcommands_Exist verifies install/remove subcommands exist. func TestWorkflowSubcommands_Exist(t *testing.T) { subcommands := []string{"install", "remove"} @@ -61,6 +77,31 @@ func TestWorkflowSubcommands_Exist(t *testing.T) { } } +func TestWorkflowInstall_CommandSyntaxAndFlags(t *testing.T) { + root := cli.NewRootCommand() + workflowCmd, _, err := root.Find([]string{"workflow"}) + require.NoError(t, err) + + var installCmdFound bool + for _, sub := range workflowCmd.Commands() { + if sub.Name() != "install" { + continue + } + + installCmdFound = true + + t.Run("newWorkflowInstallCommand keeps syntax equivalent to install owner repo version", func(t *testing.T) { + assert.Equal(t, "install ", sub.Use) + }) + + t.Run("newWorkflowInstallCommand no longer registers a local version flag", func(t *testing.T) { + assert.Nil(t, sub.Flags().Lookup("version")) + }) + } + + require.True(t, installCmdFound, "workflow install command should exist") +} + // TestWorkflowInstall_SuccessfulLocalInstall validates basic local installation workflow. // Covers: Downloads from GitHub, verifies checksum, extracts, validates manifest, and atomically installs. func TestWorkflowInstall_SuccessfulLocalInstall(t *testing.T) { @@ -83,7 +124,7 @@ func TestWorkflowInstall_SuccessfulLocalInstall(t *testing.T) { require.NoError(t, os.Chdir(projDir)) - const version = "1.0.0" + const version = "1.2.0" assetName := workflowAssetName("speckit", version) tarball := createTestWorkflowTarball(t, "speckit") checksumData := createTestWorkflowSHA256File(t, tarball, assetName) @@ -91,6 +132,15 @@ func TestWorkflowInstall_SuccessfulLocalInstall(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/releases") { releases := []map[string]interface{}{ + { + "tag_name": "v0.9.0", + "assets": []map[string]interface{}{ + { + "name": workflowAssetName("speckit", "0.9.0"), + "browser_download_url": "http://" + r.Host + "/downloads/" + workflowAssetName("speckit", "0.9.0"), + }, + }, + }, { "tag_name": "v" + version, "assets": []map[string]interface{}{ @@ -151,6 +201,10 @@ func TestWorkflowInstall_SuccessfulLocalInstall(t *testing.T) { _, err = os.Stat(statePath) assert.NoError(t, err, "state.json should exist in pack directory") + source := readWorkflowPackSource(t, packDir) + assert.Equal(t, "testorg/awf-workflow-speckit", source.Repository) + assert.Equal(t, version, source.Version) + // Verify NFR-002: .awf/workflows and .awf/plugins remain untouched workflowContent, err := os.ReadFile(filepath.Join(workflowsDir, "existing.yaml")) require.NoError(t, err) @@ -235,94 +289,117 @@ func TestWorkflowInstall_SuccessfulGlobalInstall(t *testing.T) { // TestWorkflowInstall_WithVersionConstraint validates installation with explicit version. // Covers: Parses @version suffix, resolves correct release, installs exact version. -func TestWorkflowInstall_WithVersionConstraint(t *testing.T) { - tmpDir := t.TempDir() - projDir := filepath.Join(tmpDir, "project") - require.NoError(t, os.Mkdir(projDir, 0o755)) - require.NoError(t, os.Mkdir(filepath.Join(projDir, ".awf"), 0o755)) +func TestWorkflowInstall_WithVersion(t *testing.T) { + tests := []struct { + name string + source string + expectedVersion string + }{ + { + name: "Workflow install parses owner repo 1.2.3 validates exact SemVer before release lookup and selects exactly version v1.2.3 or equivalent normalized tag", + source: "testorg/awf-workflow-speckit@1.2.3", + expectedVersion: "1.2.3", + }, + { + name: "Workflow install parses owner repo v1.2.3 accepts the prefix and persists uses the normalized selected release version consistently with existing pack metadata behavior", + source: "testorg/awf-workflow-speckit@v1.2.3", + expectedVersion: "1.2.3", + }, + } - origWd, err := os.Getwd() - require.NoError(t, err) - t.Cleanup(func() { os.Chdir(origWd) }) //nolint:errcheck // restore working directory in cleanup + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + projDir := filepath.Join(tmpDir, "project") + require.NoError(t, os.Mkdir(projDir, 0o755)) + require.NoError(t, os.Mkdir(filepath.Join(projDir, ".awf"), 0o755)) - require.NoError(t, os.Chdir(projDir)) + origWd, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { os.Chdir(origWd) }) //nolint:errcheck // restore working directory in cleanup - const requestedVersion = "2.0.0" - assetName := workflowAssetName("speckit", requestedVersion) - tarball := createTestWorkflowTarball(t, "speckit") - checksumData := createTestWorkflowSHA256File(t, tarball, assetName) + require.NoError(t, os.Chdir(projDir)) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.Contains(r.URL.Path, "/releases") { - // API returns multiple versions - releases := []map[string]interface{}{ - { - "tag_name": "v3.0.0", - "assets": []map[string]interface{}{ + assetName := workflowAssetName("speckit", tt.expectedVersion) + tarball := createTestWorkflowTarball(t, "speckit") + checksumData := createTestWorkflowSHA256File(t, tarball, assetName) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/releases") { + releases := []map[string]interface{}{ { - "name": workflowAssetName("speckit", "3.0.0"), - "browser_download_url": "http://" + r.Host + "/downloads/" + workflowAssetName("speckit", "3.0.0"), + "tag_name": "v3.0.0", + "assets": []map[string]interface{}{ + { + "name": workflowAssetName("speckit", "3.0.0"), + "browser_download_url": "http://" + r.Host + "/downloads/" + workflowAssetName("speckit", "3.0.0"), + }, + }, }, - }, - }, - { - "tag_name": "v" + requestedVersion, - "assets": []map[string]interface{}{ { - "name": assetName, - "browser_download_url": "http://" + r.Host + "/downloads/" + assetName, + "tag_name": "v" + tt.expectedVersion, + "assets": []map[string]interface{}{ + { + "name": assetName, + "browser_download_url": "http://" + r.Host + "/downloads/" + assetName, + }, + { + "name": "checksums.txt", + "browser_download_url": "http://" + r.Host + "/downloads/checksums.txt", + }, + }, }, { - "name": "checksums.txt", - "browser_download_url": "http://" + r.Host + "/downloads/checksums.txt", + "tag_name": "v1.0.0", + "assets": []map[string]interface{}{ + { + "name": workflowAssetName("speckit", "1.0.0"), + "browser_download_url": "http://" + r.Host + "/downloads/" + workflowAssetName("speckit", "1.0.0"), + }, + }, }, - }, - }, - { - "tag_name": "v1.0.0", - "assets": []map[string]interface{}{ - { - "name": workflowAssetName("speckit", "1.0.0"), - "browser_download_url": "http://" + r.Host + "/downloads/" + workflowAssetName("speckit", "1.0.0"), - }, - }, - }, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(releases) //nolint:errcheck // test fixture response - return - } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(releases) //nolint:errcheck // test fixture response + return + } - if strings.Contains(r.URL.Path, "/downloads/"+assetName) { - w.Header().Set("Content-Type", "application/gzip") - w.Write(tarball) //nolint:errcheck // test fixture response - return - } + if strings.Contains(r.URL.Path, "/downloads/"+assetName) { + w.Header().Set("Content-Type", "application/gzip") + w.Write(tarball) //nolint:errcheck // test fixture response + return + } - if strings.Contains(r.URL.Path, "/downloads/checksums.txt") { - w.Write([]byte(checksumData)) //nolint:errcheck // test fixture response - return - } + if strings.Contains(r.URL.Path, "/downloads/checksums.txt") { + w.Write([]byte(checksumData)) //nolint:errcheck // test fixture response + return + } - w.WriteHeader(http.StatusNotFound) - })) - defer server.Close() + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() - t.Setenv("GITHUB_API_URL", server.URL) + t.Setenv("GITHUB_API_URL", server.URL) - cmd := cli.NewRootCommand() - cmd.SetArgs([]string{"workflow", "install", "testorg/awf-workflow-speckit@" + requestedVersion}) + cmd := cli.NewRootCommand() + cmd.SetArgs([]string{"workflow", "install", tt.source}) - var out bytes.Buffer - cmd.SetOut(&out) - cmd.SetErr(&out) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) - err = cmd.Execute() - assert.NoError(t, err, "install with version constraint should succeed") + err = cmd.Execute() + require.NoError(t, err, "install with exact version should succeed") - packDir := filepath.Join(projDir, ".awf", "workflow-packs", "speckit") - _, err = os.Stat(packDir) - assert.NoError(t, err, "pack directory should exist after versioned install") + packDir := filepath.Join(projDir, ".awf", "workflow-packs", "speckit") + _, err = os.Stat(packDir) + require.NoError(t, err, "pack directory should exist after versioned install") + + source := readWorkflowPackSource(t, packDir) + assert.Equal(t, "testorg/awf-workflow-speckit", source.Repository) + assert.Equal(t, tt.expectedVersion, source.Version) + }) + } } // TestWorkflowInstall_ForceReinstall validates --force flag replaces existing pack. @@ -408,6 +485,83 @@ func TestWorkflowInstall_ForceReinstall(t *testing.T) { assert.NoError(t, err, "state.json should be updated after force reinstall") } +func TestWorkflowInstall_RemovedVersionFlag(t *testing.T) { + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + t.Setenv("GITHUB_API_URL", server.URL) + + cmd := cli.NewRootCommand() + cmd.SetArgs([]string{"workflow", "install", "testorg/awf-workflow-speckit", "--version", "1.2.3"}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown flag: --version") + assert.Zero(t, hits.Load()) +} + +func TestWorkflowInstall_InvalidVersionSuffix(t *testing.T) { + tests := []struct { + name string + source string + wantContains string + }{ + { + name: `Workflow install rejects owner repo latest before release lookup with invalid release version latest`, + source: "testorg/awf-workflow-speckit@latest", + wantContains: `invalid release version "latest": version: invalid format "latest"`, + }, + { + name: `Workflow install rejects owner repo greater than or equal range before release lookup with invalid release version range`, + source: "testorg/awf-workflow-speckit@>=1.0.0", + wantContains: `invalid release version ">=1.0.0": version: invalid format ">=1.0.0"`, + }, + { + name: `Workflow install rejects owner repo empty suffix before release lookup with invalid release version empty string`, + source: "testorg/awf-workflow-speckit@", + wantContains: `invalid release version "": version: empty string`, + }, + { + name: `Workflow install rejects owner repo colon version before release lookup as unsupported syntax`, + source: "testorg/awf-workflow-speckit:1.2.3", + wantContains: `owner/repo:version syntax is not supported; use owner/repo@version`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + t.Setenv("GITHUB_API_URL", server.URL) + + cmd := cli.NewRootCommand() + cmd.SetArgs([]string{"workflow", "install", tt.source}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantContains) + assert.Zero(t, hits.Load()) + }) + } +} + // TestWorkflowRemove_SuccessfulRemoval validates complete remove workflow. // Covers: Removes pack directory from local workflow-packs. func TestWorkflowRemove_SuccessfulRemoval(t *testing.T) { @@ -481,26 +635,63 @@ func TestWorkflowRemove_SearchesGlobalFallback(t *testing.T) { // TestWorkflowInstall_InvalidRepoFormat validates error on malformed owner/repo. // Covers: Rejects input without slash, returns user error (exit 1). func TestWorkflowInstall_InvalidRepoFormat(t *testing.T) { - tmpDir := t.TempDir() - projDir := filepath.Join(tmpDir, "project") - require.NoError(t, os.Mkdir(projDir, 0o755)) - require.NoError(t, os.Mkdir(filepath.Join(projDir, ".awf"), 0o755)) + tests := []struct { + name string + source string + wantContains string + }{ + { + name: "Existing invalid repository format behavior remains unchanged and continues to use registry ValidateOwnerRepo errors missing slash separator", + source: "invalid-format", + wantContains: "invalid owner/repo format: missing slash separator", + }, + { + name: "Existing invalid repository format behavior remains unchanged and continues to use registry ValidateOwnerRepo errors empty owner segment", + source: "/awf-workflow-speckit", + wantContains: "invalid owner/repo format: empty owner segment", + }, + { + name: "Existing invalid repository format behavior remains unchanged and continues to use registry ValidateOwnerRepo errors multiple slashes not allowed", + source: "testorg/team/awf-workflow-speckit", + wantContains: "invalid owner/repo format: multiple slashes not allowed", + }, + } - origWd, err := os.Getwd() - require.NoError(t, err) - t.Cleanup(func() { os.Chdir(origWd) }) //nolint:errcheck // restore working directory in cleanup + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + projDir := filepath.Join(tmpDir, "project") + require.NoError(t, os.Mkdir(projDir, 0o755)) + require.NoError(t, os.Mkdir(filepath.Join(projDir, ".awf"), 0o755)) - require.NoError(t, os.Chdir(projDir)) + origWd, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { os.Chdir(origWd) }) //nolint:errcheck // restore working directory in cleanup - cmd := cli.NewRootCommand() - cmd.SetArgs([]string{"workflow", "install", "invalid-format"}) + require.NoError(t, os.Chdir(projDir)) - var out bytes.Buffer - cmd.SetOut(&out) - cmd.SetErr(&out) + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() - err = cmd.Execute() - assert.Error(t, err, "install with invalid repo format should fail") + t.Setenv("GITHUB_API_URL", server.URL) + + cmd := cli.NewRootCommand() + cmd.SetArgs([]string{"workflow", "install", tt.source}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + err = cmd.Execute() + require.Error(t, err, "install with invalid repo format should fail") + assert.Contains(t, err.Error(), tt.wantContains) + assert.Zero(t, hits.Load()) + }) + } } // TestWorkflowInstall_VersionNotFound validates error when requested version doesn't exist. @@ -552,14 +743,15 @@ func TestWorkflowInstall_VersionNotFound(t *testing.T) { t.Setenv("GITHUB_API_URL", server.URL) cmd := cli.NewRootCommand() - cmd.SetArgs([]string{"workflow", "install", "testorg/awf-workflow-speckit@99.99.99"}) + cmd.SetArgs([]string{"workflow", "install", "testorg/awf-workflow-speckit@1.2.3"}) var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) err = cmd.Execute() - assert.Error(t, err, "install with nonexistent version should fail") + require.Error(t, err, "install with nonexistent version should fail") + assert.Contains(t, err.Error(), "release version 1.2.3 not found") } // TestWorkflowInstall_ChecksumMismatchAborts validates checksum verification prevents installation. diff --git a/tests/integration/features/cli_test.go b/tests/integration/features/cli_test.go index 2e86defa..1644dfd8 100644 --- a/tests/integration/features/cli_test.go +++ b/tests/integration/features/cli_test.go @@ -107,13 +107,15 @@ func TestCLI_Version_Integration(t *testing.T) { cmd := cli.NewRootCommand() buf := new(bytes.Buffer) cmd.SetOut(buf) - cmd.SetArgs([]string{"version"}) + cmd.SetArgs([]string{"--version"}) err := cmd.Execute() require.NoError(t, err) output := buf.String() assert.Contains(t, output, "awf version") + assert.Contains(t, output, "commit:") + assert.Contains(t, output, "built:") } func TestCLI_Help_Integration(t *testing.T) { @@ -138,9 +140,9 @@ func TestCLI_GlobalFlags_Integration(t *testing.T) { name string args []string }{ - {"verbose", []string{"--verbose", "version"}}, - {"quiet", []string{"--quiet", "version"}}, - {"no-color", []string{"--no-color", "version"}}, + {"verbose", []string{"--verbose", "--version"}}, + {"quiet", []string{"--quiet", "--version"}}, + {"no-color", []string{"--no-color", "--version"}}, } for _, tt := range tests { @@ -204,7 +206,7 @@ func TestCLI_ExitCodes_Integration(t *testing.T) { }{ { name: "success - version", - args: []string{"version"}, + args: []string{"--version"}, expectError: false, }, {