diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index 3c5c4bf..43fec27 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -28,6 +28,7 @@ import ( "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/itemrenderer" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/jobs" keybindings_service "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/keybindings" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/viewsnapshot" "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui" @@ -106,6 +107,7 @@ func main() { tableService := tables.NewService(dynamoProvider, settingStore) workspaceService := viewsnapshot.NewService(resultSetSnapshotStore) itemRendererService := itemrenderer.NewService(uiStyles.ItemView.FieldType, uiStyles.ItemView.MetaInfo) + scriptManagerService := scriptmanager.New() jobsService := jobs.NewService(eventBus) inputHistoryService := inputhistory.New(inputHistoryStore) @@ -120,6 +122,7 @@ func main() { inputHistoryService, eventBus, pasteboardProvider, + scriptManagerService, *flagTable, ) tableWriteController := controllers.NewTableWriteController(state, tableService, jobsController, tableReadController, settingStore) @@ -127,6 +130,7 @@ func main() { exportController := controllers.NewExportController(state, tableService, jobsController, columnsController, pasteboardProvider) settingsController := controllers.NewSettingsController(settingStore, eventBus) keyBindings := keybindings.Default() + //scriptController := controllers.NewScriptController(scriptManagerService, tableReadController, jobsController, settingsController, eventBus) if *flagQuery != "" { if *flagTable == "" { @@ -166,13 +170,10 @@ func main() { exportController, keyBindingController, pasteboardProvider, - settingsController, ) - commandController, err := commandctrl.NewCommandController(inputHistoryService, stdCommands) - if err != nil { - cli.Fatalf("cannot setup command controller: %v", err) - } + commandController := commandctrl.NewCommandController(inputHistoryService, stdCommands) + //commandController.AddCommandLookupExtension(scriptController) commandController.SetCommandCompletionProvider(columnsController) model := ui.NewModel( @@ -184,6 +185,7 @@ func main() { jobsController, itemRendererService, commandController, + //scriptController, eventBus, keyBindingController, pasteboardProvider, @@ -197,6 +199,8 @@ func main() { p := tea.NewProgram(model, tea.WithAltScreen()) jobsController.SetMessageSender(p.Send) + //scriptController.Init() + //scriptController.SetMessageSender(p.Send) if err := commandController.LoadExtensions(context.Background(), strings.Split(*flagExtDir, string(os.PathListSeparator))); err != nil { fmt.Printf("Unable to load extensions: %v", err) diff --git a/go.mod b/go.mod index 18738aa..3113de7 100644 --- a/go.mod +++ b/go.mod @@ -13,11 +13,14 @@ require ( github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.10.12 github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.4.39 github.com/aws/aws-sdk-go-v2/service/dynamodb v1.19.11 + github.com/aws/aws-sdk-go-v2/service/sqs v1.23.2 + github.com/aws/aws-sdk-go-v2/service/ssm v1.24.0 github.com/brianvoe/gofakeit/v6 v6.15.0 github.com/calyptia/go-bubble-table v0.2.1 github.com/charmbracelet/bubbles v0.14.0 github.com/charmbracelet/bubbletea v0.22.1 github.com/charmbracelet/lipgloss v0.6.0 + github.com/cloudcmds/tamarin v1.0.0 github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538 github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f @@ -29,33 +32,65 @@ require ( github.com/stretchr/testify v1.9.0 golang.design/x/clipboard v0.6.2 golang.org/x/exp v0.0.0-20230108222341-4b8118a2686a - ucl.lmika.dev v0.0.0-20250525023717-3076897eb73e ) require ( github.com/DataDog/zstd v1.5.2 // indirect github.com/Sereal/Sereal v0.0.0-20220220040404-e0d1e550e879 // indirect + github.com/anthonynsimon/bild v0.13.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.4 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.34 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.28 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.3.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.26 // indirect + github.com/aws/aws-sdk-go-v2/service/cloudformation v1.30.0 // indirect + github.com/aws/aws-sdk-go-v2/service/cloudfront v1.26.8 // indirect + github.com/aws/aws-sdk-go-v2/service/cloudtrail v1.27.1 // indirect + github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.26.2 // indirect github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.14.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ebs v1.16.14 // indirect + github.com/aws/aws-sdk-go-v2/service/ec2 v1.102.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ecr v1.18.13 // indirect + github.com/aws/aws-sdk-go-v2/service/ecs v1.27.4 // indirect + github.com/aws/aws-sdk-go-v2/service/eks v1.27.14 // indirect + github.com/aws/aws-sdk-go-v2/service/elasticache v1.27.2 // indirect + github.com/aws/aws-sdk-go-v2/service/elasticsearchservice v1.19.2 // indirect + github.com/aws/aws-sdk-go-v2/service/glue v1.52.0 // indirect + github.com/aws/aws-sdk-go-v2/service/iam v1.21.0 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.29 // indirect github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.28 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.28 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.3 // indirect + github.com/aws/aws-sdk-go-v2/service/kinesis v1.17.14 // indirect + github.com/aws/aws-sdk-go-v2/service/kms v1.22.2 // indirect + github.com/aws/aws-sdk-go-v2/service/lambda v1.37.0 // indirect + github.com/aws/aws-sdk-go-v2/service/rds v1.46.0 // indirect + github.com/aws/aws-sdk-go-v2/service/redshift v1.28.0 // indirect + github.com/aws/aws-sdk-go-v2/service/route53 v1.28.3 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.36.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sfn v1.18.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sns v1.20.13 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.12.12 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.12 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.19.2 // indirect + github.com/aws/aws-sdk-go-v2/service/wafv2 v1.35.1 // indirect github.com/aws/smithy-go v1.13.5 // indirect github.com/aymanbagabas/go-osc52 v1.0.3 // indirect github.com/containerd/console v1.0.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/golang/snappy v0.0.4 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.4.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc // indirect - github.com/kr/pretty v0.3.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lunixbochs/vtclean v1.0.0 // indirect github.com/mattn/go-isatty v0.0.17 // indirect @@ -63,18 +98,24 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.13.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/risor-io/risor v1.4.0 // indirect github.com/rivo/uniseg v0.4.2 // indirect - github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/sahilm/fuzzy v0.1.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/tidwall/gjson v1.14.3 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect + github.com/wI2L/jsondiff v0.3.0 // indirect go.etcd.io/bbolt v1.3.6 // indirect + golang.org/x/crypto v0.9.0 // indirect golang.org/x/exp/shiny v0.0.0-20230213192124-5e25df0256eb // indirect golang.org/x/image v0.5.0 // indirect golang.org/x/mobile v0.0.0-20210716004757-34ab1303b554 // indirect - golang.org/x/net v0.10.0 // indirect golang.org/x/sys v0.8.0 // indirect golang.org/x/term v0.8.0 // indirect golang.org/x/text v0.9.0 // indirect google.golang.org/appengine v1.6.7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + ucl.lmika.dev v0.0.0-20250525023717-3076897eb73e // indirect ) diff --git a/go.sum b/go.sum index 462dd4c..2b9e341 100644 --- a/go.sum +++ b/go.sum @@ -1,57 +1,150 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DataDog/zstd v1.5.2 h1:vUG4lAyuPCXO0TLbXvPv7EB7cNK1QV/luu55UHLrrn8= github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/Sereal/Sereal v0.0.0-20220220040404-e0d1e550e879 h1:M5ptEKnqKqpFTKbe+p5zEf3ro1deJ6opUz5j3g3/ErQ= github.com/Sereal/Sereal v0.0.0-20220220040404-e0d1e550e879/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM= -github.com/alecthomas/assert/v2 v2.3.0 h1:mAsH2wmvjsuvyBvAmCtm7zFsBlb8mIHx5ySLVdDZXL0= -github.com/alecthomas/assert/v2 v2.3.0/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= +github.com/alecthomas/assert/v2 v2.0.3 h1:WKqJODfOiQG0nEJKFKzDIG3E29CN2/4zR9XGJzKIkbg= +github.com/alecthomas/participle v0.7.1 h1:2bN7reTw//5f0cugJcTOnY/NYZcWQOaajW+BwZB5xWs= +github.com/alecthomas/participle v0.7.1/go.mod h1:HfdmEuwvr12HXQN44HPWXR0lHmVolVYe4dyL6lQ3duY= +github.com/alecthomas/participle/v2 v2.0.0-beta.5 h1:y6dsSYVb1G5eK6mgmy+BgI3Mw35a3WghArZ/Hbebrjo= +github.com/alecthomas/participle/v2 v2.0.0-beta.5/go.mod h1:RC764t6n4L8D8ITAJv0qdokritYSNR3wV5cVwmIEaMM= github.com/alecthomas/participle/v2 v2.1.1 h1:hrjKESvSqGHzRb4yW1ciisFJ4p3MGYih6icjJvbsmV8= github.com/alecthomas/participle/v2 v2.1.1/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c= -github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= -github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE= +github.com/anthonynsimon/bild v0.13.0 h1:mN3tMaNds1wBWi1BrJq0ipDBhpkooYfu7ZFSMhXt1C8= +github.com/anthonynsimon/bild v0.13.0/go.mod h1:tpzzp0aYkAsMi1zmfhimaDyX1xjn2OUc1AJZK/TF0AE= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/asdine/storm v2.1.2+incompatible h1:dczuIkyqwY2LrtXPz8ixMrU/OFgZp71kbKTHGrXYt/Q= github.com/asdine/storm v2.1.2+incompatible/go.mod h1:RarYDc9hq1UPLImuiXK3BIWPJLdIygvV3PsInK0FbVQ= 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-v2 v1.13.0/go.mod h1:L6+ZpqHaLbAaxsqV0L4cvxZY7QupWJB4fhkf8LXvC7w= +github.com/aws/aws-sdk-go-v2 v1.16.1/go.mod h1:ytwTPBG6fXTZLxxeeCCWj2/EMYp/xDUgX+OET6TLNNU= +github.com/aws/aws-sdk-go-v2 v1.17.4 h1:wyC6p9Yfq6V2y98wfDsj6OnNQa4w2BLGCLIxzNhwOGY= github.com/aws/aws-sdk-go-v2 v1.17.4/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= github.com/aws/aws-sdk-go-v2 v1.18.1 h1:+tefE750oAb7ZQGzla6bLkOwfcQCEtC5y2RqoqCeqKo= github.com/aws/aws-sdk-go-v2 v1.18.1/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 h1:dK82zF6kkPeCo8J1e+tGx4JdvDIQzj7ygIoLg8WMuGs= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10/go.mod h1:VeTZetY5KRJLuD/7fkQXMU6Mw7H5m/KP2J5Iy9osMno= +github.com/aws/aws-sdk-go-v2/config v1.13.1 h1:yLv8bfNoT4r+UvUKQKqRtdnvuWGMK5a82l4ru9Jvnuo= +github.com/aws/aws-sdk-go-v2/config v1.13.1/go.mod h1:Ba5Z4yL/UGbjQUzsiaN378YobhFo0MLfueXGiOsYtEs= github.com/aws/aws-sdk-go-v2/config v1.18.27 h1:Az9uLwmssTE6OGTpsFqOnaGpLnKDqNYOJzWuC6UAYzA= github.com/aws/aws-sdk-go-v2/config v1.18.27/go.mod h1:0My+YgmkGxeqjXZb5BYme5pc4drjTnM+x1GJ3zv42Nw= +github.com/aws/aws-sdk-go-v2/credentials v1.8.0 h1:8Ow0WcyDesGNL0No11jcgb1JAtE+WtubqXjgxau+S0o= +github.com/aws/aws-sdk-go-v2/credentials v1.8.0/go.mod h1:gnMo58Vwx3Mu7hj1wpcG8DI0s57c9o42UQ6wgTQT5to= github.com/aws/aws-sdk-go-v2/credentials v1.13.26 h1:qmU+yhKmOCyujmuPY7tf5MxR/RKyZrOPO3V4DobiTUk= github.com/aws/aws-sdk-go-v2/credentials v1.13.26/go.mod h1:GoXt2YC8jHUBbA4jr+W3JiemnIbkXOfxSXcisUsZ3os= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.10.12 h1:ama2cD4WaH6+8Gq/M/g+ZumPmmqCyanr+6Sm+iJVxfA= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.10.12/go.mod h1:tPnUO5mS3JThpwfq4Q8iPd745s7yh6fGPqDUEBw+Wv4= github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.4.39 h1:PhgfvgqwMFQKwOcxLV7V3lNDVnR3ZUWzoB6T9oCFpR4= github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.4.39/go.mod h1:/GkvC7uHpK50ilKkKx9I2gZiI/ieZbKjS2aah1rT9uE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0 h1:NITDuUZO34mqtOwFWZiXo7yAHj7kf+XPE+EiKuCBNUI= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0/go.mod h1:I6/fHT/fH460v09eg2gVrd8B/IqskhNdpcLH0WNO3QI= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.4 h1:LxK/bitrAr4lnh9LnIS6i7zWbCOdMsfzKFBI6LUCS0I= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.4/go.mod h1:E1hLXN/BL2e6YizK1zFlYd8vsfi2GTjbjBazinMmeaM= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.4/go.mod h1:XHgQ7Hz2WY2GAn//UXHofLfPXWh+s62MbMOijrg12Lw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.8/go.mod h1:LnTQMTqbKsbtt+UI5+wPsB7jedW+2ZgozoPG8k6cMxg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.28 h1:r+XwaCLpIvCKjBIYy/HVZujQS9tsz5ohHG3ZIe0wKoE= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.28/go.mod h1:3lwChorpIM/BhImY/hy+Z6jekmN92cXGPI1QJasVPYY= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.34 h1:A5UqQEmPaCFpedKouS4v+dHCTUo2sKqhoKO9U5kxyWo= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.34/go.mod h1:wZpTEecJe0Btj3IYnDx/VlUzor9wm3fJHyvLpQF0VwY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.2.0/go.mod h1:BsCSJHx5DnDXIrOcqB8KN1/B+hXLG/bi4Y6Vjcx/x9E= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.2/go.mod h1:1x4ZP3Z8odssdhuLI+/1Tqw6Pt/VAaP4Tr8EUxHvPXE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.22 h1:7AwGYXDdqRQYsluvKFmWoqpcOQJ4bH634SkYf3FNj/A= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.22/go.mod h1:EqK7gVrIGAHyZItrD1D8B0ilgwMD1GiWAmbU4u/JHNk= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.28 h1:srIVS45eQuewqz6fKKu6ZGXaq6FuFg5NzgQBAM6g8Y4= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.28/go.mod h1:7VRpKQQedkfIEXb4k52I7swUnZP0wohVajJMRn3vsUw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.5 h1:ixotxbfTCFpqbuwFv/RcZwyzhkxPSYDYEMcj4niB5Uk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.5/go.mod h1:R3sWUqPcfXSiF/LSFJhjyJmpg9uV6yP2yv3YZZjldVI= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.35 h1:LWA+3kDM8ly001vJ1X1waCuLJdtTl48gwkPKWy9sosI= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.35/go.mod h1:0Eg1YjxE0Bhn56lx+SHJwCzhW+2JGtizsrx+lCqrfm0= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.26 h1:wscW+pnn3J1OYnanMnza5ZVYXLX4cKk5rAvUAl4Qu+c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.26/go.mod h1:MtYiox5gvyB+OyP0Mr0Sm/yzbEAIPL9eijj/ouHAPw0= +github.com/aws/aws-sdk-go-v2/service/cloudformation v1.30.0 h1:XbDkc4FLeg1RfnqeblfbJvaEabqq9ByZl4zqyPFkfSc= +github.com/aws/aws-sdk-go-v2/service/cloudformation v1.30.0/go.mod h1:SwQFcCs9Rog8hSHm+81KBkAK+UKLXErA/1ChaEI8mLE= +github.com/aws/aws-sdk-go-v2/service/cloudfront v1.26.8 h1:loRDtQ0vT0+JCB0hQBCfv95tttEzJ1rqSaTDy5cpy0A= +github.com/aws/aws-sdk-go-v2/service/cloudfront v1.26.8/go.mod h1:YTd4wGn2beCF9wkSTpEcupk79zDFYJk2Ca76B8YyvJg= +github.com/aws/aws-sdk-go-v2/service/cloudtrail v1.27.1 h1:Qw1G/M7eanpm6s/URkG1UuRLKEnRnpUvkUb7NMVvWb8= +github.com/aws/aws-sdk-go-v2/service/cloudtrail v1.27.1/go.mod h1:oKRYqorIUkfAVmX03+lpv3tW5WelDpaliqzTwmCj/k8= +github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.26.2 h1:PWGu2JhCb/XJlJ7SSFJq76pxk4xWsN76nZxh7TzMHx0= +github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.26.2/go.mod h1:2KOZkkzMDZCo/aLzPhys06mHNkiU74u85aMJA3PLRvg= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.18.3 h1:MxOpCZ+o9+AIeQHi2ocW7H4D7p0LhEkmetETVvDnkvg= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.18.3/go.mod h1:nkpC9xkh+3vdxmhqN8Ac10pgV14DsJDLzUsV2CcS+44= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.19.11 h1:tLTGNAsazbfjfjW1k/i43kyCcyTTTTFaD93H7JbSbbs= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.19.11/go.mod h1:W1oiFegjVosgjIwb2Vv45jiCQT1ee8x85u8EyZRYLes= github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.14.3 h1:B+bkmCnNJi194pu9aTtYUe8f4EPXafC+xfU+zciVxdg= github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.14.3/go.mod h1:bRphLmXQD9Ux4jLcFEwyrWdmuPTj2Lh8VGl9wILuJII= +github.com/aws/aws-sdk-go-v2/service/ebs v1.16.14 h1:DosI4CvEUo6/V21pDspzYkOa2X3Zwy5XS/cbPFiqDv0= +github.com/aws/aws-sdk-go-v2/service/ebs v1.16.14/go.mod h1:yVTqVHjnrbAj6FvhTQfjNgwQbjPbDUUvA1x4IpXFmrE= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.102.0 h1:P4dyjm49F2kKws0FpouBC6fjVImACXKt752+CWa01lM= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.102.0/go.mod h1:tIctCeX9IbzsUTKHt53SVEcgyfxV2ElxJeEB+QUbc4M= +github.com/aws/aws-sdk-go-v2/service/ecr v1.18.13 h1:hF7MUVNjubetjggZDtn3AmqCJzD7EUi//tSdxMYPm7U= +github.com/aws/aws-sdk-go-v2/service/ecr v1.18.13/go.mod h1:XwEFO35g0uN/SftK0asWxh8Rk6DOx37R83TmWe2tzEE= +github.com/aws/aws-sdk-go-v2/service/ecs v1.27.4 h1:F1N0Eh5EGRRY9QpF+tMTkx8Wb59DkQWE91Xza/9dk1c= +github.com/aws/aws-sdk-go-v2/service/ecs v1.27.4/go.mod h1:0irnFofeEZwT7uTjSkNVcSQJbWRqZ9BRoxhKjt1BObM= +github.com/aws/aws-sdk-go-v2/service/eks v1.27.14 h1:47HQVuJXgwvuoc4AT3rVdm77H0qGFbFnsuE4PRT+xX0= +github.com/aws/aws-sdk-go-v2/service/eks v1.27.14/go.mod h1:QxuWcm9rlLkW3aEV8tiDzqZewnNSNUZfnqJvo1Nv9A0= +github.com/aws/aws-sdk-go-v2/service/elasticache v1.27.2 h1:IC9XLGcT3yEkziTlX7PX54km7cHJnltlV7Ppwq2+7ik= +github.com/aws/aws-sdk-go-v2/service/elasticache v1.27.2/go.mod h1:+oJhn/SIud310/2LLSVmlNZmExmlYPaGCLmUsnq5JZc= +github.com/aws/aws-sdk-go-v2/service/elasticsearchservice v1.19.2 h1:Zam6yofBgdtP13laNoeA+DA9wlKJNooU8p3CWw6xLaI= +github.com/aws/aws-sdk-go-v2/service/elasticsearchservice v1.19.2/go.mod h1:dehjpZ00q0RJcBUOUEysaj7zHK2rHSS4ePp89MsFiaI= +github.com/aws/aws-sdk-go-v2/service/glue v1.52.0 h1:ukSf8ZdoZ6AygsUWIjj177wLOXljxBspBaNMgvx6fRA= +github.com/aws/aws-sdk-go-v2/service/glue v1.52.0/go.mod h1:wMCE0B6l8eHb57l2DMYCGxt0rHIfcu3RvIY7SAfc+Fs= +github.com/aws/aws-sdk-go-v2/service/iam v1.21.0 h1:8hEpu60CWlrp7iEBUFRZhgPoX6+gadaGL1sD4LoRYS0= +github.com/aws/aws-sdk-go-v2/service/iam v1.21.0/go.mod h1:aQZ8BI+reeaY7RI/QQp7TKCSUHOesTdrzzylp3CW85c= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 h1:y2+VQzC6Zh2ojtV2LoC0MNwHWc6qXv/j2vrQtlftkdA= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11/go.mod h1:iV4q2hsqtNECrfmlXyord9u4zyuFEJX9eLgLpSPzWA8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.29 h1:zZSLP3v3riMOP14H7b4XP0uyfREDQOYv2cqIrvTXDNQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.29/go.mod h1:z7EjRjVwZ6pWcWdI2H64dKttvzaP99jRIj5hphW0M5U= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.22 h1:6zEryIiJOSk5/OcVHzkPDwzNBQ2atYCTShyA7TqkuxA= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.22/go.mod h1:moeOz5SKfY0p6pNIChdPIQdfaUfWI67+OVe0/r6+aGY= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.28 h1:/D994rtMQd1jQ2OY+7tvUlMlrv1L1c7Xtma/FhkbVtY= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.28/go.mod h1:3bJI2pLY3ilrqO5EclusI1GbjFJh1iXYrhOItf2sjKw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0 h1:4QAOB3KrvI1ApJK14sliGr3Ie2pjyvNypn/lfzDHfUw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0/go.mod h1:K/qPe6AP2TGYv4l6n7c88zh9jWBDf6nHhvg1fx/EWfU= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.28 h1:bkRyG4a929RCnpVSTvLM2j/T4ls015ZhhYApbmYs15s= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.28/go.mod h1:jj7znCIg05jXlaGBlFMGP8+7UN3VtCkRBG2spnmRQkU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.3 h1:dBL3StFxHtpBzJJ/mNEsjXVgfO+7jR0dAIEwLqMapEA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.3/go.mod h1:f1QyiAsvIv4B49DmCqrhlXqyaR+0IxMmyX+1P+AnzOM= +github.com/aws/aws-sdk-go-v2/service/kinesis v1.17.14 h1:oSw0SQN9cKeYvCUYfPul7bH11b8E9I9BnoVUme3iSaU= +github.com/aws/aws-sdk-go-v2/service/kinesis v1.17.14/go.mod h1:omXkSCk1T1difhE8wVaecXNeerY6jmpFFu49ngjEDQk= +github.com/aws/aws-sdk-go-v2/service/kms v1.22.2 h1:jwmtdM1/l1DRNy5jQrrYpsQm8zwetkgeqhAqefDr1yI= +github.com/aws/aws-sdk-go-v2/service/kms v1.22.2/go.mod h1:aNfh11Smy55o65PB3MyKbkM8BFyFUcZmj1k+4g8eNfg= +github.com/aws/aws-sdk-go-v2/service/lambda v1.37.0 h1:xzyM5ZR9kZW0/Bkw5EiihOy6B+BYclp5K+yb6OHjc7s= +github.com/aws/aws-sdk-go-v2/service/lambda v1.37.0/go.mod h1:Q8zQi5nZpjUF/H55dKEpKfEvFWJkgZzjjqvDb2AR5b4= +github.com/aws/aws-sdk-go-v2/service/rds v1.46.0 h1:uv2LAciZRd5lEXzJo2u92tdZh/JxcVL7YLC51D4NLG4= +github.com/aws/aws-sdk-go-v2/service/rds v1.46.0/go.mod h1:goBDR4OPrsnKpYyU0GHGcEnlTmL8O+eKGsWeyOAFJ5M= +github.com/aws/aws-sdk-go-v2/service/redshift v1.28.0 h1:tmhg03t7nNVSFqhxb8YpHqq8H1wwwrfEQv/rL7NkTAE= +github.com/aws/aws-sdk-go-v2/service/redshift v1.28.0/go.mod h1:x9am33DT5lVKUb0DH1UVbX+iFfpIqAKx6DAqB5Qu6jU= +github.com/aws/aws-sdk-go-v2/service/route53 v1.28.3 h1:nJbE4+tHd8xpM1RB1ZF0/xTJnFd/ATz42ZC35lwXx0w= +github.com/aws/aws-sdk-go-v2/service/route53 v1.28.3/go.mod h1:Cd4MnFoV+6fELBrgWXJ4Y09FrSkn/VjNPkOr1Yr1muU= +github.com/aws/aws-sdk-go-v2/service/s3 v1.36.0 h1:lEmQ1XSD9qLk+NZXbgvLJI/IiTz7OIR2TYUTFH25EI4= +github.com/aws/aws-sdk-go-v2/service/s3 v1.36.0/go.mod h1:aVbf0sko/TsLWHx30c/uVu7c62+0EAJ3vbxaJga0xCw= +github.com/aws/aws-sdk-go-v2/service/sfn v1.18.0 h1:1AIwJvCywFO4nGtHj7ZtKb9mhLpB5hToyjtE5OO6o/I= +github.com/aws/aws-sdk-go-v2/service/sfn v1.18.0/go.mod h1:41VgIwo6R/QE8DnFZ4RrP+f2w9xTzB77h3NRu/BzXyE= +github.com/aws/aws-sdk-go-v2/service/sns v1.20.13 h1:+ADGcDhddHTKyu6Qp3oZKootryteS7D3ODo2ZPDBgjQ= +github.com/aws/aws-sdk-go-v2/service/sns v1.20.13/go.mod h1:rWrvp9i8y/lX94lS7Kn/0iu9RY6vXzeKRqS/knVX8/c= +github.com/aws/aws-sdk-go-v2/service/sqs v1.16.0 h1:dzWS4r8E9bA0TesHM40FSAtedwpTVCuTsLI8EziSqyk= +github.com/aws/aws-sdk-go-v2/service/sqs v1.16.0/go.mod h1:IBTQMG8mtyj37OWg7vIXcg714Ntcb/LlYou/rZpvV1k= +github.com/aws/aws-sdk-go-v2/service/sqs v1.23.2 h1:Y2vfLiY3HmaMisuwx6fS2kMRYbajRXXB+9vesGVPseY= +github.com/aws/aws-sdk-go-v2/service/sqs v1.23.2/go.mod h1:TaV67b6JMD1988x/uMDop/JnMFK6v5d4Ru+sDmFg+ww= +github.com/aws/aws-sdk-go-v2/service/ssm v1.24.0 h1:p22U2yL/AeRToERGcZv1R26Yci5VQnWIrpzcZdG54cg= +github.com/aws/aws-sdk-go-v2/service/ssm v1.24.0/go.mod h1:chcyLYBEVRac/7rWJsD6cUHUR2osROwavvNqCplfwog= +github.com/aws/aws-sdk-go-v2/service/sso v1.9.0 h1:1qLJeQGBmNQW3mBNzK2CFmrQNmoXWrscPqsrAaU1aTA= +github.com/aws/aws-sdk-go-v2/service/sso v1.9.0/go.mod h1:vCV4glupK3tR7pw7ks7Y4jYRL86VvxS+g5qk04YeWrU= github.com/aws/aws-sdk-go-v2/service/sso v1.12.12 h1:nneMBM2p79PGWBQovYO/6Xnc2ryRMw3InnDJq1FHkSY= github.com/aws/aws-sdk-go-v2/service/sso v1.12.12/go.mod h1:HuCOxYsF21eKrerARYO6HapNeh9GBNq7fius2AcwodY= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.12 h1:2qTR7IFk7/0IN/adSFhYu9Xthr0zVFTgBrmPldILn80= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.12/go.mod h1:E4VrHCPzmVB/KFXtqBGKb3c8zpbNBgKe3fisDNLAW5w= +github.com/aws/aws-sdk-go-v2/service/sts v1.14.0 h1:ksiDXhvNYg0D2/UFkLejsaz3LqpW5yjNQ8Nx9Sn2c0E= +github.com/aws/aws-sdk-go-v2/service/sts v1.14.0/go.mod h1:u0xMJKDvvfocRjiozsoZglVNXRG19043xzp3r2ivLIk= github.com/aws/aws-sdk-go-v2/service/sts v1.19.2 h1:XFJ2Z6sNUUcAz9poj+245DMkrHE4h2j5I9/xD50RHfE= github.com/aws/aws-sdk-go-v2/service/sts v1.19.2/go.mod h1:dp0yLPsLBOi++WTxzCjA/oZqi6NPIhoR+uF7GeMU9eg= +github.com/aws/aws-sdk-go-v2/service/wafv2 v1.35.1 h1:FtzLuTf9HPECIcKdBMtA16ZwZWOIj/r57Z3QuWuYfqc= +github.com/aws/aws-sdk-go-v2/service/wafv2 v1.35.1/go.mod h1:RBpb9oTsEgAUfyaTAT2hFC83DxtLxj+SQpcbhaXiHnU= +github.com/aws/smithy-go v1.10.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= +github.com/aws/smithy-go v1.11.2/go.mod h1:3xHYmszWVx2c0kIwQeEVf9uSm4fYZt67FBJnwub1bgM= github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8= github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg= @@ -69,23 +162,51 @@ github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJ github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87inij9N3wJY= github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= +github.com/cloudcmds/tamarin v1.0.0 h1:PhrJ74FCUJo24/nIPXnQe9E3WVEIYo4aG58pICOMDBE= +github.com/cloudcmds/tamarin v1.0.0/go.mod h1:U1aHBoAFtJbI9jzgaj8TUo9C6vfzUKzn1OhWKIdigVM= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= +github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.0.4 h1:r5O6y84qHX/z/HZV40JBdx2obsHz7/uRj5b+CcYEdeY= +github.com/jackc/pgx/v5 v5.0.4/go.mod h1:U0ynklHtgg43fue9Ly30w3OCSTDPlXjig9ghrNGaguQ= +github.com/jackc/pgx/v5 v5.4.1 h1:oKfB/FhuVtit1bBM3zNRRsZ925ZkMN3HXL+LgLUM9lE= +github.com/jackc/pgx/v5 v5.4.1/go.mod h1:q6iHT8uDNXWiFNOlRqJzBTaSH3+2xCXkokxHZC5qWFY= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= @@ -93,18 +214,19 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfC github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc h1:ZQrgZFsLzkw7o3CoDzsfBhx0bf/1rVBXrLy8dXKRe8o= github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc/go.mod h1:PyXUpnI3olx3bsPcHt98FGPX/KCFZ1Fi+hw1XLI6384= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e h1:0QkUe2ejnT/i+xbgGylMU1b+XnZponQKiPVNi+C/xgA= github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e/go.mod h1:qtkBmNC9OfD0STtOR9sF55pQchjIfNlC3gzm4n8CrqM= github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538 h1:dtMPRNoDqDnnP3HgOvYhswcJVSqdISkYlCtGOjTqg6Q= github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538/go.mod h1:0RT1upgKZ6qZ6B1SqseE3wWsPjSQRv/G/HjpYK8jNsg= +github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890 h1:mwl/exYV/WkBMeShqK7q+B2w2r+b0vP1TSA7clBn9kI= +github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890/go.mod h1:FH6OJSvYcJ9xY8CGs9yGgR89kMCK1UimuUQ6kE5YuJQ= github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f h1:tz68Lhc1oR15HVz69IGbtdukdH0x70kBDEvvj5pTXyE= github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f/go.mod h1:zHQvhjGXRro/Xp2C9dbC+ZUpE0gL4GYW75x1lk7hwzI= github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe h1:1UXS/6OFkbi6JrihPykmYO1VtsABB02QQ+YmYYzTY18= @@ -113,9 +235,11 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mattn/go-colorable v0.1.10/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -126,6 +250,8 @@ github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRC github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA= github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= @@ -139,42 +265,88 @@ github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0= github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/risor-io/risor v0.8.0 h1:G0fpHMGztvocKVu8egkKNbvLy4Rsjkuk+0zReu2JSn8= +github.com/risor-io/risor v0.8.0/go.mod h1:lvatEIYxs6HL+X/Bm0R+Mq4Z9a5Y036mniw6DwUnqs0= +github.com/risor-io/risor v1.1.1 h1:J8rIZX/0HXhg/t2+QygksvP65XCWhg5QxRZrwZabhxE= +github.com/risor-io/risor v1.1.1/go.mod h1:0UMw7ZMbUKSPFgQyuHCFe7UuBUewBKX4K3By4ba1CBA= +github.com/risor-io/risor v1.4.0 h1:G17pWgq+N06jWvnaJVwos89tC5C4VMjqwGYRrTWleRM= +github.com/risor-io/risor v1.4.0/go.mod h1:+s/FeK0CdsTCCNZsHSp8EJa3u3mMrhqtNGLCv/GcW8Y= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8= github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw= +github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/wI2L/jsondiff v0.3.0 h1:iTzQ9u/d86GE9RsBzVHX88f2EA1vQUboHwLhSQFc1s4= +github.com/wI2L/jsondiff v0.3.0/go.mod h1:y1IMzNNjlSsk3IUoJdRJO7VRBtzMvRgyo4Vu0LdHpTc= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= golang.design/x/clipboard v0.6.2 h1:a3Np4qfKnLWwfFJQhUWU3IDeRfmVuqWl+QPtP4CSYGw= golang.design/x/clipboard v0.6.2/go.mod h1:kqBSweBP0/im4SZGGjLrppH0D400Hnfo5WbFKSNK8N4= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM= +golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= +golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= golang.org/x/exp v0.0.0-20230108222341-4b8118a2686a h1:tlXy25amD5A7gOfbXdqCGN5k8ESEed/Ee1E5RcrYnqU= golang.org/x/exp v0.0.0-20230108222341-4b8118a2686a/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/exp/shiny v0.0.0-20230213192124-5e25df0256eb h1:gdeQX7xJSkTNF+Sw7++XNIOo4pGL0CjQv3N2Vm1Erxk= golang.org/x/exp/shiny v0.0.0-20230213192124-5e25df0256eb/go.mod h1:UH99kUObWAZkDnWqppdQe5ZhPYESUw8I0zVV1uWBR+0= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ= golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/image v0.5.0 h1:5JMiNunQeQw++mMOz48/ISeNu3Iweh/JaZU8ZLqHRrI= golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4= @@ -190,12 +362,13 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -212,10 +385,14 @@ golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20220919170432-7a66f970e087 h1:tPwmk4vmvVCMdr98VgL4JH+qZxPL8fqlUOHnyOM8N3w= +golang.org/x/term v0.0.0-20220919170432-7a66f970e087/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -223,6 +400,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= @@ -243,9 +422,43 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +ucl.lmika.dev v0.0.0-20240427010304-6315afc54287 h1:llPHrjca54duvQx9PgMTFDhOW2VQiVvqV1CEHpO4AnY= +ucl.lmika.dev v0.0.0-20240427010304-6315afc54287/go.mod h1:T6V4jIUxlWvMTgn4J752VDHNA8iyVrEX6v98EvDj8G4= +ucl.lmika.dev v0.0.0-20240501110514-25594c80d273 h1:+JpKw02VTAcOjJw7Q6juun/9hk9ypNSdTRlf+E4M5Nw= +ucl.lmika.dev v0.0.0-20240501110514-25594c80d273/go.mod h1:T6V4jIUxlWvMTgn4J752VDHNA8iyVrEX6v98EvDj8G4= +ucl.lmika.dev v0.0.0-20240504001444-cf3a12bf0d4d h1:OqGmR0Y+OG6aFIOlXy2QwEHtuUNasYCh/6cxHokYQj4= +ucl.lmika.dev v0.0.0-20240504001444-cf3a12bf0d4d/go.mod h1:T6V4jIUxlWvMTgn4J752VDHNA8iyVrEX6v98EvDj8G4= +ucl.lmika.dev v0.0.0-20240504013531-0dc9fd3c3281 h1:/M7phiv/0XVp3wKkOxEnGQysf8+RS6NOaBQZyUEoSsA= +ucl.lmika.dev v0.0.0-20240504013531-0dc9fd3c3281/go.mod h1:T6V4jIUxlWvMTgn4J752VDHNA8iyVrEX6v98EvDj8G4= +ucl.lmika.dev v0.0.0-20250306030053-ad6d002a22e8 h1:vWttdW8GJWcTUQeJFbQHqCHJDLFWQ9nccUTx/lW2v8s= +ucl.lmika.dev v0.0.0-20250306030053-ad6d002a22e8/go.mod h1:FMP2ncSu4UxfvB0iA2zlebwL+1UPCARdyYNOrmi86A4= +ucl.lmika.dev v0.0.0-20250515115457-27b6cc0b92e2 h1:cvguOoQ0HVgLKbHH17ZHvAUFht6HXApLi0o8JOdaaNU= +ucl.lmika.dev v0.0.0-20250515115457-27b6cc0b92e2/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250517003439-109be33d1495 h1:r46r+7T59Drm+in7TEWKCZfFYIM0ZyZ26QjHAbj8Lto= +ucl.lmika.dev v0.0.0-20250517003439-109be33d1495/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250517115116-0f1ceba0902e h1:CQ+qPqI5lYiiEM0tNAr4jS0iMz16bFqOui5mU3AHsCU= +ucl.lmika.dev v0.0.0-20250517115116-0f1ceba0902e/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250517212052-51e35aa9a675 h1:kGKh3zj6lMzOrGAquFW7ROgx9/6nwJ8DXiSLtceRiak= +ucl.lmika.dev v0.0.0-20250517212052-51e35aa9a675/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250517212757-33d04ba18db4 h1:rnietWu2B+NXLqKfo7jgf6r+srMwxFa5eizywkq4LFk= +ucl.lmika.dev v0.0.0-20250517212757-33d04ba18db4/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250517213937-94aad417121d h1:CMcA8aQV6iiPK75EbHvoIVZhZmSggfrWNhK9BFm2aIg= +ucl.lmika.dev v0.0.0-20250517213937-94aad417121d/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250518024533-f4be44fcbc94 h1:x3IRtT1jbedblimi2hesKGBihg243+wNOSvagCPR0KU= +ucl.lmika.dev v0.0.0-20250518024533-f4be44fcbc94/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250518033831-f79e91e26d78 h1:lbOZUb6whYMLI4win5QL+eLSgqc3N9TtTgT8hTipNl8= +ucl.lmika.dev v0.0.0-20250518033831-f79e91e26d78/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250519111943-1173d163f5e3 h1:ZMQ1rkcAWa///c3bVvlXbtuqjfAWxDm01abQl3g/YVw= +ucl.lmika.dev v0.0.0-20250519111943-1173d163f5e3/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250519114239-7ca821016e9a h1:dzBBFCY50+MQcJaQ90swdDyjzag5oIhwdfqbmZkvX3Q= +ucl.lmika.dev v0.0.0-20250519114239-7ca821016e9a/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250519120409-53b05b5ba6f8 h1:h32JQi0d1MI86RaAMaEU7kvti4uSLX5XYe/nk2abApg= +ucl.lmika.dev v0.0.0-20250519120409-53b05b5ba6f8/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= ucl.lmika.dev v0.0.0-20250525023717-3076897eb73e h1:N+HzQUunDUvdjAzbSDtHQZVZ1k+XHbVgbNwmc+EKmlQ= ucl.lmika.dev v0.0.0-20250525023717-3076897eb73e/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= diff --git a/internal/common/ui/commandctrl/cmdpacks/modopt.go b/internal/common/ui/commandctrl/cmdpacks/modopt.go deleted file mode 100644 index 398a86b..0000000 --- a/internal/common/ui/commandctrl/cmdpacks/modopt.go +++ /dev/null @@ -1,47 +0,0 @@ -package cmdpacks - -import ( - "context" - "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl" - "github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers" - "ucl.lmika.dev/ucl" -) - -type optModule struct { - settingsController *controllers.SettingsController -} - -func (m optModule) pbSet(ctx context.Context, args ucl.CallArgs) (any, error) { - var ( - name string - newVale string - ) - - if args.NArgs() == 1 { - if err := args.Bind(&name); err != nil { - return nil, err - } - } else { - if err := args.Bind(&name, &newVale); err != nil { - return nil, err - } - } - - commandctrl.PostMsg(ctx, m.settingsController.SetSetting(name, newVale)) - return nil, nil -} - -func moduleOpt( - settingsController *controllers.SettingsController, -) ucl.Module { - m := &optModule{ - settingsController: settingsController, - } - - return ucl.Module{ - Name: "opt", - Builtins: map[string]ucl.BuiltinHandler{ - "set": m.pbSet, - }, - } -} diff --git a/internal/common/ui/commandctrl/cmdpacks/modpb.go b/internal/common/ui/commandctrl/cmdpacks/modpb.go index 2a27d6e..528499b 100644 --- a/internal/common/ui/commandctrl/cmdpacks/modpb.go +++ b/internal/common/ui/commandctrl/cmdpacks/modpb.go @@ -2,12 +2,12 @@ package cmdpacks import ( "context" - "github.com/lmika/dynamo-browse/internal/dynamo-browse/services" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/pasteboardprovider" "ucl.lmika.dev/ucl" ) type pbModule struct { - pasteboardProvider services.PasteboardProvider + pasteboardProvider *pasteboardprovider.Provider } func (m pbModule) pbGet(ctx context.Context, args ucl.CallArgs) (any, error) { @@ -30,7 +30,7 @@ func (m pbModule) pbPut(ctx context.Context, args ucl.CallArgs) (any, error) { } func modulePB( - pasteboardProvider services.PasteboardProvider, + pasteboardProvider *pasteboardprovider.Provider, ) ucl.Module { m := &pbModule{ pasteboardProvider: pasteboardProvider, diff --git a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go index 748aaea..9019ec6 100644 --- a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go +++ b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go @@ -6,7 +6,7 @@ import ( "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl" "github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers" "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" - "github.com/lmika/dynamo-browse/internal/dynamo-browse/services" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/pasteboardprovider" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables" "github.com/pkg/errors" "ucl.lmika.dev/repl" @@ -20,8 +20,7 @@ type StandardCommands struct { WriteController *controllers.TableWriteController ExportController *controllers.ExportController KeyBindingController *controllers.KeyBindingController - PBProvider services.PasteboardProvider - SettingsController *controllers.SettingsController + PBProvider *pasteboardprovider.Provider modUI ucl.Module } @@ -33,8 +32,7 @@ func NewStandardCommands( writeController *controllers.TableWriteController, exportController *controllers.ExportController, keyBindingController *controllers.KeyBindingController, - pbProvider services.PasteboardProvider, - settingsController *controllers.SettingsController, + pbProvider *pasteboardprovider.Provider, ) StandardCommands { modUI, ckbs := moduleUI(tableService, state, readController) keyBindingController.SetCustomKeyBindingSource(ckbs) @@ -47,7 +45,6 @@ func NewStandardCommands( ExportController: exportController, KeyBindingController: keyBindingController, PBProvider: pbProvider, - SettingsController: settingsController, modUI: modUI, } } @@ -397,7 +394,6 @@ func (sc StandardCommands) InstOptions() []ucl.InstOption { ucl.WithModule(moduleRS(sc.TableService, sc.State)), ucl.WithModule(sc.modUI), ucl.WithModule(modulePB(sc.PBProvider)), - ucl.WithModule(moduleOpt(sc.SettingsController)), } } @@ -406,6 +402,7 @@ func (sc StandardCommands) ConfigureUCL(ucl *ucl.Inst) { ucl.SetBuiltin("table", sc.cmdTable) ucl.SetBuiltin("export", sc.cmdExport) ucl.SetBuiltin("mark", sc.cmdMark) + // unmark --> alias for { mark none } ucl.SetBuiltin("next-page", sc.cmdNextPage) ucl.SetBuiltin("delete", sc.cmdDelete) ucl.SetBuiltin("new-item", sc.cmdNewItem) @@ -416,25 +413,9 @@ func (sc StandardCommands) ConfigureUCL(ucl *ucl.Inst) { ucl.SetBuiltin("touch", sc.cmdTouch) ucl.SetBuiltin("noisy-touch", sc.cmdNoisyTouch) ucl.SetBuiltin("rebind", sc.cmdRebind) - - // Aliases - ucl.SetBuiltin("sa", sc.cmdSetAttr) - ucl.SetBuiltin("da", sc.cmdDelAttr) - ucl.SetBuiltin("np", sc.cmdNextPage) - ucl.SetBuiltin("w", sc.cmdPut) - ucl.SetBuiltin("q", sc.cmdQuit) + // set-opt --> alias to opts:set ucl.SetPseudoVar("resultset", resultSetPVar{sc.State, sc.ReadController}) ucl.SetPseudoVar("table", tablePVar{sc.State}) ucl.SetPseudoVar("item", itemPVar{sc.State}) } - -func (sc StandardCommands) RunPrelude(ctx context.Context, ucl *ucl.Inst) error { - _, err := ucl.EvalString(ctx, uclPrelude) - return err -} - -const uclPrelude = ` -ui:command unmark { mark none } -ui:command set-opt { |n k| opt:set $n $k } -` diff --git a/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go b/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go index 63f099f..228c6be 100644 --- a/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go +++ b/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go @@ -126,6 +126,7 @@ func newService(t *testing.T, opts ...serviceOpt) *services { inputHistoryService, eventBus, pasteboardprovider.NilProvider{}, + nil, s.table, ) writeController := controllers.NewTableWriteController(state, service, jobsController, readController, settingStore) @@ -136,17 +137,9 @@ func newService(t *testing.T, opts ...serviceOpt) *services { keyBindingService := keybindings_service.NewService(keybindings.Default()) keyBindingController := controllers.NewKeyBindingController(keyBindingService, nil) - commandController, _ := commandctrl.NewCommandController(inputHistoryService, - cmdpacks.NewStandardCommands( - service, - state, - readController, - writeController, - exportController, - keyBindingController, - pasteboardprovider.NilProvider{}, - settingsController, - ), + _ = settingsController + commandController := commandctrl.NewCommandController(inputHistoryService, + cmdpacks.NewStandardCommands(service, state, readController, writeController, exportController, keyBindingController), ) s.State = state diff --git a/internal/common/ui/commandctrl/commandctrl.go b/internal/common/ui/commandctrl/commandctrl.go index 9075da8..bfbe79b 100644 --- a/internal/common/ui/commandctrl/commandctrl.go +++ b/internal/common/ui/commandctrl/commandctrl.go @@ -35,7 +35,7 @@ type CommandController struct { interactive bool } -func NewCommandController(historyProvider IterProvider, pkgs ...CommandPack) (*CommandController, error) { +func NewCommandController(historyProvider IterProvider, pkgs ...CommandPack) *CommandController { cc := &CommandController{ historyProvider: historyProvider, commandList: nil, @@ -60,17 +60,9 @@ func NewCommandController(historyProvider IterProvider, pkgs ...CommandPack) (*C pkg.ConfigureUCL(cc.uclInst) } - execCtx := execContext{ctrl: cc} - ctx := context.WithValue(context.Background(), commandCtlKey, &execCtx) - for _, pkg := range pkgs { - if err := pkg.RunPrelude(ctx, cc.uclInst); err != nil { - return nil, err - } - } - go cc.cmdLooper() - return cc, nil + return cc } func (c *CommandController) AddCommands(ctx *CommandList) { diff --git a/internal/common/ui/commandctrl/commandctrl_test.go b/internal/common/ui/commandctrl/commandctrl_test.go index bcc112a..b21783e 100644 --- a/internal/common/ui/commandctrl/commandctrl_test.go +++ b/internal/common/ui/commandctrl/commandctrl_test.go @@ -12,8 +12,7 @@ import ( func TestCommandController_Prompt(t *testing.T) { t.Run("prompt user for a command", func(t *testing.T) { - cmd, err := commandctrl.NewCommandController(mockIterProvider{}) - assert.NoError(t, err) + cmd := commandctrl.NewCommandController(mockIterProvider{}) res := cmd.Prompt() diff --git a/internal/common/ui/commandctrl/packs.go b/internal/common/ui/commandctrl/packs.go index 16df613..6f5dbc6 100644 --- a/internal/common/ui/commandctrl/packs.go +++ b/internal/common/ui/commandctrl/packs.go @@ -1,12 +1,8 @@ package commandctrl -import ( - "context" - "ucl.lmika.dev/ucl" -) +import "ucl.lmika.dev/ucl" type CommandPack interface { InstOptions() []ucl.InstOption ConfigureUCL(ucl *ucl.Inst) - RunPrelude(ctx context.Context, ucl *ucl.Inst) error } diff --git a/internal/dynamo-browse/controllers/scripts.go b/internal/dynamo-browse/controllers/scripts.go new file mode 100644 index 0000000..ff2b8d6 --- /dev/null +++ b/internal/dynamo-browse/controllers/scripts.go @@ -0,0 +1,290 @@ +package controllers + +import ( + "context" + "fmt" + "log" + "strings" + "ucl.lmika.dev/ucl" + + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl" + "github.com/lmika/dynamo-browse/internal/common/ui/events" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models/relitems" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" + bus "github.com/lmika/events" + "github.com/pkg/errors" +) + +type ScriptController struct { + scriptManager *scriptmanager.Service + tableReadController *TableReadController + jobController *JobsController + settingsController *SettingsController + eventBus *bus.Bus + sendMsg func(msg tea.Msg) +} + +func NewScriptController( + scriptManager *scriptmanager.Service, + tableReadController *TableReadController, + jobController *JobsController, + settingsController *SettingsController, + eventBus *bus.Bus, +) *ScriptController { + sc := &ScriptController{ + scriptManager: scriptManager, + tableReadController: tableReadController, + jobController: jobController, + settingsController: settingsController, + eventBus: eventBus, + } + + sessionImpl := &sessionImpl{sc: sc, lastSelectedItemIndex: -1} + scriptManager.SetIFaces(scriptmanager.Ifaces{ + UI: &uiImpl{sc: sc}, + Session: sessionImpl, + }) + + sessionImpl.subscribeToEvents(eventBus) + + // Setup event handling when settings have changed + eventBus.On(BusEventSettingsUpdated, func(name, value string) { + if !strings.HasPrefix(name, "script.") { + return + } + sc.Init() + }) + + return sc +} + +func (sc *ScriptController) Init() { + if lookupPaths, err := sc.settingsController.settings.ScriptLookupFS(); err == nil { + sc.scriptManager.SetLookupPaths(lookupPaths) + } else { + log.Printf("warn: script lookup paths are invalid: %v", err) + } +} + +func (sc *ScriptController) SetMessageSender(sendMsg func(msg tea.Msg)) { + sc.sendMsg = sendMsg +} + +func (sc *ScriptController) LoadScript(filename string) tea.Msg { + ctx := context.Background() + plugin, err := sc.scriptManager.LoadScript(ctx, filename) + if err != nil { + return events.Error(err) + } + + return events.StatusMsg(fmt.Sprintf("Script '%v' loaded", plugin.Name())) +} + +func (sc *ScriptController) RunScript(filename string) tea.Msg { + ctx := context.Background() + if err := sc.scriptManager.StartAdHocScript(ctx, filename, sc.waitAndPrintScriptError()); err != nil { + return events.Error(err) + } + return nil +} + +func (sc *ScriptController) waitAndPrintScriptError() chan error { + errChan := make(chan error) + go func() { + if err := <-errChan; err != nil { + sc.sendMsg(events.Error(err)) + } + }() + return errChan +} + +func (sc *ScriptController) LookupCommand(name string) commandctrl.Command { + cmd := sc.scriptManager.LookupCommand(name) + if cmd == nil { + return nil + } + + return func(execCtx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + errChan := sc.waitAndPrintScriptError() + ctx := context.Background() + + invokeArgs := make([]string, 0) + for args.NArgs() > 0 { + var s string + if err := args.Bind(&s); err == nil { + invokeArgs = append(invokeArgs, s) + } + } + + if err := cmd.Invoke(ctx, invokeArgs, errChan); err != nil { + return events.Error(err) + } + return nil + } +} + +type uiImpl struct { + sc *ScriptController +} + +func (u uiImpl) PrintMessage(ctx context.Context, msg string) { + u.sc.sendMsg(events.StatusMsg(msg)) +} + +func (u uiImpl) Prompt(ctx context.Context, msg string) chan string { + resultChan := make(chan string) + u.sc.sendMsg(events.PromptForInputMsg{ + Prompt: msg, + OnDone: func(value string) tea.Msg { + resultChan <- value + return nil + }, + OnCancel: func() tea.Msg { + close(resultChan) + return nil + }, + }) + return resultChan +} + +type sessionImpl struct { + sc *ScriptController + lastSelectedItemIndex int +} + +func (s *sessionImpl) subscribeToEvents(bus *bus.Bus) { + bus.On("ui.new-item-selected", func(rs *models.ResultSet, itemIndex int) { + s.lastSelectedItemIndex = itemIndex + }) +} + +func (s *sessionImpl) SelectedItemIndex(ctx context.Context) int { + return s.lastSelectedItemIndex +} + +func (s *sessionImpl) ResultSet(ctx context.Context) *models.ResultSet { + return s.sc.tableReadController.state.ResultSet() +} + +func (s *sessionImpl) SetResultSet(ctx context.Context, newResultSet *models.ResultSet) { + state := s.sc.tableReadController.state + msg := s.sc.tableReadController.setResultSetAndFilter(newResultSet, state.filter, true, resultSetUpdateScript) + s.sc.sendMsg(msg) +} + +func (s *sessionImpl) Query(ctx context.Context, query string, opts scriptmanager.QueryOptions) (*models.ResultSet, error) { + // Parse the query + expr, err := queryexpr.Parse(query) + if err != nil { + return nil, err + } + + if opts.NamePlaceholders != nil { + expr = expr.WithNameParams(opts.NamePlaceholders) + } + if opts.ValuePlaceholders != nil { + expr = expr.WithValueParams(opts.ValuePlaceholders) + } + if opts.IndexName != "" { + expr = expr.WithIndex(opts.IndexName) + } + + return s.sc.doQuery(ctx, expr, opts) +} + +func (s *ScriptController) doQuery(ctx context.Context, expr *queryexpr.QueryExpr, opts scriptmanager.QueryOptions) (*models.ResultSet, error) { + // Get the table info + var ( + tableInfo *models.TableInfo + err error + ) + + tableName := opts.TableName + currentResultSet := s.tableReadController.state.ResultSet() + + if tableName != "" { + // Table specified. If it's the same as the current table, then use the existing table info + if currentResultSet != nil && currentResultSet.TableInfo.Name == tableName { + tableInfo = currentResultSet.TableInfo + } + + // Otherwise, describe the table + tableInfo, err = s.tableReadController.tableService.Describe(ctx, tableName) + if err != nil { + return nil, errors.Wrapf(err, "cannot describe table '%v'", tableName) + } + } else { + // Table not specified. Use the existing table, if any + if currentResultSet == nil { + return nil, errors.New("no table currently selected") + } + tableInfo = currentResultSet.TableInfo + } + + newResultSet, err := s.tableReadController.tableService.ScanOrQuery(ctx, tableInfo, expr, nil) + if err != nil { + return nil, err + } + return newResultSet, nil +} + +func (sc *ScriptController) CustomKeyCommand(key string) tea.Cmd { + _, cmd := sc.scriptManager.LookupKeyBinding(key) + if cmd == nil { + return nil + } + + return func() tea.Msg { + errChan := sc.waitAndPrintScriptError() + ctx := context.Background() + + if err := cmd.Invoke(ctx, nil, errChan); err != nil { + return events.Error(err) + } + return nil + } +} + +func (sc *ScriptController) Rebind(bindingName string, newKey string) error { + return sc.scriptManager.RebindKeyBinding(bindingName, newKey) +} + +func (sc *ScriptController) LookupBinding(theKey string) string { + bindingName, _ := sc.scriptManager.LookupKeyBinding(theKey) + return bindingName +} + +func (sc *ScriptController) UnbindKey(key string) { + sc.scriptManager.UnbindKey(key) +} + +func (c *ScriptController) LookupRelatedItems(idx int) (res tea.Msg) { + rs := c.tableReadController.state.ResultSet() + + relItems, err := c.scriptManager.RelatedItemOfItem(context.Background(), rs, idx) + if err != nil { + return events.Error(err) + } else if len(relItems) == 0 { + return events.StatusMsg("No related items available") + } + + return ShowRelatedItemsOverlay{ + Items: relItems, + OnSelected: func(item relitems.RelatedItem) tea.Msg { + if item.OnSelect != nil { + return item.OnSelect() + } + + return NewJob(c.jobController, "Running query…", func(ctx context.Context) (*models.ResultSet, error) { + return c.doQuery(ctx, item.Query, scriptmanager.QueryOptions{ + TableName: item.Table, + }) + }).OnDone(func(rs *models.ResultSet) tea.Msg { + return c.tableReadController.setResultSetAndFilter(rs, "", true, resultSetUpdateQuery) + }).Submit() + }, + } +} diff --git a/internal/dynamo-browse/controllers/scripts_test.go b/internal/dynamo-browse/controllers/scripts_test.go new file mode 100644 index 0000000..ffcb8a8 --- /dev/null +++ b/internal/dynamo-browse/controllers/scripts_test.go @@ -0,0 +1,192 @@ +package controllers_test + +import ( + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/dynamo-browse/internal/common/ui/events" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers" + "github.com/stretchr/testify/assert" +) + +func TestScriptController_RunScript(t *testing.T) { + t.Run("should execute scripts successfully", func(t *testing.T) { + srv := newService(t, serviceConfig{ + scriptFS: testScriptFile(t, "test.tm", ` + ui.print("Hello world") + `), + }) + + msg := srv.scriptController.RunScript("test.tm") + assert.Nil(t, msg) + + srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second) + + assert.Len(t, srv.msgSender.msgs, 1) + assert.Equal(t, events.StatusMsg("Hello world"), srv.msgSender.msgs[0]) + }) + + t.Run("session.result_set", func(t *testing.T) { + t.Run("should return current result set if not-nil", func(t *testing.T) { + srv := newService(t, serviceConfig{ + tableName: "alpha-table", + scriptFS: testScriptFile(t, "test.tm", ` + rs := session.result_set() + ui.print(rs.length) + `), + }) + + invokeCommand(t, srv.readController.Init()) + + msg := srv.scriptController.RunScript("test.tm") + assert.Nil(t, msg) + + srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second) + + assert.Len(t, srv.msgSender.msgs, 1) + assert.Equal(t, events.StatusMsg("3"), srv.msgSender.msgs[0]) + }) + }) + + t.Run("session.query", func(t *testing.T) { + t.Run("should run query against current table", func(t *testing.T) { + srv := newService(t, serviceConfig{ + tableName: "alpha-table", + scriptFS: testScriptFile(t, "test.tm", ` + rs := session.query('pk="abc"') + ui.print(rs.length) + `), + }) + + invokeCommand(t, srv.readController.Init()) + msg := srv.scriptController.RunScript("test.tm") + assert.Nil(t, msg) + + srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second) + + assert.Len(t, srv.msgSender.msgs, 1) + assert.Equal(t, events.StatusMsg("2"), srv.msgSender.msgs[0]) + }) + + t.Run("should run query against another table", func(t *testing.T) { + srv := newService(t, serviceConfig{ + tableName: "alpha-table", + scriptFS: testScriptFile(t, "test.tm", ` + rs := session.query('pk!="abc"', { table: "count-to-30" }) + ui.print(rs.length) + `), + }) + + invokeCommand(t, srv.readController.Init()) + msg := srv.scriptController.RunScript("test.tm") + assert.Nil(t, msg) + + srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second) + + assert.Len(t, srv.msgSender.msgs, 1) + assert.Equal(t, events.StatusMsg("30"), srv.msgSender.msgs[0]) + }) + }) + + t.Run("session.set_result_set", func(t *testing.T) { + t.Run("should set the result set from the result of a query", func(t *testing.T) { + srv := newService(t, serviceConfig{ + tableName: "alpha-table", + scriptFS: testScriptFile(t, "test.tm", ` + rs := session.query('pk="abc"') + session.set_result_set(rs) + `), + }) + + invokeCommand(t, srv.readController.Init()) + msg := srv.scriptController.RunScript("test.tm") + assert.Nil(t, msg) + + srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second) + + assert.Len(t, srv.msgSender.msgs, 1) + assert.IsType(t, controllers.NewResultSet{}, srv.msgSender.msgs[0]) + }) + + t.Run("changed attributes of the result set should show up as modified", func(t *testing.T) { + srv := newService(t, serviceConfig{ + tableName: "alpha-table", + scriptFS: testScriptFile(t, "test.tm", ` + rs := session.query('pk="abc"') + rs[0].set_attr("pk", "131") + session.set_result_set(rs) + `), + }) + + invokeCommand(t, srv.readController.Init()) + msg := srv.scriptController.RunScript("test.tm") + assert.Nil(t, msg) + + srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second) + + assert.Len(t, srv.msgSender.msgs, 1) + assert.IsType(t, controllers.NewResultSet{}, srv.msgSender.msgs[0]) + + assert.Equal(t, "131", srv.state.ResultSet().Items()[0]["pk"].(*types.AttributeValueMemberS).Value) + assert.True(t, srv.state.ResultSet().IsDirty(0)) + }) + }) +} + +func TestScriptController_LookupCommand(t *testing.T) { + t.Run("should schedule the script on a separate go-routine", func(t *testing.T) { + scenarios := []struct { + descr string + command string + expectedOutput string + }{ + {descr: "command with arg", command: "mycommand \"test name\"", expectedOutput: "Hello, test name"}, + {descr: "command no arg", command: "mycommand", expectedOutput: "Hello, nil value"}, + } + + for _, scenario := range scenarios { + t.Run(scenario.descr, func(t *testing.T) { + srv := newService(t, serviceConfig{ + tableName: "alpha-table", + scriptFS: testScriptFile(t, "test.tm", ` + ext.command("mycommand", func(name = "nil value") { + ui.print(sprintf("Hello, %v", name)) + }) + `), + }) + + invokeCommand(t, srv.scriptController.LoadScript("test.tm")) + invokeCommand(t, srv.commandController.Execute(scenario.command)) + + srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second) + + assert.Len(t, srv.msgSender.msgs, 1) + assert.Equal(t, events.StatusMsg(scenario.expectedOutput), srv.msgSender.msgs[0]) + }) + } + }) + + t.Run("should only allow one script to run at a time", func(t *testing.T) { + srv := newService(t, serviceConfig{ + tableName: "alpha-table", + scriptFS: testScriptFile(t, "test.tm", ` + ext.command("mycommand", func() { + time.sleep(1.5) + ui.print("Done my thing") + }) + `), + }) + + invokeCommand(t, srv.scriptController.LoadScript("test.tm")) + + invokeCommand(t, srv.commandController.Execute(`mycommand`)) + invokeCommandExpectingError(t, srv.commandController.Execute(`mycommand`)) + + srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second) + + assert.Len(t, srv.msgSender.msgs, 1) + assert.Equal(t, events.StatusMsg("Done my thing"), srv.msgSender.msgs[0]) + }) + +} diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index 64aca40..38946f3 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -61,6 +61,7 @@ type TableReadController struct { tableName string loadFromLastView bool pasteboardProvider services.PasteboardProvider + relatedItemSupplier RelatedItemSupplier // state mutex *sync.Mutex @@ -76,6 +77,7 @@ func NewTableReadController( inputHistoryService *inputhistory.Service, eventBus *bus.Bus, pasteboardProvider services.PasteboardProvider, + relatedItemSupplier RelatedItemSupplier, tableName string, ) *TableReadController { return &TableReadController{ @@ -88,6 +90,7 @@ func NewTableReadController( eventBus: eventBus, tableName: tableName, pasteboardProvider: pasteboardProvider, + relatedItemSupplier: relatedItemSupplier, mutex: new(sync.Mutex), } } diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index 0ace3d9..efd0c14 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -15,6 +15,7 @@ import ( "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/inputhistory" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/itemrenderer" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/jobs" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/viewsnapshot" "github.com/lmika/dynamo-browse/test/testdynamo" @@ -586,6 +587,7 @@ type services struct { settingsController *controllers.SettingsController columnsController *controllers.ColumnsController exportController *controllers.ExportController + scriptController *controllers.ScriptController commandController *commandctrl.CommandController } @@ -605,6 +607,7 @@ func newService(t *testing.T, cfg serviceConfig) *services { workspaceService := viewsnapshot.NewService(resultSetSnapshotStore) itemRendererService := itemrenderer.NewService(itemrenderer.PlainTextRenderer(), itemrenderer.PlainTextRenderer()) + scriptService := scriptmanager.New() inputHistoryService := inputhistory.New(inputHistoryStore) client := testdynamo.SetupTestTable(t, testData) @@ -624,14 +627,17 @@ func newService(t *testing.T, cfg serviceConfig) *services { inputHistoryService, eventBus, pasteboardprovider.NilProvider{}, + nil, cfg.tableName, ) writeController := controllers.NewTableWriteController(state, service, jobsController, readController, settingStore) settingsController := controllers.NewSettingsController(settingStore, eventBus) columnsController := controllers.NewColumnsController(readController, eventBus) exportController := controllers.NewExportController(state, service, jobsController, columnsController, pasteboardprovider.NilProvider{}) + scriptController := controllers.NewScriptController(scriptService, readController, jobsController, settingsController, eventBus) - commandController, _ := commandctrl.NewCommandController(inputHistoryService) + commandController := commandctrl.NewCommandController(inputHistoryService) + commandController.AddCommandLookupExtension(scriptController) if cfg.isReadOnly { if err := settingStore.SetReadOnly(cfg.isReadOnly); err != nil { @@ -645,7 +651,12 @@ func newService(t *testing.T, cfg serviceConfig) *services { } msgSender := &msgSender{} + scriptController.Init() jobsController.SetMessageSender(msgSender.send) + scriptController.SetMessageSender(msgSender.send) + + // Initting will setup the default script lookup paths, so revert them to the test ones + scriptService.SetLookupPaths([]fs.FS{cfg.scriptFS}) return &services{ state: state, @@ -655,6 +666,7 @@ func newService(t *testing.T, cfg serviceConfig) *services { settingsController: settingsController, columnsController: columnsController, exportController: exportController, + scriptController: scriptController, commandController: commandController, msgSender: msgSender, } diff --git a/internal/dynamo-browse/services/scriptmanager/builtins.go b/internal/dynamo-browse/services/scriptmanager/builtins.go new file mode 100644 index 0000000..93c6e78 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/builtins.go @@ -0,0 +1,102 @@ +/** + * Builtins adopted and modified from Taramin + * Copyright (c) 2022 Curtis Myzie + */ + +package scriptmanager + +import ( + "context" + "fmt" + "log" + + "github.com/pkg/errors" + "github.com/risor-io/risor/object" +) + +func printBuiltin(ctx context.Context, args ...object.Object) object.Object { + env := scriptEnvFromCtx(ctx) + prefix := "script " + env.filename + ":" + + values := make([]interface{}, len(args)+1) + values[0] = prefix + for i, arg := range args { + switch arg := arg.(type) { + case *object.String: + values[i+1] = arg.Value() + default: + values[i+1] = arg.Inspect() + } + } + log.Println(values...) + return object.Nil +} + +func printfBuiltin(ctx context.Context, args ...object.Object) object.Object { + env := scriptEnvFromCtx(ctx) + prefix := "script " + env.filename + ":" + + numArgs := len(args) + if numArgs < 1 { + return object.Errorf("type error: printf() takes 1 or more arguments (%d given)", len(args)) + } + format, err := object.AsString(args[0]) + if err != nil { + return err + } + var values = []interface{}{prefix} + for _, arg := range args[1:] { + switch arg := arg.(type) { + case *object.String: + values = append(values, arg.Value()) + default: + values = append(values, arg.Interface()) + } + } + log.Printf("%s "+format, values...) + return object.Nil +} + +// This is taken from the args package +func require(funcName string, count int, args []object.Object) *object.Error { + nArgs := len(args) + if nArgs != count { + if count == 1 { + return object.Errorf( + fmt.Sprintf("type error: %s() takes exactly 1 argument (%d given)", + funcName, nArgs)) + } + return object.Errorf( + fmt.Sprintf("type error: %s() takes exactly %d arguments (%d given)", + funcName, count, nArgs)) + } + return nil +} + +func bindArgs(funcName string, args []object.Object, bindArgs ...any) *object.Error { + if err := require(funcName, len(bindArgs), args); err != nil { + return err + } + + for i, bindArg := range bindArgs { + switch t := bindArg.(type) { + case *string: + str, err := object.AsString(args[i]) + if err != nil { + return err + } + + *t = str + case **object.Function: + fnRes, isFnRes := args[i].(*object.Function) + if !isFnRes { + return object.NewError(errors.Errorf("expected arg %v to be a function, was %T", i, bindArg)) + } + + *t = fnRes + default: + return object.NewError(errors.Errorf("unhandled arg type %v", i)) + } + } + return nil +} diff --git a/internal/dynamo-browse/services/scriptmanager/iface.go b/internal/dynamo-browse/services/scriptmanager/iface.go new file mode 100644 index 0000000..39b4d9e --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/iface.go @@ -0,0 +1,38 @@ +package scriptmanager + +import ( + "context" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" +) + +//go:generate mockery --with-expecter --name UIService +//go:generate mockery --with-expecter --name SessionService + +type Ifaces struct { + UI UIService + Session SessionService +} + +type UIService interface { + PrintMessage(ctx context.Context, msg string) + + // Prompt should return a channel which will provide the input from the user. If the user + // provides no input, prompt should close the channel without providing anything. + Prompt(ctx context.Context, msg string) chan string +} + +type SessionService interface { + Query(ctx context.Context, expr string, queryOptions QueryOptions) (*models.ResultSet, error) + + ResultSet(ctx context.Context) *models.ResultSet + SelectedItemIndex(ctx context.Context) int + SetResultSet(ctx context.Context, newResultSet *models.ResultSet) +} + +type QueryOptions struct { + TableName string + IndexName string + NamePlaceholders map[string]string + ValuePlaceholders map[string]types.AttributeValue +} diff --git a/internal/dynamo-browse/services/scriptmanager/mocks/SessionService.go b/internal/dynamo-browse/services/scriptmanager/mocks/SessionService.go new file mode 100644 index 0000000..bdfa6b1 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/mocks/SessionService.go @@ -0,0 +1,216 @@ +// Code generated by mockery v2.20.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + models "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + mock "github.com/stretchr/testify/mock" + + scriptmanager "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" +) + +// SessionService is an autogenerated mock type for the SessionService type +type SessionService struct { + mock.Mock +} + +type SessionService_Expecter struct { + mock *mock.Mock +} + +func (_m *SessionService) EXPECT() *SessionService_Expecter { + return &SessionService_Expecter{mock: &_m.Mock} +} + +// Query provides a mock function with given fields: ctx, expr, queryOptions +func (_m *SessionService) Query(ctx context.Context, expr string, queryOptions scriptmanager.QueryOptions) (*models.ResultSet, error) { + ret := _m.Called(ctx, expr, queryOptions) + + var r0 *models.ResultSet + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, scriptmanager.QueryOptions) (*models.ResultSet, error)); ok { + return rf(ctx, expr, queryOptions) + } + if rf, ok := ret.Get(0).(func(context.Context, string, scriptmanager.QueryOptions) *models.ResultSet); ok { + r0 = rf(ctx, expr, queryOptions) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.ResultSet) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, scriptmanager.QueryOptions) error); ok { + r1 = rf(ctx, expr, queryOptions) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SessionService_Query_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Query' +type SessionService_Query_Call struct { + *mock.Call +} + +// Query is a helper method to define mock.On call +// - ctx context.Context +// - expr string +// - queryOptions scriptmanager.QueryOptions +func (_e *SessionService_Expecter) Query(ctx interface{}, expr interface{}, queryOptions interface{}) *SessionService_Query_Call { + return &SessionService_Query_Call{Call: _e.mock.On("Query", ctx, expr, queryOptions)} +} + +func (_c *SessionService_Query_Call) Run(run func(ctx context.Context, expr string, queryOptions scriptmanager.QueryOptions)) *SessionService_Query_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(scriptmanager.QueryOptions)) + }) + return _c +} + +func (_c *SessionService_Query_Call) Return(_a0 *models.ResultSet, _a1 error) *SessionService_Query_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *SessionService_Query_Call) RunAndReturn(run func(context.Context, string, scriptmanager.QueryOptions) (*models.ResultSet, error)) *SessionService_Query_Call { + _c.Call.Return(run) + return _c +} + +// ResultSet provides a mock function with given fields: ctx +func (_m *SessionService) ResultSet(ctx context.Context) *models.ResultSet { + ret := _m.Called(ctx) + + var r0 *models.ResultSet + if rf, ok := ret.Get(0).(func(context.Context) *models.ResultSet); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.ResultSet) + } + } + + return r0 +} + +// SessionService_ResultSet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResultSet' +type SessionService_ResultSet_Call struct { + *mock.Call +} + +// ResultSet is a helper method to define mock.On call +// - ctx context.Context +func (_e *SessionService_Expecter) ResultSet(ctx interface{}) *SessionService_ResultSet_Call { + return &SessionService_ResultSet_Call{Call: _e.mock.On("ResultSet", ctx)} +} + +func (_c *SessionService_ResultSet_Call) Run(run func(ctx context.Context)) *SessionService_ResultSet_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *SessionService_ResultSet_Call) Return(_a0 *models.ResultSet) *SessionService_ResultSet_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *SessionService_ResultSet_Call) RunAndReturn(run func(context.Context) *models.ResultSet) *SessionService_ResultSet_Call { + _c.Call.Return(run) + return _c +} + +// SelectedItemIndex provides a mock function with given fields: ctx +func (_m *SessionService) SelectedItemIndex(ctx context.Context) int { + ret := _m.Called(ctx) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context) int); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(int) + } + + return r0 +} + +// SessionService_SelectedItemIndex_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectedItemIndex' +type SessionService_SelectedItemIndex_Call struct { + *mock.Call +} + +// SelectedItemIndex is a helper method to define mock.On call +// - ctx context.Context +func (_e *SessionService_Expecter) SelectedItemIndex(ctx interface{}) *SessionService_SelectedItemIndex_Call { + return &SessionService_SelectedItemIndex_Call{Call: _e.mock.On("SelectedItemIndex", ctx)} +} + +func (_c *SessionService_SelectedItemIndex_Call) Run(run func(ctx context.Context)) *SessionService_SelectedItemIndex_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *SessionService_SelectedItemIndex_Call) Return(_a0 int) *SessionService_SelectedItemIndex_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *SessionService_SelectedItemIndex_Call) RunAndReturn(run func(context.Context) int) *SessionService_SelectedItemIndex_Call { + _c.Call.Return(run) + return _c +} + +// SetResultSet provides a mock function with given fields: ctx, newResultSet +func (_m *SessionService) SetResultSet(ctx context.Context, newResultSet *models.ResultSet) { + _m.Called(ctx, newResultSet) +} + +// SessionService_SetResultSet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetResultSet' +type SessionService_SetResultSet_Call struct { + *mock.Call +} + +// SetResultSet is a helper method to define mock.On call +// - ctx context.Context +// - newResultSet *models.ResultSet +func (_e *SessionService_Expecter) SetResultSet(ctx interface{}, newResultSet interface{}) *SessionService_SetResultSet_Call { + return &SessionService_SetResultSet_Call{Call: _e.mock.On("SetResultSet", ctx, newResultSet)} +} + +func (_c *SessionService_SetResultSet_Call) Run(run func(ctx context.Context, newResultSet *models.ResultSet)) *SessionService_SetResultSet_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*models.ResultSet)) + }) + return _c +} + +func (_c *SessionService_SetResultSet_Call) Return() *SessionService_SetResultSet_Call { + _c.Call.Return() + return _c +} + +func (_c *SessionService_SetResultSet_Call) RunAndReturn(run func(context.Context, *models.ResultSet)) *SessionService_SetResultSet_Call { + _c.Call.Return(run) + return _c +} + +type mockConstructorTestingTNewSessionService interface { + mock.TestingT + Cleanup(func()) +} + +// NewSessionService creates a new instance of SessionService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewSessionService(t mockConstructorTestingTNewSessionService) *SessionService { + mock := &SessionService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/dynamo-browse/services/scriptmanager/mocks/UIService.go b/internal/dynamo-browse/services/scriptmanager/mocks/UIService.go new file mode 100644 index 0000000..b029dd6 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/mocks/UIService.go @@ -0,0 +1,116 @@ +// Code generated by mockery v2.20.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// UIService is an autogenerated mock type for the UIService type +type UIService struct { + mock.Mock +} + +type UIService_Expecter struct { + mock *mock.Mock +} + +func (_m *UIService) EXPECT() *UIService_Expecter { + return &UIService_Expecter{mock: &_m.Mock} +} + +// PrintMessage provides a mock function with given fields: ctx, msg +func (_m *UIService) PrintMessage(ctx context.Context, msg string) { + _m.Called(ctx, msg) +} + +// UIService_PrintMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrintMessage' +type UIService_PrintMessage_Call struct { + *mock.Call +} + +// PrintMessage is a helper method to define mock.On call +// - ctx context.Context +// - msg string +func (_e *UIService_Expecter) PrintMessage(ctx interface{}, msg interface{}) *UIService_PrintMessage_Call { + return &UIService_PrintMessage_Call{Call: _e.mock.On("PrintMessage", ctx, msg)} +} + +func (_c *UIService_PrintMessage_Call) Run(run func(ctx context.Context, msg string)) *UIService_PrintMessage_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *UIService_PrintMessage_Call) Return() *UIService_PrintMessage_Call { + _c.Call.Return() + return _c +} + +func (_c *UIService_PrintMessage_Call) RunAndReturn(run func(context.Context, string)) *UIService_PrintMessage_Call { + _c.Call.Return(run) + return _c +} + +// Prompt provides a mock function with given fields: ctx, msg +func (_m *UIService) Prompt(ctx context.Context, msg string) chan string { + ret := _m.Called(ctx, msg) + + var r0 chan string + if rf, ok := ret.Get(0).(func(context.Context, string) chan string); ok { + r0 = rf(ctx, msg) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(chan string) + } + } + + return r0 +} + +// UIService_Prompt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Prompt' +type UIService_Prompt_Call struct { + *mock.Call +} + +// Prompt is a helper method to define mock.On call +// - ctx context.Context +// - msg string +func (_e *UIService_Expecter) Prompt(ctx interface{}, msg interface{}) *UIService_Prompt_Call { + return &UIService_Prompt_Call{Call: _e.mock.On("Prompt", ctx, msg)} +} + +func (_c *UIService_Prompt_Call) Run(run func(ctx context.Context, msg string)) *UIService_Prompt_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *UIService_Prompt_Call) Return(_a0 chan string) *UIService_Prompt_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *UIService_Prompt_Call) RunAndReturn(run func(context.Context, string) chan string) *UIService_Prompt_Call { + _c.Call.Return(run) + return _c +} + +type mockConstructorTestingTNewUIService interface { + mock.TestingT + Cleanup(func()) +} + +// NewUIService creates a new instance of UIService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewUIService(t mockConstructorTestingTNewUIService) *UIService { + mock := &UIService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/dynamo-browse/services/scriptmanager/modext.go b/internal/dynamo-browse/services/scriptmanager/modext.go new file mode 100644 index 0000000..4ad97d4 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modext.go @@ -0,0 +1,270 @@ +package scriptmanager + +import ( + "context" + "fmt" + "regexp" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr" + "github.com/pkg/errors" + "github.com/risor-io/risor/object" +) + +var ( + validKeyBindingNames = regexp.MustCompile(`^[-a-zA-Z0-9_]+$`) +) + +type extModule struct { + scriptPlugin *ScriptPlugin +} + +func (m *extModule) register() *object.Module { + return object.NewBuiltinsModule("ext", map[string]object.Object{ + "command": object.NewBuiltin("command", m.command), + "key_binding": object.NewBuiltin("key_binding", m.keyBinding), + "related_items": object.NewBuiltin("related_items", m.relatedItem), + }) +} + +func (m *extModule) command(ctx context.Context, args ...object.Object) object.Object { + thisEnv := scriptEnvFromCtx(ctx) + + if err := require("ext.command", 2, args); err != nil { + return err + } + + cmdName, err := object.AsString(args[0]) + if err != nil { + return err + } + fnRes, isFnRes := args[1].(*object.Function) + if !isFnRes { + return object.NewError(errors.New("expected second arg to be a function")) + } + + callFn, hasCallFn := object.GetCallFunc(ctx) + if !hasCallFn { + return object.NewError(errors.New("no callFn found in context")) + } + + // This command function will be executed by the script scheduler + newCommand := func(ctx context.Context, args []string) error { + objArgs := make([]object.Object, len(args)) + for i, a := range args { + objArgs[i] = object.NewString(a) + } + + newEnv := thisEnv + ctx = ctxWithScriptEnv(ctx, newEnv) + + res, err := callFn(ctx, fnRes, objArgs) + if err != nil { + return errors.Errorf("command error '%v':%v - %v", m.scriptPlugin.name, cmdName, err) + } else if object.IsError(res) { + errObj := res.(*object.Error) + return errors.Errorf("command error '%v':%v - %v", m.scriptPlugin.name, cmdName, errObj.Inspect()) + } + return nil + } + + if m.scriptPlugin.definedCommands == nil { + m.scriptPlugin.definedCommands = make(map[string]*Command) + } + m.scriptPlugin.definedCommands[cmdName] = &Command{plugin: m.scriptPlugin, cmdFn: newCommand} + return nil +} + +func (m *extModule) keyBinding(ctx context.Context, args ...object.Object) object.Object { + thisEnv := scriptEnvFromCtx(ctx) + + if err := require("ext.key_binding", 3, args); err != nil { + return err + } + + bindingName, err := object.AsString(args[0]) + if err != nil { + return err + } else if !validKeyBindingNames.MatchString(bindingName) { + return object.NewError(errors.New("value error: binding name must match regexp [-a-zA-Z0-9_]+")) + } + + options, err := object.AsMap(args[1]) + if err != nil { + return err + } + + var defaultKey string + if strVal, isStrVal := options.Get("default").(*object.String); isStrVal { + defaultKey = strVal.Value() + } + + fnRes, isFnRes := args[2].(*object.Function) + if !isFnRes { + return object.NewError(errors.New("expected second arg to be a function")) + } + + callFn, hasCallFn := object.GetCallFunc(ctx) + if !hasCallFn { + return object.NewError(errors.New("no callFn found in context")) + } + + // This command function will be executed by the script scheduler + newCommand := func(ctx context.Context, args []string) error { + objArgs := make([]object.Object, len(args)) + for i, a := range args { + objArgs[i] = object.NewString(a) + } + + newEnv := thisEnv + ctx = ctxWithScriptEnv(ctx, newEnv) + + res, err := callFn(ctx, fnRes, objArgs) + if err != nil { + return errors.Errorf("command error '%v':%v - %v", m.scriptPlugin.name, bindingName, err) + } else if object.IsError(res) { + errObj := res.(*object.Error) + return errors.Errorf("command error '%v':%v - %v", m.scriptPlugin.name, bindingName, errObj.Inspect()) + } + return nil + } + + fullBindingName := fmt.Sprintf("ext.%v.%v", m.scriptPlugin.name, bindingName) + + if m.scriptPlugin.definedKeyBindings == nil { + m.scriptPlugin.definedKeyBindings = make(map[string]*Command) + m.scriptPlugin.keyToKeyBinding = make(map[string]string) + } + + m.scriptPlugin.definedKeyBindings[fullBindingName] = &Command{plugin: m.scriptPlugin, cmdFn: newCommand} + m.scriptPlugin.keyToKeyBinding[defaultKey] = fullBindingName + return nil +} + +func (m *extModule) relatedItem(ctx context.Context, args ...object.Object) object.Object { + thisEnv := scriptEnvFromCtx(ctx) + + var ( + tableName string + callbackFn *object.Function + ) + if err := bindArgs("ext.related_items", args, &tableName, &callbackFn); err != nil { + return err + } + + callFn, hasCallFn := object.GetCallFunc(ctx) + if !hasCallFn { + return object.NewError(errors.New("no callFn found in context")) + } + + newHandler := func(ctx context.Context, rs *models.ResultSet, index int) ([]relatedItem, error) { + newEnv := thisEnv + ctx = ctxWithScriptEnv(ctx, newEnv) + + res, err := callFn(ctx, callbackFn, []object.Object{ + newItemProxy(newResultSetProxy(rs), index), + }) + + if err != nil { + return nil, errors.Errorf("script error '%v':related_item - %v", m.scriptPlugin.name, err) + } else if object.IsError(res) { + errObj := res.(*object.Error) + return nil, errors.Errorf("script error '%v':related_item - %v", m.scriptPlugin.name, errObj.Inspect()) + } + + itr, objErr := object.AsIterator(res) + if err != nil { + return nil, objErr.Value() + } + + var relItems []relatedItem + for next, hasNext := itr.Next(ctx); hasNext; next, hasNext = itr.Next(ctx) { + var newRelItem relatedItem + + itemMap, objErr := object.AsMap(next) + if err != nil { + return nil, objErr.Value() + } + + labelName, objErr := object.AsString(itemMap.Get("label")) + if objErr != nil { + continue + } + newRelItem.label = labelName + + var tableStr = "" + if itemMap.Get("table") != object.Nil { + tableStr, objErr = object.AsString(itemMap.Get("table")) + if objErr != nil { + continue + } + } + newRelItem.table = tableStr + + if selectFn, ok := itemMap.Get("on_select").(*object.Function); ok { + newRelItem.onSelect = func() error { + thisNewEnv := thisEnv + ctx = ctxWithScriptEnv(ctx, thisNewEnv) + + res, err := callFn(ctx, selectFn, []object.Object{}) + if err != nil { + return errors.Errorf("rel error '%v' - %v", m.scriptPlugin.name, err) + } else if object.IsError(res) { + errObj := res.(*object.Error) + return errors.Errorf("rel error '%v' - %v", m.scriptPlugin.name, errObj.Inspect()) + } + return nil + } + } else { + queryExprStr, objErr := object.AsString(itemMap.Get("query")) + if objErr != nil { + continue + } + + query, err := queryexpr.Parse(queryExprStr) + if err != nil { + continue + } + + // Placeholders + if argsVal, isArgsValMap := object.AsMap(itemMap.Get("args")); isArgsValMap == nil { + namePlaceholders := make(map[string]string) + valuePlaceholders := make(map[string]types.AttributeValue) + + for k, val := range argsVal.Value() { + switch v := val.(type) { + case *object.String: + namePlaceholders[k] = v.Value() + valuePlaceholders[k] = &types.AttributeValueMemberS{Value: v.Value()} + case *object.Int: + valuePlaceholders[k] = &types.AttributeValueMemberN{Value: fmt.Sprint(v.Value())} + case *object.Float: + valuePlaceholders[k] = &types.AttributeValueMemberN{Value: fmt.Sprint(v.Value())} + case *object.Bool: + valuePlaceholders[k] = &types.AttributeValueMemberBOOL{Value: v.Value()} + case *object.NilType: + valuePlaceholders[k] = &types.AttributeValueMemberNULL{Value: true} + default: + continue + } + } + + query = query.WithNameParams(namePlaceholders).WithValueParams(valuePlaceholders) + } + newRelItem.query = query + } + + relItems = append(relItems, newRelItem) + } + + return relItems, nil + } + + m.scriptPlugin.relatedItems = append(m.scriptPlugin.relatedItems, &relatedItemBuilder{ + table: tableName, + itemProduction: newHandler, + }) + + return nil +} diff --git a/internal/dynamo-browse/services/scriptmanager/modext_test.go b/internal/dynamo-browse/services/scriptmanager/modext_test.go new file mode 100644 index 0000000..b3413e6 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modext_test.go @@ -0,0 +1,151 @@ +package scriptmanager_test + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" + "github.com/stretchr/testify/assert" +) + +func TestExtModule_RelatedItems(t *testing.T) { + t.Run("should register a function which will return related items for an item", func(t *testing.T) { + scenarios := []struct { + desc string + code string + }{ + { + desc: "single function, table name match", + code: ` + ext.related_items("test-table", func(item) { + print("Hello") + return [ + {"label": "Customer", "query": "pk=$foo", "args": {"foo": "foo"}}, + {"label": "Payment", "query": "fla=$daa", "args": {"daa": "Hello"}}, + ] + }) + `, + }, + { + desc: "single function, table prefix match", + code: ` + ext.related_items("test-*", func(item) { + print("Hello") + return [ + {"label": "Customer", "query": "pk=$foo", "args": {"foo": "foo"}}, + {"label": "Payment", "query": "fla=$daa", "args": {"daa": "Hello"}}, + ] + }) + `, + }, + { + desc: "multi function, table name match", + code: ` + ext.related_items("test-table", func(item) { + print("Hello") + return [ + {"label": "Customer", "query": "pk=$foo", "args": {"foo": "foo"}}, + ] + }) + + ext.related_items("test-table", func(item) { + return [ + {"label": "Payment", "query": "fla=$daa", "args": {"daa": "Hello"}}, + ] + }) + `, + }, + { + desc: "multi function, table name prefix", + code: ` + ext.related_items("test-*", func(item) { + print("Hello") + return [ + {"label": "Customer", "query": "pk=$foo", "args": {"foo": "foo"}}, + ] + }) + + ext.related_items("test-*", func(item) { + return [ + {"label": "Payment", "query": "fla=$daa", "args": {"daa": "Hello"}}, + ] + }) + `, + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.desc, func(t *testing.T) { + // Load the script + srv := scriptmanager.New(scriptmanager.WithFS(testScriptFile(t, "test.tm", scenario.code))) + + ctx := context.Background() + plugin, err := srv.LoadScript(ctx, "test.tm") + assert.NoError(t, err) + assert.NotNil(t, plugin) + + // Get related items of result set + rs := &models.ResultSet{ + TableInfo: &models.TableInfo{ + Name: "test-table", + }, + } + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}}, + }) + + relItems, err := srv.RelatedItemOfItem(context.Background(), rs, 0) + assert.NoError(t, err) + assert.Len(t, relItems, 2) + + assert.Equal(t, "Customer", relItems[0].Name) + assert.Equal(t, "pk=$foo", relItems[0].Query.String()) + assert.Equal(t, "foo", relItems[0].Query.ValueParamOrNil("foo").(*types.AttributeValueMemberS).Value) + + assert.Equal(t, "Payment", relItems[1].Name) + assert.Equal(t, "fla=$daa", relItems[1].Query.String()) + assert.Equal(t, "Hello", relItems[1].Query.ValueParamOrNil("daa").(*types.AttributeValueMemberS).Value) + }) + } + }) + + t.Run("should support rel_items with on select", func(t *testing.T) { + // Load the script + srv := scriptmanager.New(scriptmanager.WithFS(testScriptFile(t, "test.tm", ` + ext.related_items("test-table", func(item) { + print("Hello") + return [ + {"label": "Customer", "on_select": func() { + print("Selected") + }}, + ] + }) + `))) + + ctx := context.Background() + plugin, err := srv.LoadScript(ctx, "test.tm") + assert.NoError(t, err) + assert.NotNil(t, plugin) + + // Get related items of result set + rs := &models.ResultSet{ + TableInfo: &models.TableInfo{ + Name: "test-table", + }, + } + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}}, + }) + + relItems, err := srv.RelatedItemOfItem(context.Background(), rs, 0) + assert.NoError(t, err) + assert.Len(t, relItems, 1) + + assert.Equal(t, "Customer", relItems[0].Name) + assert.NoError(t, relItems[0].OnSelect()) + }) +} diff --git a/internal/dynamo-browse/services/scriptmanager/modos_test.go b/internal/dynamo-browse/services/scriptmanager/modos_test.go new file mode 100644 index 0000000..455125b --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modos_test.go @@ -0,0 +1,56 @@ +package scriptmanager_test + +import ( + "context" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "testing" +) + +func TestOSModule_Env(t *testing.T) { + t.Run("should return value of environment variables", func(t *testing.T) { + t.Setenv("FULL_VALUE", "this is a value") + t.Setenv("EMPTY_VALUE", "") + + testFS := testScriptFile(t, "test.tm", ` + assert(os.getenv("FULL_VALUE") == "this is a value") + assert(os.getenv("EMPTY_VALUE") == "") + assert(os.getenv("MISSING_VALUE") == "") + + assert(bool(os.getenv("FULL_VALUE")) == true) + assert(bool(os.getenv("EMPTY_VALUE")) == false) + assert(bool(os.getenv("MISSING_VALUE")) == false) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + }) +} + +func TestOSModule_Exec(t *testing.T) { + t.Run("should run command and return stdout", func(t *testing.T) { + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "hello world\n") + + testFS := testScriptFile(t, "test.tm", ` + res := exec('echo', ["hello world"]).stdout + ui.print(res) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + }) +} diff --git a/internal/dynamo-browse/services/scriptmanager/modsession.go b/internal/dynamo-browse/services/scriptmanager/modsession.go new file mode 100644 index 0000000..95c16c7 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modsession.go @@ -0,0 +1,145 @@ +package scriptmanager + +import ( + "context" + "fmt" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/pkg/errors" + "github.com/risor-io/risor/object" +) + +type sessionModule struct { + sessionService SessionService +} + +func (um *sessionModule) query(ctx context.Context, args ...object.Object) object.Object { + if len(args) == 0 || len(args) > 2 { + return object.Errorf("type error: session.query takes either 1 or 2 arguments (%d given)", len(args)) + } + + var options QueryOptions + + expr, objErr := object.AsString(args[0]) + if objErr != nil { + return objErr + } + + if len(args) == 2 { + objMap, objErr := object.AsMap(args[1]) + if objErr != nil { + return objErr + } + + // Table name + if val := objMap.Get("table"); val != object.Nil && val.IsTruthy() { + switch tv := val.(type) { + case *object.String: + options.TableName = tv.Value() + case *tableProxy: + options.TableName = tv.table.Name + default: + return object.Errorf("type error: query option 'table' must be either a string or table") + } + } + + // Index name + if val, isStr := objMap.Get("index").(*object.String); isStr { + options.IndexName = val.Value() + } + + // Placeholders + if argsVal, isArgsValMap := objMap.Get("args").(*object.Map); isArgsValMap { + options.NamePlaceholders = make(map[string]string) + options.ValuePlaceholders = make(map[string]types.AttributeValue) + + for k, val := range argsVal.Value() { + switch v := val.(type) { + case *object.String: + options.NamePlaceholders[k] = v.Value() + options.ValuePlaceholders[k] = &types.AttributeValueMemberS{Value: v.Value()} + case *object.Int: + options.ValuePlaceholders[k] = &types.AttributeValueMemberN{Value: fmt.Sprint(v.Value())} + case *object.Float: + options.ValuePlaceholders[k] = &types.AttributeValueMemberN{Value: fmt.Sprint(v.Value())} + case *object.Bool: + options.ValuePlaceholders[k] = &types.AttributeValueMemberBOOL{Value: v.Value()} + case *object.NilType: + options.ValuePlaceholders[k] = &types.AttributeValueMemberNULL{Value: true} + default: + return object.Errorf("type error: arg '%v' of type '%v' is not supported", k, val.Type()) + } + } + } + } + + resp, err := um.sessionService.Query(ctx, expr, options) + + if err != nil { + return object.NewError(err) + } + return &resultSetProxy{resultSet: resp} +} + +func (um *sessionModule) resultSet(ctx context.Context, args ...object.Object) object.Object { + if err := require("session.result_set", 0, args); err != nil { + return err + } + + rs := um.sessionService.ResultSet(ctx) + if rs == nil { + return object.Nil + } + return &resultSetProxy{resultSet: rs} +} + +func (um *sessionModule) selectedItem(ctx context.Context, args ...object.Object) object.Object { + if err := require("session.result_set", 0, args); err != nil { + return err + } + + rs := um.sessionService.ResultSet(ctx) + idx := um.sessionService.SelectedItemIndex(ctx) + if rs == nil || idx < 0 { + return object.Nil + } + + rsProxy := &resultSetProxy{resultSet: rs} + return newItemProxy(rsProxy, idx) +} + +func (um *sessionModule) setResultSet(ctx context.Context, args ...object.Object) object.Object { + if err := require("session.set_result_set", 1, args); err != nil { + return err + } + + resultSetProxy, isResultSetProxy := args[0].(*resultSetProxy) + if !isResultSetProxy { + return object.NewError(errors.Errorf("type error: expected a resultsset (got %v)", args[0])) + } + + um.sessionService.SetResultSet(ctx, resultSetProxy.resultSet) + return nil +} + +func (um *sessionModule) currentTable(ctx context.Context, args ...object.Object) object.Object { + if err := require("session.current_table", 0, args); err != nil { + return err + } + + rs := um.sessionService.ResultSet(ctx) + if rs == nil { + return object.Nil + } + + return &tableProxy{table: rs.TableInfo} +} + +func (um *sessionModule) register() *object.Module { + return object.NewBuiltinsModule("session", map[string]object.Object{ + "query": object.NewBuiltin("query", um.query), + "current_table": object.NewBuiltin("current_table", um.currentTable), + "result_set": object.NewBuiltin("result_set", um.resultSet), + "selected_item": object.NewBuiltin("selected_item", um.selectedItem), + "set_result_set": object.NewBuiltin("set_result_set", um.setResultSet), + }) +} diff --git a/internal/dynamo-browse/services/scriptmanager/modsession_test.go b/internal/dynamo-browse/services/scriptmanager/modsession_test.go new file mode 100644 index 0000000..8712cf5 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modsession_test.go @@ -0,0 +1,426 @@ +package scriptmanager_test + +import ( + "context" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager/mocks" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "testing" +) + +func TestModSession_Table(t *testing.T) { + t.Run("should return details of the current table", func(t *testing.T) { + tableDef := models.TableInfo{ + Name: "test_table", + Keys: models.KeyAttribute{ + PartitionKey: "pk", + SortKey: "sk", + }, + GSIs: []models.TableGSI{ + { + Name: "index-1", + Keys: models.KeyAttribute{ + PartitionKey: "ipk", + SortKey: "isk", + }, + }, + }, + } + rs := models.ResultSet{TableInfo: &tableDef} + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(&rs) + + testFS := testScriptFile(t, "test.tm", ` + table := session.current_table() + + assert(table.name == "test_table") + assert(table.keys["hash"] == "pk") + assert(table.keys["range"] == "sk") + assert(len(table.gsis) == 1) + assert(table.gsis[0].name == "index-1") + assert(table.gsis[0].keys["hash"] == "ipk") + assert(table.gsis[0].keys["range"] == "isk") + + assert(table == session.result_set().table) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should return nil if no current result set", func(t *testing.T) { + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(nil) + + testFS := testScriptFile(t, "test.tm", ` + table := session.current_table() + + assert(table == nil) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedSessionService.AssertExpectations(t) + }) +} + +func TestModSession_Query(t *testing.T) { + t.Run("should successfully return query result", func(t *testing.T) { + rs := &models.ResultSet{} + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil) + + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "2") + mockedUIService.EXPECT().PrintMessage(mock.Anything, "res[0]['pk'].S = abc") + mockedUIService.EXPECT().PrintMessage(mock.Anything, "res[1]['pk'].S = 1232") + mockedUIService.EXPECT().PrintMessage(mock.Anything, "res[1].attr('size(pk)') = 4") + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr") + ui.print(res.length) + ui.print("res[0]['pk'].S = ", res[0].attr("pk")) + ui.print("res[1]['pk'].S = ", res[1].attr("pk")) + ui.print("res[1].attr('size(pk)') = ", res[1].attr("size(pk)")) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should return error if query returns error", func(t *testing.T) { + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(nil, errors.New("bang")) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr") + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.Error(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should successfully specify table name", func(t *testing.T) { + rs := &models.ResultSet{} + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{ + TableName: "some-table", + }).Return(rs, nil) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr", { + table: "some-table", + }) + assert(res) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should successfully specify table proxy", func(t *testing.T) { + rs := &models.ResultSet{} + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(&models.ResultSet{ + TableInfo: &models.TableInfo{ + Name: "some-resultset-table", + }, + }) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{ + TableName: "some-resultset-table", + }).Return(rs, nil) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr", { + table: session.result_set().table, + }) + assert(res) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should set placeholder values", func(t *testing.T) { + rs := &models.ResultSet{} + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, ":name = $value", scriptmanager.QueryOptions{ + NamePlaceholders: map[string]string{ + "name": "hello", + "value": "world", + }, + ValuePlaceholders: map[string]types.AttributeValue{ + "name": &types.AttributeValueMemberS{Value: "hello"}, + "value": &types.AttributeValueMemberS{Value: "world"}, + }, + }).Return(rs, nil) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query(":name = $value", { + args: { + name: "hello", + value: "world", + }, + }) + assert(res) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should support various placeholder value type", func(t *testing.T) { + rs := &models.ResultSet{} + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, ":name = $value", scriptmanager.QueryOptions{ + NamePlaceholders: map[string]string{ + "str": "hello", + }, + ValuePlaceholders: map[string]types.AttributeValue{ + "str": &types.AttributeValueMemberS{Value: "hello"}, + "int": &types.AttributeValueMemberN{Value: "123"}, + "float": &types.AttributeValueMemberN{Value: "3.14"}, + "bool": &types.AttributeValueMemberBOOL{Value: true}, + "nil": &types.AttributeValueMemberNULL{Value: true}, + }, + }).Return(rs, nil) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query(":name = $value", { + args: { + "str": "hello", + "int": 123, + "float": 3.14, + "bool": true, + "nil": nil, + }, + }) + assert(res) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should return error when placeholder value type is unsupported", func(t *testing.T) { + mockedSessionService := mocks.NewSessionService(t) + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query(":name = $value", { + args: { + "bad": func() { }, + }, + }) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.Error(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) +} + +func TestModSession_SelectedItem(t *testing.T) { + t.Run("should return selected item from service implementation", func(t *testing.T) { + rs := &models.ResultSet{} + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(rs) + mockedSessionService.EXPECT().SelectedItemIndex(mock.Anything).Return(1) + + testFS := testScriptFile(t, "test.tm", ` + selItem := session.selected_item() + + assert(selItem != nil, "selItem != nil") + assert(selItem.index == 1, "selItem.index") + assert(selItem.result_set == session.result_set(), "selItem.result_set") + assert(selItem.attr('pk') == '1232', "selItem.attr('pk')") + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should return nil if selected item returns -1", func(t *testing.T) { + rs := &models.ResultSet{} + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(rs) + mockedSessionService.EXPECT().SelectedItemIndex(mock.Anything).Return(-1) + + testFS := testScriptFile(t, "test.tm", ` + selItem := session.selected_item() + + assert(selItem == nil, "selItem != nil") + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedSessionService.AssertExpectations(t) + }) +} + +func TestModSession_SetResultSet(t *testing.T) { + t.Run("should set the result set on the session", func(t *testing.T) { + rs := &models.ResultSet{} + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil) + mockedSessionService.EXPECT().SetResultSet(mock.Anything, rs) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr") + session.set_result_set(res) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) +} diff --git a/internal/dynamo-browse/services/scriptmanager/modui.go b/internal/dynamo-browse/services/scriptmanager/modui.go new file mode 100644 index 0000000..d53b2e4 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modui.go @@ -0,0 +1,58 @@ +package scriptmanager + +import ( + "context" + "strings" + + "github.com/risor-io/risor/object" +) + +type uiModule struct { + uiService UIService +} + +func (um *uiModule) print(ctx context.Context, args ...object.Object) object.Object { + var msg strings.Builder + for _, arg := range args { + if arg == nil { + continue + } + + switch a := arg.(type) { + case *object.String: + msg.WriteString(a.Value()) + default: + msg.WriteString(a.Inspect()) + } + } + + um.uiService.PrintMessage(ctx, msg.String()) + return object.Nil +} + +func (um *uiModule) prompt(ctx context.Context, args ...object.Object) object.Object { + if err := require("ui.prompt", 1, args); err != nil { + return err + } + + msg, _ := object.AsString(args[0]) + respChan := um.uiService.Prompt(ctx, msg) + + select { + case resp, hasResp := <-respChan: + if hasResp { + return object.NewString(resp) + } else { + return object.Nil + } + case <-ctx.Done(): + return object.NewError(ctx.Err()) + } +} + +func (um *uiModule) register() *object.Module { + return object.NewBuiltinsModule("ui", map[string]object.Object{ + "print": object.NewBuiltin("print", um.print), + "prompt": object.NewBuiltin("prompt", um.prompt), + }) +} diff --git a/internal/dynamo-browse/services/scriptmanager/modui_test.go b/internal/dynamo-browse/services/scriptmanager/modui_test.go new file mode 100644 index 0000000..3a8b96d --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modui_test.go @@ -0,0 +1,100 @@ +package scriptmanager_test + +import ( + "context" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "testing" +) + +func TestModUI_Prompt(t *testing.T) { + t.Run("should successfully return prompt value", func(t *testing.T) { + testFS := testScriptFile(t, "test.tm", ` + ui.print("Hello, world") + var name = ui.prompt("What is your name? ") + ui.print("Hello, " + name) + `) + + promptChan := make(chan string) + go func() { + promptChan <- "T. Test" + }() + + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, world") + mockedUIService.EXPECT().Prompt(mock.Anything, "What is your name? ").Return(promptChan) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, T. Test") + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + }) + + t.Run("should return nil if prompt was cancelled", func(t *testing.T) { + testFS := testScriptFile(t, "test.tm", ` + ui.print("Hello, world") + var name = ui.prompt("What is your name? ") + ui.print("After") + ui.print(nil) + `) + + promptChan := make(chan string) + close(promptChan) + + ctx := context.Background() + + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, world") + mockedUIService.EXPECT().PrintMessage(mock.Anything, "After") + mockedUIService.EXPECT().PrintMessage(mock.Anything, "nil") + mockedUIService.EXPECT().Prompt(mock.Anything, "What is your name? ").Return(promptChan) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + }) + + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + }) + + t.Run("should return error if context was cancelled", func(t *testing.T) { + testFS := testScriptFile(t, "test.tm", ` + ui.print("Hello, world") + var name = ui.prompt("What is your name? ") + ui.print("After") + `) + + promptChan := make(chan string) + ctx, cancelFn := context.WithCancel(context.Background()) + defer cancelFn() + + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, world") + mockedUIService.EXPECT().Prompt(mock.Anything, "What is your name? ").Run(func(ctx context.Context, msg string) { + cancelFn() + }).Return(promptChan) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + }) + + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.Error(t, err) + + mockedUIService.AssertNotCalled(t, "Prompt", "after") + mockedUIService.AssertExpectations(t) + }) +} diff --git a/internal/dynamo-browse/services/scriptmanager/opts.go b/internal/dynamo-browse/services/scriptmanager/opts.go new file mode 100644 index 0000000..39d961f --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/opts.go @@ -0,0 +1,26 @@ +package scriptmanager + +import ( + "context" + "github.com/risor-io/risor/limits" +) + +// scriptEnv is the runtime environment for a particular script execution +type scriptEnv struct { + filename string +} + +type scriptEnvKeyType struct{} + +var scriptEnvKey = scriptEnvKeyType{} + +func scriptEnvFromCtx(ctx context.Context) scriptEnv { + perms, _ := ctx.Value(scriptEnvKey).(scriptEnv) + return perms +} + +func ctxWithScriptEnv(ctx context.Context, perms scriptEnv) context.Context { + newCtx := context.WithValue(ctx, scriptEnvKey, perms) + newCtx = limits.WithLimits(newCtx, limits.New()) + return newCtx +} diff --git a/internal/dynamo-browse/services/scriptmanager/relitem.go b/internal/dynamo-browse/services/scriptmanager/relitem.go new file mode 100644 index 0000000..63d7629 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/relitem.go @@ -0,0 +1,57 @@ +package scriptmanager + +import ( + "context" + "log" + "path" + + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models/relitems" +) + +type relatedItem struct { + label string + table string + query *queryexpr.QueryExpr + onSelect func() error +} + +type relatedItemBuilder struct { + table string + itemProduction func(ctx context.Context, rs *models.ResultSet, index int) ([]relatedItem, error) +} + +func (s *Service) RelatedItemOfItem(ctx context.Context, rs *models.ResultSet, index int) ([]relitems.RelatedItem, error) { + riModels := []relitems.RelatedItem{} + + for _, plugin := range s.plugins { + for _, rb := range plugin.relatedItems { + // TODO: should support matching + match, _ := tableMatchesGlob(rb.table, rs.TableInfo.Name) + log.Printf("RelatedItemOfItem: table = '%v', pattern = '%v', match = '%v'", rb.table, rs.TableInfo.Name, match) + if match { + relatedItems, err := rb.itemProduction(ctx, rs, index) + if err != nil { + // TODO: should probably return error if no rel items were found and an error was raised + return nil, err + } + + // TODO: make this nicer + for _, ri := range relatedItems { + riModels = append(riModels, relitems.RelatedItem{ + Name: ri.label, + Query: ri.query, + Table: ri.table, + OnSelect: ri.onSelect, + }) + } + } + } + } + return riModels, nil +} + +func tableMatchesGlob(tableName, pattern string) (bool, error) { + return path.Match(tableName, pattern) +} diff --git a/internal/dynamo-browse/services/scriptmanager/resultsetproxy.go b/internal/dynamo-browse/services/scriptmanager/resultsetproxy.go new file mode 100644 index 0000000..953a3e4 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/resultsetproxy.go @@ -0,0 +1,337 @@ +package scriptmanager + +import ( + "context" + "time" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models/attrutils" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr" + "github.com/pkg/errors" + "github.com/risor-io/risor/object" + "github.com/risor-io/risor/op" +) + +type resultSetProxy struct { + resultSet *models.ResultSet +} + +func newResultSetProxy(rs *models.ResultSet) *resultSetProxy { + return &resultSetProxy{resultSet: rs} +} + +func (r *resultSetProxy) SetAttr(name string, value object.Object) error { + return errors.Errorf("attribute error: %v", name) +} + +func (r *resultSetProxy) RunOperation(opType op.BinaryOpType, right object.Object) object.Object { + return object.Errorf("op error: unsupported %v", opType) +} + +func (r *resultSetProxy) Cost() int { + return len(r.resultSet.Items()) +} + +func (r *resultSetProxy) Interface() interface{} { + return r.resultSet +} + +func (r *resultSetProxy) IsTruthy() bool { + return true +} + +func (r *resultSetProxy) Type() object.Type { + return "resultset" +} + +func (r *resultSetProxy) Inspect() string { + return "resultset" +} + +func (r *resultSetProxy) Equals(other object.Object) object.Object { + otherRS, isOtherRS := other.(*resultSetProxy) + if !isOtherRS { + return object.False + } + + return object.NewBool(r.resultSet == otherRS.resultSet) +} + +// GetItem implements the [key] operator for a container type. +func (r *resultSetProxy) GetItem(key object.Object) (object.Object, *object.Error) { + idx, err := object.AsInt(key) + if err != nil { + return nil, err + } + + realIdx := int(idx) + if realIdx < 0 { + realIdx = len(r.resultSet.Items()) + realIdx + } + + if realIdx < 0 || realIdx >= len(r.resultSet.Items()) { + return nil, object.NewError(errors.Errorf("index error: index out of range: %v", idx)) + } + + return newItemProxy(r, realIdx), nil +} + +// GetSlice implements the [start:stop] operator for a container type. +func (r *resultSetProxy) GetSlice(s object.Slice) (object.Object, *object.Error) { + return nil, object.NewError(errors.New("TODO")) +} + +// SetItem implements the [key] = value operator for a container type. +func (r *resultSetProxy) SetItem(key, value object.Object) *object.Error { + return object.NewError(errors.New("TODO")) +} + +// DelItem implements the del [key] operator for a container type. +func (r *resultSetProxy) DelItem(key object.Object) *object.Error { + return object.NewError(errors.New("TODO")) +} + +// Contains returns true if the given item is found in this container. +func (r *resultSetProxy) Contains(item object.Object) *object.Bool { + // TODO + return object.False +} + +// Len returns the number of items in this container. +func (r *resultSetProxy) Len() *object.Int { + return object.NewInt(int64(len(r.resultSet.Items()))) +} + +// Iter returns an iterator for this container. +func (r *resultSetProxy) Iter() object.Iterator { + // TODO + return nil +} + +func (r *resultSetProxy) GetAttr(name string) (object.Object, bool) { + switch name { + case "table": + return &tableProxy{table: r.resultSet.TableInfo}, true + case "length": + return object.NewInt(int64(len(r.resultSet.Items()))), true + case "find": + return object.NewBuiltin("find", r.find), true + case "merge": + return object.NewBuiltin("merge", r.merge), true + } + + return nil, false +} + +func (i *resultSetProxy) find(ctx context.Context, args ...object.Object) object.Object { + if objErr := require("resultset.find", 1, args); objErr != nil { + return objErr + } + + str, objErr := object.AsString(args[0]) + if objErr != nil { + return objErr + } + + modExpr, err := queryexpr.Parse(str) + if err != nil { + return object.Errorf("arg error: invalid path expression: %v", err) + } + + for idx, item := range i.resultSet.Items() { + rs, err := modExpr.EvalItem(item) + if err != nil { + continue + } + + if attrutils.Truthy(rs) { + return newItemProxy(i, idx) + } + } + + return object.Nil +} + +func (i *resultSetProxy) merge(ctx context.Context, args ...object.Object) object.Object { + type pksk struct { + pk types.AttributeValue + sk types.AttributeValue + } + + if objErr := require("resultset.merge", 1, args); objErr != nil { + return objErr + } + + otherRS, isRS := args[0].(*resultSetProxy) + if !isRS { + return object.NewError(errors.Errorf("type error: expected a resultset (got %v)", args[0].Type())) + } + + if !i.resultSet.TableInfo.Equal(otherRS.resultSet.TableInfo) { + return object.Nil + } + + itemsInI := make(map[pksk]models.Item) + newItems := make([]models.Item, 0, len(i.resultSet.Items())+len(otherRS.resultSet.Items())) + for _, item := range i.resultSet.Items() { + pk, sk := item.PKSK(i.resultSet.TableInfo) + itemsInI[pksk{pk, sk}] = item + newItems = append(newItems, item) + } + + for _, item := range otherRS.resultSet.Items() { + pk, sk := item.PKSK(i.resultSet.TableInfo) + if _, hasItem := itemsInI[pksk{pk, sk}]; !hasItem { + newItems = append(newItems, item) + } + } + + newResultSet := &models.ResultSet{ + Created: time.Now(), + TableInfo: i.resultSet.TableInfo, + } + newResultSet.SetItems(newItems) + + return &resultSetProxy{resultSet: newResultSet} +} + +type itemProxy struct { + resultSetProxy *resultSetProxy + itemIndex int + item models.Item +} + +func (i *itemProxy) SetAttr(name string, value object.Object) error { + return errors.Errorf("attribute error: %v", name) +} + +func (i *itemProxy) RunOperation(opType op.BinaryOpType, right object.Object) object.Object { + return object.Errorf("op error: unsupported %v", opType) +} + +func (i *itemProxy) Cost() int { + return len(i.item) +} + +func newItemProxy(rs *resultSetProxy, itemIndex int) *itemProxy { + return &itemProxy{ + resultSetProxy: rs, + itemIndex: itemIndex, + item: rs.resultSet.Items()[itemIndex], + } +} + +func (i *itemProxy) Interface() interface{} { + return i.item +} + +func (i *itemProxy) IsTruthy() bool { + return true +} + +func (i *itemProxy) Type() object.Type { + return "item" +} + +func (i *itemProxy) Inspect() string { + return "item" +} + +func (i *itemProxy) Equals(other object.Object) object.Object { + // TODO + return object.False +} + +func (i *itemProxy) GetAttr(name string) (object.Object, bool) { + // TODO: this should implement the container interface + switch name { + case "result_set": + return i.resultSetProxy, true + case "index": + return object.NewInt(int64(i.itemIndex)), true + case "attr": + return object.NewBuiltin("attr", i.value), true + case "set_attr": + return object.NewBuiltin("set_attr", i.setValue), true + case "delete_attr": + return object.NewBuiltin("delete_attr", i.deleteAttr), true + } + + return nil, false +} + +func (i *itemProxy) value(ctx context.Context, args ...object.Object) object.Object { + if objErr := require("item.attr", 1, args); objErr != nil { + return objErr + } + + str, objErr := object.AsString(args[0]) + if objErr != nil { + return objErr + } + + modExpr, err := queryexpr.Parse(str) + if err != nil { + return object.Errorf("arg error: invalid path expression: %v", err) + } + av, err := modExpr.EvalItem(i.item) + if err != nil { + return object.NewError(errors.Errorf("arg error: path expression evaluate error: %v", err)) + } + + tVal, err := attributeValueToTamarin(av) + if err != nil { + return object.NewError(err) + } + return tVal +} + +func (i *itemProxy) setValue(ctx context.Context, args ...object.Object) object.Object { + if objErr := require("item.set_attr", 2, args); objErr != nil { + return objErr + } + + pathExpr, objErr := object.AsString(args[0]) + if objErr != nil { + return objErr + } + + path, err := queryexpr.Parse(pathExpr) + if err != nil { + return object.Errorf("arg error: invalid path expression: %v", err) + } + + newValue, err := tamarinValueToAttributeValue(args[1]) + if err != nil { + return object.NewError(err) + } + if err := path.SetEvalItem(i.item, newValue); err != nil { + return object.NewError(err) + } + + i.resultSetProxy.resultSet.SetDirty(i.itemIndex, true) + return nil +} + +func (i *itemProxy) deleteAttr(ctx context.Context, args ...object.Object) object.Object { + if objErr := require("item.delete_attr", 1, args); objErr != nil { + return objErr + } + + str, objErr := object.AsString(args[0]) + if objErr != nil { + return objErr + } + + modExpr, err := queryexpr.Parse(str) + if err != nil { + return object.Errorf("arg error: invalid path expression: %v", err) + } + if err := modExpr.DeleteAttribute(i.item); err != nil { + return object.NewError(errors.Errorf("arg error: path expression evaluate error: %v", err)) + } + + i.resultSetProxy.resultSet.SetDirty(i.itemIndex, true) + return nil +} diff --git a/internal/dynamo-browse/services/scriptmanager/resultsetproxy_test.go b/internal/dynamo-browse/services/scriptmanager/resultsetproxy_test.go new file mode 100644 index 0000000..3b7f354 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/resultsetproxy_test.go @@ -0,0 +1,355 @@ +package scriptmanager_test + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestResultSetProxy(t *testing.T) { + t.Run("should property return properties of a resultset and item", func(t *testing.T) { + rs := &models.ResultSet{ + TableInfo: &models.TableInfo{ + Name: "test-table", + }, + } + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr") + + // Test properties of the result set + assert(res.table.name, "hello") + + assert(res == res, "result_set.equals") + assert(res.length == 2, "result_set.length") + + // Test properties of items + assert(res[0].index == 0, "res[0].index") + assert(res[0].result_set == res, "res[0].result_set") + assert(res[0].attr('pk') == 'abc', "res[0].attr('pk')") + + assert(res[1].attr('pk') == '1232', "res[1].attr('pk')") + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) +} + +func TestResultSetProxy_Find(t *testing.T) { + t.Run("should return the first item that matches the given expression", func(t *testing.T) { + rs := &models.ResultSet{} + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}}, + {"pk": &types.AttributeValueMemberS{Value: "abc"}, "sk": &types.AttributeValueMemberS{Value: "abc"}, "primary": &types.AttributeValueMemberS{Value: "yes"}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}, "findMe": &types.AttributeValueMemberS{Value: "yes"}}, + {"pk": &types.AttributeValueMemberS{Value: "2345"}, "findMe": &types.AttributeValueMemberS{Value: "second"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr") + + assert(res.find('findMe is "any"').attr("pk") == "1232") + assert(res.find('findMe = "second"').attr("pk") == "2345") + assert(res.find('pk = sk').attr("primary") == "yes") + + assert(res.find('findMe = "missing"') == nil) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedSessionService.AssertExpectations(t) + }) +} + +func TestResultSetProxy_Merge(t *testing.T) { + t.Run("should return a result set with items from both if both are from the same table", func(t *testing.T) { + td := &models.TableInfo{Name: "test", Keys: models.KeyAttribute{PartitionKey: "pk", SortKey: "sk"}} + + rs1 := &models.ResultSet{TableInfo: td} + rs1.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}, "sk": &types.AttributeValueMemberS{Value: "123"}}, + }) + + rs2 := &models.ResultSet{TableInfo: td} + rs2.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "bcd"}, "sk": &types.AttributeValueMemberS{Value: "234"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "rs1", scriptmanager.QueryOptions{}).Return(rs1, nil) + mockedSessionService.EXPECT().Query(mock.Anything, "rs2", scriptmanager.QueryOptions{}).Return(rs2, nil) + + testFS := testScriptFile(t, "test.tm", ` + r1 := session.query("rs1") + r2 := session.query("rs2") + + res := r1.merge(r2) + + assert(res[0].attr("pk") == "abc") + assert(res[0].attr("sk") == "123") + assert(res[1].attr("pk") == "bcd") + assert(res[1].attr("sk") == "234") + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should return nil if result-sets are from different tables", func(t *testing.T) { + td1 := &models.TableInfo{Name: "test", Keys: models.KeyAttribute{PartitionKey: "pk", SortKey: "sk"}} + rs1 := &models.ResultSet{TableInfo: td1} + rs1.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}, "sk": &types.AttributeValueMemberS{Value: "123"}}, + }) + + td2 := &models.TableInfo{Name: "test2", Keys: models.KeyAttribute{PartitionKey: "pk2", SortKey: "sk"}} + rs2 := &models.ResultSet{TableInfo: td2} + rs2.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "bcd"}, "sk": &types.AttributeValueMemberS{Value: "234"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "rs1", scriptmanager.QueryOptions{}).Return(rs1, nil) + mockedSessionService.EXPECT().Query(mock.Anything, "rs2", scriptmanager.QueryOptions{}).Return(rs2, nil) + + testFS := testScriptFile(t, "test.tm", ` + r1 := session.query("rs1") + r2 := session.query("rs2") + + res := r1.merge(r2) + + assert(res == nil) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedSessionService.AssertExpectations(t) + }) +} + +func TestResultSetProxy_GetAttr(t *testing.T) { + t.Run("should return the value of items within a result set", func(t *testing.T) { + rs := &models.ResultSet{} + rs.SetItems([]models.Item{ + { + "pk": &types.AttributeValueMemberS{Value: "abc"}, + "sk": &types.AttributeValueMemberN{Value: "123"}, + "bool": &types.AttributeValueMemberBOOL{Value: true}, + "null": &types.AttributeValueMemberNULL{Value: true}, + "list": &types.AttributeValueMemberL{Value: []types.AttributeValue{ + &types.AttributeValueMemberS{Value: "apple"}, + &types.AttributeValueMemberS{Value: "banana"}, + &types.AttributeValueMemberS{Value: "cherry"}, + }}, + "map": &types.AttributeValueMemberM{Value: map[string]types.AttributeValue{ + "this": &types.AttributeValueMemberS{Value: "that"}, + "another": &types.AttributeValueMemberS{Value: "thing"}, + }}, + "strSet": &types.AttributeValueMemberSS{Value: []string{"apple", "banana", "cherry"}}, + "numSet": &types.AttributeValueMemberNS{Value: []string{"123", "45.67", "8.911", "-321"}}, + }, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr") + + assert(res[0].attr("pk") == "abc", "str attr") + assert(res[0].attr("sk") == 123, "num attr") + assert(res[0].attr("bool") == true, "bool attr") + assert(res[0].attr("null") == nil, "null attr") + assert(res[0].attr("list") == ["apple","banana","cherry"], "list attr") + assert(res[0].attr("map") == {"this":"that", "another":"thing"}, "map attr") + assert(res[0].attr("strSet") == {"apple","banana","cherry"}, "string set") + assert(res[0].attr("numSet") == {123, 45.67, 8.911, -321}, "number set") + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedSessionService.AssertExpectations(t) + }) +} + +func TestResultSetProxy_SetAttr(t *testing.T) { + t.Run("should set the value of the item within a result set", func(t *testing.T) { + rs := &models.ResultSet{} + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil) + mockedSessionService.EXPECT().SetResultSet(mock.Anything, mock.MatchedBy(func(rs *models.ResultSet) bool { + assert.Equal(t, "bla-di-bla", rs.Items()[0]["pk"].(*types.AttributeValueMemberS).Value) + assert.Equal(t, "123", rs.Items()[0]["num"].(*types.AttributeValueMemberN).Value) + assert.Equal(t, "123.45", rs.Items()[0]["numFloat"].(*types.AttributeValueMemberN).Value) + assert.Equal(t, true, rs.Items()[0]["bool"].(*types.AttributeValueMemberBOOL).Value) + assert.Equal(t, true, rs.Items()[0]["nil"].(*types.AttributeValueMemberNULL).Value) + + list := rs.Items()[0]["lists"].(*types.AttributeValueMemberL).Value + assert.Equal(t, "abc", list[0].(*types.AttributeValueMemberS).Value) + assert.Equal(t, "123", list[1].(*types.AttributeValueMemberN).Value) + assert.Equal(t, true, list[2].(*types.AttributeValueMemberBOOL).Value) + + nestedLists := rs.Items()[0]["nestedLists"].(*types.AttributeValueMemberL).Value + assert.Equal(t, "1", nestedLists[0].(*types.AttributeValueMemberL).Value[0].(*types.AttributeValueMemberN).Value) + assert.Equal(t, "2", nestedLists[0].(*types.AttributeValueMemberL).Value[1].(*types.AttributeValueMemberN).Value) + assert.Equal(t, "3", nestedLists[1].(*types.AttributeValueMemberL).Value[0].(*types.AttributeValueMemberN).Value) + assert.Equal(t, "4", nestedLists[1].(*types.AttributeValueMemberL).Value[1].(*types.AttributeValueMemberN).Value) + + mapValue := rs.Items()[0]["map"].(*types.AttributeValueMemberM).Value + assert.Equal(t, "world", mapValue["hello"].(*types.AttributeValueMemberS).Value) + assert.Equal(t, "213", mapValue["nums"].(*types.AttributeValueMemberN).Value) + + numSet := rs.Items()[0]["numSet"].(*types.AttributeValueMemberNS).Value + assert.Len(t, numSet, 4) + assert.Contains(t, numSet, "1") + assert.Contains(t, numSet, "2") + assert.Contains(t, numSet, "3") + assert.Contains(t, numSet, "4.5") + + strSet := rs.Items()[0]["strSet"].(*types.AttributeValueMemberSS).Value + assert.Len(t, strSet, 3) + assert.Contains(t, strSet, "a") + assert.Contains(t, strSet, "b") + assert.Contains(t, strSet, "c") + + assert.True(t, rs.IsDirty(0)) + return true + })) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr") + + res[0].set_attr("pk", "bla-di-bla") + res[0].set_attr("num", 123) + res[0].set_attr("numFloat", 123.45) + res[0].set_attr("bool", true) + res[0].set_attr("nil", nil) + res[0].set_attr("lists", ['abc', 123, true]) + res[0].set_attr("nestedLists", [[1,2], [3,4]]) + res[0].set_attr("map", {"hello": "world", "nums": 213}) + res[0].set_attr("numSet", {1,2,3,4.5}) + res[0].set_attr("strSet", {"a","b","c"}) + + session.set_result_set(res) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) +} + +func TestResultSetProxy_DeleteAttr(t *testing.T) { + t.Run("should delete the value of the item within a result set", func(t *testing.T) { + rs := &models.ResultSet{} + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}, "deleteMe": &types.AttributeValueMemberBOOL{Value: true}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil) + mockedSessionService.EXPECT().SetResultSet(mock.Anything, mock.MatchedBy(func(rs *models.ResultSet) bool { + assert.Equal(t, "abc", rs.Items()[0]["pk"].(*types.AttributeValueMemberS).Value) + assert.Nil(t, rs.Items()[0]["deleteMe"]) + assert.True(t, rs.IsDirty(0)) + return true + })) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr") + res[0].delete_attr("deleteMe") + session.set_result_set(res) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) + +} diff --git a/internal/dynamo-browse/services/scriptmanager/scrsched.go b/internal/dynamo-browse/services/scriptmanager/scrsched.go new file mode 100644 index 0000000..3846676 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/scrsched.go @@ -0,0 +1,53 @@ +package scriptmanager + +import ( + "context" + "github.com/pkg/errors" + "time" +) + +type scriptScheduler struct { + jobChan chan scriptJob +} + +func newScriptScheduler() *scriptScheduler { + ss := &scriptScheduler{} + ss.start() + return ss +} + +func (ss *scriptScheduler) start() { + ss.jobChan = make(chan scriptJob) + go func() { + for job := range ss.jobChan { + job.job(job.ctx) + } + }() +} + +// startJobOnceFree will submit a script execution job. The function will wait until the scheduler is free. +// The job will then run on the script goroutine and the function will return. +func (ss *scriptScheduler) startJobOnceFree(ctx context.Context, job func(ctx context.Context)) error { + select { + case ss.jobChan <- scriptJob{ctx: ctx, job: job}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// runNow will submit a job for immediate execution. The job will run as long as the scheduler is free. +// If the scheduler is not free, an error will be returned and the job will not run. +func (ss *scriptScheduler) runNow(ctx context.Context, job func(ctx context.Context)) error { + select { + case ss.jobChan <- scriptJob{ctx: ctx, job: job}: + return nil + case <-time.After(500 * time.Millisecond): + return errors.New("a script is already running") + } +} + +type scriptJob struct { + ctx context.Context + job func(ctx context.Context) +} diff --git a/internal/dynamo-browse/services/scriptmanager/service.go b/internal/dynamo-browse/services/scriptmanager/service.go new file mode 100644 index 0000000..eac6638 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/service.go @@ -0,0 +1,250 @@ +package scriptmanager + +import ( + "context" + "io/fs" + "log" + "os" + "path/filepath" + "strings" + + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/keybindings" + "github.com/pkg/errors" + "github.com/risor-io/risor" + "github.com/risor-io/risor/object" +) + +var ( + relPrefix = "." + string(filepath.Separator) +) + +type Service struct { + lookupPaths []fs.FS + ifaces Ifaces + sched *scriptScheduler + plugins []*ScriptPlugin +} + +func New(opts ...ServiceOption) *Service { + srv := &Service{ + lookupPaths: nil, + sched: newScriptScheduler(), + } + for _, opt := range opts { + opt(srv) + } + return srv +} + +func (s *Service) SetLookupPaths(fs []fs.FS) { + s.lookupPaths = fs +} + +func (s *Service) SetIFaces(ifaces Ifaces) { + s.ifaces = ifaces +} + +func (s *Service) LoadScript(ctx context.Context, filename string) (*ScriptPlugin, error) { + resChan := make(chan loadedScriptResult) + + if err := s.sched.startJobOnceFree(ctx, func(ctx context.Context) { + s.loadScript(ctx, filename, resChan) + }); err != nil { + return nil, err + } + + res := <-resChan + if res.err != nil { + return nil, res.err + } + + // Look for the previous version. If one is there, replace it, otherwise add it + // TODO: this should probably be protected by a mutex + newPlugin := res.scriptPlugin + for i, p := range s.plugins { + if p.name == newPlugin.name { + s.plugins[i] = newPlugin + return newPlugin, nil + } + } + + s.plugins = append(s.plugins, newPlugin) + return newPlugin, nil +} + +func (s *Service) RunAdHocScript(ctx context.Context, filename string) chan error { + errChan := make(chan error) + go s.startAdHocScript(ctx, filename, errChan) + return errChan +} + +func (s *Service) StartAdHocScript(ctx context.Context, filename string, errChan chan error) error { + return s.sched.startJobOnceFree(ctx, func(ctx context.Context) { + s.startAdHocScript(ctx, filename, errChan) + }) +} + +func (s *Service) startAdHocScript(ctx context.Context, filename string, errChan chan error) { + defer close(errChan) + + code, err := s.readScript(filename, true) + if err != nil { + errChan <- errors.Wrapf(err, "cannot load script file %v", filename) + return + } + + ctx = ctxWithScriptEnv(ctx, scriptEnv{filename: filepath.Base(filename)}) + + if _, err := risor.Eval(ctx, code, + risor.WithGlobals(s.builtins()), + ); err != nil { + errChan <- errors.Wrapf(err, "script %v", filename) + return + } +} + +type loadedScriptResult struct { + scriptPlugin *ScriptPlugin + err error +} + +func (s *Service) loadScript(ctx context.Context, filename string, resChan chan loadedScriptResult) { + defer close(resChan) + + code, err := s.readScript(filename, false) + if err != nil { + resChan <- loadedScriptResult{err: errors.Wrapf(err, "cannot load script file %v", filename)} + return + } + + newPlugin := &ScriptPlugin{ + name: strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)), + scriptService: s, + } + + ctx = ctxWithScriptEnv(ctx, scriptEnv{filename: filepath.Base(filename)}) + + if _, err := risor.Eval(ctx, code, + risor.WithGlobals(s.builtins()), + risor.WithGlobals(map[string]any{ + "ext": (&extModule{scriptPlugin: newPlugin}).register(), + }), + ); err != nil { + resChan <- loadedScriptResult{err: errors.Wrapf(err, "script %v", filename)} + return + } + + resChan <- loadedScriptResult{scriptPlugin: newPlugin} +} + +func (s *Service) readScript(filename string, allowCwd bool) (string, error) { + if allowCwd { + if cwd, err := os.Getwd(); err == nil { + fullScriptPath := filepath.Join(cwd, filename) + log.Printf("checking %v", fullScriptPath) + if stat, err := os.Stat(fullScriptPath); err == nil && !stat.IsDir() { + code, err := os.ReadFile(filename) + if err != nil { + return "", err + } + return string(code), nil + } + } else { + log.Printf("warn: cannot get cwd for reading script %v: %v", filename, err) + } + } + + if strings.HasPrefix(filename, string(filepath.Separator)) || strings.HasPrefix(filename, relPrefix) { + code, err := os.ReadFile(filename) + if err != nil { + return "", err + } + return string(code), nil + } + + for _, currFS := range s.lookupPaths { + log.Printf("checking %v/%v", currFS, filename) + stat, err := fs.Stat(currFS, filename) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } else { + return "", err + } + } else if stat.IsDir() { + continue + } + + code, err := fs.ReadFile(currFS, filename) + if err == nil { + return string(code), nil + } else { + return "", err + } + } + + return "", os.ErrNotExist +} + +// LookupCommand looks up a command defined by a script. +// TODO: Command should probably accept/return a chan error to indicate that this will run in a separate goroutine +func (s *Service) LookupCommand(name string) *Command { + for _, p := range s.plugins { + if cmd, hasCmd := p.definedCommands[name]; hasCmd { + return cmd + } + } + return nil +} + +func (s *Service) LookupKeyBinding(key string) (string, *Command) { + for _, p := range s.plugins { + if bindingName, hasBinding := p.keyToKeyBinding[key]; hasBinding { + if cmd, hasCmd := p.definedKeyBindings[bindingName]; hasCmd { + return bindingName, cmd + } + } + } + return "", nil +} + +func (s *Service) UnbindKey(key string) { + for _, p := range s.plugins { + if _, hasBinding := p.keyToKeyBinding[key]; hasBinding { + delete(p.keyToKeyBinding, key) + } + } +} + +func (s *Service) RebindKeyBinding(keyBinding string, newKey string) error { + if newKey == "" { + for _, p := range s.plugins { + for k, b := range p.keyToKeyBinding { + if b == keyBinding { + delete(p.keyToKeyBinding, k) + } + } + } + return nil + } + + for _, p := range s.plugins { + if _, hasCmd := p.definedKeyBindings[keyBinding]; hasCmd { + if newKey != "" { + p.keyToKeyBinding[newKey] = keyBinding + } + return nil + } + } + + return keybindings.InvalidBindingError(keyBinding) +} + +func (s *Service) builtins() map[string]any { + return map[string]any{ + "ui": (&uiModule{uiService: s.ifaces.UI}).register(), + "session": (&sessionModule{sessionService: s.ifaces.Session}).register(), + "print": object.NewBuiltin("print", printBuiltin), + "printf": object.NewBuiltin("printf", printfBuiltin), + } +} diff --git a/internal/dynamo-browse/services/scriptmanager/service_test.go b/internal/dynamo-browse/services/scriptmanager/service_test.go new file mode 100644 index 0000000..745124d --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/service_test.go @@ -0,0 +1,150 @@ +package scriptmanager_test + +import ( + "context" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "io/fs" + "testing" + "testing/fstest" + "time" +) + +func TestService_RunAdHocScript(t *testing.T) { + t.Run("successfully loads and executes a script", func(t *testing.T) { + testFS := testScriptFile(t, "test.tm", ` + ui.print("Hello, world") + `) + + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, world") + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + }) +} + +func TestService_LoadScript(t *testing.T) { + t.Run("successfully loads a script and exposes it as a plugin", func(t *testing.T) { + testFS := testScriptFile(t, "test.tm", ` + ext.command("somewhere", func(a) { + ui.print("Hello, " + a) + }) + `) + + ctx := context.Background() + + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, someone") + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + }) + + plugin, err := srv.LoadScript(ctx, "test.tm") + assert.NoError(t, err) + assert.NotNil(t, plugin) + assert.Equal(t, "test", plugin.Name()) + + cmd := srv.LookupCommand("somewhere") + assert.NotNil(t, cmd) + + errChan := make(chan error) + err = cmd.Invoke(ctx, []string{"someone"}, errChan) + assert.NoError(t, err) + assert.NoError(t, waitForErr(t, errChan)) + + mockedUIService.AssertExpectations(t) + }) + + t.Run("reloading a script with the same name should remove the old one", func(t *testing.T) { + testFS := fstest.MapFS{ + "test.tm": &fstest.MapFile{ + Data: []byte(` + ext.command("somewhere", func(a) { + ui.print("Hello, " + a) + }) + `), + }, + } + + ctx := context.Background() + + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, someone").Once() + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Goodbye, someone").Once() + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + }) + + // Execute the old script + _, err := srv.LoadScript(ctx, "test.tm") + assert.NoError(t, err) + + cmd := srv.LookupCommand("somewhere") + assert.NotNil(t, cmd) + + errChan := make(chan error) + err = cmd.Invoke(ctx, []string{"someone"}, errChan) + assert.NoError(t, err) + assert.NoError(t, waitForErr(t, errChan)) + + // Change the script and reload + testFS["test.tm"] = &fstest.MapFile{ + Data: []byte(` + ext.command("somewhere", func(a) { + ui.print("Goodbye, " + a) + }) + `), + } + + _, err = srv.LoadScript(ctx, "test.tm") + assert.NoError(t, err) + + cmd = srv.LookupCommand("somewhere") + assert.NotNil(t, cmd) + + errChan = make(chan error) + err = cmd.Invoke(ctx, []string{"someone"}, errChan) + assert.NoError(t, err) + assert.NoError(t, waitForErr(t, errChan)) + + mockedUIService.AssertExpectations(t) + }) +} + +func testScriptFile(t *testing.T, filename, code string) fs.FS { + t.Helper() + + testFs := fstest.MapFS{ + filename: &fstest.MapFile{ + Data: []byte(code), + }, + } + return testFs +} + +func waitForErr(t *testing.T, errChan chan error) error { + t.Helper() + + select { + case err := <-errChan: + return err + case <-time.After(5 * time.Second): + t.Fatalf("timed-out waiting for an error") + } + return nil +} diff --git a/internal/dynamo-browse/services/scriptmanager/serviceopts.go b/internal/dynamo-browse/services/scriptmanager/serviceopts.go new file mode 100644 index 0000000..6841531 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/serviceopts.go @@ -0,0 +1,11 @@ +package scriptmanager + +import "io/fs" + +type ServiceOption func(srv *Service) + +func WithFS(fs ...fs.FS) ServiceOption { + return func(srv *Service) { + srv.lookupPaths = fs + } +} diff --git a/internal/dynamo-browse/services/scriptmanager/tableproxy.go b/internal/dynamo-browse/services/scriptmanager/tableproxy.go new file mode 100644 index 0000000..252348f --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/tableproxy.go @@ -0,0 +1,138 @@ +package scriptmanager + +import ( + "github.com/lmika/dynamo-browse/internal/common/sliceutils" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + "github.com/pkg/errors" + "github.com/risor-io/risor/object" + "github.com/risor-io/risor/op" + "reflect" +) + +const ( + tableProxyPartitionKey = "hash" + tableProxySortKey = "range" +) + +type tableProxy struct { + table *models.TableInfo +} + +func (t *tableProxy) SetAttr(name string, value object.Object) error { + return errors.Errorf("attribute error: %v", name) +} + +func (t *tableProxy) RunOperation(opType op.BinaryOpType, right object.Object) object.Object { + return object.Errorf("op error: unsupported %v", opType) +} + +func (t *tableProxy) Cost() int { + return 0 +} + +func (t *tableProxy) Type() object.Type { + return "table" +} + +func (t *tableProxy) Inspect() string { + return "table(" + t.table.Name + ")" +} + +func (t *tableProxy) Interface() interface{} { + return t.table +} + +func (t *tableProxy) Equals(other object.Object) object.Object { + otherT, isOtherRS := other.(*tableProxy) + if !isOtherRS { + return object.False + } + + return object.NewBool(reflect.DeepEqual(t.table, otherT.table)) +} + +func (t *tableProxy) GetAttr(name string) (object.Object, bool) { + switch name { + case "name": + return object.NewString(t.table.Name), true + case "keys": + if t.table.Keys.SortKey == "" { + return object.NewMap(map[string]object.Object{ + tableProxyPartitionKey: object.NewString(t.table.Keys.PartitionKey), + tableProxySortKey: object.Nil, + }), true + } + + return object.NewMap(map[string]object.Object{ + tableProxyPartitionKey: object.NewString(t.table.Keys.PartitionKey), + tableProxySortKey: object.NewString(t.table.Keys.SortKey), + }), true + case "gsis": + return object.NewList(sliceutils.Map(t.table.GSIs, newTableIndexProxy)), true + } + + return nil, false +} + +func (t *tableProxy) IsTruthy() bool { + return true +} + +type tableIndexProxy struct { + gsi models.TableGSI +} + +func (t tableIndexProxy) SetAttr(name string, value object.Object) error { + return errors.Errorf("attribute error: %v", name) +} + +func (t tableIndexProxy) RunOperation(opType op.BinaryOpType, right object.Object) object.Object { + return object.Errorf("op error: unsupported %v", opType) +} + +func (t tableIndexProxy) Cost() int { + return 0 +} + +func newTableIndexProxy(gsi models.TableGSI) object.Object { + return tableIndexProxy{gsi: gsi} +} + +func (t tableIndexProxy) Type() object.Type { + return "table_index" +} + +func (t tableIndexProxy) Inspect() string { + return "table_index(gsi," + t.gsi.Name + ")" +} + +func (t tableIndexProxy) Interface() interface{} { + return t.gsi +} + +func (t tableIndexProxy) Equals(other object.Object) object.Object { + otherIP, isOtherIP := other.(tableIndexProxy) + if !isOtherIP { + return object.False + } + + return object.NewBool(reflect.DeepEqual(t.gsi, otherIP.gsi)) +} + +func (t tableIndexProxy) GetAttr(name string) (object.Object, bool) { + switch name { + case "name": + return object.NewString(t.gsi.Name), true + case "keys": + return object.NewMap(map[string]object.Object{ + tableProxyPartitionKey: object.NewString(t.gsi.Keys.PartitionKey), + tableProxySortKey: object.NewString(t.gsi.Keys.SortKey), + }), true + } + + return nil, false +} + +func (t tableIndexProxy) IsTruthy() bool { + return true +} diff --git a/internal/dynamo-browse/services/scriptmanager/typemapping.go b/internal/dynamo-browse/services/scriptmanager/typemapping.go new file mode 100644 index 0000000..d7b8a47 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/typemapping.go @@ -0,0 +1,135 @@ +package scriptmanager + +import ( + "fmt" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/dynamo-browse/internal/common/maputils" + "github.com/lmika/dynamo-browse/internal/common/sliceutils" + "github.com/pkg/errors" + "github.com/risor-io/risor/object" + "regexp" + "strconv" +) + +func tamarinValueToAttributeValue(val object.Object) (types.AttributeValue, error) { + switch v := val.(type) { + case *object.String: + return &types.AttributeValueMemberS{Value: v.Value()}, nil + case *object.Int: + return &types.AttributeValueMemberN{Value: strconv.FormatInt(v.Value(), 10)}, nil + case *object.Float: + return &types.AttributeValueMemberN{Value: strconv.FormatFloat(v.Value(), 'f', -1, 64)}, nil + case *object.Bool: + return &types.AttributeValueMemberBOOL{Value: v.Value()}, nil + case *object.NilType: + return &types.AttributeValueMemberNULL{Value: true}, nil + case *object.List: + attrValue, err := sliceutils.MapWithError(v.Value(), tamarinValueToAttributeValue) + if err != nil { + return nil, err + } + return &types.AttributeValueMemberL{Value: attrValue}, nil + case *object.Map: + attrValue, err := maputils.MapValuesWithError(v.Value(), tamarinValueToAttributeValue) + if err != nil { + return nil, err + } + return &types.AttributeValueMemberM{Value: attrValue}, nil + case *object.Set: + values := maputils.Values(v.Value()) + canBeNumSet := sliceutils.All(values, func(t object.Object) bool { + _, isInt := t.(*object.Int) + _, isFloat := t.(*object.Float) + return isInt || isFloat + }) + + if canBeNumSet { + return &types.AttributeValueMemberNS{ + Value: sliceutils.Map(values, func(t object.Object) string { + switch v := t.(type) { + case *object.Int: + return strconv.FormatInt(v.Value(), 10) + case *object.Float: + return strconv.FormatFloat(v.Value(), 'f', -1, 64) + } + panic(fmt.Sprintf("unhandled object type: %v", t.Type())) + }), + }, nil + } + return &types.AttributeValueMemberSS{ + Value: sliceutils.Map(values, func(t object.Object) string { + v, _ := object.AsString(t) + return v + }), + }, nil + } + return nil, errors.Errorf("type error: unsupported value type (got %v)", val.Type()) +} + +func attributeValueToTamarin(val types.AttributeValue) (object.Object, error) { + if val == nil { + return object.Nil, nil + } + + switch v := val.(type) { + case *types.AttributeValueMemberS: + return object.NewString(v.Value), nil + case *types.AttributeValueMemberN: + f, err := convertNumAttributeToTamarinValue(v.Value) + if err != nil { + return nil, errors.Errorf("value error: invalid N value: %v", v.Value) + } + return f, nil + case *types.AttributeValueMemberBOOL: + if v.Value { + return object.True, nil + } + return object.False, nil + case *types.AttributeValueMemberNULL: + return object.Nil, nil + case *types.AttributeValueMemberL: + list, err := sliceutils.MapWithError(v.Value, attributeValueToTamarin) + if err != nil { + return nil, err + } + return object.NewList(list), nil + case *types.AttributeValueMemberM: + objMap, err := maputils.MapValuesWithError(v.Value, attributeValueToTamarin) + if err != nil { + return nil, err + } + return object.NewMap(objMap), nil + case *types.AttributeValueMemberSS: + return object.NewSet(sliceutils.Map(v.Value, func(s string) object.Object { + return object.NewString(s) + })), nil + case *types.AttributeValueMemberNS: + nums, err := sliceutils.MapWithError(v.Value, func(s string) (object.Object, error) { + return convertNumAttributeToTamarinValue(s) + }) + if err != nil { + return nil, err + } + return object.NewSet(nums), nil + } + return nil, errors.Errorf("value error: cannot convert type %T to tamarin object", val) +} + +var intNumberPattern = regexp.MustCompile(`^[-]?[0-9]+$`) + +// XXX - this is pretty crappy in that it does not support large values +func convertNumAttributeToTamarinValue(n string) (object.Object, error) { + if intNumberPattern.MatchString(n) { + parsedInt, err := strconv.ParseInt(n, 10, 64) + if err != nil { + return nil, err + } + return object.NewInt(parsedInt), nil + } + + f, err := strconv.ParseFloat(n, 64) + if err != nil { + return nil, err + } + return object.NewFloat(f), nil +} diff --git a/internal/dynamo-browse/services/scriptmanager/types.go b/internal/dynamo-browse/services/scriptmanager/types.go new file mode 100644 index 0000000..442f03b --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/types.go @@ -0,0 +1,35 @@ +package scriptmanager + +import ( + "context" +) + +type ScriptPlugin struct { + scriptService *Service + name string + definedCommands map[string]*Command + definedKeyBindings map[string]*Command + keyToKeyBinding map[string]string + relatedItems []*relatedItemBuilder +} + +func (sp *ScriptPlugin) Name() string { + return sp.name +} + +type Command struct { + plugin *ScriptPlugin + cmdFn func(ctx context.Context, args []string) error +} + +// Invoke will schedule the command for invocation. If the script scheduler is free, it will be started immediately. +// Otherwise an error will be returned. +func (c *Command) Invoke(ctx context.Context, args []string, errChan chan error) error { + return c.plugin.scriptService.sched.runNow(ctx, func(ctx context.Context) { + errChan <- c.cmdFn(ctx, args) + }) +} + +//func (c *Command) LookupRelevantItems(ctx context.Context, table *models.TableInfo, item *models.Item) error { +// +//} diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index fab7026..90dfa0f 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -40,13 +40,14 @@ type Model struct { settingsController *controllers.SettingsController exportController *controllers.ExportController commandController *commandctrl.CommandController - jobController *controllers.JobsController - colSelector *colselector.Model - relSelector *relselector.Model - itemEdit *dynamoitemedit.Model - statusAndPrompt *statusandprompt.StatusAndPrompt - tableSelect *tableselect.Model - eventBus *bus.Bus + //scriptController *controllers.ScriptController + jobController *controllers.JobsController + colSelector *colselector.Model + relSelector *relselector.Model + itemEdit *dynamoitemedit.Model + statusAndPrompt *statusandprompt.StatusAndPrompt + tableSelect *tableselect.Model + eventBus *bus.Bus mainViewIndex int @@ -67,6 +68,7 @@ func NewModel( jobController *controllers.JobsController, itemRendererService *itemrenderer.Service, cc *commandctrl.CommandController, + //scriptController *controllers.ScriptController, eventBus *bus.Bus, keyBindingController *controllers.KeyBindingController, pasteboardProvider services.PasteboardProvider, @@ -85,12 +87,172 @@ func NewModel( dialogPrompt := dialogprompt.New(statusAndPrompt) tableSelect := tableselect.New(dialogPrompt, uiStyles) + /* + cc.AddCommands(&commandctrl.CommandList{ + Commands: map[string]commandctrl.Command{ + "quit": commandctrl.NoArgCommand(tea.Quit), + "table": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + var tableName string + if err := args.Bind(&tableName); err == nil { + return rc.ScanTable(tableName) + } + + return rc.ListTables(false) + }, + "export": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + var filename string + if err := args.Bind(&filename); err != nil { + return events.Error(errors.New("expected filename")) + } + + opts := controllers.ExportOptions{ + AllResults: args.HasSwitch("all"), + } + + return exportController.ExportCSV(filename, opts) + }, + "mark": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + var markOp = controllers.MarkOpMark + + var markOpStr string + if err := args.Bind(&markOpStr); err == nil { + switch markOpStr { + case "all": + markOp = controllers.MarkOpMark + case "none": + markOp = controllers.MarkOpUnmark + case "toggle": + markOp = controllers.MarkOpToggle + default: + return events.Error(errors.New("unrecognised mark operation")) + } + } + + var whereExpr = "" + _ = args.BindSwitch("where", &whereExpr) + + return rc.Mark(markOp, whereExpr) + }, + "unmark": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + return rc.Mark(controllers.MarkOpUnmark, "") + }, + "next-page": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + return rc.NextPage() + }, + "delete": commandctrl.NoArgCommand(wc.DeleteMarked), + + // TEMP + "new-item": commandctrl.NoArgCommand(wc.NewItem), + "clone": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + return wc.CloneItem(dtv.SelectedItemIndex()) + }, + "set-attr": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + var fieldName string + if err := args.Bind(&fieldName); err != nil { + return events.Error(errors.New("expected field")) + } + + var itemType = models.UnsetItemType + switch { + case args.HasSwitch("S"): + itemType = models.StringItemType + case args.HasSwitch("N"): + itemType = models.NumberItemType + case args.HasSwitch("BOOL"): + itemType = models.BoolItemType + case args.HasSwitch("NULL"): + itemType = models.NullItemType + case args.HasSwitch("TO"): + itemType = models.ExprValueItemType + } + + return wc.SetAttributeValue(dtv.SelectedItemIndex(), itemType, fieldName) + }, + "del-attr": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + var fieldName string + // TODO: support rest args + if err := args.Bind(&fieldName); err != nil { + return events.Error(errors.New("expected field")) + } + + return wc.DeleteAttribute(dtv.SelectedItemIndex(), fieldName) + }, + + "put": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + return wc.PutItems() + }, + "touch": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + return wc.TouchItem(dtv.SelectedItemIndex()) + }, + "noisy-touch": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + return wc.NoisyTouchItem(dtv.SelectedItemIndex()) + }, + + + "echo": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + s := new(strings.Builder) + for _, arg := range args { + s.WriteString(arg) + } + return events.StatusMsg(s.String()) + }, + "set-opt": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + var name string + if err := args.Bind(&name); err != nil { + return events.Error(errors.New("expected settingName")) + } + + var value string + if err := args.Bind(&value); err == nil { + return settingsController.SetSetting(name, value) + } + + return settingsController.SetSetting(name, "") + }, + "rebind": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + var bindingName, newKey string + if err := args.Bind(&bindingName, &newKey); err != nil { + return events.Error(errors.New("expected: bindingName newKey")) + } + + return keyBindingController.Rebind(bindingName, newKey, ctx.FromFile) + }, + + "run-script": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + var name string + if err := args.Bind(&name); err != nil { + return events.Error(errors.New("expected: script name")) + } + + return scriptController.RunScript(name) + }, + "load-script": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + var name string + if err := args.Bind(&name); err != nil { + return events.Error(errors.New("expected: script name")) + } + + return scriptController.LoadScript(name) + }, + + // Aliases + "sa": cc.Alias("set-attr"), + "da": cc.Alias("del-attr"), + "np": cc.Alias("next-page"), + "w": cc.Alias("put"), + "q": cc.Alias("quit"), + }, + }) + + */ + root := layout.FullScreen(tableSelect) return Model{ tableReadController: rc, tableWriteController: wc, commandController: cc, + //scriptController: scriptController, exportController: exportController, jobController: jobController, itemEdit: itemEdit,