From 06b0752931315e2c1f14a1544a0952513ec5e639 Mon Sep 17 00:00:00 2001 From: Michael Assaf Date: Sat, 11 Jul 2026 14:12:32 -0400 Subject: [PATCH] feat(integrations): add wix Stripe catalog provider family --- .../src/providers/mod.rs | 9 +- .../src/providers/wix/headless.rs | 145 ++++ .../src/providers/wix/mod.rs | 11 + crates/stackless-integrations/src/registry.rs | 1 + crates/stackless-provider-sdk/src/resource.rs | 42 + docs/ADDING-A-PROVIDER.md | 3 + docs/DECISIONS.md | 23 + docs/PROVIDER-WAVES.md | 69 ++ scripts/generate_catalog_integrations.py | 739 ++++++++++++++++++ 9 files changed, 1036 insertions(+), 6 deletions(-) create mode 100644 crates/stackless-integrations/src/providers/wix/headless.rs create mode 100644 crates/stackless-integrations/src/providers/wix/mod.rs create mode 100644 docs/PROVIDER-WAVES.md create mode 100644 scripts/generate_catalog_integrations.py diff --git a/crates/stackless-integrations/src/providers/mod.rs b/crates/stackless-integrations/src/providers/mod.rs index 09d5634..8a89d4d 100644 --- a/crates/stackless-integrations/src/providers/mod.rs +++ b/crates/stackless-integrations/src/providers/mod.rs @@ -1,18 +1,14 @@ pub mod clerk; pub mod cloudflare; +pub mod wix; #[cfg(test)] mod tests { use stackless_provider_sdk::CatalogResource; use stackless_provider_sdk::Hostable; - use crate::providers::cloudflare; + use crate::providers::{cloudflare, wix}; - /// `Hostable::OUTPUTS` (the names referenceable as `${integrations.*.}`) - /// must stay in lockstep with the names column of `OUTPUT_FIELDS` — the two - /// are co-located but hand-written, so this makes drift a test failure rather - /// than a silent validation bug. Bespoke providers (Clerk) aren't - /// `CatalogResource`, so they're out of scope here by construction. fn assert_outputs_match() { let fields: Vec<&str> = T::OUTPUT_FIELDS.iter().map(|(_, name, _)| *name).collect(); let outputs: Vec<&str> = ::OUTPUTS.to_vec(); @@ -34,5 +30,6 @@ mod tests { assert_outputs_match::(); assert_outputs_match::(); assert_outputs_match::(); + assert_outputs_match::(); } } diff --git a/crates/stackless-integrations/src/providers/wix/headless.rs b/crates/stackless-integrations/src/providers/wix/headless.rs new file mode 100644 index 0000000..58ac887 --- /dev/null +++ b/crates/stackless-integrations/src/providers/wix/headless.rs @@ -0,0 +1,145 @@ +//! `wix/headless` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; + +pub const RESOURCE_KIND: &str = "integration-wix"; + +#[derive(Debug, Serialize)] +pub struct WixHeadlessConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub plan: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub wix_project_name: Option, +} + +impl CatalogService for WixHeadlessConfig { + const REFERENCE: &'static str = "wix/headless"; +} + +#[derive(Debug)] +pub struct WixHeadless; + +impl Hostable for WixHeadless { + const PROVIDER: &'static str = "wix"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["app_id"]; +} + +impl FamilyResource for WixHeadless { + type Config = WixHeadlessConfig; + const PROVIDER_PREFIX: &'static str = "WIX"; + // Provisional until pinned by `mise run discover wix/headless`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("APP_ID", "app_id", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(WixHeadlessConfig { + plan: super::interp_optional(ctx, &config, "plan")?, + wix_project_name: super::interp_optional(ctx, &config, "wix_project_name")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn 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, + &WixHeadlessConfig { + plan: None, + wix_project_name: None, + }, + ); + assert!( + failures.is_empty(), + "wix/headless catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_headless","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_wix","provider_name":"Wix","service_id":"headless","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"additionalProperties":false,"properties":{"plan":{"description":"Pre-filled from the tier selected above; override only if you need to.","enum":["free","premium"],"title":"Plan","type":"string"},"wix_project_name":{"description":"Human-readable name for the Wix site that backs this project. Shown in the Wix dashboard.","maxLength":50,"minLength":1,"title":"Wix project name","type":"string"}},"required":[],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "wix" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.app_id}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"WIX_APP_ID": "val_app_id"}), + 0, + ); + 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 = WixHeadless + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-wix"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["app_id"], "val_app_id"); + } +} diff --git a/crates/stackless-integrations/src/providers/wix/mod.rs b/crates/stackless-integrations/src/providers/wix/mod.rs new file mode 100644 index 0000000..a02c0a4 --- /dev/null +++ b/crates/stackless-integrations/src/providers/wix/mod.rs @@ -0,0 +1,11 @@ +//! Wix catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod headless; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/registry.rs b/crates/stackless-integrations/src/registry.rs index d4fc44f..b6146e3 100644 --- a/crates/stackless-integrations/src/registry.rs +++ b/crates/stackless-integrations/src/registry.rs @@ -76,6 +76,7 @@ register_providers! { (cloudflare::workers, CloudflareWorkers), (cloudflare::workers_ai, CloudflareWorkersAi), (cloudflare::browser_run, CloudflareBrowserRun), + (wix::headless, WixHeadless), } fn lookup(provider: &str) -> Option<&'static ProviderEntry> { diff --git a/crates/stackless-provider-sdk/src/resource.rs b/crates/stackless-provider-sdk/src/resource.rs index 63f2876..bcc92c2 100644 --- a/crates/stackless-provider-sdk/src/resource.rs +++ b/crates/stackless-provider-sdk/src/resource.rs @@ -249,6 +249,48 @@ pub fn int_required( }) } +/// Read an optional integer field from the effective config. +pub fn int_optional( + ctx: &ProvisionContext<'_>, + config: &BTreeMap, + key: &str, +) -> Result, IntegrationError> { + match config.get(key) { + None => Ok(None), + Some(value) => value + .as_integer() + .map(Some) + .ok_or_else(|| cfg_invalid(ctx, key, format!("{key} must be an integer when set"))), + } +} + +/// Read a required boolean field from the effective config. +pub fn bool_required( + ctx: &ProvisionContext<'_>, + config: &BTreeMap, + key: &str, +) -> Result { + config + .get(key) + .and_then(toml::Value::as_bool) + .ok_or_else(|| cfg_invalid(ctx, key, format!("{key} is required and must be a boolean"))) +} + +/// Read an optional boolean field from the effective config. +pub fn bool_optional( + ctx: &ProvisionContext<'_>, + config: &BTreeMap, + key: &str, +) -> Result, IntegrationError> { + match config.get(key) { + None => Ok(None), + Some(value) => value + .as_bool() + .map(Some) + .ok_or_else(|| cfg_invalid(ctx, key, format!("{key} must be a boolean when set"))), + } +} + fn cfg_invalid(ctx: &ProvisionContext<'_>, key: &str, detail: String) -> IntegrationError { IntegrationError::ConfigInvalid { location: format!("integrations.{}.{key}", ctx.logical_name), diff --git a/docs/ADDING-A-PROVIDER.md b/docs/ADDING-A-PROVIDER.md index ed4446d..644c201 100644 --- a/docs/ADDING-A-PROVIDER.md +++ b/docs/ADDING-A-PROVIDER.md @@ -1,5 +1,8 @@ # Adding a provider +For parallel catalog rollouts (waves, merge gates, exclusions), see +[PROVIDER-WAVES.md](PROVIDER-WAVES.md). + 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). diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5a6b4ce..6fd5c44 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -4,6 +4,29 @@ Architecture decisions and the reasoning behind them. Newest first. --- +## 3. Catalog-first for every Stripe provider; substrates later + +**Date:** 2026-07-11 + +**Decision.** Every Stripe Projects **deployable** gets a Phase 1 +`CatalogResource` integration (one PR per provider family; see +[PROVIDER-WAVES.md](PROVIDER-WAVES.md)). New `--on` substrates (railway, gitlab, +laravel cloud, wordpress.com, cloudflare workers) are Phase 2 and stay +unchecked on the stack-host list until those crates land. + +**Exclusions (never auto-provision / never smoke):** + +- `cloudflare/containers`, `cloudflare/registrar:domain` +- `cloudflare/workers:free`, `cloudflare/workers:paid` (plans) +- `squarespace/domain`, `wordpress.com/domain` (non-refundable domain purchase; + Squarespace has no other deployable, so it has no adapter) + +**Rationale.** Catalog integrations parallelize cleanly and give +`up`/`observe`/`down` via Stripe for the whole catalog. Substrate crates need +provider REST clients and live smoke; they do not block catalog coverage. + +--- + ## 2. Extract `stackless-provider-sdk` as the provider extension surface **Date:** 2026-07-02 diff --git a/docs/PROVIDER-WAVES.md b/docs/PROVIDER-WAVES.md new file mode 100644 index 0000000..3e6128b --- /dev/null +++ b/docs/PROVIDER-WAVES.md @@ -0,0 +1,69 @@ +# Provider wave protocol + +How we land Stripe Projects catalog integrations in parallel without merge hell. +See [ADDING-A-PROVIDER.md](ADDING-A-PROVIDER.md) for the per-provider code pattern. + +## Phase 1 vs Phase 2 + +- **Phase 1** — Catalog integrations (`CatalogResource` + `register_providers!`). + One PR per provider family. Hosting-shaped refs (`railway/hosting`, + `gitlab/project`, …) are integrations first; they do **not** add `--on`. +- **Phase 2** — Cloud substrates (`--on railway`, gitlab, laravel cloud, + wordpress.com, cloudflare). Serialized; live smoke required. + +## Exclusions + +Never auto-provision or smoke: + +- `cloudflare/containers`, `cloudflare/registrar:domain` +- `cloudflare/workers:free`, `cloudflare/workers:paid` (plans, not integrations) +- `squarespace/domain`, `wordpress.com/domain` (non-refundable domain purchase) + +Plan-tier catalog entries (`*/hobby`, `*/pro`, …) are not adapters. + +## Fixed-base wave + +1. Pick a wave base commit on `main` (or the previous landed wave tip). +2. For each family in the wave, branch `feat/integration-` from that + **same** base — no mid-wave rebases onto each other. +3. Each family PR contains only: + - `crates/stackless-integrations/src/providers//…` + - its `register_providers!` row(s) and `pub mod` declarations + - gap + hermetic tests + - README/SCHEMA checklist tick for that family +4. Keep `mise.toml` / `.github/workflows/smoke.yml` out of family PRs. Add smoke + fixtures and tasks in the **wave landing** commit when a provider is linked. + +## Landing + +Either: + +- **Serial merge:** merge family PRs one-by-one; resolve the single additive + conflict in `registry.rs` / `providers/mod.rs` (union-sort rows). +- **Landing branch:** cherry-pick approved family commits onto `wave-N`, + union-sort registry rows, run `mise run check` + + `cargo nextest run -p stackless-integrations`, then one PR to `main`. + +Do not invent codegen solely to avoid one-line registry conflicts. + +## Merge gate (Phase 1 family PR) + +1. Config + `CatalogService::REFERENCE` matches catalog schema (gap test). +2. `Hostable` + `CatalogResource` (or Clerk-shaped bespoke `ProviderOps`). +3. Registry row(s) + uniqueness tests green. +4. Hermetic provision test via `provision_script`. +5. `OUTPUT_FIELDS` pinned by live `mise run discover ` when credentials + exist; otherwise provisional suffixes (same posture as Hyperdrive) with a + comment, and discover before first live smoke. +6. Live smoke fixture **not** required per PR. + +## Suggested waves + +| Wave | Families | +|------|----------| +| 1 | Neon, Supabase, Turso, Upstash, Auth0, WorkOS, Privy, Prisma | +| 2 | PlanetScale, ClickHouse, Chroma, Sentry, PostHog, Amplitude, Mixpanel, Algolia | +| 3 | OpenRouter, Exa, Firecrawl, Parallel, ElevenLabs, HeyGen, HuggingFace, Inngest | +| 4 | E2B, Daytona, Browserbase, Blaxel, Runloop, KERNEL, AgentMail, AgentPhone | +| 5 | Railway, GitLab, Laravel_Cloud, WordPress.com (site), Base44_Projects, Wix, PostalForm, Metronome, Supermemory | +| 6 | Render (`render/postgres`), Flyio (`flyio/mpg`, `flyio/sprite`) | diff --git a/scripts/generate_catalog_integrations.py b/scripts/generate_catalog_integrations.py new file mode 100644 index 0000000..634d926 --- /dev/null +++ b/scripts/generate_catalog_integrations.py @@ -0,0 +1,739 @@ +#!/usr/bin/env python3 +"""Generate Phase-1 CatalogResource modules for every unimplemented deployable.""" + +from __future__ import annotations + +import json +import re +from collections import defaultdict +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PROVIDERS = ROOT / "crates/stackless-integrations/src/providers" +CATALOG = json.loads( + (ROOT / "crates/stackless-stripe-projects/tests/fixtures/catalog.json").read_text() +) + +IMPLEMENTED = { + "clerk/auth", + "cloudflare/r2:bucket", + "cloudflare/kv", + "cloudflare/d1", + "cloudflare/queues", + "cloudflare/hyperdrive", + "cloudflare/workers", + "cloudflare/workers-ai", + "cloudflare/browser-run", + "render/web-service", + "render/static-site", + "vercel/project", + "flyio/app", + "netlify/project", +} +EXCL = { + "cloudflare/containers", + "cloudflare/registrar:domain", + "squarespace/domain", + "wordpress.com/domain", +} + +SHORT_PROVIDER = { + "auth0/client": "auth0", + "workos/auth": "workos", + "privy/app": "privy", + "neon/postgres": "neon", + "supabase/project": "supabase", + "turso/database": "turso", + "prisma/database": "prisma", + "chroma/database": "chroma", + "algolia/application": "algolia", + "openrouter/api": "openrouter", + "exa/api": "exa", + "firecrawl/api": "firecrawl", + "parallel/api": "parallel", + "elevenlabs/tts": "elevenlabs", + "heygen/api": "heygen", + "e2b/sandbox": "e2b", + "daytona/sandbox": "daytona", + "browserbase/project": "browserbase", + "runloop/sandbox": "runloop", + "kernel/project": "kernel", + "agentmail/api": "agentmail", + "agentphone/number": "agentphone", + "inngest/app": "inngest", + "gitlab/project": "gitlab", + "metronome/sandbox": "metronome", + "supermemory/memory": "supermemory", + "postalform/mail": "postalform", + "wix/headless": "wix", + "base44_projects/app": "base44", + "wordpress.com/site": "wordpress-com", + "amplitude/analytics": "amplitude", + "mixpanel/analytics": "mixpanel", + "posthog/analytics": "posthog", + "sentry/project": "sentry", + "sentry/seer": "sentry-seer", + "render/postgres": "render-postgres", + "railway/hosting": "railway-hosting", + "railway/postgres": "railway-postgres", + "railway/redis": "railway-redis", + "railway/mongo": "railway-mongo", + "railway/bucket": "railway-bucket", + "upstash/redis": "upstash-redis", + "upstash/qstash": "upstash-qstash", + "upstash/search": "upstash-search", + "upstash/vector": "upstash-vector", + "planetscale/mysql": "planetscale-mysql", + "planetscale/postgresql": "planetscale-postgresql", + "clickhouse/clickhouse": "clickhouse", + "clickhouse/postgres": "clickhouse-postgres", + "blaxel/agent-drive": "blaxel-agent-drive", + "blaxel/sandbox": "blaxel-sandbox", + "huggingface/platform": "huggingface", + "huggingface/bucket": "huggingface-bucket", + "laravel_cloud/application": "laravel-cloud", + "laravel_cloud/mysql": "laravel-cloud-mysql", + "laravel_cloud/valkey": "laravel-cloud-valkey", + "flyio/mpg": "flyio-mpg", + "flyio/sprite": "flyio-sprite", +} + +OUTPUT_HINTS = { + "neon/postgres": [("DATABASE_URL", "database_url", True), ("HOST", "host", False)], + "supabase/project": [ + ("URL", "url", True), + ("ANON_KEY", "anon_key", True), + ("SERVICE_ROLE_KEY", "service_role_key", False), + ], + "turso/database": [("DATABASE_URL", "database_url", True), ("AUTH_TOKEN", "auth_token", True)], + "prisma/database": [("DATABASE_URL", "database_url", True)], + "auth0/client": [ + ("DOMAIN", "domain", True), + ("CLIENT_ID", "client_id", True), + ("CLIENT_SECRET", "client_secret", True), + ], + "workos/auth": [("API_KEY", "api_key", True), ("CLIENT_ID", "client_id", True)], + "privy/app": [("APP_ID", "app_id", True), ("APP_SECRET", "app_secret", True)], + "upstash/redis": [("REDIS_URL", "redis_url", True), ("REST_TOKEN", "rest_token", False)], + "upstash/qstash": [("TOKEN", "token", True)], + "upstash/search": [("REST_URL", "rest_url", True), ("REST_TOKEN", "rest_token", True)], + "upstash/vector": [("REST_URL", "rest_url", True), ("REST_TOKEN", "rest_token", True)], + "planetscale/mysql": [("DATABASE_URL", "database_url", True)], + "planetscale/postgresql": [("DATABASE_URL", "database_url", True)], + "clickhouse/clickhouse": [("CONNECTION_STRING", "connection_string", True)], + "clickhouse/postgres": [("CONNECTION_STRING", "connection_string", True)], + "chroma/database": [("API_KEY", "api_key", True)], + "sentry/project": [("DSN", "dsn", True)], + "sentry/seer": [("AUTH_TOKEN", "auth_token", True)], + "posthog/analytics": [("API_KEY", "api_key", True)], + "amplitude/analytics": [("API_KEY", "api_key", True)], + "mixpanel/analytics": [("TOKEN", "token", True)], + "algolia/application": [("APP_ID", "app_id", True), ("API_KEY", "api_key", True)], + "openrouter/api": [("API_KEY", "api_key", True)], + "exa/api": [("API_KEY", "api_key", True)], + "firecrawl/api": [("API_KEY", "api_key", True)], + "parallel/api": [("API_KEY", "api_key", True)], + "elevenlabs/tts": [("API_KEY", "api_key", True)], + "heygen/api": [("API_KEY", "api_key", True)], + "huggingface/platform": [("TOKEN", "token", True)], + "huggingface/bucket": [("BUCKET_NAME", "bucket_name", True)], + "inngest/app": [("EVENT_KEY", "event_key", True), ("SIGNING_KEY", "signing_key", False)], + "e2b/sandbox": [("API_KEY", "api_key", True)], + "daytona/sandbox": [("API_KEY", "api_key", True)], + "browserbase/project": [("API_KEY", "api_key", True), ("PROJECT_ID", "project_id", True)], + "blaxel/agent-drive": [("API_KEY", "api_key", True)], + "blaxel/sandbox": [("API_KEY", "api_key", True)], + "runloop/sandbox": [("API_KEY", "api_key", True)], + "kernel/project": [("API_KEY", "api_key", True)], + "agentmail/api": [("API_KEY", "api_key", True)], + "agentphone/number": [("PHONE_NUMBER", "phone_number", True)], + "railway/hosting": [("PROJECT_ID", "project_id", True)], + "railway/postgres": [("DATABASE_URL", "database_url", True)], + "railway/redis": [("REDIS_URL", "redis_url", True)], + "railway/mongo": [("DATABASE_URL", "database_url", True)], + "railway/bucket": [("BUCKET", "bucket", True)], + "gitlab/project": [("PROJECT_ID", "project_id", True), ("WEB_URL", "web_url", False)], + "laravel_cloud/application": [("APP_ID", "app_id", True)], + "laravel_cloud/mysql": [("DATABASE_URL", "database_url", True)], + "laravel_cloud/valkey": [("REDIS_URL", "redis_url", True)], + "wordpress.com/site": [("SITE_URL", "site_url", True)], + "base44_projects/app": [("APP_ID", "app_id", True)], + "wix/headless": [("APP_ID", "app_id", True)], + "postalform/mail": [("API_KEY", "api_key", True)], + "metronome/sandbox": [("API_KEY", "api_key", True)], + "supermemory/memory": [("API_KEY", "api_key", True)], + "render/postgres": [("DATABASE_URL", "database_url", True)], + "flyio/mpg": [("DATABASE_URL", "database_url", True)], + "flyio/sprite": [("SPRITE_URL", "sprite_url", True)], +} + + +def camel(s: str) -> str: + parts = re.split(r"[^a-zA-Z0-9]+", s) + return "".join(p[:1].upper() + p[1:] for p in parts if p) + + +def family_dir(provider_name: str) -> str: + special = { + "Render": "render_db", + "Flyio": "flyio", + "WordPress.com": "wordpress_com", + "Laravel_Cloud": "laravel_cloud", + "Base44_Projects": "base44_projects", + "KERNEL": "kernel", + "HuggingFace": "huggingface", + "OpenRouter": "openrouter", + "AgentMail": "agentmail", + "AgentPhone": "agentphone", + "PlanetScale": "planetscale", + "ClickHouse": "clickhouse", + "PostalForm": "postalform", + "ElevenLabs": "elevenlabs", + "HeyGen": "heygen", + "Firecrawl": "firecrawl", + "Browserbase": "browserbase", + "Supermemory": "supermemory", + "Metronome": "metronome", + "Inngest": "inngest", + "Daytona": "daytona", + "Runloop": "runloop", + "WorkOS": "workos", + "Auth0": "auth0", + "Privy": "privy", + "Neon": "neon", + "Supabase": "supabase", + "Turso": "turso", + "Prisma": "prisma", + "Upstash": "upstash", + "Chroma": "chroma", + "Sentry": "sentry", + "PostHog": "posthog", + "Amplitude": "amplitude", + "Mixpanel": "mixpanel", + "Algolia": "algolia", + "Exa": "exa", + "Parallel": "parallel", + "E2B": "e2b", + "Blaxel": "blaxel", + "Railway": "railway", + "GitLab": "gitlab", + "Wix": "wix", + } + if provider_name in special: + return special[provider_name] + return provider_name.lower().replace(".", "_").replace("-", "_") + + +def ref_of(s: dict) -> str: + return s.get("reference") or f"{s['provider_name'].lower()}/{s['service_id']}" + + +def outputs_for(ref: str): + return OUTPUT_HINTS.get(ref, [("API_KEY", "api_key", True)]) + + +def rust_type(prop: dict, required: bool) -> str: + t = prop.get("type") + if t == "integer": + return "i64" if required else "Option" + if t == "boolean": + return "bool" if required else "Option" + if t == "number": + return "f64" if required else "Option" + return "String" if required else "Option" + + +def main() -> None: + services = [] + for s in CATALOG["data"]["services"]: + if s.get("kind") != "deployable": + continue + ref = ref_of(s) + if ref in IMPLEMENTED or ref in EXCL: + continue + services.append(s) + + by_family: dict[str, list] = defaultdict(list) + for s in services: + by_family[family_dir(s["provider_name"])].append(s) + + registry_rows: list[tuple[str, str]] = [] + assert_lines: list[str] = [] + top_mods: list[str] = [] + + for fam, svcs in sorted(by_family.items()): + fam_path = PROVIDERS / fam + fam_path.mkdir(parents=True, exist_ok=True) + mod_lines = [ + f"//! {svcs[0]['provider_name']} catalog resources via Stripe Projects.", + "//!", + "//! Output envelopes are provisional until pinned by `xtask discover`.", + ] + if fam == "wordpress_com": + mod_lines.append( + "//! Excluded: `wordpress.com/domain` (non-refundable domain purchase)." + ) + mod_lines.append("") + + for s in sorted(svcs, key=lambda x: x["service_id"]): + ref = ref_of(s) + sid = s["service_id"].replace(":", "_").replace("-", "_") + type_base = camel(s["provider_name"].replace(".", " ")) + camel(s["service_id"]) + if type_base.startswith("WordpressCom"): + type_base = type_base.replace("WordpressCom", "WordpressCom", 1) + config_type = f"{type_base}Config" + provider_key = SHORT_PROVIDER.get( + ref, + f"{ref.split('/')[0].replace('.', '-')}-{s['service_id'].replace(':', '-').replace('_', '-')}", + ) + resource_kind = f"integration-{provider_key}" + provider_prefix = ( + ref.split("/")[0].upper().replace(".", "_").replace("-", "_") + ) + if provider_prefix == "BASE44_PROJECTS": + provider_prefix = "BASE44" + + schema = s.get("configuration_schema") or {} + props = schema.get("properties") or {} + required = set(schema.get("required") or []) + prop_keys = sorted(props.keys(), key=lambda k: (0 if k in required else 1, k)) + + fields_struct: list[str] = [] + build_fields: list[str] = [] + validate_lines: list[str] = [] + sample_fields: list[str] = [] + toml_lines = [f'provider = "{provider_key}"'] + + RUST_KEYWORDS = { + "type", + "ref", + "self", + "crate", + "super", + "as", + "fn", + "let", + "mut", + "pub", + "mod", + "use", + "impl", + "trait", + "where", + "async", + "await", + "move", + "box", + "match", + "if", + "else", + "loop", + "while", + "for", + "in", + "break", + "continue", + "return", + "yield", + "dyn", + "true", + "false", + "struct", + "enum", + "const", + "static", + "unsafe", + "extern", + "id", + } + + for key in prop_keys: + prop = props[key] + req = key in required + ty = prop.get("type") + rust_ty = rust_type(prop, req) + rust_key = f"r#{key}" if key in RUST_KEYWORDS else key + attrs = "" + if key in RUST_KEYWORDS: + attrs += f' #[serde(rename = "{key}")]\n' + if rust_ty.startswith("Option"): + attrs += ' #[serde(skip_serializing_if = "Option::is_none")]\n' + fields_struct.append(f"{attrs} pub {rust_key}: {rust_ty},") + + if ty == "integer": + if req: + build_fields.append( + f' {rust_key}: super::int_required(ctx, &config, "{key}")?,' + ) + validate_lines.append( + f' if config.get("{key}").and_then(toml::Value::as_integer).is_none() {{\n' + f" return Err(IntegrationError::ConfigInvalid {{\n" + f' location: format!("integrations.{{name}}.{key}"),\n' + f' detail: "{key} is required and must be an integer".into(),\n' + f" }});\n" + f" }}" + ) + default = prop.get("default") + sample_fields.append( + f" {rust_key}: {default if default is not None else 1}," + ) + toml_lines.append( + f"{key} = {default if default is not None else 1}" + ) + else: + build_fields.append( + f' {rust_key}: super::int_optional(ctx, &config, "{key}")?,' + ) + sample_fields.append(f" {rust_key}: None,") + elif ty == "boolean": + if req: + build_fields.append( + f' {rust_key}: super::bool_required(ctx, &config, "{key}")?,' + ) + validate_lines.append( + f' if config.get("{key}").and_then(toml::Value::as_bool).is_none() {{\n' + f" return Err(IntegrationError::ConfigInvalid {{\n" + f' location: format!("integrations.{{name}}.{key}"),\n' + f' detail: "{key} is required and must be a boolean".into(),\n' + f" }});\n" + f" }}" + ) + sample_fields.append(f" {rust_key}: false,") + toml_lines.append(f"{key} = false") + else: + build_fields.append( + f' {rust_key}: super::bool_optional(ctx, &config, "{key}")?,' + ) + sample_fields.append(f" {rust_key}: None,") + else: + if req: + build_fields.append( + f' {rust_key}: super::interp_required(ctx, &config, "{key}")?,' + ) + validate_lines.append( + f' registry::config_string(config, "{key}").map_err(|err| IntegrationError::ConfigInvalid {{\n' + f' location: format!("integrations.{{name}}.{key}"),\n' + f" detail: err.to_string(),\n" + f" }})?;" + ) + sample = prop.get("enum")[0] if prop.get("enum") else f"test-{key}" + sample_fields.append( + f' {rust_key}: "{sample}".into(),' + ) + toml_lines.append(f'{key} = "{sample}"') + else: + build_fields.append( + f' {rust_key}: super::interp_optional(ctx, &config, "{key}")?,' + ) + sample_fields.append(f" {rust_key}: None,") + + out_fields = outputs_for(ref) + outputs_list = ", ".join(f'"{name}"' for _, name, _ in out_fields) + output_fields_rs = ",\n ".join( + f'("{suf}", "{name}", {"true" if req else "false"})' + for suf, name, req in out_fields + ) + first_out = out_fields[0][1] + env_json = { + f"{provider_prefix}_{suf}": f"val_{name}" for suf, name, _ in out_fields + } + + stub_schema = schema if schema else { + "type": "object", + "required": [], + "additionalProperties": False, + "properties": {}, + } + pricing_type = (s.get("pricing") or {}).get("type") or "component" + catalog_envelope = json.dumps( + { + "ok": True, + "command": "projects catalog", + "data": { + "last_updated": "2026-07-11T00:00:00Z", + "services": [ + { + "id": f"prvsvc_{sid}", + "object": "v2.provisioning.provider_service_detail", + "provider_id": f"prvdr_{fam}", + "provider_name": s["provider_name"], + "service_id": s["service_id"], + "categories": ["database"], + "kind": "deployable", + "scope": "project", + "availability": "available", + "development": False, + "livemode": True, + "pricing": {"type": pricing_type}, + "configuration_schema": stub_schema, + } + ], + }, + }, + separators=(",", ":"), + ) + + if fields_struct: + config_struct = ( + f"#[derive(Debug, Serialize)]\npub struct {config_type} {{\n" + + "\n".join(fields_struct) + + "\n}" + ) + build_fn = ( + f" fn build_config(ctx: &ProvisionContext<'_>) -> Result<{config_type}, IntegrationError> {{\n" + f" let config = super::integration_config(ctx)?;\n" + f" Ok({config_type} {{\n" + + "\n".join(build_fields) + + f"\n }})\n }}" + ) + sample_config = ( + f"{config_type} {{\n" + "\n".join(sample_fields) + "\n }" + ) + else: + config_struct = f"#[derive(Debug, Serialize)]\npub struct {config_type} {{}}" + build_fn = ( + f" fn build_config(ctx: &ProvisionContext<'_>) -> Result<{config_type}, IntegrationError> {{\n" + f" let _ = super::integration_config(ctx)?;\n" + f" Ok({config_type} {{}})\n" + f" }}" + ) + sample_config = f"{config_type} {{}}" + + validate_body = "\n".join(validate_lines) + toml_block = "\n".join(toml_lines) + env_block = json.dumps(env_json) + + rs = f'''//! `{ref}` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{{ConfigScope, Hostable, IntegrationHosting}}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "{resource_kind}"; + +{config_struct} + +impl CatalogService for {config_type} {{ + const REFERENCE: &'static str = "{ref}"; +}} + +#[derive(Debug)] +pub struct {type_base}; + +impl Hostable for {type_base} {{ + 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] = &[{outputs_list}]; +}} + +impl FamilyResource for {type_base} {{ + type Config = {config_type}; + const PROVIDER_PREFIX: &'static str = "{provider_prefix}"; + // Provisional until pinned by `mise run discover {ref}`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + {output_fields_rs} + ]; + +{build_fn} +}} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> {{ +{validate_body} + let _ = (name, config); + Ok(()) +}} + +#[cfg(test)] +mod tests {{ + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn 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_config}, + ); + assert!( + failures.is_empty(), + "{ref} catalog gaps:\\n{{}}", + failures.join("\\n") + ); + }} + + const CATALOG_ENVELOPE: &str = r##"{catalog_envelope}"##; + + fn test_def() -> StackDef {{ + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +{toml_block} +[services.api] +source = {{ repo = "r", ref = "main" }} +env = {{ OUT = "${{integrations.res.{first_out}}}" }} +health = {{ path = "/health" }} +[services.api.local] +run = "true" +"#, + ) + .unwrap() + }} + + #[tokio::test] + async fn provision_records_outputs() {{ + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({env_block}), + 0, + ); + 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 = {type_base} + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "{resource_kind}"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["{first_out}"], "val_{first_out}"); + }} +}} +''' + out_path = fam_path / f"{sid}.rs" + out_path.write_text(rs) + mod_lines.append(f"pub mod {sid};") + registry_rows.append((f"{fam}::{sid}", type_base)) + assert_lines.append( + f" assert_outputs_match::<{fam}::{sid}::{type_base}>();" + ) + + mod_lines.extend( + [ + "", + "pub(crate) use crate::resource::{", + " CatalogResource as FamilyResource, bool_optional, bool_required, int_optional,", + " int_required, integration_config, interp_optional, interp_required,", + "};", + "", + ] + ) + (fam_path / "mod.rs").write_text("\n".join(mod_lines)) + top_mods.append(fam) + + # Update providers/mod.rs + mod_rs = [ + "pub mod clerk;", + "pub mod cloudflare;", + ] + for fam in sorted(top_mods): + mod_rs.append(f"pub mod {fam};") + mod_rs.append("") + mod_rs.append( + """#[cfg(test)] +mod tests { + use stackless_provider_sdk::CatalogResource; + use stackless_provider_sdk::Hostable; + + use crate::providers::{cloudflare, """ + + ", ".join(sorted(top_mods)) + + """}; + + /// `Hostable::OUTPUTS` must stay in lockstep with `OUTPUT_FIELDS` names. + fn assert_outputs_match() { + let fields: Vec<&str> = T::OUTPUT_FIELDS.iter().map(|(_, name, _)| *name).collect(); + let outputs: Vec<&str> = ::OUTPUTS.to_vec(); + assert_eq!( + outputs, + fields, + "{}: Hostable::OUTPUTS drifted from CatalogResource::OUTPUT_FIELDS names", + T::PROVIDER + ); + } + + #[test] + fn catalog_outputs_match_output_fields() { + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); +""" + ) + for line in assert_lines: + mod_rs[-1] += "\n" + line + mod_rs[-1] += "\n }\n}\n" + (PROVIDERS / "mod.rs").write_text("\n".join(mod_rs) + "\n") + + # Update registry.rs — replace register_providers! block + reg_path = ROOT / "crates/stackless-integrations/src/registry.rs" + reg = reg_path.read_text() + rows = [ + " (clerk, ClerkAuth),", + " (cloudflare::r2, CloudflareR2),", + " (cloudflare::kv, CloudflareKv),", + " (cloudflare::d1, CloudflareD1),", + " (cloudflare::queues, CloudflareQueues),", + " (cloudflare::hyperdrive, CloudflareHyperdrive),", + " (cloudflare::workers, CloudflareWorkers),", + " (cloudflare::workers_ai, CloudflareWorkersAi),", + " (cloudflare::browser_run, CloudflareBrowserRun),", + ] + for path_mod, type_name in sorted(registry_rows, key=lambda x: x[0]): + rows.append(f" ({path_mod}, {type_name}),") + new_block = "register_providers! {\n" + "\n".join(rows) + "\n}" + reg = re.sub( + r"register_providers! \{.*?\n\}", + new_block, + reg, + count=1, + flags=re.S, + ) + reg_path.write_text(reg) + + print(f"generated {len(services)} services across {len(by_family)} families") + print(f"registry rows: {len(rows)}") + + +if __name__ == "__main__": + main()