From e64833e5b22fdb15efb2e767073c2e3406c4f1cf Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Mon, 10 Aug 2026 17:11:33 +0530 Subject: [PATCH 1/2] feat: add actions modules command tree Add the auth0 actions modules command group with list, show, create, update, and delete, plus draft publishing support. - create and update accept code, dependencies, and secrets, with guided interactive prompts and a --publish flag to snapshot a new immutable version - update skips the API call when nothing changed and reports the current module instead of issuing an empty write - delete accepts multiple ids and hides modules in use by deployed action versions, which the API refuses to delete - code is shown as a syntax-highlighted block beneath the details table - add unit tests and commander integration tests --- docs/auth0_actions.md | 1 + docs/auth0_actions_create.md | 1 + docs/auth0_actions_delete.md | 1 + docs/auth0_actions_deploy.md | 1 + docs/auth0_actions_diff.md | 1 + docs/auth0_actions_list.md | 1 + docs/auth0_actions_modules.md | 17 + docs/auth0_actions_modules_create.md | 63 ++ docs/auth0_actions_modules_delete.md | 58 ++ docs/auth0_actions_modules_list.md | 54 ++ docs/auth0_actions_modules_show.md | 50 ++ docs/auth0_actions_modules_update.md | 59 ++ docs/auth0_actions_open.md | 1 + docs/auth0_actions_show.md | 1 + docs/auth0_actions_update.md | 1 + internal/auth0/action_module.go | 58 ++ internal/auth0/auth0.go | 4 + internal/auth0/mock/action_module_mock.go | 180 +++++ internal/cli/actions.go | 1 + internal/cli/actions_modules.go | 729 ++++++++++++++++++ internal/cli/actions_modules_test.go | 537 +++++++++++++ internal/display/action_module.go | 229 ++++++ internal/display/action_module_test.go | 120 +++ .../action-modules-test-cases.yaml | 129 ++++ .../scripts/get-action-module-id.sh | 13 + test/integration/scripts/test-cleanup.sh | 1 + 26 files changed, 2311 insertions(+) create mode 100644 docs/auth0_actions_modules.md create mode 100644 docs/auth0_actions_modules_create.md create mode 100644 docs/auth0_actions_modules_delete.md create mode 100644 docs/auth0_actions_modules_list.md create mode 100644 docs/auth0_actions_modules_show.md create mode 100644 docs/auth0_actions_modules_update.md create mode 100644 internal/auth0/action_module.go create mode 100644 internal/auth0/mock/action_module_mock.go create mode 100644 internal/cli/actions_modules.go create mode 100644 internal/cli/actions_modules_test.go create mode 100644 internal/display/action_module.go create mode 100644 internal/display/action_module_test.go create mode 100644 test/integration/action-modules-test-cases.yaml create mode 100755 test/integration/scripts/get-action-module-id.sh diff --git a/docs/auth0_actions.md b/docs/auth0_actions.md index 0021023c2..a16c78bdd 100644 --- a/docs/auth0_actions.md +++ b/docs/auth0_actions.md @@ -14,6 +14,7 @@ Actions are secure, tenant-specific, versioned functions written in Node.js that - [auth0 actions deploy](auth0_actions_deploy.md) - Deploy an action - [auth0 actions diff](auth0_actions_diff.md) - Show diff between two versions of an Actions - [auth0 actions list](auth0_actions_list.md) - List your actions +- [auth0 actions modules](auth0_actions_modules.md) - Manage action modules - [auth0 actions open](auth0_actions_open.md) - Open the settings page of an action - [auth0 actions show](auth0_actions_show.md) - Show an action - [auth0 actions update](auth0_actions_update.md) - Update an action diff --git a/docs/auth0_actions_create.md b/docs/auth0_actions_create.md index dda12a021..3c7834087 100644 --- a/docs/auth0_actions_create.md +++ b/docs/auth0_actions_create.md @@ -64,6 +64,7 @@ auth0 actions create [flags] - [auth0 actions deploy](auth0_actions_deploy.md) - Deploy an action - [auth0 actions diff](auth0_actions_diff.md) - Show diff between two versions of an Actions - [auth0 actions list](auth0_actions_list.md) - List your actions +- [auth0 actions modules](auth0_actions_modules.md) - Manage action modules - [auth0 actions open](auth0_actions_open.md) - Open the settings page of an action - [auth0 actions show](auth0_actions_show.md) - Show an action - [auth0 actions update](auth0_actions_update.md) - Update an action diff --git a/docs/auth0_actions_delete.md b/docs/auth0_actions_delete.md index b7af598c3..a6b4f8e9e 100644 --- a/docs/auth0_actions_delete.md +++ b/docs/auth0_actions_delete.md @@ -52,6 +52,7 @@ auth0 actions delete [flags] - [auth0 actions deploy](auth0_actions_deploy.md) - Deploy an action - [auth0 actions diff](auth0_actions_diff.md) - Show diff between two versions of an Actions - [auth0 actions list](auth0_actions_list.md) - List your actions +- [auth0 actions modules](auth0_actions_modules.md) - Manage action modules - [auth0 actions open](auth0_actions_open.md) - Open the settings page of an action - [auth0 actions show](auth0_actions_show.md) - Show an action - [auth0 actions update](auth0_actions_update.md) - Update an action diff --git a/docs/auth0_actions_deploy.md b/docs/auth0_actions_deploy.md index 286c0fb56..afdbf91f7 100644 --- a/docs/auth0_actions_deploy.md +++ b/docs/auth0_actions_deploy.md @@ -49,6 +49,7 @@ auth0 actions deploy [flags] - [auth0 actions deploy](auth0_actions_deploy.md) - Deploy an action - [auth0 actions diff](auth0_actions_diff.md) - Show diff between two versions of an Actions - [auth0 actions list](auth0_actions_list.md) - List your actions +- [auth0 actions modules](auth0_actions_modules.md) - Manage action modules - [auth0 actions open](auth0_actions_open.md) - Open the settings page of an action - [auth0 actions show](auth0_actions_show.md) - Show an action - [auth0 actions update](auth0_actions_update.md) - Update an action diff --git a/docs/auth0_actions_diff.md b/docs/auth0_actions_diff.md index 541ff8596..f8bf2c47a 100644 --- a/docs/auth0_actions_diff.md +++ b/docs/auth0_actions_diff.md @@ -39,6 +39,7 @@ auth0 actions diff - [auth0 actions deploy](auth0_actions_deploy.md) - Deploy an action - [auth0 actions diff](auth0_actions_diff.md) - Show diff between two versions of an Actions - [auth0 actions list](auth0_actions_list.md) - List your actions +- [auth0 actions modules](auth0_actions_modules.md) - Manage action modules - [auth0 actions open](auth0_actions_open.md) - Open the settings page of an action - [auth0 actions show](auth0_actions_show.md) - Show an action - [auth0 actions update](auth0_actions_update.md) - Update an action diff --git a/docs/auth0_actions_list.md b/docs/auth0_actions_list.md index aad7a2eab..6c5bbe743 100644 --- a/docs/auth0_actions_list.md +++ b/docs/auth0_actions_list.md @@ -49,6 +49,7 @@ auth0 actions list [flags] - [auth0 actions deploy](auth0_actions_deploy.md) - Deploy an action - [auth0 actions diff](auth0_actions_diff.md) - Show diff between two versions of an Actions - [auth0 actions list](auth0_actions_list.md) - List your actions +- [auth0 actions modules](auth0_actions_modules.md) - Manage action modules - [auth0 actions open](auth0_actions_open.md) - Open the settings page of an action - [auth0 actions show](auth0_actions_show.md) - Show an action - [auth0 actions update](auth0_actions_update.md) - Update an action diff --git a/docs/auth0_actions_modules.md b/docs/auth0_actions_modules.md new file mode 100644 index 000000000..ef19edd84 --- /dev/null +++ b/docs/auth0_actions_modules.md @@ -0,0 +1,17 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 actions modules + +Action modules are reusable code libraries that actions can import. Manage them here, then associate a module with an action using the `--module` flag on `auth0 actions create` or `auth0 actions update`. + +## Commands + +- [auth0 actions modules create](auth0_actions_modules_create.md) - Create a new action module +- [auth0 actions modules delete](auth0_actions_modules_delete.md) - Delete an action module +- [auth0 actions modules list](auth0_actions_modules_list.md) - List your action modules +- [auth0 actions modules show](auth0_actions_modules_show.md) - Show an action module +- [auth0 actions modules update](auth0_actions_modules_update.md) - Update an action module + diff --git a/docs/auth0_actions_modules_create.md b/docs/auth0_actions_modules_create.md new file mode 100644 index 000000000..3d2169b14 --- /dev/null +++ b/docs/auth0_actions_modules_create.md @@ -0,0 +1,63 @@ +--- +layout: default +parent: auth0 actions modules +has_toc: false +--- +# auth0 actions modules create + +Create a new action module. + +To create interactively, use `auth0 actions modules create` with no flags. + +To create non-interactively, supply the name and code (and optionally dependencies, secrets, and publish) through flags. + +## Usage +``` +auth0 actions modules create [flags] +``` + +## Examples + +``` + auth0 actions modules create + auth0 actions modules create --name mymodule --code "$(cat path/to/module.js)" + auth0 actions modules create --name mymodule --code "$(cat path/to/module.js)" --publish + auth0 actions modules create --name mymodule --code "$(cat path/to/module.js)" --dependency "lodash=4.0.0" --secret "API_KEY=value" + auth0 actions modules create --name mymodule --code "$(cat path/to/module.js)" --api-version v1 + auth0 actions modules create -n mymodule -c "$(cat path/to/module.js)" -d "lodash=4.0.0" -s "API_KEY=value" --json +``` + + +## Flags + +``` + --api-version string API version of the action module. + -c, --code string Code content of the action module. + -d, --dependency stringToString Third party npm module, and its version, that the action module depends on. (default []) + --json Output in json format. + --json-compact Output in compact json format. + -n, --name string Name of the action module. Must start with a lowercase letter or digit and contain only lowercase letters, digits, underscores, and hyphens. + --publish Publish the module's draft as a new immutable version once the create or update succeeds. + -s, --secret stringToString Secrets to be used in the action module. (default []) +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 actions modules create](auth0_actions_modules_create.md) - Create a new action module +- [auth0 actions modules delete](auth0_actions_modules_delete.md) - Delete an action module +- [auth0 actions modules list](auth0_actions_modules_list.md) - List your action modules +- [auth0 actions modules show](auth0_actions_modules_show.md) - Show an action module +- [auth0 actions modules update](auth0_actions_modules_update.md) - Update an action module + + diff --git a/docs/auth0_actions_modules_delete.md b/docs/auth0_actions_modules_delete.md new file mode 100644 index 000000000..36276bcc5 --- /dev/null +++ b/docs/auth0_actions_modules_delete.md @@ -0,0 +1,58 @@ +--- +layout: default +parent: auth0 actions modules +has_toc: false +--- +# auth0 actions modules delete + +Delete an action module. + +To delete interactively, use `auth0 actions modules delete` with no arguments. + +To delete non-interactively, supply the module id and the `--force` flag to skip confirmation. + +A module that is in use by a deployed action version cannot be deleted; such modules are hidden from the interactive picker. + +## Usage +``` +auth0 actions modules delete [flags] +``` + +## Examples + +``` + auth0 actions modules delete + auth0 actions modules rm + auth0 actions modules delete + auth0 actions modules delete --force + auth0 actions modules delete + auth0 actions modules delete --force +``` + + +## Flags + +``` + --force Skip confirmation. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 actions modules create](auth0_actions_modules_create.md) - Create a new action module +- [auth0 actions modules delete](auth0_actions_modules_delete.md) - Delete an action module +- [auth0 actions modules list](auth0_actions_modules_list.md) - List your action modules +- [auth0 actions modules show](auth0_actions_modules_show.md) - Show an action module +- [auth0 actions modules update](auth0_actions_modules_update.md) - Update an action module + + diff --git a/docs/auth0_actions_modules_list.md b/docs/auth0_actions_modules_list.md new file mode 100644 index 000000000..def9f0b04 --- /dev/null +++ b/docs/auth0_actions_modules_list.md @@ -0,0 +1,54 @@ +--- +layout: default +parent: auth0 actions modules +has_toc: false +--- +# auth0 actions modules list + +List the action modules in your tenant. + +## Usage +``` +auth0 actions modules list [flags] +``` + +## Examples + +``` + auth0 actions modules list + auth0 actions modules ls + auth0 actions modules list --number 100 + auth0 actions modules list -n 100 --json + auth0 actions modules list --csv +``` + + +## Flags + +``` + --csv Output in csv format. + --json Output in json format. + --json-compact Output in compact json format. + -n, --number int Number of action modules to retrieve. Minimum 1, maximum 1000. (default 100) +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 actions modules create](auth0_actions_modules_create.md) - Create a new action module +- [auth0 actions modules delete](auth0_actions_modules_delete.md) - Delete an action module +- [auth0 actions modules list](auth0_actions_modules_list.md) - List your action modules +- [auth0 actions modules show](auth0_actions_modules_show.md) - Show an action module +- [auth0 actions modules update](auth0_actions_modules_update.md) - Update an action module + + diff --git a/docs/auth0_actions_modules_show.md b/docs/auth0_actions_modules_show.md new file mode 100644 index 000000000..d08f95e3d --- /dev/null +++ b/docs/auth0_actions_modules_show.md @@ -0,0 +1,50 @@ +--- +layout: default +parent: auth0 actions modules +has_toc: false +--- +# auth0 actions modules show + +Display the code, dependencies, secrets, and version information about an action module. + +## Usage +``` +auth0 actions modules show [flags] +``` + +## Examples + +``` + auth0 actions modules show + auth0 actions modules show + auth0 actions modules show --json +``` + + +## Flags + +``` + --json Output in json format. + --json-compact Output in compact json format. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 actions modules create](auth0_actions_modules_create.md) - Create a new action module +- [auth0 actions modules delete](auth0_actions_modules_delete.md) - Delete an action module +- [auth0 actions modules list](auth0_actions_modules_list.md) - List your action modules +- [auth0 actions modules show](auth0_actions_modules_show.md) - Show an action module +- [auth0 actions modules update](auth0_actions_modules_update.md) - Update an action module + + diff --git a/docs/auth0_actions_modules_update.md b/docs/auth0_actions_modules_update.md new file mode 100644 index 000000000..8e8d7dc54 --- /dev/null +++ b/docs/auth0_actions_modules_update.md @@ -0,0 +1,59 @@ +--- +layout: default +parent: auth0 actions modules +has_toc: false +--- +# auth0 actions modules update + +Update an action module. + +The module name is immutable and cannot be changed. Only the fields you pass are updated; omitted fields keep their current values. + +Updates edit the module's draft. Pass `--publish` to also snapshot the draft as a new immutable version once the update succeeds. + +## Usage +``` +auth0 actions modules update [flags] +``` + +## Examples + +``` + auth0 actions modules update --code "$(cat path/to/module.js)" + auth0 actions modules update --dependency "lodash=4.0.0" --secret "API_KEY=value" + auth0 actions modules update --code "$(cat path/to/module.js)" --publish + auth0 actions modules update -c "$(cat path/to/module.js)" --json +``` + + +## Flags + +``` + -c, --code string Code content of the action module. + -d, --dependency stringToString Third party npm module, and its version, that the action module depends on. (default []) + --json Output in json format. + --json-compact Output in compact json format. + --publish Publish the module's draft as a new immutable version once the create or update succeeds. + -s, --secret stringToString Secrets to be used in the action module. (default []) +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 actions modules create](auth0_actions_modules_create.md) - Create a new action module +- [auth0 actions modules delete](auth0_actions_modules_delete.md) - Delete an action module +- [auth0 actions modules list](auth0_actions_modules_list.md) - List your action modules +- [auth0 actions modules show](auth0_actions_modules_show.md) - Show an action module +- [auth0 actions modules update](auth0_actions_modules_update.md) - Update an action module + + diff --git a/docs/auth0_actions_open.md b/docs/auth0_actions_open.md index ed4805142..d62d9ee65 100644 --- a/docs/auth0_actions_open.md +++ b/docs/auth0_actions_open.md @@ -39,6 +39,7 @@ auth0 actions open [flags] - [auth0 actions deploy](auth0_actions_deploy.md) - Deploy an action - [auth0 actions diff](auth0_actions_diff.md) - Show diff between two versions of an Actions - [auth0 actions list](auth0_actions_list.md) - List your actions +- [auth0 actions modules](auth0_actions_modules.md) - Manage action modules - [auth0 actions open](auth0_actions_open.md) - Open the settings page of an action - [auth0 actions show](auth0_actions_show.md) - Show an action - [auth0 actions update](auth0_actions_update.md) - Update an action diff --git a/docs/auth0_actions_show.md b/docs/auth0_actions_show.md index 40fe95d79..a8a25e5f8 100644 --- a/docs/auth0_actions_show.md +++ b/docs/auth0_actions_show.md @@ -47,6 +47,7 @@ auth0 actions show [flags] - [auth0 actions deploy](auth0_actions_deploy.md) - Deploy an action - [auth0 actions diff](auth0_actions_diff.md) - Show diff between two versions of an Actions - [auth0 actions list](auth0_actions_list.md) - List your actions +- [auth0 actions modules](auth0_actions_modules.md) - Manage action modules - [auth0 actions open](auth0_actions_open.md) - Open the settings page of an action - [auth0 actions show](auth0_actions_show.md) - Show an action - [auth0 actions update](auth0_actions_update.md) - Update an action diff --git a/docs/auth0_actions_update.md b/docs/auth0_actions_update.md index e188b6a6a..ff9743c1b 100644 --- a/docs/auth0_actions_update.md +++ b/docs/auth0_actions_update.md @@ -64,6 +64,7 @@ auth0 actions update [flags] - [auth0 actions deploy](auth0_actions_deploy.md) - Deploy an action - [auth0 actions diff](auth0_actions_diff.md) - Show diff between two versions of an Actions - [auth0 actions list](auth0_actions_list.md) - List your actions +- [auth0 actions modules](auth0_actions_modules.md) - Manage action modules - [auth0 actions open](auth0_actions_open.md) - Open the settings page of an action - [auth0 actions show](auth0_actions_show.md) - Show an action - [auth0 actions update](auth0_actions_update.md) - Update an action diff --git a/internal/auth0/action_module.go b/internal/auth0/action_module.go new file mode 100644 index 000000000..613d276ad --- /dev/null +++ b/internal/auth0/action_module.go @@ -0,0 +1,58 @@ +//go:generate mockgen -source=action_module.go -destination=mock/action_module_mock.go -package=mock + +package auth0 + +import ( + "context" + + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" + "github.com/auth0/go-auth0/v3/management/option" +) + +// ActionModulePage is the paginated response returned by the action modules +// list endpoint. It is aliased here so the mock generator (which cannot parse +// instantiated generic types) sees a plain named type in the interface. +type ActionModulePage = core.Page[*int, *managementv3.ActionModuleListItem, *managementv3.GetActionModulesResponseContent] + +// ActionModuleAPIV3 is the V3 SDK interface for the /actions/modules endpoint. +// Action modules are reusable code libraries that actions can import; they are +// keyed by module ID. +type ActionModuleAPIV3 interface { + // List retrieves the action modules for the tenant. The results are + // offset-paginated via the Page/PerPage request parameters. + // + // Required scope: `read:actions`. + List(ctx context.Context, request *managementv3.GetActionModulesRequestParameters, opts ...option.RequestOption) (*ActionModulePage, error) + + // Get retrieves an action module by ID. + // + // Required scope: `read:actions`. + Get(ctx context.Context, id string, opts ...option.RequestOption) (*managementv3.GetActionModuleResponseContent, error) + + // Create a new action module. + // + // Required scope: `create:actions`. + Create(ctx context.Context, request *managementv3.CreateActionModuleRequestContent, opts ...option.RequestOption) (*managementv3.CreateActionModuleResponseContent, error) + + // Update an action module by ID. The module name is immutable and cannot be + // changed through this endpoint. + // + // Required scope: `update:actions`. + Update(ctx context.Context, id string, request *managementv3.UpdateActionModuleRequestContent, opts ...option.RequestOption) (*managementv3.UpdateActionModuleResponseContent, error) + + // Delete an action module by ID. + // + // Required scope: `delete:actions`. + Delete(ctx context.Context, id string, opts ...option.RequestOption) error +} + +// ActionModuleVersionAPIV3 is the V3 SDK interface for the +// /actions/modules/{id}/versions endpoint. Publishing a module creates a new +// immutable version from its current draft. +type ActionModuleVersionAPIV3 interface { + // Create publishes the module's current draft as a new immutable version. + // + // Required scope: `update:actions`. + Create(ctx context.Context, id string, opts ...option.RequestOption) (*managementv3.CreateActionModuleVersionResponseContent, error) +} diff --git a/internal/auth0/auth0.go b/internal/auth0/auth0.go index ebe36003d..f566abae5 100644 --- a/internal/auth0/auth0.go +++ b/internal/auth0/auth0.go @@ -85,6 +85,8 @@ type APIV3 struct { RefreshToken RefreshTokenAPIV3 UserSession UserSessionAPIV3 UserRefreshToken UserRefreshTokenAPIV3 + ActionModule ActionModuleAPIV3 + ActionModuleVersion ActionModuleVersionAPIV3 } func NewAPIV3(m *managementv3.Management) *APIV3 { @@ -98,6 +100,8 @@ func NewAPIV3(m *managementv3.Management) *APIV3 { RefreshToken: m.RefreshTokens, UserSession: m.Users.Sessions, UserRefreshToken: m.Users.RefreshToken, + ActionModule: m.Actions.Modules, + ActionModuleVersion: m.Actions.Modules.Versions, } } diff --git a/internal/auth0/mock/action_module_mock.go b/internal/auth0/mock/action_module_mock.go new file mode 100644 index 000000000..fc0e83691 --- /dev/null +++ b/internal/auth0/mock/action_module_mock.go @@ -0,0 +1,180 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: action_module.go + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + auth0 "github.com/auth0/auth0-cli/internal/auth0" + management "github.com/auth0/go-auth0/v3/management" + option "github.com/auth0/go-auth0/v3/management/option" + gomock "github.com/golang/mock/gomock" +) + +// MockActionModuleAPIV3 is a mock of ActionModuleAPIV3 interface. +type MockActionModuleAPIV3 struct { + ctrl *gomock.Controller + recorder *MockActionModuleAPIV3MockRecorder +} + +// MockActionModuleAPIV3MockRecorder is the mock recorder for MockActionModuleAPIV3. +type MockActionModuleAPIV3MockRecorder struct { + mock *MockActionModuleAPIV3 +} + +// NewMockActionModuleAPIV3 creates a new mock instance. +func NewMockActionModuleAPIV3(ctrl *gomock.Controller) *MockActionModuleAPIV3 { + mock := &MockActionModuleAPIV3{ctrl: ctrl} + mock.recorder = &MockActionModuleAPIV3MockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockActionModuleAPIV3) EXPECT() *MockActionModuleAPIV3MockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockActionModuleAPIV3) Create(ctx context.Context, request *management.CreateActionModuleRequestContent, opts ...option.RequestOption) (*management.CreateActionModuleResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Create", varargs...) + ret0, _ := ret[0].(*management.CreateActionModuleResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockActionModuleAPIV3MockRecorder) Create(ctx, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockActionModuleAPIV3)(nil).Create), varargs...) +} + +// Delete mocks base method. +func (m *MockActionModuleAPIV3) Delete(ctx context.Context, id string, opts ...option.RequestOption) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Delete", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockActionModuleAPIV3MockRecorder) Delete(ctx, id interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, id}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockActionModuleAPIV3)(nil).Delete), varargs...) +} + +// Get mocks base method. +func (m *MockActionModuleAPIV3) Get(ctx context.Context, id string, opts ...option.RequestOption) (*management.GetActionModuleResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Get", varargs...) + ret0, _ := ret[0].(*management.GetActionModuleResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockActionModuleAPIV3MockRecorder) Get(ctx, id interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, id}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockActionModuleAPIV3)(nil).Get), varargs...) +} + +// List mocks base method. +func (m *MockActionModuleAPIV3) List(ctx context.Context, request *management.GetActionModulesRequestParameters, opts ...option.RequestOption) (*auth0.ActionModulePage, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "List", varargs...) + ret0, _ := ret[0].(*auth0.ActionModulePage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockActionModuleAPIV3MockRecorder) List(ctx, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockActionModuleAPIV3)(nil).List), varargs...) +} + +// Update mocks base method. +func (m *MockActionModuleAPIV3) Update(ctx context.Context, id string, request *management.UpdateActionModuleRequestContent, opts ...option.RequestOption) (*management.UpdateActionModuleResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Update", varargs...) + ret0, _ := ret[0].(*management.UpdateActionModuleResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockActionModuleAPIV3MockRecorder) Update(ctx, id, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, id, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockActionModuleAPIV3)(nil).Update), varargs...) +} + +// MockActionModuleVersionAPIV3 is a mock of ActionModuleVersionAPIV3 interface. +type MockActionModuleVersionAPIV3 struct { + ctrl *gomock.Controller + recorder *MockActionModuleVersionAPIV3MockRecorder +} + +// MockActionModuleVersionAPIV3MockRecorder is the mock recorder for MockActionModuleVersionAPIV3. +type MockActionModuleVersionAPIV3MockRecorder struct { + mock *MockActionModuleVersionAPIV3 +} + +// NewMockActionModuleVersionAPIV3 creates a new mock instance. +func NewMockActionModuleVersionAPIV3(ctrl *gomock.Controller) *MockActionModuleVersionAPIV3 { + mock := &MockActionModuleVersionAPIV3{ctrl: ctrl} + mock.recorder = &MockActionModuleVersionAPIV3MockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockActionModuleVersionAPIV3) EXPECT() *MockActionModuleVersionAPIV3MockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockActionModuleVersionAPIV3) Create(ctx context.Context, id string, opts ...option.RequestOption) (*management.CreateActionModuleVersionResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Create", varargs...) + ret0, _ := ret[0].(*management.CreateActionModuleVersionResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockActionModuleVersionAPIV3MockRecorder) Create(ctx, id interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, id}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockActionModuleVersionAPIV3)(nil).Create), varargs...) +} diff --git a/internal/cli/actions.go b/internal/cli/actions.go index 292a557c1..a651e8370 100644 --- a/internal/cli/actions.go +++ b/internal/cli/actions.go @@ -106,6 +106,7 @@ func actionsCmd(cli *cli) *cobra.Command { cmd.AddCommand(deployActionCmd(cli)) cmd.AddCommand(openActionCmd(cli)) cmd.AddCommand(diffActionCmd(cli)) + cmd.AddCommand(actionsModulesCmd(cli)) return cmd } diff --git a/internal/cli/actions_modules.go b/internal/cli/actions_modules.go new file mode 100644 index 000000000..75015f40b --- /dev/null +++ b/internal/cli/actions_modules.go @@ -0,0 +1,729 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "maps" + "regexp" + "sort" + "strings" + + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/prompt" +) + +// actionModuleNamePattern is the name constraint enforced by the Management +// API: the name must start with a lowercase letter or digit and contain only +// lowercase letters, digits, underscores, and hyphens. +var actionModuleNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + +var ( + actionModuleID = Argument{ + Name: "Id", + Help: "Id of the action module.", + } + actionModuleName = Flag{ + Name: "Name", + LongForm: "name", + ShortForm: "n", + Help: "Name of the action module. Must start with a lowercase letter or digit and contain only lowercase letters, digits, underscores, and hyphens.", + IsRequired: true, + } + actionModuleCode = Flag{ + Name: "Code", + LongForm: "code", + ShortForm: "c", + Help: "Code content of the action module.", + IsRequired: true, + } + actionModuleDependency = Flag{ + Name: "Dependency", + LongForm: "dependency", + ShortForm: "d", + Help: "Third party npm module, and its version, that the action module depends on.", + } + actionModuleSecret = Flag{ + Name: "Secret", + LongForm: "secret", + ShortForm: "s", + Help: "Secrets to be used in the action module.", + } + actionModulePublish = Flag{ + Name: "Publish", + LongForm: "publish", + Help: "Publish the module's draft as a new immutable version once the create or update succeeds.", + } + actionModuleAPIVersion = Flag{ + Name: "API Version", + LongForm: "api-version", + Help: "API version of the action module.", + } + actionModuleNumber = Flag{ + Name: "Number", + LongForm: "number", + ShortForm: "n", + Help: "Number of action modules to retrieve. Minimum 1, maximum 1000.", + } +) + +func actionsModulesCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "modules", + Short: "Manage action modules", + Long: "Action modules are reusable code libraries that actions can import. " + + "Manage them here, then associate a module with an action using the `--module` flag on `auth0 actions create` or `auth0 actions update`.", + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(listActionModulesCmd(cli)) + cmd.AddCommand(showActionModuleCmd(cli)) + cmd.AddCommand(createActionModuleCmd(cli)) + cmd.AddCommand(updateActionModuleCmd(cli)) + cmd.AddCommand(deleteActionModuleCmd(cli)) + + return cmd +} + +func listActionModulesCmd(cli *cli) *cobra.Command { + var inputs struct { + Number int + } + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Args: cobra.NoArgs, + Short: "List your action modules", + Long: "List the action modules in your tenant.", + Example: ` auth0 actions modules list + auth0 actions modules ls + auth0 actions modules list --number 100 + auth0 actions modules list -n 100 --json + auth0 actions modules list --csv`, + RunE: func(cmd *cobra.Command, args []string) error { + if inputs.Number < 1 || inputs.Number > 1000 { + return fmt.Errorf("number flag invalid, please pass a number between 1 and 1000") + } + + modules, err := collectV3Pages(cmd.Context(), inputs.Number, + func(ctx context.Context) (*auth0.ActionModulePage, error) { + return cli.apiv3.ActionModule.List(ctx, &managementv3.GetActionModulesRequestParameters{}) + }) + if err != nil { + return fmt.Errorf("failed to list action modules: %w", err) + } + + cli.renderer.ActionModuleList(modules) + + return nil + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.Flags().BoolVar(&cli.csv, "csv", false, "Output in csv format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact", "csv") + + actionModuleNumber.RegisterInt(cmd, &inputs.Number, defaultPageSize) + + return cmd +} + +func showActionModuleCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "show", + Args: cobra.MaximumNArgs(1), + Short: "Show an action module", + Long: "Display the code, dependencies, secrets, and version information about an action module.", + Example: ` auth0 actions modules show + auth0 actions modules show + auth0 actions modules show --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := actionModuleID.Pick(cmd, &inputs.ID, cli.actionModulePickerOptions); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + var module *managementv3.GetActionModuleResponseContent + if err := ansi.Waiting(func() (err error) { + module, err = cli.apiv3.ActionModule.Get(cmd.Context(), inputs.ID) + return err + }); err != nil { + return fmt.Errorf("failed to read action module with ID %q: %w", inputs.ID, err) + } + + cli.renderer.ActionModuleShow(module) + + return nil + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact") + + return cmd +} + +func createActionModuleCmd(cli *cli) *cobra.Command { + var inputs struct { + Name string + Code string + Dependencies map[string]string + Secrets map[string]string + Publish bool + APIVersion string + } + + cmd := &cobra.Command{ + Use: "create", + Args: cobra.NoArgs, + Short: "Create a new action module", + Long: "Create a new action module.\n\n" + + "To create interactively, use `auth0 actions modules create` with no flags.\n\n" + + "To create non-interactively, supply the name and code (and optionally dependencies, secrets, and publish) through flags.", + Example: ` auth0 actions modules create + auth0 actions modules create --name mymodule --code "$(cat path/to/module.js)" + auth0 actions modules create --name mymodule --code "$(cat path/to/module.js)" --publish + auth0 actions modules create --name mymodule --code "$(cat path/to/module.js)" --dependency "lodash=4.0.0" --secret "API_KEY=value" + auth0 actions modules create --name mymodule --code "$(cat path/to/module.js)" --api-version v1 + auth0 actions modules create -n mymodule -c "$(cat path/to/module.js)" -d "lodash=4.0.0" -s "API_KEY=value" --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := actionModuleName.Ask(cmd, &inputs.Name, nil); err != nil { + return err + } + + if !actionModuleNamePattern.MatchString(inputs.Name) { + return fmt.Errorf("invalid name %q: must start with a lowercase letter or digit and contain only lowercase letters, digits, underscores, and hyphens", inputs.Name) + } + + if err := actionModuleCode.OpenEditor( + cmd, + &inputs.Code, + "", + inputs.Name+".*.js", + cli.actionEditorHint, + ); err != nil { + return err + } + + // When run interactively without the corresponding flags, collect + // the optional fields through guided prompts. + if canPrompt(cmd) { + if !actionModuleDependency.IsSet(cmd) { + deps, err := askActionModuleDependencies(nil) + if err != nil { + return err + } + inputs.Dependencies = deps + } + + if !actionModuleSecret.IsSet(cmd) { + secrets, err := askActionModuleSecrets() + if err != nil { + return err + } + inputs.Secrets = secrets + } + + if !actionModulePublish.IsSet(cmd) { + if err := prompt.AskBool("Publish this module now?", &inputs.Publish, false); err != nil { + return err + } + } + + if !actionModuleAPIVersion.IsSet(cmd) { + if err := prompt.AskOne(prompt.TextInput("", "API version (optional):", "Leave blank to use the default.", "", false), &inputs.APIVersion); err != nil { + return err + } + } + } + + module := &managementv3.CreateActionModuleRequestContent{ + Name: inputs.Name, + Code: inputs.Code, + Dependencies: inputDependenciesToActionModuleDependencies(inputs.Dependencies), + Secrets: inputSecretsToActionModuleSecrets(inputs.Secrets), + } + if inputs.Publish { + module.Publish = auth0.Bool(true) + } + if inputs.APIVersion != "" { + module.APIVersion = &inputs.APIVersion + } + + var created *managementv3.CreateActionModuleResponseContent + if err := ansi.Waiting(func() (err error) { + created, err = cli.apiv3.ActionModule.Create(cmd.Context(), module) + return err + }); err != nil { + return fmt.Errorf("failed to create action module: %w", err) + } + + cli.renderer.ActionModuleCreate(created) + + return nil + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact") + + actionModuleName.RegisterString(cmd, &inputs.Name, "") + actionModuleCode.RegisterString(cmd, &inputs.Code, "") + actionModuleDependency.RegisterStringMap(cmd, &inputs.Dependencies, nil) + actionModuleSecret.RegisterStringMap(cmd, &inputs.Secrets, nil) + actionModulePublish.RegisterBool(cmd, &inputs.Publish, false) + actionModuleAPIVersion.RegisterString(cmd, &inputs.APIVersion, "") + + return cmd +} + +func updateActionModuleCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + Code string + Dependencies map[string]string + Secrets map[string]string + Publish bool + } + + cmd := &cobra.Command{ + Use: "update", + Args: cobra.MaximumNArgs(1), + Short: "Update an action module", + Long: "Update an action module.\n\n" + + "The module name is immutable and cannot be changed. Only the fields you pass are updated; " + + "omitted fields keep their current values.\n\n" + + "Updates edit the module's draft. Pass `--publish` to also snapshot the draft as a new immutable version once the update succeeds.", + Example: ` auth0 actions modules update --code "$(cat path/to/module.js)" + auth0 actions modules update --dependency "lodash=4.0.0" --secret "API_KEY=value" + auth0 actions modules update --code "$(cat path/to/module.js)" --publish + auth0 actions modules update -c "$(cat path/to/module.js)" --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := actionModuleID.Pick(cmd, &inputs.ID, cli.actionModulePickerOptions); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + module := &managementv3.UpdateActionModuleRequestContent{} + + if actionModuleCode.IsSet(cmd) { + module.Code = &inputs.Code + } + if actionModuleDependency.IsSet(cmd) { + module.Dependencies = inputDependenciesToActionModuleDependencies(inputs.Dependencies) + } + if actionModuleSecret.IsSet(cmd) { + module.Secrets = inputSecretsToActionModuleSecrets(inputs.Secrets) + } + + // Current is populated when the interactive path fetches the module, + // and reused for the no-op case below to avoid a second read. + var current *managementv3.GetActionModuleResponseContent + + // When run interactively without the update flags, guide the user + // through editing the code, dependencies, and secrets of the module. + if canPrompt(cmd) && !actionModuleCode.IsSet(cmd) && !actionModuleDependency.IsSet(cmd) && !actionModuleSecret.IsSet(cmd) { + var err error + current, err = cli.apiv3.ActionModule.Get(cmd.Context(), inputs.ID) + if err != nil { + return fmt.Errorf("failed to read action module with ID %q: %w", inputs.ID, err) + } + + var editCode bool + if err := prompt.AskBool("Edit the code?", &editCode, false); err != nil { + return err + } + if editCode { + code := current.GetCode() + if err := actionModuleCode.OpenEditorU(cmd, &code, code, current.GetName()+".*.js"); err != nil { + return err + } + module.Code = &code + } + + var editDeps bool + if err := prompt.AskBool("Edit the dependencies?", &editDeps, false); err != nil { + return err + } + if editDeps { + deps, err := askActionModuleDependencies(currentActionModuleDependencies(current)) + if err != nil { + return err + } + module.Dependencies = inputDependenciesToActionModuleDependencies(deps) + } + + var replaceSecrets bool + if err := prompt.AskBool("Replace all secrets?", &replaceSecrets, false); err != nil { + return err + } + if replaceSecrets { + cli.renderer.Warnf("Existing secret values cannot be read back, so this replaces every secret on the module. Any secret you do not re-enter below will be removed.") + secrets, err := askActionModuleSecrets() + if err != nil { + return err + } + module.Secrets = inputSecretsToActionModuleSecrets(secrets) + } + + // Only offer to publish when there is actually something to + // publish: either the user edited a field above, or the module's + // draft already has changes that were never published. + edited := module.Code != nil || module.Dependencies != nil || module.Secrets != nil + if !actionModulePublish.IsSet(cmd) && (edited || !current.GetAllChangesPublished()) { + if err := prompt.AskBool("Publish the module as a new version?", &inputs.Publish, false); err != nil { + return err + } + } + } + + hasChanges := module.Code != nil || module.Dependencies != nil || module.Secrets != nil + + // With neither field changes nor a publish request there is nothing to + // do, so show the current module instead of issuing an empty, no-op + // write request. + if !hasChanges && !inputs.Publish { + if current == nil { + var err error + if err = ansi.Waiting(func() (err error) { + current, err = cli.apiv3.ActionModule.Get(cmd.Context(), inputs.ID) + return err + }); err != nil { + return fmt.Errorf("failed to read action module with ID %q: %w", inputs.ID, err) + } + } + + cli.renderer.Infof("No changes to apply.") + cli.renderer.ActionModuleShow(current) + + return nil + } + + // AllChangesPublished tells us whether the draft already matches the + // latest version, which lets us skip a redundant publish. + var allChangesPublished bool + if hasChanges { + var updated *managementv3.UpdateActionModuleResponseContent + if err := ansi.Waiting(func() (err error) { + updated, err = cli.apiv3.ActionModule.Update(cmd.Context(), inputs.ID, module) + return err + }); err != nil { + return fmt.Errorf("failed to update action module with ID %q: %w", inputs.ID, err) + } + allChangesPublished = updated.GetAllChangesPublished() + + if !inputs.Publish { + cli.renderer.ActionModuleUpdate(updated) + return nil + } + } else { + // Publish requested without field changes: read the module so we + // know whether the draft has anything left to publish. + if current == nil { + var err error + if err = ansi.Waiting(func() (err error) { + current, err = cli.apiv3.ActionModule.Get(cmd.Context(), inputs.ID) + return err + }); err != nil { + return fmt.Errorf("failed to read action module with ID %q: %w", inputs.ID, err) + } + } + allChangesPublished = current.GetAllChangesPublished() + } + + if allChangesPublished { + cli.renderer.Infof("The module is already fully published; there are no draft changes to publish.") + } else { + if err := ansi.Spinner("Publishing action module", func() error { + _, err := cli.apiv3.ActionModuleVersion.Create(cmd.Context(), inputs.ID) + return err + }); err != nil { + return fmt.Errorf("failed to publish action module with ID %q: %w", inputs.ID, err) + } + } + + // Re-read the module so the output reflects the published version and + // the updated publish state. + var published *managementv3.GetActionModuleResponseContent + if err := ansi.Waiting(func() (err error) { + published, err = cli.apiv3.ActionModule.Get(cmd.Context(), inputs.ID) + return err + }); err != nil { + return fmt.Errorf("failed to read action module with ID %q: %w", inputs.ID, err) + } + + cli.renderer.ActionModuleShow(published) + + return nil + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact") + + actionModuleCode.RegisterStringU(cmd, &inputs.Code, "") + actionModuleDependency.RegisterStringMapU(cmd, &inputs.Dependencies, nil) + actionModuleSecret.RegisterStringMapU(cmd, &inputs.Secrets, nil) + actionModulePublish.RegisterBool(cmd, &inputs.Publish, false) + + return cmd +} + +func deleteActionModuleCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Aliases: []string{"rm"}, + Short: "Delete an action module", + Long: "Delete an action module.\n\n" + + "To delete interactively, use `auth0 actions modules delete` with no arguments.\n\n" + + "To delete non-interactively, supply the module id and the `--force` flag to skip confirmation.\n\n" + + "A module that is in use by a deployed action version cannot be deleted; such modules are hidden from the interactive picker.", + Example: ` auth0 actions modules delete + auth0 actions modules rm + auth0 actions modules delete + auth0 actions modules delete --force + auth0 actions modules delete + auth0 actions modules delete --force`, + RunE: func(cmd *cobra.Command, args []string) error { + var ids []string + if len(args) == 0 { + if err := actionModuleID.PickMany(cmd, &ids, cli.deletableActionModulePickerOptions); err != nil { + return err + } + } else { + ids = args + } + + if !cli.force && canPrompt(cmd) { + if confirmed := prompt.Confirm("Are you sure you want to proceed?"); !confirmed { + return nil + } + } + + return ansi.ProgressBar("Deleting action module(s)", ids, func(_ int, id string) error { + if id != "" { + if err := cli.apiv3.ActionModule.Delete(cmd.Context(), id); err != nil { + return fmt.Errorf("failed to delete action module with ID %q: %w", id, err) + } + } + return nil + }) + }, + } + + cmd.Flags().BoolVar(&cli.force, "force", false, "Skip confirmation.") + + return cmd +} + +func (c *cli) actionModulePickerOptions(ctx context.Context) (pickerOptions, error) { + return c.actionModulePickerOptionsFiltered(ctx, false) +} + +// deletableActionModulePickerOptions lists only modules that can actually be +// deleted. A module in use by a deployed action version is rejected by the API +// with a 412, so those are hidden from the delete picker to keep it to choices +// that will succeed. +func (c *cli) deletableActionModulePickerOptions(ctx context.Context) (pickerOptions, error) { + return c.actionModulePickerOptionsFiltered(ctx, true) +} + +// actionModulePickerOptionsFiltered builds the module picker. When +// deletableOnly is set it omits modules still referenced by deployed action +// versions, which the API refuses to delete. +func (c *cli) actionModulePickerOptionsFiltered(ctx context.Context, deletableOnly bool) (pickerOptions, error) { + modules, err := collectV3Pages(ctx, 0, + func(ctx context.Context) (*auth0.ActionModulePage, error) { + return c.apiv3.ActionModule.List(ctx, &managementv3.GetActionModulesRequestParameters{}) + }) + if err != nil { + return nil, err + } + + var opts pickerOptions + for _, m := range modules { + if deletableOnly && m.GetActionsUsingModuleTotal() > 0 { + continue + } + label := fmt.Sprintf("%s %s", m.GetName(), ansi.Faint("("+m.GetID()+")")) + opts = append(opts, pickerOption{value: m.GetID(), label: label}) + } + + if len(opts) == 0 { + if deletableOnly { + return nil, errors.New("there are no action modules available to delete; modules in use by deployed action versions cannot be deleted") + } + return nil, errors.New("there are currently no action modules to choose from. Create one by running: `auth0 actions modules create`") + } + + return opts, nil +} + +func inputDependenciesToActionModuleDependencies(dependencies map[string]string) []*managementv3.ActionModuleDependencyRequest { + if len(dependencies) == 0 { + return nil + } + + result := make([]*managementv3.ActionModuleDependencyRequest, 0, len(dependencies)) + for name, version := range dependencies { + result = append(result, &managementv3.ActionModuleDependencyRequest{ + Name: name, + Version: version, + }) + } + + return result +} + +func inputSecretsToActionModuleSecrets(secrets map[string]string) []*managementv3.ActionModuleSecretRequest { + if len(secrets) == 0 { + return nil + } + + result := make([]*managementv3.ActionModuleSecretRequest, 0, len(secrets)) + for name, value := range secrets { + result = append(result, &managementv3.ActionModuleSecretRequest{ + Name: name, + Value: value, + }) + } + + return result +} + +// currentActionModuleDependencies maps a module's existing dependencies into +// the name/version form used by the interactive prompts. +func currentActionModuleDependencies(module *managementv3.GetActionModuleResponseContent) map[string]string { + dependencies := map[string]string{} + for _, d := range module.GetDependencies() { + dependencies[d.GetName()] = d.GetVersion() + } + return dependencies +} + +// askActionModuleDependencies interactively collects npm dependencies as +// name/version pairs. It first asks a single gate question, then loops through +// one entry at a time and prints a recap of everything collected. Any entries +// in existing are shown first and carried forward, which lets the update flow +// safely append to the current set instead of replacing it. +func askActionModuleDependencies(existing map[string]string) (map[string]string, error) { + dependencies := map[string]string{} + maps.Copy(dependencies, existing) + + for name, version := range dependencies { + fmt.Printf(" %s keeping %s\n", ansi.Faint("•"), ansi.Faint(name+"@"+version)) + } + + var add bool + if err := prompt.AskBool("Do you want to add dependencies?", &add, false); err != nil { + return nil, err + } + + for add { + var name string + if err := prompt.AskOne(prompt.TextInput("", "Dependency name:", "The npm package name, e.g. lodash.", "", true), &name); err != nil { + return nil, err + } + + var version string + if err := prompt.AskOne(prompt.TextInput("", "Dependency version:", "The npm package version, e.g. 4.17.21.", "", true), &version); err != nil { + return nil, err + } + + dependencies[name] = version + fmt.Printf(" %s added %s\n", ansi.Green("✓"), ansi.Bold(name+"@"+version)) + + if err := prompt.AskBool("Add another dependency?", &add, false); err != nil { + return nil, err + } + } + + if len(dependencies) > 0 { + fmt.Printf("%s %s\n", ansi.Green("✓"), summarizeActionModuleDependencies(dependencies)) + } + + return dependencies, nil +} + +// summarizeActionModuleDependencies renders a one-line recap such as +// "2 dependencies: lodash@4.17.21, auth0@1.4.0". +func summarizeActionModuleDependencies(dependencies map[string]string) string { + labels := make([]string, 0, len(dependencies)) + for name, version := range dependencies { + labels = append(labels, name+"@"+version) + } + sort.Strings(labels) + + return fmt.Sprintf("%s: %s", pluralize(len(labels), "dependency", "dependencies"), strings.Join(labels, ", ")) +} + +// askActionModuleSecrets interactively collects secrets as name/value pairs. It +// asks a single gate question, then loops through one entry at a time and +// prints a recap listing only the names collected. Values are entered through a +// masked prompt so they never appear on screen or in shell history, and the +// recap never echoes them back. +func askActionModuleSecrets() (map[string]string, error) { + secrets := map[string]string{} + + var add bool + if err := prompt.AskBool("Do you want to add secrets?", &add, false); err != nil { + return nil, err + } + + for add { + var name string + if err := prompt.AskOne(prompt.TextInput("", "Secret name:", "The secret name, e.g. API_KEY.", "", true), &name); err != nil { + return nil, err + } + + var value string + if err := prompt.AskOne(prompt.PasswordInput("", "Secret value:", true), &value); err != nil { + return nil, err + } + + secrets[name] = value + fmt.Printf(" %s added %s\n", ansi.Green("✓"), ansi.Bold(name)) + + if err := prompt.AskBool("Add another secret?", &add, false); err != nil { + return nil, err + } + } + + if len(secrets) > 0 { + names := make([]string, 0, len(secrets)) + for name := range secrets { + names = append(names, name) + } + sort.Strings(names) + fmt.Printf("%s %s: %s\n", ansi.Green("✓"), pluralize(len(names), "secret", "secrets"), strings.Join(names, ", ")) + } + + return secrets, nil +} + +// pluralize returns "1 singular" or "n plural" with the count prefixed. +func pluralize(n int, singular, plural string) string { + if n == 1 { + return fmt.Sprintf("1 %s", singular) + } + return fmt.Sprintf("%d %s", n, plural) +} diff --git a/internal/cli/actions_modules_test.go b/internal/cli/actions_modules_test.go new file mode 100644 index 000000000..3ffc6b047 --- /dev/null +++ b/internal/cli/actions_modules_test.go @@ -0,0 +1,537 @@ +package cli + +import ( + "context" + "errors" + "testing" + + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" + "github.com/auth0/go-auth0/v3/management/option" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/auth0/mock" +) + +func actionModulePage(results ...*managementv3.ActionModuleListItem) *auth0.ActionModulePage { + return &auth0.ActionModulePage{ + Results: results, + NextPageFunc: func(context.Context) (*auth0.ActionModulePage, error) { + return nil, core.ErrNoPages + }, + } +} + +func TestListActionModulesCmd(t *testing.T) { + t.Run("lists the action modules", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + api.EXPECT(). + List(gomock.Any(), gomock.Any()). + Return(actionModulePage( + &managementv3.ActionModuleListItem{ID: auth0.String("am_1"), Name: auth0.String("one")}, + &managementv3.ActionModuleListItem{ID: auth0.String("am_2"), Name: auth0.String("two")}, + ), nil) + + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: api}, + renderer: testRenderer(), + } + + cmd := listActionModulesCmd(cli) + cmd.SetArgs([]string{}) + + assert.NoError(t, cmd.Execute()) + }) + + t.Run("renders an empty state when there are no modules", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + api.EXPECT(). + List(gomock.Any(), gomock.Any()). + Return(actionModulePage(), nil) + + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: api}, + renderer: testRenderer(), + } + + cmd := listActionModulesCmd(cli) + cmd.SetArgs([]string{}) + + assert.NoError(t, cmd.Execute()) + }) + + t.Run("rejects an out-of-range number flag", func(t *testing.T) { + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: mock.NewMockActionModuleAPIV3(gomock.NewController(t))}, + renderer: testRenderer(), + } + + cmd := listActionModulesCmd(cli) + cmd.SetArgs([]string{"--number", "0"}) + + assert.EqualError(t, cmd.Execute(), "number flag invalid, please pass a number between 1 and 1000") + }) + + t.Run("wraps the API error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + api.EXPECT(). + List(gomock.Any(), gomock.Any()). + Return(nil, errors.New("boom")) + + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: api}, + renderer: testRenderer(), + } + + cmd := listActionModulesCmd(cli) + cmd.SetArgs([]string{}) + + assert.EqualError(t, cmd.Execute(), "failed to list action modules: boom") + }) +} + +func TestShowActionModuleCmd(t *testing.T) { + t.Run("shows the module supplied positionally", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + api.EXPECT(). + Get(gomock.Any(), "am_1"). + Return(&managementv3.GetActionModuleResponseContent{ + ID: auth0.String("am_1"), + Name: auth0.String("one"), + }, nil) + + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: api}, + renderer: testRenderer(), + } + + cmd := showActionModuleCmd(cli) + cmd.SetArgs([]string{"am_1"}) + + assert.NoError(t, cmd.Execute()) + }) + + t.Run("wraps the API error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + api.EXPECT(). + Get(gomock.Any(), "am_1"). + Return(nil, errors.New("boom")) + + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: api}, + renderer: testRenderer(), + } + + cmd := showActionModuleCmd(cli) + cmd.SetArgs([]string{"am_1"}) + + assert.EqualError(t, cmd.Execute(), `failed to read action module with ID "am_1": boom`) + }) +} + +func TestCreateActionModuleCmd(t *testing.T) { + t.Run("creates a module with all flags mapped", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + api.EXPECT(). + Create(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, req *managementv3.CreateActionModuleRequestContent, _ ...option.RequestOption) (*managementv3.CreateActionModuleResponseContent, error) { + assert.Equal(t, "mymodule", req.Name) + assert.Equal(t, "module.exports = () => {}", req.Code) + assert.Equal(t, auth0.Bool(true), req.Publish) + assert.Equal(t, auth0.String("v1"), req.APIVersion) + + assert.Len(t, req.Dependencies, 1) + assert.Equal(t, "lodash", req.Dependencies[0].Name) + assert.Equal(t, "4.0.0", req.Dependencies[0].Version) + + assert.Len(t, req.Secrets, 1) + assert.Equal(t, "API_KEY", req.Secrets[0].Name) + assert.Equal(t, "secret-value", req.Secrets[0].Value) + + return &managementv3.CreateActionModuleResponseContent{ + ID: auth0.String("am_1"), + Name: auth0.String("mymodule"), + }, nil + }) + + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: api}, + renderer: testRenderer(), + } + + cmd := createActionModuleCmd(cli) + cmd.SetArgs([]string{ + "--name", "mymodule", + "--code", "module.exports = () => {}", + "--dependency", "lodash=4.0.0", + "--secret", "API_KEY=secret-value", + "--api-version", "v1", + "--publish", + }) + + assert.NoError(t, cmd.Execute()) + }) + + t.Run("omits publish and api-version when not set", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + api.EXPECT(). + Create(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, req *managementv3.CreateActionModuleRequestContent, _ ...option.RequestOption) (*managementv3.CreateActionModuleResponseContent, error) { + assert.Nil(t, req.Publish) + assert.Nil(t, req.APIVersion) + assert.Nil(t, req.Dependencies) + assert.Nil(t, req.Secrets) + + return &managementv3.CreateActionModuleResponseContent{ID: auth0.String("am_1")}, nil + }) + + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: api}, + renderer: testRenderer(), + } + + cmd := createActionModuleCmd(cli) + cmd.SetArgs([]string{"--name", "mymodule", "--code", "code"}) + + assert.NoError(t, cmd.Execute()) + }) +} + +func TestUpdateActionModuleCmd(t *testing.T) { + t.Run("only sends the fields that were set and never a name", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + api.EXPECT(). + Update(gomock.Any(), "am_1", gomock.Any()). + DoAndReturn(func(_ context.Context, _ string, req *managementv3.UpdateActionModuleRequestContent, _ ...option.RequestOption) (*managementv3.UpdateActionModuleResponseContent, error) { + assert.Equal(t, auth0.String("new code"), req.Code) + assert.Nil(t, req.Dependencies) + assert.Nil(t, req.Secrets) + + return &managementv3.UpdateActionModuleResponseContent{ID: auth0.String("am_1")}, nil + }) + + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: api}, + renderer: testRenderer(), + } + + cmd := updateActionModuleCmd(cli) + cmd.SetArgs([]string{"am_1", "--code", "new code"}) + + assert.NoError(t, cmd.Execute()) + }) + + t.Run("leaves code nil when only dependencies change", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + api.EXPECT(). + Update(gomock.Any(), "am_1", gomock.Any()). + DoAndReturn(func(_ context.Context, _ string, req *managementv3.UpdateActionModuleRequestContent, _ ...option.RequestOption) (*managementv3.UpdateActionModuleResponseContent, error) { + assert.Nil(t, req.Code) + assert.Len(t, req.Dependencies, 1) + + return &managementv3.UpdateActionModuleResponseContent{ID: auth0.String("am_1")}, nil + }) + + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: api}, + renderer: testRenderer(), + } + + cmd := updateActionModuleCmd(cli) + cmd.SetArgs([]string{"am_1", "--dependency", "lodash=4.0.0"}) + + assert.NoError(t, cmd.Execute()) + }) + + t.Run("updates then publishes when --publish leaves unpublished changes", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + versions := mock.NewMockActionModuleVersionAPIV3(ctrl) + + gomock.InOrder( + api.EXPECT(). + Update(gomock.Any(), "am_1", gomock.Any()). + Return(&managementv3.UpdateActionModuleResponseContent{ + ID: auth0.String("am_1"), + AllChangesPublished: auth0.Bool(false), + }, nil), + versions.EXPECT(). + Create(gomock.Any(), "am_1"). + Return(&managementv3.CreateActionModuleVersionResponseContent{VersionNumber: auth0.Int(2)}, nil), + api.EXPECT(). + Get(gomock.Any(), "am_1"). + Return(&managementv3.GetActionModuleResponseContent{ID: auth0.String("am_1")}, nil), + ) + + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: api, ActionModuleVersion: versions}, + renderer: testRenderer(), + } + + cmd := updateActionModuleCmd(cli) + cmd.SetArgs([]string{"am_1", "--code", "new code", "--publish"}) + + assert.NoError(t, cmd.Execute()) + }) + + t.Run("skips publish when the draft is already fully published", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + versions := mock.NewMockActionModuleVersionAPIV3(ctrl) + + gomock.InOrder( + api.EXPECT(). + Update(gomock.Any(), "am_1", gomock.Any()). + Return(&managementv3.UpdateActionModuleResponseContent{ + ID: auth0.String("am_1"), + AllChangesPublished: auth0.Bool(true), + }, nil), + api.EXPECT(). + Get(gomock.Any(), "am_1"). + Return(&managementv3.GetActionModuleResponseContent{ID: auth0.String("am_1")}, nil), + ) + // Versions.Create must never be called when everything is published. + + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: api, ActionModuleVersion: versions}, + renderer: testRenderer(), + } + + cmd := updateActionModuleCmd(cli) + cmd.SetArgs([]string{"am_1", "--code", "new code", "--publish"}) + + assert.NoError(t, cmd.Execute()) + }) + + t.Run("publishes an existing draft without field changes", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + versions := mock.NewMockActionModuleVersionAPIV3(ctrl) + + gomock.InOrder( + api.EXPECT(). + Get(gomock.Any(), "am_1"). + Return(&managementv3.GetActionModuleResponseContent{ + ID: auth0.String("am_1"), + AllChangesPublished: auth0.Bool(false), + }, nil), + versions.EXPECT(). + Create(gomock.Any(), "am_1"). + Return(&managementv3.CreateActionModuleVersionResponseContent{VersionNumber: auth0.Int(2)}, nil), + api.EXPECT(). + Get(gomock.Any(), "am_1"). + Return(&managementv3.GetActionModuleResponseContent{ID: auth0.String("am_1")}, nil), + ) + + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: api, ActionModuleVersion: versions}, + renderer: testRenderer(), + } + + cmd := updateActionModuleCmd(cli) + cmd.SetArgs([]string{"am_1", "--publish"}) + + assert.NoError(t, cmd.Execute()) + }) + + t.Run("does not call the API when there are no changes and no publish", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + api.EXPECT(). + Get(gomock.Any(), "am_1"). + Return(&managementv3.GetActionModuleResponseContent{ID: auth0.String("am_1")}, nil) + // Update must never be called for a no-op. + + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: api}, + renderer: testRenderer(), + } + + cmd := updateActionModuleCmd(cli) + cmd.SetArgs([]string{"am_1"}) + + assert.NoError(t, cmd.Execute()) + }) +} + +func TestDeleteActionModuleCmd(t *testing.T) { + t.Run("deletes the module supplied positionally", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + api.EXPECT(). + Delete(gomock.Any(), "am_1"). + Return(nil) + + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: api}, + renderer: testRenderer(), + } + + cmd := deleteActionModuleCmd(cli) + cmd.SetArgs([]string{"am_1", "--force"}) + + assert.NoError(t, cmd.Execute()) + }) + + t.Run("deletes multiple modules supplied positionally", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + api.EXPECT(). + Delete(gomock.Any(), "am_1"). + Return(nil) + api.EXPECT(). + Delete(gomock.Any(), "am_2"). + Return(nil) + + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: api}, + renderer: testRenderer(), + } + + cmd := deleteActionModuleCmd(cli) + cmd.SetArgs([]string{"am_1", "am_2", "--force"}) + + assert.NoError(t, cmd.Execute()) + }) + + t.Run("wraps the API error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + api.EXPECT(). + Delete(gomock.Any(), "am_1"). + Return(errors.New("boom")) + + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: api}, + renderer: testRenderer(), + } + + cmd := deleteActionModuleCmd(cli) + cmd.SetArgs([]string{"am_1", "--force"}) + + assert.EqualError(t, cmd.Execute(), `failed to delete action module with ID "am_1": boom`) + }) +} + +func TestDeletableActionModulePickerOptions(t *testing.T) { + t.Run("hides modules in use by deployed action versions", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + api.EXPECT(). + List(gomock.Any(), gomock.Any()). + Return(actionModulePage( + &managementv3.ActionModuleListItem{ID: auth0.String("am_free"), Name: auth0.String("free"), ActionsUsingModuleTotal: auth0.Int(0)}, + &managementv3.ActionModuleListItem{ID: auth0.String("am_used"), Name: auth0.String("used"), ActionsUsingModuleTotal: auth0.Int(1)}, + ), nil) + + cli := &cli{apiv3: &auth0.APIV3{ActionModule: api}} + + opts, err := cli.deletableActionModulePickerOptions(context.Background()) + assert.NoError(t, err) + assert.Len(t, opts, 1) + assert.Equal(t, "am_free", opts[0].value) + }) + + t.Run("errors when every module is in use", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + api.EXPECT(). + List(gomock.Any(), gomock.Any()). + Return(actionModulePage( + &managementv3.ActionModuleListItem{ID: auth0.String("am_used"), Name: auth0.String("used"), ActionsUsingModuleTotal: auth0.Int(1)}, + ), nil) + + cli := &cli{apiv3: &auth0.APIV3{ActionModule: api}} + + _, err := cli.deletableActionModulePickerOptions(context.Background()) + assert.ErrorContains(t, err, "in use by deployed action versions") + }) +} + +func TestInputDependenciesToActionModuleDependencies(t *testing.T) { + assert.Nil(t, inputDependenciesToActionModuleDependencies(nil)) + + result := inputDependenciesToActionModuleDependencies(map[string]string{"lodash": "4.0.0"}) + assert.Len(t, result, 1) + assert.Equal(t, "lodash", result[0].Name) + assert.Equal(t, "4.0.0", result[0].Version) +} + +func TestInputSecretsToActionModuleSecrets(t *testing.T) { + assert.Nil(t, inputSecretsToActionModuleSecrets(nil)) + + result := inputSecretsToActionModuleSecrets(map[string]string{"API_KEY": "value"}) + assert.Len(t, result, 1) + assert.Equal(t, "API_KEY", result[0].Name) + assert.Equal(t, "value", result[0].Value) +} + +func TestPluralize(t *testing.T) { + assert.Equal(t, "0 secrets", pluralize(0, "secret", "secrets")) + assert.Equal(t, "1 secret", pluralize(1, "secret", "secrets")) + assert.Equal(t, "2 secrets", pluralize(2, "secret", "secrets")) + assert.Equal(t, "1 dependency", pluralize(1, "dependency", "dependencies")) + assert.Equal(t, "3 dependencies", pluralize(3, "dependency", "dependencies")) +} + +func TestSummarizeActionModuleDependencies(t *testing.T) { + t.Run("single dependency", func(t *testing.T) { + assert.Equal(t, "1 dependency: lodash@4.17.21", + summarizeActionModuleDependencies(map[string]string{"lodash": "4.17.21"})) + }) + + t.Run("multiple dependencies are sorted for a stable recap", func(t *testing.T) { + assert.Equal(t, "2 dependencies: auth0@1.4.0, lodash@4.17.21", + summarizeActionModuleDependencies(map[string]string{ + "lodash": "4.17.21", + "auth0": "1.4.0", + })) + }) +} diff --git a/internal/display/action_module.go b/internal/display/action_module.go new file mode 100644 index 000000000..cd75de3e0 --- /dev/null +++ b/internal/display/action_module.go @@ -0,0 +1,229 @@ +package display + +import ( + "fmt" + "strconv" + "strings" + "time" + + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/charmbracelet/glamour" + + "github.com/auth0/auth0-cli/internal/ansi" +) + +// actionModuleResponse is satisfied by every action module response content +// type returned by the v3 SDK (list item, get, create and update), which all +// share the same getter surface. It lets a single view constructor serve all +// of them. +type actionModuleResponse interface { + GetID() string + GetName() string + GetCode() string + GetDependencies() []*managementv3.ActionModuleDependency + GetSecrets() []*managementv3.ActionModuleSecret + GetActionsUsingModuleTotal() int + GetAllChangesPublished() bool + GetLatestVersionNumber() int + GetCreatedAt() time.Time + GetUpdatedAt() time.Time +} + +type actionModuleView struct { + ID string + Name string + LatestVersion string + ActionsUsing string + Published string + CreatedAt string + UpdatedAt string + Dependencies string + Secrets string + Code string + + raw interface{} +} + +func (v *actionModuleView) AsTableHeader() []string { + return []string{"ID", "Name", "Latest Version", "Actions Using", "Published", "Updated At"} +} + +func (v *actionModuleView) AsTableRow() []string { + return []string{ + ansi.Faint(v.ID), + v.Name, + v.LatestVersion, + v.ActionsUsing, + v.Published, + v.UpdatedAt, + } +} + +func (v *actionModuleView) KeyValues() [][]string { + keyValues := [][]string{ + {"ID", ansi.Faint(v.ID)}, + {"NAME", v.Name}, + {"LATEST VERSION", v.LatestVersion}, + {"ACTIONS USING", v.ActionsUsing}, + {"PUBLISHED", v.Published}, + {"CREATED AT", v.CreatedAt}, + {"UPDATED AT", v.UpdatedAt}, + } + + if v.Dependencies != "" { + keyValues = append(keyValues, []string{"DEPENDENCIES", v.Dependencies}) + } + if v.Secrets != "" { + keyValues = append(keyValues, []string{"SECRETS", v.Secrets}) + } + + // CODE is intentionally omitted here; it is rendered as a separate block + // below the table so multi-line source keeps its formatting instead of + // being collapsed into a single wrapped cell. + + return keyValues +} + +func (v *actionModuleView) Object() interface{} { + return v.raw +} + +func (r *Renderer) ActionModuleList(modules []*managementv3.ActionModuleListItem) { + resource := "action modules" + + r.Heading(resource) + + if len(modules) == 0 { + r.EmptyState(resource, "Use 'auth0 actions modules create' to add one") + return + } + + var results []View + for _, m := range modules { + results = append(results, makeActionModuleView(m)) + } + + r.Results(results) +} + +func (r *Renderer) ActionModuleShow(module *managementv3.GetActionModuleResponseContent) { + r.Heading("action module") + view := makeActionModuleView(module) + r.Result(view) + r.actionModuleCode(view) +} + +func (r *Renderer) ActionModuleCreate(module *managementv3.CreateActionModuleResponseContent) { + r.Heading("action module created") + view := makeActionModuleView(module) + r.Result(view) + r.actionModuleCode(view) +} + +func (r *Renderer) ActionModuleUpdate(module *managementv3.UpdateActionModuleResponseContent) { + r.Heading("action module updated") + view := makeActionModuleView(module) + r.Result(view) + r.actionModuleCode(view) +} + +// actionModuleCode prints the module source as its own labelled block beneath +// the key/value table so multi-line code keeps its formatting. The code is +// syntax-highlighted on a styled background via glamour (the same renderer the +// CLI uses for Markdown), falling back to the raw source if rendering fails. +// It is skipped for the JSON output formats, where the code is already part of +// the object. +func (r *Renderer) actionModuleCode(view *actionModuleView) { + if r.Format == OutputFormatJSON || r.Format == OutputFormatJSONCompact { + return + } + if view.Code == "" { + return + } + + fmt.Fprintf(r.ResultWriter, "\n%s\n%s", ansi.Faint("CODE"), highlightJavaScript(view.Code)) +} + +// highlightJavaScript renders code as a fenced JavaScript block through glamour, +// which syntax-highlights it on a styled background. On any rendering error it +// falls back to the raw code so output is never lost. +func highlightJavaScript(code string) string { + renderer, err := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithPreservedNewLines(), + glamour.WithWordWrap(0), + ) + if err != nil { + return code + "\n" + } + + out, err := renderer.Render("```javascript\n" + code + "\n```") + if err != nil { + return code + "\n" + } + + return out +} + +func makeActionModuleView(module actionModuleResponse) *actionModuleView { + // The list endpoint returns the flat latest_version_number field, while the + // single-item endpoints (get, create, update) instead nest it under + // latest_version.version_number. Prefer the nested value when present and + // fall back to the flat one. + versionNumber := module.GetLatestVersionNumber() + if m, ok := module.(interface { + GetLatestVersion() managementv3.ActionModuleVersionReference + }); ok { + ref := m.GetLatestVersion() + if v := ref.GetVersionNumber(); v != 0 { + versionNumber = v + } + } + + latestVersion := "-" + if versionNumber != 0 { + latestVersion = "v" + strconv.Itoa(versionNumber) + } + + return &actionModuleView{ + ID: module.GetID(), + Name: module.GetName(), + LatestVersion: latestVersion, + ActionsUsing: strconv.Itoa(module.GetActionsUsingModuleTotal()), + Published: boolean(module.GetAllChangesPublished()), + CreatedAt: timeAgo(module.GetCreatedAt()), + UpdatedAt: timeAgo(module.GetUpdatedAt()), + Dependencies: formatActionModuleDependencies(module.GetDependencies()), + Secrets: formatActionModuleSecrets(module.GetSecrets()), + Code: module.GetCode(), + raw: module, + } +} + +func formatActionModuleDependencies(dependencies []*managementv3.ActionModuleDependency) string { + if len(dependencies) == 0 { + return "" + } + + formatted := make([]string, 0, len(dependencies)) + for _, d := range dependencies { + formatted = append(formatted, fmt.Sprintf("%s@%s", d.GetName(), d.GetVersion())) + } + + return strings.Join(formatted, ", ") +} + +// formatActionModuleSecrets lists only the secret names. Secret values are +// never returned by the API. +func formatActionModuleSecrets(secrets []*managementv3.ActionModuleSecret) string { + if len(secrets) == 0 { + return "" + } + + formatted := make([]string, 0, len(secrets)) + for _, s := range secrets { + formatted = append(formatted, s.GetName()) + } + + return strings.Join(formatted, ", ") +} diff --git a/internal/display/action_module_test.go b/internal/display/action_module_test.go new file mode 100644 index 000000000..08b03266d --- /dev/null +++ b/internal/display/action_module_test.go @@ -0,0 +1,120 @@ +package display + +import ( + "testing" + "time" + + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/stretchr/testify/assert" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/auth0" +) + +func TestFormatActionModuleDependencies(t *testing.T) { + t.Run("returns an empty string when there are no dependencies", func(t *testing.T) { + assert.Equal(t, "", formatActionModuleDependencies(nil)) + }) + + t.Run("formats dependencies as name@version", func(t *testing.T) { + deps := []*managementv3.ActionModuleDependency{ + {Name: auth0.String("lodash"), Version: auth0.String("4.0.0")}, + {Name: auth0.String("axios"), Version: auth0.String("1.2.3")}, + } + + assert.Equal(t, "lodash@4.0.0, axios@1.2.3", formatActionModuleDependencies(deps)) + }) +} + +func TestFormatActionModuleSecrets(t *testing.T) { + t.Run("returns an empty string when there are no secrets", func(t *testing.T) { + assert.Equal(t, "", formatActionModuleSecrets(nil)) + }) + + t.Run("lists only the secret names", func(t *testing.T) { + secrets := []*managementv3.ActionModuleSecret{ + {Name: auth0.String("API_KEY")}, + {Name: auth0.String("DB_PASSWORD")}, + } + + assert.Equal(t, "API_KEY, DB_PASSWORD", formatActionModuleSecrets(secrets)) + }) +} + +func TestMakeActionModuleView(t *testing.T) { + updated := time.Now().Add(-time.Hour) + created := time.Now().Add(-48 * time.Hour) + + module := &managementv3.GetActionModuleResponseContent{ + ID: auth0.String("am_1"), + Name: auth0.String("mymodule"), + Code: auth0.String("module.exports = () => {}"), + ActionsUsingModuleTotal: auth0.Int(2), + AllChangesPublished: auth0.Bool(true), + LatestVersionNumber: auth0.Int(3), + CreatedAt: &created, + UpdatedAt: &updated, + Dependencies: []*managementv3.ActionModuleDependency{ + {Name: auth0.String("lodash"), Version: auth0.String("4.0.0")}, + }, + Secrets: []*managementv3.ActionModuleSecret{ + {Name: auth0.String("API_KEY")}, + }, + } + + view := makeActionModuleView(module) + + assert.Equal(t, "mymodule", view.Name) + assert.Equal(t, "v3", view.LatestVersion) + assert.Equal(t, "2", view.ActionsUsing) + assert.Equal(t, "lodash@4.0.0", view.Dependencies) + assert.Equal(t, "API_KEY", view.Secrets) + assert.Equal(t, "module.exports = () => {}", view.Code) + assert.Equal(t, module, view.Object()) + + keyValues := view.KeyValues() + assert.Equal(t, [][]string{ + {"ID", ansi.Faint("am_1")}, + {"NAME", "mymodule"}, + {"LATEST VERSION", "v3"}, + {"ACTIONS USING", "2"}, + {"PUBLISHED", boolean(true)}, + {"CREATED AT", view.CreatedAt}, + {"UPDATED AT", view.UpdatedAt}, + {"DEPENDENCIES", "lodash@4.0.0"}, + {"SECRETS", "API_KEY"}, + }, keyValues) + + // CODE is rendered as a separate block, not as a key/value row. + assert.Equal(t, "module.exports = () => {}", view.Code) +} + +func TestMakeActionModuleViewNestedLatestVersion(t *testing.T) { + // The get/create/update responses nest the version under + // latest_version.version_number and leave the flat field unset. + module := &managementv3.GetActionModuleResponseContent{ + ID: auth0.String("am_1"), + Name: auth0.String("mymodule"), + LatestVersion: &managementv3.ActionModuleVersionReference{ + VersionNumber: auth0.Int(2), + }, + } + + view := makeActionModuleView(module) + + assert.Equal(t, "v2", view.LatestVersion) +} + +func TestMakeActionModuleViewWithoutPublishedVersion(t *testing.T) { + module := &managementv3.GetActionModuleResponseContent{ + ID: auth0.String("am_2"), + Name: auth0.String("draftonly"), + } + + view := makeActionModuleView(module) + + assert.Equal(t, "-", view.LatestVersion) + assert.Equal(t, "0", view.ActionsUsing) + assert.Equal(t, "", view.Dependencies) + assert.Equal(t, "", view.Secrets) +} diff --git a/test/integration/action-modules-test-cases.yaml b/test/integration/action-modules-test-cases.yaml new file mode 100644 index 000000000..9792d4835 --- /dev/null +++ b/test/integration/action-modules-test-cases.yaml @@ -0,0 +1,129 @@ +config: + inherit-env: true + retries: 1 + +tests: + 001 - it successfully lists all action modules with no data (json): + command: auth0 actions modules list --json + exit-code: 0 + stdout: + exactly: "[]" + + 002 - it successfully creates an action module: + command: auth0 actions modules create -n "integration-test-module1" -c "module.exports = {}" -d "lodash=4.0.0" -s "SECRET=value" + exit-code: 0 + stdout: + contains: + - "NAME integration-test-module1" + - "ACTIONS USING 0" + - "PUBLISHED ✗" + - "CREATED AT 0 seconds ago" + - "UPDATED AT 0 seconds ago" + - "DEPENDENCIES lodash@4.0.0" + - "SECRETS SECRET" + + 003 - it successfully creates an action module and outputs in json: + command: auth0 actions modules create -n "integration-test-module2" -c "module.exports = {}" -d "lodash=4.0.0" -s "SECRET=value" --json + exit-code: 0 + stdout: + json: + name: "integration-test-module2" + code: "module.exports = {}" + dependencies.0.name: "lodash" + dependencies.0.version: "4.0.0" + secrets.0.name: "SECRET" + all_changes_published: "false" + + 004 - it successfully creates and publishes an action module in json: + command: auth0 actions modules create -n "integration-test-module3" -c "module.exports = {}" --publish --json + exit-code: 0 + stdout: + json: + name: "integration-test-module3" + all_changes_published: "true" + + 005 - it successfully lists all action modules with data: + command: auth0 actions modules list + exit-code: 0 + stdout: + contains: + - ID + - NAME + - LATEST VERSION + - ACTIONS USING + - PUBLISHED + - UPDATED AT + + 006 - given a test action module, it successfully gets the module's details: + command: auth0 actions modules show $(./test/integration/scripts/get-action-module-id.sh) + exit-code: 0 + stdout: + contains: + - "NAME integration-test-module" + - "ACTIONS USING 0" + - "PUBLISHED" + - "CREATED AT" + - "UPDATED AT" + - "DEPENDENCIES lodash@4.0.0" + - "SECRETS SECRET" + + 007 - given a test action module, it successfully gets the module's details and outputs in json: + command: auth0 actions modules show $(./test/integration/scripts/get-action-module-id.sh) --json + exit-code: 0 + stdout: + json: + name: "integration-test-module" + code: "module.exports = {}" + dependencies.0.name: "lodash" + dependencies.0.version: "4.0.0" + secrets.0.name: "SECRET" + + 008 - given a test action module, it successfully updates the module's code: + command: auth0 actions modules update $(./test/integration/scripts/get-action-module-id.sh) -c "module.exports = {v:2}" --json + exit-code: 0 + stdout: + json: + name: "integration-test-module" + code: "module.exports = {v:2}" + + 009 - given a test action module, it successfully replaces the module's dependencies: + command: auth0 actions modules update $(./test/integration/scripts/get-action-module-id.sh) -d "uuid=9.0.0" --json + exit-code: 0 + stdout: + json: + name: "integration-test-module" + dependencies.0.name: "uuid" + dependencies.0.version: "9.0.0" + + 010 - given a test action module, it preserves the existing secret when only the code changes: + command: auth0 actions modules update $(./test/integration/scripts/get-action-module-id.sh) -c "module.exports = {v:3}" --json + exit-code: 0 + stdout: + json: + secrets.0.name: "SECRET" + + 011 - given a test action module, it successfully publishes the draft: + command: auth0 actions modules update $(./test/integration/scripts/get-action-module-id.sh) --publish --json + exit-code: 0 + stdout: + json: + name: "integration-test-module" + all_changes_published: "true" + + 012 - given a fully published module, it reports there is nothing to publish: + command: auth0 actions modules update $(./test/integration/scripts/get-action-module-id.sh) --publish + exit-code: 0 + stderr: + contains: + - "already fully published" + + 013 - it fails to create a module with an invalid name: + command: auth0 actions modules create -n "Invalid Name" -c "module.exports = {}" --no-input + exit-code: 1 + stderr: + contains: + - "Invalid name" + + 014 - given a test action module, it successfully deletes the module: + command: auth0 actions modules delete $(./test/integration/scripts/get-action-module-id.sh) --force + exit-code: 0 diff --git a/test/integration/scripts/get-action-module-id.sh b/test/integration/scripts/get-action-module-id.sh new file mode 100755 index 000000000..7e5708292 --- /dev/null +++ b/test/integration/scripts/get-action-module-id.sh @@ -0,0 +1,13 @@ +#! /bin/bash + +FILE=./test/integration/identifiers/action-module-id +if [ -f "$FILE" ]; then + cat $FILE + exit 0 +fi + +module=$( auth0 actions modules create -n "integration-test-module" -c "module.exports = {}" -d "lodash=4.0.0" -s "SECRET=value" --json ) + +mkdir -p ./test/integration/identifiers +echo "$module" | jq -r '.["id"]' > $FILE +cat $FILE diff --git a/test/integration/scripts/test-cleanup.sh b/test/integration/scripts/test-cleanup.sh index 292e50aa7..61ff1d57f 100755 --- a/test/integration/scripts/test-cleanup.sh +++ b/test/integration/scripts/test-cleanup.sh @@ -29,6 +29,7 @@ delete_resources "roles" "integration-test-role" "id" delete_resources "rules" "integration-test-rule" "id" delete_resources "orgs" "integration-test-org" "id" delete_resources "actions" "integration-test-" "id" +delete_resources "actions modules" "integration-test-module" "id" delete_resources "token-exchange" "integration-test-" "id" delete_resources "event-streams" "integration-test-" "id" delete_resources "logs streams" "integration-test-" "id" From e07553b02a16708caf05eb22b1233a2a3b88eea1 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Mon, 10 Aug 2026 18:30:40 +0530 Subject: [PATCH 2/2] feat: add actions modules actions list command Add 'auth0 actions modules actions list' to list the actions that use an action module, along with the module version each action is on. Backed by the /actions/modules/{id}/actions endpoint. --- docs/auth0_actions_modules.md | 1 + docs/auth0_actions_modules_actions.md | 13 +++++ docs/auth0_actions_modules_actions_list.md | 49 ++++++++++++++++ docs/auth0_actions_modules_create.md | 1 + docs/auth0_actions_modules_delete.md | 1 + docs/auth0_actions_modules_list.md | 1 + docs/auth0_actions_modules_show.md | 1 + docs/auth0_actions_modules_update.md | 1 + internal/auth0/action_module.go | 11 ++++ internal/auth0/mock/action_module_mock.go | 20 +++++++ internal/cli/actions_modules.go | 67 ++++++++++++++++++++++ internal/cli/actions_modules_test.go | 65 +++++++++++++++++++++ internal/display/action_module.go | 65 +++++++++++++++++++++ 13 files changed, 296 insertions(+) create mode 100644 docs/auth0_actions_modules_actions.md create mode 100644 docs/auth0_actions_modules_actions_list.md diff --git a/docs/auth0_actions_modules.md b/docs/auth0_actions_modules.md index ef19edd84..0e595e7bc 100644 --- a/docs/auth0_actions_modules.md +++ b/docs/auth0_actions_modules.md @@ -9,6 +9,7 @@ Action modules are reusable code libraries that actions can import. Manage them ## Commands +- [auth0 actions modules actions](auth0_actions_modules_actions.md) - Manage the actions using an action module - [auth0 actions modules create](auth0_actions_modules_create.md) - Create a new action module - [auth0 actions modules delete](auth0_actions_modules_delete.md) - Delete an action module - [auth0 actions modules list](auth0_actions_modules_list.md) - List your action modules diff --git a/docs/auth0_actions_modules_actions.md b/docs/auth0_actions_modules_actions.md new file mode 100644 index 000000000..c6699c7d1 --- /dev/null +++ b/docs/auth0_actions_modules_actions.md @@ -0,0 +1,13 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 actions modules actions + +Inspect which actions import an action module. + +## Commands + +- [auth0 actions modules actions list](auth0_actions_modules_actions_list.md) - List the actions using an action module + diff --git a/docs/auth0_actions_modules_actions_list.md b/docs/auth0_actions_modules_actions_list.md new file mode 100644 index 000000000..d510c5698 --- /dev/null +++ b/docs/auth0_actions_modules_actions_list.md @@ -0,0 +1,49 @@ +--- +layout: default +parent: auth0 actions modules actions +has_toc: false +--- +# auth0 actions modules actions list + +List the actions that import an action module, along with the module version each action is using. + +## Usage +``` +auth0 actions modules actions list [flags] +``` + +## Examples + +``` + auth0 actions modules actions list + auth0 actions modules actions ls + auth0 actions modules actions list --number 100 + auth0 actions modules actions list --json +``` + + +## Flags + +``` + --csv Output in csv format. + --json Output in json format. + --json-compact Output in compact json format. + -n, --number int Number of action modules to retrieve. Minimum 1, maximum 1000. (default 100) +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 actions modules actions list](auth0_actions_modules_actions_list.md) - List the actions using an action module + + diff --git a/docs/auth0_actions_modules_create.md b/docs/auth0_actions_modules_create.md index 3d2169b14..0392973d3 100644 --- a/docs/auth0_actions_modules_create.md +++ b/docs/auth0_actions_modules_create.md @@ -54,6 +54,7 @@ auth0 actions modules create [flags] ## Related Commands +- [auth0 actions modules actions](auth0_actions_modules_actions.md) - Manage the actions using an action module - [auth0 actions modules create](auth0_actions_modules_create.md) - Create a new action module - [auth0 actions modules delete](auth0_actions_modules_delete.md) - Delete an action module - [auth0 actions modules list](auth0_actions_modules_list.md) - List your action modules diff --git a/docs/auth0_actions_modules_delete.md b/docs/auth0_actions_modules_delete.md index 36276bcc5..ca5de7636 100644 --- a/docs/auth0_actions_modules_delete.md +++ b/docs/auth0_actions_modules_delete.md @@ -49,6 +49,7 @@ auth0 actions modules delete [flags] ## Related Commands +- [auth0 actions modules actions](auth0_actions_modules_actions.md) - Manage the actions using an action module - [auth0 actions modules create](auth0_actions_modules_create.md) - Create a new action module - [auth0 actions modules delete](auth0_actions_modules_delete.md) - Delete an action module - [auth0 actions modules list](auth0_actions_modules_list.md) - List your action modules diff --git a/docs/auth0_actions_modules_list.md b/docs/auth0_actions_modules_list.md index def9f0b04..580a4d3a4 100644 --- a/docs/auth0_actions_modules_list.md +++ b/docs/auth0_actions_modules_list.md @@ -45,6 +45,7 @@ auth0 actions modules list [flags] ## Related Commands +- [auth0 actions modules actions](auth0_actions_modules_actions.md) - Manage the actions using an action module - [auth0 actions modules create](auth0_actions_modules_create.md) - Create a new action module - [auth0 actions modules delete](auth0_actions_modules_delete.md) - Delete an action module - [auth0 actions modules list](auth0_actions_modules_list.md) - List your action modules diff --git a/docs/auth0_actions_modules_show.md b/docs/auth0_actions_modules_show.md index d08f95e3d..6c29291a8 100644 --- a/docs/auth0_actions_modules_show.md +++ b/docs/auth0_actions_modules_show.md @@ -41,6 +41,7 @@ auth0 actions modules show [flags] ## Related Commands +- [auth0 actions modules actions](auth0_actions_modules_actions.md) - Manage the actions using an action module - [auth0 actions modules create](auth0_actions_modules_create.md) - Create a new action module - [auth0 actions modules delete](auth0_actions_modules_delete.md) - Delete an action module - [auth0 actions modules list](auth0_actions_modules_list.md) - List your action modules diff --git a/docs/auth0_actions_modules_update.md b/docs/auth0_actions_modules_update.md index 8e8d7dc54..a1b40e4f9 100644 --- a/docs/auth0_actions_modules_update.md +++ b/docs/auth0_actions_modules_update.md @@ -50,6 +50,7 @@ auth0 actions modules update [flags] ## Related Commands +- [auth0 actions modules actions](auth0_actions_modules_actions.md) - Manage the actions using an action module - [auth0 actions modules create](auth0_actions_modules_create.md) - Create a new action module - [auth0 actions modules delete](auth0_actions_modules_delete.md) - Delete an action module - [auth0 actions modules list](auth0_actions_modules_list.md) - List your action modules diff --git a/internal/auth0/action_module.go b/internal/auth0/action_module.go index 613d276ad..5e29ce705 100644 --- a/internal/auth0/action_module.go +++ b/internal/auth0/action_module.go @@ -15,6 +15,11 @@ import ( // instantiated generic types) sees a plain named type in the interface. type ActionModulePage = core.Page[*int, *managementv3.ActionModuleListItem, *managementv3.GetActionModulesResponseContent] +// ActionModuleActionPage is the paginated response returned by the action +// module actions list endpoint. It is aliased here for the same reason as +// ActionModulePage. +type ActionModuleActionPage = core.Page[*int, *managementv3.ActionModuleAction, *managementv3.GetActionModuleActionsResponseContent] + // ActionModuleAPIV3 is the V3 SDK interface for the /actions/modules endpoint. // Action modules are reusable code libraries that actions can import; they are // keyed by module ID. @@ -45,6 +50,12 @@ type ActionModuleAPIV3 interface { // // Required scope: `delete:actions`. Delete(ctx context.Context, id string, opts ...option.RequestOption) error + + // ListActions retrieves the actions that use an action module. The results + // are offset-paginated via the Page/PerPage request parameters. + // + // Required scope: `read:actions`. + ListActions(ctx context.Context, id string, request *managementv3.GetActionModuleActionsRequestParameters, opts ...option.RequestOption) (*ActionModuleActionPage, error) } // ActionModuleVersionAPIV3 is the V3 SDK interface for the diff --git a/internal/auth0/mock/action_module_mock.go b/internal/auth0/mock/action_module_mock.go index fc0e83691..b48bf50fa 100644 --- a/internal/auth0/mock/action_module_mock.go +++ b/internal/auth0/mock/action_module_mock.go @@ -116,6 +116,26 @@ func (mr *MockActionModuleAPIV3MockRecorder) List(ctx, request interface{}, opts return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockActionModuleAPIV3)(nil).List), varargs...) } +// ListActions mocks base method. +func (m *MockActionModuleAPIV3) ListActions(ctx context.Context, id string, request *management.GetActionModuleActionsRequestParameters, opts ...option.RequestOption) (*auth0.ActionModuleActionPage, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ListActions", varargs...) + ret0, _ := ret[0].(*auth0.ActionModuleActionPage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListActions indicates an expected call of ListActions. +func (mr *MockActionModuleAPIV3MockRecorder) ListActions(ctx, id, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, id, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListActions", reflect.TypeOf((*MockActionModuleAPIV3)(nil).ListActions), varargs...) +} + // Update mocks base method. func (m *MockActionModuleAPIV3) Update(ctx context.Context, id string, request *management.UpdateActionModuleRequestContent, opts ...option.RequestOption) (*management.UpdateActionModuleResponseContent, error) { m.ctrl.T.Helper() diff --git a/internal/cli/actions_modules.go b/internal/cli/actions_modules.go index 75015f40b..dc69822c2 100644 --- a/internal/cli/actions_modules.go +++ b/internal/cli/actions_modules.go @@ -85,6 +85,7 @@ func actionsModulesCmd(cli *cli) *cobra.Command { cmd.AddCommand(createActionModuleCmd(cli)) cmd.AddCommand(updateActionModuleCmd(cli)) cmd.AddCommand(deleteActionModuleCmd(cli)) + cmd.AddCommand(actionsModulesActionsCmd(cli)) return cmd } @@ -535,6 +536,72 @@ func deleteActionModuleCmd(cli *cli) *cobra.Command { return cmd } +func actionsModulesActionsCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "actions", + Short: "Manage the actions using an action module", + Long: "Inspect which actions import an action module.", + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(listActionsUsingModuleCmd(cli)) + + return cmd +} + +func listActionsUsingModuleCmd(cli *cli) *cobra.Command { + var inputs struct { + ModuleID string + Number int + } + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Args: cobra.MaximumNArgs(1), + Short: "List the actions using an action module", + Long: "List the actions that import an action module, along with the module version each action is using.", + Example: ` auth0 actions modules actions list + auth0 actions modules actions ls + auth0 actions modules actions list --number 100 + auth0 actions modules actions list --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if inputs.Number < 1 || inputs.Number > 1000 { + return fmt.Errorf("number flag invalid, please pass a number between 1 and 1000") + } + + if len(args) == 0 { + if err := actionModuleID.Pick(cmd, &inputs.ModuleID, cli.actionModulePickerOptions); err != nil { + return err + } + } else { + inputs.ModuleID = args[0] + } + + actions, err := collectV3Pages(cmd.Context(), inputs.Number, + func(ctx context.Context) (*auth0.ActionModuleActionPage, error) { + return cli.apiv3.ActionModule.ListActions(ctx, inputs.ModuleID, &managementv3.GetActionModuleActionsRequestParameters{}) + }) + if err != nil { + return fmt.Errorf("failed to list actions using action module with ID %q: %w", inputs.ModuleID, err) + } + + cli.renderer.ActionModuleActionsList(actions) + + return nil + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.Flags().BoolVar(&cli.csv, "csv", false, "Output in csv format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact", "csv") + + actionModuleNumber.RegisterInt(cmd, &inputs.Number, defaultPageSize) + + return cmd +} + func (c *cli) actionModulePickerOptions(ctx context.Context) (pickerOptions, error) { return c.actionModulePickerOptionsFiltered(ctx, false) } diff --git a/internal/cli/actions_modules_test.go b/internal/cli/actions_modules_test.go index 3ffc6b047..54e696818 100644 --- a/internal/cli/actions_modules_test.go +++ b/internal/cli/actions_modules_test.go @@ -101,6 +101,71 @@ func TestListActionModulesCmd(t *testing.T) { }) } +func actionModuleActionPage(results ...*managementv3.ActionModuleAction) *auth0.ActionModuleActionPage { + return &auth0.ActionModuleActionPage{ + Results: results, + NextPageFunc: func(context.Context) (*auth0.ActionModuleActionPage, error) { + return nil, core.ErrNoPages + }, + } +} + +func TestListActionsUsingModuleCmd(t *testing.T) { + t.Run("lists the actions using the module supplied positionally", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + api.EXPECT(). + ListActions(gomock.Any(), "am_1", gomock.Any()). + Return(actionModuleActionPage( + &managementv3.ActionModuleAction{ActionID: auth0.String("act_1"), ActionName: auth0.String("login"), ModuleVersionNumber: auth0.Int(2)}, + ), nil) + + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: api}, + renderer: testRenderer(), + } + + cmd := listActionsUsingModuleCmd(cli) + cmd.SetArgs([]string{"am_1"}) + + assert.NoError(t, cmd.Execute()) + }) + + t.Run("rejects an out-of-range number flag", func(t *testing.T) { + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: mock.NewMockActionModuleAPIV3(gomock.NewController(t))}, + renderer: testRenderer(), + } + + cmd := listActionsUsingModuleCmd(cli) + cmd.SetArgs([]string{"am_1", "--number", "0"}) + + assert.EqualError(t, cmd.Execute(), "number flag invalid, please pass a number between 1 and 1000") + }) + + t.Run("wraps the API error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + api := mock.NewMockActionModuleAPIV3(ctrl) + api.EXPECT(). + ListActions(gomock.Any(), "am_1", gomock.Any()). + Return(nil, errors.New("boom")) + + cli := &cli{ + apiv3: &auth0.APIV3{ActionModule: api}, + renderer: testRenderer(), + } + + cmd := listActionsUsingModuleCmd(cli) + cmd.SetArgs([]string{"am_1"}) + + assert.EqualError(t, cmd.Execute(), `failed to list actions using action module with ID "am_1": boom`) + }) +} + func TestShowActionModuleCmd(t *testing.T) { t.Run("shows the module supplied positionally", func(t *testing.T) { ctrl := gomock.NewController(t) diff --git a/internal/display/action_module.go b/internal/display/action_module.go index cd75de3e0..432c42446 100644 --- a/internal/display/action_module.go +++ b/internal/display/action_module.go @@ -200,6 +200,71 @@ func makeActionModuleView(module actionModuleResponse) *actionModuleView { } } +type actionModuleActionView struct { + ActionID string + ActionName string + ModuleVersion string + + raw interface{} +} + +func (v *actionModuleActionView) AsTableHeader() []string { + return []string{"Action ID", "Action Name", "Module Version"} +} + +func (v *actionModuleActionView) AsTableRow() []string { + return []string{ + ansi.Faint(v.ActionID), + v.ActionName, + v.ModuleVersion, + } +} + +func (v *actionModuleActionView) KeyValues() [][]string { + return [][]string{ + {"ACTION ID", ansi.Faint(v.ActionID)}, + {"ACTION NAME", v.ActionName}, + {"MODULE VERSION", v.ModuleVersion}, + } +} + +func (v *actionModuleActionView) Object() interface{} { + return v.raw +} + +func (r *Renderer) ActionModuleActionsList(actions []*managementv3.ActionModuleAction) { + resource := "actions using module" + + r.Heading(resource) + + if len(actions) == 0 { + r.EmptyState(resource, "No actions are using this module") + return + } + + var results []View + for _, a := range actions { + results = append(results, makeActionModuleActionView(a)) + } + + r.Results(results) +} + +func makeActionModuleActionView(action *managementv3.ActionModuleAction) *actionModuleActionView { + number := action.GetModuleVersionNumber() + version := "-" + if number != 0 { + version = "v" + strconv.Itoa(number) + } + + return &actionModuleActionView{ + ActionID: action.GetActionID(), + ActionName: action.GetActionName(), + ModuleVersion: version, + raw: action, + } +} + func formatActionModuleDependencies(dependencies []*managementv3.ActionModuleDependency) string { if len(dependencies) == 0 { return ""