diff --git a/cmd/scheduledTasks.go b/cmd/scheduledTasks.go index 6d4cb62..cf78be8 100644 --- a/cmd/scheduledTasks.go +++ b/cmd/scheduledTasks.go @@ -21,9 +21,9 @@ import ( "strconv" "strings" - "github.com/AlecAivazis/survey/v2" "github.com/apppackio/apppack/app" "github.com/apppackio/apppack/ui" + "github.com/charmbracelet/huh" "github.com/logrusorgru/aurora" "github.com/spf13/cobra" ) @@ -124,24 +124,14 @@ If no index is provided, an interactive prompt will be provided to choose the ta return } - var taskList []string - for _, t := range tasks { - taskList = append(taskList, fmt.Sprintf("%s %s", t.Schedule, t.Command)) + options := make([]huh.Option[int], len(tasks)) + for i, t := range tasks { + options[i] = huh.NewOption(fmt.Sprintf("%s %s", t.Schedule, t.Command), i) } - questions := []*survey.Question{{ - Name: "task", - Prompt: &survey.Select{ - Message: "Scheduled task to delete:", - Options: taskList, - FilterMessage: "", - }, - }} - answers := make(map[string]int) ui.Spinner.Stop() - if err := survey.Ask(questions, &answers); err != nil { - checkErr(err) - } - idx = answers["task"] + form, idxPtr := ScheduledTaskDeleteForm(options) + checkErr(form.Run()) + idx = *idxPtr } task, err = a.DeleteScheduledTask(idx) checkErr(err) @@ -150,6 +140,23 @@ If no index is provided, an interactive prompt will be provided to choose the ta }, } +// ScheduledTaskDeleteForm builds the interactive form for selecting a task to delete. +// Returns the form and a pointer to the selected index. +func ScheduledTaskDeleteForm(options []huh.Option[int]) (*huh.Form, *int) { + var idx int + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[int](). + Title("Scheduled task to delete:"). + Options(options...). + Value(&idx), + ), + ) + + return form, &idx +} + func init() { rootCmd.AddCommand(scheduledTasksCmd) scheduledTasksCmd.PersistentFlags().StringVarP(&AppName, "app-name", "a", "", "app name (required)") diff --git a/cmd/scheduledTasks_test.go b/cmd/scheduledTasks_test.go new file mode 100644 index 0000000..b780793 --- /dev/null +++ b/cmd/scheduledTasks_test.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "testing" + + "github.com/apppackio/apppack/ui/uitest" + "github.com/charmbracelet/huh" +) + +func TestScheduledTaskDeleteForm_SelectFirst(t *testing.T) { + options := []huh.Option[int]{ + huh.NewOption("0 0 * * ? * echo hello", 0), + huh.NewOption("0/10 * * * ? * echo world", 1), + huh.NewOption("0 12 * * ? * echo noon", 2), + } + + form, idxPtr := ScheduledTaskDeleteForm(options) + tm := uitest.RunForm(t, form) + uitest.SelectFirst(tm) + uitest.WaitDone(t, tm) + + if *idxPtr != 0 { + t.Errorf("expected index 0, got %d", *idxPtr) + } +} + +func TestScheduledTaskDeleteForm_SelectSecond(t *testing.T) { + options := []huh.Option[int]{ + huh.NewOption("0 0 * * ? * echo hello", 0), + huh.NewOption("0/10 * * * ? * echo world", 1), + huh.NewOption("0 12 * * ? * echo noon", 2), + } + + form, idxPtr := ScheduledTaskDeleteForm(options) + tm := uitest.RunForm(t, form) + uitest.SelectNth(tm, 1) + uitest.WaitDone(t, tm) + + if *idxPtr != 1 { + t.Errorf("expected index 1, got %d", *idxPtr) + } +} + +func TestScheduledTaskDeleteForm_SelectLast(t *testing.T) { + options := []huh.Option[int]{ + huh.NewOption("0 0 * * ? * echo hello", 0), + huh.NewOption("0/10 * * * ? * echo world", 1), + huh.NewOption("0 12 * * ? * echo noon", 2), + } + + form, idxPtr := ScheduledTaskDeleteForm(options) + tm := uitest.RunForm(t, form) + uitest.SelectNth(tm, 2) + uitest.WaitDone(t, tm) + + if *idxPtr != 2 { + t.Errorf("expected index 2, got %d", *idxPtr) + } +} diff --git a/demo-scheduled-tasks-delete.gif b/demo-scheduled-tasks-delete.gif new file mode 100644 index 0000000..12443ad Binary files /dev/null and b/demo-scheduled-tasks-delete.gif differ diff --git a/demo-scheduled-tasks-delete.tape b/demo-scheduled-tasks-delete.tape new file mode 100644 index 0000000..2d99c67 --- /dev/null +++ b/demo-scheduled-tasks-delete.tape @@ -0,0 +1,22 @@ +Output demo-scheduled-tasks-delete.gif + +Set Shell "bash" +Set Width 900 +Set Height 400 +Set FontSize 16 +Set Padding 20 +Set TypingSpeed 10ms +Set LoopOffset 90% + +Type "./apppack-dev -a huh-test scheduled-tasks delete" +Enter +Sleep 4s +Down +Sleep 600ms +Down +Sleep 600ms +Enter +Sleep 2s +Type "./apppack-dev -a huh-test scheduled-tasks" +Enter +Sleep 6s diff --git a/go.mod b/go.mod index 2b93cf0..2664c6b 100644 --- a/go.mod +++ b/go.mod @@ -44,11 +44,16 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 github.com/aws/session-manager-plugin v0.0.0-20250205214155-b2b0bcd769d1 github.com/aws/smithy-go v1.23.2 + github.com/charmbracelet/bubbletea v1.3.5 + github.com/charmbracelet/huh v0.7.0 + github.com/charmbracelet/x/exp/teatest v0.0.0-20260316093931-f2fb44ab3145 github.com/cli/cli/v2 v2.83.0 github.com/go-jose/go-jose/v4 v4.1.1 + github.com/rogpeppe/go-internal v1.14.1 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect @@ -64,21 +69,38 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymanbagabas/go-udiff v0.3.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.0 // indirect + github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20250630141444-821143405392 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 // indirect github.com/cli/go-gh/v2 v2.13.0 // indirect github.com/cli/shurcooL-graphql v0.0.4 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/henvic/httpretty v0.1.4 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/thlib/go-timezone-local v0.0.7 // indirect github.com/twinj/uuid v1.0.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xtaci/smux v1.5.35 // indirect golang.org/x/sync v0.18.0 // indirect + golang.org/x/tools v0.37.0 // indirect ) require ( diff --git a/go.sum b/go.sum index 836eaef..f23e2bd 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/TylerBrock/colorjson v0.0.0-20180527164720-95ec53f28296/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w= @@ -9,6 +11,8 @@ github.com/apparentlymart/go-cidr v1.1.0 h1:2mAhrMoF+nhXqxTzSZMUzDHkLjmIHC+Zzn4t github.com/apparentlymart/go-cidr v1.1.0/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= github.com/apppackio/saw v0.2.3-0.20210507180802-f6559c287e6f h1:4qSROTO6FceKFgKaoYmALA953QpYHRrQhcG1v2uqusU= github.com/apppackio/saw v0.2.3-0.20210507180802-f6559c287e6f/go.mod h1:GjKNeaxQeBkAudVlPmb2el62OMm4rjtY7Uzz1OmByAs= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go v1.13.56/go.mod h1:ZRmQr0FajVIyZ4ZzBYKG5P3ZqPz9IHG41ZoMu1ADI3k= github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= @@ -84,8 +88,42 @@ github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= +github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= +github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= +github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc= +github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20250630141444-821143405392 h1:6ipGA1NEA0AZG2UEf81RQGJvEPvYLn/M18mZcdt4J8g= +github.com/charmbracelet/x/exp/strings v0.0.0-20250630141444-821143405392/go.mod h1:Rgw3/F+xlcUc5XygUtimVSxAqCOsqyvJjqF5UHRvc5k= +github.com/charmbracelet/x/exp/teatest v0.0.0-20260316093931-f2fb44ab3145 h1:ztM3k0leceSs/tK6N3shexiN7XWUnpO885yqoDzP/Do= +github.com/charmbracelet/x/exp/teatest v0.0.0-20260316093931-f2fb44ab3145/go.mod h1:aPVjFrBwbJgj5Qz1F0IXsnbcOVJcMKgu1ySUfTAxh7k= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 h1:kHaBemcxl8o/pQ5VM1c8PVE1PubbNx3mjUr09OqWGCs= github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575/go.mod h1:9d6lWj8KzO/fd/NrVaLscBKmPigpZpn5YawRPw+e3Yo= github.com/cli/cli/v2 v2.83.0 h1:DIh4WStSDm15OT43vImvKp21v9JAg8pBbdVoisknTKo= @@ -114,6 +152,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg= github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= @@ -186,11 +226,19 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/mum4k/termdash v0.20.0 h1:g6yZvE7VJmuefJmDrSrv5Az8IFTTSCqG0x8xiOMPbyM= @@ -206,8 +254,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -233,6 +281,8 @@ github.com/thlib/go-timezone-local v0.0.7 h1:fX8zd3aJydqLlTs/TrROrIIdztzsdFV23Oz github.com/thlib/go-timezone-local v0.0.7/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= github.com/twinj/uuid v0.0.0-20151029044442-89173bcdda19 h1:HlxV0XiEKMMyjS3gGtJmmFZsxQ22GsLvA7F980il+1w= github.com/twinj/uuid v0.0.0-20151029044442-89173bcdda19/go.mod h1:mMgcE1RHFUFqe5AfiwlINXisXfDGro23fWdPUfOMjRY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xtaci/smux v1.5.35 h1:RosihGJBeaS8gxOZ17HNxbhONwnqQwNwusHx4+SEGhk= github.com/xtaci/smux v1.5.35/go.mod h1:OMlQbT5vcgl2gb49mFkYo6SMf+zP3rcjcwQz7ZU7IGY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -242,6 +292,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -262,6 +314,7 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..c532629 --- /dev/null +++ b/integration_test.go @@ -0,0 +1,24 @@ +package main_test + +import ( + "testing" + + "github.com/apppackio/apppack/cmd" + "github.com/rogpeppe/go-internal/testscript" +) + +func TestMain(m *testing.M) { + testscript.Main(m, map[string]func(){ + "apppack": cmd.Execute, + }) +} + +func TestCLI(t *testing.T) { + testscript.Run(t, testscript.Params{ + Dir: "testdata/script", + Setup: func(env *testscript.Env) error { + env.Setenv("NO_COLOR", "1") + return nil + }, + }) +} diff --git a/testdata/script/help.txtar b/testdata/script/help.txtar new file mode 100644 index 0000000..052f8d9 --- /dev/null +++ b/testdata/script/help.txtar @@ -0,0 +1,10 @@ +# Test help output lists key commands +exec apppack --help +stdout 'shell' +stdout 'scheduled-tasks' +stdout 'version' +! stderr . + +# Test unknown command +! exec apppack nonexistent-command +stderr 'unknown command' diff --git a/testdata/script/scheduled_tasks.txtar b/testdata/script/scheduled_tasks.txtar new file mode 100644 index 0000000..4155198 --- /dev/null +++ b/testdata/script/scheduled_tasks.txtar @@ -0,0 +1,12 @@ +# Test scheduled-tasks help +exec apppack scheduled-tasks --help +stdout 'list scheduled tasks' +! stderr . + +# Test create subcommand help +exec apppack scheduled-tasks create --help +stdout 'Schedule a command to run' + +# Test delete subcommand help +exec apppack scheduled-tasks delete --help +stdout 'Delete the scheduled task' diff --git a/testdata/script/version.txtar b/testdata/script/version.txtar new file mode 100644 index 0000000..295e787 --- /dev/null +++ b/testdata/script/version.txtar @@ -0,0 +1,4 @@ +# Test version command outputs version info +exec apppack version +stdout 'development' +! stderr . diff --git a/ui/questions.go b/ui/questions.go index 88608aa..adc77f1 100644 --- a/ui/questions.go +++ b/ui/questions.go @@ -7,6 +7,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/core" + "github.com/charmbracelet/huh" "github.com/logrusorgru/aurora" ) @@ -127,3 +128,39 @@ func PauseUntilEnter(msg string) { fmt.Println(aurora.Bold(aurora.White(msg))) _, _ = fmt.Scanln() } + +// --- huh-based helpers (new, coexists with survey code above until migration is complete) --- + +// YesNoOptions returns huh options for a boolean yes/no select, with the +// given default pre-selected. +func YesNoOptions(defaultValue bool) []huh.Option[string] { + opts := []huh.Option[string]{ + huh.NewOption("yes", "yes"), + huh.NewOption("no", "no"), + } + if defaultValue { + opts[0] = opts[0].Selected(true) + } else { + opts[1] = opts[1].Selected(true) + } + + return opts +} + +// YesNoToBool converts a "yes"/"no" string to a boolean. +func YesNoToBool(val string) bool { + return val == "yes" +} + +// PrintQuestionHeader prints the verbose title and optional help text for a +// question, matching the existing AskQuestions visual style. +func PrintQuestionHeader(verbose, helpText string) { + fmt.Println() + fmt.Println(aurora.Bold(aurora.White(verbose))) + + if helpText != "" { + fmt.Println(helpText) + } + + fmt.Println() +} diff --git a/ui/questions_test.go b/ui/questions_test.go new file mode 100644 index 0000000..00d72f5 --- /dev/null +++ b/ui/questions_test.go @@ -0,0 +1,72 @@ +package ui + +import ( + "io" + "strings" + "testing" + + "github.com/charmbracelet/huh" +) + +func TestBooleanAsYesNo(t *testing.T) { + t.Parallel() + + if BooleanAsYesNo(true) != "yes" { + t.Error("expected yes for true") + } + + if BooleanAsYesNo(false) != "no" { + t.Error("expected no for false") + } +} + +func TestYesNoOptions(t *testing.T) { + t.Parallel() + + opts := YesNoOptions(true) + if len(opts) != 2 { + t.Fatalf("expected 2 options, got %d", len(opts)) + } + + opts = YesNoOptions(false) + if len(opts) != 2 { + t.Fatalf("expected 2 options, got %d", len(opts)) + } +} + +func TestYesNoToBool(t *testing.T) { + t.Parallel() + + if !YesNoToBool("yes") { + t.Error("expected true for yes") + } + + if YesNoToBool("no") { + t.Error("expected false for no") + } + + if YesNoToBool("anything") { + t.Error("expected false for non-yes value") + } +} + +func TestRunFormAccessible(t *testing.T) { + t.Parallel() + + var name string + form := huh.NewForm( + huh.NewGroup( + huh.NewInput().Title("Name").Value(&name), + ), + ).WithAccessible(true). + WithInput(strings.NewReader("Alice\n")). + WithOutput(io.Discard) + + if err := form.Run(); err != nil { + t.Fatal(err) + } + + if name != "Alice" { + t.Errorf("expected Alice, got %s", name) + } +} diff --git a/ui/uitest/helpers.go b/ui/uitest/helpers.go new file mode 100644 index 0000000..c19c52b --- /dev/null +++ b/ui/uitest/helpers.go @@ -0,0 +1,53 @@ +package uitest + +import ( + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/x/exp/teatest" +) + +// RunForm creates a teatest model from a huh form with an 80x24 terminal +// and waits for the form to initialize. +func RunForm(t *testing.T, form *huh.Form) *teatest.TestModel { + t.Helper() + + tm := teatest.NewTestModel(t, form, teatest.WithInitialTermSize(80, 24)) + time.Sleep(300 * time.Millisecond) + + return tm +} + +// SelectNth sends n down-arrow keys then Enter to select the nth option (0-indexed). +func SelectNth(tm *teatest.TestModel, n int) { + for range n { + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) + } + + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) +} + +// SelectFirst sends Enter to accept the default/first option. +func SelectFirst(tm *teatest.TestModel) { + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) +} + +// TypeAndSubmit types text into an input field and presses Enter. +func TypeAndSubmit(tm *teatest.TestModel, text string) { + tm.Type(text) + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) +} + +// WaitDone signals the form to quit and waits for the final model. +// huh forms don't automatically trigger tea.Quit when complete via teatest, +// so we send QuitMsg explicitly after a brief delay for the form to process. +func WaitDone(t *testing.T, tm *teatest.TestModel) tea.Model { + t.Helper() + + time.Sleep(100 * time.Millisecond) + tm.Send(tea.QuitMsg{}) + + return tm.FinalModel(t, teatest.WithFinalTimeout(3*time.Second)) +} diff --git a/ui/uitest/helpers_test.go b/ui/uitest/helpers_test.go new file mode 100644 index 0000000..ee76eac --- /dev/null +++ b/ui/uitest/helpers_test.go @@ -0,0 +1,61 @@ +package uitest + +import ( + "testing" + + "github.com/charmbracelet/huh" +) + +func newTestSelect(selected *int) *huh.Form { + return huh.NewForm( + huh.NewGroup( + huh.NewSelect[int](). + Title("Pick:"). + Options( + huh.NewOption("A", 0), + huh.NewOption("B", 1), + huh.NewOption("C", 2), + ). + Value(selected), + ), + ) +} + +func TestSelectFirst(t *testing.T) { + var selected int + tm := RunForm(t, newTestSelect(&selected)) + SelectFirst(tm) + WaitDone(t, tm) + + if selected != 0 { + t.Errorf("expected 0, got %d", selected) + } +} + +func TestSelectNth(t *testing.T) { + var selected int + tm := RunForm(t, newTestSelect(&selected)) + SelectNth(tm, 2) + WaitDone(t, tm) + + if selected != 2 { + t.Errorf("expected 2, got %d", selected) + } +} + +func TestTypeAndSubmit(t *testing.T) { + var name string + form := huh.NewForm( + huh.NewGroup( + huh.NewInput().Title("Name:").Value(&name), + ), + ) + + tm := RunForm(t, form) + TypeAndSubmit(tm, "Alice") + WaitDone(t, tm) + + if name != "Alice" { + t.Errorf("expected Alice, got %s", name) + } +}