From 562c35a1aac5ef43245ce7326420952a177ffefd Mon Sep 17 00:00:00 2001 From: Michael Assaf Date: Tue, 16 Jun 2026 10:21:12 -0400 Subject: [PATCH 1/2] feat(stripe-projects): pin plugin + auto-detect upgrades via snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `stripe projects` plugin is versioned independently of the Stripe CLI and ships no changelog. Pin it and snapshot its surface so upgrades are reproducible and their git diff is the changelog: - plugin-version.txt, command-surface.txt, catalog.json — committed snapshots regenerated by one bless path (refresh_blesses_snapshots, gated on STRIPE_PROJECTS_REFRESH=1), reusing the typed driver + drift_report(); refuses to write on unmodeled catalog drift. - Enforced automatically, no manual ritual: fixtures_are_coherent runs offline on every PR (in `mise run test`); prek pre-commit/pre-push hooks auto-wired by `mise install`; smoke.yml gates the pinned surface against the live plugin; stripe-projects-watch.yml opens an upgrade PR nightly when upstream changes. clap was rejected for the refresh (mise + a bless-test fits the repo's conventions); both existing shell scripts stay shell. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/smoke.yml | 23 +- .github/workflows/stripe-projects-watch.yml | 124 + .pre-commit-config.yaml | 41 + ARCHITECTURE.md | 9 +- crates/stackless-stripe-projects/src/lib.rs | 5 + .../stackless-stripe-projects/src/stripe.rs | 69 + .../stackless-stripe-projects/src/surface.rs | 214 + .../tests/catalog_drift.rs | 89 + .../tests/fixtures/catalog.json | 4122 ++++++++--------- .../tests/fixtures/command-surface.txt | 1192 +++++ .../tests/fixtures/plugin-version.txt | 1 + docs/SELFTEST.md | 39 + mise.toml | 26 +- 13 files changed, 3889 insertions(+), 2065 deletions(-) create mode 100644 .github/workflows/stripe-projects-watch.yml create mode 100644 .pre-commit-config.yaml create mode 100644 crates/stackless-stripe-projects/src/surface.rs create mode 100644 crates/stackless-stripe-projects/tests/fixtures/command-surface.txt create mode 100644 crates/stackless-stripe-projects/tests/fixtures/plugin-version.txt diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 313504e..824d6e4 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -63,7 +63,28 @@ jobs: echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" \ | sudo tee /etc/apt/sources.list.d/stripe.list sudo apt-get update && sudo apt-get install -y stripe - stripe plugins install projects + stripe plugin install "projects@$(tr -d '[:space:]' < crates/stackless-stripe-projects/tests/fixtures/plugin-version.txt)" + + # Validate the committed Stripe Projects snapshots against the live PINNED + # plugin before spending on cloud resources. `stripe-refresh` re-blesses + # and refuses to write if the live catalog has unmodeled drift, so this + # also gates catalog model coverage. Only the plugin-pinned artifacts must + # match exactly — catalog.json content is server-side and drifts on + # Stripe's schedule (the nightly watcher PRs those changes). + - name: Stripe Projects snapshot gate (pinned) + # No creds: `--version`, the `--help` surface, and `catalog --json` are + # all unauthenticated (the catalog is a public provider list). + run: | + pinned=$(tr -d '[:space:]' < crates/stackless-stripe-projects/tests/fixtures/plugin-version.txt) + installed=$(stripe projects --version | tr -d '[:space:]') + if [ "$installed" != "$pinned" ]; then + echo "::error::installed projects plugin $installed != pinned $pinned"; exit 1 + fi + mise run stripe-refresh + git diff --exit-code -- \ + crates/stackless-stripe-projects/tests/fixtures/command-surface.txt \ + crates/stackless-stripe-projects/tests/fixtures/plugin-version.txt \ + || { echo "::error::command surface is stale for plugin $pinned — run 'mise run stripe-refresh' and commit"; exit 1; } - name: Live ${{ matrix.provider }} up/down env: diff --git a/.github/workflows/stripe-projects-watch.yml b/.github/workflows/stripe-projects-watch.yml new file mode 100644 index 0000000..3d37c45 --- /dev/null +++ b/.github/workflows/stripe-projects-watch.yml @@ -0,0 +1,124 @@ +name: Stripe Projects watch + +# The "never remember to upgrade" tier. Nightly, it installs the LATEST +# `stripe projects` plugin (ignoring the committed pin), re-blesses the +# snapshots, and — if anything changed (new plugin version, new commands/flags, +# new services, or unmodeled catalog drift) — opens/updates a PR carrying the +# fixture diff as the changelog Stripe never publishes. The only human action is +# reviewing that PR. Detection of new versions is automatic. +# +# The bless needs NO Stripe creds — `--version`, the `--help` surface, and +# `catalog --json` are all unauthenticated. +# +# Required repo secrets: +# GH_PAT — a PAT with repo scope, so the opened PR re-triggers ci.yml's gates +# (PRs created with the default GITHUB_TOKEN do not). Falls back to +# GITHUB_TOKEN if absent (PR opens, no CI). + +on: + workflow_dispatch: + schedule: + - cron: "0 6 * * *" # nightly 06:00 UTC (ahead of the smoke run) + +permissions: + contents: write + pull-requests: write + +jobs: + watch: + name: watch stripe projects plugin + if: ${{ github.repository_owner == 'snowmead' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Install the mise CLI only, then install tools serially with a force-retry + # to avoid mise's parallel rustup component races on a cold cache. + - name: Install mise + uses: jdx/mise-action@v2 + with: + install: false + cache: false + - name: Install tools (serial, retried) + env: + MISE_JOBS: "1" + shell: bash + run: | + mise install && exit 0 + for i in 1 2; do echo "retry $i (force)"; sleep 10; mise install --force && exit 0; done + exit 1 + + - name: Rust / Cargo cache + uses: Swatinem/rust-cache@v2 + + - name: Install Stripe CLI + LATEST projects plugin + run: | + curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public \ + | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg >/dev/null + echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" \ + | sudo tee /etc/apt/sources.list.d/stripe.list + sudo apt-get update && sudo apt-get install -y stripe + stripe plugin install projects + + - name: Re-bless against the latest plugin + id: refresh + run: | + set +e + fixtures=crates/stackless-stripe-projects/tests/fixtures + old=$(tr -d '[:space:]' < "$fixtures/plugin-version.txt") + latest=$(stripe projects --version | tr -d '[:space:]') + mise run stripe-refresh >refresh.log 2>&1 + status=$? + cat refresh.log + # The bless aborts before writing if the live catalog has unmodeled + # drift; bump the version file ourselves so the PR still carries the + # signal and the diff explains the model needs a new variant/field. + if [ "$status" -ne 0 ]; then + printf '%s\n' "$latest" > "$fixtures/plugin-version.txt" + fi + { + echo "old=$old" + echo "latest=$latest" + echo "bless_status=$status" + } >> "$GITHUB_OUTPUT" + + - name: Compose PR body + env: + OLD: ${{ steps.refresh.outputs.old }} + LATEST: ${{ steps.refresh.outputs.latest }} + BLESS_STATUS: ${{ steps.refresh.outputs.bless_status }} + run: | + { + echo "Automated by \`.github/workflows/stripe-projects-watch.yml\`." + echo + echo "- Pinned plugin: \`$OLD\`" + echo "- Latest plugin: \`$LATEST\`" + echo + if [ "$BLESS_STATUS" -ne 0 ]; then + echo "> ⚠️ **The re-bless failed** — the live catalog has wire-format the typed" + echo "> model does not cover. Update \`crates/stackless-stripe-projects/src/catalog.rs\`," + echo "> then run \`mise run stripe-refresh\` to complete the snapshots." + echo + echo '```' + tail -n 40 refresh.log + echo '```' + else + echo "Re-blessed cleanly. Review the fixture diff below as the changelog:" + echo "command-surface.txt (commands/flags), catalog.json (services/schemas/pricing)." + fi + } > pr-body.md + + - name: Open / update upgrade PR + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} + branch: bot/stripe-projects-update + base: main + commit-message: "chore(stripe-projects): plugin ${{ steps.refresh.outputs.old }} → ${{ steps.refresh.outputs.latest }}" + title: "chore(stripe-projects): plugin ${{ steps.refresh.outputs.old }} → ${{ steps.refresh.outputs.latest }}" + body-path: pr-body.md + add-paths: | + crates/stackless-stripe-projects/tests/fixtures/command-surface.txt + crates/stackless-stripe-projects/tests/fixtures/plugin-version.txt + crates/stackless-stripe-projects/tests/fixtures/catalog.json + delete-branch: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..330d9dd --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,41 @@ +# Run by `prek` (the Rust pre-commit runner, installed via mise). Hooks are +# auto-wired by mise.toml's [hooks].postinstall on `mise install`; re-wire by +# hand with `mise run hooks`. Every hook shells out to a `mise run` task so the +# toolchain matches CI exactly. +# +# pre-commit: fast, offline checks that must never block a commit for long. +# pre-push: the full local correctness gate (no network — supply-chain stays +# a CI-only job). +repos: + - repo: local + hooks: + - id: fmt + name: cargo fmt --check + entry: mise run fmt + language: system + pass_filenames: false + stages: [pre-commit] + - id: taplo + name: taplo fmt --check + entry: mise run taplo + language: system + pass_filenames: false + stages: [pre-commit] + - id: stripe-coherence + name: stripe projects fixtures coherent + entry: mise run stripe-coherence + language: system + pass_filenames: false + stages: [pre-commit] + - id: check + name: fmt + clippy + taplo + entry: mise run check + language: system + pass_filenames: false + stages: [pre-push] + - id: test + name: cargo nextest (workspace, all features) + entry: mise run test + language: system + pass_filenames: false + stages: [pre-push] diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3c3bffb..dc0d102 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -490,8 +490,13 @@ proven there. `import` commands (`init --from` exists but nothing can generate a share URL yet — the same gap atto's `config.ts` recorded). Until the new flags land, the backend keeps `cloud-env.ts`'s plain-mode - fallbacks for `--json`'s confirmation/auth quirks; re-test on each - plugin release. Environment membership (`env add`/`env remove`) can + fallbacks for `--json`'s confirmation/auth quirks. The pinned + version of record and the full command surface are committed + snapshots + (`crates/stackless-stripe-projects/tests/fixtures/{plugin-version,command-surface}.txt`, + plus `catalog.json`); CI re-tests them against each release and a + nightly watcher opens an upgrade PR automatically (see + `docs/SELFTEST.md`). Environment membership (`env add`/`env remove`) can express a resource shared across instances — the seam for invariant 8's "unless the definition explicitly says so" (not in the v0 schema). diff --git a/crates/stackless-stripe-projects/src/lib.rs b/crates/stackless-stripe-projects/src/lib.rs index 85cf46f..88fba54 100644 --- a/crates/stackless-stripe-projects/src/lib.rs +++ b/crates/stackless-stripe-projects/src/lib.rs @@ -9,6 +9,7 @@ pub mod project; pub mod provision; pub mod responses; pub mod stripe; +pub mod surface; #[cfg(feature = "test-support")] pub mod test_support; @@ -20,3 +21,7 @@ pub use catalog::{Catalog, ServiceDetail}; pub use error::ProjectsError; pub use project::recorded_project_id; pub use stripe::{CommandOutput, CommandRunner, StripeProjects, StripeResult, TokioRunner}; +pub use surface::{ + command_separators, command_surface, parse_header_version, plugin_version, render_surface, + surface_header, +}; diff --git a/crates/stackless-stripe-projects/src/stripe.rs b/crates/stackless-stripe-projects/src/stripe.rs index 0702cb7..de6f5fb 100644 --- a/crates/stackless-stripe-projects/src/stripe.rs +++ b/crates/stackless-stripe-projects/src/stripe.rs @@ -353,6 +353,75 @@ mod tests { ); } + /// The per-catalog-state content version (`data.last_updated`) is stable + /// across requests but bumps whenever Stripe republishes the catalog — + /// noise unrelated to a real service/schema change. Normalize it so the + /// committed `catalog.json` only diffs on content we actually model. + const NORMALIZED_TIMESTAMP: &str = "1970-01-01T00:00:00.000Z"; + + /// Bless mode (`STRIPE_PROJECTS_REFRESH=1`): regenerate the three committed + /// snapshots — `plugin-version.txt`, `catalog.json`, `command-surface.txt` — + /// from the LOCALLY INSTALLED plugin. The only path that writes fixtures; + /// invoked by `mise run stripe-refresh` and the CI watcher. A no-op (and + /// needs no `stripe`) otherwise, so it stays inert in the hermetic gate. + #[tokio::test] + async fn refresh_blesses_snapshots() { + if std::env::var("STRIPE_PROJECTS_REFRESH").as_deref() != Ok("1") { + return; + } + let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let stripe = StripeProjects::new(TokioRunner, std::env::current_dir().unwrap()); + + // 1. Pinned plugin version. + let version = crate::surface::plugin_version(&stripe) + .await + .expect("probe `stripe projects --version`"); + + // 2. Catalog envelope — refuse to bless anything the typed model can't + // fully represent (forces a src/catalog.rs update first). + let raw = stripe + .plain(&["catalog", "--json"]) + .await + .expect("run `stripe projects catalog --json`"); + let start = raw.stdout.find('{').expect("catalog produced JSON"); + let json = &raw.stdout[start..]; + let report = crate::catalog::Catalog::from_json_envelope(json) + .expect("catalog envelope parses") + .drift_report(); + assert!( + report.is_empty(), + "live catalog has unmodeled drift — update src/catalog.rs before blessing:\n{}", + report.join("\n") + ); + let mut envelope: serde_json::Value = + serde_json::from_str(json).expect("catalog envelope is JSON"); + if let Some(data) = envelope + .get_mut("data") + .and_then(serde_json::Value::as_object_mut) + && data.contains_key("last_updated") + { + data.insert( + "last_updated".into(), + serde_json::Value::String(NORMALIZED_TIMESTAMP.into()), + ); + } + let catalog_pretty = format!( + "{}\n", + serde_json::to_string_pretty(&envelope).expect("serialize catalog") + ); + + // 3. Command surface (header carries the pinned version). + let body = crate::surface::command_surface(&stripe) + .await + .expect("capture command surface"); + let surface = crate::surface::render_surface(&version, &body); + + std::fs::write(fixtures.join("catalog.json"), catalog_pretty).unwrap(); + std::fs::write(fixtures.join("command-surface.txt"), surface).unwrap(); + std::fs::write(fixtures.join("plugin-version.txt"), format!("{version}\n")).unwrap(); + eprintln!("blessed snapshots for stripe projects plugin v{version}"); + } + #[tokio::test] async fn confirmation_code_falls_back_to_plain_mode() { let d = driver(vec![ diff --git a/crates/stackless-stripe-projects/src/surface.rs b/crates/stackless-stripe-projects/src/surface.rs new file mode 100644 index 0000000..edfc3cf --- /dev/null +++ b/crates/stackless-stripe-projects/src/surface.rs @@ -0,0 +1,214 @@ +//! Capture of the `stripe projects` command surface — the top-level help banner +//! plus every subcommand's `--help` — as a deterministic, committable text +//! snapshot. Diffing this artifact across plugin versions is the changelog +//! Stripe does not publish: added/removed/renamed commands and flags show up as +//! line diffs. Paired with the catalog fixture and the pinned version file, it +//! is regenerated by the `STRIPE_PROJECTS_REFRESH=1` bless path (see `stripe.rs`). + +use crate::error::ProjectsError; +use crate::stripe::{CommandRunner, StripeProjects}; + +/// Every `stripe projects` invocation we snapshot, as the argv *after* `projects`. +/// The order is fixed in code, not discovered from the plugin, so the snapshot +/// never self-reorders — a newly shipped command surfaces as a diff in the +/// top-level `--help` block and is then added here in the same change. Groups +/// (`services`/`env`/`billing`) are listed immediately before their children. +/// Verified against plugin 0.19.0. +const TRAVERSAL: &[&[&str]] = &[ + &[], + // GET STARTED + &["init"], + &["list"], + &["pull"], + &["status"], + &["services"], + &["services", "list"], + &["catalog"], + &["search"], + &["switch-account"], + // MANAGE SERVICES + &["add"], + &["update"], + &["upgrade"], + &["downgrade"], + &["remove"], + &["rotate"], + &["link"], + &["unlink"], + &["open"], + // ENVIRONMENT + &["env"], + &["env", "list"], + &["env", "show"], + &["env", "create"], + &["env", "use"], + &["env", "update"], + &["env", "delete"], + &["env", "add"], + &["env", "remove"], + &["llm-context"], + // BILLING + &["billing"], + &["billing", "show"], + &["billing", "add"], + &["billing", "update"], + &["spend"], + // FEEDBACK + &["feedback"], +]; + +const HEADER_VERSION_PREFIX: &str = "# plugin-version:"; + +/// Probe the installed plugin version via `stripe projects --version` +/// (which prints a bare `X.Y.Z` line). +pub async fn plugin_version( + stripe: &StripeProjects, +) -> Result { + let out = stripe.plain(&["--version"]).await?; + out.stdout + .lines() + .map(str::trim) + .find(|line| line.starts_with(|c: char| c.is_ascii_digit())) + .map(str::to_owned) + .ok_or_else(|| ProjectsError::Unavailable { + detail: format!( + "`stripe projects --version` printed no version line: {:?}", + out.stdout + ), + }) +} + +/// Capture the full command surface (body only — compose with [`surface_header`] +/// / [`render_surface`]). Each block is the normalized `--help` of one command, +/// introduced by a stable `===== … =====` separator. Output is byte-stable +/// across machines: trailing whitespace stripped, leading/trailing blank lines +/// dropped, line order preserved as the plugin emits it (never re-sorted). +pub async fn command_surface( + stripe: &StripeProjects, +) -> Result { + let mut out = String::new(); + for path in TRAVERSAL { + let mut args: Vec<&str> = path.to_vec(); + args.extend_from_slice(&["--help", "--color", "off"]); + let result = stripe.plain(&args).await?; + out.push_str(&format!("===== {} =====\n", label(path))); + out.push_str(&normalize_block(&result.stdout)); + out.push_str("\n\n"); + } + Ok(out.trim_end().to_owned()) +} + +/// The comment header that carries the pinned version (read back by the offline +/// coherence test via [`parse_header_version`]). +pub fn surface_header(version: &str) -> String { + format!( + "# stripe projects command surface\n\ + {HEADER_VERSION_PREFIX} {version}\n\ + # DO NOT EDIT — regenerated by CI (mise run stripe-refresh)\n\n" + ) +} + +/// The full committed file: header + captured body + a single trailing newline. +pub fn render_surface(version: &str, body: &str) -> String { + format!("{}{}\n", surface_header(version), body) +} + +/// Read the `plugin-version:` value out of a committed surface file's header. +pub fn parse_header_version(text: &str) -> Option { + text.lines() + .find_map(|line| line.trim().strip_prefix(HEADER_VERSION_PREFIX)) + .map(|value| value.trim().to_owned()) +} + +/// The `===== … =====` separator lines this code expects, in order — the +/// source of truth the offline coherence test compares the committed file +/// against (so a hand-edit or a `TRAVERSAL` change without a re-bless fails CI). +pub fn command_separators() -> Vec { + TRAVERSAL + .iter() + .map(|path| format!("===== {} =====", label(path))) + .collect() +} + +fn label(path: &[&str]) -> String { + if path.is_empty() { + "stripe projects --help".to_owned() + } else { + format!("stripe projects {} --help", path.join(" ")) + } +} + +fn normalize_block(raw: &str) -> String { + let mut lines: Vec<&str> = raw.lines().map(str::trim_end).collect(); + while lines.first().is_some_and(|line| line.is_empty()) { + lines.remove(0); + } + while lines.last().is_some_and(|line| line.is_empty()) { + lines.pop(); + } + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stripe::CommandOutput; + use async_trait::async_trait; + use std::path::Path; + + /// Returns canned help text echoing the argv it was called with, so we can + /// assert the assembly logic without scripting one output per command. + struct EchoRunner; + + #[async_trait] + impl CommandRunner for EchoRunner { + async fn run(&self, args: &[String], _cwd: &Path) -> Result { + Ok(CommandOutput { + status: 0, + // Pad with blank lines + trailing spaces to exercise normalization. + stdout: format!("\n\nhelp for: {} \n\n", args.join(" ")), + stderr: String::new(), + }) + } + } + + fn driver() -> StripeProjects { + StripeProjects::new(EchoRunner, std::env::temp_dir()) + } + + #[test] + fn label_handles_top_level_and_nested() { + assert_eq!(label(&[]), "stripe projects --help"); + assert_eq!(label(&["add"]), "stripe projects add --help"); + assert_eq!( + label(&["billing", "update"]), + "stripe projects billing update --help" + ); + } + + #[test] + fn normalize_strips_trailing_ws_and_blank_edges() { + assert_eq!(normalize_block("\n\n body \n\n"), " body"); + assert_eq!(normalize_block("a \nb\t\n"), "a\nb"); + } + + #[test] + fn header_round_trips_version() { + let text = render_surface("0.19.0", "===== stripe projects --help =====\nbody"); + assert_eq!(parse_header_version(&text).as_deref(), Some("0.19.0")); + assert!(text.ends_with("body\n")); + } + + #[tokio::test] + async fn surface_has_one_block_per_traversal_entry_in_order() { + let body = command_surface(&driver()).await.unwrap(); + let seps: Vec<&str> = body.lines().filter(|l| l.starts_with("===== ")).collect(); + assert_eq!(seps, command_separators()); + // Top-level uses the bare label and the echo carries the normalized args. + assert!(body.starts_with("===== stripe projects --help =====\n")); + assert!(body.contains("help for: --help --color off")); + assert!(body.contains("help for: env list --help --color off")); + // Trimmed: no trailing blank line. + assert!(!body.ends_with('\n')); + } +} diff --git a/crates/stackless-stripe-projects/tests/catalog_drift.rs b/crates/stackless-stripe-projects/tests/catalog_drift.rs index 7da61b4..f3b2c67 100644 --- a/crates/stackless-stripe-projects/tests/catalog_drift.rs +++ b/crates/stackless-stripe-projects/tests/catalog_drift.rs @@ -8,6 +8,16 @@ const FIXTURE: &str = include_str!(concat!( "/tests/fixtures/catalog.json" )); +const SURFACE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/command-surface.txt" +)); + +const PINNED_VERSION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/plugin-version.txt" +)); + #[test] fn fixture_parses_into_typed_catalog() { let catalog = @@ -35,3 +45,82 @@ fn fixture_has_no_unmodeled_drift() { report.join("\n") ); } + +/// Offline coherence of the plugin-pinned snapshots — runs in the every-PR +/// hermetic gate (no `stripe` needed). The live bless (`STRIPE_PROJECTS_REFRESH=1`) +/// produces these; this guards against hand-edits, a stale surface after a +/// `TRAVERSAL` change, a version-header mismatch, or a command added upstream +/// but not tracked. +#[test] +fn fixtures_are_coherent() { + use stackless_stripe_projects::{command_separators, parse_header_version}; + + let pinned = PINNED_VERSION.trim(); + assert!(!pinned.is_empty(), "plugin-version.txt is empty"); + + // (a) The surface header pins the same version as plugin-version.txt. + assert_eq!( + parse_header_version(SURFACE).as_deref(), + Some(pinned), + "command-surface.txt header version disagrees with plugin-version.txt — re-run `mise run stripe-refresh`" + ); + + // (b) The committed block separators are exactly what the code's TRAVERSAL + // produces, in order — catches hand-edits or a TRAVERSAL change with no + // re-bless. + let committed: Vec<&str> = SURFACE + .lines() + .filter(|l| l.starts_with("===== ")) + .collect(); + let expected = command_separators(); + assert_eq!( + committed, + expected.iter().map(String::as_str).collect::>(), + "command-surface.txt blocks differ from the code's TRAVERSAL — re-run `mise run stripe-refresh`" + ); + + // (c) Every command the top-level banner advertises has a captured block, so + // a command shipped upstream but not added to TRAVERSAL fails CI. + for cmd in banner_commands(SURFACE) { + let sep = format!("===== stripe projects {cmd} --help ====="); + assert!( + committed.contains(&sep.as_str()), + "banner advertises `{cmd}` but it has no captured block — add it to TRAVERSAL in src/surface.rs and re-bless" + ); + } +} + +/// First tokens of the commands listed in the top-level `--help` banner block. +/// A command line is indented exactly two spaces; wrapped description lines are +/// indented further, and flag/example lines start with `-`/`$`, so both are +/// excluded. +fn banner_commands(surface: &str) -> Vec { + let mut out = Vec::new(); + let mut in_banner = false; + for line in surface.lines() { + if line.starts_with("===== ") { + if in_banner { + break; // banner is the first block only + } + in_banner = line == "===== stripe projects --help ====="; + continue; + } + if !in_banner { + continue; + } + let Some(rest) = line.strip_prefix(" ") else { + continue; + }; + if rest.starts_with(' ') { + continue; // wrapped continuation line + } + let token = rest.split_whitespace().next().unwrap_or_default(); + if !token.is_empty() + && !token.starts_with('-') + && token.chars().all(|c| c.is_ascii_lowercase() || c == '-') + { + out.push(token.to_owned()); + } + } + out +} diff --git a/crates/stackless-stripe-projects/tests/fixtures/catalog.json b/crates/stackless-stripe-projects/tests/fixtures/catalog.json index 738eb53..743e1d3 100644 --- a/crates/stackless-stripe-projects/tests/fixtures/catalog.json +++ b/crates/stackless-stripe-projects/tests/fixtures/catalog.json @@ -1,16 +1,12 @@ { - "ok": true, "command": "projects catalog", - "version": "0.1", "data": { - "last_updated": "2026-06-13T03:14:18.070Z", - "provider": null, "category_filter": null, + "last_updated": "1970-01-01T00:00:00.000Z", + "provider": null, "provider_filter": null, "services": [ { - "id": "prvsvc_61Ur2jHrx3ZK5PWZE5Ska", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -24,23 +20,26 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "Exa API — usage-based pay-as-you-go (no monthly minimum).", "development": false, "group": null, + "id": "prvsvc_61Ur2jHrx3ZK5PWZE5Ska", "kind": "plan", + "livemode": true, "llm_context": "https://docs.exa.ai", + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -59,6 +58,7 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61Unj5D2LO6EYhGmo5DBQ", "provider_name": "Exa", "scope": "project", @@ -66,13 +66,9 @@ "updateable_to": [ "free", "pay_as_you_go" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61Ur2jHF33IrgPH6L5Ll2", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -86,29 +82,33 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "Exa API — free tier (no payment method required).", "development": false, "group": null, + "id": "prvsvc_61Ur2jHF33IrgPH6L5Ll2", "kind": "plan", + "livemode": true, "llm_context": "https://docs.exa.ai", + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61Unj5D2LO6EYhGmo5DBQ", "provider_name": "Exa", "scope": "project", @@ -116,13 +116,9 @@ "updateable_to": [ "pay_as_you_go", "free" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UqFZ25TrRMFL3qf5NT6", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -135,8 +131,11 @@ "description": "Team-scoped E2B API access for creating and managing cloud sandboxes.", "development": false, "group": "sandbox", + "id": "prvsvc_61UqFZ25TrRMFL3qf5NT6", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -170,19 +169,16 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61Um10GcB4A0pdo6z56Bk", "provider_name": "E2B", "scope": "project", "service_id": "sandbox", "updateable_to": [ "sandbox" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UpYx9jEdBpzJqz856Ai", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -191,22 +187,47 @@ ], "configuration_schema": { "additionalProperties": false, - "required": [ - "name", - "region", - "minReplicaMemoryGb", - "maxReplicaMemoryGb" - ], - "type": "object", "properties": { + "idleScaling": { + "description": "When true, compute scales to zero after idleTimeoutMinutes of inactivity and bills $0/hour during idle periods. Defaults to false when omitted.", + "type": "boolean" + }, + "idleTimeoutMinutes": { + "description": "Minutes of inactivity before idle scale-down triggers. Only meaningful when idleScaling=true. Defaults to 60 when omitted.", + "maximum": 1440, + "minimum": 5, + "type": "integer" + }, + "maxReplicaMemoryGb": { + "default": 12, + "description": "Autoscale upper bound: memory per replica, in GiB. Must be ≥ minReplicaMemoryGb (enforced server-side). Accepts 8–356 GiB per replica in steps of 4; the maximum drops to 236 GiB on AWS regions due to UI caps.", + "maximum": 356, + "minimum": 8, + "multipleOf": 4, + "type": "integer" + }, + "minReplicaMemoryGb": { + "default": 12, + "description": "Autoscale lower bound: memory per replica, in GiB. Must be ≤ maxReplicaMemoryGb (enforced server-side). Accepts 8–356 GiB per replica in steps of 4; the maximum drops to 236 GiB on AWS regions due to UI caps.", + "maximum": 356, + "minimum": 8, + "multipleOf": 4, + "type": "integer" + }, "name": { - "type": "string", "description": "Human-readable name for the ClickHouse service.", + "maxLength": 64, "minLength": 1, - "maxLength": 64 + "type": "string" + }, + "numReplicas": { + "default": 2, + "description": "Number of replicas (HA). Defaults to 2 when omitted. Each replica meters compute independently, so total compute cost scales linearly with numReplicas.", + "maximum": 100, + "minimum": 1, + "type": "integer" }, "region": { - "type": "string", "description": "Cloud provider and region to host the service in, as a single cloud-qualified id (e.g. aws-us-east-1, gcp-us-east1, azure-eastus). The cloud provider is encoded in the value, so no separate field is needed. Pricing varies per region.", "enum": [ "aws-us-west-2", @@ -230,50 +251,28 @@ "azure-germanywestcentral", "azure-eastus2", "azure-westus3" - ] - }, - "minReplicaMemoryGb": { - "type": "integer", - "description": "Autoscale lower bound: memory per replica, in GiB. Must be ≤ maxReplicaMemoryGb (enforced server-side). Accepts 8–356 GiB per replica in steps of 4; the maximum drops to 236 GiB on AWS regions due to UI caps.", - "minimum": 8, - "maximum": 356, - "multipleOf": 4, - "default": 12 - }, - "maxReplicaMemoryGb": { - "type": "integer", - "description": "Autoscale upper bound: memory per replica, in GiB. Must be ≥ minReplicaMemoryGb (enforced server-side). Accepts 8–356 GiB per replica in steps of 4; the maximum drops to 236 GiB on AWS regions due to UI caps.", - "minimum": 8, - "maximum": 356, - "multipleOf": 4, - "default": 12 - }, - "numReplicas": { - "type": "integer", - "description": "Number of replicas (HA). Defaults to 2 when omitted. Each replica meters compute independently, so total compute cost scales linearly with numReplicas.", - "minimum": 1, - "maximum": 100, - "default": 2 - }, - "idleScaling": { - "type": "boolean", - "description": "When true, compute scales to zero after idleTimeoutMinutes of inactivity and bills $0/hour during idle periods. Defaults to false when omitted." - }, - "idleTimeoutMinutes": { - "type": "integer", - "description": "Minutes of inactivity before idle scale-down triggers. Only meaningful when idleScaling=true. Defaults to 60 when omitted.", - "minimum": 5, - "maximum": 1440 + ], + "type": "string" } - } + }, + "required": [ + "name", + "region", + "minReplicaMemoryGb", + "maxReplicaMemoryGb" + ], + "type": "object" }, "constraints": [], "created": null, "description": "A ClickHouse Cloud service. Provision as many as you need under your active tier; compute and storage are metered by usage and vary by cloud provider and region.", "development": false, "group": null, + "id": "prvsvc_61UpYx9jEdBpzJqz856Ai", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -319,27 +318,24 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnUOEpxbShf2sSK5NYO", "provider_name": "ClickHouse", "scope": "account", "service_id": "clickhouse", "updateable_to": [ "clickhouse" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UpYx9erAOxXMmr45NKC", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ - { - "direction": "down", - "service": "basic" - }, { "direction": "up", "service": "enterprise" + }, + { + "direction": "down", + "service": "basic" } ], "availability": "available", @@ -358,8 +354,11 @@ "description": "ClickHouse Cloud Scale — production-grade managed ClickHouse with multi-replica baseline, autoscaling, and broader memory range. Usage-based pricing: compute metered per memory-hour between minReplicaMemoryGb and maxReplicaMemoryGb (times numReplicas), storage per compressed TB-month. Prices vary per (cloud provider, region) pair.", "development": false, "group": null, + "id": "prvsvc_61UpYx9erAOxXMmr45NKC", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -378,21 +377,18 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnUOEpxbShf2sSK5NYO", "provider_name": "ClickHouse", "scope": "account", "service_id": "scale", "updateable_to": [ - "basic", "enterprise", + "basic", "scale" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UpYx9UJqB5d3F0F5NoW", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -400,21 +396,31 @@ ], "configuration_schema": { "additionalProperties": false, - "required": [ - "name", - "region", - "size" - ], - "type": "object", "properties": { + "haType": { + "description": "High-availability mode. Defaults to \"none\" when omitted.", + "enum": [ + "none", + "async", + "sync" + ], + "type": "string" + }, "name": { - "type": "string", "description": "Human-readable name for the Postgres instance.", + "maxLength": 50, "minLength": 1, - "maxLength": 50 + "type": "string" + }, + "postgresVersion": { + "description": "Major Postgres version. Defaults to the latest available when omitted.", + "enum": [ + "18", + "17" + ], + "type": "string" }, "region": { - "type": "string", "description": "Cloud provider and region to host the service in, as a single cloud-qualified id (e.g. aws-us-east-1). The cloud provider is encoded in the value, so no separate field is needed. Pricing varies per region.", "enum": [ "aws-ap-northeast-1", @@ -428,10 +434,10 @@ "aws-us-east-1", "aws-us-east-2", "aws-us-west-2" - ] + ], + "type": "string" }, "size": { - "type": "string", "description": "VM instance type determining CPU, memory, and storage.", "enum": [ "c6gd.large", @@ -516,61 +522,51 @@ "r8gd.16xlarge", "r8gd.24xlarge", "r8gd.48xlarge" - ] - }, - "haType": { - "type": "string", - "description": "High-availability mode. Defaults to \"none\" when omitted.", - "enum": [ - "none", - "async", - "sync" - ] - }, - "postgresVersion": { - "type": "string", - "description": "Major Postgres version. Defaults to the latest available when omitted.", - "enum": [ - "18", - "17" - ] + ], + "type": "string" } - } + }, + "required": [ + "name", + "region", + "size" + ], + "type": "object" }, "constraints": [], "created": null, "description": "Managed Postgres Scale — production-grade fully managed PostgreSQL with configurable VM sizes, high availability, and automatic backups. Free during preview.", "development": false, "group": null, + "id": "prvsvc_61UpYx9UJqB5d3F0F5NoW", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnUOEpxbShf2sSK5NYO", "provider_name": "ClickHouse", "scope": "account", "service_id": "postgres", "updateable_to": [ "postgres" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UpYx95XOHi1awpf5Hjc", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "up", - "service": "scale" + "service": "enterprise" }, { "direction": "up", - "service": "enterprise" + "service": "scale" } ], "availability": "available", @@ -589,8 +585,11 @@ "description": "ClickHouse Cloud Basic — single-replica managed ClickHouse for development, prototyping, and small analytical workloads. Usage-based pricing: compute metered per memory-hour at a fixed replica memory size, storage per compressed TB-month. Idle scaling on by default — compute bills $0 during idle periods. Prices vary per (cloud provider, region) pair.", "development": false, "group": null, + "id": "prvsvc_61UpYx95XOHi1awpf5Hjc", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -609,21 +608,18 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnUOEpxbShf2sSK5NYO", "provider_name": "ClickHouse", "scope": "account", "service_id": "basic", "updateable_to": [ - "scale", "enterprise", + "scale", "basic" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UpYx93aimLq8kcN50MC", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "down", @@ -650,8 +646,11 @@ "description": "ClickHouse Cloud Enterprise — dedicated-infrastructure managed ClickHouse with enhanced support and the largest memory range. Usage-based pricing: compute metered per memory-hour between minReplicaMemoryGb and maxReplicaMemoryGb (times numReplicas), storage per compressed TB-month. Prices vary per (cloud provider, region) pair.", "development": false, "group": null, + "id": "prvsvc_61UpYx93aimLq8kcN50MC", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -670,6 +669,7 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnUOEpxbShf2sSK5NYO", "provider_name": "ClickHouse", "scope": "account", @@ -678,13 +678,9 @@ "scale", "basic", "enterprise" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UoVP5PExL9casZl5MBc", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -693,8 +689,6 @@ "storage" ], "configuration_schema": { - "type": "object", - "required": [], "additionalProperties": false, "properties": { "display_name": { @@ -719,34 +713,36 @@ ], "type": "integer" } - } + }, + "required": [], + "type": "object" }, "constraints": [], "created": null, "description": "Shared filesystem for agents to work on the same files across multiple sandboxes.", "development": false, "group": null, + "id": "prvsvc_61UoVP5PExL9casZl5MBc", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnoL4ZPCBLxqCQW5WJU", "provider_name": "Blaxel", "scope": "project", "service_id": "agent-drive", "updateable_to": [ "agent-drive" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UoVP5BTDjsjsQc75CnI", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -755,8 +751,6 @@ "ai" ], "configuration_schema": { - "type": "object", - "required": [], "additionalProperties": false, "properties": { "display_name": { @@ -806,34 +800,36 @@ ], "type": "string" } - } + }, + "required": [], + "type": "object" }, "constraints": [], "created": null, "description": "Secure sandboxes that instantly suspend when idle, and resume in 25 ms.", "development": false, "group": null, + "id": "prvsvc_61UoVP5BTDjsjsQc75CnI", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnoL4ZPCBLxqCQW5WJU", "provider_name": "Blaxel", "scope": "project", "service_id": "sandbox", "updateable_to": [ "sandbox" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UoVP4sNdO98tjUl5DTc", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "up", @@ -841,19 +837,19 @@ }, { "direction": "up", - "service": "tier-6" + "service": "tier-4" }, { "direction": "up", - "service": "tier-2" + "service": "tier-3" }, { "direction": "up", - "service": "tier-3" + "service": "tier-2" }, { "direction": "up", - "service": "tier-4" + "service": "tier-6" } ], "availability": "available", @@ -867,8 +863,11 @@ "description": "$20/month top-up enables Tier 1.", "development": false, "group": null, + "id": "prvsvc_61UoVP4sNdO98tjUl5DTc", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -879,10 +878,10 @@ "paid_pricing": [ { "configuration": { - "qualification_window_days": 30, - "tos_url": "https://blaxel.ai/terms", "credits_usd": 20, - "tier": "tier_1" + "qualification_window_days": 30, + "tier": "tier_1", + "tos_url": "https://blaxel.ai/terms" }, "description": "Tier 1: higher resource limits and all previous tier features", "freeform": "paid", @@ -892,36 +891,29 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnoL4ZPCBLxqCQW5WJU", "provider_name": "Blaxel", "scope": "account", "service_id": "tier-1", "updateable_to": [ "tier-5", - "tier-6", - "tier-2", - "tier-3", "tier-4", + "tier-3", + "tier-2", + "tier-6", "tier-1" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UoVP4rgI593mHBN534q", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "up", "service": "tier-5" }, - { - "direction": "up", - "service": "tier-6" - }, { "direction": "down", - "service": "tier-1" + "service": "tier-3" }, { "direction": "down", @@ -929,7 +921,11 @@ }, { "direction": "down", - "service": "tier-3" + "service": "tier-1" + }, + { + "direction": "up", + "service": "tier-6" } ], "availability": "available", @@ -943,8 +939,11 @@ "description": "$500/month top-up enables Tier 4.", "development": false, "group": null, + "id": "prvsvc_61UoVP4rgI593mHBN534q", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -955,10 +954,10 @@ "paid_pricing": [ { "configuration": { - "qualification_window_days": 30, - "tos_url": "https://blaxel.ai/terms", "credits_usd": 500, - "tier": "tier_4" + "qualification_window_days": 30, + "tier": "tier_4", + "tos_url": "https://blaxel.ai/terms" }, "description": "Tier 4: higher resource limits and all previous tier features", "freeform": "paid", @@ -968,32 +967,29 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnoL4ZPCBLxqCQW5WJU", "provider_name": "Blaxel", "scope": "account", "service_id": "tier-4", "updateable_to": [ "tier-5", - "tier-6", - "tier-1", - "tier-2", "tier-3", + "tier-2", + "tier-1", + "tier-6", "tier-4" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UoVP4lN2r9Zz1cm52gK", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { - "direction": "up", - "service": "tier-6" + "direction": "down", + "service": "tier-4" }, { "direction": "down", - "service": "tier-1" + "service": "tier-3" }, { "direction": "down", @@ -1001,11 +997,11 @@ }, { "direction": "down", - "service": "tier-3" + "service": "tier-1" }, { - "direction": "down", - "service": "tier-4" + "direction": "up", + "service": "tier-6" } ], "availability": "available", @@ -1019,8 +1015,11 @@ "description": "$1,500/month top-up enables Tier 5.", "development": false, "group": null, + "id": "prvsvc_61UoVP4lN2r9Zz1cm52gK", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -1031,10 +1030,10 @@ "paid_pricing": [ { "configuration": { - "qualification_window_days": 30, - "tos_url": "https://blaxel.ai/terms", "credits_usd": 1500, - "tier": "tier_5" + "qualification_window_days": 30, + "tier": "tier_5", + "tos_url": "https://blaxel.ai/terms" }, "description": "Tier 5: higher resource limits and all previous tier features", "freeform": "paid", @@ -1044,24 +1043,21 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnoL4ZPCBLxqCQW5WJU", "provider_name": "Blaxel", "scope": "account", "service_id": "tier-5", "updateable_to": [ - "tier-6", - "tier-1", - "tier-2", - "tier-3", "tier-4", + "tier-3", + "tier-2", + "tier-1", + "tier-6", "tier-5" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UoVP4W86SqdZrfE5Wfo", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "up", @@ -1069,19 +1065,19 @@ }, { "direction": "up", - "service": "tier-6" - }, - { - "direction": "down", - "service": "tier-1" + "service": "tier-4" }, { "direction": "up", "service": "tier-3" }, + { + "direction": "down", + "service": "tier-1" + }, { "direction": "up", - "service": "tier-4" + "service": "tier-6" } ], "availability": "available", @@ -1095,8 +1091,11 @@ "description": "$50/month top-up enables Tier 2.", "development": false, "group": null, + "id": "prvsvc_61UoVP4W86SqdZrfE5Wfo", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -1107,10 +1106,10 @@ "paid_pricing": [ { "configuration": { - "qualification_window_days": 30, - "tos_url": "https://blaxel.ai/terms", "credits_usd": 50, - "tier": "tier_2" + "qualification_window_days": 30, + "tier": "tier_2", + "tos_url": "https://blaxel.ai/terms" }, "description": "Tier 2: higher resource limits and all previous tier features", "freeform": "paid", @@ -1120,32 +1119,29 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnoL4ZPCBLxqCQW5WJU", "provider_name": "Blaxel", "scope": "account", "service_id": "tier-2", "updateable_to": [ "tier-5", - "tier-6", - "tier-1", - "tier-3", "tier-4", + "tier-3", + "tier-1", + "tier-6", "tier-2" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UoVP48qZJ5CIIpa59nk", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "down", - "service": "tier-1" + "service": "tier-5" }, { "direction": "down", - "service": "tier-2" + "service": "tier-4" }, { "direction": "down", @@ -1153,11 +1149,11 @@ }, { "direction": "down", - "service": "tier-4" + "service": "tier-2" }, { "direction": "down", - "service": "tier-5" + "service": "tier-1" } ], "availability": "available", @@ -1171,8 +1167,11 @@ "description": "$4,000/month top-up enables Tier 6.", "development": false, "group": null, + "id": "prvsvc_61UoVP48qZJ5CIIpa59nk", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -1183,10 +1182,10 @@ "paid_pricing": [ { "configuration": { - "qualification_window_days": 30, - "tos_url": "https://blaxel.ai/terms", "credits_usd": 4000, - "tier": "tier_6" + "qualification_window_days": 30, + "tier": "tier_6", + "tos_url": "https://blaxel.ai/terms" }, "description": "Tier 6: higher resource limits and all previous tier features", "freeform": "paid", @@ -1196,24 +1195,21 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnoL4ZPCBLxqCQW5WJU", "provider_name": "Blaxel", "scope": "account", "service_id": "tier-6", "updateable_to": [ - "tier-1", - "tier-2", - "tier-3", - "tier-4", "tier-5", + "tier-4", + "tier-3", + "tier-2", + "tier-1", "tier-6" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UoVP43LSS5zYkeT5MTg", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "up", @@ -1221,19 +1217,19 @@ }, { "direction": "up", - "service": "tier-6" + "service": "tier-4" }, { "direction": "down", - "service": "tier-1" + "service": "tier-2" }, { "direction": "down", - "service": "tier-2" + "service": "tier-1" }, { "direction": "up", - "service": "tier-4" + "service": "tier-6" } ], "availability": "available", @@ -1247,8 +1243,11 @@ "description": "$200/month top-up enables Tier 3.", "development": false, "group": null, + "id": "prvsvc_61UoVP43LSS5zYkeT5MTg", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -1259,10 +1258,10 @@ "paid_pricing": [ { "configuration": { - "qualification_window_days": 30, - "tos_url": "https://blaxel.ai/terms", "credits_usd": 200, - "tier": "tier_3" + "qualification_window_days": 30, + "tier": "tier_3", + "tos_url": "https://blaxel.ai/terms" }, "description": "Tier 3: higher resource limits and all previous tier features", "freeform": "paid", @@ -1272,24 +1271,21 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnoL4ZPCBLxqCQW5WJU", "provider_name": "Blaxel", "scope": "account", "service_id": "tier-3", "updateable_to": [ "tier-5", - "tier-6", - "tier-1", - "tier-2", "tier-4", + "tier-2", + "tier-1", + "tier-6", "tier-3" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61Uo4EzNdZ9Do6pli5GKG", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -1298,14 +1294,14 @@ "configuration_schema": { "additionalProperties": true, "properties": { - "workspace_name": { - "type": "string", - "description": "Optional display name for the PostalForm Projects workspace." - }, "webhook_url": { - "type": "string", + "description": "Optional HTTPS endpoint that receives signed PostalForm status webhooks. If supplied, PostalForm creates or reuses a webhook endpoint and returns its signing secret in the access configuration.", "pattern": "^https://", - "description": "Optional HTTPS endpoint that receives signed PostalForm status webhooks. If supplied, PostalForm creates or reuses a webhook endpoint and returns its signing secret in the access configuration." + "type": "string" + }, + "workspace_name": { + "description": "Optional display name for the PostalForm Projects workspace.", + "type": "string" } }, "type": "object" @@ -1322,8 +1318,11 @@ "description": "Agentic postal mail infrastructure for uploading PDFs, quoting mailpieces, routing to the right print-mail rail, tracking status, and receiving signed webhooks.", "development": false, "group": null, + "id": "prvsvc_61Uo4EzNdZ9Do6pli5GKG", "kind": "deployable", + "livemode": true, "llm_context": "https://projects.postalform.com/llm-context.txt", + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -1353,45 +1352,41 @@ ], "type": "paid" }, - "provider_id": "prvdr_61Uo1AQvDtb7Wseng5OHw", - "provider_name": "PostalForm", - "scope": "project", - "service_id": "mail", - "updateable_to": [ - "mail" - ], - "livemode": true, "provider_configuration_schema": { - "title": "PostalForm Projects configuration", - "type": "object", "additionalProperties": false, "properties": { + "webhook_url": { + "description": "Optional customer webhook endpoint. When supplied, PostalForm creates an endpoint-scoped signing secret and returns it for Stripe Projects env sync.", + "pattern": "^https://", + "title": "Webhook URL", + "type": "string" + }, "workspace_name": { - "type": "string", - "title": "Workspace name", "description": "Optional display name for the PostalForm Projects workspace created for this Stripe Project.", + "maxLength": 120, "minLength": 1, - "maxLength": 120 - }, - "webhook_url": { - "type": "string", - "title": "Webhook URL", - "description": "Optional customer webhook endpoint. When supplied, PostalForm creates an endpoint-scoped signing secret and returns it for Stripe Projects env sync.", - "pattern": "^https://" + "title": "Workspace name", + "type": "string" } - } - } + }, + "title": "PostalForm Projects configuration", + "type": "object" + }, + "provider_id": "prvdr_61Uo1AQvDtb7Wseng5OHw", + "provider_name": "PostalForm", + "scope": "project", + "service_id": "mail", + "updateable_to": [ + "mail" + ] }, { - "id": "prvsvc_61UnpimP4ZbPSUqGE5EVs", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ "ai" ], "configuration_schema": { - "type": "object", "properties": { "auto_reload_amount": { "default": 10, @@ -1411,7 +1406,8 @@ "minimum": 5, "type": "integer" } - } + }, + "type": "object" }, "constraints": [ { @@ -1425,8 +1421,11 @@ "description": "Programmatic access to HeyGen's AI video generation platform. Create avatar videos, translate videos, generate text-to-speech, and more.", "development": false, "group": null, + "id": "prvsvc_61UnpimP4ZbPSUqGE5EVs", "kind": "deployable", + "livemode": true, "llm_context": "https://docs.heygen.com/reference/overview", + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -1445,19 +1444,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnkAFYNcaD50TOY54Q4", "provider_name": "HeyGen", "scope": "project", "service_id": "api", "updateable_to": [ "api" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61Unpik5nbTrwKNlq5Fnk", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -1470,8 +1466,11 @@ "description": "Exa - the fastest, most accurate web search API.", "development": false, "group": null, + "id": "prvsvc_61Unpik5nbTrwKNlq5Fnk", "kind": "deployable", + "livemode": true, "llm_context": "https://docs.exa.ai", + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -1499,19 +1498,16 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61Unj5D2LO6EYhGmo5DBQ", "provider_name": "Exa", "scope": "project", "service_id": "api", "updateable_to": [ "api" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UnlVNWQktyEY34f54Qy", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "up", @@ -1524,23 +1520,26 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "E2B Hobby plan for pay-as-you-go sandbox usage.", "development": false, "group": null, + "id": "prvsvc_61UnlVNWQktyEY34f54Qy", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -1559,6 +1558,7 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61Um10GcB4A0pdo6z56Bk", "provider_name": "E2B", "scope": "project", @@ -1566,13 +1566,9 @@ "updateable_to": [ "pro", "hobby" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UnlVNIHPq9taEAz5PkW", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "down", @@ -1585,23 +1581,26 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "Monthly E2B Pro plan plus pay-as-you-go usage.", "development": false, "group": null, + "id": "prvsvc_61UnlVNIHPq9taEAz5PkW", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -1620,6 +1619,7 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61Um10GcB4A0pdo6z56Bk", "provider_name": "E2B", "scope": "project", @@ -1627,13 +1627,9 @@ "updateable_to": [ "hobby", "pro" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UnW3OEYtK9tyzVp57ii", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -1645,28 +1641,31 @@ "configuration_schema": { "properties": { "plan": { - "type": "string", + "description": "Subscription tier", "enum": [ "free", "pro", "max", "scale" ], - "description": "Subscription tier" + "type": "string" } }, - "type": "object", "required": [ "plan" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "Memory for AI agents - context, SuperRAG, file system, profile, and connectors.", "development": false, "group": null, + "id": "prvsvc_61UnW3OEYtK9tyzVp57ii", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -1714,19 +1713,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnTCu1qlHAWbYZU5Sv2", "provider_name": "Supermemory", "scope": "project", "service_id": "memory", "updateable_to": [ "memory" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UnRN4g3FLaMqy3R5BbU", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -1736,22 +1732,25 @@ "configuration_schema": { "properties": { "name": { - "type": "string", - "description": "A name for this API key" + "description": "A name for this API key", + "type": "string" } }, - "type": "object", "required": [ "name" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "Kernel project + API key for the full platform: cloud browsers, serverless agent apps, managed auth, proxies, pools, and observability", "development": false, "group": null, + "id": "prvsvc_61UnRN4g3FLaMqy3R5BbU", "kind": "deployable", + "livemode": true, "llm_context": "https://kernel.sh/docs/integrations/stripe-projects-browser.md", + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -1770,8 +1769,8 @@ "paid": null, "parent_services": [ "startup", - "hobbyist", - "developer" + "developer", + "hobbyist" ], "type": "free" } @@ -1781,19 +1780,16 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnOfhF7jlP9Fcxr55Z2", "provider_name": "KERNEL", "scope": "project", "service_id": "project", "updateable_to": [ "project" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UnRN4ZCH8WZ2pj15CmW", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "up", @@ -1810,29 +1806,33 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "Developer (Free) - $5/mo free credits, 5 concurrent browsers", "development": false, "group": null, + "id": "prvsvc_61UnRN4ZCH8WZ2pj15CmW", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnOfhF7jlP9Fcxr55Z2", "provider_name": "KERNEL", "scope": "account", @@ -1841,21 +1841,17 @@ "hobbyist", "startup", "developer" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UnRN46HpcNNLu9n5LGa", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "down", - "service": "developer" + "service": "hobbyist" }, { "direction": "down", - "service": "hobbyist" + "service": "developer" } ], "availability": "available", @@ -1864,23 +1860,26 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "Start-Up - $200/mo, $50 free credits, 150 concurrent browsers", "development": false, "group": null, + "id": "prvsvc_61UnRN46HpcNNLu9n5LGa", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -1899,21 +1898,18 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnOfhF7jlP9Fcxr55Z2", "provider_name": "KERNEL", "scope": "account", "service_id": "startup", "updateable_to": [ - "developer", "hobbyist", + "developer", "startup" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UnRN42ixqqGQI7J5TsG", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "down", @@ -1930,23 +1926,26 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "Hobbyist - $30/mo, $10 free credits, 10 concurrent browsers", "development": false, "group": null, + "id": "prvsvc_61UnRN42ixqqGQI7J5TsG", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -1965,6 +1964,7 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnOfhF7jlP9Fcxr55Z2", "provider_name": "KERNEL", "scope": "account", @@ -1973,13 +1973,9 @@ "developer", "startup", "hobbyist" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UnN9mCVO72xgHJE5ByC", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -1987,26 +1983,26 @@ ], "configuration_schema": { "additionalProperties": false, - "required": [], - "type": "object", "properties": { "plan": { - "type": "string", + "description": "Pre-filled from the tier selected above; override only if you need to.", "enum": [ "free", "premium" ], "title": "Plan", - "description": "Pre-filled from the tier selected above; override only if you need to." + "type": "string" }, "wix_project_name": { - "type": "string", - "title": "Wix project name", "description": "Human-readable name for the Wix site that backs this project. Shown in the Wix dashboard.", + "maxLength": 50, "minLength": 1, - "maxLength": 50 + "title": "Wix project name", + "type": "string" } - } + }, + "required": [], + "type": "object" }, "constraints": [ { @@ -2020,8 +2016,11 @@ "description": "Use Wix Business to let your customers purchase products and services through your Stripe-powered site.", "development": false, "group": null, + "id": "prvsvc_61UnN9mCVO72xgHJE5ByC", "kind": "deployable", + "livemode": true, "llm_context": "https://raw.githubusercontent.com/wix/skills/refs/heads/headless-stripe/skills/sp-wix/SKILL.md", + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -2051,19 +2050,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61Uj2YmiyLwllzQQX5Vui", "provider_name": "Wix", "scope": "project", "service_id": "headless", "updateable_to": [ "headless" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UnMDq8omUfE6UXb5Ndg", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -2076,8 +2072,11 @@ "description": "Parallel - agent-grade web tools and intelligence APIs. One API key, five products: Search, Extract, Task, FindAll, and Monitor.", "development": false, "group": null, + "id": "prvsvc_61UnMDq8omUfE6UXb5Ndg", "kind": "deployable", + "livemode": true, "llm_context": "https://docs.parallel.ai", + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -2097,8 +2096,8 @@ }, { "configuration": { - "tier": "additional_results", - "product": "search" + "product": "search", + "tier": "additional_results" }, "description": "Search API - each result beyond the default 10", "freeform": "+$1 per 1,000 additional page results & excerpts", @@ -2206,8 +2205,8 @@ }, { "configuration": { - "product": "findall", - "generator": "preview" + "generator": "preview", + "product": "findall" }, "description": "FindAll API - preview generator", "freeform": "$0.10 per query + $0.00 per match", @@ -2216,8 +2215,8 @@ }, { "configuration": { - "product": "findall", - "generator": "base" + "generator": "base", + "product": "findall" }, "description": "FindAll API - base generator", "freeform": "$0.25 per query + $0.03 per match", @@ -2226,8 +2225,8 @@ }, { "configuration": { - "product": "findall", - "generator": "core" + "generator": "core", + "product": "findall" }, "description": "FindAll API - core generator", "freeform": "$2.00 per query + $0.15 per match", @@ -2236,8 +2235,8 @@ }, { "configuration": { - "product": "findall", - "generator": "pro" + "generator": "pro", + "product": "findall" }, "description": "FindAll API - pro generator", "freeform": "$10.00 per query + $1.00 per match", @@ -2267,19 +2266,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UnKXcVflBIkLCbX54aW", "provider_name": "Parallel", "scope": "project", "service_id": "api", "updateable_to": [ "api" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61Un5MQzPatiTZOqW518y", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "down", @@ -2292,23 +2288,26 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "Growth — $20/mo + usage", "development": false, "group": null, + "id": "prvsvc_61Un5MQzPatiTZOqW518y", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -2327,6 +2326,7 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61Ulotgugv9hEYa1b5HwO", "provider_name": "Laravel_Cloud", "scope": "account", @@ -2334,13 +2334,9 @@ "updateable_to": [ "starter", "growth" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61Un5MQtjaUBWy8U05PkO", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -2349,7 +2345,6 @@ "configuration_schema": { "properties": { "instance_size": { - "type": "string", "enum": [ "valkey-flex-250mb", "valkey-flex-1gb", @@ -2358,10 +2353,10 @@ "valkey-pro.12gb", "valkey-pro.25gb", "valkey-pro.50gb" - ] + ], + "type": "string" }, "region": { - "type": "string", "enum": [ "us-east-1", "us-east-2", @@ -2373,22 +2368,26 @@ "ap-northeast-1", "ca-central-1", "me-central-1" - ] + ], + "type": "string" } }, - "type": "object", "required": [ "instance_size", "region" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "Managed Valkey cache for session storage, queues, and application caching.", "development": false, "group": "valkey", + "id": "prvsvc_61Un5MQtjaUBWy8U05PkO", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -2400,8 +2399,8 @@ "type": "freeform" }, "parent_services": [ - "growth", - "starter" + "starter", + "growth" ], "type": "paid" } @@ -2411,19 +2410,16 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61Ulotgugv9hEYa1b5HwO", "provider_name": "Laravel_Cloud", "scope": "project", "service_id": "valkey", "updateable_to": [ "valkey" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61Un5MQcn2daMFv4M5Xkm", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -2431,18 +2427,32 @@ ], "configuration_schema": { "properties": { + "create_cache": { + "default": "no", + "description": "Provision a managed Valkey cache and connect it to this application.", + "enum": [ + "yes", + "no" + ], + "title": "Create a cache", + "type": "string" + }, + "create_database": { + "default": "no", + "description": "Provision a managed MySQL database and connect it to this application.", + "enum": [ + "yes", + "no" + ], + "title": "Create a database", + "type": "string" + }, "name": { - "type": "string", + "description": "A name for your Laravel application.", "title": "Application Name", - "description": "A name for your Laravel application." - }, - "repository": { - "type": "string", - "title": "Repository", - "description": "Full repository name (e.g. laravel/cloud)." + "type": "string" }, "region": { - "type": "string", "enum": [ "us-east-1", "us-east-2", @@ -2454,43 +2464,32 @@ "ap-northeast-1", "ca-central-1", "me-central-1" - ] - }, - "create_database": { - "type": "string", - "title": "Create a database", - "description": "Provision a managed MySQL database and connect it to this application.", - "enum": [ - "yes", - "no" ], - "default": "no" + "type": "string" }, - "create_cache": { - "type": "string", - "title": "Create a cache", - "description": "Provision a managed Valkey cache and connect it to this application.", - "enum": [ - "yes", - "no" - ], - "default": "no" + "repository": { + "description": "Full repository name (e.g. laravel/cloud).", + "title": "Repository", + "type": "string" } }, - "type": "object", "required": [ "name", "repository", "region" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "Laravel application with a default production environment.", "development": false, "group": "compute", + "id": "prvsvc_61Un5MQcn2daMFv4M5Xkm", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -2502,8 +2501,8 @@ "type": "freeform" }, "parent_services": [ - "growth", - "starter" + "starter", + "growth" ], "type": "paid" } @@ -2513,19 +2512,16 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61Ulotgugv9hEYa1b5HwO", "provider_name": "Laravel_Cloud", "scope": "project", "service_id": "application", "updateable_to": [ "application" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61Un5MQNZLdF66QkZ59BY", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -2534,7 +2530,6 @@ "configuration_schema": { "properties": { "instance_size": { - "type": "string", "enum": [ "db-flex.m-1vcpu-512mb", "db-flex.m-1vcpu-1gb", @@ -2544,10 +2539,10 @@ "db-pro.m-2vcpu-8gb", "db-pro.m-4vcpu-16gb", "db-pro.m-8vcpu-32gb" - ] + ], + "type": "string" }, "region": { - "type": "string", "enum": [ "us-east-1", "us-east-2", @@ -2559,22 +2554,26 @@ "ap-northeast-1", "ca-central-1", "me-central-1" - ] + ], + "type": "string" } }, - "type": "object", "required": [ "instance_size", "region" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "Managed MySQL database with automated backups and point-in-time recovery.", "development": false, "group": "mysql", + "id": "prvsvc_61Un5MQNZLdF66QkZ59BY", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -2586,8 +2585,8 @@ "type": "freeform" }, "parent_services": [ - "growth", - "starter" + "starter", + "growth" ], "type": "paid" } @@ -2597,19 +2596,16 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61Ulotgugv9hEYa1b5HwO", "provider_name": "Laravel_Cloud", "scope": "project", "service_id": "mysql", "updateable_to": [ "mysql" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61Un5MQMlQ7sIkE0F5SlM", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "up", @@ -2622,23 +2618,26 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "Starter — $5/mo + usage", "development": false, "group": null, + "id": "prvsvc_61Un5MQMlQ7sIkE0F5SlM", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -2657,6 +2656,7 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61Ulotgugv9hEYa1b5HwO", "provider_name": "Laravel_Cloud", "scope": "account", @@ -2664,13 +2664,9 @@ "updateable_to": [ "growth", "starter" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61Un19QRMBxRkldr75Bua", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -2679,7 +2675,6 @@ "configuration_schema": { "properties": { "plan": { - "type": "string", "description": "Which WordPress.com plan to provision. See the pricing options for what each plan includes.", "enum": [ "free", @@ -2687,21 +2682,25 @@ "premium", "business", "commerce" - ] + ], + "type": "string" } }, - "type": "object", "required": [ "plan" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "Free and Paid fully-managed WordPress sites with unlimited power, unrivaled speed, and rock-solid reliability.", "development": false, "group": null, + "id": "prvsvc_61Un19QRMBxRkldr75Bua", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, @@ -2754,19 +2753,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UmxGkoAMSyI59y25SzA", "provider_name": "WordPress.com", "scope": "project", "service_id": "site", "updateable_to": [ "site" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61Un19Q6zwT5p67dZ5HBw", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -2775,22 +2771,25 @@ "configuration_schema": { "properties": { "domain_or_query": { - "type": "string", - "description": "Enter a specific domain to register (e.g. \"example.blog\") or a search query:" + "description": "Enter a specific domain to register (e.g. \"example.blog\") or a search query:", + "type": "string" } }, - "type": "object", "required": [ "domain_or_query" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "Domain registration", "development": false, "group": null, + "id": "prvsvc_61Un19Q6zwT5p67dZ5HBw", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -2809,19 +2808,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UmxGkoAMSyI59y25SzA", "provider_name": "WordPress.com", "scope": "project", "service_id": "domain", "updateable_to": [ "domain" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UmYa9N891Xe9Jgw5VUW", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -2832,17 +2828,17 @@ ], "configuration_schema": { "additionalProperties": false, - "required": [], - "type": "object", "properties": { "app_name": { - "type": "string", - "title": "App name", "description": "Human-readable name for the Base44 app. Shown in the Base44 dashboard.", + "maxLength": 50, "minLength": 1, - "maxLength": 50 + "title": "App name", + "type": "string" } - } + }, + "required": [], + "type": "object" }, "constraints": [ { @@ -2856,27 +2852,27 @@ "description": "A Base44 app with AI services, hosting, database and auth.", "development": false, "group": null, + "id": "prvsvc_61UmYa9N891Xe9Jgw5VUW", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UlD0fxuDb9nsZ7c5VfE", "provider_name": "Base44_Projects", "scope": "project", "service_id": "app", "updateable_to": [ "app" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UlsuY5RlLAusyo45VRY", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -2884,13 +2880,9 @@ ], "configuration_schema": { "additionalProperties": false, - "required": [ - "region" - ], - "type": "object", "properties": { "region": { - "type": "string", + "default": "us-east-1", "description": "Prisma Postgres region identifier (Defaults to \"us-east-1\").", "enum": [ "us-east-1", @@ -2900,17 +2892,24 @@ "ap-northeast-1", "ap-southeast-1" ], - "default": "us-east-1" + "type": "string" } - } + }, + "required": [ + "region" + ], + "type": "object" }, "constraints": [], "created": null, "description": "Prisma Postgres — managed Postgres for Stripe Projects.", "development": false, "group": "postgresql", + "id": "prvsvc_61UlsuY5RlLAusyo45VRY", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -2918,10 +2917,10 @@ "is_default": null, "paid": null, "parent_services": [ - "free", "pro", - "starter", - "business" + "free", + "business", + "starter" ], "type": "free" } @@ -2931,23 +2930,20 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61Ulq2NBoEkAzhMKC50vA", "provider_name": "Prisma", "scope": "project", "service_id": "database", "updateable_to": [ "database" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UlrybmuvcOTXGBn5T20", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "up", - "service": "starter" + "service": "pro" }, { "direction": "any", @@ -2955,7 +2951,7 @@ }, { "direction": "up", - "service": "pro" + "service": "starter" }, { "direction": "up", @@ -2972,34 +2968,34 @@ "description": "Free: includes 100k queries and 0.5 GiB-month storage per billing cycle; hard limits, no paid overages.", "development": false, "group": "postgresql", + "id": "prvsvc_61UlrybmuvcOTXGBn5T20", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61Ulq2NBoEkAzhMKC50vA", "provider_name": "Prisma", "scope": "account", "service_id": "free", "updateable_to": [ - "starter", - "free", "pro", + "free", + "starter", "business" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UlrybPm8nSyXRAp5WQ4", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "down", - "service": "starter" + "service": "free" }, { "direction": "down", @@ -3007,7 +3003,7 @@ }, { "direction": "down", - "service": "free" + "service": "starter" }, { "direction": "any", @@ -3024,8 +3020,11 @@ "description": "Business: includes 50M queries and 100 GiB-month storage per billing cycle; usage beyond the included amounts is billed as metered overages.", "development": false, "group": "postgresql", + "id": "prvsvc_61UlrybPm8nSyXRAp5WQ4", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -3044,38 +3043,35 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61Ulq2NBoEkAzhMKC50vA", "provider_name": "Prisma", "scope": "account", "service_id": "business", "updateable_to": [ - "starter", - "pro", "free", + "pro", + "starter", "business" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UlrybITB9QLAOvr5XK4", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ - { - "direction": "any", - "service": "pro" - }, { "direction": "down", - "service": "starter" + "service": "free" }, { "direction": "down", - "service": "free" + "service": "starter" }, { "direction": "up", "service": "business" + }, + { + "direction": "any", + "service": "pro" } ], "availability": "available", @@ -3088,8 +3084,11 @@ "description": "Pro: includes 10M queries and 50 GiB-month storage per billing cycle; usage beyond the included amounts is billed as metered overages.", "development": false, "group": "postgresql", + "id": "prvsvc_61UlrybITB9QLAOvr5XK4", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -3108,38 +3107,35 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61Ulq2NBoEkAzhMKC50vA", "provider_name": "Prisma", "scope": "account", "service_id": "pro", "updateable_to": [ - "pro", - "starter", "free", - "business" - ], - "livemode": true, - "provider_configuration_schema": {} + "starter", + "business", + "pro" + ] }, { - "id": "prvsvc_61UlrybAD60mzBE535IK0", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ - { - "direction": "up", - "service": "pro" - }, { "direction": "down", "service": "free" }, { - "direction": "any", - "service": "starter" + "direction": "up", + "service": "pro" }, { "direction": "up", "service": "business" + }, + { + "direction": "any", + "service": "starter" } ], "availability": "available", @@ -3152,8 +3148,11 @@ "description": "Starter: includes 1M queries and 10 GiB-month storage per billing cycle; usage beyond the included amounts is billed as metered overages.", "development": false, "group": "postgresql", + "id": "prvsvc_61UlrybAD60mzBE535IK0", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -3172,22 +3171,19 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61Ulq2NBoEkAzhMKC50vA", "provider_name": "Prisma", "scope": "account", "service_id": "starter", "updateable_to": [ - "pro", "free", - "starter", - "business" - ], - "livemode": true, - "provider_configuration_schema": {} + "pro", + "business", + "starter" + ] }, { - "id": "prvsvc_61Uko2Io3DIJh5XPm5SMy", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -3206,27 +3202,27 @@ "description": "Metronome sandbox - usage-based billing platform that provides real-time metering, pricing, billing, and reporting", "development": false, "group": null, + "id": "prvsvc_61Uko2Io3DIJh5XPm5SMy", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61Uko2Ae2S1CUgAxI54kS", "provider_name": "Metronome", "scope": "project", "service_id": "sandbox", "updateable_to": [ "sandbox" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UjUw0uJ5MmJP6Rd5X3A", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -3235,34 +3231,37 @@ "configuration_schema": { "properties": { "agent_name": { - "type": "string", - "description": "Name for the AI agent" + "description": "Name for the AI agent", + "type": "string" + }, + "area_code": { + "description": "Preferred 3-digit area code (optional)", + "type": "string" }, "country": { - "type": "string", + "description": "Country for the phone number", "enum": [ "US", "CA" ], - "description": "Country for the phone number" - }, - "area_code": { - "type": "string", - "description": "Preferred 3-digit area code (optional)" + "type": "string" } }, - "type": "object", "required": [ "agent_name" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "Provision a phone number with voice and messaging capabilities for your AI agent. Get a US or CA number and start making calls and sending messages.", "development": false, "group": null, + "id": "prvsvc_61UjUw0uJ5MmJP6Rd5X3A", "kind": "deployable", + "livemode": true, "llm_context": "https://docs.agentphone.ai/llms-full.txt", + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -3281,19 +3280,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UjQLgXIM0R40WnZ5S8m", "provider_name": "AgentPhone", "scope": "project", "service_id": "number", "updateable_to": [ "number" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UbJKSxrFJT4QlwY5JA8", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -3305,22 +3301,25 @@ "ai" ], "configuration_schema": { - "type": "object", "properties": { "rechargeAmountUsd": { - "type": "number", + "description": "Optional pay-as-you-go auto top-up ceiling, in USD. If set, usage above the plan's included quota is billed via the shared payment token: your prepaid balance is refilled to this amount whenever it runs low. If unset, the plan's included quota is a hard cap (the service stops once depleted). Minimum $15. To disable a previously enabled auto top-up, send `{ \"rechargeAmountUsd\": null }` via update_service. See https://huggingface.co/pricing for current rates.", "minimum": 15, - "description": "Optional pay-as-you-go auto top-up ceiling, in USD. If set, usage above the plan's included quota is billed via the shared payment token: your prepaid balance is refilled to this amount whenever it runs low. If unset, the plan's included quota is a hard cap (the service stops once depleted). Minimum $15. To disable a previously enabled auto top-up, send `{ \"rechargeAmountUsd\": null }` via update_service. See https://huggingface.co/pricing for current rates." + "type": "number" } - } + }, + "type": "object" }, "constraints": [], "created": null, "description": "Pro — paid Hugging Face plan for higher limits and advanced features. Includes a generous monthly usage quota. Optional pay-as-you-go auto top-up can be enabled to bill usage above quota via a shared payment token. Requires a shared payment token. See https://huggingface.co/pricing", "development": false, "group": "plan", + "id": "prvsvc_61UbJKSxrFJT4QlwY5JA8", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -3339,6 +3338,7 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQrUnWwGIOnrGFD5D8y", "provider_name": "HuggingFace", "scope": "project", @@ -3346,13 +3346,9 @@ "updateable_to": [ "free", "pro" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UalcZgOQYcVjEIY59mS", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -3364,8 +3360,11 @@ "description": "AgentMail API — create inboxes to send and receive email programmatically for AI agents", "development": false, "group": null, + "id": "prvsvc_61UalcZgOQYcVjEIY59mS", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -3374,8 +3373,8 @@ "paid": null, "parent_services": [ "startup", - "free", - "developer" + "developer", + "free" ], "type": "free" } @@ -3385,19 +3384,16 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UWNSVKoGlVDwbHX5IUK", "provider_name": "AgentMail", "scope": "project", "service_id": "api", "updateable_to": [ "api" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UadC9N2zRR6Edxg5AF6", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -3405,26 +3401,29 @@ ], "configuration_schema": { "additionalProperties": false, - "required": [ - "domainName" - ], - "type": "object", "properties": { "domainName": { - "type": "string", + "description": "The domain name you want to register (e.g. example.com). If the domain is not available, we'll show available alternatives.", "minLength": 1, "title": "Domain Name", - "description": "The domain name you want to register (e.g. example.com). If the domain is not available, we'll show available alternatives." + "type": "string" } - } + }, + "required": [ + "domainName" + ], + "type": "object" }, "constraints": [], "created": null, "description": "Register a domain with Squarespace", "development": false, "group": null, + "id": "prvsvc_61UadC9N2zRR6Edxg5AF6", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -3443,19 +3442,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UacoeBnJSHilBZJ5FIm", "provider_name": "Squarespace", "scope": "project", "service_id": "domain", "updateable_to": [ "domain" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UabK4at7gX15Xby5QYq", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -3467,27 +3463,27 @@ "description": "ElevenLabs Text-to-Speech API", "development": false, "group": null, + "id": "prvsvc_61UabK4at7gX15Xby5QYq", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UYtnmm5DvySq8Ol56tE", "provider_name": "ElevenLabs", "scope": "project", "service_id": "tts", "updateable_to": [ "tts" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UaVhBBKAlkD9pYE5UV6", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -3496,12 +3492,14 @@ ], "configuration_schema": { "properties": { + "accept_terms": { + "type": "string" + }, "name": { - "type": "string", - "description": "Name of your Algolia application" + "description": "Name of your Algolia application", + "type": "string" }, "region": { - "type": "string", "description": "Where your Algolia application will be created. This cannot be changed after provisioning.", "enum": [ "EU West", @@ -3509,26 +3507,27 @@ "US East", "US West", "United Kingdom" - ] - }, - "accept_terms": { + ], "type": "string" } }, - "type": "object", "required": [ "name", "region", "accept_terms" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "Algolia Search & Discovery API", "development": false, "group": null, + "id": "prvsvc_61UaVhBBKAlkD9pYE5UV6", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, @@ -3566,19 +3565,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UU7UnQL58lBHk2p55uC", "provider_name": "Algolia", "scope": "project", "service_id": "application", "updateable_to": [ "application" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UaJzeka6R5UDJi85B9c", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "up", @@ -3596,8 +3592,11 @@ "description": "Privy Scale — up to 10,000 MAU. 50K free monthly wallet signatures. All Core features included.", "development": false, "group": null, + "id": "prvsvc_61UaJzeka6R5UDJi85B9c", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -3616,40 +3615,37 @@ ], "type": "paid" }, - "provider_id": "prvdr_61UWLVjDZyzuP1BCY5EAi", - "provider_name": "Privy", - "scope": "account", - "service_id": "scale", - "updateable_to": [ - "enterprise", - "scale" - ], - "livemode": true, "provider_configuration_schema": { - "type": "object", + "additionalProperties": false, "properties": { "account_name": { - "type": "string", - "description": "Name of your Privy organization account" + "description": "Name of your Privy organization account", + "type": "string" } }, "required": [ "account_name" ], - "additionalProperties": false - } + "type": "object" + }, + "provider_id": "prvdr_61UWLVjDZyzuP1BCY5EAi", + "provider_name": "Privy", + "scope": "account", + "service_id": "scale", + "updateable_to": [ + "enterprise", + "scale" + ] }, { - "id": "prvsvc_61UaJzefwMxXq3G9D5GD2", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "up", - "service": "scale" + "service": "core" }, { "direction": "up", - "service": "core" + "service": "scale" } ], "availability": "available", @@ -3663,41 +3659,41 @@ "description": "Privy Free — 0-499 MAU. 50K free monthly wallet signatures and $1M transaction volume included.", "development": false, "group": null, + "id": "prvsvc_61UaJzefwMxXq3G9D5GD2", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, - "provider_id": "prvdr_61UWLVjDZyzuP1BCY5EAi", - "provider_name": "Privy", - "scope": "account", - "service_id": "free", - "updateable_to": [ - "scale", - "core", - "free" - ], - "livemode": true, "provider_configuration_schema": { - "type": "object", + "additionalProperties": false, "properties": { "account_name": { - "type": "string", - "description": "Name of your Privy organization account" + "description": "Name of your Privy organization account", + "type": "string" } }, "required": [ "account_name" ], - "additionalProperties": false - } + "type": "object" + }, + "provider_id": "prvdr_61UWLVjDZyzuP1BCY5EAi", + "provider_name": "Privy", + "scope": "account", + "service_id": "free", + "updateable_to": [ + "core", + "scale", + "free" + ] }, { - "id": "prvsvc_61UaJze8IsVayXM4L5SmW", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "up", @@ -3715,8 +3711,11 @@ "description": "Privy Core — up to 2,500 MAU. 50K free monthly wallet signatures. Includes Custom JWT auth, Custom OAuth, fiat on-ramp, and Expo SDK.", "development": false, "group": null, + "id": "prvsvc_61UaJze8IsVayXM4L5SmW", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -3735,32 +3734,29 @@ ], "type": "paid" }, - "provider_id": "prvdr_61UWLVjDZyzuP1BCY5EAi", - "provider_name": "Privy", - "scope": "account", - "service_id": "core", - "updateable_to": [ - "scale", - "core" - ], - "livemode": true, "provider_configuration_schema": { - "type": "object", + "additionalProperties": false, "properties": { "account_name": { - "type": "string", - "description": "Name of your Privy organization account" + "description": "Name of your Privy organization account", + "type": "string" } }, "required": [ "account_name" ], - "additionalProperties": false - } + "type": "object" + }, + "provider_id": "prvdr_61UWLVjDZyzuP1BCY5EAi", + "provider_name": "Privy", + "scope": "account", + "service_id": "core", + "updateable_to": [ + "scale", + "core" + ] }, { - "id": "prvsvc_61UaJW8BdsziPb1Lc55GC", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -3768,17 +3764,9 @@ ], "configuration_schema": { "additionalProperties": false, - "required": [ - "name" - ], - "type": "object", "properties": { - "name": { - "type": "string", - "description": "Bucket name. Must be 3-63 characters of lowercase letters, numbers, and hyphens." - }, "location_hint": { - "type": "string", + "description": "Optional R2 bucket region hint.", "enum": [ "wnam", "enam", @@ -3787,17 +3775,28 @@ "apac", "oc" ], - "description": "Optional R2 bucket region hint." + "type": "string" + }, + "name": { + "description": "Bucket name. Must be 3-63 characters of lowercase letters, numbers, and hyphens.", + "type": "string" } - } + }, + "required": [ + "name" + ], + "type": "object" }, "constraints": [], "created": null, "description": "Create an R2 bucket with S3-compatible access for object storage, uploads, downloads, and usage-based billing.", "development": false, "group": null, + "id": "prvsvc_61UaJW8BdsziPb1Lc55GC", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -3816,19 +3815,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQRSWMdrciFdBNs5Ipc", "provider_name": "Cloudflare", "scope": "project", "service_id": "r2:bucket", "updateable_to": [ "r2:bucket" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UaHU57rbVlXZCv85WAa", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -3840,8 +3836,11 @@ "description": "A Browserbase project — managed cloud browsers for Playwright, Puppeteer, Stagehand, and Selenium workloads, plus Search API, Fetch API, Model Gateway access, residential proxies, Verified Agents, and live session viewing. Concurrency, browser hours, retention, and proxy / verification capabilities are determined by the active plan.", "development": false, "group": null, + "id": "prvsvc_61UaHU57rbVlXZCv85WAa", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -3850,8 +3849,8 @@ "paid": null, "parent_services": [ "startup", - "free", - "developer" + "developer", + "free" ], "type": "free" } @@ -3861,23 +3860,20 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UYA33tkF5l7Dkwz5KGG", "provider_name": "Browserbase", "scope": "project", "service_id": "project", "updateable_to": [ "project" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UaB5qtWk74WOkmv5RtA", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "free" + "service": "b2b-essentials" }, { "direction": "any", @@ -3885,7 +3881,7 @@ }, { "direction": "any", - "service": "b2b-essentials" + "service": "free" }, { "direction": "any", @@ -3902,8 +3898,11 @@ "description": "Auth0 B2B - Professional Plan", "development": false, "group": "b2b", + "id": "prvsvc_61UaB5qtWk74WOkmv5RtA", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -3922,23 +3921,11 @@ ], "type": "paid" }, - "provider_id": "prvdr_61UVctyxfLOj4FpVw5Bmi", - "provider_name": "Auth0", - "scope": "project", - "service_id": "b2b-professional", - "updateable_to": [ - "free", - "b2c-professional", - "b2b-essentials", - "b2c-essentials", - "b2b-professional" - ], - "livemode": true, "provider_configuration_schema": { - "type": "object", + "additionalProperties": false, "properties": { "locality": { - "type": "string", + "default": "us", "description": "The deployment locality/region.", "enum": [ "au", @@ -3947,30 +3934,39 @@ "jp", "us" ], - "default": "us" + "type": "string" }, "naming_prefix": { - "type": "string", "description": "A naming prefix for the tenant/teams you are creating.", - "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", "maxLength": 50, - "minLength": 3 + "minLength": 3, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "type": "string" } }, "required": [ "locality", "naming_prefix" ], - "additionalProperties": false - } + "type": "object" + }, + "provider_id": "prvdr_61UVctyxfLOj4FpVw5Bmi", + "provider_name": "Auth0", + "scope": "project", + "service_id": "b2b-professional", + "updateable_to": [ + "b2b-essentials", + "b2c-professional", + "free", + "b2c-essentials", + "b2b-professional" + ] }, { - "id": "prvsvc_61UaB5qombr3Tx5Pa5K1A", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "free" + "service": "b2c-professional" }, { "direction": "any", @@ -3978,7 +3974,7 @@ }, { "direction": "any", - "service": "b2c-professional" + "service": "free" }, { "direction": "any", @@ -3995,8 +3991,11 @@ "description": "Auth0 B2B - Essentials Plan", "development": false, "group": "b2b", + "id": "prvsvc_61UaB5qombr3Tx5Pa5K1A", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -4015,23 +4014,11 @@ ], "type": "paid" }, - "provider_id": "prvdr_61UVctyxfLOj4FpVw5Bmi", - "provider_name": "Auth0", - "scope": "project", - "service_id": "b2b-essentials", - "updateable_to": [ - "free", - "b2b-professional", - "b2c-professional", - "b2c-essentials", - "b2b-essentials" - ], - "livemode": true, "provider_configuration_schema": { - "type": "object", + "additionalProperties": false, "properties": { "locality": { - "type": "string", + "default": "us", "description": "The deployment locality/region.", "enum": [ "au", @@ -4040,30 +4027,39 @@ "jp", "us" ], - "default": "us" + "type": "string" }, "naming_prefix": { - "type": "string", "description": "A naming prefix for the tenant/teams you are creating.", - "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", "maxLength": 50, - "minLength": 3 + "minLength": 3, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "type": "string" } }, "required": [ "locality", "naming_prefix" ], - "additionalProperties": false - } + "type": "object" + }, + "provider_id": "prvdr_61UVctyxfLOj4FpVw5Bmi", + "provider_name": "Auth0", + "scope": "project", + "service_id": "b2b-essentials", + "updateable_to": [ + "b2c-professional", + "b2b-professional", + "free", + "b2c-essentials", + "b2b-essentials" + ] }, { - "id": "prvsvc_61UaB5qVSzh9K3guP5Ufw", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "free" + "service": "b2b-essentials" }, { "direction": "any", @@ -4071,7 +4067,7 @@ }, { "direction": "any", - "service": "b2b-essentials" + "service": "free" }, { "direction": "any", @@ -4088,8 +4084,11 @@ "description": "Auth0 B2C - Professional Plan", "development": false, "group": "b2c", + "id": "prvsvc_61UaB5qVSzh9K3guP5Ufw", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -4108,23 +4107,11 @@ ], "type": "paid" }, - "provider_id": "prvdr_61UVctyxfLOj4FpVw5Bmi", - "provider_name": "Auth0", - "scope": "project", - "service_id": "b2c-professional", - "updateable_to": [ - "free", - "b2b-professional", - "b2b-essentials", - "b2c-essentials", - "b2c-professional" - ], - "livemode": true, "provider_configuration_schema": { - "type": "object", + "additionalProperties": false, "properties": { "locality": { - "type": "string", + "default": "us", "description": "The deployment locality/region.", "enum": [ "au", @@ -4133,30 +4120,39 @@ "jp", "us" ], - "default": "us" + "type": "string" }, "naming_prefix": { - "type": "string", "description": "A naming prefix for the tenant/teams you are creating.", - "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", "maxLength": 50, - "minLength": 3 + "minLength": 3, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "type": "string" } }, "required": [ "locality", "naming_prefix" ], - "additionalProperties": false - } + "type": "object" + }, + "provider_id": "prvdr_61UVctyxfLOj4FpVw5Bmi", + "provider_name": "Auth0", + "scope": "project", + "service_id": "b2c-professional", + "updateable_to": [ + "b2b-essentials", + "b2b-professional", + "free", + "b2c-essentials", + "b2c-professional" + ] }, { - "id": "prvsvc_61UaB5qTyRrFdm67P556e", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "b2b-professional" + "service": "b2b-essentials" }, { "direction": "any", @@ -4164,7 +4160,7 @@ }, { "direction": "any", - "service": "b2b-essentials" + "service": "b2b-professional" }, { "direction": "any", @@ -4181,31 +4177,22 @@ "description": "Auth0 Free Plan", "development": false, "group": null, + "id": "prvsvc_61UaB5qTyRrFdm67P556e", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, - "provider_id": "prvdr_61UVctyxfLOj4FpVw5Bmi", - "provider_name": "Auth0", - "scope": "project", - "service_id": "free", - "updateable_to": [ - "b2b-professional", - "b2c-professional", - "b2b-essentials", - "b2c-essentials", - "free" - ], - "livemode": true, "provider_configuration_schema": { - "type": "object", + "additionalProperties": false, "properties": { "locality": { - "type": "string", + "default": "us", "description": "The deployment locality/region.", "enum": [ "au", @@ -4214,42 +4201,51 @@ "jp", "us" ], - "default": "us" + "type": "string" }, "naming_prefix": { - "type": "string", "description": "A naming prefix for the tenant/teams you are creating.", - "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", "maxLength": 50, - "minLength": 3 + "minLength": 3, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "type": "string" } }, "required": [ "locality", "naming_prefix" ], - "additionalProperties": false - } + "type": "object" + }, + "provider_id": "prvdr_61UVctyxfLOj4FpVw5Bmi", + "provider_name": "Auth0", + "scope": "project", + "service_id": "free", + "updateable_to": [ + "b2b-essentials", + "b2c-professional", + "b2b-professional", + "b2c-essentials", + "free" + ] }, { - "id": "prvsvc_61UaB5qPaLVvXI5OR5TVA", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "free" + "service": "b2b-essentials" }, { "direction": "any", - "service": "b2b-professional" + "service": "b2c-professional" }, { "direction": "any", - "service": "b2c-professional" + "service": "b2b-professional" }, { "direction": "any", - "service": "b2b-essentials" + "service": "free" } ], "availability": "available", @@ -4262,8 +4258,11 @@ "description": "Auth0 B2C - Essentials Plan", "development": false, "group": "b2c", + "id": "prvsvc_61UaB5qPaLVvXI5OR5TVA", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -4282,23 +4281,11 @@ ], "type": "paid" }, - "provider_id": "prvdr_61UVctyxfLOj4FpVw5Bmi", - "provider_name": "Auth0", - "scope": "project", - "service_id": "b2c-essentials", - "updateable_to": [ - "free", - "b2b-professional", - "b2c-professional", - "b2b-essentials", - "b2c-essentials" - ], - "livemode": true, "provider_configuration_schema": { - "type": "object", + "additionalProperties": false, "properties": { "locality": { - "type": "string", + "default": "us", "description": "The deployment locality/region.", "enum": [ "au", @@ -4307,26 +4294,35 @@ "jp", "us" ], - "default": "us" + "type": "string" }, "naming_prefix": { - "type": "string", "description": "A naming prefix for the tenant/teams you are creating.", - "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", "maxLength": 50, - "minLength": 3 + "minLength": 3, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "type": "string" } }, "required": [ "locality", "naming_prefix" ], - "additionalProperties": false - } + "type": "object" + }, + "provider_id": "prvdr_61UVctyxfLOj4FpVw5Bmi", + "provider_name": "Auth0", + "scope": "project", + "service_id": "b2c-essentials", + "updateable_to": [ + "b2b-essentials", + "b2c-professional", + "b2b-professional", + "free", + "b2c-essentials" + ] }, { - "id": "prvsvc_61UZDA2WPszSQxcHo58EC", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -4334,28 +4330,64 @@ ], "configuration_schema": { "properties": { - "name": { - "type": "string", - "description": "Name of the web service" + "auto_deploy": { + "description": "Whether to automatically deploy on commits to the tracked branch. Defaults to 'yes'.", + "enum": [ + "yes", + "no" + ], + "type": "string" }, - "repo": { - "type": "string", - "description": "URL of the Git repository" + "branch": { + "description": "Git branch to deploy", + "type": "string" + }, + "build_command": { + "description": "Build command to run (required for non-docker runtimes)", + "type": "string" + }, + "docker_command": { + "description": "Docker CMD to run (docker runtime only)", + "type": "string" + }, + "docker_context": { + "description": "Docker build context directory (docker runtime only). Defaults to the repo root.", + "type": "string" + }, + "dockerfile_path": { + "description": "Path to the Dockerfile (docker runtime only). Defaults to './Dockerfile'.", + "type": "string" + }, + "health_check_path": { + "description": "HTTP path for health checks", + "type": "string" + }, + "name": { + "description": "Name of the web service", + "type": "string" }, "owner_id": { - "type": "string", - "description": "Workspace ID to deploy into. If omitted, uses the default workspace." + "description": "Workspace ID to deploy into. If omitted, uses the default workspace.", + "type": "string" }, - "branch": { - "type": "string", - "description": "Git branch to deploy" + "pre_deploy_command": { + "description": "Command to run after build but before deploy (e.g. database migrations)", + "type": "string" + }, + "region": { + "description": "Region to deploy to (e.g. oregon, frankfurt, ohio, singapore, virginia). Defaults to 'oregon'.", + "type": "string" + }, + "repo": { + "description": "URL of the Git repository", + "type": "string" }, "root_dir": { - "type": "string", - "description": "Subdirectory in the repo to build and run from. Defaults to the repo root." + "description": "Subdirectory in the repo to build and run from. Defaults to the repo root.", + "type": "string" }, "runtime": { - "type": "string", + "description": "Runtime environment", "enum": [ "node", "python", @@ -4365,63 +4397,30 @@ "elixir", "docker" ], - "description": "Runtime environment" - }, - "build_command": { - "type": "string", - "description": "Build command to run (required for non-docker runtimes)" + "type": "string" }, "start_command": { - "type": "string", - "description": "Start command to run (required for non-docker runtimes)" - }, - "docker_command": { - "type": "string", - "description": "Docker CMD to run (docker runtime only)" - }, - "docker_context": { - "type": "string", - "description": "Docker build context directory (docker runtime only). Defaults to the repo root." - }, - "dockerfile_path": { - "type": "string", - "description": "Path to the Dockerfile (docker runtime only). Defaults to './Dockerfile'." - }, - "pre_deploy_command": { - "type": "string", - "description": "Command to run after build but before deploy (e.g. database migrations)" - }, - "health_check_path": { - "type": "string", - "description": "HTTP path for health checks" - }, - "region": { - "type": "string", - "description": "Region to deploy to (e.g. oregon, frankfurt, ohio, singapore, virginia). Defaults to 'oregon'." - }, - "auto_deploy": { - "type": "string", - "enum": [ - "yes", - "no" - ], - "description": "Whether to automatically deploy on commits to the tracked branch. Defaults to 'yes'." + "description": "Start command to run (required for non-docker runtimes)", + "type": "string" } }, - "type": "object", "required": [ "name", "repo", "runtime" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "Managed web service with pricing based on instance type", "development": false, "group": "web-service", + "id": "prvsvc_61UZDA2WPszSQxcHo58EC", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -4460,19 +4459,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UVwArXdJpejOfkn5POi", "provider_name": "Render", "scope": "project", "service_id": "web-service", "updateable_to": [ "web-service" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UZBkhiXh2MqKuKG5CIa", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -4485,8 +4481,11 @@ "description": "Privy Enterprise — contact sales to provision.", "development": false, "group": null, + "id": "prvsvc_61UZBkhiXh2MqKuKG5CIa", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -4505,31 +4504,28 @@ ], "type": "paid" }, - "provider_id": "prvdr_61UWLVjDZyzuP1BCY5EAi", - "provider_name": "Privy", - "scope": "account", - "service_id": "enterprise", - "updateable_to": [ - "enterprise" - ], - "livemode": true, "provider_configuration_schema": { - "type": "object", + "additionalProperties": false, "properties": { "account_name": { - "type": "string", - "description": "Name of your Privy organization account" + "description": "Name of your Privy organization account", + "type": "string" } }, "required": [ "account_name" ], - "additionalProperties": false - } + "type": "object" + }, + "provider_id": "prvdr_61UWLVjDZyzuP1BCY5EAi", + "provider_name": "Privy", + "scope": "account", + "service_id": "enterprise", + "updateable_to": [ + "enterprise" + ] }, { - "id": "prvsvc_61UZBHWr2xyJX6FIr5WDA", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -4542,7 +4538,6 @@ "ai" ], "configuration_schema": { - "type": "object", "properties": { "dimension": { "description": "Vector dimension count. Ignored when embedding_model is set (dimension is derived from the model)", @@ -4592,15 +4587,19 @@ ], "type": "string" } - } + }, + "type": "object" }, "constraints": [], "created": null, "description": "Upstash Vector - Serverless vector database.", "development": false, "group": "vector", + "id": "prvsvc_61UZBHWr2xyJX6FIr5WDA", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -4630,19 +4629,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UVa3kQyvSOEnWgd5Naa", "provider_name": "Upstash", "scope": "project", "service_id": "vector", "updateable_to": [ "vector" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UZBHWgQteognwoo55ZY", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -4654,7 +4650,6 @@ "search" ], "configuration_schema": { - "type": "object", "properties": { "name": { "description": "Search index name", @@ -4678,15 +4673,19 @@ ], "type": "string" } - } + }, + "type": "object" }, "constraints": [], "created": null, "description": "Upstash Search - Full-text + semantic search with built-in embeddings.", "development": false, "group": "search", + "id": "prvsvc_61UZBHWgQteognwoo55ZY", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -4716,19 +4715,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UVa3kQyvSOEnWgd5Naa", "provider_name": "Upstash", "scope": "project", "service_id": "search", "updateable_to": [ "search" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UZBHWUPxi5jnqZl5JUO", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -4740,7 +4736,6 @@ "messaging" ], "configuration_schema": { - "type": "object", "properties": { "price": { "default": "payg", @@ -4759,15 +4754,19 @@ ], "type": "string" } - } + }, + "type": "object" }, "constraints": [], "created": null, "description": "Upstash QStash - Serverless message queue.", "development": false, "group": "qstash", + "id": "prvsvc_61UZBHWUPxi5jnqZl5JUO", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -4797,19 +4796,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UVa3kQyvSOEnWgd5Naa", "provider_name": "Upstash", "scope": "project", "service_id": "qstash", "updateable_to": [ "qstash" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UZBHWIajlhju4M2547k", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -4822,7 +4818,6 @@ "database" ], "configuration_schema": { - "type": "object", "properties": { "eviction": { "description": "Enable eviction when data size limit is reached (on or off)", @@ -4883,15 +4878,19 @@ ], "type": "string" } - } + }, + "type": "object" }, "constraints": [], "created": null, "description": "Upstash Redis - Serverless Redis.", "development": false, "group": "redis", + "id": "prvsvc_61UZBHWIajlhju4M2547k", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -4921,31 +4920,28 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UVa3kQyvSOEnWgd5Naa", "provider_name": "Upstash", "scope": "project", "service_id": "redis", "updateable_to": [ "redis" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UZ9SwlsIAJXaSuJ5RC4", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "top-up-0025" + "service": "top-up-2000" }, { "direction": "any", - "service": "top-up-1000" + "service": "top-up-0025" }, { "direction": "any", - "service": "top-up-2000" + "service": "top-up-1000" } ], "availability": "available", @@ -4960,8 +4956,11 @@ "description": "Add $500/mo in Daytona credits for compute usage.", "development": false, "group": "daytona", + "id": "prvsvc_61UZ9SwlsIAJXaSuJ5RC4", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -4980,26 +4979,23 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UZ9SrkCMLr94xp859Vo", "provider_name": "Daytona", "scope": "project", "service_id": "top-up-0500", "updateable_to": [ + "top-up-2000", "top-up-0025", "top-up-1000", - "top-up-2000", "top-up-0500" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UZ9SwdJzDqqI8CM5Jiq", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "top-up-0025" + "service": "top-up-0500" }, { "direction": "any", @@ -5007,7 +5003,7 @@ }, { "direction": "any", - "service": "top-up-0500" + "service": "top-up-0025" } ], "availability": "available", @@ -5022,8 +5018,11 @@ "description": "Add $1000/mo in Daytona credits for compute usage.", "development": false, "group": "daytona", + "id": "prvsvc_61UZ9SwdJzDqqI8CM5Jiq", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -5042,26 +5041,23 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UZ9SrkCMLr94xp859Vo", "provider_name": "Daytona", "scope": "project", "service_id": "top-up-1000", "updateable_to": [ - "top-up-0025", - "top-up-2000", "top-up-0500", + "top-up-2000", + "top-up-0025", "top-up-1000" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UZ9SwaoBLxs25Os5VSa", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "top-up-1000" + "service": "top-up-0500" }, { "direction": "any", @@ -5069,7 +5065,7 @@ }, { "direction": "any", - "service": "top-up-0500" + "service": "top-up-1000" } ], "availability": "available", @@ -5084,8 +5080,11 @@ "description": "Add $25/mo in Daytona credits for compute usage.", "development": false, "group": "daytona", + "id": "prvsvc_61UZ9SwaoBLxs25Os5VSa", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -5104,34 +5103,31 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UZ9SrkCMLr94xp859Vo", "provider_name": "Daytona", "scope": "project", "service_id": "top-up-0025", "updateable_to": [ - "top-up-1000", - "top-up-2000", "top-up-0500", + "top-up-2000", + "top-up-1000", "top-up-0025" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UZ9SwK1byS5ZIM35ToW", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "top-up-0025" + "service": "top-up-0500" }, { "direction": "any", - "service": "top-up-1000" + "service": "top-up-0025" }, { "direction": "any", - "service": "top-up-0500" + "service": "top-up-1000" } ], "availability": "available", @@ -5146,8 +5142,11 @@ "description": "Add $2000/mo in Daytona credits for compute usage.", "development": false, "group": "daytona", + "id": "prvsvc_61UZ9SwK1byS5ZIM35ToW", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -5166,22 +5165,19 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UZ9SrkCMLr94xp859Vo", "provider_name": "Daytona", "scope": "project", "service_id": "top-up-2000", "updateable_to": [ + "top-up-0500", "top-up-0025", "top-up-1000", - "top-up-0500", "top-up-2000" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UZ9Svyarzd8HBox5Umm", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -5283,8 +5279,11 @@ "description": "Daytona Sandbox - cloud compute environment for AI agents and developers. New accounts receive $100 in free credits.", "development": false, "group": "daytona", + "id": "prvsvc_61UZ9Svyarzd8HBox5Umm", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -5297,9 +5296,9 @@ }, "parent_services": [ "top-up-0025", - "top-up-0500", + "top-up-1000", "top-up-2000", - "top-up-1000" + "top-up-0500" ], "type": "paid" } @@ -5309,27 +5308,24 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UZ9SrkCMLr94xp859Vo", "provider_name": "Daytona", "scope": "project", "service_id": "sandbox", "updateable_to": [ "sandbox" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UZ73tTpdKXSTKSb5FCa", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "free" + "service": "team" }, { "direction": "any", - "service": "team" + "service": "free" } ], "availability": "available", @@ -5344,8 +5340,11 @@ "description": "Supabase Pro Plan: Dedicated CPU • 1 GB RAM • 100K MAU • 8 GB database space • 250 GB bandwidth • 100 GB file storage", "development": false, "group": null, + "id": "prvsvc_61UZ73tTpdKXSTKSb5FCa", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -5364,29 +5363,26 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UIXYHdsyF69pZbG52n2", "provider_name": "Supabase", "scope": "project", "service_id": "pro", "updateable_to": [ - "free", "team", + "free", "pro" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UZ73t8twXKZjKj752cy", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "free" + "service": "pro" }, { "direction": "any", - "service": "pro" + "service": "free" } ], "availability": "available", @@ -5401,8 +5397,11 @@ "description": "Supabase Team Plan: SOC2 • SSO for Supabase Dashboard • Priority email support & SLAs • 28-day log retention", "development": false, "group": null, + "id": "prvsvc_61UZ73t8twXKZjKj752cy", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -5421,29 +5420,26 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UIXYHdsyF69pZbG52n2", "provider_name": "Supabase", "scope": "project", "service_id": "team", "updateable_to": [ - "free", "pro", + "free", "team" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UZ68OxDFNL5oYPg53oO", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "team" + "service": "business" }, { "direction": "any", - "service": "business" + "service": "team" } ], "availability": "available", @@ -5452,44 +5448,44 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "Sentry Developer -- error monitoring, performance, and session replay", "development": false, "group": null, + "id": "prvsvc_61UZ68OxDFNL5oYPg53oO", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UWhMpOMGYc7qQhs53su", "provider_name": "Sentry", "scope": "account", "service_id": "developer", "updateable_to": [ - "team", "business", + "team", "developer" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UZ68OBHksq3Nzwd5WPQ", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -5506,23 +5502,26 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "Sentry Business -- error monitoring, performance, and session replay", "development": false, "group": null, + "id": "prvsvc_61UZ68OBHksq3Nzwd5WPQ", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -5541,6 +5540,7 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UWhMpOMGYc7qQhs53su", "provider_name": "Sentry", "scope": "account", @@ -5549,21 +5549,17 @@ "developer", "team", "business" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UZ68OAXFie2t6X85Ldo", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "developer" + "service": "business" }, { "direction": "any", - "service": "business" + "service": "developer" } ], "availability": "available", @@ -5572,23 +5568,26 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "Sentry Team -- error monitoring, performance, and session replay", "development": false, "group": null, + "id": "prvsvc_61UZ68OAXFie2t6X85Ldo", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -5607,21 +5606,18 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UWhMpOMGYc7qQhs53su", "provider_name": "Sentry", "scope": "account", "service_id": "team", "updateable_to": [ - "developer", "business", + "developer", "team" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UYZpMjosP0lAOgR51LM", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -5642,8 +5638,11 @@ "description": "Developer — $20/month. 25 concurrent browsers, 100 browser hours then $0.12/hour, 1,000 Search calls then $7/1,000, 1,000 Fetch calls then $1/1,000 (or $4/1,000 with proxies), 1GB residential proxies then $12/GB. Verified Agents, Model Gateway pay-as-you-go at market rates. 7-day data retention.", "development": false, "group": null, + "id": "prvsvc_61UYZpMjosP0lAOgR51LM", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -5662,6 +5661,7 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UYA33tkF5l7Dkwz5KGG", "provider_name": "Browserbase", "scope": "project", @@ -5670,21 +5670,17 @@ "startup", "free", "developer" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UYZpMiiB0jGQ6LO5FTM", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "developer" + "service": "startup" }, { "direction": "any", - "service": "startup" + "service": "developer" } ], "availability": "available", @@ -5697,29 +5693,29 @@ "description": "Free — $0/month. 3 concurrent browsers, 1 browser hour/month, 1,000 Search API calls, 1,000 Fetch API calls, 15-minute session cap. 7-day data retention. $5 in model tokens included. No proxies, no Verified Agents.", "development": false, "group": null, + "id": "prvsvc_61UYZpMiiB0jGQ6LO5FTM", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UYA33tkF5l7Dkwz5KGG", "provider_name": "Browserbase", "scope": "project", "service_id": "free", "updateable_to": [ - "developer", "startup", + "developer", "free" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UYZpM49eshEboIr5Qb2", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -5740,8 +5736,11 @@ "description": "Startup — $99/month (most popular). 100 concurrent browsers, 500 browser hours then $0.10/hour, 1,000 Search calls then $7/1,000, 10,000 Fetch calls then $0.50/1,000 (or $4/1,000 with proxies), 5GB residential proxies then $10/GB. Verified Agents, Model Gateway pay-as-you-go. 30-day data retention.", "development": false, "group": null, + "id": "prvsvc_61UYZpM49eshEboIr5Qb2", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -5760,6 +5759,7 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UYA33tkF5l7Dkwz5KGG", "provider_name": "Browserbase", "scope": "project", @@ -5768,13 +5768,9 @@ "developer", "free", "startup" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UYUClAP1gqWOzMO55Q0", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -5782,39 +5778,42 @@ ], "configuration_schema": { "properties": { + "disk_size": { + "description": "Storage capacity for the database instance, in GB. Defaults to 15 GB. Must be in multiples of 5 GB.", + "type": "integer" + }, "name": { - "type": "string", - "description": "Name of the PostgreSQL database instance" + "description": "Name of the PostgreSQL database instance", + "type": "string" }, "owner_id": { - "type": "string", - "description": "Workspace ID to deploy into. If omitted, uses the default workspace." + "description": "Workspace ID to deploy into. If omitted, uses the default workspace.", + "type": "string" }, "region": { - "type": "string", - "description": "Region to deploy the database in (e.g. oregon, frankfurt, ohio, singapore, virginia). Defaults to 'oregon'." + "description": "Region to deploy the database in (e.g. oregon, frankfurt, ohio, singapore, virginia). Defaults to 'oregon'.", + "type": "string" }, "version": { - "type": "string", - "description": "PostgreSQL major version (e.g. 16, 17, 18). Defaults to the latest supported version." - }, - "disk_size": { - "type": "integer", - "description": "Storage capacity for the database instance, in GB. Defaults to 15 GB. Must be in multiples of 5 GB." + "description": "PostgreSQL major version (e.g. 16, 17, 18). Defaults to the latest supported version.", + "type": "string" } }, - "type": "object", "required": [ "name" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "Managed PostgreSQL database instance with pricing based on instance type", "development": false, "group": "postgres", + "id": "prvsvc_61UYUClAP1gqWOzMO55Q0", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -5862,31 +5861,28 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UVwArXdJpejOfkn5POi", "provider_name": "Render", "scope": "project", "service_id": "postgres", "updateable_to": [ "postgres" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UYI1ViexI3ZlTL451N2", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "free" + "service": "plus-v3-50k-mtu-monthly" }, { "direction": "any", - "service": "plus-v3-50k-mtu-monthly" + "service": "plus-v3-25k-mtu-monthly" }, { "direction": "any", - "service": "plus-v3-25k-mtu-monthly" + "service": "free" } ], "availability": "available", @@ -5901,8 +5897,11 @@ "description": "Plus plan - 10k MTU (Monthly) - Advanced analytics, unlimited feature flags, and session replay", "development": false, "group": null, + "id": "prvsvc_61UYI1ViexI3ZlTL451N2", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -5921,50 +5920,47 @@ ], "type": "paid" }, - "provider_id": "prvdr_61UQYwVkrCANKYQdE5BLM", - "provider_name": "Amplitude", - "scope": "project", - "service_id": "plus-v3-10k-mtu-monthly", - "updateable_to": [ - "free", - "plus-v3-50k-mtu-monthly", - "plus-v3-25k-mtu-monthly", - "plus-v3-10k-mtu-monthly" - ], - "livemode": true, "provider_configuration_schema": { - "type": "object", "properties": { + "marketing": { + "description": "Receive product updates and marketing communications from Amplitude. [Y/n]", + "title": "Marketing emails", + "type": "boolean" + }, "region": { - "type": "string", + "description": "Data center region where your data will be stored and processed. This cannot be changed after provisioning.", "enum": [ "us", "eu" ], - "description": "Data center region where your data will be stored and processed. This cannot be changed after provisioning." - }, - "marketing": { - "type": "boolean", - "title": "Marketing emails", - "description": "Receive product updates and marketing communications from Amplitude. [Y/n]" + "type": "string" } }, "required": [ "region" - ] - } + ], + "type": "object" + }, + "provider_id": "prvdr_61UQYwVkrCANKYQdE5BLM", + "provider_name": "Amplitude", + "scope": "project", + "service_id": "plus-v3-10k-mtu-monthly", + "updateable_to": [ + "plus-v3-50k-mtu-monthly", + "plus-v3-25k-mtu-monthly", + "free", + "plus-v3-10k-mtu-monthly" + ] }, { - "id": "prvsvc_61UYI1VfuBJJcm1M55R2O", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "free" + "service": "plus-v3-50k-mtu-monthly" }, { "direction": "any", - "service": "plus-v3-50k-mtu-monthly" + "service": "free" }, { "direction": "any", @@ -5983,8 +5979,11 @@ "description": "Plus plan - 25k MTU (Monthly) - Advanced analytics, unlimited feature flags, and session replay", "development": false, "group": null, + "id": "prvsvc_61UYI1VfuBJJcm1M55R2O", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -6003,46 +6002,43 @@ ], "type": "paid" }, - "provider_id": "prvdr_61UQYwVkrCANKYQdE5BLM", - "provider_name": "Amplitude", - "scope": "project", - "service_id": "plus-v3-25k-mtu-monthly", - "updateable_to": [ - "free", - "plus-v3-50k-mtu-monthly", - "plus-v3-10k-mtu-monthly", - "plus-v3-25k-mtu-monthly" - ], - "livemode": true, "provider_configuration_schema": { - "type": "object", "properties": { + "marketing": { + "description": "Receive product updates and marketing communications from Amplitude. [Y/n]", + "title": "Marketing emails", + "type": "boolean" + }, "region": { - "type": "string", + "description": "Data center region where your data will be stored and processed. This cannot be changed after provisioning.", "enum": [ "us", "eu" ], - "description": "Data center region where your data will be stored and processed. This cannot be changed after provisioning." - }, - "marketing": { - "type": "boolean", - "title": "Marketing emails", - "description": "Receive product updates and marketing communications from Amplitude. [Y/n]" + "type": "string" } }, "required": [ "region" - ] - } + ], + "type": "object" + }, + "provider_id": "prvdr_61UQYwVkrCANKYQdE5BLM", + "provider_name": "Amplitude", + "scope": "project", + "service_id": "plus-v3-25k-mtu-monthly", + "updateable_to": [ + "plus-v3-50k-mtu-monthly", + "free", + "plus-v3-10k-mtu-monthly", + "plus-v3-25k-mtu-monthly" + ] }, { - "id": "prvsvc_61UYI1VWx1o2eop3w5EEi", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "free" + "service": "plus-v3-10k-mtu-monthly" }, { "direction": "any", @@ -6050,7 +6046,7 @@ }, { "direction": "any", - "service": "plus-v3-10k-mtu-monthly" + "service": "free" } ], "availability": "available", @@ -6065,8 +6061,11 @@ "description": "Plus plan - 50k MTU (Monthly) - Advanced analytics, unlimited feature flags, and session replay", "development": false, "group": null, + "id": "prvsvc_61UYI1VWx1o2eop3w5EEi", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -6085,81 +6084,78 @@ ], "type": "paid" }, - "provider_id": "prvdr_61UQYwVkrCANKYQdE5BLM", - "provider_name": "Amplitude", - "scope": "project", - "service_id": "plus-v3-50k-mtu-monthly", - "updateable_to": [ - "free", - "plus-v3-25k-mtu-monthly", - "plus-v3-10k-mtu-monthly", - "plus-v3-50k-mtu-monthly" - ], - "livemode": true, "provider_configuration_schema": { - "type": "object", "properties": { + "marketing": { + "description": "Receive product updates and marketing communications from Amplitude. [Y/n]", + "title": "Marketing emails", + "type": "boolean" + }, "region": { - "type": "string", + "description": "Data center region where your data will be stored and processed. This cannot be changed after provisioning.", "enum": [ "us", "eu" ], - "description": "Data center region where your data will be stored and processed. This cannot be changed after provisioning." - }, - "marketing": { - "type": "boolean", - "title": "Marketing emails", - "description": "Receive product updates and marketing communications from Amplitude. [Y/n]" + "type": "string" } }, "required": [ "region" - ] - } + ], + "type": "object" + }, + "provider_id": "prvdr_61UQYwVkrCANKYQdE5BLM", + "provider_name": "Amplitude", + "scope": "project", + "service_id": "plus-v3-50k-mtu-monthly", + "updateable_to": [ + "plus-v3-10k-mtu-monthly", + "plus-v3-25k-mtu-monthly", + "free", + "plus-v3-50k-mtu-monthly" + ] }, { - "id": "prvsvc_61UY9bQ55SUCBmQnW5MDY", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ "compute" ], "configuration_schema": { - "type": "object", "properties": { "name": { "type": "string" } - } + }, + "type": "object" }, "constraints": [], "created": null, "description": "A hosted website backed by Netlify's global CDN. Includes serverless functions, databases, auth, storage, AI model access, and more.", "development": false, "group": null, + "id": "prvsvc_61UY9bQ55SUCBmQnW5MDY", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UXju1taz9UCvkk95GEK", "provider_name": "Netlify", "scope": "project", "service_id": "project", "updateable_to": [ "project" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UY4uYyFXu8qqDls5L4y", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -6171,16 +6167,19 @@ "compute" ], "configuration_schema": { - "type": "object", - "properties": {} + "properties": {}, + "type": "object" }, "constraints": [], "created": null, "description": "For personal projects. $5/month with $5 included usage. Up to 48 vCPU, 48 GB RAM, 5 replicas, 5 GB disk.", "development": false, "group": null, + "id": "prvsvc_61UY4uYyFXu8qqDls5L4y", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -6199,6 +6198,7 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UK2uWsoydL7tUBp57Fg", "provider_name": "Railway", "scope": "project", @@ -6206,29 +6206,28 @@ "updateable_to": [ "pro", "hobby" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UY4uYofZr2gzL5y501o", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ "compute" ], "configuration_schema": { - "type": "object", - "properties": {} + "properties": {}, + "type": "object" }, "constraints": [], "created": null, "description": "For teams and production workloads. $20/month with $20 included usage. Up to 1,000 vCPU, 1 TB RAM, 42 replicas, 1 TB disk, Railway Support.", "development": false, "group": null, + "id": "prvsvc_61UY4uYofZr2gzL5y501o", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -6247,19 +6246,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UK2uWsoydL7tUBp57Fg", "provider_name": "Railway", "scope": "project", "service_id": "pro", "updateable_to": [ "pro" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UXn7zhU6MdaXJja5Iuu", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -6269,22 +6265,25 @@ "configuration_schema": { "properties": { "app_name": { - "type": "string", - "description": "Display name for the app" + "description": "Display name for the app", + "type": "string" } }, - "type": "object", "required": [ "app_name" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "Auth and wallet functionality for your app", "development": false, "group": null, + "id": "prvsvc_61UXn7zhU6MdaXJja5Iuu", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -6293,43 +6292,40 @@ "paid": null, "parent_services": [ "free", - "core", + "scale", "enterprise", - "scale" + "core" ], "type": "free" } ] }, "paid": null, - "paid_pricing": [], - "type": "component" - }, - "provider_id": "prvdr_61UWLVjDZyzuP1BCY5EAi", - "provider_name": "Privy", - "scope": "project", - "service_id": "app", - "updateable_to": [ - "app" - ], - "livemode": true, + "paid_pricing": [], + "type": "component" + }, "provider_configuration_schema": { - "type": "object", + "additionalProperties": false, "properties": { "account_name": { - "type": "string", - "description": "Name of your Privy organization account" + "description": "Name of your Privy organization account", + "type": "string" } }, "required": [ "account_name" ], - "additionalProperties": false - } + "type": "object" + }, + "provider_id": "prvdr_61UWLVjDZyzuP1BCY5EAi", + "provider_name": "Privy", + "scope": "project", + "service_id": "app", + "updateable_to": [ + "app" + ] }, { - "id": "prvsvc_61UXlFfwxxetioKU25768", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -6352,8 +6348,11 @@ "description": "For developers making small scale applications", "development": false, "group": null, + "id": "prvsvc_61UXlFfwxxetioKU25768", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -6372,6 +6371,7 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UWNSVKoGlVDwbHX5IUK", "provider_name": "AgentMail", "scope": "project", @@ -6380,13 +6380,9 @@ "startup", "free", "developer" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UXlFfshNK3pz8JV5USe", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -6409,8 +6405,11 @@ "description": "For production workloads", "development": false, "group": null, + "id": "prvsvc_61UXlFfshNK3pz8JV5USe", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -6429,6 +6428,7 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UWNSVKoGlVDwbHX5IUK", "provider_name": "AgentMail", "scope": "project", @@ -6437,21 +6437,17 @@ "developer", "free", "startup" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UXlFfCpNTG9q6uT5Luy", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "developer" + "service": "startup" }, { "direction": "any", - "service": "startup" + "service": "developer" } ], "availability": "available", @@ -6466,29 +6462,29 @@ "description": "Build on AgentMail for free :)", "development": false, "group": null, + "id": "prvsvc_61UXlFfCpNTG9q6uT5Luy", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UWNSVKoGlVDwbHX5IUK", "provider_name": "AgentMail", "scope": "project", "service_id": "free", "updateable_to": [ - "developer", "startup", + "developer", "free" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UXlFbI3KqvBWFAr5HaS", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -6496,63 +6492,63 @@ ], "configuration_schema": { "properties": { - "name": { - "type": "string", - "description": "Name of the static site" - }, - "repo": { - "type": "string", - "description": "URL of the Git repository" - }, - "owner_id": { - "type": "string", - "description": "Workspace ID to deploy into. If omitted, uses the default workspace." - }, "branch": { - "type": "string", - "description": "Git branch to deploy" + "description": "Git branch to deploy", + "type": "string" }, "build_command": { - "type": "string", - "description": "Build command to run" + "description": "Build command to run", + "type": "string" + }, + "name": { + "description": "Name of the static site", + "type": "string" + }, + "owner_id": { + "description": "Workspace ID to deploy into. If omitted, uses the default workspace.", + "type": "string" }, "publish_path": { - "type": "string", - "description": "Directory to publish after build" + "description": "Directory to publish after build", + "type": "string" + }, + "repo": { + "description": "URL of the Git repository", + "type": "string" } }, - "type": "object", "required": [ "name", "repo" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "Free static site hosting with global CDN", "development": false, "group": "static-site", + "id": "prvsvc_61UXlFbI3KqvBWFAr5HaS", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UVwArXdJpejOfkn5POi", "provider_name": "Render", "scope": "project", "service_id": "static-site", "updateable_to": [ "static-site" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UXcp6h8xCmVayfc5O1g", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -6571,8 +6567,11 @@ "description": "Sentry Seer AI -- automated issue fixes and root cause analysis powered by AI", "development": false, "group": null, + "id": "prvsvc_61UXcp6h8xCmVayfc5O1g", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -6606,44 +6605,44 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UWhMpOMGYc7qQhs53su", "provider_name": "Sentry", "scope": "account", "service_id": "seer", "updateable_to": [ "seer" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UXcp6VHZ7WibLbg5SQC", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ "observability" ], "configuration_schema": { - "type": "object", "properties": { - "project_name": { - "type": "string", - "description": "Name for the Sentry project" - }, "platform": { - "type": "string", - "description": "Platform/language (e.g. python, javascript, node, react)" + "description": "Platform/language (e.g. python, javascript, node, react)", + "type": "string" + }, + "project_name": { + "description": "Name for the Sentry project", + "type": "string" } - } + }, + "type": "object" }, "constraints": [], "created": null, "description": "Sentry project -- error tracking, performance monitoring, and session replay for your application", "development": false, "group": null, + "id": "prvsvc_61UXcp6VHZ7WibLbg5SQC", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -6675,27 +6674,24 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UWhMpOMGYc7qQhs53su", "provider_name": "Sentry", "scope": "project", "service_id": "project", "updateable_to": [ "project" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UWwfdl8fjbyyOqy52ZM", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "free" + "service": "launch" }, { "direction": "any", - "service": "launch" + "service": "free" } ], "availability": "available", @@ -6708,8 +6704,11 @@ "description": "Neon Launch — serverless Postgres for startups and growing teams", "development": false, "group": null, + "id": "prvsvc_61UWwfdl8fjbyyOqy52ZM", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -6728,28 +6727,25 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UH8uNXFdoJUJOtT54AC", "provider_name": "Neon", "scope": "project", "service_id": "launch", "updateable_to": [ - "free", - "launch" - ], - "livemode": true, - "provider_configuration_schema": {} + "launch", + "free" + ] }, { - "id": "prvsvc_61UWdwLvsAGbqYdDW59u4", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "free" + "service": "launch" }, { "direction": "any", - "service": "launch" + "service": "free" } ], "availability": "available", @@ -6762,28 +6758,28 @@ "description": "Neon Free — serverless Postgres for prototypes and side projects", "development": false, "group": null, + "id": "prvsvc_61UWdwLvsAGbqYdDW59u4", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UH8uNXFdoJUJOtT54AC", "provider_name": "Neon", "scope": "project", "service_id": "free", "updateable_to": [ - "free", - "launch" - ], - "livemode": true, - "provider_configuration_schema": {} + "launch", + "free" + ] }, { - "id": "prvsvc_61UWKH73h3qpCiGWV5APw", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -6795,28 +6791,32 @@ "ai" ], "configuration_schema": { - "type": "object", "properties": { "rechargeAmountUsd": { - "type": "number", + "description": "Optional pay-as-you-go auto top-up ceiling, in USD. If set, usage above the plan's included quota is billed via the shared payment token: your prepaid balance is refilled to this amount whenever it runs low. If unset, the plan's included quota is a hard cap (the service stops once depleted). Minimum $15. To disable a previously enabled auto top-up, send `{ \"rechargeAmountUsd\": null }` via update_service. See https://huggingface.co/pricing for current rates.", "minimum": 15, - "description": "Optional pay-as-you-go auto top-up ceiling, in USD. If set, usage above the plan's included quota is billed via the shared payment token: your prepaid balance is refilled to this amount whenever it runs low. If unset, the plan's included quota is a hard cap (the service stops once depleted). Minimum $15. To disable a previously enabled auto top-up, send `{ \"rechargeAmountUsd\": null }` via update_service. See https://huggingface.co/pricing for current rates." + "type": "number" } - } + }, + "type": "object" }, "constraints": [], "created": null, "description": "Free — access the Hugging Face platform at zero cost, no credit card required. Includes a free usage quota. Optional pay-as-you-go auto top-up can be enabled to bill usage above quota via a shared payment token. See https://huggingface.co/pricing", "development": false, "group": "plan", + "id": "prvsvc_61UWKH73h3qpCiGWV5APw", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQrUnWwGIOnrGFD5D8y", "provider_name": "HuggingFace", "scope": "project", @@ -6824,13 +6824,9 @@ "updateable_to": [ "pro", "free" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UWHSqw9mHRO4EIp5MAC", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -6859,8 +6855,11 @@ "description": "An Inngest app for deploying reliable production workflows", "development": false, "group": null, + "id": "prvsvc_61UWHSqw9mHRO4EIp5MAC", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -6868,8 +6867,8 @@ "is_default": null, "paid": null, "parent_services": [ - "hobby", - "pro" + "pro", + "hobby" ], "type": "free" } @@ -6879,27 +6878,24 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQ7Ta2evjv111Sl5Tqa", "provider_name": "Inngest", "scope": "project", "service_id": "app", "updateable_to": [ "app" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UWHSqtcUBG7dyM95H16", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "hobby" + "service": "pro" }, { "direction": "any", - "service": "pro" + "service": "hobby" } ], "availability": "available", @@ -6912,36 +6908,36 @@ "description": "Inngest free tier - includes up to 50,000 function executions/month, unlimited functions, and all core features including retries, scheduling, fan-out, throttling, and debounce.", "development": false, "group": null, + "id": "prvsvc_61UWHSqtcUBG7dyM95H16", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQ7Ta2evjv111Sl5Tqa", "provider_name": "Inngest", "scope": "project", "service_id": "hobby", "updateable_to": [ - "hobby", - "pro" - ], - "livemode": true, - "provider_configuration_schema": {} + "pro", + "hobby" + ] }, { - "id": "prvsvc_61UW7A5rArjAvA0ew5K6C", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "team" + "service": "pro" }, { "direction": "any", - "service": "pro" + "service": "team" } ], "availability": "available", @@ -6956,29 +6952,29 @@ "description": "Supabase Free Plan: Unlimited API requests • Shared CPU • 500 MB RAM • 50K MAU • 500 MB database space • 5 GB bandwidth • 1 GB file storage", "development": false, "group": null, + "id": "prvsvc_61UW7A5rArjAvA0ew5K6C", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UIXYHdsyF69pZbG52n2", "provider_name": "Supabase", "scope": "project", "service_id": "free", "updateable_to": [ - "team", "pro", + "team", "free" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UW7A5bzEOawXq9o5Cgq", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -6989,30 +6985,33 @@ "configuration_schema": { "properties": { "name": { - "type": "string", + "maxLength": 60, "minLength": 1, - "maxLength": 60 + "type": "string" }, "region": { - "type": "string", + "default": "americas", "enum": [ "americas", "emea", "apac" ], - "default": "americas" + "type": "string" } }, - "type": "object", - "required": [] + "required": [], + "type": "object" }, "constraints": [], "created": null, "description": "A Supabase project with database, auth, and storage", "development": false, "group": null, + "id": "prvsvc_61UW7A5bzEOawXq9o5Cgq", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -7032,8 +7031,8 @@ "type": "freeform" }, "parent_services": [ - "pro", - "team" + "team", + "pro" ], "type": "paid" } @@ -7043,19 +7042,16 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UIXYHdsyF69pZbG52n2", "provider_name": "Supabase", "scope": "project", "service_id": "project", "updateable_to": [ "project" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UW2TqpDit2EIK1w5WpE", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -7080,8 +7076,11 @@ "description": "Run AI models, powered by serverless GPUs, on Cloudflare's global network", "development": false, "group": null, + "id": "prvsvc_61UW2TqpDit2EIK1w5WpE", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -7107,6 +7106,7 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQRSWMdrciFdBNs5Ipc", "provider_name": "Cloudflare", "scope": "project", @@ -7115,13 +7115,9 @@ "workers:paid", "workers:free", "workers-ai" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UW2TqgWxijDfg9P5Xbk", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -7146,8 +7142,11 @@ "description": "Run headless browsers on Cloudflare for screenshots, PDFs, scraping, testing, crawling, and agent workflows.", "development": false, "group": null, + "id": "prvsvc_61UW2TqgWxijDfg9P5Xbk", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -7177,6 +7176,7 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQRSWMdrciFdBNs5Ipc", "provider_name": "Cloudflare", "scope": "project", @@ -7185,13 +7185,9 @@ "workers:paid", "workers:free", "browser-run" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UW2Tqd0Fe6jSC4S5AYq", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -7208,101 +7204,104 @@ ], "configuration_schema": { "additionalProperties": false, - "required": [ - "name", - "database", - "host", - "password", - "port", - "scheme", - "user" - ], - "type": "object", "properties": { - "name": { - "type": "string", - "default": "default-hyperdrive", - "description": "Hyperdrive configuration name." - }, - "host": { - "type": "string", - "description": "Origin database hostname or IP address." - }, - "port": { - "type": "integer", - "description": "Origin database port. Required for standard origins; omitted for Access-protected origins." - }, - "database": { - "type": "string", - "description": "Origin database name." - }, - "password": { - "type": "string", - "description": "Origin database password." - }, - "scheme": { - "type": "string", - "enum": [ - "postgres", - "postgresql", - "mysql" - ], - "description": "Origin database engine." - }, - "user": { - "type": "string", - "description": "Origin database username." - }, "access_client_id": { - "type": "string", - "description": "Optional Cloudflare Access client ID for an Access-protected origin." + "description": "Optional Cloudflare Access client ID for an Access-protected origin.", + "type": "string" }, "access_client_secret": { - "type": "string", - "description": "Optional Cloudflare Access client secret for an Access-protected origin." + "description": "Optional Cloudflare Access client secret for an Access-protected origin.", + "type": "string" }, "caching_disabled": { - "type": "boolean", - "description": "Disable Hyperdrive SQL response caching." + "description": "Disable Hyperdrive SQL response caching.", + "type": "boolean" }, "caching_max_age": { - "type": "integer", - "description": "Maximum cache age in seconds." + "description": "Maximum cache age in seconds.", + "type": "integer" }, "caching_stale_while_revalidate": { - "type": "integer", - "description": "Allowed stale-serving window in seconds." + "description": "Allowed stale-serving window in seconds.", + "type": "integer" + }, + "database": { + "description": "Origin database name.", + "type": "string" + }, + "host": { + "description": "Origin database hostname or IP address.", + "type": "string" }, "mtls_ca_certificate_id": { - "type": "string", - "description": "Uploaded CA certificate identifier." + "description": "Uploaded CA certificate identifier.", + "type": "string" }, "mtls_certificate_id": { - "type": "string", - "description": "Uploaded client certificate identifier." + "description": "Uploaded client certificate identifier.", + "type": "string" + }, + "mtls_sslmode": { + "description": "TLS verification mode for the origin connection.", + "enum": [ + "require", + "verify-ca", + "verify-full" + ], + "type": "string" + }, + "name": { + "default": "default-hyperdrive", + "description": "Hyperdrive configuration name.", + "type": "string" + }, + "origin_connection_limit": { + "description": "Soft cap on origin database connections.", + "type": "integer" + }, + "password": { + "description": "Origin database password.", + "type": "string" + }, + "port": { + "description": "Origin database port. Required for standard origins; omitted for Access-protected origins.", + "type": "integer" }, - "mtls_sslmode": { - "type": "string", + "scheme": { + "description": "Origin database engine.", "enum": [ - "require", - "verify-ca", - "verify-full" + "postgres", + "postgresql", + "mysql" ], - "description": "TLS verification mode for the origin connection." + "type": "string" }, - "origin_connection_limit": { - "type": "integer", - "description": "Soft cap on origin database connections." + "user": { + "description": "Origin database username.", + "type": "string" } - } + }, + "required": [ + "name", + "database", + "host", + "password", + "port", + "scheme", + "user" + ], + "type": "object" }, "constraints": [], "created": null, "description": "Connect Cloudflare Workers to existing Postgres or MySQL databases with connection pooling, query caching, and global acceleration.", "development": false, "group": null, + "id": "prvsvc_61UW2Tqd0Fe6jSC4S5AYq", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -7332,6 +7331,7 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQRSWMdrciFdBNs5Ipc", "provider_name": "Cloudflare", "scope": "project", @@ -7340,13 +7340,9 @@ "workers:paid", "workers:free", "hyperdrive" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UW2TqQ7sd2GjB835U1Y", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -7363,25 +7359,28 @@ ], "configuration_schema": { "additionalProperties": false, - "required": [ - "title" - ], - "type": "object", "properties": { "title": { - "type": "string", "default": "default-kv-namespace", - "description": "Human-readable KV namespace name." + "description": "Human-readable KV namespace name.", + "type": "string" } - } + }, + "required": [ + "title" + ], + "type": "object" }, "constraints": [], "created": null, "description": "Store and read key-value data globally with low latency for configs, caches, and other high-read workloads.", "development": false, "group": null, + "id": "prvsvc_61UW2TqQ7sd2GjB835U1Y", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -7411,6 +7410,7 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQRSWMdrciFdBNs5Ipc", "provider_name": "Cloudflare", "scope": "project", @@ -7419,13 +7419,9 @@ "workers:paid", "workers:free", "kv" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UW2Tq9ugK2FT0I5598a", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -7442,18 +7438,14 @@ ], "configuration_schema": { "additionalProperties": false, - "required": [ - "name" - ], - "type": "object", "properties": { "name": { - "type": "string", "default": "default-d1-database", - "description": "D1 database name." + "description": "D1 database name.", + "type": "string" }, "primary_location_hint": { - "type": "string", + "description": "Preferred D1 primary region.", "enum": [ "wnam", "enam", @@ -7462,17 +7454,24 @@ "apac", "oc" ], - "description": "Preferred D1 primary region." + "type": "string" } - } + }, + "required": [ + "name" + ], + "type": "object" }, "constraints": [], "created": null, "description": "Serverless SQL databases with SQLite semantics, Worker and HTTP API access, and built-in point-in-time recovery.", "development": false, "group": null, + "id": "prvsvc_61UW2Tq9ugK2FT0I5598a", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -7502,6 +7501,7 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQRSWMdrciFdBNs5Ipc", "provider_name": "Cloudflare", "scope": "project", @@ -7510,13 +7510,9 @@ "workers:paid", "workers:free", "d1" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UW2Tq86s2X4k0z05XcG", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -7533,25 +7529,28 @@ ], "configuration_schema": { "additionalProperties": false, - "required": [ - "queue_name" - ], - "type": "object", "properties": { "queue_name": { - "type": "string", "default": "default-queue", - "description": "Queue name." + "description": "Queue name.", + "type": "string" } - } + }, + "required": [ + "queue_name" + ], + "type": "object" }, "constraints": [], "created": null, "description": "Send and receive messages with guaranteed delivery, batching, delays, retries, and dead-letter queues.", "development": false, "group": null, + "id": "prvsvc_61UW2Tq86s2X4k0z05XcG", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -7581,6 +7580,7 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQRSWMdrciFdBNs5Ipc", "provider_name": "Cloudflare", "scope": "project", @@ -7589,13 +7589,9 @@ "workers:paid", "workers:free", "queues" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UW2Tq6WM39IHTiG5Foe", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -7620,8 +7616,11 @@ "description": "Run code written in any programming language, built for any runtime, as part of apps built on Workers.", "development": false, "group": null, + "id": "prvsvc_61UW2Tq6WM39IHTiG5Foe", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -7643,6 +7642,7 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQRSWMdrciFdBNs5Ipc", "provider_name": "Cloudflare", "scope": "project", @@ -7651,13 +7651,9 @@ "workers:paid", "workers:free", "containers" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UW1Y47Lu4zOqmTG5AZs", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -7666,22 +7662,25 @@ "configuration_schema": { "properties": { "name": { - "type": "string", - "minLength": 1 + "minLength": 1, + "type": "string" } }, - "type": "object", "required": [ "name" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "Setup a mobile, web or IoT application to use Auth0 for authentication.", "development": false, "group": null, + "id": "prvsvc_61UW1Y47Lu4zOqmTG5AZs", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -7689,11 +7688,11 @@ "is_default": null, "paid": null, "parent_services": [ - "b2c-professional", - "free", - "b2c-essentials", + "b2b-professional", "b2b-essentials", - "b2b-professional" + "b2c-essentials", + "free", + "b2c-professional" ], "type": "free" } @@ -7703,19 +7702,11 @@ "paid_pricing": [], "type": "component" }, - "provider_id": "prvdr_61UVctyxfLOj4FpVw5Bmi", - "provider_name": "Auth0", - "scope": "project", - "service_id": "client", - "updateable_to": [ - "client" - ], - "livemode": true, "provider_configuration_schema": { - "type": "object", + "additionalProperties": false, "properties": { "locality": { - "type": "string", + "default": "us", "description": "The deployment locality/region.", "enum": [ "au", @@ -7724,26 +7715,31 @@ "jp", "us" ], - "default": "us" + "type": "string" }, "naming_prefix": { - "type": "string", "description": "A naming prefix for the tenant/teams you are creating.", - "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", "maxLength": 50, - "minLength": 3 + "minLength": 3, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "type": "string" } }, "required": [ "locality", "naming_prefix" ], - "additionalProperties": false - } + "type": "object" + }, + "provider_id": "prvdr_61UVctyxfLOj4FpVw5Bmi", + "provider_name": "Auth0", + "scope": "project", + "service_id": "client", + "updateable_to": [ + "client" + ] }, { - "id": "prvsvc_61UVxnfns4DFOZPiC56hE", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -7755,8 +7751,11 @@ "description": "OpenRouter — browse, compare, and use 300+ AI models from leading providers through a single API. Text, image, video, and audio generation.", "development": false, "group": null, + "id": "prvsvc_61UVxnfns4DFOZPiC56hE", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -7782,19 +7781,16 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61URImmFACEuDHEx651IO", "provider_name": "OpenRouter", "scope": "project", "service_id": "api", "updateable_to": [ "api" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UVxnfMkcvekEo625Koq", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -7806,8 +7802,11 @@ "description": "Pay-as-you-go — per-token usage-based pricing across 300+ models with no minimum commitment.", "development": false, "group": null, + "id": "prvsvc_61UVxnfMkcvekEo625Koq", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -7826,46 +7825,46 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61URImmFACEuDHEx651IO", "provider_name": "OpenRouter", "scope": "project", "service_id": "pay_as_you_go", "updateable_to": [ "pay_as_you_go" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UVwrnzVrUxUZ4fT54uO", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ "auth" ], "configuration_schema": { - "required": [], - "type": "object", "properties": { "environment": { - "type": "string", + "default": "sandbox", + "description": "Environment type. Sandbox environments are free for development and testing.", "enum": [ "sandbox", "production" ], - "default": "sandbox", - "description": "Environment type. Sandbox environments are free for development and testing." + "type": "string" } - } + }, + "required": [], + "type": "object" }, "constraints": [], "created": null, "description": "WorkOS AuthKit — drop-in authentication with SSO, MFA, and user management.", "development": false, "group": null, + "id": "prvsvc_61UVwrnzVrUxUZ4fT54uO", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, @@ -7891,35 +7890,32 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UVpYy9KwOpOlUVw5WD2", "provider_name": "WorkOS", "scope": "project", "service_id": "auth", "updateable_to": [ "auth" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UVsBH2OlITHROiz54wy", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "standard" + "service": "api" }, { "direction": "any", - "service": "free" + "service": "hobby" }, { "direction": "any", - "service": "api" + "service": "free" }, { "direction": "any", - "service": "hobby" + "service": "standard" } ], "availability": "available", @@ -7928,28 +7924,31 @@ ], "configuration_schema": { "additionalProperties": false, - "required": [ - "interval" - ], - "type": "object", "properties": { "interval": { - "type": "string", + "description": "Billing interval (monthly vs yearly).", "enum": [ "Monthly", "Yearly" ], - "description": "Billing interval (monthly vs yearly)." + "type": "string" } - } + }, + "required": [ + "interval" + ], + "type": "object" }, "constraints": [], "created": null, "description": "Firecrawl Growth — paid credits for scrape, crawl, extract, and other features (pick monthly or yearly).", "development": false, "group": null, + "id": "prvsvc_61UVsBH2OlITHROiz54wy", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -7979,27 +7978,24 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61URa0AjsVCYCdagK5Xqy", "provider_name": "Firecrawl", "scope": "project", "service_id": "growth", "updateable_to": [ - "standard", - "free", "api", "hobby", + "free", + "standard", "growth" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UVsBGhglI8AP3Sm56rQ", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "standard" + "service": "api" }, { "direction": "any", @@ -8007,7 +8003,7 @@ }, { "direction": "any", - "service": "api" + "service": "standard" }, { "direction": "any", @@ -8020,28 +8016,31 @@ ], "configuration_schema": { "additionalProperties": false, - "required": [ - "interval" - ], - "type": "object", "properties": { "interval": { - "type": "string", + "description": "Billing interval (monthly vs yearly).", "enum": [ "Monthly", "Yearly" ], - "description": "Billing interval (monthly vs yearly)." + "type": "string" } - } + }, + "required": [ + "interval" + ], + "type": "object" }, "constraints": [], "created": null, "description": "Firecrawl Hobby — paid credits for scrape, crawl, extract, and other features (pick monthly or yearly).", "development": false, "group": null, + "id": "prvsvc_61UVsBGhglI8AP3Sm56rQ", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -8071,35 +8070,32 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61URa0AjsVCYCdagK5Xqy", "provider_name": "Firecrawl", "scope": "project", "service_id": "hobby", "updateable_to": [ - "standard", - "free", "api", + "free", + "standard", "growth", "hobby" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UVsBGbvfSTCANPd5UEC", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "free" + "service": "api" }, { "direction": "any", - "service": "api" + "service": "hobby" }, { "direction": "any", - "service": "hobby" + "service": "free" }, { "direction": "any", @@ -8112,28 +8108,31 @@ ], "configuration_schema": { "additionalProperties": false, - "required": [ - "interval" - ], - "type": "object", "properties": { "interval": { - "type": "string", + "description": "Billing interval (monthly vs yearly).", "enum": [ "Monthly", "Yearly" ], - "description": "Billing interval (monthly vs yearly)." + "type": "string" } - } + }, + "required": [ + "interval" + ], + "type": "object" }, "constraints": [], "created": null, "description": "Firecrawl Standard — paid credits for scrape, crawl, extract, and other features (pick monthly or yearly).", "development": false, "group": null, + "id": "prvsvc_61UVsBGbvfSTCANPd5UEC", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -8163,23 +8162,20 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61URa0AjsVCYCdagK5Xqy", "provider_name": "Firecrawl", "scope": "project", "service_id": "standard", "updateable_to": [ - "free", "api", "hobby", + "free", "growth", "standard" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UVjkhHJ4Na54JXK5BSy", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -8196,14 +8192,18 @@ "description": "Free — access free AI models on OpenRouter at zero cost, no credit card required.", "development": false, "group": null, + "id": "prvsvc_61UVjkhHJ4Na54JXK5BSy", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61URImmFACEuDHEx651IO", "provider_name": "OpenRouter", "scope": "project", @@ -8211,17 +8211,13 @@ "updateable_to": [ "pay_as_you_go", "free" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UVFmdytMu0hKNRC5FLk", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "standard" + "service": "hobby" }, { "direction": "any", @@ -8229,7 +8225,7 @@ }, { "direction": "any", - "service": "hobby" + "service": "standard" }, { "direction": "any", @@ -8250,8 +8246,11 @@ "description": "Firecrawl API — search, scrape, and extract the web into LLM-ready data (workspace API access).", "development": false, "group": null, + "id": "prvsvc_61UVFmdytMu0hKNRC5FLk", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -8272,8 +8271,8 @@ }, "parent_services": [ "growth", - "hobby", - "standard" + "standard", + "hobby" ], "type": "paid" } @@ -8283,35 +8282,32 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61URa0AjsVCYCdagK5Xqy", "provider_name": "Firecrawl", "scope": "project", "service_id": "api", "updateable_to": [ - "standard", - "free", "hobby", + "free", + "standard", "growth", "api" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UVFmd7797dsI7In51Yu", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "standard" + "service": "api" }, { "direction": "any", - "service": "api" + "service": "hobby" }, { "direction": "any", - "service": "hobby" + "service": "standard" }, { "direction": "any", @@ -8332,31 +8328,31 @@ "description": "Firecrawl free tier — limited API usage (no card; same workspace API key as paid).", "development": false, "group": null, + "id": "prvsvc_61UVFmd7797dsI7In51Yu", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61URa0AjsVCYCdagK5Xqy", "provider_name": "Firecrawl", "scope": "project", "service_id": "free", "updateable_to": [ - "standard", "api", "hobby", + "standard", "growth", "free" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UVFmVrDpVhvoGC653ZY", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -8377,14 +8373,18 @@ "description": "Shared free plan for Workers-backed deployables such as Workers, KV, Hyperdrive, Queues, D1, Browser Run, and Workers AI.", "development": false, "group": null, + "id": "prvsvc_61UVFmVrDpVhvoGC653ZY", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQRSWMdrciFdBNs5Ipc", "provider_name": "Cloudflare", "scope": "project", @@ -8392,13 +8392,9 @@ "updateable_to": [ "workers:paid", "workers:free" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UVDun2YmQhQ17BI5CaG", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -8413,58 +8409,58 @@ "configuration_schema": { "properties": { "name": { - "type": "string", - "description": "Name of the project" + "description": "Name of the project", + "type": "string" }, "visibility": { - "type": "string", + "description": "Visibility level of the project", "enum": [ "private", "public" ], - "description": "Visibility level of the project" + "type": "string" } }, - "type": "object", "required": [ "name", "visibility" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "GitLab project with built-in CI/CD, container registry, and collaboration tools", "development": false, "group": null, + "id": "prvsvc_61UVDun2YmQhQ17BI5CaG", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UU5TYtG0f1neJCz5EwK", "provider_name": "GitLab", "scope": "project", "service_id": "project", "updateable_to": [ "project" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UVDuPrHyyK12dYP5PdA", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "hobby" + "service": "pro" }, { "direction": "any", - "service": "pro" + "service": "hobby" } ], "availability": "available", @@ -8477,36 +8473,36 @@ "description": "The perfect starting place for your web app or personal project.", "development": false, "group": null, + "id": "prvsvc_61UVDuPrHyyK12dYP5PdA", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61ULUAr4zlQgZkvXY5LsG", "provider_name": "Vercel", "scope": "project", "service_id": "hobby", "updateable_to": [ - "hobby", - "pro" - ], - "livemode": true, - "provider_configuration_schema": {} + "pro", + "hobby" + ] }, { - "id": "prvsvc_61UVDuPOAnLbAWk9656M4", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "hobby" + "service": "pro" }, { "direction": "any", - "service": "pro" + "service": "hobby" } ], "availability": "available", @@ -8519,8 +8515,11 @@ "description": "Everything you need to build and scale your app.", "development": false, "group": null, + "id": "prvsvc_61UVDuPOAnLbAWk9656M4", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -8539,20 +8538,17 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61ULUAr4zlQgZkvXY5LsG", "provider_name": "Vercel", "scope": "project", "service_id": "pro", "updateable_to": [ - "hobby", - "pro" - ], - "livemode": true, - "provider_configuration_schema": {} + "pro", + "hobby" + ] }, { - "id": "prvsvc_61UTm0O3ExJwRuP1R5M9o", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -8564,8 +8560,11 @@ "description": "Mixpanel product analytics", "development": false, "group": null, + "id": "prvsvc_61UTm0O3ExJwRuP1R5M9o", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -8595,23 +8594,20 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": null, "provider_id": "prvdr_61UTLwCr8KIRpEaZS54mu", "provider_name": "Mixpanel", "scope": "project", "service_id": "analytics", "updateable_to": [ "analytics" - ], - "livemode": true, - "provider_configuration_schema": null + ] }, { - "id": "prvsvc_61UTm0DyRLo4HfbOC57bc", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "plus-v3-25k-mtu-monthly" + "service": "plus-v3-10k-mtu-monthly" }, { "direction": "any", @@ -8623,7 +8619,7 @@ }, { "direction": "any", - "service": "plus-v3-10k-mtu-monthly" + "service": "plus-v3-25k-mtu-monthly" } ], "availability": "available", @@ -8638,8 +8634,11 @@ "description": "Amplitude Analytics - Product analytics, feature flags, session replay, and experimentation to help teams build better products", "development": false, "group": null, + "id": "prvsvc_61UTm0DyRLo4HfbOC57bc", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -8693,43 +8692,40 @@ "paid_pricing": [], "type": "component" }, - "provider_id": "prvdr_61UQYwVkrCANKYQdE5BLM", - "provider_name": "Amplitude", - "scope": "project", - "service_id": "analytics", - "updateable_to": [ - "plus-v3-25k-mtu-monthly", - "free", - "plus-v3-50k-mtu-monthly", - "plus-v3-10k-mtu-monthly", - "analytics" - ], - "livemode": true, "provider_configuration_schema": { - "type": "object", "properties": { + "marketing": { + "description": "Receive product updates and marketing communications from Amplitude. [Y/n]", + "title": "Marketing emails", + "type": "boolean" + }, "region": { - "type": "string", + "description": "Data center region where your data will be stored and processed. This cannot be changed after provisioning.", "enum": [ "us", "eu" ], - "description": "Data center region where your data will be stored and processed. This cannot be changed after provisioning." - }, - "marketing": { - "type": "boolean", - "title": "Marketing emails", - "description": "Receive product updates and marketing communications from Amplitude. [Y/n]" + "type": "string" } }, "required": [ "region" - ] - } + ], + "type": "object" + }, + "provider_id": "prvdr_61UQYwVkrCANKYQdE5BLM", + "provider_name": "Amplitude", + "scope": "project", + "service_id": "analytics", + "updateable_to": [ + "plus-v3-10k-mtu-monthly", + "free", + "plus-v3-50k-mtu-monthly", + "plus-v3-25k-mtu-monthly", + "analytics" + ] }, { - "id": "prvsvc_61UTm0BhOeDSQPwr65Ez2", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -8756,50 +8752,50 @@ "description": "Free plan - Product analytics, feature flags, and session replay with up to 10k MTU", "development": false, "group": null, + "id": "prvsvc_61UTm0BhOeDSQPwr65Ez2", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, - "provider_id": "prvdr_61UQYwVkrCANKYQdE5BLM", - "provider_name": "Amplitude", - "scope": "project", - "service_id": "free", - "updateable_to": [ - "plus-v3-50k-mtu-monthly", - "plus-v3-25k-mtu-monthly", - "plus-v3-10k-mtu-monthly", - "free" - ], - "livemode": true, "provider_configuration_schema": { - "type": "object", "properties": { + "marketing": { + "description": "Receive product updates and marketing communications from Amplitude. [Y/n]", + "title": "Marketing emails", + "type": "boolean" + }, "region": { - "type": "string", + "description": "Data center region where your data will be stored and processed. This cannot be changed after provisioning.", "enum": [ "us", "eu" ], - "description": "Data center region where your data will be stored and processed. This cannot be changed after provisioning." - }, - "marketing": { - "type": "boolean", - "title": "Marketing emails", - "description": "Receive product updates and marketing communications from Amplitude. [Y/n]" + "type": "string" } }, "required": [ "region" - ] - } + ], + "type": "object" + }, + "provider_id": "prvdr_61UQYwVkrCANKYQdE5BLM", + "provider_name": "Amplitude", + "scope": "project", + "service_id": "free", + "updateable_to": [ + "plus-v3-50k-mtu-monthly", + "plus-v3-25k-mtu-monthly", + "plus-v3-10k-mtu-monthly", + "free" + ] }, { - "id": "prvsvc_61UTTGnVZotu913Zg5Ejg", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -8818,8 +8814,11 @@ "description": "Pay-as-you-go - usage-based pricing across all PostHog products with no minimum commitment.", "development": false, "group": null, + "id": "prvsvc_61UTTGnVZotu913Zg5Ejg", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -8838,40 +8837,37 @@ ], "type": "paid" }, - "provider_id": "prvdr_61UGMHNyOleb3A55m5Pg8", - "provider_name": "PostHog", - "scope": "project", - "service_id": "pay_as_you_go", - "updateable_to": [ - "free", - "pay_as_you_go" - ], - "livemode": true, "provider_configuration_schema": { - "type": "object", + "additionalProperties": false, "properties": { + "organization_name": { + "description": "Name for the PostHog organization", + "type": "string" + }, "region": { - "type": "string", + "description": "Which PostHog region to connect the Stripe account to", "enum": [ "US", "EU" ], - "description": "Which PostHog region to connect the Stripe account to" - }, - "organization_name": { - "type": "string", - "description": "Name for the PostHog organization" + "type": "string" } }, "required": [ "region" ], - "additionalProperties": false - } + "type": "object" + }, + "provider_id": "prvdr_61UGMHNyOleb3A55m5Pg8", + "provider_name": "PostHog", + "scope": "project", + "service_id": "pay_as_you_go", + "updateable_to": [ + "free", + "pay_as_you_go" + ] }, { - "id": "prvsvc_61UTTGnUGhiJmkuIf5QiW", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -8890,48 +8886,48 @@ "description": "Free - generous free tier across all PostHog products, no credit card required.", "development": false, "group": null, + "id": "prvsvc_61UTTGnUGhiJmkuIf5QiW", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, - "provider_id": "prvdr_61UGMHNyOleb3A55m5Pg8", - "provider_name": "PostHog", - "scope": "project", - "service_id": "free", - "updateable_to": [ - "pay_as_you_go", - "free" - ], - "livemode": true, "provider_configuration_schema": { - "type": "object", + "additionalProperties": false, "properties": { + "organization_name": { + "description": "Name for the PostHog organization", + "type": "string" + }, "region": { - "type": "string", + "description": "Which PostHog region to connect the Stripe account to", "enum": [ "US", "EU" ], - "description": "Which PostHog region to connect the Stripe account to" - }, - "organization_name": { - "type": "string", - "description": "Name for the PostHog organization" + "type": "string" } }, "required": [ "region" ], - "additionalProperties": false - } + "type": "object" + }, + "provider_id": "prvdr_61UGMHNyOleb3A55m5Pg8", + "provider_name": "PostHog", + "scope": "project", + "service_id": "free", + "updateable_to": [ + "pay_as_you_go", + "free" + ] }, { - "id": "prvsvc_61UTPWiUOQ0dhqFtK5Eeu", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -8941,8 +8937,8 @@ "additionalProperties": false, "properties": { "domain_query": { - "type": "string", - "description": "Enter keywords, a phrase, or a domain name to search for available domains." + "description": "Enter keywords, a phrase, or a domain name to search for available domains.", + "type": "string" } }, "type": "object" @@ -8952,8 +8948,11 @@ "description": "Register a domain through Cloudflare Registrar as a one-time purchase. Cloudflare will ask for search keywords, show matching domains, and then register the selected domain.", "development": false, "group": "domain", + "id": "prvsvc_61UTPWiUOQ0dhqFtK5Eeu", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -8972,19 +8971,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQRSWMdrciFdBNs5Ipc", "provider_name": "Cloudflare", "scope": "project", "service_id": "registrar:domain", "updateable_to": [ "registrar:domain" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UTLwDTE64r9OrDR5WMa", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -9001,8 +8997,11 @@ "description": "Product analytics starting at 1M events and 20k session replays per month", "development": false, "group": null, + "id": "prvsvc_61UTLwDTE64r9OrDR5WMa", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -9021,6 +9020,7 @@ ], "type": "paid" }, + "provider_configuration_schema": null, "provider_id": "prvdr_61UTLwCr8KIRpEaZS54mu", "provider_name": "Mixpanel", "scope": "project", @@ -9028,13 +9028,9 @@ "updateable_to": [ "free", "growth" - ], - "livemode": true, - "provider_configuration_schema": null + ] }, { - "id": "prvsvc_61UTLwDScySk557s75KYa", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -9051,14 +9047,18 @@ "description": "Product analytics up to 1M events and 10k session replays per month", "development": false, "group": null, + "id": "prvsvc_61UTLwDScySk557s75KYa", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": null, "provider_id": "prvdr_61UTLwCr8KIRpEaZS54mu", "provider_name": "Mixpanel", "scope": "project", @@ -9066,13 +9066,9 @@ "updateable_to": [ "growth", "free" - ], - "livemode": true, - "provider_configuration_schema": null + ] }, { - "id": "prvsvc_61UTAXcjeOFQACWH25PEW", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -9093,8 +9089,11 @@ "description": "Shared paid plan for Workers-backed deployables with a $5/month minimum charge, included usage, and additional charges for usage above the included amounts.", "development": false, "group": null, + "id": "prvsvc_61UTAXcjeOFQACWH25PEW", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -9113,6 +9112,7 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQRSWMdrciFdBNs5Ipc", "provider_name": "Cloudflare", "scope": "project", @@ -9120,13 +9120,9 @@ "updateable_to": [ "workers:free", "workers:paid" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61USgZXXy7wCbS99252dU", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -9138,32 +9134,25 @@ "database" ], "configuration_schema": { - "required": [ - "name", - "region", - "plan" - ], - "type": "object", "properties": { "name": { - "type": "string", "description": "Cluster name", - "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$", "maxLength": 63, - "minLength": 5 + "minLength": 5, + "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$", + "type": "string" }, "plan": { - "type": "string", "enum": [ "basic", "starter", "launch", "scale", "Performance" - ] + ], + "type": "string" }, "region": { - "type": "string", "enum": [ "ams", "fra", @@ -9177,17 +9166,27 @@ "sjc", "syd", "yyz" - ] + ], + "type": "string" } - } + }, + "required": [ + "name", + "region", + "plan" + ], + "type": "object" }, "constraints": [], "created": null, "description": "Managed PostgreSQL — high-availability clusters with automatic failover, daily backups, and connection pooling via PgBouncer.", "development": false, "group": null, + "id": "prvsvc_61USgZXXy7wCbS99252dU", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, @@ -9240,19 +9239,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQUoA4NItxbLNIU5FF2", "provider_name": "Flyio", "scope": "project", "service_id": "mpg", "updateable_to": [ "mpg" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61URd0vEPDbAZMG565AUy", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -9264,8 +9260,11 @@ "description": "Clerk Pro plan — production-ready auth with MFA, SSO, custom domains, remove branding. 50,000 MAU included, then usage-based.", "development": false, "group": null, + "id": "prvsvc_61URd0vEPDbAZMG565AUy", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -9284,19 +9283,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UGOeSW4e0T9Etwn537w", "provider_name": "Clerk", "scope": "project", "service_id": "pro", "updateable_to": [ "pro" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UREfL1Yq8gOPKI856F6", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -9308,27 +9304,30 @@ "compute" ], "configuration_schema": { - "required": [ - "name" - ], - "type": "object", "properties": { "name": { - "type": "string", "description": "Sprite name", - "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$", "maxLength": 63, - "minLength": 5 + "minLength": 5, + "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$", + "type": "string" } - } + }, + "required": [ + "name" + ], + "type": "object" }, "constraints": [], "created": null, "description": "Stateful sandbox environments with checkpoint & restore", "development": false, "group": null, + "id": "prvsvc_61UREfL1Yq8gOPKI856F6", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -9347,19 +9346,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQUoA4NItxbLNIU5FF2", "provider_name": "Flyio", "scope": "project", "service_id": "sprite", "updateable_to": [ "sprite" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UR6EotHH3b5lvcb5080", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -9372,8 +9368,11 @@ "description": "Full access to the Hugging Face platform: models, datasets, GPU compute, and inference", "development": false, "group": null, + "id": "prvsvc_61UR6EotHH3b5lvcb5080", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -9413,19 +9412,16 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQrUnWwGIOnrGFD5D8y", "provider_name": "HuggingFace", "scope": "project", "service_id": "platform", "updateable_to": [ "platform" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UR6EoX0bvOlroJU5HRo", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -9433,28 +9429,31 @@ "storage" ], "configuration_schema": { - "type": "object", "properties": { "name": { "type": "string" }, "visibility": { - "type": "string", + "default": "private", "enum": [ "public", "private" ], - "default": "private" + "type": "string" } - } + }, + "type": "object" }, "constraints": [], "created": null, "description": "Create a Storage Bucket for your data", "development": false, "group": null, + "id": "prvsvc_61UR6EoX0bvOlroJU5HRo", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -9494,43 +9493,43 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQrUnWwGIOnrGFD5D8y", "provider_name": "HuggingFace", "scope": "project", "service_id": "bucket", "updateable_to": [ "bucket" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UQe8pSOds44BVqA5VEm", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ "compute" ], "configuration_schema": { - "required": [ - "app_name" - ], - "type": "object", "properties": { "app_name": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]{2,62}$" + "pattern": "^[a-z][a-z0-9-]{2,62}$", + "type": "string" } - } + }, + "required": [ + "app_name" + ], + "type": "object" }, "constraints": [], "created": null, "description": "Deploy and run your applications globally", "development": false, "group": null, + "id": "prvsvc_61UQe8pSOds44BVqA5VEm", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -9549,27 +9548,24 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQUoA4NItxbLNIU5FF2", "provider_name": "Flyio", "scope": "project", "service_id": "app", "updateable_to": [ "app" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UQe8nlWkSIYv91A5JMG", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "hobby" + "service": "pro" }, { "direction": "any", - "service": "pro" + "service": "hobby" } ], "availability": "available", @@ -9582,8 +9578,11 @@ "description": "Inngest Pro - production-grade durable workflow engine. Includes 1M function executions/month, 7 day traces and metrics retention, 100+ concurrency executions.", "development": false, "group": null, + "id": "prvsvc_61UQe8nlWkSIYv91A5JMG", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -9602,20 +9601,17 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQ7Ta2evjv111Sl5Tqa", "provider_name": "Inngest", "scope": "project", "service_id": "pro", "updateable_to": [ - "hobby", - "pro" - ], - "livemode": true, - "provider_configuration_schema": {} + "pro", + "hobby" + ] }, { - "id": "prvsvc_61UQcGg82uRNYkUxp5QDY", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -9640,8 +9636,11 @@ "description": "Build, deploy, and scale serverless apps and APIs across Cloudflare's global network.", "development": false, "group": null, + "id": "prvsvc_61UQcGg82uRNYkUxp5QDY", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -9671,6 +9670,7 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UQRSWMdrciFdBNs5Ipc", "provider_name": "Cloudflare", "scope": "project", @@ -9679,13 +9679,9 @@ "workers:paid", "workers:free", "workers" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UM1Z1aN2VWQvjsu5BU0", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -9703,8 +9699,11 @@ "description": "Postgres with built-in auth — by Databricks", "development": false, "group": null, + "id": "prvsvc_61UM1Z1aN2VWQvjsu5BU0", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -9734,19 +9733,16 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UH8uNXFdoJUJOtT54AC", "provider_name": "Neon", "scope": "project", "service_id": "postgres", "updateable_to": [ "postgres" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61ULwsmoEBCfEzlWo5Krg", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -9760,22 +9756,25 @@ "configuration_schema": { "properties": { "name": { - "type": "string", - "minLength": 1 + "minLength": 1, + "type": "string" } }, - "type": "object", "required": [ "name" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "An application deployed from a Git repository with automatic deployments on every branch push.", "development": false, "group": null, + "id": "prvsvc_61ULwsmoEBCfEzlWo5Krg", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -9783,8 +9782,8 @@ "is_default": null, "paid": null, "parent_services": [ - "hobby", - "pro" + "pro", + "hobby" ], "type": "free" } @@ -9794,19 +9793,16 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61ULUAr4zlQgZkvXY5LsG", "provider_name": "Vercel", "scope": "project", "service_id": "project", "updateable_to": [ "project" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61ULmCPEWP9tpygdH57Am", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -9819,8 +9815,11 @@ "description": "Runloop Sandbox — cloud sandbox environment for Agents and RL workloads. Projects accounts get $300 in trial value", "development": false, "group": null, + "id": "prvsvc_61ULmCPEWP9tpygdH57Am", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -9854,19 +9853,16 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UGhvzdmhefqNXre5WIS", "provider_name": "Runloop", "scope": "account", "service_id": "sandbox", "updateable_to": [ "sandbox" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61ULYJXN0ZO3MN4Oh5Dho", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -9885,8 +9881,11 @@ "description": "PostHog — product analytics, session replay, realtime destinations, feature flags & experiments, surveys, data warehouse, error tracking, ai observability, logs, posthog ai, emails, and more.", "development": false, "group": null, + "id": "prvsvc_61ULYJXN0ZO3MN4Oh5Dho", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -9916,40 +9915,37 @@ "paid_pricing": [], "type": "component" }, - "provider_id": "prvdr_61UGMHNyOleb3A55m5Pg8", - "provider_name": "PostHog", - "scope": "project", - "service_id": "analytics", - "updateable_to": [ - "service_ref", - "analytics" - ], - "livemode": true, "provider_configuration_schema": { - "type": "object", + "additionalProperties": false, "properties": { + "organization_name": { + "description": "Name for the PostHog organization", + "type": "string" + }, "region": { - "type": "string", + "description": "Which PostHog region to connect the Stripe account to", "enum": [ "US", "EU" ], - "description": "Which PostHog region to connect the Stripe account to" - }, - "organization_name": { - "type": "string", - "description": "Name for the PostHog organization" + "type": "string" } }, "required": [ "region" ], - "additionalProperties": false - } + "type": "object" + }, + "provider_id": "prvdr_61UGMHNyOleb3A55m5Pg8", + "provider_name": "PostHog", + "scope": "project", + "service_id": "analytics", + "updateable_to": [ + "service_ref", + "analytics" + ] }, { - "id": "prvsvc_61ULQ6TbicEto3pPO59sm", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -9958,29 +9954,32 @@ "configuration_schema": { "properties": { "app_name": { - "type": "string", "description": "Name of the application", + "maxLength": 100, "minLength": 1, - "maxLength": 100 + "type": "string" }, "production_domain": { - "type": "string", "description": "Production domain (e.g. myapp.com). If provided, a production instance is created alongside development. DNS setup required after provisioning.", - "pattern": "^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,}$" + "pattern": "^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,}$", + "type": "string" } }, - "type": "object", "required": [ "app_name" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "Clerk Authentication — drop-in auth for any framework. Free by default, paid under Pro.", "development": false, "group": null, + "id": "prvsvc_61ULQ6TbicEto3pPO59sm", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -10010,19 +10009,16 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UGOeSW4e0T9Etwn537w", "provider_name": "Clerk", "scope": "project", "service_id": "auth", "updateable_to": [ "auth" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61ULQ6TPBQRzonCr65IIq", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -10039,14 +10035,18 @@ "description": "Clerk Hobby plan — authentication and user management for side projects and small apps. 50,000 MAU included.", "development": false, "group": null, + "id": "prvsvc_61ULQ6TPBQRzonCr65IIq", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UGOeSW4e0T9Etwn537w", "provider_name": "Clerk", "scope": "project", @@ -10054,13 +10054,9 @@ "updateable_to": [ "pro", "hobby" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61ULLQ3kAGgttPrEz54JE", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -10073,15 +10069,8 @@ ], "configuration_schema": { "properties": { - "name": { - "type": "string", - "description": "Database name.", - "pattern": "^[a-z0-9][a-z0-9_-]*[a-z0-9]$", - "minLength": 2, - "maxLength": 63 - }, "cluster": { - "type": "string", + "description": "Cluster size", "enum": [ "PS-10", "PS-20", @@ -10090,10 +10079,17 @@ "PS-160", "PS-320" ], - "description": "Cluster size" + "type": "string" + }, + "name": { + "description": "Database name.", + "maxLength": 63, + "minLength": 2, + "pattern": "^[a-z0-9][a-z0-9_-]*[a-z0-9]$", + "type": "string" }, "region": { - "type": "string", + "description": "Deployment region (prefixed with cloud provider, e.g., aws-us-east, gcp-us-central1)", "enum": [ "aws-ap-northeast", "aws-ap-south", @@ -10115,23 +10111,26 @@ "gcp-us-east1", "gcp-us-east4" ], - "description": "Deployment region (prefixed with cloud provider, e.g., aws-us-east, gcp-us-central1)" + "type": "string" } }, - "type": "object", "required": [ "name", "cluster", "region" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "Fully managed MySQL database on PlanetScale", "development": false, "group": null, + "id": "prvsvc_61ULLQ3kAGgttPrEz54JE", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, @@ -11279,19 +11278,16 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UGL5sd8z0WkMArr5Jlo", "provider_name": "PlanetScale", "scope": "project", "service_id": "mysql", "updateable_to": [ "mysql" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61ULLQ3GBvET6sI0q5Tbs", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -11304,15 +11300,8 @@ ], "configuration_schema": { "properties": { - "name": { - "type": "string", - "description": "Database name.", - "pattern": "^[a-z0-9][a-z0-9_-]*[a-z0-9]$", - "minLength": 2, - "maxLength": 63 - }, "cluster": { - "type": "string", + "description": "Cluster size", "enum": [ "PS-5", "PS-10", @@ -11322,10 +11311,17 @@ "PS-160", "PS-320" ], - "description": "Cluster size" + "type": "string" + }, + "name": { + "description": "Database name.", + "maxLength": 63, + "minLength": 2, + "pattern": "^[a-z0-9][a-z0-9_-]*[a-z0-9]$", + "type": "string" }, "region": { - "type": "string", + "description": "Deployment region (prefixed with cloud provider, e.g., aws-us-east, gcp-us-central1)", "enum": [ "aws-ap-northeast", "aws-ap-south", @@ -11347,30 +11343,33 @@ "gcp-us-east1", "gcp-us-east4" ], - "description": "Deployment region (prefixed with cloud provider, e.g., aws-us-east, gcp-us-central1)" + "type": "string" }, "replicas": { - "type": "integer", - "minimum": 0, - "maximum": 9, "default": 0, - "description": "Number of replicas (0 for single node, 2+ for high availability)" + "description": "Number of replicas (0 for single node, 2+ for high availability)", + "maximum": 9, + "minimum": 0, + "type": "integer" } }, - "type": "object", "required": [ "name", "cluster", "region" - ] + ], + "type": "object" }, "constraints": [], "created": null, "description": "Fully managed Postgres database on PlanetScale", "development": false, "group": null, + "id": "prvsvc_61ULLQ3GBvET6sI0q5Tbs", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, @@ -12708,44 +12707,44 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UGL5sd8z0WkMArr5Jlo", "provider_name": "PlanetScale", "scope": "project", "service_id": "postgresql", "updateable_to": [ "postgresql" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61ULB7Xy1qZ2E5vBW5WNc", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ "storage" ], "configuration_schema": { - "type": "object", "properties": { "name": { - "type": "string", - "description": "Bucket name (auto-generated if omitted)." + "description": "Bucket name (auto-generated if omitted).", + "type": "string" }, "region": { - "type": "string", - "description": "Storage region (e.g. sjc, iad, ams, sin)." + "description": "Storage region (e.g. sjc, iad, ams, sin).", + "type": "string" } - } + }, + "type": "object" }, "constraints": [], "created": null, "description": "S3-compatible object storage on Railway. Powered by Tigris.", "development": false, "group": null, + "id": "prvsvc_61ULB7Xy1qZ2E5vBW5WNc", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -12765,8 +12764,8 @@ "type": "freeform" }, "parent_services": [ - "hobby", - "pro" + "pro", + "hobby" ], "type": "paid" } @@ -12776,19 +12775,16 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UK2uWsoydL7tUBp57Fg", "provider_name": "Railway", "scope": "project", "service_id": "bucket", "updateable_to": [ "bucket" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61ULB7XqHFTQLur8o573o", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -12796,21 +12792,24 @@ "cache" ], "configuration_schema": { - "type": "object", "properties": { "region": { - "type": "string", - "description": "Deployment region (e.g. us-west1, us-east4)" + "description": "Deployment region (e.g. us-west1, us-east4)", + "type": "string" } - } + }, + "type": "object" }, "constraints": [], "created": null, "description": "Managed Redis cache on Railway.", "development": false, "group": null, + "id": "prvsvc_61ULB7XqHFTQLur8o573o", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -12830,8 +12829,8 @@ "type": "freeform" }, "parent_services": [ - "hobby", - "pro" + "pro", + "hobby" ], "type": "paid" } @@ -12841,27 +12840,24 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UK2uWsoydL7tUBp57Fg", "provider_name": "Railway", "scope": "project", "service_id": "redis", "updateable_to": [ "redis" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61ULB7XluUtojEeBf5C12", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "hobby" + "service": "pro" }, { "direction": "any", - "service": "pro" + "service": "hobby" } ], "availability": "available", @@ -12869,58 +12865,61 @@ "compute" ], "configuration_schema": { - "type": "object", - "properties": {} + "properties": {}, + "type": "object" }, "constraints": [], "created": null, "description": "Trial plan with limited resources. 500 hours of compute, 512 MB RAM, shared CPU, 1 GB disk.", "development": false, "group": null, + "id": "prvsvc_61ULB7XluUtojEeBf5C12", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UK2uWsoydL7tUBp57Fg", "provider_name": "Railway", "scope": "project", "service_id": "free", "updateable_to": [ - "hobby", "pro", + "hobby", "free" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61ULB7Xf9yQ57wRfG5Q00", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ "database" ], "configuration_schema": { - "type": "object", "properties": { "region": { - "type": "string", - "description": "Deployment region (e.g. us-west1, us-east4)" + "description": "Deployment region (e.g. us-west1, us-east4)", + "type": "string" } - } + }, + "type": "object" }, "constraints": [], "created": null, "description": "Managed PostgreSQL database on Railway.", "development": false, "group": null, + "id": "prvsvc_61ULB7Xf9yQ57wRfG5Q00", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -12940,8 +12939,8 @@ "type": "freeform" }, "parent_services": [ - "hobby", - "pro" + "pro", + "hobby" ], "type": "paid" } @@ -12951,35 +12950,35 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UK2uWsoydL7tUBp57Fg", "provider_name": "Railway", "scope": "project", "service_id": "postgres", "updateable_to": [ "postgres" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61ULB7XOaJzexg4yB58Bs", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ "compute" ], "configuration_schema": { - "type": "object", - "properties": {} + "properties": {}, + "type": "object" }, "constraints": [], "created": null, "description": "Deploy a GitHub repository (public or private) or Docker image on Railway.", "development": false, "group": null, + "id": "prvsvc_61ULB7XOaJzexg4yB58Bs", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -12999,8 +12998,8 @@ "type": "freeform" }, "parent_services": [ - "hobby", - "pro" + "pro", + "hobby" ], "type": "paid" } @@ -13010,40 +13009,40 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UK2uWsoydL7tUBp57Fg", "provider_name": "Railway", "scope": "project", "service_id": "hosting", "updateable_to": [ "hosting" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61ULB7XLDCNjUBBYJ53se", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ "database" ], "configuration_schema": { - "type": "object", "properties": { "region": { - "type": "string", - "description": "Deployment region (e.g. us-west1, us-east4)" + "description": "Deployment region (e.g. us-west1, us-east4)", + "type": "string" } - } + }, + "type": "object" }, "constraints": [], "created": null, "description": "Managed MongoDB database on Railway.", "development": false, "group": null, + "id": "prvsvc_61ULB7XLDCNjUBBYJ53se", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -13063,8 +13062,8 @@ "type": "freeform" }, "parent_services": [ - "hobby", - "pro" + "pro", + "hobby" ], "type": "paid" } @@ -13074,19 +13073,16 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UK2uWsoydL7tUBp57Fg", "provider_name": "Railway", "scope": "project", "service_id": "mongo", "updateable_to": [ "mongo" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61ULB7Txc7GGTYaCf5M6a", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -13104,8 +13100,11 @@ "description": "Runloop Pro — cloud sandboxes for production AI workloads at scale", "development": false, "group": null, + "id": "prvsvc_61ULB7Txc7GGTYaCf5M6a", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -13124,6 +13123,7 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UGhvzdmhefqNXre5WIS", "provider_name": "Runloop", "scope": "account", @@ -13131,13 +13131,9 @@ "updateable_to": [ "basic", "pro" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61ULB7TIyUnWXR2qG5QYS", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -13155,8 +13151,11 @@ "description": "Runloop Basic — cloud sandboxes with high concurrency and compute", "development": false, "group": null, + "id": "prvsvc_61ULB7TIyUnWXR2qG5QYS", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -13175,6 +13174,7 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UGhvzdmhefqNXre5WIS", "provider_name": "Runloop", "scope": "account", @@ -13182,13 +13182,9 @@ "updateable_to": [ "pro", "basic" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UJObtpuBpheXzuu5SJs", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -13196,25 +13192,28 @@ "ai" ], "configuration_schema": { - "required": [ - "name" - ], - "type": "object", "properties": { "name": { - "type": "string", "description": "Name for the database (defaults to 'agent'). Must be at least 3 characters, using only letters, numbers, underscores, and hyphens.", - "minLength": 3 + "minLength": 3, + "type": "string" } - } + }, + "required": [ + "name" + ], + "type": "object" }, "constraints": [], "created": null, "description": "A hosted search database for AI applications", "development": false, "group": null, + "id": "prvsvc_61UJObtpuBpheXzuu5SJs", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -13233,27 +13232,24 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UIblE5uGbHmAZLq5BgG", "provider_name": "Chroma", "scope": "project", "service_id": "database", "updateable_to": [ "database" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UIcgOwIZtReZLb35Se0", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "scaler" + "service": "developer_overages" }, { "direction": "any", - "service": "scaler_overages" + "service": "pro_overages" }, { "direction": "any", @@ -13261,11 +13257,11 @@ }, { "direction": "any", - "service": "pro_overages" + "service": "scaler_overages" }, { "direction": "any", - "service": "developer_overages" + "service": "scaler" } ], "availability": "available", @@ -13274,23 +13270,26 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "Unlimited DBs (500 active), 9 GB storage, 2.5B rows read, 25M rows written, 10 GB syncs, 10-day PITR", "development": false, "group": null, + "id": "prvsvc_61UIcgOwIZtReZLb35Se0", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -13309,32 +13308,29 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UGLtrGjcXK8rTq15RWq", "provider_name": "Turso", "scope": "account", "service_id": "developer", "updateable_to": [ - "scaler", - "scaler_overages", - "pro", - "pro_overages", "developer_overages", + "pro_overages", + "pro", + "scaler_overages", + "scaler", "developer" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UIcgOg0kcLibjjo5RVw", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "pro_overages" + "service": "pro" }, { "direction": "any", - "service": "pro" + "service": "pro_overages" } ], "availability": "available", @@ -13343,23 +13339,26 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "Unlimited DBs (2,500 active), 24 GB storage, 100B rows read, 100M rows written, 24 GB syncs, 30-day PITR", "development": false, "group": null, + "id": "prvsvc_61UIcgOg0kcLibjjo5RVw", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -13378,21 +13377,18 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UGLtrGjcXK8rTq15RWq", "provider_name": "Turso", "scope": "account", "service_id": "scaler_overages", "updateable_to": [ - "pro_overages", "pro", + "pro_overages", "scaler_overages" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UIcgOf7Wl3MCmSo5NWq", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", @@ -13405,23 +13401,26 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "Unlimited DBs (10,000 active), 50 GB storage, 250B rows read, 250M rows written, 100 GB syncs, 90-day PITR", "development": false, "group": null, + "id": "prvsvc_61UIcgOf7Wl3MCmSo5NWq", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -13440,6 +13439,7 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UGLtrGjcXK8rTq15RWq", "provider_name": "Turso", "scope": "account", @@ -13447,17 +13447,13 @@ "updateable_to": [ "pro_overages", "pro" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UIcgOYSp85TNnsK52jg", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "scaler_overages" + "service": "pro_overages" }, { "direction": "any", @@ -13465,7 +13461,7 @@ }, { "direction": "any", - "service": "pro_overages" + "service": "scaler_overages" } ], "availability": "available", @@ -13474,23 +13470,26 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "Unlimited DBs (2,500 active), 24 GB storage, 100B rows read, 100M rows written, 24 GB syncs, 30-day PITR", "development": false, "group": null, + "id": "prvsvc_61UIcgOYSp85TNnsK52jg", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -13509,22 +13508,19 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UGLtrGjcXK8rTq15RWq", "provider_name": "Turso", "scope": "account", "service_id": "scaler", "updateable_to": [ - "scaler_overages", - "pro", "pro_overages", + "pro", + "scaler_overages", "scaler" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UIcgOCvxFAl8M8w56UC", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -13560,8 +13556,11 @@ "description": "Free SQLite-based database that's always on: no cold starts. Instant response every time. Concurrent writes. Unlimited databases.", "development": false, "group": null, + "id": "prvsvc_61UIcgOCvxFAl8M8w56UC", "kind": "deployable", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": { "options": [ @@ -13569,13 +13568,13 @@ "is_default": null, "paid": null, "parent_services": [ - "pro", "scaler_overages", - "pro_overages", - "scaler", - "starter", + "pro", + "developer", "developer_overages", - "developer" + "starter", + "scaler", + "pro_overages" ], "type": "free" } @@ -13585,23 +13584,20 @@ "paid_pricing": [], "type": "component" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UGLtrGjcXK8rTq15RWq", "provider_name": "Turso", "scope": "project", "service_id": "database", "updateable_to": [ "database" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UIcgOBM5sB6En0J5VlY", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "pro" + "service": "scaler" }, { "direction": "any", @@ -13609,7 +13605,7 @@ }, { "direction": "any", - "service": "scaler" + "service": "pro" }, { "direction": "any", @@ -13622,23 +13618,26 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "Unlimited DBs (500 active), 9 GB storage, 2.5B rows read, 25M rows written, 10 GB syncs, 10-day PITR", "development": false, "group": null, + "id": "prvsvc_61UIcgOBM5sB6En0J5VlY", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -13657,23 +13656,20 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UGLtrGjcXK8rTq15RWq", "provider_name": "Turso", "scope": "account", "service_id": "developer_overages", "updateable_to": [ - "pro", - "pro_overages", "scaler", + "pro_overages", + "pro", "scaler_overages", "developer_overages" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UIcgO7u4yMbqBIx5Ofg", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [], "availability": "available", "categories": [ @@ -13681,23 +13677,26 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "Unlimited DBs (10,000 active), 50 GB storage, 250B rows read, 250M rows written, 100 GB syncs, 90-day PITR", "development": false, "group": null, + "id": "prvsvc_61UIcgO7u4yMbqBIx5Ofg", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": { @@ -13716,43 +13715,40 @@ ], "type": "paid" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UGLtrGjcXK8rTq15RWq", "provider_name": "Turso", "scope": "account", "service_id": "pro_overages", "updateable_to": [ "pro_overages" - ], - "livemode": true, - "provider_configuration_schema": {} + ] }, { - "id": "prvsvc_61UIcgO049jQQ8J1p5VUG", - "object": "v2.provisioning.provider_service_detail", "allowed_updates": [ { "direction": "any", - "service": "pro_overages" + "service": "developer" }, { "direction": "any", - "service": "developer" + "service": "pro_overages" }, { "direction": "any", - "service": "scaler_overages" + "service": "scaler" }, { "direction": "any", - "service": "pro" + "service": "developer_overages" }, { "direction": "any", - "service": "developer_overages" + "service": "pro" }, { "direction": "any", - "service": "scaler" + "service": "scaler_overages" } ], "availability": "available", @@ -13761,52 +13757,56 @@ ], "configuration_schema": {}, "constraints": [ - { - "mutual_exclusion_allowed_updates": true, - "type": "mutual_exclusion_allowed_updates" - }, { "count": { "at_most": 1 }, "type": "count" + }, + { + "mutual_exclusion_allowed_updates": true, + "type": "mutual_exclusion_allowed_updates" } ], "created": null, "description": "100 DBs, 5 GB storage, 500M rows read, 10M rows written, 3 GB syncs, 1-day PITR", "development": false, "group": null, + "id": "prvsvc_61UIcgO049jQQ8J1p5VUG", "kind": "plan", + "livemode": true, "llm_context": null, + "object": "v2.provisioning.provider_service_detail", "pricing": { "component": null, "paid": null, "paid_pricing": [], "type": "free" }, + "provider_configuration_schema": {}, "provider_id": "prvdr_61UGLtrGjcXK8rTq15RWq", "provider_name": "Turso", "scope": "account", "service_id": "starter", "updateable_to": [ - "pro_overages", "developer", - "scaler_overages", - "pro", - "developer_overages", + "pro_overages", "scaler", + "developer_overages", + "pro", + "scaler_overages", "starter" - ], - "livemode": true, - "provider_configuration_schema": {} + ] } ], - "source": "api" + "source": "cache" }, - "warnings": [], - "next_steps": [], "meta": { "authenticated": true, "project_initialized": false - } + }, + "next_steps": [], + "ok": true, + "version": "0.1", + "warnings": [] } diff --git a/crates/stackless-stripe-projects/tests/fixtures/command-surface.txt b/crates/stackless-stripe-projects/tests/fixtures/command-surface.txt new file mode 100644 index 0000000..96dd9f8 --- /dev/null +++ b/crates/stackless-stripe-projects/tests/fixtures/command-surface.txt @@ -0,0 +1,1192 @@ +# stripe projects command surface +# plugin-version: 0.19.0 +# DO NOT EDIT — regenerated by CI (mise run stripe-refresh) + +===== stripe projects --help ===== +Stripe Projects (v0.19.0) +Provision third-party services, manage credentials, and pull environment variables. + +GET STARTED + init [name] Initialize a new project + list List all projects you can access + pull [projectId] Pull an existing project into this folder + status View the current project, providers, and services + services list Show all services in your project + catalog [filter] Browse services (filter by provider or category) + search Search services by name, description, or category + switch-account Switch to a different Stripe account + +MANAGE SERVICES + add [service] Add a service to your project + update [service] + Update a service resource to another service in the same provider + upgrade [service] + Upgrade to paid tiers, plans, or add-ons + downgrade [service] + Downgrade to a lower tier or free plan + remove Remove a service resource + rotate Rotate credentials for a service resource + link Link an existing provider account + unlink Unlink a provider account from your project + open Deep link to provider dashboard (if supported) + +ENVIRONMENT + env Manage environment variables and configuration + env list List all project environments + env show Show the active project environment + env create Create a project environment; requires --output + env use Make a project environment active + env update Rename the active environment or change its output file + env delete Delete a project environment + env add Add an existing resource to the active environment + env remove Remove resource membership from the active environment + llm-context List provider guidance URLs for AI-assisted development + +BILLING + billing show View your current payment details + billing add Add or update your billing method + spend View charges on your account + +FEEDBACK + feedback Send feedback about Stripe Projects + +FLAGS + -v, --version Show the current plugin version + --accept-tos Accept provider terms of service without prompting + --confirm-paid-service Confirm willingness to provision a paid service (required in non-interactive mode) + --json Output structured JSON and suppress interactive prompts (ideal for scripting and agents) + -y, --yes Skip confirmation prompts (required for non-interactive destructive commands) + --interactive Allow interactive prompts (disable with --non-interactive for scripting and agent use) + --stream Enable streaming output animations + --debug Enable debug logging for Stripe API requests + +EXAMPLES + + Full flow — initialize and provision a new project: + $ stripe projects init + $ stripe projects catalog + $ stripe projects search + $ stripe projects add / + $ stripe projects env --pull + + Non-interactive / agent usage (scripting, CI, AI agents): + $ stripe projects add / --json --yes provision without prompts + $ stripe projects status --json get project state as JSON + $ stripe projects remove --json --yes remove without confirmation + $ stripe projects env --json list env vars as JSON + +===== stripe projects init --help ===== +stripe projects init [name] + +Initialize a new project + +Positionals: + name Project name (defaults to the current directory name) [string] + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + --skip-skills Skip creating AI agent skill files (.agents/, + .claude/, .cursor/, .cursorignore, AGENTS.md, + CLAUDE.md) [boolean] + --from Import a shared stack after initialization (URL + from `stripe projects share`) [string] + +===== stripe projects list --help ===== +stripe projects list + +List all projects you can access + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + +===== stripe projects pull --help ===== +stripe projects pull [projectId] + +Pull an existing project into this folder + +Positionals: + projectId Existing project ID to pull into this folder [string] + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + --skip-skills Skip creating AI agent skill files (.agents/, + .claude/, .cursor/, .cursorignore, AGENTS.md, + CLAUDE.md) [boolean] + +===== stripe projects status --help ===== +stripe projects status + +View the current project, providers, and services + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + +===== stripe projects services --help ===== +stripe projects services + +Show all services in your project + +Commands: + stripe projects services list Show all services in your project + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + +===== stripe projects services list --help ===== +stripe projects services list + +Show all services in your project + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + +===== stripe projects catalog --help ===== +stripe projects catalog [filter] + +Browse services (filter by provider or category) + +Positionals: + filter Category or provider name [string] + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + --refresh Refresh the catalog cache before listing [boolean] + +Examples: + stripe projects catalog List all services grouped by category + stripe projects catalog database Show all services in one category + stripe projects catalog hostingco Show everything one provider offers + +===== stripe projects search --help ===== +stripe projects search + +Search services by name, description, or category + +Positionals: + query Search query [array] [required] [default: []] + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + --refresh Refresh the catalog cache before searching + [boolean] + +Examples: + stripe projects search postgres Find database services related to postgres + stripe projects search hosting Find services by category or provider + +===== stripe projects switch-account --help ===== +stripe projects switch-account + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + +===== stripe projects add --help ===== +stripe projects add [service] + +Positionals: + service Provider/service, bare provider, or @category (e.g., + databaseco/postgres, databaseco, @database) [string] + +Options: + --color turn on/off color output (on, off, auto) [string] + --config Service configuration as a JSON string [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without + prompting [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents)[boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + --name Logical resource name used for local resource + references and environment variable prefixes + [string] + --provider-config Provider configuration as a JSON string for + provider linking [string] + --provider-info Additional information required by the provider + as a JSON string (used when the provider requests + extra details during linking) [string] + --resource-info Additional information required to complete + provisioning as a JSON string [string] + --resource-id Existing provisioning resource ID to resume when + supplying --resource-info in non-interactive + workflows [string] + --existing Link an existing resource instead of provisioning + a new one [boolean] + --force-provider-relink Force a fresh provider link request before + provisioning [boolean] + --parent Enable automatic parent service detection for + component services [boolean] + +Examples: + stripe projects add databaseco/postgres Provision a named service resource + --name primary-db + stripe projects add databaseco/postgres Provision a service with JSON + --config '{"region":"iad1"}' configuration + stripe projects add databaseco/postgres Provision a service while supplying + --provider-config '{"region":"US"}' provider link configuration + stripe projects add databaseco Choose one of a provider’s services + interactively + stripe projects add @database Choose a service from a category + across providers + +===== stripe projects update --help ===== +stripe projects update [service] + +Positionals: + service_reference Existing local resource name or unique provider/service + reference [string] [required] + service New same-provider service id, provider/service, bare + provider, or @category [string] + +Options: + --color turn on/off color output (on, off, auto) [string] + --config Service configuration as a JSON string [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + +Examples: + stripe projects update free pro Switch a resource to another service + in the same provider + stripe projects update primary-db Choose a replacement service from a + @database category within the current provider + +===== stripe projects upgrade --help ===== +stripe projects upgrade [service] + +Positionals: + service_reference Existing local resource name or unique provider/service + reference [string] [required] + service New same-provider service id, provider/service, bare + provider, or @category [string] + +Options: + --color turn on/off color output (on, off, auto) [string] + --config Service configuration as a JSON string [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + +Examples: + stripe projects upgrade free pro Switch a resource to another service + in the same provider + stripe projects upgrade primary-db Choose a replacement service from a + @database category within the current provider + +===== stripe projects downgrade --help ===== +stripe projects downgrade [service] + +Positionals: + service_reference Existing local resource name or unique provider/service + reference [string] [required] + service New same-provider service id, provider/service, bare + provider, or @category [string] + +Options: + --color turn on/off color output (on, off, auto) [string] + --config Service configuration as a JSON string [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + +Examples: + stripe projects downgrade free pro Switch a resource to another service + in the same provider + stripe projects downgrade primary-db Choose a replacement service from a + @database category within the current provider + +===== stripe projects remove --help ===== +stripe projects remove + +Positionals: + resource Resource name or unique provider/service reference + [string] [required] + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + --force Remove even when dependent resources are still + deployed [boolean] + --only-credentials Unlink credentials for the resource instead of + deprovisioning it [boolean] + +Examples: + stripe projects remove primary-db Remove a named resource after + confirmation + stripe projects remove primary-db --yes Skip the confirmation prompt + stripe projects remove primary-db Forget the local resource after + --only-credentials --yes unlinking its credentials + stripe projects remove Remove a service when that + hostingco/postgres --yes provider/service matches exactly one + local resource + stripe projects remove shared-plan --yes Remove a parent resource even when + --force dependent resources are still + deployed + +===== stripe projects rotate --help ===== +stripe projects rotate + +Positionals: + resource Resource name or unique provider/service reference + [string] [required] + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + +Examples: + stripe projects rotate primary-db Rotate credentials for a named + resource + stripe projects rotate Rotate credentials when that + databaseco/postgres provider/service matches exactly one + local resource + +===== stripe projects link --help ===== +stripe projects link + +Positionals: + provider Provider name [string] [required] + +Options: + --color turn on/off color output (on, off, auto) [string] + --config Provider configuration as a JSON string [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + --force Force a fresh provider re-link request [boolean] + --provider-info Additional information required by the provider as + a JSON string (used when the provider requests + extra details during linking) [string] + +Examples: + stripe projects link hostingco Link one provider account + stripe projects link databaseco --config Link a provider with JSON + '{"region":"US"}' configuration + stripe projects link databaseco Provide additional information + --provider-info '{"org_id":"abc123"}' requested by the provider + +===== stripe projects unlink --help ===== +stripe projects unlink + +Positionals: + provider Provider name [string] [required] + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + +Examples: + stripe projects unlink databaseco Unlink a provider account after + confirmation + stripe projects unlink databaseco --yes Skip the confirmation prompt and + unlink a provider account + +===== stripe projects open --help ===== +stripe projects open + +Positionals: + provider Provider name [string] [required] + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + +Examples: + stripe projects open databaseco Open a provider dashboard link in your + browser + +===== stripe projects env --help ===== +stripe projects env + +Manage environment variables and configuration + +Commands: + stripe projects env list List all project environments + stripe projects env show Show the active project environment + stripe projects env create Create a project environment; + requires --output + stripe projects env use Make a project environment active + stripe projects env update Rename the active environment or + change its output file + stripe projects env delete Delete a project environment + stripe projects env add Add an existing resource to the + active environment + stripe projects env remove Remove resource membership from the + active environment + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + --provider Show variables from only this provider [string] + --refresh Fetch the latest environment variables and refresh + the local cache [boolean] + --pull Write the latest environment variables to local + files [boolean] + --combined Compatibility flag; combined credentials are + always written to .env [boolean] + --service Show variables from only this provider/service + reference [string] + +Examples: + stripe projects env List environment variables with + redacted values + stripe projects env --service Show redacted values for one service + databaseco/postgres + stripe projects env --refresh Refresh the local environment cache + from Stripe first + stripe projects env --pull Write the latest environment + variables to local files + stripe projects env list List all project environments + stripe projects env show Show the active project environment + stripe projects env create dev --output Create a project environment + .env.dev + stripe projects env update --output Update the active project + .env.dev environment + stripe projects env delete dev Delete a project environment + stripe projects env add primary-db Add an existing resource to the + active environment + +===== stripe projects env list --help ===== +stripe projects env list + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + --provider Show variables from only this provider [string] + --refresh Fetch the latest environment variables and refresh + the local cache [boolean] + --pull Write the latest environment variables to local + files [boolean] + --combined Compatibility flag; combined credentials are + always written to .env [boolean] + --service Show variables from only this provider/service + reference [string] + +===== stripe projects env show --help ===== +stripe projects env show + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + --provider Show variables from only this provider [string] + --refresh Fetch the latest environment variables and refresh + the local cache [boolean] + --pull Write the latest environment variables to local + files [boolean] + --combined Compatibility flag; combined credentials are + always written to .env [boolean] + --service Show variables from only this provider/service + reference [string] + +===== stripe projects env create --help ===== +stripe projects env create --output + +Positionals: + environment Environment name [string] [required] + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + --provider Show variables from only this provider [string] + --refresh Fetch the latest environment variables and refresh + the local cache [boolean] + --pull Write the latest environment variables to local + files [boolean] + --combined Compatibility flag; combined credentials are + always written to .env [boolean] + --service Show variables from only this provider/service + reference [string] + --output Root env file for this environment, for example + .env.dev [string] [required] + +===== stripe projects env use --help ===== +stripe projects env use + +Positionals: + environment Environment name [string] [required] + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + --provider Show variables from only this provider [string] + --refresh Fetch the latest environment variables and refresh + the local cache [boolean] + --pull Write the latest environment variables to local + files [boolean] + --combined Compatibility flag; combined credentials are + always written to .env [boolean] + --service Show variables from only this provider/service + reference [string] + +===== stripe projects env update --help ===== +stripe projects env update [--name ] [--output ] + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + --provider Show variables from only this provider [string] + --refresh Fetch the latest environment variables and refresh + the local cache [boolean] + --pull Write the latest environment variables to local + files [boolean] + --combined Compatibility flag; combined credentials are + always written to .env [boolean] + --service Show variables from only this provider/service + reference [string] + --name New active environment name [string] + --output New root env file for the active environment + [string] + +===== stripe projects env delete --help ===== +stripe projects env delete + +Positionals: + environment Environment name [string] [required] + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + --provider Show variables from only this provider [string] + --refresh Fetch the latest environment variables and refresh + the local cache [boolean] + --pull Write the latest environment variables to local + files [boolean] + --combined Compatibility flag; combined credentials are + always written to .env [boolean] + --service Show variables from only this provider/service + reference [string] + +===== stripe projects env add --help ===== +stripe projects env add + +Positionals: + resource Resource name or unique provider/service reference + [string] [required] + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + --provider Show variables from only this provider [string] + --refresh Fetch the latest environment variables and refresh + the local cache [boolean] + --pull Write the latest environment variables to local + files [boolean] + --combined Compatibility flag; combined credentials are + always written to .env [boolean] + --service Show variables from only this provider/service + reference [string] + +===== stripe projects env remove --help ===== +stripe projects env remove + +Positionals: + resource Resource name or unique provider/service reference + [string] [required] + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + --provider Show variables from only this provider [string] + --refresh Fetch the latest environment variables and refresh + the local cache [boolean] + --pull Write the latest environment variables to local + files [boolean] + --combined Compatibility flag; combined credentials are + always written to .env [boolean] + --service Show variables from only this provider/service + reference [string] + +===== stripe projects llm-context --help ===== +stripe projects llm-context + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + --fetch Fetch URL-based provider guidance and print the + contents inline [boolean] + --provider Filter by provider name [string] + --refresh Refresh the catalog cache before listing [boolean] + +Examples: + stripe projects llm-context Print guidance for active project + providers + stripe projects llm-context --provider Print one provider guidance URL + chroma + stripe projects llm-context --fetch Fetch active provider guidance and + print it inline + +===== stripe projects billing --help ===== +stripe projects billing + +Manage billing for your Stripe projects account + +Commands: + stripe projects billing show View your current payment details + stripe projects billing add Add or update your billing method + stripe projects billing update Update your billing method, global or + per-provider spending limits + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + +Examples: + stripe projects billing show View the current billing method + stripe projects billing add Create a browser session to add or + update billing + stripe projects billing update --limit Set a per-provider spending limit + 50 --provider neon + +===== stripe projects billing show --help ===== +stripe projects billing show + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + +===== stripe projects billing add --help ===== +stripe projects billing add + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + +Examples: + stripe projects billing add Create a checkout session to add or update your + billing method + +===== stripe projects billing update --help ===== +stripe projects billing update [--limit ] [--provider ] + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + --limit Set the monthly spending limit in USD (e.g., 100 + for $100) [number] + --provider Apply the limit to a specific provider instead of + globally [string] + +Examples: + stripe projects billing update Interactively update billing or + spending limit + stripe projects billing update --limit Set global spending limit to + 250 $250/month + stripe projects billing update --limit Set spending limit for Neon to + 50 --provider neon $50/month + +===== stripe projects spend --help ===== +stripe projects spend + +View charges on your account + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] + +===== stripe projects feedback --help ===== +stripe projects feedback + +Send feedback about Stripe Projects + +Options: + --color turn on/off color output (on, off, auto) [string] + -h, --help Show help [boolean] + -v, --version Show the current plugin version [boolean] + --accept-tos Accept provider terms of service without prompting + [boolean] + --confirm-paid-service Confirm willingness to provision a paid service + (required in non-interactive mode) [boolean] + --json Output structured JSON and suppress interactive + prompts (ideal for scripting and agents) [boolean] + -y, --yes Skip confirmation prompts (required for + non-interactive destructive commands) [boolean] + --interactive Allow interactive prompts (disable with + --non-interactive for scripting and agent use) + [boolean] + --stream Enable streaming output animations [boolean] + --debug Enable debug logging for Stripe API requests + [boolean] diff --git a/crates/stackless-stripe-projects/tests/fixtures/plugin-version.txt b/crates/stackless-stripe-projects/tests/fixtures/plugin-version.txt new file mode 100644 index 0000000..1cf0537 --- /dev/null +++ b/crates/stackless-stripe-projects/tests/fixtures/plugin-version.txt @@ -0,0 +1 @@ +0.19.0 diff --git a/docs/SELFTEST.md b/docs/SELFTEST.md index 88836a2..f41327e 100644 --- a/docs/SELFTEST.md +++ b/docs/SELFTEST.md @@ -56,6 +56,45 @@ job per provider, secrets `STRIPE_API_KEY` / `VERCEL_TOKEN` / `RENDER_API_KEY`. 3. Drop a `fixtures/smoke//stackless.toml` that deploys `fixtures/smoke/site`. 4. Add a `mise run smoke-` task and a matrix entry in `smoke.yml`. +## Stripe Projects plugin snapshots (versioned, auto-watched) + +The `stripe projects` plugin is versioned independently of the Stripe CLI, ships +no changelog, and its catalog is server-side. Three committed artifacts under +`crates/stackless-stripe-projects/tests/fixtures/` make upgrades reproducible and +turn their `git diff` into the changelog Stripe never publishes: + +- `plugin-version.txt` — the pinned plugin version (the version of record; CI + installs exactly this). +- `command-surface.txt` — every `stripe projects` subcommand's `--help`, so + added/removed/renamed commands and flags show up as a line diff. +- `catalog.json` — the provider catalog (services, schemas, pricing); the typed + model in `src/catalog.rs` must fully cover it. + +All three are regenerated by one bless path — the `refresh_blesses_snapshots` +test, gated on `STRIPE_PROJECTS_REFRESH=1`, which refuses to write if the live +catalog has wire-format the model does not cover. It is never run by hand; the +infrastructure drives it: + +- **Hermetic (every PR):** `fixtures_are_coherent` (runs in `mise run test`) + checks the artifacts agree offline — surface header == pinned version, blocks + == the code's `TRAVERSAL`, every banner command is captured — plus the local + `prek` pre-commit/pre-push hooks (auto-wired by `mise install`). +- **Live pinned gate (nightly smoke):** `smoke.yml` installs the pinned plugin, + re-blesses, and fails if `command-surface.txt` / `plugin-version.txt` drift or + the live catalog is unmodeled. +- **Watcher → auto-PR (nightly):** `stripe-projects-watch.yml` installs the + *latest* plugin and opens a PR with the regenerated fixtures whenever upstream + changes — the only human step is reviewing it. + +To upgrade by hand: + +``` +stripe plugin install projects@ # pin +mise run stripe-refresh # re-bless the three artifacts +git diff crates/stackless-stripe-projects/tests/fixtures/ # the changelog +# if the bless fails, add the new variant/field to src/catalog.rs and re-run +``` + ## Vercel notes (hard-won, verified live) - Stripe provisions Vercel projects in its **own managed team**; use the diff --git a/mise.toml b/mise.toml index 159aee7..c6445de 100644 --- a/mise.toml +++ b/mise.toml @@ -6,6 +6,7 @@ taplo = "latest" "cargo:cargo-deny" = "latest" "cargo:cargo-vet" = "latest" "cargo:cargo-dist" = "latest" +prek = "latest" # pre-commit runner; [hooks] below auto-wires it [tasks] # Formatting & lint (keep clippy/fmt wired even if not "crap" tools) @@ -45,5 +46,28 @@ exit $(( up != 0 || down != 0 )) ''' smoke = { depends = ["smoke-vercel", "smoke-render"] } +# Stripe Projects plugin snapshots. `stripe-refresh` is the bless path (writes +# the committed fixtures from the locally installed plugin); it needs the real +# `stripe` CLI + creds, so it is EXCLUDED from `ci`/`check`/`test` and is invoked +# by the CI watcher, not by hand. `stripe-coherence` is the fast, offline +# pre-commit check (it also runs inside `mise run test`). +stripe-refresh = ''' +[ -f .stackless.env ] && { set -a; . ./.stackless.env; set +a; } +STRIPE_PROJECTS_REFRESH=1 cargo nextest run -p stackless-stripe-projects --all-features -E 'test(refresh_blesses_snapshots)' --no-capture +''' +stripe-coherence = "cargo nextest run -p stackless-stripe-projects -E 'test(fixtures_are_coherent)'" +# Regenerate the vendored OpenAPI client crates (body stays shell — python + +# cargo-progenitor glue with no Rust types to reuse). +regen-clients = "bash specs/regen-clients.sh" +# Install the prek git hooks by hand (normally auto-wired by [hooks].postinstall). +hooks = "prek install -t pre-commit -t pre-push -f" + +# Auto-wire the prek git hooks after `mise install` — the bootstrap devs and CI +# already run — so the pre-commit/pre-push gates need no separate setup step. +# Best-effort: a hiccup here must never fail `mise install`. +[hooks] +postinstall = "prek install -t pre-commit -t pre-push -f || true" + [settings] -# optional: idiomatic for Rust projects +# Required for [hooks] (auto-installing the prek git hooks above). +experimental = true From 57d2b36276420db1a14c50558611d809eb7bdb30 Mon Sep 17 00:00:00 2001 From: Michael Assaf Date: Tue, 16 Jun 2026 16:20:12 -0400 Subject: [PATCH 2/2] feat(providers): registries, Cloudflare resources, and onboarding tooling Make adding a Stripe-Projects-backed provider a one-registration-site change, prove it by adding Cloudflare, and build tooling so the next provider is frictionless. Foundation (core never names a provider): - Replace the `Host` enum with a binary-owned substrate registry (`stackless/src/substrates.rs`); core takes the known-substrate list as input. - Fold `spend_line`/`fetch_logs` into the `Substrate` trait (fixes Vercel inheriting bogus local-file logs); move `RENDER_*`/`VERCEL_*` error codes into the provider crates with a workspace-wide uniqueness test. - Registry-driven integration dispatch via a `ProviderOps` object (no more `match provider` / `is_clerk_resource`). - New `stackless-cloud` crate: shared credential resolution + operator prepare (de-duplicates the verbatim render/vercel copies). Generalized resolution + Cloudflare: - Generic `CatalogResource` trait + shared `provision_outputs` (handles Stripe's dual `{RESOURCE}_X` / `{PROVIDER}_X` env-var naming); Clerk's single-blob path stays the documented exception. - Cloudflare catalog integrations: r2, kv, d1, queues, hyperdrive, workers, workers-ai, browser-run (live-pinned envelopes; containers/registrar excluded as paid/unknown-cost / non-disposable). Onboarding tooling + tests: - New `xtask` crate: `catalog` (offline schema explorer), `discover` (live output-envelope pinning), `new-integration` (scaffold from catalog schema). - Shared `test_support::provision_script` harness + a registry-completeness test. - One shared `fixtures/smoke/run.sh` (always tears down, unique per-run names), fixing the latent vercel/render down-skip-on-failure bug. - Docs: `docs/ADDING-A-PROVIDER.md` (workflow + gotchas) and a root `CLAUDE.md` indexing the project docs. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/smoke.yml | 4 +- CLAUDE.md | 32 ++ Cargo.lock | 21 ++ Cargo.toml | 3 + crates/stackless-cloud/Cargo.toml | 17 + crates/stackless-cloud/src/credential.rs | 87 +++++ crates/stackless-cloud/src/lib.rs | 12 + crates/stackless-cloud/src/prepare.rs | 86 +++++ crates/stackless-core/src/def/model.rs | 31 +- crates/stackless-core/src/def/validate.rs | 21 +- crates/stackless-core/src/fault.rs | 44 +-- crates/stackless-core/src/host.rs | 57 --- crates/stackless-core/src/lib.rs | 1 - crates/stackless-core/src/substrate.rs | 34 ++ crates/stackless-core/tests/def.rs | 10 +- crates/stackless-integrations/src/error.rs | 3 +- crates/stackless-integrations/src/hostable.rs | 23 +- crates/stackless-integrations/src/lib.rs | 100 ++++-- .../src/providers/clerk.rs | 56 ++- .../src/providers/cloudflare/browser_run.rs | 165 +++++++++ .../src/providers/cloudflare/d1.rs | 166 +++++++++ .../src/providers/cloudflare/hyperdrive.rs | 235 +++++++++++++ .../src/providers/cloudflare/kv.rs | 165 +++++++++ .../src/providers/cloudflare/mod.rs | 29 ++ .../src/providers/cloudflare/queues.rs | 177 ++++++++++ .../src/providers/cloudflare/r2.rs | 250 ++++++++++++++ .../src/providers/cloudflare/workers.rs | 174 ++++++++++ .../src/providers/cloudflare/workers_ai.rs | 166 +++++++++ .../src/providers/mod.rs | 1 + crates/stackless-integrations/src/registry.rs | 167 ++++++--- crates/stackless-integrations/src/resource.rs | 255 ++++++++++++++ crates/stackless-local/src/lib.rs | 30 +- crates/stackless-render/Cargo.toml | 1 + crates/stackless-render/src/api_key.rs | 41 +-- crates/stackless-render/src/codes.rs | 30 ++ crates/stackless-render/src/config.rs | 8 +- crates/stackless-render/src/error.rs | 6 +- crates/stackless-render/src/lib.rs | 36 +- crates/stackless-render/src/prepare.rs | 74 +--- crates/stackless-render/tests/render_api.rs | 6 +- .../src/provision.rs | 109 ++++++ .../stackless-stripe-projects/src/stripe.rs | 10 + .../src/test_support.rs | 27 ++ crates/stackless-vercel/Cargo.toml | 1 + crates/stackless-vercel/src/api_key.rs | 41 +-- crates/stackless-vercel/src/codes.rs | 30 ++ crates/stackless-vercel/src/config.rs | 5 +- crates/stackless-vercel/src/error.rs | 6 +- crates/stackless-vercel/src/lib.rs | 5 + crates/stackless-vercel/src/prepare.rs | 72 +--- crates/stackless/src/commands.rs | 156 +++------ crates/stackless/src/main.rs | 10 +- crates/stackless/src/substrates.rs | 109 ++++++ crates/xtask/Cargo.toml | 18 + crates/xtask/src/main.rs | 325 ++++++++++++++++++ docs/ADDING-A-PROVIDER.md | 120 +++++++ docs/SELFTEST.md | 17 +- fixtures/smoke/cloudflare/.gitignore | 15 + fixtures/smoke/cloudflare/stackless.toml | 36 ++ fixtures/smoke/render/.gitignore | 7 + fixtures/smoke/run.sh | 31 ++ fixtures/smoke/vercel/.gitignore | 7 + mise.toml | 38 +- 63 files changed, 3454 insertions(+), 565 deletions(-) create mode 100644 CLAUDE.md create mode 100644 crates/stackless-cloud/Cargo.toml create mode 100644 crates/stackless-cloud/src/credential.rs create mode 100644 crates/stackless-cloud/src/lib.rs create mode 100644 crates/stackless-cloud/src/prepare.rs delete mode 100644 crates/stackless-core/src/host.rs create mode 100644 crates/stackless-integrations/src/providers/cloudflare/browser_run.rs create mode 100644 crates/stackless-integrations/src/providers/cloudflare/d1.rs create mode 100644 crates/stackless-integrations/src/providers/cloudflare/hyperdrive.rs create mode 100644 crates/stackless-integrations/src/providers/cloudflare/kv.rs create mode 100644 crates/stackless-integrations/src/providers/cloudflare/mod.rs create mode 100644 crates/stackless-integrations/src/providers/cloudflare/queues.rs create mode 100644 crates/stackless-integrations/src/providers/cloudflare/r2.rs create mode 100644 crates/stackless-integrations/src/providers/cloudflare/workers.rs create mode 100644 crates/stackless-integrations/src/providers/cloudflare/workers_ai.rs create mode 100644 crates/stackless-integrations/src/resource.rs create mode 100644 crates/stackless-render/src/codes.rs create mode 100644 crates/stackless-vercel/src/codes.rs create mode 100644 crates/stackless/src/substrates.rs create mode 100644 crates/xtask/Cargo.toml create mode 100644 crates/xtask/src/main.rs create mode 100644 docs/ADDING-A-PROVIDER.md create mode 100644 fixtures/smoke/cloudflare/.gitignore create mode 100644 fixtures/smoke/cloudflare/stackless.toml create mode 100644 fixtures/smoke/render/.gitignore create mode 100644 fixtures/smoke/run.sh create mode 100644 fixtures/smoke/vercel/.gitignore diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 824d6e4..7319d02 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -8,7 +8,7 @@ name: Live smoke # # Required repo secrets: # STRIPE_API_KEY — a Stripe secret key for the account whose Stripe Project has -# the providers linked (`stripe projects link vercel|render`, +# the providers linked (`stripe projects link vercel|render|cloudflare`, # a one-time human step — provider links are account-level). # VERCEL_TOKEN, RENDER_API_KEY — provider API creds (Vercel is also resolved from # the Stripe-managed instance env, but the var is harmless). @@ -32,7 +32,7 @@ jobs: strategy: fail-fast: false matrix: - provider: [vercel, render] + provider: [vercel, render, cloudflare] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4b6fb30 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,32 @@ +# stackless + +Disposable software stacks: named, leased, isolated, proven, accounted for, +destroyed. A Rust workspace (edition 2024); `crates/stackless` is the CLI. + +## Project docs — read the relevant one before working in that area + +- **[ARCHITECTURE.md](ARCHITECTURE.md)** — the architecture in numbered sections; + code comments cite it as `§N`. Read before touching core / engine / the + `Substrate` & integration seams. +- **[VISION.md](VISION.md)** — founding vision: what stackless is and why. +- **[README.md](README.md)** — overview + quickstart. +- **[docs/SCHEMA.md](docs/SCHEMA.md)** — the complete `stackless.toml` schema + reference (what the parser/validator actually enforce). +- **[docs/SELFTEST.md](docs/SELFTEST.md)** — the two-tier testing strategy + (hermetic + gated live smoke) and the Stripe Projects plugin snapshot/drift + framework. Read before changing tests or the smoke setup. +- **[docs/ADDING-A-PROVIDER.md](docs/ADDING-A-PROVIDER.md)** — how to add a + hosting substrate or a catalog integration: the one-row registration seams, + the onboarding tooling below, and the hard-won gotchas. Read before adding or + changing any provider. + +## Provider-onboarding tooling (the `xtask` crate; see ADDING-A-PROVIDER.md) + +- `mise run catalog ` — list a provider's catalog services + config + schemas + pricing (offline). +- `mise run discover -- --dir ` — provision a resource + once into a throwaway environment, dump its real credential output env vars, + then tear down (live; needs `STRIPE_API_KEY` + a linked project). The catalog + describes config *input* but not credential *outputs* — this pins them. +- `mise run new-integration ` — scaffold a provider module from the + catalog schema. diff --git a/Cargo.lock b/Cargo.lock index 13c5171..7eb8946 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2743,6 +2743,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "stackless-cloud" +version = "0.1.0" +dependencies = [ + "stackless-core", + "stackless-git", + "tempfile", +] + [[package]] name = "stackless-core" version = "0.1.0" @@ -2835,6 +2844,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "stackless-cloud", "stackless-core", "stackless-git", "stackless-integrations", @@ -2871,6 +2881,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "stackless-cloud", "stackless-core", "stackless-git", "stackless-integrations", @@ -4023,6 +4034,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "clap", + "serde_json", + "stackless-stripe-projects", + "tokio", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index b84c765..3cb97e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "3" members = [ "crates/stackless-core", + "crates/stackless-cloud", "crates/stackless-daemon", "crates/stackless-integrations", "crates/stackless-local", @@ -12,6 +13,7 @@ members = [ "crates/stackless", "crates/render-client", "crates/vercel-client", + "crates/xtask", ] [workspace.package] @@ -32,6 +34,7 @@ dbg_macro = "deny" [workspace.dependencies] stackless-core = { path = "crates/stackless-core" } +stackless-cloud = { path = "crates/stackless-cloud" } stackless-daemon = { path = "crates/stackless-daemon" } stackless-local = { path = "crates/stackless-local" } stackless-git = { path = "crates/stackless-git" } diff --git a/crates/stackless-cloud/Cargo.toml b/crates/stackless-cloud/Cargo.toml new file mode 100644 index 0000000..ade1546 --- /dev/null +++ b/crates/stackless-cloud/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "stackless-cloud" +edition.workspace = true +version.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +stackless-core.workspace = true +stackless-git.workspace = true +tempfile = "3.27.0" + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/crates/stackless-cloud/src/credential.rs b/crates/stackless-cloud/src/credential.rs new file mode 100644 index 0000000..7e772e0 --- /dev/null +++ b/crates/stackless-cloud/src/credential.rs @@ -0,0 +1,87 @@ +//! Shared cloud credential resolution (§4): env var → resolved secrets → +//! scoped key file. Each cloud substrate names its own env var and key file and +//! maps the neutral [`CredentialMissing`] to its own fault, so per-provider +//! error codes and remediation stay distinct (ARCHITECTURE.md §2). + +use std::collections::BTreeMap; +use std::path::Path; + +/// No credential in any source. The provider maps this to its own +/// `ApiKeyMissing`-style fault (whose remediation names the provider). +#[derive(Debug, Clone)] +pub struct CredentialMissing { + pub env_var: String, + pub key_file: String, +} + +/// Resolve a credential from the environment, the resolved secrets, or a scoped +/// key file next to the definition. The env var wins so CI can inject without a +/// file; the secrets map is the project's canonical store; the file is a scoped +/// fallback. +pub fn resolve( + env_var: &str, + key_file_name: &str, + definition_dir: &Path, + secrets: &BTreeMap, +) -> Result { + if let Ok(value) = std::env::var(env_var) { + let value = value.trim().to_owned(); + if !value.is_empty() { + return Ok(value); + } + } + if let Some(value) = secrets.get(env_var) { + let value = value.trim().to_owned(); + if !value.is_empty() { + return Ok(value); + } + } + let key_file = definition_dir.join(key_file_name); + if let Ok(contents) = std::fs::read_to_string(&key_file) { + let value = contents.trim().to_owned(); + if !value.is_empty() { + return Ok(value); + } + } + Err(CredentialMissing { + env_var: env_var.to_owned(), + key_file: key_file.display().to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ENV: &str = "STACKLESS_CLOUD_TEST_KEY"; + const FILE: &str = ".cloud-test-key"; + + fn secret(key: &str, value: &str) -> BTreeMap { + let mut map = BTreeMap::new(); + map.insert(key.to_owned(), value.to_owned()); + map + } + + #[test] + fn resolves_from_secret() { + let dir = tempfile::tempdir().unwrap(); + let key = resolve(ENV, FILE, dir.path(), &secret(ENV, "from_secrets")).unwrap(); + assert_eq!(key, "from_secrets"); + } + + #[test] + fn key_file_is_a_fallback_when_secret_absent() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join(FILE), "from_file\n").unwrap(); + let key = resolve(ENV, FILE, dir.path(), &BTreeMap::new()).unwrap(); + assert_eq!(key, "from_file"); + } + + #[test] + fn missing_everywhere_names_both_sources() { + let dir = tempfile::tempdir().unwrap(); + let err = resolve(ENV, FILE, dir.path(), &BTreeMap::new()).unwrap_err(); + assert_eq!(err.env_var, ENV); + assert!(err.key_file.ends_with(FILE)); + } +} diff --git a/crates/stackless-cloud/src/lib.rs b/crates/stackless-cloud/src/lib.rs new file mode 100644 index 0000000..3c1d77d --- /dev/null +++ b/crates/stackless-cloud/src/lib.rs @@ -0,0 +1,12 @@ +//! Shared scaffolding for cloud substrates (Render, Vercel, …). +//! +//! Only verified-identical machinery lives here — credential resolution and +//! operator-side prepare. Each provider keeps its own error type, codes, and +//! deploy/health semantics; the shared helpers return neutral data the provider +//! maps to its own fault, so per-provider error codes stay distinct +//! (ARCHITECTURE.md §2). Deploy/lifecycle is deliberately NOT abstracted here — +//! it differs materially between providers and has too few instances to +//! generalise safely. + +pub mod credential; +pub mod prepare; diff --git a/crates/stackless-cloud/src/prepare.rs b/crates/stackless-cloud/src/prepare.rs new file mode 100644 index 0000000..6f9ce76 --- /dev/null +++ b/crates/stackless-cloud/src/prepare.rs @@ -0,0 +1,86 @@ +//! Operator-side cloud prepare (§4): shallow git checkout + run a command on +//! the operator's machine. Cloud substrates call this and map the neutral +//! [`PrepareFailure`] to their own `PrepareFailed`-style fault. + +use std::process::Stdio; + +use stackless_core::fault::FAILURE_LOG_TAIL_LINES; + +/// A prepare step that failed, as neutral data the provider maps to its own +/// fault (preserving per-provider error codes and remediation). +#[derive(Debug, Clone)] +pub struct PrepareFailure { + pub service: String, + pub command: Option, + pub message: String, + pub log_tail: Option, +} + +/// Shallow-clone `repo@reference` into a temp dir, run `command` there with +/// `env`, and clean up. Any failure is returned as a [`PrepareFailure`]. +pub fn run_prepare_command( + service: &str, + repo: &str, + reference: &str, + command: &str, + env: &[(String, String)], +) -> Result<(), PrepareFailure> { + let tmp = tempdir().map_err(|message| PrepareFailure { + service: service.to_owned(), + command: Some(command.to_owned()), + message, + log_tail: None, + })?; + let result = (|| { + stackless_git::clone_checkout( + repo, + reference, + &tmp, + &stackless_git::Credentials::default(), + ) + .map_err(|err| PrepareFailure { + service: service.to_owned(), + command: Some(format!("clone --depth 1 --branch {reference} {repo}")), + message: format!("clone {repo}@{reference} failed: {err}"), + log_tail: None, + })?; + let mut cmd = std::process::Command::new("sh"); + cmd.arg("-c") + .arg(command) + .current_dir(&tmp) + .stdin(Stdio::null()); + for (key, value) in env { + cmd.env(key, value); + } + let output = cmd.output().map_err(|err| PrepareFailure { + service: service.to_owned(), + command: Some(command.to_owned()), + message: format!("could not run prepare command: {err}"), + log_tail: None, + })?; + if !output.status.success() { + return Err(PrepareFailure { + service: service.to_owned(), + command: Some(command.to_owned()), + message: format!("`{command}` exited {}", output.status), + log_tail: Some(tail_bytes(&output.stderr)), + }); + } + Ok(()) + })(); + let _ = std::fs::remove_dir_all(&tmp); + result +} + +fn tail_bytes(bytes: &[u8]) -> String { + let text = String::from_utf8_lossy(bytes); + let lines: Vec<&str> = text.lines().collect(); + let start = lines.len().saturating_sub(FAILURE_LOG_TAIL_LINES); + lines[start..].join("\n") +} + +fn tempdir() -> Result { + tempfile::tempdir() + .map(|dir| dir.keep()) + .map_err(|err| err.to_string()) +} diff --git a/crates/stackless-core/src/def/model.rs b/crates/stackless-core/src/def/model.rs index 796d3e4..c3f9857 100644 --- a/crates/stackless-core/src/def/model.rs +++ b/crates/stackless-core/src/def/model.rs @@ -12,7 +12,6 @@ use std::collections::BTreeMap; use serde::Deserialize; use super::error::DefError; -use crate::host::Host; use crate::types::{DnsName, HttpStatus}; /// Top level of `stackless.toml`. Unknown top-level sections are @@ -83,24 +82,28 @@ pub struct Integration { } impl Integration { - /// Config keys excluding registered host override tables. - pub fn config_fields(&self) -> BTreeMap { + /// Config keys excluding registered host override tables. `known_substrates` + /// names the keys that count as host overrides (substrate names), so they are + /// stripped from the provider's own config. + pub fn config_fields(&self, known_substrates: &[&str]) -> BTreeMap { self.fields .iter() - .filter(|(key, _)| !Host::is_host_key(key)) + .filter(|(key, _)| !known_substrates.contains(&key.as_str())) .map(|(key, value)| (key.clone(), value.clone())) .collect() } - pub fn host_block(&self, host: Host) -> Option<&toml::Table> { - self.fields - .get(host.as_str()) - .and_then(toml::Value::as_table) + pub fn host_block(&self, host: &str) -> Option<&toml::Table> { + self.fields.get(host).and_then(toml::Value::as_table) } /// Parent config merged with a host override table when present. - pub fn effective_config(&self, host: Host) -> BTreeMap { - let mut out = self.config_fields(); + pub fn effective_config( + &self, + host: &str, + known_substrates: &[&str], + ) -> BTreeMap { + let mut out = self.config_fields(known_substrates); if let Some(override_table) = self.host_block(host) { for (key, value) in override_table { out.insert(key.clone(), value.clone()); @@ -110,12 +113,14 @@ impl Integration { } /// Every host-key table nested under this integration. - pub fn host_blocks(&self) -> BTreeMap { + pub fn host_blocks(&self, known_substrates: &[&str]) -> BTreeMap { self.fields .iter() .filter_map(|(key, value)| { - let host = Host::parse(key)?; - Some((host, value.as_table()?)) + if !known_substrates.contains(&key.as_str()) { + return None; + } + Some((key.clone(), value.as_table()?)) }) .collect() } diff --git a/crates/stackless-core/src/def/validate.rs b/crates/stackless-core/src/def/validate.rs index f628cf5..665d4e9 100644 --- a/crates/stackless-core/src/def/validate.rs +++ b/crates/stackless-core/src/def/validate.rs @@ -10,19 +10,13 @@ use std::collections::BTreeMap; use super::error::DefError; use super::interp::{self, Reference}; use super::model::{Integration, Service, StackDef}; -use crate::host::Host; /// Engines with built-in readiness in v0 (ARCHITECTURE.md §7). const KNOWN_ENGINES: &[&str] = &["postgres"]; impl StackDef { - /// Validate the whole definition against the rules registered hosts share. - pub fn validate(&self) -> Result<(), DefError> { - let known_substrates: Vec<&str> = Host::ALL.iter().map(|host| host.as_str()).collect(); - validate_definition(self, &known_substrates) - } - - /// Validate against an explicit host list (tests and tooling only). + /// Validate the whole definition against the rules registered substrates share. + /// Callers pass the names of registered substrates (core knows none by name). pub fn validate_hosts(&self, known_substrates: &[&str]) -> Result<(), DefError> { validate_definition(self, known_substrates) } @@ -190,7 +184,7 @@ fn validate_integrations(def: &StackDef, known_substrates: &[&str]) -> Result<() }); } validate_integration_substrate_keys(name, integration, known_substrates)?; - validate_integration_string_refs(def, name, integration)?; + validate_integration_string_refs(def, name, integration, known_substrates)?; } Ok(()) } @@ -217,8 +211,9 @@ fn validate_integration_string_refs( def: &StackDef, name: &str, integration: &Integration, + known_substrates: &[&str], ) -> Result<(), DefError> { - for (key, value) in integration.config_fields() { + for (key, value) in integration.config_fields(known_substrates) { let Some(text) = value.as_str() else { continue; }; @@ -226,15 +221,15 @@ fn validate_integration_string_refs( let refs = interp::references(text, &location)?; validate_references(def, &refs, &location)?; } - for host in Host::ALL { - let Some(block) = integration.host_block(*host) else { + for substrate in known_substrates { + let Some(block) = integration.host_block(substrate) else { continue; }; for (key, value) in block { let Some(text) = value.as_str() else { continue; }; - let location = format!("integrations.{name}.{}.{}", host.as_str(), key); + let location = format!("integrations.{name}.{substrate}.{key}"); let refs = interp::references(text, &location)?; validate_references(def, &refs, &location)?; } diff --git a/crates/stackless-core/src/fault.rs b/crates/stackless-core/src/fault.rs index fa05508..dd98917 100644 --- a/crates/stackless-core/src/fault.rs +++ b/crates/stackless-core/src/fault.rs @@ -93,9 +93,6 @@ pub mod codes { pub const VERIFY_FAILED: &str = "verify.failed"; pub const VERIFY_NOT_DECLARED: &str = "verify.not_declared"; pub const VERIFY_SOURCE_UNAVAILABLE: &str = "verify.source_unavailable"; - pub const RENDER_CONFIG_INVALID: &str = "render.config.invalid"; - pub const RENDER_API_KEY_MISSING: &str = "render.api_key.missing"; - pub const RENDER_API_FAILED: &str = "render.api.failed"; pub const STRIPE_PROJECTS_UNAVAILABLE: &str = "stripe.projects.unavailable"; pub const STRIPE_PROJECTS_AUTH: &str = "stripe.projects.auth"; pub const STRIPE_PROJECTS_FAILED: &str = "stripe.projects.failed"; @@ -106,25 +103,10 @@ pub mod codes { pub const STRIPE_PROJECTS_CONFIG_SCHEMA: &str = "stripe.projects.config_schema"; pub const INTEGRATION_CONFIG_INVALID: &str = "integration.config.invalid"; pub const INTEGRATION_HOST_UNSUPPORTED: &str = "integration.host.unsupported"; - pub const RENDER_PAYMENT_NOT_CONFIRMED: &str = "render.payment.not_confirmed"; - pub const RENDER_PROVISION_FAILED: &str = "render.provision.failed"; - pub const RENDER_DEPLOY_FAILED: &str = "render.deploy.failed"; - pub const RENDER_DEPLOY_TIMEOUT: &str = "render.deploy.timeout"; - pub const RENDER_HEALTH_FAILED: &str = "render.health.failed"; - pub const RENDER_PREPARE_FAILED: &str = "render.prepare.failed"; - pub const RENDER_TEARDOWN_SURVIVOR: &str = "render.teardown.survivor"; - pub const VERCEL_CONFIG_INVALID: &str = "vercel.config.invalid"; - pub const VERCEL_API_KEY_MISSING: &str = "vercel.api_key.missing"; - pub const VERCEL_API_FAILED: &str = "vercel.api.failed"; - pub const VERCEL_PAYMENT_NOT_CONFIRMED: &str = "vercel.payment.not_confirmed"; - pub const VERCEL_PROVISION_FAILED: &str = "vercel.provision.failed"; - pub const VERCEL_DEPLOY_FAILED: &str = "vercel.deploy.failed"; - pub const VERCEL_DEPLOY_TIMEOUT: &str = "vercel.deploy.timeout"; - pub const VERCEL_HEALTH_FAILED: &str = "vercel.health.failed"; - pub const VERCEL_PREPARE_FAILED: &str = "vercel.prepare.failed"; - pub const VERCEL_TEARDOWN_SURVIVOR: &str = "vercel.teardown.survivor"; - /// Every code in the registry, for uniqueness tests. + /// Every core code, for uniqueness tests. Substrate codes live in their + /// own crates (`stackless_render::codes`, …); the binary aggregates them + /// with these for a workspace-wide uniqueness check. pub const ALL: &[&str] = &[ DEF_PARSE_SYNTAX, DEF_PARSE_SCHEMA, @@ -189,9 +171,6 @@ pub mod codes { VERIFY_FAILED, VERIFY_NOT_DECLARED, VERIFY_SOURCE_UNAVAILABLE, - RENDER_CONFIG_INVALID, - RENDER_API_KEY_MISSING, - RENDER_API_FAILED, STRIPE_PROJECTS_UNAVAILABLE, STRIPE_PROJECTS_AUTH, STRIPE_PROJECTS_FAILED, @@ -202,23 +181,6 @@ pub mod codes { STRIPE_PROJECTS_CONFIG_SCHEMA, INTEGRATION_CONFIG_INVALID, INTEGRATION_HOST_UNSUPPORTED, - RENDER_PAYMENT_NOT_CONFIRMED, - RENDER_PROVISION_FAILED, - RENDER_DEPLOY_FAILED, - RENDER_DEPLOY_TIMEOUT, - RENDER_HEALTH_FAILED, - RENDER_PREPARE_FAILED, - RENDER_TEARDOWN_SURVIVOR, - VERCEL_CONFIG_INVALID, - VERCEL_API_KEY_MISSING, - VERCEL_API_FAILED, - VERCEL_PAYMENT_NOT_CONFIRMED, - VERCEL_PROVISION_FAILED, - VERCEL_DEPLOY_FAILED, - VERCEL_DEPLOY_TIMEOUT, - VERCEL_HEALTH_FAILED, - VERCEL_PREPARE_FAILED, - VERCEL_TEARDOWN_SURVIVOR, ]; } diff --git a/crates/stackless-core/src/host.rs b/crates/stackless-core/src/host.rs deleted file mode 100644 index da4b537..0000000 --- a/crates/stackless-core/src/host.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Stack **hosts** — where a disposable instance runs (`stackless up --on`). -//! -//! A host names a substrate implementation (`stackless-local`, `stackless-render`, -//! `stackless-vercel`). This is distinct from integration **hosting** (managed -//! provider cloud vs host-bound), which lives in `stackless-integrations`. - -/// A stack hosting substrate selected at instance creation (`--on`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum Host { - /// Operator machine via `stackless-local`. - Local, - /// Render cloud via `stackless-render`. - Render, - /// Vercel cloud via `stackless-vercel`. - Vercel, -} - -impl Host { - /// Every substrate the binary can dispatch to. - pub const ALL: &'static [Host] = &[Host::Local, Host::Render, Host::Vercel]; - - /// The CLI / TOML key for this host (`local`, `render`, `vercel`). - pub fn as_str(self) -> &'static str { - match self { - Self::Local => "local", - Self::Render => "render", - Self::Vercel => "vercel", - } - } - - /// Parse a `--on` value or substrate block key. - pub fn parse(s: &str) -> Option { - match s { - "local" => Some(Self::Local), - "render" => Some(Self::Render), - "vercel" => Some(Self::Vercel), - _ => None, - } - } - - /// Whether `key` names a registered host (for flatten-map field filtering). - pub fn is_host_key(key: &str) -> bool { - Self::parse(key).is_some() - } -} - -#[cfg(test)] -mod tests { - use super::Host; - - #[test] - fn all_hosts_round_trip() { - for host in Host::ALL { - assert_eq!(Host::parse(host.as_str()), Some(*host)); - } - } -} diff --git a/crates/stackless-core/src/lib.rs b/crates/stackless-core/src/lib.rs index 4e06e3b..d586cd4 100644 --- a/crates/stackless-core/src/lib.rs +++ b/crates/stackless-core/src/lib.rs @@ -8,7 +8,6 @@ pub mod checkpoint; pub mod def; pub mod engine; pub mod fault; -pub mod host; pub mod lockfile; pub mod names; pub mod process; diff --git a/crates/stackless-core/src/substrate.rs b/crates/stackless-core/src/substrate.rs index 9e9be98..370fefe 100644 --- a/crates/stackless-core/src/substrate.rs +++ b/crates/stackless-core/src/substrate.rs @@ -93,6 +93,19 @@ pub enum Observation { Gone, } +/// One service's recent logs as a substrate retrieved them, for the `logs` +/// verb. The substrate owns where the lines come from (a cloud API, a local +/// file); the CLI only renders them. +#[derive(Debug, Clone)] +pub struct ServiceLog { + pub service: String, + /// Provenance tag for `--json` output (e.g. `"render_api"`, `"file"`). + pub source: &'static str, + /// Local log file path, when the lines were read from disk. + pub log_path: Option, + pub lines: Vec, +} + /// What `execute` hands back for the journal: the resource the step /// created (or re-affirmed), recorded before the engine proceeds. #[derive(Debug, Clone)] @@ -165,4 +178,25 @@ pub trait Substrate: Send + Sync { async fn finalize_teardown(&self, _instance: &str) -> Result<(), SubstrateFault> { Ok(()) } + + /// A spend line to print after `up`/`down` (§4 — never silently nothing; + /// bounded by the project's hard cap). Substrates that spend nothing + /// (local) return `None`. + async fn spend_line(&self) -> Option { + None + } + + /// Recent logs for `services` (§2 — recent window, no streaming). `None` + /// means this substrate has no log facility (the daemon never saw the + /// processes and there is no remote log API); the CLI reports that rather + /// than inventing output. + async fn fetch_logs( + &self, + _def: &StackDef, + _instance: &str, + _services: &[String], + _tail: usize, + ) -> Result>, SubstrateFault> { + Ok(None) + } } diff --git a/crates/stackless-core/tests/def.rs b/crates/stackless-core/tests/def.rs index ec55a12..f6a767e 100644 --- a/crates/stackless-core/tests/def.rs +++ b/crates/stackless-core/tests/def.rs @@ -6,7 +6,9 @@ use stackless_core::def::{self, DefError, Namespace, Node, StackDef}; use stackless_core::fault::{Fault, codes}; -use stackless_core::host::Host; + +/// The substrate names the binary registers; core takes them as input. +const KNOWN: &[&str] = &["local", "render", "vercel"]; fn fixture(name: &str) -> String { let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR")); @@ -15,7 +17,7 @@ fn fixture(name: &str) -> String { fn parse_valid(name: &str) -> def::StackDef { let def = StackDef::parse(&fixture(name)).unwrap_or_else(|err| panic!("parse {name}: {err}")); - def.validate() + def.validate_hosts(KNOWN) .unwrap_or_else(|err| panic!("validate {name}: {err}")); def } @@ -53,7 +55,7 @@ fn atto_parses_to_the_documented_model() { assert_eq!(def.secrets.required, vec!["GITHUB_PACKAGES_TOKEN"]); let clerk = &def.integrations["clerk"]; assert_eq!(clerk.provider, "clerk"); - let clerk_config = clerk.effective_config(Host::Local); + let clerk_config = clerk.effective_config("local", KNOWN); assert_eq!( clerk_config["app_name"].as_str(), Some("${stack.name}-${instance.name}") @@ -206,7 +208,7 @@ fn api_env_resolves_against_a_namespace() { fn expect_invalid(name: &str, expected_code: &str) { let text = fixture(&format!("invalid/{name}")); - let result = StackDef::parse(&text).and_then(|def| def.validate()); + let result = StackDef::parse(&text).and_then(|def| def.validate_hosts(KNOWN)); let err = result.unwrap_err(); assert_eq!(err.code(), expected_code, "fixture {name}: {err}"); assert!( diff --git a/crates/stackless-integrations/src/error.rs b/crates/stackless-integrations/src/error.rs index 6987fe4..0909bb0 100644 --- a/crates/stackless-integrations/src/error.rs +++ b/crates/stackless-integrations/src/error.rs @@ -1,5 +1,4 @@ use stackless_core::fault::{ErrorContext, Fault, codes}; -use stackless_core::host::Host; use stackless_stripe_projects::ProjectsError; @@ -9,7 +8,7 @@ pub enum IntegrationError { ConfigInvalid { location: String, detail: String }, #[error("integration provider {provider:?} is not supported on host {host:?}")] - HostUnsupported { provider: String, host: Host }, + HostUnsupported { provider: String, host: String }, #[error("provisioning integration {integration:?} failed: {detail}")] ProvisionFailed { integration: String, detail: String }, diff --git a/crates/stackless-integrations/src/hostable.rs b/crates/stackless-integrations/src/hostable.rs index a9f31ab..ac63b88 100644 --- a/crates/stackless-integrations/src/hostable.rs +++ b/crates/stackless-integrations/src/hostable.rs @@ -2,8 +2,6 @@ //! output checking, and lifecycle dispatch. Each catalog adapter (Clerk, …) //! implements [`Hostable`] once; the registry is built only from those impls. -use stackless_core::host::Host; - /// Whether an integration's config may vary per stack host. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConfigScope { @@ -21,7 +19,7 @@ pub enum IntegrationHosting { /// when referenced. Must pair with [`ConfigScope::GlobalOnly`]. Managed, /// Runs on or through specific stack hosts; `--on` must be in the host list. - HostBound(&'static [Host]), + HostBound(&'static [&'static str]), } /// Metadata and compile-time constraints for one integration provider. @@ -59,15 +57,16 @@ const fn validate_hostable_pair(hosting: IntegrationHosting, scope: ConfigScope) } /// Whether `host` is listed for a host-bound provider. -pub fn host_bound_supports(hosting: IntegrationHosting, host: Host) -> bool { +pub fn host_bound_supports(hosting: IntegrationHosting, host: &str) -> bool { match hosting { IntegrationHosting::Managed => true, IntegrationHosting::HostBound(hosts) => hosts.contains(&host), } } -/// Hosts declared for a host-bound provider (empty for managed). -pub fn host_bound_hosts(hosting: IntegrationHosting) -> &'static [Host] { +/// Hosts declared for a host-bound provider (empty for managed). These are the +/// substrate keys that count as host overrides for this provider's config. +pub fn host_bound_hosts(hosting: IntegrationHosting) -> &'static [&'static str] { match hosting { IntegrationHosting::Managed => &[], IntegrationHosting::HostBound(hosts) => hosts, @@ -80,17 +79,17 @@ mod tests { #[test] fn host_bound_supports_declared_hosts_only() { - let hosting = IntegrationHosting::HostBound(&[Host::Local, Host::Render]); - assert!(host_bound_supports(hosting, Host::Local)); - assert!(host_bound_supports(hosting, Host::Render)); - assert!(!host_bound_supports(hosting, Host::Vercel)); + let hosting = IntegrationHosting::HostBound(&["local", "render"]); + assert!(host_bound_supports(hosting, "local")); + assert!(host_bound_supports(hosting, "render")); + assert!(!host_bound_supports(hosting, "vercel")); } #[test] fn managed_supports_every_active_host_check() { let hosting = IntegrationHosting::Managed; - for host in Host::ALL { - assert!(host_bound_supports(hosting, *host)); + for host in ["local", "render", "vercel"] { + assert!(host_bound_supports(hosting, host)); } } } diff --git a/crates/stackless-integrations/src/lib.rs b/crates/stackless-integrations/src/lib.rs index 7c3333a..c153707 100644 --- a/crates/stackless-integrations/src/lib.rs +++ b/crates/stackless-integrations/src/lib.rs @@ -7,11 +7,12 @@ pub mod error; pub mod hostable; pub mod providers; pub mod registry; +pub mod resource; use std::path::Path; +use async_trait::async_trait; use stackless_core::def::StackDef; -use stackless_core::host::Host; use stackless_core::substrate::{Observation, StepResource}; use stackless_stripe_projects::project; use stackless_stripe_projects::stripe::{CommandRunner, StripeProjects}; @@ -19,6 +20,43 @@ use stackless_stripe_projects::stripe::{CommandRunner, StripeProjects}; pub use error::IntegrationError; pub use registry::validate_all; +/// One integration provider's lifecycle behaviour. The registry stores a +/// `&'static dyn ProviderOps` per provider, so adding a provider is one +/// registry row + this impl — dispatch never matches on provider strings. +/// +/// The runner is erased to `&dyn CommandRunner` (sound via +/// `impl CommandRunner for &T`) so the registry table is not generic. +#[async_trait] +pub trait ProviderOps: Send + Sync { + // The provision params mirror the established provisioning call; `&self` + // for dispatch tips it one over the lint's limit. + #[allow(clippy::too_many_arguments)] + async fn provision( + &self, + stripe: &StripeProjects<&dyn CommandRunner>, + def: &StackDef, + definition_dir: &Path, + instance: &str, + name: &str, + substrate: &str, + skip_stripe_instance_context: bool, + ) -> Result; + + async fn observe( + &self, + stripe: &StripeProjects<&dyn CommandRunner>, + checkpoint_payload: &str, + fallback_resource: &str, + ) -> Result; + + async fn destroy( + &self, + stripe: &StripeProjects<&dyn CommandRunner>, + checkpoint_payload: &str, + fallback_resource: &str, + ) -> Result<(), IntegrationError>; +} + pub async fn provision( substrate: &str, stripe: &StripeProjects, @@ -35,29 +73,27 @@ pub async fn provision( location: format!("integrations.{name}"), detail: "integration not in definition".into(), })?; - let host = Host::parse(substrate).ok_or_else(|| IntegrationError::ConfigInvalid { + registry::validate_integration( + name, + spec, + Some(substrate), + registry::provider_host_keys(&spec.provider), + )?; + let ops = registry::ops_for(&spec.provider).ok_or_else(|| IntegrationError::ConfigInvalid { location: format!("integrations.{name}"), - detail: format!("unknown substrate {substrate:?}"), + detail: format!("no adapter for provider {:?}", spec.provider), })?; - registry::validate_integration(name, spec, Some(host))?; - match spec.provider.as_str() { - "clerk" => { - providers::clerk::provision_stripe( - stripe, - def, - definition_dir, - instance, - name, - substrate, - skip_stripe_instance_context, - ) - .await - } - provider => Err(IntegrationError::ConfigInvalid { - location: format!("integrations.{name}"), - detail: format!("no adapter for provider {provider:?}"), - }), - } + let stripe = stripe.as_dyn(); + ops.provision( + &stripe, + def, + definition_dir, + instance, + name, + substrate, + skip_stripe_instance_context, + ) + .await } pub async fn observe( @@ -68,10 +104,12 @@ pub async fn observe( resource_kind: &str, ) -> Result { let _ = substrate; - if providers::clerk::is_clerk_resource(resource_kind) { - providers::clerk::observe(stripe, checkpoint_payload, fallback_resource).await - } else { - Ok(Observation::Gone) + match registry::ops_for_resource_kind(resource_kind) { + Some(ops) => { + ops.observe(&stripe.as_dyn(), checkpoint_payload, fallback_resource) + .await + } + None => Ok(Observation::Gone), } } @@ -83,10 +121,12 @@ pub async fn destroy( resource_kind: &str, ) -> Result<(), IntegrationError> { let _ = substrate; - if providers::clerk::is_clerk_resource(resource_kind) { - providers::clerk::destroy(stripe, checkpoint_payload, fallback_resource).await - } else { - Ok(()) + match registry::ops_for_resource_kind(resource_kind) { + Some(ops) => { + ops.destroy(&stripe.as_dyn(), checkpoint_payload, fallback_resource) + .await + } + None => Ok(()), } } diff --git a/crates/stackless-integrations/src/providers/clerk.rs b/crates/stackless-integrations/src/providers/clerk.rs index 7f57bae..f7b4a7b 100644 --- a/crates/stackless-integrations/src/providers/clerk.rs +++ b/crates/stackless-integrations/src/providers/clerk.rs @@ -4,9 +4,9 @@ use std::collections::BTreeMap; use std::path::Path; use std::time::Duration; +use async_trait::async_trait; use serde::{Deserialize, Serialize}; use stackless_core::def::{Namespace, StackDef}; -use stackless_core::host::Host; use stackless_core::substrate::{Observation, StepResource}; use stackless_core::types::DnsName; use stackless_stripe_projects::ProjectsError; @@ -18,7 +18,7 @@ use stackless_stripe_projects::provision::{ use stackless_stripe_projects::stripe::{CommandRunner, StripeProjects}; use crate::error::IntegrationError; -use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting, host_bound_hosts}; use crate::registry; pub const RESOURCE_KIND: &str = "integration-clerk"; @@ -71,8 +71,48 @@ impl Hostable for ClerkAuth { const OUTPUTS: &'static [&'static str] = &["secret_key", "publishable_key"]; } -fn active_host(substrate: &str) -> Host { - Host::parse(substrate).unwrap_or(Host::Local) +#[async_trait] +impl crate::ProviderOps for ClerkAuth { + #[allow(clippy::too_many_arguments)] + async fn provision( + &self, + stripe: &StripeProjects<&dyn CommandRunner>, + def: &StackDef, + definition_dir: &Path, + instance: &str, + name: &str, + substrate: &str, + skip_stripe_instance_context: bool, + ) -> Result { + provision_stripe( + stripe, + def, + definition_dir, + instance, + name, + substrate, + skip_stripe_instance_context, + ) + .await + } + + async fn observe( + &self, + stripe: &StripeProjects<&dyn CommandRunner>, + checkpoint_payload: &str, + fallback_resource: &str, + ) -> Result { + observe(stripe, checkpoint_payload, fallback_resource).await + } + + async fn destroy( + &self, + stripe: &StripeProjects<&dyn CommandRunner>, + checkpoint_payload: &str, + fallback_resource: &str, + ) -> Result<(), IntegrationError> { + destroy(stripe, checkpoint_payload, fallback_resource).await + } } /// Build the typed `clerk/auth` config from the integration definition. @@ -87,7 +127,7 @@ fn build_clerk_config(ctx: &ProvisionContext<'_>) -> Result( provision_with_credentials(stripe, &catalog, &ctx, &config, CLERK_ENV_KEYS).await?; let spec = &def.integrations[name]; - let effective = spec.effective_config(active_host(substrate)); + let effective = spec.effective_config(substrate, host_bound_hosts(ClerkAuth::HOSTING)); let credential_set = registry::config_string(&effective, "credential_set").map_err(|err| { IntegrationError::ConfigInvalid { location: format!("integrations.{name}.credential_set"), @@ -278,10 +318,6 @@ pub async fn destroy( Ok(()) } -pub fn is_clerk_resource(kind: &str) -> bool { - kind == RESOURCE_KIND -} - fn stripe_resource(payload: &str) -> Option { serde_json::from_str::(payload) .ok() diff --git a/crates/stackless-integrations/src/providers/cloudflare/browser_run.rs b/crates/stackless-integrations/src/providers/cloudflare/browser_run.rs new file mode 100644 index 0000000..fb1283b --- /dev/null +++ b/crates/stackless-integrations/src/providers/cloudflare/browser_run.rs @@ -0,0 +1,165 @@ +//! Cloudflare Browser Rendering (`cloudflare/browser-run`) — an account-level +//! browser binding. Same output shape as `cloudflare/workers` (confirmed live). + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::CloudflareResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; + +pub const RESOURCE_KIND: &str = "integration-cloudflare-browser-run"; + +#[derive(Debug, Serialize)] +pub struct BrowserRunConfig {} + +impl CatalogService for BrowserRunConfig { + const REFERENCE: &'static str = "cloudflare/browser-run"; +} + +#[derive(Debug)] +pub struct CloudflareBrowserRun; + +impl Hostable for CloudflareBrowserRun { + const PROVIDER: &'static str = "cloudflare-browser-run"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &[ + "account_id", + "workers_dev_subdomain", + "api_base_url", + "dashboard_url", + "plan_service_id", + ]; +} + +impl CloudflareResource for CloudflareBrowserRun { + type Config = BrowserRunConfig; + const PROVIDER_PREFIX: &'static str = "CLOUDFLARE"; + // Confirmed by live discovery 2026-06-16 (Worker-family shape). + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("ACCOUNT_ID", "account_id", true), + ("WORKERS_DEV_SUBDOMAIN", "workers_dev_subdomain", true), + ("API_BASE_URL", "api_base_url", false), + ("DASHBOARD_URL", "dashboard_url", false), + ("PLAN_SERVICE_ID", "plan_service_id", false), + ]; + + fn build_config(_ctx: &ProvisionContext<'_>) -> Result { + Ok(BrowserRunConfig {}) + } +} + +pub fn validate_config( + _name: &str, + _config: &BTreeMap, +) -> Result<(), IntegrationError> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload as CloudflarePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::{CommandOutput, StripeProjects}; + use stackless_stripe_projects::test_support::ScriptedRunner; + + fn out(stdout: &str) -> CommandOutput { + CommandOutput { + status: 0, + stdout: stdout.to_owned(), + stderr: String::new(), + } + } + + #[test] + fn browser_run_config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service(&catalog, &BrowserRunConfig {}); + assert!( + failures.is_empty(), + "cloudflare/browser-run catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r#"{"ok":true,"command":"projects catalog","data":{ + "last_updated":"2026-06-16T00:00:00Z","services":[{ + "id":"prvsvc_br","object":"v2.provisioning.provider_service_detail", + "provider_id":"prvdr_cloudflare","provider_name":"Cloudflare","service_id":"browser-run", + "categories":["compute"],"kind":"deployable","scope":"project","availability":"available", + "development":false,"livemode":true,"pricing":{"type":"component"}, + "configuration_schema":{"type":"object","additionalProperties":false,"properties":{}} + }]}}"#; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.browser] +provider = "cloudflare-browser-run" +[services.api] +source = { repo = "r", ref = "main" } +env = { CF_SUBDOMAIN = "${integrations.browser.workers_dev_subdomain}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_browser_run_records_outputs() { + let runner = ScriptedRunner::new(vec![ + out(CATALOG_ENVELOPE), + out(r#"{"ok":true,"data":{"project":{"id":"project_1"}}}"#), + out(r#"{"ok":true,"data":{"environments":[{"name":"demo"}]}}"#), + out(r#"{"ok":true,"data":null}"#), + out(r#"{"ok":true,"data":{"services":[]}}"#), + out(&serde_json::json!({"ok":true,"data":{"variables":{ + "CLOUDFLARE_ACCOUNT_ID": "acc_1", + "CLOUDFLARE_WORKERS_DEV_SUBDOMAIN": "atto-demo" + }}}) + .to_string()), + out(r#"{"ok":true,"data":null}"#), + out(r#"{"ok":true,"data":null}"#), + ]); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = CloudflareBrowserRun + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "browser", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-cloudflare-browser-run"); + let payload: CloudflarePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["workers_dev_subdomain"], "atto-demo"); + } +} diff --git a/crates/stackless-integrations/src/providers/cloudflare/d1.rs b/crates/stackless-integrations/src/providers/cloudflare/d1.rs new file mode 100644 index 0000000..9b27fc3 --- /dev/null +++ b/crates/stackless-integrations/src/providers/cloudflare/d1.rs @@ -0,0 +1,166 @@ +//! Cloudflare D1 serverless SQLite database (`cloudflare/d1`). + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::CloudflareResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-cloudflare-d1"; + +#[derive(Debug, Serialize)] +pub struct D1Config { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub primary_location_hint: Option, +} + +impl CatalogService for D1Config { + const REFERENCE: &'static str = "cloudflare/d1"; +} + +#[derive(Debug)] +pub struct CloudflareD1; + +impl Hostable for CloudflareD1 { + const PROVIDER: &'static str = "cloudflare-d1"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["database_id", "name", "account_id"]; +} + +impl CloudflareResource for CloudflareD1 { + type Config = D1Config; + const PROVIDER_PREFIX: &'static str = "CLOUDFLARE"; + // Confirmed by live provisioning 2026-06-16. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("DATABASE_ID", "database_id", true), + ("NAME", "name", false), + ("ACCOUNT_ID", "account_id", false), + ]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(D1Config { + name: super::interp_required(ctx, &config, "name")?, + primary_location_hint: super::interp_optional(ctx, &config, "primary_location_hint")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "name").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.name"), + detail: err.to_string(), + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload as CloudflarePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn d1_config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &D1Config { + name: "stackless-db".into(), + primary_location_hint: Some("wnam".into()), + }, + ); + assert!( + failures.is_empty(), + "cloudflare/d1 catalog gaps:\n{}", + failures.join("\n") + ); + } + + const D1_CATALOG_ENVELOPE: &str = r#"{"ok":true,"command":"projects catalog","data":{ + "last_updated":"2026-06-16T00:00:00Z","services":[{ + "id":"prvsvc_d1","object":"v2.provisioning.provider_service_detail", + "provider_id":"prvdr_cloudflare","provider_name":"Cloudflare","service_id":"d1", + "categories":["database"],"kind":"deployable","scope":"project","availability":"available", + "development":false,"livemode":true,"pricing":{"type":"component"}, + "configuration_schema":{"type":"object","required":["name"],"additionalProperties":false, + "properties":{"name":{"type":"string"}, + "primary_location_hint":{"type":"string","enum":["wnam","enam","weur","eeur","apac","oc"]}}} + }]}}"#; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.db] +provider = "cloudflare-d1" +name = "${stack.name}-db" +[services.api] +source = { repo = "r", ref = "main" } +env = { D1_ID = "${integrations.db.database_id}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_d1_records_outputs() { + let runner = test_support::provision_script( + D1_CATALOG_ENVELOPE, + serde_json::json!({ + "CLOUDFLARE_DATABASE_ID": "db_123", + "CLOUDFLARE_NAME": "atto-db", + "CLOUDFLARE_ACCOUNT_ID": "acc_1" + }), + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = CloudflareD1 + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "db", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-cloudflare-d1"); + let payload: CloudflarePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["database_id"], "db_123"); + assert_eq!(payload.outputs["name"], "atto-db"); + assert_eq!(payload.outputs["account_id"], "acc_1"); + } +} diff --git a/crates/stackless-integrations/src/providers/cloudflare/hyperdrive.rs b/crates/stackless-integrations/src/providers/cloudflare/hyperdrive.rs new file mode 100644 index 0000000..3d12790 --- /dev/null +++ b/crates/stackless-integrations/src/providers/cloudflare/hyperdrive.rs @@ -0,0 +1,235 @@ +//! Cloudflare Hyperdrive — pooled/cached access to an external SQL origin +//! (`cloudflare/hyperdrive`). +//! +//! Models the required origin-connection fields. Optional tuning (caching, mTLS, +//! Access) can be added as further `Option` fields later — the catalog schema +//! validates a subset, so required-only provisions cleanly. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::CloudflareResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-cloudflare-hyperdrive"; + +#[derive(Debug, Serialize)] +pub struct HyperdriveConfig { + pub name: String, + pub database: String, + pub host: String, + pub password: String, + pub port: i64, + pub scheme: String, + pub user: String, +} + +impl CatalogService for HyperdriveConfig { + const REFERENCE: &'static str = "cloudflare/hyperdrive"; +} + +#[derive(Debug)] +pub struct CloudflareHyperdrive; + +impl Hostable for CloudflareHyperdrive { + const PROVIDER: &'static str = "cloudflare-hyperdrive"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["hyperdrive_id", "connection_string", "account_id"]; +} + +impl CloudflareResource for CloudflareHyperdrive { + type Config = HyperdriveConfig; + const PROVIDER_PREFIX: &'static str = "CLOUDFLARE"; + // Best-guess; unverified (Hyperdrive needs a real origin DB, so it is not in + // the live smoke). Pinned when first provisioned against a real origin. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("HYPERDRIVE_ID", "hyperdrive_id", true), + ("CONNECTION_STRING", "connection_string", false), + ("ACCOUNT_ID", "account_id", false), + ]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(HyperdriveConfig { + name: super::interp_required(ctx, &config, "name")?, + database: super::interp_required(ctx, &config, "database")?, + host: super::interp_required(ctx, &config, "host")?, + password: super::interp_required(ctx, &config, "password")?, + port: super::int_required(ctx, &config, "port")?, + scheme: super::interp_required(ctx, &config, "scheme")?, + user: super::interp_required(ctx, &config, "user")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + for key in ["name", "database", "host", "password", "scheme", "user"] { + registry::config_string(config, key).map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.{key}"), + detail: err.to_string(), + })?; + } + if config + .get("port") + .and_then(toml::Value::as_integer) + .is_none() + { + return Err(IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.port"), + detail: "port is required and must be an integer".into(), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload as CloudflarePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::{CommandOutput, StripeProjects}; + use stackless_stripe_projects::test_support::ScriptedRunner; + + fn out(stdout: &str) -> CommandOutput { + CommandOutput { + status: 0, + stdout: stdout.to_owned(), + stderr: String::new(), + } + } + + fn sample() -> HyperdriveConfig { + HyperdriveConfig { + name: "stackless-hd".into(), + database: "app".into(), + host: "db.example.com".into(), + password: "secret".into(), + port: 5432, + scheme: "postgres".into(), + user: "app".into(), + } + } + + #[test] + fn hyperdrive_config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service(&catalog, &sample()); + assert!( + failures.is_empty(), + "cloudflare/hyperdrive catalog gaps:\n{}", + failures.join("\n") + ); + } + + const HYPERDRIVE_CATALOG_ENVELOPE: &str = r#"{"ok":true,"command":"projects catalog","data":{ + "last_updated":"2026-06-16T00:00:00Z","services":[{ + "id":"prvsvc_hd","object":"v2.provisioning.provider_service_detail", + "provider_id":"prvdr_cloudflare","provider_name":"Cloudflare","service_id":"hyperdrive", + "categories":["database"],"kind":"deployable","scope":"project","availability":"available", + "development":false,"livemode":true,"pricing":{"type":"component"}, + "configuration_schema":{"type":"object", + "required":["name","database","host","password","port","scheme","user"], + "additionalProperties":false, + "properties":{"name":{"type":"string"},"database":{"type":"string"}, + "host":{"type":"string"},"password":{"type":"string"},"port":{"type":"integer"}, + "scheme":{"type":"string","enum":["postgres","postgresql","mysql"]}, + "user":{"type":"string"}}} + }]}}"#; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.hd] +provider = "cloudflare-hyperdrive" +name = "${stack.name}-hd" +database = "app" +host = "db.example.com" +password = "secret" +port = 5432 +scheme = "postgres" +user = "app" +[services.api] +source = { repo = "r", ref = "main" } +env = { HD_URL = "${integrations.hd.connection_string}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_hyperdrive_records_outputs() { + let runner = ScriptedRunner::new(vec![ + out(HYPERDRIVE_CATALOG_ENVELOPE), + out(r#"{"ok":true,"data":{"project":{"id":"project_1"}}}"#), + out(r#"{"ok":true,"data":{"environments":[{"name":"demo"}]}}"#), + out(r#"{"ok":true,"data":null}"#), + out(r#"{"ok":true,"data":{"services":[]}}"#), + out(&serde_json::json!({"ok":true,"data":{"variables":{ + "CLOUDFLARE_HYPERDRIVE_ID": "hd_123", + "CLOUDFLARE_CONNECTION_STRING": "postgresql://hyperdrive/atto" + }}}) + .to_string()), + out(r#"{"ok":true,"data":null}"#), + out(r#"{"ok":true,"data":null}"#), + ]); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = CloudflareHyperdrive + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "hd", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-cloudflare-hyperdrive"); + let payload: CloudflarePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["hyperdrive_id"], "hd_123"); + assert_eq!( + payload.outputs["connection_string"], + "postgresql://hyperdrive/atto" + ); + + // The add config must serialize `port` as an integer (catalog schema). + let add = runner + .calls() + .into_iter() + .find(|c| c.first().map(String::as_str) == Some("add")) + .unwrap(); + let cfg_idx = add.iter().position(|a| a == "--config").unwrap(); + let cfg: serde_json::Value = serde_json::from_str(&add[cfg_idx + 1]).unwrap(); + assert_eq!(cfg["port"], serde_json::json!(5432)); + } +} diff --git a/crates/stackless-integrations/src/providers/cloudflare/kv.rs b/crates/stackless-integrations/src/providers/cloudflare/kv.rs new file mode 100644 index 0000000..14ba3e7 --- /dev/null +++ b/crates/stackless-integrations/src/providers/cloudflare/kv.rs @@ -0,0 +1,165 @@ +//! Cloudflare Workers KV namespace (`cloudflare/kv`). + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::CloudflareResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-cloudflare-kv"; + +#[derive(Debug, Serialize)] +pub struct KvConfig { + pub title: String, +} + +impl CatalogService for KvConfig { + const REFERENCE: &'static str = "cloudflare/kv"; +} + +#[derive(Debug)] +pub struct CloudflareKv; + +impl Hostable for CloudflareKv { + const PROVIDER: &'static str = "cloudflare-kv"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["namespace_id", "account_id"]; +} + +impl CloudflareResource for CloudflareKv { + type Config = KvConfig; + const PROVIDER_PREFIX: &'static str = "CLOUDFLARE"; + // Confirmed by the live smoke 2026-06-16. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("NAMESPACE_ID", "namespace_id", true), + ("ACCOUNT_ID", "account_id", false), + ]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(KvConfig { + title: super::interp_required(ctx, &config, "title")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "title").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.title"), + detail: err.to_string(), + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload as CloudflarePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn kv_config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &KvConfig { + title: "stackless-cache".into(), + }, + ); + assert!( + failures.is_empty(), + "cloudflare/kv catalog gaps:\n{}", + failures.join("\n") + ); + } + + const KV_CATALOG_ENVELOPE: &str = r#"{"ok":true,"command":"projects catalog","data":{ + "last_updated":"2026-06-16T00:00:00Z","services":[{ + "id":"prvsvc_kv","object":"v2.provisioning.provider_service_detail", + "provider_id":"prvdr_cloudflare","provider_name":"Cloudflare","service_id":"kv", + "categories":["cache"],"kind":"deployable","scope":"project","availability":"available", + "development":false,"livemode":true,"pricing":{"type":"component"}, + "configuration_schema":{"type":"object","required":["title"],"additionalProperties":false, + "properties":{"title":{"type":"string"}}} + }]}}"#; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.cache] +provider = "cloudflare-kv" +title = "${stack.name}-cache" +[services.api] +source = { repo = "r", ref = "main" } +env = { KV_NS = "${integrations.cache.namespace_id}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_kv_records_outputs() { + let runner = test_support::provision_script( + KV_CATALOG_ENVELOPE, + serde_json::json!({ + "CLOUDFLARE_NAMESPACE_ID": "ns_123", + "CLOUDFLARE_ACCOUNT_ID": "acc_1" + }), + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = CloudflareKv + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "cache", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-cloudflare-kv"); + let payload: CloudflarePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["namespace_id"], "ns_123"); + // Component pricing → no paid confirmation. + let add = runner + .calls() + .into_iter() + .find(|c| c.first().map(String::as_str) == Some("add")) + .unwrap(); + assert_eq!(add[1], "cloudflare/kv"); + assert!(!add.contains(&"--confirm-paid-service".to_owned())); + } +} diff --git a/crates/stackless-integrations/src/providers/cloudflare/mod.rs b/crates/stackless-integrations/src/providers/cloudflare/mod.rs new file mode 100644 index 0000000..c8f6fa4 --- /dev/null +++ b/crates/stackless-integrations/src/providers/cloudflare/mod.rs @@ -0,0 +1,29 @@ +//! Cloudflare catalog resources via Stripe Projects. +//! +//! Each resource (R2, KV, D1, Queues, Hyperdrive, Workers, Workers AI, Browser +//! Run) is a generic [`crate::resource::CatalogResource`] with +//! `PROVIDER_PREFIX = "CLOUDFLARE"`: a distinct `provider = "cloudflare-"`, +//! its own `CatalogService` reference (`cloudflare/`), and declared +//! `OUTPUT_FIELDS`. The provision/observe/destroy lifecycle and credential +//! resolution are shared in `crate::resource`. Output envelopes are pinned by +//! live discovery (`xtask discover cloudflare/`) + the smoke. +//! +//! Excluded: `containers` (paid, "pricing unavailable" — unknown cost), +//! `registrar:domain` (a one-time non-refundable domain purchase), and the +//! `workers:free`/`workers:paid` plans. Cloudflare Workers as a *deploy* +//! substrate (`--on cloudflare`) is a separate build. + +pub mod browser_run; +pub mod d1; +pub mod hyperdrive; +pub mod kv; +pub mod queues; +pub mod r2; +pub mod workers; +pub mod workers_ai; + +// Backwards-compatible names for the resource files (the lifecycle is generic). +pub(crate) use crate::resource::{ + CatalogResource as CloudflareResource, int_required, integration_config, interp_optional, + interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/cloudflare/queues.rs b/crates/stackless-integrations/src/providers/cloudflare/queues.rs new file mode 100644 index 0000000..fd65154 --- /dev/null +++ b/crates/stackless-integrations/src/providers/cloudflare/queues.rs @@ -0,0 +1,177 @@ +//! Cloudflare Queues (`cloudflare/queues`). + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::CloudflareResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-cloudflare-queues"; + +#[derive(Debug, Serialize)] +pub struct QueuesConfig { + pub queue_name: String, +} + +impl CatalogService for QueuesConfig { + const REFERENCE: &'static str = "cloudflare/queues"; +} + +#[derive(Debug)] +pub struct CloudflareQueues; + +impl Hostable for CloudflareQueues { + const PROVIDER: &'static str = "cloudflare-queues"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["queue_id", "queue_name", "account_id"]; +} + +impl CloudflareResource for CloudflareQueues { + type Config = QueuesConfig; + const PROVIDER_PREFIX: &'static str = "CLOUDFLARE"; + // Confirmed by live provisioning 2026-06-16. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("QUEUE_ID", "queue_id", true), + ("QUEUE_NAME", "queue_name", false), + ("ACCOUNT_ID", "account_id", false), + ]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(QueuesConfig { + queue_name: super::interp_required(ctx, &config, "queue_name")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "queue_name").map_err(|err| { + IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.queue_name"), + detail: err.to_string(), + } + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload as CloudflarePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::{CommandOutput, StripeProjects}; + use stackless_stripe_projects::test_support::ScriptedRunner; + + fn out(stdout: &str) -> CommandOutput { + CommandOutput { + status: 0, + stdout: stdout.to_owned(), + stderr: String::new(), + } + } + + #[test] + fn queues_config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &QueuesConfig { + queue_name: "stackless-jobs".into(), + }, + ); + assert!( + failures.is_empty(), + "cloudflare/queues catalog gaps:\n{}", + failures.join("\n") + ); + } + + const QUEUES_CATALOG_ENVELOPE: &str = r#"{"ok":true,"command":"projects catalog","data":{ + "last_updated":"2026-06-16T00:00:00Z","services":[{ + "id":"prvsvc_queues","object":"v2.provisioning.provider_service_detail", + "provider_id":"prvdr_cloudflare","provider_name":"Cloudflare","service_id":"queues", + "categories":["queue"],"kind":"deployable","scope":"project","availability":"available", + "development":false,"livemode":true,"pricing":{"type":"component"}, + "configuration_schema":{"type":"object","required":["queue_name"],"additionalProperties":false, + "properties":{"queue_name":{"type":"string"}}} + }]}}"#; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.jobs] +provider = "cloudflare-queues" +queue_name = "${stack.name}-jobs" +[services.api] +source = { repo = "r", ref = "main" } +env = { QUEUE_ID = "${integrations.jobs.queue_id}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_queues_records_outputs() { + let runner = ScriptedRunner::new(vec![ + out(QUEUES_CATALOG_ENVELOPE), + out(r#"{"ok":true,"data":{"project":{"id":"project_1"}}}"#), + out(r#"{"ok":true,"data":{"environments":[{"name":"demo"}]}}"#), + out(r#"{"ok":true,"data":null}"#), + out(r#"{"ok":true,"data":{"services":[]}}"#), + out(&serde_json::json!({"ok":true,"data":{"variables":{ + "CLOUDFLARE_QUEUE_ID": "q_123", + "CLOUDFLARE_QUEUE_NAME": "atto-jobs", + "CLOUDFLARE_ACCOUNT_ID": "acc_1" + }}}) + .to_string()), + out(r#"{"ok":true,"data":null}"#), + out(r#"{"ok":true,"data":null}"#), + ]); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = CloudflareQueues + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "jobs", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-cloudflare-queues"); + let payload: CloudflarePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["queue_id"], "q_123"); + assert_eq!(payload.outputs["queue_name"], "atto-jobs"); + } +} diff --git a/crates/stackless-integrations/src/providers/cloudflare/r2.rs b/crates/stackless-integrations/src/providers/cloudflare/r2.rs new file mode 100644 index 0000000..80a8bc1 --- /dev/null +++ b/crates/stackless-integrations/src/providers/cloudflare/r2.rs @@ -0,0 +1,250 @@ +//! Cloudflare R2 object storage (`cloudflare/r2:bucket`). + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::CloudflareResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-cloudflare-r2"; + +/// The typed `cloudflare/r2:bucket` `--config`. Field names ARE the catalog +/// contract; the gap test pins them against the live `configuration_schema`. +#[derive(Debug, Serialize)] +pub struct R2Config { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub location_hint: Option, +} + +impl CatalogService for R2Config { + const REFERENCE: &'static str = "cloudflare/r2:bucket"; +} + +#[derive(Debug)] +pub struct CloudflareR2; + +impl Hostable for CloudflareR2 { + const PROVIDER: &'static str = "cloudflare-r2"; + /// R2 runs on Cloudflare's edge — not on the stack's `--on` host. + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + /// Bucket coordinates usable from any substrate's service env. + const OUTPUTS: &'static [&'static str] = &["account_id", "bucket", "endpoint", "dashboard_url"]; +} + +impl CloudflareResource for CloudflareR2 { + type Config = R2Config; + const PROVIDER_PREFIX: &'static str = "CLOUDFLARE"; + // Confirmed by the live smoke 2026-06-16. No S3 access/secret keys — Stripe's + // R2 provisioning returns the account/bucket/endpoint, not API tokens. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("ACCOUNT_ID", "account_id", true), + ("BUCKET_NAME", "bucket", true), + ("ENDPOINT", "endpoint", true), + ("DASHBOARD_URL", "dashboard_url", false), + ]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(R2Config { + name: super::interp_required(ctx, &config, "name")?, + location_hint: super::interp_optional(ctx, &config, "location_hint")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + // `name` is required; `location_hint` is optional and enum-checked against + // the catalog schema at provision. + registry::config_string(config, "name").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.name"), + detail: err.to_string(), + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload as CloudflarePayload; + use stackless_core::def::StackDef; + use stackless_core::substrate::Observation; + use stackless_stripe_projects::stripe::{CommandOutput, StripeProjects}; + use stackless_stripe_projects::test_support::ScriptedRunner; + + fn out(stdout: &str) -> CommandOutput { + CommandOutput { + status: 0, + stdout: stdout.to_owned(), + stderr: String::new(), + } + } + + /// Catalog gap check: `R2Config` must validate against the live + /// `cloudflare/r2:bucket` schema in the committed catalog fixture. + #[test] + fn r2_config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &R2Config { + name: "stackless-assets".into(), + location_hint: Some("wnam".into()), + }, + ); + assert!( + failures.is_empty(), + "cloudflare/r2:bucket catalog gaps:\n{}", + failures.join("\n") + ); + } + + /// A minimal `stripe projects catalog --json` envelope carrying + /// `cloudflare/r2:bucket` as a paid service (so `--confirm-paid-service` fires). + const R2_CATALOG_ENVELOPE: &str = r#"{"ok":true,"command":"projects catalog","data":{ + "last_updated":"2026-06-16T00:00:00Z","services":[{ + "id":"prvsvc_r2","object":"v2.provisioning.provider_service_detail", + "provider_id":"prvdr_cloudflare","provider_name":"Cloudflare","service_id":"r2:bucket", + "categories":["storage"],"kind":"deployable","scope":"project","availability":"available", + "development":false,"livemode":true, + "pricing":{"type":"paid","paid_pricing":[{"type":"freeform","freeform":"usage-based"}]}, + "configuration_schema":{"type":"object","required":["name"],"additionalProperties":false, + "properties":{"name":{"type":"string"}, + "location_hint":{"type":"string","enum":["wnam","enam","weur","eeur","apac","oc"]}}} + }]}}"#; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" + +[integrations.bucket] +provider = "cloudflare-r2" +name = "${stack.name}-${instance.name}-assets" + +[services.api] +source = { repo = "r", ref = "main" } +env = { R2_ENDPOINT = "${integrations.bucket.endpoint}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_r2_adds_paid_resource_and_records_outputs() { + // Stripe returns the R2 coordinates as distinct env vars in the add + // response (the real envelope, confirmed by the live smoke). + let runner = ScriptedRunner::new(vec![ + out(R2_CATALOG_ENVELOPE), + out(r#"{"ok":true,"data":{"project":{"id":"project_1"}}}"#), + out(r#"{"ok":true,"data":{"environments":[{"name":"demo"}]}}"#), + out(r#"{"ok":true,"data":null}"#), + out(r#"{"ok":true,"data":{"services":[]}}"#), + out(&serde_json::json!({ + "ok": true, + "data": { "variables": { + "CLOUDFLARE_ACCOUNT_ID": "acc_123", + "CLOUDFLARE_BUCKET_NAME": "atto-demo-assets", + "CLOUDFLARE_ENDPOINT": "https://acc_123.r2.cloudflarestorage.com", + "CLOUDFLARE_DASHBOARD_URL": "https://dash.cloudflare.com/acc_123/r2/atto-demo-assets" + }} + }) + .to_string()), + out(r#"{"ok":true,"data":null}"#), + // env --pull --refresh for the resource-prefixed candidate keys + out(r#"{"ok":true,"data":null}"#), + ]); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = CloudflareR2 + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "bucket", + "local", + false, + ) + .await + .unwrap(); + + assert_eq!(resource.resource_kind, "integration-cloudflare-r2"); + assert_eq!(resource.resource_id, "demo-bucket"); + let payload: CloudflarePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["bucket"], "atto-demo-assets"); + assert_eq!(payload.outputs["account_id"], "acc_123"); + assert_eq!( + payload.outputs["endpoint"], + "https://acc_123.r2.cloudflarestorage.com" + ); + + let calls = runner.calls(); + let add = calls + .iter() + .find(|call| call.first().map(String::as_str) == Some("add")) + .expect("an add call"); + assert_eq!(add[1], "cloudflare/r2:bucket"); + assert!(add.contains(&"--name".to_owned()) && add.contains(&"demo-bucket".to_owned())); + // R2 is paid → the catalog tier drives `--confirm-paid-service`. + assert!(add.contains(&"--confirm-paid-service".to_owned())); + } + + #[tokio::test] + async fn observe_and_destroy_use_stripe_resource_from_payload() { + let payload = serde_json::to_string(&CloudflarePayload { + stripe_resource: "demo-bucket".into(), + outputs: BTreeMap::new(), + }) + .unwrap(); + let runner = ScriptedRunner::new(vec![ + out(r#"{"ok":true,"data":{"services":[{"name":"demo-bucket"}]}}"#), + out(r#"{"ok":true,"data":{"services":[{"name":"demo-bucket"}]}}"#), + out(r#"{"ok":true,"data":null}"#), + ]); + let stripe = StripeProjects::new(&runner, std::env::temp_dir()); + + assert_eq!( + crate::resource::observe_resource(&stripe.as_dyn(), &payload, "fallback") + .await + .unwrap(), + Observation::Present + ); + crate::resource::destroy_resource(&stripe.as_dyn(), &payload, "fallback") + .await + .unwrap(); + let calls = runner.calls(); + assert!( + calls + .iter() + .any(|call| call.starts_with(&["remove".to_owned(), "demo-bucket".to_owned()])) + ); + } +} diff --git a/crates/stackless-integrations/src/providers/cloudflare/workers.rs b/crates/stackless-integrations/src/providers/cloudflare/workers.rs new file mode 100644 index 0000000..8e6d7f1 --- /dev/null +++ b/crates/stackless-integrations/src/providers/cloudflare/workers.rs @@ -0,0 +1,174 @@ +//! Cloudflare Workers compute resource (`cloudflare/workers`). +//! +//! This provisions the Workers *resource* (account-level Workers enablement + +//! a `*.workers.dev` subdomain) and exposes its coordinates — it is NOT a deploy +//! target. Deploying a service's code to Workers (`--on cloudflare`) is a +//! separate substrate (out of scope; see the plan's deferred section). + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::CloudflareResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; + +pub const RESOURCE_KIND: &str = "integration-cloudflare-workers"; + +/// `cloudflare/workers` takes no configuration. +#[derive(Debug, Serialize)] +pub struct WorkersConfig {} + +impl CatalogService for WorkersConfig { + const REFERENCE: &'static str = "cloudflare/workers"; +} + +#[derive(Debug)] +pub struct CloudflareWorkers; + +impl Hostable for CloudflareWorkers { + const PROVIDER: &'static str = "cloudflare-workers"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &[ + "account_id", + "workers_dev_subdomain", + "api_base_url", + "dashboard_url", + "plan_service_id", + ]; +} + +impl CloudflareResource for CloudflareWorkers { + type Config = WorkersConfig; + const PROVIDER_PREFIX: &'static str = "CLOUDFLARE"; + // Confirmed by live provisioning 2026-06-16. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("ACCOUNT_ID", "account_id", true), + ("WORKERS_DEV_SUBDOMAIN", "workers_dev_subdomain", true), + ("API_BASE_URL", "api_base_url", false), + ("DASHBOARD_URL", "dashboard_url", false), + ("PLAN_SERVICE_ID", "plan_service_id", false), + ]; + + fn build_config(_ctx: &ProvisionContext<'_>) -> Result { + Ok(WorkersConfig {}) + } +} + +pub fn validate_config( + _name: &str, + _config: &BTreeMap, +) -> Result<(), IntegrationError> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload as CloudflarePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::{CommandOutput, StripeProjects}; + use stackless_stripe_projects::test_support::ScriptedRunner; + + fn out(stdout: &str) -> CommandOutput { + CommandOutput { + status: 0, + stdout: stdout.to_owned(), + stderr: String::new(), + } + } + + #[test] + fn workers_config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service(&catalog, &WorkersConfig {}); + assert!( + failures.is_empty(), + "cloudflare/workers catalog gaps:\n{}", + failures.join("\n") + ); + } + + const WORKERS_CATALOG_ENVELOPE: &str = r#"{"ok":true,"command":"projects catalog","data":{ + "last_updated":"2026-06-16T00:00:00Z","services":[{ + "id":"prvsvc_workers","object":"v2.provisioning.provider_service_detail", + "provider_id":"prvdr_cloudflare","provider_name":"Cloudflare","service_id":"workers", + "categories":["compute"],"kind":"deployable","scope":"project","availability":"available", + "development":false,"livemode":true,"pricing":{"type":"component"}, + "configuration_schema":{"type":"object","additionalProperties":false,"properties":{}} + }]}}"#; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.edge] +provider = "cloudflare-workers" +[services.api] +source = { repo = "r", ref = "main" } +env = { CF_SUBDOMAIN = "${integrations.edge.workers_dev_subdomain}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_workers_records_outputs() { + let runner = ScriptedRunner::new(vec![ + out(WORKERS_CATALOG_ENVELOPE), + out(r#"{"ok":true,"data":{"project":{"id":"project_1"}}}"#), + out(r#"{"ok":true,"data":{"environments":[{"name":"demo"}]}}"#), + out(r#"{"ok":true,"data":null}"#), + out(r#"{"ok":true,"data":{"services":[]}}"#), + out(&serde_json::json!({"ok":true,"data":{"variables":{ + "CLOUDFLARE_ACCOUNT_ID": "acc_1", + "CLOUDFLARE_WORKERS_DEV_SUBDOMAIN": "atto-demo", + "CLOUDFLARE_API_BASE_URL": "https://api.cloudflare.com/client/v4", + "CLOUDFLARE_DASHBOARD_URL": "https://dash.cloudflare.com/acc_1/workers", + "CLOUDFLARE_PLAN_SERVICE_ID": "svc_1" + }}}) + .to_string()), + out(r#"{"ok":true,"data":null}"#), + out(r#"{"ok":true,"data":null}"#), + ]); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = CloudflareWorkers + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "edge", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-cloudflare-workers"); + let payload: CloudflarePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["workers_dev_subdomain"], "atto-demo"); + assert_eq!(payload.outputs["account_id"], "acc_1"); + } +} diff --git a/crates/stackless-integrations/src/providers/cloudflare/workers_ai.rs b/crates/stackless-integrations/src/providers/cloudflare/workers_ai.rs new file mode 100644 index 0000000..cc93307 --- /dev/null +++ b/crates/stackless-integrations/src/providers/cloudflare/workers_ai.rs @@ -0,0 +1,166 @@ +//! Cloudflare Workers AI (`cloudflare/workers-ai`) — an account-level AI binding. +//! Same output shape as `cloudflare/workers` (confirmed by live discovery). + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::CloudflareResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; + +pub const RESOURCE_KIND: &str = "integration-cloudflare-workers-ai"; + +#[derive(Debug, Serialize)] +pub struct WorkersAiConfig {} + +impl CatalogService for WorkersAiConfig { + const REFERENCE: &'static str = "cloudflare/workers-ai"; +} + +#[derive(Debug)] +pub struct CloudflareWorkersAi; + +impl Hostable for CloudflareWorkersAi { + const PROVIDER: &'static str = "cloudflare-workers-ai"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &[ + "account_id", + "workers_dev_subdomain", + "api_base_url", + "dashboard_url", + "plan_service_id", + ]; +} + +impl CloudflareResource for CloudflareWorkersAi { + type Config = WorkersAiConfig; + const PROVIDER_PREFIX: &'static str = "CLOUDFLARE"; + // Confirmed by live discovery 2026-06-16 (Worker-family shape). + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("ACCOUNT_ID", "account_id", true), + ("WORKERS_DEV_SUBDOMAIN", "workers_dev_subdomain", true), + ("API_BASE_URL", "api_base_url", false), + ("DASHBOARD_URL", "dashboard_url", false), + ("PLAN_SERVICE_ID", "plan_service_id", false), + ]; + + fn build_config(_ctx: &ProvisionContext<'_>) -> Result { + Ok(WorkersAiConfig {}) + } +} + +pub fn validate_config( + _name: &str, + _config: &BTreeMap, +) -> Result<(), IntegrationError> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload as CloudflarePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::{CommandOutput, StripeProjects}; + use stackless_stripe_projects::test_support::ScriptedRunner; + + fn out(stdout: &str) -> CommandOutput { + CommandOutput { + status: 0, + stdout: stdout.to_owned(), + stderr: String::new(), + } + } + + #[test] + fn workers_ai_config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service(&catalog, &WorkersAiConfig {}); + assert!( + failures.is_empty(), + "cloudflare/workers-ai catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r#"{"ok":true,"command":"projects catalog","data":{ + "last_updated":"2026-06-16T00:00:00Z","services":[{ + "id":"prvsvc_wai","object":"v2.provisioning.provider_service_detail", + "provider_id":"prvdr_cloudflare","provider_name":"Cloudflare","service_id":"workers-ai", + "categories":["compute"],"kind":"deployable","scope":"project","availability":"available", + "development":false,"livemode":true,"pricing":{"type":"component"}, + "configuration_schema":{"type":"object","additionalProperties":false,"properties":{}} + }]}}"#; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.ai] +provider = "cloudflare-workers-ai" +[services.api] +source = { repo = "r", ref = "main" } +env = { CF_SUBDOMAIN = "${integrations.ai.workers_dev_subdomain}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_workers_ai_records_outputs() { + let runner = ScriptedRunner::new(vec![ + out(CATALOG_ENVELOPE), + out(r#"{"ok":true,"data":{"project":{"id":"project_1"}}}"#), + out(r#"{"ok":true,"data":{"environments":[{"name":"demo"}]}}"#), + out(r#"{"ok":true,"data":null}"#), + out(r#"{"ok":true,"data":{"services":[]}}"#), + out(&serde_json::json!({"ok":true,"data":{"variables":{ + "CLOUDFLARE_ACCOUNT_ID": "acc_1", + "CLOUDFLARE_WORKERS_DEV_SUBDOMAIN": "atto-demo" + }}}) + .to_string()), + out(r#"{"ok":true,"data":null}"#), + out(r#"{"ok":true,"data":null}"#), + ]); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = CloudflareWorkersAi + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "ai", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-cloudflare-workers-ai"); + let payload: CloudflarePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["workers_dev_subdomain"], "atto-demo"); + assert_eq!(payload.outputs["account_id"], "acc_1"); + } +} diff --git a/crates/stackless-integrations/src/providers/mod.rs b/crates/stackless-integrations/src/providers/mod.rs index 9b0c46d..4d60631 100644 --- a/crates/stackless-integrations/src/providers/mod.rs +++ b/crates/stackless-integrations/src/providers/mod.rs @@ -1 +1,2 @@ pub mod clerk; +pub mod cloudflare; diff --git a/crates/stackless-integrations/src/registry.rs b/crates/stackless-integrations/src/registry.rs index ca7cff1..8ee5791 100644 --- a/crates/stackless-integrations/src/registry.rs +++ b/crates/stackless-integrations/src/registry.rs @@ -13,7 +13,6 @@ use std::collections::BTreeMap; use stackless_core::def::interp::{self, Reference}; use stackless_core::def::{Integration, StackDef}; -use stackless_core::host::Host; use crate::error::IntegrationError; use crate::hostable::{ @@ -23,7 +22,9 @@ use crate::providers; type ValidateFn = fn(&str, &BTreeMap) -> Result<(), IntegrationError>; -/// One row in the provider table, materialized from a [`Hostable`] impl. +/// One row in the provider table, materialized from a [`Hostable`] impl plus +/// its lifecycle [`ProviderOps`]. The registry is the single source of truth +/// for both metadata and behaviour — dispatch never matches provider strings. struct ProviderEntry { provider: &'static str, hosting: IntegrationHosting, @@ -31,9 +32,13 @@ struct ProviderEntry { resource_kind: &'static str, outputs: &'static [&'static str], validate_config: ValidateFn, + ops: &'static dyn crate::ProviderOps, } -const fn provider_entry(validate_config: ValidateFn) -> ProviderEntry { +const fn provider_entry( + validate_config: ValidateFn, + ops: &'static dyn crate::ProviderOps, +) -> ProviderEntry { ProviderEntry { provider: T::PROVIDER, hosting: T::HOSTING, @@ -41,12 +46,48 @@ const fn provider_entry(validate_config: ValidateFn) -> ProviderEnt resource_kind: T::RESOURCE_KIND, outputs: T::OUTPUTS, validate_config, + ops, } } -const PROVIDERS: &[ProviderEntry] = &[provider_entry::( - providers::clerk::validate_config, -)]; +const PROVIDERS: &[ProviderEntry] = &[ + provider_entry::( + providers::clerk::validate_config, + &providers::clerk::ClerkAuth, + ), + provider_entry::( + providers::cloudflare::r2::validate_config, + &providers::cloudflare::r2::CloudflareR2, + ), + provider_entry::( + providers::cloudflare::kv::validate_config, + &providers::cloudflare::kv::CloudflareKv, + ), + provider_entry::( + providers::cloudflare::d1::validate_config, + &providers::cloudflare::d1::CloudflareD1, + ), + provider_entry::( + providers::cloudflare::queues::validate_config, + &providers::cloudflare::queues::CloudflareQueues, + ), + provider_entry::( + providers::cloudflare::hyperdrive::validate_config, + &providers::cloudflare::hyperdrive::CloudflareHyperdrive, + ), + provider_entry::( + providers::cloudflare::workers::validate_config, + &providers::cloudflare::workers::CloudflareWorkers, + ), + provider_entry::( + providers::cloudflare::workers_ai::validate_config, + &providers::cloudflare::workers_ai::CloudflareWorkersAi, + ), + provider_entry::( + providers::cloudflare::browser_run::validate_config, + &providers::cloudflare::browser_run::CloudflareBrowserRun, + ), +]; fn lookup(provider: &str) -> Option<&'static ProviderEntry> { PROVIDERS.iter().find(|entry| entry.provider == provider) @@ -63,14 +104,21 @@ pub fn known_outputs(provider: &str) -> Option<&'static [&'static str]> { pub fn validate_integration( name: &str, integration: &Integration, - active_host: Option, + active_host: Option<&str>, + known_substrates: &[&str], ) -> Result<(), IntegrationError> { let entry = lookup(&integration.provider).ok_or_else(|| IntegrationError::ConfigInvalid { location: format!("integrations.{name}"), detail: format!("unsupported provider {:?}", integration.provider), })?; - validate_host_blocks(name, integration, entry.hosting, entry.config_scope)?; + validate_host_blocks( + name, + integration, + entry.hosting, + entry.config_scope, + known_substrates, + )?; if let Some(host) = active_host && matches!(entry.hosting, IntegrationHosting::HostBound(_)) @@ -78,22 +126,26 @@ pub fn validate_integration( { return Err(IntegrationError::HostUnsupported { provider: integration.provider.clone(), - host, + host: host.to_owned(), }); } let config = match (entry.config_scope, active_host) { - (ConfigScope::PerHost, Some(host)) => integration.effective_config(host), - _ => integration.config_fields(), + (ConfigScope::PerHost, Some(host)) => integration.effective_config(host, known_substrates), + _ => integration.config_fields(known_substrates), }; (entry.validate_config)(name, &config) } -pub fn validate_all(def: &StackDef, active_host: Option) -> Result<(), IntegrationError> { +pub fn validate_all( + def: &StackDef, + active_host: Option<&str>, + known_substrates: &[&str], +) -> Result<(), IntegrationError> { for (name, integration) in &def.integrations { - validate_integration(name, integration, active_host)?; + validate_integration(name, integration, active_host, known_substrates)?; } - validate_integration_outputs(def)?; + validate_integration_outputs(def, known_substrates)?; Ok(()) } @@ -102,11 +154,12 @@ fn validate_host_blocks( integration: &Integration, hosting: IntegrationHosting, scope: ConfigScope, + known_substrates: &[&str], ) -> Result<(), IntegrationError> { - for (host, _block) in integration.host_blocks() { + for (host, _block) in integration.host_blocks(known_substrates) { if matches!(hosting, IntegrationHosting::Managed) { return Err(IntegrationError::ConfigInvalid { - location: format!("integrations.{name}.{}", host.as_str()), + location: format!("integrations.{name}.{host}"), detail: format!( "provider {:?} is managed and does not support per-host configuration", integration.provider @@ -115,19 +168,18 @@ fn validate_host_blocks( } if matches!(scope, ConfigScope::GlobalOnly) { return Err(IntegrationError::ConfigInvalid { - location: format!("integrations.{name}.{}", host.as_str()), + location: format!("integrations.{name}.{host}"), detail: format!( "provider {:?} does not support per-host configuration", integration.provider ), }); } - if !host_bound_supports(hosting, host) { + if !host_bound_supports(hosting, &host) { return Err(IntegrationError::ConfigInvalid { - location: format!("integrations.{name}.{}", host.as_str()), + location: format!("integrations.{name}.{host}"), detail: format!( - "host {:?} is not supported by provider {:?}", - host.as_str(), + "host {host:?} is not supported by provider {:?}", integration.provider ), }); @@ -137,7 +189,10 @@ fn validate_host_blocks( Ok(()) } -fn validate_integration_outputs(def: &StackDef) -> Result<(), IntegrationError> { +fn validate_integration_outputs( + def: &StackDef, + known_substrates: &[&str], +) -> Result<(), IntegrationError> { let mut locations = Vec::new(); if let Some(verify) = &def.stack.verify { for (key, value) in &verify.env { @@ -148,24 +203,19 @@ fn validate_integration_outputs(def: &StackDef) -> Result<(), IntegrationError> for (key, value) in &service.env { locations.push((format!("services.{service_name}.env.{key}"), value.clone())); } - for host in Host::ALL { - for (key, value) in - service - .substrate_env(service_name, host.as_str()) - .map_err(|err| IntegrationError::ConfigInvalid { - location: format!("services.{service_name}.{}.env", host.as_str()), - detail: err.to_string(), - })? - { - locations.push(( - format!("services.{service_name}.{}.env.{key}", host.as_str()), - value, - )); + for &host in known_substrates { + for (key, value) in service.substrate_env(service_name, host).map_err(|err| { + IntegrationError::ConfigInvalid { + location: format!("services.{service_name}.{host}.env"), + detail: err.to_string(), + } + })? { + locations.push((format!("services.{service_name}.{host}.env.{key}"), value)); } } } for (name, integration) in &def.integrations { - for (key, value) in integration.config_fields() { + for (key, value) in integration.config_fields(known_substrates) { if let Some(text) = value.as_str() { locations.push((format!("integrations.{name}.{key}"), text.to_owned())); } @@ -213,6 +263,29 @@ pub fn dispatch_resource_kind(provider: &str) -> Option<&'static str> { lookup(provider).map(|entry| entry.resource_kind) } +/// The lifecycle ops for `provider`, for provisioning dispatch. +pub fn ops_for(provider: &str) -> Option<&'static dyn crate::ProviderOps> { + lookup(provider).map(|entry| entry.ops) +} + +/// The lifecycle ops owning resources of `kind`, for observe/destroy dispatch. +pub fn ops_for_resource_kind(kind: &str) -> Option<&'static dyn crate::ProviderOps> { + PROVIDERS + .iter() + .find(|entry| entry.resource_kind == kind) + .map(|entry| entry.ops) +} + +/// The substrate keys that count as host overrides for `provider`'s config — +/// its declared host-bound hosts (empty for managed providers). The provision +/// path uses this since it has no global substrate list; the authoritative +/// misplaced-block check runs at `up`/`check` with the full substrate list. +pub fn provider_host_keys(provider: &str) -> &'static [&'static str] { + lookup(provider) + .map(|entry| host_bound_hosts(entry.hosting)) + .unwrap_or(&[]) +} + pub fn config_string( config: &BTreeMap, key: &str, @@ -245,10 +318,26 @@ pub fn config_optional_string(config: &BTreeMap, key: &str) mod tests { use stackless_core::def::StackDef; use stackless_core::fault::{Fault, codes}; - use stackless_core::host::Host; use super::*; + const KNOWN: &[&str] = &["local", "render", "vercel"]; + + /// Registry hygiene: every provider string and resource kind is unique, so a + /// new `PROVIDERS` row can't silently shadow another's dispatch. + #[test] + fn registry_providers_and_resource_kinds_are_unique() { + use std::collections::BTreeSet; + let providers: BTreeSet<&str> = PROVIDERS.iter().map(|e| e.provider).collect(); + assert_eq!( + providers.len(), + PROVIDERS.len(), + "duplicate provider string" + ); + let kinds: BTreeSet<&str> = PROVIDERS.iter().map(|e| e.resource_kind).collect(); + assert_eq!(kinds.len(), PROVIDERS.len(), "duplicate resource_kind"); + } + #[test] fn managed_provider_rejects_host_block() { let def = StackDef::parse( @@ -268,7 +357,7 @@ run = "true" "#, ) .unwrap(); - let err = validate_integration("clerk", &def.integrations["clerk"], Some(Host::Render)) + let err = validate_integration("clerk", &def.integrations["clerk"], Some("render"), KNOWN) .unwrap_err(); assert_eq!(err.code(), codes::INTEGRATION_CONFIG_INVALID); assert!(err.to_string().contains("managed")); @@ -296,7 +385,7 @@ run = "true" ), ]), }; - let err = validate_integration("clerk", &integration, None).unwrap_err(); + let err = validate_integration("clerk", &integration, None, KNOWN).unwrap_err(); assert_eq!(err.code(), codes::INTEGRATION_CONFIG_INVALID); } } diff --git a/crates/stackless-integrations/src/resource.rs b/crates/stackless-integrations/src/resource.rs new file mode 100644 index 0000000..15591db --- /dev/null +++ b/crates/stackless-integrations/src/resource.rs @@ -0,0 +1,255 @@ +//! The generic catalog-resource integration: declare a config + output fields + +//! a provider prefix, and get `ProviderOps` (provision/observe/destroy) for free. +//! +//! This is the default shape for any provider whose credentials come back as +//! several flat env vars (Cloudflare R2/KV/D1/Workers/…). Stripe names them +//! `{RESOURCE}_{SUFFIX}` (when several resources share an environment) or +//! `{PROVIDER}_{SUFFIX}` (when unambiguous); the shared resolution handles both. +//! Providers with a single bespoke credential blob (Clerk) stay custom. + +use std::collections::BTreeMap; +use std::path::Path; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use stackless_core::def::{Namespace, StackDef}; +use stackless_core::substrate::{Observation, StepResource}; +use stackless_core::types::DnsName; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::project; +use stackless_stripe_projects::provision::{ProvisionContext, provision_outputs}; +use stackless_stripe_projects::stripe::{CommandRunner, StripeProjects}; + +use crate::error::IntegrationError; +use crate::hostable::Hostable; +use crate::registry; + +/// A catalog resource: a typed config + the credential fields it exposes. The +/// `ProviderOps` lifecycle is derived (blanket impl below), so a new resource is +/// just this trait + a `Hostable` + one registry row. +pub trait CatalogResource: Hostable { + // `Send + Sync` so the generic `ProviderOps` future (which holds the config + // and a `&config` across awaits) is `Send` for the boxed `async_trait`. + type Config: CatalogService + Send + Sync; + /// The env-var prefix the provider uses for the unambiguous form, e.g. + /// `"CLOUDFLARE"` (vars come back as `{RESOURCE}_{SUFFIX}` or `{PROVIDER}_{SUFFIX}`). + const PROVIDER_PREFIX: &'static str; + /// `(env-var suffix, output name, required)`. Discover the real suffixes with + /// `xtask discover `; pinned by the live smoke. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result; +} + +/// The checkpoint payload for any catalog resource: the Stripe resource name +/// (for observe/destroy) and the `${integrations..}` map. +#[derive(Debug, Serialize, Deserialize)] +pub struct ResourcePayload { + pub stripe_resource: String, + pub outputs: BTreeMap, +} + +/// Every `CatalogResource` gets its `ProviderOps` for free — the lifecycle is +/// identical, so a per-resource impl would be pure boilerplate. (Does not +/// conflict with Clerk: `ClerkAuth` is not a `CatalogResource`.) +#[async_trait] +impl crate::ProviderOps for T { + #[allow(clippy::too_many_arguments)] + async fn provision( + &self, + stripe: &StripeProjects<&dyn CommandRunner>, + def: &StackDef, + definition_dir: &Path, + instance: &str, + name: &str, + substrate: &str, + skip_stripe_instance_context: bool, + ) -> Result { + provision_resource::( + stripe, + def, + definition_dir, + instance, + name, + substrate, + skip_stripe_instance_context, + ) + .await + } + + async fn observe( + &self, + stripe: &StripeProjects<&dyn CommandRunner>, + checkpoint_payload: &str, + fallback_resource: &str, + ) -> Result { + observe_resource(stripe, checkpoint_payload, fallback_resource).await + } + + async fn destroy( + &self, + stripe: &StripeProjects<&dyn CommandRunner>, + checkpoint_payload: &str, + fallback_resource: &str, + ) -> Result<(), IntegrationError> { + destroy_resource(stripe, checkpoint_payload, fallback_resource).await + } +} + +/// Provision a resource: build config, add the catalog resource (validated + +/// paid-confirmed by the catalog tier), resolve its declared output fields +/// (shared dual-form resolution), and record them. +#[allow(clippy::too_many_arguments)] +pub async fn provision_resource( + stripe: &StripeProjects<&dyn CommandRunner>, + def: &StackDef, + definition_dir: &Path, + instance: &str, + name: &str, + substrate: &str, + skip_instance_context: bool, +) -> Result { + if !def.integrations.contains_key(name) { + return Err(IntegrationError::ConfigInvalid { + location: format!("integrations.{name}"), + detail: "integration not in definition".into(), + }); + } + let ctx = ProvisionContext { + def, + instance, + logical_name: name, + definition_dir, + substrate, + skip_instance_context, + }; + let config = T::build_config(&ctx)?; + let catalog = stripe.catalog().await?; + let (resource_name, outputs) = provision_outputs( + stripe, + &catalog, + &ctx, + &config, + T::PROVIDER_PREFIX, + T::OUTPUT_FIELDS, + ) + .await?; + let payload = ResourcePayload { + stripe_resource: resource_name.clone(), + outputs, + }; + Ok(StepResource { + resource_kind: T::RESOURCE_KIND.into(), + resource_id: resource_name, + payload: serde_json::to_string(&payload).unwrap_or_default(), + }) +} + +/// Re-check a recorded resource against Stripe Projects. +pub async fn observe_resource( + stripe: &StripeProjects<&dyn CommandRunner>, + checkpoint_payload: &str, + fallback_resource: &str, +) -> Result { + let resource = stripe_resource(checkpoint_payload).unwrap_or_else(|| fallback_resource.into()); + let present = project::resource_registered(stripe, &resource).await?; + Ok(if present { + Observation::Present + } else { + Observation::Gone + }) +} + +/// Remove a recorded resource from Stripe Projects. +pub async fn destroy_resource( + stripe: &StripeProjects<&dyn CommandRunner>, + checkpoint_payload: &str, + fallback_resource: &str, +) -> Result<(), IntegrationError> { + let resource = stripe_resource(checkpoint_payload).unwrap_or_else(|| fallback_resource.into()); + project::remove_resource(stripe, &resource).await?; + Ok(()) +} + +fn stripe_resource(payload: &str) -> Option { + serde_json::from_str::(payload) + .ok() + .map(|payload| payload.stripe_resource) +} + +/// The integration's effective config. Resource integrations are `Managed` +/// (`GlobalOnly`), so there are no per-host override blocks to strip. +pub(crate) fn integration_config( + ctx: &ProvisionContext<'_>, +) -> Result, IntegrationError> { + let spec = ctx.def.integrations.get(ctx.logical_name).ok_or_else(|| { + IntegrationError::ConfigInvalid { + location: format!("integrations.{}", ctx.logical_name), + detail: "integration not in definition".into(), + } + })?; + Ok(spec.effective_config(ctx.substrate, &[])) +} + +/// Read a required string field and interpolate `${...}` references. +pub(crate) fn interp_required( + ctx: &ProvisionContext<'_>, + config: &BTreeMap, + key: &str, +) -> Result { + let raw = registry::config_string(config, key) + .map_err(|err| cfg_invalid(ctx, key, err.to_string()))?; + interp_value(ctx, key, &raw) +} + +/// Read an optional string field and interpolate it when present. +pub(crate) fn interp_optional( + ctx: &ProvisionContext<'_>, + config: &BTreeMap, + key: &str, +) -> Result, IntegrationError> { + match registry::config_optional_string(config, key) { + None => Ok(None), + Some(raw) => Ok(Some(interp_value(ctx, key, &raw)?)), + } +} + +fn interp_value( + ctx: &ProvisionContext<'_>, + key: &str, + raw: &str, +) -> Result { + let namespace = Namespace { + stack_name: ctx.def.stack.name.clone(), + instance_name: DnsName::from_stored(ctx.instance), + ..Namespace::default() + }; + let location = format!("integrations.{}.{key}", ctx.logical_name); + stackless_core::def::interp::resolve(raw, &namespace, &location) + .map_err(|err| cfg_invalid(ctx, key, err.to_string())) +} + +/// Read a required integer field (e.g. a port) from the effective config. +pub(crate) fn int_required( + ctx: &ProvisionContext<'_>, + config: &BTreeMap, + key: &str, +) -> Result { + config + .get(key) + .and_then(toml::Value::as_integer) + .ok_or_else(|| { + cfg_invalid( + ctx, + key, + format!("{key} is required and must be an integer"), + ) + }) +} + +fn cfg_invalid(ctx: &ProvisionContext<'_>, key: &str, detail: String) -> IntegrationError { + IntegrationError::ConfigInvalid { + location: format!("integrations.{}.{key}", ctx.logical_name), + detail, + } +} diff --git a/crates/stackless-local/src/lib.rs b/crates/stackless-local/src/lib.rs index 1cf29b2..81e129a 100644 --- a/crates/stackless-local/src/lib.rs +++ b/crates/stackless-local/src/lib.rs @@ -22,7 +22,7 @@ use stackless_core::engine::StepKind; use stackless_core::process::ProcessStamp; use stackless_core::state::Checkpoint; use stackless_core::substrate::{ - NamespacePurpose, Observation, StepContext, StepResource, Substrate, SubstrateFault, + NamespacePurpose, Observation, ServiceLog, StepContext, StepResource, Substrate, SubstrateFault, }; use stackless_core::types::{DnsName, LogPath, ProxyHost, TcpPort}; use stackless_daemon::DaemonClient; @@ -720,4 +720,32 @@ impl Substrate for LocalSubstrate { stackless_integrations::finalize_stripe_instance(&stripe, instance).await; Ok(()) } + + /// Recent logs from the per-service log files the daemon spawner writes. + async fn fetch_logs( + &self, + _def: &StackDef, + instance: &str, + services: &[String], + tail: usize, + ) -> Result>, SubstrateFault> { + let spawner = crate::spawn::Spawner::new(instance); + let logs = services + .iter() + .map(|service| { + let tail_text = spawner.log_tail(service, tail); + ServiceLog { + service: service.clone(), + source: "file", + log_path: Some(spawner.log_path(service).display().to_string()), + lines: if tail_text.is_empty() { + vec![] + } else { + tail_text.lines().map(str::to_owned).collect() + }, + } + }) + .collect(); + Ok(Some(logs)) + } } diff --git a/crates/stackless-render/Cargo.toml b/crates/stackless-render/Cargo.toml index bd58e8a..a067a01 100644 --- a/crates/stackless-render/Cargo.toml +++ b/crates/stackless-render/Cargo.toml @@ -23,6 +23,7 @@ reqwest = { version = "0.13.4", default-features = false, features = [ ] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" +stackless-cloud.workspace = true stackless-core.workspace = true stackless-git.workspace = true stackless-integrations.workspace = true diff --git a/crates/stackless-render/src/api_key.rs b/crates/stackless-render/src/api_key.rs index 493bd96..dae1343 100644 --- a/crates/stackless-render/src/api_key.rs +++ b/crates/stackless-render/src/api_key.rs @@ -1,9 +1,7 @@ -//! Render API key resolution (§4). -//! -//! Order: the `RENDER_API_KEY` env var, then a `RENDER_API_KEY` entry in the -//! resolved secrets (`.stackless.env`), then a 0600 key file at -//! `/.render-api-key`. A missing key is a clean fault naming -//! all three sources. +//! Render API key resolution (§4): env var, then resolved secrets, then a 0600 +//! key file. Resolution is shared (`stackless_cloud::credential`); the env var +//! and key-file names are Render's, and a miss maps to Render's error so its +//! `render.api_key.missing` code and remediation hold. use std::collections::BTreeMap; use std::path::Path; @@ -13,36 +11,15 @@ use crate::error::RenderError; pub const KEY_FILE: &str = ".render-api-key"; pub const KEY_ENV: &str = "RENDER_API_KEY"; -/// Resolve the key from the environment, the resolved secrets, or the scoped -/// key file next to the definition. The env var wins so CI can inject without a -/// file; `.stackless.env` is the project's canonical secret store; the file is a -/// scoped fallback. pub fn resolve( definition_dir: &Path, secrets: &BTreeMap, ) -> Result { - if let Ok(key) = std::env::var(KEY_ENV) { - let key = key.trim().to_owned(); - if !key.is_empty() { - return Ok(key); - } - } - if let Some(key) = secrets.get(KEY_ENV) { - let key = key.trim().to_owned(); - if !key.is_empty() { - return Ok(key); - } - } - let key_file = definition_dir.join(KEY_FILE); - if let Ok(contents) = std::fs::read_to_string(&key_file) { - let key = contents.trim().to_owned(); - if !key.is_empty() { - return Ok(key); - } - } - Err(RenderError::ApiKeyMissing { - key_file: key_file.display().to_string(), - }) + stackless_cloud::credential::resolve(KEY_ENV, KEY_FILE, definition_dir, secrets).map_err( + |missing| RenderError::ApiKeyMissing { + key_file: missing.key_file, + }, + ) } #[cfg(test)] diff --git a/crates/stackless-render/src/codes.rs b/crates/stackless-render/src/codes.rs new file mode 100644 index 0000000..944a3d6 --- /dev/null +++ b/crates/stackless-render/src/codes.rs @@ -0,0 +1,30 @@ +//! Stable error codes for the Render substrate (ARCHITECTURE.md §2/§8). +//! +//! Codes live with the provider, not in core — adding a hosting provider adds +//! no codes to `stackless-core`. The binary aggregates every crate's `ALL` for +//! a workspace-wide uniqueness check. + +pub const RENDER_CONFIG_INVALID: &str = "render.config.invalid"; +pub const RENDER_API_KEY_MISSING: &str = "render.api_key.missing"; +pub const RENDER_API_FAILED: &str = "render.api.failed"; +pub const RENDER_PAYMENT_NOT_CONFIRMED: &str = "render.payment.not_confirmed"; +pub const RENDER_PROVISION_FAILED: &str = "render.provision.failed"; +pub const RENDER_DEPLOY_FAILED: &str = "render.deploy.failed"; +pub const RENDER_DEPLOY_TIMEOUT: &str = "render.deploy.timeout"; +pub const RENDER_HEALTH_FAILED: &str = "render.health.failed"; +pub const RENDER_PREPARE_FAILED: &str = "render.prepare.failed"; +pub const RENDER_TEARDOWN_SURVIVOR: &str = "render.teardown.survivor"; + +/// Every Render code, for the workspace uniqueness test. +pub const ALL: &[&str] = &[ + RENDER_CONFIG_INVALID, + RENDER_API_KEY_MISSING, + RENDER_API_FAILED, + RENDER_PAYMENT_NOT_CONFIRMED, + RENDER_PROVISION_FAILED, + RENDER_DEPLOY_FAILED, + RENDER_DEPLOY_TIMEOUT, + RENDER_HEALTH_FAILED, + RENDER_PREPARE_FAILED, + RENDER_TEARDOWN_SURVIVOR, +]; diff --git a/crates/stackless-render/src/config.rs b/crates/stackless-render/src/config.rs index 39fbfc5..c7a2c9d 100644 --- a/crates/stackless-render/src/config.rs +++ b/crates/stackless-render/src/config.rs @@ -341,7 +341,7 @@ static = { build = "bun run build", publish = "./dist", spa_rewrite = true } let err = RenderSubstrate::::service_render(&parse(&toml), "api").unwrap_err(); assert_eq!( stackless_core::fault::Fault::code(&err), - stackless_core::fault::codes::RENDER_CONFIG_INVALID + crate::codes::RENDER_CONFIG_INVALID ); } @@ -354,7 +354,7 @@ static = { build = "bun run build", publish = "./dist", spa_rewrite = true } let err = RenderSubstrate::::service_render(&parse(&toml), "web").unwrap_err(); assert_eq!( stackless_core::fault::Fault::code(&err), - stackless_core::fault::codes::RENDER_CONFIG_INVALID + crate::codes::RENDER_CONFIG_INVALID ); } @@ -367,7 +367,7 @@ static = { build = "bun run build", publish = "./dist", spa_rewrite = true } let err = RenderSubstrate::::service_render(&parse(&toml), "api").unwrap_err(); assert_eq!( stackless_core::fault::Fault::code(&err), - stackless_core::fault::codes::RENDER_CONFIG_INVALID + crate::codes::RENDER_CONFIG_INVALID ); } @@ -377,7 +377,7 @@ static = { build = "bun run build", publish = "./dist", spa_rewrite = true } let err = RenderSubstrate::::datastore_plan(&parse(&toml), "db").unwrap_err(); assert_eq!( stackless_core::fault::Fault::code(&err), - stackless_core::fault::codes::RENDER_CONFIG_INVALID + crate::codes::RENDER_CONFIG_INVALID ); } } diff --git a/crates/stackless-render/src/error.rs b/crates/stackless-render/src/error.rs index 40cc4f2..e944d73 100644 --- a/crates/stackless-render/src/error.rs +++ b/crates/stackless-render/src/error.rs @@ -1,6 +1,8 @@ -//! Render-substrate errors (codes in core's `render.*` registry). +//! Render-substrate errors (codes in this crate's `render.*` registry). -use stackless_core::fault::{ErrorContext, Fault, codes}; +use stackless_core::fault::{ErrorContext, Fault}; + +use crate::codes; #[derive(Debug, thiserror::Error)] pub enum RenderError { diff --git a/crates/stackless-render/src/lib.rs b/crates/stackless-render/src/lib.rs index 1cffd7a..a2d7023 100644 --- a/crates/stackless-render/src/lib.rs +++ b/crates/stackless-render/src/lib.rs @@ -28,6 +28,7 @@ //! (the engine errors before reaching us). pub mod api_key; +pub mod codes; pub mod config; pub mod error; pub mod prepare; @@ -43,7 +44,7 @@ use stackless_core::def::{Namespace, StackDef}; use stackless_core::engine::StepKind; use stackless_core::state::Checkpoint; use stackless_core::substrate::{ - Observation, StepContext, StepResource, Substrate, SubstrateFault, + Observation, ServiceLog, StepContext, StepResource, Substrate, SubstrateFault, }; use tokio::sync::Mutex; @@ -904,6 +905,39 @@ impl Substrate for RenderSubstrate { stackless_integrations::finalize_stripe_instance(&self.stripe(), instance).await; Ok(()) } + + async fn spend_line(&self) -> Option { + Some(spend_line(&self.definition_dir).await) + } + + async fn fetch_logs( + &self, + def: &StackDef, + instance: &str, + services: &[String], + tail: usize, + ) -> Result>, SubstrateFault> { + let mut out = Vec::with_capacity(services.len()); + for service in services { + let lines = fetch_logs( + &self.definition_dir, + def, + instance, + service, + tail, + &self.secrets, + ) + .await + .map_err(|err| SubstrateFault::from_fault(&err))?; + out.push(ServiceLog { + service: service.clone(), + source: "render_api", + log_path: None, + lines, + }); + } + Ok(Some(out)) + } } impl RenderSubstrate { diff --git a/crates/stackless-render/src/prepare.rs b/crates/stackless-render/src/prepare.rs index a0d3e35..5e2b367 100644 --- a/crates/stackless-render/src/prepare.rs +++ b/crates/stackless-render/src/prepare.rs @@ -1,8 +1,6 @@ -//! Operator-side cloud prepare (§4): shallow git checkout on the operator's machine. - -use std::process::Stdio; - -use stackless_core::fault::FAILURE_LOG_TAIL_LINES; +//! Operator-side cloud prepare (§4): shallow git checkout on the operator's +//! machine. The mechanics are shared (`stackless_cloud::prepare`); this maps the +//! neutral failure to Render's error so its `render.*` code/remediation hold. use crate::error::RenderError; @@ -13,62 +11,12 @@ pub fn run_prepare_command( command: &str, env: &[(String, String)], ) -> Result<(), RenderError> { - let tmp = tempdir().map_err(|message| RenderError::PrepareFailed { - service: service.to_owned(), - command: Some(command.to_owned()), - message, - log_tail: None, - })?; - let result = (|| { - stackless_git::clone_checkout( - repo, - reference, - &tmp, - &stackless_git::Credentials::default(), - ) - .map_err(|err| RenderError::PrepareFailed { - service: service.to_owned(), - command: Some(format!("clone --depth 1 --branch {reference} {repo}")), - message: format!("clone {repo}@{reference} failed: {err}"), - log_tail: None, - })?; - let mut cmd = std::process::Command::new("sh"); - cmd.arg("-c") - .arg(command) - .current_dir(&tmp) - .stdin(Stdio::null()); - for (key, value) in env { - cmd.env(key, value); - } - let output = cmd.output().map_err(|err| RenderError::PrepareFailed { - service: service.to_owned(), - command: Some(command.to_owned()), - message: format!("could not run prepare command: {err}"), - log_tail: None, - })?; - if !output.status.success() { - return Err(RenderError::PrepareFailed { - service: service.to_owned(), - command: Some(command.to_owned()), - message: format!("`{command}` exited {}", output.status), - log_tail: Some(tail_bytes(&output.stderr)), - }); - } - Ok(()) - })(); - let _ = std::fs::remove_dir_all(&tmp); - result -} - -fn tail_bytes(bytes: &[u8]) -> String { - let text = String::from_utf8_lossy(bytes); - let lines: Vec<&str> = text.lines().collect(); - let start = lines.len().saturating_sub(FAILURE_LOG_TAIL_LINES); - lines[start..].join("\n") -} - -fn tempdir() -> Result { - tempfile::tempdir() - .map(|dir| dir.keep()) - .map_err(|err| err.to_string()) + stackless_cloud::prepare::run_prepare_command(service, repo, reference, command, env).map_err( + |f| RenderError::PrepareFailed { + service: f.service, + command: f.command, + message: f.message, + log_tail: f.log_tail, + }, + ) } diff --git a/crates/stackless-render/tests/render_api.rs b/crates/stackless-render/tests/render_api.rs index bc01d7b..6cc5fab 100644 --- a/crates/stackless-render/tests/render_api.rs +++ b/crates/stackless-render/tests/render_api.rs @@ -136,7 +136,7 @@ async fn wait_for_deploy_fails_on_terminal_status() { .unwrap_err(); assert_eq!( stackless_core::fault::Fault::code(&err), - stackless_core::fault::codes::RENDER_DEPLOY_FAILED + stackless_render::codes::RENDER_DEPLOY_FAILED ); } @@ -155,7 +155,7 @@ async fn wait_for_deploy_times_out() { .unwrap_err(); assert_eq!( stackless_core::fault::Fault::code(&err), - stackless_core::fault::codes::RENDER_DEPLOY_TIMEOUT + stackless_render::codes::RENDER_DEPLOY_TIMEOUT ); } @@ -307,6 +307,6 @@ async fn api_error_status_surfaces() { let err = api(&server).find_service_by_name("x").await.unwrap_err(); assert_eq!( stackless_core::fault::Fault::code(&err), - stackless_core::fault::codes::RENDER_API_FAILED + stackless_render::codes::RENDER_API_FAILED ); } diff --git a/crates/stackless-stripe-projects/src/provision.rs b/crates/stackless-stripe-projects/src/provision.rs index d19571e..5348c24 100644 --- a/crates/stackless-stripe-projects/src/provision.rs +++ b/crates/stackless-stripe-projects/src/provision.rs @@ -4,6 +4,7 @@ //! module adds the instance-context (project + environment) and the env-blob //! credential resolution that credential-bearing services (e.g. Clerk) need. +use std::collections::BTreeMap; use std::path::Path; use serde_json::Value; @@ -74,6 +75,114 @@ where Ok(ProvisionedCredentials { resource_name, raw }) } +/// A multi-variable catalog provision result: the resource name and the +/// requested env vars that were returned (only those found). +#[derive(Debug)] +pub struct ProvisionedEnv { + pub resource_name: String, + pub values: BTreeMap, +} + +/// Like [`provision_with_credentials`], but resolves a SET of env vars. Some +/// providers (e.g. Cloudflare R2) return credentials as several distinct env +/// vars rather than one blob; each requested key is taken from the add response +/// if present, else from a single refreshing env pull. +pub async fn provision_with_env( + stripe: &StripeProjects, + catalog: &Catalog, + ctx: &ProvisionContext<'_>, + config: &C, + env_keys: &[&str], +) -> Result +where + C: CatalogService, + R: CommandRunner, +{ + if !ctx.skip_instance_context { + project::ensure_project(stripe, ctx.def, ctx.definition_dir).await?; + project::ensure_environment(stripe, ctx.instance).await?; + } + let resource_name = ctx.resource_name(); + let add_data = add_catalog_resource(stripe, catalog, config, &resource_name).await?; + let mut values = BTreeMap::new(); + let mut missing: Vec<&str> = Vec::new(); + for key in env_keys { + match find_env_value(&add_data, key) { + Some(value) => { + values.insert((*key).to_owned(), value); + } + None => missing.push(key), + } + } + // Only the keys the add response did not carry need a (single) env pull. + if !missing.is_empty() { + let pulled = project::pull_env_values(stripe, ctx.instance, &missing).await?; + for (idx, key) in missing.iter().enumerate() { + if let Some(value) = pulled.get(idx).cloned().flatten() { + values.insert((*key).to_owned(), value); + } + } + } + Ok(ProvisionedEnv { + resource_name, + values, + }) +} + +/// Provision a catalog resource and resolve its declared output fields into an +/// `output -> value` map. Stripe names a resource's env vars `{RESOURCE}_{SUFFIX}` +/// (when several share an environment) or `{PROVIDER}_{SUFFIX}` (when a resource +/// is unambiguous) — both forms are resolved. `fields` is `(env suffix, output +/// name, required)`; a missing required field is an error. This is the default +/// path for multi-output providers (Clerk's single-JSON-blob is the exception). +pub async fn provision_outputs( + stripe: &StripeProjects, + catalog: &Catalog, + ctx: &ProvisionContext<'_>, + config: &C, + provider_prefix: &str, + fields: &[(&str, &str, bool)], +) -> Result<(String, BTreeMap), ProjectsError> +where + C: CatalogService, + R: CommandRunner, +{ + let resource_prefix = ctx.resource_name().to_ascii_uppercase().replace('-', "_"); + let candidates: Vec = fields + .iter() + .flat_map(|(suffix, _, _)| { + [ + format!("{resource_prefix}_{suffix}"), + format!("{provider_prefix}_{suffix}"), + ] + }) + .collect(); + let candidate_refs: Vec<&str> = candidates.iter().map(String::as_str).collect(); + let ProvisionedEnv { + resource_name, + values, + } = provision_with_env(stripe, catalog, ctx, config, &candidate_refs).await?; + let mut outputs = BTreeMap::new(); + for (suffix, output, required) in fields { + let value = values + .get(&format!("{resource_prefix}_{suffix}")) + .or_else(|| values.get(&format!("{provider_prefix}_{suffix}"))); + match value { + Some(value) => { + outputs.insert((*output).to_owned(), value.clone()); + } + None if *required => { + return Err(ProjectsError::ProvisionFailed { + resource: resource_name.clone(), + detail: format!("provider did not return {suffix} for {resource_name}"), + }); + } + None => {} + } + } + Ok((resource_name, outputs)) +} + async fn resolve_env_blob( stripe: &StripeProjects, add_data: &Value, diff --git a/crates/stackless-stripe-projects/src/stripe.rs b/crates/stackless-stripe-projects/src/stripe.rs index de6f5fb..aecdc29 100644 --- a/crates/stackless-stripe-projects/src/stripe.rs +++ b/crates/stackless-stripe-projects/src/stripe.rs @@ -136,6 +136,16 @@ impl StripeProjects { &self.dir } + /// Borrow as a `StripeProjects<&dyn CommandRunner>` so callers holding a + /// `dyn` dispatch target can run commands without being generic over `R` + /// (`impl CommandRunner for &T` makes the erased runner a valid runner). + pub fn as_dyn(&self) -> StripeProjects<&'_ dyn CommandRunner> { + StripeProjects { + runner: &self.runner as &dyn CommandRunner, + dir: self.dir.clone(), + } + } + #[cfg(test)] fn runner(&self) -> &R { &self.runner diff --git a/crates/stackless-stripe-projects/src/test_support.rs b/crates/stackless-stripe-projects/src/test_support.rs index 8d69d32..d7d9677 100644 --- a/crates/stackless-stripe-projects/src/test_support.rs +++ b/crates/stackless-stripe-projects/src/test_support.rs @@ -92,3 +92,30 @@ pub fn env_list(names: &[&str]) -> CommandOutput { let list: Vec = names.iter().map(|n| json!({ "name": n })).collect(); ok(json!({ "environments": list })) } + +/// A raw stdout envelope (e.g. a full `catalog --json` payload passed verbatim). +pub fn raw(stdout: &str) -> CommandOutput { + CommandOutput { + status: 0, + stdout: stdout.to_owned(), + stderr: String::new(), + } +} + +/// A [`ScriptedRunner`] pre-loaded with the exact CLI conversation a +/// `CatalogResource` provision drives: catalog → ensure project/env → add (with +/// `add_variables` in the response) → env add → env pull. Collapses each +/// resource's provision test to one call; `add_variables` is the credential +/// envelope the resource maps to outputs. +pub fn provision_script(catalog_envelope: &str, add_variables: Value) -> ScriptedRunner { + ScriptedRunner::new(vec![ + raw(catalog_envelope), // catalog --json + status(Some("project_1")), // ensure_project + env_list(&["demo"]), // ensure_environment (list) + ok_empty(), // ensure_environment (use) + services(&[]), // add_resource registered pre-check + ok(json!({ "variables": add_variables })), // add + ok_empty(), // env add + ok_empty(), // env --pull --refresh + ]) +} diff --git a/crates/stackless-vercel/Cargo.toml b/crates/stackless-vercel/Cargo.toml index 1ad07e0..36b6499 100644 --- a/crates/stackless-vercel/Cargo.toml +++ b/crates/stackless-vercel/Cargo.toml @@ -20,6 +20,7 @@ reqwest = { version = "0.13.4", default-features = false, features = [ ] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" +stackless-cloud.workspace = true stackless-core.workspace = true stackless-git.workspace = true stackless-integrations.workspace = true diff --git a/crates/stackless-vercel/src/api_key.rs b/crates/stackless-vercel/src/api_key.rs index 8c40a77..8e15e10 100644 --- a/crates/stackless-vercel/src/api_key.rs +++ b/crates/stackless-vercel/src/api_key.rs @@ -1,9 +1,7 @@ -//! Vercel API token resolution. -//! -//! Order: the `VERCEL_TOKEN` env var, then a `VERCEL_TOKEN` entry in the -//! resolved secrets (`.stackless.env`), then a 0600 key file at -//! `/.vercel-token`. A missing token is a clean fault naming -//! all three sources. +//! Vercel API token resolution: env var, then resolved secrets, then a 0600 key +//! file. Resolution is shared (`stackless_cloud::credential`); the env var and +//! key-file names are Vercel's, and a miss maps to Vercel's error so its +//! `vercel.api_key.missing` code and remediation hold. use std::collections::BTreeMap; use std::path::Path; @@ -13,36 +11,15 @@ use crate::error::VercelError; pub const KEY_FILE: &str = ".vercel-token"; pub const KEY_ENV: &str = "VERCEL_TOKEN"; -/// Resolve the token from the environment, the resolved secrets, or the scoped -/// key file next to the definition. The env var wins so CI can inject without a -/// file; `.stackless.env` is the project's canonical secret store; the file is a -/// scoped fallback. pub fn resolve( definition_dir: &Path, secrets: &BTreeMap, ) -> Result { - if let Ok(token) = std::env::var(KEY_ENV) { - let token = token.trim().to_owned(); - if !token.is_empty() { - return Ok(token); - } - } - if let Some(token) = secrets.get(KEY_ENV) { - let token = token.trim().to_owned(); - if !token.is_empty() { - return Ok(token); - } - } - let key_file = definition_dir.join(KEY_FILE); - if let Ok(contents) = std::fs::read_to_string(&key_file) { - let token = contents.trim().to_owned(); - if !token.is_empty() { - return Ok(token); - } - } - Err(VercelError::ApiKeyMissing { - key_file: key_file.display().to_string(), - }) + stackless_cloud::credential::resolve(KEY_ENV, KEY_FILE, definition_dir, secrets).map_err( + |missing| VercelError::ApiKeyMissing { + key_file: missing.key_file, + }, + ) } #[cfg(test)] diff --git a/crates/stackless-vercel/src/codes.rs b/crates/stackless-vercel/src/codes.rs new file mode 100644 index 0000000..7b19ae1 --- /dev/null +++ b/crates/stackless-vercel/src/codes.rs @@ -0,0 +1,30 @@ +//! Stable error codes for the Vercel substrate (ARCHITECTURE.md §2/§8). +//! +//! Codes live with the provider, not in core — adding a hosting provider adds +//! no codes to `stackless-core`. The binary aggregates every crate's `ALL` for +//! a workspace-wide uniqueness check. + +pub const VERCEL_CONFIG_INVALID: &str = "vercel.config.invalid"; +pub const VERCEL_API_KEY_MISSING: &str = "vercel.api_key.missing"; +pub const VERCEL_API_FAILED: &str = "vercel.api.failed"; +pub const VERCEL_PAYMENT_NOT_CONFIRMED: &str = "vercel.payment.not_confirmed"; +pub const VERCEL_PROVISION_FAILED: &str = "vercel.provision.failed"; +pub const VERCEL_DEPLOY_FAILED: &str = "vercel.deploy.failed"; +pub const VERCEL_DEPLOY_TIMEOUT: &str = "vercel.deploy.timeout"; +pub const VERCEL_HEALTH_FAILED: &str = "vercel.health.failed"; +pub const VERCEL_PREPARE_FAILED: &str = "vercel.prepare.failed"; +pub const VERCEL_TEARDOWN_SURVIVOR: &str = "vercel.teardown.survivor"; + +/// Every Vercel code, for the workspace uniqueness test. +pub const ALL: &[&str] = &[ + VERCEL_CONFIG_INVALID, + VERCEL_API_KEY_MISSING, + VERCEL_API_FAILED, + VERCEL_PAYMENT_NOT_CONFIRMED, + VERCEL_PROVISION_FAILED, + VERCEL_DEPLOY_FAILED, + VERCEL_DEPLOY_TIMEOUT, + VERCEL_HEALTH_FAILED, + VERCEL_PREPARE_FAILED, + VERCEL_TEARDOWN_SURVIVOR, +]; diff --git a/crates/stackless-vercel/src/config.rs b/crates/stackless-vercel/src/config.rs index a582bb7..0289b27 100644 --- a/crates/stackless-vercel/src/config.rs +++ b/crates/stackless-vercel/src/config.rs @@ -221,10 +221,7 @@ health = { path = "/" } "#, ); let err = ServiceVercel::parse(&def, "web").unwrap_err(); - assert_eq!( - err.code(), - stackless_core::fault::codes::VERCEL_CONFIG_INVALID - ); + assert_eq!(err.code(), crate::codes::VERCEL_CONFIG_INVALID); } #[test] diff --git a/crates/stackless-vercel/src/error.rs b/crates/stackless-vercel/src/error.rs index d5cb699..110fb7f 100644 --- a/crates/stackless-vercel/src/error.rs +++ b/crates/stackless-vercel/src/error.rs @@ -1,6 +1,8 @@ -//! Vercel-substrate errors (codes in core's `vercel.*` registry). +//! Vercel-substrate errors (codes in this crate's `vercel.*` registry). -use stackless_core::fault::{ErrorContext, Fault, codes}; +use stackless_core::fault::{ErrorContext, Fault}; + +use crate::codes; #[derive(Debug, thiserror::Error)] pub enum VercelError { diff --git a/crates/stackless-vercel/src/lib.rs b/crates/stackless-vercel/src/lib.rs index fc18ce6..7c445a0 100644 --- a/crates/stackless-vercel/src/lib.rs +++ b/crates/stackless-vercel/src/lib.rs @@ -5,6 +5,7 @@ //! API (env vars, git deployments, deploy polling, health, teardown). pub mod api_key; +pub mod codes; pub mod config; pub mod error; pub mod git; @@ -866,6 +867,10 @@ impl Substrate for VercelSubstrate { stackless_integrations::finalize_stripe_instance(&self.stripe(), instance).await; Ok(()) } + + async fn spend_line(&self) -> Option { + Some(spend_line(&self.definition_dir).await) + } } impl VercelSubstrate { diff --git a/crates/stackless-vercel/src/prepare.rs b/crates/stackless-vercel/src/prepare.rs index 8c2a3a5..a8a7633 100644 --- a/crates/stackless-vercel/src/prepare.rs +++ b/crates/stackless-vercel/src/prepare.rs @@ -1,8 +1,6 @@ //! Operator-side cloud prepare: shallow git checkout on the operator's machine. - -use std::process::Stdio; - -use stackless_core::fault::FAILURE_LOG_TAIL_LINES; +//! The mechanics are shared (`stackless_cloud::prepare`); this maps the neutral +//! failure to Vercel's error so its `vercel.*` code/remediation hold. use crate::error::VercelError; @@ -13,62 +11,12 @@ pub fn run_prepare_command( command: &str, env: &[(String, String)], ) -> Result<(), VercelError> { - let tmp = tempdir().map_err(|message| VercelError::PrepareFailed { - service: service.to_owned(), - command: Some(command.to_owned()), - message, - log_tail: None, - })?; - let result = (|| { - stackless_git::clone_checkout( - repo, - reference, - &tmp, - &stackless_git::Credentials::default(), - ) - .map_err(|err| VercelError::PrepareFailed { - service: service.to_owned(), - command: Some(format!("clone --depth 1 --branch {reference} {repo}")), - message: format!("clone {repo}@{reference} failed: {err}"), - log_tail: None, - })?; - let mut cmd = std::process::Command::new("sh"); - cmd.arg("-c") - .arg(command) - .current_dir(&tmp) - .stdin(Stdio::null()); - for (key, value) in env { - cmd.env(key, value); - } - let output = cmd.output().map_err(|err| VercelError::PrepareFailed { - service: service.to_owned(), - command: Some(command.to_owned()), - message: format!("could not run prepare command: {err}"), - log_tail: None, - })?; - if !output.status.success() { - return Err(VercelError::PrepareFailed { - service: service.to_owned(), - command: Some(command.to_owned()), - message: format!("`{command}` exited {}", output.status), - log_tail: Some(tail_bytes(&output.stderr)), - }); - } - Ok(()) - })(); - let _ = std::fs::remove_dir_all(&tmp); - result -} - -fn tempdir() -> Result { - tempfile::tempdir() - .map(|dir| dir.keep()) - .map_err(|err| err.to_string()) -} - -fn tail_bytes(bytes: &[u8]) -> String { - let text = String::from_utf8_lossy(bytes); - let lines: Vec<&str> = text.lines().collect(); - let start = lines.len().saturating_sub(FAILURE_LOG_TAIL_LINES); - lines[start..].join("\n") + stackless_cloud::prepare::run_prepare_command(service, repo, reference, command, env).map_err( + |f| VercelError::PrepareFailed { + service: f.service, + command: f.command, + message: f.message, + log_tail: f.log_tail, + }, + ) } diff --git a/crates/stackless/src/commands.rs b/crates/stackless/src/commands.rs index 1a3a4d6..b1d147c 100644 --- a/crates/stackless/src/commands.rs +++ b/crates/stackless/src/commands.rs @@ -7,12 +7,8 @@ use std::path::PathBuf; use serde::Serialize; use stackless_core::def::StackDef; use stackless_core::engine::{DownOutcome, Engine, UpRequest}; -use stackless_core::host::Host; use stackless_core::state::{InstanceRecord, InstanceStatus, Store}; use stackless_core::substrate::Substrate; -use stackless_local::LocalSubstrate; -use stackless_render::{RenderSubstrate, SUBSTRATE_NAME as RENDER}; -use stackless_vercel::{SUBSTRATE_NAME as VERCEL, VercelSubstrate}; use crate::error::CliError; use crate::output::Output; @@ -28,43 +24,13 @@ pub(crate) struct SubstrateCtx { pub confirm_paid: bool, } -/// The substrate registry (ground rule: providers register here and -/// only here; core never names one). +/// Construct a substrate by name via the registry (ground rule: providers +/// register in `crate::substrates` and only there; core never names one). pub(crate) fn build_substrate( name: &str, ctx: SubstrateCtx, ) -> Result, CliError> { - substrate(name, ctx) -} - -pub(crate) fn parse_host(substrate: &str) -> Result { - Host::parse(substrate).ok_or_else(|| CliError::SubstrateUnknown { - substrate: substrate.to_owned(), - known: Host::ALL - .iter() - .map(|host| host.as_str().to_owned()) - .collect(), - }) -} - -fn substrate(name: &str, ctx: SubstrateCtx) -> Result, CliError> { - match parse_host(name)? { - Host::Local => Ok(Box::new(LocalSubstrate { - proxy_port: stackless_daemon::proxy::proxy_port(), - secrets: ctx.secrets, - definition_dir: ctx.definition_dir, - })), - Host::Render => Ok(Box::new(RenderSubstrate::new( - ctx.definition_dir, - ctx.secrets, - ctx.confirm_paid, - ))), - Host::Vercel => Ok(Box::new(VercelSubstrate::new( - ctx.definition_dir, - ctx.secrets, - ctx.confirm_paid, - ))), - } + crate::substrates::build(name, ctx) } pub struct UpArgs { @@ -247,8 +213,9 @@ pub fn up(args: UpArgs, output: &mut Output) -> Result<(), CliError> { .unwrap_or_default(); let def_dir = std::fs::canonicalize(&def_dir).unwrap_or(def_dir); let secrets = crate::secrets::resolve(&def, &def_dir)?; - stackless_integrations::validate_all(&def, Some(parse_host(&substrate_name)?))?; - let provider = substrate( + let known = crate::substrates::known_names(); + stackless_integrations::validate_all(&def, Some(substrate_name.as_str()), &known)?; + let provider = build_substrate( &substrate_name, SubstrateCtx { secrets, @@ -286,11 +253,9 @@ pub fn up(args: UpArgs, output: &mut Output) -> Result<(), CliError> { .collect(); output.up_ok(&name, &substrate_name, &outcome, &origins); // Spend is printed after every cloud `up` (§4 — never silently - // nothing; bounded by the project's hard cap). - if substrate_name == RENDER { - output.message(&rt.block_on(stackless_render::spend_line(&def_dir))); - } else if substrate_name == VERCEL { - output.message(&rt.block_on(stackless_vercel::spend_line(&def_dir))); + // nothing; bounded by the project's hard cap). Local spends nothing. + if let Some(line) = rt.block_on(provider.spend_line()) { + output.message(&line); } Ok(()) } @@ -305,7 +270,7 @@ pub fn down(name: &str, output: &Output) -> Result<(), CliError> { // `.stackless.env` overlay (best-effort, no required-secret gate) so the // provider API key resolves there, exactly as `up` does. let def_dir = PathBuf::from(&record.definition_dir); - let provider = substrate( + let provider = build_substrate( record.substrate.as_str(), SubstrateCtx { secrets: crate::secrets::load(&def_dir), @@ -325,17 +290,9 @@ pub fn down(name: &str, output: &Output) -> Result<(), CliError> { )), DownOutcome::AlreadyDown => output.message(&format!("{name}: already down")), } - // Spend is printed after every cloud `down` too (§4). - match record.substrate.as_str() { - RENDER => { - let dir = PathBuf::from(&record.definition_dir); - output.message(&rt.block_on(stackless_render::spend_line(&dir))); - } - VERCEL => { - let dir = PathBuf::from(&record.definition_dir); - output.message(&rt.block_on(stackless_vercel::spend_line(&dir))); - } - _ => {} + // Spend is printed after every cloud `down` too (§4). Local spends nothing. + if let Some(line) = rt.block_on(provider.spend_line()) { + output.message(&line); } Ok(()) } @@ -487,67 +444,44 @@ pub fn logs( Some(one) => vec![one.to_owned()], None => def.services.keys().cloned().collect(), }; - let mut entries = Vec::new(); - // On render the daemon never saw these processes — fetch recent logs - // through the Render REST API (§2: recent window, no streaming). - if record.substrate.as_str() == RENDER { - let dir = PathBuf::from(&record.definition_dir); - // Read-only: load .stackless.env best-effort (no required-secret gate) - // so the Render API key resolves there for `logs` too. - let secrets = crate::secrets::load(&dir); - let rt = runtime()?; - for service in &services { - let lines = rt - .block_on(stackless_render::fetch_logs( - &dir, &def, name, service, tail, &secrets, - )) - .map_err(|err| { - CliError::substrate( - stackless_core::substrate::SubstrateFault::from_fault(&err), - Some(name.to_owned()), - ) - })?; - if output.is_json() { - entries.push(LogService { - service, - source: "render_api", - log_path: None, - lines: if lines.is_empty() { vec![] } else { lines }, - }); - } else { - output.message(&format!("── {service} ──")); - if lines.is_empty() { - output.message("(no output captured)"); - } else { - output.message(&lines.join("\n")); - } - } - } - if output.is_json() { - output.logs_json(name, &entries); - } + // The substrate owns how logs are retrieved (cloud REST API, daemon log + // files, or none at all). Read-only: load `.stackless.env` best-effort so + // a cloud API key resolves here too. + let def_dir = PathBuf::from(&record.definition_dir); + let provider = build_substrate( + record.substrate.as_str(), + SubstrateCtx { + secrets: crate::secrets::load(&def_dir), + definition_dir: def_dir, + confirm_paid: false, + }, + )?; + let rt = runtime()?; + let logs = rt + .block_on(provider.fetch_logs(&def, name, &services, tail)) + .map_err(|err| CliError::substrate(err, Some(name.to_owned())))?; + let Some(logs) = logs else { + output.message(&format!( + "logs are not retrievable for substrate {:?}", + record.substrate.as_str() + )); return Ok(()); - } - let spawner = stackless_local::spawn::Spawner::new(name); - for service in &services { - let tail_text = spawner.log_tail(service, tail); + }; + let mut entries = Vec::new(); + for log in &logs { if output.is_json() { entries.push(LogService { - service, - source: "file", - log_path: Some(spawner.log_path(service).display().to_string()), - lines: if tail_text.is_empty() { - vec![] - } else { - tail_text.lines().map(str::to_owned).collect() - }, + service: log.service.as_str(), + source: log.source, + log_path: log.log_path.clone(), + lines: log.lines.clone(), }); } else { - output.message(&format!("── {service} ──")); - if tail_text.is_empty() { + output.message(&format!("── {} ──", log.service)); + if log.lines.is_empty() { output.message("(no output captured)"); } else { - output.message(&tail_text); + output.message(&log.lines.join("\n")); } } } @@ -559,7 +493,7 @@ pub fn logs( pub fn parse_and_validate(text: &str) -> Result { let def = StackDef::parse(text)?; - def.validate()?; + def.validate_hosts(&crate::substrates::known_names())?; Ok(def) } diff --git a/crates/stackless/src/main.rs b/crates/stackless/src/main.rs index dd2e402..ec192ab 100644 --- a/crates/stackless/src/main.rs +++ b/crates/stackless/src/main.rs @@ -7,6 +7,7 @@ mod daemon_cmd; mod error; mod output; mod secrets; +mod substrates; mod verify; use std::path::PathBuf; @@ -127,14 +128,17 @@ fn main() -> ExitCode { } fn check(file: &PathBuf, substrate: Option<&str>, output: &Output) -> Result<(), CliError> { - let active_host = substrate.map(commands::parse_host).transpose()?; + if let Some(name) = substrate { + substrates::ensure_known(name)?; + } + let known = substrates::known_names(); let text = std::fs::read_to_string(file).map_err(|source| CliError::FileRead { path: file.display().to_string(), source, })?; let def = StackDef::parse(&text)?; - def.validate()?; - stackless_integrations::validate_all(&def, active_host)?; + def.validate_hosts(&known)?; + stackless_integrations::validate_all(&def, substrate, &known)?; if let Some(substrate) = substrate { def.validate_for_substrate(substrate)?; } diff --git a/crates/stackless/src/substrates.rs b/crates/stackless/src/substrates.rs new file mode 100644 index 0000000..113b9fb --- /dev/null +++ b/crates/stackless/src/substrates.rs @@ -0,0 +1,109 @@ +//! The substrate registry — the one place the binary names hosting providers +//! (ground rule: core never names a substrate; the `Substrate` trait is the +//! only seam). Adding a hosting provider is one row here plus its own crate. + +use stackless_core::substrate::Substrate; +use stackless_local::{LocalSubstrate, SUBSTRATE_NAME as LOCAL}; +use stackless_render::{RenderSubstrate, SUBSTRATE_NAME as RENDER}; +use stackless_vercel::{SUBSTRATE_NAME as VERCEL, VercelSubstrate}; + +use crate::commands::SubstrateCtx; +use crate::error::CliError; + +/// One registered substrate: its `--on` name and how to construct it from the +/// shared build context. +struct SubstrateInfo { + name: &'static str, + build: fn(SubstrateCtx) -> Result, CliError>, +} + +static SUBSTRATES: &[SubstrateInfo] = &[ + SubstrateInfo { + name: LOCAL, + build: build_local, + }, + SubstrateInfo { + name: RENDER, + build: build_render, + }, + SubstrateInfo { + name: VERCEL, + build: build_vercel, + }, +]; + +/// Every substrate name the binary can dispatch to. +pub(crate) fn known_names() -> Vec<&'static str> { + SUBSTRATES.iter().map(|info| info.name).collect() +} + +/// Error unless `name` is a registered substrate (used where no substrate is +/// constructed, e.g. `check`; `build` enforces the same for `up`/`down`). +pub(crate) fn ensure_known(name: &str) -> Result<(), CliError> { + if SUBSTRATES.iter().any(|info| info.name == name) { + Ok(()) + } else { + Err(unknown(name)) + } +} + +/// Construct a substrate by name, or report it unknown with the known set. +pub(crate) fn build(name: &str, ctx: SubstrateCtx) -> Result, CliError> { + match SUBSTRATES.iter().find(|info| info.name == name) { + Some(info) => (info.build)(ctx), + None => Err(unknown(name)), + } +} + +fn unknown(name: &str) -> CliError { + CliError::SubstrateUnknown { + substrate: name.to_owned(), + known: known_names().iter().map(|s| (*s).to_owned()).collect(), + } +} + +fn build_local(ctx: SubstrateCtx) -> Result, CliError> { + Ok(Box::new(LocalSubstrate { + proxy_port: stackless_daemon::proxy::proxy_port(), + secrets: ctx.secrets, + definition_dir: ctx.definition_dir, + })) +} + +fn build_render(ctx: SubstrateCtx) -> Result, CliError> { + Ok(Box::new(RenderSubstrate::new( + ctx.definition_dir, + ctx.secrets, + ctx.confirm_paid, + ))) +} + +fn build_vercel(ctx: SubstrateCtx) -> Result, CliError> { + Ok(Box::new(VercelSubstrate::new( + ctx.definition_dir, + ctx.secrets, + ctx.confirm_paid, + ))) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + /// Each crate owns its error codes; the binary is the one place that sees + /// them all, so the workspace-wide no-collision check lives here. Adding a + /// provider crate adds its `codes::ALL` to this list. + #[test] + fn error_codes_are_globally_unique() { + let mut all: Vec<&str> = Vec::new(); + all.extend(stackless_core::fault::codes::ALL); + all.extend(stackless_render::codes::ALL); + all.extend(stackless_vercel::codes::ALL); + let unique: BTreeSet<&str> = all.iter().copied().collect(); + assert_eq!( + unique.len(), + all.len(), + "duplicate error code across crates" + ); + } +} diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml new file mode 100644 index 0000000..4d60086 --- /dev/null +++ b/crates/xtask/Cargo.toml @@ -0,0 +1,18 @@ +# Dev-only tooling for onboarding providers (catalog explorer, envelope +# discovery, integration scaffolder). Not a dependency of any shipped crate. +[package] +name = "xtask" +edition.workspace = true +version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +stackless-stripe-projects.workspace = true +clap = { version = "4.6.1", features = ["derive"] } +serde_json = "1.0.150" +tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros"] } diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs new file mode 100644 index 0000000..ff69eb5 --- /dev/null +++ b/crates/xtask/src/main.rs @@ -0,0 +1,325 @@ +//! Provider-onboarding dev tooling. +//! +//! - `catalog []` — list catalog services + config schemas + pricing +//! (offline, from the committed catalog fixture). +//! - `discover ` — provision a resource into a throwaway environment, +//! dump its real output env vars (the credential envelope the catalog does not +//! describe), then tear down. Live: needs `STRIPE_API_KEY` + the `stripe` CLI. +//! - `new-integration ` — scaffold a provider module from the schema. + +use std::error::Error; +use std::path::{Path, PathBuf}; + +use clap::{Parser, Subcommand}; +use serde_json::Value; +use stackless_stripe_projects::catalog::{Catalog, ServiceDetail}; +use stackless_stripe_projects::stripe::TokioRunner; +use stackless_stripe_projects::{StripeProjects, project}; + +type Fail = Box; + +/// The committed catalog fixture (the same data the gap tests validate against). +const CATALOG_FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" +)); + +#[derive(Parser)] +#[command(about = "stackless provider-onboarding tooling")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// List catalog services + config schemas + pricing (offline). + Catalog { + /// Provider name (e.g. `cloudflare`); omit to list all. + provider: Option, + }, + /// Provision a resource into a throwaway env and dump its output env vars. + Discover { + /// Catalog reference, e.g. `cloudflare/kv`. + reference: String, + /// `--config` JSON for the resource (default `{}`). + #[arg(long)] + config: Option, + /// Directory with a linked Stripe Projects context (default: cwd). + #[arg(long, default_value = ".")] + dir: PathBuf, + }, + /// Scaffold a new integration module from the catalog schema. + NewIntegration { + /// Catalog reference, e.g. `cloudflare/kv`. + reference: String, + }, +} + +#[tokio::main] +async fn main() -> Result<(), Fail> { + match Cli::parse().command { + Command::Catalog { provider } => cmd_catalog(provider.as_deref()), + Command::Discover { + reference, + config, + dir, + } => cmd_discover(&reference, config.as_deref(), &dir).await, + Command::NewIntegration { reference } => cmd_new_integration(&reference), + } +} + +fn cmd_catalog(provider: Option<&str>) -> Result<(), Fail> { + let catalog = Catalog::from_json_envelope(CATALOG_FIXTURE)?; + let mut services: Vec<&ServiceDetail> = catalog + .services + .iter() + .filter(|s| provider.is_none_or(|p| s.provider_name.eq_ignore_ascii_case(p))) + .collect(); + services.sort_by_key(|a| a.reference()); + if services.is_empty() { + println!( + "no services{}", + provider.map(|p| format!(" for {p}")).unwrap_or_default() + ); + return Ok(()); + } + for service in services { + println!( + "{} [{:?} / {:?}] pricing: {:?}", + service.reference(), + service.kind, + service.scope, + service.pricing.kind + ); + if let Some(schema) = &service.configuration_schema { + for (name, prop) in &schema.properties { + let req = if schema.required.contains(name) { + "required" + } else { + "optional" + }; + let enums = if prop.allowed.is_empty() { + String::new() + } else { + let vals: Vec<&str> = prop.allowed.iter().filter_map(Value::as_str).collect(); + format!("; enum: {}", vals.join(", ")) + }; + println!(" {name} ({req}, {:?}{enums})", prop.prop_type); + } + } + } + Ok(()) +} + +async fn cmd_discover(reference: &str, config: Option<&str>, dir: &Path) -> Result<(), Fail> { + let config_value: Value = match config { + Some(c) => serde_json::from_str(c)?, + None => serde_json::json!({}), + }; + let stripe = StripeProjects::new(TokioRunner, dir.to_path_buf()); + let catalog = stripe.catalog().await?; + let service = catalog + .lookup(reference) + .ok_or_else(|| format!("reference {reference:?} not found in the live catalog"))?; + let paid = service.requires_confirmation(&config_value); + + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + let env_name = format!("disco-{stamp}"); + let env_file = format!(".env.{env_name}"); + let resource = format!("disco-{}", reference.replace(['/', ':'], "-")); + let resource_prefix = resource.to_ascii_uppercase().replace('-', "_"); + let provider_prefix = reference + .split('/') + .next() + .unwrap_or_default() + .to_ascii_uppercase() + .replace('-', "_"); + + println!("discovering {reference} (paid={paid}) via throwaway env {env_name}..."); + stripe + .json(&["env", "create", &env_name, "--output", &env_file, "--yes"]) + .await?; + stripe.json(&["env", "use", &env_name]).await?; + + let provisioned = + project::add_resource(&stripe, reference, &resource, &config_value, paid).await; + if provisioned.is_ok() { + let _ = stripe.json(&["env", "--pull", "--refresh", "--yes"]).await; + } + let env_text = std::fs::read_to_string(dir.join(&env_file)).unwrap_or_default(); + + // Best-effort teardown before surfacing any provisioning error. + let _ = project::remove_resource(&stripe, &resource).await; + let _ = stripe.json(&["env", "use", "default"]).await; + let _ = stripe.json(&["env", "delete", &env_name, "--yes"]).await; + let _ = std::fs::remove_file(dir.join(&env_file)); + + provisioned.map_err(|err| format!("provisioning {reference} failed: {err}"))?; + + // Stripe names a resource's vars `{RESOURCE}_{SUFFIX}` (or `{PROVIDER}_{SUFFIX}` + // when unambiguous) — strip either prefix to recover the field suffix. + let mut fields: Vec = Vec::new(); + for line in env_text.lines() { + let Some((key, _)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + let suffix = key + .strip_prefix(&format!("{resource_prefix}_")) + .or_else(|| key.strip_prefix(&format!("{provider_prefix}_"))); + if let Some(suffix) = suffix { + fields.push(suffix.to_owned()); + } + } + if fields.is_empty() { + println!("no output env vars found for {reference} (resource {resource})."); + return Ok(()); + } + println!("\noutput fields for {reference}:"); + for f in &fields { + println!(" {f} -> {}", f.to_ascii_lowercase()); + } + println!("\nsuggested OUTPUT_FIELDS (mark required as appropriate):"); + for (i, f) in fields.iter().enumerate() { + let required = i == 0; + println!(" ({:?}, {:?}, {required}),", f, f.to_ascii_lowercase()); + } + Ok(()) +} + +fn cmd_new_integration(reference: &str) -> Result<(), Fail> { + let catalog = Catalog::from_json_envelope(CATALOG_FIXTURE)?; + let service = catalog + .lookup(reference) + .ok_or_else(|| format!("reference {reference:?} not found in the catalog"))?; + let (provider, svc) = reference + .split_once('/') + .ok_or("reference must be /")?; + let provider_key = format!("{provider}-{}", svc.replace(':', "-")); // provider = "cloudflare-kv" + let resource_kind = format!("integration-{provider_key}"); + let provider_prefix = provider.to_ascii_uppercase().replace('-', "_"); + let type_name = format!("{}{}", camel(provider), camel(svc)); + let config_type = format!("{type_name}Config"); + + let schema = service.configuration_schema.as_ref(); + let mut struct_fields = String::new(); + let mut build_fields = String::new(); + let mut validate = String::new(); + if let Some(schema) = schema { + for (key, prop) in &schema.properties { + let required = schema.required.contains(key); + use stackless_stripe_projects::catalog::PropertyType::*; + match (&prop.prop_type, required) { + (String, true) => { + struct_fields.push_str(&format!(" pub {key}: std::string::String,\n")); + build_fields.push_str(&format!( + " {key}: super::interp_required(ctx, &config, \"{key}\")?,\n" + )); + validate.push_str(&format!( + " registry::config_string(config, \"{key}\").map_err(|e| IntegrationError::ConfigInvalid {{ location: format!(\"integrations.{{name}}.{key}\"), detail: e.to_string() }})?;\n" + )); + } + (String, false) => { + struct_fields.push_str(&format!( + " #[serde(skip_serializing_if = \"Option::is_none\")]\n pub {key}: Option,\n" + )); + build_fields.push_str(&format!( + " {key}: super::interp_optional(ctx, &config, \"{key}\")?,\n" + )); + } + (Integer, true) => { + struct_fields.push_str(&format!(" pub {key}: i64,\n")); + build_fields.push_str(&format!( + " {key}: super::int_required(ctx, &config, \"{key}\")?,\n" + )); + } + (other, req) => { + struct_fields.push_str(&format!( + " // TODO: {key} ({other:?}, required={req}) — add field + build_config wiring\n" + )); + } + } + } + } + + println!( + r#"//! {reference} integration (generated skeleton — run `xtask discover {reference}` +//! to pin OUTPUT_FIELDS, then add the registry row + `pub mod` shown at the bottom). + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::CatalogResource; +use crate::error::IntegrationError; +use crate::hostable::{{ConfigScope, Hostable, IntegrationHosting}}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "{resource_kind}"; + +#[derive(Debug, Serialize)] +pub struct {config_type} {{ +{struct_fields}}} + +impl CatalogService for {config_type} {{ + const REFERENCE: &'static str = "{reference}"; +}} + +#[derive(Debug)] +pub struct {type_name}; + +impl Hostable for {type_name} {{ + const PROVIDER: &'static str = "{provider_key}"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &[/* fill from `xtask discover` */]; +}} + +impl CatalogResource for {type_name} {{ + type Config = {config_type}; + const PROVIDER_PREFIX: &'static str = "{provider_prefix}"; + // TODO: paste from `xtask discover {reference}` — (env suffix, output, required). + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result<{config_type}, IntegrationError> {{ + let config = super::integration_config(ctx)?; + Ok({config_type} {{ +{build_fields} }}) + }} +}} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> {{ +{validate} Ok(()) +}} + +// === register (the one site) === +// providers/mod.rs: pub mod {module}; +// registry.rs PROVIDERS: provider_entry::<...::{type_name}>(...::validate_config, &...::{type_name}), +"#, + module = svc.replace([':', '-'], "_"), + ); + Ok(()) +} + +/// CamelCase a `provider`/`service` segment (`r2:bucket` -> `R2Bucket`). +fn camel(s: &str) -> std::string::String { + s.split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|p| !p.is_empty()) + .map(|p| { + let mut chars = p.chars(); + match chars.next() { + Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(), + None => std::string::String::new(), + } + }) + .collect() +} diff --git a/docs/ADDING-A-PROVIDER.md b/docs/ADDING-A-PROVIDER.md new file mode 100644 index 0000000..444479a --- /dev/null +++ b/docs/ADDING-A-PROVIDER.md @@ -0,0 +1,120 @@ +# Adding a provider + +stackless has two provider families. Adding one touches **exactly one +registration site** plus the provider's own module/crate — the engine, core, and +sibling providers stay untouched (core never names a provider). + +- **Catalog integration** (auth, object storage, db, queue, …) — a resource + provisioned through Stripe Projects. Most of the catalog is this kind. +- **Cloud substrate** (Render/Vercel-shaped: a hosting target for `--on`) — runs + the lifecycle engine against a cloud REST API. + +Everything is provisioned through the Stripe Projects catalog, so each resource +gets config-schema validation, paid-confirmation, project-environment isolation, +and spend-cap semantics for free. A service **not** in the catalog is out of +scope (it would need a separate provisioning authority). + +--- + +## Tooling: start here + +```sh +mise run catalog # list a provider's services: schema, pricing, paid? +mise run discover -- --dir fixtures/smoke/cloudflare # provision once, dump the real output env vars, tear down +``` + +`catalog` is offline (reads the committed catalog fixture). `discover` is live +(needs `STRIPE_API_KEY` + a linked project) — it pins the credential **output +envelope**, which the catalog does *not* describe. Both are the `xtask` crate. + +## Catalog integration (the common case) + +A resource whose credentials come back as flat env vars (Cloudflare R2/KV/D1/…) +is a `CatalogResource`. The provision/observe/destroy lifecycle and credential +resolution are shared — you only declare config + fields. One file under +`crates/stackless-integrations/src/providers//.rs`: + +1. **Config** + `impl CatalogService { const REFERENCE = "/" }` + — the `stripe projects add` key (note: `provider_name.lowercase()/service_id`). + The `Serialize` shape is validated against the catalog schema at provision. +2. **`impl Hostable`** — `PROVIDER` (the `provider = "..."` key, **distinct per + service**, e.g. `"cloudflare-r2"`), `HOSTING` (`Managed`), `CONFIG_SCOPE` + (`GlobalOnly`), `RESOURCE_KIND` (unique), `OUTPUTS`. +3. **`impl crate::resource::CatalogResource`** — `type Config`, `PROVIDER_PREFIX` + (the unambiguous env-var prefix, e.g. `"CLOUDFLARE"`), `OUTPUT_FIELDS` + (`(env-suffix, output, required)` — get the real suffixes from `discover`), + and `build_config`. **`ProviderOps` is derived** by a blanket impl — no + per-service lifecycle code. +4. **`validate_config`** — provider-specific config checks beyond the schema. + +Then the **one registration site** — a row in `PROVIDERS` +(`crates/stackless-integrations/src/registry.rs`) + a `pub mod` in the provider's +`mod.rs`. Dispatch is automatic (`ops_for` / `ops_for_resource_kind`); never a +provider string. (See `providers/cloudflare/r2.rs` for a worked example.) + +**Tests** (both offline, every CI run): +- Catalog-gap: `verify_service(&catalog, &)` against the committed + `catalog.json` — fails if the reference is absent or the schema drifted. +- Provision: `test_support::provision_script(, json!({}))` + builds the whole CLI conversation; the test is ~5 lines (see `cloudflare/kv.rs`). + +## Bespoke integration (single credential blob) + +If credentials arrive as one provider-specific JSON blob (Clerk), implement +`ProviderOps` directly and parse the blob yourself — see `providers/clerk.rs` +(`provision_with_credentials` + `parse_clerk_credentials`). Everything else +(registry row, gap test, hermetic test) is the same. + +## Cloud substrate + +1. **New crate** `crates/stackless-` depending on `stackless-cloud`; add to + workspace `members` + `[workspace.dependencies]` + the binary's deps. +2. **`pub const SUBSTRATE_NAME`** (the `--on` name). +3. **`impl Substrate`** (`crates/stackless-core/src/substrate.rs`) — `execute`, + `observe`, `destroy`, and default-overridable `spend_line` / `fetch_logs`. + `#[async_trait]` (the trait is used as `dyn`). +4. **Reuse `stackless-cloud`**: `credential::resolve(ENV, FILE, …)` and + `prepare::run_prepare_command(…)`; map their neutral failures to your errors. +5. **Own `error.rs` + `codes.rs`** (`_*` + `pub const ALL`); add your + `codes::ALL` to the workspace uniqueness test in `stackless/src/substrates.rs`. +6. **Generated REST client** (if needed): vendor the spec under `specs/`, add a + block to `specs/regen-clients.sh`, `mise run regen-clients`. + +Then one `SUBSTRATES` row in `crates/stackless/src/substrates.rs`. The deeper +cloud lifecycle (deploy polling, health gating) is deliberately **not** shared — +it differs materially between providers (extract only at a third substrate). + +## Smoke + +A live smoke runs through the shared `fixtures/smoke/run.sh` (always tears down, +unique per-run instance name). Add `fixtures/smoke//stackless.toml`, a +`smoke-` mise task calling `run.sh`, and a `smoke.yml` matrix entry. +Catalog integrations have no deploy target, so they smoke under `--on local` +with a trivial probe service (see `fixtures/smoke/cloudflare`). One-time human +step: `stripe projects link ` (account-level). + +--- + +## Gotchas (learned the hard way) + +- **The catalog has config schema but no *output* schema.** You cannot know the + credential env-var names without provisioning — use `mise run discover`. +- **Output env-var names are dynamic.** Stripe names them `{RESOURCE}_{SUFFIX}` + when several resources share an environment, or `{PROVIDER}_{SUFFIX}` when + unambiguous. The shared resolution tries both — declare just the `SUFFIX` in + `OUTPUT_FIELDS`. Outputs must be flat strings (interpolation ignores non-strings). +- **Paid auto-confirms within the spend cap.** `add_resource` passes + `--confirm-paid-service` automatically when the catalog tier is paid (R2). A + service whose catalog pricing is `component`/"unavailable" but that demands + confirmation live (`PRICE_CONFIRMATION_REQUIRED`, e.g. `cloudflare/containers`) + has **unknown cost** and is **not** auto-provisioned — excluded by default. +- **Providers rate-limit provisioning.** Cloudflare allows ~2 provisions per + ~22-min window. So a smoke can't bring up many resources at once, and envelope + discovery is spaced. Verification is gap + hermetic tests (offline, always) + + spaced live pinning — not "everything live in one run". +- **Teardown removes the Stripe *record*, not always the provider resource.** + `down` reports "verified gone" via the Stripe registration; the underlying + provider resource may linger (e.g. a Cloudflare namespace), which is why smokes + use unique names. Don't assume re-provisioning the same name is collision-free. +- **Not everything is disposable.** `registrar:domain` is a one-time non-refundable + domain purchase — never smoke it. diff --git a/docs/SELFTEST.md b/docs/SELFTEST.md index f41327e..16473f0 100644 --- a/docs/SELFTEST.md +++ b/docs/SELFTEST.md @@ -40,8 +40,8 @@ job per provider, secrets `STRIPE_API_KEY` / `VERCEL_TOKEN` / `RENDER_API_KEY`. ### Prerequisites (one-time, human) - The Stripe Project must have the provider **linked**: `stripe projects link - vercel` / `render` (account-level; new projects inherit it). Check with - `stripe projects status`. + vercel` / `render` / `cloudflare` (account-level; new projects inherit it). + Check with `stripe projects status`. - The provider API token must belong to the **linked** account/team. For Vercel, the substrate already reads the Stripe-managed token + `VERCEL_ORG_ID` from the instance env — see the **Vercel notes** at the end of this doc. @@ -51,9 +51,16 @@ job per provider, secrets `STRIPE_API_KEY` / `VERCEL_TOKEN` / `RENDER_API_KEY`. ### Adding a provider/integration -1. Implement the `Substrate` (or `Hostable`) as usual. -2. Add Tier-1 hermetic tests (mock the provider API + Stripe runner). -3. Drop a `fixtures/smoke//stackless.toml` that deploys `fixtures/smoke/site`. +1. Implement the `Substrate` (or `Hostable` + `ProviderOps`) — see + `docs/ADDING-A-PROVIDER.md` for the full checklist. +2. Add Tier-1 hermetic tests (mock the provider API + Stripe runner) and a + catalog-gap test (`verify_service`) per config. +3. Drop a `fixtures/smoke//stackless.toml`. A **substrate** deploys + `fixtures/smoke/site`; a **catalog integration** (Clerk, Cloudflare R2/KV/…) + has no deploy target, so it runs `--on local` and provisions the resource(s) + alongside a trivial local probe service (see `fixtures/smoke/cloudflare`). The + first live run is the source of truth for the credential output envelope — + reconcile the provider's `ENV_KEYS`/`parse_outputs` with what Stripe returns. 4. Add a `mise run smoke-` task and a matrix entry in `smoke.yml`. ## Stripe Projects plugin snapshots (versioned, auto-watched) diff --git a/fixtures/smoke/cloudflare/.gitignore b/fixtures/smoke/cloudflare/.gitignore new file mode 100644 index 0000000..75b703a --- /dev/null +++ b/fixtures/smoke/cloudflare/.gitignore @@ -0,0 +1,15 @@ +# Agent-onboarding files scaffolded by `stripe projects init` — not part of the fixture. +AGENTS.md +CLAUDE.md +.cursorignore +.claude/ +.cursor/ +.agents/ + +.projects/cache +.projects/vault +.projects/state.test.json +.projects/state.local.test.json +.env +.env.* +!.env.example diff --git a/fixtures/smoke/cloudflare/stackless.toml b/fixtures/smoke/cloudflare/stackless.toml new file mode 100644 index 0000000..a51ef29 --- /dev/null +++ b/fixtures/smoke/cloudflare/stackless.toml @@ -0,0 +1,36 @@ +# Live smoke: provisions Cloudflare R2 + KV through Stripe Projects under +# `--on local` (integrations provision under any substrate — no deploy target +# needed), injects their outputs into a trivial local probe service, then tears +# down and verifies the resources are gone. +# +# Run: stackless up --on local --file fixtures/smoke/cloudflare/stackless.toml --name +# One-time human step: `stripe projects link cloudflare` (account-level). +# The project anchor ([stack.projects.stripe].project) is recorded here on first up. +[stack] +name = "smoke-cloudflare" + +[stack.projects] +[stack.projects.stripe] +project = "project_61UsOaLHP2DEe7yTX16UhtGl9aA8OIk3xIw9EFWsK1JQ" + +[integrations.bucket] +provider = "cloudflare-r2" +name = "stackless-smoke-${instance.name}" + +[integrations.cache] +provider = "cloudflare-kv" +title = "stackless-smoke-${instance.name}" + +# A trivial local service that proves the integration outputs resolve into a +# service env (and gives the stack a health-gated service to run). +[services.web] +source = { repo = "https://github.com/snowmead/stackless", ref = "main" } +root_origin = true +health = { path = "/", contains = "stackless-smoke-ok" } +env = { R2_ENDPOINT = "${integrations.bucket.endpoint}", R2_BUCKET = "${integrations.bucket.bucket}", KV_NAMESPACE = "${integrations.cache.namespace_id}" } + +# Self-contained probe: serves an inline page so the smoke does not depend on +# the cloned source ref having fixtures/smoke/site (it isn't on main pre-merge). +# Local execs the command (`sh -c "exec "`), so wrap the script in `sh -c`. +[services.web.local] +run = "sh -c 'd=$(mktemp -d); printf stackless-smoke-ok > \"$d/index.html\"; exec python3 -m http.server $PORT --bind 127.0.0.1 --directory \"$d\"'" diff --git a/fixtures/smoke/render/.gitignore b/fixtures/smoke/render/.gitignore new file mode 100644 index 0000000..ac460cf --- /dev/null +++ b/fixtures/smoke/render/.gitignore @@ -0,0 +1,7 @@ +.projects/cache +.projects/vault +.projects/state.test.json +.projects/state.local.test.json +.env +.env.* +!.env.example diff --git a/fixtures/smoke/run.sh b/fixtures/smoke/run.sh new file mode 100644 index 0000000..172f014 --- /dev/null +++ b/fixtures/smoke/run.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Shared live-smoke runner for every provider. Fixes the bumps the per-provider +# tasks hit: (1) ALWAYS tears down even if `up` fails (`|| up=$?` instead of the +# errexit-skipping `cmd; up=$?`), so a failed run never orphans paid resources; +# (2) a UNIQUE per-run instance name, so a re-run never collides with a resource +# that lingered provider-side after a prior teardown. +# +# Usage: run.sh +# substrate --on target (vercel | render | local) +# fixture path to the smoke stackless.toml +# name-prefix short instance-name prefix (e.g. smoke-v) +set -u + +substrate="$1" +fixture="$2" +prefix="$3" + +# Local env file for creds (CI injects them as env vars instead). +[ -f .stackless.env ] && { set -a; . ./.stackless.env; set +a; } + +inst="${prefix}-$(date +%s)" + +up=0 +cargo run -q -p stackless -- up --name "$inst" --on "$substrate" --file "$fixture" || up=$? + +# Teardown always runs; verified-gone is part of `down`. Exit non-zero if either +# the up (health gate) or the down (teardown) failed. +down=0 +cargo run -q -p stackless -- down "$inst" || down=$? + +exit $(( up != 0 || down != 0 )) diff --git a/fixtures/smoke/vercel/.gitignore b/fixtures/smoke/vercel/.gitignore new file mode 100644 index 0000000..ac460cf --- /dev/null +++ b/fixtures/smoke/vercel/.gitignore @@ -0,0 +1,7 @@ +.projects/cache +.projects/vault +.projects/state.test.json +.projects/state.local.test.json +.env +.env.* +!.env.example diff --git a/mise.toml b/mise.toml index c6445de..67ad250 100644 --- a/mise.toml +++ b/mise.toml @@ -29,22 +29,15 @@ ci = { depends = ["check", "test", "supply-chain"] } # Live cloud smokes — gated on real provider creds, EXCLUDED from `ci` (which # stays hermetic). Locally they read tokens from .stackless.env; in CI the -# workflow injects VERCEL_TOKEN / RENDER_API_KEY as secrets. Each runs a real -# up, then always tears down, and fails if either the up (health) or the down -# (verified-gone) fails. -smoke-vercel = ''' -[ -f .stackless.env ] && { set -a; . ./.stackless.env; set +a; } -cargo run -q -p stackless -- up --name smoke-v --on vercel --file fixtures/smoke/vercel/stackless.toml; up=$? -cargo run -q -p stackless -- down smoke-v; down=$? -exit $(( up != 0 || down != 0 )) -''' -smoke-render = ''' -[ -f .stackless.env ] && { set -a; . ./.stackless.env; set +a; } -cargo run -q -p stackless -- up --name smoke-r --on render --file fixtures/smoke/render/stackless.toml; up=$? -cargo run -q -p stackless -- down smoke-r; down=$? -exit $(( up != 0 || down != 0 )) -''' -smoke = { depends = ["smoke-vercel", "smoke-render"] } +# workflow injects STRIPE_API_KEY / VERCEL_TOKEN / RENDER_API_KEY as secrets. All +# three share `fixtures/smoke/run.sh`: a real up, then ALWAYS a teardown, with a +# unique per-run instance name; the task fails if up (health) or down (verified- +# gone) fails. Cloudflare is a catalog integration, so it provisions R2+KV under +# `--on local` (needs `stripe projects link cloudflare`, account-level). +smoke-vercel = "bash fixtures/smoke/run.sh vercel fixtures/smoke/vercel/stackless.toml smoke-v" +smoke-render = "bash fixtures/smoke/run.sh render fixtures/smoke/render/stackless.toml smoke-r" +smoke-cloudflare = "bash fixtures/smoke/run.sh local fixtures/smoke/cloudflare/stackless.toml smoke-c" +smoke = { depends = ["smoke-vercel", "smoke-render", "smoke-cloudflare"] } # Stripe Projects plugin snapshots. `stripe-refresh` is the bless path (writes # the committed fixtures from the locally installed plugin); it needs the real @@ -59,6 +52,19 @@ stripe-coherence = "cargo nextest run -p stackless-stripe-projects -E 'test(fixt # Regenerate the vendored OpenAPI client crates (body stays shell — python + # cargo-progenitor glue with no Rust types to reuse). regen-clients = "bash specs/regen-clients.sh" + +# Provider-onboarding tooling (the `xtask` crate). `catalog` is offline (reads the +# committed catalog fixture); `discover` is live — it provisions a resource into a +# throwaway environment to capture its real credential envelope, so it needs creds +# (STRIPE_API_KEY) and a dir with a linked project (default cwd; pass `-- --dir ...`). +# e.g. `mise run catalog cloudflare`, `mise run discover cloudflare/kv -- --dir fixtures/smoke/cloudflare` +catalog = "cargo run -q -p xtask -- catalog" +new-integration = "cargo run -q -p xtask -- new-integration" +discover = ''' +[ -f .stackless.env ] && { set -a; . ./.stackless.env; set +a; } +cargo run -q -p xtask -- discover "$@" +''' + # Install the prek git hooks by hand (normally auto-wired by [hooks].postinstall). hooks = "prek install -t pre-commit -t pre-push -f"