From 0788e4c104d8367177b716cddd8d1dd35e54b940 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 09:59:57 +0200 Subject: [PATCH 001/208] docs: capture fctl v4 architecture plan --- .codex/skills/fctl-v4-architecture/SKILL.md | 52 +++++ AGENTS.md | 16 ++ docs/adr/0001-contexts-as-primary-target.md | 18 ++ docs/adr/0002-auth-is-decoupled-from-cloud.md | 18 ++ docs/adr/0003-api-version-resolution.md | 18 ++ docs/adr/0004-cobra-thin-runtime.md | 18 ++ docs/cli-v4/command-design.md | 72 +++++++ docs/cli-v4/compatibility-manifest.md | 89 ++++++++ docs/cli-v4/config-format.md | 62 ++++++ docs/cli-v4/migration-from-v3.md | 39 ++++ docs/rfcs/0001-fctl-v4-architecture.md | 198 ++++++++++++++++++ 11 files changed, 600 insertions(+) create mode 100644 .codex/skills/fctl-v4-architecture/SKILL.md create mode 100644 AGENTS.md create mode 100644 docs/adr/0001-contexts-as-primary-target.md create mode 100644 docs/adr/0002-auth-is-decoupled-from-cloud.md create mode 100644 docs/adr/0003-api-version-resolution.md create mode 100644 docs/adr/0004-cobra-thin-runtime.md create mode 100644 docs/cli-v4/command-design.md create mode 100644 docs/cli-v4/compatibility-manifest.md create mode 100644 docs/cli-v4/config-format.md create mode 100644 docs/cli-v4/migration-from-v3.md create mode 100644 docs/rfcs/0001-fctl-v4-architecture.md diff --git a/.codex/skills/fctl-v4-architecture/SKILL.md b/.codex/skills/fctl-v4-architecture/SKILL.md new file mode 100644 index 00000000..5fe8713b --- /dev/null +++ b/.codex/skills/fctl-v4-architecture/SKILL.md @@ -0,0 +1,52 @@ +--- +name: fctl-v4-architecture +description: Use when working on the Formance fctl v4 CLI architecture, contexts, authentication, API version resolution, compatibility manifests, command design, or migrations from fctl v3. This skill keeps future work aligned with the repository's v4 RFC and ADRs. +--- + +# fctl v4 Architecture Skill + +Use this skill for any `fctl` v4 design or implementation work. + +## Required Reading + +Before changing v4 architecture or commands, read these repository files: + +- `docs/rfcs/0001-fctl-v4-architecture.md` +- `docs/cli-v4/command-design.md` +- `docs/cli-v4/compatibility-manifest.md` + +Read ADRs as needed: + +- `docs/adr/0001-contexts-as-primary-target.md` +- `docs/adr/0002-auth-is-decoupled-from-cloud.md` +- `docs/adr/0003-api-version-resolution.md` +- `docs/adr/0004-cobra-thin-runtime.md` + +## Core Rules + +- Do not make Formance Cloud membership required for stack commands. +- Treat contexts as the primary target selector. +- Keep auth as a target-local strategy. +- Use `/versions` plus the compatibility manifest to infer supported API namespaces. +- Commands express product intent; they must not expose API versions as the primary UX. +- Keep CLI flags canonical and product-oriented; map them to version-specific SDK request fields internally. +- Keep Cobra thin. Runtime concerns belong in typed internal packages. + +## Implementation Shape + +Prefer this package split: + +```text +cmd/ Cobra declarations only +internal/runtime/ target resolution, auth, versions, API selection +internal/config/ contexts, defaults, XDG paths, migrations +internal/credentials/ keyring and insecure fallback +internal/capabilities generated manifest and compatibility ranges +internal/commands/ typed product command implementations +internal/render/ table, json, yaml, markdown +internal/prompt/ optional interactive flows +``` + +## Validation + +For command behavior, prefer integration-style tests that execute real CLI commands and assert stdout, stderr, exit codes, and config files. Keep scriptability and non-interactive usage as first-class requirements. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..70714a00 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ +# fctl v4 Guidance + +Before working on the next major CLI architecture, read: + +- `docs/rfcs/0001-fctl-v4-architecture.md` +- `docs/cli-v4/command-design.md` +- `docs/cli-v4/compatibility-manifest.md` + +Core rules: + +- Do not couple stack commands to Formance Cloud membership. +- Treat context, target, auth, capabilities, API version, and rendering as separate concepts. +- Commands express product intent; API version selection belongs in the runtime. +- Use `/versions` plus the generated compatibility manifest to select the best supported SDK namespace. +- Keep Cobra as a thin parser/router; keep business logic in typed internal packages. +- Store credentials in a keyring when possible; keep config files free of long-lived secrets. diff --git a/docs/adr/0001-contexts-as-primary-target.md b/docs/adr/0001-contexts-as-primary-target.md new file mode 100644 index 00000000..4b48e7e4 --- /dev/null +++ b/docs/adr/0001-contexts-as-primary-target.md @@ -0,0 +1,18 @@ +# ADR 0001: Contexts Are The Primary Target Selector + +Status: Accepted for v4 planning + +## Context + +The current profile model is centered on Formance Cloud membership. That makes stack usage depend on Cloud identity even when the user wants to talk to a local or self-hosted stack. + +## Decision + +Use named contexts as the primary target selector. A context describes the target endpoint, authentication method, defaults, and API version policy. + +## Consequences + +- Stack commands can run without Cloud membership. +- Cloud workflows remain possible through Cloud-specific context kinds. +- `--context` and `FCTL_CONTEXT` can override the current context. +- Context export/import becomes possible later. diff --git a/docs/adr/0002-auth-is-decoupled-from-cloud.md b/docs/adr/0002-auth-is-decoupled-from-cloud.md new file mode 100644 index 00000000..3741e0e8 --- /dev/null +++ b/docs/adr/0002-auth-is-decoupled-from-cloud.md @@ -0,0 +1,18 @@ +# ADR 0002: Authentication Is Decoupled From Cloud + +Status: Accepted for v4 planning + +## Context + +The current CLI authenticates through a membership relying party. This prevents a clean local and self-hosted user experience. + +## Decision + +Model authentication as a target-local strategy. Supported strategies should include Cloud device flow, generic OIDC, client credentials, token from stdin/env, and explicit no-auth development mode. + +## Consequences + +- Local stacks can use `client_credentials` with default development clients. +- Self-hosted stacks can use their own OIDC issuer. +- CI can use tokens or client credentials without browser flows. +- Cloud membership becomes one auth strategy, not the root abstraction. diff --git a/docs/adr/0003-api-version-resolution.md b/docs/adr/0003-api-version-resolution.md new file mode 100644 index 00000000..71630488 --- /dev/null +++ b/docs/adr/0003-api-version-resolution.md @@ -0,0 +1,18 @@ +# ADR 0003: API Version Resolution Belongs In Runtime + +Status: Accepted for v4 planning + +## Context + +The public SDK exposes versioned namespaces such as `Ledger.V1`, `Ledger.V2`, and `Payments.V3`. The stack exposes `/versions`, but not a full capabilities endpoint. + +## Decision + +Commands declare product features and available handlers. The runtime calls `/versions`, maps component versions to supported API namespaces, intersects that with command handlers, and selects the best compatible handler. + +## Consequences + +- Commands do not hardcode the oldest API namespace. +- New endpoints can appear as normal product commands and fail cleanly on older targets. +- A small manual compatibility table is still required for component version ranges. +- Most operation metadata can be generated from the OpenAPI spec. diff --git a/docs/adr/0004-cobra-thin-runtime.md b/docs/adr/0004-cobra-thin-runtime.md new file mode 100644 index 00000000..5d0f4de4 --- /dev/null +++ b/docs/adr/0004-cobra-thin-runtime.md @@ -0,0 +1,18 @@ +# ADR 0004: Keep Cobra Thin + +Status: Accepted for v4 planning + +## Context + +Cobra is widely used and already present in `fctl`, but current command implementations mix parsing, auth, client construction, API selection, and rendering. + +## Decision + +Keep Cobra for routing, flags, help, aliases, deprecations, and shell completions. Move target resolution, authentication, API versioning, and business logic into typed internal packages. + +## Consequences + +- Existing Cobra knowledge remains useful. +- Command files become smaller and easier to test. +- The runtime can be tested independently from Cobra. +- The CLI can keep a stable user experience while API versions evolve. diff --git a/docs/cli-v4/command-design.md b/docs/cli-v4/command-design.md new file mode 100644 index 00000000..eead3d4f --- /dev/null +++ b/docs/cli-v4/command-design.md @@ -0,0 +1,72 @@ +# fctl v4 Command Design + +Commands should express Formance product intent, not OpenAPI or SDK structure. + +Prefer: + +```bash +fctl ledger transactions list +fctl ledger transactions revert +fctl ledger schemas insert +``` + +Avoid: + +```bash +fctl ledger v2 transactions list +fctl ledger transactions list-v2 +``` + +## Canonical Inputs + +Each command should parse into a version-independent input model. + +```go +type ListTransactionsInput struct { + Ledger string + AccountAddress string + PageSize int64 +} +``` + +Version-specific adapters convert that model into generated SDK request types. + +```go +func toLedgerV1(input ListTransactionsInput) operations.ListTransactionsRequest +func toLedgerV2(input ListTransactionsInput) operations.V2ListTransactionsRequest +``` + +## Renamed API Parameters + +If API v1 calls a field `account` and API v2 calls it `address`, but the CLI concept is the same, expose one canonical flag. + +```bash +fctl ledger transactions list --account users:123 +``` + +Keep aliases only for CLI compatibility, not because generated API names changed. + +## Version-Specific Features + +A command can exist even if only newer targets support it. + +```bash +fctl ledger transactions explain +``` + +If the current target only supports an older Ledger API, return: + +```text +ledger transactions explain requires Ledger >= 3.0.0. +Current target runs Ledger 2.3.4. +``` + +## Help Text + +Help should be stable and product-oriented. For flags or commands requiring newer APIs, include capability notes: + +```text +--include-descendants Include child accounts (requires ledger API v2+) +``` + +Context-aware help can be added later, but the base help should remain useful without network calls. diff --git a/docs/cli-v4/compatibility-manifest.md b/docs/cli-v4/compatibility-manifest.md new file mode 100644 index 00000000..8de2a30b --- /dev/null +++ b/docs/cli-v4/compatibility-manifest.md @@ -0,0 +1,89 @@ +# fctl v4 Compatibility Manifest + +The v4 CLI should derive most operation metadata from the released stack OpenAPI document. + +Current reference: + +```text +https://github.com/formancehq/stack/releases/download/v3.2.4/generate.json +``` + +The spec contains versioned tags such as: + +- `ledger.v1` +- `ledger.v2` +- `payments.v1` +- `payments.v3` +- `orchestration.v1` +- `orchestration.v2` +- `auth.v1` +- `wallets.v1` +- `webhooks.v1` +- `search.v1` +- `reconciliation.v1` + +## Generated Data + +Generate a manifest with: + +- stack spec version +- product name +- API namespace +- operation ID +- HTTP method +- path +- tags + +Example shape: + +```json +{ + "specVersion": "v3.2.4", + "products": { + "ledger": { + "apiVersions": ["v1", "v2"], + "operations": { + "listTransactions": { + "v1": { + "operationId": "listTransactions", + "path": "/api/ledger/{ledger}/transactions" + }, + "v2": { + "operationId": "v2ListTransactions", + "path": "/api/ledger/v2/{ledger}/transactions" + } + } + } + } + } +} +``` + +## Manual Data + +The OpenAPI spec does not fully define which component binary versions support which API namespaces. Keep that as a small explicit table. + +Example: + +```go +var ComponentCompatibility = []ComponentRange{ + { + Product: "ledger", + Range: ">=1.0.0 <2.0.0", + APIVersions: []APIVersion{"v1"}, + }, + { + Product: "ledger", + Range: ">=2.0.0 <3.0.0", + APIVersions: []APIVersion{"v1", "v2"}, + }, +} +``` + +## Runtime Resolution + +1. Call `GetVersions`. +2. Convert component versions into supported API namespaces. +3. Find command handlers for the requested feature. +4. Select the highest compatible API namespace unless the user pinned one. +5. Return a clean error if no handler can run. diff --git a/docs/cli-v4/config-format.md b/docs/cli-v4/config-format.md new file mode 100644 index 00000000..b956f55c --- /dev/null +++ b/docs/cli-v4/config-format.md @@ -0,0 +1,62 @@ +# fctl v4 Config Format + +The v4 config should be explicit, versioned, and free of long-lived secrets. + +## Example + +```yaml +version: 4 +currentContext: local + +contexts: + local: + kind: stack + stackURL: http://localhost/api + auth: + method: client_credentials + issuerURL: http://localhost/api/auth + clientID: testing + secretRef: keyring://formance/local/testing + defaults: + ledger: default + api: + ledger: latest-compatible + + cloud-prod: + kind: cloud-stack + cloudURL: https://app.formance.cloud/api + organization: org_x + stack: stack_y + auth: + method: cloud_device + account: user@example.com + tokenRef: keyring://formance/cloud-prod/user@example.com + api: + ledger: latest-compatible +``` + +## Context Kinds + +- `stack`: direct stack API target. +- `cloud`: Formance Cloud control plane target. +- `cloud-stack`: stack target discovered or authorized through Formance Cloud. + +## Auth Methods + +- `cloud_device` +- `oidc_device` +- `client_credentials` +- `token` +- `none` + +`none` must be explicit and should warn unless the command is non-interactive or configured to suppress warnings. + +## Paths + +Use XDG-aware locations: + +- config: user config directory +- cache: discovery and temporary API tokens +- state: telemetry IDs and non-secret local state + +Keep credentials in a system keyring when available. diff --git a/docs/cli-v4/migration-from-v3.md b/docs/cli-v4/migration-from-v3.md new file mode 100644 index 00000000..9ad5cbcd --- /dev/null +++ b/docs/cli-v4/migration-from-v3.md @@ -0,0 +1,39 @@ +# Migration From fctl v3 + +The v4 CLI should import v3 configuration without deleting or rewriting it in place. + +## Mapping + +Current v3 profile fields: + +- `membershipURI` +- `rootTokens` +- `defaultOrganization` +- `defaultStack` + +Suggested v4 mapping: + +- `membershipURI` -> `cloudURL` +- `defaultOrganization` -> context `organization` +- `defaultStack` -> context `stack` +- `rootTokens` -> keyring credential, referenced by `tokenRef` + +## Command + +Provide an explicit migration command: + +```bash +fctl config migrate-v3 +``` + +The command should: + +1. Read v3 config and profiles. +2. Show the contexts that will be created. +3. Move secrets to keyring when possible. +4. Write v4 config. +5. Leave v3 files untouched. + +## Compatibility + +During early v4 releases, support a read-only fallback that can detect v3 profiles and suggest migration. Do not silently mutate v3 profile files during normal command execution. diff --git a/docs/rfcs/0001-fctl-v4-architecture.md b/docs/rfcs/0001-fctl-v4-architecture.md new file mode 100644 index 00000000..34adfcfc --- /dev/null +++ b/docs/rfcs/0001-fctl-v4-architecture.md @@ -0,0 +1,198 @@ +# RFC 0001: fctl v4 Architecture + +Status: Draft + +## Summary + +`fctl` v4 should be rebuilt around Formance targets rather than Formance Cloud membership. The CLI should work naturally against Formance Cloud, self-hosted stacks, and local development stacks. Authentication, target selection, API version selection, and rendering should be independent runtime concerns. + +The current CLI is Cloud-first: profiles store a membership URL and root Cloud tokens, and stack clients are created through membership-derived stack access. That makes local and self-hosted usage awkward or impossible without a Cloud membership. It also makes API version selection a command-level concern, so Ledger commands often call the oldest API namespace even when newer SDK namespaces exist. + +## Goals + +- Make local and self-hosted stack usage first-class. +- Keep Cloud workflows supported without making Cloud the root abstraction. +- Introduce contexts as the primary user-facing target selector. +- Support multiple authentication methods: Cloud device flow, generic OIDC, client credentials, token, and explicit no-auth development mode. +- Select the best compatible API namespace automatically using `/versions` and a generated manifest. +- Keep CLI commands stable at the product language level, even when OpenAPI names change. +- Preserve scriptability with stable JSON/YAML output, non-interactive modes, and predictable exit codes. + +## Non-Goals + +- Do not expose API namespaces directly in the primary UX, such as `fctl ledger v2 ...`. +- Do not require a new server-side capabilities endpoint for v4 MVP. +- Do not rewrite the public Go SDK as part of the CLI rewrite. +- Do not make interactive TUI flows mandatory. + +## Current Problems + +- Authentication is structurally tied to Formance Cloud membership. +- The profile model conflates identity, Cloud organization, stack selection, and token storage. +- Stack commands must authenticate through membership before building stack clients. +- Commands call SDK namespaces directly, for example `Ledger.V1` or `Ledger.V2`, making version policy scattered. +- CLI flags mirror API shapes too closely, which makes API evolution leak into the user experience. +- Secrets are stored in profile files rather than being consistently delegated to secure storage. + +## Target Model + +The v4 runtime should separate these concepts: + +- **Context**: named user selection, similar to Docker or Kubernetes contexts. +- **Target**: the actual thing a command talks to, such as a Cloud control plane or a stack data plane. +- **Auth**: how credentials are obtained for that target. +- **Capabilities**: what the CLI infers the target can support. +- **API version policy**: how the CLI chooses among SDK namespaces. +- **Rendering**: how typed command results become tables, JSON, YAML, or human text. + +Example context: + +```yaml +currentContext: local +contexts: + local: + kind: stack + stackURL: http://localhost/api + auth: + method: client_credentials + issuerURL: http://localhost/api/auth + clientID: testing + secretRef: keyring://formance/local/testing + defaults: + ledger: default + api: + ledger: latest-compatible + + cloud-prod: + kind: cloud-stack + cloudURL: https://app.formance.cloud/api + organization: org_x + stack: stack_y + auth: + method: cloud_device + account: user@example.com + api: + ledger: latest-compatible +``` + +## Command Model + +Commands represent product intent, not OpenAPI operations. For example: + +```bash +fctl ledger transactions list +fctl ledger transactions revert +fctl ledger schemas insert +``` + +Each command parses a canonical input model, then delegates to a versioned handler selected by the runtime. + +```go +type VersionedCommand[In any, Out any] struct { + Product string + Feature string + Handlers []VersionedHandler[In, Out] +} + +type VersionedHandler[In any, Out any] struct { + APIVersion APIVersion + Run func(context.Context, *formance.Formance, In) (Out, error) +} +``` + +The Cobra command should only parse flags, construct the canonical input, and call the typed command package. + +## API Version Selection + +The server currently exposes `/versions`, not a full capabilities endpoint. That is sufficient for v4. + +Runtime flow: + +1. Call `sdk.GetVersions(ctx)`. +2. Read component versions, such as `ledger=2.3.4`. +3. Map component versions to supported API namespaces through a small compatibility table. +4. Intersect server-supported API namespaces with SDK handlers available in the CLI. +5. Choose the highest compatible version by default. + +Example: + +- CLI has handlers for `ledger.v1`, `ledger.v2`, `ledger.v3`. +- `/versions` reports Ledger `2.3.4`. +- Compatibility table says Ledger `>=2.0.0 <3.0.0` supports `v1` and `v2`. +- The command uses `Ledger.V2`. + +Users can still force a version: + +```bash +fctl ledger transactions list --api-version v1 +fctl ledger transactions list --latest +``` + +The default should be `latest-compatible`. + +## Compatibility Manifest + +Most operation metadata should be generated from the released OpenAPI document, for example: + +`https://github.com/formancehq/stack/releases/download/v3.2.4/generate.json` + +The OpenAPI tags already contain names like `ledger.v1`, `ledger.v2`, `payments.v1`, `payments.v3`, `orchestration.v1`, and `orchestration.v2`. The generator can produce a manifest mapping product, API namespace, operation ID, and path. + +The only manual compatibility data should be component-version ranges to API namespaces, such as: + +```go +ledger >= 1.0.0 < 2.0.0 => v1 +ledger >= 2.0.0 < 3.0.0 => v1, v2 +ledger >= 3.0.0 => v1, v2, v3 +``` + +## Flag and Parameter Design + +CLI flags should use Formance product vocabulary, not generated API field names. If an API changes `account` to `address`, but the product concept is still an account address, the CLI should expose one canonical flag and map it internally. + +Rules: + +- If only the API name changed, keep one canonical CLI flag. +- If an old CLI flag is widely used, keep it as a deprecated alias. +- If a flag only works on newer API versions, keep it visible and validate it against runtime capabilities. +- If concepts diverge semantically, create separate product-level commands rather than version-suffixed commands. + +## Technical Stack + +- Keep Cobra and pflag for command routing, help, aliases, deprecations, and shell completions. +- Keep Cobra thin; do not put target/auth/version logic in command files. +- Use Charmbracelet Huh for optional interactive setup flows. +- Use Lip Gloss and Glamour for terminal and Markdown rendering where useful. +- Use a system keyring for secrets and explicit insecure fallback only when requested. +- Use XDG-aware paths for config, cache, and state. +- Use `testscript` style integration tests for real CLI behavior. +- Use GoReleaser for packaging, checksums, completions, and package manager artifacts. + +## Proposed Package Shape + +```text +cmd/ Cobra declarations only +internal/runtime/ target resolution, auth, versions, API selection +internal/config/ contexts, defaults, XDG paths, migrations +internal/credentials/ keyring and insecure fallback +internal/capabilities generated manifest and compatibility ranges +internal/commands/ typed product command implementations +internal/render/ table, json, yaml, markdown +internal/prompt/ optional interactive flows +``` + +## Migration + +The v4 CLI should import v3 profiles into contexts: + +- A v3 Cloud profile becomes a `cloud-stack` or `cloud` context. +- Membership tokens move out of profile files into the credential store when possible. +- Existing default organization and stack become context defaults. +- The v3 config should not be deleted automatically. + +## Open Questions + +- Exact naming for `context` versus `target`. +- Whether `fctl transaction list` aliases should exist beside `fctl ledger transactions list`. +- Whether compatibility ranges should live in the CLI repo, the SDK repo, or both. +- How aggressively to warn when a newer API namespace is available. From 6e5b31002a3d3fb43ac1fd5c0cb3ed834edd62f4 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:18:59 +0200 Subject: [PATCH 002/208] docs: add v4 implementation goals --- .codex/skills/fctl-v4-architecture/SKILL.md | 23 ++++++----- AGENTS.md | 4 ++ .../0005-build-v4-in-isolated-directory.md | 19 +++++++++ docs/rfcs/0001-fctl-v4-architecture.md | 17 ++++---- todos/01-v4-isolated-skeleton.md | 36 +++++++++++++++++ todos/02-v4-foundation-packages.md | 37 ++++++++++++++++++ todos/03-context-commands.md | 35 +++++++++++++++++ todos/04-auth-providers.md | 35 +++++++++++++++++ todos/05-capabilities-manifest-generator.md | 35 +++++++++++++++++ todos/06-runtime-version-resolver.md | 35 +++++++++++++++++ todos/07-stack-inspection-command.md | 32 +++++++++++++++ todos/08-first-ledger-versioned-command.md | 39 +++++++++++++++++++ todos/09-v3-config-migration.md | 35 +++++++++++++++++ .../10-integration-tests-and-ux-hardening.md | 32 +++++++++++++++ todos/11-cutover-plan.md | 32 +++++++++++++++ 15 files changed, 429 insertions(+), 17 deletions(-) create mode 100644 docs/adr/0005-build-v4-in-isolated-directory.md create mode 100644 todos/01-v4-isolated-skeleton.md create mode 100644 todos/02-v4-foundation-packages.md create mode 100644 todos/03-context-commands.md create mode 100644 todos/04-auth-providers.md create mode 100644 todos/05-capabilities-manifest-generator.md create mode 100644 todos/06-runtime-version-resolver.md create mode 100644 todos/07-stack-inspection-command.md create mode 100644 todos/08-first-ledger-versioned-command.md create mode 100644 todos/09-v3-config-migration.md create mode 100644 todos/10-integration-tests-and-ux-hardening.md create mode 100644 todos/11-cutover-plan.md diff --git a/.codex/skills/fctl-v4-architecture/SKILL.md b/.codex/skills/fctl-v4-architecture/SKILL.md index 5fe8713b..7e91841a 100644 --- a/.codex/skills/fctl-v4-architecture/SKILL.md +++ b/.codex/skills/fctl-v4-architecture/SKILL.md @@ -14,6 +14,7 @@ Before changing v4 architecture or commands, read these repository files: - `docs/rfcs/0001-fctl-v4-architecture.md` - `docs/cli-v4/command-design.md` - `docs/cli-v4/compatibility-manifest.md` +- `todos/01-v4-isolated-skeleton.md` Read ADRs as needed: @@ -21,6 +22,7 @@ Read ADRs as needed: - `docs/adr/0002-auth-is-decoupled-from-cloud.md` - `docs/adr/0003-api-version-resolution.md` - `docs/adr/0004-cobra-thin-runtime.md` +- `docs/adr/0005-build-v4-in-isolated-directory.md` ## Core Rules @@ -31,20 +33,23 @@ Read ADRs as needed: - Commands express product intent; they must not expose API versions as the primary UX. - Keep CLI flags canonical and product-oriented; map them to version-specific SDK request fields internally. - Keep Cobra thin. Runtime concerns belong in typed internal packages. +- Build the rewrite under `v4/` until the explicit cutover goal. +- Follow `todos/*.md` in order unless the user explicitly reprioritizes. +- Commit after each logical step. ## Implementation Shape -Prefer this package split: +Prefer this package split under `v4/` during the transition: ```text -cmd/ Cobra declarations only -internal/runtime/ target resolution, auth, versions, API selection -internal/config/ contexts, defaults, XDG paths, migrations -internal/credentials/ keyring and insecure fallback -internal/capabilities generated manifest and compatibility ranges -internal/commands/ typed product command implementations -internal/render/ table, json, yaml, markdown -internal/prompt/ optional interactive flows +v4/cmd/ Cobra declarations only +v4/internal/runtime/ target resolution, auth, versions, API selection +v4/internal/config/ contexts, defaults, XDG paths, migrations +v4/internal/credentials/ keyring and insecure fallback +v4/internal/capabilities generated manifest and compatibility ranges +v4/internal/commands/ typed product command implementations +v4/internal/render/ table, json, yaml, markdown +v4/internal/prompt/ optional interactive flows ``` ## Validation diff --git a/AGENTS.md b/AGENTS.md index 70714a00..6f5c7ae9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ Before working on the next major CLI architecture, read: - `docs/rfcs/0001-fctl-v4-architecture.md` - `docs/cli-v4/command-design.md` - `docs/cli-v4/compatibility-manifest.md` +- `todos/01-v4-isolated-skeleton.md` Core rules: @@ -14,3 +15,6 @@ Core rules: - Use `/versions` plus the generated compatibility manifest to select the best supported SDK namespace. - Keep Cobra as a thin parser/router; keep business logic in typed internal packages. - Store credentials in a keyring when possible; keep config files free of long-lived secrets. +- Build the rewrite under `v4/` until the explicit cutover goal. +- Follow `todos/*.md` in order unless the user explicitly reprioritizes. +- Commit after each logical step when implementing v4 work. diff --git a/docs/adr/0005-build-v4-in-isolated-directory.md b/docs/adr/0005-build-v4-in-isolated-directory.md new file mode 100644 index 00000000..2964e4c6 --- /dev/null +++ b/docs/adr/0005-build-v4-in-isolated-directory.md @@ -0,0 +1,19 @@ +# ADR 0005: Build v4 In An Isolated Directory + +Status: Accepted for v4 planning + +## Context + +`fctl` v4 is intended to be a near-rewrite. The existing v3 code remains useful as a behavioral reference during implementation and review. + +## Decision + +Build the new CLI under a top-level `v4/` directory during the transition. Keep the existing root implementation intact until v4 has reached feature parity or an explicit cutover point. + +## Consequences + +- v3 remains available for comparison while v4 is built. +- Review is easier because new code is isolated from old code. +- The v4 module can start with a clean package layout. +- The final cutover will delete or archive the old root implementation and move the v4 implementation to the root. +- Build, release, and test commands must be explicit about whether they target v3 root or `v4/`. diff --git a/docs/rfcs/0001-fctl-v4-architecture.md b/docs/rfcs/0001-fctl-v4-architecture.md index 34adfcfc..56f1f2a2 100644 --- a/docs/rfcs/0001-fctl-v4-architecture.md +++ b/docs/rfcs/0001-fctl-v4-architecture.md @@ -167,18 +167,19 @@ Rules: - Use XDG-aware paths for config, cache, and state. - Use `testscript` style integration tests for real CLI behavior. - Use GoReleaser for packaging, checksums, completions, and package manager artifacts. +- Build the rewrite under a top-level `v4/` directory during the transition. Keep the current root implementation intact until the explicit cutover. ## Proposed Package Shape ```text -cmd/ Cobra declarations only -internal/runtime/ target resolution, auth, versions, API selection -internal/config/ contexts, defaults, XDG paths, migrations -internal/credentials/ keyring and insecure fallback -internal/capabilities generated manifest and compatibility ranges -internal/commands/ typed product command implementations -internal/render/ table, json, yaml, markdown -internal/prompt/ optional interactive flows +v4/cmd/ Cobra declarations only +v4/internal/runtime/ target resolution, auth, versions, API selection +v4/internal/config/ contexts, defaults, XDG paths, migrations +v4/internal/credentials/ keyring and insecure fallback +v4/internal/capabilities generated manifest and compatibility ranges +v4/internal/commands/ typed product command implementations +v4/internal/render/ table, json, yaml, markdown +v4/internal/prompt/ optional interactive flows ``` ## Migration diff --git a/todos/01-v4-isolated-skeleton.md b/todos/01-v4-isolated-skeleton.md new file mode 100644 index 00000000..a86009c7 --- /dev/null +++ b/todos/01-v4-isolated-skeleton.md @@ -0,0 +1,36 @@ +# Goal 01 - v4 isolated skeleton + +```text +/goal +Create the isolated fctl v4 skeleton under the top-level v4/ directory. + +Read first: +- AGENTS.md +- docs/rfcs/0001-fctl-v4-architecture.md +- docs/adr/0005-build-v4-in-isolated-directory.md +- docs/cli-v4/config-format.md + +Deliverables: +- v4 Go module or buildable v4 application skeleton. +- v4/cmd root command with Cobra wired as a thin parser/router. +- v4/internal package directories for config, credentials, capabilities, runtime, commands, render, and prompt. +- minimal v4 main entrypoint that can print version/help. +- documentation note explaining how to build/run v4 without touching v3. + +Constraints: +- stay on branch feat/v4. +- do not modify or delete existing v3 command behavior. +- do not move root files yet. +- keep new code isolated under v4/. +- commit each logical step separately. +- run git diff --check before each commit. + +Tests: +- run go test for the v4 module/package if a module is created. +- run the v4 binary help/version command if buildable. + +Done when: +- v4 can be built or at least tested independently. +- v3 root remains untouched except documentation/build notes if needed. +- all changes are committed in small reviewable commits. +``` diff --git a/todos/02-v4-foundation-packages.md b/todos/02-v4-foundation-packages.md new file mode 100644 index 00000000..b3985c42 --- /dev/null +++ b/todos/02-v4-foundation-packages.md @@ -0,0 +1,37 @@ +# Goal 02 - v4 foundation packages + +```text +/goal +Implement the v4 foundation packages without migrating product commands. + +Read first: +- docs/rfcs/0001-fctl-v4-architecture.md +- docs/cli-v4/config-format.md +- docs/cli-v4/compatibility-manifest.md +- docs/adr/0001-contexts-as-primary-target.md +- docs/adr/0002-auth-is-decoupled-from-cloud.md +- docs/adr/0003-api-version-resolution.md + +Deliverables: +- v4/internal/config for versioned context config structs, load, save, validate, current context resolution, and env/flag override hooks. +- v4/internal/credentials with interfaces for secret get/set/delete and an explicit insecure file implementation for development tests. +- v4/internal/capabilities with APIVersion, Product, Feature, Manifest, ComponentCompatibility, and resolver data types. +- v4/internal/runtime with a typed Runtime shell that resolves config, context, target, and API policy. +- unit tests for config validation, context selection, and compatibility range resolution. + +Constraints: +- do not add real auth flows yet. +- do not migrate existing v3 commands. +- do not store long-lived secrets in config structs except secret references. +- commit after each package or cohesive test set. +- run git diff --check before each commit. + +Tests: +- go test ./... in v4. +- targeted unit tests for each new package. + +Done when: +- the foundation packages compile and are tested. +- no v3 behavior changes. +- commits are small and reviewable. +``` diff --git a/todos/03-context-commands.md b/todos/03-context-commands.md new file mode 100644 index 00000000..6fdc2f5d --- /dev/null +++ b/todos/03-context-commands.md @@ -0,0 +1,35 @@ +# Goal 03 - v4 context commands + +```text +/goal +Add the first v4 context management commands. + +Read first: +- docs/cli-v4/config-format.md +- docs/adr/0001-contexts-as-primary-target.md +- v4/internal/config and v4/internal/runtime from prior goals. + +Deliverables: +- fctl v4 context list. +- fctl v4 context show [name]. +- fctl v4 context use . +- fctl v4 context create stack --stack-url ... with minimal auth reference support. +- JSON output support for these commands. +- tests for config mutation and command output. + +Constraints: +- commands must be non-destructive. +- no Cloud membership dependency. +- support non-interactive usage. +- keep Cobra command files thin. +- commit after each command group or test group. +- run git diff --check before each commit. + +Tests: +- go test ./... in v4. +- command-level tests for list/show/use/create. + +Done when: +- contexts can be created, listed, inspected, and selected in v4 config. +- command output is stable enough for scripts. +``` diff --git a/todos/04-auth-providers.md b/todos/04-auth-providers.md new file mode 100644 index 00000000..83c0e397 --- /dev/null +++ b/todos/04-auth-providers.md @@ -0,0 +1,35 @@ +# Goal 04 - v4 auth providers + +```text +/goal +Implement initial v4 authentication providers for stack targets. + +Read first: +- docs/adr/0002-auth-is-decoupled-from-cloud.md +- docs/cli-v4/config-format.md +- v4/internal/credentials from prior goals. + +Deliverables: +- auth provider interface in v4/internal/runtime or v4/internal/auth if the split is warranted. +- client_credentials provider using issuer URL, client ID, and secret reference. +- token provider using token from env, stdin, or credential reference. +- explicit none provider for local development targets. +- credential storage through the credentials interface. +- clear errors for missing credentials. + +Constraints: +- do not implement Cloud device flow yet unless it is needed for tests. +- do not store secrets directly in config. +- none auth must be explicit. +- keep CI/non-interactive behavior deterministic. +- commit each provider separately when practical. +- run git diff --check before each commit. + +Tests: +- unit tests for provider selection. +- unit tests with fake HTTP token endpoint for client_credentials. +- no tests should require real Formance Cloud. + +Done when: +- stack contexts can resolve an HTTP client/token source for client_credentials, token, and none. +``` diff --git a/todos/05-capabilities-manifest-generator.md b/todos/05-capabilities-manifest-generator.md new file mode 100644 index 00000000..f007a4b3 --- /dev/null +++ b/todos/05-capabilities-manifest-generator.md @@ -0,0 +1,35 @@ +# Goal 05 - capabilities manifest generator + +```text +/goal +Generate the v4 compatibility manifest from the stack OpenAPI spec. + +Read first: +- docs/cli-v4/compatibility-manifest.md +- docs/adr/0003-api-version-resolution.md + +Reference spec: +- https://github.com/formancehq/stack/releases/download/v3.2.4/generate.json + +Deliverables: +- generator script or Go tool that reads generate.json. +- generated v4/internal/capabilities manifest containing spec version, products, API namespaces, operation IDs, HTTP methods, paths, and tags. +- manual component version compatibility table kept separate from generated data. +- test fixture based on a reduced OpenAPI sample. +- documentation for regenerating the manifest. + +Constraints: +- generated files must be deterministic. +- do not require network in normal tests. +- keep manual compatibility ranges small and explicit. +- commit generator and generated output separately if useful for review. +- run git diff --check before each commit. + +Tests: +- unit tests for generator parsing. +- go test ./... in v4. +- optional check that generated output is up to date. + +Done when: +- v4 has generated operation metadata from OpenAPI and manual component compatibility ranges. +``` diff --git a/todos/06-runtime-version-resolver.md b/todos/06-runtime-version-resolver.md new file mode 100644 index 00000000..aa6f469d --- /dev/null +++ b/todos/06-runtime-version-resolver.md @@ -0,0 +1,35 @@ +# Goal 06 - runtime API version resolver + +```text +/goal +Implement runtime API version resolution using /versions and the compatibility manifest. + +Read first: +- docs/cli-v4/compatibility-manifest.md +- docs/adr/0003-api-version-resolution.md +- v4/internal/capabilities from prior goals. + +Deliverables: +- Runtime support for calling SDK GetVersions or an abstract versions client. +- parser for /versions response into component versions. +- resolver that maps component version -> supported API namespaces. +- resolver that selects the highest compatible command handler unless pinned by policy. +- support for policies: latest-compatible, pinned, latest if feasible. +- clean unsupported-feature errors. + +Constraints: +- make resolver testable without network. +- do not add Ledger command migration yet. +- avoid probing endpoints as a substitute for /versions. +- commit resolver model, implementation, and tests in small chunks. +- run git diff --check before each commit. + +Tests: +- unit tests for component version mapping. +- unit tests for handler selection. +- unit tests for pinned version errors. +- go test ./... in v4. + +Done when: +- a command can ask runtime for the correct API handler based on target versions and policy. +``` diff --git a/todos/07-stack-inspection-command.md b/todos/07-stack-inspection-command.md new file mode 100644 index 00000000..48a5c5f6 --- /dev/null +++ b/todos/07-stack-inspection-command.md @@ -0,0 +1,32 @@ +# Goal 07 - first stack inspection command + +```text +/goal +Add a first non-destructive v4 stack inspection command to validate runtime resolution. + +Read first: +- docs/rfcs/0001-fctl-v4-architecture.md +- docs/cli-v4/compatibility-manifest.md +- v4/internal/runtime from prior goals. + +Deliverables: +- fctl v4 target inspect or fctl v4 capabilities inspect. +- command calls /versions for the current stack target. +- output includes target URL, component versions, health, inferred API namespaces, and API policy. +- JSON output support. +- command-level tests with fake versions response. + +Constraints: +- no Cloud membership requirement. +- no mutation of remote state. +- command must work against local/self-hosted contexts. +- commit command, renderers, and tests in reviewable chunks. +- run git diff --check before each commit. + +Tests: +- go test ./... in v4. +- command-level tests for table and JSON output. + +Done when: +- v4 can inspect a configured stack and show inferred capabilities without touching Ledger data. +``` diff --git a/todos/08-first-ledger-versioned-command.md b/todos/08-first-ledger-versioned-command.md new file mode 100644 index 00000000..68977930 --- /dev/null +++ b/todos/08-first-ledger-versioned-command.md @@ -0,0 +1,39 @@ +# Goal 08 - first Ledger versioned command + +```text +/goal +Implement the first Ledger command using versioned handlers. + +Read first: +- docs/cli-v4/command-design.md +- docs/cli-v4/compatibility-manifest.md +- docs/adr/0003-api-version-resolution.md + +Suggested command: +- fctl v4 ledger transactions list + +Deliverables: +- canonical input model for listing Ledger transactions. +- versioned handlers for the SDK namespaces available in the public SDK. +- adapters from canonical input to generated SDK request types. +- runtime selection of the best compatible handler. +- JSON and table rendering. +- clear error when no compatible API version exists. + +Constraints: +- do not migrate all Ledger commands. +- avoid exposing v1/v2 in the primary command path. +- support --api-version for pinning if the runtime already supports it. +- keep Cobra thin. +- commit adapters, runtime wiring, and command tests separately where practical. +- run git diff --check before each commit. + +Tests: +- unit tests for canonical input parsing. +- unit tests for handler selection. +- command-level tests using fake SDK/runtime boundaries. +- go test ./... in v4. + +Done when: +- one Ledger command proves the versioned command pattern end to end. +``` diff --git a/todos/09-v3-config-migration.md b/todos/09-v3-config-migration.md new file mode 100644 index 00000000..49c4fbc3 --- /dev/null +++ b/todos/09-v3-config-migration.md @@ -0,0 +1,35 @@ +# Goal 09 - v3 config migration + +```text +/goal +Implement explicit v3 to v4 configuration migration. + +Read first: +- docs/cli-v4/migration-from-v3.md +- docs/cli-v4/config-format.md +- docs/adr/0001-contexts-as-primary-target.md + +Deliverables: +- fctl v4 config migrate-v3 command. +- read-only parser for existing v3 config/profile files. +- migration planner that shows contexts and credential moves before writing. +- migration writer that creates v4 config and stores credentials through the credentials interface when possible. +- dry-run mode. +- tests with fixture v3 configs. + +Constraints: +- do not delete or mutate v3 files. +- do not silently migrate during normal command execution. +- never print secrets in logs or normal output. +- commit parser, planner, writer, and command in separate reviewable commits. +- run git diff --check before each commit. + +Tests: +- unit tests for v3 fixture parsing. +- unit tests for migration plan output. +- command-level tests for dry-run and write mode. +- go test ./... in v4. + +Done when: +- users can explicitly migrate v3 profiles to v4 contexts without losing v3 data. +``` diff --git a/todos/10-integration-tests-and-ux-hardening.md b/todos/10-integration-tests-and-ux-hardening.md new file mode 100644 index 00000000..4d2fbc32 --- /dev/null +++ b/todos/10-integration-tests-and-ux-hardening.md @@ -0,0 +1,32 @@ +# Goal 10 - integration tests and UX hardening + +```text +/goal +Harden v4 CLI behavior with integration-style tests and user-facing error/output polish. + +Read first: +- docs/rfcs/0001-fctl-v4-architecture.md +- docs/cli-v4/command-design.md +- all prior todos that have been completed. + +Deliverables: +- testscript-style integration tests or equivalent command execution harness. +- stable stdout/stderr assertions for context, inspect, auth, and first Ledger command. +- consistent error types and exit codes for unsupported target, missing auth, unsupported API, and invalid config. +- JSON/YAML output checks. +- non-interactive mode checks. + +Constraints: +- do not require real Formance Cloud. +- prefer fake local HTTP servers or fixtures. +- do not add broad refactors unrelated to UX/test hardening. +- commit test harness first, then behavior changes in small commits. +- run git diff --check before each commit. + +Tests: +- go test ./... in v4. +- integration harness runs in CI-friendly mode. + +Done when: +- core v4 workflows are covered as real CLI usage, not only unit tests. +``` diff --git a/todos/11-cutover-plan.md b/todos/11-cutover-plan.md new file mode 100644 index 00000000..c231d2aa --- /dev/null +++ b/todos/11-cutover-plan.md @@ -0,0 +1,32 @@ +# Goal 11 - v4 cutover plan + +```text +/goal +Prepare the final cutover plan for moving the completed v4 implementation from v4/ to the repository root. + +Read first: +- docs/adr/0005-build-v4-in-isolated-directory.md +- docs/rfcs/0001-fctl-v4-architecture.md +- all completed todos. + +Deliverables: +- written cutover checklist. +- inventory of root files to delete, move, or preserve. +- module path and import path plan. +- release and packaging plan. +- compatibility notes for users and contributors. +- final validation matrix before the cutover commit. + +Constraints: +- this goal is planning only unless the user explicitly asks to execute cutover. +- do not delete v3 root files during this goal. +- do not move v4 to root yet. +- commit the cutover plan as documentation. +- run git diff --check before commit. + +Tests: +- documentation-only unless build metadata is touched. + +Done when: +- the team has an explicit, reviewable checklist for the final root replacement. +``` From beb91d7622e5791f7f39e2efda3d257b3097bfac Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:22:17 +0200 Subject: [PATCH 003/208] feat: add isolated v4 cli skeleton --- v4/README.md | 28 ++++++++++++++ v4/cmd/root.go | 66 +++++++++++++++++++++++++++++++++ v4/cmd/root_test.go | 55 +++++++++++++++++++++++++++ v4/go.mod | 12 ++++++ v4/go.sum | 10 +++++ v4/internal/capabilities/doc.go | 3 ++ v4/internal/commands/doc.go | 4 ++ v4/internal/config/doc.go | 3 ++ v4/internal/credentials/doc.go | 3 ++ v4/internal/prompt/doc.go | 3 ++ v4/internal/render/doc.go | 3 ++ v4/internal/runtime/doc.go | 3 ++ v4/main.go | 9 +++++ 13 files changed, 202 insertions(+) create mode 100644 v4/README.md create mode 100644 v4/cmd/root.go create mode 100644 v4/cmd/root_test.go create mode 100644 v4/go.mod create mode 100644 v4/go.sum create mode 100644 v4/internal/capabilities/doc.go create mode 100644 v4/internal/commands/doc.go create mode 100644 v4/internal/config/doc.go create mode 100644 v4/internal/credentials/doc.go create mode 100644 v4/internal/prompt/doc.go create mode 100644 v4/internal/render/doc.go create mode 100644 v4/internal/runtime/doc.go create mode 100644 v4/main.go diff --git a/v4/README.md b/v4/README.md new file mode 100644 index 00000000..cfceac9f --- /dev/null +++ b/v4/README.md @@ -0,0 +1,28 @@ +# fctl v4 + +This directory contains the isolated fctl v4 rewrite. The existing repository +root remains the current v3 implementation during the transition. + +## Run + +From this directory: + +```bash +go run . --help +go run . version +``` + +## Test + +From this directory: + +```bash +go test ./... +``` + +## Boundaries + +- Keep new implementation code under `v4/` until the explicit cutover. +- Do not import root v3 packages from v4 code. +- Keep Cobra command files thin; place runtime and product behavior under + `v4/internal`. diff --git a/v4/cmd/root.go b/v4/cmd/root.go new file mode 100644 index 00000000..aeaf8b44 --- /dev/null +++ b/v4/cmd/root.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +const ( + contextFlag = "context" + configDirFlag = "config-dir" + outputFlag = "output" + nonInteractiveFlag = "non-interactive" +) + +// NewRootCommand builds the v4 command tree. Keep this package focused on +// parsing and dispatch; runtime work belongs under internal packages. +func NewRootCommand(version string) *cobra.Command { + if version == "" { + version = "dev" + } + + root := &cobra.Command{ + Use: "fctl", + Short: "Formance Control CLI v4", + Long: "Formance Control CLI v4 targets Cloud, self-hosted, and local Formance stacks through explicit contexts.", + Version: version, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + root.SetVersionTemplate("fctl v4 {{.Version}}\n") + root.PersistentFlags().String(contextFlag, "", "Context to use") + root.PersistentFlags().String(configDirFlag, "", "Path to the v4 configuration directory") + root.PersistentFlags().StringP(outputFlag, "o", "plain", "Output format (plain, json, yaml)") + root.PersistentFlags().Bool(nonInteractiveFlag, false, "Disable interactive prompts") + + root.AddCommand(newVersionCommand()) + + return root +} + +func newVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the fctl v4 version", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + _, err := fmt.Fprintf(cmd.OutOrStdout(), "fctl v4 %s\n", cmd.Root().Version) + return err + }, + } +} + +// Execute runs the v4 command tree. +func Execute(version string) { + root := NewRootCommand(version) + if err := root.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/v4/cmd/root_test.go b/v4/cmd/root_test.go new file mode 100644 index 00000000..804a4ae5 --- /dev/null +++ b/v4/cmd/root_test.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" +) + +func executeCommand(t *testing.T, args ...string) (string, string, error) { + t.Helper() + + command := NewRootCommand("test-version") + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + command.SetOut(&stdout) + command.SetErr(&stderr) + command.SetArgs(args) + + err := command.Execute() + return stdout.String(), stderr.String(), err +} + +func TestRootHelp(t *testing.T) { + stdout, stderr, err := executeCommand(t, "--help") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if stderr != "" { + t.Fatalf("expected empty stderr, got %q", stderr) + } + for _, expected := range []string{ + "Formance Control CLI v4", + "--context", + "--config-dir", + "--non-interactive", + "version", + } { + if !strings.Contains(stdout, expected) { + t.Fatalf("expected help output to contain %q, got:\n%s", expected, stdout) + } + } +} + +func TestVersionCommand(t *testing.T) { + stdout, stderr, err := executeCommand(t, "version") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if stderr != "" { + t.Fatalf("expected empty stderr, got %q", stderr) + } + if stdout != "fctl v4 test-version\n" { + t.Fatalf("unexpected version output: %q", stdout) + } +} diff --git a/v4/go.mod b/v4/go.mod new file mode 100644 index 00000000..9d2b145f --- /dev/null +++ b/v4/go.mod @@ -0,0 +1,12 @@ +module github.com/formancehq/fctl/v4 + +go 1.25.0 + +toolchain go1.25.7 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/v4/go.sum b/v4/go.sum new file mode 100644 index 00000000..a6ee3e0f --- /dev/null +++ b/v4/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/v4/internal/capabilities/doc.go b/v4/internal/capabilities/doc.go new file mode 100644 index 00000000..6c642e7b --- /dev/null +++ b/v4/internal/capabilities/doc.go @@ -0,0 +1,3 @@ +// Package capabilities models stack component versions, API namespaces, and +// generated operation metadata used by the v4 runtime resolver. +package capabilities diff --git a/v4/internal/commands/doc.go b/v4/internal/commands/doc.go new file mode 100644 index 00000000..bc1bdef7 --- /dev/null +++ b/v4/internal/commands/doc.go @@ -0,0 +1,4 @@ +// Package commands contains typed product-level command implementations. +// Cobra command files should call into this package instead of embedding +// business logic directly. +package commands diff --git a/v4/internal/config/doc.go b/v4/internal/config/doc.go new file mode 100644 index 00000000..aa252d2d --- /dev/null +++ b/v4/internal/config/doc.go @@ -0,0 +1,3 @@ +// Package config owns the fctl v4 context configuration format, validation, +// persistence, and migration-facing types. +package config diff --git a/v4/internal/credentials/doc.go b/v4/internal/credentials/doc.go new file mode 100644 index 00000000..0bbf30c2 --- /dev/null +++ b/v4/internal/credentials/doc.go @@ -0,0 +1,3 @@ +// Package credentials abstracts secure and explicit-insecure secret storage for +// authentication providers. +package credentials diff --git a/v4/internal/prompt/doc.go b/v4/internal/prompt/doc.go new file mode 100644 index 00000000..eff46783 --- /dev/null +++ b/v4/internal/prompt/doc.go @@ -0,0 +1,3 @@ +// Package prompt contains optional interactive flows. Commands must remain +// usable in non-interactive mode. +package prompt diff --git a/v4/internal/render/doc.go b/v4/internal/render/doc.go new file mode 100644 index 00000000..6477efe1 --- /dev/null +++ b/v4/internal/render/doc.go @@ -0,0 +1,3 @@ +// Package render converts typed command results into human and machine-readable +// output formats. +package render diff --git a/v4/internal/runtime/doc.go b/v4/internal/runtime/doc.go new file mode 100644 index 00000000..8b3d0f36 --- /dev/null +++ b/v4/internal/runtime/doc.go @@ -0,0 +1,3 @@ +// Package runtime resolves contexts, targets, authentication, component +// versions, and API version policy for v4 commands. +package runtime diff --git a/v4/main.go b/v4/main.go new file mode 100644 index 00000000..5098d0b2 --- /dev/null +++ b/v4/main.go @@ -0,0 +1,9 @@ +package main + +import "github.com/formancehq/fctl/v4/cmd" + +var version = "dev" + +func main() { + cmd.Execute(version) +} From b8e37ff8259dc4408ee48c7c865ce79d9271f043 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:24:49 +0200 Subject: [PATCH 004/208] docs: track v4 todo status --- todos/STATUS.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 todos/STATUS.md diff --git a/todos/STATUS.md b/todos/STATUS.md new file mode 100644 index 00000000..0f92ebe6 --- /dev/null +++ b/todos/STATUS.md @@ -0,0 +1,15 @@ +# v4 Todo Status + +- [x] 01 - v4 isolated skeleton +- [ ] 02 - v4 foundation packages +- [ ] 03 - context commands +- [ ] 04 - auth providers +- [ ] 05 - capabilities manifest generator +- [ ] 06 - runtime API version resolver +- [ ] 07 - first stack inspection command +- [ ] 08 - first Ledger versioned command +- [ ] 09 - v3 config migration +- [ ] 10 - integration tests and UX hardening +- [ ] 11 - v4 cutover plan + +Update this file after each completed todo. Keep implementation commits separate from status-only commits when practical. From 378edcf497606344c0d82cce73cb874d81f4739d Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:26:33 +0200 Subject: [PATCH 005/208] feat: add v4 context config package --- v4/go.mod | 5 +- v4/go.sum | 3 + v4/internal/config/config.go | 269 ++++++++++++++++++++++++++++++ v4/internal/config/config_test.go | 133 +++++++++++++++ v4/internal/config/doc.go | 3 - 5 files changed, 409 insertions(+), 4 deletions(-) create mode 100644 v4/internal/config/config.go create mode 100644 v4/internal/config/config_test.go delete mode 100644 v4/internal/config/doc.go diff --git a/v4/go.mod b/v4/go.mod index 9d2b145f..5dfc2c31 100644 --- a/v4/go.mod +++ b/v4/go.mod @@ -4,7 +4,10 @@ go 1.25.0 toolchain go1.25.7 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 +) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/v4/go.sum b/v4/go.sum index a6ee3e0f..47edb24d 100644 --- a/v4/go.sum +++ b/v4/go.sum @@ -7,4 +7,7 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/v4/internal/config/config.go b/v4/internal/config/config.go new file mode 100644 index 00000000..ab9bb571 --- /dev/null +++ b/v4/internal/config/config.go @@ -0,0 +1,269 @@ +// Package config owns the fctl v4 context configuration format, validation, +// persistence, and migration-facing types. +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + Version = 4 + + EnvContext = "FCTL_CONTEXT" +) + +type ContextKind string + +const ( + ContextKindStack ContextKind = "stack" + ContextKindCloud ContextKind = "cloud" + ContextKindCloudStack ContextKind = "cloud-stack" +) + +type AuthMethod string + +const ( + AuthMethodCloudDevice AuthMethod = "cloud_device" + AuthMethodOIDCDevice AuthMethod = "oidc_device" + AuthMethodClientCredentials AuthMethod = "client_credentials" + AuthMethodToken AuthMethod = "token" + AuthMethodNone AuthMethod = "none" +) + +type APIPolicy string + +const ( + APIPolicyLatestCompatible APIPolicy = "latest-compatible" + APIPolicyPinned APIPolicy = "pinned" + APIPolicyLatest APIPolicy = "latest" +) + +type Config struct { + Version int `json:"version" yaml:"version"` + CurrentContext string `json:"currentContext,omitempty" yaml:"currentContext,omitempty"` + Contexts map[string]Context `json:"contexts,omitempty" yaml:"contexts,omitempty"` +} + +type Context struct { + Kind ContextKind `json:"kind" yaml:"kind"` + StackURL string `json:"stackURL,omitempty" yaml:"stackURL,omitempty"` + CloudURL string `json:"cloudURL,omitempty" yaml:"cloudURL,omitempty"` + Organization string `json:"organization,omitempty" yaml:"organization,omitempty"` + Stack string `json:"stack,omitempty" yaml:"stack,omitempty"` + Auth Auth `json:"auth" yaml:"auth"` + Defaults map[string]string `json:"defaults,omitempty" yaml:"defaults,omitempty"` + API map[string]string `json:"api,omitempty" yaml:"api,omitempty"` +} + +type Auth struct { + Method AuthMethod `json:"method" yaml:"method"` + IssuerURL string `json:"issuerURL,omitempty" yaml:"issuerURL,omitempty"` + ClientID string `json:"clientID,omitempty" yaml:"clientID,omitempty"` + SecretRef string `json:"secretRef,omitempty" yaml:"secretRef,omitempty"` + TokenRef string `json:"tokenRef,omitempty" yaml:"tokenRef,omitempty"` + Account string `json:"account,omitempty" yaml:"account,omitempty"` +} + +type ContextOverride struct { + Name string +} + +func New() Config { + return Config{ + Version: Version, + Contexts: map[string]Context{}, + } +} + +func LoadFile(path string) (Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return Config{}, err + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return Config{}, fmt.Errorf("parse config: %w", err) + } + if err := cfg.Validate(); err != nil { + return Config{}, err + } + return cfg, nil +} + +func SaveFile(path string, cfg Config) error { + if err := cfg.Validate(); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create config directory: %w", err) + } + + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("write config: %w", err) + } + return nil +} + +func (c Config) Validate() error { + var errs []error + + if c.Version != Version { + errs = append(errs, fmt.Errorf("unsupported config version %d", c.Version)) + } + if len(c.Contexts) == 0 { + errs = append(errs, errors.New("at least one context is required")) + } + if c.CurrentContext != "" { + if _, ok := c.Contexts[c.CurrentContext]; !ok { + errs = append(errs, fmt.Errorf("current context %q does not exist", c.CurrentContext)) + } + } + + for name, context := range c.Contexts { + if strings.TrimSpace(name) == "" { + errs = append(errs, errors.New("context name cannot be empty")) + continue + } + if err := context.Validate(); err != nil { + errs = append(errs, fmt.Errorf("context %q: %w", name, err)) + } + } + + return errors.Join(errs...) +} + +func (c Context) Validate() error { + var errs []error + + switch c.Kind { + case ContextKindStack: + if c.StackURL == "" { + errs = append(errs, errors.New("stackURL is required for stack contexts")) + } + case ContextKindCloud: + if c.CloudURL == "" { + errs = append(errs, errors.New("cloudURL is required for cloud contexts")) + } + case ContextKindCloudStack: + if c.CloudURL == "" { + errs = append(errs, errors.New("cloudURL is required for cloud-stack contexts")) + } + if c.Organization == "" { + errs = append(errs, errors.New("organization is required for cloud-stack contexts")) + } + if c.Stack == "" { + errs = append(errs, errors.New("stack is required for cloud-stack contexts")) + } + default: + errs = append(errs, fmt.Errorf("unsupported kind %q", c.Kind)) + } + + if err := c.Auth.Validate(); err != nil { + errs = append(errs, err) + } + for product, policy := range c.API { + if strings.TrimSpace(product) == "" { + errs = append(errs, errors.New("api product cannot be empty")) + continue + } + if err := ValidateAPIPolicy(policy); err != nil { + errs = append(errs, fmt.Errorf("api policy for %q: %w", product, err)) + } + } + + return errors.Join(errs...) +} + +func (a Auth) Validate() error { + switch a.Method { + case AuthMethodCloudDevice: + return nil + case AuthMethodOIDCDevice: + if a.IssuerURL == "" { + return errors.New("issuerURL is required for oidc_device auth") + } + case AuthMethodClientCredentials: + var errs []error + if a.IssuerURL == "" { + errs = append(errs, errors.New("issuerURL is required for client_credentials auth")) + } + if a.ClientID == "" { + errs = append(errs, errors.New("clientID is required for client_credentials auth")) + } + if a.SecretRef == "" { + errs = append(errs, errors.New("secretRef is required for client_credentials auth")) + } + return errors.Join(errs...) + case AuthMethodToken: + if a.TokenRef == "" { + return errors.New("tokenRef is required for token auth") + } + case AuthMethodNone: + return nil + default: + return fmt.Errorf("unsupported auth method %q", a.Method) + } + return nil +} + +func ValidateAPIPolicy(policy string) error { + switch APIPolicy(policy) { + case APIPolicyLatestCompatible, APIPolicyPinned, APIPolicyLatest: + return nil + default: + return fmt.Errorf("unsupported api policy %q", policy) + } +} + +func ResolveCurrentContext(cfg Config, override ContextOverride) (string, Context, error) { + name := override.Name + if name == "" { + name = cfg.CurrentContext + } + if name == "" { + switch len(cfg.Contexts) { + case 0: + return "", Context{}, errors.New("no contexts configured") + case 1: + for only := range cfg.Contexts { + name = only + } + default: + return "", Context{}, fmt.Errorf("no current context configured; available contexts: %s", strings.Join(cfg.ContextNames(), ", ")) + } + } + + context, ok := cfg.Contexts[name] + if !ok { + return "", Context{}, fmt.Errorf("context %q does not exist", name) + } + return name, context, nil +} + +func ContextOverrideFromEnv(getenv func(string) string) ContextOverride { + if getenv == nil { + getenv = os.Getenv + } + return ContextOverride{Name: getenv(EnvContext)} +} + +func (c Config) ContextNames() []string { + names := make([]string, 0, len(c.Contexts)) + for name := range c.Contexts { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/v4/internal/config/config_test.go b/v4/internal/config/config_test.go new file mode 100644 index 00000000..bb045b80 --- /dev/null +++ b/v4/internal/config/config_test.go @@ -0,0 +1,133 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func validConfig() Config { + return Config{ + Version: Version, + CurrentContext: "local", + Contexts: map[string]Context{ + "local": { + Kind: ContextKindStack, + StackURL: "http://localhost/api", + Auth: Auth{ + Method: AuthMethodClientCredentials, + IssuerURL: "http://localhost/api/auth", + ClientID: "testing", + SecretRef: "keyring://formance/local/testing", + }, + Defaults: map[string]string{"ledger": "default"}, + API: map[string]string{"ledger": string(APIPolicyLatestCompatible)}, + }, + }, + } +} + +func TestValidateAcceptsStackContext(t *testing.T) { + if err := validConfig().Validate(); err != nil { + t.Fatalf("expected valid config, got %v", err) + } +} + +func TestValidateRejectsMissingCurrentContext(t *testing.T) { + cfg := validConfig() + cfg.CurrentContext = "missing" + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), `current context "missing" does not exist`) { + t.Fatalf("expected missing current context error, got %v", err) + } +} + +func TestValidateRejectsSecretInlineByRequiringReference(t *testing.T) { + cfg := validConfig() + cfg.Contexts["local"] = Context{ + Kind: ContextKindStack, + StackURL: "http://localhost/api", + Auth: Auth{ + Method: AuthMethodClientCredentials, + IssuerURL: "http://localhost/api/auth", + ClientID: "testing", + }, + } + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "secretRef is required") { + t.Fatalf("expected secretRef validation error, got %v", err) + } +} + +func TestResolveCurrentContextUsesOverride(t *testing.T) { + cfg := validConfig() + cfg.Contexts["other"] = Context{ + Kind: ContextKindStack, + StackURL: "http://other/api", + Auth: Auth{Method: AuthMethodNone}, + } + + name, context, err := ResolveCurrentContext(cfg, ContextOverride{Name: "other"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if name != "other" || context.StackURL != "http://other/api" { + t.Fatalf("unexpected context resolution: %s %#v", name, context) + } +} + +func TestResolveCurrentContextUsesSingleContextWhenCurrentUnset(t *testing.T) { + cfg := validConfig() + cfg.CurrentContext = "" + + name, _, err := ResolveCurrentContext(cfg, ContextOverride{}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if name != "local" { + t.Fatalf("expected local context, got %q", name) + } +} + +func TestContextOverrideFromEnv(t *testing.T) { + override := ContextOverrideFromEnv(func(key string) string { + if key != EnvContext { + t.Fatalf("unexpected env key %q", key) + } + return "local" + }) + if override.Name != "local" { + t.Fatalf("expected local override, got %q", override.Name) + } +} + +func TestLoadSaveFileRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "config.yaml") + cfg := validConfig() + + if err := SaveFile(path, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat config: %v", err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Fatalf("expected config mode 0600, got %o", mode) + } + + loaded, err := LoadFile(path) + if err != nil { + t.Fatalf("load config: %v", err) + } + if loaded.CurrentContext != cfg.CurrentContext { + t.Fatalf("expected current context %q, got %q", cfg.CurrentContext, loaded.CurrentContext) + } + if loaded.Contexts["local"].Auth.SecretRef != cfg.Contexts["local"].Auth.SecretRef { + t.Fatalf("expected secret ref to round trip") + } +} diff --git a/v4/internal/config/doc.go b/v4/internal/config/doc.go deleted file mode 100644 index aa252d2d..00000000 --- a/v4/internal/config/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package config owns the fctl v4 context configuration format, validation, -// persistence, and migration-facing types. -package config From 506e468b84d00b36b5cf7fd076043ed2fab28acb Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:27:29 +0200 Subject: [PATCH 006/208] feat: add v4 credential store abstractions --- v4/internal/credentials/doc.go | 3 - v4/internal/credentials/store.go | 133 ++++++++++++++++++++++++++ v4/internal/credentials/store_test.go | 74 ++++++++++++++ 3 files changed, 207 insertions(+), 3 deletions(-) delete mode 100644 v4/internal/credentials/doc.go create mode 100644 v4/internal/credentials/store.go create mode 100644 v4/internal/credentials/store_test.go diff --git a/v4/internal/credentials/doc.go b/v4/internal/credentials/doc.go deleted file mode 100644 index 0bbf30c2..00000000 --- a/v4/internal/credentials/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package credentials abstracts secure and explicit-insecure secret storage for -// authentication providers. -package credentials diff --git a/v4/internal/credentials/store.go b/v4/internal/credentials/store.go new file mode 100644 index 00000000..ff93fd87 --- /dev/null +++ b/v4/internal/credentials/store.go @@ -0,0 +1,133 @@ +// Package credentials abstracts secure and explicit-insecure secret storage for +// authentication providers. +package credentials + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" +) + +var ErrNotFound = errors.New("credential not found") + +type Store interface { + Get(ctx context.Context, ref string) (string, error) + Set(ctx context.Context, ref string, value string) error + Delete(ctx context.Context, ref string) error +} + +type MemoryStore struct { + mu sync.RWMutex + secrets map[string]string +} + +func NewMemoryStore() *MemoryStore { + return &MemoryStore{secrets: map[string]string{}} +} + +func (s *MemoryStore) Get(_ context.Context, ref string) (string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + value, ok := s.secrets[ref] + if !ok { + return "", fmt.Errorf("%w: %s", ErrNotFound, ref) + } + return value, nil +} + +func (s *MemoryStore) Set(_ context.Context, ref string, value string) error { + if ref == "" { + return errors.New("credential ref cannot be empty") + } + + s.mu.Lock() + defer s.mu.Unlock() + s.secrets[ref] = value + return nil +} + +func (s *MemoryStore) Delete(_ context.Context, ref string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.secrets, ref) + return nil +} + +// InsecureFileStore stores secrets in plain text files. It is intended for +// development and tests only, and must be selected explicitly by callers. +type InsecureFileStore struct { + dir string +} + +func NewInsecureFileStore(dir string) *InsecureFileStore { + return &InsecureFileStore{dir: dir} +} + +func (s *InsecureFileStore) Get(_ context.Context, ref string) (string, error) { + path, err := s.path(ref) + if err != nil { + return "", err + } + + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("%w: %s", ErrNotFound, ref) + } + return "", fmt.Errorf("read credential: %w", err) + } + return string(data), nil +} + +func (s *InsecureFileStore) Set(_ context.Context, ref string, value string) error { + path, err := s.path(ref) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create credential directory: %w", err) + } + if err := os.WriteFile(path, []byte(value), 0o600); err != nil { + return fmt.Errorf("write credential: %w", err) + } + return nil +} + +func (s *InsecureFileStore) Delete(_ context.Context, ref string) error { + path, err := s.path(ref) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("delete credential: %w", err) + } + return nil +} + +func (s *InsecureFileStore) path(ref string) (string, error) { + if s.dir == "" { + return "", errors.New("credential directory cannot be empty") + } + if ref == "" { + return "", errors.New("credential ref cannot be empty") + } + clean := filepath.Clean(ref) + if filepath.IsAbs(clean) || clean == "." || clean == ".." || hasPathTraversal(clean) { + return "", fmt.Errorf("invalid credential ref %q", ref) + } + return filepath.Join(s.dir, clean), nil +} + +func hasPathTraversal(path string) bool { + for _, part := range strings.Split(filepath.ToSlash(path), "/") { + if part == ".." { + return true + } + } + return false +} diff --git a/v4/internal/credentials/store_test.go b/v4/internal/credentials/store_test.go new file mode 100644 index 00000000..612a9f4f --- /dev/null +++ b/v4/internal/credentials/store_test.go @@ -0,0 +1,74 @@ +package credentials + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestMemoryStoreRoundTrip(t *testing.T) { + ctx := context.Background() + store := NewMemoryStore() + + if err := store.Set(ctx, "keyring://local/testing", "secret"); err != nil { + t.Fatalf("set credential: %v", err) + } + + value, err := store.Get(ctx, "keyring://local/testing") + if err != nil { + t.Fatalf("get credential: %v", err) + } + if value != "secret" { + t.Fatalf("expected secret, got %q", value) + } + + if err := store.Delete(ctx, "keyring://local/testing"); err != nil { + t.Fatalf("delete credential: %v", err) + } + if _, err := store.Get(ctx, "keyring://local/testing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestInsecureFileStoreRoundTrip(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + store := NewInsecureFileStore(dir) + + if err := store.Set(ctx, "local/testing", "secret"); err != nil { + t.Fatalf("set credential: %v", err) + } + + path := filepath.Join(dir, "local", "testing") + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat credential: %v", err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Fatalf("expected credential mode 0600, got %o", mode) + } + + value, err := store.Get(ctx, "local/testing") + if err != nil { + t.Fatalf("get credential: %v", err) + } + if value != "secret" { + t.Fatalf("expected secret, got %q", value) + } + + if err := store.Delete(ctx, "local/testing"); err != nil { + t.Fatalf("delete credential: %v", err) + } + if _, err := store.Get(ctx, "local/testing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestInsecureFileStoreRejectsTraversal(t *testing.T) { + store := NewInsecureFileStore(t.TempDir()) + if err := store.Set(context.Background(), "../secret", "secret"); err == nil { + t.Fatal("expected traversal error") + } +} From b030fca72e740af8b01097586f357fd33454173f Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:28:34 +0200 Subject: [PATCH 007/208] feat: add v4 capabilities model --- v4/go.mod | 1 + v4/go.sum | 2 + v4/internal/capabilities/capabilities.go | 178 ++++++++++++++++++ v4/internal/capabilities/capabilities_test.go | 71 +++++++ v4/internal/capabilities/doc.go | 3 - 5 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 v4/internal/capabilities/capabilities.go create mode 100644 v4/internal/capabilities/capabilities_test.go delete mode 100644 v4/internal/capabilities/doc.go diff --git a/v4/go.mod b/v4/go.mod index 5dfc2c31..8533bcae 100644 --- a/v4/go.mod +++ b/v4/go.mod @@ -6,6 +6,7 @@ toolchain go1.25.7 require ( github.com/spf13/cobra v1.10.2 + golang.org/x/mod v0.36.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/v4/go.sum b/v4/go.sum index 47edb24d..a0315ef4 100644 --- a/v4/go.sum +++ b/v4/go.sum @@ -7,6 +7,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/v4/internal/capabilities/capabilities.go b/v4/internal/capabilities/capabilities.go new file mode 100644 index 00000000..79ae87f5 --- /dev/null +++ b/v4/internal/capabilities/capabilities.go @@ -0,0 +1,178 @@ +// Package capabilities models stack component versions, API namespaces, and +// generated operation metadata used by the v4 runtime resolver. +package capabilities + +import ( + "errors" + "fmt" + "sort" + "strconv" + "strings" + + "golang.org/x/mod/semver" +) + +type Product string +type Feature string +type APIVersion string + +type Manifest struct { + SpecVersion string `json:"specVersion"` + Products map[Product]ProductManifest `json:"products"` +} + +type ProductManifest struct { + APIVersions []APIVersion `json:"apiVersions"` + Operations map[Feature]map[APIVersion]Operation `json:"operations"` +} + +type Operation struct { + OperationID string `json:"operationId"` + Method string `json:"method"` + Path string `json:"path"` + Tags []string `json:"tags,omitempty"` +} + +type ComponentVersion struct { + Product Product + Version string + Health bool +} + +type ComponentRange struct { + Product Product + Range string + APIVersions []APIVersion +} + +type ComponentCompatibility []ComponentRange + +func (c ComponentCompatibility) APIVersionsFor(product Product, componentVersion string) ([]APIVersion, error) { + var matches []APIVersion + for _, candidate := range c { + if candidate.Product != product { + continue + } + ok, err := MatchVersionRange(componentVersion, candidate.Range) + if err != nil { + return nil, fmt.Errorf("match %s range %q: %w", product, candidate.Range, err) + } + if ok { + matches = append(matches, candidate.APIVersions...) + } + } + if len(matches) == 0 { + return nil, fmt.Errorf("no api versions for %s component version %q", product, componentVersion) + } + return UniqueSortedAPIVersions(matches), nil +} + +func MatchVersionRange(version string, versionRange string) (bool, error) { + normalizedVersion, err := normalizeSemver(version) + if err != nil { + return false, err + } + if strings.TrimSpace(versionRange) == "" { + return false, errors.New("range cannot be empty") + } + + for _, constraint := range strings.Fields(versionRange) { + if constraint == "" { + continue + } + if ok, err := matchConstraint(normalizedVersion, constraint); err != nil || !ok { + return ok, err + } + } + return true, nil +} + +func matchConstraint(version string, constraint string) (bool, error) { + for _, operator := range []string{">=", "<=", ">", "<", "="} { + if strings.HasPrefix(constraint, operator) { + target, err := normalizeSemver(strings.TrimPrefix(constraint, operator)) + if err != nil { + return false, err + } + cmp := semver.Compare(version, target) + switch operator { + case ">=": + return cmp >= 0, nil + case "<=": + return cmp <= 0, nil + case ">": + return cmp > 0, nil + case "<": + return cmp < 0, nil + case "=": + return cmp == 0, nil + } + } + } + + target, err := normalizeSemver(constraint) + if err != nil { + return false, err + } + return semver.Compare(version, target) == 0, nil +} + +func normalizeSemver(version string) (string, error) { + version = strings.TrimSpace(version) + if version == "" { + return "", errors.New("version cannot be empty") + } + if !strings.HasPrefix(version, "v") { + version = "v" + version + } + if !semver.IsValid(version) { + return "", fmt.Errorf("invalid semver %q", version) + } + return version, nil +} + +func UniqueSortedAPIVersions(versions []APIVersion) []APIVersion { + seen := map[APIVersion]struct{}{} + ret := make([]APIVersion, 0, len(versions)) + for _, version := range versions { + if _, ok := seen[version]; ok { + continue + } + seen[version] = struct{}{} + ret = append(ret, version) + } + sort.Slice(ret, func(i, j int) bool { + return compareAPIVersion(ret[i], ret[j]) < 0 + }) + return ret +} + +func HighestAPIVersion(versions []APIVersion) (APIVersion, bool) { + versions = UniqueSortedAPIVersions(versions) + if len(versions) == 0 { + return "", false + } + return versions[len(versions)-1], true +} + +func compareAPIVersion(a, b APIVersion) int { + an := apiVersionNumber(a) + bn := apiVersionNumber(b) + switch { + case an < bn: + return -1 + case an > bn: + return 1 + default: + return strings.Compare(string(a), string(b)) + } +} + +func apiVersionNumber(version APIVersion) int { + raw := strings.TrimPrefix(string(version), "v") + n, err := strconv.Atoi(raw) + if err != nil { + return 0 + } + return n +} diff --git a/v4/internal/capabilities/capabilities_test.go b/v4/internal/capabilities/capabilities_test.go new file mode 100644 index 00000000..f227cb31 --- /dev/null +++ b/v4/internal/capabilities/capabilities_test.go @@ -0,0 +1,71 @@ +package capabilities + +import "testing" + +func TestMatchVersionRange(t *testing.T) { + tests := []struct { + name string + rangeExpr string + version string + want bool + }{ + {name: "inside lower inclusive upper exclusive", rangeExpr: ">=2.0.0 <3.0.0", version: "2.3.4", want: true}, + {name: "below lower", rangeExpr: ">=2.0.0 <3.0.0", version: "1.9.9", want: false}, + {name: "at upper exclusive", rangeExpr: ">=2.0.0 <3.0.0", version: "3.0.0", want: false}, + {name: "accepts v prefix", rangeExpr: ">=v2.0.0 =1.0.0 <2.0.0", APIVersions: []APIVersion{"v1"}}, + {Product: "ledger", Range: ">=2.0.0 <3.0.0", APIVersions: []APIVersion{"v1", "v2"}}, + {Product: "payments", Range: ">=3.0.0", APIVersions: []APIVersion{"v1", "v3"}}, + } + + versions, err := compatibility.APIVersionsFor("ledger", "2.3.4") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + assertAPIVersions(t, versions, []APIVersion{"v1", "v2"}) + + versions, err = compatibility.APIVersionsFor("payments", "3.1.0") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + assertAPIVersions(t, versions, []APIVersion{"v1", "v3"}) +} + +func TestHighestAPIVersion(t *testing.T) { + highest, ok := HighestAPIVersion([]APIVersion{"v1", "v3", "v2"}) + if !ok { + t.Fatal("expected highest api version") + } + if highest != "v3" { + t.Fatalf("expected v3, got %q", highest) + } +} + +func assertAPIVersions(t *testing.T, got []APIVersion, want []APIVersion) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("expected %v, got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("expected %v, got %v", want, got) + } + } +} diff --git a/v4/internal/capabilities/doc.go b/v4/internal/capabilities/doc.go deleted file mode 100644 index 6c642e7b..00000000 --- a/v4/internal/capabilities/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package capabilities models stack component versions, API namespaces, and -// generated operation metadata used by the v4 runtime resolver. -package capabilities From dd55ac62ebce3139b18e1112a052c4fe7c0ebb10 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:29:26 +0200 Subject: [PATCH 008/208] feat: add v4 runtime shell --- v4/internal/runtime/doc.go | 3 - v4/internal/runtime/runtime.go | 115 ++++++++++++++++++++++++++++ v4/internal/runtime/runtime_test.go | 99 ++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 3 deletions(-) delete mode 100644 v4/internal/runtime/doc.go create mode 100644 v4/internal/runtime/runtime.go create mode 100644 v4/internal/runtime/runtime_test.go diff --git a/v4/internal/runtime/doc.go b/v4/internal/runtime/doc.go deleted file mode 100644 index 8b3d0f36..00000000 --- a/v4/internal/runtime/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package runtime resolves contexts, targets, authentication, component -// versions, and API version policy for v4 commands. -package runtime diff --git a/v4/internal/runtime/runtime.go b/v4/internal/runtime/runtime.go new file mode 100644 index 00000000..f4cae6dc --- /dev/null +++ b/v4/internal/runtime/runtime.go @@ -0,0 +1,115 @@ +// Package runtime resolves contexts, targets, authentication, component +// versions, and API version policy for v4 commands. +package runtime + +import ( + "context" + "errors" + "fmt" + + "github.com/formancehq/fctl/v4/internal/capabilities" + "github.com/formancehq/fctl/v4/internal/config" + "github.com/formancehq/fctl/v4/internal/credentials" +) + +type TargetKind string + +const ( + TargetKindStack TargetKind = "stack" + TargetKindCloud TargetKind = "cloud" + TargetKindCloudStack TargetKind = "cloud-stack" +) + +type Options struct { + ConfigPath string + ContextOverride config.ContextOverride + Credentials credentials.Store + Manifest capabilities.Manifest + Compatibility capabilities.ComponentCompatibility +} + +type Runtime struct { + Config config.Config + ContextName string + Context config.Context + Target Target + + Credentials credentials.Store + Manifest capabilities.Manifest + Compatibility capabilities.ComponentCompatibility +} + +type Target struct { + Kind TargetKind + URL string + Organization string + Stack string +} + +func New(ctx context.Context, options Options) (*Runtime, error) { + if ctx == nil { + ctx = context.Background() + } + if options.ConfigPath == "" { + return nil, errors.New("config path is required") + } + + cfg, err := config.LoadFile(options.ConfigPath) + if err != nil { + return nil, err + } + + contextName, selectedContext, err := config.ResolveCurrentContext(cfg, options.ContextOverride) + if err != nil { + return nil, err + } + + target, err := TargetFromContext(selectedContext) + if err != nil { + return nil, err + } + + return &Runtime{ + Config: cfg, + ContextName: contextName, + Context: selectedContext, + Target: target, + Credentials: options.Credentials, + Manifest: options.Manifest, + Compatibility: options.Compatibility, + }, nil +} + +func TargetFromContext(context config.Context) (Target, error) { + switch context.Kind { + case config.ContextKindStack: + return Target{ + Kind: TargetKindStack, + URL: context.StackURL, + }, nil + case config.ContextKindCloud: + return Target{ + Kind: TargetKindCloud, + URL: context.CloudURL, + }, nil + case config.ContextKindCloudStack: + return Target{ + Kind: TargetKindCloudStack, + URL: context.CloudURL, + Organization: context.Organization, + Stack: context.Stack, + }, nil + default: + return Target{}, fmt.Errorf("unsupported context kind %q", context.Kind) + } +} + +func (r *Runtime) APIPolicyFor(product capabilities.Product) config.APIPolicy { + if r == nil { + return config.APIPolicyLatestCompatible + } + if policy := r.Context.API[string(product)]; policy != "" { + return config.APIPolicy(policy) + } + return config.APIPolicyLatestCompatible +} diff --git a/v4/internal/runtime/runtime_test.go b/v4/internal/runtime/runtime_test.go new file mode 100644 index 00000000..655a4aac --- /dev/null +++ b/v4/internal/runtime/runtime_test.go @@ -0,0 +1,99 @@ +package runtime + +import ( + "context" + "path/filepath" + "testing" + + "github.com/formancehq/fctl/v4/internal/capabilities" + "github.com/formancehq/fctl/v4/internal/config" + "github.com/formancehq/fctl/v4/internal/credentials" +) + +func TestNewResolvesCurrentStackContext(t *testing.T) { + configPath := writeRuntimeConfig(t, config.Config{ + Version: config.Version, + CurrentContext: "local", + Contexts: map[string]config.Context{ + "local": { + Kind: config.ContextKindStack, + StackURL: "http://localhost/api", + Auth: config.Auth{Method: config.AuthMethodNone}, + API: map[string]string{"ledger": string(config.APIPolicyPinned)}, + }, + }, + }) + + rt, err := New(context.Background(), Options{ + ConfigPath: configPath, + Credentials: credentials.NewMemoryStore(), + Manifest: capabilities.Manifest{SpecVersion: "test"}, + Compatibility: capabilities.ComponentCompatibility{}, + ContextOverride: config.ContextOverride{}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if rt.ContextName != "local" { + t.Fatalf("expected local context, got %q", rt.ContextName) + } + if rt.Target.Kind != TargetKindStack || rt.Target.URL != "http://localhost/api" { + t.Fatalf("unexpected target: %#v", rt.Target) + } + if policy := rt.APIPolicyFor("ledger"); policy != config.APIPolicyPinned { + t.Fatalf("expected pinned policy, got %q", policy) + } + if policy := rt.APIPolicyFor("payments"); policy != config.APIPolicyLatestCompatible { + t.Fatalf("expected default latest-compatible policy, got %q", policy) + } +} + +func TestNewUsesContextOverride(t *testing.T) { + configPath := writeRuntimeConfig(t, config.Config{ + Version: config.Version, + CurrentContext: "local", + Contexts: map[string]config.Context{ + "local": { + Kind: config.ContextKindStack, + StackURL: "http://localhost/api", + Auth: config.Auth{Method: config.AuthMethodNone}, + }, + "cloud": { + Kind: config.ContextKindCloud, + CloudURL: "https://app.formance.cloud/api", + Auth: config.Auth{Method: config.AuthMethodCloudDevice}, + }, + }, + }) + + rt, err := New(context.Background(), Options{ + ConfigPath: configPath, + ContextOverride: config.ContextOverride{Name: "cloud"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if rt.ContextName != "cloud" { + t.Fatalf("expected cloud context, got %q", rt.ContextName) + } + if rt.Target.Kind != TargetKindCloud || rt.Target.URL != "https://app.formance.cloud/api" { + t.Fatalf("unexpected target: %#v", rt.Target) + } +} + +func TestNewRequiresConfigPath(t *testing.T) { + _, err := New(context.Background(), Options{}) + if err == nil { + t.Fatal("expected config path error") + } +} + +func writeRuntimeConfig(t *testing.T, cfg config.Config) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + if err := config.SaveFile(path, cfg); err != nil { + t.Fatalf("save runtime config: %v", err) + } + return path +} From 80cc1150439885c1c9148fcf967bc80f17326be2 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:29:43 +0200 Subject: [PATCH 009/208] docs: mark v4 foundation goal complete --- todos/STATUS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todos/STATUS.md b/todos/STATUS.md index 0f92ebe6..8c26a359 100644 --- a/todos/STATUS.md +++ b/todos/STATUS.md @@ -1,7 +1,7 @@ # v4 Todo Status - [x] 01 - v4 isolated skeleton -- [ ] 02 - v4 foundation packages +- [x] 02 - v4 foundation packages - [ ] 03 - context commands - [ ] 04 - auth providers - [ ] 05 - capabilities manifest generator From 846c40a66c76361c7933f743e897bcae60336b9d Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:31:20 +0200 Subject: [PATCH 010/208] feat: add v4 context commands --- v4/cmd/config.go | 49 ++++++++ v4/cmd/context.go | 238 +++++++++++++++++++++++++++++++++++++ v4/cmd/root.go | 1 + v4/cmd/root_test.go | 102 ++++++++++++++++ v4/internal/render/json.go | 12 ++ 5 files changed, 402 insertions(+) create mode 100644 v4/cmd/config.go create mode 100644 v4/cmd/context.go create mode 100644 v4/internal/render/json.go diff --git a/v4/cmd/config.go b/v4/cmd/config.go new file mode 100644 index 00000000..983f25be --- /dev/null +++ b/v4/cmd/config.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + v4config "github.com/formancehq/fctl/v4/internal/config" +) + +const configFilename = "config.yaml" + +func configPath(cmd *cobra.Command) (string, error) { + configDir, err := cmd.Root().PersistentFlags().GetString(configDirFlag) + if err != nil { + return "", err + } + if configDir == "" { + userConfigDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("resolve user config directory: %w", err) + } + configDir = filepath.Join(userConfigDir, "formance", "fctl-v4") + } + return filepath.Join(configDir, configFilename), nil +} + +func loadConfig(cmd *cobra.Command, allowMissing bool) (v4config.Config, string, error) { + path, err := configPath(cmd) + if err != nil { + return v4config.Config{}, "", err + } + + cfg, err := v4config.LoadFile(path) + if err != nil { + if allowMissing && errors.Is(err, os.ErrNotExist) { + return v4config.New(), path, nil + } + return v4config.Config{}, "", err + } + return cfg, path, nil +} + +func outputFormat(cmd *cobra.Command) (string, error) { + return cmd.Root().PersistentFlags().GetString(outputFlag) +} diff --git a/v4/cmd/context.go b/v4/cmd/context.go new file mode 100644 index 00000000..b2cb720a --- /dev/null +++ b/v4/cmd/context.go @@ -0,0 +1,238 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + v4config "github.com/formancehq/fctl/v4/internal/config" + "github.com/formancehq/fctl/v4/internal/render" +) + +func newContextCommand() *cobra.Command { + command := &cobra.Command{ + Use: "context", + Short: "Manage fctl v4 contexts", + } + + command.AddCommand( + newContextListCommand(), + newContextShowCommand(), + newContextUseCommand(), + newContextCreateCommand(), + ) + + return command +} + +func newContextListCommand() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List configured contexts", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, _, err := loadConfig(cmd, true) + if err != nil { + return err + } + + output, err := outputFormat(cmd) + if err != nil { + return err + } + names := cfg.ContextNames() + if output == "json" { + return render.JSON(cmd.OutOrStdout(), contextListOutput{ + Current: cfg.CurrentContext, + Contexts: names, + }) + } + if len(names) == 0 { + _, err := fmt.Fprintln(cmd.OutOrStdout(), "No contexts found.") + return err + } + for _, name := range names { + prefix := " " + if name == cfg.CurrentContext { + prefix = "*" + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s\n", prefix, name); err != nil { + return err + } + } + return nil + }, + } +} + +func newContextShowCommand() *cobra.Command { + return &cobra.Command{ + Use: "show [name]", + Short: "Show a configured context", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _, err := loadConfig(cmd, false) + if err != nil { + return err + } + + override := v4config.ContextOverride{} + if len(args) == 1 { + override.Name = args[0] + } + name, context, err := v4config.ResolveCurrentContext(cfg, override) + if err != nil { + return err + } + + output, err := outputFormat(cmd) + if err != nil { + return err + } + result := contextShowOutput{Name: name, Current: name == cfg.CurrentContext, Context: context} + if output == "json" { + return render.JSON(cmd.OutOrStdout(), result) + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Name: %s\nKind: %s\n", name, context.Kind) + return err + }, + } +} + +func newContextUseCommand() *cobra.Command { + return &cobra.Command{ + Use: "use ", + Short: "Set the current context", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, path, err := loadConfig(cmd, false) + if err != nil { + return err + } + name := args[0] + if _, ok := cfg.Contexts[name]; !ok { + return fmt.Errorf("context %q does not exist", name) + } + cfg.CurrentContext = name + if err := v4config.SaveFile(path, cfg); err != nil { + return err + } + + output, err := outputFormat(cmd) + if err != nil { + return err + } + if output == "json" { + return render.JSON(cmd.OutOrStdout(), map[string]string{"currentContext": name}) + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Current context set to %s.\n", name) + return err + }, + } +} + +func newContextCreateCommand() *cobra.Command { + command := &cobra.Command{ + Use: "create", + Short: "Create contexts", + } + command.AddCommand(newContextCreateStackCommand()) + return command +} + +func newContextCreateStackCommand() *cobra.Command { + var stackURL string + var authMethod string + var issuerURL string + var clientID string + var secretRef string + var defaultLedger string + + command := &cobra.Command{ + Use: "stack ", + Short: "Create a direct stack context", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, path, err := loadConfig(cmd, true) + if err != nil { + return err + } + if cfg.Contexts == nil { + cfg.Contexts = map[string]v4config.Context{} + } + + name := args[0] + if _, exists := cfg.Contexts[name]; exists { + return fmt.Errorf("context %q already exists", name) + } + + auth := v4config.Auth{Method: v4config.AuthMethod(authMethod)} + switch auth.Method { + case v4config.AuthMethodClientCredentials: + auth.IssuerURL = issuerURL + auth.ClientID = clientID + auth.SecretRef = secretRef + case v4config.AuthMethodNone: + default: + return fmt.Errorf("unsupported stack auth method %q", authMethod) + } + + defaults := map[string]string{} + if defaultLedger != "" { + defaults["ledger"] = defaultLedger + } + if len(defaults) == 0 { + defaults = nil + } + + cfg.Contexts[name] = v4config.Context{ + Kind: v4config.ContextKindStack, + StackURL: stackURL, + Auth: auth, + Defaults: defaults, + API: map[string]string{"ledger": string(v4config.APIPolicyLatestCompatible)}, + } + if cfg.CurrentContext == "" { + cfg.CurrentContext = name + } + + if err := v4config.SaveFile(path, cfg); err != nil { + return err + } + + output, err := outputFormat(cmd) + if err != nil { + return err + } + if output == "json" { + return render.JSON(cmd.OutOrStdout(), contextShowOutput{ + Name: name, + Current: name == cfg.CurrentContext, + Context: cfg.Contexts[name], + }) + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Context %s created.\n", name) + return err + }, + } + + command.Flags().StringVar(&stackURL, "stack-url", "", "Stack API URL") + command.Flags().StringVar(&authMethod, "auth-method", string(v4config.AuthMethodNone), "Authentication method (none, client_credentials)") + command.Flags().StringVar(&issuerURL, "issuer-url", "", "OIDC issuer URL for client credentials") + command.Flags().StringVar(&clientID, "client-id", "", "Client ID for client credentials") + command.Flags().StringVar(&secretRef, "secret-ref", "", "Credential reference for client secret") + command.Flags().StringVar(&defaultLedger, "default-ledger", "", "Default ledger for this context") + + return command +} + +type contextListOutput struct { + Current string `json:"currentContext"` + Contexts []string `json:"contexts"` +} + +type contextShowOutput struct { + Name string `json:"name"` + Current bool `json:"current"` + Context v4config.Context `json:"context"` +} diff --git a/v4/cmd/root.go b/v4/cmd/root.go index aeaf8b44..5bd46d77 100644 --- a/v4/cmd/root.go +++ b/v4/cmd/root.go @@ -40,6 +40,7 @@ func NewRootCommand(version string) *cobra.Command { root.PersistentFlags().Bool(nonInteractiveFlag, false, "Disable interactive prompts") root.AddCommand(newVersionCommand()) + root.AddCommand(newContextCommand()) return root } diff --git a/v4/cmd/root_test.go b/v4/cmd/root_test.go index 804a4ae5..44d5fe19 100644 --- a/v4/cmd/root_test.go +++ b/v4/cmd/root_test.go @@ -2,8 +2,11 @@ package cmd import ( "bytes" + "path/filepath" "strings" "testing" + + v4config "github.com/formancehq/fctl/v4/internal/config" ) func executeCommand(t *testing.T, args ...string) (string, string, error) { @@ -53,3 +56,102 @@ func TestVersionCommand(t *testing.T) { t.Fatalf("unexpected version output: %q", stdout) } } + +func TestContextCreateListShowUse(t *testing.T) { + configDir := t.TempDir() + + stdout, stderr, err := executeCommand(t, + "--config-dir", configDir, + "context", "create", "stack", "local", + "--stack-url", "http://localhost/api", + "--default-ledger", "default", + ) + if err != nil { + t.Fatalf("create context: %v stderr=%s", err, stderr) + } + if stdout != "Context local created.\n" { + t.Fatalf("unexpected create output: %q", stdout) + } + + cfg, err := v4config.LoadFile(filepath.Join(configDir, "config.yaml")) + if err != nil { + t.Fatalf("load config: %v", err) + } + if cfg.CurrentContext != "local" { + t.Fatalf("expected current context local, got %q", cfg.CurrentContext) + } + if cfg.Contexts["local"].Defaults["ledger"] != "default" { + t.Fatalf("expected default ledger to be persisted") + } + + stdout, stderr, err = executeCommand(t, "--config-dir", configDir, "context", "list") + if err != nil { + t.Fatalf("list contexts: %v stderr=%s", err, stderr) + } + if stdout != "* local\n" { + t.Fatalf("unexpected list output: %q", stdout) + } + + stdout, stderr, err = executeCommand(t, "--config-dir", configDir, "context", "show", "local") + if err != nil { + t.Fatalf("show context: %v stderr=%s", err, stderr) + } + if !strings.Contains(stdout, "Name: local") || !strings.Contains(stdout, "Kind: stack") { + t.Fatalf("unexpected show output: %q", stdout) + } + + stdout, stderr, err = executeCommand(t, + "--config-dir", configDir, + "context", "create", "stack", "other", + "--stack-url", "http://other/api", + ) + if err != nil { + t.Fatalf("create second context: %v stderr=%s", err, stderr) + } + if stdout != "Context other created.\n" { + t.Fatalf("unexpected second create output: %q", stdout) + } + + stdout, stderr, err = executeCommand(t, "--config-dir", configDir, "context", "use", "other") + if err != nil { + t.Fatalf("use context: %v stderr=%s", err, stderr) + } + if stdout != "Current context set to other.\n" { + t.Fatalf("unexpected use output: %q", stdout) + } + + cfg, err = v4config.LoadFile(filepath.Join(configDir, "config.yaml")) + if err != nil { + t.Fatalf("load config after use: %v", err) + } + if cfg.CurrentContext != "other" { + t.Fatalf("expected current context other, got %q", cfg.CurrentContext) + } +} + +func TestContextCommandsJSON(t *testing.T) { + configDir := t.TempDir() + + _, stderr, err := executeCommand(t, + "--config-dir", configDir, + "context", "create", "stack", "local", + "--stack-url", "http://localhost/api", + "--auth-method", "client_credentials", + "--issuer-url", "http://localhost/api/auth", + "--client-id", "testing", + "--secret-ref", "keyring://local/testing", + ) + if err != nil { + t.Fatalf("create context: %v stderr=%s", err, stderr) + } + + stdout, stderr, err := executeCommand(t, "--config-dir", configDir, "-o", "json", "context", "list") + if err != nil { + t.Fatalf("list contexts json: %v stderr=%s", err, stderr) + } + for _, expected := range []string{`"currentContext": "local"`, `"contexts": [`, `"local"`} { + if !strings.Contains(stdout, expected) { + t.Fatalf("expected JSON output to contain %q, got:\n%s", expected, stdout) + } + } +} diff --git a/v4/internal/render/json.go b/v4/internal/render/json.go new file mode 100644 index 00000000..1e9dcc8d --- /dev/null +++ b/v4/internal/render/json.go @@ -0,0 +1,12 @@ +package render + +import ( + "encoding/json" + "io" +) + +func JSON(w io.Writer, value any) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(value) +} From e1f460f5407beb751776bda3b648adcbe2664db2 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:31:31 +0200 Subject: [PATCH 011/208] docs: mark v4 context goal complete --- todos/STATUS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todos/STATUS.md b/todos/STATUS.md index 8c26a359..23d78cb5 100644 --- a/todos/STATUS.md +++ b/todos/STATUS.md @@ -2,7 +2,7 @@ - [x] 01 - v4 isolated skeleton - [x] 02 - v4 foundation packages -- [ ] 03 - context commands +- [x] 03 - context commands - [ ] 04 - auth providers - [ ] 05 - capabilities manifest generator - [ ] 06 - runtime API version resolver From 6304dee5e5876914332caabf345aa5e3cba76182 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:33:24 +0200 Subject: [PATCH 012/208] feat: add v4 auth providers --- v4/internal/auth/auth.go | 275 ++++++++++++++++++++++++++++++++++ v4/internal/auth/auth_test.go | 151 +++++++++++++++++++ 2 files changed, 426 insertions(+) create mode 100644 v4/internal/auth/auth.go create mode 100644 v4/internal/auth/auth_test.go diff --git a/v4/internal/auth/auth.go b/v4/internal/auth/auth.go new file mode 100644 index 00000000..f1e47606 --- /dev/null +++ b/v4/internal/auth/auth.go @@ -0,0 +1,275 @@ +// Package auth resolves v4 target-local authentication into HTTP clients. +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "sync" + + v4config "github.com/formancehq/fctl/v4/internal/config" + "github.com/formancehq/fctl/v4/internal/credentials" +) + +type Options struct { + HTTPClient *http.Client + Env func(string) string + Stdin io.Reader +} + +type Token struct { + AccessToken string + TokenType string +} + +type TokenSource interface { + Token(ctx context.Context) (Token, error) +} + +func NewHTTPClient(ctx context.Context, authConfig v4config.Auth, store credentials.Store, options Options) (*http.Client, error) { + base := options.HTTPClient + if base == nil { + base = http.DefaultClient + } + + source, err := NewTokenSource(authConfig, store, options) + if err != nil { + return nil, err + } + if source == nil { + return base, nil + } + + transport := base.Transport + if transport == nil { + transport = http.DefaultTransport + } + return &http.Client{ + Transport: &bearerTransport{source: source, base: transport}, + CheckRedirect: base.CheckRedirect, + Jar: base.Jar, + Timeout: base.Timeout, + }, nil +} + +func NewTokenSource(authConfig v4config.Auth, store credentials.Store, options Options) (TokenSource, error) { + switch authConfig.Method { + case v4config.AuthMethodNone: + return nil, nil + case v4config.AuthMethodToken: + return tokenSourceForRef(authConfig.TokenRef, store, options) + case v4config.AuthMethodClientCredentials: + if store == nil { + return nil, errors.New("credential store is required for client_credentials auth") + } + httpClient := options.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + return &ClientCredentialsSource{ + IssuerURL: authConfig.IssuerURL, + ClientID: authConfig.ClientID, + SecretRef: authConfig.SecretRef, + Store: store, + HTTPClient: httpClient, + }, nil + case v4config.AuthMethodCloudDevice, v4config.AuthMethodOIDCDevice: + return nil, fmt.Errorf("auth method %q is not implemented yet", authConfig.Method) + default: + return nil, fmt.Errorf("unsupported auth method %q", authConfig.Method) + } +} + +type StaticTokenSource struct { + TokenValue Token +} + +func (s StaticTokenSource) Token(context.Context) (Token, error) { + return normalizeToken(s.TokenValue), nil +} + +type CredentialTokenSource struct { + Ref string + Store credentials.Store +} + +func (s CredentialTokenSource) Token(ctx context.Context) (Token, error) { + if s.Store == nil { + return Token{}, errors.New("credential store is required") + } + value, err := s.Store.Get(ctx, s.Ref) + if err != nil { + return Token{}, err + } + return normalizeToken(Token{AccessToken: value, TokenType: "Bearer"}), nil +} + +type ClientCredentialsSource struct { + IssuerURL string + ClientID string + SecretRef string + Store credentials.Store + HTTPClient *http.Client + + mu sync.Mutex + cachedToken *Token + tokenURL string +} + +func (s *ClientCredentialsSource) Token(ctx context.Context) (Token, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.cachedToken != nil { + return *s.cachedToken, nil + } + if s.HTTPClient == nil { + s.HTTPClient = http.DefaultClient + } + if s.Store == nil { + return Token{}, errors.New("credential store is required") + } + if s.IssuerURL == "" { + return Token{}, errors.New("issuer URL is required") + } + if s.ClientID == "" { + return Token{}, errors.New("client ID is required") + } + + secret, err := s.Store.Get(ctx, s.SecretRef) + if err != nil { + return Token{}, err + } + tokenURL, err := s.resolveTokenURL(ctx) + if err != nil { + return Token{}, err + } + + form := url.Values{} + form.Set("grant_type", "client_credentials") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return Token{}, err + } + req.SetBasicAuth(s.ClientID, secret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + rsp, err := s.HTTPClient.Do(req) + if err != nil { + return Token{}, err + } + defer rsp.Body.Close() + if rsp.StatusCode < 200 || rsp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(rsp.Body, 4096)) + return Token{}, fmt.Errorf("client credentials token request failed: status %d: %s", rsp.StatusCode, string(body)) + } + + var tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + } + if err := json.NewDecoder(rsp.Body).Decode(&tokenResponse); err != nil { + return Token{}, fmt.Errorf("decode token response: %w", err) + } + token := normalizeToken(Token{ + AccessToken: tokenResponse.AccessToken, + TokenType: tokenResponse.TokenType, + }) + if token.AccessToken == "" { + return Token{}, errors.New("token response did not include access_token") + } + s.cachedToken = &token + return token, nil +} + +func (s *ClientCredentialsSource) resolveTokenURL(ctx context.Context) (string, error) { + if s.tokenURL != "" { + return s.tokenURL, nil + } + + discoveryURL := strings.TrimRight(s.IssuerURL, "/") + "/.well-known/openid-configuration" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil) + if err != nil { + return "", err + } + rsp, err := s.HTTPClient.Do(req) + if err != nil { + return "", err + } + defer rsp.Body.Close() + if rsp.StatusCode < 200 || rsp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(rsp.Body, 4096)) + return "", fmt.Errorf("oidc discovery failed: status %d: %s", rsp.StatusCode, string(body)) + } + + var discovery struct { + TokenEndpoint string `json:"token_endpoint"` + } + if err := json.NewDecoder(rsp.Body).Decode(&discovery); err != nil { + return "", fmt.Errorf("decode oidc discovery: %w", err) + } + if discovery.TokenEndpoint == "" { + return "", errors.New("oidc discovery did not include token_endpoint") + } + s.tokenURL = discovery.TokenEndpoint + return s.tokenURL, nil +} + +type bearerTransport struct { + source TokenSource + base http.RoundTripper +} + +func (t *bearerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + token, err := t.source.Token(req.Context()) + if err != nil { + return nil, err + } + + cloned := req.Clone(req.Context()) + cloned.Header = req.Header.Clone() + cloned.Header.Set("Authorization", token.TokenType+" "+token.AccessToken) + return t.base.RoundTrip(cloned) +} + +func tokenSourceForRef(ref string, store credentials.Store, options Options) (TokenSource, error) { + switch { + case ref == "stdin://": + if options.Stdin == nil { + return nil, errors.New("stdin token source requires stdin") + } + data, err := io.ReadAll(options.Stdin) + if err != nil { + return nil, fmt.Errorf("read token from stdin: %w", err) + } + return StaticTokenSource{TokenValue: Token{AccessToken: strings.TrimSpace(string(data)), TokenType: "Bearer"}}, nil + case strings.HasPrefix(ref, "env://"): + getenv := options.Env + if getenv == nil { + getenv = os.Getenv + } + name := strings.TrimPrefix(ref, "env://") + value := strings.TrimSpace(getenv(name)) + if value == "" { + return nil, fmt.Errorf("environment variable %s is empty", name) + } + return StaticTokenSource{TokenValue: Token{AccessToken: value, TokenType: "Bearer"}}, nil + default: + return CredentialTokenSource{Ref: ref, Store: store}, nil + } +} + +func normalizeToken(token Token) Token { + token.AccessToken = strings.TrimSpace(token.AccessToken) + token.TokenType = strings.TrimSpace(token.TokenType) + if token.TokenType == "" { + token.TokenType = "Bearer" + } + return token +} diff --git a/v4/internal/auth/auth_test.go b/v4/internal/auth/auth_test.go new file mode 100644 index 00000000..341f4d4b --- /dev/null +++ b/v4/internal/auth/auth_test.go @@ -0,0 +1,151 @@ +package auth + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + v4config "github.com/formancehq/fctl/v4/internal/config" + "github.com/formancehq/fctl/v4/internal/credentials" +) + +func TestNoneAuthDoesNotSetAuthorization(t *testing.T) { + server := authHeaderServer(t, "") + defer server.Close() + + client, err := NewHTTPClient(context.Background(), v4config.Auth{Method: v4config.AuthMethodNone}, nil, Options{}) + if err != nil { + t.Fatalf("new http client: %v", err) + } + if _, err := client.Get(server.URL); err != nil { + t.Fatalf("request: %v", err) + } +} + +func TestTokenAuthFromCredentialRef(t *testing.T) { + ctx := context.Background() + store := credentials.NewMemoryStore() + if err := store.Set(ctx, "token-ref", "abc123"); err != nil { + t.Fatalf("set token: %v", err) + } + server := authHeaderServer(t, "Bearer abc123") + defer server.Close() + + client, err := NewHTTPClient(ctx, v4config.Auth{ + Method: v4config.AuthMethodToken, + TokenRef: "token-ref", + }, store, Options{}) + if err != nil { + t.Fatalf("new http client: %v", err) + } + if _, err := client.Get(server.URL); err != nil { + t.Fatalf("request: %v", err) + } +} + +func TestTokenAuthFromEnvRef(t *testing.T) { + source, err := NewTokenSource(v4config.Auth{ + Method: v4config.AuthMethodToken, + TokenRef: "env://FCTL_TOKEN", + }, nil, Options{Env: func(key string) string { + if key != "FCTL_TOKEN" { + t.Fatalf("unexpected env key %q", key) + } + return "from-env" + }}) + if err != nil { + t.Fatalf("new token source: %v", err) + } + token, err := source.Token(context.Background()) + if err != nil { + t.Fatalf("get token: %v", err) + } + if token.AccessToken != "from-env" || token.TokenType != "Bearer" { + t.Fatalf("unexpected token: %#v", token) + } +} + +func TestClientCredentialsAuth(t *testing.T) { + ctx := context.Background() + store := credentials.NewMemoryStore() + if err := store.Set(ctx, "secret-ref", "secret"); err != nil { + t.Fatalf("set secret: %v", err) + } + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + fmt.Fprintf(w, `{"token_endpoint":%q}`, server.URL+"/token") + case "/token": + clientID, clientSecret, ok := r.BasicAuth() + if !ok || clientID != "client" || clientSecret != "secret" { + t.Fatalf("unexpected basic auth: %q %q %v", clientID, clientSecret, ok) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + if r.Form.Get("grant_type") != "client_credentials" { + t.Fatalf("unexpected grant_type %q", r.Form.Get("grant_type")) + } + fmt.Fprint(w, `{"access_token":"cc-token","token_type":"Bearer"}`) + case "/resource": + if got := r.Header.Get("Authorization"); got != "Bearer cc-token" { + t.Fatalf("unexpected authorization header %q", got) + } + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + client, err := NewHTTPClient(ctx, v4config.Auth{ + Method: v4config.AuthMethodClientCredentials, + IssuerURL: server.URL, + ClientID: "client", + SecretRef: "secret-ref", + }, store, Options{}) + if err != nil { + t.Fatalf("new http client: %v", err) + } + rsp, err := client.Get(server.URL + "/resource") + if err != nil { + t.Fatalf("request: %v", err) + } + defer rsp.Body.Close() + if rsp.StatusCode != http.StatusNoContent { + t.Fatalf("expected status 204, got %d", rsp.StatusCode) + } +} + +func authHeaderServer(t *testing.T, expected string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got := r.Header.Get("Authorization") + if got != expected { + t.Fatalf("expected authorization header %q, got %q", expected, got) + } + w.WriteHeader(http.StatusNoContent) + })) +} + +func TestTokenAuthFromStdinRef(t *testing.T) { + source, err := NewTokenSource(v4config.Auth{ + Method: v4config.AuthMethodToken, + TokenRef: "stdin://", + }, nil, Options{Stdin: strings.NewReader("from-stdin\n")}) + if err != nil { + t.Fatalf("new token source: %v", err) + } + token, err := source.Token(context.Background()) + if err != nil { + t.Fatalf("get token: %v", err) + } + if token.AccessToken != "from-stdin" { + t.Fatalf("unexpected token: %#v", token) + } +} From e6ea0b601ca8a429ff86e9846f64a944b0b2bd33 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:33:57 +0200 Subject: [PATCH 013/208] feat: wire auth into v4 runtime --- v4/internal/runtime/runtime.go | 12 +++++++ v4/internal/runtime/runtime_test.go | 51 +++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/v4/internal/runtime/runtime.go b/v4/internal/runtime/runtime.go index f4cae6dc..91d95e85 100644 --- a/v4/internal/runtime/runtime.go +++ b/v4/internal/runtime/runtime.go @@ -6,7 +6,9 @@ import ( "context" "errors" "fmt" + "net/http" + "github.com/formancehq/fctl/v4/internal/auth" "github.com/formancehq/fctl/v4/internal/capabilities" "github.com/formancehq/fctl/v4/internal/config" "github.com/formancehq/fctl/v4/internal/credentials" @@ -24,6 +26,7 @@ type Options struct { ConfigPath string ContextOverride config.ContextOverride Credentials credentials.Store + Auth auth.Options Manifest capabilities.Manifest Compatibility capabilities.ComponentCompatibility } @@ -35,6 +38,7 @@ type Runtime struct { Target Target Credentials credentials.Store + AuthOptions auth.Options Manifest capabilities.Manifest Compatibility capabilities.ComponentCompatibility } @@ -75,6 +79,7 @@ func New(ctx context.Context, options Options) (*Runtime, error) { Context: selectedContext, Target: target, Credentials: options.Credentials, + AuthOptions: options.Auth, Manifest: options.Manifest, Compatibility: options.Compatibility, }, nil @@ -113,3 +118,10 @@ func (r *Runtime) APIPolicyFor(product capabilities.Product) config.APIPolicy { } return config.APIPolicyLatestCompatible } + +func (r *Runtime) HTTPClient(ctx context.Context) (*http.Client, error) { + if r == nil { + return nil, errors.New("runtime is nil") + } + return auth.NewHTTPClient(ctx, r.Context.Auth, r.Credentials, r.AuthOptions) +} diff --git a/v4/internal/runtime/runtime_test.go b/v4/internal/runtime/runtime_test.go index 655a4aac..8e49db2b 100644 --- a/v4/internal/runtime/runtime_test.go +++ b/v4/internal/runtime/runtime_test.go @@ -2,6 +2,8 @@ package runtime import ( "context" + "net/http" + "net/http/httptest" "path/filepath" "testing" @@ -89,6 +91,55 @@ func TestNewRequiresConfigPath(t *testing.T) { } } +func TestHTTPClientUsesContextAuth(t *testing.T) { + ctx := context.Background() + store := credentials.NewMemoryStore() + if err := store.Set(ctx, "token-ref", "runtime-token"); err != nil { + t.Fatalf("set token: %v", err) + } + + configPath := writeRuntimeConfig(t, config.Config{ + Version: config.Version, + CurrentContext: "local", + Contexts: map[string]config.Context{ + "local": { + Kind: config.ContextKindStack, + StackURL: "http://localhost/api", + Auth: config.Auth{ + Method: config.AuthMethodToken, + TokenRef: "token-ref", + }, + }, + }, + }) + + rt, err := New(ctx, Options{ConfigPath: configPath, Credentials: store}) + if err != nil { + t.Fatalf("new runtime: %v", err) + } + client, err := rt.HTTPClient(ctx) + if err != nil { + t.Fatalf("runtime http client: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer runtime-token" { + t.Fatalf("unexpected authorization header %q", got) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + rsp, err := client.Get(server.URL) + if err != nil { + t.Fatalf("request: %v", err) + } + defer rsp.Body.Close() + if rsp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204, got %d", rsp.StatusCode) + } +} + func writeRuntimeConfig(t *testing.T, cfg config.Config) string { t.Helper() path := filepath.Join(t.TempDir(), "config.yaml") From a307e0fb34a6b6226f0563a17525d5ebe8c0bcad Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:34:13 +0200 Subject: [PATCH 014/208] docs: mark v4 auth goal complete --- todos/STATUS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todos/STATUS.md b/todos/STATUS.md index 23d78cb5..db169436 100644 --- a/todos/STATUS.md +++ b/todos/STATUS.md @@ -3,7 +3,7 @@ - [x] 01 - v4 isolated skeleton - [x] 02 - v4 foundation packages - [x] 03 - context commands -- [ ] 04 - auth providers +- [x] 04 - auth providers - [ ] 05 - capabilities manifest generator - [ ] 06 - runtime API version resolver - [ ] 07 - first stack inspection command From ec5dde8f6adb81456facc2905a33a2f615dc99a4 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:35:49 +0200 Subject: [PATCH 015/208] feat: add capabilities manifest generator --- v4/internal/capabilities/compatibility.go | 16 +++ v4/internal/capabilities/genmanifest/main.go | 122 +++++++++++++++++ v4/internal/capabilities/openapi.go | 134 +++++++++++++++++++ v4/internal/capabilities/openapi_test.go | 65 +++++++++ 4 files changed, 337 insertions(+) create mode 100644 v4/internal/capabilities/compatibility.go create mode 100644 v4/internal/capabilities/genmanifest/main.go create mode 100644 v4/internal/capabilities/openapi.go create mode 100644 v4/internal/capabilities/openapi_test.go diff --git a/v4/internal/capabilities/compatibility.go b/v4/internal/capabilities/compatibility.go new file mode 100644 index 00000000..c90799c0 --- /dev/null +++ b/v4/internal/capabilities/compatibility.go @@ -0,0 +1,16 @@ +package capabilities + +var DefaultComponentCompatibility = ComponentCompatibility{ + {Product: "ledger", Range: ">=1.0.0 <2.0.0", APIVersions: []APIVersion{"v1"}}, + {Product: "ledger", Range: ">=2.0.0 <3.0.0", APIVersions: []APIVersion{"v1", "v2"}}, + {Product: "ledger", Range: ">=3.0.0", APIVersions: []APIVersion{"v1", "v2", "v3"}}, + {Product: "payments", Range: ">=1.0.0 <3.0.0", APIVersions: []APIVersion{"v1"}}, + {Product: "payments", Range: ">=3.0.0", APIVersions: []APIVersion{"v1", "v3"}}, + {Product: "orchestration", Range: ">=1.0.0 <2.0.0", APIVersions: []APIVersion{"v1"}}, + {Product: "orchestration", Range: ">=2.0.0", APIVersions: []APIVersion{"v1", "v2"}}, + {Product: "auth", Range: ">=0.0.0", APIVersions: []APIVersion{"v1"}}, + {Product: "wallets", Range: ">=0.0.0", APIVersions: []APIVersion{"v1"}}, + {Product: "webhooks", Range: ">=0.0.0", APIVersions: []APIVersion{"v1"}}, + {Product: "search", Range: ">=0.0.0", APIVersions: []APIVersion{"v1"}}, + {Product: "reconciliation", Range: ">=0.0.0", APIVersions: []APIVersion{"v1"}}, +} diff --git a/v4/internal/capabilities/genmanifest/main.go b/v4/internal/capabilities/genmanifest/main.go new file mode 100644 index 00000000..abefb5d5 --- /dev/null +++ b/v4/internal/capabilities/genmanifest/main.go @@ -0,0 +1,122 @@ +package main + +import ( + "bytes" + "flag" + "fmt" + "go/format" + "os" + "sort" + "strconv" + + "github.com/formancehq/fctl/v4/internal/capabilities" +) + +func main() { + input := flag.String("input", "", "Path to OpenAPI JSON document") + output := flag.String("output", "", "Path to generated Go manifest") + flag.Parse() + + if *input == "" || *output == "" { + fmt.Fprintln(os.Stderr, "usage: genmanifest -input generate.json -output manifest_generated.go") + os.Exit(2) + } + + file, err := os.Open(*input) + if err != nil { + fail("open input", err) + } + defer file.Close() + + manifest, err := capabilities.ParseOpenAPIManifest(file) + if err != nil { + fail("parse openapi", err) + } + + data, err := renderGo(manifest) + if err != nil { + fail("render go", err) + } + if err := os.WriteFile(*output, data, 0o644); err != nil { + fail("write output", err) + } +} + +func renderGo(manifest capabilities.Manifest) ([]byte, error) { + buffer := bytes.Buffer{} + buffer.WriteString("// Code generated by genmanifest; DO NOT EDIT.\n\n") + buffer.WriteString("package capabilities\n\n") + buffer.WriteString("var GeneratedManifest = Manifest{\n") + fmt.Fprintf(&buffer, "\tSpecVersion: %s,\n", strconv.Quote(manifest.SpecVersion)) + buffer.WriteString("\tProducts: map[Product]ProductManifest{\n") + + products := make([]string, 0, len(manifest.Products)) + for product := range manifest.Products { + products = append(products, string(product)) + } + sort.Strings(products) + + for _, product := range products { + productManifest := manifest.Products[capabilities.Product(product)] + fmt.Fprintf(&buffer, "\t\tProduct(%s): {\n", strconv.Quote(product)) + buffer.WriteString("\t\t\tAPIVersions: []APIVersion{") + for i, apiVersion := range productManifest.APIVersions { + if i > 0 { + buffer.WriteString(", ") + } + fmt.Fprintf(&buffer, "APIVersion(%s)", strconv.Quote(string(apiVersion))) + } + buffer.WriteString("},\n") + buffer.WriteString("\t\t\tOperations: map[Feature]map[APIVersion]Operation{\n") + + features := make([]string, 0, len(productManifest.Operations)) + for feature := range productManifest.Operations { + features = append(features, string(feature)) + } + sort.Strings(features) + + for _, feature := range features { + fmt.Fprintf(&buffer, "\t\t\t\tFeature(%s): {\n", strconv.Quote(feature)) + operations := productManifest.Operations[capabilities.Feature(feature)] + apiVersions := make([]string, 0, len(operations)) + for apiVersion := range operations { + apiVersions = append(apiVersions, string(apiVersion)) + } + sort.Strings(apiVersions) + for _, apiVersion := range apiVersions { + operation := operations[capabilities.APIVersion(apiVersion)] + fmt.Fprintf(&buffer, "\t\t\t\t\tAPIVersion(%s): {OperationID: %s, Method: %s, Path: %s", + strconv.Quote(apiVersion), + strconv.Quote(operation.OperationID), + strconv.Quote(operation.Method), + strconv.Quote(operation.Path), + ) + if len(operation.Tags) > 0 { + buffer.WriteString(", Tags: []string{") + for i, tag := range operation.Tags { + if i > 0 { + buffer.WriteString(", ") + } + buffer.WriteString(strconv.Quote(tag)) + } + buffer.WriteString("}") + } + buffer.WriteString("},\n") + } + buffer.WriteString("\t\t\t\t},\n") + } + + buffer.WriteString("\t\t\t},\n") + buffer.WriteString("\t\t},\n") + } + + buffer.WriteString("\t},\n") + buffer.WriteString("}\n") + + return format.Source(buffer.Bytes()) +} + +func fail(message string, err error) { + fmt.Fprintf(os.Stderr, "%s: %v\n", message, err) + os.Exit(1) +} diff --git a/v4/internal/capabilities/openapi.go b/v4/internal/capabilities/openapi.go new file mode 100644 index 00000000..9e1366ef --- /dev/null +++ b/v4/internal/capabilities/openapi.go @@ -0,0 +1,134 @@ +package capabilities + +import ( + "encoding/json" + "fmt" + "io" + "regexp" + "sort" + "strings" + "unicode" + "unicode/utf8" +) + +var versionedTagPattern = regexp.MustCompile(`^([A-Za-z0-9_-]+)\.(v[0-9]+)$`) +var pathVersionPattern = regexp.MustCompile(`^/api/([^/]+)/((?:v)[0-9]+)(?:/|$)`) + +func ParseOpenAPIManifest(reader io.Reader) (Manifest, error) { + var document openAPIDocument + if err := json.NewDecoder(reader).Decode(&document); err != nil { + return Manifest{}, fmt.Errorf("decode openapi document: %w", err) + } + + manifest := Manifest{ + SpecVersion: document.Info.Version, + Products: map[Product]ProductManifest{}, + } + + paths := make([]string, 0, len(document.Paths)) + for path := range document.Paths { + paths = append(paths, path) + } + sort.Strings(paths) + + for _, path := range paths { + pathItem := document.Paths[path] + methods := make([]string, 0, len(pathItem)) + for method := range pathItem { + methods = append(methods, method) + } + sort.Strings(methods) + + for _, method := range methods { + operation := pathItem[method] + if operation.OperationID == "" { + continue + } + + product, apiVersion, ok := operationProductVersion(path, operation.Tags) + if !ok { + continue + } + + productManifest := manifest.Products[product] + if productManifest.Operations == nil { + productManifest.Operations = map[Feature]map[APIVersion]Operation{} + } + productManifest.APIVersions = append(productManifest.APIVersions, apiVersion) + + feature := Feature(canonicalFeature(operation.OperationID)) + if productManifest.Operations[feature] == nil { + productManifest.Operations[feature] = map[APIVersion]Operation{} + } + productManifest.Operations[feature][apiVersion] = Operation{ + OperationID: operation.OperationID, + Method: strings.ToUpper(method), + Path: path, + Tags: append([]string(nil), operation.Tags...), + } + manifest.Products[product] = productManifest + } + } + + for product, productManifest := range manifest.Products { + productManifest.APIVersions = UniqueSortedAPIVersions(productManifest.APIVersions) + manifest.Products[product] = productManifest + } + + return manifest, nil +} + +func operationProductVersion(path string, tags []string) (Product, APIVersion, bool) { + for _, tag := range tags { + matches := versionedTagPattern.FindStringSubmatch(tag) + if len(matches) == 3 { + return Product(matches[1]), APIVersion(matches[2]), true + } + } + + matches := pathVersionPattern.FindStringSubmatch(path) + if len(matches) == 3 { + return Product(matches[1]), APIVersion(matches[2]), true + } + + return "", "", false +} + +func canonicalFeature(operationID string) string { + for { + if len(operationID) < 3 || operationID[0] != 'v' { + break + } + i := 1 + for i < len(operationID) && operationID[i] >= '0' && operationID[i] <= '9' { + i++ + } + if i == 1 || i >= len(operationID) { + break + } + operationID = operationID[i:] + break + } + if operationID == "" { + return operationID + } + r, size := utf8.DecodeRuneInString(operationID) + if r == utf8.RuneError { + return operationID + } + return string(unicode.ToLower(r)) + operationID[size:] +} + +type openAPIDocument struct { + Info openAPIInfo `json:"info"` + Paths map[string]map[string]openAPIOperation `json:"paths"` +} + +type openAPIInfo struct { + Version string `json:"version"` +} + +type openAPIOperation struct { + OperationID string `json:"operationId"` + Tags []string `json:"tags"` +} diff --git a/v4/internal/capabilities/openapi_test.go b/v4/internal/capabilities/openapi_test.go new file mode 100644 index 00000000..93fc6d50 --- /dev/null +++ b/v4/internal/capabilities/openapi_test.go @@ -0,0 +1,65 @@ +package capabilities + +import ( + "strings" + "testing" +) + +func TestParseOpenAPIManifest(t *testing.T) { + const document = `{ + "info": {"version": "v-test"}, + "paths": { + "/api/ledger/{ledger}/transactions": { + "get": {"operationId": "listTransactions", "tags": ["ledger.v1"]} + }, + "/api/ledger/v2/{ledger}/transactions": { + "get": {"operationId": "v2ListTransactions", "tags": ["ledger.v2"]} + }, + "/api/payments/v3/payments": { + "get": {"operationId": "v3ListPayments", "tags": ["payments.v3"]} + }, + "/versions": { + "get": {"operationId": "getVersions", "tags": []} + } + } + }` + + manifest, err := ParseOpenAPIManifest(strings.NewReader(document)) + if err != nil { + t.Fatalf("parse manifest: %v", err) + } + if manifest.SpecVersion != "v-test" { + t.Fatalf("expected spec version v-test, got %q", manifest.SpecVersion) + } + + ledger := manifest.Products["ledger"] + assertAPIVersions(t, ledger.APIVersions, []APIVersion{"v1", "v2"}) + if ledger.Operations["listTransactions"]["v1"].OperationID != "listTransactions" { + t.Fatalf("missing ledger v1 listTransactions operation") + } + if ledger.Operations["listTransactions"]["v2"].OperationID != "v2ListTransactions" { + t.Fatalf("missing ledger v2 listTransactions operation") + } + + payments := manifest.Products["payments"] + assertAPIVersions(t, payments.APIVersions, []APIVersion{"v3"}) + if payments.Operations["listPayments"]["v3"].Path != "/api/payments/v3/payments" { + t.Fatalf("missing payments v3 listPayments operation") + } + if _, ok := manifest.Products["versions"]; ok { + t.Fatalf("/versions should not be included as a product") + } +} + +func TestCanonicalFeature(t *testing.T) { + tests := map[string]string{ + "listTransactions": "listTransactions", + "v2ListTransactions": "listTransactions", + "CreateTransactions": "createTransactions", + } + for input, expected := range tests { + if got := canonicalFeature(input); got != expected { + t.Fatalf("expected %s -> %s, got %s", input, expected, got) + } + } +} From 7b86175260dd64d3c7b2ec767e121d05e438963f Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:36:49 +0200 Subject: [PATCH 016/208] feat: generate initial capabilities manifest --- v4/internal/capabilities/README.md | 15 + .../capabilities/manifest_generated.go | 687 ++++++++++++++++++ v4/internal/capabilities/openapi.go | 21 +- 3 files changed, 720 insertions(+), 3 deletions(-) create mode 100644 v4/internal/capabilities/README.md create mode 100644 v4/internal/capabilities/manifest_generated.go diff --git a/v4/internal/capabilities/README.md b/v4/internal/capabilities/README.md new file mode 100644 index 00000000..4913d042 --- /dev/null +++ b/v4/internal/capabilities/README.md @@ -0,0 +1,15 @@ +# Capabilities Manifest + +`manifest_generated.go` is generated from the stack OpenAPI document. + +Regenerate it from the v4 module directory: + +```bash +curl -L https://github.com/formancehq/stack/releases/download/v3.2.4/generate.json -o /tmp/formance-stack-generate.json +go run ./internal/capabilities/genmanifest \ + -input /tmp/formance-stack-generate.json \ + -output internal/capabilities/manifest_generated.go +go test ./internal/capabilities ./internal/capabilities/genmanifest +``` + +The manual component-version compatibility ranges live in `compatibility.go`. diff --git a/v4/internal/capabilities/manifest_generated.go b/v4/internal/capabilities/manifest_generated.go new file mode 100644 index 00000000..88699702 --- /dev/null +++ b/v4/internal/capabilities/manifest_generated.go @@ -0,0 +1,687 @@ +// Code generated by genmanifest; DO NOT EDIT. + +package capabilities + +var GeneratedManifest = Manifest{ + SpecVersion: "v3.2.4", + Products: map[Product]ProductManifest{ + Product("auth"): { + APIVersions: []APIVersion{APIVersion("v1")}, + Operations: map[Feature]map[APIVersion]Operation{ + Feature("createClient"): { + APIVersion("v1"): {OperationID: "createClient", Method: "POST", Path: "/api/auth/clients", Tags: []string{"auth.v1"}}, + }, + Feature("createSecret"): { + APIVersion("v1"): {OperationID: "createSecret", Method: "POST", Path: "/api/auth/clients/{clientId}/secrets", Tags: []string{"auth.v1"}}, + }, + Feature("deleteClient"): { + APIVersion("v1"): {OperationID: "deleteClient", Method: "DELETE", Path: "/api/auth/clients/{clientId}", Tags: []string{"auth.v1"}}, + }, + Feature("deleteSecret"): { + APIVersion("v1"): {OperationID: "deleteSecret", Method: "DELETE", Path: "/api/auth/clients/{clientId}/secrets/{secretId}", Tags: []string{"auth.v1"}}, + }, + Feature("getOIDCWellKnowns"): { + APIVersion("v1"): {OperationID: "getOIDCWellKnowns", Method: "GET", Path: "/api/auth/.well-known/openid-configuration", Tags: []string{"auth.v1"}}, + }, + Feature("getServerInfo_auth"): { + APIVersion("v1"): {OperationID: "getServerInfo_auth", Method: "GET", Path: "/api/auth/_info", Tags: []string{"auth.v1"}}, + }, + Feature("listClients"): { + APIVersion("v1"): {OperationID: "listClients", Method: "GET", Path: "/api/auth/clients", Tags: []string{"auth.v1"}}, + }, + Feature("listUsers"): { + APIVersion("v1"): {OperationID: "listUsers", Method: "GET", Path: "/api/auth/users", Tags: []string{"auth.v1"}}, + }, + Feature("readClient"): { + APIVersion("v1"): {OperationID: "readClient", Method: "GET", Path: "/api/auth/clients/{clientId}", Tags: []string{"auth.v1"}}, + }, + Feature("readUser"): { + APIVersion("v1"): {OperationID: "readUser", Method: "GET", Path: "/api/auth/users/{userId}", Tags: []string{"auth.v1"}}, + }, + Feature("updateClient"): { + APIVersion("v1"): {OperationID: "updateClient", Method: "PUT", Path: "/api/auth/clients/{clientId}", Tags: []string{"auth.v1"}}, + }, + }, + }, + Product("ledger"): { + APIVersions: []APIVersion{APIVersion("v1"), APIVersion("v2")}, + Operations: map[Feature]map[APIVersion]Operation{ + Feature("addMetadataOnTransaction"): { + APIVersion("v1"): {OperationID: "addMetadataOnTransaction", Method: "POST", Path: "/api/ledger/{ledger}/transactions/{txid}/metadata", Tags: []string{"ledger.v1"}}, + APIVersion("v2"): {OperationID: "v2AddMetadataOnTransaction", Method: "POST", Path: "/api/ledger/v2/{ledger}/transactions/{id}/metadata", Tags: []string{"ledger.v2"}}, + }, + Feature("addMetadataToAccount"): { + APIVersion("v1"): {OperationID: "addMetadataToAccount", Method: "POST", Path: "/api/ledger/{ledger}/accounts/{address}/metadata", Tags: []string{"ledger.v1"}}, + APIVersion("v2"): {OperationID: "v2AddMetadataToAccount", Method: "POST", Path: "/api/ledger/v2/{ledger}/accounts/{address}/metadata", Tags: []string{"ledger.v2"}}, + }, + Feature("countAccounts"): { + APIVersion("v1"): {OperationID: "countAccounts", Method: "HEAD", Path: "/api/ledger/{ledger}/accounts", Tags: []string{"ledger.v1"}}, + APIVersion("v2"): {OperationID: "v2CountAccounts", Method: "HEAD", Path: "/api/ledger/v2/{ledger}/accounts", Tags: []string{"ledger.v2"}}, + }, + Feature("countTransactions"): { + APIVersion("v1"): {OperationID: "countTransactions", Method: "HEAD", Path: "/api/ledger/{ledger}/transactions", Tags: []string{"ledger.v1"}}, + APIVersion("v2"): {OperationID: "v2CountTransactions", Method: "HEAD", Path: "/api/ledger/v2/{ledger}/transactions", Tags: []string{"ledger.v2"}}, + }, + Feature("createBulk"): { + APIVersion("v2"): {OperationID: "v2CreateBulk", Method: "POST", Path: "/api/ledger/v2/{ledger}/_bulk", Tags: []string{"ledger.v2"}}, + }, + Feature("createExporter"): { + APIVersion("v2"): {OperationID: "v2CreateExporter", Method: "POST", Path: "/api/ledger/v2/_/exporters", Tags: []string{"ledger.v2"}}, + }, + Feature("createLedger"): { + APIVersion("v2"): {OperationID: "v2CreateLedger", Method: "POST", Path: "/api/ledger/v2/{ledger}", Tags: []string{"ledger.v2"}}, + }, + Feature("createPipeline"): { + APIVersion("v2"): {OperationID: "v2CreatePipeline", Method: "POST", Path: "/api/ledger/v2/{ledger}/pipelines", Tags: []string{"ledger.v2"}}, + }, + Feature("createTransaction"): { + APIVersion("v1"): {OperationID: "createTransaction", Method: "POST", Path: "/api/ledger/{ledger}/transactions", Tags: []string{"ledger.v1"}}, + APIVersion("v2"): {OperationID: "v2CreateTransaction", Method: "POST", Path: "/api/ledger/v2/{ledger}/transactions", Tags: []string{"ledger.v2"}}, + }, + Feature("createTransactions"): { + APIVersion("v1"): {OperationID: "CreateTransactions", Method: "POST", Path: "/api/ledger/{ledger}/transactions/batch", Tags: []string{"ledger.v1"}}, + }, + Feature("deleteAccountMetadata"): { + APIVersion("v2"): {OperationID: "v2DeleteAccountMetadata", Method: "DELETE", Path: "/api/ledger/v2/{ledger}/accounts/{address}/metadata/{key}", Tags: []string{"ledger.v2"}}, + }, + Feature("deleteBucket"): { + APIVersion("v2"): {OperationID: "v2DeleteBucket", Method: "DELETE", Path: "/api/ledger/v2/_/buckets/{bucket}", Tags: []string{"ledger.v2"}}, + }, + Feature("deleteExporter"): { + APIVersion("v2"): {OperationID: "v2DeleteExporter", Method: "DELETE", Path: "/api/ledger/v2/_/exporters/{exporterID}", Tags: []string{"ledger.v2"}}, + }, + Feature("deleteLedgerMetadata"): { + APIVersion("v2"): {OperationID: "v2DeleteLedgerMetadata", Method: "DELETE", Path: "/api/ledger/v2/{ledger}/metadata/{key}", Tags: []string{"ledger.v2"}}, + }, + Feature("deletePipeline"): { + APIVersion("v2"): {OperationID: "v2DeletePipeline", Method: "DELETE", Path: "/api/ledger/v2/{ledger}/pipelines/{pipelineID}", Tags: []string{"ledger.v2"}}, + }, + Feature("deleteTransactionMetadata"): { + APIVersion("v2"): {OperationID: "v2DeleteTransactionMetadata", Method: "DELETE", Path: "/api/ledger/v2/{ledger}/transactions/{id}/metadata/{key}", Tags: []string{"ledger.v2"}}, + }, + Feature("exportLogs"): { + APIVersion("v2"): {OperationID: "v2ExportLogs", Method: "POST", Path: "/api/ledger/v2/{ledger}/logs/export", Tags: []string{"ledger.v2"}}, + }, + Feature("getAccount"): { + APIVersion("v2"): {OperationID: "v2GetAccount", Method: "GET", Path: "/api/ledger/v2/{ledger}/accounts/{address}", Tags: []string{"ledger.v2"}}, + }, + Feature("getAccount_ledger"): { + APIVersion("v1"): {OperationID: "getAccount_ledger", Method: "GET", Path: "/api/ledger/{ledger}/accounts/{address}", Tags: []string{"ledger.v1"}}, + }, + Feature("getBalances"): { + APIVersion("v1"): {OperationID: "getBalances", Method: "GET", Path: "/api/ledger/{ledger}/balances", Tags: []string{"ledger.v1"}}, + }, + Feature("getBalancesAggregated"): { + APIVersion("v1"): {OperationID: "getBalancesAggregated", Method: "GET", Path: "/api/ledger/{ledger}/aggregate/balances", Tags: []string{"ledger.v1"}}, + APIVersion("v2"): {OperationID: "v2GetBalancesAggregated", Method: "GET", Path: "/api/ledger/v2/{ledger}/aggregate/balances", Tags: []string{"ledger.v2"}}, + }, + Feature("getExporterState"): { + APIVersion("v2"): {OperationID: "v2GetExporterState", Method: "GET", Path: "/api/ledger/v2/_/exporters/{exporterID}", Tags: []string{"ledger.v2"}}, + }, + Feature("getInfo"): { + APIVersion("v1"): {OperationID: "getInfo", Method: "GET", Path: "/api/ledger/_info", Tags: []string{"ledger.v1"}}, + }, + Feature("getLedger"): { + APIVersion("v2"): {OperationID: "v2GetLedger", Method: "GET", Path: "/api/ledger/v2/{ledger}", Tags: []string{"ledger.v2"}}, + }, + Feature("getLedgerInfo"): { + APIVersion("v1"): {OperationID: "getLedgerInfo", Method: "GET", Path: "/api/ledger/{ledger}/_info", Tags: []string{"ledger.v1"}}, + APIVersion("v2"): {OperationID: "v2GetLedgerInfo", Method: "GET", Path: "/api/ledger/v2/{ledger}/_info", Tags: []string{"ledger.v2"}}, + }, + Feature("getMapping"): { + APIVersion("v1"): {OperationID: "getMapping", Method: "GET", Path: "/api/ledger/{ledger}/mapping", Tags: []string{"ledger.v1"}}, + }, + Feature("getPipelineState"): { + APIVersion("v2"): {OperationID: "v2GetPipelineState", Method: "GET", Path: "/api/ledger/v2/{ledger}/pipelines/{pipelineID}", Tags: []string{"ledger.v2"}}, + }, + Feature("getSchema"): { + APIVersion("v2"): {OperationID: "v2GetSchema", Method: "GET", Path: "/api/ledger/v2/{ledger}/schemas/{version}", Tags: []string{"ledger.v2"}}, + }, + Feature("getTransaction"): { + APIVersion("v1"): {OperationID: "getTransaction", Method: "GET", Path: "/api/ledger/{ledger}/transactions/{txid}", Tags: []string{"ledger.v1"}}, + APIVersion("v2"): {OperationID: "v2GetTransaction", Method: "GET", Path: "/api/ledger/v2/{ledger}/transactions/{id}", Tags: []string{"ledger.v2"}}, + }, + Feature("getVolumesWithBalances"): { + APIVersion("v2"): {OperationID: "v2GetVolumesWithBalances", Method: "GET", Path: "/api/ledger/v2/{ledger}/volumes", Tags: []string{"ledger.v2"}}, + }, + Feature("importLogs"): { + APIVersion("v2"): {OperationID: "v2ImportLogs", Method: "POST", Path: "/api/ledger/v2/{ledger}/logs/import", Tags: []string{"ledger.v2"}}, + }, + Feature("insertSchema"): { + APIVersion("v2"): {OperationID: "v2InsertSchema", Method: "POST", Path: "/api/ledger/v2/{ledger}/schemas/{version}", Tags: []string{"ledger.v2"}}, + }, + Feature("listAccounts"): { + APIVersion("v2"): {OperationID: "v2ListAccounts", Method: "GET", Path: "/api/ledger/v2/{ledger}/accounts", Tags: []string{"ledger.v2"}}, + }, + Feature("listAccounts_ledger"): { + APIVersion("v1"): {OperationID: "listAccounts_ledger", Method: "GET", Path: "/api/ledger/{ledger}/accounts", Tags: []string{"ledger.v1"}}, + }, + Feature("listExporters"): { + APIVersion("v2"): {OperationID: "v2ListExporters", Method: "GET", Path: "/api/ledger/v2/_/exporters", Tags: []string{"ledger.v2"}}, + }, + Feature("listLedgers"): { + APIVersion("v2"): {OperationID: "v2ListLedgers", Method: "GET", Path: "/api/ledger/v2", Tags: []string{"ledger.v2"}}, + }, + Feature("listLogs"): { + APIVersion("v1"): {OperationID: "listLogs", Method: "GET", Path: "/api/ledger/{ledger}/logs", Tags: []string{"ledger.v1"}}, + APIVersion("v2"): {OperationID: "v2ListLogs", Method: "GET", Path: "/api/ledger/v2/{ledger}/logs", Tags: []string{"ledger.v2"}}, + }, + Feature("listPipelines"): { + APIVersion("v2"): {OperationID: "v2ListPipelines", Method: "GET", Path: "/api/ledger/v2/{ledger}/pipelines", Tags: []string{"ledger.v2"}}, + }, + Feature("listSchemas"): { + APIVersion("v2"): {OperationID: "v2ListSchemas", Method: "GET", Path: "/api/ledger/v2/{ledger}/schemas", Tags: []string{"ledger.v2"}}, + }, + Feature("listTransactions"): { + APIVersion("v1"): {OperationID: "listTransactions", Method: "GET", Path: "/api/ledger/{ledger}/transactions", Tags: []string{"ledger.v1"}}, + APIVersion("v2"): {OperationID: "v2ListTransactions", Method: "GET", Path: "/api/ledger/v2/{ledger}/transactions", Tags: []string{"ledger.v2"}}, + }, + Feature("readStats"): { + APIVersion("v1"): {OperationID: "readStats", Method: "GET", Path: "/api/ledger/{ledger}/stats", Tags: []string{"ledger.v1"}}, + APIVersion("v2"): {OperationID: "v2ReadStats", Method: "GET", Path: "/api/ledger/v2/{ledger}/stats", Tags: []string{"ledger.v2"}}, + }, + Feature("resetPipeline"): { + APIVersion("v2"): {OperationID: "v2ResetPipeline", Method: "POST", Path: "/api/ledger/v2/{ledger}/pipelines/{pipelineID}/reset", Tags: []string{"ledger.v2"}}, + }, + Feature("restoreBucket"): { + APIVersion("v2"): {OperationID: "v2RestoreBucket", Method: "POST", Path: "/api/ledger/v2/_/buckets/{bucket}/restore", Tags: []string{"ledger.v2"}}, + }, + Feature("revertTransaction"): { + APIVersion("v1"): {OperationID: "revertTransaction", Method: "POST", Path: "/api/ledger/{ledger}/transactions/{txid}/revert", Tags: []string{"ledger.v1"}}, + APIVersion("v2"): {OperationID: "v2RevertTransaction", Method: "POST", Path: "/api/ledger/v2/{ledger}/transactions/{id}/revert", Tags: []string{"ledger.v2"}}, + }, + Feature("runQuery"): { + APIVersion("v2"): {OperationID: "v2RunQuery", Method: "POST", Path: "/api/ledger/v2/{ledger}/queries/{id}/run", Tags: []string{"ledger.v2"}}, + }, + Feature("runScript"): { + APIVersion("v1"): {OperationID: "runScript", Method: "POST", Path: "/api/ledger/{ledger}/script", Tags: []string{"ledger.v1"}}, + }, + Feature("startPipeline"): { + APIVersion("v2"): {OperationID: "v2StartPipeline", Method: "POST", Path: "/api/ledger/v2/{ledger}/pipelines/{pipelineID}/start", Tags: []string{"ledger.v2"}}, + }, + Feature("stopPipeline"): { + APIVersion("v2"): {OperationID: "v2StopPipeline", Method: "POST", Path: "/api/ledger/v2/{ledger}/pipelines/{pipelineID}/stop", Tags: []string{"ledger.v2"}}, + }, + Feature("updateExporter"): { + APIVersion("v2"): {OperationID: "v2UpdateExporter", Method: "PUT", Path: "/api/ledger/v2/_/exporters/{exporterID}", Tags: []string{"ledger.v2"}}, + }, + Feature("updateLedgerMetadata"): { + APIVersion("v2"): {OperationID: "v2UpdateLedgerMetadata", Method: "PUT", Path: "/api/ledger/v2/{ledger}/metadata", Tags: []string{"ledger.v2"}}, + }, + Feature("updateMapping"): { + APIVersion("v1"): {OperationID: "updateMapping", Method: "PUT", Path: "/api/ledger/{ledger}/mapping", Tags: []string{"ledger.v1"}}, + }, + }, + }, + Product("orchestration"): { + APIVersions: []APIVersion{APIVersion("v1"), APIVersion("v2")}, + Operations: map[Feature]map[APIVersion]Operation{ + Feature("cancelEvent"): { + APIVersion("v1"): {OperationID: "cancelEvent", Method: "PUT", Path: "/api/orchestration/instances/{instanceID}/abort", Tags: []string{"orchestration.v1"}}, + APIVersion("v2"): {OperationID: "v2CancelEvent", Method: "PUT", Path: "/api/orchestration/v2/instances/{instanceID}/abort", Tags: []string{"orchestration.v2"}}, + }, + Feature("createTrigger"): { + APIVersion("v1"): {OperationID: "createTrigger", Method: "POST", Path: "/api/orchestration/triggers", Tags: []string{"orchestration.v1"}}, + APIVersion("v2"): {OperationID: "v2CreateTrigger", Method: "POST", Path: "/api/orchestration/v2/triggers", Tags: []string{"orchestration.v2"}}, + }, + Feature("createWorkflow"): { + APIVersion("v1"): {OperationID: "createWorkflow", Method: "POST", Path: "/api/orchestration/workflows", Tags: []string{"orchestration.v1"}}, + APIVersion("v2"): {OperationID: "v2CreateWorkflow", Method: "POST", Path: "/api/orchestration/v2/workflows", Tags: []string{"orchestration.v2"}}, + }, + Feature("deleteTrigger"): { + APIVersion("v1"): {OperationID: "deleteTrigger", Method: "DELETE", Path: "/api/orchestration/triggers/{triggerID}", Tags: []string{"orchestration.v1"}}, + APIVersion("v2"): {OperationID: "v2DeleteTrigger", Method: "DELETE", Path: "/api/orchestration/v2/triggers/{triggerID}", Tags: []string{"orchestration.v2"}}, + }, + Feature("deleteWorkflow"): { + APIVersion("v1"): {OperationID: "deleteWorkflow", Method: "DELETE", Path: "/api/orchestration/workflows/{flowId}", Tags: []string{"orchestration.v1"}}, + APIVersion("v2"): {OperationID: "v2DeleteWorkflow", Method: "DELETE", Path: "/api/orchestration/v2/workflows/{flowId}", Tags: []string{"orchestration.v2"}}, + }, + Feature("getInstance"): { + APIVersion("v1"): {OperationID: "getInstance", Method: "GET", Path: "/api/orchestration/instances/{instanceID}", Tags: []string{"orchestration.v1"}}, + APIVersion("v2"): {OperationID: "v2GetInstance", Method: "GET", Path: "/api/orchestration/v2/instances/{instanceID}", Tags: []string{"orchestration.v2"}}, + }, + Feature("getInstanceHistory"): { + APIVersion("v1"): {OperationID: "getInstanceHistory", Method: "GET", Path: "/api/orchestration/instances/{instanceID}/history", Tags: []string{"orchestration.v1"}}, + APIVersion("v2"): {OperationID: "v2GetInstanceHistory", Method: "GET", Path: "/api/orchestration/v2/instances/{instanceID}/history", Tags: []string{"orchestration.v2"}}, + }, + Feature("getInstanceStageHistory"): { + APIVersion("v1"): {OperationID: "getInstanceStageHistory", Method: "GET", Path: "/api/orchestration/instances/{instanceID}/stages/{number}/history", Tags: []string{"orchestration.v1"}}, + APIVersion("v2"): {OperationID: "v2GetInstanceStageHistory", Method: "GET", Path: "/api/orchestration/v2/instances/{instanceID}/stages/{number}/history", Tags: []string{"orchestration.v2"}}, + }, + Feature("getServerInfo"): { + APIVersion("v2"): {OperationID: "v2GetServerInfo", Method: "GET", Path: "/api/orchestration/v2/_info", Tags: []string{"orchestration.v2"}}, + }, + Feature("getServerInfo_orchestration"): { + APIVersion("v1"): {OperationID: "getServerInfo_orchestration", Method: "GET", Path: "/api/orchestration/_info", Tags: []string{"orchestration.v1"}}, + }, + Feature("getWorkflow"): { + APIVersion("v1"): {OperationID: "getWorkflow", Method: "GET", Path: "/api/orchestration/workflows/{flowId}", Tags: []string{"orchestration.v1"}}, + APIVersion("v2"): {OperationID: "v2GetWorkflow", Method: "GET", Path: "/api/orchestration/v2/workflows/{flowId}", Tags: []string{"orchestration.v2"}}, + }, + Feature("listInstances"): { + APIVersion("v1"): {OperationID: "listInstances", Method: "GET", Path: "/api/orchestration/instances", Tags: []string{"orchestration.v1"}}, + APIVersion("v2"): {OperationID: "v2ListInstances", Method: "GET", Path: "/api/orchestration/v2/instances", Tags: []string{"orchestration.v2"}}, + }, + Feature("listTriggers"): { + APIVersion("v1"): {OperationID: "listTriggers", Method: "GET", Path: "/api/orchestration/triggers", Tags: []string{"orchestration.v1"}}, + APIVersion("v2"): {OperationID: "v2ListTriggers", Method: "GET", Path: "/api/orchestration/v2/triggers", Tags: []string{"orchestration.v2"}}, + }, + Feature("listTriggersOccurrences"): { + APIVersion("v1"): {OperationID: "listTriggersOccurrences", Method: "GET", Path: "/api/orchestration/triggers/{triggerID}/occurrences", Tags: []string{"orchestration.v1"}}, + APIVersion("v2"): {OperationID: "v2ListTriggersOccurrences", Method: "GET", Path: "/api/orchestration/v2/triggers/{triggerID}/occurrences", Tags: []string{"orchestration.v2"}}, + }, + Feature("listWorkflows"): { + APIVersion("v1"): {OperationID: "listWorkflows", Method: "GET", Path: "/api/orchestration/workflows", Tags: []string{"orchestration.v1"}}, + APIVersion("v2"): {OperationID: "v2ListWorkflows", Method: "GET", Path: "/api/orchestration/v2/workflows", Tags: []string{"orchestration.v2"}}, + }, + Feature("readTrigger"): { + APIVersion("v1"): {OperationID: "readTrigger", Method: "GET", Path: "/api/orchestration/triggers/{triggerID}", Tags: []string{"orchestration.v1"}}, + APIVersion("v2"): {OperationID: "v2ReadTrigger", Method: "GET", Path: "/api/orchestration/v2/triggers/{triggerID}", Tags: []string{"orchestration.v2"}}, + }, + Feature("runWorkflow"): { + APIVersion("v1"): {OperationID: "runWorkflow", Method: "POST", Path: "/api/orchestration/workflows/{workflowID}/instances", Tags: []string{"orchestration.v1"}}, + APIVersion("v2"): {OperationID: "v2RunWorkflow", Method: "POST", Path: "/api/orchestration/v2/workflows/{workflowID}/instances", Tags: []string{"orchestration.v2"}}, + }, + Feature("sendEvent"): { + APIVersion("v1"): {OperationID: "sendEvent", Method: "POST", Path: "/api/orchestration/instances/{instanceID}/events", Tags: []string{"orchestration.v1"}}, + APIVersion("v2"): {OperationID: "v2SendEvent", Method: "POST", Path: "/api/orchestration/v2/instances/{instanceID}/events", Tags: []string{"orchestration.v2"}}, + }, + Feature("testTrigger"): { + APIVersion("v2"): {OperationID: "testTrigger", Method: "POST", Path: "/api/orchestration/v2/triggers/{triggerID}/test", Tags: []string{"orchestration.v2"}}, + }, + }, + }, + Product("payments"): { + APIVersions: []APIVersion{APIVersion("v1"), APIVersion("v3")}, + Operations: map[Feature]map[APIVersion]Operation{ + Feature("addAccountToPool"): { + APIVersion("v1"): {OperationID: "addAccountToPool", Method: "POST", Path: "/api/payments/pools/{poolId}/accounts", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3AddAccountToPool", Method: "POST", Path: "/api/payments/v3/pools/{poolID}/accounts/{accountID}", Tags: []string{"payments.v3"}}, + }, + Feature("addBankAccountToPaymentServiceUser"): { + APIVersion("v3"): {OperationID: "v3AddBankAccountToPaymentServiceUser", Method: "POST", Path: "/api/payments/v3/payment-service-users/{paymentServiceUserID}/bank-accounts/{bankAccountID}", Tags: []string{"payments.v3"}}, + }, + Feature("approvePaymentInitiation"): { + APIVersion("v3"): {OperationID: "v3ApprovePaymentInitiation", Method: "POST", Path: "/api/payments/v3/payment-initiations/{paymentInitiationID}/approve", Tags: []string{"payments.v3"}}, + }, + Feature("connectorsTransfer"): { + APIVersion("v1"): {OperationID: "connectorsTransfer", Method: "POST", Path: "/api/payments/connectors/{connector}/transfers", Tags: []string{"payments.v1"}}, + }, + Feature("createAccount"): { + APIVersion("v1"): {OperationID: "createAccount", Method: "POST", Path: "/api/payments/accounts", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3CreateAccount", Method: "POST", Path: "/api/payments/v3/accounts", Tags: []string{"payments.v3"}}, + }, + Feature("createBankAccount"): { + APIVersion("v1"): {OperationID: "createBankAccount", Method: "POST", Path: "/api/payments/bank-accounts", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3CreateBankAccount", Method: "POST", Path: "/api/payments/v3/bank-accounts", Tags: []string{"payments.v3"}}, + }, + Feature("createLinkForPaymentServiceUser"): { + APIVersion("v3"): {OperationID: "v3CreateLinkForPaymentServiceUser", Method: "POST", Path: "/api/payments/v3/payment-service-users/{paymentServiceUserID}/connectors/{connectorID}/create-link", Tags: []string{"payments.v3"}}, + }, + Feature("createPayment"): { + APIVersion("v1"): {OperationID: "createPayment", Method: "POST", Path: "/api/payments/payments", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3CreatePayment", Method: "POST", Path: "/api/payments/v3/payments", Tags: []string{"payments.v3"}}, + }, + Feature("createPaymentServiceUser"): { + APIVersion("v3"): {OperationID: "v3CreatePaymentServiceUser", Method: "POST", Path: "/api/payments/v3/payment-service-users", Tags: []string{"payments.v3"}}, + }, + Feature("createPool"): { + APIVersion("v1"): {OperationID: "createPool", Method: "POST", Path: "/api/payments/pools", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3CreatePool", Method: "POST", Path: "/api/payments/v3/pools", Tags: []string{"payments.v3"}}, + }, + Feature("createTransferInitiation"): { + APIVersion("v1"): {OperationID: "createTransferInitiation", Method: "POST", Path: "/api/payments/transfer-initiations", Tags: []string{"payments.v1"}}, + }, + Feature("deletePaymentInitiation"): { + APIVersion("v3"): {OperationID: "v3DeletePaymentInitiation", Method: "DELETE", Path: "/api/payments/v3/payment-initiations/{paymentInitiationID}", Tags: []string{"payments.v3"}}, + }, + Feature("deletePaymentServiceUser"): { + APIVersion("v3"): {OperationID: "v3DeletePaymentServiceUser", Method: "DELETE", Path: "/api/payments/v3/payment-service-users/{paymentServiceUserID}", Tags: []string{"payments.v3"}}, + }, + Feature("deletePaymentServiceUserConnectionFromConnectorID"): { + APIVersion("v3"): {OperationID: "v3DeletePaymentServiceUserConnectionFromConnectorID", Method: "DELETE", Path: "/api/payments/v3/payment-service-users/{paymentServiceUserID}/connectors/{connectorID}/connections/{connectionID}", Tags: []string{"payments.v3"}}, + }, + Feature("deletePaymentServiceUserConnector"): { + APIVersion("v3"): {OperationID: "v3DeletePaymentServiceUserConnector", Method: "DELETE", Path: "/api/payments/v3/payment-service-users/{paymentServiceUserID}/connectors/{connectorID}", Tags: []string{"payments.v3"}}, + }, + Feature("deletePool"): { + APIVersion("v1"): {OperationID: "deletePool", Method: "DELETE", Path: "/api/payments/pools/{poolId}", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3DeletePool", Method: "DELETE", Path: "/api/payments/v3/pools/{poolID}", Tags: []string{"payments.v3"}}, + }, + Feature("deleteTransferInitiation"): { + APIVersion("v1"): {OperationID: "deleteTransferInitiation", Method: "DELETE", Path: "/api/payments/transfer-initiations/{transferId}", Tags: []string{"payments.v1"}}, + }, + Feature("forwardBankAccount"): { + APIVersion("v1"): {OperationID: "forwardBankAccount", Method: "POST", Path: "/api/payments/bank-accounts/{bankAccountId}/forward", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3ForwardBankAccount", Method: "POST", Path: "/api/payments/v3/bank-accounts/{bankAccountID}/forward", Tags: []string{"payments.v3"}}, + }, + Feature("forwardPaymentServiceUserBankAccount"): { + APIVersion("v3"): {OperationID: "v3ForwardPaymentServiceUserBankAccount", Method: "POST", Path: "/api/payments/v3/payment-service-users/{paymentServiceUserID}/bank-accounts/{bankAccountID}/forward", Tags: []string{"payments.v3"}}, + }, + Feature("forwardPaymentServiceUserToProvider"): { + APIVersion("v3"): {OperationID: "v3ForwardPaymentServiceUserToProvider", Method: "POST", Path: "/api/payments/v3/payment-service-users/{paymentServiceUserID}/connectors/{connectorID}/forward", Tags: []string{"payments.v3"}}, + }, + Feature("getAccount"): { + APIVersion("v3"): {OperationID: "v3GetAccount", Method: "GET", Path: "/api/payments/v3/accounts/{accountID}", Tags: []string{"payments.v3"}}, + }, + Feature("getAccountBalances"): { + APIVersion("v1"): {OperationID: "getAccountBalances", Method: "GET", Path: "/api/payments/accounts/{accountId}/balances", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3GetAccountBalances", Method: "GET", Path: "/api/payments/v3/accounts/{accountID}/balances", Tags: []string{"payments.v3"}}, + }, + Feature("getAccount_payments"): { + APIVersion("v1"): {OperationID: "getAccount_payments", Method: "GET", Path: "/api/payments/accounts/{accountId}", Tags: []string{"payments.v1"}}, + }, + Feature("getBankAccount"): { + APIVersion("v1"): {OperationID: "getBankAccount", Method: "GET", Path: "/api/payments/bank-accounts/{bankAccountId}", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3GetBankAccount", Method: "GET", Path: "/api/payments/v3/bank-accounts/{bankAccountID}", Tags: []string{"payments.v3"}}, + }, + Feature("getConnectorConfig"): { + APIVersion("v3"): {OperationID: "v3GetConnectorConfig", Method: "GET", Path: "/api/payments/v3/connectors/{connectorID}/config", Tags: []string{"payments.v3"}}, + }, + Feature("getConnectorSchedule"): { + APIVersion("v3"): {OperationID: "v3GetConnectorSchedule", Method: "GET", Path: "/api/payments/v3/connectors/{connectorID}/schedules/{scheduleID}", Tags: []string{"payments.v3"}}, + }, + Feature("getConnectorTask"): { + APIVersion("v1"): {OperationID: "getConnectorTask", Method: "GET", Path: "/api/payments/connectors/{connector}/tasks/{taskId}", Tags: []string{"payments.v1"}}, + }, + Feature("getConnectorTaskV1"): { + APIVersion("v1"): {OperationID: "getConnectorTaskV1", Method: "GET", Path: "/api/payments/connectors/{connector}/{connectorId}/tasks/{taskId}", Tags: []string{"payments.v1"}}, + }, + Feature("getPayment"): { + APIVersion("v1"): {OperationID: "getPayment", Method: "GET", Path: "/api/payments/payments/{paymentId}", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3GetPayment", Method: "GET", Path: "/api/payments/v3/payments/{paymentID}", Tags: []string{"payments.v3"}}, + }, + Feature("getPaymentInitiation"): { + APIVersion("v3"): {OperationID: "v3GetPaymentInitiation", Method: "GET", Path: "/api/payments/v3/payment-initiations/{paymentInitiationID}", Tags: []string{"payments.v3"}}, + }, + Feature("getPaymentServiceUser"): { + APIVersion("v3"): {OperationID: "v3GetPaymentServiceUser", Method: "GET", Path: "/api/payments/v3/payment-service-users/{paymentServiceUserID}", Tags: []string{"payments.v3"}}, + }, + Feature("getPaymentServiceUserLinkAttemptFromConnectorID"): { + APIVersion("v3"): {OperationID: "v3GetPaymentServiceUserLinkAttemptFromConnectorID", Method: "GET", Path: "/api/payments/v3/payment-service-users/{paymentServiceUserID}/connectors/{connectorID}/link-attempts/{attemptID}", Tags: []string{"payments.v3"}}, + }, + Feature("getPool"): { + APIVersion("v1"): {OperationID: "getPool", Method: "GET", Path: "/api/payments/pools/{poolId}", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3GetPool", Method: "GET", Path: "/api/payments/v3/pools/{poolID}", Tags: []string{"payments.v3"}}, + }, + Feature("getPoolBalances"): { + APIVersion("v1"): {OperationID: "getPoolBalances", Method: "GET", Path: "/api/payments/pools/{poolId}/balances", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3GetPoolBalances", Method: "GET", Path: "/api/payments/v3/pools/{poolID}/balances", Tags: []string{"payments.v3"}}, + }, + Feature("getPoolBalancesLatest"): { + APIVersion("v1"): {OperationID: "getPoolBalancesLatest", Method: "GET", Path: "/api/payments/pools/{poolId}/balances/latest", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3GetPoolBalancesLatest", Method: "GET", Path: "/api/payments/v3/pools/{poolID}/balances/latest", Tags: []string{"payments.v3"}}, + }, + Feature("getServerInfo_payments"): { + APIVersion("v1"): {OperationID: "getServerInfo_payments", Method: "GET", Path: "/api/payments/_info", Tags: []string{"payments.v1"}}, + }, + Feature("getTask"): { + APIVersion("v3"): {OperationID: "v3GetTask", Method: "GET", Path: "/api/payments/v3/tasks/{taskID}", Tags: []string{"payments.v3"}}, + }, + Feature("getTransferInitiation"): { + APIVersion("v1"): {OperationID: "getTransferInitiation", Method: "GET", Path: "/api/payments/transfer-initiations/{transferId}", Tags: []string{"payments.v1"}}, + }, + Feature("initiatePayment"): { + APIVersion("v3"): {OperationID: "v3InitiatePayment", Method: "POST", Path: "/api/payments/v3/payment-initiations", Tags: []string{"payments.v3"}}, + }, + Feature("installConnector"): { + APIVersion("v1"): {OperationID: "installConnector", Method: "POST", Path: "/api/payments/connectors/{connector}", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3InstallConnector", Method: "POST", Path: "/api/payments/v3/connectors/install/{connector}", Tags: []string{"payments.v3"}}, + }, + Feature("listAccounts"): { + APIVersion("v3"): {OperationID: "v3ListAccounts", Method: "GET", Path: "/api/payments/v3/accounts", Tags: []string{"payments.v3"}}, + }, + Feature("listAccounts_payments"): { + APIVersion("v1"): {OperationID: "listAccounts_payments", Method: "GET", Path: "/api/payments/accounts", Tags: []string{"payments.v1"}}, + }, + Feature("listAllConnectors"): { + APIVersion("v1"): {OperationID: "listAllConnectors", Method: "GET", Path: "/api/payments/connectors", Tags: []string{"payments.v1"}}, + }, + Feature("listBankAccounts"): { + APIVersion("v1"): {OperationID: "listBankAccounts", Method: "GET", Path: "/api/payments/bank-accounts", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3ListBankAccounts", Method: "GET", Path: "/api/payments/v3/bank-accounts", Tags: []string{"payments.v3"}}, + }, + Feature("listConfigsAvailableConnectors"): { + APIVersion("v1"): {OperationID: "listConfigsAvailableConnectors", Method: "GET", Path: "/api/payments/connectors/configs", Tags: []string{"payments.v1"}}, + }, + Feature("listConnectorConfigs"): { + APIVersion("v3"): {OperationID: "v3ListConnectorConfigs", Method: "GET", Path: "/api/payments/v3/connectors/configs", Tags: []string{"payments.v3"}}, + }, + Feature("listConnectorScheduleInstances"): { + APIVersion("v3"): {OperationID: "v3ListConnectorScheduleInstances", Method: "GET", Path: "/api/payments/v3/connectors/{connectorID}/schedules/{scheduleID}/instances", Tags: []string{"payments.v3"}}, + }, + Feature("listConnectorSchedules"): { + APIVersion("v3"): {OperationID: "v3ListConnectorSchedules", Method: "GET", Path: "/api/payments/v3/connectors/{connectorID}/schedules", Tags: []string{"payments.v3"}}, + }, + Feature("listConnectorTasks"): { + APIVersion("v1"): {OperationID: "listConnectorTasks", Method: "GET", Path: "/api/payments/connectors/{connector}/tasks", Tags: []string{"payments.v1"}}, + }, + Feature("listConnectorTasksV1"): { + APIVersion("v1"): {OperationID: "listConnectorTasksV1", Method: "GET", Path: "/api/payments/connectors/{connector}/{connectorId}/tasks", Tags: []string{"payments.v1"}}, + }, + Feature("listConnectors"): { + APIVersion("v3"): {OperationID: "v3ListConnectors", Method: "GET", Path: "/api/payments/v3/connectors", Tags: []string{"payments.v3"}}, + }, + Feature("listPaymentInitiationAdjustments"): { + APIVersion("v3"): {OperationID: "v3ListPaymentInitiationAdjustments", Method: "GET", Path: "/api/payments/v3/payment-initiations/{paymentInitiationID}/adjustments", Tags: []string{"payments.v3"}}, + }, + Feature("listPaymentInitiationRelatedPayments"): { + APIVersion("v3"): {OperationID: "v3ListPaymentInitiationRelatedPayments", Method: "GET", Path: "/api/payments/v3/payment-initiations/{paymentInitiationID}/payments", Tags: []string{"payments.v3"}}, + }, + Feature("listPaymentInitiations"): { + APIVersion("v3"): {OperationID: "v3ListPaymentInitiations", Method: "GET", Path: "/api/payments/v3/payment-initiations", Tags: []string{"payments.v3"}}, + }, + Feature("listPaymentServiceUserConnections"): { + APIVersion("v3"): {OperationID: "v3ListPaymentServiceUserConnections", Method: "GET", Path: "/api/payments/v3/payment-service-users/{paymentServiceUserID}/connections", Tags: []string{"payments.v3"}}, + }, + Feature("listPaymentServiceUserConnectionsFromConnectorID"): { + APIVersion("v3"): {OperationID: "v3ListPaymentServiceUserConnectionsFromConnectorID", Method: "GET", Path: "/api/payments/v3/payment-service-users/{paymentServiceUserID}/connectors/{connectorID}/connections", Tags: []string{"payments.v3"}}, + }, + Feature("listPaymentServiceUserLinkAttemptsFromConnectorID"): { + APIVersion("v3"): {OperationID: "v3ListPaymentServiceUserLinkAttemptsFromConnectorID", Method: "GET", Path: "/api/payments/v3/payment-service-users/{paymentServiceUserID}/connectors/{connectorID}/link-attempts", Tags: []string{"payments.v3"}}, + }, + Feature("listPaymentServiceUsers"): { + APIVersion("v3"): {OperationID: "v3ListPaymentServiceUsers", Method: "GET", Path: "/api/payments/v3/payment-service-users", Tags: []string{"payments.v3"}}, + }, + Feature("listPayments"): { + APIVersion("v1"): {OperationID: "listPayments", Method: "GET", Path: "/api/payments/payments", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3ListPayments", Method: "GET", Path: "/api/payments/v3/payments", Tags: []string{"payments.v3"}}, + }, + Feature("listPools"): { + APIVersion("v1"): {OperationID: "listPools", Method: "GET", Path: "/api/payments/pools", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3ListPools", Method: "GET", Path: "/api/payments/v3/pools", Tags: []string{"payments.v3"}}, + }, + Feature("listTransferInitiations"): { + APIVersion("v1"): {OperationID: "listTransferInitiations", Method: "GET", Path: "/api/payments/transfer-initiations", Tags: []string{"payments.v1"}}, + }, + Feature("readConnectorConfig"): { + APIVersion("v1"): {OperationID: "readConnectorConfig", Method: "GET", Path: "/api/payments/connectors/{connector}/config", Tags: []string{"payments.v1"}}, + }, + Feature("readConnectorConfigV1"): { + APIVersion("v1"): {OperationID: "readConnectorConfigV1", Method: "GET", Path: "/api/payments/connectors/{connector}/{connectorId}/config", Tags: []string{"payments.v1"}}, + }, + Feature("rejectPaymentInitiation"): { + APIVersion("v3"): {OperationID: "v3RejectPaymentInitiation", Method: "POST", Path: "/api/payments/v3/payment-initiations/{paymentInitiationID}/reject", Tags: []string{"payments.v3"}}, + }, + Feature("removeAccountFromPool"): { + APIVersion("v1"): {OperationID: "removeAccountFromPool", Method: "DELETE", Path: "/api/payments/pools/{poolId}/accounts/{accountId}", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3RemoveAccountFromPool", Method: "DELETE", Path: "/api/payments/v3/pools/{poolID}/accounts/{accountID}", Tags: []string{"payments.v3"}}, + }, + Feature("resetConnector"): { + APIVersion("v1"): {OperationID: "resetConnector", Method: "POST", Path: "/api/payments/connectors/{connector}/reset", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3ResetConnector", Method: "POST", Path: "/api/payments/v3/connectors/{connectorID}/reset", Tags: []string{"payments.v3"}}, + }, + Feature("resetConnectorV1"): { + APIVersion("v1"): {OperationID: "resetConnectorV1", Method: "POST", Path: "/api/payments/connectors/{connector}/{connectorId}/reset", Tags: []string{"payments.v1"}}, + }, + Feature("retryPaymentInitiation"): { + APIVersion("v3"): {OperationID: "v3RetryPaymentInitiation", Method: "POST", Path: "/api/payments/v3/payment-initiations/{paymentInitiationID}/retry", Tags: []string{"payments.v3"}}, + }, + Feature("retryTransferInitiation"): { + APIVersion("v1"): {OperationID: "retryTransferInitiation", Method: "POST", Path: "/api/payments/transfer-initiations/{transferId}/retry", Tags: []string{"payments.v1"}}, + }, + Feature("reversePaymentInitiation"): { + APIVersion("v3"): {OperationID: "v3ReversePaymentInitiation", Method: "POST", Path: "/api/payments/v3/payment-initiations/{paymentInitiationID}/reverse", Tags: []string{"payments.v3"}}, + }, + Feature("reverseTransferInitiation"): { + APIVersion("v1"): {OperationID: "reverseTransferInitiation", Method: "POST", Path: "/api/payments/transfer-initiations/{transferId}/reverse", Tags: []string{"payments.v1"}}, + }, + Feature("uninstallConnector"): { + APIVersion("v1"): {OperationID: "uninstallConnector", Method: "DELETE", Path: "/api/payments/connectors/{connector}", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3UninstallConnector", Method: "DELETE", Path: "/api/payments/v3/connectors/{connectorID}", Tags: []string{"payments.v3"}}, + }, + Feature("uninstallConnectorV1"): { + APIVersion("v1"): {OperationID: "uninstallConnectorV1", Method: "DELETE", Path: "/api/payments/connectors/{connector}/{connectorId}", Tags: []string{"payments.v1"}}, + }, + Feature("updateBankAccountMetadata"): { + APIVersion("v1"): {OperationID: "updateBankAccountMetadata", Method: "PATCH", Path: "/api/payments/bank-accounts/{bankAccountId}/metadata", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3UpdateBankAccountMetadata", Method: "PATCH", Path: "/api/payments/v3/bank-accounts/{bankAccountID}/metadata", Tags: []string{"payments.v3"}}, + }, + Feature("updateConnectorConfig"): { + APIVersion("v3"): {OperationID: "v3UpdateConnectorConfig", Method: "PATCH", Path: "/api/payments/v3/connectors/{connectorID}/config", Tags: []string{"payments.v3"}}, + }, + Feature("updateConnectorConfigV1"): { + APIVersion("v1"): {OperationID: "updateConnectorConfigV1", Method: "POST", Path: "/api/payments/connectors/{connector}/{connectorId}/config", Tags: []string{"payments.v1"}}, + }, + Feature("updateLinkForPaymentServiceUserOnConnector"): { + APIVersion("v3"): {OperationID: "v3UpdateLinkForPaymentServiceUserOnConnector", Method: "POST", Path: "/api/payments/v3/payment-service-users/{paymentServiceUserID}/connectors/{connectorID}/connections/{connectionID}/update-link", Tags: []string{"payments.v3"}}, + }, + Feature("updateMetadata"): { + APIVersion("v1"): {OperationID: "updateMetadata", Method: "PATCH", Path: "/api/payments/payments/{paymentId}/metadata", Tags: []string{"payments.v1"}}, + }, + Feature("updatePaymentMetadata"): { + APIVersion("v3"): {OperationID: "v3UpdatePaymentMetadata", Method: "PATCH", Path: "/api/payments/v3/payments/{paymentID}/metadata", Tags: []string{"payments.v3"}}, + }, + Feature("updatePoolQuery"): { + APIVersion("v1"): {OperationID: "updatePoolQuery", Method: "PATCH", Path: "/api/payments/pools/{poolId}/query", Tags: []string{"payments.v1"}}, + APIVersion("v3"): {OperationID: "v3UpdatePoolQuery", Method: "PATCH", Path: "/api/payments/v3/pools/{poolID}/query", Tags: []string{"payments.v3"}}, + }, + Feature("updateTransferInitiationStatus"): { + APIVersion("v1"): {OperationID: "updateTransferInitiationStatus", Method: "POST", Path: "/api/payments/transfer-initiations/{transferId}/status", Tags: []string{"payments.v1"}}, + }, + }, + }, + Product("reconciliation"): { + APIVersions: []APIVersion{APIVersion("v1")}, + Operations: map[Feature]map[APIVersion]Operation{ + Feature("createPolicy"): { + APIVersion("v1"): {OperationID: "createPolicy", Method: "POST", Path: "/api/reconciliation/policies", Tags: []string{"reconciliation.v1"}}, + }, + Feature("deletePolicy"): { + APIVersion("v1"): {OperationID: "deletePolicy", Method: "DELETE", Path: "/api/reconciliation/policies/{policyID}", Tags: []string{"reconciliation.v1"}}, + }, + Feature("getPolicy"): { + APIVersion("v1"): {OperationID: "getPolicy", Method: "GET", Path: "/api/reconciliation/policies/{policyID}", Tags: []string{"reconciliation.v1"}}, + }, + Feature("getReconciliation"): { + APIVersion("v1"): {OperationID: "getReconciliation", Method: "GET", Path: "/api/reconciliation/reconciliations/{reconciliationID}", Tags: []string{"reconciliation.v1"}}, + }, + Feature("getServerInfo_reconciliation"): { + APIVersion("v1"): {OperationID: "getServerInfo_reconciliation", Method: "GET", Path: "/api/reconciliation/_info", Tags: []string{"reconciliation.v1"}}, + }, + Feature("listPolicies"): { + APIVersion("v1"): {OperationID: "listPolicies", Method: "GET", Path: "/api/reconciliation/policies", Tags: []string{"reconciliation.v1"}}, + }, + Feature("listReconciliations"): { + APIVersion("v1"): {OperationID: "listReconciliations", Method: "GET", Path: "/api/reconciliation/reconciliations", Tags: []string{"reconciliation.v1"}}, + }, + Feature("reconcile"): { + APIVersion("v1"): {OperationID: "reconcile", Method: "POST", Path: "/api/reconciliation/policies/{policyID}/reconciliation", Tags: []string{"reconciliation.v1"}}, + }, + }, + }, + Product("search"): { + APIVersions: []APIVersion{APIVersion("v1")}, + Operations: map[Feature]map[APIVersion]Operation{ + Feature("getServerInfo_search"): { + APIVersion("v1"): {OperationID: "getServerInfo_search", Method: "GET", Path: "/api/search/_info", Tags: []string{"search.v1"}}, + }, + Feature("search"): { + APIVersion("v1"): {OperationID: "search", Method: "POST", Path: "/api/search/", Tags: []string{"search.v1"}}, + }, + }, + }, + Product("wallets"): { + APIVersions: []APIVersion{APIVersion("v1")}, + Operations: map[Feature]map[APIVersion]Operation{ + Feature("confirmHold"): { + APIVersion("v1"): {OperationID: "confirmHold", Method: "POST", Path: "/api/wallets/holds/{hold_id}/confirm", Tags: []string{"wallets.v1"}}, + }, + Feature("createBalance"): { + APIVersion("v1"): {OperationID: "createBalance", Method: "POST", Path: "/api/wallets/wallets/{id}/balances", Tags: []string{"wallets.v1"}}, + }, + Feature("createWallet"): { + APIVersion("v1"): {OperationID: "createWallet", Method: "POST", Path: "/api/wallets/wallets", Tags: []string{"wallets.v1"}}, + }, + Feature("creditWallet"): { + APIVersion("v1"): {OperationID: "creditWallet", Method: "POST", Path: "/api/wallets/wallets/{id}/credit", Tags: []string{"wallets.v1"}}, + }, + Feature("debitWallet"): { + APIVersion("v1"): {OperationID: "debitWallet", Method: "POST", Path: "/api/wallets/wallets/{id}/debit", Tags: []string{"wallets.v1"}}, + }, + Feature("getBalance"): { + APIVersion("v1"): {OperationID: "getBalance", Method: "GET", Path: "/api/wallets/wallets/{id}/balances/{balanceName}", Tags: []string{"wallets.v1"}}, + }, + Feature("getHold"): { + APIVersion("v1"): {OperationID: "getHold", Method: "GET", Path: "/api/wallets/holds/{holdID}", Tags: []string{"wallets.v1"}}, + }, + Feature("getHolds"): { + APIVersion("v1"): {OperationID: "getHolds", Method: "GET", Path: "/api/wallets/holds", Tags: []string{"wallets.v1"}}, + }, + Feature("getServerInfo_wallets"): { + APIVersion("v1"): {OperationID: "getServerInfo_wallets", Method: "GET", Path: "/api/wallets/_info", Tags: []string{"wallets.v1"}}, + }, + Feature("getTransactions"): { + APIVersion("v1"): {OperationID: "getTransactions", Method: "GET", Path: "/api/wallets/transactions", Tags: []string{"wallets.v1"}}, + }, + Feature("getWallet"): { + APIVersion("v1"): {OperationID: "getWallet", Method: "GET", Path: "/api/wallets/wallets/{id}", Tags: []string{"wallets.v1"}}, + }, + Feature("getWalletSummary"): { + APIVersion("v1"): {OperationID: "getWalletSummary", Method: "GET", Path: "/api/wallets/wallets/{id}/summary", Tags: []string{"wallets.v1"}}, + }, + Feature("listBalances"): { + APIVersion("v1"): {OperationID: "listBalances", Method: "GET", Path: "/api/wallets/wallets/{id}/balances", Tags: []string{"wallets.v1"}}, + }, + Feature("listWallets"): { + APIVersion("v1"): {OperationID: "listWallets", Method: "GET", Path: "/api/wallets/wallets", Tags: []string{"wallets.v1"}}, + }, + Feature("updateWallet"): { + APIVersion("v1"): {OperationID: "updateWallet", Method: "PATCH", Path: "/api/wallets/wallets/{id}", Tags: []string{"wallets.v1"}}, + }, + Feature("voidHold"): { + APIVersion("v1"): {OperationID: "voidHold", Method: "POST", Path: "/api/wallets/holds/{hold_id}/void", Tags: []string{"wallets.v1"}}, + }, + }, + }, + Product("webhooks"): { + APIVersions: []APIVersion{APIVersion("v1")}, + Operations: map[Feature]map[APIVersion]Operation{ + Feature("activateConfig"): { + APIVersion("v1"): {OperationID: "activateConfig", Method: "PUT", Path: "/api/webhooks/configs/{id}/activate", Tags: []string{"webhooks.v1"}}, + }, + Feature("changeConfigSecret"): { + APIVersion("v1"): {OperationID: "changeConfigSecret", Method: "PUT", Path: "/api/webhooks/configs/{id}/secret/change", Tags: []string{"webhooks.v1"}}, + }, + Feature("deactivateConfig"): { + APIVersion("v1"): {OperationID: "deactivateConfig", Method: "PUT", Path: "/api/webhooks/configs/{id}/deactivate", Tags: []string{"webhooks.v1"}}, + }, + Feature("deleteConfig"): { + APIVersion("v1"): {OperationID: "deleteConfig", Method: "DELETE", Path: "/api/webhooks/configs/{id}", Tags: []string{"webhooks.v1"}}, + }, + Feature("getManyConfigs"): { + APIVersion("v1"): {OperationID: "getManyConfigs", Method: "GET", Path: "/api/webhooks/configs", Tags: []string{"webhooks.v1"}}, + }, + Feature("insertConfig"): { + APIVersion("v1"): {OperationID: "insertConfig", Method: "POST", Path: "/api/webhooks/configs", Tags: []string{"webhooks.v1"}}, + }, + Feature("testConfig"): { + APIVersion("v1"): {OperationID: "testConfig", Method: "GET", Path: "/api/webhooks/configs/{id}/test", Tags: []string{"webhooks.v1"}}, + }, + Feature("updateConfig"): { + APIVersion("v1"): {OperationID: "updateConfig", Method: "PUT", Path: "/api/webhooks/configs/{id}", Tags: []string{"webhooks.v1"}}, + }, + }, + }, + }, +} diff --git a/v4/internal/capabilities/openapi.go b/v4/internal/capabilities/openapi.go index 9e1366ef..1128ef92 100644 --- a/v4/internal/capabilities/openapi.go +++ b/v4/internal/capabilities/openapi.go @@ -40,7 +40,13 @@ func ParseOpenAPIManifest(reader io.Reader) (Manifest, error) { sort.Strings(methods) for _, method := range methods { - operation := pathItem[method] + if !isHTTPMethod(method) { + continue + } + var operation openAPIOperation + if err := json.Unmarshal(pathItem[method], &operation); err != nil { + return Manifest{}, fmt.Errorf("decode operation %s %s: %w", method, path, err) + } if operation.OperationID == "" { continue } @@ -120,8 +126,8 @@ func canonicalFeature(operationID string) string { } type openAPIDocument struct { - Info openAPIInfo `json:"info"` - Paths map[string]map[string]openAPIOperation `json:"paths"` + Info openAPIInfo `json:"info"` + Paths map[string]map[string]json.RawMessage `json:"paths"` } type openAPIInfo struct { @@ -132,3 +138,12 @@ type openAPIOperation struct { OperationID string `json:"operationId"` Tags []string `json:"tags"` } + +func isHTTPMethod(method string) bool { + switch strings.ToLower(method) { + case "get", "put", "post", "delete", "options", "head", "patch", "trace": + return true + default: + return false + } +} From 50a32ed1aff876e01fd8178836af192732f15787 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:37:05 +0200 Subject: [PATCH 017/208] docs: mark capabilities manifest goal complete --- todos/STATUS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todos/STATUS.md b/todos/STATUS.md index db169436..7371bfe2 100644 --- a/todos/STATUS.md +++ b/todos/STATUS.md @@ -4,7 +4,7 @@ - [x] 02 - v4 foundation packages - [x] 03 - context commands - [x] 04 - auth providers -- [ ] 05 - capabilities manifest generator +- [x] 05 - capabilities manifest generator - [ ] 06 - runtime API version resolver - [ ] 07 - first stack inspection command - [ ] 08 - first Ledger versioned command From 96487c1d5c22fb03c80a6835f1c7749f12a18a48 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:38:06 +0200 Subject: [PATCH 018/208] feat: add api version resolver --- v4/internal/capabilities/resolver.go | 128 ++++++++++++++++++++++ v4/internal/capabilities/resolver_test.go | 65 +++++++++++ 2 files changed, 193 insertions(+) create mode 100644 v4/internal/capabilities/resolver.go create mode 100644 v4/internal/capabilities/resolver_test.go diff --git a/v4/internal/capabilities/resolver.go b/v4/internal/capabilities/resolver.go new file mode 100644 index 00000000..f73ec619 --- /dev/null +++ b/v4/internal/capabilities/resolver.go @@ -0,0 +1,128 @@ +package capabilities + +import ( + "errors" + "fmt" + "strings" +) + +type VersionPolicy string + +const ( + VersionPolicyLatestCompatible VersionPolicy = "latest-compatible" + VersionPolicyPinned VersionPolicy = "pinned" + VersionPolicyLatest VersionPolicy = "latest" +) + +type VersionResolutionRequest struct { + Product Product + Feature Feature + ComponentVersion string + Compatibility ComponentCompatibility + HandlerVersions []APIVersion + Policy VersionPolicy + PinnedVersion APIVersion +} + +type UnsupportedFeatureError struct { + Product Product + Feature Feature + ComponentVersion string + Supported []APIVersion + Handlers []APIVersion + PinnedVersion APIVersion +} + +func (e *UnsupportedFeatureError) Error() string { + if e.PinnedVersion != "" { + return fmt.Sprintf("%s.%s does not support pinned api version %s; target supports %s and command supports %s", + e.Product, e.Feature, e.PinnedVersion, joinAPIVersions(e.Supported), joinAPIVersions(e.Handlers)) + } + return fmt.Sprintf("%s.%s is not supported by component version %s; target supports %s and command supports %s", + e.Product, e.Feature, e.ComponentVersion, joinAPIVersions(e.Supported), joinAPIVersions(e.Handlers)) +} + +func ResolveAPIVersion(request VersionResolutionRequest) (APIVersion, error) { + if request.Policy == "" { + request.Policy = VersionPolicyLatestCompatible + } + if request.Product == "" { + return "", errors.New("product is required") + } + if request.ComponentVersion == "" { + return "", errors.New("component version is required") + } + if len(request.HandlerVersions) == 0 { + return "", errors.New("handler versions are required") + } + + supported, err := request.Compatibility.APIVersionsFor(request.Product, request.ComponentVersion) + if err != nil { + return "", err + } + handlers := UniqueSortedAPIVersions(request.HandlerVersions) + + if request.Policy == VersionPolicyPinned { + if request.PinnedVersion == "" { + return "", errors.New("pinned api version is required") + } + if containsAPIVersion(supported, request.PinnedVersion) && containsAPIVersion(handlers, request.PinnedVersion) { + return request.PinnedVersion, nil + } + return "", &UnsupportedFeatureError{ + Product: request.Product, + Feature: request.Feature, + ComponentVersion: request.ComponentVersion, + Supported: supported, + Handlers: handlers, + PinnedVersion: request.PinnedVersion, + } + } + + intersection := intersectAPIVersions(supported, handlers) + if selected, ok := HighestAPIVersion(intersection); ok { + return selected, nil + } + return "", &UnsupportedFeatureError{ + Product: request.Product, + Feature: request.Feature, + ComponentVersion: request.ComponentVersion, + Supported: supported, + Handlers: handlers, + } +} + +func intersectAPIVersions(a, b []APIVersion) []APIVersion { + seen := map[APIVersion]struct{}{} + for _, version := range a { + seen[version] = struct{}{} + } + var ret []APIVersion + for _, version := range b { + if _, ok := seen[version]; ok { + ret = append(ret, version) + } + } + return UniqueSortedAPIVersions(ret) +} + +func containsAPIVersion(versions []APIVersion, version APIVersion) bool { + for _, candidate := range versions { + if candidate == version { + return true + } + } + return false +} + +func joinAPIVersions(versions []APIVersion) string { + versions = UniqueSortedAPIVersions(versions) + if len(versions) == 0 { + return "" + } + parts := make([]string, len(versions)) + for i, version := range versions { + parts[i] = string(version) + } + return strings.Join(parts, ",") +} diff --git a/v4/internal/capabilities/resolver_test.go b/v4/internal/capabilities/resolver_test.go new file mode 100644 index 00000000..e753236c --- /dev/null +++ b/v4/internal/capabilities/resolver_test.go @@ -0,0 +1,65 @@ +package capabilities + +import ( + "errors" + "testing" +) + +func TestResolveAPIVersionSelectsHighestCompatible(t *testing.T) { + selected, err := ResolveAPIVersion(VersionResolutionRequest{ + Product: "ledger", + Feature: "listTransactions", + ComponentVersion: "2.3.4", + Compatibility: ComponentCompatibility{ + {Product: "ledger", Range: ">=2.0.0 <3.0.0", APIVersions: []APIVersion{"v1", "v2"}}, + }, + HandlerVersions: []APIVersion{"v1", "v2", "v3"}, + Policy: VersionPolicyLatestCompatible, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if selected != "v2" { + t.Fatalf("expected v2, got %q", selected) + } +} + +func TestResolveAPIVersionHonorsPinned(t *testing.T) { + selected, err := ResolveAPIVersion(VersionResolutionRequest{ + Product: "ledger", + Feature: "listTransactions", + ComponentVersion: "2.3.4", + Compatibility: ComponentCompatibility{ + {Product: "ledger", Range: ">=2.0.0 <3.0.0", APIVersions: []APIVersion{"v1", "v2"}}, + }, + HandlerVersions: []APIVersion{"v1", "v2"}, + Policy: VersionPolicyPinned, + PinnedVersion: "v1", + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if selected != "v1" { + t.Fatalf("expected v1, got %q", selected) + } +} + +func TestResolveAPIVersionReturnsUnsupportedFeatureError(t *testing.T) { + _, err := ResolveAPIVersion(VersionResolutionRequest{ + Product: "ledger", + Feature: "explainTransaction", + ComponentVersion: "2.3.4", + Compatibility: ComponentCompatibility{ + {Product: "ledger", Range: ">=2.0.0 <3.0.0", APIVersions: []APIVersion{"v1", "v2"}}, + }, + HandlerVersions: []APIVersion{"v3"}, + Policy: VersionPolicyLatestCompatible, + }) + if err == nil { + t.Fatal("expected unsupported feature error") + } + var unsupported *UnsupportedFeatureError + if !errors.As(err, &unsupported) { + t.Fatalf("expected UnsupportedFeatureError, got %T %v", err, err) + } +} From 91bab20b6452f884bf2ab13817abf384231ebbba Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:39:54 +0200 Subject: [PATCH 019/208] feat: resolve api versions in runtime --- v4/internal/runtime/runtime.go | 66 ++++++++++++++++++++----- v4/internal/runtime/runtime_test.go | 65 +++++++++++++++++++++++++ v4/internal/runtime/versions.go | 75 +++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 12 deletions(-) create mode 100644 v4/internal/runtime/versions.go diff --git a/v4/internal/runtime/runtime.go b/v4/internal/runtime/runtime.go index 91d95e85..4217589a 100644 --- a/v4/internal/runtime/runtime.go +++ b/v4/internal/runtime/runtime.go @@ -27,6 +27,7 @@ type Options struct { ContextOverride config.ContextOverride Credentials credentials.Store Auth auth.Options + VersionsClient VersionsClient Manifest capabilities.Manifest Compatibility capabilities.ComponentCompatibility } @@ -37,10 +38,11 @@ type Runtime struct { Context config.Context Target Target - Credentials credentials.Store - AuthOptions auth.Options - Manifest capabilities.Manifest - Compatibility capabilities.ComponentCompatibility + Credentials credentials.Store + AuthOptions auth.Options + VersionsClient VersionsClient + Manifest capabilities.Manifest + Compatibility capabilities.ComponentCompatibility } type Target struct { @@ -74,14 +76,15 @@ func New(ctx context.Context, options Options) (*Runtime, error) { } return &Runtime{ - Config: cfg, - ContextName: contextName, - Context: selectedContext, - Target: target, - Credentials: options.Credentials, - AuthOptions: options.Auth, - Manifest: options.Manifest, - Compatibility: options.Compatibility, + Config: cfg, + ContextName: contextName, + Context: selectedContext, + Target: target, + Credentials: options.Credentials, + AuthOptions: options.Auth, + VersionsClient: options.VersionsClient, + Manifest: options.Manifest, + Compatibility: options.Compatibility, }, nil } @@ -125,3 +128,42 @@ func (r *Runtime) HTTPClient(ctx context.Context) (*http.Client, error) { } return auth.NewHTTPClient(ctx, r.Context.Auth, r.Credentials, r.AuthOptions) } + +func (r *Runtime) ComponentVersions(ctx context.Context) ([]capabilities.ComponentVersion, error) { + client := r.VersionsClient + if client == nil { + httpClient, err := r.HTTPClient(ctx) + if err != nil { + return nil, err + } + client = HTTPVersionsClient{ + BaseURL: r.Target.URL, + HTTPClient: httpClient, + } + } + return client.GetVersions(ctx) +} + +func (r *Runtime) ResolveAPIVersion(ctx context.Context, request capabilities.VersionResolutionRequest) (capabilities.APIVersion, error) { + if request.Compatibility == nil { + request.Compatibility = r.Compatibility + } + if request.Compatibility == nil { + request.Compatibility = capabilities.DefaultComponentCompatibility + } + if request.Policy == "" { + request.Policy = capabilities.VersionPolicy(r.APIPolicyFor(request.Product)) + } + if request.ComponentVersion == "" { + versions, err := r.ComponentVersions(ctx) + if err != nil { + return "", err + } + componentVersion, ok := componentVersionFor(versions, request.Product) + if !ok { + return "", fmt.Errorf("component version for %s not found", request.Product) + } + request.ComponentVersion = componentVersion.Version + } + return capabilities.ResolveAPIVersion(request) +} diff --git a/v4/internal/runtime/runtime_test.go b/v4/internal/runtime/runtime_test.go index 8e49db2b..50f0a33e 100644 --- a/v4/internal/runtime/runtime_test.go +++ b/v4/internal/runtime/runtime_test.go @@ -2,6 +2,7 @@ package runtime import ( "context" + "fmt" "net/http" "net/http/httptest" "path/filepath" @@ -140,6 +141,70 @@ func TestHTTPClientUsesContextAuth(t *testing.T) { } } +func TestHTTPVersionsClientParsesVersions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/versions" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + fmt.Fprint(w, `{"versions":[{"name":"ledger","version":"2.3.4","health":true}]}`) + })) + defer server.Close() + + versions, err := HTTPVersionsClient{BaseURL: server.URL + "/api"}.GetVersions(context.Background()) + if err != nil { + t.Fatalf("get versions: %v", err) + } + if len(versions) != 1 || versions[0].Product != "ledger" || versions[0].Version != "2.3.4" || !versions[0].Health { + t.Fatalf("unexpected versions: %#v", versions) + } +} + +func TestResolveAPIVersionFetchesComponentVersion(t *testing.T) { + configPath := writeRuntimeConfig(t, config.Config{ + Version: config.Version, + CurrentContext: "local", + Contexts: map[string]config.Context{ + "local": { + Kind: config.ContextKindStack, + StackURL: "http://localhost/api", + Auth: config.Auth{Method: config.AuthMethodNone}, + }, + }, + }) + rt, err := New(context.Background(), Options{ + ConfigPath: configPath, + VersionsClient: staticVersionsClient{versions: []capabilities.ComponentVersion{ + {Product: "ledger", Version: "2.3.4", Health: true}, + }}, + Compatibility: capabilities.ComponentCompatibility{ + {Product: "ledger", Range: ">=2.0.0 <3.0.0", APIVersions: []capabilities.APIVersion{"v1", "v2"}}, + }, + }) + if err != nil { + t.Fatalf("new runtime: %v", err) + } + + selected, err := rt.ResolveAPIVersion(context.Background(), capabilities.VersionResolutionRequest{ + Product: "ledger", + Feature: "listTransactions", + HandlerVersions: []capabilities.APIVersion{"v1", "v2", "v3"}, + }) + if err != nil { + t.Fatalf("resolve api version: %v", err) + } + if selected != "v2" { + t.Fatalf("expected v2, got %q", selected) + } +} + +type staticVersionsClient struct { + versions []capabilities.ComponentVersion +} + +func (c staticVersionsClient) GetVersions(context.Context) ([]capabilities.ComponentVersion, error) { + return c.versions, nil +} + func writeRuntimeConfig(t *testing.T, cfg config.Config) string { t.Helper() path := filepath.Join(t.TempDir(), "config.yaml") diff --git a/v4/internal/runtime/versions.go b/v4/internal/runtime/versions.go new file mode 100644 index 00000000..72907295 --- /dev/null +++ b/v4/internal/runtime/versions.go @@ -0,0 +1,75 @@ +package runtime + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/formancehq/fctl/v4/internal/capabilities" +) + +type VersionsClient interface { + GetVersions(ctx context.Context) ([]capabilities.ComponentVersion, error) +} + +type HTTPVersionsClient struct { + BaseURL string + HTTPClient *http.Client +} + +func (c HTTPVersionsClient) GetVersions(ctx context.Context) ([]capabilities.ComponentVersion, error) { + httpClient := c.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + endpoint, err := url.JoinPath(c.BaseURL, "versions") + if err != nil { + return nil, fmt.Errorf("build versions url: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + rsp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer rsp.Body.Close() + if rsp.StatusCode < 200 || rsp.StatusCode >= 300 { + return nil, fmt.Errorf("get versions failed: status %d", rsp.StatusCode) + } + + var response versionsResponse + if err := json.NewDecoder(rsp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("decode versions response: %w", err) + } + + versions := make([]capabilities.ComponentVersion, 0, len(response.Versions)) + for _, version := range response.Versions { + versions = append(versions, capabilities.ComponentVersion{ + Product: capabilities.Product(version.Name), + Version: version.Version, + Health: version.Health, + }) + } + return versions, nil +} + +type versionsResponse struct { + Versions []struct { + Name string `json:"name"` + Version string `json:"version"` + Health bool `json:"health"` + } `json:"versions"` +} + +func componentVersionFor(versions []capabilities.ComponentVersion, product capabilities.Product) (capabilities.ComponentVersion, bool) { + for _, version := range versions { + if version.Product == product { + return version, true + } + } + return capabilities.ComponentVersion{}, false +} From 25715dd946398edde0995cb8c5826504ee207ed4 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:40:15 +0200 Subject: [PATCH 020/208] docs: mark api resolver goal complete --- todos/STATUS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todos/STATUS.md b/todos/STATUS.md index 7371bfe2..6f36d0fc 100644 --- a/todos/STATUS.md +++ b/todos/STATUS.md @@ -5,7 +5,7 @@ - [x] 03 - context commands - [x] 04 - auth providers - [x] 05 - capabilities manifest generator -- [ ] 06 - runtime API version resolver +- [x] 06 - runtime API version resolver - [ ] 07 - first stack inspection command - [ ] 08 - first Ledger versioned command - [ ] 09 - v3 config migration From 46cca8202623a178ba216ba2d1d72dbca6c010b7 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:41:44 +0200 Subject: [PATCH 021/208] feat: add v4 target inspect command --- v4/cmd/root.go | 1 + v4/cmd/root_test.go | 70 +++++++++++++++++++++++++++ v4/cmd/runtime.go | 29 ++++++++++++ v4/cmd/target.go | 112 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+) create mode 100644 v4/cmd/runtime.go create mode 100644 v4/cmd/target.go diff --git a/v4/cmd/root.go b/v4/cmd/root.go index 5bd46d77..d5170eef 100644 --- a/v4/cmd/root.go +++ b/v4/cmd/root.go @@ -41,6 +41,7 @@ func NewRootCommand(version string) *cobra.Command { root.AddCommand(newVersionCommand()) root.AddCommand(newContextCommand()) + root.AddCommand(newTargetCommand()) return root } diff --git a/v4/cmd/root_test.go b/v4/cmd/root_test.go index 44d5fe19..be598091 100644 --- a/v4/cmd/root_test.go +++ b/v4/cmd/root_test.go @@ -2,6 +2,9 @@ package cmd import ( "bytes" + "fmt" + "net/http" + "net/http/httptest" "path/filepath" "strings" "testing" @@ -155,3 +158,70 @@ func TestContextCommandsJSON(t *testing.T) { } } } + +func TestTargetInspect(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/versions" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + fmt.Fprint(w, `{"versions":[{"name":"ledger","version":"2.3.4","health":true}]}`) + })) + defer server.Close() + + configDir := t.TempDir() + _, stderr, err := executeCommand(t, + "--config-dir", configDir, + "context", "create", "stack", "local", + "--stack-url", server.URL+"/api", + ) + if err != nil { + t.Fatalf("create context: %v stderr=%s", err, stderr) + } + + stdout, stderr, err := executeCommand(t, "--config-dir", configDir, "target", "inspect") + if err != nil { + t.Fatalf("inspect target: %v stderr=%s", err, stderr) + } + for _, expected := range []string{ + "Context: local", + "Target: " + server.URL + "/api (stack)", + "- ledger 2.3.4 healthy api=[v1 v2] policy=latest-compatible", + } { + if !strings.Contains(stdout, expected) { + t.Fatalf("expected inspect output to contain %q, got:\n%s", expected, stdout) + } + } +} + +func TestTargetInspectJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"versions":[{"name":"ledger","version":"2.3.4","health":true}]}`) + })) + defer server.Close() + + configDir := t.TempDir() + _, stderr, err := executeCommand(t, + "--config-dir", configDir, + "context", "create", "stack", "local", + "--stack-url", server.URL+"/api", + ) + if err != nil { + t.Fatalf("create context: %v stderr=%s", err, stderr) + } + + stdout, stderr, err := executeCommand(t, "--config-dir", configDir, "-o", "json", "target", "inspect") + if err != nil { + t.Fatalf("inspect target json: %v stderr=%s", err, stderr) + } + for _, expected := range []string{ + `"context": "local"`, + `"targetKind": "stack"`, + `"name": "ledger"`, + `"apiVersions": [`, + `"v2"`, + } { + if !strings.Contains(stdout, expected) { + t.Fatalf("expected JSON inspect output to contain %q, got:\n%s", expected, stdout) + } + } +} diff --git a/v4/cmd/runtime.go b/v4/cmd/runtime.go new file mode 100644 index 00000000..9d577c26 --- /dev/null +++ b/v4/cmd/runtime.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/capabilities" + v4config "github.com/formancehq/fctl/v4/internal/config" + "github.com/formancehq/fctl/v4/internal/credentials" + "github.com/formancehq/fctl/v4/internal/runtime" +) + +func runtimeFromCommand(cmd *cobra.Command) (*runtime.Runtime, error) { + path, err := configPath(cmd) + if err != nil { + return nil, err + } + contextName, err := cmd.Root().PersistentFlags().GetString(contextFlag) + if err != nil { + return nil, err + } + + return runtime.New(cmd.Context(), runtime.Options{ + ConfigPath: path, + ContextOverride: v4config.ContextOverride{Name: contextName}, + Credentials: credentials.NewMemoryStore(), + Manifest: capabilities.GeneratedManifest, + Compatibility: capabilities.DefaultComponentCompatibility, + }) +} diff --git a/v4/cmd/target.go b/v4/cmd/target.go new file mode 100644 index 00000000..0c24ba2b --- /dev/null +++ b/v4/cmd/target.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/capabilities" + "github.com/formancehq/fctl/v4/internal/render" +) + +func newTargetCommand() *cobra.Command { + command := &cobra.Command{ + Use: "target", + Short: "Inspect the active fctl v4 target", + } + command.AddCommand(newTargetInspectCommand()) + return command +} + +func newTargetInspectCommand() *cobra.Command { + return &cobra.Command{ + Use: "inspect", + Short: "Inspect the current target and inferred capabilities", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + rt, err := runtimeFromCommand(cmd) + if err != nil { + return err + } + versions, err := rt.ComponentVersions(cmd.Context()) + if err != nil { + return err + } + + components := make([]targetInspectComponent, 0, len(versions)) + for _, version := range versions { + apiVersions, _ := rt.Compatibility.APIVersionsFor(version.Product, version.Version) + components = append(components, targetInspectComponent{ + Name: string(version.Product), + Version: version.Version, + Health: version.Health, + APIVersions: apiVersionsToStrings(apiVersions), + APIPolicy: string(rt.APIPolicyFor(version.Product)), + }) + } + output := targetInspectOutput{ + Context: rt.ContextName, + TargetURL: rt.Target.URL, + TargetKind: string(rt.Target.Kind), + Components: components, + } + + format, err := outputFormat(cmd) + if err != nil { + return err + } + if format == "json" { + return render.JSON(cmd.OutOrStdout(), output) + } + + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Context: %s\nTarget: %s (%s)\n", output.Context, output.TargetURL, output.TargetKind); err != nil { + return err + } + if len(output.Components) == 0 { + _, err := fmt.Fprintln(cmd.OutOrStdout(), "Components: none") + return err + } + if _, err := fmt.Fprintln(cmd.OutOrStdout(), "Components:"); err != nil { + return err + } + for _, component := range output.Components { + health := "unhealthy" + if component.Health { + health = "healthy" + } + apiVersions := "" + if len(component.APIVersions) > 0 { + apiVersions = fmt.Sprintf("%v", component.APIVersions) + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "- %s %s %s api=%s policy=%s\n", + component.Name, component.Version, health, apiVersions, component.APIPolicy); err != nil { + return err + } + } + return nil + }, + } +} + +type targetInspectOutput struct { + Context string `json:"context"` + TargetURL string `json:"targetUrl"` + TargetKind string `json:"targetKind"` + Components []targetInspectComponent `json:"components"` +} + +type targetInspectComponent struct { + Name string `json:"name"` + Version string `json:"version"` + Health bool `json:"health"` + APIVersions []string `json:"apiVersions"` + APIPolicy string `json:"apiPolicy"` +} + +func apiVersionsToStrings(versions []capabilities.APIVersion) []string { + ret := make([]string, len(versions)) + for i, version := range versions { + ret[i] = string(version) + } + return ret +} From 5a068cdb4c47a81dcfb635a00e45011eef7875db Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:42:02 +0200 Subject: [PATCH 022/208] docs: mark target inspect goal complete --- todos/STATUS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todos/STATUS.md b/todos/STATUS.md index 6f36d0fc..0614ddfa 100644 --- a/todos/STATUS.md +++ b/todos/STATUS.md @@ -6,7 +6,7 @@ - [x] 04 - auth providers - [x] 05 - capabilities manifest generator - [x] 06 - runtime API version resolver -- [ ] 07 - first stack inspection command +- [x] 07 - first stack inspection command - [ ] 08 - first Ledger versioned command - [ ] 09 - v3 config migration - [ ] 10 - integration tests and UX hardening From 69540515a49b70e4e72e7cf7db00133464a8001d Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:44:02 +0200 Subject: [PATCH 023/208] feat: add ledger transaction list service --- v4/go.mod | 2 + v4/go.sum | 10 + .../commands/ledger/list_transactions.go | 225 ++++++++++++++++++ .../commands/ledger/list_transactions_test.go | 90 +++++++ 4 files changed, 327 insertions(+) create mode 100644 v4/internal/commands/ledger/list_transactions.go create mode 100644 v4/internal/commands/ledger/list_transactions_test.go diff --git a/v4/go.mod b/v4/go.mod index 8533bcae..210b830a 100644 --- a/v4/go.mod +++ b/v4/go.mod @@ -5,6 +5,7 @@ go 1.25.0 toolchain go1.25.7 require ( + github.com/formancehq/formance-sdk-go/v3 v3.8.1 github.com/spf13/cobra v1.10.2 golang.org/x/mod v0.36.0 gopkg.in/yaml.v3 v3.0.1 @@ -13,4 +14,5 @@ require ( require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sync v0.8.0 // indirect ) diff --git a/v4/go.sum b/v4/go.sum index a0315ef4..a7b39c26 100644 --- a/v4/go.sum +++ b/v4/go.sum @@ -1,14 +1,24 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/formancehq/formance-sdk-go/v3 v3.8.1 h1:nKWcpZT70vegEUV+/Xl/wekiI1PsCFVe7mvGgwaA9fk= +github.com/formancehq/formance-sdk-go/v3 v3.8.1/go.mod h1:2Kb2Z4bN8/I4MQQnuSilvThqeaUtuLaTdmMGIb3nMJY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/v4/internal/commands/ledger/list_transactions.go b/v4/internal/commands/ledger/list_transactions.go new file mode 100644 index 00000000..20667e1f --- /dev/null +++ b/v4/internal/commands/ledger/list_transactions.go @@ -0,0 +1,225 @@ +package ledger + +import ( + "context" + "fmt" + "math/big" + "time" + + formance "github.com/formancehq/formance-sdk-go/v3" + "github.com/formancehq/formance-sdk-go/v3/pkg/models/operations" + "github.com/formancehq/formance-sdk-go/v3/pkg/models/shared" + + "github.com/formancehq/fctl/v4/internal/capabilities" +) + +const ( + ProductLedger capabilities.Product = "ledger" + FeatureListTransactions capabilities.Feature = "listTransactions" +) + +type ListTransactionsInput struct { + Ledger string + PageSize int64 + Cursor string + Account string + Source string + Destination string + Reference string +} + +type ListTransactionsOutput struct { + APIVersion capabilities.APIVersion `json:"apiVersion"` + Transactions []TransactionSummary `json:"transactions"` + HasMore bool `json:"hasMore"` + PageSize int64 `json:"pageSize"` + Next *string `json:"next,omitempty"` + Previous *string `json:"previous,omitempty"` +} + +type TransactionSummary struct { + ID string `json:"id"` + Reference *string `json:"reference,omitempty"` + Timestamp time.Time `json:"timestamp"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type ListTransactionsHandler struct { + APIVersion capabilities.APIVersion + Run func(context.Context, ListTransactionsInput) (ListTransactionsOutput, error) +} + +type ListTransactionsService struct { + Handlers []ListTransactionsHandler + Resolve func(context.Context, []capabilities.APIVersion) (capabilities.APIVersion, error) +} + +func (s ListTransactionsService) Run(ctx context.Context, input ListTransactionsInput) (ListTransactionsOutput, error) { + if input.Ledger == "" { + return ListTransactionsOutput{}, fmt.Errorf("ledger is required") + } + + handlerVersions := make([]capabilities.APIVersion, 0, len(s.Handlers)) + handlers := map[capabilities.APIVersion]ListTransactionsHandler{} + for _, handler := range s.Handlers { + handlerVersions = append(handlerVersions, handler.APIVersion) + handlers[handler.APIVersion] = handler + } + + selected, err := s.Resolve(ctx, handlerVersions) + if err != nil { + return ListTransactionsOutput{}, err + } + handler, ok := handlers[selected] + if !ok { + return ListTransactionsOutput{}, fmt.Errorf("resolved api version %s has no handler", selected) + } + + output, err := handler.Run(ctx, input) + if err != nil { + return ListTransactionsOutput{}, err + } + output.APIVersion = selected + return output, nil +} + +func SDKListTransactionsHandlers(sdk *formance.Formance) []ListTransactionsHandler { + return []ListTransactionsHandler{ + { + APIVersion: "v1", + Run: func(ctx context.Context, input ListTransactionsInput) (ListTransactionsOutput, error) { + response, err := sdk.Ledger.V1.ListTransactions(ctx, toV1ListTransactionsRequest(input)) + if err != nil { + return ListTransactionsOutput{}, err + } + if response.TransactionsCursorResponse == nil { + return ListTransactionsOutput{}, fmt.Errorf("ledger v1 list transactions returned no cursor") + } + return fromV1ListTransactions(response.TransactionsCursorResponse.Cursor), nil + }, + }, + { + APIVersion: "v2", + Run: func(ctx context.Context, input ListTransactionsInput) (ListTransactionsOutput, error) { + response, err := sdk.Ledger.V2.ListTransactions(ctx, toV2ListTransactionsRequest(input)) + if err != nil { + return ListTransactionsOutput{}, err + } + if response.V2TransactionsCursorResponse == nil { + return ListTransactionsOutput{}, fmt.Errorf("ledger v2 list transactions returned no cursor") + } + return fromV2ListTransactions(response.V2TransactionsCursorResponse.Cursor), nil + }, + }, + } +} + +func toV1ListTransactionsRequest(input ListTransactionsInput) operations.ListTransactionsRequest { + request := operations.ListTransactionsRequest{ + Ledger: input.Ledger, + PageSize: pointer(input.PageSize), + } + if input.Cursor != "" { + request.Cursor = pointer(input.Cursor) + } + if input.Account != "" { + request.Account = pointer(input.Account) + } + if input.Source != "" { + request.Source = pointer(input.Source) + } + if input.Destination != "" { + request.Destination = pointer(input.Destination) + } + if input.Reference != "" { + request.Reference = pointer(input.Reference) + } + return request +} + +func toV2ListTransactionsRequest(input ListTransactionsInput) operations.V2ListTransactionsRequest { + request := operations.V2ListTransactionsRequest{ + Ledger: input.Ledger, + PageSize: pointer(input.PageSize), + } + if input.Cursor != "" { + request.Cursor = pointer(input.Cursor) + } + query := map[string]any{} + if input.Account != "" { + query["account"] = input.Account + } + if input.Source != "" { + query["source"] = input.Source + } + if input.Destination != "" { + query["destination"] = input.Destination + } + if input.Reference != "" { + query["reference"] = input.Reference + } + if len(query) > 0 { + request.Query = query + } + return request +} + +func fromV1ListTransactions(cursor shared.TransactionsCursorResponseCursor) ListTransactionsOutput { + transactions := make([]TransactionSummary, 0, len(cursor.Data)) + for _, transaction := range cursor.Data { + transactions = append(transactions, TransactionSummary{ + ID: bigIntString(transaction.Txid), + Reference: transaction.Reference, + Timestamp: transaction.Timestamp, + Metadata: transaction.Metadata, + }) + } + return ListTransactionsOutput{ + Transactions: transactions, + HasMore: cursor.HasMore, + PageSize: cursor.PageSize, + Next: cursor.Next, + Previous: cursor.Previous, + } +} + +func fromV2ListTransactions(cursor shared.V2TransactionsCursorResponseCursor) ListTransactionsOutput { + transactions := make([]TransactionSummary, 0, len(cursor.Data)) + for _, transaction := range cursor.Data { + transactions = append(transactions, TransactionSummary{ + ID: bigIntString(transaction.ID), + Reference: transaction.Reference, + Timestamp: transaction.Timestamp, + Metadata: stringMapToAny(transaction.Metadata), + }) + } + return ListTransactionsOutput{ + Transactions: transactions, + HasMore: cursor.HasMore, + PageSize: cursor.PageSize, + Next: cursor.Next, + Previous: cursor.Previous, + } +} + +func pointer[T any](value T) *T { + return &value +} + +func bigIntString(value *big.Int) string { + if value == nil { + return "" + } + return value.String() +} + +func stringMapToAny(values map[string]string) map[string]any { + if len(values) == 0 { + return nil + } + ret := make(map[string]any, len(values)) + for key, value := range values { + ret[key] = value + } + return ret +} diff --git a/v4/internal/commands/ledger/list_transactions_test.go b/v4/internal/commands/ledger/list_transactions_test.go new file mode 100644 index 00000000..bf9d4447 --- /dev/null +++ b/v4/internal/commands/ledger/list_transactions_test.go @@ -0,0 +1,90 @@ +package ledger + +import ( + "context" + "errors" + "testing" + + "github.com/formancehq/fctl/v4/internal/capabilities" +) + +func TestListTransactionsServiceSelectsResolvedHandler(t *testing.T) { + service := ListTransactionsService{ + Handlers: []ListTransactionsHandler{ + { + APIVersion: "v1", + Run: func(context.Context, ListTransactionsInput) (ListTransactionsOutput, error) { + t.Fatal("v1 handler should not run") + return ListTransactionsOutput{}, nil + }, + }, + { + APIVersion: "v2", + Run: func(_ context.Context, input ListTransactionsInput) (ListTransactionsOutput, error) { + if input.Ledger != "default" { + t.Fatalf("unexpected ledger %q", input.Ledger) + } + return ListTransactionsOutput{PageSize: input.PageSize}, nil + }, + }, + }, + Resolve: func(_ context.Context, versions []capabilities.APIVersion) (capabilities.APIVersion, error) { + assertAPIVersions(t, versions, []capabilities.APIVersion{"v1", "v2"}) + return "v2", nil + }, + } + + output, err := service.Run(context.Background(), ListTransactionsInput{Ledger: "default", PageSize: 10}) + if err != nil { + t.Fatalf("run service: %v", err) + } + if output.APIVersion != "v2" || output.PageSize != 10 { + t.Fatalf("unexpected output: %#v", output) + } +} + +func TestListTransactionsServiceReturnsResolverError(t *testing.T) { + expected := errors.New("unsupported") + service := ListTransactionsService{ + Handlers: []ListTransactionsHandler{{APIVersion: "v3", Run: nil}}, + Resolve: func(context.Context, []capabilities.APIVersion) (capabilities.APIVersion, error) { + return "", expected + }, + } + + _, err := service.Run(context.Background(), ListTransactionsInput{Ledger: "default"}) + if !errors.Is(err, expected) { + t.Fatalf("expected resolver error, got %v", err) + } +} + +func TestToV2ListTransactionsRequestMapsCanonicalFilters(t *testing.T) { + request := toV2ListTransactionsRequest(ListTransactionsInput{ + Ledger: "default", + PageSize: 10, + Account: "users:123", + Source: "world", + Destination: "users:123", + Reference: "ref", + }) + + if request.Ledger != "default" || *request.PageSize != 10 { + t.Fatalf("unexpected base request: %#v", request) + } + if request.Query["account"] != "users:123" || request.Query["source"] != "world" || + request.Query["destination"] != "users:123" || request.Query["reference"] != "ref" { + t.Fatalf("unexpected query mapping: %#v", request.Query) + } +} + +func assertAPIVersions(t *testing.T, got []capabilities.APIVersion, want []capabilities.APIVersion) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("expected versions %v, got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("expected versions %v, got %v", want, got) + } + } +} From bac6423bdde688f8f094309386ae3b9b059d89d5 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:45:43 +0200 Subject: [PATCH 024/208] feat: wire v4 ledger transaction list command --- v4/cmd/ledger.go | 141 ++++++++++++++++++++++++++++++++++++++++++++ v4/cmd/root.go | 1 + v4/cmd/root_test.go | 83 ++++++++++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 v4/cmd/ledger.go diff --git a/v4/cmd/ledger.go b/v4/cmd/ledger.go new file mode 100644 index 00000000..eb9db7e0 --- /dev/null +++ b/v4/cmd/ledger.go @@ -0,0 +1,141 @@ +package cmd + +import ( + "context" + "fmt" + "time" + + formance "github.com/formancehq/formance-sdk-go/v3" + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/capabilities" + ledgercmd "github.com/formancehq/fctl/v4/internal/commands/ledger" + "github.com/formancehq/fctl/v4/internal/render" +) + +func newLedgerCommand() *cobra.Command { + command := &cobra.Command{ + Use: "ledger", + Short: "Manage ledgers", + } + command.AddCommand(newLedgerTransactionsCommand()) + return command +} + +func newLedgerTransactionsCommand() *cobra.Command { + command := &cobra.Command{ + Use: "transactions", + Short: "Manage ledger transactions", + } + command.AddCommand(newLedgerTransactionsListCommand()) + return command +} + +func newLedgerTransactionsListCommand() *cobra.Command { + var ledger string + var pageSize int64 + var cursor string + var account string + var source string + var destination string + var reference string + var apiVersion string + + command := &cobra.Command{ + Use: "list", + Short: "List ledger transactions", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + rt, err := runtimeFromCommand(cmd) + if err != nil { + return err + } + if ledger == "" { + ledger = rt.Context.Defaults["ledger"] + } + if ledger == "" { + ledger = "default" + } + + httpClient, err := rt.HTTPClient(cmd.Context()) + if err != nil { + return err + } + sdk := formance.New( + formance.WithServerURL(rt.Target.URL), + formance.WithClient(httpClient), + ) + service := ledgercmd.ListTransactionsService{ + Handlers: ledgercmd.SDKListTransactionsHandlers(sdk), + Resolve: func(ctx context.Context, handlerVersions []capabilities.APIVersion) (capabilities.APIVersion, error) { + request := capabilities.VersionResolutionRequest{ + Product: ledgercmd.ProductLedger, + Feature: ledgercmd.FeatureListTransactions, + HandlerVersions: handlerVersions, + } + if apiVersion != "" { + request.Policy = capabilities.VersionPolicyPinned + request.PinnedVersion = capabilities.APIVersion(apiVersion) + } + return rt.ResolveAPIVersion(ctx, request) + }, + } + output, err := service.Run(cmd.Context(), ledgercmd.ListTransactionsInput{ + Ledger: ledger, + PageSize: pageSize, + Cursor: cursor, + Account: account, + Source: source, + Destination: destination, + Reference: reference, + }) + if err != nil { + return err + } + + format, err := outputFormat(cmd) + if err != nil { + return err + } + if format == "json" { + return render.JSON(cmd.OutOrStdout(), output) + } + return renderLedgerTransactions(cmd, output) + }, + } + + command.Flags().StringVar(&ledger, "ledger", "", "Ledger name") + command.Flags().Int64Var(&pageSize, "page-size", 15, "Page size") + command.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor") + command.Flags().StringVar(&account, "account", "", "Filter by account") + command.Flags().StringVar(&source, "src", "", "Filter by source account") + command.Flags().StringVar(&destination, "dst", "", "Filter by destination account") + command.Flags().StringVar(&reference, "reference", "", "Filter by reference") + command.Flags().StringVar(&apiVersion, "api-version", "", "Pin ledger API version") + + return command +} + +func renderLedgerTransactions(cmd *cobra.Command, output ledgercmd.ListTransactionsOutput) error { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "API version: %s\n", output.APIVersion); err != nil { + return err + } + if len(output.Transactions) == 0 { + _, err := fmt.Fprintln(cmd.OutOrStdout(), "No transactions found.") + return err + } + for _, transaction := range output.Transactions { + reference := "" + if transaction.Reference != nil { + reference = *transaction.Reference + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\n", + transaction.ID, + reference, + transaction.Timestamp.Format(time.RFC3339), + ); err != nil { + return err + } + } + return nil +} diff --git a/v4/cmd/root.go b/v4/cmd/root.go index d5170eef..32e2d6d4 100644 --- a/v4/cmd/root.go +++ b/v4/cmd/root.go @@ -42,6 +42,7 @@ func NewRootCommand(version string) *cobra.Command { root.AddCommand(newVersionCommand()) root.AddCommand(newContextCommand()) root.AddCommand(newTargetCommand()) + root.AddCommand(newLedgerCommand()) return root } diff --git a/v4/cmd/root_test.go b/v4/cmd/root_test.go index be598091..70a692ba 100644 --- a/v4/cmd/root_test.go +++ b/v4/cmd/root_test.go @@ -225,3 +225,86 @@ func TestTargetInspectJSON(t *testing.T) { } } } + +func TestLedgerTransactionsListSelectsV2(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/versions": + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"versions":[{"name":"ledger","version":"2.3.4","health":true}]}`) + case "/api/ledger/v2/default/transactions": + if got := r.URL.Query().Get("pageSize"); got != "15" { + t.Fatalf("expected pageSize 15, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"cursor":{"data":[{"id":1,"metadata":{"foo":"bar"},"postings":[],"reverted":false,"timestamp":"2026-01-01T00:00:00Z","reference":"ref"}],"hasMore":false,"pageSize":15}}`) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + configDir := t.TempDir() + _, stderr, err := executeCommand(t, + "--config-dir", configDir, + "context", "create", "stack", "local", + "--stack-url", server.URL, + "--default-ledger", "default", + ) + if err != nil { + t.Fatalf("create context: %v stderr=%s", err, stderr) + } + + stdout, stderr, err := executeCommand(t, "--config-dir", configDir, "ledger", "transactions", "list") + if err != nil { + t.Fatalf("list transactions: %v stderr=%s", err, stderr) + } + for _, expected := range []string{ + "API version: v2", + "1\tref\t2026-01-01T00:00:00Z", + } { + if !strings.Contains(stdout, expected) { + t.Fatalf("expected ledger output to contain %q, got:\n%s", expected, stdout) + } + } +} + +func TestLedgerTransactionsListJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/versions": + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"versions":[{"name":"ledger","version":"2.3.4","health":true}]}`) + case "/api/ledger/v2/default/transactions": + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"cursor":{"data":[],"hasMore":false,"pageSize":15}}`) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + configDir := t.TempDir() + _, stderr, err := executeCommand(t, + "--config-dir", configDir, + "context", "create", "stack", "local", + "--stack-url", server.URL, + ) + if err != nil { + t.Fatalf("create context: %v stderr=%s", err, stderr) + } + + stdout, stderr, err := executeCommand(t, "--config-dir", configDir, "-o", "json", "ledger", "transactions", "list") + if err != nil { + t.Fatalf("list transactions json: %v stderr=%s", err, stderr) + } + for _, expected := range []string{ + `"apiVersion": "v2"`, + `"transactions": []`, + `"pageSize": 15`, + } { + if !strings.Contains(stdout, expected) { + t.Fatalf("expected ledger JSON output to contain %q, got:\n%s", expected, stdout) + } + } +} From 75ebe96cc9592feec94b012b3d6581987ac19142 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:46:08 +0200 Subject: [PATCH 025/208] docs: mark first ledger command goal complete --- todos/STATUS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todos/STATUS.md b/todos/STATUS.md index 0614ddfa..6241d3e6 100644 --- a/todos/STATUS.md +++ b/todos/STATUS.md @@ -7,7 +7,7 @@ - [x] 05 - capabilities manifest generator - [x] 06 - runtime API version resolver - [x] 07 - first stack inspection command -- [ ] 08 - first Ledger versioned command +- [x] 08 - first Ledger versioned command - [ ] 09 - v3 config migration - [ ] 10 - integration tests and UX hardening - [ ] 11 - v4 cutover plan From 3fe30f28bc05a0e8cb44ce0fafc9c59ee2c7d991 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:48:04 +0200 Subject: [PATCH 026/208] feat: add v3 migration planner --- v4/internal/config/v3migration.go | 159 +++++++++++++++++++++++++ v4/internal/config/v3migration_test.go | 104 ++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 v4/internal/config/v3migration.go create mode 100644 v4/internal/config/v3migration_test.go diff --git a/v4/internal/config/v3migration.go b/v4/internal/config/v3migration.go new file mode 100644 index 00000000..1fffd813 --- /dev/null +++ b/v4/internal/config/v3migration.go @@ -0,0 +1,159 @@ +package config + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" + + "github.com/formancehq/fctl/v4/internal/credentials" +) + +const DefaultCloudURL = "https://app.formance.cloud/api" + +type V3State struct { + Config V3Config + Profiles map[string]V3Profile +} + +type V3Config struct { + CurrentProfile string `json:"currentProfile" yaml:"currentProfile"` +} + +type V3Profile struct { + MembershipURI string `json:"membershipURI" yaml:"membershipURI"` + RootTokens json.RawMessage `json:"rootTokens" yaml:"rootTokens"` + DefaultOrganization string `json:"defaultOrganization" yaml:"defaultOrganization"` + DefaultStack string `json:"defaultStack" yaml:"defaultStack"` +} + +type MigrationPlan struct { + CurrentContext string + Contexts map[string]Context + CredentialMoves []CredentialMove +} + +type CredentialMove struct { + Profile string + Ref string + Value string +} + +func LoadV3State(dir string) (V3State, error) { + if dir == "" { + return V3State{}, errors.New("v3 config directory is required") + } + + configBytes, err := os.ReadFile(filepath.Join(dir, "config.yml")) + if err != nil { + return V3State{}, fmt.Errorf("read v3 config: %w", err) + } + var v3Config V3Config + if err := yaml.Unmarshal(configBytes, &v3Config); err != nil { + return V3State{}, fmt.Errorf("parse v3 config: %w", err) + } + + profilesDir := filepath.Join(dir, "profiles") + entries, err := os.ReadDir(profilesDir) + if err != nil { + return V3State{}, fmt.Errorf("read v3 profiles: %w", err) + } + + profiles := map[string]V3Profile{} + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + profileBytes, err := os.ReadFile(filepath.Join(profilesDir, name, "profile.json")) + if err != nil { + return V3State{}, fmt.Errorf("read v3 profile %q: %w", name, err) + } + var profile V3Profile + if err := json.Unmarshal(profileBytes, &profile); err != nil { + return V3State{}, fmt.Errorf("parse v3 profile %q: %w", name, err) + } + profiles[name] = profile + } + + return V3State{Config: v3Config, Profiles: profiles}, nil +} + +func PlanV3Migration(state V3State) (MigrationPlan, error) { + if len(state.Profiles) == 0 { + return MigrationPlan{}, errors.New("no v3 profiles found") + } + + plan := MigrationPlan{ + CurrentContext: state.Config.CurrentProfile, + Contexts: map[string]Context{}, + } + for name, profile := range state.Profiles { + cloudURL := profile.MembershipURI + if cloudURL == "" { + cloudURL = DefaultCloudURL + } + + kind := ContextKindCloud + if profile.DefaultOrganization != "" && profile.DefaultStack != "" { + kind = ContextKindCloudStack + } + + auth := Auth{Method: AuthMethodCloudDevice} + if len(profile.RootTokens) > 0 && string(profile.RootTokens) != "null" { + ref := "keyring://formance/fctl-v4/" + name + "/rootTokens" + auth.TokenRef = ref + plan.CredentialMoves = append(plan.CredentialMoves, CredentialMove{ + Profile: name, + Ref: ref, + Value: string(profile.RootTokens), + }) + } + + plan.Contexts[name] = Context{ + Kind: kind, + CloudURL: cloudURL, + Organization: profile.DefaultOrganization, + Stack: profile.DefaultStack, + Auth: auth, + API: map[string]string{ + "ledger": string(APIPolicyLatestCompatible), + }, + } + } + if plan.CurrentContext == "" { + if _, ok := plan.Contexts["default"]; ok { + plan.CurrentContext = "default" + } + } + if plan.CurrentContext != "" { + if _, ok := plan.Contexts[plan.CurrentContext]; !ok { + return MigrationPlan{}, fmt.Errorf("current v3 profile %q has no matching profile", plan.CurrentContext) + } + } + return plan, nil +} + +func (p MigrationPlan) Config() Config { + return Config{ + Version: Version, + CurrentContext: p.CurrentContext, + Contexts: p.Contexts, + } +} + +func WriteMigration(ctx context.Context, path string, plan MigrationPlan, store credentials.Store) error { + if len(plan.CredentialMoves) > 0 && store == nil { + return errors.New("credential store is required to migrate v3 tokens") + } + for _, move := range plan.CredentialMoves { + if err := store.Set(ctx, move.Ref, move.Value); err != nil { + return fmt.Errorf("store credential for profile %q: %w", move.Profile, err) + } + } + return SaveFile(path, plan.Config()) +} diff --git a/v4/internal/config/v3migration_test.go b/v4/internal/config/v3migration_test.go new file mode 100644 index 00000000..a1716aad --- /dev/null +++ b/v4/internal/config/v3migration_test.go @@ -0,0 +1,104 @@ +package config + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/formancehq/fctl/v4/internal/credentials" +) + +func TestLoadAndPlanV3Migration(t *testing.T) { + dir := writeV3Fixture(t, true) + + state, err := LoadV3State(dir) + if err != nil { + t.Fatalf("load v3 state: %v", err) + } + plan, err := PlanV3Migration(state) + if err != nil { + t.Fatalf("plan migration: %v", err) + } + + if plan.CurrentContext != "default" { + t.Fatalf("expected current context default, got %q", plan.CurrentContext) + } + context := plan.Contexts["default"] + if context.Kind != ContextKindCloudStack { + t.Fatalf("expected cloud-stack context, got %q", context.Kind) + } + if context.CloudURL != "https://app.formance.cloud/api" || + context.Organization != "org_123" || + context.Stack != "stack_123" { + t.Fatalf("unexpected migrated context: %#v", context) + } + if len(plan.CredentialMoves) != 1 { + t.Fatalf("expected one credential move, got %d", len(plan.CredentialMoves)) + } + if plan.CredentialMoves[0].Ref != context.Auth.TokenRef { + t.Fatalf("expected auth token ref to match credential move") + } + if err := plan.Config().Validate(); err != nil { + t.Fatalf("expected valid v4 config, got %v", err) + } +} + +func TestWriteMigrationStoresCredentials(t *testing.T) { + dir := writeV3Fixture(t, true) + state, err := LoadV3State(dir) + if err != nil { + t.Fatalf("load v3 state: %v", err) + } + plan, err := PlanV3Migration(state) + if err != nil { + t.Fatalf("plan migration: %v", err) + } + + store := credentials.NewMemoryStore() + output := filepath.Join(t.TempDir(), "config.yaml") + if err := WriteMigration(context.Background(), output, plan, store); err != nil { + t.Fatalf("write migration: %v", err) + } + + loaded, err := LoadFile(output) + if err != nil { + t.Fatalf("load migrated config: %v", err) + } + if loaded.CurrentContext != "default" { + t.Fatalf("expected default current context, got %q", loaded.CurrentContext) + } + + value, err := store.Get(context.Background(), plan.CredentialMoves[0].Ref) + if err != nil { + t.Fatalf("get migrated credential: %v", err) + } + if value == "" { + t.Fatalf("expected migrated credential value") + } +} + +func writeV3Fixture(t *testing.T, withTokens bool) string { + t.Helper() + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "profiles", "default"), 0o700); err != nil { + t.Fatalf("create fixture dirs: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "config.yml"), []byte(`{"currentProfile":"default"}`), 0o600); err != nil { + t.Fatalf("write v3 config: %v", err) + } + rootTokens := "null" + if withTokens { + rootTokens = `{"access":{"token":"access-token"},"id":{"token":"id-token"}}` + } + profile := `{ + "membershipURI": "https://app.formance.cloud/api", + "rootTokens": ` + rootTokens + `, + "defaultOrganization": "org_123", + "defaultStack": "stack_123" + }` + if err := os.WriteFile(filepath.Join(dir, "profiles", "default", "profile.json"), []byte(profile), 0o600); err != nil { + t.Fatalf("write v3 profile: %v", err) + } + return dir +} From e66c415cd3f2ddc6ea6ad94a28d28cb9b09e179d Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:49:06 +0200 Subject: [PATCH 027/208] feat: add v3 migration command --- v4/cmd/config_command.go | 120 +++++++++++++++++++++++++++++++++++++++ v4/cmd/root.go | 1 + v4/cmd/root_test.go | 65 +++++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 v4/cmd/config_command.go diff --git a/v4/cmd/config_command.go b/v4/cmd/config_command.go new file mode 100644 index 00000000..4a95a944 --- /dev/null +++ b/v4/cmd/config_command.go @@ -0,0 +1,120 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + v4config "github.com/formancehq/fctl/v4/internal/config" + "github.com/formancehq/fctl/v4/internal/credentials" + "github.com/formancehq/fctl/v4/internal/render" +) + +func newConfigCommand() *cobra.Command { + command := &cobra.Command{ + Use: "config", + Short: "Manage fctl v4 configuration", + } + command.AddCommand(newConfigMigrateV3Command()) + return command +} + +func newConfigMigrateV3Command() *cobra.Command { + var fromDir string + var dryRun bool + var credentialDir string + + command := &cobra.Command{ + Use: "migrate-v3", + Short: "Migrate fctl v3 profiles into v4 contexts", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if fromDir == "" { + return fmt.Errorf("--from is required") + } + + state, err := v4config.LoadV3State(fromDir) + if err != nil { + return err + } + plan, err := v4config.PlanV3Migration(state) + if err != nil { + return err + } + + output, err := outputFormat(cmd) + if err != nil { + return err + } + if dryRun { + return renderMigrationPlan(cmd, output, plan) + } + + var store credentials.Store + if len(plan.CredentialMoves) > 0 { + if credentialDir == "" { + return fmt.Errorf("--credential-dir is required to migrate v3 tokens without a keyring backend") + } + store = credentials.NewInsecureFileStore(credentialDir) + } + path, err := configPath(cmd) + if err != nil { + return err + } + if err := v4config.WriteMigration(cmd.Context(), path, plan, store); err != nil { + return err + } + if output == "json" { + return render.JSON(cmd.OutOrStdout(), map[string]any{ + "configPath": path, + "contexts": len(plan.Contexts), + "credentialMoves": len(plan.CredentialMoves), + }) + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Migrated %d context(s) to %s.\n", len(plan.Contexts), path) + return err + }, + } + + command.Flags().StringVar(&fromDir, "from", "", "Path to the fctl v3 configuration directory") + command.Flags().BoolVar(&dryRun, "dry-run", false, "Show the migration plan without writing v4 config") + command.Flags().StringVar(&credentialDir, "credential-dir", "", "Explicit insecure credential directory for migrated v3 tokens") + + return command +} + +func renderMigrationPlan(cmd *cobra.Command, output string, plan v4config.MigrationPlan) error { + if output == "json" { + return render.JSON(cmd.OutOrStdout(), migrationPlanOutput{ + CurrentContext: plan.CurrentContext, + Contexts: contextNames(plan.Contexts), + CredentialMoves: len(plan.CredentialMoves), + }) + } + + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Current context: %s\n", plan.CurrentContext); err != nil { + return err + } + if _, err := fmt.Fprintln(cmd.OutOrStdout(), "Contexts:"); err != nil { + return err + } + for _, name := range contextNames(plan.Contexts) { + context := plan.Contexts[name] + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "- %s (%s)\n", name, context.Kind); err != nil { + return err + } + } + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Credential moves: %d\n", len(plan.CredentialMoves)) + return err +} + +type migrationPlanOutput struct { + CurrentContext string `json:"currentContext"` + Contexts []string `json:"contexts"` + CredentialMoves int `json:"credentialMoves"` +} + +func contextNames(contexts map[string]v4config.Context) []string { + cfg := v4config.Config{Contexts: contexts} + return cfg.ContextNames() +} diff --git a/v4/cmd/root.go b/v4/cmd/root.go index 32e2d6d4..5951246b 100644 --- a/v4/cmd/root.go +++ b/v4/cmd/root.go @@ -41,6 +41,7 @@ func NewRootCommand(version string) *cobra.Command { root.AddCommand(newVersionCommand()) root.AddCommand(newContextCommand()) + root.AddCommand(newConfigCommand()) root.AddCommand(newTargetCommand()) root.AddCommand(newLedgerCommand()) diff --git a/v4/cmd/root_test.go b/v4/cmd/root_test.go index 70a692ba..2fb2aa6e 100644 --- a/v4/cmd/root_test.go +++ b/v4/cmd/root_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -308,3 +309,67 @@ func TestLedgerTransactionsListJSON(t *testing.T) { } } } + +func TestConfigMigrateV3DryRun(t *testing.T) { + v3Dir := writeV3CommandFixture(t, true) + + stdout, stderr, err := executeCommand(t, "config", "migrate-v3", "--from", v3Dir, "--dry-run") + if err != nil { + t.Fatalf("migrate dry-run: %v stderr=%s", err, stderr) + } + for _, expected := range []string{ + "Current context: default", + "- default (cloud-stack)", + "Credential moves: 1", + } { + if !strings.Contains(stdout, expected) { + t.Fatalf("expected migration output to contain %q, got:\n%s", expected, stdout) + } + } +} + +func TestConfigMigrateV3Write(t *testing.T) { + v3Dir := writeV3CommandFixture(t, false) + configDir := t.TempDir() + + stdout, stderr, err := executeCommand(t, "--config-dir", configDir, "config", "migrate-v3", "--from", v3Dir) + if err != nil { + t.Fatalf("migrate write: %v stderr=%s", err, stderr) + } + if !strings.Contains(stdout, "Migrated 1 context(s)") { + t.Fatalf("unexpected migration output: %q", stdout) + } + + cfg, err := v4config.LoadFile(filepath.Join(configDir, "config.yaml")) + if err != nil { + t.Fatalf("load migrated config: %v", err) + } + if cfg.CurrentContext != "default" || cfg.Contexts["default"].Kind != v4config.ContextKindCloudStack { + t.Fatalf("unexpected migrated config: %#v", cfg) + } +} + +func writeV3CommandFixture(t *testing.T, withTokens bool) string { + t.Helper() + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "profiles", "default"), 0o700); err != nil { + t.Fatalf("create v3 fixture dirs: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "config.yml"), []byte(`{"currentProfile":"default"}`), 0o600); err != nil { + t.Fatalf("write v3 config: %v", err) + } + rootTokens := "null" + if withTokens { + rootTokens = `{"access":{"token":"access-token"},"id":{"token":"id-token"}}` + } + profile := `{ + "membershipURI": "https://app.formance.cloud/api", + "rootTokens": ` + rootTokens + `, + "defaultOrganization": "org_123", + "defaultStack": "stack_123" + }` + if err := os.WriteFile(filepath.Join(dir, "profiles", "default", "profile.json"), []byte(profile), 0o600); err != nil { + t.Fatalf("write v3 profile: %v", err) + } + return dir +} From e13b88e5d0b6a229854b1b08f319fb758f80a2c4 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:49:20 +0200 Subject: [PATCH 028/208] docs: mark v3 migration goal complete --- todos/STATUS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todos/STATUS.md b/todos/STATUS.md index 6241d3e6..5deea10d 100644 --- a/todos/STATUS.md +++ b/todos/STATUS.md @@ -8,7 +8,7 @@ - [x] 06 - runtime API version resolver - [x] 07 - first stack inspection command - [x] 08 - first Ledger versioned command -- [ ] 09 - v3 config migration +- [x] 09 - v3 config migration - [ ] 10 - integration tests and UX hardening - [ ] 11 - v4 cutover plan From 81db37daaa895c3363f8b774e7094e8b85c877c7 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:50:39 +0200 Subject: [PATCH 029/208] feat: add structured yaml output --- v4/cmd/config_command.go | 34 +++++++++++++---------------- v4/cmd/context.go | 44 ++++++++++++-------------------------- v4/cmd/ledger.go | 7 +----- v4/cmd/output.go | 22 +++++++++++++++++++ v4/cmd/target.go | 7 +----- v4/internal/render/yaml.go | 14 ++++++++++++ 6 files changed, 67 insertions(+), 61 deletions(-) create mode 100644 v4/cmd/output.go create mode 100644 v4/internal/render/yaml.go diff --git a/v4/cmd/config_command.go b/v4/cmd/config_command.go index 4a95a944..4126b88b 100644 --- a/v4/cmd/config_command.go +++ b/v4/cmd/config_command.go @@ -7,7 +7,6 @@ import ( v4config "github.com/formancehq/fctl/v4/internal/config" "github.com/formancehq/fctl/v4/internal/credentials" - "github.com/formancehq/fctl/v4/internal/render" ) func newConfigCommand() *cobra.Command { @@ -42,12 +41,8 @@ func newConfigMigrateV3Command() *cobra.Command { return err } - output, err := outputFormat(cmd) - if err != nil { - return err - } if dryRun { - return renderMigrationPlan(cmd, output, plan) + return renderMigrationPlan(cmd, plan) } var store credentials.Store @@ -64,12 +59,12 @@ func newConfigMigrateV3Command() *cobra.Command { if err := v4config.WriteMigration(cmd.Context(), path, plan, store); err != nil { return err } - if output == "json" { - return render.JSON(cmd.OutOrStdout(), map[string]any{ - "configPath": path, - "contexts": len(plan.Contexts), - "credentialMoves": len(plan.CredentialMoves), - }) + if handled, err := writeStructuredOutput(cmd, map[string]any{ + "configPath": path, + "contexts": len(plan.Contexts), + "credentialMoves": len(plan.CredentialMoves), + }); handled || err != nil { + return err } _, err = fmt.Fprintf(cmd.OutOrStdout(), "Migrated %d context(s) to %s.\n", len(plan.Contexts), path) return err @@ -83,13 +78,14 @@ func newConfigMigrateV3Command() *cobra.Command { return command } -func renderMigrationPlan(cmd *cobra.Command, output string, plan v4config.MigrationPlan) error { - if output == "json" { - return render.JSON(cmd.OutOrStdout(), migrationPlanOutput{ - CurrentContext: plan.CurrentContext, - Contexts: contextNames(plan.Contexts), - CredentialMoves: len(plan.CredentialMoves), - }) +func renderMigrationPlan(cmd *cobra.Command, plan v4config.MigrationPlan) error { + result := migrationPlanOutput{ + CurrentContext: plan.CurrentContext, + Contexts: contextNames(plan.Contexts), + CredentialMoves: len(plan.CredentialMoves), + } + if handled, err := writeStructuredOutput(cmd, result); handled || err != nil { + return err } if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Current context: %s\n", plan.CurrentContext); err != nil { diff --git a/v4/cmd/context.go b/v4/cmd/context.go index b2cb720a..acfebf5b 100644 --- a/v4/cmd/context.go +++ b/v4/cmd/context.go @@ -6,7 +6,6 @@ import ( "github.com/spf13/cobra" v4config "github.com/formancehq/fctl/v4/internal/config" - "github.com/formancehq/fctl/v4/internal/render" ) func newContextCommand() *cobra.Command { @@ -36,16 +35,13 @@ func newContextListCommand() *cobra.Command { return err } - output, err := outputFormat(cmd) - if err != nil { - return err - } names := cfg.ContextNames() - if output == "json" { - return render.JSON(cmd.OutOrStdout(), contextListOutput{ - Current: cfg.CurrentContext, - Contexts: names, - }) + result := contextListOutput{ + Current: cfg.CurrentContext, + Contexts: names, + } + if handled, err := writeStructuredOutput(cmd, result); handled || err != nil { + return err } if len(names) == 0 { _, err := fmt.Fprintln(cmd.OutOrStdout(), "No contexts found.") @@ -85,13 +81,9 @@ func newContextShowCommand() *cobra.Command { return err } - output, err := outputFormat(cmd) - if err != nil { - return err - } result := contextShowOutput{Name: name, Current: name == cfg.CurrentContext, Context: context} - if output == "json" { - return render.JSON(cmd.OutOrStdout(), result) + if handled, err := writeStructuredOutput(cmd, result); handled || err != nil { + return err } _, err = fmt.Fprintf(cmd.OutOrStdout(), "Name: %s\nKind: %s\n", name, context.Kind) return err @@ -118,13 +110,9 @@ func newContextUseCommand() *cobra.Command { return err } - output, err := outputFormat(cmd) - if err != nil { + if handled, err := writeStructuredOutput(cmd, map[string]string{"currentContext": name}); handled || err != nil { return err } - if output == "json" { - return render.JSON(cmd.OutOrStdout(), map[string]string{"currentContext": name}) - } _, err = fmt.Fprintf(cmd.OutOrStdout(), "Current context set to %s.\n", name) return err }, @@ -200,17 +188,13 @@ func newContextCreateStackCommand() *cobra.Command { return err } - output, err := outputFormat(cmd) - if err != nil { + if handled, err := writeStructuredOutput(cmd, contextShowOutput{ + Name: name, + Current: name == cfg.CurrentContext, + Context: cfg.Contexts[name], + }); handled || err != nil { return err } - if output == "json" { - return render.JSON(cmd.OutOrStdout(), contextShowOutput{ - Name: name, - Current: name == cfg.CurrentContext, - Context: cfg.Contexts[name], - }) - } _, err = fmt.Fprintf(cmd.OutOrStdout(), "Context %s created.\n", name) return err }, diff --git a/v4/cmd/ledger.go b/v4/cmd/ledger.go index eb9db7e0..9dd4af36 100644 --- a/v4/cmd/ledger.go +++ b/v4/cmd/ledger.go @@ -10,7 +10,6 @@ import ( "github.com/formancehq/fctl/v4/internal/capabilities" ledgercmd "github.com/formancehq/fctl/v4/internal/commands/ledger" - "github.com/formancehq/fctl/v4/internal/render" ) func newLedgerCommand() *cobra.Command { @@ -93,13 +92,9 @@ func newLedgerTransactionsListCommand() *cobra.Command { return err } - format, err := outputFormat(cmd) - if err != nil { + if handled, err := writeStructuredOutput(cmd, output); handled || err != nil { return err } - if format == "json" { - return render.JSON(cmd.OutOrStdout(), output) - } return renderLedgerTransactions(cmd, output) }, } diff --git a/v4/cmd/output.go b/v4/cmd/output.go new file mode 100644 index 00000000..595742ef --- /dev/null +++ b/v4/cmd/output.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/render" +) + +func writeStructuredOutput(cmd *cobra.Command, value any) (bool, error) { + format, err := outputFormat(cmd) + if err != nil { + return false, err + } + switch format { + case "json": + return true, render.JSON(cmd.OutOrStdout(), value) + case "yaml": + return true, render.YAML(cmd.OutOrStdout(), value) + default: + return false, nil + } +} diff --git a/v4/cmd/target.go b/v4/cmd/target.go index 0c24ba2b..b04c86d3 100644 --- a/v4/cmd/target.go +++ b/v4/cmd/target.go @@ -6,7 +6,6 @@ import ( "github.com/spf13/cobra" "github.com/formancehq/fctl/v4/internal/capabilities" - "github.com/formancehq/fctl/v4/internal/render" ) func newTargetCommand() *cobra.Command { @@ -51,13 +50,9 @@ func newTargetInspectCommand() *cobra.Command { Components: components, } - format, err := outputFormat(cmd) - if err != nil { + if handled, err := writeStructuredOutput(cmd, output); handled || err != nil { return err } - if format == "json" { - return render.JSON(cmd.OutOrStdout(), output) - } if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Context: %s\nTarget: %s (%s)\n", output.Context, output.TargetURL, output.TargetKind); err != nil { return err diff --git a/v4/internal/render/yaml.go b/v4/internal/render/yaml.go new file mode 100644 index 00000000..705acf18 --- /dev/null +++ b/v4/internal/render/yaml.go @@ -0,0 +1,14 @@ +package render + +import ( + "io" + + "gopkg.in/yaml.v3" +) + +func YAML(w io.Writer, value any) error { + encoder := yaml.NewEncoder(w) + encoder.SetIndent(2) + defer encoder.Close() + return encoder.Encode(value) +} From dc5c1fc273f5b82bdd5d075c2527fec7cb3bdec8 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:52:01 +0200 Subject: [PATCH 030/208] test: add v4 cli integration coverage --- v4/cli_integration_test.go | 119 ++++++++++++++++++ v4/cmd/config_command.go | 6 +- v4/cmd/context.go | 10 +- v4/cmd/target.go | 18 +-- .../commands/ledger/list_transactions.go | 20 +-- 5 files changed, 146 insertions(+), 27 deletions(-) create mode 100644 v4/cli_integration_test.go diff --git a/v4/cli_integration_test.go b/v4/cli_integration_test.go new file mode 100644 index 00000000..af7a7879 --- /dev/null +++ b/v4/cli_integration_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "os/exec" + "strings" + "testing" +) + +func TestCLIIntegrationCoreWorkflow(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/versions": + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"versions":[{"name":"ledger","version":"2.3.4","health":true}]}`) + case "/api/ledger/v2/default/transactions": + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"cursor":{"data":[],"hasMore":false,"pageSize":15}}`) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + configDir := t.TempDir() + + result := runCLI(t, + "--config-dir", configDir, + "--non-interactive", + "context", "create", "stack", "local", + "--stack-url", server.URL, + ) + result.requireSuccess(t) + if !strings.Contains(result.stdout, "Context local created.") { + t.Fatalf("unexpected create output: %q", result.stdout) + } + + result = runCLI(t, "--config-dir", configDir, "-o", "yaml", "context", "list") + result.requireSuccess(t) + for _, expected := range []string{"currentContext: local", "- local"} { + if !strings.Contains(result.stdout, expected) { + t.Fatalf("expected YAML output to contain %q, got:\n%s", expected, result.stdout) + } + } + + result = runCLI(t, "--config-dir", configDir, "-o", "json", "target", "inspect") + result.requireSuccess(t) + for _, expected := range []string{`"targetKind": "stack"`, `"name": "ledger"`, `"v2"`} { + if !strings.Contains(result.stdout, expected) { + t.Fatalf("expected inspect JSON to contain %q, got:\n%s", expected, result.stdout) + } + } + + result = runCLI(t, "--config-dir", configDir, "-o", "json", "ledger", "transactions", "list") + result.requireSuccess(t) + for _, expected := range []string{`"apiVersion": "v2"`, `"transactions": []`} { + if !strings.Contains(result.stdout, expected) { + t.Fatalf("expected ledger JSON to contain %q, got:\n%s", expected, result.stdout) + } + } + + result = runCLI(t, "--config-dir", configDir, "ledger", "transactions", "list", "--api-version", "v3") + if result.exitCode == 0 { + t.Fatalf("expected pinned unsupported API version to fail") + } + if !strings.Contains(result.stderr, "does not support pinned api version v3") { + t.Fatalf("expected unsupported api error, got stderr:\n%s", result.stderr) + } +} + +func TestCLIIntegrationMissingConfigError(t *testing.T) { + result := runCLI(t, "--config-dir", t.TempDir(), "target", "inspect") + if result.exitCode == 0 { + t.Fatalf("expected missing config to fail") + } + if !strings.Contains(result.stderr, "read config") && !strings.Contains(result.stderr, "no such file") { + t.Fatalf("unexpected stderr:\n%s", result.stderr) + } +} + +type cliResult struct { + stdout string + stderr string + exitCode int +} + +func (r cliResult) requireSuccess(t *testing.T) { + t.Helper() + if r.exitCode != 0 { + t.Fatalf("expected success, got exit %d\nstdout:\n%s\nstderr:\n%s", r.exitCode, r.stdout, r.stderr) + } + if r.stderr != "" { + t.Fatalf("expected empty stderr, got:\n%s", r.stderr) + } +} + +func runCLI(t *testing.T, args ...string) cliResult { + t.Helper() + + commandArgs := append([]string{"run", "."}, args...) + cmd := exec.Command("go", commandArgs...) + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + exitCode := 0 + if err != nil { + exitCode = 1 + if exitError, ok := err.(*exec.ExitError); ok { + exitCode = exitError.ExitCode() + } + } + return cliResult{stdout: stdout.String(), stderr: stderr.String(), exitCode: exitCode} +} diff --git a/v4/cmd/config_command.go b/v4/cmd/config_command.go index 4126b88b..ee935b8a 100644 --- a/v4/cmd/config_command.go +++ b/v4/cmd/config_command.go @@ -105,9 +105,9 @@ func renderMigrationPlan(cmd *cobra.Command, plan v4config.MigrationPlan) error } type migrationPlanOutput struct { - CurrentContext string `json:"currentContext"` - Contexts []string `json:"contexts"` - CredentialMoves int `json:"credentialMoves"` + CurrentContext string `json:"currentContext" yaml:"currentContext"` + Contexts []string `json:"contexts" yaml:"contexts"` + CredentialMoves int `json:"credentialMoves" yaml:"credentialMoves"` } func contextNames(contexts map[string]v4config.Context) []string { diff --git a/v4/cmd/context.go b/v4/cmd/context.go index acfebf5b..e6fdc740 100644 --- a/v4/cmd/context.go +++ b/v4/cmd/context.go @@ -211,12 +211,12 @@ func newContextCreateStackCommand() *cobra.Command { } type contextListOutput struct { - Current string `json:"currentContext"` - Contexts []string `json:"contexts"` + Current string `json:"currentContext" yaml:"currentContext"` + Contexts []string `json:"contexts" yaml:"contexts"` } type contextShowOutput struct { - Name string `json:"name"` - Current bool `json:"current"` - Context v4config.Context `json:"context"` + Name string `json:"name" yaml:"name"` + Current bool `json:"current" yaml:"current"` + Context v4config.Context `json:"context" yaml:"context"` } diff --git a/v4/cmd/target.go b/v4/cmd/target.go index b04c86d3..3ae4618b 100644 --- a/v4/cmd/target.go +++ b/v4/cmd/target.go @@ -84,18 +84,18 @@ func newTargetInspectCommand() *cobra.Command { } type targetInspectOutput struct { - Context string `json:"context"` - TargetURL string `json:"targetUrl"` - TargetKind string `json:"targetKind"` - Components []targetInspectComponent `json:"components"` + Context string `json:"context" yaml:"context"` + TargetURL string `json:"targetUrl" yaml:"targetUrl"` + TargetKind string `json:"targetKind" yaml:"targetKind"` + Components []targetInspectComponent `json:"components" yaml:"components"` } type targetInspectComponent struct { - Name string `json:"name"` - Version string `json:"version"` - Health bool `json:"health"` - APIVersions []string `json:"apiVersions"` - APIPolicy string `json:"apiPolicy"` + Name string `json:"name" yaml:"name"` + Version string `json:"version" yaml:"version"` + Health bool `json:"health" yaml:"health"` + APIVersions []string `json:"apiVersions" yaml:"apiVersions"` + APIPolicy string `json:"apiPolicy" yaml:"apiPolicy"` } func apiVersionsToStrings(versions []capabilities.APIVersion) []string { diff --git a/v4/internal/commands/ledger/list_transactions.go b/v4/internal/commands/ledger/list_transactions.go index 20667e1f..dd7f123b 100644 --- a/v4/internal/commands/ledger/list_transactions.go +++ b/v4/internal/commands/ledger/list_transactions.go @@ -29,19 +29,19 @@ type ListTransactionsInput struct { } type ListTransactionsOutput struct { - APIVersion capabilities.APIVersion `json:"apiVersion"` - Transactions []TransactionSummary `json:"transactions"` - HasMore bool `json:"hasMore"` - PageSize int64 `json:"pageSize"` - Next *string `json:"next,omitempty"` - Previous *string `json:"previous,omitempty"` + APIVersion capabilities.APIVersion `json:"apiVersion" yaml:"apiVersion"` + Transactions []TransactionSummary `json:"transactions" yaml:"transactions"` + HasMore bool `json:"hasMore" yaml:"hasMore"` + PageSize int64 `json:"pageSize" yaml:"pageSize"` + Next *string `json:"next,omitempty" yaml:"next,omitempty"` + Previous *string `json:"previous,omitempty" yaml:"previous,omitempty"` } type TransactionSummary struct { - ID string `json:"id"` - Reference *string `json:"reference,omitempty"` - Timestamp time.Time `json:"timestamp"` - Metadata map[string]any `json:"metadata,omitempty"` + ID string `json:"id" yaml:"id"` + Reference *string `json:"reference,omitempty" yaml:"reference,omitempty"` + Timestamp time.Time `json:"timestamp" yaml:"timestamp"` + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` } type ListTransactionsHandler struct { From 47970d5a79f4036295d9ad7ab61169471fed2d75 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:52:37 +0200 Subject: [PATCH 031/208] test: cover v4 cli error paths --- v4/cli_integration_test.go | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/v4/cli_integration_test.go b/v4/cli_integration_test.go index af7a7879..758ca36f 100644 --- a/v4/cli_integration_test.go +++ b/v4/cli_integration_test.go @@ -81,6 +81,46 @@ func TestCLIIntegrationMissingConfigError(t *testing.T) { } } +func TestCLIIntegrationInvalidConfigError(t *testing.T) { + result := runCLI(t, + "--config-dir", t.TempDir(), + "context", "create", "stack", "local", + ) + if result.exitCode == 0 { + t.Fatalf("expected invalid context creation to fail") + } + if !strings.Contains(result.stderr, "stackURL is required") { + t.Fatalf("unexpected stderr:\n%s", result.stderr) + } +} + +func TestCLIIntegrationMissingAuthError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("request should not reach server without credentials") + })) + defer server.Close() + + configDir := t.TempDir() + result := runCLI(t, + "--config-dir", configDir, + "context", "create", "stack", "local", + "--stack-url", server.URL, + "--auth-method", "client_credentials", + "--issuer-url", server.URL, + "--client-id", "client", + "--secret-ref", "missing-secret", + ) + result.requireSuccess(t) + + result = runCLI(t, "--config-dir", configDir, "target", "inspect") + if result.exitCode == 0 { + t.Fatalf("expected missing auth to fail") + } + if !strings.Contains(result.stderr, "credential not found") { + t.Fatalf("unexpected stderr:\n%s", result.stderr) + } +} + type cliResult struct { stdout string stderr string From a7bafb228eca66e746ccfecde89caa83d125f6cf Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:53:01 +0200 Subject: [PATCH 032/208] docs: mark integration hardening goal complete --- todos/STATUS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todos/STATUS.md b/todos/STATUS.md index 5deea10d..8896daf5 100644 --- a/todos/STATUS.md +++ b/todos/STATUS.md @@ -9,7 +9,7 @@ - [x] 07 - first stack inspection command - [x] 08 - first Ledger versioned command - [x] 09 - v3 config migration -- [ ] 10 - integration tests and UX hardening +- [x] 10 - integration tests and UX hardening - [ ] 11 - v4 cutover plan Update this file after each completed todo. Keep implementation commits separate from status-only commits when practical. From 6e5c3066544d31990701cf8ecee95da2e0848bdc Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:53:45 +0200 Subject: [PATCH 033/208] docs: add v4 cutover plan --- docs/cli-v4/cutover-plan.md | 136 ++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/cli-v4/cutover-plan.md diff --git a/docs/cli-v4/cutover-plan.md b/docs/cli-v4/cutover-plan.md new file mode 100644 index 00000000..4bc09339 --- /dev/null +++ b/docs/cli-v4/cutover-plan.md @@ -0,0 +1,136 @@ +# fctl v4 Cutover Plan + +Status: Draft, planning only. + +Do not execute this plan until v4 feature parity and release readiness are explicitly approved. + +## Current State + +- The current repository root is the v3 implementation. +- The v4 rewrite lives under `v4/`. +- v4 is a separate Go module: `github.com/formancehq/fctl/v4`. +- v4 currently validates the architecture through contexts, runtime resolution, auth providers, capabilities, target inspection, v3 migration, and one versioned Ledger command. + +## Cutover Preconditions + +- All required product commands have v4 equivalents or documented compatibility gaps. +- `go test ./...` passes in `v4/`. +- The final root module test suite passes after the move. +- Release packaging, completions, Docker/Homebrew/GitHub Actions paths are updated. +- Migration documentation is published. +- A rollback branch or tag exists before deleting root v3 files. + +## Root Inventory + +Likely preserve or update: + +- `.github/` +- `.codex/` +- `AGENTS.md` +- `README.md` +- `docs/` +- `todos/` +- `openapi/`, if still useful for generated clients or historical specs +- `flake.nix`, if updated to build v4 +- `build.Dockerfile`, if updated to build v4 + +Likely remove or archive during cutover: + +- root `cmd/` +- root `pkg/` +- root `internal/` generated v3 membership/deploy clients, unless still needed +- root `main.go` +- root `go.mod` +- root `go.sum` +- root `completions/`, if regenerated by v4 release process + +Likely move from `v4/` to root: + +- `v4/main.go` -> `main.go` +- `v4/cmd/` -> `cmd/` +- `v4/internal/` -> `internal/` +- `v4/go.mod` -> `go.mod` +- `v4/go.sum` -> `go.sum` +- `v4/README.md` content merged into root `README.md` + +## Module Path Plan + +Before cutover, decide whether the root module path remains: + +```text +github.com/formancehq/fctl/v4 +``` + +or whether packaging keeps the module path separate from binary versioning. + +If the module path changes during the move, update imports mechanically and run: + +```bash +go mod tidy +go test ./... +``` + +## Proposed Cutover Steps + +1. Create a final pre-cutover tag or branch. +2. Ensure `feat/v4` is up to date with the target base branch. +3. Run from `v4/`: + + ```bash + go test ./... + go run . --help + go run . version + ``` + +4. Remove root v3 implementation files listed in the inventory. +5. Move v4 implementation files to root. +6. Update import paths if needed. +7. Update release/build files to target the new root module. +8. Regenerate completions if they are committed artifacts. +9. Run from root: + + ```bash + go mod tidy + go test ./... + go run . --help + go run . version + ``` + +10. Run migration and context smoke tests against temporary config directories. +11. Review generated manifest and release packaging diffs. +12. Commit the cutover as one explicit commit. + +## Validation Matrix + +- `context create/list/show/use` against temp config. +- `target inspect` against fake `/versions`. +- `ledger transactions list` against fake Ledger v2 endpoint. +- `config migrate-v3 --dry-run` against fixture v3 config. +- `config migrate-v3` write mode against fixture v3 config. +- JSON output for context, target, Ledger, migration. +- YAML output for at least context and target. +- Non-interactive mode for setup-like commands. +- Error paths: + - missing config + - invalid context config + - missing credentials + - unsupported pinned API version + +## User Compatibility Notes + +- v3 profiles are not silently modified. +- Users migrate explicitly with `config migrate-v3`. +- Stack contexts no longer require Formance Cloud membership. +- API version selection defaults to latest compatible target/CLI intersection. +- Product commands should avoid exposing API namespaces in command paths. + +## Risks + +- Some v3 commands may not have v4 parity at cutover. +- Release tooling may still assume the root v3 module layout. +- The generated capabilities manifest may need to be refreshed before release. +- Cloud auth device flow is not part of the initial skeleton and must be validated before Cloud cutover. + +## Rollback + +Rollback should restore the pre-cutover branch or tag. Do not attempt partial rollback by mixing root v3 and moved v4 files. From d329e3086deb93870688a21151067aa32beb7a2c Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 10:54:04 +0200 Subject: [PATCH 034/208] docs: mark v4 roadmap goals complete --- todos/STATUS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todos/STATUS.md b/todos/STATUS.md index 8896daf5..9669145d 100644 --- a/todos/STATUS.md +++ b/todos/STATUS.md @@ -10,6 +10,6 @@ - [x] 08 - first Ledger versioned command - [x] 09 - v3 config migration - [x] 10 - integration tests and UX hardening -- [ ] 11 - v4 cutover plan +- [x] 11 - v4 cutover plan Update this file after each completed todo. Keep implementation commits separate from status-only commits when practical. From 614e0bbd531598dd4c96152650c24df54b9b17c5 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 14 May 2026 11:05:37 +0200 Subject: [PATCH 035/208] docs: add v3 to v4 migration plan --- plan.md | 689 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 689 insertions(+) create mode 100644 plan.md diff --git a/plan.md b/plan.md new file mode 100644 index 00000000..d55a7b41 --- /dev/null +++ b/plan.md @@ -0,0 +1,689 @@ +# Plan de migration fctl v3 vers fctl v4 + +Statut: brouillon de travail pour la migration complete. + +Source d'inventaire: + +- commandes v3 sous `cmd/`; +- architecture v4 sous `docs/rfcs/0001-fctl-v4-architecture.md`; +- ADR v4 sous `docs/adr/`; +- design des commandes sous `docs/cli-v4/command-design.md`; +- manifeste de compatibilite sous `docs/cli-v4/compatibility-manifest.md`; +- migration de configuration sous `docs/cli-v4/migration-from-v3.md`. + +Objectif du document: + +- donner une vision exhaustive des commandes v3 a migrer; +- definir le nom canonique v4, les alias de compatibilite et les changements d'arguments; +- servir de base a la documentation utilisateur "v3 vers v4"; +- servir de checklist de review pendant l'implementation; +- cadrer les tests unitaires, integration et end-to-end necessaires. + +## Principes v4 + +### Modele utilisateur + +La v4 ne doit plus supposer que l'utilisateur dispose d'un compte Formance Cloud ou d'un membership. + +Les commandes parlent a un contexte: + +- `stack`: stack locale ou self-hosted; +- `cloud`: controle plane Formance Cloud; +- `cloud-stack`: stack Formance Cloud selectionnee par organisation et stack. + +L'authentification est une propriete du contexte, pas une hypothese globale du CLI. + +### Resolution des versions API + +Les commandes exposent une intention produit stable: + +```bash +fctl ledger transactions list +``` + +Elles ne doivent pas exposer l'espace de noms SDK comme UX primaire: + +```bash +# Non canonique +fctl ledger v2 transactions list +fctl ledger transactions list-v2 +``` + +Le runtime v4 choisit l'API comme suit: + +1. appeler `/versions`; +2. lire la version du composant, par exemple `ledger=2.3.4`; +3. convertir la version composant en namespaces API supportes via le manifeste de compatibilite; +4. intersecter avec les handlers disponibles dans le CLI; +5. choisir par defaut le namespace le plus recent compatible; +6. autoriser un override explicite avec `--api-version ledger=v1` ou `--api-version v1` dans une commande produit. + +### Regles de naming + +| Cas v3 | Regle v4 | Exemple | +| --- | --- | --- | +| Commandes avec `_` | kebab-case canonique, alias underscore deprecie | `transfer_initiation` -> `transfer-initiation` | +| `get` et `describe` melanges | `show` canonique, aliases `get`/`describe` si existants | `payments payments get` -> `payments payments show` | +| Abreviations peu claires | flag explicite canonique, alias court deprecie | `--ik` -> `--idempotency-key` | +| Flags API-specifiques | flag produit stable | `--account` reste `--account` meme si l'API v2 attend `address` | +| Flags v3 tres utilises | conserver comme alias cache ou deprecie | `--src` -> `--source`, `--dst` -> `--destination` | +| Saisie fichier | `--file |-` quand l'objet principal n'est pas naturellement positionnel | `create |-` peut rester alias | +| Pagination | `--cursor`, `--page-size`; `--limit` pour la recherche globale | `search --limit 20` | +| Metadata | `--metadata key=value` repetable, `--metadata-file |-` si utile | toutes familles | +| Confirmation | `--confirm` pour scripts, prompt interactif seulement si TTY | commandes destructives ou mutantes | + +### Politique d'alias + +La v4 etant une version majeure, elle peut casser des commandes, mais les chemins v3 les plus courants doivent rester disponibles comme aliases de migration quand cela ne complique pas l'implementation. + +Regles: + +- alias visibles pour les raccourcis utiles (`list`, `ls`); +- alias deprecie pour les anciens noms (`get`, `transfer_initiation`, `bank_accounts`, `update_status`); +- alias cache seulement pour les formes tres anciennes ou incoherentes; +- erreur claire si un ancien flag ne peut pas etre mappe sans ambiguite; +- documentation generee a partir de cette table pour chaque suppression volontaire. + +### Format global des flags + +| v3 | v4 canonique | Compatibilite | Notes | +| --- | --- | --- | --- | +| `--profile`, `-p` | `--context`, `-c` si non conflictuel | `--profile` alias deprecie | La v3 utilise `-c` pour config dir, donc la v4 doit eviter une collision si `-c` reste config. | +| `--config-dir`, `-c` | `--config-dir` | garder `-c` seulement si `--context` n'a pas `-c` | Preferer `--context` sans short pour eviter ambiguite. | +| `--debug`, `-d` | `--debug` | garder `-d` | Active logs techniques sur stderr. | +| `--output`, `-o plain,json` | `--output`, `-o plain,json,yaml` | extension compatible | Les sorties structurees doivent etre stables. | +| `--insecure-tls` | `--insecure-tls` dans le contexte ou flag override | garder flag | Ne pas persister implicitement sans action explicite. | +| `--telemetry` | `--telemetry` / config | a confirmer | La v4 doit documenter opt-in/opt-out. | +| absent | `--non-interactive` | nouveau | Aucune question, erreurs propres. | +| absent | `--api-version` | nouveau | Pin produit ou commande, ex: `ledger=v2`. | +| absent | `--no-color` | nouveau | Necessaire pour CI et golden tests. | +| absent | `--quiet` | nouveau | Ne sortir que la donnee principale ou l'identifiant cree. | + +## Migration configuration et session + +| v3 | v4 canonique | Changements | Tests critiques | +| --- | --- | --- | --- | +| `fctl login --membership-uri ` | `fctl auth login cloud --cloud-url ` | Cloud devient un provider d'auth, pas une condition pour la stack. | migration de token, erreurs sans browser, non-interactif. | +| aucun equivalent local propre | `fctl auth login token --token ` | Pour self-hosted et CI. | secret jamais ecrit en clair dans config si keyring disponible. | +| aucun equivalent local propre | `fctl auth login client-credentials --issuer-url --client-id --client-secret` | Auth machine-to-machine. | renouvellement, expiration, erreurs OAuth. | +| aucun equivalent local propre | `fctl auth login oidc --issuer-url --client-id` | OIDC generique. | scopes, device flow si supporte. | +| aucun equivalent local propre | `fctl auth login none` | Local/dev sans auth. | refuse sur contexte non local sauf confirmation explicite. | +| `fctl profiles list` | `fctl context list` | `profiles` alias deprecie. | format table/json/yaml stable. | +| `fctl profiles show ` | `fctl context show ` | meme argument. | masque les secrets. | +| `fctl profiles use ` | `fctl context use ` | current context. | ecriture atomique. | +| `fctl profiles delete ` | `fctl context delete ` | refuse si current sans `--force`. | deletion config + references credentials. | +| `fctl profiles rename ` | `fctl context rename ` | meme forme. | conserve current si necessaire. | +| `fctl profiles reset ` | `fctl context reset ` ou `context unset-defaults` | A clarifier pendant implementation; ne pas supprimer credentials sans confirmation. | confirmation et non-interactif. | +| `fctl profiles set-default-organization ` | `fctl context set --organization ` | Peut prendre current context par defaut. | validation kind cloud/cloud-stack. | +| `fctl profiles set-default-stack ` | `fctl context set --stack ` | Peut prendre current context par defaut. | validation kind cloud-stack. | +| aucun equivalent v3 | `fctl context create stack --stack-url [--auth ...]` | Base self-hosted/local. | config schema, auth method. | +| aucun equivalent v3 | `fctl context create cloud --cloud-url ` | Controle plane Cloud. | aucun stack requis. | +| aucun equivalent v3 | `fctl context create cloud-stack --cloud-url --organization --stack` | Stack Cloud data-plane. | resolution d'URL stack. | +| aucun equivalent v3 | `fctl target inspect` | Montre target, auth, `/versions`, API choisies. | mock `/versions`. | +| aucun equivalent v3 | `fctl config migrate-v3` | Importe les profiles v3 sans les modifier. | fixtures v3, keyring fake, idempotence. | +| `fctl prompt` | `fctl setup` ou `fctl context wizard` | Garder `prompt` alias cache/deprecie. | ne jamais bloquer en `--non-interactive`. | +| `fctl version` | `fctl version` | Ajouter build metadata v4. | stdout stable. | +| `fctl ui` | `fctl ui` | A reevaluer: garder si encore utile, sinon documenter retrait. | detection browser/TTY. | + +## Mapping commandes Cloud + +Les commandes Cloud restent sous `cloud`, mais elles doivent utiliser un contexte `cloud` ou `cloud-stack`. Elles ne doivent pas etre requises pour utiliser les produits stack (`ledger`, `payments`, etc.) contre une stack locale. + +| v3 | v4 canonique | Changements d'arguments | Notes | +| --- | --- | --- | --- | +| `cloud generate-personal-token` | `cloud personal-tokens create` | Sortir le token en `--output json` sans decoration. | Garder ancien nom alias si simple. | +| `cloud me info` | `cloud me show` | `info` alias. | | +| `cloud me invitations list` | identique | pagination normalisee si disponible. | | +| `cloud me invitations accept ` | identique | `--confirm` si action irreversible. | | +| `cloud me invitations decline ` | identique | `--confirm` si action irreversible. | | +| `cloud organizations create --default-stack-role --default-organization-role` | identique | flags kebab-case deja OK; valider enums. | | +| `cloud organizations list` | identique | `--cursor/--page-size` si API le supporte, sinon adapter page interne. | | +| `cloud organizations describe ` | `cloud organizations show ` | alias `describe`; argument kebab dans docs. | | +| `cloud organizations update --name --default-policy-id` | `cloud organizations update ` | flags repetables documentes. | | +| `cloud organizations delete ` | identique | `--confirm` obligatoire en non-interactif. | | +| `cloud organizations history` | identique | Sortie evenement structuree. | | +| `cloud organizations applications list` | identique | | | +| `cloud organizations applications show ` | identique | | | +| `cloud organizations authentication-provider show` | `cloud organizations authentication-provider show` | | | +| `cloud organizations authentication-provider configure ` | `cloud organizations authentication-provider configure --type --name --client-id --client-secret` | Eviter secret positionnel dans shell history; accepter ancien format alias. | Secret via prompt ou `--client-secret-stdin`. | +| `cloud organizations authentication-provider delete` | identique | `--confirm`. | | +| `cloud organizations invitations list` | identique | | | +| `cloud organizations invitations send ` | identique | flags role/policy si presents dans API. | | +| `cloud organizations invitations delete ` | identique | `--confirm`. | | +| `cloud organizations oauth-clients create` | identique | preferer flags explicites ou `--file`. | | +| `cloud organizations oauth-clients list` | identique | | | +| `cloud organizations oauth-clients show ` | `cloud organizations oauth-clients show ` | alias underscore. | | +| `cloud organizations oauth-clients update ` | `cloud organizations oauth-clients update ` | normaliser argument. | | +| `cloud organizations oauth-clients delete ` | `cloud organizations oauth-clients delete ` | `--confirm`. | | +| `cloud organizations policies create ` | identique | scopes via `--scope` repetable. | | +| `cloud organizations policies list` | identique | | | +| `cloud organizations policies show ` | identique | | | +| `cloud organizations policies update ` | identique | | | +| `cloud organizations policies delete ` | identique | `--confirm`. | | +| `cloud organizations policies add-scope ` | identique | | | +| `cloud organizations policies remove-scope ` | identique | `--confirm` si necessaire. | | +| `cloud organizations users list` | identique | | | +| `cloud organizations users show ` | identique | | | +| `cloud organizations users link ` | identique | | | +| `cloud organizations users unlink ` | identique | `--confirm`. | | +| `cloud regions create` | identique | payload flags ou `--file`. | | +| `cloud regions list` | identique | | | +| `cloud regions show` | identique | | | +| `cloud regions delete` | identique | `--confirm`. | | +| `cloud apps create` | identique | Clarifier si app appartient org/stack/contexte. | | +| `cloud apps list` | identique | | | +| `cloud apps show` | identique | | | +| `cloud apps delete` | identique | `--confirm`. | | +| `cloud apps deploy` | identique | Documenter source: cwd, image, manifest. | | +| `cloud apps runs list` | identique | | | +| `cloud apps runs show` | identique | | | +| `cloud apps runs logs` | identique | `--follow`, `--since`, `--tail` si API. | | +| `cloud apps versions list` | identique | | | +| `cloud apps versions show` | identique | | | +| `cloud apps versions show-manifest` | `cloud apps versions manifest` | alias `show-manifest`. | | +| `cloud apps versions show-archive` | `cloud apps versions archive show` ou garder `show-archive` | Choisir selon API; documenter. | | +| `cloud apps versions archive` | identique si existant | `--confirm`. | | +| `cloud apps variables list` | identique | | | +| `cloud apps variables create` | identique | Secret via stdin/prompt si sensible. | | +| `cloud apps variables delete` | identique | `--confirm`. | | + +## Mapping Stack lifecycle + +Les commandes `stack` v3 sont Cloud-control-plane. En v4, elles doivent etre clairement distinguees des commandes qui parlent a une stack data-plane. + +| v3 | v4 canonique | Changements | Notes | +| --- | --- | --- | --- | +| `stack create` | `cloud stacks create` | `stack create` alias deprecie si contexte Cloud. | Ne doit pas exister pour contexte `stack` local. | +| `stack list` | `cloud stacks list` | | | +| `stack show` | `cloud stacks show ` | | | +| `stack update` | `cloud stacks update ` | | | +| `stack delete` | `cloud stacks delete ` | `--confirm`. | | +| `stack enable` | `cloud stacks enable ` | | | +| `stack disable` | `cloud stacks disable ` | `--confirm`. | | +| `stack restore` | `cloud stacks restore ` | `--confirm`. | | +| `stack upgrade` | `cloud stacks upgrade ` | `--confirm`, afficher target version. | | +| `stack history` | `cloud stacks history ` | | | +| `stack proxy` | `target proxy` ou `cloud stacks proxy ` | Clarifier usage: proxy data-plane vs Cloud. | | +| `stack users list` | `cloud stacks users list ` | | | +| `stack users link ` | `cloud stacks users link ` | stack explicite ou contexte courant. | | +| `stack users unlink ` | `cloud stacks users unlink ` | `--confirm`. | | +| `stack modules list` | `cloud stacks modules list ` | | | +| `stack modules enable` | `cloud stacks modules enable ` | | | +| `stack modules disable` | `cloud stacks modules disable ` | `--confirm`. | | + +## Mapping Ledger + +Le ledger est la premiere famille a beneficier de la resolution API automatique. Les commandes v4 doivent construire des inputs canoniques, puis laisser `internal/runtime` choisir `ledger.v1`, `ledger.v2`, `ledger.v3`, etc. + +Regles de flags ledger: + +- `--ledger` reste le selecteur produit, avec defaut depuis le contexte; +- `--account` reste le terme CLI canonique pour une adresse de compte; +- si une API appelle le champ `address`, l'adapter v4 fait la traduction; +- `--src` et `--dst` deviennent aliases deprecies de `--source` et `--destination`; +- les timestamps acceptent RFC3339 et doivent produire des erreurs explicites; +- les commandes qui n'existent qu'en v3+ doivent etre visibles avec une note `requires ledger API v3+`. + +| v3 | v4 canonique | Changements d'arguments | Notes | +| --- | --- | --- | --- | +| `ledger --ledger ` | `ledger --ledger ` | Defaut depuis `context.defaults.ledger`. | Pas de Cloud obligatoire. | +| `ledger list` | identique | | Liste des ledgers si API exposee. | +| `ledger create --bucket --features --metadata` | identique | `--metadata` repetable, `--feature` repetable; `--confirm` si destructif non applicable. | Adapter selon API. | +| `ledger import --input --resume` | `ledger import --file |-` | ancien positionnel garde; clarifier `--input` vs `--file`. | Tester reprise/resume. | +| `ledger export --output ` | `ledger export --file |-` | `--output` global ne doit pas entrer en conflit; utiliser `--file` pour fichier. | `--output json` reste rendu CLI. | +| `ledger server-infos` | `ledger info` | alias `server-infos`. | | +| `ledger stats` | identique | | | +| `ledger send [source] --metadata --reference` | `ledger transactions send --source --destination --amount --asset` | garder `ledger send` alias; source positionnelle depreciee. | Evite ambiguite des positionnels. | +| `ledger set-metadata key=value...` | identique | ajouter `--metadata-file`. | | +| `ledger delete-metadata ` | identique | `--confirm` non necessaire si API idempotente, a verifier. | | +| `ledger accounts list --address --metadata --page-size` | identique | `--address` devient alias de `--account` si le concept est une adresse; garder `--address` si on documente "address" comme objet account. | Decision a prendre avant implementation finale. | +| `ledger accounts show
` | identique | | | +| `ledger accounts set-metadata
key=value...` | identique | | | +| `ledger accounts delete-metadata
` | identique | | | +| `ledger transactions list --account --dst --src --reference --metadata --page-size --start --end` | `ledger transactions list --account --destination --source --reference --metadata --page-size --start --end` | aliases `--dst`, `--src`; adapter `account/address` selon API. | Deja commence en v4, etendre avec tous filtres. | +| `ledger transactions show ` | identique | ID type string cote CLI, adapter int/uuid selon API. | | +| `ledger transactions num -|` | `ledger transactions count --file |-` | alias `num`; documenter format d'entree. | | +| `ledger transactions revert --at-effective-date --force` | identique | `--force` devient alias ou complement de `--confirm`; date RFC3339. | | +| `ledger transactions set-metadata key=value...` | identique | | | +| `ledger transactions delete-metadata ` | identique | | | +| `ledger volumes list --pit --oot --use-insertion-date --group-by --address --metadata --cursor --page-size` | identique | `--address`/`--account` a harmoniser; `--group-by` enum validee. | | + +Commandes nouvelles possibles si l'API Ledger v3 les expose: + +| Nouvelle commande | Condition | Comportement si cible trop ancienne | +| --- | --- | --- | +| `ledger transactions explain ` | `ledger API v3+` | erreur `requires ledger API v3+`, avec version cible courante. | +| `ledger schemas list/show/insert` | selon manifeste | commande visible, validation runtime. | +| `ledger accounts query` | selon manifeste | proposer `--api-version` seulement comme override technique. | + +## Mapping Payments + +Regles: + +- utiliser kebab-case pour les resources composees; +- conserver les anciens chemins avec underscores comme aliases deprecies; +- les payloads JSON passent par `--file |-` tout en acceptant l'ancien positionnel; +- `get` devient `show`; +- les connecteurs gardent leurs noms metier exacts. + +| v3 | v4 canonique | Changements d'arguments | Notes | +| --- | --- | --- | --- | +| `payments versions` | `payments versions` ou `target inspect --product payments` | Garder pour compat si utile. | Le runtime centralise deja `/versions`. | +| `payments accounts create |-` | `payments accounts create --file |-` | ancien positionnel alias. | Valider JSON par schema si possible. | +| `payments accounts list` | identique | pagination et filtres normalises. | | +| `payments accounts get ` | `payments accounts show ` | alias `get`; argument kebab dans docs. | | +| `payments accounts balances ` | `payments accounts balances ` | | | +| `payments bank_accounts create |-` | `payments bank-accounts create --file |-` | alias `bank_accounts`. | | +| `payments bank_accounts list` | `payments bank-accounts list` | | | +| `payments bank_accounts get ` | `payments bank-accounts show ` | alias `get`. | | +| `payments bank_accounts forward ` | `payments bank-accounts forward ` | | | +| `payments bank_accounts update-metadata key=value...` | `payments bank-accounts set-metadata key=value...` | `update-metadata` alias si API reste ainsi. | Harmoniser avec autres produits. | +| `payments payments create |-` | `payments payments create --file |-` | ancien positionnel alias. | Nom double conserve mais documenter. | +| `payments payments list` | identique | | | +| `payments payments get ` | `payments payments show ` | alias `get`. | | +| `payments payments set-metadata key=value...` | `payments payments set-metadata key=value...` | | | +| `payments pools create |-` | `payments pools create --file |-` | | | +| `payments pools list` | identique | | | +| `payments pools get ` | `payments pools show ` | alias `get`. | | +| `payments pools delete ` | `payments pools delete ` | `--confirm`. | | +| `payments pools add-account ` | `payments pools add-account ` | | v3 file name is `add_accounts.go` but command is singular. | +| `payments pools remove-account ` | `payments pools remove-account ` | `--confirm` if destructive. | | +| `payments pools update-query |-` | `payments pools update-query --file |-` | | | +| `payments pools balances ` | `payments pools balances --at