diff --git a/cmd/wfctl/secrets.go b/cmd/wfctl/secrets.go index 2ed3fc32..99cb1b53 100644 --- a/cmd/wfctl/secrets.go +++ b/cmd/wfctl/secrets.go @@ -3,6 +3,7 @@ package main import ( "flag" "fmt" + "strings" ) func runSecrets(args []string) error { @@ -31,6 +32,14 @@ func runSecrets(args []string) error { case "sync": return runSecretsSync(args[1:]) case "setup": + // `secrets setup --plugin ` shells out to a separate + // dispatcher that reads plugin.json required_secrets[]. The + // env-name flow stays on runSecretsSetup. + for _, a := range args[1:] { + if a == "--plugin" || strings.HasPrefix(a, "--plugin=") { + return runSecretsSetupPlugin(args[1:]) + } + } return runSecretsSetup(args[1:]) case "list-orphans": return runSecretsListOrphans(args[1:]) diff --git a/cmd/wfctl/secrets_setup_plugin.go b/cmd/wfctl/secrets_setup_plugin.go new file mode 100644 index 00000000..d9dc8915 --- /dev/null +++ b/cmd/wfctl/secrets_setup_plugin.go @@ -0,0 +1,227 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/GoCodeAlone/workflow/secrets" + "github.com/mattn/go-isatty" + "golang.org/x/term" +) + +// PluginRequiredSecret mirrors the plugin.json `required_secrets[]` +// entry. Each entry tells `wfctl secrets setup --plugin ` what +// to prompt for + whether to mask input. +type PluginRequiredSecret struct { + Name string `json:"name"` + Sensitive bool `json:"sensitive"` + Description string `json:"description,omitempty"` + Prompt string `json:"prompt,omitempty"` +} + +// pluginManifest is the slice of plugin.json this command actually +// reads. Other fields are ignored. +type pluginManifest struct { + Name string `json:"name"` + RequiredSecrets []PluginRequiredSecret `json:"required_secrets,omitempty"` +} + +// runSecretsSetupPlugin is the entry-point for `wfctl secrets setup +// --plugin `. It reads the plugin's plugin.json, prompts for +// each declared required secret, and writes the values to the chosen +// GitHub scope (repo|env|org). +func runSecretsSetupPlugin(args []string) error { + return runSecretsSetupPluginWithIO(args, nil, os.Stdout) +} + +func runSecretsSetupPluginWithIO(args []string, in io.Reader, out io.Writer) error { + fs := flag.NewFlagSet("secrets setup --plugin", flag.ContinueOnError) + pluginName := fs.String("plugin", "", "Plugin name (must match a directory under --plugin-dir / $WFCTL_PLUGIN_DIR)") + pluginDir := fs.String("plugin-dir", "", "Plugin install dir (default: $WFCTL_PLUGIN_DIR or ./data/plugins)") + scope := fs.String("scope", "repo", "GitHub scope: repo | env | org") + envName := fs.String("env", "", "Environment name (required with --scope=env)") + org := fs.String("org", "", "Organization slug (required with --scope=org)") + orgVisibility := fs.String("visibility", "all", "Org-scope visibility: all | selected | private") + tokenEnv := fs.String("token-env", "GITHUB_TOKEN", "Env var holding the GitHub PAT") + configFile := fs.String("config", "app.yaml", "app.yaml (used to resolve the github repo when --scope=repo|env)") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), `Usage: wfctl secrets setup --plugin [options] + +Interactively set the secrets declared by a plugin's plugin.json +required_secrets[] block. Sensitive fields are masked. + +Options: +`) + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + if *pluginName == "" { + return errors.New("--plugin is required") + } + + manifest, err := loadPluginManifest(*pluginName, *pluginDir) + if err != nil { + return err + } + if len(manifest.RequiredSecrets) == 0 { + fmt.Fprintf(out, "plugin %q declares no required_secrets[]; nothing to do\n", manifest.Name) + return nil + } + + // Pre-build the destination provider so a malformed scope fails + // loud BEFORE prompting. + scopeStr := strings.ToLower(strings.TrimSpace(*scope)) + provider, scopeLabel, err := buildSecretWriter(scopeStr, *envName, *org, *orgVisibility, *tokenEnv, *configFile) + if err != nil { + return err + } + + fmt.Fprintf(out, "Setting up secrets for plugin %q → %s\n\n", manifest.Name, scopeLabel) + + for _, rs := range manifest.RequiredSecrets { + val, err := promptOne(rs, in) + if err != nil { + return err + } + if val == "" { + fmt.Fprintf(out, " %s: skipped (no value provided)\n", rs.Name) + continue + } + if err := provider.Set(context.Background(), rs.Name, val); err != nil { + return fmt.Errorf("set %s: %w", rs.Name, err) + } + fmt.Fprintf(out, " %s: set\n", rs.Name) + } + fmt.Fprintf(out, "\nAll done.\n") + return nil +} + +// loadPluginManifest looks for the plugin.json under the resolved +// plugin install dir, parses it, and returns the manifest. Returns +// a clear error when the directory is missing. +func loadPluginManifest(name, dirOverride string) (*pluginManifest, error) { + dir := dirOverride + if dir == "" { + dir = os.Getenv("WFCTL_PLUGIN_DIR") + } + if dir == "" { + dir = "./data/plugins" + } + path := filepath.Join(dir, name, "plugin.json") + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read plugin manifest %s: %w (run `wfctl plugin install` first; or pass --plugin-dir)", path, err) + } + var m pluginManifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parse plugin manifest %s: %w", path, err) + } + return &m, nil +} + +// promptOne reads a value for one required secret. Masks if Sensitive. +// When `in` is non-nil (tests / piped input) it reads a line from it +// regardless of Sensitive — masking is interactive-only. +func promptOne(rs PluginRequiredSecret, in io.Reader) (string, error) { + label := rs.Prompt + if label == "" { + label = rs.Name + } + if rs.Description != "" { + fmt.Fprintf(os.Stderr, "\n# %s\n", rs.Description) + } + fmt.Fprintf(os.Stderr, "%s: ", label) + + if in != nil { + // Test/piped path — read one line. + buf := make([]byte, 4096) + n, err := in.Read(buf) + if err != nil && err != io.EOF { + return "", err + } + return strings.TrimRight(string(buf[:n]), "\r\n"), nil + } + + if rs.Sensitive && isatty.IsTerminal(os.Stdin.Fd()) { + fd, err := stdinFileDescriptor() + if err != nil { + return "", err + } + b, err := term.ReadPassword(fd) + fmt.Fprintln(os.Stderr) + if err != nil { + return "", fmt.Errorf("read masked: %w", err) + } + return string(b), nil + } + // Non-sensitive interactive — echo. + var line string + if _, err := fmt.Fscanln(os.Stdin, &line); err != nil && err.Error() != "unexpected newline" { + return "", err + } + return line, nil +} + +// scopedWriter is the narrow interface secrets setup --plugin needs. +// Both secrets.GitHubSecretsProvider satisfies it. +type scopedWriter interface { + Set(ctx context.Context, key, value string) error +} + +// buildSecretWriter mints the GitHub provider for the requested scope. +// scopeLabel is a human-readable string for the setup prelude. +func buildSecretWriter(scope, envName, org, visibility, tokenEnv, configFile string) (scopedWriter, string, error) { + switch scope { + case "org": + if org == "" { + return nil, "", errors.New("--scope=org requires --org ") + } + vis, err := parseGitHubOrgVisibility(visibility) + if err != nil { + return nil, "", err + } + p, err := secrets.NewGitHubOrgSecretsProvider(org, tokenEnv, vis, nil) + if err != nil { + return nil, "", err + } + return p, fmt.Sprintf("github org %q (visibility=%s)", org, visibility), nil + + case "env": + if envName == "" { + return nil, "", errors.New("--scope=env requires --env ") + } + repo, err := readGitHubRepoFromAppYAML(configFile) + if err != nil { + return nil, "", err + } + p, err := secrets.NewGitHubSecretsProvider(repo, tokenEnv) + if err != nil { + return nil, "", err + } + p.SetEnvironment(envName) + return p, fmt.Sprintf("github env %q on %s", envName, repo), nil + + case "", "repo": + repo, err := readGitHubRepoFromAppYAML(configFile) + if err != nil { + return nil, "", err + } + p, err := secrets.NewGitHubSecretsProvider(repo, tokenEnv) + if err != nil { + return nil, "", err + } + return p, fmt.Sprintf("github repo %s", repo), nil + + default: + return nil, "", fmt.Errorf("unknown --scope %q (want repo|env|org)", scope) + } +} diff --git a/cmd/wfctl/secrets_setup_plugin_test.go b/cmd/wfctl/secrets_setup_plugin_test.go new file mode 100644 index 00000000..4f17b248 --- /dev/null +++ b/cmd/wfctl/secrets_setup_plugin_test.go @@ -0,0 +1,163 @@ +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func writePluginManifestFile(t *testing.T, dir, name, manifest string) string { + t.Helper() + pdir := filepath.Join(dir, name) + if err := os.MkdirAll(pdir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + path := filepath.Join(pdir, "plugin.json") + if err := os.WriteFile(path, []byte(manifest), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + return pdir +} + +func TestLoadPluginManifest_HappyPath(t *testing.T) { + dir := t.TempDir() + writePluginManifestFile(t, dir, "workflow-plugin-fake", `{ + "name": "workflow-plugin-fake", + "required_secrets": [ + {"name": "FAKE_USER", "sensitive": false}, + {"name": "FAKE_TOKEN", "sensitive": true, "description": "API token"} + ] + }`) + m, err := loadPluginManifest("workflow-plugin-fake", dir) + if err != nil { + t.Fatalf("loadPluginManifest: %v", err) + } + if m.Name != "workflow-plugin-fake" { + t.Errorf("name = %q", m.Name) + } + if len(m.RequiredSecrets) != 2 { + t.Fatalf("required_secrets = %d want 2", len(m.RequiredSecrets)) + } + if m.RequiredSecrets[0].Name != "FAKE_USER" || m.RequiredSecrets[0].Sensitive { + t.Errorf("rs[0] = %+v", m.RequiredSecrets[0]) + } + if m.RequiredSecrets[1].Name != "FAKE_TOKEN" || !m.RequiredSecrets[1].Sensitive { + t.Errorf("rs[1] = %+v", m.RequiredSecrets[1]) + } +} + +func TestLoadPluginManifest_MissingDir(t *testing.T) { + _, err := loadPluginManifest("nope", t.TempDir()) + if err == nil { + t.Fatal("expected error for missing manifest") + } + if !strings.Contains(err.Error(), "wfctl plugin install") { + t.Errorf("error should hint at remediation: %v", err) + } +} + +func TestLoadPluginManifest_BadJSON(t *testing.T) { + dir := t.TempDir() + writePluginManifestFile(t, dir, "x", `{not-json}`) + _, err := loadPluginManifest("x", dir) + if err == nil { + t.Fatal("expected parse error") + } +} + +// TestPromptOne_PipedNonSensitive reads a single line from a piped +// reader and returns it. +func TestPromptOne_PipedNonSensitive(t *testing.T) { + got, err := promptOne(PluginRequiredSecret{Name: "X"}, strings.NewReader("hello\n")) + if err != nil { + t.Fatalf("promptOne: %v", err) + } + if got != "hello" { + t.Errorf("got %q want hello", got) + } +} + +// TestPromptOne_PipedSensitive — sensitive value can still come via +// pipe (tests bypass tty path). +func TestPromptOne_PipedSensitive(t *testing.T) { + got, err := promptOne(PluginRequiredSecret{Name: "Y", Sensitive: true}, strings.NewReader("hunter2\n")) + if err != nil { + t.Fatalf("promptOne: %v", err) + } + if got != "hunter2" { + t.Errorf("got %q want hunter2", got) + } +} + +// TestRunSecretsSetupPlugin_PiperReadsRequiredSecrets exercises the +// full flow with a piped reader for input. Output goes to a buffer. +// +// We swap out stdin via the io.Reader arg + verify the buffered out +// reports each secret as "set". +func TestRunSecretsSetupPlugin_PiperReadsRequiredSecrets(t *testing.T) { + dir := t.TempDir() + writePluginManifestFile(t, dir, "wp-fake", `{ + "name": "wp-fake", + "required_secrets": [ + {"name": "A", "sensitive": false}, + {"name": "B", "sensitive": true} + ] + }`) + // Stub the writer side by setting the org via env so + // buildSecretWriter is short-circuited (we just want to exercise + // the prompt loop). Use --scope=org with a stub provider not + // reachable in tests; the call will fail at network → we assert + // we got at least to the writer construction. + in := io.Reader(strings.NewReader("alice\nhunter2\n")) + var out bytes.Buffer + t.Setenv("GITHUB_TOKEN", "stub") + + // We can't actually hit the GH API; use --scope=org pointing + // at a non-resolvable token+org, then assert error returns from + // the network-side path (after the prompts succeed). + err := runSecretsSetupPluginWithIO([]string{ + "--plugin", "wp-fake", + "--plugin-dir", dir, + "--scope", "org", + "--org", "test-org", + "--token-env", "GITHUB_TOKEN", + }, in, &out) + if err == nil { + t.Fatal("expected network-side error reaching GH (test runs offline)") + } + // The output buffer should still show that we entered the setup + // loop (prompt prelude). + if !strings.Contains(out.String(), "Setting up secrets for plugin") { + t.Errorf("setup prelude missing from output:\n%s", out.String()) + } +} + +func TestBuildSecretWriter_ScopeRouting(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "stub") + // Org happy path. + w, label, err := buildSecretWriter("org", "", "my-org", "all", "GITHUB_TOKEN", "") + if err != nil || w == nil { + t.Errorf("org: err=%v writer=%v", err, w) + } + if !strings.Contains(label, "github org \"my-org\"") { + t.Errorf("org label: %q", label) + } + + // Org rejects missing --org. + if _, _, err := buildSecretWriter("org", "", "", "all", "GITHUB_TOKEN", ""); err == nil { + t.Error("org: expected error without --org") + } + + // Env rejects missing --env. + if _, _, err := buildSecretWriter("env", "", "", "all", "GITHUB_TOKEN", "app.yaml"); err == nil { + t.Error("env: expected error without --env") + } + + // Unknown scope. + if _, _, err := buildSecretWriter("nope", "", "", "all", "GITHUB_TOKEN", ""); err == nil { + t.Error("unknown scope should error") + } +}