From 6b9a3b686bcb664d74ba3246f0e7de2d594caed7 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Sun, 25 Oct 2026 01:40:47 +0530 Subject: [PATCH 1/3] feat(plugins): add plugin dev create and starter templates - Add plugin dev create, which scaffolds a Go or TypeScript plugin project - Embed the starter templates with tests, CI and a local release command - Generate plugin.json from the typed manifest and validate it before writing --- internal/cli/plugin_dev.go | 329 ++++++- internal/cli/plugin_dev_test.go | 556 +++++++++++ internal/display/plugin_dev.go | 24 + internal/plugins/scaffold/embed.go | 24 + internal/plugins/scaffold/embed_test.go | 226 +++++ internal/plugins/scaffold/scaffold.go | 561 +++++++++++ internal/plugins/scaffold/scaffold_test.go | 890 ++++++++++++++++++ .../actions/read-version/action.yml.tmpl | 26 + .../templates/go/.github/dependabot.yml.tmpl | 10 + .../go/.github/scripts/read-version.sh.tmpl | 49 + .../go/.github/workflows/ci.yml.tmpl | 41 + .../go/.github/workflows/release.yml.tmpl | 62 ++ .../scaffold/templates/go/.gitignore.tmpl | 4 + .../scaffold/templates/go/.golangci.yml.tmpl | 14 + .../templates/go/.goreleaser.yaml.tmpl | 55 ++ .../scaffold/templates/go/Makefile.tmpl | 16 + .../scaffold/templates/go/README.md.tmpl | 95 ++ .../templates/go/commands/clients.go.tmpl | 70 ++ .../go/commands/clients_test.go.tmpl | 205 ++++ .../templates/go/commands/registry.go.tmpl | 97 ++ .../go/commands/registry_test.go.tmpl | 47 + .../plugins/scaffold/templates/go/go.mod.tmpl | 5 + .../scaffold/templates/go/main.go.tmpl | 23 + .../templates/go/plugin/client.go.tmpl | 99 ++ .../templates/go/plugin/client_test.go.tmpl | 65 ++ .../templates/go/plugin/errors.go.tmpl | 107 +++ .../templates/go/plugin/errors_test.go.tmpl | 80 ++ .../templates/go/plugin/output.go.tmpl | 80 ++ .../templates/go/plugin/paginate.go.tmpl | 26 + .../templates/go/plugin/plugin.go.tmpl | 39 + .../templates/go/scripts/release/main.go.tmpl | 247 +++++ .../go/scripts/release/version.go.tmpl | 146 +++ .../go/scripts/release/version_test.go.tmpl | 83 ++ .../actions/build-archive/action.yml.tmpl | 20 + .../actions/read-version/action.yml.tmpl | 26 + .../templates/ts/.github/dependabot.yml.tmpl | 10 + .../ts/.github/scripts/build-archive.sh.tmpl | 34 + .../templates/ts/.github/scripts/lib.sh.tmpl | 28 + .../.github/scripts/publish-release.sh.tmpl | 42 + .../ts/.github/scripts/read-version.sh.tmpl | 42 + .../ts/.github/workflows/ci.yml.tmpl | 29 + .../ts/.github/workflows/release.yml.tmpl | 78 ++ .../scaffold/templates/ts/.gitignore.tmpl | 7 + .../scaffold/templates/ts/README.md.tmpl | 95 ++ .../scaffold/templates/ts/package.json.tmpl | 19 + .../templates/ts/scripts/release.ts.tmpl | 148 +++ .../templates/ts/scripts/version.test.ts.tmpl | 68 ++ .../templates/ts/scripts/version.ts.tmpl | 90 ++ .../ts/src/commands/clients.test.ts.tmpl | 161 ++++ .../templates/ts/src/commands/clients.ts.tmpl | 37 + .../templates/ts/src/commands/index.ts.tmpl | 25 + .../templates/ts/src/commands/types.ts.tmpl | 28 + .../scaffold/templates/ts/src/index.ts.tmpl | 18 + .../templates/ts/src/lib/client.ts.tmpl | 19 + .../templates/ts/src/lib/env.test.ts.tmpl | 27 + .../scaffold/templates/ts/src/lib/env.ts.tmpl | 37 + .../templates/ts/src/lib/errors.ts.tmpl | 49 + .../templates/ts/src/lib/flags.ts.tmpl | 17 + .../templates/ts/src/lib/output.ts.tmpl | 58 ++ .../templates/ts/src/lib/paginate.ts.tmpl | 16 + .../templates/ts/src/lib/plugin.ts.tmpl | 33 + .../scaffold/templates/ts/src/run.ts.tmpl | 58 ++ .../scaffold/templates/ts/tsconfig.json.tmpl | 15 + 63 files changed, 5734 insertions(+), 1 deletion(-) create mode 100644 internal/plugins/scaffold/embed.go create mode 100644 internal/plugins/scaffold/embed_test.go create mode 100644 internal/plugins/scaffold/scaffold.go create mode 100644 internal/plugins/scaffold/scaffold_test.go create mode 100644 internal/plugins/scaffold/templates/go/.github/actions/read-version/action.yml.tmpl create mode 100644 internal/plugins/scaffold/templates/go/.github/dependabot.yml.tmpl create mode 100644 internal/plugins/scaffold/templates/go/.github/scripts/read-version.sh.tmpl create mode 100644 internal/plugins/scaffold/templates/go/.github/workflows/ci.yml.tmpl create mode 100644 internal/plugins/scaffold/templates/go/.github/workflows/release.yml.tmpl create mode 100644 internal/plugins/scaffold/templates/go/.gitignore.tmpl create mode 100644 internal/plugins/scaffold/templates/go/.golangci.yml.tmpl create mode 100644 internal/plugins/scaffold/templates/go/.goreleaser.yaml.tmpl create mode 100644 internal/plugins/scaffold/templates/go/Makefile.tmpl create mode 100644 internal/plugins/scaffold/templates/go/README.md.tmpl create mode 100644 internal/plugins/scaffold/templates/go/commands/clients.go.tmpl create mode 100644 internal/plugins/scaffold/templates/go/commands/clients_test.go.tmpl create mode 100644 internal/plugins/scaffold/templates/go/commands/registry.go.tmpl create mode 100644 internal/plugins/scaffold/templates/go/commands/registry_test.go.tmpl create mode 100644 internal/plugins/scaffold/templates/go/go.mod.tmpl create mode 100644 internal/plugins/scaffold/templates/go/main.go.tmpl create mode 100644 internal/plugins/scaffold/templates/go/plugin/client.go.tmpl create mode 100644 internal/plugins/scaffold/templates/go/plugin/client_test.go.tmpl create mode 100644 internal/plugins/scaffold/templates/go/plugin/errors.go.tmpl create mode 100644 internal/plugins/scaffold/templates/go/plugin/errors_test.go.tmpl create mode 100644 internal/plugins/scaffold/templates/go/plugin/output.go.tmpl create mode 100644 internal/plugins/scaffold/templates/go/plugin/paginate.go.tmpl create mode 100644 internal/plugins/scaffold/templates/go/plugin/plugin.go.tmpl create mode 100644 internal/plugins/scaffold/templates/go/scripts/release/main.go.tmpl create mode 100644 internal/plugins/scaffold/templates/go/scripts/release/version.go.tmpl create mode 100644 internal/plugins/scaffold/templates/go/scripts/release/version_test.go.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/.github/actions/build-archive/action.yml.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/.github/actions/read-version/action.yml.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/.github/dependabot.yml.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/.github/scripts/build-archive.sh.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/.github/scripts/lib.sh.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/.github/scripts/publish-release.sh.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/.github/scripts/read-version.sh.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/.github/workflows/ci.yml.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/.github/workflows/release.yml.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/.gitignore.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/README.md.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/package.json.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/scripts/release.ts.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/scripts/version.test.ts.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/scripts/version.ts.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/src/commands/clients.test.ts.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/src/commands/clients.ts.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/src/commands/index.ts.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/src/commands/types.ts.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/src/index.ts.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/src/lib/client.ts.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/src/lib/env.test.ts.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/src/lib/env.ts.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/src/lib/errors.ts.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/src/lib/flags.ts.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/src/lib/output.ts.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/src/lib/paginate.ts.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/src/lib/plugin.ts.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/src/run.ts.tmpl create mode 100644 internal/plugins/scaffold/templates/ts/tsconfig.json.tmpl diff --git a/internal/cli/plugin_dev.go b/internal/cli/plugin_dev.go index bf80145d3..77076b4df 100644 --- a/internal/cli/plugin_dev.go +++ b/internal/cli/plugin_dev.go @@ -7,15 +7,18 @@ import ( "maps" "os" "path/filepath" + "runtime" "slices" "strings" "github.com/spf13/cobra" + "github.com/auth0/auth0-cli/internal/buildinfo" "github.com/auth0/auth0-cli/internal/display" "github.com/auth0/auth0-cli/internal/plugins" "github.com/auth0/auth0-cli/internal/plugins/devlink" "github.com/auth0/auth0-cli/internal/plugins/registry" + "github.com/auth0/auth0-cli/internal/plugins/scaffold" "github.com/auth0/auth0-cli/internal/plugins/store" ) @@ -31,6 +34,83 @@ var pluginDevBin = Flag{ Help: "The executable of the plugin. By default it is the file named like the plugin in the directory of the plugin.", } +const ( + pluginCreateDefaultVersion = "0.1.0" + pluginCreateDefaultDescription = "A plugin for the Auth0 CLI" + pluginCreateDefaultLicense = "UNLICENSED" + pluginCreateDefaultScope = "read:clients" +) + +var ( + pluginCreateName = Argument{ + Name: "Name", + Help: "Short name of the plugin: lowercase letters, digits and hyphens. The plugin runs as auth0 .", + } + pluginCreateLang = Flag{ + Name: "Language", + LongForm: "lang", + Help: "Language of the starter project: go or ts.", + IsRequired: true, + } + pluginCreateAuthor = Flag{ + Name: "Author", + LongForm: "author", + Help: "Team or person that owns the plugin. It becomes owner.team in plugin.json.", + IsRequired: true, + } + pluginCreateBugs = Flag{ + Name: "Bugs URL", + LongForm: "bugs", + Help: "HTTPS URL where people report problems with the plugin. It becomes owner.bugs in plugin.json.", + IsRequired: true, + } + pluginCreateID = Flag{ + Name: "ID", + LongForm: "id", + Help: "ID of the plugin, " + registry.DefaultRegistryName + "/. The default is " + registry.DefaultRegistryName + "/ followed by the name.", + } + pluginCreateVersion = Flag{ + Name: "Version", + LongForm: "version", + Help: "First version of the plugin, as MAJOR.MINOR.PATCH.", + } + pluginCreateDescription = Flag{ + Name: "Description", + LongForm: "description", + Help: "One line that says what the plugin does, up to 120 characters.", + } + pluginCreateCommand = Flag{ + Name: "Command", + LongForm: "command", + Help: "Name of the first command. When it is the name of the plugin it runs as `auth0 `, otherwise as `auth0 `. The default is the name of the plugin.", + } + pluginCreateCommandDescription = Flag{ + Name: "Command Description", + LongForm: "command-description", + Help: "What the first command does. The default is the description of the plugin.", + } + pluginCreateDir = Flag{ + Name: "Directory", + LongForm: "dir", + Help: "Where to write the project. It may not exist, or may be empty. The default is a directory named like the plugin.", + } + pluginCreateLicense = Flag{ + Name: "License", + LongForm: "license", + Help: "License of the project, written into its files. There is no LICENSE file.", + } + pluginCreateScopes = Flag{ + Name: "Scopes", + LongForm: "scopes", + Help: "Comma-separated Management API scopes that the first command may use.", + } + pluginCreateFrom = Flag{ + Name: "From", + LongForm: "from", + Help: "A directory with your own starter projects, laid out like the built-in ones: go and ts, each a project. A file named X.tmpl is written as X.", + } +) + // pluginDevMode reports whether dev mode is on. It reads one variable and // nothing else, so it is cheap to ask on every start of the CLI. func pluginDevMode() bool { @@ -56,13 +136,15 @@ func pluginDevCmd(cli *cli) *cobra.Command { Long: "Commands for plugin authors. They are available only in dev mode, which you turn on with " + pluginDevModeEnv + "=true.\n\n" + "A linked plugin runs as `auth0 ` from the directory of your build, through the same proxy, permissions and approval as an installed plugin. " + "Only the checks that make no sense for a build that is not published are skipped: the hashes of its files, the lockfile and the revocation list.", - Example: ` auth0 plugin dev validate + Example: ` auth0 plugin dev create checkmate --lang go --author "Platform team" --bugs https://github.com/acme/checkmate/issues + auth0 plugin dev validate auth0 plugin dev link ./checkmate`, // The commands for authors show in the help only in dev mode. Hidden: !pluginDevMode(), } cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(createPluginCmd(cli)) cmd.AddCommand(linkPluginCmd(cli)) cmd.AddCommand(unlinkPluginCmd(cli)) cmd.AddCommand(validatePluginCmd(cli)) @@ -70,6 +152,135 @@ func pluginDevCmd(cli *cli) *cobra.Command { return cmd } +func createPluginCmd(cli *cli) *cobra.Command { + var ( + opts scaffold.Options + lang string + from string + version = pluginCreateDefaultVersion + scopes []string + ) + + cmd := &cobra.Command{ + Use: "create [name]", + Args: cobra.MaximumNArgs(1), + Short: "Create a new plugin project", + Long: "Write a new plugin project into a directory: a plugin.json, and a small starter project in Go or TypeScript that calls the Management API through the CLI. " + + "The project builds, tests and releases with the tools of its language, and the README says how.\n\n" + + "The name is the short name of the plugin: lowercase letters, digits and hyphens, starting with a letter. The plugin runs as `auth0 `. " + + "The directory may not exist or may be empty, and an existing project is never overwritten. " + + "Missing values are asked for in a terminal and are an error otherwise.\n\n" + + "With `--from` the starter projects come from a directory of yours instead of the CLI. They may not hold links, more than 64 files, or more than 256 KB.", + Example: ` auth0 plugin dev create checkmate --lang go --author "Platform team" --bugs https://github.com/acme/checkmate/issues + auth0 plugin dev create checkmate --lang ts --author "Platform team" --bugs https://github.com/acme/checkmate/issues --scopes read:clients,read:users + auth0 plugin dev create checkmate --lang go --author "Platform team" --bugs https://github.com/acme/checkmate/issues --dir ./plugins/checkmate --json`, + // The gate comes before the check of the required flags. + PreRunE: func(*cobra.Command, []string) error { return requirePluginDevMode() }, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + opts.Name = args[0] + } + + if opts.Name == "" { + if !canPrompt(cmd) { + return createInvalid(fmt.Errorf(" is required: %s", strings.TrimSuffix(pluginCreateName.Help, "."))) + } + + if err := pluginCreateName.Ask(cmd, &opts.Name); err != nil { + return err + } + } + + // A name that is typed goes through the same checks as one that is given. + if err := scaffold.CheckName(opts.Name); err != nil { + return createInvalid(err) + } + + if err := checkPluginNameAvailable(cmd.Root(), opts.Name); err != nil { + return err + } + + if err := pluginCreateLang.Select(cmd, &lang, []string{string(scaffold.LangGo), string(scaffold.LangTypeScript)}, nil); err != nil { + return err + } + + if err := pluginCreateAuthor.Ask(cmd, &opts.Team, nil); err != nil { + return err + } + + if err := pluginCreateBugs.Ask(cmd, &opts.Bugs, nil); err != nil { + return err + } + + for _, missing := range []struct { + flag Flag + value string + }{{pluginCreateLang, lang}, {pluginCreateAuthor, opts.Team}, {pluginCreateBugs, opts.Bugs}} { + if missing.value == "" { + return createInvalid(fmt.Errorf("--%s is required: %s", missing.flag.LongForm, strings.TrimSuffix(missing.flag.Help, "."))) + } + } + + for i := range scopes { + scopes[i] = strings.TrimSpace(scopes[i]) + } + + opts.Lang = scaffold.Lang(lang) + opts.Version = version + opts.Scopes = scopes + opts.CLIVersion = buildinfo.Version + + // The values that were not given. + setDefault(&opts.ID, registry.DefaultRegistryName+"/"+opts.Name) + setDefault(&opts.Description, pluginCreateDefaultDescription) + setDefault(&opts.Command, opts.Name) + setDefault(&opts.CommandDescription, opts.Description) + setDefault(&opts.Dir, opts.Name) + setDefault(&opts.License, pluginCreateDefaultLicense) + + templates := scaffold.Embedded() + + if from != "" { + info, err := os.Stat(from) + if err == nil && !info.IsDir() { + err = fmt.Errorf("%s is not a directory", from) + } + + if err != nil { + return createInvalid(fmt.Errorf("cannot use --from: %w", err)) + } + + templates = os.DirFS(from) + } + + files, err := scaffold.Create(opts, templates) + if err != nil { + return createFailed(err) + } + + return sayCreated(cli, opts, files) + }, + } + + pluginCreateLang.RegisterString(cmd, &lang, "") + pluginCreateAuthor.RegisterString(cmd, &opts.Team, "") + pluginCreateBugs.RegisterString(cmd, &opts.Bugs, "") + pluginCreateID.RegisterString(cmd, &opts.ID, "") + pluginCreateVersion.RegisterString(cmd, &version, pluginCreateDefaultVersion) + pluginCreateDescription.RegisterString(cmd, &opts.Description, "") + pluginCreateCommand.RegisterString(cmd, &opts.Command, "") + pluginCreateCommandDescription.RegisterString(cmd, &opts.CommandDescription, "") + pluginCreateDir.RegisterString(cmd, &opts.Dir, "") + pluginCreateLicense.RegisterString(cmd, &opts.License, "") + pluginCreateScopes.RegisterStringSlice(cmd, &scopes, []string{pluginCreateDefaultScope}) + pluginCreateFrom.RegisterString(cmd, &from, "") + 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 linkPluginCmd(cli *cli) *cobra.Command { var bin string @@ -480,3 +691,119 @@ func verifiedLinked(build *devlink.Build) store.Verified { PermissionsHash: build.Manifest.PermissionsHash(), } } + +// setDefault sets a value that was not given. +func setDefault(value *string, fallback string) { + if *value == "" { + *value = fallback + } +} + +// createInvalid is the failure of an input or a target that cannot be used. The +// text may hold a path or a name from a directory given with --from, so it is +// made safe to print. +func createInvalid(err error) error { + return validationError{err: errors.New(plugins.SafeText(err.Error())), reason: "plugin_create_invalid"} +} + +// createFailed is the failure of scaffold.Create. Only a failure that the person +// can fix by changing what they gave has the reason of an invalid input, and any +// other, such as a full disk, is a plain error that names the path. The text may +// hold a path or a name from a directory given with --from, so it is made safe to +// print. +func createFailed(err error) error { + var input *scaffold.InputError + if errors.As(err, &input) { + return createInvalid(err) + } + + return errors.New(plugins.SafeText(err.Error())) +} + +// sayCreated tells the person what was written and what to do next, and prints +// the result for a machine. +func sayCreated(cli *cli, opts scaffold.Options, files []string) error { + dir, err := filepath.Abs(opts.Dir) + if err != nil { + return createInvalid(err) + } + + r := cli.renderer + + r.Infof("Created %s in %s (%s).", opts.ID, plugins.SafeText(dir), pluralize(len(files), "file", "files")) + + var warnings []string + + if len(opts.Scopes) == 0 { + warnings = append(warnings, "The first command declares no scopes, so the Management API call of the sample is denied. Add the scopes it needs under permissions.scopes in plugin.json.") + } + + for _, warning := range warnings { + r.Warnf("%s", warning) + } + + r.Infof("Next:") + + for _, step := range createNextSteps(opts) { + r.Detailf("%s", step) + } + + r.Infof("To publish it, push the project to a new GitHub repository: the first push to main releases version %s. Release again with: %s", opts.Version, createReleaseCommand(opts.Lang)) + + r.PluginCreated(display.PluginCreateResult{Dir: dir, Files: files, ID: opts.ID, Lang: string(opts.Lang), Warnings: warnings}) + + return nil +} + +// createNextSteps returns the commands to run after the project is created, in +// order. They are the ones in the README of the project. +func createNextSteps(opts scaffold.Options) []string { + build := "go mod tidy && go build -o " + opts.Name + " ." + if opts.Lang == scaffold.LangTypeScript { + build = "bun install && bun run build" + } + + run := "auth0 " + opts.Name + if opts.Command != opts.Name { + run += " " + opts.Command + } + + return []string{ + "cd " + shellWord(opts.Dir), + build, + pluginDevModeEnv + "=true auth0 plugin dev link", + "auth0 plugin approve " + opts.Name, + run, + } +} + +// createReleaseCommand returns the command of the project that releases a new +// version. +func createReleaseCommand(lang scaffold.Lang) string { + if lang == scaffold.LangTypeScript { + return "bun run release minor" + } + + return "make release BUMP=minor" +} + +// shellWord returns a path as one word of a shell command line. +func shellWord(path string) string { + return quoteWord(path, runtime.GOOS == "windows") +} + +// quoteWord quotes a path that has a character the shell treats as special. The +// Windows shells do not read single quotes, and a Windows path holds no double +// quote, so it is wrapped in double quotes as it is. +func quoteWord(path string, windows bool) string { + path = plugins.SafeText(path) + if !strings.ContainsAny(path, " \t'\"$`\\&;|<>()*?[]{}!#~") { + return path + } + + if windows { + return `"` + path + `"` + } + + return "'" + strings.ReplaceAll(path, "'", `'\''`) + "'" +} diff --git a/internal/cli/plugin_dev_test.go b/internal/cli/plugin_dev_test.go index 401dd6429..27cd75491 100644 --- a/internal/cli/plugin_dev_test.go +++ b/internal/cli/plugin_dev_test.go @@ -14,9 +14,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/auth0/auth0-cli/internal/buildinfo" "github.com/auth0/auth0-cli/internal/display" "github.com/auth0/auth0-cli/internal/plugins" "github.com/auth0/auth0-cli/internal/plugins/devlink" + "github.com/auth0/auth0-cli/internal/plugins/scaffold" ) // stubExecutable is the executable of a build that is linked or checked but never @@ -165,8 +167,10 @@ func TestPluginDevCmds_NeedDevMode(t *testing.T) { t.Setenv(pluginDevModeEnv, value) build := writeDevBuild(t, "1.0.0", fixtureCommands(), stubExecutable) + created := filepath.Join(t.TempDir(), "demo") commands := [][]string{ + {"plugin", "dev", "create", "demo", "--lang", "go", "--author", "Me", "--bugs", "https://example.com/issues", "--dir", created}, {"plugin", "dev", "link", build.Dir}, {"plugin", "dev", "unlink", "checkmate"}, {"plugin", "dev", "validate", build.Dir}, @@ -182,6 +186,7 @@ func TestPluginDevCmds_NeedDevMode(t *testing.T) { } assert.NoDirExists(t, e.Dir, "nothing is written") + assert.NoDirExists(t, created, "nothing is written") }) } } @@ -946,3 +951,554 @@ func TestVerifiedLinked(t *testing.T) { assert.Equal(t, filepath.Join(build.Dir, devlink.ManifestName), verified.ManifestPath) assert.Equal(t, build.Manifest.PermissionsHash(), verified.PermissionsHash, "the hash is that of the manifest just read") } + +// createArgs returns the arguments that create the plugin demo in a Go project +// in dir. A flag that is given again later in more wins. +func createArgs(dir string, more ...string) []string { + args := []string{"plugin", "dev", "create", "demo", "--lang", "go", "--author", "Platform team", "--bugs", "https://example.com/issues", "--dir", dir} + + return append(args, more...) +} + +// createdManifest reads and parses the plugin.json of a created project. +func createdManifest(t *testing.T, dir string) *plugins.Manifest { + t.Helper() + + data, err := os.ReadFile(filepath.Join(dir, devlink.ManifestName)) + require.NoError(t, err) + + manifest, err := plugins.ParseManifest(data) + require.NoError(t, err) + + return manifest +} + +func TestCreatePluginCmd(t *testing.T) { + tests := []struct { + lang string + build string + release string + }{ + {"go", "go mod tidy && go build -o demo .", "make release BUMP=minor"}, + {"ts", "bun install && bun run build", "bun run release minor"}, + } + + for _, test := range tests { + t.Run("writes a "+test.lang+" project, says what to do next, and keeps the next steps out of the JSON", func(t *testing.T) { + e := newDevEnv(t) + dir := filepath.Join(t.TempDir(), "demo") + + require.NoError(t, e.run(createArgs(dir, "--lang", test.lang)...)) + + assert.Empty(t, e.stdout.String()) + + out := e.stderr.String() + assert.Contains(t, out, "Created auth0/demo in "+dir) + assert.Contains(t, out, "cd "+dir) + assert.Contains(t, out, test.build) + assert.Contains(t, out, "AUTH0_CLI_PLUGIN_DEV_MODE=true auth0 plugin dev link") + assert.Contains(t, out, "auth0 plugin approve demo") + assert.Contains(t, out, "\n auth0 demo\n") + assert.Contains(t, out, "the first push to main releases version 0.1.0. Release again with: "+test.release) + assert.NotContains(t, out, "declares no scopes") + + manifest := createdManifest(t, dir) + assert.Equal(t, "auth0/demo", manifest.ID) + assert.Equal(t, "Platform team", manifest.Owner.Team) + assert.Equal(t, "https://example.com/issues", manifest.Owner.Bugs) + assert.Equal(t, []string{"read:clients"}, manifest.Commands[0].Permissions.Scopes) + assert.Nil(t, manifest.Engines, "a build with no version requires nothing") + + assert.NoDirExists(t, e.Dir, "the plugins directory is not touched") + }) + + t.Run("prints the result as JSON for "+test.lang, func(t *testing.T) { + e := newDevEnv(t) + dir := filepath.Join(t.TempDir(), "demo") + + require.NoError(t, e.run(createArgs(dir, "--lang", test.lang, "--json-compact")...)) + + object := e.jsonObject() + assert.Len(t, object, 4, "dir, files, id and lang") + assert.Equal(t, dir, object["dir"]) + assert.Equal(t, "auth0/demo", object["id"]) + assert.Equal(t, test.lang, object["lang"]) + + list, ok := object["files"].([]any) + require.True(t, ok) + + files := make([]string, 0, len(list)) + for _, file := range list { + files = append(files, file.(string)) + assert.FileExists(t, filepath.Join(dir, filepath.FromSlash(file.(string)))) + } + + assert.Contains(t, files, "plugin.json") + assert.Contains(t, files, "README.md") + assert.IsIncreasing(t, files) + + assert.NotContains(t, e.stdout.String(), "go mod tidy", "the next steps are for a person") + assert.Contains(t, e.stderr.String(), test.build) + }) + } + + t.Run("takes every value from the flags", func(t *testing.T) { + e := newDevEnv(t) + dir := filepath.Join(t.TempDir(), "elsewhere") + + require.NoError(t, e.run(createArgs(dir, + "--version", "1.2.3", + "--description", "Counts things", + "--command", "lister", + "--command-description", "Lists them", + "--license", "Apache-2.0", + "--scopes", "read:clients, read:users", + )...)) + + manifest := createdManifest(t, dir) + assert.Equal(t, "1.2.3", manifest.Version) + assert.Equal(t, "Counts things", manifest.Description) + assert.Equal(t, "lister", manifest.Commands[0].Name) + assert.Equal(t, "Lists them", manifest.Commands[0].Description) + assert.Equal(t, []string{"read:clients", "read:users"}, manifest.Commands[0].Permissions.Scopes) + + readme, err := os.ReadFile(filepath.Join(dir, "README.md")) + require.NoError(t, err) + assert.Contains(t, string(readme), "Apache-2.0") + + out := e.stderr.String() + assert.Contains(t, out, "auth0 demo lister", "a command that is not the root command runs under the name of the plugin") + assert.Contains(t, out, "version 1.2.3") + }) + + t.Run("the command and its description follow the name and the description", func(t *testing.T) { + e := newDevEnv(t) + dir := filepath.Join(t.TempDir(), "demo") + + require.NoError(t, e.run(createArgs(dir, "--description", "Counts things")...)) + + manifest := createdManifest(t, dir) + assert.Equal(t, "demo", manifest.Commands[0].Name) + assert.Equal(t, "Counts things", manifest.Commands[0].Description) + assert.NotNil(t, manifest.RootCommand()) + }) + + t.Run("the directory is named like the plugin by default", func(t *testing.T) { + e := newDevEnv(t) + parent := t.TempDir() + t.Chdir(parent) + + args := createArgs("") + require.NoError(t, e.run(args[:len(args)-2]...)) + + assert.FileExists(t, filepath.Join(parent, "demo", devlink.ManifestName)) + assert.Contains(t, e.stderr.String(), "cd demo\n") + }) + + t.Run("no scopes is allowed and the person is told", func(t *testing.T) { + e := newDevEnv(t) + dir := filepath.Join(t.TempDir(), "demo") + + require.NoError(t, e.run(createArgs(dir, "--scopes", "")...)) + + assert.Empty(t, createdManifest(t, dir).Commands[0].Permissions.Scopes) + assert.Contains(t, e.stderr.String(), "The first command declares no scopes, so the Management API call of the sample is denied") + }) + + t.Run("no scopes is a warning in the JSON result, and a project with scopes has none", func(t *testing.T) { + e := newDevEnv(t) + + require.NoError(t, e.run(createArgs(filepath.Join(t.TempDir(), "demo"), "--scopes", "", "--json-compact")...)) + + assert.Equal(t, []any{"The first command declares no scopes, so the Management API call of the sample is denied. Add the scopes it needs under permissions.scopes in plugin.json."}, e.jsonObject()["warnings"]) + + require.NoError(t, e.run(createArgs(filepath.Join(t.TempDir(), "demo"), "--json-compact")...)) + + assert.NotContains(t, e.jsonObject(), "warnings") + }) + + t.Run("requires the version of this CLI, which has the plugin commands only as a prerelease", func(t *testing.T) { + original := buildinfo.Version + t.Cleanup(func() { buildinfo.Version = original }) + + buildinfo.Version = "1.40.0-beta.1" + + e := newDevEnv(t) + dir := filepath.Join(t.TempDir(), "demo") + + require.NoError(t, e.run(createArgs(dir)...)) + + assert.Equal(t, ">=1.40.0", createdManifest(t, dir).Engines.Auth0CLI) + }) + + t.Run("fills an empty directory", func(t *testing.T) { + e := newDevEnv(t) + dir := t.TempDir() + + require.NoError(t, e.run(createArgs(dir)...)) + + assert.FileExists(t, filepath.Join(dir, devlink.ManifestName)) + }) + + t.Run("uses the starter projects of a directory given with --from", func(t *testing.T) { + e := newDevEnv(t) + from := t.TempDir() + dir := filepath.Join(t.TempDir(), "demo") + + require.NoError(t, os.MkdirAll(filepath.Join(from, "go"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(from, "go", "main.go.tmpl"), []byte("// __PLUGIN_NAME__\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(from, "go", "plain.txt"), []byte("as it is\n"), 0o644)) + + require.NoError(t, e.run(createArgs(dir, "--from", from, "--json-compact")...)) + + assert.Equal(t, []any{"main.go", "plain.txt", "plugin.json"}, e.jsonObject()["files"]) + + main, err := os.ReadFile(filepath.Join(dir, "main.go")) + require.NoError(t, err) + assert.Equal(t, "// demo\n", string(main)) + }) +} + +func TestCreatePluginCmd_Refusals(t *testing.T) { + // A tree for --from with a symlink in it. + symlinkTree := func(t *testing.T) string { + t.Helper() + + from := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(from, "go"), 0o755)) + + if err := os.Symlink(from, filepath.Join(from, "go", "link")); err != nil { + t.Skipf("cannot create a symlink here: %v", err) + } + + return from + } + + tests := []struct { + name string + + // Args returns the arguments after "plugin dev create", given the directory + // to create the project in. + args func(t *testing.T, dir string) []string + reason string + want []string + }{ + { + name: "a name that is a core command", + args: func(t *testing.T, dir string) []string { return append([]string{"apps"}, createArgs(dir)[4:]...) }, + reason: "plugin_name_reserved", + want: []string{`already has a command named "apps"`}, + }, + { + name: "a name that is not a name", + args: func(t *testing.T, dir string) []string { return append([]string{"My_Plugin"}, createArgs(dir)[4:]...) }, + reason: "plugin_create_invalid", + want: []string{"My_Plugin", "lowercase"}, + }, + { + name: "no name and no terminal to ask in", + args: func(t *testing.T, dir string) []string { + return append([]string{}, createArgs(dir)[4:]...) + }, + reason: "plugin_create_invalid", + want: []string{" is required"}, + }, + { + name: "no language", + args: func(t *testing.T, dir string) []string { return withoutFlag(createArgs(dir)[3:], "--lang") }, + reason: "plugin_create_invalid", + want: []string{"--lang is required"}, + }, + { + name: "another language", + args: func(t *testing.T, dir string) []string { return append(createArgs(dir)[3:], "--lang", "rust") }, + reason: "plugin_create_invalid", + want: []string{`"rust"`, "go and ts"}, + }, + { + name: "no author and no terminal to ask in", + args: func(t *testing.T, dir string) []string { return withoutFlag(createArgs(dir)[3:], "--author") }, + reason: "plugin_create_invalid", + want: []string{"--author is required"}, + }, + { + name: "no bugs URL and no terminal to ask in", + args: func(t *testing.T, dir string) []string { return withoutFlag(createArgs(dir)[3:], "--bugs") }, + reason: "plugin_create_invalid", + want: []string{"--bugs is required"}, + }, + { + name: "a bugs URL without TLS", + args: func(t *testing.T, dir string) []string { + return append(createArgs(dir)[3:], "--bugs", "http://example.com") + }, + reason: "plugin_create_invalid", + want: []string{"owner.bugs"}, + }, + { + name: "a version that is not semver", + args: func(t *testing.T, dir string) []string { return append(createArgs(dir)[3:], "--version", "one") }, + reason: "plugin_create_invalid", + want: []string{"version"}, + }, + { + name: "a description of 121 characters", + args: func(t *testing.T, dir string) []string { + return append(createArgs(dir)[3:], "--description", strings.Repeat("x", 121)) + }, + reason: "plugin_create_invalid", + want: []string{"description"}, + }, + { + name: "a scope that is not one", + args: func(t *testing.T, dir string) []string { return append(createArgs(dir)[3:], "--scopes", "everything") }, + reason: "plugin_create_invalid", + want: []string{"scopes"}, + }, + { + name: "a license that is code", + args: func(t *testing.T, dir string) []string { return append(createArgs(dir)[3:], "--license", "MIT;ls") }, + reason: "plugin_create_invalid", + want: []string{"license"}, + }, + { + name: "a command that is not a name", + args: func(t *testing.T, dir string) []string { return append(createArgs(dir)[3:], "--command", "List All") }, + reason: "plugin_create_invalid", + want: []string{"command"}, + }, + { + name: "an ID of another registry", + args: func(t *testing.T, dir string) []string { return append(createArgs(dir)[3:], "--id", "acme/demo") }, + reason: "plugin_create_invalid", + want: []string{"auth0/demo"}, + }, + { + name: "--from that does not exist", + args: func(t *testing.T, dir string) []string { + return append(createArgs(dir)[3:], "--from", filepath.Join(dir, "nope")) + }, + reason: "plugin_create_invalid", + want: []string{"cannot use --from"}, + }, + { + name: "--from that is a file", + args: func(t *testing.T, dir string) []string { + file := filepath.Join(t.TempDir(), "file") + require.NoError(t, os.WriteFile(file, []byte("x"), 0o644)) + + return append(createArgs(dir)[3:], "--from", file) + }, + reason: "plugin_create_invalid", + want: []string{"is not a directory"}, + }, + { + name: "--from with a symlink", + args: func(t *testing.T, dir string) []string { return append(createArgs(dir)[3:], "--from", symlinkTree(t)) }, + reason: "plugin_create_invalid", + want: []string{"link", "symlink is not followed"}, + }, + { + name: "--from with too many files", + args: func(t *testing.T, dir string) []string { + from := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(from, "go"), 0o755)) + + for i := range 65 { + require.NoError(t, os.WriteFile(filepath.Join(from, "go", "f"+strconv.Itoa(i)), []byte("x"), 0o644)) + } + + return append(createArgs(dir)[3:], "--from", from) + }, + reason: "plugin_create_invalid", + want: []string{"more than 64 files"}, + }, + { + name: "--from with a file that is too big", + args: func(t *testing.T, dir string) []string { + from := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(from, "go"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(from, "go", "big"), []byte(strings.Repeat("x", 64<<10+1)), 0o644)) + + return append(createArgs(dir)[3:], "--from", from) + }, + reason: "plugin_create_invalid", + want: []string{"big is larger than 65536 bytes"}, + }, + { + name: "--from with a token that is not in the set", + args: func(t *testing.T, dir string) []string { + from := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(from, "go"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(from, "go", "a.tmpl"), []byte("__AUTHOR__"), 0o644)) + + return append(createArgs(dir)[3:], "--from", from) + }, + reason: "plugin_create_invalid", + want: []string{"__AUTHOR__"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e := newDevEnv(t) + dir := filepath.Join(t.TempDir(), "demo") + + args := test.args(t, dir) + if args[0] != "plugin" { + args = append([]string{"plugin", "dev", "create"}, args...) + } + + err := e.run(args...) + + require.Error(t, err) + assertValidationError(t, err, test.reason) + + for _, want := range test.want { + assert.Contains(t, err.Error(), want) + } + + assert.Empty(t, e.stdout.String()) + assert.NoDirExists(t, dir, "nothing is written") + assert.NoDirExists(t, e.Dir) + }) + } + + t.Run("a directory that is not empty is left as it is", func(t *testing.T) { + e := newDevEnv(t) + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "mine.txt"), []byte("mine"), 0o644)) + + err := e.run(createArgs(dir)...) + + require.Error(t, err) + assertValidationError(t, err, "plugin_create_invalid") + assert.Contains(t, err.Error(), "never overwrite a non-empty directory") + assert.Contains(t, err.Error(), dir) + + list, readErr := os.ReadDir(dir) + require.NoError(t, readErr) + require.Len(t, list, 1) + assert.Equal(t, "mine.txt", list[0].Name()) + }) + + t.Run("a failure that is not the input is a plain error that names the path", func(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("the mode of a directory does not stop this user") + } + + e := newDevEnv(t) + parent := t.TempDir() + require.NoError(t, os.Chmod(parent, 0o500)) + t.Cleanup(func() { _ = os.Chmod(parent, 0o700) }) + + dir := filepath.Join(parent, "demo") + + err := e.run(createArgs(dir)...) + + require.Error(t, err) + assert.ErrorContains(t, err, "cannot create "+dir) + assert.NotEqual(t, "validation", errorClass(err)) + assert.NotEqual(t, "plugin_create_invalid", errorReason(err)) + }) + + t.Run("the path of a file that --from holds is safe to print", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows has no file names with control characters") + } + + e := newDevEnv(t) + from := symlinkTree(t) + require.NoError(t, os.Symlink(from, filepath.Join(from, "go", "evil\x1b[31m"))) + + err := e.run(createArgs(filepath.Join(t.TempDir(), "demo"), "--from", from)...) + + require.Error(t, err) + assert.NotContains(t, err.Error(), "\x1b") + }) +} + +// withoutFlag returns the arguments without a flag and its value. +func withoutFlag(args []string, flag string) []string { + var kept []string + + for i := 0; i < len(args); i++ { + if args[i] == flag { + i++ + + continue + } + + kept = append(kept, args[i]) + } + + return kept +} + +func TestCreateNextSteps(t *testing.T) { + opts := scaffold.Options{Name: "demo", Command: "demo", Dir: "demo", Lang: scaffold.LangGo} + + tests := []struct { + name string + change func(*scaffold.Options) + want []string + }{ + { + name: "go", + want: []string{"cd demo", "go mod tidy && go build -o demo .", "AUTH0_CLI_PLUGIN_DEV_MODE=true auth0 plugin dev link", "auth0 plugin approve demo", "auth0 demo"}, + }, + { + name: "typescript", + change: func(o *scaffold.Options) { o.Lang = scaffold.LangTypeScript }, + want: []string{"cd demo", "bun install && bun run build", "AUTH0_CLI_PLUGIN_DEV_MODE=true auth0 plugin dev link", "auth0 plugin approve demo", "auth0 demo"}, + }, + { + name: "another command", + change: func(o *scaffold.Options) { o.Command = "lister" }, + want: []string{"cd demo", "go mod tidy && go build -o demo .", "AUTH0_CLI_PLUGIN_DEV_MODE=true auth0 plugin dev link", "auth0 plugin approve demo", "auth0 demo lister"}, + }, + { + name: "a directory with a space", + change: func(o *scaffold.Options) { o.Dir = "my plugins/demo" }, + want: []string{"cd " + shellWord("my plugins/demo"), "go mod tidy && go build -o demo .", "AUTH0_CLI_PLUGIN_DEV_MODE=true auth0 plugin dev link", "auth0 plugin approve demo", "auth0 demo"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + o := opts + if test.change != nil { + test.change(&o) + } + + assert.Equal(t, test.want, createNextSteps(o)) + }) + } +} + +func TestQuoteWord(t *testing.T) { + tests := []struct{ path, posix, windows string }{ + {"demo", "demo", "demo"}, + {"./plugins/demo", "./plugins/demo", "./plugins/demo"}, + {`C:\work\demo`, `'C:\work\demo'`, `"C:\work\demo"`}, + {"my dir", "'my dir'", `"my dir"`}, + {"it's", `'it'\''s'`, `"it's"`}, + {"a$b", "'a$b'", `"a$b"`}, + {"a;b", "'a;b'", `"a;b"`}, + {"a\x1bb", "a\ufffdb", "a\ufffdb"}, + } + + for _, test := range tests { + t.Run(test.path, func(t *testing.T) { + assert.Equal(t, test.posix, quoteWord(test.path, false), "POSIX shells") + assert.Equal(t, test.windows, quoteWord(test.path, true), "Windows shells") + }) + } +} + +func TestCreateFailed(t *testing.T) { + err := createFailed(errors.New("cannot create /tmp/x\x1b[31m: no space left on device")) + + assert.EqualError(t, err, "cannot create /tmp/x\ufffd[31m: no space left on device") + assert.NotEqual(t, "validation", errorClass(err)) +} diff --git a/internal/display/plugin_dev.go b/internal/display/plugin_dev.go index 52129ae65..777b284cc 100644 --- a/internal/display/plugin_dev.go +++ b/internal/display/plugin_dev.go @@ -31,6 +31,18 @@ type PluginLinkResult struct { ApproveCommand string `json:"approveCommand,omitempty"` } +// PluginCreateResult is the result of creating a plugin project: the absolute +// directory it was written to, the paths of its files relative to that directory, +// the plugin ID and the language. Warnings holds what the person should know +// about the project, and is left out when there is nothing. +type PluginCreateResult struct { + Dir string `json:"dir"` + Files []string `json:"files"` + ID string `json:"id"` + Lang string `json:"lang"` + Warnings []string `json:"warnings,omitempty"` +} + type pluginProblemView struct { PluginProblem } @@ -78,3 +90,15 @@ func (r *Renderer) PluginLinked(result PluginLinkResult) { r.JSONCompactResult(result) } } + +// PluginCreated prints the result of creating a plugin project as JSON. In the +// other formats it prints nothing, because the command already told the person +// what it did and what to do next. +func (r *Renderer) PluginCreated(result PluginCreateResult) { + switch r.Format { + case OutputFormatJSON: + r.JSONResult(result) + case OutputFormatJSONCompact: + r.JSONCompactResult(result) + } +} diff --git a/internal/plugins/scaffold/embed.go b/internal/plugins/scaffold/embed.go new file mode 100644 index 000000000..912d6782a --- /dev/null +++ b/internal/plugins/scaffold/embed.go @@ -0,0 +1,24 @@ +package scaffold + +import ( + "embed" + "io/fs" +) + +// embeddedTrees holds the starter projects of both languages: go/ and ts/ are +// each a project root. Every file carries a .tmpl suffix, because go:embed +// refuses a directory that holds a go.mod and the Go files would otherwise be +// compiled with the CLI. The all: prefix keeps the files whose names start with a +// dot. +// +//go:embed all:templates +var embeddedTrees embed.FS + +// Embedded returns the starter projects that are part of the CLI: go/ and ts/ at +// the root of the file system, laid out the way Create reads a template tree. +func Embedded() fs.FS { + // The directory is embedded above, so it exists and Sub cannot fail. + sub, _ := fs.Sub(embeddedTrees, "templates") + + return sub +} diff --git a/internal/plugins/scaffold/embed_test.go b/internal/plugins/scaffold/embed_test.go new file mode 100644 index 000000000..0d6fd102a --- /dev/null +++ b/internal/plugins/scaffold/embed_test.go @@ -0,0 +1,226 @@ +package scaffold + +import ( + "bytes" + "io/fs" + "os" + "os/exec" + "path" + "path/filepath" + "regexp" + "strings" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// embeddedBudgetBytes is the most the embedded templates may add to the binary. +// The templates were 100,431 bytes when it was set, plus about a quarter. Raise +// it on purpose, with a reason, and never to make a test pass. +const embeddedBudgetBytes = 125_000 + +// A template tree never holds lock files, which belong to the project that is +// created and not to the template, or the directories of installed dependencies. +var ( + forbiddenFiles = regexp.MustCompile(`^(go\.sum|package-lock\.json|bun\.lockb?|yarn\.lock|pnpm-lock\.yaml)(\.tmpl)?$`) + forbiddenDirs = map[string]bool{"node_modules": true, "vendor": true} +) + +// treeProblems returns everything wrong with a template tree: more bytes than the +// budget, a file that looks binary, a file that is a lock file, a directory of +// dependencies, or a file without the template suffix. +func treeProblems(templates fs.FS, budget int) []string { + var ( + problems []string + total int + ) + + err := fs.WalkDir(templates, ".", func(name string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + + if entry.IsDir() { + if forbiddenDirs[entry.Name()] { + problems = append(problems, name+" is a directory of dependencies") + } + + return nil + } + + if forbiddenFiles.MatchString(entry.Name()) { + problems = append(problems, name+" is a lock file") + } + + if path.Ext(name) != templateSuffix { + problems = append(problems, name+" has no "+templateSuffix+" suffix") + } + + data, err := fs.ReadFile(templates, name) + if err != nil { + return err + } + + if bytes.IndexByte(data, 0) >= 0 { + problems = append(problems, name+" holds a NUL byte, so it looks binary") + } + + total += len(data) + + return nil + }) + if err != nil { + problems = append(problems, err.Error()) + } + + if total > budget { + problems = append(problems, "the templates are larger than the budget") + } + + return problems +} + +func TestEmbedded(t *testing.T) { + templates := Embedded() + + assert.Empty(t, treeProblems(templates, embeddedBudgetBytes)) + + t.Run("holds a project for each language", func(t *testing.T) { + for _, lang := range []Lang{LangGo, LangTypeScript} { + info, err := fs.Stat(templates, string(lang)) + require.NoError(t, err) + assert.True(t, info.IsDir(), lang) + } + + top, err := fs.ReadDir(templates, ".") + require.NoError(t, err) + assert.Len(t, top, 2, "only go and ts") + }) + + t.Run("keeps the files whose names start with a dot", func(t *testing.T) { + for _, name := range []string{"go/.gitignore.tmpl", "ts/.gitignore.tmpl", "go/.github/workflows/ci.yml.tmpl"} { + _, err := fs.Stat(templates, name) + assert.NoError(t, err, name) + } + }) + + t.Run("holds go.mod only as a template", func(t *testing.T) { + _, err := fs.Stat(templates, "go/go.mod.tmpl") + require.NoError(t, err) + + _, err = fs.Stat(templates, "go/go.mod") + assert.Error(t, err) + }) + + t.Run("is within the limits that --from has", func(t *testing.T) { + for _, lang := range []Lang{LangGo, LangTypeScript} { + tree, err := readTree(templates, lang) + require.NoError(t, err, lang) + assert.LessOrEqual(t, len(tree), maxFiles) + } + }) +} + +func TestTreeProblems(t *testing.T) { + file := func(data string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(data)} } + + tests := []struct { + name string + tree fstest.MapFS + want []string + }{ + {name: "a good tree", tree: fstest.MapFS{"go/main.go.tmpl": file("package main\n")}}, + {name: "over the budget", tree: fstest.MapFS{"go/a.tmpl": file(strings.Repeat("x", 600)), "ts/b.tmpl": file(strings.Repeat("y", 600))}, want: []string{"larger than the budget"}}, + {name: "exactly the budget", tree: fstest.MapFS{"go/a.tmpl": file(strings.Repeat("x", 1000))}}, + {name: "a binary file", tree: fstest.MapFS{"go/logo.png.tmpl": file("\x89PNG\x00\x01")}, want: []string{"logo.png.tmpl holds a NUL byte"}}, + {name: "go.sum", tree: fstest.MapFS{"go/go.sum.tmpl": file("x")}, want: []string{"go/go.sum.tmpl is a lock file"}}, + {name: "go.sum without the suffix", tree: fstest.MapFS{"go/go.sum": file("x")}, want: []string{"go/go.sum is a lock file", "go/go.sum has no .tmpl suffix"}}, + {name: "package-lock.json", tree: fstest.MapFS{"ts/package-lock.json.tmpl": file("{}")}, want: []string{"is a lock file"}}, + {name: "bun.lock", tree: fstest.MapFS{"ts/bun.lock.tmpl": file("x")}, want: []string{"is a lock file"}}, + {name: "bun.lockb", tree: fstest.MapFS{"ts/bun.lockb.tmpl": file("x")}, want: []string{"is a lock file"}}, + {name: "yarn.lock", tree: fstest.MapFS{"ts/yarn.lock.tmpl": file("x")}, want: []string{"is a lock file"}}, + {name: "pnpm-lock.yaml", tree: fstest.MapFS{"ts/pnpm-lock.yaml.tmpl": file("x")}, want: []string{"is a lock file"}}, + {name: "node_modules", tree: fstest.MapFS{"ts/node_modules/x/index.js.tmpl": file("x")}, want: []string{"ts/node_modules is a directory of dependencies"}}, + {name: "vendor", tree: fstest.MapFS{"go/vendor/x/x.go.tmpl": file("x")}, want: []string{"go/vendor is a directory of dependencies"}}, + {name: "a file named like a lock file in a longer name", tree: fstest.MapFS{"go/my-go.sum.tmpl": file("x")}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + problems := treeProblems(test.tree, 1000) + + require.Len(t, problems, len(test.want), problems) + + for i, want := range test.want { + assert.Contains(t, problems[i], want) + } + }) + } +} + +// networkFailure matches what the go and bun commands say when they cannot +// download. +var networkFailure = regexp.MustCompile(`dial tcp|no such host|i/o timeout|proxy.golang.org|connection refused|network is unreachable|TLS handshake|ConnectionRefused|FailedToOpenSocket|ConnectionClosed|ENOTFOUND|Unable to connect`) + +// TestEmbedded_BuildsAndPasses scaffolds each project and builds, vets and tests +// it with the tools of its language, the way a person would. It needs the tool +// and the package registry (or a warm cache), so a case is skipped when either is +// missing. +func TestEmbedded_BuildsAndPasses(t *testing.T) { + if testing.Short() { + t.Skip("builds projects with the go and bun commands") + } + + tests := []struct { + name string + lang Lang + plugin string + command string + }{ + {"go", LangGo, "demo", "demo"}, + {"go with a command that is not the root command", LangGo, "demo", "list"}, + {"go named like a standard library package", LangGo, "net", "net"}, + {"ts", LangTypeScript, "demo", "demo"}, + {"ts with a command that is not the root command", LangTypeScript, "demo", "list"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tool, steps := "go", [][]string{{"mod", "tidy"}, {"vet", "./..."}, {"test", "./..."}, {"build", "-o", test.plugin, "."}} + if test.lang == LangTypeScript { + tool, steps = "bun", [][]string{{"install"}, {"run", "typecheck"}, {"test"}} + } + + bin, err := exec.LookPath(tool) + if err != nil { + t.Skipf("the %s command is not on the PATH", tool) + } + + opts := goodOptions(filepath.Join(t.TempDir(), test.plugin)) + opts.Name, opts.ID, opts.Command, opts.Lang = test.plugin, "auth0/"+test.plugin, test.command, test.lang + + _, err = Create(opts, Embedded()) + require.NoError(t, err) + + for i, args := range steps { + cmd := exec.Command(bin, args...) + cmd.Dir = opts.Dir + // The project is its own module, whatever the environment of the test says. + cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod", "GOWORK=off", "GO111MODULE=on") + + output, err := cmd.CombinedOutput() + if err != nil && i == 0 && networkFailure.Match(output) { + t.Skipf("%s %s needs the network: %s", tool, args[0], output) + } + + require.NoError(t, err, "%s %s:\n%s", tool, strings.Join(args, " "), output) + } + + if test.lang == LangGo { + assert.FileExists(t, filepath.Join(opts.Dir, "go.sum"), "the project writes it, the template does not") + } + }) + } +} diff --git a/internal/plugins/scaffold/scaffold.go b/internal/plugins/scaffold/scaffold.go new file mode 100644 index 000000000..305144364 --- /dev/null +++ b/internal/plugins/scaffold/scaffold.go @@ -0,0 +1,561 @@ +// Package scaffold writes a new plugin project: a plugin.json generated from the +// typed manifest, and the files of a starter project for one language. +// +// A template tree holds one project root per language, go/ and ts/. A file named +// X.tmpl is written as X, and a file without the suffix is written as it is. Three +// tokens are replaced in the content of every file: __PLUGIN_NAME__, __COMMAND__ +// and __LICENSE__. Only values with a strict pattern are ever substituted, so the +// free text of the author, such as the description, goes into plugin.json through +// the JSON encoder and never into a source file. +package scaffold + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/auth0/auth0-cli/internal/plugins" + "github.com/auth0/auth0-cli/internal/plugins/registry" + "github.com/auth0/auth0-cli/internal/plugins/store" +) + +// Lang is a language that has a starter project. +type Lang string + +// The languages that have a starter project, named like the directories of a +// template tree. +const ( + LangGo Lang = "go" + LangTypeScript Lang = "ts" +) + +const ( + // The suffix of a template file, which is not part of the name it is written as. + templateSuffix = ".tmpl" + + // The file that is generated, so a template tree cannot hold it. + manifestName = "plugin.json" + + // The limits of one project in a template tree. They are checked before + // anything is written, and they hold for the tree inside the CLI as well, so a + // tree from --from cannot be bigger than the one that ships. + maxFiles = 64 + maxDirs = 64 + maxDepth = 8 + maxFileBytes = 64 << 10 + maxTotalBytes = 256 << 10 + + dirPerm os.FileMode = 0o755 + filePerm os.FileMode = 0o644 +) + +var ( + // The pattern of a name that goes into source files and file names. It is + // stricter than the pattern of a plugin ID, so it is also a command name. + namePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + + licensePattern = regexp.MustCompile(`^[A-Za-z0-9.+-]+$`) + + // A token that is left after the replacement is a typo in a template. + leftoverToken = regexp.MustCompile(`__[A-Z][A-Z_]*__`) + + // The function that moves the finished directory into place, which tests + // replace to make it fail. + renameDir = plugins.Rename +) + +// Options describes the plugin to create. Every field is required, apart from +// Scopes, which may be empty, and CLIVersion. +type Options struct { + // Name is the short name of the plugin. It is the name of the executable and + // of the module or package, and ID ends with it. + Name string + + // ID is the plugin ID, registry/name. + ID string + + Version string + Description string + + // Command is the name of the first command. It is the root command, which runs + // as `auth0 `, when it equals Name. + Command string + CommandDescription string + + // Dir is where the project is written. It may not exist, or may be empty. + Dir string + + // Team and Bugs become the owner of the plugin. + Team string + Bugs string + + // License is written into the files of the project. It is not in the manifest. + License string + + // Scopes are the Management API scopes of the first command. + Scopes []string + Lang Lang + + // CLIVersion is the version of this CLI. The plugin requires it or a later one. + // A version that is not MAJOR.MINOR.PATCH, such as dev, sets no requirement. + CLIVersion string +} + +// InputError is a failure caused by what the caller gave: a value that cannot be +// used, a template tree that breaks a rule, or a target directory that cannot be +// written to. Any other error is a failure of the machine, such as a full disk. +type InputError struct { + err error +} + +func (e *InputError) Error() string { return e.err.Error() } + +func (e *InputError) Unwrap() error { return e.err } + +// inputf returns an InputError with a formatted text. +func inputf(format string, args ...any) error { + return &InputError{err: fmt.Errorf(format, args...)} +} + +// file is a file to write, with a slash separated path inside the project. +type file struct { + path string + data []byte +} + +// Create writes the plugin project into opts.Dir and returns the paths of the +// files it wrote, sorted, relative to that directory and separated by slashes. +// The project is built in a temporary directory beside the target and renamed +// into place, so a failure leaves the target as it was. A failure that the caller +// can fix is an *InputError. +func Create(opts Options, templates fs.FS) ([]string, error) { + if err := opts.validate(); err != nil { + return nil, err + } + + manifest, err := opts.manifest() + if err != nil { + return nil, err + } + + tree, err := readTree(templates, opts.Lang) + if err != nil { + return nil, err + } + + files, err := opts.render(tree, manifest) + if err != nil { + return nil, err + } + + target, exists, err := checkTarget(opts.Dir) + if err != nil { + return nil, err + } + + if err := writeTree(target, exists, files); err != nil { + return nil, fmt.Errorf("cannot create %s: %w", target, err) + } + + paths := make([]string, 0, len(files)) + for _, f := range files { + paths = append(paths, f.path) + } + + return paths, nil +} + +// CheckName fails unless name can be the short name of a plugin: it goes into file +// names and source files, and it is the command that runs the plugin. +func CheckName(name string) error { + if !namePattern.MatchString(name) { + return fmt.Errorf("the name %q must start with a lowercase letter and hold only lowercase letters, digits and hyphens", name) + } + + return nil +} + +// validate checks the values that go into source files and file names, and the +// ones that tie the plugin together. The rest is checked by the manifest. +func (o Options) validate() error { + if err := o.check(); err != nil { + return &InputError{err: err} + } + + return nil +} + +func (o Options) check() error { + if o.Lang != LangGo && o.Lang != LangTypeScript { + return fmt.Errorf("the language %q is not one of %s and %s", o.Lang, LangGo, LangTypeScript) + } + + if err := CheckName(o.Name); err != nil { + return err + } + + if err := store.CheckID(o.ID); err != nil { + return err + } + + if want := registry.DefaultRegistryName + "/" + o.Name; o.ID != want { + return fmt.Errorf("the ID %q must be %s: the only registry this CLI knows is %q, and the ID ends with the name of the plugin", o.ID, want, registry.DefaultRegistryName) + } + + if !namePattern.MatchString(o.Command) { + return fmt.Errorf("the command %q must start with a lowercase letter and hold only lowercase letters, digits and hyphens", o.Command) + } + + if !licensePattern.MatchString(o.License) { + return fmt.Errorf("the license %q may hold only letters, digits, dots, plus signs and hyphens", o.License) + } + + switch { + case o.Dir == "": + return errors.New("the directory is required") + case o.Team == "": + return errors.New("the team is required") + case o.Bugs == "": + return errors.New("the bugs URL is required") + } + + return nil +} + +// manifest returns the content of plugin.json. It is read back with the strict +// parser of the registry before anything is written, so a plugin that this +// command creates is one that it can publish. +func (o Options) manifest() ([]byte, error) { + // The schema names itself, so that the manifest points at the schema it + // is checked against. + var schema struct { + ID string `json:"$id"` + } + + if err := json.Unmarshal(plugins.ManifestSchema(), &schema); err != nil { + return nil, fmt.Errorf("cannot read the manifest schema: %w", err) + } + + manifest := plugins.Manifest{ + Schema: schema.ID, + ID: o.ID, + Version: o.Version, + APIVersion: 1, + Description: o.Description, + Owner: plugins.Owner{Team: o.Team, Bugs: o.Bugs}, + Engines: engines(o.CLIVersion), + Platforms: plugins.AllPlatforms(), + Commands: []plugins.Command{{ + Name: o.Command, + Description: o.CommandDescription, + Timeout: "5m", + Output: &plugins.Output{JSON: true, AgentMode: true}, + Permissions: &plugins.Permissions{ + // Empty lists, not null, which the schema does not accept. + Scopes: append([]string{}, o.Scopes...), + Hosts: []plugins.HostPermission{}, + Files: []plugins.FilePermission{}, + Env: []plugins.EnvPermission{}, + }, + }}, + } + + var out bytes.Buffer + + encoder := json.NewEncoder(&out) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + + if err := encoder.Encode(manifest); err != nil { + return nil, fmt.Errorf("cannot write %s: %w", manifestName, err) + } + + if _, err := plugins.ParseManifest(out.Bytes()); err != nil { + return nil, inputf("%s would not be valid: %s", manifestName, strings.ReplaceAll(err.Error(), "\n", "; ")) + } + + return out.Bytes(), nil +} + +// engines returns the requirement on the CLI that runs the plugin, which is the +// version of this CLI or a later one. It is nil for a build that has no version. +func engines(cliVersion string) *plugins.Engines { + version, ok := plugins.ParseVersion(cliVersion) + if !ok { + return nil + } + + return &plugins.Engines{Auth0CLI: ">=" + version.String()} +} + +// readTree reads every file of the project of a language, and fails on anything +// that is not a regular file or that goes over a limit. It follows no symlink. +func readTree(templates fs.FS, lang Lang) ([]file, error) { + root, err := projectRoot(templates, lang) + if err != nil { + return nil, err + } + + var ( + files []file + dirs int + total int + ) + + err = fs.WalkDir(root, ".", func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + + if entry.IsDir() { + if path == "." { + return nil + } + + if dirs++; dirs > maxDirs { + return inputf("the %s templates have more than %d directories", lang, maxDirs) + } + + if strings.Count(path, "/") >= maxDepth { + return inputf("the %s templates have a directory deeper than %d levels", lang, maxDepth) + } + + return nil + } + + if !entry.Type().IsRegular() { + return inputf("%s in the %s templates is not a regular file (a symlink is not followed)", path, lang) + } + + if len(files) == maxFiles { + return inputf("the %s templates have more than %d files", lang, maxFiles) + } + + data, err := readFile(root, path) + if err != nil { + return err + } + + if total += len(data); total > maxTotalBytes { + return inputf("the %s templates are larger than %d bytes", lang, maxTotalBytes) + } + + files = append(files, file{path: path, data: data}) + + return nil + }) + if err != nil { + return nil, err + } + + return files, nil +} + +// projectRoot returns the project of a language, which must be a directory of the +// tree and not a symlink to one. +func projectRoot(templates fs.FS, lang Lang) (fs.FS, error) { + entries, err := fs.ReadDir(templates, ".") + if err != nil { + return nil, fmt.Errorf("cannot read the templates: %w", err) + } + + for _, entry := range entries { + if entry.Name() != string(lang) { + continue + } + + if !entry.IsDir() { + return nil, inputf("%s in the templates is not a directory (a symlink is not followed)", lang) + } + + return fs.Sub(templates, string(lang)) + } + + return nil, inputf("the templates have no %s directory", lang) +} + +// readFile reads a file of the tree, up to the limit for one file. +func readFile(root fs.FS, path string) ([]byte, error) { + handle, err := root.Open(path) + if err != nil { + return nil, err + } + + defer handle.Close() + + data, err := plugins.ReadCapped(handle, maxFileBytes) + if errors.Is(err, plugins.ErrTooLarge) { + return nil, inputf("%s is larger than %d bytes", path, maxFileBytes) + } + + if err != nil { + return nil, fmt.Errorf("cannot read %s: %w", path, err) + } + + return data, nil +} + +// render replaces the tokens in the files, drops the template suffix from their +// names, adds plugin.json and sorts the result by path. +func (o Options) render(tree []file, manifest []byte) ([]file, error) { + replacer := strings.NewReplacer( + "__PLUGIN_NAME__", o.Name, + "__COMMAND__", o.Command, + "__LICENSE__", o.License, + ) + + files := make([]file, 0, len(tree)+1) + files = append(files, file{path: manifestName, data: manifest}) + + // The files by lower-case path, since a file system may fold the case of names. + type origin struct{ from, path string } + + seen := map[string]origin{strings.ToLower(manifestName): {"the generated " + manifestName, manifestName}} + + for _, f := range tree { + path := strings.TrimSuffix(f.path, templateSuffix) + if path == "" || strings.HasSuffix(path, "/") { + return nil, inputf("%s has no name once %s is dropped", f.path, templateSuffix) + } + + if !filepath.IsLocal(filepath.FromSlash(path)) { + return nil, inputf("%s is not a path inside the project", f.path) + } + + if first, duplicate := seen[strings.ToLower(path)]; duplicate { + if first.path != path { + return nil, inputf("%s would be written to %s, and %s to %s, which is the same name when the case is ignored", f.path, path, first.from, first.path) + } + + return nil, inputf("%s would be written to %s, which %s already uses", f.path, path, first.from) + } + + seen[strings.ToLower(path)] = origin{f.path, path} + + // A name carries no token: nothing is replaced in it. + if token := leftoverToken.FindString(path); token != "" { + return nil, inputf("%s has the token %s in its name, and file names are not replaced", f.path, token) + } + + text := replacer.Replace(string(f.data)) + if token := leftoverToken.FindString(text); token != "" { + return nil, inputf("%s has the token %s, which is not one of __PLUGIN_NAME__, __COMMAND__ and __LICENSE__", f.path, token) + } + + files = append(files, file{path: path, data: []byte(text)}) + } + + slices.SortFunc(files, func(a, b file) int { return strings.Compare(a.path, b.path) }) + + return files, nil +} + +// checkTarget returns the absolute path of the directory to write, and whether it +// exists. It fails unless the directory is missing, with a parent that exists, or +// is an empty directory. +func checkTarget(dir string) (string, bool, error) { + target, err := filepath.Abs(dir) + if err != nil { + return "", false, fmt.Errorf("cannot find %s: %w", dir, err) + } + + info, err := os.Lstat(target) + if errors.Is(err, fs.ErrNotExist) { + parent, statErr := os.Stat(filepath.Dir(target)) + if statErr != nil || !parent.IsDir() { + return "", false, inputf("cannot create %s: its parent directory does not exist", target) + } + + return target, false, nil + } + + if err != nil { + return "", false, fmt.Errorf("cannot check %s: %w", target, err) + } + + switch { + case info.Mode()&fs.ModeSymlink != 0: + return "", false, inputf("%s is a symlink, which is not followed. Give a directory that does not exist, or an empty one", target) + case !info.IsDir(): + return "", false, inputf("%s exists and is not a directory. Give a directory that does not exist, or an empty one", target) + } + + entries, err := os.ReadDir(target) + if err != nil { + return "", false, fmt.Errorf("cannot read %s: %w", target, err) + } + + if len(entries) > 0 { + return "", false, inputf("%s is not empty: never overwrite a non-empty directory. Give a new directory with --dir", target) + } + + // An empty directory is replaced by the finished one, which would leave a + // shell that is in it in a directory that no longer exists. + if here, err := os.Stat("."); err == nil && os.SameFile(info, here) { + return "", false, inputf("%s is the current directory, which cannot be replaced. Run the command from the parent directory, or give a directory that does not exist", target) + } + + return target, true, nil +} + +// writeTree writes the files into a temporary directory beside the target and +// renames it to the target. The temporary directory is removed when anything +// fails. An empty target is removed first, because Windows cannot rename a +// directory over another. +func writeTree(target string, exists bool, files []file) error { + temp, err := os.MkdirTemp(filepath.Dir(target), "."+filepath.Base(target)+".tmp-") + if err != nil { + return err + } + + done := false + + defer func() { + if !done { + _ = os.RemoveAll(temp) + } + }() + + for _, f := range files { + path := filepath.Join(temp, filepath.FromSlash(f.path)) + + if err := os.MkdirAll(filepath.Dir(path), dirPerm); err != nil { + return err + } + + if err := os.WriteFile(path, f.data, filePerm); err != nil { + return err + } + } + + // MkdirTemp makes a private directory, and the project is not. + if err := os.Chmod(temp, dirPerm); err != nil { + return err + } + + if exists { + if err := os.Remove(target); err != nil { + return err + } + } + + if err := renameDir(temp, target); err != nil { + if exists { + // Put the empty directory back, so the target is as it was. + _ = os.Mkdir(target, dirPerm) + } + + return err + } + + done = true + + return nil +} diff --git a/internal/plugins/scaffold/scaffold_test.go b/internal/plugins/scaffold/scaffold_test.go new file mode 100644 index 000000000..ba176ce48 --- /dev/null +++ b/internal/plugins/scaffold/scaffold_test.go @@ -0,0 +1,890 @@ +package scaffold + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/plugins" +) + +// goodOptions returns options that create a plugin in dir. +func goodOptions(dir string) Options { + return Options{ + Name: "demo", + ID: "auth0/demo", + Version: "0.1.0", + Description: "A plugin for the Auth0 CLI", + Command: "demo", + CommandDescription: "Lists the clients", + Dir: dir, + Team: "Platform team", + Bugs: "https://example.com/issues", + License: "UNLICENSED", + Scopes: []string{"read:clients"}, + Lang: LangGo, + CLIVersion: "1.40.0", + } +} + +// miniTree is a small template tree with every kind of file the layout allows. +func miniTree() fstest.MapFS { + return fstest.MapFS{ + "go/main.go.tmpl": {Data: []byte("package main // __PLUGIN_NAME__ __COMMAND__ __LICENSE__\n")}, + "go/.gitignore.tmpl": {Data: []byte("/__PLUGIN_NAME__\n")}, + "go/plain.txt": {Data: []byte("kept ${{ secrets.TOKEN }} and __lower__ as they are\n")}, + "go/sub/dir/file.tmpl": {Data: []byte("nested\n")}, + "ts/package.json.tmpl": {Data: []byte(`{"name": "__PLUGIN_NAME__"}` + "\n")}, + } +} + +// readProject returns the content of every file under dir by its slash separated +// path. +func readProject(t *testing.T, dir string) map[string]string { + t.Helper() + + files := map[string]string{} + + err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + + relative, err := filepath.Rel(dir, path) + if err != nil { + return err + } + + files[filepath.ToSlash(relative)] = string(data) + + return nil + }) + require.NoError(t, err) + + return files +} + +// readManifest parses the plugin.json of a project with the strict parser. +func readManifest(t *testing.T, dir string) *plugins.Manifest { + t.Helper() + + data, err := os.ReadFile(filepath.Join(dir, "plugin.json")) + require.NoError(t, err) + + manifest, err := plugins.ParseManifest(data) + require.NoError(t, err) + + return manifest +} + +// entries returns the names in a directory. +func entries(t *testing.T, dir string) []string { + t.Helper() + + list, err := os.ReadDir(dir) + require.NoError(t, err) + + names := []string{} + for _, entry := range list { + names = append(names, entry.Name()) + } + + return names +} + +// requireInputError fails unless err is an InputError, the kind that the caller +// can fix. +func requireInputError(t *testing.T, err error) { + t.Helper() + + require.Error(t, err) + + var input *InputError + assert.ErrorAs(t, err, &input, "an error of the input") +} + +// permissions returns the modes that a new directory and a new file get here: +// 0o755 and 0o644 minus the umask, which is read from a probe of each. +func permissions(t *testing.T) (dir, file os.FileMode) { + t.Helper() + + root := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(root, "dir"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "file"), nil, 0o644)) + + dirInfo, err := os.Stat(filepath.Join(root, "dir")) + require.NoError(t, err) + + fileInfo, err := os.Stat(filepath.Join(root, "file")) + require.NoError(t, err) + + return dirInfo.Mode().Perm(), fileInfo.Mode().Perm() +} + +func TestCreate_Embedded(t *testing.T) { + for _, lang := range []Lang{LangGo, LangTypeScript} { + t.Run(string(lang), func(t *testing.T) { + opts := goodOptions(filepath.Join(t.TempDir(), "demo")) + opts.Lang = lang + + files, err := Create(opts, Embedded()) + require.NoError(t, err) + + assert.IsIncreasing(t, files, "the paths are sorted and unique") + assert.Contains(t, files, "plugin.json") + assert.Contains(t, files, "README.md") + + written := readProject(t, opts.Dir) + assert.Len(t, written, len(files)) + + for path, content := range written { + assert.Contains(t, files, path) + assert.NotContains(t, path, templateSuffix, "the suffix is dropped") + assert.NotRegexp(t, leftoverToken, content, path) + assert.NotContains(t, content, "__PLUGIN_NAME__", path) + } + + assert.Contains(t, written["README.md"], "auth0 demo") + + if runtime.GOOS != "windows" { + wantDir, wantFile := permissions(t) + + assert.NoError(t, filepath.WalkDir(opts.Dir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + + info, err := entry.Info() + require.NoError(t, err) + + // The mode is 0o755 or 0o644 minus what the umask takes away. The + // project directory is the exception: it is made private by the + // temporary directory and set to 0o755 by a chmod, which the umask + // does not touch. + want := wantFile + + switch { + case path == opts.Dir: + want = 0o755 + case entry.IsDir(): + want = wantDir + } + + assert.Equal(t, want, info.Mode().Perm(), path) + + return nil + })) + } + }) + } +} + +func TestCreate_Manifest(t *testing.T) { + tests := []struct { + name string + change func(*Options) + check func(*testing.T, *plugins.Manifest) + }{ + { + name: "defaults", + check: func(t *testing.T, m *plugins.Manifest) { + assert.Equal(t, "auth0/demo", m.ID) + assert.Equal(t, "0.1.0", m.Version) + assert.Equal(t, 1, m.APIVersion) + assert.Equal(t, plugins.Owner{Team: "Platform team", Bugs: "https://example.com/issues"}, m.Owner) + assert.Equal(t, plugins.AllPlatforms(), m.Platforms) + assert.Contains(t, m.Schema, "plugin.v1.schema.json") + assert.Equal(t, ">=1.40.0", m.Engines.Auth0CLI) + + require.Len(t, m.Commands, 1) + command := m.Commands[0] + assert.Equal(t, "demo", command.Name) + assert.Equal(t, "Lists the clients", command.Description) + assert.False(t, command.Interactive) + assert.Equal(t, "5m", command.Timeout) + assert.Equal(t, &plugins.Output{JSON: true, AgentMode: true}, command.Output) + assert.Equal(t, []string{"read:clients"}, command.Permissions.Scopes) + assert.NotNil(t, m.RootCommand(), "the command is named like the plugin") + }, + }, + { + name: "no scopes", + change: func(o *Options) { o.Scopes = nil }, + check: func(t *testing.T, m *plugins.Manifest) { + assert.Empty(t, m.Commands[0].Permissions.Scopes) + }, + }, + { + name: "empty scopes", + change: func(o *Options) { o.Scopes = []string{} }, + check: func(t *testing.T, m *plugins.Manifest) { + assert.Empty(t, m.Commands[0].Permissions.Scopes) + }, + }, + { + name: "several scopes", + change: func(o *Options) { o.Scopes = []string{"read:clients", "read:users"} }, + check: func(t *testing.T, m *plugins.Manifest) { + assert.Equal(t, []string{"read:clients", "read:users"}, m.Commands[0].Permissions.Scopes) + }, + }, + { + name: "another command than the plugin", + change: func(o *Options) { o.Command = "lister" }, + check: func(t *testing.T, m *plugins.Manifest) { + assert.Equal(t, "lister", m.Commands[0].Name) + assert.Nil(t, m.RootCommand()) + }, + }, + { + name: "a prerelease CLI requires its core version", + change: func(o *Options) { o.CLIVersion = "v1.41.0-beta.2" }, + check: func(t *testing.T, m *plugins.Manifest) { assert.Equal(t, ">=1.41.0", m.Engines.Auth0CLI) }, + }, + { + name: "a build with no version requires nothing", + change: func(o *Options) { o.CLIVersion = "dev" }, + check: func(t *testing.T, m *plugins.Manifest) { assert.Nil(t, m.Engines) }, + }, + { + name: "an empty version requires nothing", + change: func(o *Options) { o.CLIVersion = "" }, + check: func(t *testing.T, m *plugins.Manifest) { assert.Nil(t, m.Engines) }, + }, + { + name: "free text with quotes, markup and a token", + change: func(o *Options) { + o.Description = `He said "hi" \ & __PLUGIN_NAME__ $(rm -rf /)` + o.CommandDescription = "Two\nlines and a tab\t" + o.Team = "Team \"A\"\nand B" + }, + check: func(t *testing.T, m *plugins.Manifest) { + assert.Equal(t, `He said "hi" \ & __PLUGIN_NAME__ $(rm -rf /)`, m.Description) + assert.Equal(t, "Two\nlines and a tab\t", m.Commands[0].Description) + assert.Equal(t, "Team \"A\"\nand B", m.Owner.Team) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + opts := goodOptions(filepath.Join(t.TempDir(), "demo")) + if test.change != nil { + test.change(&opts) + } + + _, err := Create(opts, miniTree()) + require.NoError(t, err) + + test.check(t, readManifest(t, opts.Dir)) + }) + } + + t.Run("the file is indented by two spaces and ends with a newline", func(t *testing.T) { + opts := goodOptions(filepath.Join(t.TempDir(), "demo")) + opts.Description = "Less < more & more" + + _, err := Create(opts, miniTree()) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(opts.Dir, "plugin.json")) + require.NoError(t, err) + + assert.Contains(t, string(data), "\n \"id\": \"auth0/demo\",\n") + assert.Contains(t, string(data), "Less < more & more", "markup is not escaped") + assert.True(t, strings.HasSuffix(string(data), "}\n")) + }) +} + +func TestCreate_FreeTextStaysOutOfSourceFiles(t *testing.T) { + for _, lang := range []Lang{LangGo, LangTypeScript} { + t.Run(string(lang), func(t *testing.T) { + opts := goodOptions(filepath.Join(t.TempDir(), "demo")) + opts.Lang = lang + opts.Description = `HOSTILE-DESCRIPTION "; rm -rf / #` + opts.CommandDescription = "HOSTILE-COMMAND\nnewline `backtick` ${x}" + opts.Team = "HOSTILE-TEAM\"; drop" + opts.Bugs = "https://example.com/HOSTILE-BUGS?a=\"b\"" + + _, err := Create(opts, Embedded()) + require.NoError(t, err) + + for path, content := range readProject(t, opts.Dir) { + if path == "plugin.json" { + continue + } + + assert.NotContains(t, content, "HOSTILE", path) + } + + manifest := readManifest(t, opts.Dir) + assert.Equal(t, opts.Description, manifest.Description) + assert.Equal(t, opts.Team, manifest.Owner.Team) + }) + } +} + +func TestCreate_InvalidOptions(t *testing.T) { + tests := []struct { + name string + change func(*Options) + want string + }{ + {"no language", func(o *Options) { o.Lang = "" }, "language"}, + {"unknown language", func(o *Options) { o.Lang = "rust" }, `"rust" is not one of go and ts`}, + {"uppercase name", func(o *Options) { o.Name = "Demo" }, "name"}, + {"name that starts with a digit", func(o *Options) { o.Name = "1demo" }, "name"}, + {"name with an underscore", func(o *Options) { o.Name = "my_plugin" }, "name"}, + {"name that is a path", func(o *Options) { o.Name = "../demo" }, "name"}, + {"empty name", func(o *Options) { o.Name = "" }, "name"}, + {"ID that is not an ID", func(o *Options) { o.ID = "demo" }, "registry/name"}, + {"ID of another registry", func(o *Options) { o.ID = "acme/demo" }, "auth0/demo"}, + {"ID of another plugin", func(o *Options) { o.ID = "auth0/other" }, "auth0/demo"}, + {"ID with a Windows device name", func(o *Options) { o.Name, o.ID, o.Command = "con", "auth0/con", "con" }, "directory name"}, + {"uppercase command", func(o *Options) { o.Command = "Lister" }, "command"}, + {"command with a space", func(o *Options) { o.Command = "my command" }, "command"}, + {"license with a space", func(o *Options) { o.License = "MIT license" }, "license"}, + {"license that is code", func(o *Options) { o.License = "MIT$(id)" }, "license"}, + {"empty license", func(o *Options) { o.License = "" }, "license"}, + {"no directory", func(o *Options) { o.Dir = "" }, "directory is required"}, + {"no team", func(o *Options) { o.Team = "" }, "team is required"}, + {"no bugs URL", func(o *Options) { o.Bugs = "" }, "bugs URL is required"}, + {"bugs URL without TLS", func(o *Options) { o.Bugs = "http://example.com/issues" }, "owner.bugs"}, + {"version with a prefix", func(o *Options) { o.Version = "v1.0.0" }, "version"}, + {"description of two lines", func(o *Options) { o.Description = "one\ntwo" }, "description"}, + {"description that is too long", func(o *Options) { o.Description = strings.Repeat("x", 121) }, "description"}, + {"empty description", func(o *Options) { o.Description = "" }, "description"}, + {"empty command description", func(o *Options) { o.CommandDescription = "" }, "description"}, + {"scope that is not a scope", func(o *Options) { o.Scopes = []string{"read clients"} }, "scopes"}, + {"scope twice", func(o *Options) { o.Scopes = []string{"read:clients", "read:clients"} }, "scopes"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + parent := t.TempDir() + opts := goodOptions(filepath.Join(parent, "demo")) + test.change(&opts) + + if opts.Dir != "" { + opts.Dir = filepath.Join(parent, "demo") + } + + files, err := Create(opts, miniTree()) + + requireInputError(t, err) + assert.Contains(t, err.Error(), test.want) + assert.Nil(t, files) + assert.Empty(t, entries(t, parent), "nothing is written") + }) + } +} + +func TestCreate_Tokens(t *testing.T) { + t.Run("replaces the three tokens in the content and nothing else", func(t *testing.T) { + opts := goodOptions(filepath.Join(t.TempDir(), "demo")) + opts.Command = "lister" + opts.License = "Apache-2.0" + + files, err := Create(opts, miniTree()) + require.NoError(t, err) + + assert.Equal(t, []string{".gitignore", "main.go", "plain.txt", "plugin.json", "sub/dir/file"}, files) + + written := readProject(t, opts.Dir) + assert.Equal(t, "package main // demo lister Apache-2.0\n", written["main.go"]) + assert.Equal(t, "/demo\n", written[".gitignore"]) + assert.Equal(t, "nested\n", written["sub/dir/file"]) + assert.Equal(t, "kept ${{ secrets.TOKEN }} and __lower__ as they are\n", written["plain.txt"], "a file without the suffix is written as it is") + }) + + t.Run("writes the project of the language asked for", func(t *testing.T) { + opts := goodOptions(filepath.Join(t.TempDir(), "demo")) + opts.Lang = LangTypeScript + + files, err := Create(opts, miniTree()) + require.NoError(t, err) + + assert.Equal(t, []string{"package.json", "plugin.json"}, files) + assert.JSONEq(t, `{"name": "demo"}`, readProject(t, opts.Dir)["package.json"]) + }) + + tests := []struct { + name string + tree fstest.MapFS + want string + }{ + { + name: "a token that is not in the set", + tree: fstest.MapFS{"go/a.go.tmpl": {Data: []byte("x\n// __DESCRIPTION__\n")}}, + want: "a.go.tmpl has the token __DESCRIPTION__", + }, + { + name: "a typo in a token", + tree: fstest.MapFS{"go/a.go.tmpl": {Data: []byte("// __PLUGIN_NAM__\n")}}, + want: "__PLUGIN_NAM__", + }, + { + name: "a token in a file name", + tree: fstest.MapFS{"go/__PLUGIN_NAME__.go.tmpl": {Data: []byte("x\n")}}, + want: "token __PLUGIN_NAME__ in its name", + }, + { + name: "a token in a file without the suffix", + tree: fstest.MapFS{"go/a.txt": {Data: []byte("__TEAM__\n")}}, + want: "a.txt has the token __TEAM__", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + parent := t.TempDir() + + _, err := Create(goodOptions(filepath.Join(parent, "demo")), test.tree) + + requireInputError(t, err) + assert.Contains(t, err.Error(), test.want) + assert.Empty(t, entries(t, parent), "nothing is written") + }) + } +} + +func TestCreate_TemplateNames(t *testing.T) { + tests := []struct { + name string + tree fstest.MapFS + want string + }{ + { + name: "plugin.json", + tree: fstest.MapFS{"go/plugin.json": {Data: []byte("{}")}}, + want: "plugin.json, which the generated plugin.json already uses", + }, + { + name: "plugin.json with the suffix", + tree: fstest.MapFS{"go/plugin.json.tmpl": {Data: []byte("{}")}}, + want: "plugin.json, which the generated plugin.json already uses", + }, + { + name: "a name with and without the suffix", + tree: fstest.MapFS{"go/a.txt": {Data: []byte("x")}, "go/a.txt.tmpl": {Data: []byte("y")}}, + want: "already uses", + }, + { + name: "names that differ only in case", + tree: fstest.MapFS{"go/README.md": {Data: []byte("x")}, "go/readme.md.tmpl": {Data: []byte("y")}}, + want: "readme.md.tmpl would be written to readme.md, and README.md to README.md, which is the same name when the case is ignored", + }, + { + name: "a name that differs from plugin.json only in case", + tree: fstest.MapFS{"go/Plugin.JSON.tmpl": {Data: []byte("{}")}}, + want: "Plugin.JSON.tmpl would be written to Plugin.JSON, and the generated plugin.json to plugin.json, which is the same name when the case is ignored", + }, + { + name: "only the suffix", + tree: fstest.MapFS{"go/.tmpl": {Data: []byte("x")}}, + want: "has no name", + }, + { + name: "no project for the language", + tree: fstest.MapFS{"ts/a.tmpl": {Data: []byte("x")}}, + want: "no go directory", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + parent := t.TempDir() + + _, err := Create(goodOptions(filepath.Join(parent, "demo")), test.tree) + + requireInputError(t, err) + assert.Contains(t, err.Error(), test.want) + assert.Empty(t, entries(t, parent)) + }) + } +} + +func TestRender_RefusesPathsOutsideTheProject(t *testing.T) { + for _, path := range []string{"../evil", "a/../../evil", "/etc/passwd", "a/./../../b", ".."} { + t.Run(path, func(t *testing.T) { + _, err := goodOptions("x").render([]file{{path: path + templateSuffix, data: []byte("x")}}, []byte("{}")) + + require.Error(t, err) + assert.Contains(t, err.Error(), "not a path inside the project") + }) + } + + t.Run("a path inside is fine", func(t *testing.T) { + files, err := goodOptions("x").render([]file{{path: "a/b/c.tmpl", data: []byte("x")}}, []byte("{}")) + + require.NoError(t, err) + assert.Equal(t, "a/b/c", files[0].path) + }) +} + +func TestCreate_Target(t *testing.T) { + t.Run("a directory that does not exist is created", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "demo") + + _, err := Create(goodOptions(dir), miniTree()) + require.NoError(t, err) + + assert.FileExists(t, filepath.Join(dir, "main.go")) + }) + + t.Run("an empty directory is filled and keeps being a directory", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "demo") + require.NoError(t, os.Mkdir(dir, 0o700)) + + _, err := Create(goodOptions(dir), miniTree()) + require.NoError(t, err) + + assert.FileExists(t, filepath.Join(dir, "main.go")) + assert.Equal(t, []string{"demo"}, entries(t, filepath.Dir(dir)), "no temporary directory is left") + }) + + t.Run("a relative path is made absolute", func(t *testing.T) { + parent := t.TempDir() + t.Chdir(parent) + + _, err := Create(goodOptions("demo"), miniTree()) + require.NoError(t, err) + + assert.FileExists(t, filepath.Join(parent, "demo", "main.go")) + }) + + t.Run("a directory that is not empty is refused and left as it is", func(t *testing.T) { + parent := t.TempDir() + dir := filepath.Join(parent, "demo") + require.NoError(t, os.Mkdir(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "mine.txt"), []byte("mine"), 0o644)) + + files, err := Create(goodOptions(dir), miniTree()) + + requireInputError(t, err) + assert.Contains(t, err.Error(), dir) + assert.Contains(t, err.Error(), "never overwrite a non-empty directory") + assert.Nil(t, files) + assert.Equal(t, map[string]string{"mine.txt": "mine"}, readProject(t, dir)) + assert.Equal(t, []string{"demo"}, entries(t, parent), "no temporary directory is left") + }) + + t.Run("a hidden file makes a directory not empty", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "demo") + require.NoError(t, os.Mkdir(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".git"), nil, 0o644)) + + _, err := Create(goodOptions(dir), miniTree()) + + requireInputError(t, err) + assert.Contains(t, err.Error(), "never overwrite") + }) + + t.Run("a file is refused", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "demo") + require.NoError(t, os.WriteFile(dir, []byte("mine"), 0o644)) + + _, err := Create(goodOptions(dir), miniTree()) + + requireInputError(t, err) + assert.Contains(t, err.Error(), dir+" exists and is not a directory. Give a directory that does not exist, or an empty one") + assert.NotContains(t, err.Error(), "symlink") + + data, readErr := os.ReadFile(dir) + require.NoError(t, readErr) + assert.Equal(t, "mine", string(data)) + }) + + t.Run("a symlink to an empty directory is refused", func(t *testing.T) { + parent := t.TempDir() + target := filepath.Join(parent, "target") + require.NoError(t, os.Mkdir(target, 0o755)) + + link := filepath.Join(parent, "demo") + if err := os.Symlink(target, link); err != nil { + t.Skipf("cannot create a symlink here: %v", err) + } + + _, err := Create(goodOptions(link), miniTree()) + + requireInputError(t, err) + assert.Contains(t, err.Error(), link+" is a symlink, which is not followed. Give a directory that does not exist, or an empty one") + assert.Empty(t, entries(t, target)) + }) + + t.Run("a missing parent is refused and not created", func(t *testing.T) { + parent := t.TempDir() + + _, err := Create(goodOptions(filepath.Join(parent, "missing", "demo")), miniTree()) + + requireInputError(t, err) + assert.Contains(t, err.Error(), "parent directory does not exist") + assert.Empty(t, entries(t, parent)) + }) + + t.Run("the current directory is refused even when it is empty", func(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + _, err := Create(goodOptions("."), miniTree()) + + requireInputError(t, err) + assert.Contains(t, err.Error(), "is the current directory, which cannot be replaced. Run the command from the parent directory, or give a directory that does not exist") + assert.Empty(t, entries(t, dir)) + }) +} + +func TestCreate_FailureLeavesNothing(t *testing.T) { + failing := func(t *testing.T) { + t.Helper() + + original := renameDir + renameDir = func(string, string) error { return errors.New("cannot rename") } + + t.Cleanup(func() { renameDir = original }) + } + + t.Run("a target that did not exist", func(t *testing.T) { + parent := t.TempDir() + dir := filepath.Join(parent, "demo") + failing(t) + + _, err := Create(goodOptions(dir), miniTree()) + + require.Error(t, err) + assert.ErrorContains(t, err, "cannot create "+dir+": cannot rename", "the message names the path") + assert.NotErrorAs(t, err, new(*InputError), "a failure of the machine is not an error of the input") + assert.Empty(t, entries(t, parent), "the temporary directory is removed and the target was not made") + }) + + t.Run("a parent directory that cannot be written to", func(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("the mode of a directory does not stop this user") + } + + parent := t.TempDir() + require.NoError(t, os.Chmod(parent, 0o500)) + t.Cleanup(func() { _ = os.Chmod(parent, 0o700) }) + + dir := filepath.Join(parent, "demo") + + _, err := Create(goodOptions(dir), miniTree()) + + require.Error(t, err) + assert.ErrorContains(t, err, "cannot create "+dir) + assert.ErrorIs(t, err, fs.ErrPermission) + assert.NotErrorAs(t, err, new(*InputError), "a failure of the machine is not an error of the input") + }) + + t.Run("an empty target is still there and empty", func(t *testing.T) { + parent := t.TempDir() + dir := filepath.Join(parent, "demo") + require.NoError(t, os.Mkdir(dir, 0o755)) + failing(t) + + _, err := Create(goodOptions(dir), miniTree()) + + require.Error(t, err) + assert.Equal(t, []string{"demo"}, entries(t, parent)) + assert.Empty(t, entries(t, dir)) + }) +} + +// treeDir writes a template tree to a new directory and returns it as the file +// system that --from would use. +func treeDir(t *testing.T, files map[string]string) fs.FS { + t.Helper() + + root := t.TempDir() + + for path, content := range files { + full := filepath.Join(root, filepath.FromSlash(path)) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(content), 0o644)) + } + + return os.DirFS(root) +} + +func TestCreate_FromDirectory(t *testing.T) { + t.Run("takes a tree with and without the suffix", func(t *testing.T) { + opts := goodOptions(filepath.Join(t.TempDir(), "demo")) + + files, err := Create(opts, treeDir(t, map[string]string{ + "go/main.go.tmpl": "// __PLUGIN_NAME__\n", + "go/plain.go": "// plain\n", + })) + require.NoError(t, err) + + assert.Equal(t, []string{"main.go", "plain.go", "plugin.json"}, files) + assert.Equal(t, "// demo\n", readProject(t, opts.Dir)["main.go"]) + }) + + symlinks := []struct { + name string + build func(t *testing.T, root string) + }{ + {"a symlinked file", func(t *testing.T, root string) { + require.NoError(t, os.MkdirAll(filepath.Join(root, "go"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "secret"), []byte("secret"), 0o644)) + require.NoError(t, os.Symlink(filepath.Join(root, "secret"), filepath.Join(root, "go", "link.txt"))) + }}, + {"a symlinked directory inside the project", func(t *testing.T, root string) { + require.NoError(t, os.MkdirAll(filepath.Join(root, "go"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "elsewhere"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "elsewhere", "a.txt"), []byte("x"), 0o644)) + require.NoError(t, os.Symlink(filepath.Join(root, "elsewhere"), filepath.Join(root, "go", "dir"))) + }}, + {"a symlinked project directory", func(t *testing.T, root string) { + require.NoError(t, os.MkdirAll(filepath.Join(root, "elsewhere"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "elsewhere", "a.txt"), []byte("x"), 0o644)) + require.NoError(t, os.Symlink(filepath.Join(root, "elsewhere"), filepath.Join(root, "go"))) + }}, + {"a dangling symlink", func(t *testing.T, root string) { + require.NoError(t, os.MkdirAll(filepath.Join(root, "go"), 0o755)) + require.NoError(t, os.Symlink(filepath.Join(root, "missing"), filepath.Join(root, "go", "link.txt"))) + }}, + } + + for _, test := range symlinks { + t.Run("refuses "+test.name, func(t *testing.T) { + root := t.TempDir() + parent := t.TempDir() + + // The symlink is made first, so a platform that cannot make one skips. + if err := os.Symlink(root, filepath.Join(parent, "probe")); err != nil { + t.Skipf("cannot create a symlink here: %v", err) + } + + test.build(t, root) + + _, err := Create(goodOptions(filepath.Join(parent, "demo")), os.DirFS(root)) + + require.Error(t, err) + assert.Contains(t, err.Error(), "symlink is not followed") + assert.NoDirExists(t, filepath.Join(parent, "demo")) + assert.Equal(t, []string{"probe"}, entries(t, parent)) + }) + } +} + +func TestCreate_Limits(t *testing.T) { + file := func(size int) *fstest.MapFile { return &fstest.MapFile{Data: []byte(strings.Repeat("x", size))} } + + // A tree of count small files, or of count empty directories. + files := func(count int) fstest.MapFS { + tree := fstest.MapFS{} + for i := range count { + tree["go/f"+strings.Repeat("a", i)+".txt"] = file(1) + } + + return tree + } + + dirs := func(count int) fstest.MapFS { + tree := fstest.MapFS{} + for i := range count { + tree["go/d"+strings.Repeat("a", i)] = &fstest.MapFile{Mode: fs.ModeDir | 0o755} + } + + return tree + } + + tests := []struct { + name string + tree fstest.MapFS + want string // Empty when the tree is accepted. + }{ + {name: "the most files", tree: files(maxFiles)}, + {name: "one file too many", tree: files(maxFiles + 1), want: "more than 64 files"}, + {name: "the most directories", tree: dirs(maxDirs)}, + {name: "one directory too many", tree: dirs(maxDirs + 1), want: "more than 64 directories"}, + {name: "the biggest file", tree: fstest.MapFS{"go/a.txt": file(maxFileBytes)}}, + {name: "a file one byte too big", tree: fstest.MapFS{"go/a.txt": file(maxFileBytes + 1)}, want: "a.txt is larger than 65536 bytes"}, + {name: "files that fill the total", tree: fstest.MapFS{"go/a": file(maxFileBytes), "go/b": file(maxFileBytes), "go/c": file(maxFileBytes), "go/d": file(maxFileBytes)}}, + {name: "files that are too big together", tree: fstest.MapFS{"go/a": file(maxFileBytes), "go/b": file(maxFileBytes), "go/c": file(maxFileBytes), "go/d": file(maxFileBytes), "go/e": file(1)}, want: "larger than 262144 bytes"}, + {name: "the deepest directory", tree: fstest.MapFS{"go/a/b/c/d/e/f/g/h/file.txt": file(1)}}, + {name: "a directory that goes too deep", tree: fstest.MapFS{"go/a/b/c/d/e/f/g/h/i/file.txt": file(1)}, want: "deeper than 8 levels"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + parent := t.TempDir() + + _, err := Create(goodOptions(filepath.Join(parent, "demo")), test.tree) + + if test.want == "" { + require.NoError(t, err) + + return + } + + requireInputError(t, err) + assert.Contains(t, err.Error(), test.want) + assert.Empty(t, entries(t, parent), "nothing is written") + }) + } +} + +func TestCheckName(t *testing.T) { + for _, name := range []string{"demo", "my-plugin", "a1", "x"} { + assert.NoError(t, CheckName(name), name) + } + + for _, name := range []string{"", "Demo", "1demo", "-demo", "my_plugin", "my plugin", "demo/x", "../demo", "démo"} { + assert.ErrorContains(t, CheckName(name), "must start with a lowercase letter", name) + } +} + +func TestEngines(t *testing.T) { + tests := []struct { + version string + want *plugins.Engines + }{ + {"1.40.0", &plugins.Engines{Auth0CLI: ">=1.40.0"}}, + {"v1.40.2", &plugins.Engines{Auth0CLI: ">=1.40.2"}}, + {"1.41.0-beta.1", &plugins.Engines{Auth0CLI: ">=1.41.0"}}, + {"1.41.0+build", &plugins.Engines{Auth0CLI: ">=1.41.0"}}, + {"dev", nil}, + {"", nil}, + {"garbage", nil}, + {"1.2", nil}, + } + + for _, test := range tests { + t.Run(test.version, func(t *testing.T) { + assert.Equal(t, test.want, engines(test.version)) + }) + } +} + +func TestPlugin_JSONShape(t *testing.T) { + opts := goodOptions(filepath.Join(t.TempDir(), "demo")) + + _, err := Create(opts, miniTree()) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(opts.Dir, "plugin.json")) + require.NoError(t, err) + + var document map[string]any + require.NoError(t, json.Unmarshal(data, &document)) + + permissions := document["commands"].([]any)[0].(map[string]any)["permissions"].(map[string]any) + assert.Equal(t, []any{"read:clients"}, permissions["scopes"]) + assert.Equal(t, []any{}, permissions["hosts"], "an empty list, not null") +} diff --git a/internal/plugins/scaffold/templates/go/.github/actions/read-version/action.yml.tmpl b/internal/plugins/scaffold/templates/go/.github/actions/read-version/action.yml.tmpl new file mode 100644 index 000000000..f7744ad4d --- /dev/null +++ b/internal/plugins/scaffold/templates/go/.github/actions/read-version/action.yml.tmpl @@ -0,0 +1,26 @@ +name: Read version +description: Read the version from plugin.json and report what exists for its tag. + +outputs: + version: + description: Version, such as 1.2.3. + value: ${{ steps.read.outputs.version }} + tag: + description: Tag, such as v1.2.3. + value: ${{ steps.read.outputs.tag }} + tag-exists: + description: true when the tag exists. + value: ${{ steps.read.outputs.tag-exists }} + release-exists: + description: true when the release is complete. + value: ${{ steps.read.outputs.release-exists }} + +runs: + using: composite + steps: + - id: read + shell: bash + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: bash .github/scripts/read-version.sh >> "$GITHUB_OUTPUT" diff --git a/internal/plugins/scaffold/templates/go/.github/dependabot.yml.tmpl b/internal/plugins/scaffold/templates/go/.github/dependabot.yml.tmpl new file mode 100644 index 000000000..cb021397e --- /dev/null +++ b/internal/plugins/scaffold/templates/go/.github/dependabot.yml.tmpl @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + - package-ecosystem: github-actions + directories: [/, /.github/actions/*] # The workflows and the composite actions. + schedule: + interval: weekly diff --git a/internal/plugins/scaffold/templates/go/.github/scripts/read-version.sh.tmpl b/internal/plugins/scaffold/templates/go/.github/scripts/read-version.sh.tmpl new file mode 100644 index 000000000..33c5a7141 --- /dev/null +++ b/internal/plugins/scaffold/templates/go/.github/scripts/read-version.sh.tmpl @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Prints version, tag, tag-exists and release-exists as key=value lines for +# $GITHUB_OUTPUT. A release exists when it holds checksums.txt and an archive +# for every platform, so one that stopped half way counts as missing and the +# workflow finishes it. Needs the git tags, and gh with GH_TOKEN and GH_REPO. +set -euo pipefail + +# A semantic version without build metadata: the tag and the archive names carry it. +semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?$' + +version=$(jq -er '.version | strings' plugin.json) || { + echo "plugin.json has no version." >&2 + exit 1 +} +[[ $version =~ $semver ]] || { + echo "The version in plugin.json is not a semantic version such as 1.2.3 (no build metadata)." >&2 + exit 1 +} +tag="v$version" +sha=${GITHUB_SHA:-$(git rev-parse HEAD)} + +tag_exists=false +tag_sha= +if tag_sha=$(git rev-parse -q --verify "refs/tags/$tag^{commit}"); then + tag_exists=true +fi + +release_exists=false +if out=$(gh release view "$tag" --json assets --jq '[.assets[].name]' 2>&1); then + wanted=$(jq '(.platforms | length) + 1' plugin.json) + if jq -e --argjson n "$wanted" 'index("checksums.txt") != null and length >= $n' <<<"$out" >/dev/null; then + release_exists=true + fi +elif [[ $out != *"release not found"* ]]; then + echo "$out" >&2 + exit 1 +fi + +# A tag somewhere else means plugin.json changed after the tag was made, and +# building this commit under that tag would publish the wrong code. +if [[ $release_exists == false && $tag_exists == true && $tag_sha != "$sha" ]]; then + echo "The tag $tag is at $tag_sha, but this run is for $sha. Raise the version in plugin.json, or re-run the workflow of the commit the tag points to." >&2 + exit 1 +fi + +echo "version=$version" +echo "tag=$tag" +echo "tag-exists=$tag_exists" +echo "release-exists=$release_exists" diff --git a/internal/plugins/scaffold/templates/go/.github/workflows/ci.yml.tmpl b/internal/plugins/scaffold/templates/go/.github/workflows/ci.yml.tmpl new file mode 100644 index 000000000..b1e67a325 --- /dev/null +++ b/internal/plugins/scaffold/templates/go/.github/workflows/ci.yml.tmpl @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_call: # The Release workflow runs the tests before it publishes. + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - run: go vet ./... + - run: go test ./... + - run: go build -o __PLUGIN_NAME__ . + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + version: v2.14.0 diff --git a/internal/plugins/scaffold/templates/go/.github/workflows/release.yml.tmpl b/internal/plugins/scaffold/templates/go/.github/workflows/release.yml.tmpl new file mode 100644 index 000000000..242303732 --- /dev/null +++ b/internal/plugins/scaffold/templates/go/.github/workflows/release.yml.tmpl @@ -0,0 +1,62 @@ +name: Release + +# Runs when a change to the version in plugin.json reaches main, which is what +# merging the pull request from `make release` does. Run it by hand to finish a +# release that failed half way. +on: + push: + branches: [main] + paths: [plugin.json] + workflow_dispatch: + +permissions: + contents: read + +jobs: + version: + # A manual run on another branch is skipped, and so is every job that needs it. + if: github.ref_name == github.event.repository.default_branch + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.read.outputs.tag }} + release-exists: ${{ steps.read.outputs.release-exists }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # The tags are needed to see what exists. + persist-credentials: false + - id: read + uses: ./.github/actions/read-version + + # When the release exists, this job is skipped and so is every job that needs it. + ci: + needs: version + if: needs.version.outputs.release-exists != 'true' + uses: ./.github/workflows/ci.yml + + release: + needs: [version, ci] + runs-on: ubuntu-latest + permissions: + contents: write # Creates the tag, the release and the assets. + concurrency: + group: release-${{ needs.version.outputs.tag }} + cancel-in-progress: false + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # GoReleaser reads the tag and the history. + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - name: Tag the commit for GoReleaser + env: + TAG: ${{ needs.version.outputs.tag }} + run: git tag --force "$TAG" "$GITHUB_SHA" + - uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ github.token }} diff --git a/internal/plugins/scaffold/templates/go/.gitignore.tmpl b/internal/plugins/scaffold/templates/go/.gitignore.tmpl new file mode 100644 index 000000000..5e4e43444 --- /dev/null +++ b/internal/plugins/scaffold/templates/go/.gitignore.tmpl @@ -0,0 +1,4 @@ +# What `go build -o __PLUGIN_NAME__ .` writes, and what GoReleaser writes. +/__PLUGIN_NAME__ +/__PLUGIN_NAME__.exe +/dist/ diff --git a/internal/plugins/scaffold/templates/go/.golangci.yml.tmpl b/internal/plugins/scaffold/templates/go/.golangci.yml.tmpl new file mode 100644 index 000000000..8c29f5715 --- /dev/null +++ b/internal/plugins/scaffold/templates/go/.golangci.yml.tmpl @@ -0,0 +1,14 @@ +version: "2" +linters: + default: none + enable: + - errcheck + - gocritic + - revive + - staticcheck + - unconvert + - unused +formatters: + enable: + - gofmt + - goimports diff --git a/internal/plugins/scaffold/templates/go/.goreleaser.yaml.tmpl b/internal/plugins/scaffold/templates/go/.goreleaser.yaml.tmpl new file mode 100644 index 000000000..af4bd34c5 --- /dev/null +++ b/internal/plugins/scaffold/templates/go/.goreleaser.yaml.tmpl @@ -0,0 +1,55 @@ +# Builds what the Auth0 CLI installs: one archive per platform holding the +# executable and plugin.json at its root, and checksums.txt with their SHA-256. +version: 2 + +project_name: __PLUGIN_NAME__ + +builds: + - &build + id: default + main: . + binary: __PLUGIN_NAME__ + env: [CGO_ENABLED=0] + flags: [-trimpath] + ldflags: ["-s -w -X example.com/__PLUGIN_NAME__/plugin.Version={{ .Version }}"] + goos: [darwin, linux, windows] + goarch: [amd64, arm64] + ignore: + - {goos: windows, goarch: arm64} + # A static Go binary also runs on musl Linux; this build gives the CLI the + # separate linux-*-musl archives it looks for. + - <<: *build + id: musl + goos: [linux] + ignore: [] + +archives: + - &archive + id: default + builds: [default] + formats: [tar.gz] + format_overrides: + - {goos: windows, formats: [zip]} + # The names of the platforms in plugin.json: darwin-arm64, linux-x64, windows-x64. + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}-{{ if eq .Arch "amd64" }}x64{{ else }}{{ .Arch }}{{ end }} + # The CLI accepts only the executable and plugin.json, so no README or LICENSE. + files: [plugin.json] + - <<: *archive + id: musl + builds: [musl] + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}-{{ if eq .Arch "amd64" }}x64{{ else }}{{ .Arch }}{{ end }}-musl + +checksum: + name_template: checksums.txt + algorithm: sha256 + +changelog: + use: github-native # GitHub writes the release notes. + +release: + # GitHub creates the tag at this commit when it does not exist yet. + target_commitish: "{{ .FullCommit }}" + # A second run replaces the assets of the first, which finishes a half-made release. + replace_existing_artifacts: true diff --git a/internal/plugins/scaffold/templates/go/Makefile.tmpl b/internal/plugins/scaffold/templates/go/Makefile.tmpl new file mode 100644 index 000000000..4e65062d8 --- /dev/null +++ b/internal/plugins/scaffold/templates/go/Makefile.tmpl @@ -0,0 +1,16 @@ +.PHONY: build test lint release release-snapshot + +build: ## Build the plugin for this machine. + go build -trimpath -o __PLUGIN_NAME__ . + +test: ## Run the tests. + go test ./... + +lint: ## Run golangci-lint (https://golangci-lint.run/welcome/install/). + golangci-lint run ./... + +release: ## Start a release: make release BUMP=patch|minor|major or VERSION=1.2.3. Add DRY_RUN=1 to preview. + @go run ./scripts/release $(if $(DRY_RUN),--dry-run) "$(or $(VERSION),$(BUMP))" + +release-snapshot: ## Build every release archive into dist/ without publishing (needs goreleaser). + goreleaser release --snapshot --clean diff --git a/internal/plugins/scaffold/templates/go/README.md.tmpl b/internal/plugins/scaffold/templates/go/README.md.tmpl new file mode 100644 index 000000000..2ed00f0ba --- /dev/null +++ b/internal/plugins/scaffold/templates/go/README.md.tmpl @@ -0,0 +1,95 @@ +# __PLUGIN_NAME__ + +A plugin for the [Auth0 CLI](https://github.com/auth0/auth0-cli), written in Go with the [go-auth0](https://github.com/auth0/go-auth0) SDK. It runs as `auth0 __PLUGIN_NAME__` and calls the Management API through the CLI, which never gives the plugin a real token. + +The sample command lists the clients of your tenant. Make it your own. + +## Requirements + +- Go 1.26 or later +- The Auth0 CLI, with plugin development turned on: `export AUTH0_CLI_PLUGIN_DEV_MODE=true` + +## Layout + +`main.go` starts the command, `commands/` holds one file per command and the registry that finds it, and `plugin/` holds the shared code: environment and SDK client, output, error text with exit codes, and pagination. `plugin.json` is the manifest: commands, flags and the Management API scopes each command may use. + +The Go module is named `example.com/__PLUGIN_NAME__`, a path reserved for examples, so change it in `go.mod`, the imports and the `-X` flag of `.goreleaser.yaml` when you publish the project. + +## Build and run + +```sh +go mod tidy # also writes go.sum; commit it +go build -o __PLUGIN_NAME__ . +``` + +Link the build to the CLI once. The link reads `plugin.json` and the executable again on every run, so after a change you only rebuild. + +```sh +auth0 plugin dev link +auth0 plugin approve __PLUGIN_NAME__ # a person reviews the scopes; repeat when they change +auth0 __PLUGIN_NAME__ +auth0 __PLUGIN_NAME__ --json +``` + +`make build`, `make test` and `make lint` do the same from the Makefile. `plugin.json` is checked with `auth0 plugin dev validate`. + +## Scopes + +The CLI proxy refuses every call that needs a scope the command did not declare, and the plugin then says which scope is missing. Declare scopes under `permissions.scopes` of the command in `plugin.json`, then approve again. The sample command needs `read:clients`. + +## Add a command + +1. Copy `commands/clients.go` to `commands/.go`. Change the name, the help text and the code in the run function, and keep the call to `Register` in `init`. +2. Add the command to `commands` in `plugin.json`, with its description and the scopes it needs. +3. Rebuild. A command that is not the root command runs as `auth0 __PLUGIN_NAME__ `. + +Flags: declare them on the command's `flag.FlagSet` and list them under `flags` in `plugin.json`; the flags of the CLI itself (`--json`, `--tenant` and so on) are handled for you. Use `out.JSON` to choose between `out.PrintJSON` and `out.PrintTable`, and `plugin.Collect` for every page of a list call. Results go to stdout, anything else to stderr. + +## Environment + +The CLI sets these for every run. Only `plugin/client.go` and `plugin/output.go` read them. + +| Variable | Meaning | +| --- | --- | +| `AUTH0_PLUGIN_DOMAIN` | Address of the CLI proxy (`host:port`) | +| `AUTH0_PLUGIN_MANAGEMENT_TOKEN` | A secret for this run that lets the plugin use the proxy | +| `AUTH0_PLUGIN_CA_CERT` | PEM file with the certificate of the proxy | +| `AUTH0_PLUGIN_JSON`, `AUTH0_PLUGIN_JSON_COMPACT` | `true` when the user asked for JSON output (agent mode turns it on) | +| `AUTH0_PLUGIN_TENANT` | The tenant the run is for | + +Exit codes: 0 on success, 1 when the command fails, 2 when the arguments are wrong. + +## Releasing + +Edit nothing by hand: run one command, then merge the pull request it opens. + +```sh +make release BUMP=patch # or minor, or major +make release VERSION=1.2.3 # an exact version +make release BUMP=patch DRY_RUN=1 # show the steps, change nothing +``` + +It needs `git`, `gh` (`gh auth login`) and a clean `main` that matches `origin`. It creates `release/v`, raises `version` in `plugin.json`, commits `chore: release v` with your own git settings, pushes and opens the pull request. A keyword refuses a prerelease version; give the exact version then. + +Merging runs the Release workflow, which: + +1. runs the tests (`ci.yml`) on the merged commit, +2. tags it `v`, +3. runs GoReleaser: seven platforms, each packed with `plugin.json` into `__.tar.gz` (`.zip` on Windows), plus `checksums.txt`, +4. publishes them as a GitHub release with generated notes. + +If a run fails half way, re-run it or run the workflow by hand on `main`: it finishes what is missing and does nothing once the release is complete. The first push of a new project releases the scaffolded version. The workflow listens to `main`; change that in `release.yml` for another branch name. + +| File | What it does | +| --- | --- | +| `scripts/release/` | The program behind `make release` | +| `.github/workflows/ci.yml` | Vet, test, build and lint; the release calls it too | +| `.github/workflows/release.yml` | The steps above | +| `.github/actions/read-version/` | Reads the version and tells whether its tag and release exist (`.github/scripts/`) | +| `.goreleaser.yaml` | Builds, archives and checksums; `make release-snapshot` runs it into `dist/` without publishing | + +The workflow stops at the GitHub release. Signing, attestations and publishing to the plugin catalog come later. + +## License + +__LICENSE__ diff --git a/internal/plugins/scaffold/templates/go/commands/clients.go.tmpl b/internal/plugins/scaffold/templates/go/commands/clients.go.tmpl new file mode 100644 index 000000000..72fb41bef --- /dev/null +++ b/internal/plugins/scaffold/templates/go/commands/clients.go.tmpl @@ -0,0 +1,70 @@ +package commands + +import ( + "context" + "flag" + + "github.com/auth0/go-auth0/v3/management" + + "example.com/__PLUGIN_NAME__/plugin" +) + +// This is the sample command. To add your own, copy this file, change the name, +// the help and the code in runClients, and add the command to plugin.json with +// the scopes it needs. +func init() { + Register(Command{ + Name: "__COMMAND__", + Help: "List the clients of the tenant", + Run: runClients, + }) +} + +// runClients lists the clients (applications) of the tenant. It needs the +// read:clients scope, which plugin.json declares for the command. +func runClients(ctx context.Context, args []string, out *plugin.Output) error { + fs := flag.NewFlagSet("__COMMAND__", flag.ContinueOnError) + // Declare the flags of the command here, for example: + // limit := fs.Int("limit", 0, "Stop after this many clients") + + if err := plugin.ParseFlags(fs, args, out); err != nil { + return err + } + + api, err := plugin.NewClient() + if err != nil { + return err + } + + // Ask only for the two fields the command prints. + page, err := api.Clients.List(ctx, &management.ListClientsRequestParameters{ + Fields: management.String("client_id,name"), + IncludeFields: management.Bool(true), + PerPage: management.Int(50), + }) + if err != nil { + return err + } + + clients, err := plugin.Collect(ctx, page) + if err != nil { + return err + } + + if out.JSON { + return out.PrintJSON(clients) + } + + if len(clients) == 0 { + out.Note("No clients found.") + + return nil + } + + rows := make([][]string, 0, len(clients)) + for _, c := range clients { + rows = append(rows, []string{c.GetClientID(), c.GetName()}) + } + + return out.PrintTable([]string{"CLIENT ID", "NAME"}, rows) +} diff --git a/internal/plugins/scaffold/templates/go/commands/clients_test.go.tmpl b/internal/plugins/scaffold/templates/go/commands/clients_test.go.tmpl new file mode 100644 index 000000000..1bd7717a8 --- /dev/null +++ b/internal/plugins/scaffold/templates/go/commands/clients_test.go.tmpl @@ -0,0 +1,205 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "example.com/__PLUGIN_NAME__/plugin" +) + +// serve starts a TLS server in place of the CLI proxy and points the plugin at +// it with the same variables the CLI sets. The handler sees every request. +func serve(t *testing.T, handler http.HandlerFunc) { + t.Helper() + + server := httptest.NewTLSServer(handler) + t.Cleanup(server.Close) + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + caPath := filepath.Join(t.TempDir(), "proxy.pem") + + if err := os.WriteFile(caPath, certPEM, 0o600); err != nil { + t.Fatal(err) + } + + t.Setenv(plugin.EnvDomain, strings.TrimPrefix(server.URL, "https://")) + t.Setenv(plugin.EnvToken, "run-secret") + t.Setenv(plugin.EnvCACert, caPath) +} + +// run executes the sample command the way the CLI does: the root command gets +// no name, any other command gets its name as the first argument. +func run(t *testing.T, out *plugin.Output, args ...string) int { + t.Helper() + + if plugin.Name != "__COMMAND__" { + args = append([]string{"__COMMAND__"}, args...) + } + + return Run(context.Background(), args, out) +} + +// pageSizes are the pages of a list of 103 clients where the page size is 50. +var pageSizes = []int{50, 50, 3} + +// clientRange returns the clients with the numbers from start up to end. +func clientRange(start, end int) []map[string]string { + clients := []map[string]string{} + for i := start; i < end; i++ { + clients = append(clients, map[string]string{"client_id": fmt.Sprintf("id%d", i), "name": fmt.Sprintf("Client %d", i)}) + } + + return clients +} + +// clientsPage returns the page of the list with this index, as the API sends it. +func clientsPage(index int) string { + start := 0 + for _, size := range pageSizes[:index] { + start += size + } + + clients := clientRange(start, start+pageSizes[index]) + body, _ := json.Marshal(map[string]any{"start": start, "limit": 50, "length": len(clients), "total": 103, "clients": clients}) + + return string(body) +} + +func TestClients(t *testing.T) { + const ( + page = `{"start":0,"limit":50,"length":2,"total":2,"clients":[{"client_id":"id1","name":"First"},{"client_id":"id2","name":"Second"}]}` + empty = `{"start":0,"limit":50,"length":0,"total":0,"clients":[]}` + denied = `{"statusCode":403,"error":"Forbidden","errorCode":"insufficient_scope","message":"Plugin auth0/x did not declare scope \"read:clients\" required for GET /api/v2/clients. Add it to permissions.scopes in plugin.json."}` + tenant = `{"statusCode":403,"error":"Forbidden","errorCode":"insufficient_scope","message":"Insufficient scope, expected any of: read:clients"}` + ) + + tests := []struct { + name string + args []string + compact bool + json bool + status int + body string + paged bool // The server answers with the pages of clientsPage, by the page parameter. + wantLen int // The number of clients in a JSON list on stdout, instead of wantOut. + wantCode int + wantOut string // The whole of stdout. + wantErr []string // Pieces of stderr. + }{ + { + name: "table", status: 200, body: page, + wantOut: "CLIENT ID NAME\nid1 First\nid2 Second\n", + }, + { + name: "json", json: true, status: 200, body: page, + wantOut: "[\n {\n \"client_id\": \"id1\",\n \"name\": \"First\"\n },\n {\n \"client_id\": \"id2\",\n \"name\": \"Second\"\n }\n]\n", + }, + { + name: "compact json", json: true, compact: true, status: 200, body: page, + wantOut: "[{\"client_id\":\"id1\",\"name\":\"First\"},{\"client_id\":\"id2\",\"name\":\"Second\"}]\n", + }, + { + name: "every page of the list", json: true, compact: true, status: 200, paged: true, + wantLen: 103, + }, + { + name: "no clients", status: 200, body: empty, + wantErr: []string{"No clients found."}, + }, + { + name: "no clients as json", json: true, status: 200, body: empty, + wantOut: "[]\n", + }, + { + name: "scope missing from plugin.json", status: 403, body: denied, + wantCode: 1, wantErr: []string{"read:clients", "permissions.scopes", "auth0 plugin approve " + plugin.Name}, + }, + { + name: "scope missing from the token", status: 403, body: tenant, + wantCode: 1, wantErr: []string{"the API answered 403", "Insufficient scope"}, + }, + { + name: "unknown flag", args: []string{"--nope"}, status: 200, body: page, + wantCode: 2, wantErr: []string{"flag provided but not defined"}, + }, + { + name: "stray argument", args: []string{"extra"}, status: 200, body: page, + wantCode: 2, wantErr: []string{`unexpected argument "extra"`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var calls int + + serve(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + + if got := r.Header.Get("Authorization"); got != "Bearer run-secret" { + t.Errorf("Authorization = %q, want the token of the run", got) + } + + // The API returns an empty page after the last one. + body := tt.body + if r.URL.Query().Get("page") != "0" && tt.status == 200 { + body = empty + } + + if index, err := strconv.Atoi(r.URL.Query().Get("page")); tt.paged && err == nil && index < len(pageSizes) { + body = clientsPage(index) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(body)) + }) + + var stdout, stderr bytes.Buffer + out := &plugin.Output{Stdout: &stdout, Stderr: &stderr, JSON: tt.json, Compact: tt.compact} + + if code := run(t, out, tt.args...); code != tt.wantCode { + t.Errorf("exit code = %d, want %d (stderr: %s)", code, tt.wantCode, stderr.String()) + } + + if tt.wantLen > 0 { + var clients []map[string]string + if err := json.Unmarshal(stdout.Bytes(), &clients); err != nil { + t.Fatalf("stdout is not a JSON list: %v", err) + } + + if len(clients) != tt.wantLen { + t.Errorf("stdout has %d clients, want %d", len(clients), tt.wantLen) + } + + // Every page in order, and none twice. + for i, c := range clients { + if want := fmt.Sprintf("id%d", i); c["client_id"] != want { + t.Fatalf("client %d is %q, want %q", i, c["client_id"], want) + } + } + } else if stdout.String() != tt.wantOut { + t.Errorf("stdout = %q, want %q", stdout.String(), tt.wantOut) + } + + for _, want := range tt.wantErr { + if !strings.Contains(stderr.String(), want) { + t.Errorf("stderr = %q, want it to contain %q", stderr.String(), want) + } + } + + if tt.wantCode == 2 && calls != 0 { + t.Errorf("a usage error made %d API calls, want none", calls) + } + }) + } +} diff --git a/internal/plugins/scaffold/templates/go/commands/registry.go.tmpl b/internal/plugins/scaffold/templates/go/commands/registry.go.tmpl new file mode 100644 index 000000000..2976c8c62 --- /dev/null +++ b/internal/plugins/scaffold/templates/go/commands/registry.go.tmpl @@ -0,0 +1,97 @@ +// Package commands holds the commands of the plugin, one file each. A command +// registers itself in an init function, so adding a file adds a command. +package commands + +import ( + "context" + "fmt" + "slices" + "strings" + + "example.com/__PLUGIN_NAME__/plugin" +) + +// Command is one command of the plugin. Every command also needs an entry under +// "commands" in plugin.json, with the Management API scopes it uses. +// +// A command named like the plugin is the root command, run as +// `auth0 __PLUGIN_NAME__`. Any other command is run as +// `auth0 __PLUGIN_NAME__ `, and the CLI then passes the name as the first argument. +type Command struct { + Name string + Help string + Run func(ctx context.Context, args []string, out *plugin.Output) error +} + +var registered []Command + +// Register adds a command. It panics on a second command with the same name, +// which is a mistake in the code and shows up the first time the plugin runs. +func Register(command Command) { + if slices.ContainsFunc(registered, func(c Command) bool { return c.Name == command.Name }) { + panic("commands: two commands are named " + command.Name) + } + + registered = append(registered, command) +} + +// Run picks the command from the arguments the CLI passed, runs it and returns +// the exit code. The CLI has already removed its own flags (--json, --tenant), +// so what is left belongs to the plugin. +func Run(ctx context.Context, args []string, out *plugin.Output) int { + if len(args) > 0 && args[0] == "--version" { + _, _ = fmt.Fprintf(out.Stdout, "%s %s\n", plugin.Name, plugin.Version) + + return plugin.ExitOK + } + + command, rest, err := find(args) + if err != nil && len(args) > 0 && (args[0] == "help" || args[0] == "-h" || args[0] == "--help") { + printHelp(out) + + return plugin.ExitOK + } + + if err == nil { + err = command.Run(ctx, rest, out) + } + + return plugin.Report(out, err) +} + +// find returns the command for the arguments and the arguments that are left. +func find(args []string) (Command, []string, error) { + if len(args) > 0 { + for _, c := range registered { + if c.Name == args[0] && c.Name != plugin.Name { + return c, args[1:], nil + } + } + } + + for _, c := range registered { + if c.Name == plugin.Name { + return c, args, nil + } + } + + if len(args) == 0 { + return Command{}, nil, plugin.UsageError{Message: "a command is required"} + } + + return Command{}, nil, plugin.UsageError{Message: fmt.Sprintf("unknown command %q", args[0])} +} + +// printHelp lists the commands, for people who run the file directly. +// `auth0 __PLUGIN_NAME__ --help` is answered by the CLI from plugin.json. +func printHelp(out *plugin.Output) { + lines := make([]string, 0, len(registered)) + for _, c := range registered { + lines = append(lines, fmt.Sprintf(" %-36s %s", plugin.Invocation(c.Name), c.Help)) + } + + slices.Sort(lines) + + _, _ = fmt.Fprintf(out.Stdout, "%s is a plugin for the Auth0 CLI. Run it with `auth0`, not directly.\n\nCommands:\n%s\n", + plugin.Name, strings.Join(lines, "\n")) +} diff --git a/internal/plugins/scaffold/templates/go/commands/registry_test.go.tmpl b/internal/plugins/scaffold/templates/go/commands/registry_test.go.tmpl new file mode 100644 index 000000000..cea759bb4 --- /dev/null +++ b/internal/plugins/scaffold/templates/go/commands/registry_test.go.tmpl @@ -0,0 +1,47 @@ +package commands + +import ( + "bytes" + "context" + "strings" + "testing" + + "example.com/__PLUGIN_NAME__/plugin" +) + +func TestRegisterRejectsADuplicate(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("Register accepted a second command with the same name") + } + }() + + Register(Command{Name: "__COMMAND__"}) +} + +func TestRun(t *testing.T) { + tests := []struct { + name string + args []string + wantCode int + wantOut string + }{ + {name: "version", args: []string{"--version"}, wantOut: plugin.Name + " " + plugin.Version + "\n"}, + {name: "unknown command", args: []string{"no-such-command"}, wantCode: plugin.ExitUsage}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := Run(context.Background(), tt.args, &plugin.Output{Stdout: &stdout, Stderr: &stderr}) + if code != tt.wantCode { + t.Errorf("exit code = %d, want %d", code, tt.wantCode) + } + + if !strings.Contains(stdout.String(), tt.wantOut) { + t.Errorf("stdout = %q, want it to contain %q", stdout.String(), tt.wantOut) + } + }) + } +} diff --git a/internal/plugins/scaffold/templates/go/go.mod.tmpl b/internal/plugins/scaffold/templates/go/go.mod.tmpl new file mode 100644 index 000000000..a3789cf57 --- /dev/null +++ b/internal/plugins/scaffold/templates/go/go.mod.tmpl @@ -0,0 +1,5 @@ +module example.com/__PLUGIN_NAME__ + +go 1.26.0 + +require github.com/auth0/go-auth0/v3 v3.10.0 diff --git a/internal/plugins/scaffold/templates/go/main.go.tmpl b/internal/plugins/scaffold/templates/go/main.go.tmpl new file mode 100644 index 000000000..5b99d0900 --- /dev/null +++ b/internal/plugins/scaffold/templates/go/main.go.tmpl @@ -0,0 +1,23 @@ +// Command __PLUGIN_NAME__ is a plugin for the Auth0 CLI. The CLI starts it when +// someone runs `auth0 __PLUGIN_NAME__`. +// +// This file only wires things together. The commands are in commands/, and +// everything they share (environment, SDK client, output, errors) is in plugin/. +package main + +import ( + "os" + + "example.com/__PLUGIN_NAME__/commands" + "example.com/__PLUGIN_NAME__/plugin" +) + +func main() { + // The CLI stops a plugin that runs past its timeout, so a signal cancels + // the context and with it the request that is in flight. + ctx, stop := plugin.SignalContext() + code := commands.Run(ctx, os.Args[1:], plugin.NewOutput(os.Stdout, os.Stderr)) + stop() + + os.Exit(code) +} diff --git a/internal/plugins/scaffold/templates/go/plugin/client.go.tmpl b/internal/plugins/scaffold/templates/go/plugin/client.go.tmpl new file mode 100644 index 000000000..3bbf59ab5 --- /dev/null +++ b/internal/plugins/scaffold/templates/go/plugin/client.go.tmpl @@ -0,0 +1,99 @@ +package plugin + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "os" + + managementclient "github.com/auth0/go-auth0/v3/management/client" + "github.com/auth0/go-auth0/v3/management/option" +) + +// The variables the CLI sets for every run. This is the only file that reads +// them, so look here when the plugin cannot reach the API. +const ( + // EnvDomain is the address of the CLI proxy as host:port. It is not the + // tenant domain: the plugin never talks to Auth0 directly. + EnvDomain = "AUTH0_PLUGIN_DOMAIN" + + // EnvToken is a secret for this run only. It lets the plugin use the proxy, + // which swaps in the real token. It is not an Auth0 token. + EnvToken = "AUTH0_PLUGIN_MANAGEMENT_TOKEN" + + // EnvCACert is a PEM file with the certificate that the proxy uses. + EnvCACert = "AUTH0_PLUGIN_CA_CERT" +) + +// NewClient returns the go-auth0 Management API client for this run. Every call +// goes through the proxy of the CLI, which allows only the scopes that +// plugin.json declares for the command. +func NewClient() (*managementclient.Management, error) { + domain, err := requireEnv(EnvDomain) + if err != nil { + return nil, err + } + + token, err := requireEnv(EnvToken) + if err != nil { + return nil, err + } + + caCert, err := requireEnv(EnvCACert) + if err != nil { + return nil, err + } + + httpClient, err := newHTTPClient(caCert) + if err != nil { + return nil, err + } + + // The SDK builds https:///api/v2 from the domain, so host:port works. + return managementclient.New(domain, + option.WithToken(token), + option.WithHTTPClient(httpClient), + ) +} + +// requireEnv returns a variable of the CLI, or an error that says what to do +// when someone runs the plugin without the CLI. +func requireEnv(name string) (string, error) { + value := os.Getenv(name) + if value == "" { + return "", fmt.Errorf("%s is not set. This plugin has to be started by the Auth0 CLI: run `auth0 %s` instead of running the file directly", name, Name) + } + + return value, nil +} + +// newHTTPClient returns an HTTP client that trusts the proxy certificate. +// +// The CLI also sets SSL_CERT_FILE, but Go ignores it on macOS and Windows, so +// the plugin loads the certificate itself, next to the system certificates. +func newHTTPClient(caCertPath string) (*http.Client, error) { + pem, err := os.ReadFile(caCertPath) + if err != nil { + return nil, fmt.Errorf("cannot read the proxy certificate: %w", err) + } + + pool, err := x509.SystemCertPool() + if err != nil { + pool = x509.NewCertPool() + } + + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("%s does not hold a PEM certificate", caCertPath) + } + + return &http.Client{ + Transport: &http.Transport{ + // The only host this plugin calls is the proxy on the loopback + // address, so HTTP_PROXY must never apply. The CLI itself uses the + // proxy of the user when it forwards a call. + Proxy: nil, + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + }, + }, nil +} diff --git a/internal/plugins/scaffold/templates/go/plugin/client_test.go.tmpl b/internal/plugins/scaffold/templates/go/plugin/client_test.go.tmpl new file mode 100644 index 000000000..425ca863e --- /dev/null +++ b/internal/plugins/scaffold/templates/go/plugin/client_test.go.tmpl @@ -0,0 +1,65 @@ +package plugin + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNewClientEnvironment(t *testing.T) { + notPEM := filepath.Join(t.TempDir(), "not-a-certificate") + if err := os.WriteFile(notPEM, []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + env map[string]string + wantErr string + }{ + { + name: "no domain", + env: map[string]string{EnvToken: "t", EnvCACert: notPEM}, + wantErr: EnvDomain + " is not set", + }, + { + name: "no token", + env: map[string]string{EnvDomain: "127.0.0.1:1", EnvCACert: notPEM}, + wantErr: EnvToken + " is not set", + }, + { + name: "no certificate path", + env: map[string]string{EnvDomain: "127.0.0.1:1", EnvToken: "t"}, + wantErr: EnvCACert + " is not set", + }, + { + name: "certificate file is missing", + env: map[string]string{EnvDomain: "127.0.0.1:1", EnvToken: "t", EnvCACert: filepath.Join(t.TempDir(), "missing")}, + wantErr: "cannot read the proxy certificate", + }, + { + name: "certificate is not PEM", + env: map[string]string{EnvDomain: "127.0.0.1:1", EnvToken: "t", EnvCACert: notPEM}, + wantErr: "does not hold a PEM certificate", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, name := range []string{EnvDomain, EnvToken, EnvCACert} { + t.Setenv(name, tt.env[name]) + } + + _, err := NewClient() + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want one that contains %q", err, tt.wantErr) + } + + // Whoever runs the file directly must learn how to run it. + if strings.HasSuffix(tt.wantErr, "is not set") && !strings.Contains(err.Error(), "auth0 "+Name) { + t.Errorf("error = %v, want it to name `auth0 %s`", err, Name) + } + }) + } +} diff --git a/internal/plugins/scaffold/templates/go/plugin/errors.go.tmpl b/internal/plugins/scaffold/templates/go/plugin/errors.go.tmpl new file mode 100644 index 000000000..cdc128490 --- /dev/null +++ b/internal/plugins/scaffold/templates/go/plugin/errors.go.tmpl @@ -0,0 +1,107 @@ +package plugin + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/url" + "strings" + + "github.com/auth0/go-auth0/v3/management/core" +) + +// proxyDeniedText is part of every message that the CLI proxy writes when a +// call needs a scope that plugin.json does not declare. Auth0 never says it. +const proxyDeniedText = "did not declare scope" + +// UsageError means the arguments were wrong. The exit code is 2. +type UsageError struct{ Message string } + +// Error returns the message. +func (e UsageError) Error() string { return e.Message } + +// ErrHelp is what a command returns after it printed its help. +var ErrHelp = errors.New("help requested") + +// ParseFlags parses the arguments of one command. Declare the flags on the +// FlagSet first, and list them in plugin.json so that the CLI shows them in its +// help. The flags that the CLI owns (--json, --tenant and so on) never get here. +func ParseFlags(fs *flag.FlagSet, args []string, out *Output) error { + fs.SetOutput(io.Discard) // Errors are reported once, by Report. + + err := fs.Parse(args) + + switch { + case errors.Is(err, flag.ErrHelp): + _, _ = fmt.Fprintf(out.Stdout, "Usage: %s [flags]\n\n", Invocation(fs.Name())) + fs.SetOutput(out.Stdout) + fs.PrintDefaults() + + return ErrHelp + case err != nil: + return UsageError{err.Error()} + case fs.NArg() > 0: + return UsageError{fmt.Sprintf("unexpected argument %q", fs.Arg(0))} + } + + return nil +} + +// Report prints an error to out.Stderr and returns the exit code for it: 0 for +// no error, 2 for wrong usage and 1 for anything else. This is the one place +// that decides what an error looks like, so change it here. +func Report(out *Output, err error) int { + var ( + usage UsageError + apiErr *core.APIError + urlErr *url.Error + ) + + switch { + case err == nil, errors.Is(err, ErrHelp): + return ExitOK + case errors.As(err, &usage): + out.Note("error: %s\nRun `auth0 %s --help` for usage.", usage, Name) + + return ExitUsage + case errors.Is(err, context.Canceled): + out.Note("interrupted") + case errors.As(err, &apiErr): + out.Note("%s", apiErrorText(apiErr)) + case errors.As(err, &urlErr): + // The call got no answer: a refused connection, a TLS error, a timeout. + out.Note("error: could not reach the CLI proxy: %v", urlErr.Err) + default: + out.Note("error: %v", err) + } + + return ExitError +} + +// apiErrorText turns an error response of the Management API into text. The API +// answers with {"statusCode", "error", "message", "errorCode"}, which the SDK +// keeps as the wrapped error. +func apiErrorText(err *core.APIError) string { + var body struct { + Message string `json:"message"` + } + + if cause := err.Unwrap(); cause != nil { + _ = json.Unmarshal([]byte(cause.Error()), &body) + } + + if body.Message == "" { + body.Message = "no details" + } + + if err.StatusCode == 403 && strings.Contains(body.Message, proxyDeniedText) { + return fmt.Sprintf("error: the CLI blocked this call.\n %s\n"+ + "Declare the scope under permissions.scopes of the command in plugin.json, "+ + "then approve the new permissions with: auth0 plugin approve %s", body.Message, Name) + } + + return fmt.Sprintf("error: the API answered %d: %s", err.StatusCode, body.Message) +} diff --git a/internal/plugins/scaffold/templates/go/plugin/errors_test.go.tmpl b/internal/plugins/scaffold/templates/go/plugin/errors_test.go.tmpl new file mode 100644 index 000000000..b2d4aae5b --- /dev/null +++ b/internal/plugins/scaffold/templates/go/plugin/errors_test.go.tmpl @@ -0,0 +1,80 @@ +package plugin + +import ( + "bytes" + "context" + "errors" + "flag" + "fmt" + "net/url" + "testing" +) + +func TestReport(t *testing.T) { + tests := []struct { + name string + err error + wantCode int + wantErr string + }{ + {name: "success", wantCode: ExitOK}, + {name: "help", err: ErrHelp, wantCode: ExitOK}, + {name: "usage", err: UsageError{"bad flag"}, wantCode: ExitUsage, wantErr: "error: bad flag"}, + {name: "wrapped usage", err: fmt.Errorf("x: %w", UsageError{"bad flag"}), wantCode: ExitUsage, wantErr: "error: bad flag"}, + {name: "interrupted", err: fmt.Errorf("list: %w", context.Canceled), wantCode: ExitError, wantErr: "interrupted"}, + {name: "other", err: errors.New("boom"), wantCode: ExitError, wantErr: "error: boom"}, + { + name: "proxy not reachable", + err: &url.Error{Op: "Get", URL: "https://127.0.0.1:1/api/v2/clients", Err: errors.New("dial tcp 127.0.0.1:1: connect: connection refused")}, + wantCode: ExitError, + wantErr: "error: could not reach the CLI proxy: dial tcp 127.0.0.1:1: connect: connection refused", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stderr bytes.Buffer + + code := Report(&Output{Stdout: &bytes.Buffer{}, Stderr: &stderr}, tt.err) + if code != tt.wantCode { + t.Errorf("exit code = %d, want %d", code, tt.wantCode) + } + + if tt.wantErr != "" && !bytes.Contains(stderr.Bytes(), []byte(tt.wantErr)) { + t.Errorf("stderr = %q, want it to contain %q", stderr.String(), tt.wantErr) + } + }) + } +} + +func TestParseFlags(t *testing.T) { + tests := []struct { + name string + args []string + want error + }{ + {name: "no arguments"}, + {name: "help", args: []string{"-h"}, want: ErrHelp}, + {name: "unknown flag", args: []string{"--nope"}, want: UsageError{}}, + {name: "positional argument", args: []string{"extra"}, want: UsageError{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := &Output{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} + + err := ParseFlags(flag.NewFlagSet("cmd", flag.ContinueOnError), tt.args, out) + + var usage UsageError + + switch { + case tt.want == nil && err != nil: + t.Errorf("error = %v, want none", err) + case errors.Is(tt.want, ErrHelp) && !errors.Is(err, ErrHelp): + t.Errorf("error = %v, want ErrHelp", err) + case errors.As(tt.want, &usage) && !errors.As(err, &usage): + t.Errorf("error = %v, want a UsageError", err) + } + }) + } +} diff --git a/internal/plugins/scaffold/templates/go/plugin/output.go.tmpl b/internal/plugins/scaffold/templates/go/plugin/output.go.tmpl new file mode 100644 index 000000000..4283ef7c4 --- /dev/null +++ b/internal/plugins/scaffold/templates/go/plugin/output.go.tmpl @@ -0,0 +1,80 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" +) + +// The output flags of the CLI, as variables. The CLI removes --json and +// --json-compact from the arguments and sets these instead. In agent mode it +// turns JSON on by itself. +const ( + EnvJSON = "AUTH0_PLUGIN_JSON" + EnvJSONCompact = "AUTH0_PLUGIN_JSON_COMPACT" +) + +// Output decides how a command prints. The rule for every command: the result +// goes to Stdout, as JSON when the user asked for it and as a table otherwise, +// and everything else (notes, errors) goes to Stderr. Scripts and agents read +// stdout, so keep it free of anything but the result. +type Output struct { + Stdout, Stderr io.Writer + JSON bool + Compact bool +} + +// NewOutput reads the output flags of the CLI. +func NewOutput(stdout, stderr io.Writer) *Output { + compact := os.Getenv(EnvJSONCompact) == "true" + + return &Output{ + Stdout: stdout, + Stderr: stderr, + JSON: compact || os.Getenv(EnvJSON) == "true", + Compact: compact, + } +} + +// PrintJSON writes a value as JSON, indented unless --json-compact was used. +func (o *Output) PrintJSON(value any) error { + var ( + data []byte + err error + ) + + if o.Compact { + data, err = json.Marshal(value) + } else { + data, err = json.MarshalIndent(value, "", " ") + } + + if err != nil { + return err + } + + _, err = fmt.Fprintf(o.Stdout, "%s\n", data) + + return err +} + +// PrintTable writes rows as a table with aligned columns. +func (o *Output) PrintTable(header []string, rows [][]string) error { + table := tabwriter.NewWriter(o.Stdout, 0, 0, 2, ' ', 0) + + _, _ = fmt.Fprintln(table, strings.Join(header, "\t")) + + for _, row := range rows { + _, _ = fmt.Fprintln(table, strings.Join(row, "\t")) + } + + return table.Flush() +} + +// Note writes a line for people to stderr, such as "No clients found.". +func (o *Output) Note(format string, args ...any) { + _, _ = fmt.Fprintf(o.Stderr, format+"\n", args...) +} diff --git a/internal/plugins/scaffold/templates/go/plugin/paginate.go.tmpl b/internal/plugins/scaffold/templates/go/plugin/paginate.go.tmpl new file mode 100644 index 000000000..2ded7f608 --- /dev/null +++ b/internal/plugins/scaffold/templates/go/plugin/paginate.go.tmpl @@ -0,0 +1,26 @@ +package plugin + +import ( + "context" + + "github.com/auth0/go-auth0/v3/management/core" +) + +// Collect walks every page of a list call and returns all the items. Pass it +// the result of any SDK List method: +// +// page, err := api.Clients.List(ctx, request) +// if err != nil { ... } +// clients, err := plugin.Collect(ctx, page) +// +// The result is never nil, so JSON output of an empty list is [] and not null. +func Collect[C comparable, T, R any](ctx context.Context, page *core.Page[C, T, R]) ([]T, error) { + items := []T{} + + iter := page.Iterator() + for iter.Next(ctx) { + items = append(items, iter.Current()) + } + + return items, iter.Err() +} diff --git a/internal/plugins/scaffold/templates/go/plugin/plugin.go.tmpl b/internal/plugins/scaffold/templates/go/plugin/plugin.go.tmpl new file mode 100644 index 000000000..e0ac0465e --- /dev/null +++ b/internal/plugins/scaffold/templates/go/plugin/plugin.go.tmpl @@ -0,0 +1,39 @@ +// Package plugin holds what every command of this plugin shares: how it talks +// to the Auth0 CLI (environment, output flags, exit codes, signals) and +// helpers for the SDK. Commands are in the commands package. +package plugin + +import ( + "context" + "os" + "os/signal" + "syscall" +) + +// Name is the name people type after `auth0`. +const Name = "__PLUGIN_NAME__" + +// Version is stamped at release time by GoReleaser (see .goreleaser.yaml). +var Version = "dev" + +// The exit codes of the plugin. The CLI reports every code but 0 as a failure. +const ( + ExitOK = 0 + ExitError = 1 // The command ran and failed. + ExitUsage = 2 // The arguments were wrong. +) + +// SignalContext returns a context that ends on Ctrl-C or SIGTERM. The CLI sends +// SIGTERM to a plugin that runs past its timeout. +func SignalContext() (context.Context, context.CancelFunc) { + return signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) +} + +// Invocation returns what people type to run a command. +func Invocation(command string) string { + if command == Name { + return "auth0 " + Name + } + + return "auth0 " + Name + " " + command +} diff --git a/internal/plugins/scaffold/templates/go/scripts/release/main.go.tmpl b/internal/plugins/scaffold/templates/go/scripts/release/main.go.tmpl new file mode 100644 index 000000000..d2697817b --- /dev/null +++ b/internal/plugins/scaffold/templates/go/scripts/release/main.go.tmpl @@ -0,0 +1,247 @@ +// Command release raises the version in plugin.json on a release branch, +// commits, pushes and opens a pull request, which runs the Release workflow +// when merged. It uses your own git and gh, so your credentials apply. +// +// Usage: go run ./scripts/release [--dry-run] patch|minor|major|X.Y.Z +package main + +import ( + "bytes" + "errors" + "flag" + "fmt" + "os" + "os/exec" + "strings" +) + +const manifest = "plugin.json" + +func main() { + dryRun := flag.Bool("dry-run", false, "print the steps and change nothing") + flag.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: go run ./scripts/release [--dry-run] patch|minor|major|X.Y.Z") + } + flag.Parse() + + if flag.NArg() != 1 { + flag.Usage() + os.Exit(2) + } + + if err := release(flag.Arg(0), *dryRun); err != nil { + fmt.Fprintln(os.Stderr, "release:", err) + os.Exit(1) + } +} + +func release(request string, dryRun bool) error { + for _, tool := range [][2]string{{"git", "https://git-scm.com"}, {"gh", "https://cli.github.com"}} { + if _, err := exec.LookPath(tool[0]); err != nil { + return fmt.Errorf("%s is not installed. Install it from %s", tool[0], tool[1]) + } + } + + if _, err := run("gh", "auth", "status"); err != nil { + return fmt.Errorf("gh is not logged in. Run: gh auth login\n%w", err) + } + + base, err := defaultBranch() + if err != nil { + return err + } + + if err := checkBranch(base); err != nil { + return err + } + + doc, err := os.ReadFile(manifest) + if err != nil { + return err + } + + current, err := readVersion(doc) + if err != nil { + return fmt.Errorf("%s: %w", manifest, err) + } + + version, err := nextVersion(current, request) + if err != nil { + return err + } + + tag, branch := "v"+version, "release/v"+version + + if err := checkFree(tag, branch); err != nil { + return err + } + + updated, err := setVersion(doc, version) + if err != nil { + return fmt.Errorf("%s: %w", manifest, err) + } + + fmt.Printf("Releasing %s (now %s) from %s:\n create branch %s, set the version in %s,\n commit \"chore: release %s\", push and open the pull request \"Release %s\"\n", + version, current, base, branch, manifest, tag, tag) + + if dryRun { + fmt.Println("Dry run: nothing was changed.") + + return nil + } + + return publish(base, branch, tag, updated) +} + +// publish creates the branch and the pull request. Before the push, a failure +// restores the repository. +func publish(base, branch, tag string, updated []byte) (err error) { + if _, err := run("git", "switch", "-c", branch); err != nil { + return err + } + + pushed := false + + defer func() { + if err != nil && !pushed { + _, _ = run("git", "restore", "--staged", "--worktree", "--", manifest) + _, _ = run("git", "switch", base) + _, _ = run("git", "branch", "-D", branch) + } + }() + + info, err := os.Stat(manifest) + if err != nil { + return err + } + + if err := os.WriteFile(manifest, updated, info.Mode().Perm()); err != nil { + return err + } + + for _, args := range [][]string{ + {"add", "--", manifest}, + {"commit", "-m", "chore: release " + tag}, + {"push", "--set-upstream", "origin", branch}, + } { + if _, err := run("git", args...); err != nil { + return err + } + } + + pushed = true + + body := "Merging this pull request releases " + tag + ": the Release workflow tests, builds, tags and publishes it." + + url, err := run("gh", "pr", "create", "--base", base, "--head", branch, "--title", "Release "+tag, "--body", body) + if err != nil { + return fmt.Errorf("the branch %s is pushed but the pull request was not opened. Open it with: gh pr create --head %s\n%w", branch, branch, err) + } + + if _, err := run("git", "switch", base); err != nil { + return err + } + + fmt.Printf("%s\nMerge the pull request to publish %s.\n", url, tag) + + return nil +} + +func defaultBranch() (string, error) { + out, err := run("git", "ls-remote", "--symref", "origin", "HEAD") + if err != nil { + return "", err + } + + first, _, _ := strings.Cut(out, "\n") + + ref, ok := strings.CutPrefix(first, "ref: refs/heads/") + if !ok { + return "", errors.New("origin did not say which branch is its default branch") + } + + name, _, _ := strings.Cut(ref, "\t") + + return name, nil +} + +// checkBranch requires a clean checkout of the default branch, level with origin. +func checkBranch(base string) error { + current, err := run("git", "branch", "--show-current") + if err != nil { + return err + } + + if current != base { + return fmt.Errorf("you are on %q. Switch to %s first: git switch %s", current, base, base) + } + + changes, err := run("git", "status", "--porcelain", "--untracked-files=no") + if err != nil { + return err + } + + if changes != "" { + return fmt.Errorf("there are uncommitted changes. Commit or stash them first:\n%s", changes) + } + + head, err := run("git", "rev-parse", "HEAD") + if err != nil { + return err + } + + remote, err := run("git", "ls-remote", "origin", "refs/heads/"+base) + if err != nil { + return err + } + + if tip, _, _ := strings.Cut(remote, "\t"); tip != head { + return fmt.Errorf("%s is not level with origin/%s (here %.8s, origin %.8s). Run git pull or git push first", base, base, head, tip) + } + + return nil +} + +// checkFree refuses a tag or branch that exists here or on origin. +func checkFree(tag, branch string) error { + tagRef, branchRef := "refs/tags/"+tag, "refs/heads/"+branch + + local, err := run("git", "for-each-ref", "--format=%(refname)", tagRef, branchRef) + if err != nil { + return err + } + + remote, err := run("git", "ls-remote", "origin", tagRef, branchRef) + if err != nil { + return err + } + + // Remote lines are "", plus ^{} on an annotated tag. + for _, line := range strings.Split(local+"\n"+remote, "\n") { + ref := strings.TrimSuffix(line[strings.LastIndex(line, "\t")+1:], "^{}") + if ref == tagRef || ref == branchRef { + return fmt.Errorf("%s already exists, here or on origin. Pick another version, or delete it first", ref) + } + } + + return nil +} + +// run executes a command and returns its trimmed output; an error carries its stderr. +func run(name string, args ...string) (string, error) { + var stdout, stderr bytes.Buffer + + cmd := exec.Command(name, args...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, &stdout, &stderr + + if err := cmd.Run(); err != nil { + message := strings.TrimSpace(stderr.String()) + if message == "" { + message = err.Error() + } + + return "", fmt.Errorf("%s %s: %s", name, args[0], message) + } + + return strings.TrimSpace(stdout.String()), nil +} diff --git a/internal/plugins/scaffold/templates/go/scripts/release/version.go.tmpl b/internal/plugins/scaffold/templates/go/scripts/release/version.go.tmpl new file mode 100644 index 000000000..a8cb2f1d7 --- /dev/null +++ b/internal/plugins/scaffold/templates/go/scripts/release/version.go.tmpl @@ -0,0 +1,146 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "regexp" + "strconv" + "strings" +) + +// semver matches a semantic version without build metadata. +var semver = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?$`) + +// nextVersion returns the version that request (patch, minor, major or an exact +// version) leads to. +func nextVersion(current, request string) (string, error) { + parts := semver.FindStringSubmatch(current) + if parts == nil { + return "", fmt.Errorf("the version %q in plugin.json is not a semantic version such as 1.2.3", current) + } + + switch request { + case "patch", "minor", "major": + if parts[4] != "" { + return "", fmt.Errorf("the current version %s is a prerelease, so %q is ambiguous: give the exact version, for example %s.%s.%s", + current, request, parts[1], parts[2], parts[3]) + } + + var n [3]uint64 + + for i := range n { + // Thirty-two bits keep the increment below from overflowing. + v, err := strconv.ParseUint(parts[i+1], 10, 32) + if err != nil { + return "", fmt.Errorf("the version %s in plugin.json has a number too large to raise", current) + } + + n[i] = v + } + + switch request { + case "major": + n = [3]uint64{n[0] + 1, 0, 0} + case "minor": + n = [3]uint64{n[0], n[1] + 1, 0} + default: + n[2]++ + } + + return fmt.Sprintf("%d.%d.%d", n[0], n[1], n[2]), nil + } + + if !semver.MatchString(request) { + hint := "" + if strings.HasPrefix(request, "v") && semver.MatchString(request[1:]) { + hint = fmt.Sprintf(" Write %s without the v.", request[1:]) + } + + return "", fmt.Errorf("%q is not patch, minor, major or a semantic version such as 1.2.3 (no build metadata).%s", request, hint) + } + + if request == current { + return "", fmt.Errorf("%s is already the version in plugin.json", current) + } + + return request, nil +} + +// versionSpan finds the top-level "version" string of a JSON object: its value +// and the byte range of the value, quotes included. +func versionSpan(doc []byte) (value string, start, end int, err error) { + decoder := json.NewDecoder(bytes.NewReader(doc)) + + if token, err := decoder.Token(); err != nil || token != json.Delim('{') { + return "", 0, 0, errors.New("the file is not a JSON object") + } + + found := false + + for decoder.More() { + key, err := decoder.Token() + if err != nil { + return "", 0, 0, err + } + + var raw json.RawMessage + if err := decoder.Decode(&raw); err != nil { + return "", 0, 0, err + } + + if key != "version" { + continue + } + + if found { + return "", 0, 0, errors.New(`the file has two "version" keys`) + } + + if err := json.Unmarshal(raw, &value); err != nil { + return "", 0, 0, errors.New(`"version" is not a string`) + } + + end = int(decoder.InputOffset()) + start = end - len(raw) + found = true + + if !bytes.Equal(doc[start:end], raw) { + return "", 0, 0, errors.New(`cannot locate "version" in the file`) + } + } + + if !found { + return "", 0, 0, errors.New(`the file has no "version" key`) + } + + return value, start, end, nil +} + +func readVersion(doc []byte) (string, error) { + value, _, _, err := versionSpan(doc) + + return value, err +} + +// setVersion replaces the top-level "version" and leaves every other byte as it +// was, so the diff of a release is one line. +func setVersion(doc []byte, version string) ([]byte, error) { + if !semver.MatchString(version) { + return nil, fmt.Errorf("%q is not a semantic version", version) + } + + _, start, end, err := versionSpan(doc) + if err != nil { + return nil, err + } + + out := append(append(append([]byte{}, doc[:start]...), strconv.Quote(version)...), doc[end:]...) + + if got, err := readVersion(out); err != nil || got != version { + return nil, errors.New(`could not set "version" in the file`) + } + + return out, nil +} diff --git a/internal/plugins/scaffold/templates/go/scripts/release/version_test.go.tmpl b/internal/plugins/scaffold/templates/go/scripts/release/version_test.go.tmpl new file mode 100644 index 000000000..63ae7a5e1 --- /dev/null +++ b/internal/plugins/scaffold/templates/go/scripts/release/version_test.go.tmpl @@ -0,0 +1,83 @@ +package main + +import ( + "strings" + "testing" +) + +func TestNextVersion(t *testing.T) { + const bad = "not patch, minor, major" + + tests := []struct { + name, current, request, want, wantErr string + }{ + {"patch", "1.2.3", "patch", "1.2.4", ""}, + {"minor resets patch", "1.2.3", "minor", "1.3.0", ""}, + {"major resets the rest", "1.2.3", "major", "2.0.0", ""}, + {"exact version", "1.2.3", "2.5.0", "2.5.0", ""}, + {"release after a prerelease", "2.0.0-rc.1", "2.0.0", "2.0.0", ""}, + {"same version", "1.2.3", "1.2.3", "", "already the version"}, + {"keyword on a prerelease", "2.0.0-rc.1", "patch", "", "is a prerelease"}, + {"leading v", "1.2.3", "v1.2.4", "", "Write 1.2.4 without the v"}, + {"build metadata", "1.2.3", "1.2.4+build", "", bad}, + {"leading zero", "1.2.3", "1.02.4", "", bad}, + {"newline", "1.2.3", "1.2.4\n", "", bad}, + {"invalid current", "dev", "patch", "", "not a semantic version"}, + {"number too large", "1.2.99999999999", "patch", "", "too large"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := nextVersion(test.current, test.request) + if !errorMatches(err, test.wantErr) || got != test.want { + t.Fatalf("got %q, %v; want %q, an error with %q", got, err, test.want, test.wantErr) + } + }) + } +} + +func TestSetVersion(t *testing.T) { + tests := []struct { + name, doc, want, wantErr string + }{ + { + "keeps every other byte", + "{\n \"id\": \"x\",\n \"version\": \"1.0.0\",\n \"engines\": {\n \"version\": \"9\"\n }\n}\n", + "{\n \"id\": \"x\",\n \"version\": \"1.1.0\",\n \"engines\": {\n \"version\": \"9\"\n }\n}\n", + "", + }, + {"tabs and odd spacing", "{\t\"version\" :\n\t\"1.0.0\" }", "{\t\"version\" :\n\t\"1.1.0\" }", ""}, + {"compact", `{"a":["version"],"version":"1.0.0"}`, `{"a":["version"],"version":"1.1.0"}`, ""}, + {"escaped value", `{"version":"1\u002e0.0"}`, `{"version":"1.1.0"}`, ""}, + {"only a nested version", `{"engines":{"version":"1.0.0"}}`, "", `no "version" key`}, + {"two version keys", `{"version":"1.0.0","version":"1.0.1"}`, "", "two"}, + {"not a string", `{"version":1}`, "", "not a string"}, + {"not an object", `["version"]`, "", "not a JSON object"}, + {"truncated", `{"a":`, "", "EOF"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := setVersion([]byte(test.doc), "1.1.0") + if !errorMatches(err, test.wantErr) || string(got) != test.want { + t.Fatalf("got %q, %v; want %q, an error with %q", got, err, test.want, test.wantErr) + } + }) + } + + for _, bad := range []string{"dev", `1.0.0","x":"`} { + if _, err := setVersion([]byte(`{"version":"1.0.0"}`), bad); err == nil { + t.Errorf("%q was written", bad) + } + } +} + +// errorMatches reports whether err is nil when want is empty, and otherwise +// holds want. +func errorMatches(err error, want string) bool { + if want == "" { + return err == nil + } + + return err != nil && strings.Contains(err.Error(), want) +} diff --git a/internal/plugins/scaffold/templates/ts/.github/actions/build-archive/action.yml.tmpl b/internal/plugins/scaffold/templates/ts/.github/actions/build-archive/action.yml.tmpl new file mode 100644 index 000000000..a9df2a928 --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/.github/actions/build-archive/action.yml.tmpl @@ -0,0 +1,20 @@ +name: Build archive +description: Compile the plugin for one platform and upload its archive as a workflow artifact. + +inputs: + platform: + description: The platform in plugin.json, such as linux-x64-musl. + required: true + +runs: + using: composite + steps: + - shell: bash + env: + PLATFORM: ${{ inputs.platform }} + run: bash .github/scripts/build-archive.sh "$PLATFORM" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: archive-${{ inputs.platform }} + path: out/* + if-no-files-found: error diff --git a/internal/plugins/scaffold/templates/ts/.github/actions/read-version/action.yml.tmpl b/internal/plugins/scaffold/templates/ts/.github/actions/read-version/action.yml.tmpl new file mode 100644 index 000000000..f7744ad4d --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/.github/actions/read-version/action.yml.tmpl @@ -0,0 +1,26 @@ +name: Read version +description: Read the version from plugin.json and report what exists for its tag. + +outputs: + version: + description: Version, such as 1.2.3. + value: ${{ steps.read.outputs.version }} + tag: + description: Tag, such as v1.2.3. + value: ${{ steps.read.outputs.tag }} + tag-exists: + description: true when the tag exists. + value: ${{ steps.read.outputs.tag-exists }} + release-exists: + description: true when the release is complete. + value: ${{ steps.read.outputs.release-exists }} + +runs: + using: composite + steps: + - id: read + shell: bash + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: bash .github/scripts/read-version.sh >> "$GITHUB_OUTPUT" diff --git a/internal/plugins/scaffold/templates/ts/.github/dependabot.yml.tmpl b/internal/plugins/scaffold/templates/ts/.github/dependabot.yml.tmpl new file mode 100644 index 000000000..699b3b286 --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/.github/dependabot.yml.tmpl @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: bun + directory: / + schedule: + interval: weekly + - package-ecosystem: github-actions + directories: [/, /.github/actions/*] # The workflows and the composite actions. + schedule: + interval: weekly diff --git a/internal/plugins/scaffold/templates/ts/.github/scripts/build-archive.sh.tmpl b/internal/plugins/scaffold/templates/ts/.github/scripts/build-archive.sh.tmpl new file mode 100644 index 000000000..deec85995 --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/.github/scripts/build-archive.sh.tmpl @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Usage: build-archive.sh PLATFORM +# Compiles the plugin for one platform of plugin.json (Bun target bun-PLATFORM) +# into out/__.tar.gz, or .zip for Windows. The CLI wants +# exactly the executable and plugin.json at the root. +set -euo pipefail +# shellcheck source=lib.sh +# shellcheck source-path=SCRIPTDIR +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +platform=${1:?usage: build-archive.sh PLATFORM} + +version=$(project_version) +name=__PLUGIN_NAME__ +binary=$name +extension=tar.gz +if [[ $platform == windows-* ]]; then + binary=$name.exe + extension=zip +fi +archive="out/${name}_${version}_${platform}.${extension}" + +rm -rf stage +mkdir -p stage out +bun build --compile --target="bun-$platform" --outfile "stage/$binary" src/index.ts +cp plugin.json stage/ + +if [[ $extension == zip ]]; then + rm -f "$archive" + (cd stage && zip -q -X "../$archive" "$binary" plugin.json) +else + tar -czf "$archive" -C stage "$binary" plugin.json +fi +echo "$archive" diff --git a/internal/plugins/scaffold/templates/ts/.github/scripts/lib.sh.tmpl b/internal/plugins/scaffold/templates/ts/.github/scripts/lib.sh.tmpl new file mode 100644 index 000000000..f9898770b --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/.github/scripts/lib.sh.tmpl @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Helpers for the release scripts. Source this file, do not run it. + +# A semantic version without build metadata: the tag and the archive names carry it. +SEMVER_RE='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?$' + +# Prints the version of plugin.json, or fails when it is missing or invalid or +# when package.json has another version (it need not have one). +project_version() { + local version + version=$(jq -er '.version | strings' plugin.json) || { + echo "plugin.json has no version." >&2 + return 1 + } + [[ $version =~ $SEMVER_RE ]] || { + echo "The version in plugin.json is not a semantic version such as 1.2.3 (no build metadata)." >&2 + return 1 + } + if [[ -f package.json ]] && jq -e 'has("version")' package.json >/dev/null; then + local package + package=$(jq -r '.version' package.json) + [[ $package == "$version" ]] || { + echo "plugin.json has version $version but package.json has $package. Make them the same." >&2 + return 1 + } + fi + echo "$version" +} diff --git a/internal/plugins/scaffold/templates/ts/.github/scripts/publish-release.sh.tmpl b/internal/plugins/scaffold/templates/ts/.github/scripts/publish-release.sh.tmpl new file mode 100644 index 000000000..948b4ddb5 --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/.github/scripts/publish-release.sh.tmpl @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Usage: publish-release.sh DIR +# Writes checksums.txt for the archives in DIR, one per platform of plugin.json, +# then creates the GitHub release (and with it the tag at GITHUB_SHA) or completes +# one left unfinished. checksums.txt goes up last: read-version.sh takes it as +# the sign of a complete release. Needs gh with GH_TOKEN and GH_REPO. +set -euo pipefail +# shellcheck source=lib.sh +# shellcheck source-path=SCRIPTDIR +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +dir=${1:?usage: publish-release.sh DIR} +version=$(project_version) +tag="v$version" +name=__PLUGIN_NAME__ + +archives=() +while IFS= read -r platform; do + extension=tar.gz + [[ $platform == windows-* ]] && extension=zip + archive="${name}_${version}_${platform}.${extension}" + [[ -f $dir/$archive ]] || { + echo "The archive $dir/$archive is missing." >&2 + exit 1 + } + archives+=("$archive") +done < <(jq -r '.platforms[]' plugin.json) + +cd "$dir" +if command -v sha256sum >/dev/null; then + sha256sum -- "${archives[@]}" >checksums.txt +else + shasum -a 256 -- "${archives[@]}" >checksums.txt +fi + +if gh release view "$tag" >/dev/null 2>&1; then + gh release upload "$tag" --clobber -- "${archives[@]}" +else + gh release create "$tag" --target "${GITHUB_SHA:?GITHUB_SHA is not set}" --title "$tag" --generate-notes -- "${archives[@]}" +fi +gh release upload "$tag" --clobber -- checksums.txt +echo "Published $tag." diff --git a/internal/plugins/scaffold/templates/ts/.github/scripts/read-version.sh.tmpl b/internal/plugins/scaffold/templates/ts/.github/scripts/read-version.sh.tmpl new file mode 100644 index 000000000..37ecae8a7 --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/.github/scripts/read-version.sh.tmpl @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Prints version, tag, tag-exists and release-exists as key=value lines for +# $GITHUB_OUTPUT. A release exists when it holds checksums.txt and an archive +# for every platform, so one that stopped half way counts as missing and the +# workflow finishes it. Needs the git tags, and gh with GH_TOKEN and GH_REPO. +set -euo pipefail +# shellcheck source=lib.sh +# shellcheck source-path=SCRIPTDIR +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +version=$(project_version) +tag="v$version" +sha=${GITHUB_SHA:-$(git rev-parse HEAD)} + +tag_exists=false +tag_sha= +if tag_sha=$(git rev-parse -q --verify "refs/tags/$tag^{commit}"); then + tag_exists=true +fi + +release_exists=false +if out=$(gh release view "$tag" --json assets --jq '[.assets[].name]' 2>&1); then + wanted=$(jq '(.platforms | length) + 1' plugin.json) + if jq -e --argjson n "$wanted" 'index("checksums.txt") != null and length >= $n' <<<"$out" >/dev/null; then + release_exists=true + fi +elif [[ $out != *"release not found"* ]]; then + echo "$out" >&2 + exit 1 +fi + +# A tag somewhere else means plugin.json changed after the tag was made, and +# building this commit under that tag would publish the wrong code. +if [[ $release_exists == false && $tag_exists == true && $tag_sha != "$sha" ]]; then + echo "The tag $tag is at $tag_sha, but this run is for $sha. Raise the version in plugin.json, or re-run the workflow of the commit the tag points to." >&2 + exit 1 +fi + +echo "version=$version" +echo "tag=$tag" +echo "tag-exists=$tag_exists" +echo "release-exists=$release_exists" diff --git a/internal/plugins/scaffold/templates/ts/.github/workflows/ci.yml.tmpl b/internal/plugins/scaffold/templates/ts/.github/workflows/ci.yml.tmpl new file mode 100644 index 000000000..8c65e3f41 --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/.github/workflows/ci.yml.tmpl @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_call: # The Release workflow runs the tests before it publishes. + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.4 + - run: bun install --frozen-lockfile + - run: bun run typecheck + - run: bun test + - run: bun run build diff --git a/internal/plugins/scaffold/templates/ts/.github/workflows/release.yml.tmpl b/internal/plugins/scaffold/templates/ts/.github/workflows/release.yml.tmpl new file mode 100644 index 000000000..5c68fe6f7 --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/.github/workflows/release.yml.tmpl @@ -0,0 +1,78 @@ +name: Release + +# Runs when a change to the version in plugin.json reaches main, which is what +# merging the pull request from `bun run release` does. Run it by hand to finish +# a release that failed half way. +on: + push: + branches: [main] + paths: [plugin.json] + workflow_dispatch: + +permissions: + contents: read + +jobs: + version: + # A manual run on another branch is skipped, and so is every job that needs it. + if: github.ref_name == github.event.repository.default_branch + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.read.outputs.tag }} + release-exists: ${{ steps.read.outputs.release-exists }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # The tags are needed to see what exists. + persist-credentials: false + - id: read + uses: ./.github/actions/read-version + + # When the release exists, this job is skipped and so is every job that needs it. + ci: + needs: version + if: needs.version.outputs.release-exists != 'true' + uses: ./.github/workflows/ci.yml + + build: + needs: [version, ci] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The platforms of plugin.json. Each builds for the Bun target bun-. + platform: [darwin-arm64, darwin-x64, linux-arm64, linux-x64, linux-arm64-musl, linux-x64-musl, windows-x64] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.4 + - run: bun install --frozen-lockfile + - uses: ./.github/actions/build-archive + with: + platform: ${{ matrix.platform }} + + publish: + needs: [version, build] + runs-on: ubuntu-latest + permissions: + contents: write # Creates the tag and the release, and uploads the assets. + concurrency: + group: release-${{ needs.version.outputs.tag }} + cancel-in-progress: false + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: archive-* + merge-multiple: true + path: out + - name: Publish the release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: bash .github/scripts/publish-release.sh out diff --git a/internal/plugins/scaffold/templates/ts/.gitignore.tmpl b/internal/plugins/scaffold/templates/ts/.gitignore.tmpl new file mode 100644 index 000000000..c848c3a1b --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/.gitignore.tmpl @@ -0,0 +1,7 @@ +node_modules/ +/__PLUGIN_NAME__ +/__PLUGIN_NAME__.exe +/dist/ +# What the release workflow writes. +/stage/ +/out/ diff --git a/internal/plugins/scaffold/templates/ts/README.md.tmpl b/internal/plugins/scaffold/templates/ts/README.md.tmpl new file mode 100644 index 000000000..3d1da86b1 --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/README.md.tmpl @@ -0,0 +1,95 @@ +# __PLUGIN_NAME__ + +A plugin for the [Auth0 CLI](https://github.com/auth0/auth0-cli), written in TypeScript with the [auth0](https://www.npmjs.com/package/auth0) Node package and compiled to one executable with [Bun](https://bun.sh). It runs as `auth0 __PLUGIN_NAME__` and calls the Management API through the CLI, which never gives the plugin a real token. + +The sample command lists the clients of your tenant. Make it your own. + +## Requirements + +- Bun 1.4 or later +- The Auth0 CLI, with plugin development turned on: `export AUTH0_CLI_PLUGIN_DEV_MODE=true` + +## Layout + +`src/index.ts` is the entry point and `src/run.ts` finds the command and turns errors into exit codes. `src/commands/` holds one file per command and the list of them. `src/lib/` holds the shared code: environment, SDK client, output, error text with exit codes, flags and pagination. `plugin.json` is the manifest: commands, flags and the Management API scopes each command may use. + +## Build and run + +```sh +bun install # also writes bun.lock; commit it +bun run build # writes ./__PLUGIN_NAME__ for this machine +``` + +Link the build to the CLI once. The link reads `plugin.json` and the executable again on every run, so after a change you only rebuild. + +```sh +auth0 plugin dev link +auth0 plugin approve __PLUGIN_NAME__ # a person reviews the scopes; repeat when they change +auth0 __PLUGIN_NAME__ +auth0 __PLUGIN_NAME__ --json +``` + +`bun test` runs the tests, `bun run typecheck` checks the types, and `auth0 plugin dev validate` checks `plugin.json`. + +## Scopes + +The CLI proxy refuses every call that needs a scope the command did not declare, and the plugin then says which scope is missing. Declare scopes under `permissions.scopes` of the command in `plugin.json`, then approve again. The sample command needs `read:clients`. + +## Add a command + +1. Copy `src/commands/clients.ts` to `src/commands/.ts`. Change the name, the help text and the code in `run`. +2. Import it in `src/commands/index.ts` and add it to the list. +3. Add the command to `commands` in `plugin.json`, with its description and the scopes it needs. Rebuild. A command that is not the root command runs as `auth0 __PLUGIN_NAME__ `. + +Flags: declare them with `parseFlags` in the command and list them under `flags` in `plugin.json`; the flags of the CLI itself (`--json`, `--tenant` and so on) are handled for you. Use `out.json` to choose between `out.printJson` and `out.printTable`, `collect` for every page of a list call, and pass `signal` to every SDK call so that Ctrl-C and the CLI's timeout stop it. Results go to stdout, anything else to stderr. + +## Environment + +The CLI sets these for every run. Only `src/lib/env.ts` reads them. + +| Variable | Meaning | +| --- | --- | +| `AUTH0_PLUGIN_DOMAIN` | Address of the CLI proxy (`host:port`) | +| `AUTH0_PLUGIN_MANAGEMENT_TOKEN` | A secret for this run that lets the plugin use the proxy | +| `AUTH0_PLUGIN_JSON`, `AUTH0_PLUGIN_JSON_COMPACT` | `true` when the user asked for JSON output (agent mode turns it on) | +| `AUTH0_PLUGIN_TENANT` | The tenant the run is for | +| `NODE_EXTRA_CA_CERTS` | The certificate of the proxy, which Bun trusts without code | + +Exit codes: 0 on success, 1 when the command fails, 2 when the arguments are wrong. + +## Releasing + +Edit nothing by hand except `version` in `plugin.json` (a `version` in `package.json` is kept in step). Run one command, then merge the pull request it opens. + +```sh +bun run release patch # or minor, or major +bun run release 1.2.3 # an exact version +bun run release patch --dry-run # show the steps, change nothing +``` + +It needs `git`, `gh` (`gh auth login`) and a clean `main` that matches `origin`. It creates `release/v`, raises the version, commits `chore: release v` with your own git settings, pushes and opens the pull request. A keyword refuses a prerelease version; give the exact version then. + +Merging runs the Release workflow, which: + +1. runs the tests (`ci.yml`) on the merged commit, +2. compiles one executable per platform with `bun build --compile`, +3. packs each with `plugin.json` into `__.tar.gz` (`.zip` on Windows), +4. creates the release `v` at that commit with the archives, `checksums.txt` and generated notes. + +If a run fails half way, re-run it or run the workflow by hand on `main`: it finishes what is missing and does nothing once the release is complete. The first push of a new project releases the scaffolded version. The workflow listens to `main`; change that in `release.yml` for another branch name. + +| File | What it does | +| --- | --- | +| `scripts/release.ts` | The program behind `bun run release`; `scripts/version.ts` holds the version rules | +| `.github/workflows/ci.yml` | Install, typecheck, test and build; the release calls it too | +| `.github/workflows/release.yml` | The steps above | +| `.github/actions/read-version/` | Reads the version and tells whether its tag and release exist (`.github/scripts/`) | +| `.github/actions/build-archive/` | Compiles and packs one platform (`build-archive.sh`), uploads the archive | + +Every platform of `plugin.json` is built with `bun build --compile --target=bun-`, for example `bun-linux-x64-musl`. + +The workflow stops at the GitHub release. Signing, attestations and publishing to the plugin catalog come later. + +## License + +__LICENSE__ diff --git a/internal/plugins/scaffold/templates/ts/package.json.tmpl b/internal/plugins/scaffold/templates/ts/package.json.tmpl new file mode 100644 index 000000000..cce45a7ff --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/package.json.tmpl @@ -0,0 +1,19 @@ +{ + "name": "__PLUGIN_NAME__", + "license": "__LICENSE__", + "private": true, + "type": "module", + "scripts": { + "build": "bun build --compile --outfile __PLUGIN_NAME__ src/index.ts", + "test": "bun test", + "release": "bun run scripts/release.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "auth0": "^7.6.0" + }, + "devDependencies": { + "@types/bun": "^1.4.4", + "typescript": "^7.0.2" + } +} diff --git a/internal/plugins/scaffold/templates/ts/scripts/release.ts.tmpl b/internal/plugins/scaffold/templates/ts/scripts/release.ts.tmpl new file mode 100644 index 000000000..000dc96b7 --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/scripts/release.ts.tmpl @@ -0,0 +1,148 @@ +// Raises the version in plugin.json (and package.json) on a release branch, +// commits, pushes and opens a pull request, which runs the Release workflow +// when merged. It uses your own git and gh, so your credentials apply. +// +// Usage: bun run release [--dry-run] patch|minor|major|X.Y.Z +import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { nextVersion, readVersion, setVersion } from "./version"; + +function run(command: string, ...args: string[]): string { + const result = Bun.spawnSync([command, ...args], { stdin: "inherit", stdout: "pipe", stderr: "pipe" }); + if (!result.success) { + throw new Error(`${command} ${args[0]}: ${result.stderr.toString().trim() || `exit code ${result.exitCode}`}`); + } + return result.stdout.toString().trim(); +} + +function read(file: string): [string, string | undefined] { + const text = readFileSync(file, "utf8"); + try { + return [text, readVersion(text)]; + } catch (error) { + throw new Error(`${file}: ${(error as Error).message}`); + } +} + +function checkTools(): void { + for (const [tool, url] of [["git", "https://git-scm.com"], ["gh", "https://cli.github.com"]] as const) { + if (!Bun.which(tool)) throw new Error(`${tool} is not installed. Install it from ${url}`); + } + try { + run("gh", "auth", "status"); + } catch (error) { + throw new Error(`gh is not logged in. Run: gh auth login\n${(error as Error).message}`); + } +} + +function defaultBranch(): string { + const first = run("git", "ls-remote", "--symref", "origin", "HEAD").split("\n")[0] ?? ""; + const match = /^ref: refs\/heads\/([^\t]+)\t/.exec(first); + if (!match?.[1]) throw new Error("origin did not say which branch is its default branch"); + return match[1]; +} + +// Requires a clean checkout of the default branch, level with origin. +function checkBranch(base: string): void { + const current = run("git", "branch", "--show-current"); + if (current !== base) throw new Error(`you are on ${current || "a detached HEAD"}. Switch to ${base} first: git switch ${base}`); + + const changes = run("git", "status", "--porcelain", "--untracked-files=no"); + if (changes !== "") throw new Error(`there are uncommitted changes. Commit or stash them first:\n${changes}`); + + const head = run("git", "rev-parse", "HEAD"); + const tip = run("git", "ls-remote", "origin", `refs/heads/${base}`).split("\t")[0] ?? ""; + if (tip !== head) { + throw new Error(`${base} is not level with origin/${base} (here ${head.slice(0, 8)}, origin ${tip.slice(0, 8)}). Run git pull or git push first`); + } +} + +// Refuses a tag or branch that exists here or on origin. +function checkFree(tag: string, branch: string): void { + const refs = [`refs/tags/${tag}`, `refs/heads/${branch}`]; + const local = run("git", "for-each-ref", "--format=%(refname)", ...refs); + const remote = run("git", "ls-remote", "origin", ...refs); + + // Remote lines are "", plus ^{} on an annotated tag. + for (const line of `${local}\n${remote}`.split("\n")) { + const ref = (line.split("\t").pop() ?? "").replace(/\^\{\}$/, ""); + if (refs.includes(ref)) throw new Error(`${ref} already exists, here or on origin. Pick another version, or delete it first`); + } +} + +// Creates the branch and the pull request. Before the push, a failure restores +// the repository. +function publish(base: string, branch: string, tag: string, edited: Map): void { + run("git", "switch", "-c", branch); + try { + for (const [file, text] of edited) writeFileSync(file, text, { mode: statSync(file).mode & 0o777 }); + run("git", "add", "--", ...edited.keys()); + run("git", "commit", "-m", `chore: release ${tag}`); + run("git", "push", "--set-upstream", "origin", branch); + } catch (error) { + for (const undo of [["restore", "--staged", "--worktree", "--", ...edited.keys()], ["switch", base], ["branch", "-D", branch]]) { + try { + run("git", ...undo); + } catch { + // Keep going to leave as little behind as possible. + } + } + throw error; + } + + const body = `Merging this pull request releases ${tag}: the Release workflow tests, builds, tags and publishes it.`; + let url: string; + try { + url = run("gh", "pr", "create", "--base", base, "--head", branch, "--title", `Release ${tag}`, "--body", body); + } catch (error) { + throw new Error(`the branch ${branch} is pushed but the pull request was not opened. Open it with: gh pr create --head ${branch}\n${(error as Error).message}`); + } + run("git", "switch", base); + console.log(`${url}\nMerge the pull request to publish ${tag}.`); +} + +function release(request: string, dryRun: boolean): void { + checkTools(); + const base = defaultBranch(); + checkBranch(base); + + const [plugin, current] = read("plugin.json"); + if (current === undefined) throw new Error('plugin.json: the file has no "version" key'); + + // package.json need not have a version, but when it does, it follows plugin.json. + const [other, otherVersion] = existsSync("package.json") ? read("package.json") : ["", undefined]; + if (otherVersion !== undefined && otherVersion !== current) { + throw new Error(`plugin.json has version ${current} but package.json has ${otherVersion}. Make them the same first`); + } + + const version = nextVersion(current, request); + const tag = `v${version}`; + const branch = `release/${tag}`; + checkFree(tag, branch); + + const edited = new Map([["plugin.json", setVersion(plugin, version)]]); + if (otherVersion !== undefined) edited.set("package.json", setVersion(other, version)); + console.log( + `Releasing ${version} (now ${current}) from ${base}:\n create branch ${branch}, set the version in ${[...edited.keys()].join(" and ")},\n` + + ` commit "chore: release ${tag}", push and open the pull request "Release ${tag}"`, + ); + if (dryRun) { + console.log("Dry run: nothing was changed."); + return; + } + publish(base, branch, tag, edited); +} + +const args = process.argv.slice(2); +const dryRun = args.includes("--dry-run"); +const positional = args.filter((arg) => arg !== "--dry-run"); +const request = positional[0]; +if (request === undefined || positional.length > 1 || request.startsWith("-")) { + console.error("usage: bun run release [--dry-run] patch|minor|major|X.Y.Z"); + process.exit(2); +} +try { + release(request, dryRun); +} catch (error) { + console.error(`release: ${(error as Error).message}`); + process.exit(1); +} diff --git a/internal/plugins/scaffold/templates/ts/scripts/version.test.ts.tmpl b/internal/plugins/scaffold/templates/ts/scripts/version.test.ts.tmpl new file mode 100644 index 000000000..58396f3eb --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/scripts/version.test.ts.tmpl @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { nextVersion, readVersion, setVersion } from "./version"; + +describe("nextVersion", () => { + const cases: [string, string, string, string][] = [ + ["patch", "1.2.3", "patch", "1.2.4"], + ["minor resets patch", "1.2.3", "minor", "1.3.0"], + ["major resets minor and patch", "1.2.3", "major", "2.0.0"], + ["exact version", "1.2.3", "2.5.0", "2.5.0"], + ["release after a prerelease", "2.0.0-rc.1", "2.0.0", "2.0.0"], + ["a number beyond 64 bits", "1.2.99999999999999999999", "patch", "1.2.100000000000000000000"], + ]; + for (const [name, current, request, want] of cases) { + test(name, () => expect(nextVersion(current, request)).toBe(want)); + } + + const failures: [string, string, string, string][] = [ + ["same version", "1.2.3", "1.2.3", "already the current version"], + ["keyword on a prerelease", "2.0.0-rc.1", "patch", "is a prerelease"], + ["leading v", "1.2.3", "v1.2.4", "Write 1.2.4 without the v"], + ["build metadata", "1.2.3", "1.2.4+build", "not patch, minor, major"], + ["leading zero", "1.2.3", "1.02.4", "not patch, minor, major"], + ["newline", "1.2.3", "1.2.4\n", "not patch, minor, major"], + ["invalid current", "dev", "patch", "not a semantic version"], + ]; + for (const [name, current, request, message] of failures) { + test(name, () => expect(() => nextVersion(current, request)).toThrow(message)); + } +}); + +describe("setVersion", () => { + const cases: [string, string, string][] = [ + [ + "keeps every other character", + '{\n "id": "x",\n "version": "1.0.0",\n "engines": {\n "version": "9"\n }\n}\n', + '{\n "id": "x",\n "version": "1.1.0",\n "engines": {\n "version": "9"\n }\n}\n', + ], + ["tabs and odd spacing", '{\t"version" :\n\t"1.0.0" }', '{\t"version" :\n\t"1.1.0" }'], + ["compact", '{"a":["version"],"version":"1.0.0"}', '{"a":["version"],"version":"1.1.0"}'], + ["escaped value", '{"version":"1\\u002e0.0"}', '{"version":"1.1.0"}'], + ["a key that holds a quote", '{"a\\"b":"x","version":"1.0.0"}', '{"a\\"b":"x","version":"1.1.0"}'], + ]; + for (const [name, doc, want] of cases) { + test(name, () => expect(setVersion(doc, "1.1.0")).toBe(want)); + } + + const failures: [string, string, string][] = [ + ["only a nested version", '{"engines":{"version":"1.0.0"}}', 'no "version" key'], + ["two version keys", '{"version":"1.0.0","version":"1.0.1"}', "two"], + ["not a string", '{"version":1}', "not a string"], + ["not an object", '["version"]', "not a JSON object"], + ["truncated", '{"version":"1.0', "JSON"], + ]; + for (const [name, doc, message] of failures) { + test(name, () => expect(() => setVersion(doc, "1.1.0")).toThrow(message)); + } + + test("a value that is not a semantic version is never written", () => { + for (const bad of ["dev", '1.0.0","x":"']) { + expect(() => setVersion('{"version":"1.0.0"}', bad)).toThrow("not a semantic version"); + } + }); + + test("readVersion", () => { + expect(readVersion('{"id":"x","version":"3.4.5"}')).toBe("3.4.5"); + expect(readVersion('{"engines":{"version":"3.4.5"}}')).toBeUndefined(); + }); +}); diff --git a/internal/plugins/scaffold/templates/ts/scripts/version.ts.tmpl b/internal/plugins/scaffold/templates/ts/scripts/version.ts.tmpl new file mode 100644 index 000000000..ea9290f1d --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/scripts/version.ts.tmpl @@ -0,0 +1,90 @@ +// The version rules of the release command. + +// A semantic version without build metadata. +const semver = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?$/; + +/** Returns the version that request leads to: patch, minor, major or an exact version. */ +export function nextVersion(current: string, request: string): string { + const parts = semver.exec(current); + if (!parts) { + throw new Error(`the version "${current}" is not a semantic version such as 1.2.3`); + } + const [, major = "", minor = "", patch = "", prerelease = ""] = parts; + + if (request === "patch" || request === "minor" || request === "major") { + if (prerelease) { + throw new Error( + `the current version ${current} is a prerelease, so "${request}" is ambiguous: give the exact version, for example ${major}.${minor}.${patch}`, + ); + } + const up = (n: string) => (BigInt(n) + 1n).toString(); + if (request === "major") return `${up(major)}.0.0`; + if (request === "minor") return `${major}.${up(minor)}.0`; + return `${major}.${minor}.${up(patch)}`; + } + + if (!semver.test(request)) { + const hint = request.startsWith("v") && semver.test(request.slice(1)) ? ` Write ${request.slice(1)} without the v.` : ""; + throw new Error(`"${request}" is not patch, minor, major or a semantic version such as 1.2.3 (no build metadata).${hint}`); + } + if (request === current) { + throw new Error(`${current} is already the current version`); + } + return request; +} + +type Span = { value: string; start: number; end: number }; + +const token = /"(?:[^"\\]|\\.)*"|[{[]|[}\]]/g; +const colon = /^\s*:\s*/; +const string = /^"(?:[^"\\]|\\.)*"/; + +// Finds the top-level "version" key. The file is valid JSON by now, so every +// quote outside a string opens a string. +function versionSpan(text: string): Span | undefined { + const doc: unknown = JSON.parse(text); + if (typeof doc !== "object" || doc === null || Array.isArray(doc)) { + throw new Error("the file is not a JSON object"); + } + const spans: Span[] = []; + let depth = 0; + for (const match of text.matchAll(token)) { + const [found = ""] = match; + const after = (match.index ?? 0) + found.length; + if (found === "{" || found === "[") depth++; + else if (found === "}" || found === "]") depth--; + else if (depth === 1 && JSON.parse(found) === "version") { + const key = colon.exec(text.slice(after)); + if (!key) continue; // A string value, not a key. + const start = after + key[0].length; + const value = string.exec(text.slice(start)); + if (!value) throw new Error('"version" is not a string'); + spans.push({ value: JSON.parse(value[0]) as string, start, end: start + value[0].length }); + } + } + if (spans.length > 1) throw new Error('the file has two "version" keys'); + return spans[0]; +} + +/** Returns the top-level "version" of a JSON document, if it has one. */ +export function readVersion(text: string): string | undefined { + return versionSpan(text)?.value; +} + +/** Replaces the top-level "version" and leaves every other character as it was. */ +export function setVersion(text: string, version: string): string { + if (!semver.test(version)) { + throw new Error(`"${version}" is not a semantic version`); + } + const span = versionSpan(text); + if (!span) { + throw new Error('the file has no "version" key'); + } + const { start, end } = span; + const out = `${text.slice(0, start)}"${version}"${text.slice(end)}`; + if ((JSON.parse(out) as { version?: unknown }).version !== version) { + throw new Error('could not set "version" in the file'); + } + return out; +} diff --git a/internal/plugins/scaffold/templates/ts/src/commands/clients.test.ts.tmpl b/internal/plugins/scaffold/templates/ts/src/commands/clients.test.ts.tmpl new file mode 100644 index 000000000..fb6d1790d --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/src/commands/clients.test.ts.tmpl @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; + +import { createClient } from "../lib/client"; +import { ENV } from "../lib/env"; +import { Output } from "../lib/output"; +import { name } from "../lib/plugin"; +import { run } from "../run"; + +const page = JSON.stringify({ + start: 0, + limit: 50, + length: 2, + total: 2, + clients: [ + { client_id: "id1", name: "First" }, + { client_id: "id2", name: "Second" }, + ], +}); +const empty = JSON.stringify({ start: 0, limit: 50, length: 0, total: 0, clients: [] }); + +// Three pages of the list, 50 + 50 + 3 clients, where the page size is 50. +const pageSizes = [50, 50, 3]; +const allClients = Array.from({ length: 103 }, (_, i) => ({ client_id: `id${i}`, name: `Client ${i}` })); +function pageOf(index: number): string { + const start = pageSizes.slice(0, index).reduce((sum, size) => sum + size, 0); + const clients = allClients.slice(start, start + (pageSizes[index] ?? 0)); + return JSON.stringify({ start, limit: 50, length: clients.length, total: allClients.length, clients }); +} +const denied = JSON.stringify({ + statusCode: 403, + error: "Forbidden", + errorCode: "insufficient_scope", + message: `Plugin auth0/x did not declare scope "read:clients" required for GET /api/v2/clients. Add it to permissions.scopes in plugin.json.`, +}); +const tenant = JSON.stringify({ + statusCode: 403, + error: "Forbidden", + errorCode: "insufficient_scope", + message: "Insufficient scope, expected any of: read:clients", +}); + +interface Case { + name: string; + args?: string[]; + json?: boolean; + compact?: boolean; + status?: number; + body: string; + paged?: boolean; // The stub serves the three pages of pageOf, by the page parameter. + fails?: Error; // The fetch function throws, as it does when the proxy is not there. + code?: number; + stdout?: string; // The whole of stdout. + items?: number; // The number of clients in the JSON list on stdout, instead of stdout. + stderr?: string[]; // Pieces of stderr. +} + +const cases: Case[] = [ + { name: "table", body: page, stdout: "CLIENT ID NAME\nid1 First\nid2 Second\n" }, + { + name: "json", + json: true, + body: page, + stdout: JSON.stringify([{ client_id: "id1", name: "First" }, { client_id: "id2", name: "Second" }], null, 2) + "\n", + }, + { + name: "compact json", + json: true, + compact: true, + body: page, + stdout: `[{"client_id":"id1","name":"First"},{"client_id":"id2","name":"Second"}]\n`, + }, + { + name: "every page of the list", + json: true, + compact: true, + paged: true, + body: "", + items: allClients.length, + }, + { name: "no clients", body: empty, stdout: "", stderr: ["No clients found."] }, + { name: "no clients as json", json: true, body: empty, stdout: "[]\n" }, + { + name: "scope missing from plugin.json", + status: 403, + body: denied, + code: 1, + stderr: ["read:clients", "permissions.scopes", `auth0 plugin approve ${name}`], + }, + { + name: "scope missing from the token", + status: 403, + body: tenant, + code: 1, + stderr: ["the API answered 403", "Insufficient scope"], + }, + { + name: "proxy not reachable", + fails: new TypeError("fetch failed"), + body: "", + code: 1, + stderr: ["error: could not reach the CLI proxy: fetch failed"], + }, + { name: "unknown flag", args: ["--nope"], body: page, code: 2, stderr: ["Unknown option"] }, + { name: "stray argument", args: ["extra"], body: page, code: 2, stderr: ["error:"] }, +]; + +describe("clients command", () => { + test.each(cases)("$name", async (c) => { + const requests: Request[] = []; + // The API returns an empty page after the last one. + const stub = Object.assign( + async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init); + requests.push(request); + if (c.fails) { + throw c.fails; + } + const param = new URL(request.url).searchParams.get("page"); + const first = param === "0"; + return new Response(c.paged ? pageOf(Number(param)) : first || c.status ? c.body : empty, { + status: c.status ?? 200, + headers: { "content-type": "application/json" }, + }); + }, + { preconnect: () => {} }, + ); + + const env = { [ENV.domain]: "127.0.0.1:1", [ENV.token]: "run-secret" }; + let stdout = ""; + let stderr = ""; + const out = new Output(c.json ?? false, c.compact ?? false, (t) => (stdout += t), (t) => (stderr += t)); + + // The root command gets no name, any other command gets its name first. + // The type is widened, because both names are literal types once they are written out. + const pluginName: string = name; + const command = pluginName === "__COMMAND__" ? [] : ["__COMMAND__"]; + const code = await run([...command, ...(c.args ?? [])], { + out, + signal: new AbortController().signal, + client: () => createClient(env, stub), + }); + + expect(code).toBe(c.code ?? 0); + if (c.items !== undefined) { + const clients: { client_id: string }[] = JSON.parse(stdout); + expect(clients).toHaveLength(c.items); + // Every page in order, and none twice. + expect(clients.every((client, i) => client.client_id === `id${i}`)).toBe(true); + } else if (c.stdout !== undefined) { + expect(stdout).toBe(c.stdout); + } + for (const piece of c.stderr ?? []) { + expect(stderr).toContain(piece); + } + if (c.code === 2) { + expect(requests).toHaveLength(0); + } else { + expect(requests[0]?.headers.get("authorization")).toBe("Bearer run-secret"); + } + }); +}); diff --git a/internal/plugins/scaffold/templates/ts/src/commands/clients.ts.tmpl b/internal/plugins/scaffold/templates/ts/src/commands/clients.ts.tmpl new file mode 100644 index 000000000..ac5118936 --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/src/commands/clients.ts.tmpl @@ -0,0 +1,37 @@ +import { parseFlags } from "../lib/flags"; +import { collect } from "../lib/paginate"; +import type { Command } from "./types"; + +// This is the sample command. To add your own, copy this file, change the name, +// the help and the code in run, add it to commands/index.ts, and add the command +// to plugin.json with the scopes it needs. +export const clients: Command = { + name: "__COMMAND__", + help: "List the clients of the tenant", + + // It needs the read:clients scope, which plugin.json declares for the command. + async run({ args, out, signal, client }) { + // Declare the flags of the command here, for example: + // const { values } = parseFlags(args, { limit: { type: "string" } }); + parseFlags(args); + + // Ask only for the two fields the command prints. The result is a page that + // walks every page when it is iterated. + const page = await client().clients.list( + { fields: "client_id,name", include_fields: true, per_page: 50 }, + { abortSignal: signal }, + ); + const items = await collect(page); + + if (out.json) { + out.printJson(items); + } else if (items.length === 0) { + out.note("No clients found."); + } else { + out.printTable( + ["CLIENT ID", "NAME"], + items.map((c) => [c.client_id ?? "", c.name ?? ""]), + ); + } + }, +}; diff --git a/internal/plugins/scaffold/templates/ts/src/commands/index.ts.tmpl b/internal/plugins/scaffold/templates/ts/src/commands/index.ts.tmpl new file mode 100644 index 000000000..8ecbd631a --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/src/commands/index.ts.tmpl @@ -0,0 +1,25 @@ +import { UsageError } from "../lib/errors"; +import { name } from "../lib/plugin"; +import { clients } from "./clients"; +import type { Command } from "./types"; + +/** Every command of the plugin. To add one, import it and add it here. */ +export const commands: readonly Command[] = [clients]; + +/** Returns the command for the arguments, and the arguments that are left. */ +export function findCommand(argv: readonly string[]): { command: Command; rest: string[] } { + const named = commands.find((c) => c.name === argv[0] && c.name !== name); + if (named) { + return { command: named, rest: argv.slice(1) }; + } + + const root = commands.find((c) => c.name === name); + if (root) { + return { command: root, rest: [...argv] }; + } + + if (argv.length === 0) { + throw new UsageError("a command is required"); + } + throw new UsageError(`unknown command "${argv[0]}"`); +} diff --git a/internal/plugins/scaffold/templates/ts/src/commands/types.ts.tmpl b/internal/plugins/scaffold/templates/ts/src/commands/types.ts.tmpl new file mode 100644 index 000000000..a6a935d71 --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/src/commands/types.ts.tmpl @@ -0,0 +1,28 @@ +import type { ManagementClient } from "auth0"; + +import type { Output } from "../lib/output"; + +/** What a command gets when it runs. */ +export interface Context { + /** The arguments after the command name; the flags of the CLI are already removed. */ + args: string[]; + out: Output; + /** Aborted on Ctrl-C and SIGTERM. Pass it to every SDK call as `abortSignal`. */ + signal: AbortSignal; + /** The SDK client for this run. It reads the environment the first time it is called. */ + client: () => ManagementClient; +} + +/** + * One command of the plugin. Every command also needs an entry under "commands" + * in plugin.json, with the Management API scopes it uses. + * + * A command named like the plugin is the root command, run as `auth0 __PLUGIN_NAME__`. + * Any other command is run as `auth0 __PLUGIN_NAME__ `, and the CLI then + * passes the name as the first argument. + */ +export interface Command { + name: string; + help: string; + run(context: Context): Promise; +} diff --git a/internal/plugins/scaffold/templates/ts/src/index.ts.tmpl b/internal/plugins/scaffold/templates/ts/src/index.ts.tmpl new file mode 100644 index 000000000..c1504ee64 --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/src/index.ts.tmpl @@ -0,0 +1,18 @@ +// Entry point of the plugin. The Auth0 CLI starts it when someone runs +// `auth0 __PLUGIN_NAME__`. This file only wires things together: the commands are in +// src/commands/, and what they share (environment, SDK client, output, errors) +// is in src/lib/. +import { createClient } from "./lib/client"; +import { Output } from "./lib/output"; +import { signalContext } from "./lib/plugin"; +import { run } from "./run"; + +const out = Output.fromEnv(); +const code = await run(process.argv.slice(2), { + out, + signal: signalContext(), + client: () => createClient(), +}); + +await out.flush(); +process.exit(code); diff --git a/internal/plugins/scaffold/templates/ts/src/lib/client.ts.tmpl b/internal/plugins/scaffold/templates/ts/src/lib/client.ts.tmpl new file mode 100644 index 000000000..fcdc6872e --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/src/lib/client.ts.tmpl @@ -0,0 +1,19 @@ +import { ManagementClient } from "auth0"; + +import { ENV, requireEnv, type Env } from "./env"; + +/** + * Returns the Management API client of the auth0 package for this run. Every + * call goes through the proxy of the CLI, which allows only the scopes that + * plugin.json declares for the command. + * + * The package builds https:///api/v2 from the domain, so host:port works. + * Tests pass their own fetch function. + */ +export function createClient(env: Env = process.env, fetchFn?: typeof fetch): ManagementClient { + return new ManagementClient({ + domain: requireEnv(ENV.domain, env), + token: requireEnv(ENV.token, env), + fetch: fetchFn, + }); +} diff --git a/internal/plugins/scaffold/templates/ts/src/lib/env.test.ts.tmpl b/internal/plugins/scaffold/templates/ts/src/lib/env.test.ts.tmpl new file mode 100644 index 000000000..4c8fb8053 --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/src/lib/env.test.ts.tmpl @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; + +import { ENV, outputFlags, requireEnv, type Env } from "./env"; +import { name } from "./plugin"; + +describe("requireEnv", () => { + test("returns the value", () => { + expect(requireEnv(ENV.domain, { [ENV.domain]: "127.0.0.1:1" })).toBe("127.0.0.1:1"); + }); + + test.each([ENV.domain, ENV.token])("names %s when it is missing and says how to run the plugin", (variable) => { + expect(() => requireEnv(variable, {})).toThrow(variable); + expect(() => requireEnv(variable, {})).toThrow(`auth0 ${name}`); + }); +}); + +describe("outputFlags", () => { + const cases: { env: Env; want: { json: boolean; compact: boolean } }[] = [ + { env: {}, want: { json: false, compact: false } }, + { env: { [ENV.json]: "true" }, want: { json: true, compact: false } }, + { env: { [ENV.json]: "false", [ENV.jsonCompact]: "true" }, want: { json: true, compact: true } }, + ]; + + test.each(cases)("reads $env", ({ env, want }) => { + expect(outputFlags(env)).toEqual(want); + }); +}); diff --git a/internal/plugins/scaffold/templates/ts/src/lib/env.ts.tmpl b/internal/plugins/scaffold/templates/ts/src/lib/env.ts.tmpl new file mode 100644 index 000000000..fd8bdcca9 --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/src/lib/env.ts.tmpl @@ -0,0 +1,37 @@ +import { name } from "./plugin"; + +/** + * The variables the CLI sets for every run. This is the only file that reads + * them, so look here when the plugin cannot reach the API. + */ +export const ENV = { + /** The address of the CLI proxy as host:port. It is not the tenant domain. */ + domain: "AUTH0_PLUGIN_DOMAIN", + /** A secret for this run only. It lets the plugin use the proxy, which swaps in the real token. */ + token: "AUTH0_PLUGIN_MANAGEMENT_TOKEN", + /** `true` when the user asked for JSON output. Agent mode turns it on. */ + json: "AUTH0_PLUGIN_JSON", + jsonCompact: "AUTH0_PLUGIN_JSON_COMPACT", +} as const; + +// The proxy uses its own certificate. The CLI also sets NODE_EXTRA_CA_CERTS to +// it, which Node and Bun read at start, so no code is needed to trust it. + +export type Env = Record; + +/** Returns a variable of the CLI, or an error that says what to do. */ +export function requireEnv(variable: string, env: Env = process.env): string { + const value = env[variable]; + if (!value) { + throw new Error( + `${variable} is not set. This plugin has to be started by the Auth0 CLI: run \`auth0 ${name}\` instead of running the file directly`, + ); + } + return value; +} + +/** Returns the output flags of the CLI. */ +export function outputFlags(env: Env = process.env): { json: boolean; compact: boolean } { + const compact = env[ENV.jsonCompact] === "true"; + return { json: compact || env[ENV.json] === "true", compact }; +} diff --git a/internal/plugins/scaffold/templates/ts/src/lib/errors.ts.tmpl b/internal/plugins/scaffold/templates/ts/src/lib/errors.ts.tmpl new file mode 100644 index 000000000..fd1c76d85 --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/src/lib/errors.ts.tmpl @@ -0,0 +1,49 @@ +import { ManagementError } from "auth0"; + +import type { Output } from "./output"; +import { Exit, name } from "./plugin"; + +/** The message that the CLI proxy writes when a call needs a scope that plugin.json does not declare. Auth0 never says it. */ +const proxyDeniedText = "did not declare scope"; + +/** The arguments were wrong. The exit code is 2. */ +export class UsageError extends Error {} + +/** + * Prints an error to stderr and returns the exit code for it: 2 for wrong + * usage and 1 for anything else. This is the one place that decides what an + * error looks like, so change it here. + */ +export function reportError(out: Output, error: unknown): number { + if (error instanceof UsageError) { + out.note(`error: ${error.message}\nRun \`auth0 ${name} --help\` for usage.`); + return Exit.usage; + } + + if (error instanceof ManagementError) { + out.note(apiErrorText(error)); + } else { + out.note(`error: ${error instanceof Error ? error.message : String(error)}`); + } + return Exit.error; +} + +/** Turns an error response of the Management API into text. The API answers with {statusCode, error, message, errorCode}. */ +function apiErrorText(error: ManagementError): string { + // The call got no answer: a refused connection, a TLS error, a timeout. + if (error.statusCode === undefined) { + return `error: could not reach the CLI proxy: ${error.message}`; + } + + const body = error.body as { message?: unknown } | undefined; + const message = typeof body?.message === "string" ? body.message : "no details"; + + if (error.statusCode === 403 && message.includes(proxyDeniedText)) { + return ( + `error: the CLI blocked this call.\n ${message}\n` + + "Declare the scope under permissions.scopes of the command in plugin.json, " + + `then approve the new permissions with: auth0 plugin approve ${name}` + ); + } + return `error: the API answered ${error.statusCode}: ${message}`; +} diff --git a/internal/plugins/scaffold/templates/ts/src/lib/flags.ts.tmpl b/internal/plugins/scaffold/templates/ts/src/lib/flags.ts.tmpl new file mode 100644 index 000000000..9ff1b5dff --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/src/lib/flags.ts.tmpl @@ -0,0 +1,17 @@ +import { parseArgs, type ParseArgsConfig } from "node:util"; + +import { UsageError } from "./errors"; + +/** + * Parses the arguments of one command and rejects unknown flags and stray + * arguments with a UsageError. Declare the flags of a command in `options`, and + * list them in plugin.json so that the CLI shows them in its help. The flags + * that the CLI owns (--json, --tenant and so on) never get here. + */ +export function parseFlags(args: string[], options: NonNullable = {}) { + try { + return parseArgs({ args, options, strict: true, allowPositionals: false }); + } catch (error) { + throw new UsageError(error instanceof Error ? error.message : String(error)); + } +} diff --git a/internal/plugins/scaffold/templates/ts/src/lib/output.ts.tmpl b/internal/plugins/scaffold/templates/ts/src/lib/output.ts.tmpl new file mode 100644 index 000000000..99a50ab4d --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/src/lib/output.ts.tmpl @@ -0,0 +1,58 @@ +import { outputFlags } from "./env"; + +/** + * Decides how a command prints. The rule for every command: the result goes to + * stdout, as JSON when the user asked for it and as a table otherwise, and + * everything else (notes, errors) goes to stderr. Scripts and agents read + * stdout, so keep it free of anything but the result. + */ +export class Output { + constructor( + readonly json: boolean, + readonly compact: boolean, + private readonly stdout: (text: string) => void, + private readonly stderr: (text: string) => void, + ) {} + + /** Reads the output flags of the CLI. */ + static fromEnv(): Output { + const { json, compact } = outputFlags(); + return new Output( + json, + compact, + (text) => process.stdout.write(text), + (text) => process.stderr.write(text), + ); + } + + /** Writes a line to stdout. */ + print(line: string): void { + this.stdout(`${line}\n`); + } + + /** Writes a note for people to stderr, such as "No clients found.". */ + note(line: string): void { + this.stderr(`${line}\n`); + } + + /** Writes a value as JSON, indented unless --json-compact was used. */ + printJson(value: unknown): void { + this.print(JSON.stringify(value, null, this.compact ? undefined : 2)); + } + + /** Writes rows as a table with aligned columns. */ + printTable(header: string[], rows: string[][]): void { + const all = [header, ...rows]; + const widths = header.map((_, i) => Math.max(...all.map((row) => (row[i] ?? "").length))); + for (const row of all) { + this.print(row.map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd((widths[i] ?? 0) + 2))).join("")); + } + } + + /** Waits until everything written so far has left the process. */ + async flush(): Promise { + for (const stream of [process.stdout, process.stderr]) { + await new Promise((resolve) => stream.write("", () => resolve())); + } + } +} diff --git a/internal/plugins/scaffold/templates/ts/src/lib/paginate.ts.tmpl b/internal/plugins/scaffold/templates/ts/src/lib/paginate.ts.tmpl new file mode 100644 index 000000000..4bb81c61d --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/src/lib/paginate.ts.tmpl @@ -0,0 +1,16 @@ +/** + * Returns every item of a list call. Pass it the result of any SDK `list` + * method: it is a page that fetches the next page while it is iterated. + * + * const page = await client().clients.list({ per_page: 50 }, { abortSignal: signal }); + * const items = await collect(page); + * + * Set `per_page`: the SDK stops at the first page that is shorter than it. + */ +export async function collect(pages: AsyncIterable): Promise { + const items: T[] = []; + for await (const item of pages) { + items.push(item); + } + return items; +} diff --git a/internal/plugins/scaffold/templates/ts/src/lib/plugin.ts.tmpl b/internal/plugins/scaffold/templates/ts/src/lib/plugin.ts.tmpl new file mode 100644 index 000000000..339c2d4a4 --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/src/lib/plugin.ts.tmpl @@ -0,0 +1,33 @@ +import manifest from "../../plugin.json"; + +/** The name people type after `auth0`. */ +export const name = "__PLUGIN_NAME__"; + +/** The version in plugin.json, so that there is one place to change it. */ +export const version: string = manifest.version; + +/** The exit codes of the plugin. The CLI reports every code but 0 as a failure. */ +export const Exit = { + ok: 0, + /** The command ran and failed. */ + error: 1, + /** The arguments were wrong. */ + usage: 2, +} as const; + +/** Returns what people type to run a command. */ +export function invocation(command: string): string { + return command === name ? `auth0 ${name}` : `auth0 ${name} ${command}`; +} + +/** + * Returns a signal that aborts on Ctrl-C and SIGTERM. The CLI sends SIGTERM to a + * plugin that runs past its timeout. + */ +export function signalContext(): AbortSignal { + const controller = new AbortController(); + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => controller.abort()); + } + return controller.signal; +} diff --git a/internal/plugins/scaffold/templates/ts/src/run.ts.tmpl b/internal/plugins/scaffold/templates/ts/src/run.ts.tmpl new file mode 100644 index 000000000..508ab4c07 --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/src/run.ts.tmpl @@ -0,0 +1,58 @@ +import type { ManagementClient } from "auth0"; + +import { commands, findCommand } from "./commands"; +import { reportError } from "./lib/errors"; +import type { Output } from "./lib/output"; +import { Exit, invocation, name, version } from "./lib/plugin"; + +/** What a run needs from the outside, so that tests can replace it. */ +export interface Deps { + out: Output; + signal: AbortSignal; + client: () => ManagementClient; +} + +/** + * Picks the command from the arguments the CLI passed, runs it and returns the + * exit code. The CLI has already removed its own flags (--json, --tenant), so + * what is left belongs to the plugin. + */ +export async function run(argv: readonly string[], deps: Deps): Promise { + const { out, signal, client } = deps; + + if (argv[0] === "--version") { + out.print(`${name} ${version}`); + return Exit.ok; + } + + // A plugin without a root command has nothing to run without a command name. + if (["help", "-h", "--help"].includes(argv[0] ?? "") && !commands.some((c) => c.name === name)) { + out.print(helpText()); + return Exit.ok; + } + + try { + const found = findCommand(argv); + + if (found.rest.includes("-h") || found.rest.includes("--help")) { + out.print(`Usage: ${invocation(found.command.name)} [flags]\n\n${found.command.help}`); + return Exit.ok; + } + + await found.command.run({ args: found.rest, out, signal, client }); + return Exit.ok; + } catch (error) { + if (signal.aborted) { + out.note("interrupted"); + return Exit.error; + } + + return reportError(out, error); + } +} + +/** Lists the commands, for people who run the file directly. */ +function helpText(): string { + const lines = commands.map((c) => ` ${invocation(c.name).padEnd(36)} ${c.help}`); + return `${name} is a plugin for the Auth0 CLI. Run it with \`auth0\`, not directly.\n\nCommands:\n${lines.join("\n")}`; +} diff --git a/internal/plugins/scaffold/templates/ts/tsconfig.json.tmpl b/internal/plugins/scaffold/templates/ts/tsconfig.json.tmpl new file mode 100644 index 000000000..734350a7d --- /dev/null +++ b/internal/plugins/scaffold/templates/ts/tsconfig.json.tmpl @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["bun"] + }, + "include": ["src", "scripts"] +} From f90013351dc7459cf0193a75c0bfabdee0211d09 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Mon, 2 Nov 2026 16:02:13 +0530 Subject: [PATCH 2/3] test(plugins): skip the scaffold build test when the package registry denies access --- internal/plugins/scaffold/embed_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/plugins/scaffold/embed_test.go b/internal/plugins/scaffold/embed_test.go index 0d6fd102a..960e1732f 100644 --- a/internal/plugins/scaffold/embed_test.go +++ b/internal/plugins/scaffold/embed_test.go @@ -164,6 +164,11 @@ func TestTreeProblems(t *testing.T) { // download. var networkFailure = regexp.MustCompile(`dial tcp|no such host|i/o timeout|proxy.golang.org|connection refused|network is unreachable|TLS handshake|ConnectionRefused|FailedToOpenSocket|ConnectionClosed|ENOTFOUND|Unable to connect`) +// registryDenied matches a package registry that refuses the request because +// this machine has no credentials for it (for example a private mirror in the +// bun configuration). It is an environment condition, like a missing network. +var registryDenied = regexp.MustCompile(`error: GET \S+ - 40[13]`) + // TestEmbedded_BuildsAndPasses scaffolds each project and builds, vets and tests // it with the tools of its language, the way a person would. It needs the tool // and the package registry (or a warm cache), so a case is skipped when either is @@ -214,6 +219,9 @@ func TestEmbedded_BuildsAndPasses(t *testing.T) { if err != nil && i == 0 && networkFailure.Match(output) { t.Skipf("%s %s needs the network: %s", tool, args[0], output) } + if err != nil && i == 0 && registryDenied.Match(output) { + t.Skipf("%s %s cannot reach the package registry without credentials: %s", tool, args[0], output) + } require.NoError(t, err, "%s %s:\n%s", tool, strings.Join(args, " "), output) } From 79175332df727ae833c4cebc0b49bace2cdc85d7 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Mon, 2 Nov 2026 16:41:12 +0530 Subject: [PATCH 3/3] test(plugins): expect the quoted cd path on Windows --- internal/cli/plugin_dev_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cli/plugin_dev_test.go b/internal/cli/plugin_dev_test.go index 27cd75491..d1066bf46 100644 --- a/internal/cli/plugin_dev_test.go +++ b/internal/cli/plugin_dev_test.go @@ -994,7 +994,7 @@ func TestCreatePluginCmd(t *testing.T) { out := e.stderr.String() assert.Contains(t, out, "Created auth0/demo in "+dir) - assert.Contains(t, out, "cd "+dir) + assert.Contains(t, out, "cd "+shellWord(dir)) assert.Contains(t, out, test.build) assert.Contains(t, out, "AUTH0_CLI_PLUGIN_DEV_MODE=true auth0 plugin dev link") assert.Contains(t, out, "auth0 plugin approve demo")