diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e9203a..66155c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ on: - main env: - VERSION_NUMBER: 'v1.10.1' + VERSION_NUMBER: 'v1.10.2' DOCKERHUB_REGISTRY_NAME: 'digitalghostdev/poke-cli' AWS_REGION: 'us-west-2' diff --git a/.goreleaser.yml b/.goreleaser.yml index a796c28..eb5a0e0 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -14,7 +14,7 @@ builds: - windows - darwin ldflags: - - -s -w -X main.version=v1.10.1 + - -s -w -X main.version=v1.10.2 archives: - formats: [ 'zip' ] diff --git a/Dockerfile b/Dockerfile index 13dd81c..411f8b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ RUN go mod download COPY . . -RUN go build -ldflags "-X main.version=v1.10.1" -o poke-cli . +RUN go build -ldflags "-X main.version=v1.10.2" -o poke-cli . # build 2 FROM --platform=$BUILDPLATFORM alpine:3.23 diff --git a/README.md b/README.md index 23ab118..b3272b9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ pokemon-logo

version-label - docker-image-size + docker-image-size ci-status-badge
@@ -99,11 +99,11 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an 3. Choose how to interact with the container: * Run a single command and exit: ```bash - docker run --rm -it digitalghostdev/poke-cli:v1.10.1 [subcommand] [flag] + docker run --rm -it digitalghostdev/poke-cli:v1.10.2 [subcommand] [flag] ``` * Enter the container and use its shell: ```bash - docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.10.1 -c "cd /app && exec sh" + docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.10.2 -c "cd /app && exec sh" # placed into the /app directory, run the program with './poke-cli' # example: ./poke-cli ability swift-swim ``` @@ -112,13 +112,13 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an > The `card` command renders TCG card images using your terminal's graphics protocol. When running inside Docker, pass your terminal's environment variables so image rendering works correctly: > ```bash > # Kitty -> docker run --rm -it -e TERM -e KITTY_WINDOW_ID digitalghostdev/poke-cli:v1.10.1 card +> docker run --rm -it -e TERM -e KITTY_WINDOW_ID digitalghostdev/poke-cli:v1.10.2 card > > # WezTerm, iTerm2, Ghostty, Konsole, Rio, Tabby -> docker run --rm -it -e TERM -e TERM_PROGRAM digitalghostdev/poke-cli:v1.10.1 card +> docker run --rm -it -e TERM -e TERM_PROGRAM digitalghostdev/poke-cli:v1.10.2 card > > # Windows Terminal (Sixel) -> docker run --rm -it -e WT_SESSION digitalghostdev/poke-cli:v1.10.1 card +> docker run --rm -it -e WT_SESSION digitalghostdev/poke-cli:v1.10.2 card > ``` > If your terminal is not listed above, image rendering is not supported inside Docker. diff --git a/card_data/pipelines/poke_cli_dbt/dbt_project.yml b/card_data/pipelines/poke_cli_dbt/dbt_project.yml index 181a825..6839ad1 100644 --- a/card_data/pipelines/poke_cli_dbt/dbt_project.yml +++ b/card_data/pipelines/poke_cli_dbt/dbt_project.yml @@ -1,5 +1,5 @@ name: 'poke_cli_dbt' -version: '1.10.1' +version: '1.10.2' profile: 'poke_cli_dbt' diff --git a/card_data/pyproject.toml b/card_data/pyproject.toml index 40203d2..7a331c4 100644 --- a/card_data/pyproject.toml +++ b/card_data/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "card-data" -version = "v1.10.1" +version = "v1.10.2" description = "File directory to store all data related processes for the Pokémon TCG." readme = "README.md" requires-python = ">=3.12" diff --git a/cli.go b/cli.go index 19e7b66..4dbf015 100644 --- a/cli.go +++ b/cli.go @@ -123,18 +123,19 @@ func runCLI(args []string) int { remainingArgs := mainFlagSet.Args() - commands := map[string]func() int{ - "ability": utils.HandleCommandOutput(ability.AbilityCommand), - "berry": utils.HandleCommandOutput(berry.BerryCommand), - "card": utils.HandleCommandOutput(card.CardCommand), - "item": utils.HandleCommandOutput(item.ItemCommand), - "move": utils.HandleCommandOutput(move.MoveCommand), - "natures": utils.HandleCommandOutput(natures.NaturesCommand), - "pokemon": utils.HandleCommandOutput(pokemon.PokemonCommand), - "speed": utils.HandleCommandOutput(speed.SpeedCommand), - "tcg": utils.HandleCommandOutput(tcg.TcgCommand), - "types": utils.HandleCommandOutput(types.TypesCommand), - "search": utils.HandleCommandOutput(search.SearchCommand), + type commandFunc func([]string) (string, error) + commands := map[string]commandFunc{ + "ability": ability.AbilityCommand, + "berry": berry.BerryCommand, + "card": card.CardCommand, + "item": item.ItemCommand, + "move": move.MoveCommand, + "natures": natures.NaturesCommand, + "pokemon": pokemon.PokemonCommand, + "speed": speed.SpeedCommand, + "tcg": tcg.TcgCommand, + "types": types.TypesCommand, + "search": search.SearchCommand, } cmdArg := "" @@ -157,7 +158,7 @@ func runCLI(args []string) int { currentVersion() return 0 case exists: - return cmdFunc() + return utils.HandleCommandOutput(cmdFunc, remainingArgs)() default: msg := fmt.Sprintf("\t%-15s", fmt.Sprintf("'%s' is not a valid command.\n", cmdArg)) + styling.StyleBold.Render("\nCommands:") + diff --git a/cli_test.go b/cli_test.go index 91400b3..6639806 100644 --- a/cli_test.go +++ b/cli_test.go @@ -108,10 +108,6 @@ func TestRunCLI(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - originalArgs := os.Args - os.Args = append([]string{"poke-cli"}, tt.args...) - defer func() { os.Args = originalArgs }() - var exitCode int output := captureOutput(func() { exitCode = runCLI(tt.args) diff --git a/cmd/ability/ability.go b/cmd/ability/ability.go index e3a9c0e..656b4b5 100644 --- a/cmd/ability/ability.go +++ b/cmd/ability/ability.go @@ -4,7 +4,6 @@ import ( "errors" "flag" "fmt" - "os" "strings" "github.com/digitalghost-dev/poke-cli/cmd/utils" @@ -13,10 +12,10 @@ import ( "github.com/digitalghost-dev/poke-cli/styling" ) -func AbilityCommand() (string, error) { +func AbilityCommand(args []string) (string, error) { var output strings.Builder - flag.Usage = func() { + usage := func() { output.WriteString( utils.GenerateHelpMessage( utils.HelpConfig{ @@ -34,21 +33,22 @@ func AbilityCommand() (string, error) { af := flags.SetupAbilityFlagSet() - args := os.Args - - if utils.CheckHelpFlag(&output, flag.Usage) { + if utils.CheckHelpFlag(args, usage) { return output.String(), nil } - if err := utils.ValidateArgs(args, utils.Validator{MaxArgs: 4, CmdName: "ability", RequireName: true, HasFlags: true}); err != nil { + if err := utils.ValidateArgs( + args, + utils.Validator{MaxArgs: 3, CmdName: "ability", RequireName: true, HasFlags: true}, + ); err != nil { output.WriteString(err.Error()) return output.String(), err } - endpoint := strings.ToLower(args[1]) - abilityName := strings.ToLower(args[2]) + endpoint := strings.ToLower(args[0]) + abilityName := strings.ToLower(args[1]) - if err := af.FlagSet.Parse(args[3:]); err != nil { + if err := af.FlagSet.Parse(args[2:]); err != nil { if errors.Is(err, flag.ErrHelp) { return output.String(), nil } diff --git a/cmd/ability/ability_test.go b/cmd/ability/ability_test.go index dedfaa5..397a250 100644 --- a/cmd/ability/ability_test.go +++ b/cmd/ability/ability_test.go @@ -1,7 +1,6 @@ package ability import ( - "os" "testing" "github.com/digitalghost-dev/poke-cli/cmd/utils" @@ -62,11 +61,7 @@ func TestAbilityCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - originalArgs := os.Args - os.Args = append([]string{"poke-cli"}, tt.args...) - defer func() { os.Args = originalArgs }() - - output, _ := AbilityCommand() + output, _ := AbilityCommand(tt.args) cleanOutput := styling.StripANSI(output) assert.Equal(t, tt.expectedOutput, cleanOutput, "Output should match expected") diff --git a/cmd/berry/berry.go b/cmd/berry/berry.go index b2c120b..830175e 100644 --- a/cmd/berry/berry.go +++ b/cmd/berry/berry.go @@ -1,9 +1,7 @@ package berry import ( - "flag" "fmt" - "os" "strings" "charm.land/bubbles/v2/table" @@ -14,10 +12,10 @@ import ( "github.com/digitalghost-dev/poke-cli/styling" ) -func BerryCommand() (string, error) { +func BerryCommand(args []string) (string, error) { var output strings.Builder - flag.Usage = func() { + usage := func() { output.WriteString( utils.GenerateHelpMessage( utils.HelpConfig{ @@ -28,27 +26,28 @@ func BerryCommand() (string, error) { ) } - if utils.CheckHelpFlag(&output, flag.Usage) { + if utils.CheckHelpFlag(args, usage) { return output.String(), nil } - flag.Parse() - // Validate arguments - if err := utils.ValidateArgs(os.Args, utils.Validator{MaxArgs: 4, CmdName: "berry", RequireName: false, HasFlags: false}); err != nil { + if err := utils.ValidateArgs( + args, + utils.Validator{MaxArgs: 3, CmdName: "berry", RequireName: false, HasFlags: false}, + ); err != nil { output.WriteString(err.Error()) return output.String(), err } - if len(os.Args) > 2 { - berryName := styling.CapitalizeResourceName(os.Args[2]) + if len(args) > 1 { + berryName := styling.CapitalizeResourceName(args[1]) exists, err := berryExists(berryName) if err != nil { output.WriteString(utils.FormatError(err.Error())) return output.String(), err } if !exists { - err := fmt.Errorf("berry %q not found", os.Args[2]) + err := fmt.Errorf("berry %q not found", args[1]) output.WriteString(utils.FormatError(err.Error())) return output.String(), err } diff --git a/cmd/berry/berry_test.go b/cmd/berry/berry_test.go index 1176b26..31b828e 100644 --- a/cmd/berry/berry_test.go +++ b/cmd/berry/berry_test.go @@ -25,19 +25,19 @@ func TestBerryCommand(t *testing.T) { }{ { name: "help flag short", - args: []string{"poke-cli", "berry", "-h"}, + args: []string{"berry", "-h"}, wantErr: false, contains: "USAGE:", }, { name: "help flag long", - args: []string{"poke-cli", "berry", "--help"}, + args: []string{"berry", "--help"}, wantErr: false, contains: "FLAGS:", }, { name: "invalid berry name", - args: []string{"poke-cli", "berry", "fakemon"}, + args: []string{"berry", "fakemon"}, wantErr: true, contains: "not found", }, @@ -45,12 +45,7 @@ func TestBerryCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Set up os.Args for the test - oldArgs := os.Args - os.Args = tt.args - defer func() { os.Args = oldArgs }() - - output, err := BerryCommand() + output, err := BerryCommand(tt.args) if (err != nil) != tt.wantErr { t.Errorf("BerryCommand() error = %v, wantErr %v", err, tt.wantErr) @@ -312,11 +307,7 @@ func TestBerryCommandOutput(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - originalArgs := os.Args - os.Args = append([]string{"poke-cli"}, tt.args...) - defer func() { os.Args = originalArgs }() - - output, err := BerryCommand() + output, err := BerryCommand(tt.args) require.NoError(t, err, "BerryCommand failed: %v", err) cleanOutput := styling.StripANSI(output) @@ -326,13 +317,7 @@ func TestBerryCommandOutput(t *testing.T) { } func TestBerryCommandValidationError(t *testing.T) { - originalArgs := os.Args - defer func() { os.Args = originalArgs }() - - // Set os.Args with extra argument to trigger validation error - os.Args = []string{"poke-cli", "berry", "cheri", "extra-arg"} - - output, err := BerryCommand() + output, err := BerryCommand([]string{"berry", "cheri", "extra-arg"}) require.Error(t, err, "BerryCommand should return error for invalid args") assert.Contains(t, output, "Error", "Output should contain error message") } diff --git a/cmd/berry/berryinfo.go b/cmd/berry/berryinfo.go index a1ca79e..1c306b4 100644 --- a/cmd/berry/berryinfo.go +++ b/cmd/berry/berryinfo.go @@ -2,6 +2,7 @@ package berry import ( "image" + "io" "net/http" "strings" @@ -11,6 +12,10 @@ import ( "github.com/disintegration/imaging" ) +const maxBerryImageBytes = 5 * 1024 * 1024 // 5 MiB + +var berryImageHTTPClient = connections.NewDefaultHTTPClient() + func berryExists(name string) (bool, error) { results, err := connections.QueryBerryData(` SELECT 1 FROM berries @@ -121,13 +126,17 @@ func berryImage(berryName string) string { return str.String() } - imageResp, err := http.Get(berryImage[0]) + imageResp, err := berryImageHTTPClient.Get(berryImage[0]) if err != nil { return "Error downloading berry image" } defer imageResp.Body.Close() - img, err := imaging.Decode(imageResp.Body) + if imageResp.StatusCode != http.StatusOK { + return "Error downloading berry image" + } + + img, err := imaging.Decode(io.LimitReader(imageResp.Body, maxBerryImageBytes)) if err != nil { return "Error decoding berry image" } diff --git a/cmd/card/card.go b/cmd/card/card.go index 41e53b7..bd9b955 100644 --- a/cmd/card/card.go +++ b/cmd/card/card.go @@ -1,7 +1,6 @@ package card import ( - "flag" "fmt" "os" "strings" @@ -10,10 +9,10 @@ import ( "github.com/digitalghost-dev/poke-cli/cmd/utils" ) -func CardCommand() (string, error) { +func CardCommand(args []string) (string, error) { var output strings.Builder - flag.Usage = func() { + usage := func() { output.WriteString( utils.GenerateHelpMessage( utils.HelpConfig{ @@ -24,14 +23,15 @@ func CardCommand() (string, error) { ) } - if utils.CheckHelpFlag(&output, flag.Usage) { + if utils.CheckHelpFlag(args, usage) { return output.String(), nil } - flag.Parse() - // Validate arguments - if err := utils.ValidateArgs(os.Args, utils.Validator{MaxArgs: 3, CmdName: "card", RequireName: false, HasFlags: false}); err != nil { + if err := utils.ValidateArgs( + args, + utils.Validator{MaxArgs: 2, CmdName: "card", RequireName: false, HasFlags: false}, + ); err != nil { output.WriteString(err.Error()) return output.String(), err } diff --git a/cmd/card/card_test.go b/cmd/card/card_test.go index 209e0df..4681820 100644 --- a/cmd/card/card_test.go +++ b/cmd/card/card_test.go @@ -1,7 +1,6 @@ package card import ( - "os" "strings" "testing" ) @@ -15,19 +14,19 @@ func TestCardCommand(t *testing.T) { }{ { name: "help flag short", - args: []string{"poke-cli", "card", "-h"}, + args: []string{"card", "-h"}, wantErr: false, contains: "USAGE:", }, { name: "help flag long", - args: []string{"poke-cli", "card", "--help"}, + args: []string{"card", "--help"}, wantErr: false, contains: "FLAGS:", }, { name: "invalid args", - args: []string{"poke-cli", "card", "invalid-arg"}, + args: []string{"card", "invalid-arg"}, wantErr: true, contains: "", }, @@ -35,11 +34,7 @@ func TestCardCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - oldArgs := os.Args - os.Args = tt.args - defer func() { os.Args = oldArgs }() - - output, err := CardCommand() + output, err := CardCommand(tt.args) if (err != nil) != tt.wantErr { t.Errorf("CardCommand() error = %v, wantErr %v", err, tt.wantErr) diff --git a/cmd/card/cardinfo.go b/cmd/card/cardinfo.go index 43bbdb3..c86deb7 100644 --- a/cmd/card/cardinfo.go +++ b/cmd/card/cardinfo.go @@ -10,13 +10,15 @@ import ( "net/url" "os" "strings" - "time" "github.com/charmbracelet/x/ansi/sixel" + "github.com/digitalghost-dev/poke-cli/connections" "github.com/dolmen-go/kittyimg" "golang.org/x/image/draw" ) +var cardImageHTTPClient = connections.NewDefaultHTTPClient() + func resizeImage(img image.Image, width, height int) image.Image { dst := image.NewRGBA(image.Rect(0, 0, width, height)) draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil) @@ -78,14 +80,11 @@ func supportsSixelGraphics() bool { // CardImage downloads and renders an image using Kitty protocol if supported, otherwise Sixel. func CardImage(imageURL string) (imageData string, protocol string, err error) { - client := &http.Client{ - Timeout: time.Second * 60, - } parsedURL, err := url.Parse(imageURL) if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") { return "", "", errors.New("image is not available from the API") } - resp, err := client.Get(imageURL) + resp, err := cardImageHTTPClient.Get(imageURL) if err != nil { return "", "", fmt.Errorf("failed to fetch image: %w", err) } diff --git a/cmd/item/item.go b/cmd/item/item.go index fd82ecf..6f9e7a8 100644 --- a/cmd/item/item.go +++ b/cmd/item/item.go @@ -1,7 +1,6 @@ package item import ( - "flag" "fmt" "os" "strings" @@ -14,10 +13,10 @@ import ( "github.com/digitalghost-dev/poke-cli/styling" ) -func ItemCommand() (string, error) { +func ItemCommand(args []string) (string, error) { var output strings.Builder - flag.Usage = func() { + usage := func() { output.WriteString( utils.GenerateHelpMessage( utils.HelpConfig{ @@ -30,21 +29,20 @@ func ItemCommand() (string, error) { ) } - args := os.Args - - if utils.CheckHelpFlag(&output, flag.Usage) { + if utils.CheckHelpFlag(args, usage) { return output.String(), nil } - flag.Parse() - - if err := utils.ValidateArgs(os.Args, utils.Validator{MaxArgs: 3, CmdName: "item", RequireName: true, HasFlags: false}); err != nil { + if err := utils.ValidateArgs( + args, + utils.Validator{MaxArgs: 2, CmdName: "item", RequireName: true, HasFlags: false}, + ); err != nil { output.WriteString(err.Error()) return output.String(), err } - endpoint := strings.ToLower(args[1]) - itemName := strings.ToLower(args[2]) + endpoint := strings.ToLower(args[0]) + itemName := strings.ToLower(args[1]) itemStruct, itemName, err := connections.ItemApiCall(endpoint, itemName, connections.APIURL) if err != nil { diff --git a/cmd/item/item_test.go b/cmd/item/item_test.go index 57142ed..19baa47 100644 --- a/cmd/item/item_test.go +++ b/cmd/item/item_test.go @@ -58,11 +58,7 @@ func TestItemCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - originalArgs := os.Args - os.Args = append([]string{"poke-cli"}, tt.args...) - defer func() { os.Args = originalArgs }() - - output, _ := ItemCommand() + output, _ := ItemCommand(tt.args) cleanOutput := styling.StripANSI(output) assert.Equal(t, tt.expectedOutput, cleanOutput, "Output should match expected") diff --git a/cmd/move/move.go b/cmd/move/move.go index 00c3573..6e86cfc 100644 --- a/cmd/move/move.go +++ b/cmd/move/move.go @@ -1,9 +1,7 @@ package move import ( - "flag" "fmt" - "os" "strconv" "strings" @@ -17,10 +15,10 @@ import ( "golang.org/x/text/language" ) -func MoveCommand() (string, error) { +func MoveCommand(args []string) (string, error) { var output strings.Builder - flag.Usage = func() { + usage := func() { output.WriteString( utils.GenerateHelpMessage( utils.HelpConfig{ @@ -33,18 +31,18 @@ func MoveCommand() (string, error) { ) } - if utils.CheckHelpFlag(&output, flag.Usage) { + if utils.CheckHelpFlag(args, usage) { return output.String(), nil } - flag.Parse() - - if err := utils.ValidateArgs(os.Args, utils.Validator{MaxArgs: 3, CmdName: "move", RequireName: true, HasFlags: false}); err != nil { + if err := utils.ValidateArgs( + args, + utils.Validator{MaxArgs: 2, CmdName: "move", RequireName: true, HasFlags: false}, + ); err != nil { output.WriteString(err.Error()) return output.String(), err } - args := flag.Args() endpoint := strings.ToLower(args[0]) moveName := strings.ToLower(args[1]) diff --git a/cmd/move/move_test.go b/cmd/move/move_test.go index 837d798..7176258 100644 --- a/cmd/move/move_test.go +++ b/cmd/move/move_test.go @@ -46,11 +46,7 @@ func TestMoveCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - originalArgs := os.Args - os.Args = append([]string{"poke-cli"}, tt.args...) - defer func() { os.Args = originalArgs }() - - output, _ := MoveCommand() + output, _ := MoveCommand(tt.args) cleanOutput := styling.StripANSI(output) assert.Equal(t, tt.expectedOutput, cleanOutput, "Output should match expected") diff --git a/cmd/natures/natures.go b/cmd/natures/natures.go index f5242a9..a280562 100644 --- a/cmd/natures/natures.go +++ b/cmd/natures/natures.go @@ -1,8 +1,6 @@ package natures import ( - "flag" - "os" "strings" "charm.land/lipgloss/v2" @@ -11,10 +9,10 @@ import ( "github.com/digitalghost-dev/poke-cli/styling" ) -func NaturesCommand() (string, error) { +func NaturesCommand(args []string) (string, error) { var output strings.Builder - flag.Usage = func() { + usage := func() { output.WriteString( utils.GenerateHelpMessage( utils.HelpConfig{ @@ -25,13 +23,14 @@ func NaturesCommand() (string, error) { ) } - if utils.CheckHelpFlag(&output, flag.Usage) { + if utils.CheckHelpFlag(args, usage) { return output.String(), nil } - flag.Parse() - - if err := utils.ValidateArgs(os.Args, utils.Validator{MaxArgs: 3, CmdName: "natures", RequireName: false, HasFlags: false}); err != nil { + if err := utils.ValidateArgs( + args, + utils.Validator{MaxArgs: 2, CmdName: "natures", RequireName: false, HasFlags: false}, + ); err != nil { output.WriteString(err.Error()) return output.String(), err } diff --git a/cmd/natures/natures_test.go b/cmd/natures/natures_test.go index c495e21..05b2c5b 100644 --- a/cmd/natures/natures_test.go +++ b/cmd/natures/natures_test.go @@ -4,7 +4,6 @@ import ( "github.com/digitalghost-dev/poke-cli/cmd/utils" "github.com/digitalghost-dev/poke-cli/styling" "github.com/stretchr/testify/assert" - "os" "testing" ) @@ -40,11 +39,7 @@ func TestNaturesCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - originalArgs := os.Args - os.Args = append([]string{"poke-cli"}, tt.args...) - defer func() { os.Args = originalArgs }() - - output, _ := NaturesCommand() + output, _ := NaturesCommand(tt.args) cleanOutput := styling.StripANSI(output) assert.Equal(t, tt.expectedOutput, cleanOutput, "Output should match expected") diff --git a/cmd/pokemon/pokemon.go b/cmd/pokemon/pokemon.go index eb3ac37..c741301 100644 --- a/cmd/pokemon/pokemon.go +++ b/cmd/pokemon/pokemon.go @@ -6,7 +6,6 @@ import ( "flag" "fmt" "io" - "os" "strings" "github.com/digitalghost-dev/poke-cli/cmd/utils" @@ -16,10 +15,10 @@ import ( ) // PokemonCommand processes the Pokémon command -func PokemonCommand() (string, error) { +func PokemonCommand(args []string) (string, error) { var output strings.Builder - flag.Usage = func() { + usage := func() { output.WriteString( utils.GenerateHelpMessage( utils.HelpConfig{ @@ -42,9 +41,7 @@ func PokemonCommand() (string, error) { pf := flags.SetupPokemonFlagSet() - args := os.Args - - if utils.CheckHelpFlag(&output, flag.Usage) { + if utils.CheckHelpFlag(args, usage) { return output.String(), nil } @@ -54,10 +51,10 @@ func PokemonCommand() (string, error) { return output.String(), err } - endpoint := strings.ToLower(args[1]) - pokemonName := strings.ToLower(args[2]) + endpoint := strings.ToLower(args[0]) + pokemonName := strings.ToLower(args[1]) - if err := pf.FlagSet.Parse(args[3:]); err != nil { + if err := pf.FlagSet.Parse(args[2:]); err != nil { if errors.Is(err, flag.ErrHelp) { return output.String(), nil } diff --git a/cmd/pokemon/pokemon_test.go b/cmd/pokemon/pokemon_test.go index a2bc417..9b50865 100644 --- a/cmd/pokemon/pokemon_test.go +++ b/cmd/pokemon/pokemon_test.go @@ -102,11 +102,7 @@ func TestPokemonCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - originalArgs := os.Args - os.Args = append([]string{"poke-cli"}, tt.args...) - defer func() { os.Args = originalArgs }() - - output, _ := PokemonCommand() + output, _ := PokemonCommand(tt.args) cleanOutput := styling.StripANSI(output) assert.Equal(t, tt.expectedOutput, cleanOutput, "Output should match expected") diff --git a/cmd/search/search.go b/cmd/search/search.go index 0de158f..02d4c50 100644 --- a/cmd/search/search.go +++ b/cmd/search/search.go @@ -1,8 +1,6 @@ package search import ( - "flag" - "os" "strings" "charm.land/bubbles/v2/textinput" @@ -10,10 +8,10 @@ import ( "github.com/digitalghost-dev/poke-cli/cmd/utils" ) -func SearchCommand() (string, error) { +func SearchCommand(args []string) (string, error) { var output strings.Builder - flag.Usage = func() { + usage := func() { output.WriteString( utils.GenerateHelpMessage( utils.HelpConfig{ @@ -24,13 +22,14 @@ func SearchCommand() (string, error) { ) } - if utils.CheckHelpFlag(&output, flag.Usage) { + if utils.CheckHelpFlag(args, usage) { return output.String(), nil } - flag.Parse() - - if err := utils.ValidateArgs(os.Args, utils.Validator{MaxArgs: 3, CmdName: "search", RequireName: false, HasFlags: false}); err != nil { + if err := utils.ValidateArgs( + args, + utils.Validator{MaxArgs: 2, CmdName: "search", RequireName: false, HasFlags: false}, + ); err != nil { output.WriteString(err.Error()) return output.String(), err } diff --git a/cmd/search/search_test.go b/cmd/search/search_test.go index bd1406e..cd00c0e 100644 --- a/cmd/search/search_test.go +++ b/cmd/search/search_test.go @@ -1,7 +1,6 @@ package search import ( - "os" "strings" "testing" @@ -35,12 +34,7 @@ func TestSearchCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - originalArgs := os.Args - defer func() { os.Args = originalArgs }() - - os.Args = append([]string{"poke-cli"}, tt.args...) - - output, err := SearchCommand() + output, err := SearchCommand(tt.args) cleanOutput := styling.StripANSI(output) if tt.expectedError { @@ -79,13 +73,7 @@ func TestModelQuit(t *testing.T) { } func TestSearchCommandValidationError(t *testing.T) { - originalArgs := os.Args - defer func() { os.Args = originalArgs }() - - // Set os.Args with extra argument to trigger validation error - os.Args = []string{"poke-cli", "search", "pokemon", "extra-arg"} - - _, err := SearchCommand() + _, err := SearchCommand([]string{"search", "pokemon", "extra-arg"}) assert.Error(t, err, "SearchCommand should return error for invalid args") } diff --git a/cmd/speed/speed.go b/cmd/speed/speed.go index 906334c..dd8d96e 100644 --- a/cmd/speed/speed.go +++ b/cmd/speed/speed.go @@ -2,7 +2,6 @@ package speed import ( "errors" - "flag" "fmt" "math" "os" @@ -88,11 +87,11 @@ type PokemonDetails struct { // SpeedStatFunc is a function type for getting a Pokémon's base speed stat type SpeedStatFunc func(name string) (string, error) -func SpeedCommand() (string, error) { +func SpeedCommand(args []string) (string, error) { // Reset the output string builder output.Reset() - flag.Usage = func() { + usage := func() { output.WriteString( utils.GenerateHelpMessage( utils.HelpConfig{ @@ -103,14 +102,15 @@ func SpeedCommand() (string, error) { ) } - if utils.CheckHelpFlag(&output, flag.Usage) { + if utils.CheckHelpFlag(args, usage) { return output.String(), nil } - flag.Parse() - // Validate arguments - if err := utils.ValidateArgs(os.Args, utils.Validator{MaxArgs: 3, CmdName: "speed", RequireName: false, HasFlags: false}); err != nil { + if err := utils.ValidateArgs( + args, + utils.Validator{MaxArgs: 2, CmdName: "speed", RequireName: false, HasFlags: false}, + ); err != nil { output.WriteString(err.Error()) return output.String(), err } diff --git a/cmd/speed/speed_test.go b/cmd/speed/speed_test.go index 64db11e..cbe0c2e 100644 --- a/cmd/speed/speed_test.go +++ b/cmd/speed/speed_test.go @@ -5,7 +5,6 @@ import ( "github.com/digitalghost-dev/poke-cli/styling" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "os" "testing" ) @@ -30,11 +29,7 @@ func TestSpeedCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - originalArgs := os.Args - os.Args = append([]string{"poke-cli"}, tt.args...) - defer func() { os.Args = originalArgs }() - - output, _ := SpeedCommand() + output, _ := SpeedCommand(tt.args) cleanOutput := styling.StripANSI(output) assert.Equal(t, tt.expectedOutput, cleanOutput, "Output should match expected") diff --git a/cmd/tcg/tcg.go b/cmd/tcg/tcg.go index 1a96df3..1fca541 100644 --- a/cmd/tcg/tcg.go +++ b/cmd/tcg/tcg.go @@ -4,7 +4,6 @@ import ( "errors" "flag" "fmt" - "os" "strings" tea "charm.land/bubbletea/v2" @@ -13,10 +12,10 @@ import ( "github.com/digitalghost-dev/poke-cli/flags" ) -func TcgCommand() (string, error) { +func TcgCommand(args []string) (string, error) { var output strings.Builder - flag.Usage = func() { + usage := func() { output.WriteString( utils.GenerateHelpMessage( utils.HelpConfig{ @@ -30,17 +29,20 @@ func TcgCommand() (string, error) { ) } - if utils.CheckHelpFlag(&output, flag.Usage) { + if utils.CheckHelpFlag(args, usage) { return output.String(), nil } - if err := utils.ValidateArgs(os.Args, utils.Validator{MaxArgs: 3, CmdName: "tcg", RequireName: false, HasFlags: true}); err != nil { + if err := utils.ValidateArgs( + args, + utils.Validator{MaxArgs: 2, CmdName: "tcg", RequireName: false, HasFlags: true}, + ); err != nil { output.WriteString(err.Error()) return output.String(), err } tf := flags.SetupTcgFlagSet() - if err := tf.FlagSet.Parse(os.Args[2:]); err != nil { + if err := tf.FlagSet.Parse(args[1:]); err != nil { if errors.Is(err, flag.ErrHelp) { return output.String(), nil } diff --git a/cmd/tcg/tcg_test.go b/cmd/tcg/tcg_test.go index 20b47e0..c6069dc 100644 --- a/cmd/tcg/tcg_test.go +++ b/cmd/tcg/tcg_test.go @@ -2,7 +2,6 @@ package tcg import ( "errors" - "os" "testing" "github.com/digitalghost-dev/poke-cli/cmd/utils" @@ -91,25 +90,25 @@ func TestTcgCommand(t *testing.T) { }{ { name: "help flag short", - args: []string{"poke-cli", "tcg", "-h"}, + args: []string{"tcg", "-h"}, golden: "tcg_help.golden", wantErr: false, }, { name: "help flag long", - args: []string{"poke-cli", "tcg", "--help"}, + args: []string{"tcg", "--help"}, golden: "tcg_help.golden", wantErr: false, }, { name: "too many args", - args: []string{"poke-cli", "tcg", "foo", "bar"}, + args: []string{"tcg", "foo", "bar"}, golden: "tcg_too_many_args.golden", wantErr: true, }, { name: "invalid flag", - args: []string{"poke-cli", "tcg", "--bogus"}, + args: []string{"tcg", "--bogus"}, golden: "tcg_invalid_flag.golden", wantErr: true, }, @@ -117,11 +116,7 @@ func TestTcgCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - originalArgs := os.Args - os.Args = tt.args - defer func() { os.Args = originalArgs }() - - output, err := TcgCommand() + output, err := TcgCommand(tt.args) clean := styling.StripANSI(output) if tt.wantErr { diff --git a/cmd/types/types.go b/cmd/types/types.go index 7ae0128..94bc841 100644 --- a/cmd/types/types.go +++ b/cmd/types/types.go @@ -1,9 +1,7 @@ package types import ( - "flag" "fmt" - "os" "strings" "charm.land/bubbles/v2/table" @@ -13,10 +11,10 @@ import ( "github.com/digitalghost-dev/poke-cli/styling" ) -func TypesCommand() (string, error) { +func TypesCommand(args []string) (string, error) { var output strings.Builder - flag.Usage = func() { + usage := func() { output.WriteString( utils.GenerateHelpMessage( utils.HelpConfig{ @@ -27,19 +25,20 @@ func TypesCommand() (string, error) { ) } - if utils.CheckHelpFlag(&output, flag.Usage) { + if utils.CheckHelpFlag(args, usage) { return output.String(), nil } - flag.Parse() - // Validate arguments - if err := utils.ValidateArgs(os.Args, utils.Validator{MaxArgs: 3, CmdName: "types", RequireName: false, HasFlags: false}); err != nil { + if err := utils.ValidateArgs( + args, + utils.Validator{MaxArgs: 2, CmdName: "types", RequireName: false, HasFlags: false}, + ); err != nil { output.WriteString(err.Error()) return output.String(), err } - endpoint := strings.ToLower(os.Args[1])[0:4] + endpoint := strings.ToLower(args[0])[0:4] if err := runTypeSelectionTable(endpoint); err != nil { output.WriteString(err.Error()) return output.String(), err diff --git a/cmd/types/types_test.go b/cmd/types/types_test.go index 1af37df..648f19b 100644 --- a/cmd/types/types_test.go +++ b/cmd/types/types_test.go @@ -1,7 +1,6 @@ package types import ( - "os" "testing" "time" @@ -36,11 +35,7 @@ func TestTypesCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - originalArgs := os.Args - os.Args = append([]string{"poke-cli"}, tt.args...) - defer func() { os.Args = originalArgs }() - - output, _ := TypesCommand() + output, _ := TypesCommand(tt.args) cleanOutput := styling.StripANSI(output) assert.Equal(t, tt.expectedOutput, cleanOutput, "Output should match expected") @@ -181,13 +176,7 @@ func TestTypeSelection(t *testing.T) { } func TestTypesCommandValidationError(t *testing.T) { - originalArgs := os.Args - defer func() { os.Args = originalArgs }() - - // Set os.Args with extra argument to trigger validation error - os.Args = []string{"poke-cli", "types", "fire", "extra-arg"} - - output, err := TypesCommand() + output, err := TypesCommand([]string{"types", "fire", "extra-arg"}) require.Error(t, err, "TypesCommand should return error for invalid args") assert.Contains(t, output, "Error", "Output should contain error message") } diff --git a/cmd/utils/output.go b/cmd/utils/output.go index a73a4be..1ffdd89 100644 --- a/cmd/utils/output.go +++ b/cmd/utils/output.go @@ -9,9 +9,9 @@ import ( // HandleCommandOutput takes a function that returns (string, error) and wraps it in a no-argument // function that writes the returned string to stdout if there's no error, or to stderr if there is. // It returns an exit code: 0 on success, 1 on error. -func HandleCommandOutput(fn func() (string, error)) func() int { +func HandleCommandOutput(fn func([]string) (string, error), args []string) func() int { return func() int { - output, err := fn() + output, err := fn(args) if err != nil { fmt.Fprintln(os.Stderr, output) return 1 @@ -26,8 +26,8 @@ func HandleFlagError(output *strings.Builder, err error) (string, error) { return "", fmt.Errorf("error parsing flags: %w", err) } -func CheckHelpFlag(output *strings.Builder, usageFunc func()) bool { - if len(os.Args) == 3 && (os.Args[2] == "-h" || os.Args[2] == "--help") { +func CheckHelpFlag(args []string, usageFunc func()) bool { + if len(args) == 2 && (args[1] == "-h" || args[1] == "--help") { usageFunc() return true } diff --git a/cmd/utils/output_test.go b/cmd/utils/output_test.go index be92c8c..722cd00 100644 --- a/cmd/utils/output_test.go +++ b/cmd/utils/output_test.go @@ -28,12 +28,15 @@ func captureOutput(target **os.File, fn func()) string { } func TestHandleCommandOutput_Success(t *testing.T) { - fn := func() (string, error) { + fn := func(args []string) (string, error) { + if strings.Join(args, " ") != "item choice-band" { + t.Fatalf("unexpected args: %v", args) + } return "it worked", nil } output := captureOutput(&os.Stdout, func() { - HandleCommandOutput(fn)() + HandleCommandOutput(fn, []string{"item", "choice-band"})() }) if output != "it worked\n" { @@ -42,12 +45,15 @@ func TestHandleCommandOutput_Success(t *testing.T) { } func TestHandleCommandOutput_Error(t *testing.T) { - fn := func() (string, error) { + fn := func(args []string) (string, error) { + if strings.Join(args, " ") != "item missing" { + t.Fatalf("unexpected args: %v", args) + } return "something failed", errors.New("error") } output := captureOutput(&os.Stderr, func() { - HandleCommandOutput(fn)() + HandleCommandOutput(fn, []string{"item", "missing"})() }) if output != "something failed\n" { @@ -138,12 +144,6 @@ func TestWrapText_MultipleLines(t *testing.T) { } func TestCheckHelpFlag_ShortFlag(t *testing.T) { - // Save and restore original os.Args - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"poke-cli", "pokemon", "-h"} - var output strings.Builder usageCalled := false usageFunc := func() { @@ -151,7 +151,7 @@ func TestCheckHelpFlag_ShortFlag(t *testing.T) { usageCalled = true } - result := CheckHelpFlag(&output, usageFunc) + result := CheckHelpFlag([]string{"pokemon", "-h"}, usageFunc) if !result { t.Error("CheckHelpFlag should return true for -h flag") @@ -162,12 +162,6 @@ func TestCheckHelpFlag_ShortFlag(t *testing.T) { } func TestCheckHelpFlag_LongFlag(t *testing.T) { - // Save and restore original os.Args - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"poke-cli", "pokemon", "--help"} - var output strings.Builder usageCalled := false usageFunc := func() { @@ -175,7 +169,7 @@ func TestCheckHelpFlag_LongFlag(t *testing.T) { usageCalled = true } - result := CheckHelpFlag(&output, usageFunc) + result := CheckHelpFlag([]string{"pokemon", "--help"}, usageFunc) if !result { t.Error("CheckHelpFlag should return true for --help flag") @@ -186,19 +180,12 @@ func TestCheckHelpFlag_LongFlag(t *testing.T) { } func TestCheckHelpFlag_NoFlag(t *testing.T) { - // Save and restore original os.Args - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = []string{"poke-cli", "pokemon", "charizard"} - - var output strings.Builder usageCalled := false usageFunc := func() { usageCalled = true } - result := CheckHelpFlag(&output, usageFunc) + result := CheckHelpFlag([]string{"pokemon", "charizard"}, usageFunc) if result { t.Error("CheckHelpFlag should return false when no help flag present") @@ -215,33 +202,26 @@ func TestCheckHelpFlag_WrongNumberOfArgs(t *testing.T) { }{ { name: "too few args", - args: []string{"poke-cli", "pokemon"}, + args: []string{"pokemon"}, }, { name: "too many args", - args: []string{"poke-cli", "pokemon", "-h", "extra"}, + args: []string{"pokemon", "-h", "extra"}, }, { name: "help flag in wrong position", - args: []string{"poke-cli", "pokemon", "charizard", "-h"}, + args: []string{"pokemon", "charizard", "-h"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Save and restore original os.Args - oldArgs := os.Args - defer func() { os.Args = oldArgs }() - - os.Args = tt.args - - var output strings.Builder usageCalled := false usageFunc := func() { usageCalled = true } - result := CheckHelpFlag(&output, usageFunc) + result := CheckHelpFlag(tt.args, usageFunc) if result { t.Errorf("CheckHelpFlag should return false for args: %v", tt.args) diff --git a/cmd/utils/validateargs.go b/cmd/utils/validateargs.go index b2dc035..7c07029 100644 --- a/cmd/utils/validateargs.go +++ b/cmd/utils/validateargs.go @@ -20,9 +20,10 @@ func checkLength(args []string, max int) error { return nil } -// checkNoOtherOptions checks if there are exactly 3 arguments and the third argument is neither '-h' nor '--help' +// checkNoOtherOptions checks if a no-flag command received an extra argument +// and that argument is neither '-h' nor '--help'. func checkNoOtherOptions(args []string, max int, commandName string) error { - if len(args) == max && args[2] != "-h" && args[2] != "--help" { + if len(args) == max && args[max-1] != "-h" && args[max-1] != "--help" { return fmt.Errorf("%s", FormatError(fmt.Sprintf("The only available options after the\n<%s> command are '-h' or '--help'", commandName))) } return nil @@ -32,7 +33,7 @@ func ValidateArgs(args []string, v Validator) error { if err := checkLength(args, v.MaxArgs); err != nil { return err } - if v.RequireName && len(args) == 2 { + if v.RequireName && len(args) == 1 { return fmt.Errorf("%s", FormatError(fmt.Sprintf( "Please declare a(n) %s's name after the <%s> command\nRun 'poke-cli %s -h' for more details\nerror: insufficient arguments", v.CmdName, v.CmdName, v.CmdName, @@ -48,14 +49,13 @@ func ValidateArgs(args []string, v Validator) error { // ValidatePokemonArgs validates the command line arguments func ValidatePokemonArgs(args []string) error { - // Check if the number of arguments is less than 3 - if len(args) < 3 { + if len(args) < 2 { return fmt.Errorf("%s", FormatError( "Please declare a Pokémon's name after the command\nRun 'poke-cli pokemon -h' for more details\nerror: insufficient arguments", )) } - if err := checkLength(args, 8); err != nil { + if err := checkLength(args, 7); err != nil { return err } @@ -74,9 +74,8 @@ func ValidatePokemonArgs(args []string) error { } } - // Validate each argument after the Pokémon's name - if len(args) > 3 { - for _, arg := range args[3:] { + if len(args) > 2 { + for _, arg := range args[2:] { // Check for an empty flag after Pokémon's name if arg == "-" || arg == "--" { return fmt.Errorf("%s", FormatError(fmt.Sprintf("Empty flag '%s'.\nPlease specify valid flag(s).", arg))) diff --git a/cmd/utils/validateargs_test.go b/cmd/utils/validateargs_test.go index aadfd2d..2a59fb5 100644 --- a/cmd/utils/validateargs_test.go +++ b/cmd/utils/validateargs_test.go @@ -9,7 +9,6 @@ import ( ) func TestCheckLength(t *testing.T) { - // Define test cases tests := []struct { name string args []string @@ -17,446 +16,268 @@ func TestCheckLength(t *testing.T) { wantErr bool expectedErr string }{ + {name: "empty slice", args: []string{}, maxLength: 1}, + {name: "within limit", args: []string{"arg1", "arg2"}, maxLength: 3}, + {name: "exactly at limit", args: []string{"arg1", "arg2", "arg3"}, maxLength: 3}, + {name: "exceeds limit", args: []string{"arg1", "arg2", "arg3", "arg4"}, maxLength: 3, wantErr: true, expectedErr: "Too many arguments"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkLength(tt.args, tt.maxLength) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, styling.StripANSI(err.Error()), tt.expectedErr) + return + } + assert.NoError(t, err) + }) + } +} + +func TestValidateArgs(t *testing.T) { + tests := []struct { + name string + args []string + validator Validator + wantErr bool + contains string + }{ + { + name: "ability accepts name and flag", + args: []string{"ability", "technician", "--pokemon"}, + validator: Validator{MaxArgs: 3, CmdName: "ability", RequireName: true, HasFlags: true}, + }, + { + name: "ability rejects missing name", + args: []string{"ability"}, + validator: Validator{MaxArgs: 3, CmdName: "ability", RequireName: true, HasFlags: true}, + wantErr: true, + contains: "Please declare", + }, + { + name: "ability rejects too many args", + args: []string{"ability", "strong-jaw", "all", "pokemon"}, + validator: Validator{MaxArgs: 3, CmdName: "ability", RequireName: true, HasFlags: true}, + wantErr: true, + contains: "Too many arguments", + }, + { + name: "berry accepts no name", + args: []string{"berry"}, + validator: Validator{MaxArgs: 3, CmdName: "berry"}, + }, + { + name: "berry accepts name", + args: []string{"berry", "oran"}, + validator: Validator{MaxArgs: 3, CmdName: "berry"}, + }, + { + name: "berry accepts help", + args: []string{"berry", "--help"}, + validator: Validator{MaxArgs: 3, CmdName: "berry"}, + }, + { + name: "berry rejects extra arg", + args: []string{"berry", "oran", "sitrus"}, + validator: Validator{MaxArgs: 3, CmdName: "berry"}, + wantErr: true, + contains: "only available options", + }, + { + name: "card accepts no args", + args: []string{"card"}, + validator: Validator{MaxArgs: 2, CmdName: "card"}, + }, + { + name: "card accepts help", + args: []string{"card", "--help"}, + validator: Validator{MaxArgs: 2, CmdName: "card"}, + }, + { + name: "card rejects extra arg", + args: []string{"card", "scarlet"}, + validator: Validator{MaxArgs: 2, CmdName: "card"}, + wantErr: true, + contains: "only available options", + }, + { + name: "item accepts name", + args: []string{"item", "potion"}, + validator: Validator{MaxArgs: 2, CmdName: "item", RequireName: true}, + }, + { + name: "item rejects missing name", + args: []string{"item"}, + validator: Validator{MaxArgs: 2, CmdName: "item", RequireName: true}, + wantErr: true, + contains: "Please declare", + }, + { + name: "item rejects too many args", + args: []string{"item", "potion", "extra"}, + validator: Validator{MaxArgs: 2, CmdName: "item", RequireName: true}, + wantErr: true, + contains: "Too many arguments", + }, + { + name: "move accepts name", + args: []string{"move", "thunderbolt"}, + validator: Validator{MaxArgs: 2, CmdName: "move", RequireName: true}, + }, + { + name: "move rejects missing name", + args: []string{"move"}, + validator: Validator{MaxArgs: 2, CmdName: "move", RequireName: true}, + wantErr: true, + contains: "Please declare", + }, + { + name: "move rejects too many args", + args: []string{"move", "tackle", "scratch"}, + validator: Validator{MaxArgs: 2, CmdName: "move", RequireName: true}, + wantErr: true, + contains: "Too many arguments", + }, + { + name: "natures accepts no args", + args: []string{"natures"}, + validator: Validator{MaxArgs: 2, CmdName: "natures"}, + }, + { + name: "natures accepts help", + args: []string{"natures", "--help"}, + validator: Validator{MaxArgs: 2, CmdName: "natures"}, + }, + { + name: "natures rejects extra arg", + args: []string{"natures", "brave"}, + validator: Validator{MaxArgs: 2, CmdName: "natures"}, + wantErr: true, + contains: "only available options", + }, + { + name: "natures rejects too many args", + args: []string{"natures", "brave", "--help"}, + validator: Validator{MaxArgs: 2, CmdName: "natures"}, + wantErr: true, + contains: "Too many arguments", + }, + { + name: "search accepts no args", + args: []string{"search"}, + validator: Validator{MaxArgs: 2, CmdName: "search"}, + }, + { + name: "search accepts help", + args: []string{"search", "--help"}, + validator: Validator{MaxArgs: 2, CmdName: "search"}, + }, + { + name: "search rejects extra arg", + args: []string{"search", "pokemon"}, + validator: Validator{MaxArgs: 2, CmdName: "search"}, + wantErr: true, + contains: "only available options", + }, + { + name: "speed accepts no args", + args: []string{"speed"}, + validator: Validator{MaxArgs: 2, CmdName: "speed"}, + }, + { + name: "speed accepts help", + args: []string{"speed", "--help"}, + validator: Validator{MaxArgs: 2, CmdName: "speed"}, + }, + { + name: "speed rejects extra arg", + args: []string{"speed", "100"}, + validator: Validator{MaxArgs: 2, CmdName: "speed"}, + wantErr: true, + contains: "only available options", + }, + { + name: "tcg accepts no args", + args: []string{"tcg"}, + validator: Validator{MaxArgs: 2, CmdName: "tcg", HasFlags: true}, + }, + { + name: "tcg accepts web flag", + args: []string{"tcg", "--web"}, + validator: Validator{MaxArgs: 2, CmdName: "tcg", HasFlags: true}, + }, { - name: "Valid length - Empty slice", - args: []string{}, - maxLength: 1, - wantErr: false, - expectedErr: "", + name: "tcg rejects too many args", + args: []string{"tcg", "--web", "extra"}, + validator: Validator{MaxArgs: 2, CmdName: "tcg", HasFlags: true}, + wantErr: true, + contains: "Too many arguments", }, { - name: "Valid length - Within limit", - args: []string{"arg1", "arg2"}, - maxLength: 3, - wantErr: false, - expectedErr: "", + name: "types accepts no args", + args: []string{"types"}, + validator: Validator{MaxArgs: 2, CmdName: "types"}, }, { - name: "Valid length - Exactly at limit", - args: []string{"arg1", "arg2", "arg3"}, - maxLength: 3, - wantErr: false, - expectedErr: "", + name: "types accepts help", + args: []string{"types", "--help"}, + validator: Validator{MaxArgs: 2, CmdName: "types"}, }, { - name: "Invalid length - Exceeds limit", - args: []string{"arg1", "arg2", "arg3", "arg4"}, - maxLength: 3, - wantErr: true, - expectedErr: "Too many arguments", + name: "types rejects extra arg", + args: []string{"types", "rock"}, + validator: Validator{MaxArgs: 2, CmdName: "types"}, + wantErr: true, + contains: "only available options", }, } - // Run test cases for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := checkLength(tt.args, tt.maxLength) - - // Check if an error was expected + err := ValidateArgs(tt.args, tt.validator) if tt.wantErr { require.Error(t, err) - assert.Contains(t, styling.StripANSI(err.Error()), tt.expectedErr) - } else { - assert.NoError(t, err) + assert.Contains(t, styling.StripANSI(err.Error()), tt.contains) + return } + require.NoError(t, err) }) } } -func TestValidateAbilityArgs(t *testing.T) { - // Testing valid arguments - validInputs := [][]string{ - {"poke-cli", "ability", "--help"}, - {"poke-cli", "ability", "inner-focus"}, - {"poke-cli", "ability", "unaware", "-h"}, - {"poke-cli", "ability", "technician", "--pokemon"}, - } - - for _, input := range validInputs { - err := ValidateArgs(input, Validator{MaxArgs: 4, CmdName: "ability", RequireName: true, HasFlags: true}) - require.NoError(t, err, "Expected no error for valid input") - } - - // Testing invalid arguments - invalidInputs := [][]string{ - {"poke-cli", "abilities"}, - } - - for _, input := range invalidInputs { - err := ValidateArgs(input, Validator{MaxArgs: 4, CmdName: "ability", RequireName: true, HasFlags: true}) - require.Error(t, err, "Expected error for invalid input") - } - - // Testing too many arguments - tooManyArgs := [][]string{ - {"poke-cli", "ability", "strong-jaw", "all", "pokemon"}, - } - - expectedError := styling.StripANSI("╭──────────────────╮\n│✖ Error! │\n│Too many arguments│\n╰──────────────────╯") - - for _, input := range tooManyArgs { - err := ValidateArgs(input, Validator{MaxArgs: 4, CmdName: "ability", RequireName: true, HasFlags: true}) - - if err == nil { - t.Fatalf("Expected an error for input %v, but got nil", input) - } - - strippedErr := styling.StripANSI(err.Error()) - assert.Equal(t, expectedError, strippedErr, "Unexpected error message for invalid input") - } -} - -func TestValidateNaturesArgs(t *testing.T) { - // Testing valid arguments - validInputs := [][]string{ - {"poke-cli", "natures"}, - {"poke-cli", "natures", "--help"}, - } - - for _, input := range validInputs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "natures", RequireName: false, HasFlags: false}) - require.NoError(t, err, "Expected no error for valid input") - } - - // Testing invalid arguments - invalidInputs := [][]string{ - {"poke-cli", "natures", "docile"}, - {"poke-cli", "natures", "brave", "--help"}, - } - - for _, input := range invalidInputs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "natures", RequireName: false, HasFlags: false}) - assert.Error(t, err, "Expected error for invalid input") - } -} - -// TestValidatePokemonArgs tests the ValidatePokemonArgs function func TestValidatePokemonArgs(t *testing.T) { - // Testing valid arguments validInputs := [][]string{ - {"poke-cli", "pokemon", "--help"}, - {"poke-cli", "pokemon", "mankey"}, - {"poke-cli", "pokemon", "talonflame", "--stats", "--types"}, - {"poke-cli", "pokemon", "passimian", "--abilities", "-t"}, - {"poke-cli", "pokemon", "dodrio", "-a", "-s", "-t"}, - {"poke-cli", "pokemon", "dragalge", "-a", "-s", "-t", "--image=sm"}, - {"poke-cli", "pokemon", "squirtle", "-a", "-s"}, - {"poke-cli", "pokemon", "dragapult", "-s", "-a"}, + {"pokemon", "--help"}, + {"pokemon", "mankey"}, + {"pokemon", "talonflame", "--stats", "--types"}, + {"pokemon", "passimian", "--abilities", "-t"}, + {"pokemon", "dodrio", "-a", "-s", "-t"}, + {"pokemon", "dragalge", "-a", "-s", "-t", "--image=sm"}, + {"pokemon", "squirtle", "-a", "-s"}, + {"pokemon", "dragapult", "-s", "-a"}, } for _, input := range validInputs { err := ValidatePokemonArgs(input) - require.NoError(t, err, "Expected no error for valid input") - } - - // Testing invalid arguments - invalidInputs := [][]string{ - {"poke-cli"}, - {"poke-cli", "pokemon"}, - {"poke-cli", "pokemons"}, - {"poke-cli", "pokemon", "mewtwo", "--"}, - {"poke-cli", "pokemon", "baxcalibur", "-"}, - {"poke-cli", "pokemon", "charizard", "extraArg"}, - } - - for _, input := range invalidInputs { - err := ValidatePokemonArgs(input) - require.Error(t, err, "Expected error for invalid input") - } - - // Testing too many arguments - tooManyArgs := [][]string{ - {"poke-cli", "pokemon", "hypo", "--abilities", "-s", "--types", "--image=sm", "-m", "-p"}, - } - - expectedError := styling.StripANSI("╭──────────────────╮\n│✖ Error! │\n│Too many arguments│\n╰──────────────────╯") - - for _, input := range tooManyArgs { - err := ValidatePokemonArgs(input) - - if err == nil { - t.Fatalf("Expected an error for input %v, but got nil", input) - } - - strippedErr := styling.StripANSI(err.Error()) - assert.Equal(t, expectedError, strippedErr, "Unexpected error message for invalid input") - } -} - -// TestValidateBerryArgs tests the ValidateBerryArgs function -func TestValidateBerryArgs(t *testing.T) { - validInputs := [][]string{ - {"poke-cli", "berry"}, - {"poke-cli", "berry", "--help"}, - } - - for _, input := range validInputs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "berry", RequireName: false, HasFlags: false}) - require.NoError(t, err, "Expected no error for valid input") - } - - invalidInputs := [][]string{ - {"poke-cli", "berry", "oran"}, - } - - for _, input := range invalidInputs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "berry", RequireName: false, HasFlags: false}) - require.Error(t, err, "Expected error for invalid input") - } - - tooManyArgs := [][]string{ - {"poke-cli", "berry", "oran", "sitrus"}, - } - - expectedError := styling.StripANSI("╭──────────────────╮\n│✖ Error! │\n│Too many arguments│\n╰──────────────────╯") - - for _, input := range tooManyArgs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "berry", RequireName: false, HasFlags: false}) - - if err == nil { - t.Fatalf("Expected an error for input %v, but got nil", input) - } - - strippedErr := styling.StripANSI(err.Error()) - assert.Equal(t, expectedError, strippedErr, "Unexpected error message for invalid input") - } -} - -// TestValidateCardArgs tests the ValidateCardArgs function -func TestValidateCardArgs(t *testing.T) { - validInputs := [][]string{ - {"poke-cli", "card"}, - {"poke-cli", "card", "--help"}, - } - - for _, input := range validInputs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "card", RequireName: false, HasFlags: false}) - require.NoError(t, err, "Expected no error for valid input") - } - - invalidInputs := [][]string{ - {"poke-cli", "card", "scarlet"}, - } - - for _, input := range invalidInputs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "card", RequireName: false, HasFlags: false}) - require.Error(t, err, "Expected error for invalid input") - } - - tooManyArgs := [][]string{ - {"poke-cli", "card", "scarlet", "violet"}, - } - - expectedError := styling.StripANSI("╭──────────────────╮\n│✖ Error! │\n│Too many arguments│\n╰──────────────────╯") - - for _, input := range tooManyArgs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "card", RequireName: false, HasFlags: false}) - - if err == nil { - t.Fatalf("Expected an error for input %v, but got nil", input) - } - - strippedErr := styling.StripANSI(err.Error()) - assert.Equal(t, expectedError, strippedErr, "Unexpected error message for invalid input") - } -} - -// TestValidateItemArgs tests the ValidateItemArgs function -func TestValidateItemArgs(t *testing.T) { - validInputs := [][]string{ - {"poke-cli", "item", "--help"}, - {"poke-cli", "item", "potion"}, - {"poke-cli", "item", "master-ball"}, - } - - for _, input := range validInputs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "item", RequireName: true, HasFlags: false}) - require.NoError(t, err, "Expected no error for valid input") - } - - invalidInputs := [][]string{ - {"poke-cli", "item"}, - } - - for _, input := range invalidInputs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "item", RequireName: true, HasFlags: false}) - require.Error(t, err, "Expected error for invalid input") - } - - tooManyArgs := [][]string{ - {"poke-cli", "item", "potion", "super-potion"}, - } - - expectedError := styling.StripANSI("╭──────────────────╮\n│✖ Error! │\n│Too many arguments│\n╰──────────────────╯") - - for _, input := range tooManyArgs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "item", RequireName: true, HasFlags: false}) - - if err == nil { - t.Fatalf("Expected an error for input %v, but got nil", input) - } - - strippedErr := styling.StripANSI(err.Error()) - assert.Equal(t, expectedError, strippedErr, "Unexpected error message for invalid input") - } -} - -// TestValidateMoveArgs tests the ValidateMoveArgs function -func TestValidateMoveArgs(t *testing.T) { - validInputs := [][]string{ - {"poke-cli", "move", "--help"}, - {"poke-cli", "move", "thunderbolt"}, - {"poke-cli", "move", "Dragon-Tail"}, - } - - for _, input := range validInputs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "move", RequireName: true, HasFlags: false}) - require.NoError(t, err, "Expected no error for valid input") - } - - invalidInputs := [][]string{ - {"poke-cli", "move"}, - } - - for _, input := range invalidInputs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "move", RequireName: true, HasFlags: false}) - require.Error(t, err, "Expected error for invalid input") - } - - tooManyArgs := [][]string{ - {"poke-cli", "move", "tackle", "scratch"}, - } - - expectedError := styling.StripANSI("╭──────────────────╮\n│✖ Error! │\n│Too many arguments│\n╰──────────────────╯") - - for _, input := range tooManyArgs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "move", RequireName: true, HasFlags: false}) - - if err == nil { - t.Fatalf("Expected an error for input %v, but got nil", input) - } - - strippedErr := styling.StripANSI(err.Error()) - assert.Equal(t, expectedError, strippedErr, "Unexpected error message for invalid input") - } -} - -// TestValidateSearchArgs tests the ValidateSearchArgs function -func TestValidateSearchArgs(t *testing.T) { - validInputs := [][]string{ - {"poke-cli", "search"}, - {"poke-cli", "search", "--help"}, - } - - for _, input := range validInputs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "search", RequireName: false, HasFlags: false}) - require.NoError(t, err, "Expected no error for valid input") - } - - invalidInputs := [][]string{ - {"poke-cli", "search", "pokemon"}, - } - - for _, input := range invalidInputs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "search", RequireName: false, HasFlags: false}) - require.Error(t, err, "Expected error for invalid input") - } - - tooManyArgs := [][]string{ - {"poke-cli", "search", "pokemon", "meowscarada"}, - } - - expectedError := styling.StripANSI("╭──────────────────╮\n│✖ Error! │\n│Too many arguments│\n╰──────────────────╯") - - for _, input := range tooManyArgs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "search", RequireName: false, HasFlags: false}) - - if err == nil { - t.Fatalf("Expected an error for input %v, but got nil", input) - } - - strippedErr := styling.StripANSI(err.Error()) - assert.Equal(t, expectedError, strippedErr, "Unexpected error message for invalid input") - } -} - -// TestValidateTypesArgs tests the ValidateTypesArgs function -func TestValidateTypesArgs(t *testing.T) { - // Testing valid arguments - validInputs := [][]string{ - {"poke-cli", "types"}, - {"poke-cli", "types", "--help"}, - } - - for _, input := range validInputs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "types", RequireName: false, HasFlags: false}) - require.NoError(t, err, "Expected no error for valid input") - } - - // Testing invalid arguments - invalidInputs := [][]string{ - {"poke-cli", "types", "rock"}, - } - - for _, input := range invalidInputs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "types", RequireName: false, HasFlags: false}) - require.Error(t, err, "Expected error for invalid input") - } - - // Testing too many arguments - tooManyArgs := [][]string{ - {"poke-cli", "types", "rock", "pokemon"}, + require.NoError(t, err, "expected no error for valid input %v", input) } - expectedError := styling.StripANSI("╭──────────────────╮\n│✖ Error! │\n│Too many arguments│\n╰──────────────────╯") - - for _, input := range tooManyArgs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "types", RequireName: false, HasFlags: false}) - - if err == nil { - t.Fatalf("Expected an error for input %v, but got nil", input) - } - - strippedErr := styling.StripANSI(err.Error()) - assert.Equal(t, expectedError, strippedErr, "Unexpected error message for invalid input") - } -} - -// TestValidateSpeedArgs tests the ValidateSpeedArgs function -func TestValidateSpeedArgs(t *testing.T) { - validInputs := [][]string{ - {"poke-cli", "speed"}, - {"poke-cli", "speed", "--help"}, - } - - for _, input := range validInputs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "speed", RequireName: false, HasFlags: false}) - require.NoError(t, err, "Expected no error for valid input") - } - - invalidInputs := [][]string{ - {"poke-cli", "speed", "100"}, + invalidInputs := []struct { + args []string + contains string + }{ + {args: []string{"pokemon"}, contains: "Please declare"}, + {args: []string{"pokemons"}, contains: "Please declare"}, + {args: []string{"pokemon", "mewtwo", "--"}, contains: "Empty flag"}, + {args: []string{"pokemon", "baxcalibur", "-"}, contains: "Empty flag"}, + {args: []string{"pokemon", "charizard", "extraArg"}, contains: "Invalid argument"}, + {args: []string{"pokemon", "hypo", "--abilities", "-s", "--types", "--image=sm", "-m", "-p"}, contains: "Too many arguments"}, } for _, input := range invalidInputs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "speed", RequireName: false, HasFlags: false}) - require.Error(t, err, "Expected error for invalid input") - } - - tooManyArgs := [][]string{ - {"poke-cli", "speed", "100", "200"}, - } - - expectedError := styling.StripANSI("╭──────────────────╮\n│✖ Error! │\n│Too many arguments│\n╰──────────────────╯") - - for _, input := range tooManyArgs { - err := ValidateArgs(input, Validator{MaxArgs: 3, CmdName: "speed", RequireName: false, HasFlags: false}) - - if err == nil { - t.Fatalf("Expected an error for input %v, but got nil", input) - } - - strippedErr := styling.StripANSI(err.Error()) - assert.Equal(t, expectedError, strippedErr, "Unexpected error message for invalid input") + err := ValidatePokemonArgs(input.args) + require.Error(t, err, "expected error for invalid input %v", input.args) + assert.Contains(t, styling.StripANSI(err.Error()), input.contains) } } diff --git a/connections/connection.go b/connections/connection.go index 3fc8f29..5da6d0f 100644 --- a/connections/connection.go +++ b/connections/connection.go @@ -15,8 +15,15 @@ import ( ) const APIURL = "https://pokeapi.co/api/v2/" +const maxAPIResponseBytes = 10 * 1024 * 1024 // 10 MiB -var httpClient = &http.Client{Timeout: 30 * time.Second} +const DefaultHTTPTimeout = 60 * time.Second + +var httpClient = NewDefaultHTTPClient() + +func NewDefaultHTTPClient() *http.Client { + return &http.Client{Timeout: DefaultHTTPTimeout} +} type EndpointResource interface { GetResourceName() string @@ -97,10 +104,13 @@ func ApiCallSetup(rawURL string, target interface{}, skipHTTPSCheck bool) error return HTTPStatusError{StatusCode: resp.StatusCode, URL: rawURL} } - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes+1)) if err != nil { return fmt.Errorf("error reading response body: %w", err) } + if len(body) > maxAPIResponseBytes { + return fmt.Errorf("response body exceeds %d bytes", maxAPIResponseBytes) + } err = json.Unmarshal(body, target) if err != nil { @@ -144,8 +154,7 @@ func CallTCGData(url string) ([]byte, error) { req.Header.Add("Authorization", "Bearer sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j") req.Header.Add("Content-Type", "application/json") - client := &http.Client{Timeout: 60 * time.Second} - resp, err := client.Do(req) + resp, err := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("error making GET request: %w", err) } diff --git a/connections/connection_test.go b/connections/connection_test.go index e96b99c..364a046 100644 --- a/connections/connection_test.go +++ b/connections/connection_test.go @@ -2,9 +2,11 @@ package connections import ( "encoding/json" + "io" "net/http" "net/http/httptest" "strconv" + "strings" "testing" "github.com/digitalghost-dev/poke-cli/structs" @@ -85,6 +87,19 @@ func TestApiCallSetup(t *testing.T) { }) } }) + + t.Run("response body over limit returns error", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := io.WriteString(w, strings.Repeat("x", maxAPIResponseBytes+1)) + assert.NoError(t, err) + })) + defer ts.Close() + + var target map[string]string + err := ApiCallSetup(ts.URL, &target, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "response body exceeds") + }) } func TestAbilityApiCall(t *testing.T) { diff --git a/docs/Infrastructure_Guide/cloudflare-tunnel.md b/docs/Infrastructure_Guide/cloudflare-tunnel.md new file mode 100644 index 0000000..a5c8a69 --- /dev/null +++ b/docs/Infrastructure_Guide/cloudflare-tunnel.md @@ -0,0 +1,355 @@ +--- +weight: 9 +--- + +# 9 // Cloudflare Tunnel + +!!! question "What is Cloudflare Tunnel?" + + Cloudflare Tunnel exposes a service running behind a private network (an AWS VM, a home server, etc.) + to the public internet through Cloudflare's edge — without opening any inbound ports. The VM runs the + `cloudflared` daemon, which makes an outbound connection to Cloudflare; the tunnel terminates inside + the Cloudflare account and traffic is routed to a hostname on a configured domain. + +## Overview + +Cloudflare Tunnel is used here to make the **Dagster webserver** (running on an EC2 instance) reachable +to **n8n Cloud** so that Pipeline 3 in [8 // n8n](n8n.md#pipeline-3-speed-tiers-scrape) can launch a Dagster +job via the GraphQL API. n8n Cloud's dynamic egress IPs never need to be whitelisted because the tunnel is an +outbound connection initiated by the VM, not an inbound one accepted by it. + +The end result: `https://dagster.example.com` resolves through Cloudflare to the Dagster UI/GraphQL +endpoint, with no public ports opened on the VM. The hostname is protected by Cloudflare Access, so +requests are rejected unless they include the `CF-Access-Client-Id` and `CF-Access-Client-Secret` +headers from the n8n service token created in Cloudflare Zero Trust. + +## Prerequisites + +* A **Cloudflare account** with a domain whose nameservers point at Cloudflare. +* SSH access to the AWS VM running Dagster +* Dagster running on `localhost:3000` on the VM (default port). + +This guide uses the following placeholder values: + +| Placeholder | Meaning | +|---|---| +| `dagster.example.com` | Public hostname routed through Cloudflare Tunnel | +| `example.com` | Domain managed by Cloudflare | +| `dagster` | Subdomain for the Dagster hostname | +| `dagster-tunnel` | Cloudflare Tunnel name | +| `` | Tunnel credentials UUID generated by Cloudflare | +| `` | Dagster repository location name | +| `` | Dagster repository name | +| `` | Dagster job to launch from n8n | + +## Setup + +### 1. Install cloudflared on the VM + +_For ARM64 VMs, use the `linux-arm64` binary. For x86_64 VMs, use the matching `linux-amd64` binary._ + +```bash +sudo curl -L \ + https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64 \ + -o /usr/local/bin/cloudflared + +sudo chmod +x /usr/local/bin/cloudflared + +cloudflared --version +``` + +### 2. Authenticate with Cloudflare + +```bash +cloudflared tunnel login +``` + +This prints a URL. Open it in a local browser, log in to Cloudflare, and pick the relevant domain from +the list. `cloudflared` writes a cert to `~/.cloudflared/cert.pem` on the VM. + +### 3. Create the Tunnel + +```bash +cloudflared tunnel create dagster-tunnel +``` + +This prints a tunnel UUID and writes `~/.cloudflared/.json` (the tunnel credentials). The +UUID goes into the config file in step 6. + +### 4. Route DNS + +```bash +cloudflared tunnel route dns dagster-tunnel dagster.example.com +``` + +This adds a CNAME from `dagster.example.com` → the tunnel automatically. Verify in the Cloudflare dashboard +under **DNS** that a new proxied (orange-cloud) CNAME entry was added. + +### 5. Move Credentials to a System Location + +_Both `cert.pem` and the tunnel credentials JSON live in the user's `~/.cloudflared/` directory by default, +but the systemd service runs as a different user that can't read `/home/ubuntu/`. Create the system +config directory and copy both files there._ + +```bash +sudo mkdir -p /etc/cloudflared +sudo cp /home/ubuntu/.cloudflared/cert.pem /etc/cloudflared/ +sudo cp /home/ubuntu/.cloudflared/*.json /etc/cloudflared/ +sudo chmod 600 /etc/cloudflared/cert.pem /etc/cloudflared/*.json +``` + +The `chmod 600` locks both files to owner read/write only — they're credentials and should be treated +like SSH keys. + +### 6. Write the Tunnel Config + +_Write the YAML pointing at the credentials file from step 5 and routing the hostname to Dagster's port._ + +```bash +sudo tee /etc/cloudflared/config.yml > /dev/null << EOF +tunnel: dagster-tunnel +credentials-file: /etc/cloudflared/.json + +ingress: + - hostname: dagster.example.com + service: http://localhost:3000 + - service: http_status:404 +EOF +``` + +Verify the config: + +```bash +sudo cat /etc/cloudflared/config.yml +``` + +The `credentials-file:` line should read `/etc/cloudflared/.json`. +The trailing `http_status:404` rule is mandatory since Cloudflare's tunnel +ingress always requires a catch-all. + +### 7. Install as a `systemd` Service + +```bash +sudo cloudflared service install + +sudo systemctl status cloudflared +``` + +Look for `active (running)` in the status output. The service is set to start on boot automatically. + +If the service fails to start, the most common diagnostic is: + +```bash +sudo journalctl -u cloudflared -n 30 --no-pager +``` + +### 8. Verify + +From a machine outside the VM: + +```bash +curl -fsSI https://dagster.example.com/server_info +``` + +A `200 OK` response with the `x-dagster-call-counts` header confirms the tunnel is forwarding traffic to +Dagster end-to-end. This check assumes Cloudflare Access has not been configured yet. At this stage, the tunnel +is **public** to the internet. It will be secured in [Locking Down the Tunnel](#locking-down-the-tunnel). + +## Calling the Dagster GraphQL API + +Once the tunnel is live, the Dagster GraphQL endpoint is reachable at `https://dagster.example.com/graphql`. +The examples in this section assume Cloudflare Access has not been configured yet. After the tunnel is +locked down, include the service token headers shown in [Locking Down the Tunnel](#locking-down-the-tunnel). + +### Discovering Jobs + +To enumerate available repositories, locations, and jobs: + +```bash +curl -fsS https://dagster.example.com/graphql \ + -H "Content-Type: application/json" \ + -d '{"query":"{ workspaceOrError { ... on Workspace { locationEntries { name locationOrLoadError { ... on RepositoryLocation { repositories { name jobs { name } } } } } } } }"}' \ + | jq '.data.workspaceOrError.locationEntries[] | {location: .name, repos: .locationOrLoadError.repositories | map({name, jobs: .jobs | map(.name)})}' +``` + +The output's `location` and `repos[].name` values are used in the `launchRun` mutation below. + +| Field | Value | +|---|---| +| `repositoryLocationName` | `` | +| `repositoryName` | `` | + +### Launching a Job + +The `launchRun` mutation in this Dagster version takes `executionParams: ExecutionParams!`, with the +selector nested inside. The selector field for the job name is `pipelineName`. Dagster's GraphQL retains +the older "pipeline" terminology even for asset jobs. + +```bash +curl -fsS https://dagster.example.com/graphql \ + -H "Content-Type: application/json" \ + -d '{ + "query": "mutation LaunchRun($p: ExecutionParams!) { launchRun(executionParams: $p) { __typename ... on LaunchRunSuccess { run { runId status } } ... on PythonError { message } ... on InvalidSubsetError { message } ... on RunConfigValidationInvalid { errors { message } } } }", + "variables": { + "p": { + "selector": { + "repositoryLocationName": "", + "repositoryName": "", + "pipelineName": "" + } + } + } + }' | jq . +``` + +Expected response on success: + +```json +{ + "data": { + "launchRun": { + "__typename": "LaunchRunSuccess", + "run": { + "runId": "", + "status": "QUEUED" + } + } + } +} +``` + +The run will then appear in the Dagster UI under **Runs**, transition through `QUEUED → STARTED → SUCCESS`, +and on completion fire the existing run-status webhook into n8n's +[Pipeline 1](n8n.md#pipeline-1-job-status-check) for Discord notification. + +## Locking Down the Tunnel + +The tunnel hostname should not be left publicly reachable. Cloudflare Access can require a service +token before traffic reaches Dagster, which lets n8n call the GraphQL endpoint without exposing the UI +or API to the open internet. + +### 1. Open Zero Trust + +From the main Cloudflare dashboard, select the account and use the left-hand menu to open +**Zero Trust**. + +If this is the first Zero Trust setup for the account, Cloudflare asks for a team name. A project or +account name is sufficient, then the **Free** plan can be selected. + +### 2. Create the Service Credential + +In the Zero Trust dashboard: + +1. Go to **Access controls → Service credentials → Service Tokens**. +2. Create a service token named `n8n-dagster-trigger`. +3. Copy both generated values: + * `CF-Access-Client-Id` + * `CF-Access-Client-Secret` + +!!! warning + + Cloudflare only shows the client secret once. Store it in the n8n HTTP Request node credentials or + another secure secret store before leaving the page. + +### 3. Create the Access Policy + +In **Access → Applications**, start a new self-hosted application and create a policy with: + +| Field | Value | +|---|---| +| **Policy name** | `n8n service token` | +| **Action** | `Service Auth` | +| **Rule type** | `Include` | +| **Selector** | `Service Token` | +| **Value** | `n8n-dagster-trigger` | + +If the **Value** dropdown has no service token options, create the service token first, then refresh the +application page. + +### 4. Create the Application + +Configure the self-hosted application: + +| Field | Value | +|---|---| +| **Application type** | `Self-hosted` | +| **Subdomain** | `dagster` | +| **Domain** | `example.com` | +| **Path** | leave blank | +| **Browser-based RDP, SSH, or VNC sessions** | off | +| **Policy** | `n8n service token` | + +Leaving **Path** blank protects the whole `dagster.example.com` hostname. That is intentional: both the +Dagster UI and `/graphql` endpoint should be behind Access. + +### 5. Test Access + +Without the service token headers, Cloudflare should block the request: + +```bash +curl -fsS https://dagster.example.com/graphql +``` + +Expected result: `403` or a Cloudflare Access login/block response. + +With the service token headers, Cloudflare should forward the request to Dagster: + +```bash +curl -fsS \ + -H "CF-Access-Client-Id: " \ + -H "CF-Access-Client-Secret: " \ + https://dagster.example.com/graphql +``` + +Expected result: a Dagster response. A `400` with `No GraphQL query found in the request` is a successful +Access test because it proves the request reached Dagster and only failed because no GraphQL body was sent. + +Cloudflare validates these headers at the edge before forwarding to Dagster. No VM-side changes are +required. + +## Wiring n8n to the Locked-Down Tunnel + +The n8n HTTP Request node that closes Pipeline 3's loop uses the same `launchRun` mutation body from +[Launching a Job](#launching-a-job). Settings: + +| Field | Value | +|---|---| +| **Method** | `POST` | +| **URL** | `https://dagster.example.com/graphql` | +| **Authentication** | None | +| **Send Headers** | enabled, with `Content-Type: application/json`, `CF-Access-Client-Id`, and `CF-Access-Client-Secret` | +| **Send Body** | enabled | +| **Body Content Type** | JSON | +| **Specify Body** | Using JSON | +| **Response → Response Format** | JSON | + +The Cloudflare Access headers use the service token values created in [Locking Down the Tunnel](#locking-down-the-tunnel): + +| Header | Value | +|---|---| +| `CF-Access-Client-Id` | the service token client ID | +| `CF-Access-Client-Secret` | the service token client secret | + +**Body:** + +```json +{ + "query": "mutation LaunchRun($p: ExecutionParams!) { launchRun(executionParams: $p) { __typename ... on LaunchRunSuccess { run { runId } } ... on PythonError { message } ... on InvalidSubsetError { message } ... on RunConfigValidationInvalid { errors { message } } } }", + "variables": { + "p": { + "selector": { + "repositoryLocationName": "", + "repositoryName": "", + "pipelineName": "" + } + } + } +} +``` + +A successful execution shows `data.launchRun.__typename = "LaunchRunSuccess"` plus a `runId` in the n8n +output panel, and a fresh run appears in the Dagster UI within seconds. + +--- + +Related: [n8n](n8n.md) | [AWS](aws.md) diff --git a/docs/Infrastructure_Guide/n8n.md b/docs/Infrastructure_Guide/n8n.md new file mode 100644 index 0000000..ce837ef --- /dev/null +++ b/docs/Infrastructure_Guide/n8n.md @@ -0,0 +1,406 @@ +--- +weight: 8 +--- + +# 8 // n8n + +!!! question "What is n8n?" + + n8n is a workflow automation platform that lets you wire together HTTP calls, databases, AI services, and notifications using a visual node editor. Each node is one step in a pipeline; data flows between them as JSON. n8n Cloud is the managed, hosted version where workflows run on n8n's infrastructure on a scheduled configuration. + +## Overview + +n8n is used in this project for a few different reasoons such as performing API status checks, sending sucess/failure notifications, or ingesting data from sources that are not in a friendly format like a REST API. + +One of n8n pipelines in this project, for example, scrapes `.md` files from [Pikalytics](https://www.pikalytics.com/ai/speed-tiers) with Firecrawl's LLM-powered extraction service to pull structured speed tier data into Supabase. + +This project uses n8n cloud. + +## Current Pipelines +* Pipeline 1 - Dagster Job Status Check +* Pipeline 2 - Supabase API Status Check +* Pipeline 3 - Champions Speed Tiers Scrape + +### Pipeline 1: Job Status Check + +_Receives Dagster run lifecycle webhooks and forwards a summary to Discord._ + +The TCG data pipeline emits a webhook on run completion (success or failure). This workflow accepts the +webhook payload and translates it into a Discord message, giving immediate visibility on pipeline status +without needing to log into the Dagster UI. + +#### Pipeline Shape + +```mermaid +graph LR + A[Webhook
POST /webhook/<path>] --> B[Discord
posts run details] +``` + +#### Workflow + +##### 1. Webhook + +_Public endpoint that Dagster posts to on run completion._ + +1. Add a **Webhook** node. +2. Configure: + * **HTTP Method:** `POST` + * **Path:** a randomized slug (e.g. `dagster-job-alert-webhook-7k9m2x`). The full URL becomes + `https:///webhook/`. + * **Authentication:** None + * **Response:** Immediately +3. No authentication is required on the webhook itself — the random path segment acts as a shared secret. + Sufficient for a low-value notification stream; rotate the path if it leaks. + +##### 2. Discord + +_Posts a templated run summary to a Discord channel._ + +1. Create a Discord server and within the defaut text channel, create a webhook. + 1. Server Settings + 2. Integrations + 3. Webhooks + 4. New Webhook + 5. Name the webhook and copy the URL. +2. Back in n8n, add a **Discord** node (send a message), connected to the Webhook output. +3. Configure: + * **Connection Type:** Webhook + * **Credential for Discord Webhook:** Click on *Set up Credential* and paste the webhook URL from Discord. +3. Set the message content to: + +```text +Job: {{ $json.body.job_name }} +Status: {{ $json.body.status }} +Run ID: {{ $json.body.run_id }} +``` + +#### Dagster-side Configuration + +Dagster's run status sensor (or an asset post-hook) posts a JSON payload of the form: + +```json +{ + "job_name": "tcg_pricing", + "status": "SUCCESS", + "run_id": "abc123..." +} +``` + +to the n8n webhook URL on every run completion. The status field is one of `SUCCESS` or `FAILURE`. + +--- + +### Pipeline 2: API Status Check + +_Daily health check on the Supabase TCG API. Posts the result to Discord either way._ + +Catches outages or breaking changes on the `card_pricing_view` REST endpoint that powers the `card` +command's pricing data. A daily heartbeat is enough — this isn't synthetic monitoring of every endpoint, +just a smoke test that the most user-facing view is reachable. + +#### Pipeline Shape + +```mermaid +graph LR + A[Schedule Trigger
daily @ 9 AM] --> B[HTTP Request
GET card_pricing_view?limit=1] + B --> C{If
statusCode == 200?} + C -->|true| D[Discord
success] + C -->|false| E[Discord
failure] +``` + +#### Workflow + +##### 1. Schedule Trigger + +_Fires the workflow once a day._ + +1. Add a **Schedule Trigger** node. +2. Set the rule to trigger at hour `9` (the n8n default uses the workflow's configured timezone — UTC unless overridden). + +##### 2. HTTP Request + +_Hits the Supabase REST view with a 1-row limit to confirm the endpoint is reachable._ + +1. Add an **HTTP Request** node, connected to the Schedule Trigger. +2. Configure: + * **Method:** `GET` + * **URL:** `https://.supabase.co/rest/v1/card_pricing_view?limit=1` + * **Send Headers:** enabled, with three header parameters: + * `apikey`: your Supabase publishable key + * `Authorization`: `Bearer ` + * `Content-Type`: `application/json` + * **Response → Full Response:** enabled — this passes through the HTTP status code (not just the body) + so the next node can branch on it. + +!!! note + + The publishable key (`sb_publishable_*`) is designed to be safe to expose client-side and is sufficient + for read-only health checks. Do not use the secret/service-role key for this. + +##### 3. If + +_Branches the flow based on whether the API responded successfully._ + +1. Add an **If** node, connected to the HTTP Request output. +2. Configure a single condition: + * **Left value:** `{{ $json.statusCode }}` + * **Operator:** `number` / `equals` + * **Right value:** `200` + +The True branch goes to the success Discord node; the False branch goes to the failure Discord node. + +##### 4. Discord (Success / Failure) + +_Posts the result to a Discord channel either way._ + +Two Discord nodes, one on each branch of the If node. Both use **Webhook** authentication. + +* **Success branch:** content `API response check: {{ $json.statusCode }}` +* **Failure branch:** content `API Response Fail: {{ $('HTTP Request').item.json.statusCode }}` + +The failure branch references the upstream HTTP Request explicitly because once the If node's False branch +takes over, `$json` reflects the If node's output rather than the original HTTP response. + +--- + +### Pipeline 3: Speed Tiers Scrape + +#### Pipeline Shape + +```mermaid +sequenceDiagram + autonumber + participant n8n + participant Firecrawl + participant Supabase + participant Dagster + participant dbt + + Note over n8n: Schedule Trigger fires (monthly) + n8n->>Firecrawl: POST /v2/scrape (URL + JSON schema) + Firecrawl-->>n8n: 263 rows (rank, pokemon, base_spe) + Note over n8n: Code node derives 6 speed columns + n8n->>Supabase: UPSERT staging.champions_speed_tiers + Supabase-->>n8n: 263 rows affected + n8n->>Dagster: POST /graphql launchRun(...) + Dagster-->>n8n: { runId } + Dagster->>dbt: dbt build --select tag:champions_speed_tiers + dbt->>Supabase: CREATE OR REPLACE public.champions_speed_tiers + Supabase-->>dbt: ok + dbt-->>Dagster: success +``` + +#### Create an Account + +Visit the n8n [sign-up page](https://n8n.io/) to create an account. The Cloud plan is recommended for this project — +it removes the operational overhead of self-hosting. + +#### Supabase: Create the Staging Table + +_Run this SQL in the Supabase SQL Editor once before configuring the n8n workflow._ + +```sql +-- This should already exist from the initial Supabase setup +create schema if not exists staging; + +create table if not exists staging.champions_speed_tiers ( + snapshot_month date not null, + format text not null, + rank int not null, + pokemon text not null, + base_spe int not null, + neutral_0_sp int not null, + neutral_32_sp int not null, + max_speed int not null, + neg_spe_0_sp int not null, + max_scarf int not null, + neutral_32_scarf int not null, + ingested_at timestamptz not null default now(), + primary key (snapshot_month, format, pokemon) +); + +create index if not exists idx_speed_tiers_rank + on staging.champions_speed_tiers (snapshot_month, format, rank); +``` + +The composite primary key on `(snapshot_month, format, pokemon)` makes the upsert idempotent. re-runs within the same +month overwrite rather than duplicate. + +#### Workflow + +##### 1. Schedule Trigger + +_Fires the workflow on a monthly cadence._ + +1. Add a **Schedule Trigger** node. + +2. Set the cron expression to `0 8 5 * *` (8 AM UTC on the 5th of each month). + +3. Pikalytics regenerates its AI endpoints around the 1st of each month — running on the 5th gives a buffer in case + regeneration is delayed. + +##### 2. Firecrawl Scrape + +_Hits Pikalytics' AI markdown endpoint and extracts structured rows via Firecrawl's JSON mode._ + +1. Create a [Firecrawl](https://www.firecrawl.dev/signin?view=signup) account to get an API key. The free tier is sufficient for this use case. +2. After account creation, find your API key in the [dashboard](https://www.firecrawl.dev/app/api-keys) and copy it. +3. In n8n, add a **Firecrawl** node. +4. Set up the credential with your Firecrawl API key. +5. Configure the node: + * **Resource:** `Scraping` + * **Operation:** `/scrape` + * **URL:** `https://www.pikalytics.com/ai/speed-tiers` +6. Under **Scrape Options**, add **Formats** and configure: + * **Type:** JSON + * **Prompt:** `Extract every row from the Champions Speed Tiers table. Each row maps to one Pokemon. Do not skip any rows.` + * **Schema:** + ```json + { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "object", + "properties": { + "rank": { "type": "integer" }, + "pokemon": { "type": "string" }, + "base_spe": { "type": "integer" } + }, + "required": ["rank", "pokemon", "base_spe"] + } + } + } + } + ``` +7. Set **Timeout (Ms)** to `120000`. The LLM extraction takes ~55 seconds for 263 rows. +8. Leave **Only Main Content** enabled. + +!!! note "Why only 3 fields?" + + Pikalytics' speed tier table has 9 columns, but the other 6 are deterministic functions of `base_spe` + (Champions uses fixed level 50, max 32 Skill Points, 31 IVs). Asking the LLM to emit all 9 fields × 263 rows + pushes past Firecrawl's completion limits and times out. Asking for just `rank`, `pokemon`, `base_spe` + succeeds reliably and lets us derive the rest in the next node. + +##### 3. Code (Math + Metadata) + +_Derives the remaining six speed columns from `base_spe` and stamps each row with snapshot metadata._ + +1. Add a **Code** node, connected to Firecrawl's output. +2. Set **Mode** to `Run Once for All Items` and **Language** to `JavaScript`. +3. Paste: + +```javascript +const fc = $input.first().json.data.json.rows; + +const today = new Date(); +const snapshotMonth = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-01`; + +const rows = fc.map(r => { + const neutral_0_sp = r.base_spe + 20; + const neutral_32_sp = r.base_spe + 52; + const max_speed = Math.floor(neutral_32_sp * 1.1); + return { + snapshot_month: snapshotMonth, + format: "gen9championsvgc2026", + rank: r.rank, + pokemon: r.pokemon, + base_spe: r.base_spe, + neutral_0_sp, + neutral_32_sp, + max_speed, + neg_spe_0_sp: Math.floor(neutral_0_sp * 0.9), + max_scarf: Math.floor(max_speed * 1.5), + neutral_32_scarf: Math.floor(neutral_32_sp * 1.5), + }; +}); + +if (rows.length < 200) { + throw new Error(`Speed tiers row count looks wrong: got ${rows.length}, expected ~263`); +} + +return rows.map(json => ({ json })); +``` + +The `rows.length < 200` guard turns silent breakage (e.g. Pikalytics changes the table layout) into a workflow +failure that surfaces immediately rather than letting bad data into Postgres. + +The derivation formulas are: + +| Column | Formula | +|--------|---------| +| `neutral_0_sp` | `base_spe + 20` | +| `neutral_32_sp` | `base_spe + 52` | +| `max_speed` | `floor((base_spe + 52) * 1.1)` | +| `neg_spe_0_sp` | `floor((base_spe + 20) * 0.9)` | +| `max_scarf` | `floor(max_speed * 1.5)` | +| `neutral_32_scarf` | `floor((base_spe + 52) * 1.5)` | + +!!! warning "n8n Cloud's Python sandbox" + + The Code node also supports Python, but n8n Cloud runs Python via Pyodide with no standard library access + (`datetime`, `math`, etc. are all blocked). For this reason JavaScript is the more practical choice for + transformations inside n8n. Python work for this project lives in the Dagster pipeline instead. + +##### 4. Postgres Upsert (Supabase) + +_Lands the transformed rows in `staging.champions_speed_tiers` idempotently._ + +1. Add a **Postgres** node, connected to the Code node's output. +2. Set up the credential with the Supabase **Session Pooler** connection string (port `6543`). The pooler is + recommended for serverless callers like n8n Cloud. +3. Configure the node: + * **Operation:** `Insert or Update` (Upsert) + * **Schema:** `staging` + * **Table:** `champions_speed_tiers` + * **Mapping Column Mode:** `Map Automatically` (the JSON keys from the Code node match column names exactly) + * **Columns to Match On:** `snapshot_month`, `format`, `pokemon` + +!!! note + + Supabase requires SSL. If the connection fails with a `pg_hba.conf` error, ensure SSL is enabled in the + credential settings. + +##### 5. Trigger Dagster (Planned) + +_Once the staging row lands, n8n hits the Dagster GraphQL endpoint to launch the dbt-materialization job._ + +This step calls Dagster's `launchRun` mutation against the `dagster-webserver` instance running on EC2. +Because n8n Cloud has dynamic egress IPs, Dagster is fronted by a Cloudflare Tunnel rather than exposed +through the EC2 security group directly. + +The HTTP Request node will POST to `https:///graphql` with the mutation: + +```graphql +mutation LaunchRun($s: JobOrPipelineSelector!) { + launchRun(selector: $s) { + __typename + ... on LaunchRunSuccess { run { runId } } + ... on PythonError { message } + } +} +``` + +Selecting the `champions_speed_tiers_dbt_job` job in the `card_data` repository location. + +##### 6. Notify (Planned) + +_Posts run status to Discord on success or failure._ + +A final HTTP Request (or Discord) node sends a webhook with run summary — row count, duration, dbt status — +mirroring the existing Dagster-run notifications described in the [Overview](index.md#data-infrastructure-diagram). + +##### 7. Verifying the Pipeline + +After the upsert step succeeds: + +1. Open Supabase → Table Editor → `staging.champions_speed_tiers`. +2. Confirm 263 rows exist with `snapshot_month` set to the first of the current month. +3. Spot-check a handful of rows against [pikalytics.com/speed-tiers](https://www.pikalytics.com/speed-tiers) — + for example, Mega Aerodactyl should appear at rank 1 with `base_spe = 150`, `max_speed = 222`. + +--- + +Related: [Supabase](supabase.md) | [AWS](aws.md) | [Grafana Cloud](grafana.md) diff --git a/docs/installation.md b/docs/installation.md index 4eb4584..bf6eb6a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -63,11 +63,11 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an 3. Choose how to interact with the container: * Run a single command and exit: ```console - docker run --rm -it digitalghostdev/poke-cli:v1.10.1 [subcommand] [flag] + docker run --rm -it digitalghostdev/poke-cli:v1.10.2 [subcommand] [flag] ``` * Enter the container and use its shell: ```console - docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.10.1 -c "cd /app && exec sh" + docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.10.2 -c "cd /app && exec sh" # placed into the /app directory, run the program with './poke-cli' # example: ./poke-cli ability swift-swim ``` @@ -77,13 +77,13 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an The `card` command renders TCG card images using your terminal's graphics protocol. When running inside Docker, pass your terminal's environment variables so image rendering works correctly: ```console # Kitty - docker run --rm -it -e TERM -e KITTY_WINDOW_ID digitalghostdev/poke-cli:v1.10.1 card + docker run --rm -it -e TERM -e KITTY_WINDOW_ID digitalghostdev/poke-cli:v1.10.2 card # WezTerm, iTerm2, Ghostty, Konsole, Rio, Tabby - docker run --rm -it -e TERM -e TERM_PROGRAM digitalghostdev/poke-cli:v1.10.1 card + docker run --rm -it -e TERM -e TERM_PROGRAM digitalghostdev/poke-cli:v1.10.2 card # Windows Terminal (Sixel) - docker run --rm -it -e WT_SESSION digitalghostdev/poke-cli:v1.10.1 card + docker run --rm -it -e WT_SESSION digitalghostdev/poke-cli:v1.10.2 card ``` If your terminal is not listed above, image rendering is not supported inside Docker. diff --git a/docs/nginx.conf b/docs/nginx.conf index c75c6f0..e80b0c1 100644 --- a/docs/nginx.conf +++ b/docs/nginx.conf @@ -3,7 +3,7 @@ server { root /usr/share/nginx/html; index index.html; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.github.com;" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://unpkg.com https://api.github.com;" always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-XSS-Protection "1; mode=block" always; diff --git a/flags/pokemonflagset.go b/flags/pokemonflagset.go index 2748d0f..d0d34d6 100644 --- a/flags/pokemonflagset.go +++ b/flags/pokemonflagset.go @@ -27,6 +27,10 @@ import ( "golang.org/x/text/language" ) +const maxPokemonSpriteBytes = 5 * 1024 * 1024 // 5 MiB + +var pokemonSpriteHTTPClient = connections.NewDefaultHTTPClient() + type PokemonFlags struct { FlagSet *flag.FlagSet Abilities *bool @@ -389,14 +393,20 @@ func ImageFlag(w io.Writer, endpoint string, pokemonName string, size string) er return str.String() } - imageResp, err := http.Get(pokemonStruct.Sprites.FrontDefault) + imageResp, err := pokemonSpriteHTTPClient.Get(pokemonStruct.Sprites.FrontDefault) if err != nil { fmt.Println("Error downloading sprite image:", err) return err } defer imageResp.Body.Close() - img, err := imaging.Decode(imageResp.Body) + if imageResp.StatusCode != http.StatusOK { + err := fmt.Errorf("unexpected sprite response status: %d", imageResp.StatusCode) + fmt.Println("Error downloading sprite image:", err) + return err + } + + img, err := imaging.Decode(io.LimitReader(imageResp.Body, maxPokemonSpriteBytes)) if err != nil { fmt.Println("Error decoding image:", err) return err diff --git a/flags/version.go b/flags/version.go index 91c6eaa..2c2249d 100644 --- a/flags/version.go +++ b/flags/version.go @@ -12,9 +12,14 @@ import ( "strings" "charm.land/lipgloss/v2" + "github.com/digitalghost-dev/poke-cli/connections" "github.com/digitalghost-dev/poke-cli/styling" ) +const maxLatestReleaseBytes = 1 * 1024 * 1024 // 1 MiB + +var latestReleaseHTTPClient = connections.NewDefaultHTTPClient() + func LatestFlag() (string, error) { var output strings.Builder @@ -27,11 +32,15 @@ func LatestFlag() (string, error) { } func latestRelease(output *strings.Builder) error { + return latestReleaseFromURL(output, "https://api.github.com/repos/digitalghost-dev/poke-cli/releases/latest", latestReleaseHTTPClient) +} + +func latestReleaseFromURL(output *strings.Builder, releaseURL string, client *http.Client) error { type Release struct { TagName string `json:"tag_name"` } - parsedURL, err := url.Parse("https://api.github.com/repos/digitalghost-dev/poke-cli/releases/latest") + parsedURL, err := url.Parse(releaseURL) if err != nil { err = fmt.Errorf("invalid URL: %w", err) fmt.Fprintln(output, err) @@ -51,7 +60,7 @@ func latestRelease(output *strings.Builder) error { } } - response, err := http.Get(parsedURL.String()) + response, err := client.Get(parsedURL.String()) if err != nil { err = fmt.Errorf("error fetching data: %w", err) fmt.Fprintln(output, err) @@ -59,12 +68,23 @@ func latestRelease(output *strings.Builder) error { } defer response.Body.Close() - body, err := io.ReadAll(response.Body) + if response.StatusCode != http.StatusOK { + err := fmt.Errorf("unexpected GitHub response status: %d", response.StatusCode) + fmt.Fprintln(output, err) + return err + } + + body, err := io.ReadAll(io.LimitReader(response.Body, maxLatestReleaseBytes+1)) if err != nil { err = fmt.Errorf("error reading response body: %w", err) fmt.Fprintln(output, err) return err } + if len(body) > maxLatestReleaseBytes { + err := fmt.Errorf("response body exceeds %d bytes", maxLatestReleaseBytes) + fmt.Fprintln(output, err) + return err + } var release Release if err := json.Unmarshal(body, &release); err != nil { @@ -72,6 +92,11 @@ func latestRelease(output *strings.Builder) error { fmt.Fprintln(output, err) return err } + if release.TagName == "" { + err := errors.New("latest release response did not include a tag name") + fmt.Fprintln(output, err) + return err + } releaseString := "Latest available release on GitHub:" releaseTag := styling.ColoredBullet.Render("") + release.TagName diff --git a/flags/version_test.go b/flags/version_test.go index 869c127..3e6bf10 100644 --- a/flags/version_test.go +++ b/flags/version_test.go @@ -1,7 +1,10 @@ package flags import ( + "io" + "net/http" "os" + "strings" "testing" "github.com/digitalghost-dev/poke-cli/cmd/utils" @@ -10,6 +13,25 @@ import ( "github.com/stretchr/testify/require" ) +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func latestReleaseTestClient(statusCode int, body string) *http.Client { + return &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: statusCode, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + }), + } +} + func TestLatestVersionFlag(t *testing.T) { err := os.Setenv("GO_TESTING", "1") if err != nil { @@ -23,6 +45,10 @@ func TestLatestVersionFlag(t *testing.T) { } }() + originalClient := latestReleaseHTTPClient + latestReleaseHTTPClient = latestReleaseTestClient(http.StatusOK, `{"tag_name":"v1.10.1"}`) + defer func() { latestReleaseHTTPClient = originalClient }() + tests := []struct { name string args []string @@ -62,3 +88,64 @@ func TestLatestVersionFlag(t *testing.T) { }) } } + +func TestLatestReleaseFromURL(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + wantErr bool + contains string + outputHas string + }{ + { + name: "valid latest release", + statusCode: http.StatusOK, + body: `{"tag_name":"v1.2.3"}`, + outputHas: "v1.2.3", + }, + { + name: "non-200 response", + statusCode: http.StatusForbidden, + body: "rate limit", + wantErr: true, + contains: "unexpected GitHub response status: 403", + }, + { + name: "empty release tag", + statusCode: http.StatusOK, + body: `{"tag_name":""}`, + wantErr: true, + contains: "did not include a tag name", + }, + { + name: "response body too large", + statusCode: http.StatusOK, + body: strings.Repeat("x", maxLatestReleaseBytes+1), + wantErr: true, + contains: "response body exceeds", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var output strings.Builder + err := latestReleaseFromURL( + &output, + "https://api.github.com/repos/digitalghost-dev/poke-cli/releases/latest", + latestReleaseTestClient(tt.statusCode, tt.body), + ) + cleanOutput := styling.StripANSI(output.String()) + + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.contains) + assert.Contains(t, cleanOutput, tt.contains) + return + } + + require.NoError(t, err) + assert.Contains(t, cleanOutput, tt.outputHas) + }) + } +} diff --git a/mkdocs.yml b/mkdocs.yml index 7396ed8..642e5be 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -60,7 +60,11 @@ markdown_extensions: anchor_linenums: true line_spans: __span pygments_lang_class: true - - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.details - pymdownx.inlinehilite - pymdownx.snippets diff --git a/nfpm.yaml b/nfpm.yaml index 03cf344..56f5c4c 100644 --- a/nfpm.yaml +++ b/nfpm.yaml @@ -1,7 +1,7 @@ name: "poke-cli" arch: "arm64" platform: "linux" -version: "v1.10.1" +version: "v1.10.2" section: "default" version_schema: semver maintainer: "Christian S" diff --git a/testdata/main_latest_flag.golden b/testdata/main_latest_flag.golden index d2e3224..dad9b03 100644 --- a/testdata/main_latest_flag.golden +++ b/testdata/main_latest_flag.golden @@ -2,6 +2,6 @@ ┃ ┃ ┃ Latest available release ┃ ┃ on GitHub: ┃ -┃ • v1.10.0 ┃ +┃ • v1.10.1 ┃ ┃ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/web/pyproject.toml b/web/pyproject.toml index 9fcc485..d53fd45 100644 --- a/web/pyproject.toml +++ b/web/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "web" -version = "v1.10.1" +version = "v1.10.2" description = "Streamlit dashboard for browsing and visualizing Pokémon TCG tournament standings and results." readme = "README.md" requires-python = ">=3.12"