From e0397579fad6ca9fb9d1f6f0b853fd3def389675 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 6 Jun 2026 04:10:32 -0400 Subject: [PATCH] feat(wfctl): discover manifest secrets --- cmd/wfctl/secrets_setup.go | 10 + cmd/wfctl/secrets_setup_manifest.go | 440 +++++++++++++++++++++++ cmd/wfctl/secrets_setup_manifest_test.go | 93 +++++ cmd/wfctl/secrets_setup_plugin.go | 45 ++- cmd/wfctl/secrets_setup_plugin_test.go | 20 ++ docs/WFCTL.md | 17 +- docs/iac-dns-providers.md | 11 + docs/wfctl-secrets-scopes.md | 13 +- 8 files changed, 635 insertions(+), 14 deletions(-) create mode 100644 cmd/wfctl/secrets_setup_manifest.go create mode 100644 cmd/wfctl/secrets_setup_manifest_test.go diff --git a/cmd/wfctl/secrets_setup.go b/cmd/wfctl/secrets_setup.go index c226dcd76..42f2c4fef 100644 --- a/cmd/wfctl/secrets_setup.go +++ b/cmd/wfctl/secrets_setup.go @@ -23,6 +23,16 @@ import ( // // Both paths share the same runSetupEngine + audit logic. func runSecretsSetup(args []string) error { + for _, a := range args { + if a == "--manifest" || strings.HasPrefix(a, "--manifest=") { + manifestArgs, err := parseManifestSetupFlags(args) + if err != nil { + return err + } + return runSecretsSetupManifestWithIO(manifestArgs, nil, os.Stdout) + } + } + fs := flag.NewFlagSet("secrets setup", flag.ContinueOnError) envName := fs.String("env", "local", "Target environment name") configFile := fs.String("config", "app.yaml", "Workflow config file") diff --git a/cmd/wfctl/secrets_setup_manifest.go b/cmd/wfctl/secrets_setup_manifest.go new file mode 100644 index 000000000..1526f3a5b --- /dev/null +++ b/cmd/wfctl/secrets_setup_manifest.go @@ -0,0 +1,440 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/GoCodeAlone/workflow/cmd/wfctl/internal/prompt" + "github.com/GoCodeAlone/workflow/config" + "github.com/mattn/go-isatty" + "gopkg.in/yaml.v3" +) + +var manifestEnvRefPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`) + +type manifestDiscoveredSecret struct { + PluginRequiredSecret + Sources []string +} + +type manifestSetupArgs struct { + manifestPath string + lockfilePath string + pluginDir string + configPatterns string + scope string + envName string + org string + visibility string + tokenEnv string + fromEnv bool + secretLiterals []string + only []string + skipExisting bool +} + +func runSecretsSetupManifestWithIO(a *manifestSetupArgs, in io.Reader, out io.Writer) error { + discovered, err := discoverManifestSecrets(a.manifestPath, a.lockfilePath, a.pluginDir, a.configPatterns) + if err != nil { + return err + } + if len(discovered) == 0 { + fmt.Fprintln(out, "No plugin required_secrets[] or config env references found.") + return nil + } + + ghProvider, scopeLabel, err := buildSecretWriter(strings.ToLower(strings.TrimSpace(a.scope)), a.envName, a.org, a.visibility, a.tokenEnv, firstConfigPattern(a.configPatterns)) + if err != nil { + return err + } + provider := secretsProviderAdapter{p: ghProvider} + + secretMap, err := buildSecretLiteralMap(a.secretLiterals) + if err != nil { + return err + } + if in != nil { + for _, kv := range readKVLines(in) { + k, v, ok := strings.Cut(kv, "=") + if ok { + secretMap[k] = v + } + } + } + interactive := in == nil && isatty.IsTerminal(os.Stdin.Fd()) + + onlySet := make(map[string]bool, len(a.only)) + for _, name := range a.only { + onlySet[name] = true + } + selector := func(ds []manifestDiscoveredSecret, statuses []SecretStatus) ([]manifestDiscoveredSecret, error) { + setMap := make(map[string]bool, len(statuses)) + for _, status := range statuses { + if status.IsSet { + setMap[status.Name] = true + } + } + var selected []manifestDiscoveredSecret + for _, secret := range ds { + if len(onlySet) > 0 && !onlySet[secret.Name] { + continue + } + if a.skipExisting && setMap[secret.Name] { + continue + } + selected = append(selected, secret) + } + return selected, nil + } + var promptErr error + valuer := func(secret manifestDiscoveredSecret) (string, bool, error) { + if a.fromEnv { + if v := os.Getenv(secret.Name); v != "" { + return v, true, nil + } + } + if v, ok := secretMap[secret.Name]; ok { + return v, true, nil + } + if interactive { + label := secret.Name + if secret.Description != "" { + label += " - " + secret.Description + } + value, err := prompt.Input(label, secret.Sensitive) + if err != nil { + if errors.Is(err, prompt.ErrNotInteractive) { + promptErr = err + } + return "", false, err + } + if value == "" { + return "", false, nil + } + return value, true, nil + } + return "", false, nil + } + auditFn := func(name, _ string) { + _ = writeSecretsAuditRecord(name, "github:"+strings.ToLower(strings.TrimSpace(a.scope))) //nolint:errcheck // best-effort audit + } + + fmt.Fprintf(out, "Setting up secrets from %s -> %s\n\n", a.manifestPath, scopeLabel) + for _, secret := range discovered { + fmt.Fprintf(out, " %s (%s)\n", secret.Name, strings.Join(secret.Sources, ", ")) + } + fmt.Fprintln(out) + + report, err := runSetupEngine(context.Background(), discovered, + func(secret manifestDiscoveredSecret) string { return secret.Name }, + provider, selector, valuer, auditFn, true) + if promptErr != nil { + return promptErr + } + if err != nil { + return err + } + for _, n := range report.Set { + fmt.Fprintf(out, " %s: set\n", n) + } + for _, n := range report.Skipped { + fmt.Fprintf(out, " %s: skipped (no value provided)\n", n) + } + fmt.Fprintln(out, "\nAll done.") + return nil +} + +func discoverManifestSecrets(manifestPath, lockfilePath, pluginDir, configPatterns string) ([]manifestDiscoveredSecret, error) { + plugins, err := discoverManifestPlugins(manifestPath, lockfilePath) + if err != nil { + return nil, err + } + secretsByName := map[string]*manifestDiscoveredSecret{} + for _, pluginName := range plugins { + manifest, err := loadPluginManifest(pluginName, pluginDir) + if err != nil { + return nil, err + } + sourceName := manifest.Name + if sourceName == "" { + sourceName = pluginName + } + for _, required := range manifest.RequiredSecrets { + if strings.TrimSpace(required.Name) == "" { + continue + } + addDiscoveredSecret(secretsByName, required, "plugin:"+sourceName) + } + } + configFiles, err := expandConfigPatterns(configPatterns) + if err != nil { + return nil, err + } + for _, configFile := range configFiles { + refs, err := discoverConfigEnvRefs(configFile) + if err != nil { + return nil, err + } + for _, ref := range refs { + addDiscoveredSecret(secretsByName, PluginRequiredSecret{ + Name: ref, + Sensitive: isSecretSensitive(ref), + }, "config:"+filepath.Base(configFile)) + } + } + return sortedManifestSecrets(secretsByName), nil +} + +func discoverManifestPlugins(manifestPath, lockfilePath string) ([]string, error) { + seen := map[string]bool{} + var plugins []string + add := func(name string) { + name = strings.TrimSpace(name) + if name == "" || seen[name] { + return + } + seen[name] = true + plugins = append(plugins, name) + } + manifest, err := config.LoadWfctlManifest(manifestPath) + if err != nil { + return nil, err + } + for _, plugin := range manifest.Plugins { + add(plugin.Name) + } + if lockfilePath != "" { + lockfile, err := config.LoadWfctlLockfile(lockfilePath) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + } else { + for name := range lockfile.Plugins { + add(name) + } + } + } + sort.Strings(plugins) + return plugins, nil +} + +func addDiscoveredSecret(secretsByName map[string]*manifestDiscoveredSecret, required PluginRequiredSecret, source string) { + name := strings.TrimSpace(required.Name) + if name == "" { + return + } + required.Name = name + secret, ok := secretsByName[name] + if !ok { + secret = &manifestDiscoveredSecret{PluginRequiredSecret: required} + secretsByName[name] = secret + } + if required.Description != "" && secret.Description == "" { + secret.Description = required.Description + } + secret.Sensitive = secret.Sensitive || required.Sensitive || isSecretSensitive(name) + for _, existing := range secret.Sources { + if existing == source { + return + } + } + secret.Sources = append(secret.Sources, source) + sort.Strings(secret.Sources) +} + +func sortedManifestSecrets(secretsByName map[string]*manifestDiscoveredSecret) []manifestDiscoveredSecret { + names := make([]string, 0, len(secretsByName)) + for name := range secretsByName { + names = append(names, name) + } + sort.Strings(names) + out := make([]manifestDiscoveredSecret, 0, len(names)) + for _, name := range names { + out = append(out, *secretsByName[name]) + } + return out +} + +func expandConfigPatterns(patterns string) ([]string, error) { + var files []string + seen := map[string]bool{} + for _, raw := range strings.Split(patterns, ",") { + pattern := strings.TrimSpace(raw) + if pattern == "" { + continue + } + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, fmt.Errorf("expand config pattern %q: %w", pattern, err) + } + if len(matches) == 0 { + if _, err := os.Stat(pattern); err == nil { + matches = []string{pattern} + } + } + for _, match := range matches { + if seen[match] { + continue + } + seen[match] = true + files = append(files, match) + } + } + sort.Strings(files) + return files, nil +} + +func discoverConfigEnvRefs(path string) ([]string, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("read config %s: %w", path, err) + } + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("parse config %s: %w", path, err) + } + refs := map[string]bool{} + collectEnvRefs(&doc, refs) + out := make([]string, 0, len(refs)) + for ref := range refs { + out = append(out, ref) + } + sort.Strings(out) + return out, nil +} + +func collectEnvRefs(node *yaml.Node, refs map[string]bool) { + if node == nil { + return + } + if node.Kind == yaml.ScalarNode { + for _, match := range manifestEnvRefPattern.FindAllStringSubmatch(node.Value, -1) { + if len(match) > 1 { + refs[match[1]] = true + } + } + } + for _, child := range node.Content { + collectEnvRefs(child, refs) + } +} + +func buildSecretLiteralMap(literals []string) (map[string]string, error) { + secretMap := make(map[string]string) + for _, lit := range literals { + k, v, found := strings.Cut(lit, "=") + if !found { + return nil, fmt.Errorf("--secret %q: expected NAME=VALUE format", lit) + } + secretMap[k] = v + } + return secretMap, nil +} + +func readKVLines(r io.Reader) []string { + data, err := io.ReadAll(r) + if err != nil { + return nil + } + var out []string + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") { + continue + } + out = append(out, line) + } + return out +} + +func firstConfigPattern(patterns string) string { + for _, raw := range strings.Split(patterns, ",") { + if pattern := strings.TrimSpace(raw); pattern != "" { + return pattern + } + } + return "app.yaml" +} + +func parseManifestSetupFlags(args []string) (*manifestSetupArgs, error) { + fs := flag.NewFlagSet("secrets setup --manifest", flag.ContinueOnError) + manifestPath := fs.String("manifest", "", "wfctl.yaml plugin manifest") + lockfilePath := fs.String("lock-file", ".wfctl-lock.yaml", "wfctl plugin lockfile") + pluginDir := fs.String("plugin-dir", "", "Plugin install dir (default: $WFCTL_PLUGIN_DIR or ./data/plugins)") + configPatterns := fs.String("config", "app.yaml", "Workflow config file or comma-separated glob list for env reference discovery") + 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)") + visibility := fs.String("visibility", "all", "Org-scope visibility: all | selected | private") + tokenEnv := fs.String("token-env", "GITHUB_TOKEN", "Env var holding the GitHub PAT") + fromEnv := fs.Bool("from-env", false, "Read each secret value from $NAME") + nonInteractive := fs.Bool("non-interactive", false, "Accepted for parity with config setup; manifest setup auto-detects input mode") + onlyFlag := fs.String("only", "", "Comma-separated list of secret names to set") + allFlag := fs.Bool("all", false, "Set all discovered secrets") + skipExisting := fs.Bool("skip-existing", false, "Skip secrets that already have a value in the target scope") + var secretFlag multiStringFlag + fs.Var(&secretFlag, "secret", "NAME=VALUE literal. Repeatable.") + if err := fs.Parse(args); err != nil { + return nil, err + } + _ = nonInteractive + if strings.TrimSpace(*manifestPath) == "" { + return nil, errors.New("--manifest is required") + } + only, err := parseSecretOnlyList(*onlyFlag) + if err != nil { + return nil, err + } + if *allFlag && len(only) > 0 { + return nil, fmt.Errorf("--all and --only are mutually exclusive") + } + return &manifestSetupArgs{ + manifestPath: *manifestPath, + lockfilePath: *lockfilePath, + pluginDir: *pluginDir, + configPatterns: *configPatterns, + scope: *scope, + envName: *envName, + org: *org, + visibility: *visibility, + tokenEnv: *tokenEnv, + fromEnv: *fromEnv, + secretLiterals: []string(secretFlag), + only: only, + skipExisting: *skipExisting, + }, nil +} + +func parseSecretOnlyList(raw string) ([]string, error) { + if strings.TrimSpace(raw) == "" { + return nil, nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + seen := map[string]bool{} + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" { + continue + } + if seen[name] { + continue + } + seen[name] = true + out = append(out, name) + } + sort.Strings(out) + return out, nil +} diff --git a/cmd/wfctl/secrets_setup_manifest_test.go b/cmd/wfctl/secrets_setup_manifest_test.go new file mode 100644 index 000000000..8f2d4fcd9 --- /dev/null +++ b/cmd/wfctl/secrets_setup_manifest_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestDiscoverManifestSecrets(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "wfctl.yaml") + lockPath := filepath.Join(dir, ".wfctl-lock.yaml") + pluginDir := filepath.Join(dir, "plugins") + configPath := filepath.Join(dir, "infra.yaml") + + if err := os.WriteFile(manifestPath, []byte(`version: 1 +plugins: + - name: workflow-plugin-cloudflare + version: v1.2.3 +`), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + if err := os.WriteFile(lockPath, []byte(`version: 1 +generated_at: 2026-06-06T00:00:00Z +plugins: + namecheap: + version: v0.4.5 + source: github.com/GoCodeAlone/workflow-plugin-namecheap +`), 0o600); err != nil { + t.Fatalf("write lockfile: %v", err) + } + writePluginManifestFile(t, pluginDir, "cloudflare", `{ + "name": "workflow-plugin-cloudflare", + "required_secrets": [ + {"name": "CLOUDFLARE_API_TOKEN", "sensitive": true, "description": "Cloudflare API token"} + ] + }`) + writePluginManifestFile(t, pluginDir, "workflow-plugin-namecheap", `{ + "name": "workflow-plugin-namecheap", + "required_secrets": [ + {"name": "NAMECHEAP_API_KEY", "sensitive": true} + ] + }`) + if err := os.WriteFile(configPath, []byte(`providers: + cloudflare: + api_token: ${CLOUDFLARE_API_TOKEN} + extra: + webhook: ${CONFIG_ONLY_TOKEN} +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + secrets, err := discoverManifestSecrets(manifestPath, lockPath, pluginDir, configPath) + if err != nil { + t.Fatalf("discoverManifestSecrets: %v", err) + } + got := make([]string, 0, len(secrets)) + sources := map[string][]string{} + for _, secret := range secrets { + got = append(got, secret.Name) + sources[secret.Name] = secret.Sources + } + want := []string{"CLOUDFLARE_API_TOKEN", "CONFIG_ONLY_TOKEN", "NAMECHEAP_API_KEY"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("secrets = %v, want %v", got, want) + } + if !reflect.DeepEqual(sources["CLOUDFLARE_API_TOKEN"], []string{"config:infra.yaml", "plugin:workflow-plugin-cloudflare"}) { + t.Fatalf("cloudflare sources = %v", sources["CLOUDFLARE_API_TOKEN"]) + } + if !reflect.DeepEqual(sources["CONFIG_ONLY_TOKEN"], []string{"config:infra.yaml"}) { + t.Fatalf("config-only sources = %v", sources["CONFIG_ONLY_TOKEN"]) + } +} + +func TestParseManifestSetupFlagsAcceptsSetupSelectors(t *testing.T) { + args, err := parseManifestSetupFlags([]string{ + "--manifest", "wfctl.yaml", + "--non-interactive", + "--only", "B,A,A", + "--skip-existing", + "--from-env", + }) + if err != nil { + t.Fatalf("parseManifestSetupFlags: %v", err) + } + if !reflect.DeepEqual(args.only, []string{"A", "B"}) { + t.Fatalf("only = %v", args.only) + } + if !args.skipExisting || !args.fromEnv { + t.Fatalf("flags not preserved: %+v", args) + } +} diff --git a/cmd/wfctl/secrets_setup_plugin.go b/cmd/wfctl/secrets_setup_plugin.go index ae3fe7872..6f8f28ba7 100644 --- a/cmd/wfctl/secrets_setup_plugin.go +++ b/cmd/wfctl/secrets_setup_plugin.go @@ -211,16 +211,45 @@ func loadPluginManifest(name, dirOverride string) (*pluginManifest, error) { 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 tried []string + var lastErr error + for _, candidate := range pluginManifestCandidateDirs(name) { + path := filepath.Join(dir, candidate, "plugin.json") + tried = append(tried, path) + data, err := os.ReadFile(path) + if err != nil { + lastErr = err + continue + } + 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 + } + if len(tried) == 1 { + return nil, fmt.Errorf("read plugin manifest %s: %w (run `wfctl plugin install` first; or pass --plugin-dir)", tried[0], lastErr) } - var m pluginManifest - if err := json.Unmarshal(data, &m); err != nil { - return nil, fmt.Errorf("parse plugin manifest %s: %w", path, err) + return nil, fmt.Errorf("read plugin manifest for %q: tried %s: %w (run `wfctl plugin install` first; or pass --plugin-dir)", name, strings.Join(tried, ", "), lastErr) +} + +func pluginManifestCandidateDirs(name string) []string { + trimmed := strings.TrimSpace(name) + normalized := normalizePluginName(trimmed) + candidates := []string{trimmed, normalized} + if normalized != "" { + candidates = append(candidates, "workflow-plugin-"+normalized) + } + seen := make(map[string]bool, len(candidates)) + out := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + if candidate == "" || seen[candidate] { + continue + } + seen[candidate] = true + out = append(out, candidate) } - return &m, nil + return out } // promptOne reads a single value for one required secret from the supplied diff --git a/cmd/wfctl/secrets_setup_plugin_test.go b/cmd/wfctl/secrets_setup_plugin_test.go index 4f17b248f..5a2ba116f 100644 --- a/cmd/wfctl/secrets_setup_plugin_test.go +++ b/cmd/wfctl/secrets_setup_plugin_test.go @@ -49,6 +49,26 @@ func TestLoadPluginManifest_HappyPath(t *testing.T) { } } +func TestLoadPluginManifest_NormalizedInstallDir(t *testing.T) { + dir := t.TempDir() + writePluginManifestFile(t, dir, "cloudflare", `{ + "name": "workflow-plugin-cloudflare", + "required_secrets": [ + {"name": "CLOUDFLARE_API_TOKEN", "sensitive": true} + ] + }`) + m, err := loadPluginManifest("workflow-plugin-cloudflare", dir) + if err != nil { + t.Fatalf("loadPluginManifest: %v", err) + } + if m.Name != "workflow-plugin-cloudflare" { + t.Errorf("name = %q", m.Name) + } + if len(m.RequiredSecrets) != 1 || m.RequiredSecrets[0].Name != "CLOUDFLARE_API_TOKEN" { + t.Fatalf("required_secrets = %+v", m.RequiredSecrets) + } +} + func TestLoadPluginManifest_MissingDir(t *testing.T) { _, err := loadPluginManifest("nope", t.TempDir()) if err == nil { diff --git a/docs/WFCTL.md b/docs/WFCTL.md index 5cbf41e53..681fcfd45 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -2509,7 +2509,7 @@ wfctl secrets sync --from staging --to production #### `secrets setup` -Set secrets declared in the config for a given environment. Automatically selects interactive or non-interactive mode based on whether stdin is a TTY. +Set secrets declared in the config for a given environment. Automatically selects interactive or non-interactive mode based on whether stdin is a TTY. With `--manifest`, discovers secrets from `wfctl.yaml`, `.wfctl-lock.yaml`, installed plugin `required_secrets[]`, and `${ENV_VAR}` references in workflow configs. **Interactive mode** (default when stdin is a TTY): lists each declared secret with its current set/unset status, presents a multi-select to choose which secrets to set, prompts to pick a store when none is configured (resolves via `--store` > `secrets.defaultStore` > single-store auto-select > interactive pick), and collects values with masked terminal input for sensitive names. @@ -2529,6 +2529,9 @@ wfctl secrets setup [options] |------|---------|-------------| | `--env` | `local` | Target environment name (interactive path) | | `--config` | `app.yaml` | Workflow config file | +| `--manifest` | _(none)_ | Use a `wfctl.yaml` plugin manifest to discover repo-level plugin and config secrets | +| `--lock-file` | `.wfctl-lock.yaml` | Lockfile to include when `--manifest` is used | +| `--plugin-dir` | `$WFCTL_PLUGIN_DIR` or `./data/plugins` | Installed plugin directory for `required_secrets[]` discovery | | `--store` | _(config defaultStore)_ | Named store to use; overrides `secrets.defaultStore` | | `--non-interactive` | `false` | Force non-interactive mode (also auto when stdin is not a TTY) | | `--from-env` | `false` | Read each secret value from `$NAME`. Recommended for CI. | @@ -2537,10 +2540,10 @@ wfctl secrets setup [options] | `--only` | _(all)_ | Comma-separated list of secret names to set; mutually exclusive with `--all` | | `--skip-existing` | `false` | Skip secrets that already have a value in the store | | `--auto-gen-keys` | `false` | Auto-generate random values for secrets ending in `_KEY`, `_SECRET`, `_TOKEN`, or `_SIGNING`; implies non-interactive | -| `--scope` | `repo` | GitHub scope: `repo` \| `env` \| `org` (legacy `--plugin` path) | -| `--org` | _(none)_ | Organization slug (legacy `--plugin` path) | -| `--visibility` | `all` | Org-scope visibility: `all` \| `selected` \| `private` (legacy `--plugin` path) | -| `--token-env` | `GITHUB_TOKEN` | Env var holding the GitHub PAT (legacy `--plugin` path) | +| `--scope` | `repo` | GitHub scope for plugin or manifest setup: `repo` \| `env` \| `org` | +| `--org` | _(none)_ | Organization slug for `--scope org` | +| `--visibility` | `all` | Org-scope visibility: `all` \| `selected` \| `private` | +| `--token-env` | `GITHUB_TOKEN` | Env var holding the GitHub PAT | ```bash # Interactive wizard @@ -2555,6 +2558,10 @@ wfctl secrets setup --env production --auto-gen-keys # Set specific secrets only wfctl secrets setup --only DB_URL,STRIPE_KEY --from-env + +# Discover provider plugin secrets from wfctl.yaml/.wfctl-lock.yaml plus config ${ENV_VAR} refs +wfctl secrets setup --manifest wfctl.yaml --config 'infra/*.yaml,deploy.yaml' \ + --plugin-dir data/plugins --scope org --org GoCodeAlone --from-env ``` --- diff --git a/docs/iac-dns-providers.md b/docs/iac-dns-providers.md index fe90bad4f..b37621727 100644 --- a/docs/iac-dns-providers.md +++ b/docs/iac-dns-providers.md @@ -199,6 +199,17 @@ wfctl secrets setup --plugin workflow-plugin-hover \ --scope env --env production ``` +For a repo that already has `wfctl.yaml` and `.wfctl-lock.yaml`, use the +manifest-driven form to discover all installed provider plugin secrets plus +`${ENV_VAR}` references in config files: + +```sh +wfctl secrets setup --manifest wfctl.yaml \ + --config 'infra/*.yaml,deploy.yaml' \ + --plugin-dir data/plugins \ + --scope org --org GoCodeAlone --from-env +``` + See `docs/wfctl-secrets-scopes.md` for the scope flag matrix. ## Provider plan diff --git a/docs/wfctl-secrets-scopes.md b/docs/wfctl-secrets-scopes.md index abdbf7def..afb28a4be 100644 --- a/docs/wfctl-secrets-scopes.md +++ b/docs/wfctl-secrets-scopes.md @@ -85,11 +85,22 @@ wfctl secrets setup --plugin workflow-plugin-hover \ This: -1. Reads `data/plugins/workflow-plugin-hover/plugin.json`. +1. Reads `plugin.json` from the installed plugin directory. The directory may be + the full plugin name, the normalized provider name, or + `workflow-plugin-`. 2. Iterates `required_secrets[]`. 3. Prompts for each (masked iff `sensitive: true`). 4. Writes each to the chosen GH scope. +Repo-level setup can discover all provider plugin secrets from `wfctl.yaml` and +`.wfctl-lock.yaml`: + +```sh +wfctl secrets setup --manifest wfctl.yaml \ + --config 'infra/*.yaml,deploy.yaml' \ + --scope org --org GoCodeAlone --from-env +``` + Pipe a value list to skip the prompt loop in CI: ```sh