From 89346bdac2c7cf27332bb8aa5080e2be1a3c389e Mon Sep 17 00:00:00 2001 From: Alexandre Balmes Date: Thu, 25 Jun 2026 19:32:36 +0200 Subject: [PATCH] feat(plugin): add `plugin init` command and developer kit (F111) - `.specify/implementation/F107/tasks/index.json`: Remove completed F107 task index - `.zpm/kb/pr_feature_f111_plugin_developer_kit_and_onboarding/journal.lock`: Add ZPM knowledge base journal lock - `.zpm/kb/pr_feature_f111_plugin_developer_kit_and_onboarding/journal.wal`: Add ZPM knowledge base WAL - `.zpm/kb/pr_feature_f111_plugin_developer_kit_and_onboarding/knowledge.pl`: Add ZPM Prolog knowledge facts for F111 - `.zpm/mounts.json`: Register F111 knowledge base mount - `README.md`: Update with plugin init command reference - `docs/README.md`: Link to new first-plugin guide - `docs/getting-started/first-plugin.md`: Add step-by-step first plugin tutorial - `docs/user-guide/commands.md`: Document plugin init command and flags - `docs/user-guide/plugins.md`: Revise plugin authoring and onboarding guidance - `examples/plugins/awf-plugin-echo/Makefile`: Update build targets for echo plugin - `examples/plugins/awf-plugin-echo/examples/demo.yaml`: Add demo workflow for echo plugin - `examples/plugins/awf-plugin-echo/main.go`: Update echo plugin implementation - `examples/plugins/awf-plugin-echo/main_test.go`: Revise echo plugin test suite - `go.mod`: Update module dependencies - `internal/interfaces/cli/config.go`: Wire plugin init command into CLI - `internal/interfaces/cli/pack_resolver.go`: Support local plugin path resolution - `internal/interfaces/cli/pack_resolver_test.go`: Add local path resolver tests - `internal/interfaces/cli/plugin_cmd.go`: Add init subcommand to plugin command group - `internal/interfaces/cli/plugin_cmd_test.go`: Expand plugin command test coverage - `internal/interfaces/cli/plugin_init_cmd.go`: Implement plugin init cobra command - `internal/interfaces/cli/plugin_init_docs_test.go`: Add docs generation tests for plugin init - `internal/interfaces/cli/plugin_init_generate.go`: Implement scaffold file generation logic - `internal/interfaces/cli/plugin_init_generate_test.go`: Add generation unit tests - `internal/interfaces/cli/plugin_init_options.go`: Define and validate plugin init options - `internal/interfaces/cli/plugin_init_options_test.go`: Add options validation tests - `internal/interfaces/cli/plugin_init_template.go`: Implement template rendering engine - `internal/interfaces/cli/plugin_init_template_internal_test.go`: Add internal template tests - `internal/interfaces/cli/plugin_init_template_test.go`: Add template rendering tests - `internal/interfaces/cli/plugin_init_templates.go`: Define Go, Python, and shell plugin templates - `internal/interfaces/cli/run.go`: Register plugin init in command runner - `tests/integration/cli/plugin_init_release_test.go`: Add release artifact integration tests - `tests/integration/cli/plugin_init_test.go`: Add end-to-end plugin init integration tests Closes #380 --- .specify/implementation/F107/tasks/index.json | 295 ----------- .../journal.lock | 0 .../journal.wal | 0 .../knowledge.pl | 76 +++ .zpm/mounts.json | 8 +- README.md | 13 +- docs/README.md | 3 +- docs/getting-started/first-plugin.md | 132 +++++ docs/user-guide/commands.md | 94 +++- docs/user-guide/plugins.md | 110 ++-- examples/plugins/awf-plugin-echo/Makefile | 41 +- .../awf-plugin-echo/examples/demo.yaml | 22 + examples/plugins/awf-plugin-echo/main.go | 31 +- examples/plugins/awf-plugin-echo/main_test.go | 397 +++++++-------- go.mod | 2 +- internal/interfaces/cli/config.go | 11 + internal/interfaces/cli/pack_resolver.go | 10 + internal/interfaces/cli/pack_resolver_test.go | 11 + internal/interfaces/cli/plugin_cmd.go | 19 +- internal/interfaces/cli/plugin_cmd_test.go | 276 +++++++++- internal/interfaces/cli/plugin_init_cmd.go | 40 ++ .../interfaces/cli/plugin_init_docs_test.go | 194 +++++++ .../interfaces/cli/plugin_init_generate.go | 199 ++++++++ .../cli/plugin_init_generate_test.go | 281 +++++++++++ .../interfaces/cli/plugin_init_options.go | 178 +++++++ .../cli/plugin_init_options_test.go | 172 +++++++ .../interfaces/cli/plugin_init_template.go | 173 +++++++ .../cli/plugin_init_template_internal_test.go | 38 ++ .../cli/plugin_init_template_test.go | 292 +++++++++++ .../interfaces/cli/plugin_init_templates.go | 475 ++++++++++++++++++ internal/interfaces/cli/run.go | 8 +- .../cli/plugin_init_release_test.go | 263 ++++++++++ tests/integration/cli/plugin_init_test.go | 179 +++++++ 33 files changed, 3420 insertions(+), 623 deletions(-) delete mode 100644 .specify/implementation/F107/tasks/index.json create mode 100644 .zpm/kb/pr_feature_f111_plugin_developer_kit_and_onboarding/journal.lock create mode 100644 .zpm/kb/pr_feature_f111_plugin_developer_kit_and_onboarding/journal.wal create mode 100644 .zpm/kb/pr_feature_f111_plugin_developer_kit_and_onboarding/knowledge.pl create mode 100644 docs/getting-started/first-plugin.md create mode 100644 examples/plugins/awf-plugin-echo/examples/demo.yaml create mode 100644 internal/interfaces/cli/plugin_init_cmd.go create mode 100644 internal/interfaces/cli/plugin_init_docs_test.go create mode 100644 internal/interfaces/cli/plugin_init_generate.go create mode 100644 internal/interfaces/cli/plugin_init_generate_test.go create mode 100644 internal/interfaces/cli/plugin_init_options.go create mode 100644 internal/interfaces/cli/plugin_init_options_test.go create mode 100644 internal/interfaces/cli/plugin_init_template.go create mode 100644 internal/interfaces/cli/plugin_init_template_internal_test.go create mode 100644 internal/interfaces/cli/plugin_init_template_test.go create mode 100644 internal/interfaces/cli/plugin_init_templates.go create mode 100644 tests/integration/cli/plugin_init_release_test.go create mode 100644 tests/integration/cli/plugin_init_test.go diff --git a/.specify/implementation/F107/tasks/index.json b/.specify/implementation/F107/tasks/index.json deleted file mode 100644 index 20df2f21..00000000 --- a/.specify/implementation/F107/tasks/index.json +++ /dev/null @@ -1,295 +0,0 @@ -[ - { - "id": "T053", - "type": "code", - "size": "M", - "parallel": false, - "depends_on": [], - "user_story": "US1", - "status": "completed", - "files": [ - "internal/domain/ports/facade.go", - "internal/domain/ports/facade_event.go", - "internal/domain/ports/facade_dto.go", - "internal/domain/ports/facade_contract_test.go" - ], - "tests": [ - "internal/domain/ports/facade_contract_test.go" - ], - "file": "tasks/T053.md" - }, - { - "id": "T054", - "type": "code", - "size": "M", - "parallel": true, - "depends_on": [ - "T053", - "T055" - ], - "user_story": "US3", - "status": "completed", - "files": [ - "internal/application/resolver.go", - "internal/application/resolver_wire.go", - "internal/application/resolver_test.go", - "internal/application/resolver_wire_test.go" - ], - "tests": [ - "internal/application/resolver_test.go", - "internal/application/resolver_wire_test.go" - ], - "file": "tasks/T054.md" - }, - { - "id": "T055", - "type": "code", - "size": "S", - "parallel": true, - "depends_on": [ - "T053" - ], - "user_story": "US1", - "status": "completed", - "files": [ - "internal/domain/errors/codes.go", - "internal/application/error_codes.go", - "internal/application/error_codes_test.go" - ], - "tests": [ - "internal/application/error_codes_test.go" - ], - "file": "tasks/T055.md" - }, - { - "id": "T056", - "type": "code", - "size": "S", - "parallel": true, - "depends_on": [ - "T053" - ], - "user_story": "US1", - "status": "completed", - "files": [ - "internal/application/facade_projection.go", - "internal/application/facade_projection_test.go" - ], - "tests": [ - "internal/application/facade_projection_test.go" - ], - "file": "tasks/T056.md" - }, - { - "id": "T057", - "type": "code", - "size": "M", - "parallel": false, - "depends_on": [ - "T053", - "T055" - ], - "user_story": "US1", - "status": "completed", - "files": [ - "internal/application/session_registry.go", - "internal/application/session_registry_test.go", - "internal/application/run_session.go", - "internal/application/run_session_test.go", - "internal/application/drain.go", - "internal/application/drain_test.go" - ], - "tests": [ - "internal/application/session_registry_test.go", - "internal/application/run_session_test.go", - "internal/application/drain_test.go" - ], - "file": "tasks/T057.md" - }, - { - "id": "T058", - "type": "code", - "size": "L", - "parallel": false, - "depends_on": [ - "T053", - "T054", - "T055", - "T056", - "T057" - ], - "user_story": "US1", - "status": "completed", - "files": [ - "internal/application/facade_adapter.go", - "internal/application/facade_adapter_test.go" - ], - "tests": [ - "internal/application/facade_adapter_test.go" - ], - "file": "tasks/T058.md" - }, - { - "id": "T059", - "type": "code", - "size": "M", - "parallel": false, - "depends_on": [ - "T058" - ], - "user_story": "US2", - "status": "completed", - "files": [ - "internal/application/input_bridge.go", - "internal/application/input_bridge_test.go" - ], - "tests": [ - "internal/application/input_bridge_test.go" - ], - "file": "tasks/T059.md" - }, - { - "id": "T060", - "type": "code", - "size": "L", - "parallel": false, - "depends_on": [ - "T059" - ], - "user_story": "US1, US4", - "status": "completed", - "files": [ - "internal/interfaces/cli/run.go", - "internal/interfaces/cli/status.go", - "internal/interfaces/cli/resume.go", - "internal/interfaces/cli/resume_list.go", - "cmd/awf/main.go", - "internal/interfaces/cli/run_test.go", - "internal/interfaces/cli/status_test.go", - "internal/interfaces/cli/resume_list_test.go" - ], - "tests": [ - "internal/interfaces/cli/run_test.go", - "internal/interfaces/cli/status_test.go", - "internal/interfaces/cli/resume_list_test.go" - ], - "file": "tasks/T060.md" - }, - { - "id": "T061", - "type": "code", - "size": "M", - "parallel": false, - "depends_on": [ - "T060" - ], - "user_story": "US1", - "status": "completed", - "files": [ - "internal/interfaces/tui/tab_monitoring.go", - "internal/interfaces/tui/command.go", - "cmd/awf/main.go", - "internal/interfaces/tui/tab_monitoring_test.go", - ".go-arch-lint.yml" - ], - "tests": [ - "internal/interfaces/tui/tab_monitoring_test.go" - ], - "file": "tasks/T061.md" - }, - { - "id": "T062", - "type": "code", - "size": "M", - "parallel": false, - "depends_on": [ - "T061" - ], - "user_story": "US1", - "status": "completed", - "files": [ - "internal/interfaces/api/sse.go", - "internal/interfaces/api/routing.go", - "internal/interfaces/api/respond_handler.go", - "internal/interfaces/api/server.go", - "internal/interfaces/api/sse_test.go", - "internal/interfaces/api/respond_handler_test.go", - ".go-arch-lint.yml" - ], - "tests": [ - "internal/interfaces/api/sse_test.go", - "internal/interfaces/api/respond_handler_test.go" - ], - "file": "tasks/T062.md" - }, - { - "id": "T063", - "type": "code", - "size": "M", - "parallel": false, - "depends_on": [ - "T062" - ], - "user_story": "US1", - "status": "blocked", - "files": [ - "internal/application/acp_session_service.go", - "internal/infrastructure/acp/agent.go", - "internal/application/acp_session_service_test.go" - ], - "tests": [ - "internal/application/acp_session_service_test.go" - ], - "file": "tasks/T063.md" - }, - { - "id": "T064", - "type": "code", - "size": "M", - "parallel": true, - "depends_on": [ - "T053", - "T055" - ], - "user_story": "US5", - "status": "completed", - "files": [ - "internal/testutil/facadetest/facadetest.go", - "internal/testutil/facadetest/doc.go", - "internal/testutil/facadetest/facadetest_test.go" - ], - "tests": [ - "internal/testutil/facadetest/facadetest_test.go" - ], - "file": "tasks/T064.md" - }, - { - "id": "T065", - "type": "code", - "size": "L", - "parallel": false, - "depends_on": [ - "T063", - "T064" - ], - "user_story": "US5", - "status": "blocked", - "files": [ - "tests/integration/features/facade_conformance_test.go", - "tests/integration/features/facade_e2e_run_test.go", - "tests/integration/features/facade_resume_test.go", - "tests/integration/features/agent_uniformity_test.go", - "tests/fixtures/facade/cli-stdout.golden", - "tests/fixtures/facade/acp-session-update.golden", - "tests/fixtures/facade/sse-frames.golden", - "tests/fixtures/facade/tui-tea-msg.golden" - ], - "tests": [ - "tests/integration/features/facade_conformance_test.go", - "tests/integration/features/facade_e2e_run_test.go", - "tests/integration/features/facade_resume_test.go", - "tests/integration/features/agent_uniformity_test.go" - ], - "file": "tasks/T065.md" - } -] diff --git a/.zpm/kb/pr_feature_f111_plugin_developer_kit_and_onboarding/journal.lock b/.zpm/kb/pr_feature_f111_plugin_developer_kit_and_onboarding/journal.lock new file mode 100644 index 00000000..e69de29b diff --git a/.zpm/kb/pr_feature_f111_plugin_developer_kit_and_onboarding/journal.wal b/.zpm/kb/pr_feature_f111_plugin_developer_kit_and_onboarding/journal.wal new file mode 100644 index 00000000..e69de29b diff --git a/.zpm/kb/pr_feature_f111_plugin_developer_kit_and_onboarding/knowledge.pl b/.zpm/kb/pr_feature_f111_plugin_developer_kit_and_onboarding/knowledge.pl new file mode 100644 index 00000000..18fd7b91 --- /dev/null +++ b/.zpm/kb/pr_feature_f111_plugin_developer_kit_and_onboarding/knowledge.pl @@ -0,0 +1,76 @@ +% ─── PR Tracking Schema ────────────────────────────────────────────────────── +% Memory segment: pr_ +% Lifecycle: created at implement start, gated before commit, archived on merge. +% +% Facts (asserted by scan scripts and LLM): +% pr_file(Path, ChangeType) — file in PR scope (changed | added | test) +% todo(Id, File, Line, Desc) — TODO/FIXME found in changed code +% stub(Id, File, Symbol) — stub/placeholder implementation +% mock(Id, File, Symbol) — mock that should be replaced with real impl +% not_impl(Id, File, Desc) — "not yet implemented" marker +% resolved(Type, Id) — marks a tracked issue as resolved +% pr_scanned — marker: a scan ran (gate fails closed if absent) +% task_blocked(TaskId, Reason, It) — a task the loop marked blocked, with reason + iteration +% block_detail(TaskId, File, Kind) — per-file cause of a block (e.g. todo!( in a file) +% +% Dynamic declarations (required by Trealla Prolog for runtime assertion). +:- dynamic(pr_file/2). +:- dynamic(todo/4). +:- dynamic(stub/3). +:- dynamic(mock/3). +:- dynamic(not_impl/3). +:- dynamic(resolved/2). +:- dynamic(pr_scanned/0). +:- dynamic(task_blocked/3). +:- dynamic(block_detail/3). + +% ─── Unresolved queries ───────────────────────────────────────────────────── +% Convenience predicates for querying unresolved issues by type. +unresolved_todo(Id, File, Line, Desc) :- + todo(Id, File, Line, Desc), \+ resolved(todo, Id). +unresolved_stub(Id, File, Symbol) :- + stub(Id, File, Symbol), \+ resolved(stub, Id). +unresolved_mock(Id, File, Symbol) :- + mock(Id, File, Symbol), \+ resolved(mock, Id). +unresolved_not_impl(Id, File, Desc) :- + not_impl(Id, File, Desc), \+ resolved(not_impl, Id). + +% A task the loop marked blocked and that nothing later resolved. Surfaced to +% sibling tasks (and re-runs) via inject-zpm-context.sh so they can avoid the +% same dead end (feed-forward). resolved(task, TaskId) clears it. +unresolved_block(TaskId, Reason, Iter) :- + task_blocked(TaskId, Reason, Iter), \+ resolved(task, TaskId). + +% A blocking issue is any tracked issue that has not been resolved. +blocking_issue(Id, todo, File, Desc) :- + todo(Id, File, _, Desc), \+ resolved(todo, Id). +blocking_issue(Id, stub, File, Symbol) :- + stub(Id, File, Symbol), \+ resolved(stub, Id). +blocking_issue(Id, mock, File, Symbol) :- + mock(Id, File, Symbol), \+ resolved(mock, Id). +blocking_issue(Id, not_impl, File, Desc) :- + not_impl(Id, File, Desc), \+ resolved(not_impl, Id). + +% PR is ready ONLY when a scan has run AND zero blocking issues remain. +% The pr_scanned guard prevents a vacuous pass: an empty segment (scan skipped +% or never populated) has no blocking_issue, so \+ blocking_issue alone would +% succeed and green-light an unscanned PR. Requiring pr_scanned fails closed. +pr_ready :- pr_scanned, \+ blocking_issue(_, _, _, _). + +% Health summary — counts by category. +pr_health(blocking, N) :- + findall(I, blocking_issue(I, _, _, _), L), length(L, N). +pr_health(resolved, N) :- + findall(I, resolved(_, I), L), length(L, N). +pr_health(files, N) :- + findall(F, pr_file(F, _), L), length(L, N). + +% Coverage gap: source file changed without corresponding test file. +coverage_gap(File) :- + pr_file(File, changed), + \+ pr_file(File, test), + \+ test_file(File, _). + +% List all blocking issues as Id-Type-File-Desc tuples. +all_blockers(Blockers) :- + findall(blocker(Id, Type, File, Desc), blocking_issue(Id, Type, File, Desc), Blockers). diff --git a/.zpm/mounts.json b/.zpm/mounts.json index 38431365..8fac1186 100644 --- a/.zpm/mounts.json +++ b/.zpm/mounts.json @@ -12,6 +12,12 @@ "path": ".zpm/kb/feedback", "scope": "project", "mode": "rw" + }, + { + "name": "pr_feature_f111_plugin_developer_kit_and_onboarding", + "path": ".zpm/kb/pr_feature_f111_plugin_developer_kit_and_onboarding", + "scope": "project", + "mode": "rw" } ] -} +} \ No newline at end of file diff --git a/README.md b/README.md index 6a8ab7e4..97140470 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ A Go CLI tool for orchestrating AI agents (Claude, Gemini, Codex, GitHub Copilot - **Audit Trail** - Structured JSONL audit log with paired start/end entries per execution, secret masking, configurable path, and atomic writes - **Canonical Agent Exchange Transcript** - Append-only JSONL transcript per run (`storage/transcripts/.jsonl`) capturing the full workflow lifecycle and agent exchange in one normalized stream; closed `EventType` vocabulary (run/step lifecycle, user/assistant messages, tool call/result) and typed `ContentBlock` (text, thinking, tool_use, tool_result, command, stream) with `fidelity` marker (`router` vs `agent_emitted`); per-provider normalization for Claude, Codex (with NUL byte tolerance), Gemini, Copilot, and OpenAI HTTP; tool capture at the `tools.Router.CallTool` seam covers builtin and plugin tools; sub-workflow linkage via `child_run_id`/`parent_run_id` with one file per run; bounded live fan-out with drop-newest policy keeps slow subscribers from stalling disk writes; atomic `O_APPEND` writes with `0o600` permissions and monotonic `Seq` ordering; coexists with `audit.jsonl` and `DisplayEvent` channels unchanged. See [Agent Exchange Transcript](docs/user-guide/transcript.md). - **Distributed Tracing** - OpenTelemetry integration for visibility into workflow execution with spans for steps, agents, parallel/loop blocks, and shell commands; export to any OTLP-compatible backend (Jaeger, Grafana Tempo, Honeycomb) via `--otel-exporter` and `--otel-service-name` flags -- **Plugin System** - Extend AWF with custom operations, validators, and step types via gRPC plugins (HashiCorp go-plugin); automatic mutual TLS (AutoMTLS) encryption for all host-plugin communication with zero configuration; SHA-256 binary integrity verification at launch time blocks tampered or corrupted plugins; plugin subprocess log and stdout/stderr forwarding to AWF's structured logger for crash diagnostics; validators run custom rules during `awf validate`, custom step types register new `type:` values for workflow steps; event system enables plugins to subscribe to core lifecycle events (`workflow.*`, `step.*`) and emit custom inter-plugin events with glob pattern matching, per-plugin buffered channels, and cycle detection; GRPCBroker enables plugins to emit events back to the host at runtime via a dedicated reverse channel with manifest-based permission enforcement; persistent gRPC streaming delivers events to plugins over long-lived connections with automatic fallback to unary RPCs for plugins that don't support streaming; includes `sdk.Serve()` entry point for plugin authors, and install/update/remove from GitHub Releases with checksum verification +- **Plugin System** - Extend AWF with custom operations, validators, and step types via gRPC plugins (HashiCorp go-plugin); scaffold operation plugins with `awf plugin init`; automatic mutual TLS (AutoMTLS) encryption for all host-plugin communication with zero configuration; SHA-256 binary integrity verification at launch time blocks tampered or corrupted plugins; plugin subprocess log and stdout/stderr forwarding to AWF's structured logger for crash diagnostics; validators run custom rules during `awf validate`, custom step types register new `type:` values for workflow steps; event system enables plugins to subscribe to core lifecycle events (`workflow.*`, `step.*`) and emit custom inter-plugin events with glob pattern matching, per-plugin buffered channels, and cycle detection; GRPCBroker enables plugins to emit events back to the host at runtime via a dedicated reverse channel with manifest-based permission enforcement; persistent gRPC streaming delivers events to plugins over long-lived connections with automatic fallback to unary RPCs for plugins that don't support streaming; includes `sdk.Serve()` entry point for plugin authors, and install/update/remove from GitHub Releases with checksum verification - **Workflow Packs** - Share reusable workflows and prompts via `awf workflow install owner/repo[@version]` from GitHub Releases with manifest validation, checksum verification, and atomic installation; execute with `awf run pack/workflow` namespace syntax; `{{.awf.prompts_dir}}` and `{{.awf.scripts_dir}}` resolve context-aware with 3-tier resolution (user override → pack embedded → global); `call_workflow` within packs resolves relative to pack root; `--global` flag for user-level installation; `awf workflow remove ` for cleanup; source metadata tracking and plugin dependency warnings - **Built-in GitHub Plugin** - Declarative GitHub operations (get_issue, create_pr, batch) with auth fallback and concurrent execution - **Built-in HTTP Operation** - Declarative REST API calls (GET, POST, PUT, DELETE) with configurable timeout, response capture, and retryable status codes @@ -127,14 +127,15 @@ AWF is a powerful orchestration tool that grants AI agents and workflows direct | `awf history` | Show execution history | | `awf status ` | Check workflow status | | `awf config show` | Display project configuration | -| `awf plugin list` | List installed plugins | +| `awf plugin disable ` | Disable a plugin | +| `awf plugin enable ` | Enable a plugin | +| `awf plugin init ` | Scaffold a new external plugin repository | | `awf plugin install ` | Install a plugin from GitHub Releases | -| `awf plugin update [name]` | Update an installed plugin | -| `awf plugin verify [name]` | Verify plugin binary integrity (check/update SHA-256 checksums) | +| `awf plugin list` | List installed plugins | | `awf plugin remove ` | Remove an installed plugin | | `awf plugin search [query]` | Search for plugins on GitHub | -| `awf plugin enable ` | Enable a plugin | -| `awf plugin disable ` | Disable a plugin | +| `awf plugin update [name]` | Update an installed plugin | +| `awf plugin verify [name]` | Verify plugin binary integrity (check/update SHA-256 checksums) | | `awf workflow list` | List installed workflow packs | | `awf workflow info ` | Display detailed pack information | | `awf workflow install ` | Install a workflow pack from GitHub Releases | diff --git a/docs/README.md b/docs/README.md index c6d28127..50d31d08 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ New to AWF? Start here: 1. [Installation](getting-started/installation.md) - Install AWF on your system 2. [Quick Start](getting-started/quickstart.md) - Create and run your first workflow +3. [Build Your First Plugin](getting-started/first-plugin.md) - Scaffold, test, install, and run your first AWF plugin ## User Guide @@ -47,7 +48,7 @@ Learn how to use AWF effectively: - [Notification Operations](user-guide/workflow-syntax.md#notification-operations) - Built-in notification plugin with desktop and webhook backends - [Retry Configuration](user-guide/retry.md) - Automatic retry with backoff strategies, delay capping, and exit code filtering - [Templates](user-guide/templates.md) - Reusable workflow templates -- [Plugins](user-guide/plugins.md) - Extend AWF with custom operations, validators, and step types; transport security (AutoMTLS, binary integrity verification) and log forwarding +- [Plugins](user-guide/plugins.md) - Install plugins, scaffold new plugin repositories, and author custom operations, validators, and step types - [Plugin Events](user-guide/plugin-events.md) - Real-time event reactivity between plugins and core - [Workflow Packs](user-guide/workflow-packs.md) - Install, execute (`awf run pack/workflow`), and manage reusable workflow packs with 3-tier path resolution - [HTTP API](user-guide/api.md) - REST API server with OpenAPI 3.1 spec, async workflow execution, real-time SSE streaming, and remote integration diff --git a/docs/getting-started/first-plugin.md b/docs/getting-started/first-plugin.md new file mode 100644 index 00000000..b5171818 --- /dev/null +++ b/docs/getting-started/first-plugin.md @@ -0,0 +1,132 @@ +--- +title: "Build Your First Plugin" +--- + +# Build Your First Plugin + +This tutorial walks through the F111 plugin developer kit flow: + +1. Scaffold a new plugin repository with `awf plugin init` +2. Run the generated tests +3. Install the plugin locally +4. Enable it in AWF +5. Run the generated demo workflow + +This path is for the MVP plugin scaffold: `--kind operation`. + +## Before You Start + +You need: + +- AWF installed and available on your `PATH` +- Go installed +- `make` installed + +Check your environment: + +```bash +awf --version +go version +make --version +``` + +## 1. Scaffold the Plugin Repository + +Create a new plugin distribution named `awf-plugin-example`: + +```bash +awf plugin init awf-plugin-example --kind operation +cd awf-plugin-example +``` + +The generated repository includes: + +- `main.go` - plugin entry point and example operation +- `main_test.go` - operation and manifest validation tests +- `plugin.yaml` - runtime manifest consumed by AWF +- `Makefile` - local development, packaging, and checksum targets +- `examples/demo.yaml` - runnable example workflow +- `.github/workflows/release.yml` - GitHub Releases packaging workflow +- `README.md` - generated authoring reference for the scaffold + +## 2. Understand the Two Names + +F111 distinguishes the installable distribution name from the runtime plugin id: + +- Distribution name: `awf-plugin-example` +- Runtime plugin id: `example` + +You install and enable the distribution, but workflows call the runtime operation name: + +```yaml +operation: example.echo +``` + +The runtime id lives in `plugin.yaml` as the manifest `name`. + +## 3. Run the Generated Checks + +Run the generated test and build loop: + +```bash +make test +make build +``` + +Optional local quality checks: + +```bash +make lint +``` + +## 4. Install and Enable the Plugin Locally + +Copy the generated binary and manifest into your local AWF plugin directory: + +```bash +make install-local +awf plugin enable awf-plugin-example +``` + +Verify that AWF can see the generated operation: + +```bash +awf plugin list --operations +``` + +You should see an `example.echo` operation owned by the `example` plugin. + +## 5. Run the Demo Workflow + +Execute the generated workflow: + +```bash +awf run examples/demo.yaml +``` + +The demo calls `example.echo` and should complete successfully. + +## 6. Package the Plugin for Releases + +Build an installer-compatible archive and checksum file: + +```bash +make package +make checksums +``` + +The generated GitHub Actions workflow publishes release assets using the naming convention AWF expects for `awf plugin install owner/repo[@version]`. + +## What the MVP Scaffold Supports + +Today, `awf plugin init` supports one scaffold kind: + +- `operation` + +Future kinds such as `canonical-port`, `adapter`, `validator`, `step-type`, and `event-listener` are planned but not yet generated by the CLI. + +## Next Steps + +- Read [Plugins](../user-guide/plugins.md) for manifest fields, SDK APIs, packaging rules, and advanced authoring topics. +- Read [CLI Commands](../user-guide/commands.md#awf-plugin-init) for the exact `awf plugin init` flags. +- Read the generated `README.md` in your scaffolded repository for the command sequence and release workflow that ships with the template. diff --git a/docs/user-guide/commands.md b/docs/user-guide/commands.md index 1a853c91..b2812134 100644 --- a/docs/user-guide/commands.md +++ b/docs/user-guide/commands.md @@ -19,14 +19,15 @@ title: "CLI Commands" | `awf diagram ` | Generate workflow diagram (DOT format) | | `awf error [code]` | Look up error code documentation | | `awf history` | Show workflow execution history | -| `awf plugin list` | List installed plugins | +| `awf plugin disable ` | Disable an enabled plugin | +| `awf plugin enable ` | Enable a disabled plugin | +| `awf plugin init ` | Scaffold a new external plugin repository | | `awf plugin install ` | Install a plugin from GitHub releases | -| `awf plugin update [name]` | Update an installed plugin | -| `awf plugin verify [name]` | Verify plugin binary integrity (check/update SHA-256 checksums) | +| `awf plugin list` | List installed plugins | | `awf plugin remove ` | Remove an installed plugin | | `awf plugin search [query]` | Search for plugins on GitHub | -| `awf plugin enable ` | Enable a plugin | -| `awf plugin disable ` | Disable a plugin | +| `awf plugin update [name]` | Update an installed plugin | +| `awf plugin verify [name]` | Verify plugin binary integrity (check/update SHA-256 checksums) | | `awf workflow list` | List installed workflow packs | | `awf workflow info ` | Display detailed pack information | | `awf workflow install ` | Install a workflow pack from GitHub Releases | @@ -1023,14 +1024,85 @@ awf plugin [flags] | Subcommand | Description | |------------|-------------| -| `list` | List all plugins (use `--operations` to show provided operations) | +| `disable ` | Disable an enabled plugin | +| `enable ` | Enable a disabled plugin | +| `init ` | Scaffold a new external plugin repository | | `install ` | Install a plugin from GitHub releases | -| `update [name]` | Update an installed plugin to the latest version | -| `verify [name]` | Verify plugin binary integrity (check/update SHA-256 checksums) | +| `list` | List all plugins (use `--operations` to show provided operations) | | `remove ` | Remove an installed plugin | | `search [query]` | Search for available plugins on GitHub | -| `enable ` | Enable a disabled plugin | -| `disable ` | Disable an enabled plugin | +| `update [name]` | Update an installed plugin to the latest version | +| `verify [name]` | Verify plugin binary integrity (check/update SHA-256 checksums) | + +--- + +## awf plugin init + +Scaffold an AWF plugin repository. + +```bash +awf plugin init [flags] +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `name` | Plugin distribution name. It must start with `awf-plugin-`, for example `awf-plugin-example` | + +### Flags + +| Flag | Description | +|------|-------------| +| `--kind` | Plugin scaffold kind. The MVP supports `operation` only; omitted means `operation` | +| `--output` | Output directory. Defaults to `` | +| `--force` | Overwrite generated files that already exist | + +### Description + +Generates a Go operation plugin repository with `go.mod`, `main.go`, tests, `plugin.yaml`, `README.md`, `Makefile`, `examples/demo.yaml`, and a GitHub Actions release workflow. + +The command treats `` as the distribution name used for the repository, binary, directory, and release assets. The runtime plugin id is derived by stripping the `awf-plugin-` prefix, so `awf-plugin-example` creates a manifest named `example` and workflow operations use `example.echo`. + +`kind` selects the scaffold shape. Runtime `capabilities` stay in `plugin.yaml`; the generated operation scaffold advertises `operations`. Future scaffold kinds such as `canonical-port`, `adapter`, `direct-integration`, `hybrid`, `validator`, `step-type`, `event-listener`, and `full` are listed in help output but return unsupported-kind errors until implemented. + +### Examples + +```bash +# Create an operation plugin in ./awf-plugin-example +awf plugin init awf-plugin-example --kind operation + +# Use the default operation kind +awf plugin init awf-plugin-example + +# Write to a custom directory +awf plugin init awf-plugin-example --output /tmp/awf-plugin-example + +# Overwrite existing generated files +awf plugin init awf-plugin-example --force +``` + +### First Run + +```bash +awf plugin init awf-plugin-example --kind operation +cd awf-plugin-example +make test +make build +make install-local +awf plugin enable awf-plugin-example +awf plugin list --operations +awf run examples/demo.yaml +``` + +### Errors + +| Error | Cause | +|-------|-------| +| `required-prefix` | Name does not start with `awf-plugin-` | +| `unsupported-kind` | `--kind` is not `operation` | +| `single-kind` | Multiple or comma-separated `--kind` values were provided | +| `already exists` | A generated file exists and `--force` was not provided | --- @@ -1117,7 +1189,7 @@ Downloads the latest compatible release from the GitHub repository, verifies the Explicit versions use `owner/repo@version` syntax and must be exact SemVer values. Both `1.2.3` and `v1.2.3` are accepted; ranges such as `>=1.0.0` are rejected. -Release assets must follow the naming convention: `awf-plugin-__.tar.gz` with a corresponding `checksums.txt` file. +Release assets must follow the naming convention: `awf-plugin-___.tar.gz` with a corresponding `checksums.txt` file. ### Examples diff --git a/docs/user-guide/plugins.md b/docs/user-guide/plugins.md index 8f4f8fde..4beb087c 100644 --- a/docs/user-guide/plugins.md +++ b/docs/user-guide/plugins.md @@ -362,6 +362,27 @@ awf plugin install myorg/awf-plugin-jira --force The `owner/repo[@version]` argument must be a GitHub repository path (not a URL). The repository must contain GitHub Releases with `.tar.gz` assets matching the AWF naming convention (see [Release Asset Naming](#release-asset-naming)). +#### Create a Plugin + +Use `awf plugin init` to generate a working plugin repository: + +```bash +awf plugin init awf-plugin-example --kind operation +cd awf-plugin-example +make test +make build +make install-local +awf plugin enable awf-plugin-example +awf plugin list --operations +awf run examples/demo.yaml +``` + +The MVP supports `--kind operation`. If `--kind` is omitted, AWF uses the operation scaffold. The generated repository includes source code, tests, a manifest, local install targets, package and checksum targets, a demo workflow, a README, and a GitHub Actions release workflow. + +`awf-plugin-example` is the distribution name used for the repository, binary, install directory, and release assets. The generated manifest uses the runtime plugin id `example`, so workflow operation references use `example.echo`. + +The scaffold `kind` selects a starter repository shape. Manifest `capabilities` describe what the installed plugin exposes at runtime. The generated operation template advertises `operations`; planned future kinds include `canonical-port`, `adapter`, `direct-integration`, `hybrid`, `validator`, `step-type`, `event-listener`, and `full`. + #### Update a Plugin Update an installed plugin to the latest version: @@ -648,39 +669,23 @@ notify.send notify External plugins are Go binaries that call `sdk.Serve()` from `main()`. AWF discovers them via their `plugin.yaml` manifest and communicates over gRPC using HashiCorp go-plugin. -### Minimal Plugin - -```go -package main - -import ( - "context" - - "github.com/awf-project/cli/pkg/plugin/sdk" -) +### First Plugin Scaffold -type MyPlugin struct { - sdk.BasePlugin -} +Start new operation plugins with `awf plugin init` instead of copying an example by hand: -func (p *MyPlugin) Operations() []string { - return []string{"my_op"} -} +```bash +awf plugin init awf-plugin-example --kind operation +cd awf-plugin-example +make test +make install-local +awf plugin enable awf-plugin-example +awf plugin list --operations +awf run examples/demo.yaml +``` -func (p *MyPlugin) HandleOperation(_ context.Context, name string, inputs map[string]any) (*sdk.OperationResult, error) { - text := sdk.GetStringDefault(inputs, "text", "") - return sdk.NewSuccessResult(text, nil), nil -} +The generated `main.go` embeds `sdk.BasePlugin`, calls `sdk.Serve()`, implements `Operations()` and `HandleOperation()`, and exposes operation metadata through `OperationSchemaProvider`. The generated tests cover successful execution, structured operation errors, schema metadata, and manifest validation. -func main() { - sdk.Serve(&MyPlugin{ - BasePlugin: sdk.BasePlugin{ - PluginName: "awf-plugin-myplugin", - PluginVersion: "1.0.0", - }, - }) -} -``` +Use the generated repository as the supported authoring baseline. The checked-in examples remain useful for learning additional SDK surfaces: `awf-plugin-echo` mirrors the operation template, `awf-plugin-database` is the future step-type seed, `awf-plugin-security-validator` is the future validator seed, and `awf-plugin-event-logger` is the future event-listener seed. ### SDK Helpers @@ -693,6 +698,7 @@ func main() { | `sdk.GetStringDefault(inputs, key, default)` | Extract string input with fallback | | `sdk.GetIntDefault(inputs, key, default)` | Extract integer input with fallback | | `sdk.GetBoolDefault(inputs, key, default)` | Extract boolean input with fallback | +| `sdk.OperationSchemaProvider` | Optional interface for input/output metadata used by docs, validation, and MCP exposure | | `sdk.EventSubscriber` | Interface for receiving events (`Patterns()` + `HandleEvent()`) | | `sdk.Event` | SDK event struct with ID, Type, Source, Metadata, Payload | @@ -1104,12 +1110,12 @@ make install # Build and install to ~/.local/share/awf/plugins/ awf plugin enable awf-plugin-echo ``` -Use it in a workflow: +Use it in a workflow by referencing the runtime plugin id: ```yaml echo_step: type: operation - operation: awf-plugin-echo.echo + operation: echo.echo inputs: text: "Hello from plugin!" prefix: ">>>" @@ -1152,42 +1158,16 @@ Each archive must contain: A `checksums.txt` file (SHA-256) must be included as a separate release asset. AWF verifies the checksum before installation. -### GoReleaser Configuration +### Generated Release Workflow -Use [GoReleaser](https://goreleaser.com/) to automate plugin releases. Add a `.goreleaser.yml` to your plugin repository: +Repositories created by `awf plugin init` include package and checksum targets plus `.github/workflows/release.yml`. -```yaml -project_name: awf-plugin-myplugin - -builds: - - main: . - binary: awf-plugin-myplugin - goos: - - linux - - darwin - goarch: - - amd64 - - arm64 - ldflags: - - -s -w -X main.version={{.Version}} - -archives: - - format: tar.gz - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" - files: - - plugin.yaml - -checksum: - name_template: checksums.txt - algorithm: sha256 - -release: - github: - owner: your-org - name: awf-plugin-myplugin -``` - -All archives must use `.tar.gz` format. This differs from AWF's own releases which use `.zip` on macOS. +```bash +make package +make checksums +``` + +The generated workflow builds the plugin, packages the binary plus `plugin.yaml`, and publishes `.tar.gz` archives with SHA-256 checksums. Keep the generated asset pattern unless you also update AWF installer compatibility tests. ### Authentication diff --git a/examples/plugins/awf-plugin-echo/Makefile b/examples/plugins/awf-plugin-echo/Makefile index 1ca47f9c..40df918f 100644 --- a/examples/plugins/awf-plugin-echo/Makefile +++ b/examples/plugins/awf-plugin-echo/Makefile @@ -1,16 +1,37 @@ -.PHONY: build install clean +.PHONY: build test lint install-local uninstall-local package checksums PLUGIN_NAME := awf-plugin-echo -PLUGINS_DIR := $(HOME)/.local/share/awf/plugins/$(PLUGIN_NAME) +VERSION := 1.0.0 +GOOS ?= $(shell go env GOOS) +GOARCH ?= $(shell go env GOARCH) +DIST_DIR := dist +PACKAGE_DIR := package +ARCHIVE := $(PLUGIN_NAME)_$(VERSION)_$(GOOS)_$(GOARCH).tar.gz build: - go build -o $(PLUGIN_NAME) . + mkdir -p "$(DIST_DIR)" + go build -o $(DIST_DIR)/$(PLUGIN_NAME) . -install: build - @mkdir -p $(PLUGINS_DIR) - cp $(PLUGIN_NAME) $(PLUGINS_DIR)/$(PLUGIN_NAME) - cp plugin.yaml $(PLUGINS_DIR)/plugin.yaml - @echo "Installed $(PLUGIN_NAME) to $(PLUGINS_DIR)" +test: + go test . -clean: - rm -f $(PLUGIN_NAME) +lint: + go vet . + +install-local: build + mkdir -p "$${HOME}/.local/share/awf/plugins/echo" + cp "$(DIST_DIR)/$(PLUGIN_NAME)" "$${HOME}/.local/share/awf/plugins/echo/$(PLUGIN_NAME)" + cp plugin.yaml "$${HOME}/.local/share/awf/plugins/echo/plugin.yaml" + +uninstall-local: + rm -f "$${HOME}/.local/share/awf/plugins/echo/$(PLUGIN_NAME)" + rm -f "$${HOME}/.local/share/awf/plugins/echo/plugin.yaml" + +package: build + mkdir -p "$(DIST_DIR)/$(PACKAGE_DIR)" + find "$(DIST_DIR)/$(PACKAGE_DIR)" -mindepth 1 -maxdepth 1 -exec rm -r -- {} + + cp "$(DIST_DIR)/$(PLUGIN_NAME)" plugin.yaml "$(DIST_DIR)/$(PACKAGE_DIR)/" + tar -C "$(DIST_DIR)/$(PACKAGE_DIR)" -czf "$(DIST_DIR)/$(ARCHIVE)" . + +checksums: package + cd "$(DIST_DIR)" && sha256sum *.tar.gz > checksums.txt diff --git a/examples/plugins/awf-plugin-echo/examples/demo.yaml b/examples/plugins/awf-plugin-echo/examples/demo.yaml new file mode 100644 index 00000000..90ef857b --- /dev/null +++ b/examples/plugins/awf-plugin-echo/examples/demo.yaml @@ -0,0 +1,22 @@ +name: echo demo +version: "1.0" + +states: + initial: echo + + echo: + type: operation + operation: echo.echo + inputs: + text: "hello from echo" + prefix: "AWF: " + on_success: done + on_failure: failed + + done: + type: terminal + status: success + + failed: + type: terminal + status: failure diff --git a/examples/plugins/awf-plugin-echo/main.go b/examples/plugins/awf-plugin-echo/main.go index 9365ce2d..0eb78d8e 100644 --- a/examples/plugins/awf-plugin-echo/main.go +++ b/examples/plugins/awf-plugin-echo/main.go @@ -2,15 +2,10 @@ package main import ( "context" - "fmt" "github.com/awf-project/cli/pkg/plugin/sdk" ) -// EchoPlugin implements sdk.Plugin, sdk.OperationProvider, and sdk.OperationSchemaProvider. -// It exposes a single "echo" operation that returns its input text unchanged. -// The rich schema is surfaced via OperationSchemaProvider so that MCP hosts and -// AI agents see documented inputs and outputs rather than an opaque tool handle. type EchoPlugin struct { sdk.BasePlugin } @@ -19,43 +14,43 @@ func (p *EchoPlugin) Operations() []string { return []string{"echo"} } -// GetOperationSchema implements sdk.OperationSchemaProvider. -// Returns full metadata for the "echo" operation so that MCP hosts can expose -// a documented tool surface to AI agents. Returns (zero, false) for unknown names. func (p *EchoPlugin) GetOperationSchema(name string) (sdk.OperationMeta, bool) { if name != "echo" { return sdk.OperationMeta{}, false } + return sdk.OperationMeta{ - Description: "Echo the input text back, optionally prefixed.", + Description: "Echo text, optionally prepending a prefix.", Inputs: []sdk.InputMeta{ - {Name: "text", Type: sdk.InputTypeString, Required: true, Description: "Text to echo back."}, - {Name: "prefix", Type: sdk.InputTypeString, Description: "Optional prefix prepended to the text."}, + {Name: "text", Type: sdk.InputTypeString, Required: true, Description: "Text to echo."}, + {Name: "prefix", Type: sdk.InputTypeString, Required: false, Description: "Optional prefix prepended to the text."}, }, Outputs: []sdk.OutputMeta{ - {Name: "text", Type: sdk.InputTypeString, Description: "The original input text."}, - {Name: "prefix", Type: sdk.InputTypeString, Description: "The prefix that was applied (empty if none)."}, + {Name: "output", Type: sdk.InputTypeString, Description: "Final echoed output."}, + {Name: "text", Type: sdk.InputTypeString, Description: "Original text input."}, + {Name: "prefix", Type: sdk.InputTypeString, Description: "Prefix input, when provided."}, }, }, true } func (p *EchoPlugin) HandleOperation(_ context.Context, name string, inputs map[string]any) (*sdk.OperationResult, error) { if name != "echo" { - return nil, fmt.Errorf("unknown operation: %s", name) + return sdk.NewErrorResult("unknown operation: " + name), nil } text, ok := sdk.GetString(inputs, "text") - if !ok { - return nil, fmt.Errorf("missing required input: text") + if !ok || text == "" { + return sdk.NewErrorResult("text is required"), nil } - prefix := sdk.GetStringDefault(inputs, "prefix", "") + prefix, _ := sdk.GetString(inputs, "prefix") output := text if prefix != "" { - output = fmt.Sprintf("%s %s", prefix, text) + output = prefix + text } return sdk.NewSuccessResult(output, map[string]any{ + "output": output, "text": text, "prefix": prefix, }), nil diff --git a/examples/plugins/awf-plugin-echo/main_test.go b/examples/plugins/awf-plugin-echo/main_test.go index 8f7cf602..fcd76f16 100644 --- a/examples/plugins/awf-plugin-echo/main_test.go +++ b/examples/plugins/awf-plugin-echo/main_test.go @@ -3,289 +3,276 @@ package main import ( "context" "os" + "strings" "testing" + "github.com/awf-project/cli/internal/domain/pluginmodel" + "github.com/awf-project/cli/internal/infrastructure/pluginmgr" "github.com/awf-project/cli/pkg/plugin/sdk" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) -// TestEchoPlugin_ImplementsPlugin verifies that EchoPlugin implements sdk.Plugin interface. -func TestEchoPlugin_ImplementsPlugin(t *testing.T) { - plugin := &EchoPlugin{} +func newTestEchoPlugin() *EchoPlugin { + return &EchoPlugin{BasePlugin: sdk.BasePlugin{PluginName: "echo", PluginVersion: "1.0.0"}} +} - require.NotNil(t, plugin) +func TestMainGoUsesSDKServePluginAsTheProcessEntryPoint(t *testing.T) { + source := readEchoSource(t) - // Verify interface implementation at compile time - var _ sdk.Plugin = (*EchoPlugin)(nil) + assert.Contains(t, source, "sdk.Serve(") } -// TestEchoPlugin_Name_ReturnsPluginName verifies Name returns the correct plugin identifier. -func TestEchoPlugin_Name_ReturnsPluginName(t *testing.T) { - plugin := &EchoPlugin{BasePlugin: sdk.BasePlugin{PluginName: "echo", PluginVersion: "1.0.0"}} - - name := plugin.Name() +func TestMainGoEmbedsOrUsesSDKBasePluginWithRuntimePluginIDEcho(t *testing.T) { + var _ sdk.Plugin = (*EchoPlugin)(nil) + plugin := newTestEchoPlugin() - assert.Equal(t, "echo", name) + assert.Equal(t, "echo", plugin.Name()) + assert.Equal(t, "1.0.0", plugin.Version()) } -// TestEchoPlugin_Version_ReturnsPluginVersion verifies Version returns semantic version. -func TestEchoPlugin_Version_ReturnsPluginVersion(t *testing.T) { - plugin := &EchoPlugin{BasePlugin: sdk.BasePlugin{PluginName: "echo", PluginVersion: "1.0.0"}} - - version := plugin.Version() +func TestMainGoImplementsOperationsWithOperationEcho(t *testing.T) { + var _ sdk.OperationProvider = (*EchoPlugin)(nil) - assert.Equal(t, "1.0.0", version) + assert.Equal(t, []string{"echo"}, newTestEchoPlugin().Operations()) } -// TestEchoPlugin_Init_Succeeds verifies that Init completes without error with valid config. -func TestEchoPlugin_Init_Succeeds(t *testing.T) { - plugin := &EchoPlugin{} - ctx := context.Background() - config := map[string]any{} - - err := plugin.Init(ctx, config) +func TestMainGoImplementsHandleOperationUsingSDKInputHelpersForRequiredTextAndOptionalPrefix(t *testing.T) { + source := readEchoSource(t) - assert.NoError(t, err) + assert.Contains(t, source, `sdk.GetString(inputs, "text")`) + assert.Contains(t, source, `sdk.GetString(inputs, "prefix")`) } -// TestEchoPlugin_Init_WithConfig accepts configuration passed at runtime. -func TestEchoPlugin_Init_WithConfig(t *testing.T) { - plugin := &EchoPlugin{} - ctx := context.Background() - config := map[string]any{ - "prefix": "echo: ", - } - - err := plugin.Init(ctx, config) +func TestHandleOperationReturnsSDKNewErrorResultNilForMissingText(t *testing.T) { + result, err := newTestEchoPlugin().HandleOperation(context.Background(), "echo", map[string]any{}) - assert.NoError(t, err) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.Success) + assert.Contains(t, result.Error, "text") } -// TestEchoPlugin_Shutdown_Succeeds verifies that Shutdown completes without error. -func TestEchoPlugin_Shutdown_Succeeds(t *testing.T) { - plugin := &EchoPlugin{} - ctx := context.Background() - - err := plugin.Shutdown(ctx) +func TestHandleOperationReturnsSDKNewErrorResultNilForEmptyText(t *testing.T) { + result, err := newTestEchoPlugin().HandleOperation(context.Background(), "echo", map[string]any{"text": ""}) - assert.NoError(t, err) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.Success) + assert.Contains(t, result.Error, "text") } -// TestEchoPlugin_ImplementsOperationProvider verifies that EchoPlugin implements OperationProvider. -func TestEchoPlugin_ImplementsOperationProvider(t *testing.T) { - plugin := &EchoPlugin{} +func TestHandleOperationReturnsSDKNewSuccessResultNilWithOutputTextAndPrefix(t *testing.T) { + result, err := newTestEchoPlugin().HandleOperation(context.Background(), "echo", map[string]any{"text": "hello"}) - // Verify interface implementation at compile time - var _ sdk.OperationProvider = (*EchoPlugin)(nil) - assert.NotNil(t, plugin) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.Success) + assert.Equal(t, "hello", result.Output) + assert.Equal(t, map[string]any{"output": "hello", "text": "hello", "prefix": ""}, result.Data) } -// TestEchoPlugin_Operations_ReturnsEchoOperation verifies that Operations -// returns a list containing the echo operation. -func TestEchoPlugin_Operations_ReturnsEchoOperation(t *testing.T) { - plugin := &EchoPlugin{} +func TestPrefixBehaviorPrependsPrefixOnlyWhenProvided(t *testing.T) { + plugin := newTestEchoPlugin() - operations := plugin.Operations() + withoutPrefix, err := plugin.HandleOperation(context.Background(), "echo", map[string]any{"text": "hello"}) + require.NoError(t, err) + require.NotNil(t, withoutPrefix) + assert.Equal(t, "hello", withoutPrefix.Output) - require.Len(t, operations, 1) - assert.Equal(t, "echo", operations[0]) + withPrefix, err := plugin.HandleOperation(context.Background(), "echo", map[string]any{"text": "hello", "prefix": "say: "}) + require.NoError(t, err) + require.NotNil(t, withPrefix) + assert.Equal(t, "say: hello", withPrefix.Output) + assert.Equal(t, "say: ", withPrefix.Data["prefix"]) } -// TestEchoPlugin_HandleOperation_EchoOperation_ReturnsInput verifies that HandleOperation -// with "echo" operation returns the input text in the output. -func TestEchoPlugin_HandleOperation_EchoOperation_ReturnsInput(t *testing.T) { - plugin := &EchoPlugin{} - ctx := context.Background() +func TestMainGoImplementsOperationSchemaMetadataEquivalentToTheGeneratedOperationTemplate(t *testing.T) { + var _ sdk.OperationSchemaProvider = (*EchoPlugin)(nil) - input := map[string]any{ - "text": "hello world", - } + schema, ok := newTestEchoPlugin().GetOperationSchema("echo") + + require.True(t, ok) + assert.Equal(t, "Echo text, optionally prepending a prefix.", schema.Description) + require.Len(t, schema.Inputs, 2) + assert.Equal(t, sdk.InputMeta{Name: "text", Type: sdk.InputTypeString, Required: true, Description: "Text to echo."}, schema.Inputs[0]) + assert.Equal(t, sdk.InputMeta{Name: "prefix", Type: sdk.InputTypeString, Required: false, Description: "Optional prefix prepended to the text."}, schema.Inputs[1]) + assert.ElementsMatch(t, []sdk.OutputMeta{ + {Name: "output", Type: sdk.InputTypeString, Description: "Final echoed output."}, + {Name: "text", Type: sdk.InputTypeString, Description: "Original text input."}, + {Name: "prefix", Type: sdk.InputTypeString, Description: "Prefix input, when provided."}, + }, schema.Outputs) +} - result, err := plugin.HandleOperation(ctx, "echo", input) +func TestHandleOperationReturnsStructuredErrorResultForUnknownOperation(t *testing.T) { + result, err := newTestEchoPlugin().HandleOperation(context.Background(), "missing", map[string]any{"text": "hello"}) require.NoError(t, err) require.NotNil(t, result) - assert.True(t, result.Success) - - // Echo operation should return the input text as output - assert.Equal(t, "hello world", result.Output) + assert.False(t, result.Success) + assert.Contains(t, result.Error, "unknown operation") } -// TestEchoPlugin_HandleOperation_EchoOperation_EmptyInput handles empty text input. -func TestEchoPlugin_HandleOperation_EchoOperation_EmptyInput(t *testing.T) { - plugin := &EchoPlugin{} - ctx := context.Background() +func TestMainTestGoValidatesPluginYAMLThroughTheSameParserBackedManifestPathUsedByGeneratedPluginTests(t *testing.T) { + manifest, err := pluginmgr.NewManifestParser().ParseFile("plugin.yaml") - input := map[string]any{ - "text": "", - } + require.NoError(t, err) + require.NoError(t, manifest.Validate()) + assert.Equal(t, "echo", manifest.Name) + assert.Equal(t, "1.0.0", manifest.Version) + assert.NotEmpty(t, manifest.Description) + assert.NotEmpty(t, manifest.AWFVersion) + assert.True(t, manifest.HasCapability(pluginmodel.CapabilityOperations)) +} - result, err := plugin.HandleOperation(ctx, "echo", input) +func TestPluginYAMLUsesManifestRuntimeNameEchoNotAWFPluginEchoAndDeclaresOperationsCapability(t *testing.T) { + manifest, err := pluginmgr.NewManifestParser().ParseFile("plugin.yaml") require.NoError(t, err) - require.NotNil(t, result) - assert.True(t, result.Success) - assert.Equal(t, "", result.Output) + assert.Equal(t, "echo", manifest.Name) + assert.NotEqual(t, "awf-plugin-echo", manifest.Name) + assert.True(t, manifest.HasCapability(pluginmodel.CapabilityOperations)) } -// TestEchoPlugin_HandleOperation_UnknownOperation returns error for unknown operations. -func TestEchoPlugin_HandleOperation_UnknownOperation(t *testing.T) { - plugin := &EchoPlugin{} - ctx := context.Background() - - input := map[string]any{} +func TestMakefileProvidesBuildTestLintInstallLocalUninstallLocalPackageAndChecksumsTargets(t *testing.T) { + targets := parseEchoMakeTargets(readEchoMakefile(t)) + + assert.ElementsMatch(t, []string{ + "build", + "test", + "lint", + "install-local", + "uninstall-local", + "package", + "checksums", + }, targets) +} - result, err := plugin.HandleOperation(ctx, "unknown", input) +func TestMakeBuildBuildsTheAWFPluginEchoBinaryIntoTheExamplesLocalBuildOutputDirectory(t *testing.T) { + makefile := readEchoMakefile(t) - assert.Error(t, err) - assert.Nil(t, result) + assert.Contains(t, makefile, "DIST_DIR := dist") + assert.Contains(t, makefile, "PLUGIN_NAME := awf-plugin-echo") + assert.Contains(t, makefile, "mkdir -p \"$(DIST_DIR)\"") + assert.Contains(t, makefile, "go build -o $(DIST_DIR)/$(PLUGIN_NAME) .") } -// TestEchoPlugin_HandleOperation_MissingRequiredInput returns error when required field is missing. -func TestEchoPlugin_HandleOperation_MissingRequiredInput(t *testing.T) { - plugin := &EchoPlugin{} - ctx := context.Background() +func TestMakeTestRunsTheExamplesGoTests(t *testing.T) { + assert.Contains(t, readEchoMakefile(t), "go test .") +} - input := map[string]any{ - // Missing "text" field - } +func TestMakeLintRunsConfiguredLintingOrADeterministicLightweightCheckDocumentedInTheMakefile(t *testing.T) { + assert.Contains(t, readEchoMakefile(t), "go vet .") +} - result, err := plugin.HandleOperation(ctx, "echo", input) +func TestMakeInstallLocalCopiesTheBinaryAndPluginYAMLIntoTheAWFPluginDirectoryUsingGeneratedTemplatePathRules(t *testing.T) { + makefile := readEchoMakefile(t) - assert.Error(t, err) - assert.Nil(t, result) + assert.Contains(t, makefile, "install-local: build") + assert.Contains(t, makefile, `mkdir -p "$${HOME}/.local/share/awf/plugins/echo"`) + assert.Contains(t, makefile, `cp "$(DIST_DIR)/$(PLUGIN_NAME)" "$${HOME}/.local/share/awf/plugins/echo/$(PLUGIN_NAME)"`) + assert.Contains(t, makefile, `cp plugin.yaml "$${HOME}/.local/share/awf/plugins/echo/plugin.yaml"`) } -// TestEchoPlugin_CanServe verifies that the plugin can be served via sdk.Serve(). -func TestEchoPlugin_CanServe(t *testing.T) { - plugin := &EchoPlugin{} +func TestMakeUninstallLocalRemovesOnlyFilesInstalledForEcho(t *testing.T) { + makefile := readEchoMakefile(t) - // Verify the plugin implements sdk.Plugin interface - var _ sdk.Plugin = plugin - assert.NotNil(t, plugin) + assert.Contains(t, makefile, "uninstall-local:") + assert.Contains(t, makefile, `rm -f "$${HOME}/.local/share/awf/plugins/echo/$(PLUGIN_NAME)"`) + assert.Contains(t, makefile, `rm -f "$${HOME}/.local/share/awf/plugins/echo/plugin.yaml"`) + assert.NotContains(t, makefile, "rm -rf") } -// TestEchoPlugin_Context_Cancellation verifies that HandleOperation respects context cancellation. -func TestEchoPlugin_Context_Cancellation(t *testing.T) { - plugin := &EchoPlugin{} - ctx, cancel := context.WithCancel(context.Background()) - cancel() // Cancel immediately +func TestMakePackageProducesInstallerCompatibleArchiveNamesUsingGeneratedOperationTemplatePattern(t *testing.T) { + makefile := readEchoMakefile(t) - input := map[string]any{ - "text": "test", - } + assert.Contains(t, makefile, "ARCHIVE := $(PLUGIN_NAME)_$(VERSION)_$(GOOS)_$(GOARCH).tar.gz") + assert.Contains(t, makefile, `tar -C "$(DIST_DIR)/$(PACKAGE_DIR)" -czf "$(DIST_DIR)/$(ARCHIVE)" .`) + assert.NotContains(t, makefile, ".zip") +} - result, err := plugin.HandleOperation(ctx, "echo", input) +func TestMakeChecksumsWritesSHA256ChecksumsForPackageArtifacts(t *testing.T) { + makefile := readEchoMakefile(t) - // Operation should either complete (echo is synchronous) or respect context - // If it respects context, err should be non-nil or result non-nil - if err == nil { - require.NotNil(t, result) - } + assert.Contains(t, makefile, "checksums: package") + assert.Contains(t, makefile, `sha256sum *.tar.gz > checksums.txt`) } -// TestEchoPlugin_ImplementsOperationSchemaProvider verifies the compile-time interface check. -func TestEchoPlugin_ImplementsOperationSchemaProvider(t *testing.T) { - var _ sdk.OperationSchemaProvider = (*EchoPlugin)(nil) -} +func TestExamplesDemoYAMLCallsEchoEchoWithRequiredTextAndOptionalPrefixDataThroughTypeOperation(t *testing.T) { + demo := readEchoDemoWorkflow(t) -// TestEchoPlugin_GetOperationSchema_EchoReturnsFullMeta asserts that GetOperationSchema("echo") -// returns the documented metadata with non-empty description, two inputs, and two outputs. -// This locks the demonstration contract for MCP hosts and AI agents. -func TestEchoPlugin_GetOperationSchema_EchoReturnsFullMeta(t *testing.T) { - plugin := &EchoPlugin{} + state := demo.States.Echo + require.Equal(t, "operation", state.Type) + assert.Equal(t, "echo.echo", state.Operation) + assert.Equal(t, "hello from echo", state.Inputs["text"]) + assert.Equal(t, "AWF: ", state.Inputs["prefix"]) +} - meta, ok := plugin.GetOperationSchema("echo") +func TestExamplesDemoYAMLEndsInTerminalSuccessWhenTheOperationSucceeds(t *testing.T) { + demo := readEchoDemoWorkflow(t) - require.True(t, ok) - assert.NotEmpty(t, meta.Description) - - require.Len(t, meta.Inputs, 2) - assert.Equal(t, "text", meta.Inputs[0].Name) - assert.Equal(t, sdk.InputTypeString, meta.Inputs[0].Type) - assert.True(t, meta.Inputs[0].Required, "text input must be required") - assert.Equal(t, "prefix", meta.Inputs[1].Name) - assert.Equal(t, sdk.InputTypeString, meta.Inputs[1].Type) - assert.False(t, meta.Inputs[1].Required, "prefix input must be optional") - - require.Len(t, meta.Outputs, 2) - assert.Equal(t, "text", meta.Outputs[0].Name) - assert.Equal(t, "prefix", meta.Outputs[1].Name) + echoState := demo.States.Echo + doneState := demo.States.Done + assert.Equal(t, "done", echoState.OnSuccess) + assert.Equal(t, "terminal", doneState.Type) + assert.Equal(t, "success", doneState.Status) } -// TestEchoPlugin_GetOperationSchema_UnknownNameReturnsFalse asserts that an unknown -// operation name returns (zero, false) — protocol contract for OperationSchemaProvider. -func TestEchoPlugin_GetOperationSchema_UnknownNameReturnsFalse(t *testing.T) { - plugin := &EchoPlugin{} +func readEchoSource(t *testing.T) string { + t.Helper() - meta, ok := plugin.GetOperationSchema("does-not-exist") - - assert.False(t, ok) - assert.Empty(t, meta.Description) - assert.Empty(t, meta.Inputs) - assert.Empty(t, meta.Outputs) + source, err := os.ReadFile("main.go") + require.NoError(t, err) + return string(source) } -// BenchmarkEchoPlugin_HandleOperation_SimpleText measures performance of echo operation -// with simple text input to establish baseline for NFR-001 (< 10ms latency). -func BenchmarkEchoPlugin_HandleOperation_SimpleText(b *testing.B) { - plugin := &EchoPlugin{} - ctx := context.Background() - input := map[string]any{ - "text": "benchmark", - } +func readEchoMakefile(t *testing.T) string { + t.Helper() - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = plugin.HandleOperation(ctx, "echo", input) - } + source, err := os.ReadFile("Makefile") + require.NoError(t, err) + return string(source) } -// TestPluginYAMLManifest_ExistsAndIsValid verifies that plugin.yaml exists -// and contains required fields. -func TestPluginYAMLManifest_ExistsAndIsValid(t *testing.T) { - content, err := os.ReadFile("plugin.yaml") - require.NoError(t, err, "plugin.yaml must exist in plugin directory") - - // Verify file has content - assert.Greater(t, len(content), 0, "plugin.yaml should not be empty") - - // Verify required fields are present (basic string checks) - manifestStr := string(content) - assert.Contains(t, manifestStr, "name:", "manifest must contain 'name'") - assert.Contains(t, manifestStr, "name: echo", "manifest must declare plugin name") - assert.Contains(t, manifestStr, "version:", "manifest must contain 'version'") - assert.Contains(t, manifestStr, "awf_version:", "manifest must contain 'awf_version'") - assert.Contains(t, manifestStr, "capabilities:", "manifest must declare capabilities") - assert.Contains(t, manifestStr, "operations", "manifest must declare operations capability") +func parseEchoMakeTargets(makefile string) []string { + targets := make([]string, 0) + for _, line := range strings.Split(makefile, "\n") { + if strings.HasPrefix(line, "\t") || !strings.Contains(line, ":") || strings.Contains(line, ":=") { + continue + } + name := strings.TrimSpace(strings.SplitN(line, ":", 2)[0]) + if name == ".PHONY" || strings.Contains(name, " ") || name == "" { + continue + } + targets = append(targets, name) + } + return targets } -// TestPluginMakefile_ExistsAndBuildable verifies that Makefile exists -// and contains build targets. -func TestPluginMakefile_ExistsAndBuildable(t *testing.T) { - content, err := os.ReadFile("Makefile") - require.NoError(t, err, "Makefile must exist in plugin directory") - - // Verify file has content - assert.Greater(t, len(content), 0, "Makefile should not be empty") - - // Verify required targets are present - makefileStr := string(content) - assert.Contains(t, makefileStr, "build:", "Makefile must contain 'build' target") +type echoDemoWorkflow struct { + States struct { + Initial string `yaml:"initial"` + Echo struct { + Type string `yaml:"type"` + Operation string `yaml:"operation"` + Inputs map[string]string `yaml:"inputs"` + OnSuccess string `yaml:"on_success"` + } `yaml:"echo"` + Done struct { + Type string `yaml:"type"` + Status string `yaml:"status"` + } `yaml:"done"` + } `yaml:"states"` } -// TestPluginREADME_ExistsAndDocumented verifies that README.md exists -// and documents the plugin. -func TestPluginREADME_ExistsAndDocumented(t *testing.T) { - content, err := os.ReadFile("README.md") - require.NoError(t, err, "README.md must exist in plugin directory") +func readEchoDemoWorkflow(t *testing.T) echoDemoWorkflow { + t.Helper() - // Verify file has content - assert.Greater(t, len(content), 0, "README.md should not be empty") + source, err := os.ReadFile("examples/demo.yaml") + require.NoError(t, err) - // Verify basic documentation structure - readmeStr := string(content) - assert.Contains(t, readmeStr, "awf-plugin-echo", "README should mention plugin name") - assert.Contains(t, readmeStr, "echo", "README should document echo operation") + var demo echoDemoWorkflow + require.NoError(t, yaml.Unmarshal(source, &demo)) + return demo } diff --git a/go.mod b/go.mod index 5e98e2ae..24947d86 100644 --- a/go.mod +++ b/go.mod @@ -69,7 +69,7 @@ require ( github.com/oklog/run v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rivo/uniseg v0.4.7 // indirect + github.com/rivo/uniseg v0.4.7 github.com/sahilm/fuzzy v0.1.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect diff --git a/internal/interfaces/cli/config.go b/internal/interfaces/cli/config.go index 48d63937..e621c68d 100644 --- a/internal/interfaces/cli/config.go +++ b/internal/interfaces/cli/config.go @@ -124,6 +124,17 @@ func NewWorkflowRepository() *repository.CompositeRepository { return repository.NewCompositeRepository(BuildWorkflowPaths()) } +func newWorkflowRepositoryForIdentifier(identifier string) (repo *repository.CompositeRepository, workflowName string) { + if !isExistingWorkflowFile(identifier) { + return NewWorkflowRepository(), identifier + } + + cleaned := filepath.Clean(identifier) + return repository.NewCompositeRepository([]repository.SourcedPath{ + {Path: filepath.Dir(cleaned), Source: repository.SourceLocal}, + }), filepath.Base(cleaned) +} + // buildFacade constructs a CLI-wide ports.WorkflowFacade for the read/validate operations // (list, history, status, validate). Execution paths build their own run-capable facade // (buildRunCapableFacade), so this read-only facade carries a no-op recorder and a zero diff --git a/internal/interfaces/cli/pack_resolver.go b/internal/interfaces/cli/pack_resolver.go index 1b2b53e7..4e3eaa57 100644 --- a/internal/interfaces/cli/pack_resolver.go +++ b/internal/interfaces/cli/pack_resolver.go @@ -18,10 +18,15 @@ import ( // parseWorkflowNamespace splits a workflow name into pack and workflow components. // For "speckit/specify", returns ("speckit", "specify"). // For "my-workflow" (no slash), returns ("", "my-workflow"). +// Existing filesystem paths are local workflows even when they contain slashes. // Splits on first "/" only per FR-001. // Note: duplicated as splitCallWorkflowName in internal/application/subworkflow_executor.go // — cross-layer import (application→interfaces) is forbidden by go-arch-lint. func parseWorkflowNamespace(name string) (packName, workflowName string) { + if isExistingWorkflowFile(name) { + return "", name + } + parts := strings.SplitN(name, "/", 2) if len(parts) == 2 { return parts[0], parts[1] @@ -29,6 +34,11 @@ func parseWorkflowNamespace(name string) (packName, workflowName string) { return "", name } +func isExistingWorkflowFile(name string) bool { + info, err := os.Stat(filepath.Clean(name)) + return err == nil && !info.IsDir() +} + // validatePackWorkflow checks that workflowName is listed as a public workflow in the pack manifest. // packDir is the root directory of the installed pack (contains manifest.yaml). // Returns USER.INPUT.VALIDATION_FAILED if the workflow is not listed, error if manifest is missing. diff --git a/internal/interfaces/cli/pack_resolver_test.go b/internal/interfaces/cli/pack_resolver_test.go index 99b21687..6143cf2b 100644 --- a/internal/interfaces/cli/pack_resolver_test.go +++ b/internal/interfaces/cli/pack_resolver_test.go @@ -119,6 +119,17 @@ func TestParseWorkflowNamespace_NamespacePresence(t *testing.T) { } } +func TestParseWorkflowNamespace_ExistingFilePathIsLocalWorkflow(t *testing.T) { + workflowPath := filepath.Join(t.TempDir(), "examples", "demo.yaml") + require.NoError(t, os.MkdirAll(filepath.Dir(workflowPath), 0o755)) + require.NoError(t, os.WriteFile(workflowPath, []byte("name: demo\n"), 0o644)) + + packName, workflowName := parseWorkflowNamespace(workflowPath) + + assert.Empty(t, packName) + assert.Equal(t, workflowPath, workflowName) +} + func TestValidatePackWorkflow(t *testing.T) { tests := []struct { name string diff --git a/internal/interfaces/cli/plugin_cmd.go b/internal/interfaces/cli/plugin_cmd.go index 0fb9fd64..461a56e1 100644 --- a/internal/interfaces/cli/plugin_cmd.go +++ b/internal/interfaces/cli/plugin_cmd.go @@ -43,6 +43,7 @@ Examples: cmd.AddCommand(newPluginEnableCommand(cfg)) cmd.AddCommand(newPluginDisableCommand(cfg)) cmd.AddCommand(newPluginInstallCommand(cfg)) + cmd.AddCommand(newPluginInitCommand()) cmd.AddCommand(newPluginUpdateCommand(cfg)) cmd.AddCommand(newPluginRemoveCommand(cfg)) cmd.AddCommand(newPluginSearchCommand(cfg)) @@ -274,12 +275,20 @@ func runPluginEnable(cmd *cobra.Command, cfg *Config, name string) error { return fmt.Errorf("failed to initialize plugin system: %w", err) } - // Enable the plugin - if err := result.Service.EnablePlugin(ctx, name); err != nil { + resolvedName := name + if _, found := result.Service.GetPlugin(name); !found { + if short := extractPluginName(name); short != name { + if _, found := result.Service.GetPlugin(short); found { + resolvedName = short + } + } + } + + if err := result.Service.EnablePlugin(ctx, resolvedName); err != nil { if writer.IsJSONFormat() { return writer.WriteError(err, ExitUser) } - return fmt.Errorf("failed to enable plugin %q: %w", name, err) + return fmt.Errorf("failed to enable plugin %q: %w", resolvedName, err) } // Save state @@ -292,12 +301,12 @@ func runPluginEnable(cmd *cobra.Command, cfg *Config, name string) error { if writer.IsJSONFormat() { return writer.WriteJSON(map[string]any{ - "name": name, + "name": resolvedName, "enabled": true, }) } - formatter.Success(fmt.Sprintf("Plugin %q enabled", name)) + formatter.Success(fmt.Sprintf("Plugin %q enabled", resolvedName)) return nil } diff --git a/internal/interfaces/cli/plugin_cmd_test.go b/internal/interfaces/cli/plugin_cmd_test.go index ae79fd15..a8b8e4ad 100644 --- a/internal/interfaces/cli/plugin_cmd_test.go +++ b/internal/interfaces/cli/plugin_cmd_test.go @@ -5,18 +5,22 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "os" "path/filepath" "strings" "testing" + domerrors "github.com/awf-project/cli/internal/domain/errors" "github.com/awf-project/cli/internal/interfaces/cli" "github.com/spf13/pflag" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// Acceptance: {"component":"integrate_plugin_init_command","criteria":["newPluginInitCommand() returns a Cobra command for awf plugin init ","newPluginCommand(cfg *Config) registers init without removing or changing existing plugin subcommands","awf plugin init awf-plugin-example --kind operation creates the full MVP file structure and prints next steps","omitted --kind behaves as operation","--output writes to the requested destination","--force is passed through to repository generation","unsupported --kind validator returns an explicit unsupported-kind error naming operation","repeated --kind and comma-separated --kind fail with single-kind guidance","invalid plugin names return user-facing validation errors before file writes","awf plugin init --help lists only the implemented operation kind as supported","existing plugin command tests still pass for list, install, remove, enable, disable, verify, update, and operations paths","awf plugin enable awf-plugin-example normalizes to runtime id example when the installed plugin manifest is known","awf plugin enable example continues to work"]} + func TestPluginCommand_Exists(t *testing.T) { cmd := cli.NewRootCommand() @@ -53,11 +57,204 @@ func TestPluginCommand_HasSubcommands(t *testing.T) { subcommands[sub.Name()] = true } - for _, name := range []string{"list", "enable", "disable", "search", "verify"} { + for _, name := range []string{"list", "enable", "disable", "install", "init", "remove", "search", "update", "verify"} { assert.True(t, subcommands[name], "plugin command should have '%s' subcommand", name) } } +func TestPluginInitCommand_ReturnsACobraCommandForAwfPluginInitName(t *testing.T) { + cmd := cli.NewRootCommand() + initCmd, _, err := cmd.Find([]string{"plugin", "init"}) + require.NoError(t, err) + require.NotNil(t, initCmd) + + assert.Equal(t, "init", initCmd.Name()) + assert.Contains(t, initCmd.Use, "") + assert.NotNil(t, initCmd.RunE) + assert.NotNil(t, initCmd.Flags().Lookup("kind")) + assert.NotNil(t, initCmd.Flags().Lookup("output")) + assert.NotNil(t, initCmd.Flags().Lookup("force")) +} + +func TestPluginInitCommand_AwfPluginInitAwfPluginExampleKindOperationCreatesTheFullMVPFileStructureAndPrintsNextSteps(t *testing.T) { + outputDir := filepath.Join(t.TempDir(), "awf-plugin-example") + + out, err := executePluginInitCommand( + t, + "plugin", "init", "awf-plugin-example", + "--kind", "operation", + "--output", outputDir, + ) + + require.NoError(t, err, "plugin init output:\n%s", out) + assertPluginInitGeneratedFileSet(t, outputDir) + assert.Equal(t, []string{ + "cd " + outputDir, + "make test", + "make build", + "make install-local", + "awf plugin enable awf-plugin-example", + "awf plugin list --operations", + "awf run examples/demo.yaml", + }, nonEmptyPluginInitOutputLines(out)) +} + +func TestPluginInitCommand_OmittedKindBehavesAsOperation(t *testing.T) { + outputDir := filepath.Join(t.TempDir(), "awf-plugin-example") + + out, err := executePluginInitCommand( + t, + "plugin", "init", "awf-plugin-example", + "--output", outputDir, + ) + + require.NoError(t, err, "plugin init output:\n%s", out) + assertPluginInitGeneratedFileSet(t, outputDir) + assert.Contains(t, string(readPluginInitTestFile(t, filepath.Join(outputDir, "plugin.yaml"))), "operations") + assert.Contains(t, string(readPluginInitTestFile(t, filepath.Join(outputDir, "main.go"))), "echo") +} + +func TestPluginInitCommand_OutputWritesToTheRequestedDestination(t *testing.T) { + requestedOutput := filepath.Join(t.TempDir(), "custom-destination") + + out, err := executePluginInitCommand( + t, + "plugin", "init", "awf-plugin-example", + "--kind", "operation", + "--output", requestedOutput, + ) + + require.NoError(t, err, "plugin init output:\n%s", out) + assertPluginInitGeneratedFileSet(t, requestedOutput) + assert.Contains(t, out, "cd "+requestedOutput) +} + +func TestPluginInitCommand_OutputPathContainingNextStepTextIsNotRewritten(t *testing.T) { + requestedOutput := filepath.Join(t.TempDir(), "awf plugin operations", "awf-plugin-example") + + out, err := executePluginInitCommand( + t, + "plugin", "init", "awf-plugin-example", + "--kind", "operation", + "--output", requestedOutput, + ) + + require.NoError(t, err, "plugin init output:\n%s", out) + assertPluginInitGeneratedFileSet(t, requestedOutput) + assert.Equal(t, []string{ + "cd " + requestedOutput, + "make test", + "make build", + "make install-local", + "awf plugin enable awf-plugin-example", + "awf plugin list --operations", + "awf run examples/demo.yaml", + }, nonEmptyPluginInitOutputLines(out)) +} + +func TestPluginInitCommand_ForceIsPassedThroughToRepositoryGeneration(t *testing.T) { + outputDir := filepath.Join(t.TempDir(), "awf-plugin-example") + require.NoError(t, os.MkdirAll(outputDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(outputDir, "go.mod"), []byte("module stale\n"), 0o644)) + + out, err := executePluginInitCommand( + t, + "plugin", "init", "awf-plugin-example", + "--kind", "operation", + "--output", outputDir, + "--force", + ) + + require.NoError(t, err, "plugin init output:\n%s", out) + assertPluginInitGeneratedFileSet(t, outputDir) + assert.NotContains(t, string(readPluginInitTestFile(t, filepath.Join(outputDir, "go.mod"))), "module stale") +} + +func TestPluginInitCommand_UnsupportedKindValidatorReturnsAnExplicitUnsupportedKindErrorNamingOperation(t *testing.T) { + outputDir := filepath.Join(t.TempDir(), "awf-plugin-example") + + out, err := executePluginInitCommand( + t, + "plugin", "init", "awf-plugin-example", + "--kind", "validator", + "--output", outputDir, + ) + + require.Error(t, err, "plugin init output:\n%s", out) + structErr := requirePluginInitCommandValidationError(t, err, "kind", "validator", "unsupported-kind") + assert.Equal( + t, + `USER.INPUT.VALIDATION_FAILED: invalid plugin init kind "validator" violates unsupported-kind: unsupported plugin init kind "validator"; supported kind is "operation"`, + structErr.Message, + ) + assert.NoDirExists(t, outputDir) +} + +func TestPluginInitCommand_RepeatedKindAndCommaSeparatedKindFailWithSingleKindGuidance(t *testing.T) { + tests := []struct { + name string + args []string + wantValue any + wantMessage string + }{ + { + name: "repeated --kind", + args: []string{"plugin", "init", "awf-plugin-example", "--kind", "operation", "--kind", "validator"}, + wantValue: []string{"operation", "validator"}, + wantMessage: `USER.INPUT.VALIDATION_FAILED: invalid plugin init kind ["operation" "validator"] violates single-kind: ` + + `choose exactly one --kind value; awf plugin init supports a single scaffold kind`, + }, + { + name: "comma-separated --kind", + args: []string{"plugin", "init", "awf-plugin-example", "--kind", "operation,validator"}, + wantValue: "operation,validator", + wantMessage: `USER.INPUT.VALIDATION_FAILED: invalid plugin init kind "operation,validator" violates single-kind: ` + + `choose exactly one --kind value; supported kind is "operation"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := executePluginInitCommand(t, tt.args...) + + require.Error(t, err, "plugin init output:\n%s", out) + structErr := requirePluginInitCommandValidationError(t, err, "kind", tt.wantValue, "single-kind") + assert.Equal(t, tt.wantMessage, structErr.Message) + }) + } +} + +func TestPluginInitCommand_InvalidPluginNamesReturnUserFacingValidationErrorsBeforeFileWrites(t *testing.T) { + outputDir := filepath.Join(t.TempDir(), "bad-output") + + out, err := executePluginInitCommand( + t, + "plugin", "init", "example", + "--output", outputDir, + ) + + require.Error(t, err, "plugin init output:\n%s", out) + structErr := requirePluginInitCommandValidationError(t, err, "name", "example", "required-prefix") + assert.Equal( + t, + `USER.INPUT.VALIDATION_FAILED: invalid plugin init name "example" violates required-prefix: plugin distribution name must start with "awf-plugin-", for example awf-plugin-example`, + structErr.Message, + ) + assert.NoDirExists(t, outputDir) +} + +func TestPluginInitCommand_HelpListsOnlyImplementedOperationKind(t *testing.T) { + out, err := executePluginInitCommand(t, "plugin", "init", "--help") + + require.NoError(t, err, "plugin init help output:\n%s", out) + assert.Contains(t, out, "operation") + assert.Contains(t, strings.ToLower(out), "supported") + assert.Contains(t, out, "plugin scaffold kind (supported: operation)") + assert.NotContains(t, strings.ToLower(out), "future") + assert.NotContains(t, strings.ToLower(out), "deferred") + assert.NotContains(t, out, "validator") +} + func TestPluginListCommand_HasLsAlias(t *testing.T) { cmd := cli.NewRootCommand() pluginCmd, _, err := cmd.Find([]string{"plugin"}) @@ -374,6 +571,42 @@ capabilities: assert.Contains(t, output, "enabled", "output should confirm plugin was enabled") } +func TestPluginCommand_EnableAwfPluginExampleNormalizesToRuntimeIDExampleWhenInstalledPluginManifestIsKnown(t *testing.T) { + tmpDir := t.TempDir() + pluginsDir := filepath.Join(tmpDir, "plugins") + writePluginCommandTestManifest(t, pluginsDir, "awf-plugin-example", "example") + t.Setenv("AWF_PLUGINS_PATH", pluginsDir) + + cmd := cli.NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"plugin", "enable", "awf-plugin-example", "--storage", tmpDir}) + + err := cmd.Execute() + require.NoError(t, err, "plugin enable output:\n%s", out.String()) + assert.Contains(t, out.String(), "example") + assert.NotContains(t, out.String(), "awf-plugin-example") +} + +func TestPluginCommand_EnableExampleContinuesToWork(t *testing.T) { + tmpDir := t.TempDir() + pluginsDir := filepath.Join(tmpDir, "plugins") + writePluginCommandTestManifest(t, pluginsDir, "awf-plugin-example", "example") + t.Setenv("AWF_PLUGINS_PATH", pluginsDir) + + cmd := cli.NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"plugin", "enable", "example", "--storage", tmpDir}) + + err := cmd.Execute() + require.NoError(t, err, "plugin enable output:\n%s", out.String()) + assert.Contains(t, out.String(), "example") + assert.Contains(t, out.String(), "enabled") +} + func TestPluginEnableCommand_JSONOutput(t *testing.T) { tmpDir := t.TempDir() @@ -882,3 +1115,44 @@ capabilities: err := cmd.Execute() require.Error(t, err, "should exit with code 1 when verification fails") } + +func executePluginInitCommand(t *testing.T, args ...string) (string, error) { + t.Helper() + + cmd := cli.NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(append(args, "--storage", filepath.Join(t.TempDir(), "storage"))) + + err := cmd.Execute() + return out.String(), err +} + +func requirePluginInitCommandValidationError(t *testing.T, err error, field string, value any, rule string) *domerrors.StructuredError { + t.Helper() + + var structErr *domerrors.StructuredError + require.True(t, errors.As(err, &structErr), "error must be a *domerrors.StructuredError; got %T: %v", err, err) + assert.Equal(t, domerrors.ErrorCodeUserInputValidationFailed, structErr.Code) + assert.Equal(t, 1, structErr.ExitCode()) + assert.Equal(t, field, structErr.Details["field"]) + assert.Equal(t, value, structErr.Details["value"]) + assert.Equal(t, rule, structErr.Details["rule"]) + + return structErr +} + +func writePluginCommandTestManifest(t *testing.T, pluginsDir, directoryName, runtimeID string) { + t.Helper() + + pluginDir := filepath.Join(pluginsDir, directoryName) + require.NoError(t, os.MkdirAll(pluginDir, 0o755)) + manifestContent := fmt.Sprintf(`name: %s +version: 1.0.0 +awf_version: ">=0.1.0" +capabilities: + - operations +`, runtimeID) + require.NoError(t, os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(manifestContent), 0o644)) +} diff --git a/internal/interfaces/cli/plugin_init_cmd.go b/internal/interfaces/cli/plugin_init_cmd.go new file mode 100644 index 00000000..13cfbe3e --- /dev/null +++ b/internal/interfaces/cli/plugin_init_cmd.go @@ -0,0 +1,40 @@ +package cli + +import "github.com/spf13/cobra" + +func newPluginInitCommand() *cobra.Command { + var flags pluginInitFlags + + cmd := &cobra.Command{ + Use: "init ", + Short: "Scaffold an AWF plugin repository", + Long: `Scaffold an AWF plugin repository. + +Supported kinds: + operation Generate an operation plugin scaffold.`, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + options, err := parsePluginInitOptions(args, flags) + if err != nil { + return err + } + + if err := generatePluginRepository(cmd.Context(), options, cmd.OutOrStdout()); err != nil { + return err + } + + return ensurePluginInitGoFlags() + }, + } + + cmd.Flags().StringArrayVar( + &flags.kind, + "kind", + nil, + "plugin scaffold kind (supported: operation)", + ) + cmd.Flags().StringVar(&flags.output, "output", "", "output directory") + cmd.Flags().BoolVar(&flags.force, "force", false, "overwrite an existing output directory") + + return cmd +} diff --git a/internal/interfaces/cli/plugin_init_docs_test.go b/internal/interfaces/cli/plugin_init_docs_test.go new file mode 100644 index 00000000..eca88496 --- /dev/null +++ b/internal/interfaces/cli/plugin_init_docs_test.go @@ -0,0 +1,194 @@ +package cli_test + +import ( + "bytes" + "path/filepath" + "strings" + "testing" + + "github.com/awf-project/cli/internal/interfaces/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPluginInitGeneratedDocs(t *testing.T) { + pluginDir := scaffoldPluginWithCLI(t) + readme := readGeneratedFile(t, pluginDir, "README.md") + + tests := []struct { + name string + markers []string + }{ + { + name: "asserts generated README.md includes the exact first-run command sequence from scaffold through demo workflow execution", + markers: []string{ + "awf plugin init awf-plugin-example --kind operation", + "cd awf-plugin-example", + "make test", + "make build", + "make install-local", + "awf plugin enable awf-plugin-example", + "awf plugin list --operations", + "awf run examples/demo.yaml", + }, + }, + { + name: "generated README.md explains manifest fields operation capability semantic version expectations AWF version compatibility config schema checksum release expectations common validation failures and that manifest name is the runtime id", + markers: []string{ + "Required fields", + "Optional fields", + "Supported capability", + "semantic version", + "AWF version compatibility", + "config schema", + "checksum", + "release", + "Common validation failures", + "manifest `name` is the runtime id", + }, + }, + { + name: "generated README.md identifies pkg/plugin/sdk as the supported Go authoring API", + markers: []string{ + "pkg/plugin/sdk", + "supported Go authoring API", + }, + }, + { + name: "generated README.md covers only the SDK APIs used by the operation scaffold", + markers: []string{ + "sdk.Serve", + "sdk.BasePlugin", + "lifecycle methods", + "OperationProvider", + "OperationSchemaProvider", + "schema metadata", + "input helpers", + "sdk.NewSuccessResult", + "sdk.NewErrorResult", + "structured outputs", + "compatibility expectations", + }, + }, + { + name: "generated README.md documents make build make test make lint make install-local make uninstall-local make package and make checksums", + markers: []string{ + "make build", + "make test", + "make lint", + "make install-local", + "make uninstall-local", + "make package", + "make checksums", + }, + }, + { + name: "generated README.md documents local awf plugin enable distribution-name awf plugin list operations and awf run examples demo yaml", + markers: []string{ + "awf plugin enable awf-plugin-example", + "awf plugin list --operations", + "awf run examples/demo.yaml", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, marker := range tt.markers { + assert.Contains(t, readme, marker) + } + }) + } + + assertOrderedSubstrings( + t, + readme, + []string{ + "awf plugin init awf-plugin-example --kind operation", + "cd awf-plugin-example", + "make test", + "make build", + "make install-local", + "awf plugin enable awf-plugin-example", + "awf plugin list --operations", + "awf run examples/demo.yaml", + }, + ) + assert.NotContains(t, readme, "future") + assert.NotContains(t, readme, "planned") + for _, kind := range futurePluginInitKinds() { + assert.NotContains(t, readme, kind) + } +} + +func TestPluginInitHelp(t *testing.T) { + help := pluginInitHelpOutput(t) + + assert.Contains(t, help, "Supported kinds:") + assert.Contains(t, help, "operation") + assert.Contains(t, help, "plugin scaffold kind (supported: operation)") + + assert.NotContains(t, help, "Future/deferred kinds:") + assert.NotContains(t, help, "planned") + assert.NotContains(t, help, "deferred") + for _, kind := range futurePluginInitKinds() { + assert.NotContains(t, help, kind) + } +} + +func TestPluginInitUnsupportedKindReturnsError(t *testing.T) { + outputDir := filepath.Join(t.TempDir(), "awf-plugin-example") + + out, err := executePluginInitCommand( + t, + "plugin", "init", "awf-plugin-example", + "--kind", "canonical-port", + "--output", outputDir, + ) + + require.Error(t, err, "plugin init output:\n%s", out) + structErr := requirePluginInitCommandValidationError(t, err, "kind", "canonical-port", "unsupported-kind") + assert.Equal( + t, + `USER.INPUT.VALIDATION_FAILED: invalid plugin init kind "canonical-port" violates unsupported-kind: unsupported plugin init kind "canonical-port"; supported kind is "operation"`, + structErr.Message, + ) + assert.NoDirExists(t, outputDir) +} + +func pluginInitHelpOutput(t *testing.T) string { + t.Helper() + + cmd := cli.NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"plugin", "init", "--help"}) + + require.NoError(t, cmd.Execute()) + return out.String() +} + +func futurePluginInitKinds() []string { + return []string{ + "canonical-port", + "adapter", + "direct-integration", + "hybrid", + "validator", + "step-type", + "event-listener", + "full", + } +} + +func assertOrderedSubstrings(t *testing.T, content string, markers []string) { + t.Helper() + + offset := 0 + for _, marker := range markers { + index := strings.Index(content[offset:], marker) + require.NotEqual(t, -1, index, "expected %q after byte offset %d", marker, offset) + offset += index + len(marker) + } +} diff --git a/internal/interfaces/cli/plugin_init_generate.go b/internal/interfaces/cli/plugin_init_generate.go new file mode 100644 index 00000000..714724ad --- /dev/null +++ b/internal/interfaces/cli/plugin_init_generate.go @@ -0,0 +1,199 @@ +package cli + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func generatePluginRepository(ctx context.Context, options pluginInitOptions, out io.Writer) error { + files, err := renderPluginInitTemplate(options) + if err != nil { + return err + } + + if err := detectPluginInitConflicts(options, files); err != nil { + return err + } + + if err := writePluginInitFiles(ctx, options, files); err != nil { + return err + } + + return printPluginInitNextSteps(options, out) +} + +func detectPluginInitConflicts(options pluginInitOptions, files []pluginInitFile) error { + if err := rejectPluginInitOutputRootSymlink(options.outputDir); err != nil { + return err + } + + for _, file := range files { + if err := rejectPluginInitSymlinkTarget(options.outputDir, file.path); err != nil { + return err + } + if options.force { + continue + } + + target, err := pluginInitTargetPath(options.outputDir, file.path) + if err != nil { + return err + } + + if _, err := os.Lstat(target); err == nil { + return fmt.Errorf("plugin init output %s already exists; use --force to overwrite", file.path) + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect plugin init output %s: %w", file.path, err) + } + } + + return nil +} + +func writePluginInitFiles(ctx context.Context, options pluginInitOptions, files []pluginInitFile) error { + if err := rejectPluginInitOutputRootSymlink(options.outputDir); err != nil { + return err + } + if err := os.MkdirAll(options.outputDir, 0o755); err != nil { //nolint:gosec // T065 requires generated directories to be user-readable/executable. + return fmt.Errorf("create plugin init output %s: %w", options.outputDir, err) + } + if err := os.Chmod(options.outputDir, 0o755); err != nil { //nolint:gosec // T065 requires stable 0755 generated directory permissions. + return fmt.Errorf("set plugin init output mode %s: %w", options.outputDir, err) + } + + for _, file := range files { + if err := ctx.Err(); err != nil { + return err + } + + target, err := pluginInitTargetPath(options.outputDir, file.path) + if err != nil { + return err + } + if err := rejectPluginInitSymlinkTarget(options.outputDir, file.path); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { //nolint:gosec // T065 requires generated directories to be 0755. + return fmt.Errorf("create plugin init directory for %s: %w", file.path, err) + } + if err := chmodPluginInitPathParents(options.outputDir, filepath.Dir(target)); err != nil { + return fmt.Errorf("set plugin init directory mode for %s: %w", file.path, err) + } + if err := os.WriteFile(target, file.content, 0o644); err != nil { //nolint:gosec // T065 requires generated regular files to be 0644. + return fmt.Errorf("write plugin init file %s: %w", file.path, err) + } + if err := os.Chmod(target, 0o644); err != nil { //nolint:gosec // T065 requires stable 0644 generated file permissions. + return fmt.Errorf("set plugin init file mode %s: %w", file.path, err) + } + } + + return nil +} + +func printPluginInitNextSteps(options pluginInitOptions, out io.Writer) error { + steps := []string{ + "cd " + options.outputDir, + "make test", + "make build", + "make install-local", + "awf plugin enable " + options.distributionName, + "awf plugin list --operations", + "awf run examples/demo.yaml", + } + + for _, step := range steps { + if _, err := fmt.Fprintln(out, step); err != nil { + return fmt.Errorf("write plugin init next steps: %w", err) + } + } + + return nil +} + +func pluginInitTargetPath(outputDir, generatedPath string) (string, error) { + var emptyPath string + + cleanGeneratedPath := filepath.Clean(filepath.FromSlash(generatedPath)) + if filepath.IsAbs(cleanGeneratedPath) || cleanGeneratedPath == "." || cleanGeneratedPath == ".." || + strings.HasPrefix(cleanGeneratedPath, ".."+string(filepath.Separator)) { + return emptyPath, fmt.Errorf("invalid plugin init generated path %s", generatedPath) + } + + target := filepath.Join(outputDir, cleanGeneratedPath) + cleanOutputDir := filepath.Clean(outputDir) + rel, err := filepath.Rel(cleanOutputDir, target) + if err != nil { + return emptyPath, fmt.Errorf("resolve plugin init generated path %s: %w", generatedPath, err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return emptyPath, fmt.Errorf("invalid plugin init generated path %s", generatedPath) + } + + return target, nil +} + +func rejectPluginInitOutputRootSymlink(outputDir string) error { + info, err := os.Lstat(filepath.Clean(outputDir)) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("inspect plugin init output directory: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("plugin init output directory uses symbolic link") + } + + return nil +} + +func rejectPluginInitSymlinkTarget(outputDir, generatedPath string) error { + cleanGeneratedPath := filepath.Clean(filepath.FromSlash(generatedPath)) + if _, err := pluginInitTargetPath(outputDir, generatedPath); err != nil { + return err + } + + cleanOutputDir := filepath.Clean(outputDir) + current := cleanOutputDir + for _, component := range strings.Split(cleanGeneratedPath, string(filepath.Separator)) { + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("inspect plugin init output %s: %w", generatedPath, err) + } + if info.Mode()&os.ModeSymlink != 0 { + rel, relErr := filepath.Rel(cleanOutputDir, current) + if relErr != nil { + return fmt.Errorf("inspect plugin init output %s: %w", generatedPath, relErr) + } + return fmt.Errorf("plugin init output %s uses symbolic link %s", generatedPath, filepath.ToSlash(rel)) + } + } + + return nil +} + +func chmodPluginInitPathParents(outputDir, dir string) error { + cleanOutputDir := filepath.Clean(outputDir) + for { + if err := os.Chmod(dir, 0o755); err != nil { //nolint:gosec // T065 requires stable 0755 generated directory permissions. + return err + } + if filepath.Clean(dir) == cleanOutputDir { + return nil + } + + parent := filepath.Dir(dir) + if parent == dir { + return nil + } + dir = parent + } +} diff --git a/internal/interfaces/cli/plugin_init_generate_test.go b/internal/interfaces/cli/plugin_init_generate_test.go new file mode 100644 index 00000000..3df0b385 --- /dev/null +++ b/internal/interfaces/cli/plugin_init_generate_test.go @@ -0,0 +1,281 @@ +package cli_test + +import ( + "bytes" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/awf-project/cli/internal/interfaces/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPluginInitGenerate_CreatesAMissingOutputDirectoryAndWritesEveryRenderedFile(t *testing.T) { + outputDir := filepath.Join(t.TempDir(), "awf-plugin-example") + + out, err := executePluginInitGenerateCommand(t, outputDir, false) + + require.NoError(t, err, "plugin init output:\n%s", out) + assertPluginInitGeneratedFileSet(t, outputDir) +} + +func TestPluginInitGenerate_SucceedsInAnExistingEmptyOutputDirectory(t *testing.T) { + outputDir := filepath.Join(t.TempDir(), "awf-plugin-example") + require.NoError(t, os.Mkdir(outputDir, 0o755)) + + out, err := executePluginInitGenerateCommand(t, outputDir, false) + + require.NoError(t, err, "plugin init output:\n%s", out) + assertPluginInitGeneratedFileSet(t, outputDir) +} + +func TestPluginInitGenerate_SucceedsInAnExistingOutputDirectoryContainingUnrelatedFilesAndPreservesThoseFiles(t *testing.T) { + outputDir := filepath.Join(t.TempDir(), "awf-plugin-example") + require.NoError(t, os.Mkdir(outputDir, 0o755)) + unrelatedPath := filepath.Join(outputDir, "notes.txt") + unrelatedContent := []byte("keep this file\n") + require.NoError(t, os.WriteFile(unrelatedPath, unrelatedContent, 0o644)) + + out, err := executePluginInitGenerateCommand(t, outputDir, false) + + require.NoError(t, err, "plugin init output:\n%s", out) + assertPluginInitGeneratedFileSet(t, outputDir) + assert.Equal(t, unrelatedContent, readPluginInitTestFile(t, unrelatedPath)) +} + +func TestPluginInitGenerate_RejectsAnExistingConflictingGeneratedFileWithoutForce(t *testing.T) { + outputDir := t.TempDir() + conflictPath := filepath.Join(outputDir, "go.mod") + require.NoError(t, os.WriteFile(conflictPath, []byte("module existing\n"), 0o644)) + + out, err := executePluginInitGenerateCommand(t, outputDir, false) + + require.EqualError(t, err, "plugin init output go.mod already exists; use --force to overwrite", "plugin init output:\n%s", out) + assert.Equal(t, []byte("module existing\n"), readPluginInitTestFile(t, conflictPath)) +} + +func TestPluginInitGenerate_ConflictRejectionHappensBeforeAnyRenderedFileIsWrittenOrOverwritten(t *testing.T) { + outputDir := t.TempDir() + conflictPath := filepath.Join(outputDir, "go.mod") + require.NoError(t, os.WriteFile(conflictPath, []byte("module existing\n"), 0o644)) + + out, err := executePluginInitGenerateCommand(t, outputDir, false) + + require.EqualError(t, err, "plugin init output go.mod already exists; use --force to overwrite", "plugin init output:\n%s", out) + assert.Equal(t, []byte("module existing\n"), readPluginInitTestFile(t, conflictPath)) + assert.NoFileExists(t, filepath.Join(outputDir, "main.go")) + assert.NoFileExists(t, filepath.Join(outputDir, "plugin.yaml")) +} + +func TestPluginInitGenerate_RejectsSymlinkedGeneratedDirectoryWithoutWritingOutsideOutput(t *testing.T) { + outputDir := t.TempDir() + victimDir := t.TempDir() + symlinkPath := filepath.Join(outputDir, "examples") + require.NoError(t, os.Symlink(victimDir, symlinkPath)) + + out, err := executePluginInitGenerateCommand(t, outputDir, false) + + require.EqualError(t, err, "plugin init output examples/demo.yaml uses symbolic link examples", "plugin init output:\n%s", out) + assert.NoFileExists(t, filepath.Join(victimDir, "demo.yaml")) + assert.NoFileExists(t, filepath.Join(outputDir, "go.mod")) +} + +func TestPluginInitGenerate_RejectsSymlinkedGeneratedFileWithForceWithoutWritingOutsideOutput(t *testing.T) { + outputDir := t.TempDir() + victimDir := t.TempDir() + victimGoMod := filepath.Join(victimDir, "go.mod") + require.NoError(t, os.WriteFile(victimGoMod, []byte("module victim\n"), 0o644)) + require.NoError(t, os.Symlink(victimGoMod, filepath.Join(outputDir, "go.mod"))) + + out, err := executePluginInitGenerateCommand(t, outputDir, true) + + require.EqualError(t, err, "plugin init output go.mod uses symbolic link go.mod", "plugin init output:\n%s", out) + assert.Equal(t, []byte("module victim\n"), readPluginInitTestFile(t, victimGoMod)) + assert.NoFileExists(t, filepath.Join(outputDir, "README.md")) +} + +func TestPluginInitGenerate_RejectsSymlinkedOutputDirectoryWithoutWritingOutsideOutput(t *testing.T) { + parentDir := t.TempDir() + victimDir := t.TempDir() + outputDir := "awf-plugin-example" + require.NoError(t, os.Symlink(victimDir, filepath.Join(parentDir, outputDir))) + + wd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(parentDir)) + t.Cleanup(func() { require.NoError(t, os.Chdir(wd)) }) + + out, err := executePluginInitGenerateCommand(t, outputDir, true) + + require.EqualError(t, err, "plugin init output directory uses symbolic link", "plugin init output:\n%s", out) + assert.NoFileExists(t, filepath.Join(victimDir, "go.mod")) + assert.NoFileExists(t, filepath.Join(victimDir, "plugin.yaml")) +} + +func TestPluginInitGenerate_OverwritesConflictingGeneratedTargetFilesWhenForceIsTrue(t *testing.T) { + outputDir := t.TempDir() + conflictPath := filepath.Join(outputDir, "go.mod") + require.NoError(t, os.WriteFile(conflictPath, []byte("module existing\n"), 0o644)) + + out, err := executePluginInitGenerateCommand(t, outputDir, true) + + require.NoError(t, err, "plugin init output:\n%s", out) + assertPluginInitGeneratedFileSet(t, outputDir) + assert.NotEqual(t, []byte("module existing\n"), readPluginInitTestFile(t, conflictPath)) +} + +func TestPluginInitGenerate_NeverDeletesUnrelatedFilesWhenForceIsTrue(t *testing.T) { + outputDir := t.TempDir() + unrelatedPath := filepath.Join(outputDir, "local-only.txt") + unrelatedContent := []byte("local state\n") + require.NoError(t, os.WriteFile(unrelatedPath, unrelatedContent, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(outputDir, "go.mod"), []byte("module existing\n"), 0o644)) + + out, err := executePluginInitGenerateCommand(t, outputDir, true) + + require.NoError(t, err, "plugin init output:\n%s", out) + assertPluginInitGeneratedFileSet(t, outputDir) + assert.Equal(t, unrelatedContent, readPluginInitTestFile(t, unrelatedPath)) +} + +func TestPluginInitGenerate_WrittenDirectoriesUseMode0755AndGeneratedRegularFilesUseMode0644(t *testing.T) { + outputDir := filepath.Join(t.TempDir(), "awf-plugin-example") + + out, err := executePluginInitGenerateCommand(t, outputDir, false) + + require.NoError(t, err, "plugin init output:\n%s", out) + assertPluginInitPathMode(t, outputDir, 0o755) + assertPluginInitPathMode(t, filepath.Join(outputDir, ".github"), 0o755) + assertPluginInitPathMode(t, filepath.Join(outputDir, ".github", "workflows"), 0o755) + assertPluginInitPathMode(t, filepath.Join(outputDir, "examples"), 0o755) + + for _, path := range expectedPluginInitGeneratedPaths() { + t.Run(path, func(t *testing.T) { + assertPluginInitPathMode(t, filepath.Join(outputDir, filepath.FromSlash(path)), 0o644) + }) + } +} + +func TestPluginInitGenerate_WriteErrorsIncludeTheRelativeGeneratedPathThatFailed(t *testing.T) { + outputDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(outputDir, ".github"), []byte("not a directory\n"), 0o644)) + + out, err := executePluginInitGenerateCommand(t, outputDir, true) + + require.Error(t, err, "plugin init output:\n%s", out) + assert.Contains(t, err.Error(), ".github/workflows/release.yml") +} + +func TestPluginInitGenerate_SuccessfulOutputIncludesExactlyTheRequiredNextStepStages(t *testing.T) { + outputDir := filepath.Join(t.TempDir(), "awf-plugin-example") + + out, err := executePluginInitGenerateCommand(t, outputDir, false) + + require.NoError(t, err, "plugin init output:\n%s", out) + assert.Equal(t, []string{ + "cd " + outputDir, + "make test", + "make build", + "make install-local", + "awf plugin enable awf-plugin-example", + "awf plugin list --operations", + "awf run examples/demo.yaml", + }, nonEmptyPluginInitOutputLines(out)) +} + +func TestPluginInitGenerate_SuccessfulOutputAvoidsUnrelatedAbsolutePrivatePathsExceptTheRequestedOrImpliedTargetPath(t *testing.T) { + privateRoot := t.TempDir() + outputDir := filepath.Join(privateRoot, "awf-plugin-example") + + out, err := executePluginInitGenerateCommand(t, outputDir, false) + + require.NoError(t, err, "plugin init output:\n%s", out) + assert.Contains(t, out, outputDir) + outputWithoutTarget := strings.ReplaceAll(out, outputDir, "") + assert.NotContains(t, outputWithoutTarget, "/home/") + assert.NotContains(t, outputWithoutTarget, "/tmp/awf-code-ctx") + assert.NotContains(t, outputWithoutTarget, privateRoot) +} + +func executePluginInitGenerateCommand(t *testing.T, outputDir string, force bool) (string, error) { + t.Helper() + + cmd := cli.NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + args := []string{ + "plugin", + "init", + "awf-plugin-example", + "--kind", + "operation", + "--output", + outputDir, + "--storage", + filepath.Join(t.TempDir(), "storage"), + } + if force { + args = append(args, "--force") + } + cmd.SetArgs(args) + + err := cmd.Execute() + return out.String(), err +} + +func assertPluginInitGeneratedFileSet(t *testing.T, outputDir string) { + t.Helper() + + for _, path := range expectedPluginInitGeneratedPaths() { + fullPath := filepath.Join(outputDir, filepath.FromSlash(path)) + assert.FileExists(t, fullPath) + assert.NotEmpty(t, readPluginInitTestFile(t, fullPath)) + } +} + +func expectedPluginInitGeneratedPaths() []string { + return []string{ + ".github/workflows/release.yml", + "AGENTS.md", + "Makefile", + "README.md", + "examples/demo.yaml", + "go.mod", + "main.go", + "main_test.go", + "plugin.yaml", + } +} + +func assertPluginInitPathMode(t *testing.T, path string, want fs.FileMode) { + t.Helper() + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, want, info.Mode().Perm()) +} + +func readPluginInitTestFile(t *testing.T, path string) []byte { + t.Helper() + + content, err := os.ReadFile(path) + require.NoError(t, err) + return content +} + +func nonEmptyPluginInitOutputLines(output string) []string { + lines := strings.Split(output, "\n") + filtered := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + filtered = append(filtered, line) + } + } + return filtered +} diff --git a/internal/interfaces/cli/plugin_init_options.go b/internal/interfaces/cli/plugin_init_options.go new file mode 100644 index 00000000..85dc94e3 --- /dev/null +++ b/internal/interfaces/cli/plugin_init_options.go @@ -0,0 +1,178 @@ +package cli + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" + + domerrors "github.com/awf-project/cli/internal/domain/errors" +) + +const ( + pluginInitDistributionPrefix = "awf-plugin-" + pluginInitExampleName = "awf-plugin-example" + pluginInitKindOperation = "operation" +) + +var pluginInitDistributionNamePattern = regexp.MustCompile(`^awf-plugin-[a-z0-9]+(?:-[a-z0-9]+)*$`) + +type pluginInitOptions struct { + distributionName string + runtimeID string + kind string + outputDir string + force bool +} + +type pluginInitFlags struct { + kind []string + output string + force bool +} + +func parsePluginInitOptions(args []string, flags pluginInitFlags) (pluginInitOptions, error) { + if len(args) != 1 { + value := any(args) + if len(args) == 0 { + value = "" + } + + return pluginInitOptions{}, newPluginInitValidationError( + "name", + value, + "single-name", + "provide exactly one argument such as awf-plugin-example", + ) + } + + distributionName := args[0] + runtimeID, err := derivePluginRuntimeID(distributionName) + if err != nil { + return pluginInitOptions{}, err + } + + kindValue := "" + if len(flags.kind) > 1 { + return pluginInitOptions{}, newPluginInitValidationError( + "kind", + flags.kind, + "single-kind", + "choose exactly one --kind value; awf plugin init supports a single scaffold kind", + ) + } + if len(flags.kind) == 1 { + kindValue = flags.kind[0] + } + + kind, err := validatePluginInitKind(kindValue) + if err != nil { + return pluginInitOptions{}, err + } + + outputDir, err := normalizePluginInitOutput(distributionName, flags.output) + if err != nil { + return pluginInitOptions{}, err + } + + return pluginInitOptions{ + distributionName: distributionName, + runtimeID: runtimeID, + kind: kind, + outputDir: outputDir, + force: flags.force, + }, nil +} + +func derivePluginRuntimeID(distributionName string) (runtimeID string, err error) { + if err := validatePluginDistributionName(distributionName); err != nil { + return runtimeID, err + } + + return strings.TrimPrefix(distributionName, pluginInitDistributionPrefix), nil +} + +func validatePluginInitKind(rawValue string) (kind string, err error) { + if rawValue == "" { + return pluginInitKindOperation, nil + } + if strings.Contains(rawValue, ",") { + return kind, newPluginInitValidationError( + "kind", + rawValue, + "single-kind", + fmt.Sprintf("choose exactly one --kind value; supported kind is %q", pluginInitKindOperation), + ) + } + if rawValue != pluginInitKindOperation { + return kind, newPluginInitValidationError( + "kind", + rawValue, + "unsupported-kind", + fmt.Sprintf("unsupported plugin init kind %q; supported kind is %q", rawValue, pluginInitKindOperation), + ) + } + + return pluginInitKindOperation, nil +} + +func normalizePluginInitOutput(distributionName, output string) (outputDir string, err error) { + if err := validatePluginDistributionName(distributionName); err != nil { + return outputDir, newPluginInitValidationError( + "output", + distributionName, + "safe-distribution-name", + fmt.Sprintf("output defaults require a safe distribution name such as %s", pluginInitExampleName), + ) + } + + if output == "" { + return "." + string(filepath.Separator) + distributionName, nil + } + + cleaned := filepath.Clean(output) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { + return outputDir, newPluginInitValidationError( + "output", + output, + "safe-output-path", + "choose a relative output path inside the current directory", + ) + } + + return strings.TrimPrefix(cleaned, "."+string(filepath.Separator)), nil +} + +func validatePluginDistributionName(distributionName string) error { + if !strings.HasPrefix(distributionName, pluginInitDistributionPrefix) { + return newPluginInitValidationError( + "name", + distributionName, + "required-prefix", + fmt.Sprintf("plugin distribution name must start with %q, for example %s", pluginInitDistributionPrefix, pluginInitExampleName), + ) + } + if !pluginInitDistributionNamePattern.MatchString(distributionName) { + return newPluginInitValidationError( + "name", + distributionName, + "lowercase-ascii-filesystem-safe", + fmt.Sprintf("plugin distribution name must be lowercase ASCII, filesystem-safe, and shaped like %s", pluginInitExampleName), + ) + } + + return nil +} + +func newPluginInitValidationError(field string, value any, rule, guidance string) *domerrors.StructuredError { + return domerrors.NewUserError( + domerrors.ErrorCodeUserInputValidationFailed, + fmt.Sprintf("%s: invalid plugin init %s %q violates %s: %s", domerrors.ErrorCodeUserInputValidationFailed, field, value, rule, guidance), + map[string]any{ + "field": field, + "value": value, + "rule": rule, + }, + nil, + ) +} diff --git a/internal/interfaces/cli/plugin_init_options_test.go b/internal/interfaces/cli/plugin_init_options_test.go new file mode 100644 index 00000000..d56e78ba --- /dev/null +++ b/internal/interfaces/cli/plugin_init_options_test.go @@ -0,0 +1,172 @@ +package cli + +import ( + "errors" + "path/filepath" + "testing" + + domerrors "github.com/awf-project/cli/internal/domain/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPluginInitOptions_ParsePluginInitOptionsReturnsDistributionNameRuntimeIDKindOutputDirectoryAndForceFlagForAwfPluginExample(t *testing.T) { + got, err := parsePluginInitOptions([]string{"awf-plugin-example"}, pluginInitFlags{ + kind: []string{"operation"}, + output: "./plugins/example", + force: true, + }) + + require.NoError(t, err) + assert.Equal(t, "awf-plugin-example", got.distributionName) + assert.Equal(t, "example", got.runtimeID) + assert.Equal(t, "operation", got.kind) + assert.Equal(t, "plugins/example", got.outputDir) + assert.True(t, got.force) +} + +func TestPluginInitOptions_ParsePluginInitOptionsDefaultsOmittedKindToOperation(t *testing.T) { + got, err := parsePluginInitOptions([]string{"awf-plugin-example"}, pluginInitFlags{}) + + require.NoError(t, err) + assert.Equal(t, "operation", got.kind) +} + +func TestPluginInitName_ParsePluginInitOptionsRejectsInvalidNamesBeforeAnyOutputPathIsUsed(t *testing.T) { + tests := []struct { + name string + args []string + value string + }{ + {name: "missing name", args: nil, value: ""}, + {name: "empty name", args: []string{""}, value: ""}, + {name: "uppercase", args: []string{"awf-plugin-Example"}, value: "awf-plugin-Example"}, + {name: "unicode", args: []string{"awf-plugin-cafeé"}, value: "awf-plugin-cafeé"}, + {name: "whitespace", args: []string{"awf-plugin-example name"}, value: "awf-plugin-example name"}, + {name: "shell metacharacter", args: []string{"awf-plugin-example;rm"}, value: "awf-plugin-example;rm"}, + {name: "dot dot", args: []string{"awf-plugin-.."}, value: "awf-plugin-.."}, + {name: "slash", args: []string{"awf-plugin-example/name"}, value: "awf-plugin-example/name"}, + {name: "backslash", args: []string{`awf-plugin-example\name`}, value: `awf-plugin-example\name`}, + {name: "leading dot", args: []string{"awf-plugin-.example"}, value: "awf-plugin-.example"}, + {name: "missing prefix", args: []string{"example"}, value: "example"}, + {name: "trailing hyphen", args: []string{"awf-plugin-example-"}, value: "awf-plugin-example-"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parsePluginInitOptions(tt.args, pluginInitFlags{output: "../must-not-be-normalized"}) + + structErr := requirePluginInitValidationError(t, err, "name", tt.value) + assert.NotEmpty(t, structErr.Details["rule"]) + assert.Contains(t, structErr.Error(), "awf-plugin-example") + }) + } +} + +func TestPluginInitName_InvalidNameErrorsIncludeInvalidValueViolatedRuleAndAwfPluginExample(t *testing.T) { + _, err := parsePluginInitOptions([]string{"bad name"}, pluginInitFlags{}) + + structErr := requirePluginInitValidationError(t, err, "name", "bad name") + assert.Contains(t, structErr.Error(), "bad name") + assert.Contains(t, structErr.Error(), "awf-plugin-example") + assert.NotEmpty(t, structErr.Details["rule"]) +} + +func TestPluginInitName_ParsePluginInitOptionsRejectsExtraPositionalArguments(t *testing.T) { + args := []string{"awf-plugin-example", "awf-plugin-other"} + _, err := parsePluginInitOptions(args, pluginInitFlags{}) + + structErr := requirePluginInitValidationError(t, err, "name", args) + assert.Equal(t, "single-name", structErr.Details["rule"]) + assert.Contains(t, structErr.Error(), "exactly one ") + assert.Contains(t, structErr.Error(), "awf-plugin-example") +} + +func TestPluginInitOptions_DerivePluginRuntimeIDAwfPluginSecurityValidatorReturnsSecurityValidator(t *testing.T) { + got, err := derivePluginRuntimeID("awf-plugin-security-validator") + + require.NoError(t, err) + assert.Equal(t, "security-validator", got) +} + +func TestPluginInitOptions_DerivePluginRuntimeIDRejectsAwfPluginWithEmptyRuntimeID(t *testing.T) { + _, err := derivePluginRuntimeID("awf-plugin-") + + requirePluginInitValidationError(t, err, "name", "awf-plugin-") +} + +func TestPluginInitKind_ValidatePluginInitKindOperationSucceeds(t *testing.T) { + got, err := validatePluginInitKind("operation") + + require.NoError(t, err) + assert.Equal(t, "operation", got) +} + +func TestPluginInitKind_ValidatePluginInitKindEmptyResolvesToOperation(t *testing.T) { + got, err := validatePluginInitKind("") + + require.NoError(t, err) + assert.Equal(t, "operation", got) +} + +func TestPluginInitKind_ValidatePluginInitKindValidatorReturnsUnsupportedKindErrorNamingOperationAsSupportedKind(t *testing.T) { + _, err := validatePluginInitKind("validator") + + structErr := requirePluginInitValidationError(t, err, "kind", "validator") + assert.Contains(t, structErr.Error(), "unsupported") + assert.Contains(t, structErr.Error(), `supported kind is "operation"`) +} + +func TestPluginInitKind_ValidatePluginInitKindOperationValidatorRejectsCommaSeparatedKindsWithSingleKindGuidance(t *testing.T) { + _, err := validatePluginInitKind("operation,validator") + + structErr := requirePluginInitValidationError(t, err, "kind", "operation,validator") + assert.Contains(t, structErr.Error(), "choose exactly one --kind value") + assert.Contains(t, structErr.Error(), `supported kind is "operation"`) + assert.NotContains(t, structErr.Error(), "future") + assert.NotContains(t, structErr.Error(), "MVP") +} + +func TestPluginInitKind_RepeatedKindValuesAreRejectedAsSingleKindViolation(t *testing.T) { + _, err := parsePluginInitOptions([]string{"awf-plugin-example"}, pluginInitFlags{ + kind: []string{"operation", "validator"}, + }) + + structErr := requirePluginInitValidationError(t, err, "kind", []string{"operation", "validator"}) + assert.Contains(t, structErr.Error(), "single") + assert.Contains(t, structErr.Error(), "kind") +} + +func TestPluginInitOptions_NormalizePluginInitOutputDefaultsAndCleansWithoutAcceptingTraversalThroughPluginName(t *testing.T) { + gotDefault, err := normalizePluginInitOutput("awf-plugin-example", "") + require.NoError(t, err) + assert.Equal(t, "./awf-plugin-example", gotDefault) + + gotExplicit, err := normalizePluginInitOutput("awf-plugin-example", "./plugins/../generated/example") + require.NoError(t, err) + assert.Equal(t, "generated/example", gotExplicit) + + _, err = normalizePluginInitOutput("../awf-plugin-example", "") + requirePluginInitValidationError(t, err, "output", "../awf-plugin-example") + + absoluteOutput := filepath.Join(string(filepath.Separator), "tmp", "awf-plugin-example") + gotAbsolute, err := normalizePluginInitOutput("awf-plugin-example", absoluteOutput) + require.NoError(t, err) + assert.Equal(t, absoluteOutput, gotAbsolute) +} + +func requirePluginInitValidationError(t *testing.T, err error, field string, value any) *domerrors.StructuredError { + t.Helper() + + require.Error(t, err) + + var structErr *domerrors.StructuredError + require.True(t, errors.As(err, &structErr), "error must be a *domerrors.StructuredError") + assert.Equal(t, domerrors.ErrorCodeUserInputValidationFailed, structErr.Code) + assert.Equal(t, ExitUser, categorizeError(err)) + assert.Contains(t, structErr.Error(), string(domerrors.ErrorCodeUserInputValidationFailed)) + assert.Equal(t, field, structErr.Details["field"]) + assert.Equal(t, value, structErr.Details["value"]) + + return structErr +} diff --git a/internal/interfaces/cli/plugin_init_template.go b/internal/interfaces/cli/plugin_init_template.go new file mode 100644 index 00000000..830aa5f8 --- /dev/null +++ b/internal/interfaces/cli/plugin_init_template.go @@ -0,0 +1,173 @@ +package cli + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "text/template" +) + +const awfPluginSDKModulePath = "github.com/awf-project/cli" + +var awfModuleVersionPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$`) + +type pluginInitFile struct { + path string + content []byte +} + +type pluginInitTemplateData struct { + DistributionName string + RuntimeID string + Kind string + AWFModuleVersion string + LocalAWFModulePath string + HasLocalAWFModule bool + Release pluginInitReleaseTemplateData +} + +type pluginInitReleaseTemplateData struct { + Version string + ArchiveName string + ArchiveSuffix string + ChecksumFile string + PackageDir string + BinaryName string + ManifestName string +} + +func newPluginInitReleaseTemplateData(options pluginInitOptions) pluginInitReleaseTemplateData { + const version = "0.1.0" + + archiveSuffix := "$(GOOS)_$(GOARCH).tar.gz" + return pluginInitReleaseTemplateData{ + Version: version, + ArchiveName: fmt.Sprintf("%s_%s_%s", options.distributionName, version, archiveSuffix), + ArchiveSuffix: archiveSuffix, + ChecksumFile: "checksums.txt", + PackageDir: "package", + BinaryName: options.distributionName, + ManifestName: "plugin.yaml", + } +} + +func renderPluginInitTemplate(options pluginInitOptions) ([]pluginInitFile, error) { + localAWFModulePath, hasLocalAWFModule := currentAWFModulePath() + awfModuleVersion, err := currentAWFModuleVersion(hasLocalAWFModule) + if err != nil { + return nil, err + } + data := pluginInitTemplateData{ + DistributionName: options.distributionName, + RuntimeID: options.runtimeID, + Kind: options.kind, + AWFModuleVersion: awfModuleVersion, + LocalAWFModulePath: localAWFModulePath, + HasLocalAWFModule: hasLocalAWFModule, + Release: newPluginInitReleaseTemplateData(options), + } + + descriptors := operationPluginInitTemplates + files := make([]pluginInitFile, 0, len(descriptors)) + for _, descriptor := range descriptors { + tmpl, err := template.New(descriptor.path).Parse(descriptor.template) + if err != nil { + return nil, fmt.Errorf("parse plugin init template %s: %w", descriptor.path, err) + } + + var content bytes.Buffer + if err := tmpl.Execute(&content, data); err != nil { + return nil, fmt.Errorf("render plugin init template %s: %w", descriptor.path, err) + } + + files = append(files, pluginInitFile{ + path: descriptor.path, + content: content.Bytes(), + }) + } + + return files, nil +} + +func ensurePluginInitGoFlags() error { + current := os.Getenv("GOFLAGS") + for _, field := range strings.Fields(current) { + if strings.HasPrefix(field, "-mod=") { + return nil + } + } + if current == "" { + if err := os.Setenv("GOFLAGS", "-mod=mod"); err != nil { + return fmt.Errorf("set plugin init go flags: %w", err) + } + return nil + } + if err := os.Setenv("GOFLAGS", current+" -mod=mod"); err != nil { + return fmt.Errorf("set plugin init go flags: %w", err) + } + return nil +} + +func currentAWFModuleVersion(hasLocalAWFModule bool) (string, error) { + if hasLocalAWFModule { + return "v0.0.0", nil + } + + version := Version + if version != "" && !strings.HasPrefix(version, "v") { + version = "v" + version + } + if awfModuleVersionPattern.MatchString(version) { + return version, nil + } + + return "", fmt.Errorf( + "cannot scaffold plugin SDK dependency: run awf plugin init from an AWF source checkout or use a released awf binary with a semantic version", + ) +} + +func currentAWFModulePath() (string, bool) { + wd, err := os.Getwd() + if err == nil { + if root, ok := findAWFModuleRoot(wd); ok { + return filepath.ToSlash(root), true + } + } + + _, sourceFile, _, ok := runtime.Caller(0) + if ok { + if root, found := findAWFModuleRoot(filepath.Dir(sourceFile)); found { + return filepath.ToSlash(root), true + } + } + + return "", false +} + +func findAWFModuleRoot(start string) (string, bool) { + path := filepath.Clean(start) + for { + if isAWFModuleRoot(path) { + return path, true + } + + parent := filepath.Dir(path) + if parent == path { + return path, false + } + path = parent + } +} + +func isAWFModuleRoot(path string) bool { + goModPath := filepath.Join(path, "go.mod") + content, err := os.ReadFile(goModPath) + if err != nil { + return false + } + return strings.Contains(string(content), "module "+awfPluginSDKModulePath+"\n") +} diff --git a/internal/interfaces/cli/plugin_init_template_internal_test.go b/internal/interfaces/cli/plugin_init_template_internal_test.go new file mode 100644 index 00000000..f2b44341 --- /dev/null +++ b/internal/interfaces/cli/plugin_init_template_internal_test.go @@ -0,0 +1,38 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCurrentAWFModuleVersion_UsesPseudoVersionForLocalSourceCheckout(t *testing.T) { + version, err := currentAWFModuleVersion(true) + + require.NoError(t, err) + assert.Equal(t, "v0.0.0", version) +} + +func TestCurrentAWFModuleVersion_UsesReleasedCLIVersionWhenNoLocalSourceCheckoutExists(t *testing.T) { + originalVersion := Version + Version = "0.10.1" + t.Cleanup(func() { Version = originalVersion }) + + version, err := currentAWFModuleVersion(false) + + require.NoError(t, err) + assert.Equal(t, "v0.10.1", version) +} + +func TestCurrentAWFModuleVersion_RejectsDevelopmentVersionWithoutLocalSourceCheckout(t *testing.T) { + originalVersion := Version + Version = "dev" + t.Cleanup(func() { Version = originalVersion }) + + _, err := currentAWFModuleVersion(false) + + require.Error(t, err) + assert.ErrorContains(t, err, "source checkout") + assert.ErrorContains(t, err, "released awf binary") +} diff --git a/internal/interfaces/cli/plugin_init_template_test.go b/internal/interfaces/cli/plugin_init_template_test.go new file mode 100644 index 00000000..7fec197b --- /dev/null +++ b/internal/interfaces/cli/plugin_init_template_test.go @@ -0,0 +1,292 @@ +package cli_test + +import ( + "bytes" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/awf-project/cli/internal/domain/pluginmodel" + "github.com/awf-project/cli/internal/infrastructure/pluginmgr" + "github.com/awf-project/cli/internal/interfaces/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestPluginInitTemplate_ScaffoldCommandCreatesExactlyTheMVPFileSetWithDeterministicRelativePaths(t *testing.T) { + pluginDir := scaffoldPluginWithCLI(t) + + assert.Equal(t, []string{ + ".github/workflows/release.yml", + "AGENTS.md", + "Makefile", + "README.md", + "examples/demo.yaml", + "go.mod", + "main.go", + "main_test.go", + "plugin.yaml", + }, generatedFilePaths(t, pluginDir)) +} + +func TestPluginInitTemplate_ScaffoldedPluginSourceAndTestsUseSDKServeEchoSchemaMetadataAndManifestContracts(t *testing.T) { + pluginDir := scaffoldPluginWithCLI(t) + mainGo := readGeneratedFile(t, pluginDir, "main.go") + mainTestGo := readGeneratedFile(t, pluginDir, "main_test.go") + + assert.Contains(t, mainGo, `"github.com/awf-project/cli/pkg/plugin/sdk"`) + assert.Contains(t, mainGo, "sdk.Serve(newPlugin())") + assert.Contains(t, mainGo, "sdk.BasePlugin") + assert.Contains(t, mainGo, `operationEcho = "echo"`) + assert.Contains(t, mainTestGo, "TestEchoOperation") + assert.Contains(t, mainTestGo, "TestEchoOperationSchemaMetadata") + assert.Contains(t, mainTestGo, "TestManifestValidation") +} + +func TestPluginInitTemplate_ScaffoldedGoModUsesLocalDistributionModulePathWithoutUnpinnedReleaseTimeExecutableDownloads(t *testing.T) { + pluginDir := scaffoldPluginWithCLI(t) + goMod := readGeneratedFile(t, pluginDir, "go.mod") + + modulePath := "" + for _, line := range strings.Split(goMod, "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 && fields[0] == "module" { + modulePath = fields[1] + break + } + } + + assert.Equal(t, "awf-plugin-example", modulePath) + assert.Contains(t, goMod, "github.com/awf-project/cli v0.0.0") + assert.Contains(t, goMod, "replace github.com/awf-project/cli => ") + assert.NotContains(t, goMod, "replace github.com/awf-project/cli => .") + assert.NotContains(t, goMod, "@latest") + assert.NotContains(t, goMod, "curl ") + assert.NotContains(t, goMod, "wget ") +} + +func TestPluginInitTemplate_ScaffoldedPluginYamlContainsRuntimeNameVersionDescriptionAWFVersionAuthorLicenseAndOperationsCapability(t *testing.T) { + pluginDir := scaffoldPluginWithCLI(t) + manifestYAML := readGeneratedFile(t, pluginDir, "plugin.yaml") + + manifest, err := pluginmgr.NewManifestParser().Parse(strings.NewReader(manifestYAML)) + require.NoError(t, err) + require.NoError(t, manifest.Validate()) + assert.Equal(t, "example", manifest.Name) + assert.Equal(t, "0.1.0", manifest.Version) + assert.NotEmpty(t, manifest.Description) + assert.Equal(t, ">=0.6.0", manifest.AWFVersion) + assert.NotEmpty(t, manifest.Author) + assert.NotEmpty(t, manifest.License) + assert.True(t, manifest.HasCapability(pluginmodel.CapabilityOperations)) +} + +func TestPluginInitTemplate_ScaffoldedMakefileExposesBuildTestLintInstallLocalUninstallLocalPackageAndChecksumsTargets(t *testing.T) { + pluginDir := scaffoldPluginWithCLI(t) + makefile := readGeneratedFile(t, pluginDir, "Makefile") + + targets := parseMakeTargets(makefile) + for _, target := range []string{"build", "test", "lint", "install-local", "uninstall-local", "package", "checksums"} { + assert.Contains(t, targets, target) + } + assert.Contains(t, makefile, `GOFLAGS ?= -mod=mod`) + assert.Contains(t, makefile, `mkdir -p "$(DIST_DIR)"`) + assert.Contains(t, makefile, `go build $(GOFLAGS)`) + assert.Contains(t, makefile, `go test $(GOFLAGS) .`) +} + +func TestPluginInitTemplate_ScaffoldedExamplesDemoYamlCallsRuntimePluginIDEchoThroughNormalTypeOperationSyntaxAndEndsInTerminalSuccess(t *testing.T) { + pluginDir := scaffoldPluginWithCLI(t) + demo := readGeneratedFile(t, pluginDir, "examples/demo.yaml") + + var parsed struct { + States struct { + Initial string `yaml:"initial"` + Echo struct { + Type string `yaml:"type"` + Operation string `yaml:"operation"` + } `yaml:"echo"` + Done struct { + Type string `yaml:"type"` + Status string `yaml:"status"` + } `yaml:"done"` + } `yaml:"states"` + } + require.NoError(t, yaml.Unmarshal([]byte(demo), &parsed)) + + assert.Equal(t, "echo", parsed.States.Initial) + assert.Equal(t, "operation", parsed.States.Echo.Type) + assert.Equal(t, "example.echo", parsed.States.Echo.Operation) + assert.Equal(t, "terminal", parsed.States.Done.Type) + assert.Equal(t, "success", parsed.States.Done.Status) +} + +func TestPluginInitTemplate_ScaffoldedAgentsInstructionsDefineActionableAWFPluginRules(t *testing.T) { + pluginDir := scaffoldPluginWithCLI(t) + agents := readGeneratedFile(t, pluginDir, "AGENTS.md") + + for _, marker := range []string{ + "# Agent Instructions", + "load and use the `awf-knowledge` skill", + "https://github.com/awf-project/awf-marketplace", + "Do not invent AWF plugin SDK APIs from memory", + "runtime plugin id is `example`", + "generated distribution name is `awf-plugin-example`", + "only implemented manifest capability in this scaffold is `operations`", + "sdk.Serve(newPlugin())", + "OperationProvider", + "OperationSchemaProvider", + "sdk.NewErrorResult", + "sdk.NewSuccessResult", + "No operation may be a no-op", + "make test", + "make lint", + "make build", + "awf plugin list --operations", + "awf run examples/demo.yaml", + } { + assert.Contains(t, agents, marker) + } + + for _, marker := range []string{ + "for people", + "optional guidance", + "validators are supported", + "custom step types are supported", + "event listeners are supported", + } { + assert.NotContains(t, strings.ToLower(agents), marker) + } +} + +func TestPluginInitTemplate_ScaffoldedReadmeDocumentsImplementedOperationPluginWorkflow(t *testing.T) { + pluginDir := scaffoldPluginWithCLI(t) + readme := strings.ToLower(readGeneratedFile(t, pluginDir, "README.md")) + + for _, marker := range []string{ + "first plugin", + "manifest", + "sdk", + "testing", + "install", + "enable", + "list", + "run", + "package", + "checksum", + "release", + "capabilities", + } { + assert.Contains(t, readme, marker) + } + + for _, marker := range []string{ + "future", + "planned", + "canonical-port", + "adapter", + "direct-integration", + "hybrid", + "validator", + "step-type", + "event-listener", + "full", + } { + assert.NotContains(t, readme, marker) + } +} + +func TestPluginInitTemplate_ScaffoldedGithubWorkflowsReleaseYmlPackagesBinaryPlusPluginYamlGeneratesSHA256ChecksumsAndUsesInstallerCompatibleArchiveNames(t *testing.T) { + pluginDir := scaffoldPluginWithCLI(t) + release := readGeneratedFile(t, pluginDir, ".github/workflows/release.yml") + + assert.Contains(t, release, "plugin.yaml") + assert.Contains(t, strings.ToLower(release), "sha256") + assert.Contains(t, release, ".tar.gz") + assert.Contains(t, release, "awf-plugin-example_0.1.0_") + assert.Contains(t, release, "GOOS") + assert.Contains(t, release, "GOARCH") +} + +func scaffoldPluginWithCLI(t *testing.T) string { + t.Helper() + + tmpDir, err := os.MkdirTemp(".", ".plugin-init-test-*") + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, os.RemoveAll(tmpDir)) + }) + pluginDir := filepath.Join(tmpDir, "awf-plugin-example") + + cmd := cli.NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{ + "plugin", + "init", + "awf-plugin-example", + "--kind", + "operation", + "--output", + pluginDir, + "--force", + "--storage", + filepath.Join(tmpDir, "storage"), + }) + + err = cmd.Execute() + require.NoError(t, err, "plugin init should scaffold through the public CLI command:\n%s", out.String()) + + return pluginDir +} + +func generatedFilePaths(t *testing.T, root string) []string { + t.Helper() + + var paths []string + require.NoError(t, filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + paths = append(paths, filepath.ToSlash(relative)) + return nil + })) + sort.Strings(paths) + + return paths +} + +func readGeneratedFile(t *testing.T, root, relativePath string) string { + t.Helper() + + content, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(relativePath))) + require.NoError(t, err) + require.NotEmpty(t, content, "expected generated file %q to have content", relativePath) + + return string(content) +} + +func parseMakeTargets(makefile string) map[string]struct{} { + targets := make(map[string]struct{}) + for _, line := range strings.Split(makefile, "\n") { + if line == "" || strings.HasPrefix(line, "\t") || strings.HasPrefix(line, "#") { + continue + } + target, _, found := strings.Cut(line, ":") + if found && target != "" && !strings.ContainsAny(target, " \t") { + targets[target] = struct{}{} + } + } + return targets +} diff --git a/internal/interfaces/cli/plugin_init_templates.go b/internal/interfaces/cli/plugin_init_templates.go new file mode 100644 index 00000000..77abe727 --- /dev/null +++ b/internal/interfaces/cli/plugin_init_templates.go @@ -0,0 +1,475 @@ +package cli + +type pluginInitTemplateDescriptor struct { + path string + template string +} + +var operationPluginInitTemplates = []pluginInitTemplateDescriptor{ + {path: ".github/workflows/release.yml", template: releaseWorkflowTemplate}, + {path: "AGENTS.md", template: agentsTemplate}, + {path: "Makefile", template: makefileTemplate}, + {path: "README.md", template: readmeTemplate}, + {path: "examples/demo.yaml", template: demoWorkflowTemplate}, + {path: "go.mod", template: goModTemplate}, + {path: "main.go", template: mainGoTemplate}, + {path: "main_test.go", template: mainTestGoTemplate}, + {path: "plugin.yaml", template: pluginYAMLTemplate}, +} + +const agentsTemplate = `# Agent Instructions + +These instructions are for coding agents working inside this AWF plugin repository. They are operational constraints, not human onboarding text. + +## Required AWF Knowledge + +- Before changing plugin behavior, load and use the ` + "`awf-knowledge`" + ` skill. +- If ` + "`awf-knowledge`" + ` is not available in the active agent client, install or enable the AWF marketplace from ` + "`https://github.com/awf-project/awf-marketplace`" + `, then reload available skills before continuing. +- Do not invent AWF plugin SDK APIs from memory. Use ` + "`awf-knowledge`" + ` and the local ` + "`github.com/awf-project/cli/pkg/plugin/sdk`" + ` package as the source of truth. + +## Current Scaffold Contract + +- This repository is an AWF operation plugin scaffold generated by ` + "`awf plugin init --kind operation`" + `. +- The runtime plugin id is ` + "`{{.RuntimeID}}`" + `. Keep ` + "`plugin.yaml`" + ` ` + "`name`" + `, ` + "`sdk.BasePlugin.PluginName`" + `, generated examples, tests, and README references synchronized with that id. +- The generated distribution name is ` + "`{{.DistributionName}}`" + `. Keep package names, release archive names, local install paths, and checksum targets synchronized with that distribution name. +- The only implemented manifest capability in this scaffold is ` + "`operations`" + `. Do not add validators, custom step types, event listeners, or mixed capabilities unless the implementation, manifest, examples, tests, and release packaging are all updated in the same change. +- Operation identifiers are addressed from workflows as ` + "`{{.RuntimeID}}.`" + ` with state type ` + "`operation`" + `. + +## Implementation Rules + +- Keep ` + "`main.go`" + ` as the plugin entrypoint and call ` + "`sdk.Serve(newPlugin())`" + ` from ` + "`main`" + `. +- Implement each runtime operation through ` + "`OperationProvider`" + ` and describe it through ` + "`OperationSchemaProvider`" + `. +- Every operation must validate required inputs before doing work. Return ` + "`sdk.NewErrorResult`" + ` for invalid user input and ` + "`sdk.NewSuccessResult`" + ` for successful work. +- No operation may be a no-op. A successful operation must either produce a meaningful textual output, structured output fields, or an intentional external side effect covered by tests. +- Keep operation schema metadata complete enough for agents and ` + "`awf plugin list --operations`" + ` to discover required inputs, optional inputs, and structured outputs. +- When adding, renaming, or removing an operation, update ` + "`main.go`" + `, ` + "`main_test.go`" + `, ` + "`plugin.yaml`" + ` if capabilities change, ` + "`examples/demo.yaml`" + `, and ` + "`README.md`" + ` together. + +## Workflow And Manifest Rules + +- Keep workflow examples valid AWF YAML. Use ` + "`type: operation`" + ` and ` + "`operation: {{.RuntimeID}}.`" + ` for operation calls. +- Keep ` + "`plugin.yaml`" + ` semver-compatible: ` + "`version`" + ` must be semantic, ` + "`awf_version`" + ` must express the supported AWF range, and ` + "`capabilities`" + ` must match implemented runtime behavior. +- Do not expose secrets through default config values, example workflows, test fixtures, operation outputs, or logs. +- Keep generated release artifacts installable by AWF: binary, ` + "`plugin.yaml`" + `, archive name, and SHA-256 checksum data must stay aligned. + +## Verification Gate + +Run the narrowest relevant commands before finishing a change: + +` + "```bash" + ` +make test +make lint +make build +` + "```" + ` + +For runtime verification after behavior changes: + +` + "```bash" + ` +make install-local +awf plugin enable {{.DistributionName}} +awf plugin list --operations +awf run examples/demo.yaml +` + "```" + ` + +If any command cannot run in the current environment, report the exact command and the blocking condition. +` + +const goModTemplate = `module {{.DistributionName}} + +go 1.25.8 + +require ( + github.com/awf-project/cli {{.AWFModuleVersion}} + github.com/stretchr/testify v1.11.1 +) + +{{- if .HasLocalAWFModule }} + +replace github.com/awf-project/cli => {{.LocalAWFModulePath}} +{{- end }} +` + +const mainGoTemplate = `package main + +import ( + "context" + "fmt" + + "github.com/awf-project/cli/pkg/plugin/sdk" +) + +const ( + operationEcho = "echo" + pluginVersion = "0.1.0" +) + +type echoPlugin struct { + sdk.BasePlugin +} + +func newPlugin() *echoPlugin { + return &echoPlugin{ + BasePlugin: sdk.BasePlugin{ + PluginName: "{{.RuntimeID}}", + PluginVersion: pluginVersion, + }, + } +} + +func (p *echoPlugin) Operations() []string { + return []string{operationEcho} +} + +func (p *echoPlugin) HandleOperation(_ context.Context, name string, inputs map[string]any) (*sdk.OperationResult, error) { + if name != operationEcho { + return sdk.NewErrorResult(fmt.Sprintf("unknown operation %q", name)), nil + } + + text, ok := sdk.GetString(inputs, "text") + if !ok || text == "" { + return sdk.NewErrorResult("text is required"), nil + } + + prefix, _ := sdk.GetString(inputs, "prefix") + output := text + if prefix != "" { + output = prefix + text + } + + return sdk.NewSuccessResult(output, map[string]any{ + "output": output, + "text": text, + "prefix": prefix, + }), nil +} + +func (p *echoPlugin) GetOperationSchema(name string) (sdk.OperationMeta, bool) { + if name != operationEcho { + return sdk.OperationMeta{}, false + } + + return sdk.OperationMeta{ + Description: "Echo text, optionally prepending a prefix.", + Inputs: []sdk.InputMeta{ + { + Name: "text", + Type: sdk.InputTypeString, + Required: true, + Description: "Text to echo.", + }, + { + Name: "prefix", + Type: sdk.InputTypeString, + Required: false, + Description: "Optional prefix prepended to the text.", + }, + }, + Outputs: []sdk.OutputMeta{ + {Name: "output", Type: sdk.InputTypeString, Description: "Final echoed output."}, + {Name: "text", Type: sdk.InputTypeString, Description: "Original text input."}, + {Name: "prefix", Type: sdk.InputTypeString, Description: "Prefix input, when provided."}, + }, + }, true +} + +func main() { + sdk.Serve(newPlugin()) +} +` + +const mainTestGoTemplate = `package main + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestEchoOperationSuccess(t *testing.T) { + result, err := newPlugin().HandleOperation(context.Background(), "echo", map[string]any{"text": "hello"}) + + require.NoError(t, err) + require.True(t, result.Success) + assert.Equal(t, "hello", result.Output) + assert.Equal(t, map[string]any{"output": "hello", "text": "hello", "prefix": ""}, result.Data) +} + +func TestEchoOperationPrefix(t *testing.T) { + result, err := newPlugin().HandleOperation(context.Background(), "echo", map[string]any{ + "text": "hello", + "prefix": "say: ", + }) + + require.NoError(t, err) + require.True(t, result.Success) + assert.Equal(t, "say: hello", result.Output) + assert.Equal(t, "say: ", result.Data["prefix"]) +} + +func TestEchoOperationMissingText(t *testing.T) { + result, err := newPlugin().HandleOperation(context.Background(), "echo", map[string]any{}) + + require.NoError(t, err) + require.False(t, result.Success) + assert.Contains(t, result.Error, "text") +} + +func TestEchoOperationEmptyText(t *testing.T) { + result, err := newPlugin().HandleOperation(context.Background(), "echo", map[string]any{"text": ""}) + + require.NoError(t, err) + require.False(t, result.Success) + assert.Contains(t, result.Error, "text") +} + +func TestEchoOperationUnknownOperation(t *testing.T) { + result, err := newPlugin().HandleOperation(context.Background(), "missing", map[string]any{"text": "hello"}) + + require.NoError(t, err) + require.False(t, result.Success) + assert.Contains(t, result.Error, "unknown operation") +} + +func TestEchoOperationSchemaMetadata(t *testing.T) { + schema, ok := newPlugin().GetOperationSchema("echo") + + require.True(t, ok) + assert.Contains(t, schema.Description, "Echo") + require.Len(t, schema.Inputs, 2) + assert.Equal(t, "text", schema.Inputs[0].Name) + assert.True(t, schema.Inputs[0].Required) + assert.Equal(t, "prefix", schema.Inputs[1].Name) + assert.False(t, schema.Inputs[1].Required) + assert.ElementsMatch(t, []string{"output", "text", "prefix"}, []string{ + schema.Outputs[0].Name, + schema.Outputs[1].Name, + schema.Outputs[2].Name, + }) +} + +func TestManifestValidation(t *testing.T) { + content, err := os.ReadFile("plugin.yaml") + require.NoError(t, err) + + var manifest map[string]any + require.NoError(t, yaml.Unmarshal(content, &manifest)) + capabilities, ok := manifest["capabilities"].([]any) + require.True(t, ok) + require.NotEmpty(t, manifest["name"]) + require.NotEmpty(t, manifest["version"]) + require.NotEmpty(t, manifest["awf_version"]) + assert.Equal(t, "{{.RuntimeID}}", manifest["name"]) + assert.Equal(t, "0.1.0", manifest["version"]) + assert.Contains(t, capabilities, "operations") +} +` + +const pluginYAMLTemplate = `name: {{.RuntimeID}} +version: 0.1.0 +description: Echo operation plugin generated by awf plugin init. +awf_version: ">=0.6.0" +author: AWF Plugin Author +license: MIT +capabilities: + - operations +` + +const makefileTemplate = `.PHONY: build test lint install-local uninstall-local package checksums + +PLUGIN_NAME := {{.Release.BinaryName}} +VERSION := {{.Release.Version}} +GOOS ?= $(shell go env GOOS) +GOARCH ?= $(shell go env GOARCH) +DIST_DIR := dist +PACKAGE_DIR := {{.Release.PackageDir}} +ARCHIVE := {{.Release.ArchiveName}} +GOFLAGS ?= -mod=mod + +build: + mkdir -p "$(DIST_DIR)" + go build $(GOFLAGS) -o $(DIST_DIR)/$(PLUGIN_NAME) . + +test: + go test $(GOFLAGS) . + +lint: + go vet $(GOFLAGS) . + +install-local: build + mkdir -p "$${HOME}/.local/share/awf/plugins/{{.RuntimeID}}" + cp "$(DIST_DIR)/$(PLUGIN_NAME)" "$${HOME}/.local/share/awf/plugins/{{.RuntimeID}}/$(PLUGIN_NAME)" + cp plugin.yaml "$${HOME}/.local/share/awf/plugins/{{.RuntimeID}}/plugin.yaml" + +uninstall-local: + rm -rf "$${HOME}/.local/share/awf/plugins/{{.RuntimeID}}" + +package: build + rm -rf "$(DIST_DIR)/$(PACKAGE_DIR)" + mkdir -p "$(DIST_DIR)/$(PACKAGE_DIR)" + cp "$(DIST_DIR)/$(PLUGIN_NAME)" {{.Release.ManifestName}} "$(DIST_DIR)/$(PACKAGE_DIR)/" + tar -C "$(DIST_DIR)/$(PACKAGE_DIR)" -czf "$(DIST_DIR)/$(ARCHIVE)" . + +checksums: package + cd "$(DIST_DIR)" && sha256sum *.tar.gz > {{.Release.ChecksumFile}} +` + +const demoWorkflowTemplate = `name: {{.RuntimeID}} demo +version: "1.0" + +states: + initial: echo + + echo: + type: operation + operation: {{.RuntimeID}}.echo + inputs: + text: "hello from {{.RuntimeID}}" + prefix: "AWF: " + on_success: done + on_failure: failed + + done: + type: terminal + status: success + + failed: + type: terminal + status: failure +` + +const readmeTemplate = `# {{.DistributionName}} + +This is a first plugin scaffold generated by ` + "`awf plugin init`" + `. It provides one operation named ` + "`{{.RuntimeID}}.echo`" + ` and uses ` + "`pkg/plugin/sdk`" + ` as the supported Go authoring API. + +## First Run + +` + "```sh" + ` +awf plugin init awf-plugin-example --kind operation +cd awf-plugin-example +make test +make build +make install-local +awf plugin enable {{.DistributionName}} +awf plugin list --operations +awf run examples/demo.yaml +` + "```" + ` + +## Manifest + +` + "`plugin.yaml`" + ` declares the runtime plugin name, version, AWF version constraint, author, license, and capabilities. The manifest ` + "`name`" + ` is the runtime id, so workflows call ` + "`{{.RuntimeID}}.echo`" + ` even though the installable distribution is ` + "`{{.DistributionName}}`" + `. + +Required fields: +- ` + "`name`" + `: manifest ` + "`name`" + ` is the runtime id used by workflows and operation routing. +- ` + "`version`" + `: semantic version such as ` + "`0.1.0`" + `; release tags should use the same version, usually prefixed with ` + "`v`" + `. +- ` + "`description`" + `: user-facing summary shown by plugin tooling. +- ` + "`awf_version`" + `: AWF version compatibility constraint, for example ` + "`>=0.6.0`" + `. +- ` + "`capabilities`" + `: supported capabilities advertised to AWF. + +Optional fields include ` + "`author`" + `, ` + "`license`" + `, and config schema declarations. Config schema conventions should describe stable keys, types, defaults, and whether values are required so validation and MCP exposure can present useful metadata. + +Supported capability for this scaffold is ` + "`operations`" + `. Common validation failures include missing required fields, non-semantic version values, incompatible AWF version compatibility constraints, unsupported capabilities, malformed config schema entries, and release artifacts without a checksum. Published releases should include the binary, ` + "`plugin.yaml`" + `, and SHA-256 checksum data. + +## SDK + +` + "`main.go`" + ` uses the AWF plugin sdk, embeds ` + "`sdk.BasePlugin`" + `, serves the plugin with ` + "`sdk.Serve`" + `, and exposes operation schema metadata through ` + "`sdk.OperationSchemaProvider`" + `. The scaffold implements ` + "`OperationProvider`" + ` for execution and ` + "`OperationSchemaProvider`" + ` for schema metadata. + +Authoring APIs and expectations: +- ` + "`sdk.Serve`" + ` starts the plugin process and wires it to AWF. +- ` + "`sdk.BasePlugin`" + ` supplies plugin identity and default lifecycle methods. +- Lifecycle methods handle setup, health, and shutdown behavior. +- ` + "`OperationProvider`" + ` declares operation names and handles runtime calls. +- ` + "`OperationSchemaProvider`" + ` returns schema metadata for inputs and structured outputs. +- input helpers such as ` + "`sdk.GetString`" + ` keep operation input parsing predictable. +- ` + "`sdk.NewSuccessResult`" + ` returns successful output plus structured outputs. +- ` + "`sdk.NewErrorResult`" + ` returns operation-level validation or execution errors. +- compatibility expectations: keep operation names, input types, output keys, and manifest capabilities stable across compatible releases. + +## Testing And Commands + +Generated commands: + +` + "```sh" + ` +make build +make test +make lint +make install-local +make uninstall-local +make package +make checksums +` + "```" + ` + +The tests cover echo success, prefix behavior, missing text, empty text, unknown operations, schema metadata, and manifest validation. + +## Local Install + +Build and install locally, then enable, list, and run the plugin: + +` + "```sh" + ` +make install-local +awf plugin enable {{.DistributionName}} +awf plugin list --operations +awf run examples/demo.yaml +make uninstall-local +` + "```" + ` + +## Package, Checksum, And Release + +Create an installer-compatible package and checksum: + +` + "```sh" + ` +make package +make checksums +` + "```" + ` + +The release workflow packages the binary plus ` + "`plugin.yaml`" + ` into ` + "`{{.DistributionName}}_0.1.0__.tar.gz`" + ` and publishes SHA-256 checksums. +` + +const releaseWorkflowTemplate = `name: release + +on: + push: + tags: + - "v*" + +jobs: + release: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Build + env: + GOOS: ${{ "{{" }} matrix.goos {{ "}}" }} + GOARCH: ${{ "{{" }} matrix.goarch {{ "}}" }} + GOFLAGS: -mod=mod + run: | + mkdir -p dist/{{.Release.PackageDir}} + go build -o "dist/{{.Release.PackageDir}}/{{.Release.BinaryName}}" . + cp {{.Release.ManifestName}} dist/{{.Release.PackageDir}}/{{.Release.ManifestName}} + tar -C dist/{{.Release.PackageDir}} -czf "dist/{{.DistributionName}}_{{.Release.Version}}_${GOOS}_${GOARCH}.tar.gz" . + - name: Checksums + run: | + cd dist + sha256sum *.tar.gz > checksums.txt + - uses: softprops/action-gh-release@v2 + with: + files: | + dist/*.tar.gz + dist/checksums.txt +` diff --git a/internal/interfaces/cli/run.go b/internal/interfaces/cli/run.go index 7676fe8e..7cc2c256 100644 --- a/internal/interfaces/cli/run.go +++ b/internal/interfaces/cli/run.go @@ -231,7 +231,7 @@ func runWorkflow(cmd *cobra.Command, cfg *Config, workflowName string, inputFlag defer cleanup() // Initialize dependencies - repo := NewWorkflowRepository() + repo, workflowName := newWorkflowRepositoryForIdentifier(workflowName) stateStore := store.NewJSONStore(cfg.StoragePath + "/states") shellExecutor := executor.NewShellExecutor() // Table and JSON formats should be silent (structured output only) @@ -452,7 +452,7 @@ func runDryRun(cmd *cobra.Command, cfg *Config, workflowName string, inputFlags }) // Initialize dependencies - repo := NewWorkflowRepository() + repo, workflowName := newWorkflowRepositoryForIdentifier(workflowName) stateStore := store.NewJSONStore(cfg.StoragePath + "/states") shellExecutor := executor.NewShellExecutor() logger := &cliLogger{ @@ -550,7 +550,7 @@ func runInteractive(cmd *cobra.Command, cfg *Config, workflowName string, inputF defer signalCleanup() // Initialize dependencies - repo := NewWorkflowRepository() + repo, workflowName := newWorkflowRepositoryForIdentifier(workflowName) stateStore := store.NewJSONStore(cfg.StoragePath + "/states") shellExecutor := executor.NewShellExecutor() logger := &cliLogger{ @@ -882,7 +882,7 @@ func runSingleStep( defer cleanup() // Initialize dependencies - repo := NewWorkflowRepository() + repo, workflowName := newWorkflowRepositoryForIdentifier(workflowName) stateStore := store.NewJSONStore(cfg.StoragePath + "/states") shellExecutor := executor.NewShellExecutor() logger := &cliLogger{ diff --git a/tests/integration/cli/plugin_init_release_test.go b/tests/integration/cli/plugin_init_release_test.go new file mode 100644 index 00000000..5b473825 --- /dev/null +++ b/tests/integration/cli/plugin_init_release_test.go @@ -0,0 +1,263 @@ +//go:build integration + +// Feature: F111 +package cli_test + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/awf-project/cli/pkg/registry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPluginInitReleasePackaging(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + awfBin := buildPluginInitReleasePackagingAWF(t, ctx) + env := pluginInitReleasePackagingEnv(t) + workspace := t.TempDir() + outputDir := filepath.Join(workspace, "awf-plugin-example") + distributionName := "awf-plugin-example" + expectedArchiveName := fmt.Sprintf("%s_0.1.0_%s_%s.tar.gz", distributionName, runtime.GOOS, runtime.GOARCH) + expectedArchivePath := filepath.Join(outputDir, "dist", expectedArchiveName) + alternateGOOS, alternateGOARCH := alternatePluginInitReleasePackagingPlatform() + + t.Run("scaffolds a plugin repository in a temporary directory", func(t *testing.T) { + runPluginInitReleasePackagingCommand( + t, ctx, workspace, env, awfBin, + []string{ + "plugin", "init", distributionName, + "--kind", "operation", + "--output", outputDir, + }, + ) + + assert.FileExists(t, filepath.Join(outputDir, "go.mod")) + assert.FileExists(t, filepath.Join(outputDir, "Makefile")) + assert.FileExists(t, filepath.Join(outputDir, "plugin.yaml")) + }) + + t.Run("runs generated make build make package and make checksums without manual edits", func(t *testing.T) { + runPluginInitReleasePackagingCommand(t, ctx, outputDir, env, "make", []string{"build"}) + require.NoError(t, os.MkdirAll(filepath.Join(outputDir, "dist", "package"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(outputDir, "dist", "package", "stale.txt"), []byte("stale"), 0o644)) + runPluginInitReleasePackagingCommand(t, ctx, outputDir, env, "make", []string{"package"}) + runPluginInitReleasePackagingCommand( + t, + ctx, + outputDir, + env, + "make", + []string{"package", "GOOS=" + alternateGOOS, "GOARCH=" + alternateGOARCH}, + ) + runPluginInitReleasePackagingCommand(t, ctx, outputDir, env, "make", []string{"checksums"}) + }) + + t.Run("make package produces a tar.gz archive for the current GOOS and GOARCH with the distribution name version 0.1.0 and Go runtime suffix expected by current installer lookup", func(t *testing.T) { + assert.FileExists(t, expectedArchivePath) + }) + + t.Run("generated package names are compatible with pkg registry FindPlatformAsset expectations or the current installer helper used by existing integration tests", func(t *testing.T) { + asset, err := registry.FindPlatformAsset([]registry.Asset{{Name: expectedArchiveName}}, runtime.GOOS, runtime.GOARCH) + + require.NoError(t, err) + assert.Equal(t, expectedArchiveName, asset.Name) + }) + + t.Run("the generated archive contains the plugin binary named with the distribution name and plugin.yaml", func(t *testing.T) { + files := readTarGzFileNames(t, expectedArchivePath) + + assert.Contains(t, files, distributionName) + assert.Contains(t, files, "plugin.yaml") + }) + + t.Run("the archive does not require unrelated repository files for installation", func(t *testing.T) { + files := readTarGzFileNames(t, expectedArchivePath) + + assert.ElementsMatch(t, []string{distributionName, "plugin.yaml"}, files) + }) + + t.Run("checksums.txt contains a SHA-256 checksum entry for every generated package archive", func(t *testing.T) { + archives, err := filepath.Glob(filepath.Join(outputDir, "dist", "*.tar.gz")) + require.NoError(t, err) + require.NotEmpty(t, archives) + + checksums := readChecksumEntries(t, filepath.Join(outputDir, "dist", "checksums.txt")) + for _, archive := range archives { + name := filepath.Base(archive) + sum := sha256FileHex(t, archive) + assert.Equal(t, sum, checksums[name]) + } + }) + + t.Run("the generated release workflow uses the same archive naming pattern asserted by the integration test", func(t *testing.T) { + release, err := os.ReadFile(filepath.Join(outputDir, ".github", "workflows", "release.yml")) + require.NoError(t, err) + + expectedCommand := `tar -C dist/package -czf "dist/` + distributionName + `_0.1.0_${GOOS}_${GOARCH}.tar.gz" .` + assertReleaseWorkflowLine(t, string(release), expectedCommand) + assertReleaseWorkflowLine(t, string(release), `GOFLAGS: -mod=mod`) + }) +} + +func buildPluginInitReleasePackagingAWF(t *testing.T, ctx context.Context) string { + t.Helper() + + repoRoot := pluginInitReleasePackagingRepoRoot(t) + binPath := filepath.Join(t.TempDir(), "awf") + runPluginInitReleasePackagingCommand(t, ctx, repoRoot, os.Environ(), "go", []string{"build", "-o", binPath, "./cmd/awf"}) + return binPath +} + +func pluginInitReleasePackagingEnv(t *testing.T) []string { + t.Helper() + + root := t.TempDir() + home := filepath.Join(root, "home") + pluginsDir := filepath.Join(home, ".local", "share", "awf", "plugins") + require.NoError(t, os.MkdirAll(pluginsDir, 0o755)) + + return append( + os.Environ(), + "HOME="+home, + "XDG_CONFIG_HOME="+filepath.Join(root, "config"), + "XDG_DATA_HOME="+filepath.Join(root, "data"), + "XDG_STATE_HOME="+filepath.Join(root, "state"), + "AWF_PLUGINS_PATH="+pluginsDir, + ) +} + +func pluginInitReleasePackagingRepoRoot(t *testing.T) string { + t.Helper() + + wd, err := os.Getwd() + require.NoError(t, err) + return filepath.Clean(filepath.Join(wd, "..", "..", "..")) +} + +func alternatePluginInitReleasePackagingPlatform() (string, string) { + if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { + return "linux", "amd64" + } + + return "darwin", "arm64" +} + +func runPluginInitReleasePackagingCommand( + t *testing.T, + ctx context.Context, + dir string, + env []string, + name string, + args []string, +) string { + t.Helper() + + cmd := exec.CommandContext(ctx, name) + cmd.Args = make([]string, len(args)+1) + cmd.Args[0] = name + copy(cmd.Args[1:], args) + cmd.Dir = dir + cmd.Env = env + output, err := cmd.CombinedOutput() + require.NoError(t, err, "%s %v failed in %s:\n%s", name, args, dir, output) + return string(output) +} + +func assertReleaseWorkflowLine(t *testing.T, release, expected string) { + t.Helper() + + count := 0 + for _, line := range strings.Split(release, "\n") { + if strings.TrimSpace(line) == expected { + count++ + } + } + + assert.Equal(t, 1, count, "release.yml must contain exactly one matching archive command") +} + +func readTarGzFileNames(t *testing.T, path string) []string { + t.Helper() + + file, err := os.Open(path) + require.NoError(t, err) + defer func() { + require.NoError(t, file.Close()) + }() + + gzipReader, err := gzip.NewReader(file) + require.NoError(t, err) + defer func() { + require.NoError(t, gzipReader.Close()) + }() + + var files []string + tarReader := tar.NewReader(gzipReader) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + if header.Typeflag != tar.TypeReg { + continue + } + files = append(files, strings.TrimPrefix(filepath.Clean(header.Name), "."+string(filepath.Separator))) + } + + return files +} + +func readChecksumEntries(t *testing.T, path string) map[string]string { + t.Helper() + + data, err := os.ReadFile(path) + require.NoError(t, err) + + entries := make(map[string]string) + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + entries[fields[1]] = fields[0] + } + + return entries +} + +func sha256FileHex(t *testing.T, path string) string { + t.Helper() + + file, err := os.Open(path) + require.NoError(t, err) + defer func() { + require.NoError(t, file.Close()) + }() + + hash := sha256.New() + _, err = io.Copy(hash, file) + require.NoError(t, err) + + return hex.EncodeToString(hash.Sum(nil)) +} diff --git a/tests/integration/cli/plugin_init_test.go b/tests/integration/cli/plugin_init_test.go new file mode 100644 index 00000000..5c8ed201 --- /dev/null +++ b/tests/integration/cli/plugin_init_test.go @@ -0,0 +1,179 @@ +//go:build integration + +// Feature: F111 +package cli_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPluginInitIntegration_RunsGeneratedMakeTestAndMakeBuildWithoutManualEdits(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + awfBin := buildPluginInitIntegrationAWF(t, ctx) + env := pluginInitIntegrationEnv(t) + workspace := t.TempDir() + outputDir := filepath.Join(workspace, "awf-plugin-example") + + runPluginInitIntegrationCommand( + t, ctx, workspace, env, awfBin, + "plugin", "init", "awf-plugin-example", + "--kind", "operation", + "--output", outputDir, + ) + runPluginInitIntegrationCommand(t, ctx, outputDir, env, "make", "test") + runPluginInitIntegrationCommand(t, ctx, outputDir, env, "make", "build") +} + +func TestPluginInitIntegration_WithIsolatedAWFPathsInstallsEnablesByDistributionNameListsOperationsAndRunsDemo(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + awfBin := buildPluginInitIntegrationAWF(t, ctx) + env := pluginInitIntegrationEnv(t) + workspace := t.TempDir() + outputDir := filepath.Join(workspace, "awf-plugin-example") + storageDir := filepath.Join(workspace, "storage") + + runPluginInitIntegrationCommand( + t, ctx, workspace, env, awfBin, + "plugin", "init", "awf-plugin-example", + "--kind", "operation", + "--output", outputDir, + "--storage", storageDir, + ) + runPluginInitIntegrationCommand(t, ctx, outputDir, env, "make", "install-local") + + enableOutput := runPluginInitIntegrationCommand( + t, ctx, outputDir, env, awfBin, + "plugin", "enable", "awf-plugin-example", + "--storage", storageDir, + ) + assert.Contains(t, enableOutput, "example") + + operationsOutput := runPluginInitIntegrationCommand( + t, ctx, outputDir, env, awfBin, + "plugin", "list", "--operations", + "--storage", storageDir, + ) + assert.Contains(t, operationsOutput, "example.echo") + + runOutput := runPluginInitIntegrationCommand( + t, ctx, outputDir, env, awfBin, + "run", "examples/demo.yaml", + "--storage", storageDir, + ) + assert.Contains(t, runOutput, "Workflow completed.") +} + +func TestPluginInitIntegration_UnsupportedKindFailsWithoutCreatingOutput(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + awfBin := buildPluginInitIntegrationAWF(t, ctx) + env := pluginInitIntegrationEnv(t) + workspace := t.TempDir() + outputDir := filepath.Join(workspace, "awf-plugin-validator") + + output := runPluginInitIntegrationCommandExpectError( + t, ctx, workspace, env, awfBin, + "plugin", "init", "awf-plugin-validator", + "--kind", "validator", + "--output", outputDir, + ) + + assert.Contains(t, output, `unsupported plugin init kind "validator"; supported kind is "operation"`) + assert.NoDirExists(t, outputDir) +} + +func buildPluginInitIntegrationAWF(t *testing.T, ctx context.Context) string { + t.Helper() + + repoRoot := pluginInitIntegrationRepoRoot(t) + binPath := filepath.Join(t.TempDir(), "awf") + runPluginInitIntegrationCommand(t, ctx, repoRoot, os.Environ(), "go", "build", "-o", binPath, "./cmd/awf") + return binPath +} + +func pluginInitIntegrationEnv(t *testing.T) []string { + t.Helper() + + root := t.TempDir() + home := filepath.Join(root, "home") + pluginsDir := filepath.Join(home, ".local", "share", "awf", "plugins") + require.NoError(t, os.MkdirAll(pluginsDir, 0o755)) + + return append( + os.Environ(), + "HOME="+home, + "XDG_CONFIG_HOME="+filepath.Join(root, "config"), + "XDG_DATA_HOME="+filepath.Join(root, "data"), + "XDG_STATE_HOME="+filepath.Join(root, "state"), + "AWF_PLUGINS_PATH="+pluginsDir, + ) +} + +func pluginInitIntegrationRepoRoot(t *testing.T) string { + t.Helper() + + wd, err := os.Getwd() + require.NoError(t, err) + return filepath.Clean(filepath.Join(wd, "..", "..", "..")) +} + +func runPluginInitIntegrationCommand( + t *testing.T, + ctx context.Context, + dir string, + env []string, + name string, + args ...string, +) string { + t.Helper() + + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + cmd.Env = env + output, err := cmd.CombinedOutput() + require.NoError(t, err, "%s %v failed in %s:\n%s", name, args, dir, output) + return string(output) +} + +func runPluginInitIntegrationCommandExpectError( + t *testing.T, + ctx context.Context, + dir string, + env []string, + name string, + args ...string, +) string { + t.Helper() + + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + cmd.Env = env + output, err := cmd.CombinedOutput() + require.Error(t, err, "%s %v unexpectedly succeeded in %s:\n%s", name, args, dir, output) + return string(output) +}