diff --git a/docs/application-settings.md b/docs/application-settings.md new file mode 100644 index 00000000..71a800de --- /dev/null +++ b/docs/application-settings.md @@ -0,0 +1,166 @@ +# Application settings + +Also available at the terminal, without a repo checkout, via `gddy guide platform-settings` (and `gddy guide platform-overview` for the full app lifecycle). + +An application-settings capability lets a GoDaddy Platform Application (GPA) contribute a form to a Commerce-owned settings surface (e.g. `tax-center`) — merchants fill it out, the GPA's own `load`/`save`/`validate` endpoints own the data. `app-registry-api` stores and validates only the registration and presentation metadata; it never sees merchant values. This doc covers the `gddy platform app` side of registering one. For the platform contract itself (lifecycle endpoints, signing, `settings-api` composition), see `app-registry-api`'s `docs/GPA-SETTINGS-REGISTRATION.md` and `docs/SETTINGS.md`. + +## Workflow + +1. **Add the placement** — `gddy platform app add settings` writes the group/slug/entryPath/order/capabilities/icon fields into `godaddy.toml`: + + ```bash + gddy platform app add settings \ + --group tax-center \ + --slug godaddy-tax \ + --title "GoDaddy Tax" \ + --description "Choose manual tax rules or automatic U.S. ZIP-code tax rates." \ + --entry-path /settings/godaddy-tax \ + --order 10 \ + --capability read --capability write --capability validate \ + --icon-name percent --icon-library lucide + ``` + + `entryPath` is relative to the application's registered `proxy_url`, same as action URLs. `--capability` defaults to `read`+`write` server-side if omitted. `--icon-name`/`--icon-library` must be given together or not at all. + +2. **Author the form** — this command only writes placement metadata; the actual field/section definitions (`presentation`) aren't flag-driven. Either hand-add a `[settings.presentation]` block directly into the entry `add settings` just wrote, or point it at a JSON file with `--presentation-file ` (or by editing `presentationFile` into the entry afterward). The two are mutually exclusive — see Presentation shape below. + +3. **Release** — `gddy platform app release --application-id --version ` resends every setting in `godaddy.toml`, same as it does for actions/subscriptions/extensions. It rejects any settings entry with neither `presentation` nor `presentationFile`: + + ```json + { "error": { "code": "VALIDATION_ERROR", "message": "settings 'godaddy-tax' has no presentation — add a [settings.presentation] block or a presentationFile before releasing" } } + ``` + +4. **Enable/backfill** — `gddy platform app enable --store-id ` makes the placement discoverable for a store. Settings (like actions/subscriptions/uiExtensions) are keyed per-release with no inheritance — a store already enabled against an older release doesn't pick up settings added in a newer one until `enable` is re-run for that store. + +## Presentation shape + +`presentation` is a `settings-form-v1` form: one or more sections, each with one or more fields. Add it as nested tables under the setting's own `[[settings]]` entry: + +```toml +[[settings.presentation.sections]] +key = "defaults" +label = "Calculation defaults" + +[[settings.presentation.sections.fields]] +type = "select" +key = "calculateUsing" +label = "Calculate using" +required = true +defaultValue = "destination" + +[[settings.presentation.sections.fields.options]] +label = "Customer destination" +value = "destination" + +[[settings.presentation.sections.fields.options]] +label = "Order origin" +value = "origin" +``` + +A field `key` becomes a property in the merchant's saved `values` document and must stay stable once merchants have data — don't rename a field key without a GPA-owned migration. Section keys are display-only and safe to change freely. + +Supported field types (`type` discriminates the shape — every field needs `type`, `key`, `label`): + +- **`text`** / **`textarea`** — string value. + - Optional: `placeholder`, `minLength`, `maxLength`, `defaultValue` (string). +- **`number`** — numeric value. + - Optional: `min`, `max`, `step`, `suffix`, `defaultValue` (number). +- **`boolean`** — true/false value. + - Optional: `defaultValue` (bool). +- **`select`** — one value chosen from `options` (required, non-empty). + - Each option: `value`, `label`, optional `description`. Add more by repeating the `[[...options]]` array-of-tables header — one block per option, at whatever nesting depth the field sits (e.g. `[[settings.presentation.sections.fields.item.fields.options]]` inside a `list-group` item field): + ```toml + [[settings.presentation.sections.fields.options]] + label = "United States" + value = "US" + + [[settings.presentation.sections.fields.options]] + label = "Canada" + value = "CA" + ``` + - Optional: `defaultValue` must match one option's `value`. +- **`multi-select`** — array of values chosen from `options` (required, non-empty). + - Optional: `minItems`, `maxItems`, `defaultValue` (array, each entry must match an option). +- **`list-group`** — array of objects, one merchant-added item per array entry. + - `item.idField` names the item's reserved stable-UUID property (renderer-generated, GPA echoes it back unchanged — it can't also be an editable field key). + - `item.titleField` (optional) names a `text`/`textarea`/`number`/`select` field used to label each item in the UI. + - `item.fields` is a list of fields using the same types above — `list-group` may nest one level further (max depth 2), but not deeper. + +Every field, section, and `list-group` item field also accepts `description`. Sections additionally accept `visibleWhen = { field = "...", equals = ... }` to show/hide based on another top-level field's value. + +Worked example — the full `godaddy.toml` shape for a manual-tax-style GPA with a nested `list-group`: + +```toml +[[settings]] +group = "tax-center" +slug = "manual-tax" +title = "Manual Tax" +entryPath = "/settings/manual-tax" +order = 10 +capabilities = ["read", "write", "validate"] + +[settings.icon] +name = "percent" +library = "lucide" + +[[settings.presentation.sections]] +key = "rules" +label = "Tax rules" + +[[settings.presentation.sections.fields]] +type = "list-group" +key = "rules" +label = "Rules" +minItems = 1 + +[settings.presentation.sections.fields.item] +idField = "id" +titleField = "displayName" + +[[settings.presentation.sections.fields.item.fields]] +type = "select" +key = "country" +label = "Country" + +[[settings.presentation.sections.fields.item.fields.options]] +label = "United States" +value = "US" + +[[settings.presentation.sections.fields.item.fields]] +type = "text" +key = "displayName" +label = "Display at checkout" +``` + +### Referencing a JSON file instead of inline TOML + +For a GPA that already keeps its presentation as a JSON fixture (a common shape for existing registry examples), point `presentationFile` at it instead of re-authoring the same form as TOML: + +```toml +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "/settings/manual-tax" +presentationFile = "fixtures/manual-tax-registry-presentation.json" +``` + +The referenced file must be the complete API presentation object — `type` (`"form"`), `schemaVersion` (`"settings-form-v1"`), and `sections` — the same shape `createRelease.settings[].presentation` expects, so an existing fixture can be reused verbatim. The path is relative to the directory containing the `godaddy.toml` being released, not the shell's working directory. `presentation` and `presentationFile` are mutually exclusive; both forms run through the same field/section validation and produce an identical release payload. The file itself is only opened at `release` — like inline `presentation`, it's optional at `add settings`/`config validate` time — and a missing, unreadable, malformed, or wrong-`type`/`schemaVersion` file fails the release with a `VALIDATION_ERROR` naming the resolved path. + +## What the CLI validates locally vs. server-side + +`gddy platform app add settings`/`release` catch cheap, structural problems before any network call: + +- `group`/`slug` match the platform's slug pattern (`lowercase-with-dashes`). +- `entryPath` is a route-safe path (`/`-prefixed, no query string/fragment/`..`), and doesn't overlap another setting's `entryPath` in the same manifest. +- `capabilities` are a subset of `read`, `write`, `validate`, `test`, `delete`. +- `icon.library` is one of `ux`, `lucide`, `commerce`. +- Every field/section `key` matches the platform's key pattern, `select`/`multi-select` have at least one option, and no two fields/sections share a key. +- `presentation` and `presentationFile` aren't both set on the same entry — checked as soon as the manifest is touched, not just at release. + +Deeper semantics stay server-validated — bounds consistency (`maxLength ≥ minLength`), a `defaultValue` actually matching a registered option or satisfying bounds, and `list-group` nesting depth. A rejection there surfaces as a `release` API error, not a local one. + +## Gotchas + +- **No release inheritance.** `release` resends every current setting from `godaddy.toml`; leaving one out doesn't archive it globally, but any store enabled against the *new* release loses it. +- **Existing stores don't auto-upgrade.** Adding settings to a release only affects stores enabled *after* that release goes active — re-run `gddy platform app enable --store-id ` per store to backfill. +- **`presentation`/`presentationFile` is mandatory before release, not before `add settings`.** A placement-only entry parses and works fine for every other command (`add action`, `info`, `validate`, `deploy`) — it only fails at `release`, with the message shown above. diff --git a/rust/examples/smoke_mock_server.rs b/rust/examples/smoke_mock_server.rs new file mode 100644 index 00000000..958f8347 --- /dev/null +++ b/rust/examples/smoke_mock_server.rs @@ -0,0 +1,91 @@ +//! Mock `app-registry-api` for `rust/scripts/smoke-test.sh`. Dev-only — +//! must stay a Cargo example, never a `[[bin]]` (uses the `httpmock` dev-dependency). +#![allow(clippy::print_stdout)] + +use std::thread; +use std::time::Duration; + +use httpmock::{HttpMockRequest, HttpMockResponse, MockServer}; +use serde_json::{Value, json}; + +const GRAPHQL_PATH: &str = "/v1/apps/app-registry-subgraph"; + +/// `applicationId` values that select a canned error response instead of +/// the normal success echo, so the smoke test can drive real HTTP/GraphQL +/// error handling without a second mock process. +const GRAPHQL_ERROR_APPLICATION_ID: &str = "smoke-graphql-error-id"; +const HTTP_500_APPLICATION_ID: &str = "smoke-http-500-id"; +const HTTP_401_APPLICATION_ID: &str = "smoke-http-401-id"; + +fn create_release_response(req: &HttpMockRequest) -> HttpMockResponse { + let body: Value = match serde_json::from_slice(&req.body_bytes()) { + Ok(v) => v, + Err(e) => { + return HttpMockResponse::builder() + .status(400) + .body(format!("smoke mock: request body is not JSON: {e}")) + .build(); + } + }; + let query = body["query"].as_str().unwrap_or_default(); + if !query.contains("CreateRelease") { + return HttpMockResponse::builder() + .status(400) + .body(format!( + "smoke mock: only CreateRelease is mocked, got query: {query}" + )) + .build(); + } + + let input = &body["variables"]["input"]; + match input["applicationId"].as_str() { + Some(HTTP_500_APPLICATION_ID) => { + return HttpMockResponse::builder() + .status(500) + .body("smoke mock: internal server error") + .build(); + } + Some(HTTP_401_APPLICATION_ID) => { + return HttpMockResponse::builder() + .status(401) + .body("smoke mock: unauthorized") + .build(); + } + Some(GRAPHQL_ERROR_APPLICATION_ID) => { + return HttpMockResponse::builder() + .status(200) + .header("content-type", "application/json") + .body( + json!({ "data": null, "errors": [{ "message": "release not found" }] }) + .to_string(), + ) + .build(); + } + _ => {} + } + let release = json!({ + "id": "smoke-release-id", + "version": input.get("version").cloned().unwrap_or(json!("0.0.0")), + "description": input.get("description").cloned().unwrap_or(Value::Null), + "createdAt": "2026-01-01T00:00:00Z", + "uiExtensions": input.get("uiExtensions").cloned().unwrap_or(json!([])), + "settings": input.get("settings").cloned().unwrap_or(json!([])), + }); + HttpMockResponse::builder() + .status(200) + .header("content-type", "application/json") + .body(json!({ "data": { "createRelease": release } }).to_string()) + .build() +} + +fn main() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method("POST").path(GRAPHQL_PATH); + then.respond_with(create_release_response); + }); + println!("PORT={}", server.port()); + loop { + thread::sleep(Duration::from_secs(3600)); + } +} diff --git a/rust/scripts/build-and-verify.sh b/rust/scripts/build-and-verify.sh new file mode 100755 index 00000000..19fe6438 --- /dev/null +++ b/rust/scripts/build-and-verify.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Run check/clippy/fmt/module-size (no tests), then build and run the `gddy` +# binary. Any arguments passed to this script are forwarded to `gddy` at the +# end (e.g. `./scripts/build-and-verify.sh platform app --help`); with no +# arguments it just runs `gddy --help` as a smoke check. +set -euo pipefail + +rust_root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$rust_root" + +step() { + echo + echo "==> $1" +} + +step "cargo check" +cargo check + +step "cargo clippy -- -D warnings" +cargo clippy -- -D warnings + +step "cargo fmt --check" +cargo fmt --check + +step "check-module-size.sh" +./scripts/check-module-size.sh + +step "cargo build" +cargo build + +step "gddy ${*:-\"--help\"}" +if [ "$#" -gt 0 ]; then + ./target/debug/gddy "$@" +else + ./target/debug/gddy --help +fi diff --git a/rust/scripts/smoke-test.sh b/rust/scripts/smoke-test.sh new file mode 100755 index 00000000..dd9975a9 --- /dev/null +++ b/rust/scripts/smoke-test.sh @@ -0,0 +1,865 @@ +#!/usr/bin/env bash +# Smoke test for every settings situation, over the real gddy binary and a +# mocked app-registry-api. Never touches ~/.config/gddy — uses a scratch XDG_CONFIG_HOME. +set -uo pipefail + +rust_root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$rust_root" + +mock_pid="" +scratch_dir="" +failures=0 + +pass() { echo "PASS: $1"; } +fail() { + echo "FAIL: $1" + failures=$((failures + 1)) +} + +cleanup() { + if [ -n "$mock_pid" ]; then + kill "$mock_pid" 2>/dev/null + wait "$mock_pid" 2>/dev/null + fi + if [ -n "$scratch_dir" ]; then + rm -rf "$scratch_dir" + fi +} +trap cleanup EXIT + +echo "==> cargo build" +cargo build --quiet +cargo build --quiet --example smoke_mock_server + +echo "==> starting mock app-registry-api" +mock_log="$(mktemp)" +./target/debug/examples/smoke_mock_server >"$mock_log" 2>&1 & +mock_pid=$! +mock_port="" +for _ in $(seq 1 20); do + mock_port="$(grep -m1 '^PORT=' "$mock_log" 2>/dev/null | cut -d= -f2)" + [ -n "$mock_port" ] && break + sleep 0.25 +done +if [ -z "$mock_port" ]; then + fail "mock server never printed its port" + cat "$mock_log" + exit 1 +fi +echo " mock listening on 127.0.0.1:$mock_port" + +scratch_dir="$(mktemp -d)" +export XDG_CONFIG_HOME="$scratch_dir/.config" +mkdir -p "$XDG_CONFIG_HOME/gddy" +cat >"$XDG_CONFIG_HOME/gddy/environments.toml" <godaddy.smoke.toml +} + +check_valid() { + local desc="$1" out + out="$(gddy platform app config validate 2>&1)" + if echo "$out" | jq -e '.data.valid == true' >/dev/null 2>&1; then + pass "$desc" + else + fail "$desc: $out" + fi +} + +check_invalid() { + local desc="$1" pattern="$2" out + out="$(gddy platform app config validate 2>&1)" + if echo "$out" | jq -e '.data.valid == false' >/dev/null 2>&1 && echo "$out" | grep -q "$pattern"; then + pass "$desc" + else + fail "$desc: $out" + fi +} + +echo "=== Group A: local validation (config validate, no network) ===" + +write_manifest <<'EOF' +[[settings]] +group = "Tax_Center" +slug = "manual-tax" +entryPath = "/settings/manual-tax" +EOF +check_invalid "A1 rejects invalid group slug" "settings\[0\].group must match" + +write_manifest <<'EOF' +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "settings/manual-tax" +EOF +check_invalid "A2 rejects entryPath missing leading slash" "must be a route-safe path" + +write_manifest <<'EOF' +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "/settings/manual-tax" +capabilities = ["read", "not-a-capability"] +EOF +check_invalid "A3 rejects an unknown capability" "capabilities contains" + +write_manifest <<'EOF' +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "/settings/manual-tax" + +[settings.icon] +name = "percent" +library = "material" +EOF +check_invalid "A4 rejects an unknown icon library" "icon.library must be one of" + +write_manifest <<'EOF' +[[settings]] +group = "tax-center" +slug = "a" +entryPath = "/settings/tax" + +[[settings]] +group = "tax-center" +slug = "b" +entryPath = "/settings/tax" +EOF +check_invalid "A5 rejects overlapping entryPaths" "overlaps with" + +write_manifest <<'EOF' +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "/settings/manual-tax" + +[[settings.presentation.sections]] +key = "defaults" +label = "Defaults" + +[[settings.presentation.sections.fields]] +type = "boolean" +key = "flagA" +label = "Flag A" + +[[settings.presentation.sections]] +key = "defaults" +label = "Defaults again" + +[[settings.presentation.sections.fields]] +type = "boolean" +key = "flagB" +label = "Flag B" +EOF +check_invalid "A6 rejects duplicate section keys" "duplicates another section key" + +write_manifest <<'EOF' +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "/settings/manual-tax" + +[[settings.presentation.sections]] +key = "s1" +label = "S1" + +[[settings.presentation.sections.fields]] +type = "boolean" +key = "flag" +label = "Flag" + +[[settings.presentation.sections]] +key = "s2" +label = "S2" + +[[settings.presentation.sections.fields]] +type = "boolean" +key = "flag" +label = "Flag again" +EOF +check_invalid "A7 rejects duplicate field keys across sections" "duplicates another field key" + +write_manifest <<'EOF' +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "/settings/manual-tax" + +[[settings.presentation.sections]] +key = "s1" +label = "S1" + +[[settings.presentation.sections.fields]] +type = "select" +key = "choice" +label = "Choice" +options = [] +EOF +check_invalid "A8 rejects a select field with no options" "must contain at least one option" + +write_manifest <<'EOF' +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "/settings/manual-tax" + +[[settings.presentation.sections]] +key = "s1" +label = "S1" + +[[settings.presentation.sections.fields]] +type = "boolean" +key = "1bad" +label = "Bad" +EOF +check_invalid "A9 rejects a malformed field key" "fields\[0\].key must match" + +write_manifest <<'EOF' +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "/settings/manual-tax" + +[[settings.presentation.sections]] +key = "s1" +label = "S1" + +[[settings.presentation.sections.fields]] +type = "list-group" +key = "rules" +label = "Rules" + +[settings.presentation.sections.fields.item] +idField = "1bad" + +[[settings.presentation.sections.fields.item.fields]] +type = "text" +key = "name" +label = "Name" +EOF +check_invalid "A10 rejects a malformed list-group idField" "item.idField must match" + +write_manifest <<'EOF' +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "/settings/manual-tax" +presentationFile = "fixtures/manual-tax.json" + +[[settings.presentation.sections]] +key = "x" +label = "X" + +[[settings.presentation.sections.fields]] +type = "boolean" +key = "flag" +label = "Flag" +EOF +check_invalid "A11 rejects presentation + presentationFile on the same entry" "presentationFile" + +write_manifest <<'EOF' +EOF +icon_out="$(gddy platform app add settings \ + --group tax-center --slug manual-tax --entry-path /settings/manual-tax \ + --icon-name percent 2>&1)" +if [ $? -ne 0 ] && echo "$icon_out" | grep -q "icon-name and --icon-library must be provided together"; then + pass "A12 rejects --icon-name without --icon-library" +else + fail "A12 did not reject a lone --icon-name: $icon_out" +fi + +write_manifest <<'EOF' +EOF +icon_out="$(gddy platform app add settings \ + --group tax-center --slug manual-tax --entry-path /settings/manual-tax \ + --icon-library lucide 2>&1)" +if [ $? -ne 0 ] && echo "$icon_out" | grep -q "icon-name and --icon-library must be provided together"; then + pass "A13 rejects --icon-library without --icon-name" +else + fail "A13 did not reject a lone --icon-library: $icon_out" +fi + +write_manifest <<'EOF' +[[settings]] +group = "tax-center" +slug = "Manual_Tax" +entryPath = "/settings/manual-tax" +EOF +check_invalid "A14 rejects invalid slug pattern" "settings\[0\].slug must match" + +write_manifest <<'EOF' +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "/settings/manual-tax?x=1" +EOF +check_invalid "A15 rejects entryPath with a query string" "route-safe path" + +write_manifest <<'EOF' +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "/settings/manual-tax#frag" +EOF +check_invalid "A16 rejects entryPath with a fragment" "route-safe path" + +write_manifest <<'EOF' +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "https://example.com/settings" +EOF +check_invalid "A17 rejects entryPath with a scheme" "route-safe path" + +write_manifest <<'EOF' +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "/settings/../manual-tax" +EOF +check_invalid "A18 rejects entryPath with a .. segment" "route-safe path" + +write_manifest <<'EOF' +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "/" +EOF +check_invalid "A19 rejects a bare / entryPath" "route-safe path" + +write_manifest <<'EOF' +[[settings]] +group = "tax-center" +slug = "root" +entryPath = "/settings" + +[[settings]] +group = "tax-center" +slug = "child" +entryPath = "/settings/tax" +EOF +check_invalid "A20 rejects entryPath overlap via path prefix" "overlaps with" + +write_manifest <<'EOF' +EOF +add_out="$(gddy platform app add settings \ + --group tax-center --slug manual-tax \ + --entry-path /settings/manual-tax \ + --title "GoDaddy Tax" --description "Tax settings" \ + --order 10 \ + --capability read --capability write \ + --icon-name percent --icon-library lucide 2>&1)" +add_status=$? +if [ "$add_status" -eq 0 ] \ + && grep -q 'group = "tax-center"' godaddy.smoke.toml \ + && grep -q 'slug = "manual-tax"' godaddy.smoke.toml \ + && grep -q 'entryPath = "/settings/manual-tax"' godaddy.smoke.toml \ + && grep -q 'title = "GoDaddy Tax"' godaddy.smoke.toml \ + && grep -q 'description = "Tax settings"' godaddy.smoke.toml \ + && grep -q 'order = 10' godaddy.smoke.toml \ + && grep -q '"read"' godaddy.smoke.toml \ + && grep -q '"write"' godaddy.smoke.toml \ + && grep -q 'name = "percent"' godaddy.smoke.toml \ + && grep -q 'library = "lucide"' godaddy.smoke.toml; then + pass "A21 add settings writes the full flag set to godaddy.toml" +else + fail "A21 add settings did not write the expected fields (exit $add_status): $add_out" +fi +check_valid "A22 config validate accepts the CLI-added placement-only entry" + +echo "=== Group B: release-time behavior (mocked network) ===" + +cat >fixtures/manual-tax.json <<'EOF' +{ + "type": "form", + "schemaVersion": "settings-form-v1", + "sections": [{ + "key": "defaults", + "label": "Defaults", + "fields": [ + {"type": "text", "key": "displayName", "label": "Display name", "required": true, "defaultValue": "GoDaddy Tax"}, + {"type": "textarea", "key": "notes", "label": "Notes"}, + {"type": "number", "key": "rate", "label": "Rate", "min": 0, "max": 100, "defaultValue": 7.5}, + {"type": "boolean", "key": "autoCalculate", "label": "Auto-calculate", "defaultValue": true}, + {"type": "select", "key": "calculateUsing", "label": "Calculate using", "defaultValue": "destination", + "options": [{"value": "destination", "label": "Destination"}, {"value": "origin", "label": "Origin"}]}, + {"type": "multi-select", "key": "regions", "label": "Regions", + "options": [{"value": "us", "label": "US"}, {"value": "ca", "label": "Canada"}]}, + {"type": "list-group", "key": "rules", "label": "Rules", + "item": {"idField": "id", "titleField": "country", + "fields": [{"type": "select", "key": "country", "label": "Country", + "options": [{"value": "US", "label": "United States"}]}]}} + ] + }] +} +EOF + +write_manifest <<'EOF' + +[[settings]] +group = "tax-center" +slug = "manual-tax" +title = "GoDaddy Tax" +description = "Tax settings" +entryPath = "/settings/manual-tax" +order = 10 +capabilities = ["read", "write", "validate"] +presentationFile = "fixtures/manual-tax.json" + +[settings.icon] +name = "percent" +library = "lucide" + +[[settings]] +group = "tax-center" +slug = "manual-tax-inline" +entryPath = "/settings/manual-tax-inline" + +[[settings.presentation.sections]] +key = "defaults" +label = "Defaults" + +[[settings.presentation.sections.fields]] +type = "text" +key = "displayName" +label = "Display name" +required = true +defaultValue = "GoDaddy Tax" + +[[settings.presentation.sections.fields]] +type = "textarea" +key = "notes" +label = "Notes" + +[[settings.presentation.sections.fields]] +type = "number" +key = "rate" +label = "Rate" +min = 0.0 +max = 100.0 +defaultValue = 7.5 + +[[settings.presentation.sections.fields]] +type = "boolean" +key = "autoCalculate" +label = "Auto-calculate" +defaultValue = true + +[[settings.presentation.sections.fields]] +type = "select" +key = "calculateUsing" +label = "Calculate using" +defaultValue = "destination" + +[[settings.presentation.sections.fields.options]] +value = "destination" +label = "Destination" + +[[settings.presentation.sections.fields.options]] +value = "origin" +label = "Origin" + +[[settings.presentation.sections.fields]] +type = "multi-select" +key = "regions" +label = "Regions" + +[[settings.presentation.sections.fields.options]] +value = "us" +label = "US" + +[[settings.presentation.sections.fields.options]] +value = "ca" +label = "Canada" + +[[settings.presentation.sections.fields]] +type = "list-group" +key = "rules" +label = "Rules" + +[settings.presentation.sections.fields.item] +idField = "id" +titleField = "country" + +[[settings.presentation.sections.fields.item.fields]] +type = "select" +key = "country" +label = "Country" + +[[settings.presentation.sections.fields.item.fields.options]] +value = "US" +label = "United States" +EOF + +check_valid "B1 config validate accepts both entries, fixture unopened" + +cp fixtures/manual-tax.json fixtures/manual-tax.json.bak + +rm fixtures/manual-tax.json +out="$(gddy platform app release --application-id smoke-app-id --version 0.0.1 2>&1)" +status=$? +if [ "$status" -ne 0 ] && echo "$out" | grep -q "could not be read" && echo "$out" | grep -q "manual-tax.json"; then + pass "B2 release fails when presentationFile is missing" +else + fail "B2 did not fail as expected on a missing presentationFile (exit $status): $out" +fi + +echo "not json" >fixtures/manual-tax.json +out="$(gddy platform app release --application-id smoke-app-id --version 0.0.1 2>&1)" +status=$? +if [ "$status" -ne 0 ] && echo "$out" | grep -q "is invalid" && echo "$out" | grep -q "manual-tax.json"; then + pass "B3 release fails on malformed JSON in presentationFile" +else + fail "B3 did not fail as expected on malformed presentationFile (exit $status): $out" +fi + +jq '.schemaVersion = "something-else"' fixtures/manual-tax.json.bak >fixtures/manual-tax.json +out="$(gddy platform app release --application-id smoke-app-id --version 0.0.1 2>&1)" +status=$? +if [ "$status" -ne 0 ] && echo "$out" | grep -q 'schemaVersion must be'; then + pass "B4 release fails on the wrong schemaVersion in presentationFile" +else + fail "B4 did not fail as expected on a wrong schemaVersion (exit $status): $out" +fi + +mv fixtures/manual-tax.json.bak fixtures/manual-tax.json + +echo "$(base_fields) + +[[settings]] +group = \"tax-center\" +slug = \"no-presentation\" +entryPath = \"/settings/no-presentation\"" >godaddy.smoke.toml +out="$(gddy platform app release --application-id smoke-app-id --version 0.0.1 2>&1)" +status=$? +if [ "$status" -ne 0 ] && echo "$out" | grep -q "no presentation"; then + pass "B5 release fails when a setting has neither presentation nor presentationFile" +else + fail "B5 did not fail as expected on a placement-only entry (exit $status): $out" +fi + +write_manifest <<'EOF' + +[[settings]] +group = "tax-center" +slug = "manual-tax" +title = "GoDaddy Tax" +description = "Tax settings" +entryPath = "/settings/manual-tax" +order = 10 +capabilities = ["read", "write", "validate"] +presentationFile = "fixtures/manual-tax.json" + +[settings.icon] +name = "percent" +library = "lucide" + +[[settings]] +group = "tax-center" +slug = "manual-tax-inline" +entryPath = "/settings/manual-tax-inline" + +[[settings.presentation.sections]] +key = "defaults" +label = "Defaults" + +[[settings.presentation.sections.fields]] +type = "text" +key = "displayName" +label = "Display name" +required = true +defaultValue = "GoDaddy Tax" + +[[settings.presentation.sections.fields]] +type = "textarea" +key = "notes" +label = "Notes" + +[[settings.presentation.sections.fields]] +type = "number" +key = "rate" +label = "Rate" +min = 0.0 +max = 100.0 +defaultValue = 7.5 + +[[settings.presentation.sections.fields]] +type = "boolean" +key = "autoCalculate" +label = "Auto-calculate" +defaultValue = true + +[[settings.presentation.sections.fields]] +type = "select" +key = "calculateUsing" +label = "Calculate using" +defaultValue = "destination" + +[[settings.presentation.sections.fields.options]] +value = "destination" +label = "Destination" + +[[settings.presentation.sections.fields.options]] +value = "origin" +label = "Origin" + +[[settings.presentation.sections.fields]] +type = "multi-select" +key = "regions" +label = "Regions" + +[[settings.presentation.sections.fields.options]] +value = "us" +label = "US" + +[[settings.presentation.sections.fields.options]] +value = "ca" +label = "Canada" + +[[settings.presentation.sections.fields]] +type = "list-group" +key = "rules" +label = "Rules" + +[settings.presentation.sections.fields.item] +idField = "id" +titleField = "country" + +[[settings.presentation.sections.fields.item.fields]] +type = "select" +key = "country" +label = "Country" + +[[settings.presentation.sections.fields.item.fields.options]] +value = "US" +label = "United States" +EOF + +release_out="$(gddy platform app release --application-id smoke-app-id --version 0.0.1 2>&1)" +if echo "$release_out" | jq -e '.data.settings | length == 2' >/dev/null 2>&1; then + file_presentation="$(echo "$release_out" | jq -c '.data.settings[] | select(.appSettingSlug=="manual-tax") | .presentation')" + inline_presentation="$(echo "$release_out" | jq -c '.data.settings[] | select(.appSettingSlug=="manual-tax-inline") | .presentation')" + if [ "$file_presentation" = "$inline_presentation" ] && [ -n "$file_presentation" ]; then + pass "B6 presentationFile and inline presentation are identical across all field types" + else + fail "B6 presentation payloads differ — file: $file_presentation inline: $inline_presentation" + fi + file_entry="$(echo "$release_out" | jq -c '.data.settings[] | select(.appSettingSlug=="manual-tax")')" + if echo "$file_entry" | jq -e ' + .title == "GoDaddy Tax" and .description == "Tax settings" and .order == 10 + and .capabilities == ["read","write","validate"] + and .iconName == "percent" and .iconLibrary == "lucide" + ' >/dev/null 2>&1; then + pass "B7 release echoes title/description/order/capabilities/icon" + else + fail "B7 release did not echo optional fields correctly: $file_entry" + fi +else + fail "B6/B7 release did not succeed with both settings entries: $release_out" +fi + +cp godaddy.smoke.toml godaddy.smoke.toml.bak +echo "this = is not [valid toml" >godaddy.smoke.toml +out="$(gddy platform app release --application-id smoke-app-id --version 0.0.1 2>&1)" +status=$? +if [ "$status" -ne 0 ] && echo "$out" | grep -q "failed to load"; then + pass "B8 release fails on a manifest that fails to parse" +else + fail "B8 did not fail on an unparseable manifest (exit $status): $out" +fi +mv godaddy.smoke.toml.bak godaddy.smoke.toml + +echo "=== Group C: additional settings coverage ===" + +write_manifest <<'EOF' + +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "/settings/manual-tax" +metadata = { internalId = "abc123", tags = ["a", "b"] } + +[[settings.presentation.sections]] +key = "defaults" +label = "Defaults" + +[[settings.presentation.sections.fields]] +type = "boolean" +key = "flag" +label = "Flag" +EOF +release_out="$(gddy platform app release --application-id smoke-app-id --version 0.0.1 2>&1)" +if echo "$release_out" | jq -e ' + .data.settings[0].metadata.internalId == "abc123" + and .data.settings[0].metadata.tags == ["a","b"] + ' >/dev/null 2>&1; then + pass "C1 release echoes arbitrary settings metadata" +else + fail "C1 metadata not echoed as expected: $release_out" +fi + +write_manifest <<'EOF' +EOF +release_out="$(gddy platform app release --application-id smoke-app-id --version 0.0.1 2>&1)" +if echo "$release_out" | jq -e '.data.settings == []' >/dev/null 2>&1; then + pass "C2 release succeeds with zero settings entries" +else + fail "C2 release with no settings did not return an empty settings array: $release_out" +fi + +write_manifest <<'EOF' + +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "/settings/manual-tax" + +[[settings.presentation.sections]] +key = "defaults" +label = "Defaults" + +[[settings.presentation.sections.fields]] +type = "boolean" +key = "autoCalculate" +label = "Auto-calculate" +defaultValue = true + +[[settings.presentation.sections]] +key = "advanced" +label = "Advanced" + +[settings.presentation.sections.visibleWhen] +field = "autoCalculate" +equals = true + +[[settings.presentation.sections.fields]] +type = "multi-select" +key = "regions" +label = "Regions" +minItems = 1 +maxItems = 2 + +[[settings.presentation.sections.fields.options]] +value = "us" +label = "US" + +[[settings.presentation.sections.fields.options]] +value = "ca" +label = "Canada" + +[[settings.presentation.sections.fields]] +type = "list-group" +key = "rules" +label = "Rules" +minItems = 0 +maxItems = 5 + +[settings.presentation.sections.fields.item] +idField = "id" + +[[settings.presentation.sections.fields.item.fields]] +type = "select" +key = "priority" +label = "Priority" +defaultValue = 1 + +[[settings.presentation.sections.fields.item.fields.options]] +value = 1 +label = "Low" + +[[settings.presentation.sections.fields.item.fields.options]] +value = 2 +label = "High" +EOF +check_valid "C3a config validate accepts minItems/maxItems, visibleWhen, and numeric option values" + +release_out="$(gddy platform app release --application-id smoke-app-id --version 0.0.1 2>&1)" +if echo "$release_out" | jq -e ' + .data.settings[0].presentation.sections[1].visibleWhen == {"field":"autoCalculate","equals":true} + and .data.settings[0].presentation.sections[1].fields[0].minItems == 1 + and .data.settings[0].presentation.sections[1].fields[0].maxItems == 2 + and .data.settings[0].presentation.sections[1].fields[1].minItems == 0 + and .data.settings[0].presentation.sections[1].fields[1].maxItems == 5 + and .data.settings[0].presentation.sections[1].fields[1].item.fields[0].options[0].value == 1 + ' >/dev/null 2>&1; then + pass "C3b release echoes visibleWhen/minItems/maxItems/numeric option values unchanged" +else + fail "C3b release did not echo extended field options as expected: $release_out" +fi + +echo "=== Group D: server error responses (mocked network) ===" + +write_manifest <<'EOF' + +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "/settings/manual-tax" + +[[settings.presentation.sections]] +key = "defaults" +label = "Defaults" + +[[settings.presentation.sections.fields]] +type = "boolean" +key = "flag" +label = "Flag" +EOF + +out="$(gddy platform app release --application-id smoke-http-500-id --version 0.0.1 2>&1)" +status=$? +if [ "$status" -ne 0 ] && echo "$out" | grep -q "HTTP error 500"; then + pass "D1 release surfaces a 500 response from the server" +else + fail "D1 did not surface a 500 as expected (exit $status): $out" +fi + +out="$(gddy platform app release --application-id smoke-http-401-id --version 0.0.1 2>&1)" +status=$? +if [ "$status" -ne 0 ] && echo "$out" | grep -q "HTTP error 401"; then + pass "D2 release surfaces a 401 response from the server" +else + fail "D2 did not surface a 401 as expected (exit $status): $out" +fi + +out="$(gddy platform app release --application-id smoke-graphql-error-id --version 0.0.1 2>&1)" +status=$? +if [ "$status" -ne 0 ] && echo "$out" | grep -q "release not found"; then + pass "D3 release surfaces a GraphQL errors array on an HTTP 200" +else + fail "D3 did not surface a GraphQL error as expected (exit $status): $out" +fi + +echo +if [ "$failures" -eq 0 ]; then + echo "==> smoke test passed" + exit 0 +else + echo "==> smoke test FAILED ($failures check(s))" + exit 1 +fi diff --git a/rust/src/application/client.rs b/rust/src/application/client.rs index aa0bf8ec..f6d99b34 100644 --- a/rust/src/application/client.rs +++ b/rust/src/application/client.rs @@ -188,7 +188,7 @@ impl ApplicationClient { pub async fn create_release(&self, input: Value) -> Result { self.query(json!({ - "query": "mutation CreateRelease($input: MutationCreateReleaseInput!) { createRelease(input: $input) { id version description createdAt uiExtensions { id name handle type source target } } }", + "query": "mutation CreateRelease($input: MutationCreateReleaseInput!) { createRelease(input: $input) { id version description createdAt uiExtensions { id name handle type source target } settings { id groupSlug appSettingSlug entryPath capabilities order title } } }", "variables": { "input": input } })) .await @@ -489,6 +489,62 @@ mod tests { assert_eq!(data["activateRelease"]["status"], "ACTIVE"); } + #[tokio::test] + async fn create_release_sends_settings_input_and_returns_settings() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/apps/app-registry-subgraph") + .header("authorization", "Bearer test-token") + .is_true(|req| { + let body = req.body_string(); + body.contains("CreateRelease") + && body.contains("settings { id groupSlug appSettingSlug entryPath capabilities order title }") + && body.contains(r#""entryPath":"/settings/godaddy-tax""#) + }); + then.status(200).json_body(json!({ + "data": { + "createRelease": { + "id": "rel-1", + "version": "1.0.0", + "settings": [{ + "id": "setting-1", + "groupSlug": "tax-center", + "appSettingSlug": "godaddy-tax", + "entryPath": "/settings/godaddy-tax", + "capabilities": ["read", "write"], + "order": 10, + "title": "GoDaddy Tax" + }] + } + } + })); + }) + .await; + + let input = json!({ + "applicationId": "app-123", + "version": "1.0.0", + "settings": [{ + "groupSlug": "tax-center", + "appSettingSlug": "godaddy-tax", + "entryPath": "/settings/godaddy-tax", + "presentation": { "type": "form", "schemaVersion": "settings-form-v1", "sections": [] } + }] + }); + let data = ApplicationClient::new(server.base_url(), "test-token") + .create_release(input) + .await + .expect("create release"); + + mock.assert_async().await; + assert_eq!( + data["createRelease"]["settings"][0]["entryPath"], + "/settings/godaddy-tax" + ); + } + #[tokio::test] async fn activate_release_surfaces_graphql_errors() { let server = MockServer::start_async().await; diff --git a/rust/src/application/commands/add.rs b/rust/src/application/commands/add.rs index af237696..db8a9925 100644 --- a/rust/src/application/commands/add.rs +++ b/rust/src/application/commands/add.rs @@ -6,7 +6,7 @@ use cli_engine::{ }; use serde_json::json; -use super::schemas::{ConfigAction, ConfigSubscription}; +use super::schemas::{ConfigAction, ConfigSetting, ConfigSubscription}; #[derive(Debug, Clone, clap::Args)] struct ActionArgs { @@ -19,6 +19,51 @@ struct ActionArgs { url: String, } +#[derive(Debug, Clone, clap::Args)] +struct SettingsArgs { + /// Commerce-owned settings group slug written into godaddy.toml. + #[arg(long)] + group: String, + + /// App-owned setting slug written into godaddy.toml. + #[arg(long)] + slug: String, + + /// Display title for the settings entry. + #[arg(long)] + title: Option, + + /// Display description for the settings entry. + #[arg(long)] + description: Option, + + /// GPA settings namespace path lifecycle endpoints live beneath. + #[arg(long = "entry-path", value_name = "PATH")] + entry_path: String, + + /// Sort order within the settings group. + #[arg(long)] + order: Option, + + /// One or more lifecycle capabilities (read, write, validate, test, + /// delete). Defaults to read+write server-side when omitted. + #[arg(long = "capability", value_name = "CAPABILITY", num_args = 1..)] + capabilities: Vec, + + /// Icon name for display; must be provided together with --icon-library. + #[arg(long = "icon-name", value_name = "NAME")] + icon_name: Option, + + /// Icon library for display; must be provided together with --icon-name. + #[arg(long = "icon-library", value_name = "LIBRARY")] + icon_library: Option, + + /// Path to a JSON presentation file; alternative to hand-authoring + /// [settings.presentation]. + #[arg(long = "presentation-file", value_name = "PATH")] + presentation_file: Option, +} + #[derive(Debug, Clone, clap::Args)] struct SubscriptionArgs { /// Unique subscription name written into godaddy.toml. @@ -122,5 +167,90 @@ pub(super) fn group() -> RuntimeGroupSpec { ) }, )) + .with_command(RuntimeCommandSpec::new_typed_with_context::< + SettingsArgs, + _, + _, + _, + >( + CommandSpec::from_args::( + "settings", + "Add an application settings placement to godaddy.toml", + ) + .with_long( + "Register the placement metadata for an application-settings \ + capability in the godaddy.toml manifest in the current directory. \ + This command only writes group/slug/entryPath/order/capabilities/icon \ + — it cannot author the settings-form-v1 form itself. After running \ + it, hand-add a [settings.presentation] block (sections and fields) \ + to the written entry; `gddy platform app release` rejects a \ + settings entry with no presentation.", + ) + .with_system("applications") + .with_tier(Tier::Mutate) + .with_output_schema::() + .no_auth(true), + |ctx, args: SettingsArgs| async move { + let group = args.group; + let slug = args.slug; + let entry_path = args.entry_path; + if args.icon_name.is_some() != args.icon_library.is_some() { + return Err(crate::error::GddyError::validation( + "--icon-name and --icon-library must be provided together", + ) + .into_cli_error()); + } + let icon = args + .icon_name + .zip(args.icon_library) + .map(|(name, library)| crate::config::SettingIcon { name, library }); + let path = crate::config::config_path(Some(&ctx.middleware.env)); + let mut config = crate::config::read_config(&path) + .map_err(|e| crate::error::GddyError::config(e.to_string()).into_cli_error())?; + config.settings.push(crate::config::SettingConfig { + group: group.clone(), + slug: slug.clone(), + title: args.title, + description: args.description, + entry_path: entry_path.clone(), + order: args.order, + capabilities: args.capabilities, + icon, + metadata: None, + presentation_file: args.presentation_file, + presentation: None, + }); + crate::config::write_config(&path, &config) + .map_err(|e| crate::error::GddyError::config(e.to_string()).into_cli_error())?; + Ok( + CommandResult::new( + json!({ "group": group, "slug": slug, "entryPath": entry_path }), + ) + .with_next_actions(super::add_config_next_actions(&config.name)), + ) + }, + )) .with_group(super::add_extension::group()) } + +#[cfg(test)] +mod tests { + #[test] + fn settings_subcommand_accepts_presentation_file_flag() { + super::group() + .clap_command() + .try_get_matches_from([ + "add", + "settings", + "--group", + "tax-center", + "--slug", + "godaddy-tax", + "--entry-path", + "/settings/godaddy-tax", + "--presentation-file", + "fixtures/manual-tax-presentation.json", + ]) + .expect("--presentation-file flag should be accepted"); + } +} diff --git a/rust/src/application/commands/config.rs b/rust/src/application/commands/config.rs new file mode 100644 index 00000000..8c71a419 --- /dev/null +++ b/rust/src/application/commands/config.rs @@ -0,0 +1,55 @@ +//! `gddy platform app config` — inspect the local godaddy.toml manifest. + +use cli_engine::{ + CommandResult, CommandSpec, GroupSpec, RuntimeCommandSpec, RuntimeGroupSpec, Tier, +}; +use serde_json::json; + +use super::schemas::ValidationResult; +use crate::config::ConfigError; +use crate::next_action::{next_action, required_value}; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new(GroupSpec::new( + "config", + "Inspect the local godaddy.toml manifest", + )) + .with_command(RuntimeCommandSpec::new_with_context( + CommandSpec::new("validate", "Validate the local godaddy.toml manifest") + .with_long( + "Read the godaddy.toml (or godaddy..toml, per --env) \ + manifest in the current directory and validate it against the \ + same rules enforced when writing it via `platform app add`/ \ + `init`/`release` — required fields, URL/UUID/semver shapes, \ + and settings placement rules (slug/entry-path/capability \ + enum). Reports every violation found, not just the first.", + ) + .with_system("applications") + .with_tier(Tier::Read) + .with_output_schema::() + .no_auth(true), + |ctx| async move { + let path = crate::config::config_path(Some(&ctx.middleware.env)); + match crate::config::read_config(&path) { + Ok(config) => Ok(CommandResult::new(json!({ + "valid": true, + "errors": Vec::::new(), + "warnings": Vec::::new(), + })) + .with_next_actions(vec![ + next_action( + "platform app info --name ", + "Inspect the registered application", + ) + .with_param("name", required_value(&config.name)), + ])), + Err(ConfigError::Validation(message)) => Ok(CommandResult::new(json!({ + "valid": false, + "errors": message.split("; ").map(str::to_owned).collect::>(), + "warnings": Vec::::new(), + }))), + Err(e) => Err(crate::error::GddyError::config(e.to_string()).into_cli_error()), + } + }, + )) +} diff --git a/rust/src/application/commands/deploy/extensions.rs b/rust/src/application/commands/deploy/extensions.rs index dd226c7d..fff395d6 100644 --- a/rust/src/application/commands/deploy/extensions.rs +++ b/rust/src/application/commands/deploy/extensions.rs @@ -292,6 +292,7 @@ mod tests { subscriptions: None, dependencies: vec![], extensions, + settings: vec![], } } diff --git a/rust/src/application/commands/deploy/mod.rs b/rust/src/application/commands/deploy/mod.rs index cfe12744..564227c4 100644 --- a/rust/src/application/commands/deploy/mod.rs +++ b/rust/src/application/commands/deploy/mod.rs @@ -435,6 +435,7 @@ mod tests { subscriptions: None, dependencies: vec![], extensions: None, + settings: vec![], }; let input = super::manifest_metadata_input(&config); diff --git a/rust/src/application/commands/init.rs b/rust/src/application/commands/init.rs index ebee3c51..bb95f920 100644 --- a/rust/src/application/commands/init.rs +++ b/rust/src/application/commands/init.rs @@ -203,6 +203,7 @@ pub(super) fn command() -> RuntimeCommandSpec { subscriptions: Some(crate::config::SubscriptionsConfig { webhook: vec![] }), dependencies: vec![], extensions: None, + settings: vec![], }; let cwd = match std::env::current_dir() { Ok(dir) => dir, diff --git a/rust/src/application/commands/mod.rs b/rust/src/application/commands/mod.rs index 7dc2b3de..d91431a1 100644 --- a/rust/src/application/commands/mod.rs +++ b/rust/src/application/commands/mod.rs @@ -8,6 +8,7 @@ use crate::next_action::{next_action, required_value}; mod add; mod add_extension; +mod config; mod deploy; mod info; mod init; @@ -67,8 +68,9 @@ pub fn application_group() -> RuntimeGroupSpec { "Manage GoDaddy developer-platform applications. A GoDaddy application is a \ developer-platform app described by a godaddy.toml manifest in your working \ directory. Use `gddy platform app init` to create one, `gddy platform app \ - validate ` to check remote application state, and `gddy platform app \ - deploy` to publish it.", + config validate` to check the local manifest, `gddy platform app validate \ + ` to check remote application state, and `gddy platform app deploy` to \ + publish it.", ) .with_alias("application"), ) @@ -83,6 +85,7 @@ pub fn application_group() -> RuntimeGroupSpec { .with_command(release::command()) .with_command(deploy::command()) .with_group(add::group()) + .with_group(config::group()) } #[cfg(test)] diff --git a/rust/src/application/commands/release.rs b/rust/src/application/commands/release.rs index cd65c476..94f2a4cd 100644 --- a/rust/src/application/commands/release.rs +++ b/rust/src/application/commands/release.rs @@ -1,9 +1,14 @@ //! `gddy platform app release` — tag a new versioned release. +use std::path::Path; + use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; use serde_json::{Value, json}; use super::schemas::ApplicationRelease; +use crate::config::settings_form::{ + SettingsFormV1Presentation, presentation_from_json, validate_presentation, +}; use crate::next_action::next_action; use crate::scopes::{APP_REGISTRY_READ, APP_REGISTRY_WRITE}; @@ -29,6 +34,129 @@ fn ui_extension_entry( Ok(entry) } +/// Resolves a setting's presentation from `presentation` or `presentationFile`. +fn resolve_presentation( + setting: &crate::config::SettingConfig, + manifest_dir: &Path, +) -> cli_engine::Result { + match (&setting.presentation, &setting.presentation_file) { + (Some(_), Some(_)) => Err(crate::error::GddyError::validation(format!( + "settings '{}' has both presentation and presentationFile — provide only one", + setting.slug + )) + .into_cli_error()), + (Some(p), None) => Ok(p.clone()), + (None, Some(file)) => { + let path = manifest_dir.join(file); + let content = std::fs::read_to_string(&path).map_err(|e| { + crate::error::GddyError::validation(format!( + "settings '{}' presentationFile {} could not be read: {e}", + setting.slug, + path.display() + )) + .into_cli_error() + })?; + presentation_from_json(&content).map_err(|e| { + crate::error::GddyError::validation(format!( + "settings '{}' presentationFile {} is invalid: {e}", + setting.slug, + path.display() + )) + .into_cli_error() + }) + } + (None, None) => Err(crate::error::GddyError::validation(format!( + "settings '{}' has no presentation — add a [settings.presentation] block or a presentationFile before releasing", + setting.slug + )) + .into_cli_error()), + } +} + +/// Build one `settings` release entry from a placement-only `[[settings]]` +/// block plus its presentation (inline or file-sourced). +fn setting_entry( + setting: &crate::config::SettingConfig, + manifest_dir: &Path, +) -> cli_engine::Result { + let presentation = resolve_presentation(setting, manifest_dir)?; + let mut errors = Vec::new(); + validate_presentation(&presentation, &mut errors, "presentation"); + if !errors.is_empty() { + return Err(crate::error::GddyError::validation(format!( + "settings '{}' presentation is invalid: {}", + setting.slug, + errors.join("; ") + )) + .into_cli_error()); + } + let mut presentation_json = serde_json::to_value(&presentation) + .map_err(|e| cli_engine::CliCoreError::message(e.to_string()))?; + if let Value::Object(map) = &mut presentation_json { + map.insert("type".to_owned(), json!("form")); + map.insert("schemaVersion".to_owned(), json!("settings-form-v1")); + } + + let mut entry = json!({ + "groupSlug": setting.group, + "appSettingSlug": setting.slug, + "entryPath": setting.entry_path, + "presentation": presentation_json, + }); + if let Some(title) = &setting.title { + entry["title"] = json!(title); + } + if let Some(description) = &setting.description { + entry["description"] = json!(description); + } + if let Some(icon) = &setting.icon { + entry["iconName"] = json!(icon.name); + entry["iconLibrary"] = json!(icon.library); + } + if let Some(order) = setting.order { + entry["order"] = json!(order); + } + if !setting.capabilities.is_empty() { + entry["capabilities"] = json!(setting.capabilities); + } + if let Some(metadata) = &setting.metadata { + entry["metadata"] = metadata.clone(); + } + Ok(entry) +} + +/// Map godaddy.toml `[[settings]]` placements to the release `settings` input. +fn build_settings( + config: &crate::config::Config, + manifest_dir: &Path, +) -> cli_engine::Result> { + config + .settings + .iter() + .map(|s| setting_entry(s, manifest_dir)) + .collect() +} + +/// Missing manifest returns `Ok(None)`; a manifest that exists but fails to +/// read, parse, or validate is an error rather than a silent empty fallback. +fn load_manifest(path: &Path) -> cli_engine::Result> { + match crate::config::read_config(path) { + Ok(config) => Ok(Some(config)), + Err(crate::config::ConfigError::NotFound { path }) => { + tracing::warn!( + path = %path, + "no manifest found; releasing with empty actions, subscriptions, uiExtensions, and settings" + ); + Ok(None) + } + Err(e) => Err(crate::error::GddyError::config(format!( + "failed to load {}: {e}", + path.display() + )) + .into_cli_error()), + } +} + /// Map godaddy.toml extensions (embed / checkout / blocks) to the release /// `uiExtensions` input. Mirrors the TS release mapping (single target each). fn build_ui_extensions(config: &crate::config::Config) -> cli_engine::Result> { @@ -95,47 +223,38 @@ pub(super) fn command() -> RuntimeCommandSpec { } let config_path = crate::config::config_path(Some(&ctx.middleware.env)); - // Include actions, webhook subscriptions, and UI extensions from - // godaddy.toml so configured behavior is captured in the release. - // Without this, everything added via `platform app add` was silently - // dropped. A missing or invalid config is non-fatal (empty arrays); - // too many targets per extension is a hard error. - let (actions, subscriptions, ui_extensions) = match crate::config::read_config( - &config_path, - ) { - Ok(config) => { - let actions: Vec = config - .actions - .iter() - .map(|a| json!({ "name": a.name, "url": a.url })) - .collect(); - let subscriptions: Vec = config - .subscriptions - .as_ref() - .map(|s| { - s.webhook - .iter() - .map( - |w| json!({ "name": w.name, "events": w.events, "url": w.url }), - ) - .collect() - }) - .unwrap_or_default(); - let ui_extensions = build_ui_extensions(&config)?; - (actions, subscriptions, ui_extensions) - } - Err(e) => { - tracing::warn!( - error = %e, - path = %config_path.display(), - "failed to read config; releasing with empty actions, subscriptions, and uiExtensions" - ); - (Vec::new(), Vec::new(), Vec::new()) - } - }; + let manifest_dir = config_path.parent().unwrap_or_else(|| Path::new("")); + // Pulls actions/subscriptions/uiExtensions/settings from godaddy.toml; see load_manifest. + let (actions, subscriptions, ui_extensions, settings) = + match load_manifest(&config_path)? { + Some(config) => { + let actions: Vec = config + .actions + .iter() + .map(|a| json!({ "name": a.name, "url": a.url })) + .collect(); + let subscriptions: Vec = config + .subscriptions + .as_ref() + .map(|s| { + s.webhook + .iter() + .map(|w| { + json!({ "name": w.name, "events": w.events, "url": w.url }) + }) + .collect() + }) + .unwrap_or_default(); + let ui_extensions = build_ui_extensions(&config)?; + let settings = build_settings(&config, manifest_dir)?; + (actions, subscriptions, ui_extensions, settings) + } + None => (Vec::new(), Vec::new(), Vec::new(), Vec::new()), + }; input["actions"] = json!(actions); input["subscriptions"] = json!(subscriptions); input["uiExtensions"] = json!(ui_extensions); + input["settings"] = json!(settings); let client = super::make_client(&ctx).await?; let data = client @@ -207,6 +326,225 @@ mod tests { assert_eq!(one["target"], "checkout.block"); } + fn placement_only_setting() -> crate::config::SettingConfig { + crate::config::SettingConfig { + group: "tax-center".to_owned(), + slug: "godaddy-tax".to_owned(), + title: None, + description: None, + entry_path: "/settings/godaddy-tax".to_owned(), + order: None, + capabilities: vec![], + icon: None, + metadata: None, + presentation_file: None, + presentation: None, + } + } + + fn boolean_presentation() -> crate::config::settings_form::SettingsFormV1Presentation { + use crate::config::settings_form::{SettingsFormV1Field, SettingsFormV1Section}; + crate::config::settings_form::SettingsFormV1Presentation { + sections: vec![SettingsFormV1Section { + key: "defaults".to_owned(), + label: "Defaults".to_owned(), + description: None, + visible_when: None, + fields: vec![SettingsFormV1Field::Boolean { + key: "autoCalculate".to_owned(), + label: "Auto-calculate".to_owned(), + description: None, + required: false, + default_value: Some(true), + }], + }], + } + } + + #[test] + fn setting_entry_rejects_missing_presentation() { + let err = super::setting_entry(&placement_only_setting(), std::path::Path::new("")) + .expect_err("missing presentation must be rejected"); + assert!(err.to_string().contains("no presentation"), "{err}"); + } + + #[test] + fn setting_entry_maps_placement_and_presentation() { + let mut setting = placement_only_setting(); + setting.presentation = Some(boolean_presentation()); + let entry = super::setting_entry(&setting, std::path::Path::new("")).expect("entry builds"); + assert_eq!(entry["groupSlug"], "tax-center"); + assert_eq!(entry["appSettingSlug"], "godaddy-tax"); + assert_eq!(entry["entryPath"], "/settings/godaddy-tax"); + assert_eq!(entry["presentation"]["type"], "form"); + assert_eq!(entry["presentation"]["schemaVersion"], "settings-form-v1"); + assert_eq!( + entry["presentation"]["sections"][0]["fields"][0]["type"], + "boolean" + ); + assert!( + entry.get("capabilities").is_none(), + "empty capabilities should be omitted" + ); + assert!( + entry.get("iconName").is_none(), + "absent icon should be omitted" + ); + } + + #[test] + fn setting_entry_includes_optional_fields_when_present() { + let mut setting = placement_only_setting(); + setting.presentation = Some(boolean_presentation()); + setting.title = Some("GoDaddy Tax".to_owned()); + setting.description = Some("Tax settings".to_owned()); + setting.order = Some(10); + setting.capabilities = vec!["read".to_owned(), "write".to_owned()]; + setting.icon = Some(crate::config::SettingIcon { + name: "percent".to_owned(), + library: "lucide".to_owned(), + }); + setting.metadata = Some(serde_json::json!({ "provider": "godaddy-tax" })); + let entry = super::setting_entry(&setting, std::path::Path::new("")).expect("entry builds"); + assert_eq!(entry["title"], "GoDaddy Tax"); + assert_eq!(entry["description"], "Tax settings"); + assert_eq!(entry["order"], 10); + assert_eq!(entry["capabilities"], serde_json::json!(["read", "write"])); + assert_eq!(entry["iconName"], "percent"); + assert_eq!(entry["iconLibrary"], "lucide"); + assert_eq!(entry["metadata"]["provider"], "godaddy-tax"); + } + + #[test] + fn setting_entry_resolves_presentation_file_relative_to_manifest_dir() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("presentation.json"), + serde_json::json!({ + "type": "form", + "schemaVersion": "settings-form-v1", + "sections": [{ + "key": "defaults", + "label": "Defaults", + "fields": [{ + "type": "boolean", + "key": "autoCalculate", + "label": "Auto-calculate", + "defaultValue": true, + }], + }], + }) + .to_string(), + ) + .expect("write presentation fixture"); + + let mut setting = placement_only_setting(); + setting.presentation_file = Some("presentation.json".to_owned()); + let via_file = super::setting_entry(&setting, dir.path()).expect("entry builds from file"); + + let mut inline = placement_only_setting(); + inline.presentation = Some(boolean_presentation()); + let via_inline = + super::setting_entry(&inline, std::path::Path::new("")).expect("entry builds inline"); + + assert_eq!(via_file["presentation"], via_inline["presentation"]); + } + + #[test] + fn setting_entry_rejects_missing_presentation_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut setting = placement_only_setting(); + setting.presentation_file = Some("missing.json".to_owned()); + let err = super::setting_entry(&setting, dir.path()) + .expect_err("missing presentation file must be rejected"); + assert!(err.to_string().contains("could not be read"), "{err}"); + } + + #[test] + fn setting_entry_rejects_malformed_presentation_file() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("presentation.json"), "not json").expect("write fixture"); + let mut setting = placement_only_setting(); + setting.presentation_file = Some("presentation.json".to_owned()); + let err = super::setting_entry(&setting, dir.path()) + .expect_err("malformed JSON must be rejected"); + assert!(err.to_string().contains("is invalid"), "{err}"); + } + + #[test] + fn setting_entry_rejects_wrong_schema_version_in_presentation_file() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("presentation.json"), + serde_json::json!({ + "type": "form", + "schemaVersion": "something-else", + "sections": [], + }) + .to_string(), + ) + .expect("write fixture"); + let mut setting = placement_only_setting(); + setting.presentation_file = Some("presentation.json".to_owned()); + let err = super::setting_entry(&setting, dir.path()) + .expect_err("wrong schemaVersion must be rejected"); + assert!(err.to_string().contains("schemaVersion"), "{err}"); + } + + #[test] + fn setting_entry_rejects_both_presentation_and_presentation_file() { + let mut setting = placement_only_setting(); + setting.presentation = Some(boolean_presentation()); + setting.presentation_file = Some("presentation.json".to_owned()); + let err = super::setting_entry(&setting, std::path::Path::new("")) + .expect_err("both set must be rejected"); + assert!(err.to_string().contains("presentationFile"), "{err}"); + } + + #[test] + fn load_manifest_returns_none_for_missing_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + let result = super::load_manifest(&path).expect("missing manifest is not an error"); + assert!(result.is_none()); + } + + #[test] + fn load_manifest_fails_release_on_parse_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + std::fs::write(&path, "this = is not [valid toml").expect("write manifest"); + let err = super::load_manifest(&path).expect_err("parse error must fail the release"); + assert!( + err.to_string().contains("failed to load"), + "unexpected error: {err}" + ); + } + + #[test] + fn load_manifest_fails_release_on_validation_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + // Parses cleanly; `name` fails Config::validate's pattern check. + std::fs::write( + &path, + r#" +name = "Not Valid!" +client_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" +version = "1.0.0" +url = "https://example.com" +proxy_url = "https://example.com/proxy" +authorization_scopes = [] +"#, + ) + .expect("write manifest"); + let err = super::load_manifest(&path).expect_err("validation error must fail the release"); + assert!( + err.to_string().contains("failed to load"), + "unexpected error: {err}" + ); + } + #[test] fn ui_extension_entry_rejects_multiple_targets() { use crate::config::ExtensionTarget; diff --git a/rust/src/application/commands/schemas.rs b/rust/src/application/commands/schemas.rs index 353ddc2f..2169b230 100644 --- a/rust/src/application/commands/schemas.rs +++ b/rust/src/application/commands/schemas.rs @@ -84,3 +84,9 @@ output_schema!(ExtensionBlocks { "source": "string"; "type": "string"; }); + +output_schema!(ConfigSetting { + "group": "string"; + "slug": "string"; + "entryPath": "string"; +}); diff --git a/rust/src/config/mod.rs b/rust/src/config/mod.rs index 9fd26878..f6c855b1 100644 --- a/rust/src/config/mod.rs +++ b/rust/src/config/mod.rs @@ -1,5 +1,10 @@ use serde::{Deserialize, Serialize}; +mod settings; +pub(crate) mod settings_form; + +pub use settings::{SettingConfig, SettingIcon}; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { pub name: String, @@ -18,6 +23,8 @@ pub struct Config { pub dependencies: Vec, #[serde(default)] pub extensions: Option, + #[serde(default)] + pub settings: Vec, } impl Config { @@ -114,6 +121,8 @@ impl Config { } } + settings::validate_settings(&self.settings, &mut errors); + if errors.is_empty() { Ok(()) } else { @@ -436,6 +445,9 @@ pub fn write_env_file( #[cfg(test)] mod tests { + use super::settings_form::{ + SettingsFormV1Field, SettingsFormV1Presentation, SettingsFormV1Section, + }; use super::*; fn valid_config() -> Config { @@ -451,6 +463,7 @@ mod tests { subscriptions: None, dependencies: vec![], extensions: None, + settings: vec![], } } @@ -690,6 +703,125 @@ mod tests { ); } + #[test] + fn validate_accepts_placement_only_setting() { + let mut config = valid_config(); + config.settings.push(SettingConfig { + group: "tax-center".to_owned(), + slug: "godaddy-tax".to_owned(), + title: None, + description: None, + entry_path: "/settings/godaddy-tax".to_owned(), + order: None, + capabilities: vec![], + icon: None, + metadata: None, + presentation_file: None, + presentation: None, + }); + config + .validate() + .expect("placement-only setting should be valid"); + } + + #[test] + fn validate_rejects_invalid_setting_slug() { + let mut config = valid_config(); + config.settings.push(SettingConfig { + group: "Tax_Center".to_owned(), + slug: "godaddy-tax".to_owned(), + title: None, + description: None, + entry_path: "/settings/godaddy-tax".to_owned(), + order: None, + capabilities: vec![], + icon: None, + metadata: None, + presentation_file: None, + presentation: None, + }); + let err = config.validate().expect_err("bad group slug"); + assert!(err.to_string().contains("settings[0].group"), "{err}"); + } + + #[test] + fn setting_with_presentation_round_trips_through_toml() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + let mut config = valid_config(); + config.settings.push(SettingConfig { + group: "tax-center".to_owned(), + slug: "godaddy-tax".to_owned(), + title: Some("GoDaddy Tax".to_owned()), + description: None, + entry_path: "/settings/godaddy-tax".to_owned(), + order: Some(10), + capabilities: vec!["read".to_owned(), "write".to_owned()], + icon: Some(SettingIcon { + name: "percent".to_owned(), + library: "lucide".to_owned(), + }), + metadata: None, + presentation_file: None, + presentation: Some(SettingsFormV1Presentation { + sections: vec![SettingsFormV1Section { + key: "defaults".to_owned(), + label: "Defaults".to_owned(), + description: None, + visible_when: None, + fields: vec![SettingsFormV1Field::Boolean { + key: "autoCalculate".to_owned(), + label: "Auto-calculate".to_owned(), + description: None, + required: false, + default_value: Some(true), + }], + }], + }), + }); + write_config(&path, &config).expect("write config with setting"); + let read_back = read_config(&path).expect("read config with setting"); + assert_eq!(read_back.settings.len(), 1); + assert_eq!(read_back.settings[0].entry_path, "/settings/godaddy-tax"); + let SettingsFormV1Field::Boolean { default_value, .. } = &read_back.settings[0] + .presentation + .as_ref() + .expect("presentation") + .sections[0] + .fields[0] + else { + unreachable!("expected boolean field"); + }; + assert_eq!(default_value, &Some(true)); + } + + #[test] + fn setting_with_presentation_file_round_trips_without_expansion() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + let mut config = valid_config(); + config.settings.push(SettingConfig { + group: "tax-center".to_owned(), + slug: "manual-tax".to_owned(), + title: None, + description: None, + entry_path: "/settings/manual-tax".to_owned(), + order: None, + capabilities: vec![], + icon: None, + metadata: None, + presentation_file: Some("fixtures/manual-tax-presentation.json".to_owned()), + presentation: None, + }); + write_config(&path, &config).expect("write config with presentationFile"); + let read_back = read_config(&path).expect("read config with presentationFile"); + assert_eq!( + read_back.settings[0].presentation_file, + Some("fixtures/manual-tax-presentation.json".to_owned()) + ); + assert!(read_back.settings[0].presentation.is_none()); + } + #[test] fn read_config_runs_validation() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/rust/src/config/settings.rs b/rust/src/config/settings.rs new file mode 100644 index 00000000..7925b275 --- /dev/null +++ b/rust/src/config/settings.rs @@ -0,0 +1,273 @@ +//! `[[settings]]` — placement metadata for an application-settings capability +//! registered with `app-registry-api`'s `createRelease.settings`. + +use serde::{Deserialize, Serialize}; + +use super::settings_form::{SettingsFormV1Presentation, validate_presentation}; + +const ALLOWED_CAPABILITIES: &[&str] = &["read", "write", "validate", "test", "delete"]; +const ALLOWED_ICON_LIBRARIES: &[&str] = &["ux", "lucide", "commerce"]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SettingConfig { + pub group: String, + pub slug: String, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub description: Option, + pub entry_path: String, + #[serde(default)] + pub order: Option, + #[serde(default)] + pub capabilities: Vec, + #[serde(default)] + pub icon: Option, + #[serde(default)] + pub metadata: Option, + /// Path to a JSON presentation file, resolved against the manifest's + /// directory at release time. Mutually exclusive with `presentation`. + #[serde(default)] + pub presentation_file: Option, + /// The `settings-form-v1` form shape. `None` until hand-added to + /// `godaddy.toml` — `gddy platform app add settings` can only write the + /// placement fields above; `release` rejects a settings entry with no + /// presentation instead of `Config::validate()`, so a placement-only + /// entry still parses/writes/validates fine for every other command. + #[serde(default)] + pub presentation: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SettingIcon { + pub name: String, + pub library: String, +} + +/// True when `value` matches the API's slug pattern: `^[a-z0-9]+(-[a-z0-9]+)*$`. +fn is_valid_slug(value: &str) -> bool { + !value.is_empty() + && value.split('-').all(|part| { + !part.is_empty() + && part + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + }) +} + +/// True when `path` is a route-safe entry path: starts with `/`, no scheme, +/// query string, fragment, or `..` segments, and every non-empty segment +/// uses only `[A-Za-z0-9._~-]`. +fn is_valid_entry_path(path: &str) -> bool { + if !path.starts_with('/') || path.contains("://") || path.contains('?') || path.contains('#') { + return false; + } + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() { + return false; + } + trimmed[1..].split('/').all(|segment| { + !segment.is_empty() + && segment != ".." + && segment + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'~' | b'-')) + }) +} + +/// Normalize an entry path the same way the API does before comparing for +/// overlap: strip trailing slashes, collapsing an all-slash path to `/`. +fn normalize_entry_path(path: &str) -> &str { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() { "/" } else { trimmed } +} + +/// Port of `applicationSettingEntryPathsOverlap` (`application-setting.ts`): +/// two entry paths overlap if they're equal, one is `/`, or one is a +/// slash-bounded prefix of the other. +fn entry_paths_overlap(first: &str, second: &str) -> bool { + let first = normalize_entry_path(first); + let second = normalize_entry_path(second); + if first == "/" || second == "/" { + return true; + } + first == second + || first.starts_with(&format!("{second}/")) + || second.starts_with(&format!("{first}/")) +} + +pub(super) fn validate_settings(settings: &[SettingConfig], errors: &mut Vec) { + for (i, setting) in settings.iter().enumerate() { + let path = format!("settings[{i}]"); + + if !is_valid_slug(&setting.group) { + errors.push(format!( + "{path}.group must match /^[a-z0-9]+(-[a-z0-9]+)*$/ (got {:?})", + setting.group + )); + } + if !is_valid_slug(&setting.slug) { + errors.push(format!( + "{path}.slug must match /^[a-z0-9]+(-[a-z0-9]+)*$/ (got {:?})", + setting.slug + )); + } + if !is_valid_entry_path(&setting.entry_path) { + errors.push(format!( + "{path}.entryPath must be a route-safe path starting with / (got {:?})", + setting.entry_path + )); + } + for capability in &setting.capabilities { + if !ALLOWED_CAPABILITIES.contains(&capability.as_str()) { + errors.push(format!( + "{path}.capabilities contains {capability:?}, must be one of {ALLOWED_CAPABILITIES:?}" + )); + } + } + if let Some(icon) = &setting.icon + && !ALLOWED_ICON_LIBRARIES.contains(&icon.library.as_str()) + { + errors.push(format!( + "{path}.icon.library must be one of {ALLOWED_ICON_LIBRARIES:?} (got {:?})", + icon.library + )); + } + if setting.presentation.is_some() && setting.presentation_file.is_some() { + errors.push(format!( + "{path} has both presentation and presentationFile — provide only one" + )); + } + if let Some(presentation) = &setting.presentation { + validate_presentation(presentation, errors, &format!("{path}.presentation")); + } + } + + for i in 0..settings.len() { + for j in (i + 1)..settings.len() { + if entry_paths_overlap(&settings[i].entry_path, &settings[j].entry_path) { + errors.push(format!( + "settings[{j}].entryPath {:?} overlaps with settings[{i}].entryPath {:?}", + settings[j].entry_path, settings[i].entry_path + )); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setting(slug: &str, entry_path: &str) -> SettingConfig { + SettingConfig { + group: "tax-center".to_owned(), + slug: slug.to_owned(), + title: None, + description: None, + entry_path: entry_path.to_owned(), + order: None, + capabilities: vec![], + icon: None, + metadata: None, + presentation_file: None, + presentation: None, + } + } + + #[test] + fn is_valid_slug_pattern() { + assert!(is_valid_slug("tax-center")); + assert!(is_valid_slug("a")); + assert!(!is_valid_slug("")); + assert!(!is_valid_slug("Tax-Center")); + assert!(!is_valid_slug("tax_center")); + assert!(!is_valid_slug("-tax")); + assert!(!is_valid_slug("tax-")); + } + + #[test] + fn is_valid_entry_path_shape() { + assert!(is_valid_entry_path("/settings/manual-tax")); + assert!(!is_valid_entry_path("settings/manual-tax")); + assert!(!is_valid_entry_path("/settings?x=1")); + assert!(!is_valid_entry_path("/settings#frag")); + assert!(!is_valid_entry_path("https://example.com/settings")); + assert!(!is_valid_entry_path("/../settings")); + assert!(!is_valid_entry_path("/")); + } + + #[test] + fn entry_paths_overlap_detects_prefix_and_exact() { + assert!(entry_paths_overlap("/settings/tax", "/settings/tax")); + assert!(entry_paths_overlap("/settings", "/settings/tax")); + assert!(entry_paths_overlap("/settings/tax/", "/settings/tax")); + assert!(!entry_paths_overlap("/settings/tax", "/settings/shipping")); + } + + #[test] + fn validate_settings_accepts_well_formed_placement_only_entry() { + let mut errors = Vec::new(); + validate_settings( + &[setting("godaddy-tax", "/settings/godaddy-tax")], + &mut errors, + ); + assert!(errors.is_empty(), "{errors:?}"); + } + + #[test] + fn validate_settings_rejects_invalid_capability() { + let mut s = setting("godaddy-tax", "/settings/godaddy-tax"); + s.capabilities = vec!["read".to_owned(), "not-a-capability".to_owned()]; + let mut errors = Vec::new(); + validate_settings(&[s], &mut errors); + assert!( + errors.iter().any(|e| e.contains("capabilities")), + "{errors:?}" + ); + } + + #[test] + fn validate_settings_rejects_invalid_icon_library() { + let mut s = setting("godaddy-tax", "/settings/godaddy-tax"); + s.icon = Some(SettingIcon { + name: "percent".to_owned(), + library: "material".to_owned(), + }); + let mut errors = Vec::new(); + validate_settings(&[s], &mut errors); + assert!( + errors.iter().any(|e| e.contains("icon.library")), + "{errors:?}" + ); + } + + #[test] + fn validate_settings_rejects_both_presentation_and_presentation_file() { + let mut s = setting("godaddy-tax", "/settings/godaddy-tax"); + s.presentation_file = Some("presentation.json".to_owned()); + s.presentation = Some(SettingsFormV1Presentation { sections: vec![] }); + let mut errors = Vec::new(); + validate_settings(&[s], &mut errors); + assert!( + errors + .iter() + .any(|e| e.contains("presentation") && e.contains("presentationFile")), + "{errors:?}" + ); + } + + #[test] + fn validate_settings_rejects_overlapping_entry_paths() { + let mut errors = Vec::new(); + validate_settings( + &[setting("a", "/settings/tax"), setting("b", "/settings/tax")], + &mut errors, + ); + assert!( + errors.iter().any(|e| e.contains("overlaps with")), + "{errors:?}" + ); + } +} diff --git a/rust/src/config/settings_form.rs b/rust/src/config/settings_form.rs new file mode 100644 index 00000000..78f7e5b1 --- /dev/null +++ b/rust/src/config/settings_form.rs @@ -0,0 +1,479 @@ +//! `[settings.presentation]` — the `settings-form-v1` shape registered with +//! `app-registry-api`'s `createRelease.settings[].presentation`. Structural +//! shape only; bounds/default-consistency/depth checks stay server-validated. + +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SettingsFormV1Presentation { + pub sections: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SettingsFormV1Section { + pub key: String, + pub label: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub visible_when: Option, + pub fields: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SettingsFormV1VisibilityCondition { + pub field: String, + pub equals: SelectValue, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum SettingsFormV1Field { + #[serde(rename = "text", rename_all = "camelCase")] + Text { + key: String, + label: String, + #[serde(default)] + description: Option, + #[serde(default)] + required: bool, + #[serde(default)] + placeholder: Option, + #[serde(default)] + min_length: Option, + #[serde(default)] + max_length: Option, + #[serde(default)] + default_value: Option, + }, + #[serde(rename = "textarea", rename_all = "camelCase")] + Textarea { + key: String, + label: String, + #[serde(default)] + description: Option, + #[serde(default)] + required: bool, + #[serde(default)] + placeholder: Option, + #[serde(default)] + min_length: Option, + #[serde(default)] + max_length: Option, + #[serde(default)] + default_value: Option, + }, + #[serde(rename = "number", rename_all = "camelCase")] + Number { + key: String, + label: String, + #[serde(default)] + description: Option, + #[serde(default)] + required: bool, + #[serde(default)] + min: Option, + #[serde(default)] + max: Option, + #[serde(default)] + step: Option, + #[serde(default)] + suffix: Option, + #[serde(default)] + default_value: Option, + }, + #[serde(rename = "boolean", rename_all = "camelCase")] + Boolean { + key: String, + label: String, + #[serde(default)] + description: Option, + #[serde(default)] + required: bool, + #[serde(default)] + default_value: Option, + }, + #[serde(rename = "select", rename_all = "camelCase")] + Select { + key: String, + label: String, + #[serde(default)] + description: Option, + #[serde(default)] + required: bool, + options: Vec, + #[serde(default)] + default_value: Option, + }, + #[serde(rename = "multi-select", rename_all = "camelCase")] + MultiSelect { + key: String, + label: String, + #[serde(default)] + description: Option, + #[serde(default)] + required: bool, + options: Vec, + #[serde(default)] + min_items: Option, + #[serde(default)] + max_items: Option, + #[serde(default)] + default_value: Option>, + }, + #[serde(rename = "list-group", rename_all = "camelCase")] + ListGroup { + key: String, + label: String, + #[serde(default)] + description: Option, + #[serde(default)] + required: bool, + #[serde(default)] + min_items: Option, + #[serde(default)] + max_items: Option, + item: ListGroupItem, + }, +} + +impl SettingsFormV1Field { + fn key(&self) -> &str { + match self { + Self::Text { key, .. } + | Self::Textarea { key, .. } + | Self::Number { key, .. } + | Self::Boolean { key, .. } + | Self::Select { key, .. } + | Self::MultiSelect { key, .. } + | Self::ListGroup { key, .. } => key, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListGroupItem { + pub id_field: String, + #[serde(default)] + pub title_field: Option, + pub fields: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChoiceOption { + pub value: SelectValue, + pub label: String, + #[serde(default)] + pub description: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SelectValue { + Str(String), + Num(f64), + Bool(bool), +} + +/// True when `key` matches the same `fieldNamePattern` the API uses: +/// `^[A-Za-z][A-Za-z0-9_]*$`. +fn is_field_name(key: &str) -> bool { + let mut chars = key.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// Structural validation for a `presentation` block: field-name shape, +/// non-empty choice options, and the two uniqueness checks +/// `SettingsFormV1Presentation`'s own Zod `superRefine` runs (unique section +/// keys, unique top-level field keys across sections). Bounds/default +/// consistency and `list-group` depth are left to the API. +pub(crate) fn validate_presentation( + presentation: &SettingsFormV1Presentation, + errors: &mut Vec, + path: &str, +) { + let mut seen_section_keys = HashSet::new(); + let mut seen_top_level_field_keys = HashSet::new(); + + for (i, section) in presentation.sections.iter().enumerate() { + let section_path = format!("{path}.sections[{i}]"); + if !is_field_name(§ion.key) { + errors.push(format!( + "{section_path}.key must match ^[A-Za-z][A-Za-z0-9_]*$ (got {:?})", + section.key + )); + } + if !seen_section_keys.insert(section.key.clone()) { + errors.push(format!( + "{section_path}.key {:?} duplicates another section key", + section.key + )); + } + + for (j, field) in section.fields.iter().enumerate() { + let field_path = format!("{section_path}.fields[{j}]"); + validate_field(field, errors, &field_path); + if !seen_top_level_field_keys.insert(field.key().to_owned()) { + errors.push(format!( + "{field_path}.key {:?} duplicates another field key", + field.key() + )); + } + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PresentationFileDocument { + #[serde(default)] + r#type: Option, + #[serde(default)] + schema_version: Option, + sections: Vec, +} + +/// Parses a `presentationFile`'s JSON (full API object: `type`, +/// `schemaVersion`, `sections`). +pub(crate) fn presentation_from_json(content: &str) -> Result { + let doc: PresentationFileDocument = serde_json::from_str(content).map_err(|e| e.to_string())?; + if let Some(t) = &doc.r#type + && t != "form" + { + return Err(format!("type must be \"form\" (got {t:?})")); + } + if let Some(v) = &doc.schema_version + && v != "settings-form-v1" + { + return Err(format!( + "schemaVersion must be \"settings-form-v1\" (got {v:?})" + )); + } + Ok(SettingsFormV1Presentation { + sections: doc.sections, + }) +} + +fn validate_field(field: &SettingsFormV1Field, errors: &mut Vec, path: &str) { + if !is_field_name(field.key()) { + errors.push(format!( + "{path}.key must match ^[A-Za-z][A-Za-z0-9_]*$ (got {:?})", + field.key() + )); + } + match field { + SettingsFormV1Field::Select { options, .. } + | SettingsFormV1Field::MultiSelect { options, .. } => { + if options.is_empty() { + errors.push(format!("{path}.options must contain at least one option")); + } + } + SettingsFormV1Field::ListGroup { item, .. } => { + if !is_field_name(&item.id_field) { + errors.push(format!( + "{path}.item.idField must match ^[A-Za-z][A-Za-z0-9_]*$ (got {:?})", + item.id_field + )); + } + if let Some(title_field) = &item.title_field + && !is_field_name(title_field) + { + errors.push(format!( + "{path}.item.titleField must match ^[A-Za-z][A-Za-z0-9_]*$ (got {:?})", + title_field + )); + } + for (k, inner) in item.fields.iter().enumerate() { + validate_field(inner, errors, &format!("{path}.item.fields[{k}]")); + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text_field(key: &str) -> SettingsFormV1Field { + SettingsFormV1Field::Text { + key: key.to_owned(), + label: "Label".to_owned(), + description: None, + required: false, + placeholder: None, + min_length: None, + max_length: None, + default_value: None, + } + } + + fn section(key: &str, fields: Vec) -> SettingsFormV1Section { + SettingsFormV1Section { + key: key.to_owned(), + label: "Label".to_owned(), + description: None, + visible_when: None, + fields, + } + } + + #[test] + fn is_field_name_pattern() { + assert!(is_field_name("calculateUsing")); + assert!(is_field_name("a")); + assert!(is_field_name("a_1")); + assert!(!is_field_name("")); + assert!(!is_field_name("1abc")); + assert!(!is_field_name("has-dash")); + } + + #[test] + fn validate_presentation_accepts_well_formed() { + let presentation = SettingsFormV1Presentation { + sections: vec![section("defaults", vec![text_field("calculateUsing")])], + }; + let mut errors = Vec::new(); + validate_presentation(&presentation, &mut errors, "settings[0].presentation"); + assert!(errors.is_empty(), "{errors:?}"); + } + + #[test] + fn validate_presentation_rejects_duplicate_section_keys() { + let presentation = SettingsFormV1Presentation { + sections: vec![ + section("defaults", vec![text_field("a")]), + section("defaults", vec![text_field("b")]), + ], + }; + let mut errors = Vec::new(); + validate_presentation(&presentation, &mut errors, "settings[0].presentation"); + assert!( + errors + .iter() + .any(|e| e.contains("duplicates another section key")), + "{errors:?}" + ); + } + + #[test] + fn validate_presentation_rejects_duplicate_field_keys_across_sections() { + let presentation = SettingsFormV1Presentation { + sections: vec![ + section("a", vec![text_field("shared")]), + section("b", vec![text_field("shared")]), + ], + }; + let mut errors = Vec::new(); + validate_presentation(&presentation, &mut errors, "settings[0].presentation"); + assert!( + errors + .iter() + .any(|e| e.contains("duplicates another field key")), + "{errors:?}" + ); + } + + #[test] + fn validate_presentation_rejects_bad_field_key() { + let presentation = SettingsFormV1Presentation { + sections: vec![section("defaults", vec![text_field("bad-key")])], + }; + let mut errors = Vec::new(); + validate_presentation(&presentation, &mut errors, "settings[0].presentation"); + assert!( + errors.iter().any(|e| e.contains("must match")), + "{errors:?}" + ); + } + + #[test] + fn validate_presentation_rejects_empty_select_options() { + let field = SettingsFormV1Field::Select { + key: "choice".to_owned(), + label: "Choice".to_owned(), + description: None, + required: false, + options: vec![], + default_value: None, + }; + let presentation = SettingsFormV1Presentation { + sections: vec![section("defaults", vec![field])], + }; + let mut errors = Vec::new(); + validate_presentation(&presentation, &mut errors, "settings[0].presentation"); + assert!( + errors.iter().any(|e| e.contains("options must contain")), + "{errors:?}" + ); + } + + #[test] + fn toml_round_trip_preserves_nested_list_group_and_default_value() { + let toml_src = r#" +[[sections]] +key = "defaults" +label = "Calculation defaults" + +[[sections.fields]] +type = "select" +key = "calculateUsing" +label = "Calculate using" +required = true +defaultValue = "destination" + +[[sections.fields.options]] +label = "Customer destination" +value = "destination" + +[[sections]] +key = "rules" +label = "Rules" + +[[sections.fields]] +type = "list-group" +key = "rules" +label = "Rules" + +[sections.fields.item] +idField = "id" +titleField = "displayName" + +[[sections.fields.item.fields]] +type = "number" +key = "rate" +label = "Rate" +min = 0.0 +max = 100.0 +"#; + let presentation: SettingsFormV1Presentation = + toml::from_str(toml_src).expect("valid presentation toml"); + let SettingsFormV1Field::Select { default_value, .. } = &presentation.sections[0].fields[0] + else { + unreachable!("expected select field"); + }; + assert_eq!( + default_value, + &Some(SelectValue::Str("destination".to_owned())) + ); + + let serialized = toml::to_string_pretty(&presentation).expect("serialize"); + let reparsed: SettingsFormV1Presentation = toml::from_str(&serialized).expect("reparse"); + let SettingsFormV1Field::Select { + default_value: reparsed_default, + .. + } = &reparsed.sections[0].fields[0] + else { + unreachable!("expected select field"); + }; + assert_eq!(reparsed_default, default_value); + } +} diff --git a/rust/src/main.rs b/rust/src/main.rs index b29c3840..0bb84417 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -192,6 +192,33 @@ mod tests { ); } + #[tokio::test] + async fn platform_guides_are_discoverable_even_when_the_namespace_is_hidden() { + let cli = Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_min_stage(Stage::Ga) + .with_modules(super::all_modules()), + ); + + let list = cli.run(["gddy", "guide"]).await; + assert_eq!(list.exit_code, 0, "{}", list.rendered); + for topic in ["platform-overview", "platform-settings"] { + assert!( + list.rendered.contains(topic), + "guide list should include {topic:?}: {}", + list.rendered + ); + } + + let settings = cli.run(["gddy", "guide", "platform-settings"]).await; + assert_eq!(settings.exit_code, 0, "{}", settings.rendered); + assert!( + settings.rendered.contains("settings-form-v1"), + "{}", + settings.rendered + ); + } + #[tokio::test] async fn platform_namespace_exposes_the_gpa_command_tree() { let cli = Cli::new( diff --git a/rust/src/platform/guides/platform-overview.md b/rust/src/platform/guides/platform-overview.md new file mode 100644 index 00000000..6d477617 --- /dev/null +++ b/rust/src/platform/guides/platform-overview.md @@ -0,0 +1,63 @@ +--- +summary: How to build, configure, and ship a GoDaddy Platform Application (GPA) end to end +--- + +# gddy platform app — building a GoDaddy Platform Application + +`gddy platform app` registers and manages a GoDaddy developer-platform application (a "GPA"), described by a `godaddy.toml` manifest in your working directory. This guide walks the full lifecycle in order. Every command also has its own `--help` with the same detail. + +## 1. Create the application + +```sh +gddy platform app init --name my-app --url https://example.com --proxy-url https://example.com/proxy --scopes commerce.order:read,commerce.order:write +``` + +This calls the app-registry API to create the application, then writes `godaddy.toml` (and a per-env secrets file) to the current directory. `url`/`proxy-url` must be publicly resolvable HTTP(S) — localhost, loopback, and private IPs are rejected. Re-run with `--config ` to seed flags from an existing manifest instead of retyping them. Requires the `applications.*:read`/`write` scopes (`--scope` on `gddy auth login`, or a PAT with the same scopes). + +## 2. Configure it locally + +`gddy platform app add ` appends to `godaddy.toml` without any network call: + +- `add action --name --url ` — an HTTP endpoint the platform calls on the app's behalf. +- `add subscription --name --url --events ` — a webhook route for platform events; run `gddy platform webhook events` to see valid event types. +- `add extension ...` — a UI extension bundle (see that subcommand's own `--help`). +- `add settings --group --slug --entry-path ...` — placement metadata for a merchant-facing settings form. This only writes placement fields (group/slug/entryPath/order/capabilities/icon); the form itself (`[settings.presentation]`) has to be hand-authored in `godaddy.toml` afterward. See the `platform-settings` guide (`gddy guide platform-settings`) for the full presentation shape. + +Run `gddy platform app config validate` any time to check the manifest against every rule the API would otherwise enforce (required fields, URL/UUID/semver shapes, settings placement rules) without a network call — it reports every violation found, not just the first. + +## 3. Release + +```sh +gddy platform app release --application-id --version 1.2.3 +``` + +Resends every action, subscription, UI extension, and settings entry currently in `godaddy.toml` as one versioned release — omitting an entry from the manifest doesn't archive it globally, but a store enabled against a *newer* release won't have it. A settings entry with no `[settings.presentation]` block fails the release with a `VALIDATION_ERROR`; a manifest that fails to parse or validate fails the release outright (only a genuinely missing manifest falls back to an empty release). Version must be semver. + +## 4. Deploy + +```sh +gddy platform app deploy +``` + +Bundles, security-scans, and uploads the extensions declared in `godaddy.toml`, streaming progress as JSON events. + +## 5. Enable / disable per store + +```sh +gddy platform app enable --store-id +gddy platform app disable --store-id +``` + +Makes the application (and everything in its latest release — actions, subscriptions, extensions, settings) available on, or removes it from, one store. Settings have no inheritance across releases: a store already enabled against an older release does not pick up settings added by a newer one until `enable` is re-run for that store. + +## Other useful commands + +- `gddy platform app validate ` — check *remote* application state (URL/proxy-url set, not INACTIVE), as opposed to `config validate`'s local manifest check. +- `gddy platform app info --name ` / `list` — inspect a single app or list all of them. +- `gddy platform app archive ` — irreversible; confirm the name with `list` first. +- `gddy platform actions` / `gddy platform webhook` — browse the platform's action and webhook-event catalogs (used when choosing values for `add action`/`add subscription`). + +## See also + +- `gddy guide platform-settings` — the `settings-form-v1` presentation shape in depth. +- `docs/application-settings.md` in this repo — the same settings content, plus a pointer to `app-registry-api`'s platform-contract docs for the Commerce-side settings surface. diff --git a/rust/src/platform/guides/platform-settings.md b/rust/src/platform/guides/platform-settings.md new file mode 100644 index 00000000..a2f1dc62 --- /dev/null +++ b/rust/src/platform/guides/platform-settings.md @@ -0,0 +1,139 @@ +--- +summary: How to register an application-settings form via godaddy.toml (settings-form-v1) +--- + +# Application settings + +An application-settings capability lets a GoDaddy Platform Application (GPA) contribute a form to a Commerce-owned settings surface (e.g. `tax-center`) — merchants fill it out, the GPA's own `load`/`save`/`validate` endpoints own the data. `app-registry-api` stores and validates only the registration and presentation metadata; it never sees merchant values. This covers the `gddy platform app` side of registering one — for the platform contract itself (lifecycle endpoints, signing, `settings-api` composition), see `app-registry-api`'s `docs/GPA-SETTINGS-REGISTRATION.md` and `docs/SETTINGS.md`. + +## Workflow + +1. **Add the placement** — `gddy platform app add settings` writes the group/slug/entryPath/order/capabilities/icon fields into `godaddy.toml`: + + ```sh + gddy platform app add settings \ + --group tax-center \ + --slug godaddy-tax \ + --title "GoDaddy Tax" \ + --description "Choose manual tax rules or automatic U.S. ZIP-code tax rates." \ + --entry-path /settings/godaddy-tax \ + --order 10 \ + --capability read --capability write --capability validate \ + --icon-name percent --icon-library lucide + ``` + + `entryPath` is relative to the application's registered `proxy_url`, same as action URLs. `--capability` defaults to `read`+`write` server-side if omitted. `--icon-name`/`--icon-library` must be given together or not at all. + +2. **Author the form** — this command only writes placement metadata; the actual field/section definitions (`presentation`) aren't flag-driven. Either hand-add a `[settings.presentation]` block directly into the entry `add settings` just wrote, or point it at a JSON file with `--presentation-file ` (or by editing `presentationFile` into the entry afterward). The two are mutually exclusive — see "Presentation shape" below. + +3. **Release** — `gddy platform app release --application-id --version ` resends every setting in `godaddy.toml`, same as it does for actions/subscriptions/extensions. It rejects any settings entry with neither `presentation` nor `presentationFile` (`{"error":{"code":"VALIDATION_ERROR","message":"settings 'godaddy-tax' has no presentation — add a [settings.presentation] block or a presentationFile before releasing"}}`). + +4. **Enable/backfill** — `gddy platform app enable --store-id ` makes the placement discoverable for a store. Settings (like actions/subscriptions/uiExtensions) are keyed per-release with no inheritance — a store already enabled against an older release doesn't pick up settings added in a newer one until `enable` is re-run for that store. + +## Presentation shape + +`presentation` is a `settings-form-v1` form: one or more sections, each with one or more fields. Add it as nested tables under the setting's own `[[settings]]` entry: + +```toml +[[settings.presentation.sections]] +key = "defaults" +label = "Calculation defaults" + +[[settings.presentation.sections.fields]] +type = "select" +key = "calculateUsing" +label = "Calculate using" +required = true +defaultValue = "destination" + +[[settings.presentation.sections.fields.options]] +label = "Customer destination" +value = "destination" + +[[settings.presentation.sections.fields.options]] +label = "Order origin" +value = "origin" +``` + +A field `key` becomes a property in the merchant's saved `values` document and must stay stable once merchants have data — don't rename a field key without a GPA-owned migration. Section keys are display-only and safe to change freely. + +Supported field types (`type` discriminates the shape — every field needs `type`, `key`, `label`): + +- **`text`** / **`textarea`** — string value. Optional: `placeholder`, `minLength`, `maxLength`, `defaultValue` (string). +- **`number`** — numeric value. Optional: `min`, `max`, `step`, `suffix`, `defaultValue` (number). +- **`boolean`** — true/false value. Optional: `defaultValue` (bool). +- **`select`** — one value chosen from `options` (required, non-empty). Each option: `value`, `label`, optional `description`. Add more by repeating the `[[...options]]` array-of-tables header — one block per option, at whatever nesting depth the field sits (e.g. `[[settings.presentation.sections.fields.item.fields.options]]` inside a `list-group` item field). Optional: `defaultValue` must match one option's `value`. +- **`multi-select`** — array of values chosen from `options` (required, non-empty). Optional: `minItems`, `maxItems`, `defaultValue` (array, each entry must match an option). +- **`list-group`** — array of objects, one merchant-added item per array entry. `item.idField` names the item's reserved stable-UUID property (renderer-generated, GPA echoes it back unchanged — it can't also be an editable field key). `item.titleField` (optional) names a `text`/`textarea`/`number`/`select` field used to label each item in the UI. `item.fields` is a list of fields using the same types above — `list-group` may nest one level further (max depth 2), but not deeper. + +Every field, section, and `list-group` item field also accepts `description`. Sections additionally accept `visibleWhen = { field = "...", equals = ... }` to show/hide based on another top-level field's value. + +Worked example — the full `godaddy.toml` shape for a manual-tax-style GPA with a nested `list-group`: + +```toml +[[settings]] +group = "tax-center" +slug = "manual-tax" +title = "Manual Tax" +entryPath = "/settings/manual-tax" +order = 10 +capabilities = ["read", "write", "validate"] + +[settings.icon] +name = "percent" +library = "lucide" + +[[settings.presentation.sections]] +key = "rules" +label = "Tax rules" + +[[settings.presentation.sections.fields]] +type = "list-group" +key = "rules" +label = "Rules" +minItems = 1 + +[settings.presentation.sections.fields.item] +idField = "id" +titleField = "displayName" + +[[settings.presentation.sections.fields.item.fields]] +type = "select" +key = "country" +label = "Country" + +[[settings.presentation.sections.fields.item.fields.options]] +label = "United States" +value = "US" + +[[settings.presentation.sections.fields.item.fields]] +type = "text" +key = "displayName" +label = "Display at checkout" +``` + +### Referencing a JSON file instead of inline TOML + +For a GPA that already keeps its presentation as a JSON fixture, point `presentationFile` at it instead of re-authoring the same form as TOML: + +```toml +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "/settings/manual-tax" +presentationFile = "fixtures/manual-tax-registry-presentation.json" +``` + +The referenced file must be the complete API presentation object — `type` (`"form"`), `schemaVersion` (`"settings-form-v1"`), and `sections` — the same shape `createRelease.settings[].presentation` expects, so an existing fixture can be reused verbatim. The path is relative to the directory containing the `godaddy.toml` being released, not the shell's working directory. `presentation` and `presentationFile` are mutually exclusive; both forms run through the same field/section validation and produce an identical release payload. The file is only opened at `release` — like inline `presentation`, it's optional at `add settings`/`config validate` time — and a missing, unreadable, malformed, or wrong-`type`/`schemaVersion` file fails the release with a `VALIDATION_ERROR` naming the resolved path. + +## What the CLI validates locally vs. server-side + +`gddy platform app add settings`/`release` catch cheap, structural problems before any network call: `group`/`slug` match the platform's slug pattern (`lowercase-with-dashes`); `entryPath` is a route-safe path (`/`-prefixed, no query string/fragment/`..`) and doesn't overlap another setting's `entryPath` in the same manifest; `capabilities` are a subset of `read`, `write`, `validate`, `test`, `delete`; `icon.library` is one of `ux`, `lucide`, `commerce`; every field/section `key` matches the platform's key pattern, `select`/`multi-select` have at least one option, and no two fields/sections share a key; `presentation` and `presentationFile` aren't both set on the same entry — checked as soon as the manifest is touched, not just at release. + +Deeper semantics stay server-validated — bounds consistency (`maxLength ≥ minLength`), a `defaultValue` actually matching a registered option or satisfying bounds, and `list-group` nesting depth. A rejection there surfaces as a `release` API error, not a local one. + +## Gotchas + +- **No release inheritance.** `release` resends every current setting from `godaddy.toml`; leaving one out doesn't archive it globally, but any store enabled against the *new* release loses it. +- **Existing stores don't auto-upgrade.** Adding settings to a release only affects stores enabled *after* that release goes active — re-run `gddy platform app enable --store-id ` per store to backfill. +- **`presentation`/`presentationFile` is mandatory before release, not before `add settings`.** A placement-only entry parses and works fine for every other command (`add action`, `info`, `validate`, `deploy`) — it only fails at `release`, with the message shown above. diff --git a/rust/src/platform/mod.rs b/rust/src/platform/mod.rs index cac19d39..fcadf2a9 100644 --- a/rust/src/platform/mod.rs +++ b/rust/src/platform/mod.rs @@ -21,4 +21,14 @@ pub fn module() -> Module { .with_group(crate::actions_catalog::group()) .with_group(crate::webhook::group()) }) + .with_guides_from_markdown([ + ( + "platform-overview.md", + include_bytes!("guides/platform-overview.md").as_slice(), + ), + ( + "platform-settings.md", + include_bytes!("guides/platform-settings.md").as_slice(), + ), + ]) }