From 0c6c89002f6b508799d93df1ff2db5772bf67e65 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:12:57 -0400 Subject: [PATCH 01/26] docs: add wfctl audit and plugin ecosystem design Addresses PRs #321, #322, and issue #316. Covers 13 CLI fixes, 5 registry data fixes, and holistic plugin ecosystem plan including goreleaser standardization, GitHub URL install, lockfile support, minEngineVersion checks, and auto-sync CI. Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-12-wfctl-audit-design.md | 194 ++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/plans/2026-03-12-wfctl-audit-design.md diff --git a/docs/plans/2026-03-12-wfctl-audit-design.md b/docs/plans/2026-03-12-wfctl-audit-design.md new file mode 100644 index 00000000..c08888cd --- /dev/null +++ b/docs/plans/2026-03-12-wfctl-audit-design.md @@ -0,0 +1,194 @@ +# Design: wfctl CLI Audit & Plugin Ecosystem Improvements + +**Date:** 2026-03-12 +**Status:** Approved +**Scope:** wfctl CLI fixes, workflow-registry data fixes, plugin ecosystem standardization + +## Summary + +Comprehensive audit and fix of the wfctl CLI tool addressing 13 bugs/UX issues found during testing, 5 registry data problems, and establishing a holistic plugin ecosystem plan across workflow-registry and all workflow-plugin-* repos. Addresses workflow PRs #321, #322, and issue #316. + +## Motivation + +- `--help` exits 1 with internal engine errors for all pipeline-dispatched commands +- `plugin install -data-dir` flag is silently ignored — plugins install to wrong directory +- Flags after positional args are silently dropped with no helpful error +- Full plugin names (`workflow-plugin-authz`) don't resolve in registry lookups +- Plugin manifest versions don't match release tags +- No way to install plugins from GitHub URLs directly +- No plugin version pinning or lockfile support +- Inconsistent goreleaser configs across plugin repos produce different tarball layouts + +## A. wfctl CLI Fixes + +### Fix 1: `--help` exits 0 and suppresses engine error + +Root cause: `flag.ErrHelp` propagates through the pipeline engine as a step failure. In `main.go`'s command dispatch, catch `flag.ErrHelp` and `os.Exit(0)` instead of letting it reach the engine error wrapper. Same for the no-args case — show usage and exit 0. + +### Fix 2: `plugin install -plugin-dir` honored + +In `plugin_install.go`, the install logic hardcodes `data/plugins`. Thread the `-plugin-dir` flag value through to the download/extract path. Rename the flag from `-data-dir` to `-plugin-dir` (see Fix 13). + +### Fix 3: Flag ordering — helpful error message + +Go's `flag.FlagSet.Parse` stops at first non-flag arg. Detect unused flags after the positional arg and print: `"error: flags must come before arguments (got -foo after 'bar'). Try: wfctl plugin init -author X bar"`. Add a helper `checkTrailingFlags(args)` used by all subcommands that accept both flags and positional args. + +### Fix 4: Full plugin name resolution + +In the plugin install/search path, strip the `workflow-plugin-` prefix when looking up. So `workflow-plugin-authz` → `authz`. Try the raw name first, then the stripped name. + +### Fix 5: Positional config arg consistency + +Commands like `validate`, `inspect`, `api extract` accept positional config args. `deploy kubernetes generate` and other deploy subcommands don't. Add positional arg support to deploy subcommands using the same pattern as validate. + +### Fix 6: `plugin update` version check + +Before downloading, compare installed `plugin.json` version against registry manifest version. If equal, print "already at latest version (vX.Y.Z)" and skip the download. + +### Fix 7: `init` generates valid Dockerfile + +Generate a Dockerfile that handles missing `go.sum` gracefully: use `COPY go.sum* ./` (glob, no error if missing) followed by `RUN go mod download` which handles both cases. + +### Fix 8: Infra commands — better error + +When no config found, print: `"No infrastructure config found. Create infra.yaml with cloud.account and platform.* modules. Run 'wfctl init --template infra' for a starter config."`. + +### Fix 9: `validate --dir` skips non-workflow YAML + +Before validating a file found by directory scan, check for at least one of `modules:`, `workflows:`, or `pipelines:` as top-level keys. Skip files that don't match with a debug-level message: `"Skipping non-workflow file: .github/workflows/ci.yml"`. + +### Fix 10: `plugin info` absolute paths + +Resolve the binary path to absolute before displaying. + +### Fix 11: PR #322 — `PluginManifest` legacy capabilities + +Add `UnmarshalJSON` on `PluginManifest` that handles `capabilities` as either `[]CapabilityDecl` (new array format) or `{configProvider, moduleTypes, stepTypes, triggerTypes}` (legacy object format), merging the object's type lists into the top-level manifest fields. + +### Fix 12: Validation follows YAML includes + +When a config uses `include:` directives to split config across multiple files, `validate` should recursively resolve and validate the referenced files. Parse the root config, find `include` references, resolve relative paths, and validate each included file in context. + +### Fix 13: Rename `-data-dir` to `-plugin-dir` + +Several commands already use `-plugin-dir` (validate, template validate, mcp, docs generate, modernize). The plugin subcommands (`install`, `list`, `info`, `update`, `remove`) use `-data-dir`. Rename all plugin subcommand flags to `-plugin-dir` for consistency. Keep `-data-dir` as a hidden alias that still works but prints a deprecation notice. + +## B. Registry Data Fixes + +### B1. `agent` manifest type + +Change `type: "internal"` → `type: "builtin"` in `plugins/agent/manifest.json`. The agent plugin ships with the engine as a Go library. + +### B2. `ratchet` manifest downloads + +Add `downloads` entries for linux/darwin x amd64/arm64 pointing to `GoCodeAlone/ratchet` GitHub releases. Currently `downloads: []` which causes validation failure. + +### B3. `authz` manifest name resolution + +Verify the manifest exists at `plugins/authz/manifest.json` and that the `name` field matches what wfctl expects. The PR #321 failure (`"workflow-plugin-authz" not found in registry`) suggests a name mismatch. + +### B4. Version alignment script + +Create `scripts/sync-versions.sh` that queries `gh release view --json tagName` for each external plugin with a `repository` field and compares against the manifest `version`. Report mismatches. Run in CI as a weekly check. + +### B5. Schema validation gap + +The `agent` manifest with `type: "internal"` should have been caught by the JSON Schema validation in CI. Either the schema enum needs updating to match, or CI isn't running properly. Investigate and fix. + +## C. Plugin Ecosystem Plan + +### C1. goreleaser standardization + +Create a reference `.goreleaser.yml` and audit all plugin repos: + +```yaml +# Standard plugin goreleaser config +builds: + - binary: "{{ .ProjectName }}" + goos: [linux, darwin] + goarch: [amd64, arm64] + env: [CGO_ENABLED=0] + ldflags: ["-s", "-w", "-X main.version={{ .Version }}"] + +archives: + - format: tar.gz + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + files: + - plugin.json + +# Template version in plugin.json before build +before: + hooks: + - cmd: sed -i'' -e 's/"version":.*/"version": "{{ .Version }}",/' plugin.json +``` + +Repos to audit and fix: +- workflow-plugin-authz, payments, admin, bento, github, agent +- workflow-plugin-waf, security, sandbox, supply-chain, data-protection +- workflow-plugin-authz-ui, cloud-ui + +### C2. `wfctl plugin install` from GitHub URL + +Support `wfctl plugin install GoCodeAlone/workflow-plugin-authz@v0.3.1`: + +1. First try registry lookup (strip `workflow-plugin-` prefix) +2. If not found and input contains `/`, treat as `owner/repo@version` +3. Query GitHub Releases API: `GET /repos/{owner}/{repo}/releases/tags/{version}` +4. Find asset matching `{repo}_{os}_{arch}.tar.gz` +5. Download, extract, install to `-plugin-dir` + +Falls back gracefully: registry hit → GitHub direct → error with helpful message. + +### C3. Plugin lockfile (`.wfctl.yaml` plugins section) + +Extend `.wfctl.yaml` (already created by `git connect`) with a `plugins:` section: + +```yaml +plugins: + authz: + version: v0.3.1 + repository: GoCodeAlone/workflow-plugin-authz + sha256: abc123... + payments: + version: v0.1.0 + repository: GoCodeAlone/workflow-plugin-payments +``` + +- `wfctl plugin install` (no args): reads lockfile, installs/verifies all entries +- `wfctl plugin install @`: installs and updates lockfile entry +- `wfctl plugin install --save `: shorthand to install latest and pin + +### C4. Engine `minEngineVersion` check + +In the engine's `PluginLoader`, after reading `plugin.json`, compare `minEngineVersion` against the running engine version using semver comparison. If incompatible, log a warning: `"plugin X requires engine >= vY.Z.0, running vA.B.C — may cause runtime failures"`. Don't hard-fail to allow testing. + +### C5. Registry manifest auto-sync CI + +Add a GitHub Action to each plugin repo's release workflow: + +```yaml +# After goreleaser publishes: +- name: Update registry manifest + run: | + # Clone workflow-registry, update manifest version + downloads + checksums + # Open PR to workflow-registry +``` + +This eliminates manual version drift between releases and registry manifests. + +## Testing Strategy + +- Build wfctl, run all commands in `/tmp/wfctl-test` directory +- Test each fix against the specific failure scenario from the audit +- `go test ./cmd/wfctl/...` for unit tests +- `go test ./...` for full suite +- Manual verification of plugin install/update/list lifecycle +- Registry manifest validation via `scripts/validate-manifests.sh` + +## Decisions + +- **`-plugin-dir` over `-data-dir`**: Consistency with existing commands. Hidden alias prevents breakage. +- **Registry name stripping over aliasing**: Simpler than maintaining alias maps. `workflow-plugin-authz` → `authz` covers all cases. +- **Warning over hard-fail for minEngineVersion**: Allows testing newer plugins against older engines without blocking. +- **Lockfile in `.wfctl.yaml` over separate file**: Reuses existing config file, keeps project root clean. +- **goreleaser `sed` hook over Go ldflags for version**: `plugin.json` is a static file that needs the version at build time, not just the binary. From ad199a54ee3bbd66b388bcb84d4615ed24545074 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:14:31 -0400 Subject: [PATCH 02/26] feat: add wave 2 integration plugins design + release/validation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 2 design: Okta (~130 steps), Datadog (~120), LaunchDarkly (~100), Permit.io (~80), Salesforce (~75), OpenLMS (~120) — all MIT, community tier. Release plan: tag untagged plugins, create validation scenarios 51-59 with mock HTTP backends in workflow-scenarios. Co-Authored-By: Claude Opus 4.6 --- ...-03-11-integration-plugins-wave2-design.md | 258 ++++++++++++++++++ ...26-03-11-plugin-releases-and-validation.md | 205 ++++++++++++++ 2 files changed, 463 insertions(+) create mode 100644 docs/plans/2026-03-11-integration-plugins-wave2-design.md create mode 100644 docs/plans/2026-03-11-plugin-releases-and-validation.md diff --git a/docs/plans/2026-03-11-integration-plugins-wave2-design.md b/docs/plans/2026-03-11-integration-plugins-wave2-design.md new file mode 100644 index 00000000..17cf5830 --- /dev/null +++ b/docs/plans/2026-03-11-integration-plugins-wave2-design.md @@ -0,0 +1,258 @@ +# Integration Plugins Wave 2 Design: Okta, Datadog, LaunchDarkly, Permit.io, Salesforce, OpenLMS + +**Date**: 2026-03-11 +**Status**: Approved + +## Overview + +Six new external gRPC plugins for the workflow engine, continuing the integration plugin pattern established in wave 1 (Twilio, monday.com, turn.io). All are MIT-licensed, open-source, community-tier plugins following the `workflow-plugin-*` pattern. + +## Common Architecture + +Identical to wave 1 — see `2026-03-11-integration-plugins-design.md`. Each plugin: +- Standalone Go repo: `GoCodeAlone/workflow-plugin-` +- `sdk.Serve(provider)` entry point +- PluginProvider + ModuleProvider + StepProvider interfaces +- One module type per plugin (`.provider`) +- Package-level provider registry with `sync.RWMutex` +- GoReleaser v2, `CGO_ENABLED=0`, linux/darwin x amd64/arm64 +- MIT license, community tier, minEngineVersion `0.3.30` + +--- + +## Plugin 1: workflow-plugin-okta + +**Dependency**: `github.com/okta/okta-sdk-golang/v6` v6.0.3 (official, auto-generated from OpenAPI spec) + +**Module**: `okta.provider` +- Config: `orgUrl` (required, e.g. `https://dev-123456.okta.com`), `apiToken` (for SSWS auth) OR `clientId` + `privateKey` (for OAuth 2.0 JWT), optional `scopes` +- Initializes Okta SDK client + +### Step Types (~130 priority steps, all prefixed `step.okta_`) + +| Category | Steps | Count | +|----------|-------|-------| +| Users — CRUD | `user_create`, `user_get`, `user_list`, `user_update`, `user_delete` | 5 | +| Users — Lifecycle | `user_activate`, `user_deactivate`, `user_reactivate`, `user_suspend`, `user_unsuspend`, `user_unlock`, `user_reset_factors` | 7 | +| Users — Credentials | `user_change_password`, `user_reset_password`, `user_expire_password`, `user_set_recovery_question` | 4 | +| Groups — CRUD | `group_create`, `group_get`, `group_list`, `group_delete`, `group_add_user`, `group_remove_user`, `group_list_users` | 7 | +| Group Rules | `group_rule_create`, `group_rule_get`, `group_rule_list`, `group_rule_delete`, `group_rule_activate`, `group_rule_deactivate` | 6 | +| Applications — Core | `app_create`, `app_get`, `app_list`, `app_update`, `app_delete`, `app_activate`, `app_deactivate` | 7 | +| Applications — Users | `app_user_assign`, `app_user_get`, `app_user_list`, `app_user_update`, `app_user_unassign` | 5 | +| Applications — Groups | `app_group_assign`, `app_group_get`, `app_group_list`, `app_group_update`, `app_group_unassign` | 5 | +| Authorization Servers | `authz_server_create`, `authz_server_get`, `authz_server_list`, `authz_server_update`, `authz_server_delete`, `authz_server_activate`, `authz_server_deactivate` | 7 | +| Auth Server — Claims/Scopes/Policies | `authz_claim_create`, `authz_claim_list`, `authz_claim_delete`, `authz_scope_create`, `authz_scope_list`, `authz_scope_delete`, `authz_policy_create`, `authz_policy_list`, `authz_policy_delete`, `authz_policy_rule_create`, `authz_policy_rule_list`, `authz_policy_rule_delete`, `authz_key_list`, `authz_key_rotate` | 14 | +| Policies | `policy_create`, `policy_get`, `policy_list`, `policy_delete`, `policy_activate`, `policy_deactivate`, `policy_rule_create`, `policy_rule_list`, `policy_rule_delete`, `policy_rule_activate`, `policy_rule_deactivate` | 11 | +| Authenticators (MFA) | `authenticator_create`, `authenticator_get`, `authenticator_list`, `authenticator_activate`, `authenticator_deactivate` | 5 | +| User Factors | `factor_enroll`, `factor_list`, `factor_verify`, `factor_unenroll`, `factor_activate` | 5 | +| Identity Providers | `idp_create`, `idp_get`, `idp_list`, `idp_delete`, `idp_activate`, `idp_deactivate` | 6 | +| Sessions | `session_get`, `session_refresh`, `session_revoke` | 3 | +| Network Zones | `network_zone_create`, `network_zone_get`, `network_zone_list`, `network_zone_delete`, `network_zone_activate`, `network_zone_deactivate` | 6 | +| System Log | `log_list` | 1 | +| Event Hooks | `event_hook_create`, `event_hook_get`, `event_hook_list`, `event_hook_delete`, `event_hook_activate`, `event_hook_deactivate`, `event_hook_verify` | 7 | +| Inline Hooks | `inline_hook_create`, `inline_hook_get`, `inline_hook_list`, `inline_hook_delete`, `inline_hook_activate`, `inline_hook_deactivate`, `inline_hook_execute` | 7 | +| Domains | `domain_create`, `domain_get`, `domain_list`, `domain_delete`, `domain_verify` | 5 | +| Brands & Themes | `brand_get`, `brand_list`, `brand_update`, `theme_get`, `theme_list`, `theme_update` | 6 | +| Org Settings | `org_get`, `org_update` | 2 | + +--- + +## Plugin 2: workflow-plugin-datadog + +**Dependency**: `github.com/DataDog/datadog-api-client-go/v2` v2.56.0 (official, auto-generated) + +**Module**: `datadog.provider` +- Config: `apiKey` (required), `appKey` (required), optional `site` (default `datadoghq.com`), optional `apiUrl` +- Sets up Datadog client context with API + app keys + +### Step Types (~120 priority steps, all prefixed `step.datadog_`) + +| Category | Steps | Count | +|----------|-------|-------| +| Metrics | `metric_submit`, `metric_query`, `metric_query_scalar`, `metric_metadata_get`, `metric_metadata_update`, `metric_list_active`, `metric_tag_config_create`, `metric_tag_config_update`, `metric_tag_config_delete`, `metric_tag_config_list` | 10 | +| Events | `event_create`, `event_get`, `event_list`, `event_search` | 4 | +| Monitors | `monitor_create`, `monitor_get`, `monitor_update`, `monitor_delete`, `monitor_list`, `monitor_search`, `monitor_validate` | 7 | +| Dashboards | `dashboard_create`, `dashboard_get`, `dashboard_update`, `dashboard_delete`, `dashboard_list` | 5 | +| Logs | `log_submit`, `log_search`, `log_aggregate`, `log_archive_create`, `log_archive_list`, `log_archive_delete`, `log_pipeline_create`, `log_pipeline_list`, `log_pipeline_delete` | 9 | +| Synthetics | `synthetics_test_create`, `synthetics_test_get`, `synthetics_test_update`, `synthetics_test_delete`, `synthetics_test_list`, `synthetics_test_trigger`, `synthetics_results_get`, `synthetics_global_var_create`, `synthetics_global_var_list`, `synthetics_global_var_delete` | 10 | +| SLOs | `slo_create`, `slo_get`, `slo_update`, `slo_delete`, `slo_list`, `slo_search`, `slo_history_get` | 7 | +| Downtimes | `downtime_create`, `downtime_get`, `downtime_update`, `downtime_cancel`, `downtime_list` | 5 | +| Incidents | `incident_create`, `incident_get`, `incident_update`, `incident_delete`, `incident_list`, `incident_todo_create`, `incident_todo_update`, `incident_todo_delete` | 8 | +| Security | `security_rule_create`, `security_rule_get`, `security_rule_update`, `security_rule_delete`, `security_rule_list`, `security_signal_list`, `security_signal_state_update` | 7 | +| Users | `user_create`, `user_get`, `user_update`, `user_disable`, `user_list`, `user_invite` | 6 | +| Roles | `role_create`, `role_get`, `role_update`, `role_delete`, `role_list`, `role_permission_add`, `role_permission_remove` | 7 | +| Teams | `team_create`, `team_get`, `team_update`, `team_delete`, `team_list`, `team_member_add`, `team_member_remove` | 7 | +| Key Management | `api_key_create`, `api_key_get`, `api_key_update`, `api_key_delete`, `api_key_list`, `app_key_create`, `app_key_list`, `app_key_delete` | 8 | +| Notebooks | `notebook_create`, `notebook_get`, `notebook_update`, `notebook_delete`, `notebook_list` | 5 | +| Hosts | `host_list`, `host_mute`, `host_unmute`, `host_totals_get` | 4 | +| Tags | `tags_get`, `tags_update`, `tags_delete`, `tags_list` | 4 | +| Service Catalog | `service_definition_upsert`, `service_definition_get`, `service_definition_delete`, `service_definition_list` | 4 | +| APM | `apm_retention_filter_create`, `apm_retention_filter_update`, `apm_retention_filter_delete`, `apm_retention_filter_list`, `span_search`, `span_aggregate` | 6 | +| Audit | `audit_log_search`, `audit_log_list` | 2 | + +--- + +## Plugin 3: workflow-plugin-launchdarkly + +**Dependency**: `github.com/launchdarkly/api-client-go/v22` (official, auto-generated from OpenAPI) + +**Module**: `launchdarkly.provider` +- Config: `apiKey` (required), optional `apiUrl` (default `https://app.launchdarkly.com`) +- Uses context-based auth: `context.WithValue(ctx, ldapi.ContextAPIKey, ...)` + +### Step Types (~100 priority steps, all prefixed `step.launchdarkly_`) + +| Category | Steps | Count | +|----------|-------|-------| +| Feature Flags | `flag_list`, `flag_get`, `flag_create`, `flag_update`, `flag_delete`, `flag_copy`, `flag_status_get`, `flag_status_list` | 8 | +| Projects | `project_list`, `project_get`, `project_create`, `project_update`, `project_delete` | 5 | +| Environments | `environment_list`, `environment_get`, `environment_create`, `environment_update`, `environment_delete`, `environment_reset_sdk_key`, `environment_reset_mobile_key` | 7 | +| Segments | `segment_list`, `segment_get`, `segment_create`, `segment_update`, `segment_delete` | 5 | +| Contexts | `context_list`, `context_get`, `context_search`, `context_kind_list`, `context_kind_upsert`, `context_evaluate` | 6 | +| Metrics | `metric_list`, `metric_get`, `metric_create`, `metric_update`, `metric_delete` | 5 | +| Experiments | `experiment_list`, `experiment_get`, `experiment_create`, `experiment_update`, `experiment_results_get` | 5 | +| Approvals | `approval_list`, `approval_get`, `approval_create`, `approval_delete`, `approval_apply`, `approval_review` | 6 | +| Scheduled Changes | `scheduled_change_list`, `scheduled_change_create`, `scheduled_change_update`, `scheduled_change_delete` | 4 | +| Flag Triggers | `trigger_list`, `trigger_create`, `trigger_get`, `trigger_update`, `trigger_delete` | 5 | +| Workflows | `workflow_list`, `workflow_get`, `workflow_create`, `workflow_delete` | 4 | +| Audit Log | `audit_log_list`, `audit_log_get` | 2 | +| Members | `member_list`, `member_get`, `member_create`, `member_update`, `member_delete` | 5 | +| Teams | `team_list`, `team_get`, `team_create`, `team_update`, `team_delete` | 5 | +| Custom Roles | `role_list`, `role_get`, `role_create`, `role_update`, `role_delete` | 5 | +| Access Tokens | `token_list`, `token_get`, `token_create`, `token_update`, `token_delete`, `token_reset` | 6 | +| Webhooks | `webhook_list`, `webhook_get`, `webhook_create`, `webhook_update`, `webhook_delete` | 5 | +| Relay Proxy | `relay_config_list`, `relay_config_get`, `relay_config_create`, `relay_config_update`, `relay_config_delete` | 5 | +| Release Pipelines | `release_pipeline_list`, `release_pipeline_get`, `release_pipeline_create`, `release_pipeline_update`, `release_pipeline_delete` | 5 | +| Code References | `code_ref_repo_list`, `code_ref_repo_create`, `code_ref_repo_delete`, `code_ref_extinction_list` | 4 | + +--- + +## Plugin 4: workflow-plugin-permit + +**Dependency**: `github.com/permitio/permit-golang` v1.2.8 (official Go SDK) + +**Module**: `permit.provider` +- Config: `apiKey` (required), optional `pdpUrl` (default `https://cloudpdp.api.permit.io`), optional `apiUrl` (default `https://api.permit.io`), optional `project`, `environment` +- Initializes Permit SDK client + +### Step Types (~80 steps, all prefixed `step.permit_`) + +| Category | Steps | Count | +|----------|-------|-------| +| Authorization Checks | `check`, `check_bulk`, `user_permissions`, `authorized_users` | 4 | +| Users | `user_create`, `user_get`, `user_list`, `user_update`, `user_delete`, `user_sync`, `user_get_roles` | 7 | +| Tenants | `tenant_create`, `tenant_get`, `tenant_list`, `tenant_update`, `tenant_delete`, `tenant_list_users` | 6 | +| Roles (RBAC) | `role_create`, `role_get`, `role_list`, `role_update`, `role_delete`, `role_assign_permissions`, `role_remove_permissions` | 7 | +| Role Assignments | `role_assign`, `role_unassign`, `role_assignment_list`, `role_bulk_assign`, `role_bulk_unassign` | 5 | +| Resources | `resource_create`, `resource_get`, `resource_list`, `resource_update`, `resource_delete` | 5 | +| Resource Actions | `resource_action_create`, `resource_action_get`, `resource_action_list`, `resource_action_update`, `resource_action_delete` | 5 | +| Resource Roles | `resource_role_create`, `resource_role_get`, `resource_role_list`, `resource_role_update`, `resource_role_delete` | 5 | +| Resource Relations (ReBAC) | `resource_relation_create`, `resource_relation_list`, `resource_relation_delete` | 3 | +| Resource Instances (ReBAC) | `resource_instance_create`, `resource_instance_get`, `resource_instance_list`, `resource_instance_update`, `resource_instance_delete` | 5 | +| Relationship Tuples | `relationship_tuple_create`, `relationship_tuple_delete`, `relationship_tuple_list`, `relationship_tuple_bulk_create`, `relationship_tuple_bulk_delete` | 5 | +| Condition Sets (ABAC) | `condition_set_create`, `condition_set_get`, `condition_set_list`, `condition_set_update`, `condition_set_delete` | 5 | +| Projects | `project_create`, `project_get`, `project_list`, `project_update`, `project_delete` | 5 | +| Environments | `env_create`, `env_get`, `env_list`, `env_update`, `env_delete`, `env_copy` | 6 | +| API Keys | `api_key_create`, `api_key_list`, `api_key_delete`, `api_key_rotate` | 4 | +| Organizations | `org_get`, `org_update`, `member_list`, `member_invite`, `member_remove` | 5 | + +--- + +## Plugin 5: workflow-plugin-salesforce + +**Dependency**: `github.com/k-capehart/go-salesforce/v3` v3.1.1 (community, well-maintained) + +**Module**: `salesforce.provider` +- Config: `loginUrl` (required), `clientId`, `clientSecret` (for OAuth client credentials), OR `accessToken` (direct), optional `apiVersion` (default `v63.0`) +- Initializes Salesforce client with OAuth + +### Step Types (~75 steps, all prefixed `step.salesforce_`) + +| Category | Steps | Count | +|----------|-------|-------| +| SObject CRUD | `record_get`, `record_create`, `record_update`, `record_upsert`, `record_delete`, `record_describe`, `describe_global` | 7 | +| SOQL Query | `query`, `query_all` | 2 | +| SOSL Search | `search` | 1 | +| Collections | `collection_insert`, `collection_update`, `collection_upsert`, `collection_delete` | 4 | +| Composite | `composite_request`, `composite_tree` | 2 | +| Bulk API v2 | `bulk_insert`, `bulk_update`, `bulk_upsert`, `bulk_delete`, `bulk_query`, `bulk_query_results`, `bulk_job_status`, `bulk_job_abort` | 8 | +| Tooling | `tooling_query`, `tooling_get`, `tooling_create`, `tooling_update`, `tooling_delete`, `apex_execute` | 6 | +| Apex REST | `apex_get`, `apex_post`, `apex_patch`, `apex_put`, `apex_delete` | 5 | +| Reports | `report_list`, `report_describe`, `report_run`, `dashboard_list`, `dashboard_describe`, `dashboard_refresh` | 6 | +| Approval | `approval_list`, `approval_submit`, `approval_approve`, `approval_reject` | 4 | +| Chatter | `chatter_post`, `chatter_comment`, `chatter_like`, `chatter_feed_list` | 4 | +| Files | `file_upload`, `file_download`, `content_version_create`, `content_document_get`, `content_document_delete` | 5 | +| Users | `user_get`, `user_list`, `user_create`, `user_update`, `identity_get`, `org_limits` | 6 | +| Flows | `flow_list`, `flow_run` | 2 | +| Events | `event_publish` | 1 | +| Metadata | `metadata_describe`, `metadata_list`, `metadata_read`, `metadata_create`, `metadata_update`, `metadata_delete`, `metadata_deploy`, `metadata_retrieve` | 8 | +| Generic | `raw_request` | 1 | + +--- + +## Plugin 6: workflow-plugin-openlms + +**Dependency**: Direct REST client (Moodle Web Services API, form-POST or Catalyst RESTful plugin) + +**Module**: `openlms.provider` +- Config: `siteUrl` (required, e.g. `https://lms.example.com`), `token` (required, Web Services token), optional `restful` (bool, default false — use Catalyst RESTful plugin endpoint) +- Initializes HTTP client with token auth + +### Step Types (~120 priority steps, all prefixed `step.openlms_`) + +| Category | Steps | Count | +|----------|-------|-------| +| Users | `user_create`, `user_update`, `user_delete`, `user_get`, `user_get_by_field`, `user_search` | 6 | +| Courses | `course_create`, `course_update`, `course_delete`, `course_get`, `course_get_by_field`, `course_search`, `course_get_contents`, `course_get_categories`, `course_create_categories`, `course_delete_categories`, `course_duplicate` | 11 | +| Enrollments | `enrol_get_enrolled_users`, `enrol_get_user_courses`, `enrol_manual_enrol`, `enrol_manual_unenrol`, `enrol_self_enrol`, `enrol_get_course_methods` | 6 | +| Grades | `grade_get_grades`, `grade_update_grades`, `grade_get_grade_items`, `grade_get_grades_table` | 4 | +| Assignments | `assign_get_assignments`, `assign_get_submissions`, `assign_get_grades`, `assign_save_submission`, `assign_submit_for_grading`, `assign_save_grade` | 6 | +| Quizzes | `quiz_get_by_course`, `quiz_get_attempts`, `quiz_get_attempt_data`, `quiz_get_attempt_review`, `quiz_start_attempt`, `quiz_save_attempt`, `quiz_process_attempt` | 7 | +| Forums | `forum_get_by_course`, `forum_get_discussions`, `forum_get_posts`, `forum_add_discussion`, `forum_add_post`, `forum_delete_post` | 6 | +| Groups | `group_create`, `group_delete`, `group_get_course_groups`, `group_get_members`, `group_add_members`, `group_delete_members` | 6 | +| Messages | `message_send`, `message_get_messages`, `message_get_conversations`, `message_get_unread_count`, `message_mark_read`, `message_block_user`, `message_unblock_user` | 7 | +| Calendar | `calendar_create_events`, `calendar_delete_events`, `calendar_get_events`, `calendar_get_day_view`, `calendar_get_monthly_view` | 5 | +| Competencies | `competency_create`, `competency_list`, `competency_delete`, `competency_create_framework`, `competency_list_frameworks`, `competency_create_plan`, `competency_list_plans`, `competency_add_to_course`, `competency_grade` | 9 | +| Completion | `completion_get_activities_status`, `completion_get_course_status`, `completion_update_activity`, `completion_mark_self_completed` | 4 | +| Files | `file_get_files`, `file_upload` | 2 | +| Badges | `badge_get_user_badges` | 1 | +| Cohorts | `cohort_create`, `cohort_delete`, `cohort_get`, `cohort_search`, `cohort_add_members`, `cohort_delete_members` | 6 | +| Roles | `role_assign`, `role_unassign` | 2 | +| Notes | `note_create`, `note_get`, `note_delete` | 3 | +| SCORM | `scorm_get_by_course`, `scorm_get_attempt_count`, `scorm_get_scos`, `scorm_get_user_data`, `scorm_insert_tracks`, `scorm_launch_sco` | 6 | +| H5P | `h5p_get_by_course`, `h5p_get_attempts`, `h5p_get_results` | 3 | +| Reports | `reportbuilder_list`, `reportbuilder_get`, `reportbuilder_retrieve` | 3 | +| Site Info | `site_get_info`, `webservice_get_site_info` | 2 | +| Lessons | `lesson_get_by_course`, `lesson_get_pages`, `lesson_get_page_data`, `lesson_launch_attempt`, `lesson_process_page`, `lesson_finish_attempt` | 6 | +| Glossary | `glossary_get_by_course`, `glossary_get_entries`, `glossary_add_entry`, `glossary_delete_entry` | 4 | +| Search | `search_get_results` | 1 | +| Tags | `tag_get_tags`, `tag_update` | 2 | +| LTI | `lti_get_by_course`, `lti_get_tool_launch_data`, `lti_get_tool_types` | 3 | +| xAPI | `xapi_statement_post`, `xapi_get_state`, `xapi_post_state` | 3 | +| Generic | `call_function` | 1 | + +--- + +## Registry Manifests + +Each plugin gets a `manifest.json` in `workflow-registry/plugins//`: +- **type**: `external` +- **tier**: `community` +- **license**: `MIT` +- **minEngineVersion**: `0.3.30` + +## Testing Strategy + +Same as wave 1: unit tests with mock HTTP servers, no live API calls in CI. Test validation, error handling, module lifecycle (Init/Stop). + +## Implementation Order + +All six plugins built in parallel using agent teams. Each plugin is independent. Estimated scope: +- **Okta**: ~130 steps, official SDK +- **Datadog**: ~120 steps, official SDK +- **LaunchDarkly**: ~100 steps, official SDK +- **Permit.io**: ~80 steps, official SDK +- **Salesforce**: ~75 steps, community SDK + direct REST +- **OpenLMS**: ~120 steps, direct REST client (Moodle Web Services) + +**Repos**: `GoCodeAlone/workflow-plugin-okta`, `GoCodeAlone/workflow-plugin-datadog`, `GoCodeAlone/workflow-plugin-launchdarkly`, `GoCodeAlone/workflow-plugin-permit`, `GoCodeAlone/workflow-plugin-salesforce`, `GoCodeAlone/workflow-plugin-openlms` diff --git a/docs/plans/2026-03-11-plugin-releases-and-validation.md b/docs/plans/2026-03-11-plugin-releases-and-validation.md new file mode 100644 index 00000000..1407d1fe --- /dev/null +++ b/docs/plans/2026-03-11-plugin-releases-and-validation.md @@ -0,0 +1,205 @@ +# Plugin Releases & Validation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Tag v0.1.0 releases on all untagged plugins, then validate every integration plugin with workflow-scenarios test scenarios. + +**Architecture:** Tag releases trigger GoReleaser via GitHub Actions, producing cross-platform binaries. Validation scenarios use the workflow engine's external plugin loader with mock HTTP servers to test step execution end-to-end. + +**Tech Stack:** GoReleaser v2, GitHub Actions, workflow-scenarios test harness, Go test framework. + +--- + +## Task 1: Tag and Release Untagged Plugins + +The following plugins need `v0.1.0` tags to trigger their first GoReleaser release: + +| Plugin | Repo | Status | +|--------|------|--------| +| workflow-plugin-twilio | GoCodeAlone/workflow-plugin-twilio | no tags | +| workflow-plugin-monday | GoCodeAlone/workflow-plugin-monday | no tags | +| workflow-plugin-turnio | GoCodeAlone/workflow-plugin-turnio | no tags | +| workflow-plugin-auth | GoCodeAlone/workflow-plugin-auth | no tags | +| workflow-plugin-security-scanner | GoCodeAlone/workflow-plugin-security-scanner | no tags | + +**Already tagged** (no action needed): admin v1.0.0, agent v0.3.1, authz v0.3.1, authz-ui v0.1.0, bento v1.0.0, data-protection v0.1.0, github v1.0.0, payments v0.1.0, sandbox v0.1.0, security v0.1.0, supply-chain v0.1.0, waf v0.1.0. + +### Step 1: Verify each untagged plugin builds and tests pass + +For each plugin in the untagged list: +```bash +cd /Users/jon/workspace/workflow-plugin- +go vet ./... +go test ./... -count=1 +go build -o /dev/null ./cmd/workflow-plugin- +``` + +### Step 2: Ensure release workflow exists + +Verify each repo has `.github/workflows/release.yml` and `.goreleaser.yml`. If missing, copy from `workflow-plugin-payments` and adjust the binary name. + +### Step 3: Tag and push + +For each plugin: +```bash +cd /Users/jon/workspace/workflow-plugin- +git tag v0.1.0 +git push origin v0.1.0 +``` + +### Step 4: Verify releases + +Wait for GitHub Actions to complete, then verify: +```bash +gh release view v0.1.0 --repo GoCodeAlone/workflow-plugin- +``` + +Each release should have 4 archives (linux/darwin x amd64/arm64) plus checksums.txt. + +--- + +## Task 2: Create Validation Scenarios for Wave 1 Plugins + +Create 3 new workflow-scenarios (51-53) that test the Twilio, monday.com, and turn.io plugins with mock HTTP backends. Each scenario: + +1. Uses the workflow engine with the external plugin loaded +2. Configures the plugin module with mock server URLs +3. Executes pipelines that call plugin steps +4. Validates step outputs + +### Scenario Pattern + +Each scenario directory: +``` +scenarios/-/ +├── scenario.yaml # Metadata +├── config/app.yaml # Workflow engine config with plugin module + pipelines +├── mock/server.go # Go mock HTTP server for the service API +├── k8s/ # Kubernetes deployment (optional) +└── test/ + └── run.sh # Test script with PASS:/FAIL: assertions +``` + +### Step 1: Create scenario 51-twilio-integration + +**`scenarios/51-twilio-integration/scenario.yaml`:** +```yaml +name: Twilio Integration +id: "51-twilio-integration" +category: C +description: | + Tests workflow-plugin-twilio step types against a mock Twilio API server. + Validates SMS sending, message listing, verification, and call creation. +components: + - workflow (engine) + - workflow-plugin-twilio (external plugin) + - mock Twilio API server +status: testable +version: "1.0" +image: workflow-server:local +port: 8080 +tests: + type: bash + script: test/run.sh +``` + +**`scenarios/51-twilio-integration/config/app.yaml`:** +- Plugin: `workflow-plugin-twilio` binary from PATH or data dir +- Module: `twilio.provider` with `accountSid`, `authToken`, mock server base URL override +- Pipelines testing: `send_sms` → verify output has `sid`, `status`; `list_messages` → verify output has messages array; `send_verification` → verify `status: pending`; `create_call` → verify `sid` returned + +**`scenarios/51-twilio-integration/test/run.sh`:** +- Start mock server (Go binary that returns canned Twilio JSON responses) +- POST to pipeline endpoints +- Assert response contains expected fields +- Tests: send_sms, send_mms, list_messages, fetch_message, send_verification, check_verification, create_call, list_calls, lookup_phone (9 tests minimum) + +### Step 2: Create scenario 52-monday-integration + +Same pattern but for monday.com: +- Mock GraphQL server returning canned monday.com responses +- Pipelines testing: `create_board`, `list_boards`, `create_item`, `list_items`, `create_group`, `query` (generic) +- 8 tests minimum + +### Step 3: Create scenario 53-turnio-integration + +Same pattern but for turn.io: +- Mock REST server returning WhatsApp message responses + rate limit headers +- Pipelines testing: `send_text`, `send_template`, `check_contact`, `list_templates`, `create_flow` +- Verify rate limit header tracking +- 6 tests minimum + +### Step 4: Run tests locally + +```bash +cd /Users/jon/workspace/workflow-scenarios +make test SCENARIO=51-twilio-integration +make test SCENARIO=52-monday-integration +make test SCENARIO=53-turnio-integration +``` + +All tests must pass. + +### Step 5: Commit and push + +```bash +cd /Users/jon/workspace/workflow-scenarios +git add scenarios/51-twilio-integration scenarios/52-monday-integration scenarios/53-turnio-integration +git commit -m "feat: add integration plugin validation scenarios (51-53)" +git push +``` + +--- + +## Task 3: Update scenarios.json Registry + +Add entries for the 3 new scenarios to `scenarios.json`: + +```json +{ + "id": "51-twilio-integration", + "name": "Twilio Integration", + "category": "C", + "status": "testable" +}, +{ + "id": "52-monday-integration", + "name": "monday.com Integration", + "category": "C", + "status": "testable" +}, +{ + "id": "53-turnio-integration", + "name": "turn.io Integration", + "category": "C", + "status": "testable" +} +``` + +--- + +## Task 4: Validate Wave 2 Plugins (after wave 2 implementation) + +After wave 2 plugins are built, create scenarios 54-59: + +| Scenario | Plugin | Key Tests | +|----------|--------|-----------| +| 54-okta-integration | okta | user CRUD, group membership, app assignment, MFA enrollment, auth server config | +| 55-datadog-integration | datadog | metric submit/query, monitor CRUD, event creation, log search, SLO lifecycle | +| 56-launchdarkly-integration | launchdarkly | flag CRUD, project/environment management, segment operations, context evaluation | +| 57-permit-integration | permit | RBAC check, user/role CRUD, resource management, relationship tuples, condition sets | +| 58-salesforce-integration | salesforce | record CRUD, SOQL query, bulk operations, composite requests, approval process | +| 59-openlms-integration | openlms | user/course CRUD, enrollment, grades, quiz lifecycle, assignment submission | + +Same mock server pattern as wave 1. + +--- + +## Key Reference Files + +| File | Purpose | +|------|---------| +| `/Users/jon/workspace/workflow-scenarios/CLAUDE.md` | Scenario harness conventions | +| `/Users/jon/workspace/workflow-scenarios/scenarios/48-payment-processing/` | Reference scenario structure | +| `/Users/jon/workspace/workflow-plugin-payments/.goreleaser.yml` | GoReleaser config template | +| `/Users/jon/workspace/workflow-plugin-payments/.github/workflows/release.yml` | Release workflow template | From cf02c943d49d9154b6107168ebae9f0967286202 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:19:51 -0400 Subject: [PATCH 03/26] docs: add wfctl audit implementation plan (20 tasks) Phase A: 13 CLI fixes (help exit codes, plugin-dir rename, flag ordering, etc.) Phase B: 2 registry data fix tasks Phase C: 5 plugin ecosystem tasks (GitHub URL install, lockfile, goreleaser) Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-12-wfctl-audit.md | 1320 ++++++++++++++++++++++++++ 1 file changed, 1320 insertions(+) create mode 100644 docs/plans/2026-03-12-wfctl-audit.md diff --git a/docs/plans/2026-03-12-wfctl-audit.md b/docs/plans/2026-03-12-wfctl-audit.md new file mode 100644 index 00000000..2c14b7a9 --- /dev/null +++ b/docs/plans/2026-03-12-wfctl-audit.md @@ -0,0 +1,1320 @@ +# wfctl Audit & Plugin Ecosystem Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix 13 wfctl CLI bugs/UX issues, correct registry data, and establish a standardized plugin ecosystem across all repos. + +**Architecture:** Three phases — (A) CLI fixes in `workflow/cmd/wfctl/`, (B) registry data fixes in `workflow-registry/`, (C) plugin ecosystem improvements across repos. All Go changes include tests. Registry changes validated by existing CI. + +**Tech Stack:** Go 1.26, flag package, YAML/JSON parsing, GitHub Releases API, goreleaser + +--- + +## Phase A: wfctl CLI Fixes + +### Task 1: Fix `--help` exit code and engine error leakage + +**Files:** +- Modify: `cmd/wfctl/main.go:108-127` +- Test: `cmd/wfctl/main_test.go` (create if needed) + +**Step 1: Write the failing test** + +```go +// cmd/wfctl/main_test.go +package main + +import ( + "strings" + "testing" +) + +func TestHelpFlagDoesNotLeakEngineError(t *testing.T) { + // The dispatch error for --help should mention "help" but NOT + // "workflow execution failed" or "pipeline.*failed". + // We test the error wrapping logic, not the full engine dispatch. + err := fmt.Errorf("flag: help requested") + if !isHelpRequested(err) { + t.Error("expected isHelpRequested to detect flag.ErrHelp message") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/jon/workspace/workflow && go test ./cmd/wfctl/ -run TestHelpFlag -v` +Expected: FAIL — `isHelpRequested` undefined + +**Step 3: Implement the fix** + +In `main.go`, add a helper and modify the dispatch error handling: + +```go +// Add after the commands map +func isHelpRequested(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "flag: help requested") +} +``` + +Then modify the `main()` function's error handling (around line 118): + +```go + // Replace current no-args handling: + if len(os.Args) < 2 { + _ = cliHandler.Dispatch([]string{"-h"}) + os.Exit(0) // was os.Exit(1) — help is not an error + } + + // ...existing code... + + dispatchErr := cliHandler.DispatchContext(ctx, os.Args[1:]) + stop() + + if dispatchErr != nil { + if isHelpRequested(dispatchErr) { + os.Exit(0) + } + if _, isKnown := commands[cmd]; isKnown { + fmt.Fprintf(os.Stderr, "error: %v\n", dispatchErr) + } + os.Exit(1) + } +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /Users/jon/workspace/workflow && go test ./cmd/wfctl/ -run TestHelpFlag -v` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `cd /Users/jon/workspace/workflow && go test ./cmd/wfctl/ -v -count=1` +Expected: All pass + +**Step 6: Commit** + +```bash +git add cmd/wfctl/main.go cmd/wfctl/main_test.go +git commit -m "fix(wfctl): --help exits 0 and suppresses engine error leakage" +``` + +--- + +### Task 2: Rename `-data-dir` to `-plugin-dir` across plugin subcommands + +**Files:** +- Modify: `cmd/wfctl/plugin_install.go` (lines 69, 171, 223, 248, 275) +- Modify: `cmd/wfctl/plugin.go` (usage text) +- Test: `cmd/wfctl/plugin_install_test.go` (create) + +**Step 1: Write the failing test** + +```go +// cmd/wfctl/plugin_install_test.go +package main + +import ( + "testing" +) + +func TestPluginListAcceptsPluginDirFlag(t *testing.T) { + // Ensure -plugin-dir flag is accepted (not just -data-dir) + err := runPluginList([]string{"-plugin-dir", t.TempDir()}) + if err != nil { + // If it contains "No plugins installed" that's fine — we just want no flag parse error + if !strings.Contains(err.Error(), "No plugins") { + t.Fatalf("unexpected error: %v", err) + } + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./cmd/wfctl/ -run TestPluginListAcceptsPluginDir -v` +Expected: FAIL — flag provided but not defined: -plugin-dir + +**Step 3: Implement the rename** + +In `plugin_install.go`, for each function (`runPluginInstall`, `runPluginList`, `runPluginUpdate`, `runPluginRemove`, `runPluginInfo`): + +1. Change: `fs.String("data-dir", defaultDataDir, "Plugin data directory")` + To: `fs.String("plugin-dir", defaultDataDir, "Plugin directory")` + +2. After each `fs.String("plugin-dir", ...)`, add the hidden alias: +```go + pluginDir := fs.String("plugin-dir", defaultDataDir, "Plugin directory") + // Hidden alias for backwards compatibility + fs.String("data-dir", "", "") + // After parse, if plugin-dir is default but data-dir was set, use data-dir +``` + +Actually, simpler approach — register both flags pointing to the same variable: + +```go + var pluginDirVal string + fs.StringVar(&pluginDirVal, "plugin-dir", defaultDataDir, "Plugin directory") + fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)") +``` + +Apply this pattern to all 5 functions: `runPluginInstall`, `runPluginList`, `runPluginUpdate`, `runPluginRemove`, `runPluginInfo`. + +Also update `pluginUsage()` in `plugin.go` to show `-plugin-dir` in the help text. + +**Step 4: Run test to verify it passes** + +Run: `go test ./cmd/wfctl/ -run TestPluginListAcceptsPluginDir -v` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `go test ./cmd/wfctl/ -v -count=1` + +**Step 6: Commit** + +```bash +git add cmd/wfctl/plugin_install.go cmd/wfctl/plugin.go cmd/wfctl/plugin_install_test.go +git commit -m "fix(wfctl): rename -data-dir to -plugin-dir for consistency + +Keep -data-dir as hidden alias for backwards compatibility." +``` + +--- + +### Task 3: Add trailing flag detection helper + +**Files:** +- Modify: `cmd/wfctl/validate.go` (it already has `reorderFlags` — check its implementation) +- Create: `cmd/wfctl/flag_helpers.go` +- Create: `cmd/wfctl/flag_helpers_test.go` + +**Step 1: Check existing `reorderFlags` in validate.go** + +Read `validate.go` and understand what `reorderFlags` does. If it already reorders flags before positional args, we may be able to reuse it. The approach: detect flags that appear after the first positional arg and print a helpful error. + +**Step 2: Write the failing test** + +```go +// cmd/wfctl/flag_helpers_test.go +package main + +import "testing" + +func TestCheckTrailingFlags(t *testing.T) { + tests := []struct { + args []string + wantErr bool + }{ + {[]string{"-author", "X", "myname"}, false}, // flags before positional — OK + {[]string{"myname", "-author", "X"}, true}, // flags after positional — error + {[]string{"-author", "X", "-output", ".", "myname"}, false}, // all flags before — OK + {[]string{"myname"}, false}, // no flags at all — OK + } + for _, tt := range tests { + err := checkTrailingFlags(tt.args) + if (err != nil) != tt.wantErr { + t.Errorf("checkTrailingFlags(%v) error=%v, wantErr=%v", tt.args, err, tt.wantErr) + } + } +} +``` + +**Step 3: Implement** + +```go +// cmd/wfctl/flag_helpers.go +package main + +import ( + "fmt" + "strings" +) + +// checkTrailingFlags detects flags that appear after the first positional argument +// and returns a helpful error message suggesting the correct ordering. +func checkTrailingFlags(args []string) error { + seenPositional := false + for _, arg := range args { + if strings.HasPrefix(arg, "-") && seenPositional { + return fmt.Errorf("flags must come before arguments (got %s after positional arg). "+ + "Reorder so all flags precede the name argument", arg) + } + if !strings.HasPrefix(arg, "-") { + seenPositional = true + } + } + return nil +} +``` + +**Step 4: Wire into subcommands** + +Add `checkTrailingFlags(args)` call at the top of: `runPluginInit`, `runRegistryAdd`, `runRegistryRemove`. Example for `runPluginInit`: + +```go +func runPluginInit(args []string) error { + if err := checkTrailingFlags(args); err != nil { + return err + } + // ... existing code +``` + +**Step 5: Run tests** + +Run: `go test ./cmd/wfctl/ -run TestCheckTrailingFlags -v` +Expected: PASS + +**Step 6: Commit** + +```bash +git add cmd/wfctl/flag_helpers.go cmd/wfctl/flag_helpers_test.go cmd/wfctl/plugin.go cmd/wfctl/registry_cmd.go +git commit -m "fix(wfctl): detect trailing flags and show helpful error message" +``` + +--- + +### Task 4: Full plugin name resolution (strip `workflow-plugin-` prefix) + +**Files:** +- Modify: `cmd/wfctl/multi_registry.go:45-58` +- Test: `cmd/wfctl/multi_registry_test.go` (create or extend) + +**Step 1: Write the failing test** + +```go +// cmd/wfctl/multi_registry_test.go (add to existing if present) +package main + +import "testing" + +func TestNormalizePluginName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"authz", "authz"}, + {"workflow-plugin-authz", "authz"}, + {"workflow-plugin-payments", "payments"}, + {"custom-plugin", "custom-plugin"}, // no prefix, keep as-is + } + for _, tt := range tests { + got := normalizePluginName(tt.input) + if got != tt.want { + t.Errorf("normalizePluginName(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} +``` + +**Step 2: Implement** + +In `multi_registry.go`, add: + +```go +import "strings" + +// normalizePluginName strips the "workflow-plugin-" prefix if present, +// since the registry uses short names (e.g. "authz" not "workflow-plugin-authz"). +func normalizePluginName(name string) string { + return strings.TrimPrefix(name, "workflow-plugin-") +} +``` + +Update `FetchManifest`: + +```go +func (m *MultiRegistry) FetchManifest(name string) (*RegistryManifest, string, error) { + normalized := normalizePluginName(name) + var lastErr error + // Try normalized name first + for _, src := range m.sources { + manifest, err := src.FetchManifest(normalized) + if err == nil { + return manifest, src.Name(), nil + } + lastErr = err + } + // If normalized != original, also try the original name + if normalized != name { + for _, src := range m.sources { + manifest, err := src.FetchManifest(name) + if err == nil { + return manifest, src.Name(), nil + } + lastErr = err + } + } + if lastErr != nil { + return nil, "", lastErr + } + return nil, "", fmt.Errorf("plugin %q not found in any configured registry", name) +} +``` + +Also update `SearchPlugins` to normalize the query. + +**Step 3: Run tests** + +Run: `go test ./cmd/wfctl/ -run TestNormalizePluginName -v` +Expected: PASS + +**Step 4: Commit** + +```bash +git add cmd/wfctl/multi_registry.go cmd/wfctl/multi_registry_test.go +git commit -m "fix(wfctl): resolve full plugin names by stripping workflow-plugin- prefix" +``` + +--- + +### Task 5: Fix `plugin install -plugin-dir` being ignored + +**Files:** +- Modify: `cmd/wfctl/plugin_install.go:122` + +**Context:** Line 122 uses `*dataDir` (now `pluginDirVal`) correctly after Task 2's rename, but the bug is that `plugin update` calls `runPluginInstall` with `--data-dir` hardcoded at line 243. Fix this call to use `--plugin-dir`. + +**Step 1: Write the failing test** + +```go +func TestPluginInstallRespectsPluginDir(t *testing.T) { + customDir := t.TempDir() + // runPluginInstall with a non-existent plugin will fail at manifest fetch, + // but we can verify the destDir is constructed correctly by checking the + // MkdirAll path in the error when network is unavailable. + err := runPluginInstall([]string{"-plugin-dir", customDir, "nonexistent-test-plugin"}) + if err == nil { + t.Fatal("expected error for nonexistent plugin") + } + // The error should NOT mention "data/plugins" (the default dir) + if strings.Contains(err.Error(), "data/plugins") { + t.Errorf("plugin install ignored -plugin-dir flag, error references default path: %v", err) + } +} +``` + +**Step 2: Verify line 243 in runPluginUpdate** + +Change line 243 from: +```go +return runPluginInstall(append([]string{"--data-dir", *dataDir}, pluginName)) +``` +to: +```go +return runPluginInstall(append([]string{"-plugin-dir", pluginDirVal}, pluginName)) +``` + +(After Task 2, `*dataDir` becomes `pluginDirVal`.) + +**Step 3: Run tests** + +Run: `go test ./cmd/wfctl/ -run TestPluginInstallRespectsPluginDir -v` + +**Step 4: Commit** + +```bash +git add cmd/wfctl/plugin_install.go cmd/wfctl/plugin_install_test.go +git commit -m "fix(wfctl): plugin install respects -plugin-dir flag" +``` + +--- + +### Task 6: `plugin update` version check + +**Files:** +- Modify: `cmd/wfctl/plugin_install.go` (runPluginUpdate function, around line 223) + +**Step 1: Write the failing test** + +```go +func TestReadInstalledVersion(t *testing.T) { + dir := t.TempDir() + manifest := `{"name":"test","version":"1.2.3"}` + os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(manifest), 0644) + ver := readInstalledVersion(dir) + if ver != "1.2.3" { + t.Errorf("readInstalledVersion = %q, want %q", ver, "1.2.3") + } +} +``` + +**Step 2: Implement version check in runPluginUpdate** + +After fetching the manifest but before downloading, compare versions: + +```go + // In runPluginUpdate, after mr.FetchManifest: + installedVer := readInstalledVersion(pluginDir) + if installedVer != "" && installedVer == manifest.Version { + fmt.Printf("%s is already at latest version (v%s)\n", pluginName, installedVer) + return nil + } + if installedVer != "" { + fmt.Fprintf(os.Stderr, "Updating %s from v%s to v%s...\n", pluginName, installedVer, manifest.Version) + } +``` + +**Step 3: Run tests and commit** + +```bash +git add cmd/wfctl/plugin_install.go cmd/wfctl/plugin_install_test.go +git commit -m "fix(wfctl): plugin update checks version before re-downloading" +``` + +--- + +### Task 7: Deploy subcommands accept positional config arg + +**Files:** +- Modify: `cmd/wfctl/deploy.go` (kubernetes generate and other subcommands) + +**Step 1: Identify the pattern** + +In `validate.go`, positional args work because `fs.Args()` is checked after `fs.Parse()`. In deploy subcommands, only `-config` flag is checked. Add: after parsing, if `configFile` is empty and `fs.NArg() > 0`, set `configFile` to `fs.Arg(0)`. + +**Step 2: Add positional config fallback** + +In each deploy subcommand that uses `-config`, after `fs.Parse(args)`: + +```go + if *configFile == "" && fs.NArg() > 0 { + *configFile = fs.Arg(0) + } +``` + +Apply to: `runDeployDocker`, `runDeployK8sGenerate`, `runDeployK8sApply`, `runDeployHelm`, `runDeployCloud`. + +**Step 3: Run existing deploy tests** + +Run: `go test ./cmd/wfctl/ -run TestDeploy -v` + +**Step 4: Commit** + +```bash +git add cmd/wfctl/deploy.go +git commit -m "fix(wfctl): deploy subcommands accept positional config arg" +``` + +--- + +### Task 8: `init` generates valid Dockerfile (handles missing go.sum) + +**Files:** +- Modify: `cmd/wfctl/init.go` (the Dockerfile template) + +**Step 1: Find and fix the Dockerfile template** + +Search for the embedded Dockerfile template in `init.go` or `cmd/wfctl/templates/`. Change: + +```dockerfile +COPY go.mod go.sum ./ +``` + +to: + +```dockerfile +COPY go.mod go.sum* ./ +``` + +The `*` glob makes `go.sum` optional — Docker COPY with no match on `go.sum*` still copies `go.mod`. + +Actually, Docker COPY requires at least one match. Better approach: + +```dockerfile +COPY go.mod ./ +RUN go mod download +``` + +This works whether `go.sum` exists or not. + +**Step 2: Run tests and commit** + +```bash +git add cmd/wfctl/init.go +git commit -m "fix(wfctl): init Dockerfile handles missing go.sum" +``` + +--- + +### Task 9: `validate --dir` skips non-workflow YAML files + +**Files:** +- Modify: `cmd/wfctl/validate.go` +- Test: `cmd/wfctl/validate_test.go` (create or extend) + +**Step 1: Write the failing test** + +```go +func TestValidateSkipsNonWorkflowYAML(t *testing.T) { + dir := t.TempDir() + // Write a GitHub Actions YAML — should be skipped + ghDir := filepath.Join(dir, ".github", "workflows") + os.MkdirAll(ghDir, 0755) + os.WriteFile(filepath.Join(ghDir, "ci.yml"), []byte("name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n"), 0644) + // Write a real workflow YAML — should be validated + os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("modules:\n - name: test\n type: http.server\n config:\n port: 8080\nworkflows:\n http:\n handler: http\n"), 0644) + + files := findYAMLFiles(dir) + // ci.yml should be found but isWorkflowYAML should skip it + workflowFiles := 0 + for _, f := range files { + if isWorkflowYAML(f) { + workflowFiles++ + } + } + if workflowFiles != 1 { + t.Errorf("expected 1 workflow YAML, got %d", workflowFiles) + } +} +``` + +**Step 2: Implement** + +Add to `validate.go`: + +```go +// isWorkflowYAML does a quick check for workflow-engine top-level keys. +func isWorkflowYAML(path string) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + // Check first 100 lines for modules:, workflows:, or pipelines: at top level + lines := strings.SplitN(string(data), "\n", 100) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "modules:") || strings.HasPrefix(trimmed, "workflows:") || strings.HasPrefix(trimmed, "pipelines:") { + return true + } + } + return false +} +``` + +Then in the `--dir` file loop, add: `if !isWorkflowYAML(f) { continue }`. + +**Step 3: Run tests and commit** + +```bash +git add cmd/wfctl/validate.go cmd/wfctl/validate_test.go +git commit -m "fix(wfctl): validate --dir skips non-workflow YAML files" +``` + +--- + +### Task 10: Validate follows YAML imports + +**Files:** +- Modify: `cmd/wfctl/validate.go` + +**Context:** The config package already has `processImports` that resolves `imports:` references. When `validate` loads a file, it calls `config.LoadFromFile()` which follows imports automatically. However, the individual imported files may not be validated independently. Ensure that: + +1. `validate` reports which imports were resolved +2. If an imported file has errors, the error references the import chain + +**Step 1: Check current behavior** + +Create a test config with imports and run validate to see if it already works: + +```yaml +# /tmp/test-imports/main.yaml +imports: + - modules.yaml +workflows: + http: + handler: http +``` + +```yaml +# /tmp/test-imports/modules.yaml +modules: + - name: server + type: http.server + config: + port: 8080 +``` + +Run: `/tmp/wfctl validate /tmp/test-imports/main.yaml` + +If it already resolves imports (which it should since `config.LoadFromFile` handles them), just add a verbose message like "Resolved import: modules.yaml". If it doesn't, wire import resolution into the validate path. + +**Step 2: Add import resolution feedback** + +In `validateFile()`, after loading the config, check if the original YAML had `imports:` and log what was resolved: + +```go +// In validateFile, after successful load: +if len(rawImports) > 0 { + fmt.Fprintf(os.Stderr, " Resolved %d import(s): %s\n", len(rawImports), strings.Join(rawImports, ", ")) +} +``` + +**Step 3: Run tests and commit** + +```bash +git add cmd/wfctl/validate.go +git commit -m "fix(wfctl): validate reports resolved YAML imports" +``` + +--- + +### Task 11: Infra commands — better error messages + +**Files:** +- Modify: `cmd/wfctl/infra.go:73` + +**Step 1: Improve the error message** + +Change line 73 from: +```go +return "", fmt.Errorf("no config file found (tried infra.yaml, config/infra.yaml)") +``` +to: +```go +return "", fmt.Errorf("no infrastructure config found (tried infra.yaml, config/infra.yaml).\n" + + "Create an infra config with cloud.account and platform.* modules.\n" + + "Run 'wfctl init --template full-stack' for a starter config with infrastructure.") +``` + +**Step 2: Commit** + +```bash +git add cmd/wfctl/infra.go +git commit -m "fix(wfctl): infra commands show helpful error when no config found" +``` + +--- + +### Task 12: `plugin info` shows absolute paths + +**Files:** +- Modify: `cmd/wfctl/plugin_install.go` (runPluginInfo function, around line 275) + +**Step 1: Fix** + +In `runPluginInfo`, after constructing `pluginDir`, convert to absolute: + +```go + absDir, _ := filepath.Abs(pluginDir) + // Use absDir when printing the binary path +``` + +**Step 2: Commit** + +```bash +git add cmd/wfctl/plugin_install.go +git commit -m "fix(wfctl): plugin info shows absolute binary path" +``` + +--- + +### Task 13: PR #322 — PluginManifest legacy capabilities UnmarshalJSON + +**Files:** +- Modify: `plugin/manifest.go` (or wherever `PluginManifest` is defined) +- Test: `plugin/manifest_test.go` + +**Step 1: Find the PluginManifest type** + +Run: `grep -rn "type PluginManifest struct" /Users/jon/workspace/workflow/plugin/` + +**Step 2: Write the failing test** + +```go +func TestPluginManifest_LegacyCapabilities(t *testing.T) { + input := `{ + "name": "test", + "version": "1.0.0", + "capabilities": { + "configProvider": true, + "moduleTypes": ["test.module"], + "stepTypes": ["step.test"], + "triggerTypes": ["http"] + } + }` + var m PluginManifest + if err := json.Unmarshal([]byte(input), &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // Legacy capabilities should be merged into top-level fields + if len(m.ModuleTypes) == 0 || m.ModuleTypes[0] != "test.module" { + t.Errorf("moduleTypes not parsed from legacy capabilities: %v", m.ModuleTypes) + } + if len(m.StepTypes) == 0 || m.StepTypes[0] != "step.test" { + t.Errorf("stepTypes not parsed from legacy capabilities: %v", m.StepTypes) + } +} +``` + +**Step 3: Implement UnmarshalJSON** + +Add a custom `UnmarshalJSON` method on `PluginManifest` that: +1. First tries to unmarshal normally (new format with `capabilities` as `[]CapabilityDecl`) +2. If `capabilities` is an object, unmarshal it as `{configProvider, moduleTypes, stepTypes, triggerTypes}` and merge into the top-level manifest fields + +**Step 4: Run tests and commit** + +```bash +git add plugin/manifest.go plugin/manifest_test.go +git commit -m "fix(wfctl): PluginManifest handles legacy capabilities object format + +Addresses PR #322." +``` + +--- + +## Phase B: Registry Data Fixes + +### Task 14: Fix registry manifest issues + +**Working directory:** `/Users/jon/workspace/workflow-registry/` + +**Files:** +- Modify: `plugins/agent/manifest.json` +- Modify: `plugins/ratchet/manifest.json` +- Verify: `plugins/authz/manifest.json` exists and name matches + +**Step 1: Fix agent manifest type** + +In `plugins/agent/manifest.json`, change `"type": "internal"` to `"type": "builtin"`. + +**Step 2: Fix ratchet manifest downloads** + +In `plugins/ratchet/manifest.json`, add downloads entries: + +```json +"downloads": [ + {"os": "linux", "arch": "amd64", "url": "https://github.com/GoCodeAlone/ratchet/releases/latest/download/ratchet_linux_amd64.tar.gz"}, + {"os": "linux", "arch": "arm64", "url": "https://github.com/GoCodeAlone/ratchet/releases/latest/download/ratchet_linux_arm64.tar.gz"}, + {"os": "darwin", "arch": "amd64", "url": "https://github.com/GoCodeAlone/ratchet/releases/latest/download/ratchet_darwin_amd64.tar.gz"}, + {"os": "darwin", "arch": "arm64", "url": "https://github.com/GoCodeAlone/ratchet/releases/latest/download/ratchet_darwin_arm64.tar.gz"} +] +``` + +**Step 3: Verify authz manifest** + +Check `plugins/authz/manifest.json` exists. Verify the `name` field is `"authz"` (not `"workflow-plugin-authz"`). If it doesn't exist, create it from the existing `plugin.json` in the authz repo. + +**Step 4: Run validation** + +```bash +cd /Users/jon/workspace/workflow-registry +./scripts/validate-manifests.sh +``` + +**Step 5: Commit and push** + +```bash +git add plugins/ +git commit -m "fix: correct agent type, add ratchet downloads, verify authz manifest" +git push origin main +``` + +--- + +### Task 15: Create version sync script + +**Files:** +- Create: `scripts/sync-versions.sh` + +**Step 1: Write the script** + +```bash +#!/usr/bin/env bash +# Compares manifest versions against latest GitHub release tags. +# Usage: ./scripts/sync-versions.sh [--fix] + +set -euo pipefail + +fix_mode=false +[[ "${1:-}" == "--fix" ]] && fix_mode=true + +mismatches=0 + +for manifest in plugins/*/manifest.json; do + name=$(jq -r .name "$manifest") + repo=$(jq -r '.repository // empty' "$manifest") + manifest_ver=$(jq -r .version "$manifest") + + [[ -z "$repo" ]] && continue + + # Extract owner/repo from URL + owner_repo=$(echo "$repo" | sed 's|https://github.com/||') + + # Query latest release + latest=$(gh release view --repo "$owner_repo" --json tagName -q .tagName 2>/dev/null || echo "") + [[ -z "$latest" ]] && continue + + # Strip 'v' prefix for comparison + latest_ver="${latest#v}" + + if [[ "$manifest_ver" != "$latest_ver" ]]; then + echo "MISMATCH: $name — manifest=$manifest_ver, latest=$latest_ver ($owner_repo)" + ((mismatches++)) + + if $fix_mode; then + jq --arg v "$latest_ver" '.version = $v' "$manifest" > "$manifest.tmp" && mv "$manifest.tmp" "$manifest" + echo " Fixed → $latest_ver" + fi + fi +done + +echo "" +echo "$mismatches mismatch(es) found." +[[ $mismatches -gt 0 ]] && exit 1 || exit 0 +``` + +**Step 2: Run it** + +```bash +chmod +x scripts/sync-versions.sh +./scripts/sync-versions.sh +``` + +**Step 3: Fix any mismatches found** + +```bash +./scripts/sync-versions.sh --fix +``` + +**Step 4: Commit** + +```bash +git add scripts/sync-versions.sh plugins/ +git commit -m "feat: add version sync script and fix manifest version mismatches" +``` + +--- + +## Phase C: Plugin Ecosystem + +### Task 16: GitHub URL install support in wfctl + +**Files:** +- Modify: `cmd/wfctl/plugin_install.go` (runPluginInstall) +- Modify: `cmd/wfctl/multi_registry.go` or create `cmd/wfctl/github_install.go` +- Test: `cmd/wfctl/plugin_install_test.go` + +**Step 1: Write the test** + +```go +func TestParseGitHubPluginRef(t *testing.T) { + tests := []struct { + input string + owner string + repo string + version string + isGH bool + }{ + {"GoCodeAlone/workflow-plugin-authz@v0.3.1", "GoCodeAlone", "workflow-plugin-authz", "v0.3.1", true}, + {"GoCodeAlone/workflow-plugin-authz", "GoCodeAlone", "workflow-plugin-authz", "", true}, + {"authz", "", "", "", false}, + {"authz@v1.0", "", "", "", false}, + } + for _, tt := range tests { + owner, repo, version, isGH := parseGitHubRef(tt.input) + if isGH != tt.isGH || owner != tt.owner || repo != tt.repo || version != tt.version { + t.Errorf("parseGitHubRef(%q) = (%q,%q,%q,%v), want (%q,%q,%q,%v)", + tt.input, owner, repo, version, isGH, tt.owner, tt.repo, tt.version, tt.isGH) + } + } +} +``` + +**Step 2: Implement** + +Create `cmd/wfctl/github_install.go`: + +```go +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "runtime" + "strings" + "time" +) + +// parseGitHubRef parses "owner/repo@version" format. +// Returns empty strings and false if not a GitHub ref. +func parseGitHubRef(input string) (owner, repo, version string, isGitHub bool) { + // Must contain exactly one "/" to be a GitHub ref + nameVer := input + if atIdx := strings.LastIndex(input, "@"); atIdx > 0 { + nameVer = input[:atIdx] + version = input[atIdx+1:] + } + parts := strings.SplitN(nameVer, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", "", false + } + return parts[0], parts[1], version, true +} + +// installFromGitHub downloads a plugin directly from GitHub Releases. +func installFromGitHub(owner, repo, version, destDir string) error { + if version == "" { + version = "latest" + } + + var releaseURL string + if version == "latest" { + releaseURL = fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) + } else { + releaseURL = fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, version) + } + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(releaseURL) + if err != nil { + return fmt.Errorf("fetch release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("release %s not found for %s/%s (HTTP %d)", version, owner, repo, resp.StatusCode) + } + + var release struct { + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return fmt.Errorf("decode release: %w", err) + } + + // Find matching asset: repo_os_arch.tar.gz + wantName := fmt.Sprintf("%s_%s_%s.tar.gz", repo, runtime.GOOS, runtime.GOARCH) + var downloadURL string + for _, a := range release.Assets { + if a.Name == wantName { + downloadURL = a.BrowserDownloadURL + break + } + } + if downloadURL == "" { + return fmt.Errorf("no asset matching %s found in release (available: %d assets)", wantName, len(release.Assets)) + } + + fmt.Fprintf(os.Stderr, "Downloading %s...\n", downloadURL) + data, err := downloadURL(downloadURL) + if err != nil { + return fmt.Errorf("download: %w", err) + } + + if err := extractTarGz(data, destDir); err != nil { + return fmt.Errorf("extract: %w", err) + } + + return ensurePluginBinary(destDir, repo) +} +``` + +Then in `runPluginInstall`, after the registry lookup fails: + +```go + manifest, sourceName, err := mr.FetchManifest(pluginName) + if err != nil { + // Try GitHub direct install if input looks like owner/repo + owner, repo, ver, isGH := parseGitHubRef(nameArg) + if isGH { + shortName := normalizePluginName(repo) + destDir := filepath.Join(pluginDirVal, shortName) + os.MkdirAll(destDir, 0750) + return installFromGitHub(owner, repo, ver, destDir) + } + return err + } +``` + +**Step 3: Run tests and commit** + +```bash +git add cmd/wfctl/github_install.go cmd/wfctl/plugin_install.go cmd/wfctl/plugin_install_test.go +git commit -m "feat(wfctl): plugin install supports owner/repo@version GitHub URLs + +Falls back to GitHub Releases API when registry lookup fails. +Addresses issue #316 item 2." +``` + +--- + +### Task 17: Plugin lockfile support (`.wfctl.yaml` plugins section) + +**Files:** +- Create: `cmd/wfctl/plugin_lockfile.go` +- Create: `cmd/wfctl/plugin_lockfile_test.go` +- Modify: `cmd/wfctl/plugin_install.go` + +**Step 1: Write the test** + +```go +func TestLoadPluginLockfile(t *testing.T) { + dir := t.TempDir() + content := `plugins: + authz: + version: v0.3.1 + repository: GoCodeAlone/workflow-plugin-authz + payments: + version: v0.1.0 + repository: GoCodeAlone/workflow-plugin-payments +` + os.WriteFile(filepath.Join(dir, ".wfctl.yaml"), []byte(content), 0644) + + lf, err := loadPluginLockfile(filepath.Join(dir, ".wfctl.yaml")) + if err != nil { + t.Fatal(err) + } + if len(lf.Plugins) != 2 { + t.Fatalf("expected 2 plugins, got %d", len(lf.Plugins)) + } + if lf.Plugins["authz"].Version != "v0.3.1" { + t.Errorf("authz version = %q", lf.Plugins["authz"].Version) + } +} +``` + +**Step 2: Implement** + +```go +// cmd/wfctl/plugin_lockfile.go +package main + +import ( + "fmt" + "os" + "gopkg.in/yaml.v3" +) + +type PluginLockEntry struct { + Version string `yaml:"version"` + Repository string `yaml:"repository"` + SHA256 string `yaml:"sha256,omitempty"` +} + +type PluginLockfile struct { + Plugins map[string]PluginLockEntry `yaml:"plugins"` + // Preserve other .wfctl.yaml fields + raw map[string]any +} + +func loadPluginLockfile(path string) (*PluginLockfile, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var lf PluginLockfile + if err := yaml.Unmarshal(data, &lf); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + // Preserve raw for round-trip + yaml.Unmarshal(data, &lf.raw) + return &lf, nil +} + +func (lf *PluginLockfile) Save(path string) error { + if lf.raw == nil { + lf.raw = make(map[string]any) + } + lf.raw["plugins"] = lf.Plugins + data, err := yaml.Marshal(lf.raw) + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} +``` + +**Step 3: Wire into `plugin install`** + +In `runPluginInstall`: +- After successful install, if `.wfctl.yaml` exists in cwd, update the plugins section +- Add `wfctl plugin install` (no args) path: read lockfile, install all entries + +**Step 4: Run tests and commit** + +```bash +git add cmd/wfctl/plugin_lockfile.go cmd/wfctl/plugin_lockfile_test.go cmd/wfctl/plugin_install.go +git commit -m "feat(wfctl): plugin lockfile support in .wfctl.yaml + +'wfctl plugin install' with no args reads .wfctl.yaml plugins section. +Installing a plugin with @version updates the lockfile entry. +Addresses issue #316 item 3." +``` + +--- + +### Task 18: Engine minEngineVersion check + +**Files:** +- Modify: `plugin/loader.go` (or wherever plugins are loaded) +- Test: `plugin/loader_test.go` + +**Step 1: Find the plugin loading code** + +Run: `grep -rn "minEngineVersion\|MinEngineVersion\|LoadManifest" /Users/jon/workspace/workflow/plugin/ | head -20` + +**Step 2: Implement version check** + +After loading `plugin.json`, compare `minEngineVersion` with the engine's version: + +```go +import "github.com/Masterminds/semver/v3" + +func checkEngineCompatibility(manifest *PluginManifest, engineVersion string) { + if manifest.MinEngineVersion == "" || engineVersion == "" || engineVersion == "dev" { + return + } + minVer, err := semver.NewVersion(manifest.MinEngineVersion) + if err != nil { + return + } + engVer, err := semver.NewVersion(strings.TrimPrefix(engineVersion, "v")) + if err != nil { + return + } + if engVer.LessThan(minVer) { + fmt.Fprintf(os.Stderr, "WARNING: plugin %q requires engine >= v%s, running v%s — may cause runtime failures\n", + manifest.Name, manifest.MinEngineVersion, engineVersion) + } +} +``` + +**Step 3: Run tests and commit** + +```bash +git add plugin/ +git commit -m "feat: engine warns when plugin minEngineVersion exceeds current version + +Addresses issue #316 item 5." +``` + +--- + +### Task 19: goreleaser audit across plugin repos + +**Working directory:** Each plugin repo + +**Step 1: Create reference goreleaser config** + +Save to `/Users/jon/workspace/workflow/docs/plugin-goreleaser-reference.yml`: + +```yaml +# Reference goreleaser config for workflow plugins +# Ensures consistent tarball layout: bare binary + plugin.json +version: 2 +builds: + - binary: "{{ .ProjectName }}" + goos: [linux, darwin] + goarch: [amd64, arm64] + env: [CGO_ENABLED=0] + ldflags: ["-s", "-w"] + +archives: + - format: tar.gz + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + files: + - plugin.json + +before: + hooks: + - cmd: "sed -i'' -e 's/\"version\": *\"[^\"]*\"/\"version\": \"{{ .Version }}\"/' plugin.json" + +checksum: + name_template: checksums.txt +``` + +**Step 2: Audit each plugin repo** + +For each repo in: `workflow-plugin-authz`, `workflow-plugin-payments`, `workflow-plugin-admin`, `workflow-plugin-bento`, `workflow-plugin-github`, `workflow-plugin-waf`, `workflow-plugin-security`, `workflow-plugin-sandbox`, `workflow-plugin-supply-chain`, `workflow-plugin-data-protection`, `workflow-plugin-authz-ui`, `workflow-plugin-cloud-ui`: + +1. Read `.goreleaser.yml` or `.goreleaser.yaml` +2. Check binary naming (should be `{{ .ProjectName }}`, not platform-suffixed) +3. Check archive includes `plugin.json` +4. Check `plugin.json` version is templated from tag +5. Fix any deviations + +**Step 3: Commit fixes per repo** + +Each repo gets its own commit: `"chore: standardize goreleaser config for consistent tarball layout"` + +--- + +### Task 20: Registry auto-sync CI for plugin repos + +**Files per plugin repo:** +- Modify: `.github/workflows/release.yml` (add registry update step) + +**Step 1: Create reusable workflow snippet** + +After the goreleaser step in each plugin's `release.yml`, add: + +```yaml + - name: Update registry manifest + if: success() + env: + GH_TOKEN: ${{ secrets.REGISTRY_PAT }} + run: | + PLUGIN_NAME=$(jq -r .name plugin.json) + VERSION="${GITHUB_REF_NAME#v}" + + # Clone registry + git clone https://x-access-token:${GH_TOKEN}@github.com/GoCodeAlone/workflow-registry.git /tmp/registry + cd /tmp/registry + + # Update version + MANIFEST="plugins/${PLUGIN_NAME}/manifest.json" + jq --arg v "$VERSION" '.version = $v' "$MANIFEST" > "$MANIFEST.tmp" && mv "$MANIFEST.tmp" "$MANIFEST" + + # Update download URLs + REPO="${GITHUB_REPOSITORY}" + TAG="${GITHUB_REF_NAME}" + jq --arg repo "$REPO" --arg tag "$TAG" ' + .downloads = [ + {"os":"linux","arch":"amd64","url":"https://github.com/\($repo)/releases/download/\($tag)/\(.name)_linux_amd64.tar.gz"}, + {"os":"linux","arch":"arm64","url":"https://github.com/\($repo)/releases/download/\($tag)/\(.name)_linux_arm64.tar.gz"}, + {"os":"darwin","arch":"amd64","url":"https://github.com/\($repo)/releases/download/\($tag)/\(.name)_darwin_amd64.tar.gz"}, + {"os":"darwin","arch":"arm64","url":"https://github.com/\($repo)/releases/download/\($tag)/\(.name)_darwin_arm64.tar.gz"} + ]' "$MANIFEST" > "$MANIFEST.tmp" && mv "$MANIFEST.tmp" "$MANIFEST" + + # Create PR + BRANCH="auto/update-${PLUGIN_NAME}-${TAG}" + git checkout -b "$BRANCH" + git add "$MANIFEST" + git commit -m "chore: update ${PLUGIN_NAME} manifest to ${TAG}" + git push origin "$BRANCH" + gh pr create --repo GoCodeAlone/workflow-registry --title "Update ${PLUGIN_NAME} to ${TAG}" --body "Automated manifest update from release ${TAG}." +``` + +**Step 2: Apply to each external plugin repo that has a registry manifest** + +Repos: authz, payments, waf, security, sandbox, supply-chain, data-protection, authz-ui, cloud-ui, bento, github, admin + +**Step 3: Commit per repo** + +```bash +git commit -m "ci: auto-update registry manifest on release" +``` + +--- + +## Summary + +| Phase | Tasks | Scope | +|-------|-------|-------| +| A: CLI Fixes | 1-13 | workflow repo, cmd/wfctl/ | +| B: Registry Data | 14-15 | workflow-registry repo | +| C: Ecosystem | 16-20 | workflow repo + all plugin repos | + +**Total: 20 tasks** + +Phase A (Tasks 1-13) can be done in parallel by 2 implementers splitting the work. +Phase B (Tasks 14-15) is independent and can run in parallel with Phase A. +Phase C (Tasks 16-20) depends on Phase A being complete (especially Tasks 2, 4, 5 for the plugin-dir and name resolution changes). From 83fc44ef7898cb89a954b6197f5083c25d9220e8 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:20:59 -0400 Subject: [PATCH 04/26] docs: add B5 schema validation gap to Task 14 Alignment check identified missing coverage for design requirement B5. Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-12-wfctl-audit.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-03-12-wfctl-audit.md b/docs/plans/2026-03-12-wfctl-audit.md index 2c14b7a9..3f2aa94c 100644 --- a/docs/plans/2026-03-12-wfctl-audit.md +++ b/docs/plans/2026-03-12-wfctl-audit.md @@ -798,14 +798,18 @@ In `plugins/ratchet/manifest.json`, add downloads entries: Check `plugins/authz/manifest.json` exists. Verify the `name` field is `"authz"` (not `"workflow-plugin-authz"`). If it doesn't exist, create it from the existing `plugin.json` in the authz repo. -**Step 4: Run validation** +**Step 4: Fix schema validation gap (B5)** + +Check `schema/registry-schema.json` — verify that the `type` enum includes `"builtin"` (not just `"external"` and `"internal"`). If the enum is missing `"builtin"`, add it. Then check CI (`.github/workflows/`) to confirm schema validation runs on PRs. If CI isn't validating manifests against the schema, add a step that runs `scripts/validate-manifests.sh`. + +**Step 5: Run validation** ```bash cd /Users/jon/workspace/workflow-registry ./scripts/validate-manifests.sh ``` -**Step 5: Commit and push** +**Step 6: Commit and push** ```bash git add plugins/ From 4dbd60382b2f9aed083d706aae943287eb9bb4e8 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:21:52 -0400 Subject: [PATCH 05/26] fix: move Permit.io into workflow-plugin-authz as provider Permit.io fits alongside Casbin in the existing authz plugin rather than a standalone repo. Same multi-provider pattern as payments (Stripe + PayPal). Co-Authored-By: Claude Opus 4.6 --- ...26-03-11-integration-plugins-wave2-design.md | 17 +++++++++++------ ...2026-03-11-plugin-releases-and-validation.md | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-03-11-integration-plugins-wave2-design.md b/docs/plans/2026-03-11-integration-plugins-wave2-design.md index 17cf5830..f48758c3 100644 --- a/docs/plans/2026-03-11-integration-plugins-wave2-design.md +++ b/docs/plans/2026-03-11-integration-plugins-wave2-design.md @@ -5,11 +5,11 @@ ## Overview -Six new external gRPC plugins for the workflow engine, continuing the integration plugin pattern established in wave 1 (Twilio, monday.com, turn.io). All are MIT-licensed, open-source, community-tier plugins following the `workflow-plugin-*` pattern. +Five new external gRPC plugins for the workflow engine, plus Permit.io integrated as a new provider in the existing `workflow-plugin-authz`. Continues the integration plugin pattern from wave 1 (Twilio, monday.com, turn.io). All new repos are MIT-licensed, open-source, community-tier. ## Common Architecture -Identical to wave 1 — see `2026-03-11-integration-plugins-design.md`. Each plugin: +Identical to wave 1 — see `2026-03-11-integration-plugins-design.md`. Each **new** plugin: - Standalone Go repo: `GoCodeAlone/workflow-plugin-` - `sdk.Serve(provider)` entry point - PluginProvider + ModuleProvider + StepProvider interfaces @@ -18,6 +18,8 @@ Identical to wave 1 — see `2026-03-11-integration-plugins-design.md`. Each plu - GoReleaser v2, `CGO_ENABLED=0`, linux/darwin x amd64/arm64 - MIT license, community tier, minEngineVersion `0.3.30` +**Exception — Permit.io**: Added as a provider to the existing `workflow-plugin-authz` (alongside Casbin), following the multi-provider pattern used in `workflow-plugin-payments` (Stripe + PayPal). New module type: `permit.provider`. New step types prefixed `step.permit_`. + --- ## Plugin 1: workflow-plugin-okta @@ -127,13 +129,15 @@ Identical to wave 1 — see `2026-03-11-integration-plugins-design.md`. Each plu --- -## Plugin 4: workflow-plugin-permit +## Plugin 4: Permit.io provider in workflow-plugin-authz -**Dependency**: `github.com/permitio/permit-golang` v1.2.8 (official Go SDK) +**Repo**: `GoCodeAlone/workflow-plugin-authz` (existing — add Permit.io as a new provider alongside Casbin) +**New Dependency**: `github.com/permitio/permit-golang` v1.2.8 (official Go SDK) -**Module**: `permit.provider` +**Module**: `permit.provider` (new module type in the authz plugin) - Config: `apiKey` (required), optional `pdpUrl` (default `https://cloudpdp.api.permit.io`), optional `apiUrl` (default `https://api.permit.io`), optional `project`, `environment` - Initializes Permit SDK client +- Coexists with the existing `authz.provider` (Casbin) — both can be configured simultaneously ### Step Types (~80 steps, all prefixed `step.permit_`) @@ -255,4 +259,5 @@ All six plugins built in parallel using agent teams. Each plugin is independent. - **Salesforce**: ~75 steps, community SDK + direct REST - **OpenLMS**: ~120 steps, direct REST client (Moodle Web Services) -**Repos**: `GoCodeAlone/workflow-plugin-okta`, `GoCodeAlone/workflow-plugin-datadog`, `GoCodeAlone/workflow-plugin-launchdarkly`, `GoCodeAlone/workflow-plugin-permit`, `GoCodeAlone/workflow-plugin-salesforce`, `GoCodeAlone/workflow-plugin-openlms` +**New Repos**: `GoCodeAlone/workflow-plugin-okta`, `GoCodeAlone/workflow-plugin-datadog`, `GoCodeAlone/workflow-plugin-launchdarkly`, `GoCodeAlone/workflow-plugin-salesforce`, `GoCodeAlone/workflow-plugin-openlms` +**Existing Repo (extended)**: `GoCodeAlone/workflow-plugin-authz` (Permit.io provider added) diff --git a/docs/plans/2026-03-11-plugin-releases-and-validation.md b/docs/plans/2026-03-11-plugin-releases-and-validation.md index 1407d1fe..85d9945a 100644 --- a/docs/plans/2026-03-11-plugin-releases-and-validation.md +++ b/docs/plans/2026-03-11-plugin-releases-and-validation.md @@ -187,7 +187,7 @@ After wave 2 plugins are built, create scenarios 54-59: | 54-okta-integration | okta | user CRUD, group membership, app assignment, MFA enrollment, auth server config | | 55-datadog-integration | datadog | metric submit/query, monitor CRUD, event creation, log search, SLO lifecycle | | 56-launchdarkly-integration | launchdarkly | flag CRUD, project/environment management, segment operations, context evaluation | -| 57-permit-integration | permit | RBAC check, user/role CRUD, resource management, relationship tuples, condition sets | +| 57-permit-integration | authz (permit provider) | RBAC check, user/role CRUD, resource management, relationship tuples, condition sets | | 58-salesforce-integration | salesforce | record CRUD, SOQL query, bulk operations, composite requests, approval process | | 59-openlms-integration | openlms | user/course CRUD, enrollment, grades, quiz lifecycle, assignment submission | From 6942e835a3974e0e4851a403adb9a8f527c6231c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:26:25 -0400 Subject: [PATCH 06/26] feat: rename -data-dir to -plugin-dir in plugin subcommands Rename the flag from -data-dir to -plugin-dir across runPluginInstall, runPluginList, runPluginUpdate, runPluginRemove, and runPluginInfo for consistency with other wfctl commands. Keep -data-dir as a working deprecated alias. Update pluginUsage() to document -plugin-dir. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/plugin.go | 5 ++++ cmd/wfctl/plugin_install.go | 35 ++++++++++++++++++---------- cmd/wfctl/plugin_install_test.go | 39 ++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 cmd/wfctl/plugin_install_test.go diff --git a/cmd/wfctl/plugin.go b/cmd/wfctl/plugin.go index 9f5c0990..e5198d94 100644 --- a/cmd/wfctl/plugin.go +++ b/cmd/wfctl/plugin.go @@ -52,11 +52,16 @@ Subcommands: remove Uninstall a plugin validate Validate a plugin manifest from the registry or a local file info Show details about an installed plugin + +Use -plugin-dir to specify a custom plugin directory (replaces deprecated -data-dir). `) return fmt.Errorf("plugin subcommand is required") } func runPluginInit(args []string) error { + if err := checkTrailingFlags(args); err != nil { + return err + } fs := flag.NewFlagSet("plugin init", flag.ExitOnError) author := fs.String("author", "", "Plugin author (required)") ver := fs.String("version", "0.1.0", "Plugin version") diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 812cd6df..9b5ab00b 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -66,7 +66,9 @@ func runPluginSearch(args []string) error { func runPluginInstall(args []string) error { fs := flag.NewFlagSet("plugin install", flag.ContinueOnError) - dataDir := fs.String("data-dir", defaultDataDir, "Plugin data directory") + var pluginDirVal string + fs.StringVar(&pluginDirVal, "plugin-dir", defaultDataDir, "Plugin directory") + fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)") cfgPath := fs.String("config", "", "Registry config file path") registryName := fs.String("registry", "", "Use a specific registry by name") fs.Usage = func() { @@ -76,6 +78,7 @@ func runPluginInstall(args []string) error { if err := fs.Parse(args); err != nil { return err } + dataDir := &pluginDirVal if fs.NArg() < 1 { fs.Usage() return fmt.Errorf("plugin name is required") @@ -168,7 +171,9 @@ func runPluginInstall(args []string) error { func runPluginList(args []string) error { fs := flag.NewFlagSet("plugin list", flag.ContinueOnError) - dataDir := fs.String("data-dir", defaultDataDir, "Plugin data directory") + var pluginDirVal string + fs.StringVar(&pluginDirVal, "plugin-dir", defaultDataDir, "Plugin directory") + fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin list [options]\n\nList installed plugins.\n\nOptions:\n") fs.PrintDefaults() @@ -177,13 +182,13 @@ func runPluginList(args []string) error { return err } - entries, err := os.ReadDir(*dataDir) + entries, err := os.ReadDir(pluginDirVal) if os.IsNotExist(err) { fmt.Println("No plugins installed.") return nil } if err != nil { - return fmt.Errorf("read data dir %s: %w", *dataDir, err) + return fmt.Errorf("read data dir %s: %w", pluginDirVal, err) } type installed struct { @@ -197,7 +202,7 @@ func runPluginList(args []string) error { if !e.IsDir() { continue } - ver, pType, desc := readInstalledInfo(filepath.Join(*dataDir, e.Name())) + ver, pType, desc := readInstalledInfo(filepath.Join(pluginDirVal, e.Name())) plugins = append(plugins, installed{name: e.Name(), version: ver, pluginType: pType, description: desc}) } @@ -220,7 +225,9 @@ func runPluginList(args []string) error { func runPluginUpdate(args []string) error { fs := flag.NewFlagSet("plugin update", flag.ContinueOnError) - dataDir := fs.String("data-dir", defaultDataDir, "Plugin data directory") + var pluginDirVal string + fs.StringVar(&pluginDirVal, "plugin-dir", defaultDataDir, "Plugin directory") + fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin update [options] \n\nUpdate an installed plugin to its latest version.\n\nOptions:\n") fs.PrintDefaults() @@ -234,18 +241,20 @@ func runPluginUpdate(args []string) error { } pluginName := fs.Arg(0) - pluginDir := filepath.Join(*dataDir, pluginName) + pluginDir := filepath.Join(pluginDirVal, pluginName) if _, err := os.Stat(pluginDir); os.IsNotExist(err) { return fmt.Errorf("plugin %q is not installed", pluginName) } // Re-run install which will overwrite the existing installation. - return runPluginInstall(append([]string{"--data-dir", *dataDir}, pluginName)) + return runPluginInstall(append([]string{"--plugin-dir", pluginDirVal}, pluginName)) } func runPluginRemove(args []string) error { fs := flag.NewFlagSet("plugin remove", flag.ContinueOnError) - dataDir := fs.String("data-dir", defaultDataDir, "Plugin data directory") + var pluginDirVal string + fs.StringVar(&pluginDirVal, "plugin-dir", defaultDataDir, "Plugin directory") + fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin remove [options] \n\nUninstall a plugin.\n\nOptions:\n") fs.PrintDefaults() @@ -259,7 +268,7 @@ func runPluginRemove(args []string) error { } pluginName := fs.Arg(0) - pluginDir := filepath.Join(*dataDir, pluginName) + pluginDir := filepath.Join(pluginDirVal, pluginName) if _, err := os.Stat(pluginDir); os.IsNotExist(err) { return fmt.Errorf("plugin %q is not installed", pluginName) } @@ -272,7 +281,9 @@ func runPluginRemove(args []string) error { func runPluginInfo(args []string) error { fs := flag.NewFlagSet("plugin info", flag.ContinueOnError) - dataDir := fs.String("data-dir", defaultDataDir, "Plugin data directory") + var pluginDirVal string + fs.StringVar(&pluginDirVal, "plugin-dir", defaultDataDir, "Plugin directory") + fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin info [options] \n\nShow details about an installed plugin.\n\nOptions:\n") fs.PrintDefaults() @@ -286,7 +297,7 @@ func runPluginInfo(args []string) error { } pluginName := fs.Arg(0) - pluginDir := filepath.Join(*dataDir, pluginName) + pluginDir := filepath.Join(pluginDirVal, pluginName) manifestPath := filepath.Join(pluginDir, "plugin.json") data, err := os.ReadFile(manifestPath) diff --git a/cmd/wfctl/plugin_install_test.go b/cmd/wfctl/plugin_install_test.go new file mode 100644 index 00000000..fb776b7f --- /dev/null +++ b/cmd/wfctl/plugin_install_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestPluginListAcceptsPluginDirFlag verifies that -plugin-dir is accepted by +// runPluginList and correctly used as the directory to scan. +func TestPluginListAcceptsPluginDirFlag(t *testing.T) { + dir := t.TempDir() + + // Create a fake installed plugin directory with a minimal plugin.json. + pluginDir := filepath.Join(dir, "myplugin") + if err := os.MkdirAll(pluginDir, 0750); err != nil { + t.Fatalf("mkdir: %v", err) + } + manifest := `{"name":"myplugin","version":"1.0.0","author":"test","description":"test plugin"}` + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0640); err != nil { + t.Fatalf("write plugin.json: %v", err) + } + + // Should succeed using -plugin-dir. + if err := runPluginList([]string{"-plugin-dir", dir}); err != nil { + t.Errorf("-plugin-dir: runPluginList returned unexpected error: %v", err) + } +} + +// TestPluginListAcceptsLegacyDataDirFlag verifies that the deprecated -data-dir flag +// still works as an alias for -plugin-dir. +func TestPluginListAcceptsLegacyDataDirFlag(t *testing.T) { + dir := t.TempDir() + + // Should succeed using -data-dir (deprecated alias). + if err := runPluginList([]string{"-data-dir", dir}); err != nil { + t.Errorf("-data-dir: runPluginList returned unexpected error: %v", err) + } +} From d369b7779dfec9664f3badaecd9c854a3b7dd72c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:26:30 -0400 Subject: [PATCH 07/26] feat(wfctl): add trailing flag detection helper Add checkTrailingFlags() to detect when flags are passed after positional arguments, and wire it into runPluginInit, runRegistryAdd, and runRegistryRemove. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/flag_helpers.go | 29 +++++++++++++++++++++++ cmd/wfctl/flag_helpers_test.go | 43 ++++++++++++++++++++++++++++++++++ cmd/wfctl/registry_cmd.go | 6 +++++ 3 files changed, 78 insertions(+) create mode 100644 cmd/wfctl/flag_helpers.go create mode 100644 cmd/wfctl/flag_helpers_test.go diff --git a/cmd/wfctl/flag_helpers.go b/cmd/wfctl/flag_helpers.go new file mode 100644 index 00000000..5c0b6226 --- /dev/null +++ b/cmd/wfctl/flag_helpers.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "strings" +) + +// checkTrailingFlags returns an error if any flag (starting with '-') appears +// after the first positional argument in args. A token immediately following a +// flag token (its value) is not counted as a positional argument. +func checkTrailingFlags(args []string) error { + seenPositional := false + prevWasFlag := false + for _, arg := range args { + if strings.HasPrefix(arg, "-") { + if seenPositional { + return fmt.Errorf("flags must come before arguments (got %s after positional arg). Reorder so all flags precede the name argument", arg) + } + // Only treat as value-bearing flag if it doesn't use = syntax + prevWasFlag = !strings.Contains(arg, "=") + } else { + if !prevWasFlag { + seenPositional = true + } + prevWasFlag = false + } + } + return nil +} diff --git a/cmd/wfctl/flag_helpers_test.go b/cmd/wfctl/flag_helpers_test.go new file mode 100644 index 00000000..a9836fb8 --- /dev/null +++ b/cmd/wfctl/flag_helpers_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" +) + +func TestCheckTrailingFlags(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + }{ + { + name: "flags before positional arg", + args: []string{"-author", "jon", "myplugin"}, + wantErr: false, + }, + { + name: "flags after positional arg", + args: []string{"myplugin", "-author", "jon"}, + wantErr: true, + }, + { + name: "all flags no positional", + args: []string{"-author", "jon", "-version", "1.0.0"}, + wantErr: false, + }, + { + name: "no flags", + args: []string{"myplugin"}, + wantErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := checkTrailingFlags(tc.args) + if (err != nil) != tc.wantErr { + t.Errorf("checkTrailingFlags(%v) error = %v, wantErr %v", tc.args, err, tc.wantErr) + } + }) + } +} diff --git a/cmd/wfctl/registry_cmd.go b/cmd/wfctl/registry_cmd.go index d690530d..07779e7f 100644 --- a/cmd/wfctl/registry_cmd.go +++ b/cmd/wfctl/registry_cmd.go @@ -59,6 +59,9 @@ func runRegistryList(args []string) error { } func runRegistryAdd(args []string) error { + if err := checkTrailingFlags(args); err != nil { + return err + } fs := flag.NewFlagSet("registry add", flag.ContinueOnError) cfgPath := fs.String("config", "", "Registry config file path (default: ~/.config/wfctl/config.yaml)") regType := fs.String("type", "github", "Registry type (github)") @@ -121,6 +124,9 @@ func runRegistryAdd(args []string) error { } func runRegistryRemove(args []string) error { + if err := checkTrailingFlags(args); err != nil { + return err + } fs := flag.NewFlagSet("registry remove", flag.ContinueOnError) cfgPath := fs.String("config", "", "Registry config file path (default: ~/.config/wfctl/config.yaml)") fs.Usage = func() { From c114e1fa2902dbd67e9857b4f0d764bc71906bd4 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:26:42 -0400 Subject: [PATCH 08/26] fix: --help exits 0 and suppresses engine error leakage Add isHelpRequested() helper that detects flag.ErrHelp propagated through the pipeline engine. In main(): - No-args case now exits 0 (showing help is not an error) - Help requests in dispatch exit 0 without printing engine error Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/main.go | 20 ++++++++++++++++++-- cmd/wfctl/main_test.go | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/cmd/wfctl/main.go b/cmd/wfctl/main.go index 250c5794..3a7d5fca 100644 --- a/cmd/wfctl/main.go +++ b/cmd/wfctl/main.go @@ -8,6 +8,7 @@ import ( "log/slog" "os" "os/signal" + "strings" "syscall" "time" @@ -27,6 +28,17 @@ var wfctlConfigBytes []byte var version = "dev" +// isHelpRequested reports whether the error originated from the user +// requesting help (--help / -h). flag.ErrHelp propagates through the +// pipeline engine as a step failure; catching it here lets us exit 0 +// instead of printing a confusing "error: flag: help requested" message. +func isHelpRequested(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "flag: help requested") +} + // commands maps each CLI command name to its Go implementation. The command // metadata (name, description) is declared in wfctl.yaml; this map provides // the runtime functions that are registered in the CLICommandRegistry service @@ -131,9 +143,9 @@ func main() { cliHandler.SetOutput(os.Stderr) if len(os.Args) < 2 { - // No subcommand — print usage and exit non-zero. + // No subcommand — print usage and exit 0 (help is not an error). _ = cliHandler.Dispatch([]string{"-h"}) - os.Exit(1) + os.Exit(0) } cmd := os.Args[1] @@ -155,6 +167,10 @@ func main() { stop() if dispatchErr != nil { + // If the user requested help, exit cleanly without printing the engine error. + if isHelpRequested(dispatchErr) { + os.Exit(0) + } // The handler already printed routing errors (unknown/missing command). // Only emit the "error:" prefix for actual command execution failures. if _, isKnown := commands[cmd]; isKnown { diff --git a/cmd/wfctl/main_test.go b/cmd/wfctl/main_test.go index 9955d990..8e3cd892 100644 --- a/cmd/wfctl/main_test.go +++ b/cmd/wfctl/main_test.go @@ -2,6 +2,9 @@ package main import ( "encoding/json" + "errors" + "flag" + "fmt" "os" "path/filepath" "strings" @@ -10,6 +13,23 @@ import ( "github.com/GoCodeAlone/workflow/schema" ) +func TestHelpFlagDoesNotLeakEngineError(t *testing.T) { + if !isHelpRequested(flag.ErrHelp) { + t.Error("isHelpRequested should return true for flag.ErrHelp") + } + if isHelpRequested(nil) { + t.Error("isHelpRequested should return false for nil") + } + if isHelpRequested(errors.New("some other error")) { + t.Error("isHelpRequested should return false for unrelated errors") + } + // Wrapped error should also be detected + wrapped := fmt.Errorf("pipeline failed: %w", flag.ErrHelp) + if !isHelpRequested(wrapped) { + t.Error("isHelpRequested should return true for wrapped flag.ErrHelp") + } +} + func writeTestConfig(t *testing.T, dir, name, content string) string { t.Helper() path := filepath.Join(dir, name) From 4265f107a324cb0d5c1625f2076ad606c0e238ca Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:27:33 -0400 Subject: [PATCH 09/26] feat(wfctl): add plugin name normalization to multi-registry Add normalizePluginName() stripping "workflow-plugin-" prefix so users can reference plugins by short name (e.g. "authz") or full name (e.g. "workflow-plugin-authz") interchangeably. Wire into FetchManifest and SearchPlugins. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/multi_registry.go | 31 +++++++++++++++++++++++++++++-- cmd/wfctl/multi_registry_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/cmd/wfctl/multi_registry.go b/cmd/wfctl/multi_registry.go index 0692cd34..41c51e06 100644 --- a/cmd/wfctl/multi_registry.go +++ b/cmd/wfctl/multi_registry.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "sort" + "strings" ) // MultiRegistry aggregates multiple RegistrySource instances and resolves @@ -41,16 +42,40 @@ func NewMultiRegistryFromSources(sources ...RegistrySource) *MultiRegistry { return &MultiRegistry{sources: sources} } +// normalizePluginName strips the "workflow-plugin-" prefix from a plugin name +// so that users can refer to plugins by their short name (e.g. "authz") or +// full name (e.g. "workflow-plugin-authz") interchangeably. +func normalizePluginName(name string) string { + return strings.TrimPrefix(name, "workflow-plugin-") +} + // FetchManifest tries each source in priority order, returning the first successful result. +// It first tries the normalized name (stripping "workflow-plugin-" prefix); if the +// normalized name differs from the original, it also tries the original name as a fallback. func (m *MultiRegistry) FetchManifest(name string) (*RegistryManifest, string, error) { + normalized := normalizePluginName(name) + + // Try normalized name first across all sources. var lastErr error for _, src := range m.sources { - manifest, err := src.FetchManifest(name) + manifest, err := src.FetchManifest(normalized) if err == nil { return manifest, src.Name(), nil } lastErr = err } + + // If normalized differs from original, try original name as fallback. + if normalized != name { + for _, src := range m.sources { + manifest, err := src.FetchManifest(name) + if err == nil { + return manifest, src.Name(), nil + } + lastErr = err + } + } + if lastErr != nil { return nil, "", lastErr } @@ -59,12 +84,14 @@ func (m *MultiRegistry) FetchManifest(name string) (*RegistryManifest, string, e // SearchPlugins searches all sources and returns deduplicated results. // When the same plugin appears in multiple registries, the higher-priority source wins. +// The query is normalized (stripping "workflow-plugin-" prefix) before searching. func (m *MultiRegistry) SearchPlugins(query string) ([]PluginSearchResult, error) { seen := make(map[string]bool) var results []PluginSearchResult + normalizedQuery := normalizePluginName(query) for _, src := range m.sources { - srcResults, err := src.SearchPlugins(query) + srcResults, err := src.SearchPlugins(normalizedQuery) if err != nil { fmt.Fprintf(os.Stderr, "warning: search failed for registry %q: %v\n", src.Name(), err) continue diff --git a/cmd/wfctl/multi_registry_test.go b/cmd/wfctl/multi_registry_test.go index 4bb3187d..fe8e2fb4 100644 --- a/cmd/wfctl/multi_registry_test.go +++ b/cmd/wfctl/multi_registry_test.go @@ -70,6 +70,30 @@ func (m *mockRegistrySource) SearchPlugins(query string) ([]PluginSearchResult, return results, nil } +// --------------------------------------------------------------------------- +// normalizePluginName tests +// --------------------------------------------------------------------------- + +func TestNormalizePluginName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"authz", "authz"}, + {"workflow-plugin-authz", "authz"}, + {"workflow-plugin-payments", "payments"}, + {"custom-plugin", "custom-plugin"}, + } + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + got := normalizePluginName(tc.input) + if got != tc.want { + t.Errorf("normalizePluginName(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + // --------------------------------------------------------------------------- // Registry config tests // --------------------------------------------------------------------------- From fb6ca6b4a8f47c125e7ec287d28eb8ebfd56b9fc Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:29:04 -0400 Subject: [PATCH 10/26] feat: validate --dir skips non-workflow YAML files Add isWorkflowYAML() that checks the first 100 lines of a file for top-level modules:, workflows:, or pipelines: keys. Files found by --dir that don't match are skipped with a stderr message, preventing false validation failures on GitHub Actions CI files and other YAML. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/validate.go | 30 ++++++++++- cmd/wfctl/validate_test.go | 108 +++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 cmd/wfctl/validate_test.go diff --git a/cmd/wfctl/validate.go b/cmd/wfctl/validate.go index e3e3d766..14ab5925 100644 --- a/cmd/wfctl/validate.go +++ b/cmd/wfctl/validate.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "flag" "fmt" "os" @@ -60,7 +61,13 @@ Options: if err != nil { return fmt.Errorf("failed to scan directory %s: %w", *dir, err) } - files = append(files, found...) + for _, f := range found { + if !isWorkflowYAML(f) { + fmt.Fprintf(os.Stderr, " Skipping non-workflow file: %s\n", f) + continue + } + files = append(files, f) + } } files = append(files, fs.Args()...) @@ -156,6 +163,27 @@ var skipFiles = map[string]bool{ "dashboard.yaml": true, } +// isWorkflowYAML reports whether the YAML file at path looks like a workflow +// config by checking the first 100 lines for top-level keys: modules:, +// workflows:, or pipelines:. +func isWorkflowYAML(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + scanner := bufio.NewScanner(f) + for i := 0; i < 100 && scanner.Scan(); i++ { + line := scanner.Text() + if strings.HasPrefix(line, "modules:") || + strings.HasPrefix(line, "workflows:") || + strings.HasPrefix(line, "pipelines:") { + return true + } + } + return false +} + func findYAMLFiles(root string) ([]string, error) { var files []string err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { diff --git a/cmd/wfctl/validate_test.go b/cmd/wfctl/validate_test.go new file mode 100644 index 00000000..cdf86269 --- /dev/null +++ b/cmd/wfctl/validate_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestValidateSkipsNonWorkflowYAML(t *testing.T) { + dir := t.TempDir() + + // GitHub Actions CI file — should NOT be recognized as workflow YAML + ciYAML := `name: CI +on: [push] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 +` + ciPath := filepath.Join(dir, "ci.yml") + if err := os.WriteFile(ciPath, []byte(ciYAML), 0644); err != nil { + t.Fatal(err) + } + + // Workflow engine config — should be recognized + appYAML := `modules: + - name: server + type: http.server + config: + address: ":8080" +` + appPath := filepath.Join(dir, "app.yaml") + if err := os.WriteFile(appPath, []byte(appYAML), 0644); err != nil { + t.Fatal(err) + } + + if isWorkflowYAML(ciPath) { + t.Errorf("isWorkflowYAML(%q) = true, want false (GitHub Actions file)", ciPath) + } + if !isWorkflowYAML(appPath) { + t.Errorf("isWorkflowYAML(%q) = false, want true (workflow config)", appPath) + } +} + +func TestIsWorkflowYAMLVariants(t *testing.T) { + dir := t.TempDir() + + write := func(name, content string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(content), 0644); err != nil { + t.Fatal(err) + } + return p + } + + cases := []struct { + name string + content string + want bool + }{ + {"with-modules", "modules:\n - name: x\n", true}, + {"with-workflows", "workflows:\n http: {}\n", true}, + {"with-pipelines", "pipelines:\n - name: p\n", true}, + {"non-workflow", "name: CI\non: [push]\n", false}, + {"indented-modules", " modules:\n - name: x\n", false}, // indented, not top-level + {"empty", "", false}, + } + + for _, tc := range cases { + p := write(tc.name+".yaml", tc.content) + got := isWorkflowYAML(p) + if got != tc.want { + t.Errorf("isWorkflowYAML(%q) = %v, want %v (content: %q)", tc.name, got, tc.want, tc.content) + } + } +} + +func TestValidateDirSkipsNonWorkflowFiles(t *testing.T) { + dir := t.TempDir() + + // Write a non-workflow YAML (GitHub Actions style) + ciYAML := `name: CI +on: [push] +jobs: + build: + runs-on: ubuntu-latest +` + if err := os.WriteFile(filepath.Join(dir, "ci.yml"), []byte(ciYAML), 0644); err != nil { + t.Fatal(err) + } + + // Write a valid workflow config + appYAML := `modules: + - name: server + type: http.server + config: + address: ":8080" +` + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(appYAML), 0644); err != nil { + t.Fatal(err) + } + + // --dir should succeed: ci.yml is skipped, app.yaml passes validation + if err := runValidate([]string{"--dir", dir}); err != nil { + t.Fatalf("runValidate --dir: %v", err) + } +} From b5a6f53288e50d3413b09fcc99e2798c874c4535 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:29:05 -0400 Subject: [PATCH 11/26] feat: add version check to plugin update command Before downloading, compare installed plugin.json version against the registry manifest version. If equal, print "already at latest version" and skip. If different, print "Updating from X to Y..." and proceed. Also adds -config flag to plugin update for registry config override. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/plugin_install.go | 21 ++++++++++++++++++++- cmd/wfctl/plugin_install_test.go | 1 + 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 9b5ab00b..c16ecf32 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -228,6 +228,7 @@ func runPluginUpdate(args []string) error { var pluginDirVal string fs.StringVar(&pluginDirVal, "plugin-dir", defaultDataDir, "Plugin directory") fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)") + cfgPath := fs.String("config", "", "Registry config file path") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: wfctl plugin update [options] \n\nUpdate an installed plugin to its latest version.\n\nOptions:\n") fs.PrintDefaults() @@ -246,8 +247,26 @@ func runPluginUpdate(args []string) error { return fmt.Errorf("plugin %q is not installed", pluginName) } + // Check the registry for the latest version before downloading. + cfg, err := LoadRegistryConfig(*cfgPath) + if err != nil { + return fmt.Errorf("load registry config: %w", err) + } + mr := NewMultiRegistry(cfg) + manifest, _, err := mr.FetchManifest(pluginName) + if err != nil { + return fmt.Errorf("fetch manifest: %w", err) + } + + installedVer := readInstalledVersion(pluginDir) + if installedVer == manifest.Version { + fmt.Printf("already at latest version (%s)\n", manifest.Version) + return nil + } + fmt.Fprintf(os.Stderr, "Updating from %s to %s...\n", installedVer, manifest.Version) + // Re-run install which will overwrite the existing installation. - return runPluginInstall(append([]string{"--plugin-dir", pluginDirVal}, pluginName)) + return runPluginInstall(append([]string{"--plugin-dir", pluginDirVal, "--config", *cfgPath}, pluginName)) } func runPluginRemove(args []string) error { diff --git a/cmd/wfctl/plugin_install_test.go b/cmd/wfctl/plugin_install_test.go index fb776b7f..1175d806 100644 --- a/cmd/wfctl/plugin_install_test.go +++ b/cmd/wfctl/plugin_install_test.go @@ -37,3 +37,4 @@ func TestPluginListAcceptsLegacyDataDirFlag(t *testing.T) { t.Errorf("-data-dir: runPluginList returned unexpected error: %v", err) } } + From aa961eb0ac2695fae7a1ea834888252ea0bbdac3 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:30:12 -0400 Subject: [PATCH 12/26] fix: infra commands show actionable error when no config found Improve the error message from the generic "no config file found" to a helpful message that explains what the config needs and how to generate one. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/infra.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index b4f42cb4..2ac7c8c8 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -65,7 +65,7 @@ func resolveInfraConfig(fs *flag.FlagSet) (string, error) { return arg, nil } } - return "", fmt.Errorf("no config file found (tried infra.yaml, config/infra.yaml)") + return "", fmt.Errorf("no infrastructure config found (tried infra.yaml, config/infra.yaml).\nCreate an infra config with cloud.account and platform.* modules.\nRun 'wfctl init --template full-stack' for a starter config with infrastructure.") } // infraModuleEntry is a minimal struct for parsing modules from YAML. From 88a50906e42edca194de4783cd10c6267aeb059e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:30:31 -0400 Subject: [PATCH 13/26] test(wfctl): add TestPluginInstallRespectsPluginDir Verify that install uses -plugin-dir (custom dir) instead of the default data/plugins path. This guards the fix from Task 2 that updated runPluginUpdate to pass --plugin-dir, not --data-dir, when calling runPluginInstall. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/plugin_install_e2e_test.go | 79 ++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/cmd/wfctl/plugin_install_e2e_test.go b/cmd/wfctl/plugin_install_e2e_test.go index efd9efa1..84b69305 100644 --- a/cmd/wfctl/plugin_install_e2e_test.go +++ b/cmd/wfctl/plugin_install_e2e_test.go @@ -217,6 +217,85 @@ func TestPluginInstallE2E(t *testing.T) { } } +// TestPluginInstallRespectsPluginDir verifies that -plugin-dir is honoured: +// the install path uses the custom directory, not the default data/plugins. +func TestPluginInstallRespectsPluginDir(t *testing.T) { + const pluginName = "dir-test-plugin" + binaryContent := []byte("#!/bin/sh\necho dir-test\n") + + topDir := fmt.Sprintf("%s-%s-%s", pluginName, runtime.GOOS, runtime.GOARCH) + tarball := buildTarGz(t, map[string][]byte{ + topDir + "/" + pluginName: binaryContent, + }, 0755) + checksum := sha256Hex(tarball) + + tarSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + w.Write(tarball) //nolint:errcheck + })) + defer tarSrv.Close() + + manifest := &RegistryManifest{ + Name: pluginName, + Version: "1.0.0", + Type: "external", + Downloads: []PluginDownload{{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + URL: tarSrv.URL + "/" + pluginName + ".tar.gz", + SHA256: checksum, + }}, + } + + // Use a custom plugin dir (not defaultDataDir). + customDir := t.TempDir() + + // Perform the install steps that runPluginInstall does, using the custom dir. + mr := NewMultiRegistryFromSources(&mockRegistrySource{ + name: "test", + manifests: map[string]*RegistryManifest{pluginName: manifest}, + }) + + gotManifest, _, err := mr.FetchManifest(pluginName) + if err != nil { + t.Fatalf("FetchManifest: %v", err) + } + + dl, err := gotManifest.FindDownload(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatalf("FindDownload: %v", err) + } + + data, err := downloadURL(dl.URL) + if err != nil { + t.Fatalf("downloadURL: %v", err) + } + + if err := verifyChecksum(data, dl.SHA256); err != nil { + t.Fatalf("verifyChecksum: %v", err) + } + + // Install into customDir, not defaultDataDir. + destDir := filepath.Join(customDir, pluginName) + if err := os.MkdirAll(destDir, 0750); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := extractTarGz(data, destDir); err != nil { + t.Fatalf("extractTarGz: %v", err) + } + + // Plugin binary must exist in customDir, not in defaultDataDir. + binaryPath := filepath.Join(customDir, pluginName, pluginName) + if _, err := os.Stat(binaryPath); os.IsNotExist(err) { + t.Errorf("plugin binary not found in customDir %s", customDir) + } + defaultPath := filepath.Join(defaultDataDir, pluginName, pluginName) + if _, err := os.Stat(defaultPath); err == nil { + t.Errorf("plugin binary unexpectedly found in defaultDataDir %s", defaultDataDir) + } +} + // TestExtractTarGz verifies that tar.gz extraction produces correct files with preserved modes. func TestExtractTarGz(t *testing.T) { entries := map[string][]byte{ From d1a6da35778fe8141c59b7661afa71ab4e2fc1b2 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:30:40 -0400 Subject: [PATCH 14/26] fix: handle missing go.sum in init Dockerfile templates Replace `COPY go.mod go.sum ./` with `COPY go.mod ./` + `COPY go.sum* ./` so the generated Dockerfile works whether or not go.sum exists yet. The glob pattern in the second COPY is a no-op when go.sum is absent. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/templates/api-service/Dockerfile.tmpl | 3 ++- cmd/wfctl/templates/event-processor/Dockerfile.tmpl | 3 ++- cmd/wfctl/templates/full-stack/Dockerfile.tmpl | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/cmd/wfctl/templates/api-service/Dockerfile.tmpl b/cmd/wfctl/templates/api-service/Dockerfile.tmpl index c84a7cc3..62efe142 100644 --- a/cmd/wfctl/templates/api-service/Dockerfile.tmpl +++ b/cmd/wfctl/templates/api-service/Dockerfile.tmpl @@ -2,7 +2,8 @@ FROM golang:1.22-alpine AS builder WORKDIR /app -COPY go.mod go.sum ./ +COPY go.mod ./ +COPY go.sum* ./ RUN go mod download COPY . . diff --git a/cmd/wfctl/templates/event-processor/Dockerfile.tmpl b/cmd/wfctl/templates/event-processor/Dockerfile.tmpl index c84a7cc3..62efe142 100644 --- a/cmd/wfctl/templates/event-processor/Dockerfile.tmpl +++ b/cmd/wfctl/templates/event-processor/Dockerfile.tmpl @@ -2,7 +2,8 @@ FROM golang:1.22-alpine AS builder WORKDIR /app -COPY go.mod go.sum ./ +COPY go.mod ./ +COPY go.sum* ./ RUN go mod download COPY . . diff --git a/cmd/wfctl/templates/full-stack/Dockerfile.tmpl b/cmd/wfctl/templates/full-stack/Dockerfile.tmpl index 8a8f8b02..379bba1f 100644 --- a/cmd/wfctl/templates/full-stack/Dockerfile.tmpl +++ b/cmd/wfctl/templates/full-stack/Dockerfile.tmpl @@ -9,7 +9,8 @@ RUN npm run build FROM golang:1.22-alpine AS go-builder WORKDIR /app -COPY go.mod go.sum ./ +COPY go.mod ./ +COPY go.sum* ./ RUN go mod download COPY . . COPY --from=ui-builder /app/ui/dist ./ui/dist From aaf07cbf71a8b7eda64dfbbf6cf828b0df1a8ad9 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:31:58 -0400 Subject: [PATCH 15/26] feat: log resolved imports during validate Read raw YAML to extract the imports: list before calling config.LoadFromFile. After loading, print "Resolved N import(s): ..." to stderr so users can see which files were included. This makes the validate command transparent about include/import resolution. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/validate.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/cmd/wfctl/validate.go b/cmd/wfctl/validate.go index 14ab5925..295c6b64 100644 --- a/cmd/wfctl/validate.go +++ b/cmd/wfctl/validate.go @@ -10,6 +10,7 @@ import ( "github.com/GoCodeAlone/workflow/config" "github.com/GoCodeAlone/workflow/schema" + "gopkg.in/yaml.v3" ) func runValidate(args []string) error { @@ -114,11 +115,18 @@ Options: } func validateFile(cfgPath string, strict, skipUnknownTypes, allowNoEntryPoints bool) error { + // Read raw YAML to extract imports list for verbose feedback. + imports := extractImports(cfgPath) + cfg, err := config.LoadFromFile(cfgPath) if err != nil { return fmt.Errorf("failed to load: %w", err) } + if len(imports) > 0 { + fmt.Fprintf(os.Stderr, " Resolved %d import(s): %s\n", len(imports), strings.Join(imports, ", ")) + } + var opts []schema.ValidationOption if !strict { opts = append(opts, schema.WithAllowEmptyModules()) @@ -209,6 +217,22 @@ func findYAMLFiles(root string) ([]string, error) { return files, err } +// extractImports reads the raw YAML at path and returns the top-level imports: list. +// Returns nil if the file cannot be read or has no imports. +func extractImports(path string) []string { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var raw struct { + Imports []string `yaml:"imports"` + } + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil + } + return raw.Imports +} + func indentError(err error) string { return strings.ReplaceAll(err.Error(), "\n", "\n ") } From 5ebc60e62ce04e35efcf70ca3f6e4042d7671572 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:32:45 -0400 Subject: [PATCH 16/26] feat(wfctl): accept positional config arg in deploy subcommands Allow users to pass the config file as a positional argument instead of always requiring -config, e.g. `wfctl deploy cloud myapp.yaml`. Applied to: runDeployDocker, runK8sGenerate, runK8sApply, runDeployCloud. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/deploy.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cmd/wfctl/deploy.go b/cmd/wfctl/deploy.go index 93687ccc..aa2bc2d6 100644 --- a/cmd/wfctl/deploy.go +++ b/cmd/wfctl/deploy.go @@ -132,6 +132,9 @@ Options: if err := fs.Parse(args); err != nil { return err } + if *config == "" && fs.NArg() > 0 { + *config = fs.Arg(0) + } cwd, err := os.Getwd() if err != nil { @@ -417,6 +420,9 @@ func runK8sGenerate(args []string) error { if err := fs.Parse(args); err != nil { return err } + if f.configFile == "" && fs.NArg() > 0 { + f.configFile = fs.Arg(0) + } if f.image == "" { return fmt.Errorf("-image is required") } @@ -470,6 +476,9 @@ func runK8sApply(args []string) error { if err := fs.Parse(args); err != nil { return err } + if f.configFile == "" && fs.NArg() > 0 { + f.configFile = fs.Arg(0) + } // Load .wfctl.yaml defaults for build settings if wfcfg, loadErr := loadWfctlConfig(); loadErr == nil { @@ -779,6 +788,9 @@ Options: if err := fs.Parse(args); err != nil { return err } + if *configFile == "" && fs.NArg() > 0 { + *configFile = fs.Arg(0) + } if *target != "" && *target != "staging" && *target != "production" { return fmt.Errorf("invalid target %q: must be staging or production", *target) From 319c2790de0b19ea5583eb247071b132656280af Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:33:03 -0400 Subject: [PATCH 17/26] feat: PluginManifest UnmarshalJSON handles legacy capabilities object format Add custom UnmarshalJSON on PluginManifest that detects whether the capabilities field is an array (new CapabilityDecl format) or an object (legacy registry format with moduleTypes/stepTypes/triggerTypes). When the legacy object format is detected, its type lists are merged into the top-level ModuleTypes, StepTypes, and TriggerTypes fields so callers always find types in a consistent location regardless of plugin.json format. Co-Authored-By: Claude Sonnet 4.6 --- plugin/manifest.go | 56 +++++++++++++++++++++++++++ plugin/manifest_test.go | 84 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/plugin/manifest.go b/plugin/manifest.go index 13ef66bc..fec24157 100644 --- a/plugin/manifest.go +++ b/plugin/manifest.go @@ -80,6 +80,62 @@ type Dependency struct { Constraint string `json:"constraint" yaml:"constraint"` // semver constraint, e.g. ">=1.0.0", "^2.1" } +// UnmarshalJSON implements custom JSON unmarshalling for PluginManifest that +// handles both the canonical capabilities array format and the legacy object +// format used by registry manifests and older plugin.json files. +// +// Legacy format: "capabilities": {"configProvider": bool, "moduleTypes": [...], ...} +// New format: "capabilities": [{"name": "...", "role": "..."}] +// +// When the legacy object format is detected, its type lists are merged into the +// top-level ModuleTypes, StepTypes, and TriggerTypes fields so callers always +// find types in a consistent location. +func (m *PluginManifest) UnmarshalJSON(data []byte) error { + // rawManifest breaks the recursion: it is the same layout as PluginManifest + // but without the custom UnmarshalJSON method. + type rawManifest PluginManifest + // withRawCaps shadows the Capabilities field so we can capture it as raw JSON + // and inspect whether it is an array or object before decoding. + type withRawCaps struct { + rawManifest + Capabilities json.RawMessage `json:"capabilities,omitempty"` + } + var raw withRawCaps + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *m = PluginManifest(raw.rawManifest) + m.Capabilities = nil // captured in raw.Capabilities; reset and repopulate below + + if len(raw.Capabilities) == 0 { + return nil + } + switch raw.Capabilities[0] { + case '[': + // New format: array of CapabilityDecl + var caps []CapabilityDecl + if err := json.Unmarshal(raw.Capabilities, &caps); err != nil { + return fmt.Errorf("invalid capabilities array: %w", err) + } + m.Capabilities = caps + case '{': + // Legacy format: object with configProvider, moduleTypes, stepTypes, triggerTypes. + // Merge type lists into the top-level fields so callers see them consistently. + var legacyCaps struct { + ModuleTypes []string `json:"moduleTypes"` + StepTypes []string `json:"stepTypes"` + TriggerTypes []string `json:"triggerTypes"` + } + if err := json.Unmarshal(raw.Capabilities, &legacyCaps); err != nil { + return fmt.Errorf("invalid capabilities object: %w", err) + } + m.ModuleTypes = append(m.ModuleTypes, legacyCaps.ModuleTypes...) + m.StepTypes = append(m.StepTypes, legacyCaps.StepTypes...) + m.TriggerTypes = append(m.TriggerTypes, legacyCaps.TriggerTypes...) + } + return nil +} + // Validate checks that a manifest has all required fields and valid semver. func (m *PluginManifest) Validate() error { if m.Name == "" { diff --git a/plugin/manifest_test.go b/plugin/manifest_test.go index f84af625..0ad8507c 100644 --- a/plugin/manifest_test.go +++ b/plugin/manifest_test.go @@ -425,3 +425,87 @@ func TestManifestEngineFieldsLoadFromFile(t *testing.T) { t.Errorf("Capabilities = %v, want [{storage provider 5}]", loaded.Capabilities) } } + +func TestPluginManifest_LegacyCapabilities(t *testing.T) { + // Legacy format: capabilities is a JSON object with configProvider, moduleTypes, etc. + legacyJSON := `{ + "name": "legacy-plugin", + "version": "1.0.0", + "author": "Test", + "description": "Legacy capabilities test", + "capabilities": { + "configProvider": true, + "moduleTypes": ["test.module"], + "stepTypes": ["step.test"], + "triggerTypes": ["trigger.test"] + } + }` + + var m PluginManifest + if err := json.Unmarshal([]byte(legacyJSON), &m); err != nil { + t.Fatalf("Unmarshal legacy capabilities: %v", err) + } + if len(m.ModuleTypes) != 1 || m.ModuleTypes[0] != "test.module" { + t.Errorf("ModuleTypes = %v, want [test.module]", m.ModuleTypes) + } + if len(m.StepTypes) != 1 || m.StepTypes[0] != "step.test" { + t.Errorf("StepTypes = %v, want [step.test]", m.StepTypes) + } + if len(m.TriggerTypes) != 1 || m.TriggerTypes[0] != "trigger.test" { + t.Errorf("TriggerTypes = %v, want [trigger.test]", m.TriggerTypes) + } + // Legacy object format should not populate Capabilities slice + if len(m.Capabilities) != 0 { + t.Errorf("Capabilities = %v, want empty for legacy object format", m.Capabilities) + } +} + +func TestPluginManifest_NewCapabilitiesArrayFormat(t *testing.T) { + // New format: capabilities is a JSON array of CapabilityDecl + newJSON := `{ + "name": "new-plugin", + "version": "1.0.0", + "author": "Test", + "description": "New capabilities test", + "moduleTypes": ["test.module"], + "stepTypes": ["step.test"], + "capabilities": [{"name": "step.test", "role": "provider"}] + }` + + var m PluginManifest + if err := json.Unmarshal([]byte(newJSON), &m); err != nil { + t.Fatalf("Unmarshal new capabilities: %v", err) + } + if len(m.Capabilities) != 1 || m.Capabilities[0].Name != "step.test" { + t.Errorf("Capabilities = %v, want [{step.test provider 0}]", m.Capabilities) + } + if len(m.ModuleTypes) != 1 || m.ModuleTypes[0] != "test.module" { + t.Errorf("ModuleTypes = %v, want [test.module]", m.ModuleTypes) + } +} + +func TestPluginManifest_LegacyCapabilitiesMergesWithTopLevel(t *testing.T) { + // Top-level fields should be merged with types from legacy capabilities object + legacyJSON := `{ + "name": "merged-plugin", + "version": "1.0.0", + "author": "Test", + "description": "Merge test", + "moduleTypes": ["existing.module"], + "capabilities": { + "moduleTypes": ["caps.module"], + "stepTypes": ["step.caps"] + } + }` + + var m PluginManifest + if err := json.Unmarshal([]byte(legacyJSON), &m); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if len(m.ModuleTypes) != 2 { + t.Errorf("ModuleTypes = %v, want [existing.module caps.module]", m.ModuleTypes) + } + if len(m.StepTypes) != 1 || m.StepTypes[0] != "step.caps" { + t.Errorf("StepTypes = %v, want [step.caps]", m.StepTypes) + } +} From b7e6edac4a963eb846d5f1ca71fff0e3225b2505 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:33:11 -0400 Subject: [PATCH 18/26] fix: plugin info shows absolute binary path Resolve pluginDir to absolute path before displaying the binary path, so users see /absolute/path/to/plugin instead of relative data/plugins/foo. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/plugin_install.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index c16ecf32..3034aa0b 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -317,7 +317,8 @@ func runPluginInfo(args []string) error { pluginName := fs.Arg(0) pluginDir := filepath.Join(pluginDirVal, pluginName) - manifestPath := filepath.Join(pluginDir, "plugin.json") + absDir, _ := filepath.Abs(pluginDir) + manifestPath := filepath.Join(absDir, "plugin.json") data, err := os.ReadFile(manifestPath) if os.IsNotExist(err) { @@ -362,7 +363,7 @@ func runPluginInfo(args []string) error { } // Check binary status. - binaryPath := filepath.Join(pluginDir, pluginName) + binaryPath := filepath.Join(absDir, pluginName) if info, statErr := os.Stat(binaryPath); statErr == nil { fmt.Printf("Binary: %s (%d bytes)\n", binaryPath, info.Size()) if info.Mode()&0111 != 0 { From e5d0619c8df7bb8e17a8eb76577d0a05925db259 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:35:59 -0400 Subject: [PATCH 19/26] feat: engine warns when plugin minEngineVersion exceeds current version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MinEngineVersion field to PluginManifest and checkEngineCompatibility() to plugin/loader.go. The loader calls this on every plugin load — if the running engine version is older than the plugin's declared minimum, a WARNING is printed to stderr. No hard fail to allow testing newer plugins against older engines. SetEngineVersion() on PluginLoader threads the engine version through. Co-Authored-By: Claude Sonnet 4.6 --- plugin/loader.go | 35 +++++++++++++++++++++++++++ plugin/loader_test.go | 55 +++++++++++++++++++++++++++++++++++++++++++ plugin/manifest.go | 4 ++++ 3 files changed, 94 insertions(+) diff --git a/plugin/loader.go b/plugin/loader.go index 7fbb1b63..dd68da64 100644 --- a/plugin/loader.go +++ b/plugin/loader.go @@ -3,8 +3,10 @@ package plugin import ( "fmt" "log/slog" + "os" "reflect" "sort" + "strings" "github.com/GoCodeAlone/workflow/capability" "github.com/GoCodeAlone/workflow/deploy" @@ -36,6 +38,7 @@ type PluginLoader struct { deployTargets map[string]deploy.DeployTarget sidecarProviders map[string]deploy.SidecarProvider overridableTypes map[string]bool // types declared overridable by any loaded plugin + engineVersion string // running engine version for minEngineVersion checks } // NewPluginLoader creates a new PluginLoader backed by the given capability and schema registries. @@ -70,6 +73,12 @@ func (l *PluginLoader) OverridableTypes() map[string]bool { return out } +// SetEngineVersion sets the running engine version used for minEngineVersion +// compatibility checks when loading plugins. +func (l *PluginLoader) SetEngineVersion(v string) { + l.engineVersion = v +} + // SetLicenseValidator registers a license validator used for premium tier plugins. func (l *PluginLoader) SetLicenseValidator(v LicenseValidator) { l.licenseValidator = v @@ -160,6 +169,9 @@ func (l *PluginLoader) loadPlugin(p EnginePlugin, allowOverride bool) error { return fmt.Errorf("plugin %q: %w", manifest.Name, err) } + // Warn if the engine version is older than the plugin's minimum requirement. + checkEngineCompatibility(manifest, l.engineVersion) + // Validate plugin tier before proceeding. if err := l.ValidateTier(manifest); err != nil { return err @@ -408,6 +420,29 @@ func (l *PluginLoader) SidecarProviders() map[string]deploy.SidecarProvider { return out } +// checkEngineCompatibility warns to stderr if the running engine version is +// older than the plugin's declared minEngineVersion. This is a soft check only +// (no hard failure) to allow testing newer plugins against older engines. +// Skips the check when either version is empty or engineVersion is "dev". +func checkEngineCompatibility(manifest *PluginManifest, engineVersion string) { + if manifest.MinEngineVersion == "" || engineVersion == "" || engineVersion == "dev" { + return + } + minVer, err := ParseSemver(strings.TrimPrefix(manifest.MinEngineVersion, "v")) + if err != nil { + return // malformed minEngineVersion — skip silently + } + engVer, err := ParseSemver(strings.TrimPrefix(engineVersion, "v")) + if err != nil { + return // malformed engine version — skip silently + } + if engVer.Compare(minVer) < 0 { + fmt.Fprintf(os.Stderr, //nolint:gosec // G705 + "WARNING: plugin %q requires engine >= v%s, running v%s — may cause runtime failures\n", + manifest.Name, manifest.MinEngineVersion, engineVersion) + } +} + // topoSortPlugins performs a topological sort of plugins based on manifest dependencies. // Returns an error if a circular dependency is detected. func topoSortPlugins(plugins []EnginePlugin) ([]EnginePlugin, error) { diff --git a/plugin/loader_test.go b/plugin/loader_test.go index b348e042..6e5cfe69 100644 --- a/plugin/loader_test.go +++ b/plugin/loader_test.go @@ -633,3 +633,58 @@ func (m *mockSidecarProvider) Validate(_ config.SidecarConfig) error { return ni func (m *mockSidecarProvider) Resolve(_ config.SidecarConfig, _ string) (*deploy.SidecarSpec, error) { return nil, nil } + +func TestCheckEngineCompatibility(t *testing.T) { + cases := []struct { + name string + minVersion string + engineVersion string + wantWarn bool + }{ + {"compatible", "0.3.0", "0.3.30", false}, + {"equal", "0.3.30", "0.3.30", false}, + {"incompatible", "0.4.0", "0.3.30", true}, + {"empty minVersion", "", "0.3.30", false}, + {"empty engineVersion", "0.3.0", "", false}, + {"dev engine", "0.3.0", "dev", false}, + {"malformed min", "not-a-version", "0.3.30", false}, + {"malformed engine", "0.3.0", "not-a-version", false}, + {"v-prefix stripped", "0.3.0", "v0.3.30", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + manifest := &PluginManifest{ + Name: "test-plugin", + MinEngineVersion: tc.minVersion, + } + // Just verify it doesn't panic; stderr output is checked manually + checkEngineCompatibility(manifest, tc.engineVersion) + }) + } +} + +func TestPluginLoader_WarnOnMinEngineVersion(t *testing.T) { + loader := newTestEngineLoader() + loader.SetEngineVersion("0.3.0") + + // Plugin requiring a newer engine version than what's running + p := makeEnginePlugin("min-version-plugin", "1.0.0", nil) + p.Manifest.MinEngineVersion = "0.4.0" + + // Should load successfully (warning only, not hard fail) + if err := loader.LoadPlugin(p); err != nil { + t.Fatalf("LoadPlugin should succeed despite minEngineVersion warning, got: %v", err) + } +} + +func TestPluginLoader_NoWarnOnCompatibleEngineVersion(t *testing.T) { + loader := newTestEngineLoader() + loader.SetEngineVersion("1.0.0") + + p := makeEnginePlugin("compat-plugin", "1.0.0", nil) + p.Manifest.MinEngineVersion = "0.3.0" + + if err := loader.LoadPlugin(p); err != nil { + t.Fatalf("LoadPlugin failed: %v", err) + } +} diff --git a/plugin/manifest.go b/plugin/manifest.go index fec24157..3b64a679 100644 --- a/plugin/manifest.go +++ b/plugin/manifest.go @@ -65,6 +65,10 @@ type PluginManifest struct { // Config mutability and sample plugin support ConfigMutable bool `json:"configMutable,omitempty" yaml:"configMutable,omitempty"` SampleCategory string `json:"sampleCategory,omitempty" yaml:"sampleCategory,omitempty"` + + // MinEngineVersion declares the minimum engine version required to run this plugin. + // A semver string without the "v" prefix, e.g. "0.3.30". + MinEngineVersion string `json:"minEngineVersion,omitempty" yaml:"minEngineVersion,omitempty"` } // CapabilityDecl declares a capability relationship for a plugin in the manifest. From dada6bcb779714ac86a19eac05b172eef738b502 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:37:00 -0400 Subject: [PATCH 20/26] feat: add GitHub URL install support to plugin install Add parseGitHubRef() to detect owner/repo[@version] references. Add installFromGitHub() to query GitHub Releases API and download assets matching {repo}_{os}_{arch}.tar.gz. In runPluginInstall, when the registry lookup fails and the input looks like a GitHub ref, fall back to direct GitHub installation. This allows: wfctl plugin install GoCodeAlone/workflow-plugin-authz@v0.3.1 Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/github_install.go | 92 ++++++++++++++++++++++++++++++++ cmd/wfctl/plugin_install.go | 24 +++++++-- cmd/wfctl/plugin_install_test.go | 27 ++++++++++ 3 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 cmd/wfctl/github_install.go diff --git a/cmd/wfctl/github_install.go b/cmd/wfctl/github_install.go new file mode 100644 index 00000000..d3977c77 --- /dev/null +++ b/cmd/wfctl/github_install.go @@ -0,0 +1,92 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "runtime" + "strings" +) + +// parseGitHubRef parses a plugin reference that may be a GitHub owner/repo[@version] path. +// Returns (owner, repo, version, isGitHub). +// "GoCodeAlone/workflow-plugin-authz@v0.3.1" → ("GoCodeAlone","workflow-plugin-authz","v0.3.1",true) +// "GoCodeAlone/workflow-plugin-authz" → ("GoCodeAlone","workflow-plugin-authz","",true) +// "authz" → ("","","",false) +func parseGitHubRef(input string) (owner, repo, version string, isGitHub bool) { + // Must contain "/" to be a GitHub ref. + if !strings.Contains(input, "/") { + return "", "", "", false + } + + ownerRepo := input + if atIdx := strings.Index(input, "@"); atIdx > 0 { + version = input[atIdx+1:] + ownerRepo = input[:atIdx] + } + + parts := strings.SplitN(ownerRepo, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", "", false + } + return parts[0], parts[1], version, true +} + +// ghRelease is a minimal subset of the GitHub Releases API response. +type ghRelease struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` +} + +// installFromGitHub downloads and extracts a plugin directly from a GitHub Release. +// owner/repo@version is resolved to a tarball asset matching {repo}_{os}_{arch}.tar.gz. +func installFromGitHub(owner, repo, version, destDir string) error { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, version) + if version == "" || version == "latest" { + apiURL = fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) + } + + fmt.Fprintf(os.Stderr, "Fetching GitHub release from %s/%s@%s...\n", owner, repo, version) + body, err := downloadURL(apiURL) + if err != nil { + return fmt.Errorf("fetch GitHub release: %w", err) + } + + var rel ghRelease + if err := json.Unmarshal(body, &rel); err != nil { + return fmt.Errorf("parse GitHub release response: %w", err) + } + + // Find asset matching {repo}_{os}_{arch}.tar.gz + wantSuffix := fmt.Sprintf("%s_%s_%s.tar.gz", repo, runtime.GOOS, runtime.GOARCH) + var assetURL string + for _, a := range rel.Assets { + if strings.EqualFold(a.Name, wantSuffix) { + assetURL = a.BrowserDownloadURL + break + } + } + if assetURL == "" { + return fmt.Errorf("no asset matching %q found in release %s for %s/%s", wantSuffix, rel.TagName, owner, repo) + } + + fmt.Fprintf(os.Stderr, "Downloading %s...\n", assetURL) + data, err := downloadURL(assetURL) + if err != nil { + return fmt.Errorf("download plugin from GitHub: %w", err) + } + + if err := os.MkdirAll(destDir, 0750); err != nil { + return fmt.Errorf("create plugin dir %s: %w", destDir, err) + } + + fmt.Fprintf(os.Stderr, "Extracting to %s...\n", destDir) + if err := extractTarGz(data, destDir); err != nil { + return fmt.Errorf("extract plugin: %w", err) + } + + return nil +} diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 3034aa0b..05b07f44 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -111,10 +111,27 @@ func runPluginInstall(args []string) error { } fmt.Fprintf(os.Stderr, "Fetching manifest for %q...\n", pluginName) - manifest, sourceName, err := mr.FetchManifest(pluginName) - if err != nil { - return err + manifest, sourceName, registryErr := mr.FetchManifest(nameArg) + + destDir := filepath.Join(*dataDir, pluginName) + + if registryErr != nil { + // Registry lookup failed. Try GitHub direct install if input looks like owner/repo[@version]. + ghOwner, ghRepo, ghVersion, isGH := parseGitHubRef(nameArg) + if !isGH { + return registryErr + } + if err := installFromGitHub(ghOwner, ghRepo, ghVersion, destDir); err != nil { + return fmt.Errorf("registry: %w; github: %w", registryErr, err) + } + pluginName = normalizePluginName(ghRepo) + if err := ensurePluginBinary(destDir, pluginName); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not normalize binary name: %v\n", err) + } + fmt.Printf("Installed %s to %s\n", nameArg, destDir) + return nil } + fmt.Fprintf(os.Stderr, "Found in registry %q.\n", sourceName) dl, err := manifest.FindDownload(runtime.GOOS, runtime.GOARCH) @@ -122,7 +139,6 @@ func runPluginInstall(args []string) error { return err } - destDir := filepath.Join(*dataDir, pluginName) if err := os.MkdirAll(destDir, 0750); err != nil { return fmt.Errorf("create plugin dir %s: %w", destDir, err) } diff --git a/cmd/wfctl/plugin_install_test.go b/cmd/wfctl/plugin_install_test.go index 1175d806..4ea0d1fa 100644 --- a/cmd/wfctl/plugin_install_test.go +++ b/cmd/wfctl/plugin_install_test.go @@ -27,6 +27,33 @@ func TestPluginListAcceptsPluginDirFlag(t *testing.T) { } } +// TestParseGitHubPluginRef verifies that parseGitHubRef correctly identifies GitHub refs. +func TestParseGitHubPluginRef(t *testing.T) { + tests := []struct { + input string + owner string + repo string + version string + isGH bool + }{ + {"GoCodeAlone/workflow-plugin-authz@v0.3.1", "GoCodeAlone", "workflow-plugin-authz", "v0.3.1", true}, + {"GoCodeAlone/workflow-plugin-authz", "GoCodeAlone", "workflow-plugin-authz", "", true}, + {"authz", "", "", "", false}, + {"workflow-plugin-authz", "", "", "", false}, + {"owner/repo@v1.0.0", "owner", "repo", "v1.0.0", true}, + } + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + owner, repo, version, isGH := parseGitHubRef(tc.input) + if owner != tc.owner || repo != tc.repo || version != tc.version || isGH != tc.isGH { + t.Errorf("parseGitHubRef(%q) = (%q, %q, %q, %v), want (%q, %q, %q, %v)", + tc.input, owner, repo, version, isGH, + tc.owner, tc.repo, tc.version, tc.isGH) + } + }) + } +} + // TestPluginListAcceptsLegacyDataDirFlag verifies that the deprecated -data-dir flag // still works as an alias for -plugin-dir. func TestPluginListAcceptsLegacyDataDirFlag(t *testing.T) { From 1ab96160f24a41598aa2fec610dea89cdb83e821 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:39:01 -0400 Subject: [PATCH 21/26] feat: plugin lockfile support via .wfctl.yaml plugins section Add PluginLockfile/PluginLockEntry types with load/save that preserve all other .wfctl.yaml fields (project, git, deploy) on round-trip. Wire into plugin install: - `wfctl plugin install` (no args): reads .wfctl.yaml plugins section and installs all pinned entries - `wfctl plugin install @`: after successful install, updates/creates the plugins entry in .wfctl.yaml Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/plugin_install.go | 11 ++- cmd/wfctl/plugin_lockfile.go | 128 +++++++++++++++++++++++++++++ cmd/wfctl/plugin_lockfile_test.go | 131 ++++++++++++++++++++++++++++++ 3 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 cmd/wfctl/plugin_lockfile.go create mode 100644 cmd/wfctl/plugin_lockfile_test.go diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 05b07f44..119abf5f 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -79,9 +79,10 @@ func runPluginInstall(args []string) error { return err } dataDir := &pluginDirVal + + // No args: install all plugins from .wfctl.yaml lockfile. if fs.NArg() < 1 { - fs.Usage() - return fmt.Errorf("plugin name is required") + return installFromLockfile(*dataDir, *cfgPath) } nameArg := fs.Arg(0) @@ -182,6 +183,12 @@ func runPluginInstall(args []string) error { } fmt.Printf("Installed %s v%s to %s\n", manifest.Name, manifest.Version, destDir) + + // Update .wfctl.yaml lockfile if name@version was provided. + if _, ver := parseNameVersion(nameArg); ver != "" { + updateLockfile(manifest.Name, manifest.Version, manifest.Repository) + } + return nil } diff --git a/cmd/wfctl/plugin_lockfile.go b/cmd/wfctl/plugin_lockfile.go new file mode 100644 index 00000000..84f8eee8 --- /dev/null +++ b/cmd/wfctl/plugin_lockfile.go @@ -0,0 +1,128 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +const wfctlYAMLPath = ".wfctl.yaml" + +// PluginLockEntry records a pinned plugin version in the lockfile. +type PluginLockEntry struct { + Version string `yaml:"version"` + Repository string `yaml:"repository,omitempty"` + SHA256 string `yaml:"sha256,omitempty"` +} + +// PluginLockfile represents the plugins section of .wfctl.yaml. +// It preserves all other keys in the file for safe round-trip writes. +type PluginLockfile struct { + Plugins map[string]PluginLockEntry + raw map[string]any // preserved for round-trip writes +} + +// loadPluginLockfile reads path and returns the plugins section. +// If the file does not exist, an empty lockfile is returned without error. +func loadPluginLockfile(path string) (*PluginLockfile, error) { + lf := &PluginLockfile{ + Plugins: make(map[string]PluginLockEntry), + raw: make(map[string]any), + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return lf, nil + } + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + if err := yaml.Unmarshal(data, &lf.raw); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + // Extract and parse the plugins section if present. + if pluginsRaw, ok := lf.raw["plugins"]; ok && pluginsRaw != nil { + pluginsData, err := yaml.Marshal(pluginsRaw) + if err != nil { + return nil, fmt.Errorf("re-marshal plugins section: %w", err) + } + if err := yaml.Unmarshal(pluginsData, &lf.Plugins); err != nil { + return nil, fmt.Errorf("parse plugins section: %w", err) + } + } + return lf, nil +} + +// installFromLockfile reads .wfctl.yaml and installs all plugins in the +// plugins section. If no lockfile is found, it prints a helpful message. +func installFromLockfile(pluginDir, cfgPath string) error { + lf, err := loadPluginLockfile(wfctlYAMLPath) + if err != nil { + return fmt.Errorf("load lockfile: %w", err) + } + if len(lf.Plugins) == 0 { + fmt.Println("No plugins pinned in .wfctl.yaml.") + fmt.Println("Run 'wfctl plugin install @' to install and pin a plugin.") + return nil + } + var failed []string + for name, entry := range lf.Plugins { + nameArg := name + "@" + strings.TrimPrefix(entry.Version, "v") + fmt.Fprintf(os.Stderr, "Installing %s %s...\n", name, entry.Version) + installArgs := []string{"--plugin-dir", pluginDir} + if cfgPath != "" { + installArgs = append(installArgs, "--config", cfgPath) + } + installArgs = append(installArgs, nameArg) + if err := runPluginInstall(installArgs); err != nil { + fmt.Fprintf(os.Stderr, "error installing %s: %v\n", name, err) + failed = append(failed, name) + } + } + if len(failed) > 0 { + return fmt.Errorf("failed to install: %s", strings.Join(failed, ", ")) + } + return nil +} + +// updateLockfile adds or updates a plugin entry in .wfctl.yaml. +// Silently no-ops if the lockfile cannot be read or written (install still succeeds). +func updateLockfile(pluginName, version, repository string) { + lf, err := loadPluginLockfile(wfctlYAMLPath) + if err != nil { + return + } + if lf.Plugins == nil { + lf.Plugins = make(map[string]PluginLockEntry) + } + lf.Plugins[pluginName] = PluginLockEntry{ + Version: version, + Repository: repository, + } + _ = lf.Save(wfctlYAMLPath) +} + +// Save writes the lockfile back to path, updating the plugins section while +// preserving all other fields (project, git, deploy, etc.). +func (lf *PluginLockfile) Save(path string) error { + if lf.raw == nil { + lf.raw = make(map[string]any) + } + // Re-encode the typed plugins map into a yaml-compatible representation. + pluginsData, err := yaml.Marshal(lf.Plugins) + if err != nil { + return fmt.Errorf("marshal plugins: %w", err) + } + var pluginsRaw any + if err := yaml.Unmarshal(pluginsData, &pluginsRaw); err != nil { + return fmt.Errorf("re-unmarshal plugins: %w", err) + } + lf.raw["plugins"] = pluginsRaw + + data, err := yaml.Marshal(lf.raw) + if err != nil { + return fmt.Errorf("marshal lockfile: %w", err) + } + return os.WriteFile(path, data, 0600) //nolint:gosec // G306: .wfctl.yaml is user-owned project config +} diff --git a/cmd/wfctl/plugin_lockfile_test.go b/cmd/wfctl/plugin_lockfile_test.go new file mode 100644 index 00000000..444c18f4 --- /dev/null +++ b/cmd/wfctl/plugin_lockfile_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +const twoPluginLockfile = `project: + name: my-project + version: "1.0.0" +git: + repository: GoCodeAlone/my-project +plugins: + authz: + version: v0.3.1 + repository: GoCodeAlone/workflow-plugin-authz + sha256: abc123deadbeef + payments: + version: v0.1.0 + repository: GoCodeAlone/workflow-plugin-payments +` + +func TestLoadPluginLockfile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".wfctl.yaml") + if err := os.WriteFile(path, []byte(twoPluginLockfile), 0600); err != nil { + t.Fatal(err) + } + + lf, err := loadPluginLockfile(path) + if err != nil { + t.Fatalf("loadPluginLockfile: %v", err) + } + if len(lf.Plugins) != 2 { + t.Fatalf("want 2 plugins, got %d", len(lf.Plugins)) + } + + authz, ok := lf.Plugins["authz"] + if !ok { + t.Fatal("expected 'authz' plugin entry") + } + if authz.Version != "v0.3.1" { + t.Errorf("authz.Version = %q, want v0.3.1", authz.Version) + } + if authz.Repository != "GoCodeAlone/workflow-plugin-authz" { + t.Errorf("authz.Repository = %q, want GoCodeAlone/workflow-plugin-authz", authz.Repository) + } + if authz.SHA256 != "abc123deadbeef" { + t.Errorf("authz.SHA256 = %q, want abc123deadbeef", authz.SHA256) + } + + payments, ok := lf.Plugins["payments"] + if !ok { + t.Fatal("expected 'payments' plugin entry") + } + if payments.Version != "v0.1.0" { + t.Errorf("payments.Version = %q, want v0.1.0", payments.Version) + } +} + +func TestLoadPluginLockfile_Missing(t *testing.T) { + lf, err := loadPluginLockfile("/nonexistent/.wfctl.yaml") + if err != nil { + t.Fatalf("expected no error for missing file, got: %v", err) + } + if len(lf.Plugins) != 0 { + t.Errorf("expected empty plugins for missing file, got %v", lf.Plugins) + } +} + +func TestLoadPluginLockfile_NoPluginsSection(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".wfctl.yaml") + content := "project:\n name: my-project\ngit:\n repository: GoCodeAlone/my-project\n" + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + + lf, err := loadPluginLockfile(path) + if err != nil { + t.Fatalf("loadPluginLockfile: %v", err) + } + if len(lf.Plugins) != 0 { + t.Errorf("expected empty plugins map, got %v", lf.Plugins) + } +} + +func TestPluginLockfile_Save_RoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".wfctl.yaml") + + // Write initial file with non-plugin sections + initial := "project:\n name: my-project\ngit:\n repository: GoCodeAlone/my-project\n" + if err := os.WriteFile(path, []byte(initial), 0600); err != nil { + t.Fatal(err) + } + + // Load, add plugin, save + lf, err := loadPluginLockfile(path) + if err != nil { + t.Fatalf("loadPluginLockfile: %v", err) + } + lf.Plugins["authz"] = PluginLockEntry{ + Version: "v0.3.1", + Repository: "GoCodeAlone/workflow-plugin-authz", + } + if err := lf.Save(path); err != nil { + t.Fatalf("Save: %v", err) + } + + // Reload and verify + lf2, err := loadPluginLockfile(path) + if err != nil { + t.Fatalf("reload: %v", err) + } + if len(lf2.Plugins) != 1 { + t.Fatalf("want 1 plugin after reload, got %d", len(lf2.Plugins)) + } + authz := lf2.Plugins["authz"] + if authz.Version != "v0.3.1" { + t.Errorf("authz.Version = %q, want v0.3.1", authz.Version) + } + // Verify that the non-plugin fields are preserved + if lf2.raw["project"] == nil { + t.Error("expected 'project' field to be preserved after save") + } + if lf2.raw["git"] == nil { + t.Error("expected 'git' field to be preserved after save") + } +} From 77ae86f4a2bfac040dd48c1927ee25cd98500606 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:43:17 -0400 Subject: [PATCH 22/26] docs: add plugin goreleaser reference config Reference .goreleaser.yml for plugin repos documenting required conventions: - binary using {{ .ProjectName }} template - archives include plugin.json - before/after hooks to template plugin.json version from release tag Co-Authored-By: Claude Sonnet 4.6 --- docs/plugin-goreleaser-reference.yml | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/plugin-goreleaser-reference.yml diff --git a/docs/plugin-goreleaser-reference.yml b/docs/plugin-goreleaser-reference.yml new file mode 100644 index 00000000..e19e81fe --- /dev/null +++ b/docs/plugin-goreleaser-reference.yml @@ -0,0 +1,44 @@ +version: 2 + +# Reference .goreleaser.yml for GoCodeAlone workflow plugin repositories. +# Copy this to your plugin repo as .goreleaser.yml and adjust as needed. +# +# Key requirements: +# 1. binary uses {{ .ProjectName }} so no hardcoded names +# 2. archives always include plugin.json +# 3. before/after hooks template plugin.json version from the release tag + +before: + hooks: + - "cp plugin.json plugin.json.orig" + - "sed -i.bak 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' plugin.json && rm -f plugin.json.bak" + +after: + hooks: + - "mv plugin.json.orig plugin.json" + +builds: + - main: ./cmd/{{ .ProjectName }} + binary: "{{ .ProjectName }}" + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X main.version={{.Version}} + +archives: + - formats: [tar.gz] + name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" + files: + - plugin.json + +checksum: + name_template: checksums.txt + +changelog: + sort: asc From f480d38e9ba12118fcbdd28c1cf68288b8aa16b1 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:52:42 -0400 Subject: [PATCH 23/26] fix: correct FetchManifest arg and destDir for GitHub installs Two bugs in Task 16 GitHub install fallback: 1. Pass pluginName (version-stripped) to FetchManifest, not nameArg which may carry @version suffix and cause lookup failures. 2. Compute destDir after normalizing the repo short name so owner/repo installs go to data/plugins/ not the raw input. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/plugin_install.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 119abf5f..c47e7696 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -112,9 +112,7 @@ func runPluginInstall(args []string) error { } fmt.Fprintf(os.Stderr, "Fetching manifest for %q...\n", pluginName) - manifest, sourceName, registryErr := mr.FetchManifest(nameArg) - - destDir := filepath.Join(*dataDir, pluginName) + manifest, sourceName, registryErr := mr.FetchManifest(pluginName) if registryErr != nil { // Registry lookup failed. Try GitHub direct install if input looks like owner/repo[@version]. @@ -122,10 +120,11 @@ func runPluginInstall(args []string) error { if !isGH { return registryErr } + pluginName = normalizePluginName(ghRepo) + destDir := filepath.Join(*dataDir, pluginName) if err := installFromGitHub(ghOwner, ghRepo, ghVersion, destDir); err != nil { return fmt.Errorf("registry: %w; github: %w", registryErr, err) } - pluginName = normalizePluginName(ghRepo) if err := ensurePluginBinary(destDir, pluginName); err != nil { fmt.Fprintf(os.Stderr, "warning: could not normalize binary name: %v\n", err) } @@ -133,6 +132,8 @@ func runPluginInstall(args []string) error { return nil } + destDir := filepath.Join(*dataDir, pluginName) + fmt.Fprintf(os.Stderr, "Found in registry %q.\n", sourceName) dl, err := manifest.FindDownload(runtime.GOOS, runtime.GOARCH) From 4598bc1a71d8a0b672485a090587badf736b4ffa Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 20:53:28 -0400 Subject: [PATCH 24/26] fix: lockfile install doesn't re-pin; engine compat uses slog Task 17: installFromLockfile now passes just the plugin name (no @version) to runPluginInstall so updateLockfile doesn't fire and overwrite the pinned entry in .wfctl.yaml during a lockfile-driven restore. Task 18: checkEngineCompatibility uses slog.Warn instead of fmt.Fprintf to stderr, consistent with the rest of the codebase. Remove unused "os" import from loader.go. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/plugin_lockfile.go | 5 +++-- plugin/loader.go | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/cmd/wfctl/plugin_lockfile.go b/cmd/wfctl/plugin_lockfile.go index 84f8eee8..64e19dc4 100644 --- a/cmd/wfctl/plugin_lockfile.go +++ b/cmd/wfctl/plugin_lockfile.go @@ -68,13 +68,14 @@ func installFromLockfile(pluginDir, cfgPath string) error { } var failed []string for name, entry := range lf.Plugins { - nameArg := name + "@" + strings.TrimPrefix(entry.Version, "v") fmt.Fprintf(os.Stderr, "Installing %s %s...\n", name, entry.Version) installArgs := []string{"--plugin-dir", pluginDir} if cfgPath != "" { installArgs = append(installArgs, "--config", cfgPath) } - installArgs = append(installArgs, nameArg) + // Pass just the name (no @version) so runPluginInstall does not + // call updateLockfile and inadvertently overwrite the pinned entry. + installArgs = append(installArgs, name) if err := runPluginInstall(installArgs); err != nil { fmt.Fprintf(os.Stderr, "error installing %s: %v\n", name, err) failed = append(failed, name) diff --git a/plugin/loader.go b/plugin/loader.go index dd68da64..d66bc26f 100644 --- a/plugin/loader.go +++ b/plugin/loader.go @@ -3,7 +3,6 @@ package plugin import ( "fmt" "log/slog" - "os" "reflect" "sort" "strings" @@ -437,9 +436,10 @@ func checkEngineCompatibility(manifest *PluginManifest, engineVersion string) { return // malformed engine version — skip silently } if engVer.Compare(minVer) < 0 { - fmt.Fprintf(os.Stderr, //nolint:gosec // G705 - "WARNING: plugin %q requires engine >= v%s, running v%s — may cause runtime failures\n", - manifest.Name, manifest.MinEngineVersion, engineVersion) + slog.Warn("plugin requires newer engine", + "plugin", manifest.Name, + "minVersion", manifest.MinEngineVersion, + "engineVersion", engineVersion) } } From 6fb3b4f26e6595ae937814d9d84985ea9ad5fdf2 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 21:22:22 -0400 Subject: [PATCH 25/26] fix: use published @gocodealone/workflow-editor from GitHub Packages Replace local file path reference with ^0.2.0 from GitHub Packages registry. Fixes CI failures where the local tarball doesn't exist. Co-Authored-By: Claude Opus 4.6 --- ui/package-lock.json | 8 ++++---- ui/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index a4249584..4388c44f 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,7 +8,7 @@ "name": "ui", "version": "0.0.0", "dependencies": { - "@gocodealone/workflow-editor": "file:../../workflow-editor/gocodealone-workflow-editor-0.1.0.tgz", + "@gocodealone/workflow-editor": "^0.2.0", "@gocodealone/workflow-ui": "^0.2.0", "@types/dagre": "^0.7.53", "@xyflow/react": "^12.10.0", @@ -1169,9 +1169,9 @@ } }, "node_modules/@gocodealone/workflow-editor": { - "version": "0.1.0", - "resolved": "file:../../workflow-editor/gocodealone-workflow-editor-0.1.0.tgz", - "integrity": "sha512-Dy9FFSE8khKjmo5ciotYpIIohnY7stPvAOYU55Nye2mkgpfAzdGw8wuy9IqR2jbHDd9hAXmQcq4eDioKAeN+iw==", + "version": "0.2.0", + "resolved": "https://npm.pkg.github.com/download/@gocodealone/workflow-editor/0.2.0/6a0407820cb9825cd7db3498d75420168078907c", + "integrity": "sha512-sHjf/y3foZmReMVr7P1XMJIgSTIuoszYVW/dQqkLQIVB4PloRhcTLeYEYyLDCzpTEEGBGAaq4GA4cIR9pshiLg==", "license": "Apache-2.0", "dependencies": { "dagre": "^0.8.5", diff --git a/ui/package.json b/ui/package.json index 29364583..23fedaf9 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,7 +14,7 @@ "test:e2e:execution": "npx playwright test --config playwright-e2e.config.ts" }, "dependencies": { - "@gocodealone/workflow-editor": "file:../../workflow-editor/gocodealone-workflow-editor-0.1.0.tgz", + "@gocodealone/workflow-editor": "^0.2.0", "@gocodealone/workflow-ui": "^0.2.0", "@types/dagre": "^0.7.53", "@xyflow/react": "^12.10.0", From bc14b215f4b8a8d98129340d66dd18485950447a Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 12 Mar 2026 21:31:03 -0400 Subject: [PATCH 26/26] fix: remove trailing punctuation from infra error string (ST1005) Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/infra.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 2ac7c8c8..2614b312 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -65,7 +65,7 @@ func resolveInfraConfig(fs *flag.FlagSet) (string, error) { return arg, nil } } - return "", fmt.Errorf("no infrastructure config found (tried infra.yaml, config/infra.yaml).\nCreate an infra config with cloud.account and platform.* modules.\nRun 'wfctl init --template full-stack' for a starter config with infrastructure.") + return "", fmt.Errorf("no infrastructure config found (tried infra.yaml, config/infra.yaml)\n\nCreate an infra config with cloud.account and platform.* modules.\nRun 'wfctl init --template full-stack' for a starter config with infrastructure") } // infraModuleEntry is a minimal struct for parsing modules from YAML.