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 @@
-
+
@@ -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"