From 828eb87aad27814264d85dce3d412ad980293b58 Mon Sep 17 00:00:00 2001 From: yardend Date: Mon, 9 Mar 2026 11:50:01 +0200 Subject: [PATCH 1/3] feat(stripe): supports stripe connector Add Stripe connector support with OAuth flow, deploy/pull integration, and connector management commands. Co-Authored-By: Claude Opus 4.6 --- .../cli/src/cli/commands/connectors/index.ts | 2 +- .../cli/commands/connectors/oauth-prompt.ts | 15 +- .../cli/src/cli/commands/connectors/pull.ts | 11 +- .../cli/src/cli/commands/connectors/push.ts | 64 ++-- .../cli/src/cli/commands/project/deploy.ts | 57 +++- packages/cli/src/cli/utils/urls.ts | 5 + .../cli/src/core/resources/connector/api.ts | 83 ++++++ .../src/core/resources/connector/config.ts | 21 +- .../cli/src/core/resources/connector/index.ts | 1 + .../cli/src/core/resources/connector/pull.ts | 23 ++ .../cli/src/core/resources/connector/push.ts | 138 ++++++++- .../src/core/resources/connector/schema.ts | 31 ++ .../cli/tests/cli/connectors_pull.spec.ts | 22 ++ .../cli/tests/cli/connectors_push.spec.ts | 81 ++++++ packages/cli/tests/cli/deploy.spec.ts | 35 +++ .../cli/tests/cli/testkit/Base44APIMock.ts | 53 ++++ packages/cli/tests/core/connectors.spec.ts | 273 +++++++++++++++++- .../with-stripe-connector/base44/.app.jsonc | 3 + .../with-stripe-connector/config.jsonc | 3 + .../connectors/slack.jsonc | 4 + .../connectors/stripe.jsonc | 4 + 21 files changed, 842 insertions(+), 87 deletions(-) create mode 100644 packages/cli/src/core/resources/connector/pull.ts create mode 100644 packages/cli/tests/fixtures/with-stripe-connector/base44/.app.jsonc create mode 100644 packages/cli/tests/fixtures/with-stripe-connector/config.jsonc create mode 100644 packages/cli/tests/fixtures/with-stripe-connector/connectors/slack.jsonc create mode 100644 packages/cli/tests/fixtures/with-stripe-connector/connectors/stripe.jsonc diff --git a/packages/cli/src/cli/commands/connectors/index.ts b/packages/cli/src/cli/commands/connectors/index.ts index 695a9c6b..34ec65dc 100644 --- a/packages/cli/src/cli/commands/connectors/index.ts +++ b/packages/cli/src/cli/commands/connectors/index.ts @@ -5,7 +5,7 @@ import { getConnectorsPushCommand } from "./push.js"; export function getConnectorsCommand(context: CLIContext): Command { return new Command("connectors") - .description("Manage project connectors (OAuth integrations)") + .description("Manage project connectors") .addCommand(getConnectorsPullCommand(context)) .addCommand(getConnectorsPushCommand(context)); } diff --git a/packages/cli/src/cli/commands/connectors/oauth-prompt.ts b/packages/cli/src/cli/commands/connectors/oauth-prompt.ts index 288ddb39..3c9825d0 100644 --- a/packages/cli/src/cli/commands/connectors/oauth-prompt.ts +++ b/packages/cli/src/cli/commands/connectors/oauth-prompt.ts @@ -6,6 +6,7 @@ import type { ConnectorOAuthStatus, ConnectorSyncResult, IntegrationType, + OAuthSyncResult, } from "@/core/resources/connector/index.js"; import { getOAuthStatus } from "@/core/resources/connector/index.js"; @@ -14,17 +15,11 @@ const POLL_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes export type OAuthFlowStatus = ConnectorOAuthStatus | "SKIPPED"; -type PendingOAuthResult = ConnectorSyncResult & { - redirectUrl: string; - connectionId: string; -}; - export function filterPendingOAuth( results: ConnectorSyncResult[], -): PendingOAuthResult[] { +): OAuthSyncResult[] { return results.filter( - (r): r is PendingOAuthResult => - r.action === "needs_oauth" && !!r.redirectUrl && !!r.connectionId, + (r): r is OAuthSyncResult => r.action === "needs_oauth" && !!r.connectionId, ); } @@ -38,7 +33,7 @@ interface OAuthPromptOptions { * so Ctrl+C/Esc skips the current connector instead of killing the process. */ async function runOAuthFlowWithSkip( - connector: PendingOAuthResult, + connector: OAuthSyncResult, ): Promise { await open(connector.redirectUrl); @@ -103,7 +98,7 @@ async function runOAuthFlowWithSkip( * the prompt was skipped / declined. */ export async function promptOAuthFlows( - pending: PendingOAuthResult[], + pending: OAuthSyncResult[], options?: OAuthPromptOptions, ): Promise> { const outcomes = new Map(); diff --git a/packages/cli/src/cli/commands/connectors/pull.ts b/packages/cli/src/cli/commands/connectors/pull.ts index 902e2db5..8ae26f6e 100644 --- a/packages/cli/src/cli/commands/connectors/pull.ts +++ b/packages/cli/src/cli/commands/connectors/pull.ts @@ -4,7 +4,7 @@ import { Command } from "commander"; import type { CLIContext } from "@/cli/types.js"; import { readProjectConfig } from "@/core/index.js"; import { - listConnectors, + pullAllConnectors, writeConnectors, } from "@/core/resources/connector/index.js"; import { runCommand, runTask } from "../../utils/index.js"; @@ -19,7 +19,7 @@ async function pullConnectorsAction(): Promise { const remoteConnectors = await runTask( "Fetching connectors from Base44", async () => { - return await listConnectors(); + return await pullAllConnectors(); }, { successMessage: "Connectors fetched successfully", @@ -30,10 +30,7 @@ async function pullConnectorsAction(): Promise { const { written, deleted } = await runTask( "Syncing connector files", async () => { - return await writeConnectors( - connectorsDir, - remoteConnectors.integrations, - ); + return await writeConnectors(connectorsDir, remoteConnectors); }, { successMessage: "Connector files synced successfully", @@ -52,7 +49,7 @@ async function pullConnectorsAction(): Promise { } return { - outroMessage: `Pulled ${remoteConnectors.integrations.length} connectors to ${connectorsDir}`, + outroMessage: `Pulled ${remoteConnectors.length} connectors to ${connectorsDir}`, }; } diff --git a/packages/cli/src/cli/commands/connectors/push.ts b/packages/cli/src/cli/commands/connectors/push.ts index 198350cc..28f6b94c 100644 --- a/packages/cli/src/cli/commands/connectors/push.ts +++ b/packages/cli/src/cli/commands/connectors/push.ts @@ -3,11 +3,13 @@ import { Command } from "commander"; import type { CLIContext } from "@/cli/types.js"; import { runCommand, runTask, theme } from "@/cli/utils/index.js"; import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import { getConnectorsUrl } from "@/cli/utils/urls.js"; import { readProjectConfig } from "@/core/index.js"; import { type ConnectorSyncResult, type IntegrationType, pushConnectors, + type StripeSyncResult, } from "@/core/resources/connector/index.js"; import { filterPendingOAuth, @@ -21,36 +23,56 @@ function printSummary( ): void { const synced: IntegrationType[] = []; const added: IntegrationType[] = []; + let provisioned: StripeSyncResult | undefined; const removed: IntegrationType[] = []; const skipped: IntegrationType[] = []; - const failed: { type: IntegrationType; error?: string }[] = []; + const failed: { type: IntegrationType; error: string }[] = []; for (const r of results) { - const oauthStatus = oauthOutcomes.get(r.type); - - if (r.action === "synced") { - synced.push(r.type); - } else if (r.action === "removed") { - removed.push(r.type); - } else if (r.action === "error") { - failed.push({ type: r.type, error: r.error }); - } else if (r.action === "needs_oauth") { - if (oauthStatus === "ACTIVE") { - added.push(r.type); - } else if (oauthStatus === "SKIPPED") { - skipped.push(r.type); - } else if (oauthStatus === "PENDING") { - failed.push({ type: r.type, error: "authorization timed out" }); - } else if (oauthStatus === "FAILED") { - failed.push({ type: r.type, error: "authorization failed" }); - } else { - failed.push({ type: r.type, error: "needs authorization" }); + switch (r.action) { + case "provisioned": + provisioned = r; + break; + case "synced": + synced.push(r.type); + break; + case "removed": + removed.push(r.type); + break; + case "error": + failed.push({ type: r.type, error: r.error }); + break; + case "needs_oauth": { + const oauthStatus = oauthOutcomes.get(r.type); + if (oauthStatus === "ACTIVE") { + added.push(r.type); + } else if (oauthStatus === "SKIPPED") { + skipped.push(r.type); + } else if (oauthStatus === "PENDING") { + failed.push({ type: r.type, error: "authorization timed out" }); + } else if (oauthStatus === "FAILED") { + failed.push({ type: r.type, error: "authorization failed" }); + } else { + failed.push({ type: r.type, error: "needs authorization" }); + } + break; } } } log.info(theme.styles.bold("Summary:")); + if (provisioned) { + log.success("Stripe sandbox provisioned"); + if (provisioned.claimUrl) { + log.info( + ` Claim your Stripe sandbox: ${theme.colors.links(provisioned.claimUrl)}`, + ); + } + log.info( + ` Connectors dashboard: ${theme.colors.links(getConnectorsUrl())}`, + ); + } if (synced.length > 0) { log.success(`Synced: ${synced.join(", ")}`); } @@ -64,7 +86,7 @@ function printSummary( log.warn(`Skipped: ${skipped.join(", ")}`); } for (const r of failed) { - log.error(`Failed: ${r.type}${r.error ? ` - ${r.error}` : ""}`); + log.error(`Failed: ${r.type} - ${r.error}`); } } diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index da58b570..1dda3657 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -6,6 +6,7 @@ import { } from "@/cli/commands/connectors/oauth-prompt.js"; import type { CLIContext } from "@/cli/types.js"; import { + getConnectorsUrl, getDashboardUrl, runCommand, runTask, @@ -17,6 +18,10 @@ import { hasResourcesToDeploy, readProjectConfig, } from "@/core/project/index.js"; +import type { + ConnectorSyncResult, + StripeSyncResult, +} from "@/core/resources/connector/index.js"; interface DeployOptions { yes?: boolean; @@ -91,21 +96,14 @@ export async function deployAction( }, ); - // Handle connector OAuth flows - const needsOAuth = filterPendingOAuth(result.connectorResults ?? []); - if (needsOAuth.length > 0) { - const oauthOutcomes = await promptOAuthFlows(needsOAuth, { - skipPrompt: options.yes || options.isNonInteractive, - }); - - const allAuthorized = - oauthOutcomes.size > 0 && - [...oauthOutcomes.values()].every((s) => s === "ACTIVE"); - if (!allAuthorized) { - log.info( - "Some connectors still require authorization. Run 'base44 connectors push' or open the links above in your browser.", - ); - } + // Handle connector-specific post-deploy flows + const connectorResults = result.connectorResults ?? []; + await handleOAuthConnectors(connectorResults, options); + const stripeResult = connectorResults.find( + (r): r is StripeSyncResult => r.action === "provisioned", + ); + if (stripeResult) { + printStripeResult(stripeResult); } log.message( @@ -138,3 +136,32 @@ export function getDeployCommand(context: CLIContext): Command { ); }); } + +async function handleOAuthConnectors( + connectorResults: ConnectorSyncResult[], + options: DeployOptions, +): Promise { + const needsOAuth = filterPendingOAuth(connectorResults); + if (needsOAuth.length === 0) return; + + const oauthOutcomes = await promptOAuthFlows(needsOAuth, { + skipPrompt: options.yes || options.isNonInteractive, + }); + + const allAuthorized = + oauthOutcomes.size > 0 && + [...oauthOutcomes.values()].every((s) => s === "ACTIVE"); + if (!allAuthorized) { + log.info( + "Some connectors still require authorization. Run 'base44 connectors push' or open the links above in your browser.", + ); + } +} + +function printStripeResult(r: StripeSyncResult): void { + log.success("Stripe sandbox provisioned"); + if (r.claimUrl) { + log.info(` Claim your Stripe sandbox: ${theme.colors.links(r.claimUrl)}`); + } + log.info(` Connectors dashboard: ${theme.colors.links(getConnectorsUrl())}`); +} diff --git a/packages/cli/src/cli/utils/urls.ts b/packages/cli/src/cli/utils/urls.ts index c86fd43a..d0d2ceca 100644 --- a/packages/cli/src/cli/utils/urls.ts +++ b/packages/cli/src/cli/utils/urls.ts @@ -12,3 +12,8 @@ export function getDashboardUrl(projectId?: string): string { const id = projectId ?? getAppConfig().id; return `${getBase44ApiUrl()}/apps/${id}/editor/workspace/overview`; } + +export function getConnectorsUrl(projectId?: string): string { + const id = projectId ?? getAppConfig().id; + return `${getBase44ApiUrl()}/apps/${id}/editor/workspace/app-connections`; +} diff --git a/packages/cli/src/core/resources/connector/api.ts b/packages/cli/src/core/resources/connector/api.ts index d9e3a578..0bef4dce 100644 --- a/packages/cli/src/core/resources/connector/api.ts +++ b/packages/cli/src/core/resources/connector/api.ts @@ -2,17 +2,23 @@ import type { KyResponse } from "ky"; import { getAppClient } from "@/core/clients/index.js"; import { ApiError, SchemaValidationError } from "@/core/errors.js"; import type { + InstallStripeResponse, IntegrationType, ListConnectorsResponse, OAuthStatusResponse, RemoveConnectorResponse, + RemoveStripeResponse, SetConnectorResponse, + StripeStatusResponse, } from "./schema.js"; import { + InstallStripeResponseSchema, ListConnectorsResponseSchema, OAuthStatusResponseSchema, RemoveConnectorResponseSchema, + RemoveStripeResponseSchema, SetConnectorResponseSchema, + StripeStatusResponseSchema, } from "./schema.js"; export async function listConnectors(): Promise { @@ -124,3 +130,80 @@ export async function removeConnector( return result.data; } + +// ─── STRIPE-SPECIFIC ENDPOINTS ─────────────────────────────── + +export async function installStripe(): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.post("payments/stripe/install", { + timeout: 60_000, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "installing Stripe"); + } + + const result = InstallStripeResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + + return result.data; +} + +export async function getStripeStatus(): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.get("payments/stripe/status", { + timeout: 60_000, + }); + } catch (error) { + throw await ApiError.fromHttpError( + error, + "checking Stripe integration status", + ); + } + + const result = StripeStatusResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + + return result.data; +} + +export async function removeStripe(): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.delete("payments/stripe", { + timeout: 60_000, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "removing Stripe integration"); + } + + const result = RemoveStripeResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + + return result.data; +} diff --git a/packages/cli/src/core/resources/connector/config.ts b/packages/cli/src/core/resources/connector/config.ts index e6930172..d920d20e 100644 --- a/packages/cli/src/core/resources/connector/config.ts +++ b/packages/cli/src/core/resources/connector/config.ts @@ -89,12 +89,12 @@ export async function readAllConnectors( export async function writeConnectors( connectorsDir: string, - remoteConnectors: { integrationType: string; scopes: string[] }[], + remoteConnectors: ConnectorResource[], ): Promise<{ written: string[]; deleted: string[] }> { const entries = await readConnectorFiles(connectorsDir); const typeToEntry = buildTypeToEntryMap(entries); - const newTypes = new Set(remoteConnectors.map((c) => c.integrationType)); + const newTypes = new Set(remoteConnectors.map((c) => c.type)); const deleted: string[] = []; for (const [type, entry] of typeToEntry) { @@ -106,24 +106,17 @@ export async function writeConnectors( const written: string[] = []; for (const connector of remoteConnectors) { - const existing = typeToEntry.get(connector.integrationType); - const localConnector: ConnectorResource = { - type: connector.integrationType, - scopes: connector.scopes, - }; + const existing = typeToEntry.get(connector.type); - if (existing && isDeepStrictEqual(existing.data, localConnector)) { + if (existing && isDeepStrictEqual(existing.data, connector)) { continue; } const filePath = existing?.filePath ?? - join( - connectorsDir, - `${connector.integrationType}.${CONFIG_FILE_EXTENSION}`, - ); - await writeJsonFile(filePath, localConnector); - written.push(connector.integrationType); + join(connectorsDir, `${connector.type}.${CONFIG_FILE_EXTENSION}`); + await writeJsonFile(filePath, connector); + written.push(connector.type); } return { written, deleted }; diff --git a/packages/cli/src/core/resources/connector/index.ts b/packages/cli/src/core/resources/connector/index.ts index 2d2896b9..1419e93b 100644 --- a/packages/cli/src/core/resources/connector/index.ts +++ b/packages/cli/src/core/resources/connector/index.ts @@ -1,5 +1,6 @@ export * from "./api.js"; export * from "./config.js"; +export * from "./pull.js"; export * from "./push.js"; export * from "./resource.js"; export * from "./schema.js"; diff --git a/packages/cli/src/core/resources/connector/pull.ts b/packages/cli/src/core/resources/connector/pull.ts new file mode 100644 index 00000000..6b90c87f --- /dev/null +++ b/packages/cli/src/core/resources/connector/pull.ts @@ -0,0 +1,23 @@ +import { getStripeStatus, listConnectors } from "./api.js"; +import type { ConnectorResource } from "./schema.js"; +import { STRIPE_CONNECTOR_TYPE } from "./schema.js"; + +export async function pullAllConnectors(): Promise { + const [oauthResponse, stripeStatus] = await Promise.all([ + listConnectors(), + getStripeStatus(), + ]); + + const connectors: ConnectorResource[] = oauthResponse.integrations.map( + (i) => ({ + type: i.integrationType, + scopes: i.scopes, + }), + ); + + if (stripeStatus.stripe_mode !== null) { + connectors.push({ type: STRIPE_CONNECTOR_TYPE, scopes: [] }); + } + + return connectors; +} diff --git a/packages/cli/src/core/resources/connector/push.ts b/packages/cli/src/core/resources/connector/push.ts index 8dc99833..e8c092c5 100644 --- a/packages/cli/src/core/resources/connector/push.ts +++ b/packages/cli/src/core/resources/connector/push.ts @@ -1,17 +1,41 @@ -import { listConnectors, removeConnector, setConnector } from "./api.js"; +import { + getStripeStatus, + installStripe, + listConnectors, + removeConnector, + removeStripe, + setConnector, +} from "./api.js"; import type { ConnectorResource, IntegrationType, SetConnectorResponse, + StripeStatusResponse, } from "./schema.js"; +import { STRIPE_CONNECTOR_TYPE } from "./schema.js"; -export interface ConnectorSyncResult { +type SharedSyncResult = + | { type: IntegrationType; action: "synced" } + | { type: IntegrationType; action: "removed" } + | { type: IntegrationType; action: "error"; error: string }; + +export type OAuthSyncResult = { type: IntegrationType; - action: "synced" | "removed" | "needs_oauth" | "error"; - redirectUrl?: string; - connectionId?: string; - error?: string; -} + action: "needs_oauth"; + redirectUrl: string; + connectionId: string; +}; + +export type StripeSyncResult = { + type: "stripe"; + action: "provisioned"; + claimUrl?: string; +}; + +export type ConnectorSyncResult = + | SharedSyncResult + | OAuthSyncResult + | StripeSyncResult; interface PushConnectorsResponse { results: ConnectorSyncResult[]; @@ -20,6 +44,27 @@ interface PushConnectorsResponse { export async function pushConnectors( connectors: ConnectorResource[], ): Promise { + const stripeConnector = connectors.find( + (c) => c.type === STRIPE_CONNECTOR_TYPE, + ); + const oauthConnectors = connectors.filter( + (c) => c.type !== STRIPE_CONNECTOR_TYPE, + ); + + const oauthResults = await syncOAuthConnectors(oauthConnectors); + const stripeResult = await syncStripeConnector(stripeConnector); + + const results = [...oauthResults]; + if (stripeResult) { + results.push(stripeResult); + } + + return { results }; +} + +async function syncOAuthConnectors( + connectors: ConnectorResource[], +): Promise { const results: ConnectorSyncResult[] = []; const upstream = await listConnectors(); const localTypes = new Set(connectors.map((c) => c.type)); @@ -60,7 +105,82 @@ export async function pushConnectors( } } - return { results }; + return results; +} + +async function syncStripeConnector( + localStripe: ConnectorResource | undefined, +): Promise { + const remoteStatus = await fetchStripeRemoteStatus(); + + if (remoteStatus === "error") { + return localStripe + ? stripeError("Failed to check Stripe integration status") + : null; + } + + const isRemoteInstalled = remoteStatus.stripe_mode !== null; + const needsInstall = localStripe && !isRemoteInstalled; + const alreadySynced = localStripe && isRemoteInstalled; + const needsRemoval = !localStripe && isRemoteInstalled; + + if (needsInstall) { + return handleStripeInstall(); + } + + if (alreadySynced) { + return stripeSynced(); + } + + if (needsRemoval) { + return handleStripeRemoval(); + } + + return null; +} + +async function fetchStripeRemoteStatus(): Promise< + StripeStatusResponse | "error" +> { + try { + return await getStripeStatus(); + } catch { + return "error"; + } +} + +async function handleStripeInstall(): Promise { + try { + const result = await installStripe(); + return stripeProvisioned(result.claim_url ?? undefined); + } catch (err) { + return stripeError(err instanceof Error ? err.message : String(err)); + } +} + +async function handleStripeRemoval(): Promise { + try { + await removeStripe(); + return stripeRemoved(); + } catch (err) { + return stripeError(err instanceof Error ? err.message : String(err)); + } +} + +function stripeSynced(): SharedSyncResult { + return { type: STRIPE_CONNECTOR_TYPE, action: "synced" }; +} + +function stripeProvisioned(claimUrl?: string): StripeSyncResult { + return { type: STRIPE_CONNECTOR_TYPE, action: "provisioned", claimUrl }; +} + +function stripeRemoved(): SharedSyncResult { + return { type: STRIPE_CONNECTOR_TYPE, action: "removed" }; +} + +function stripeError(error: string): SharedSyncResult { + return { type: STRIPE_CONNECTOR_TYPE, action: "error", error }; } function getConnectorSyncResult( @@ -86,7 +206,7 @@ function getConnectorSyncResult( type, action: "needs_oauth", redirectUrl: response.redirectUrl, - connectionId: response.connectionId ?? undefined, + connectionId: response.connectionId ?? "", }; } diff --git a/packages/cli/src/core/resources/connector/schema.ts b/packages/cli/src/core/resources/connector/schema.ts index f29769fe..c5c50e96 100644 --- a/packages/cli/src/core/resources/connector/schema.ts +++ b/packages/cli/src/core/resources/connector/schema.ts @@ -77,6 +77,11 @@ const GoogleBigQueryConnectorSchema = z.object({ scopes: z.array(z.string()).default([]), }); +const StripeConnectorSchema = z.object({ + type: z.literal("stripe"), + scopes: z.array(z.string()).default([]), +}); + const CustomTypeSchema = z .string() .min(1) @@ -101,6 +106,7 @@ export const ConnectorResourceSchema = z.union([ HubspotConnectorSchema, LinkedInConnectorSchema, TikTokConnectorSchema, + StripeConnectorSchema, GenericConnectorSchema, ]); @@ -120,6 +126,7 @@ const KnownIntegrationTypes = [ "hubspot", "linkedin", "tiktok", + "stripe", ] as const; export const IntegrationTypeSchema = z.union([ @@ -202,3 +209,27 @@ export const RemoveConnectorResponseSchema = z export type RemoveConnectorResponse = z.infer< typeof RemoveConnectorResponseSchema >; + +// ─── STRIPE-SPECIFIC SCHEMAS ───────────────────────────────── + +export const STRIPE_CONNECTOR_TYPE = "stripe" as const; + +export const InstallStripeResponseSchema = z.object({ + already_installed: z.boolean(), + claim_url: z.string().nullable(), +}); + +export type InstallStripeResponse = z.infer; + +export const StripeStatusResponseSchema = z.object({ + stripe_mode: z.enum(["sandbox", "live"]).nullable(), + sandbox_claim_url: z.string().nullable().optional(), +}); + +export type StripeStatusResponse = z.infer; + +export const RemoveStripeResponseSchema = z.object({ + success: z.boolean(), +}); + +export type RemoveStripeResponse = z.infer; diff --git a/packages/cli/tests/cli/connectors_pull.spec.ts b/packages/cli/tests/cli/connectors_pull.spec.ts index 1d1f33d6..290d2d06 100644 --- a/packages/cli/tests/cli/connectors_pull.spec.ts +++ b/packages/cli/tests/cli/connectors_pull.spec.ts @@ -7,6 +7,7 @@ describe("connectors pull command", () => { it("syncs when remote has no connectors", async () => { await t.givenLoggedInWithProject(fixture("basic")); t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); const result = await t.run("connectors", "pull"); @@ -40,6 +41,7 @@ describe("connectors pull command", () => { }, ], }); + t.api.mockStripeStatus({ stripe_mode: null }); const result = await t.run("connectors", "pull"); @@ -60,4 +62,24 @@ describe("connectors pull command", () => { t.expectResult(result).toFail(); }); + + it("pulls Stripe connector when Stripe is installed remotely", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockConnectorsList({ + integrations: [ + { + integration_type: "slack", + status: "active", + scopes: ["chat:write"], + }, + ], + }); + t.api.mockStripeStatus({ stripe_mode: "sandbox" }); + + const result = await t.run("connectors", "pull"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Written: slack, stripe"); + t.expectResult(result).toContain("Pulled 2 connectors"); + }); }); diff --git a/packages/cli/tests/cli/connectors_push.spec.ts b/packages/cli/tests/cli/connectors_push.spec.ts index 2b35ee7f..1fe7aa86 100644 --- a/packages/cli/tests/cli/connectors_push.spec.ts +++ b/packages/cli/tests/cli/connectors_push.spec.ts @@ -7,6 +7,7 @@ describe("connectors push command", () => { it("shows message when no local connectors found", async () => { await t.givenLoggedInWithProject(fixture("basic")); t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); const result = await t.run("connectors", "push"); @@ -26,6 +27,7 @@ describe("connectors push command", () => { it("finds and lists connectors in project", async () => { await t.givenLoggedInWithProject(fixture("with-connectors")); t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); t.api.mockConnectorSet({ redirect_url: null, connection_id: null, @@ -41,6 +43,7 @@ describe("connectors push command", () => { it("displays synced connectors with checkmark", async () => { await t.givenLoggedInWithProject(fixture("with-connectors")); t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); t.api.mockConnectorSet({ redirect_url: null, connection_id: null, @@ -62,6 +65,7 @@ describe("connectors push command", () => { { integration_type: "slack", status: "active", scopes: ["chat:write"] }, ], }); + t.api.mockStripeStatus({ stripe_mode: null }); t.api.mockConnectorRemove({ status: "removed", integration_type: "slack" }); const result = await t.run("connectors", "push"); @@ -74,6 +78,7 @@ describe("connectors push command", () => { it("displays error when sync fails", async () => { await t.givenLoggedInWithProject(fixture("with-connectors")); t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); t.api.mockConnectorSetError({ status: 500, body: { error: "Server error" }, @@ -89,6 +94,7 @@ describe("connectors push command", () => { it("shows needs authorization when redirect_url is returned", async () => { await t.givenLoggedInWithProject(fixture("with-connectors")); t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); t.api.mockConnectorSet({ redirect_url: "https://accounts.google.com/oauth", connection_id: "conn_123", @@ -105,6 +111,7 @@ describe("connectors push command", () => { it("shows error for different_user response", async () => { await t.givenLoggedInWithProject(fixture("with-connectors")); t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); t.api.mockConnectorSet({ redirect_url: null, connection_id: null, @@ -119,4 +126,78 @@ describe("connectors push command", () => { t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Already connected by another user"); }); + + describe("stripe", () => { + it("shows provisioned output with claim URL on fresh install", async () => { + await t.givenLoggedInWithProject(fixture("with-stripe-connector")); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); + t.api.mockConnectorSet({ + redirect_url: null, + connection_id: null, + already_authorized: true, + }); + t.api.mockStripeInstall({ + already_installed: false, + claim_url: "https://connect.stripe.com/setup/claim/xxx", + }); + + const result = await t.run("connectors", "push"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Found 2 connectors to push"); + t.expectResult(result).toContain("Stripe sandbox provisioned"); + t.expectResult(result).toContain("connect.stripe.com/setup/claim/xxx"); + t.expectResult(result).toContain("Connectors dashboard"); + }); + + it("shows synced when Stripe is already installed", async () => { + await t.givenLoggedInWithProject(fixture("with-stripe-connector")); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: "sandbox" }); + t.api.mockConnectorSet({ + redirect_url: null, + connection_id: null, + already_authorized: true, + }); + + const result = await t.run("connectors", "push"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Synced: slack, stripe"); + }); + + it("shows removed when Stripe is removed locally", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: "sandbox" }); + t.api.mockStripeRemove({ success: true }); + + const result = await t.run("connectors", "push"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Removed:"); + t.expectResult(result).toContain("stripe"); + }); + + it("shows error when Stripe install fails", async () => { + await t.givenLoggedInWithProject(fixture("with-stripe-connector")); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); + t.api.mockConnectorSet({ + redirect_url: null, + connection_id: null, + already_authorized: true, + }); + t.api.mockStripeInstallError({ + status: 500, + body: { error: "Stripe install failed" }, + }); + + const result = await t.run("connectors", "push"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Failed: stripe"); + }); + }); }); diff --git a/packages/cli/tests/cli/deploy.spec.ts b/packages/cli/tests/cli/deploy.spec.ts index eab73e56..a19d6ddd 100644 --- a/packages/cli/tests/cli/deploy.spec.ts +++ b/packages/cli/tests/cli/deploy.spec.ts @@ -31,6 +31,7 @@ describe("deploy command (unified)", () => { }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); const result = await t.run("deploy", "-y"); @@ -48,6 +49,7 @@ describe("deploy command (unified)", () => { }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); const result = await t.run("deploy", "--yes"); @@ -65,6 +67,7 @@ describe("deploy command (unified)", () => { }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); const result = await t.run("deploy", "-y"); @@ -95,6 +98,7 @@ describe("deploy command (unified)", () => { t.api.mockFunctionsPush({ deployed: ["hello"], deleted: [], errors: null }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); t.api.mockSiteDeploy({ app_url: "https://full-project.base44.app" }); const result = await t.run("deploy", "-y"); @@ -114,6 +118,7 @@ describe("deploy command (unified)", () => { deleted: [], }); t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); const result = await t.run("deploy", "-y"); @@ -132,6 +137,7 @@ describe("deploy command (unified)", () => { deleted: [], }); t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); const result = await t.run("deploy", "-y"); @@ -145,6 +151,7 @@ describe("deploy command (unified)", () => { t.api.mockFunctionsPush({ deployed: [], deleted: [], errors: null }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); t.api.mockConnectorSet({ redirect_url: null, connection_id: null, @@ -164,6 +171,7 @@ describe("deploy command (unified)", () => { t.api.mockFunctionsPush({ deployed: [], deleted: [], errors: null }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); t.api.mockConnectorSet({ redirect_url: "https://accounts.google.com/oauth", connection_id: "conn_123", @@ -177,4 +185,31 @@ describe("deploy command (unified)", () => { t.expectResult(result).toContain("require authorization"); t.expectResult(result).toContain("base44 connectors push"); }); + + it("shows Stripe provisioned output when Stripe connector is deployed", async () => { + await t.givenLoggedInWithProject(fixture("with-stripe-connector")); + t.api.mockEntitiesPush({ created: [], updated: [], deleted: [] }); + t.api.mockFunctionsPush({ deployed: [], deleted: [], errors: null }); + t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); + t.api.mockConnectorSet({ + redirect_url: null, + connection_id: null, + already_authorized: true, + }); + t.api.mockStripeInstall({ + already_installed: false, + claim_url: "https://connect.stripe.com/setup/claim/xxx", + }); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Deployment completed"); + t.expectResult(result).toContain("2 connectors"); + t.expectResult(result).toContain("Stripe sandbox provisioned"); + t.expectResult(result).toContain("connect.stripe.com/setup/claim/xxx"); + t.expectResult(result).toContain("Connectors dashboard"); + }); }); diff --git a/packages/cli/tests/cli/testkit/Base44APIMock.ts b/packages/cli/tests/cli/testkit/Base44APIMock.ts index 9c68f21b..0185bf0f 100644 --- a/packages/cli/tests/cli/testkit/Base44APIMock.ts +++ b/packages/cli/tests/cli/testkit/Base44APIMock.ts @@ -98,6 +98,20 @@ interface ConnectorRemoveResponse { integration_type: string; } +interface StripeInstallResponse { + already_installed: boolean; + claim_url: string | null; +} + +interface StripeStatusResponse { + stripe_mode: "sandbox" | "live" | null; + sandbox_claim_url?: string | null; +} + +interface StripeRemoveResponse { + success: boolean; +} + interface CreateAppResponse { id: string; name: string; @@ -281,6 +295,37 @@ export class Base44APIMock { return this; } + // ─── STRIPE ENDPOINTS ───────────────────────────────────── + + mockStripeInstall(response: StripeInstallResponse): this { + this.handlers.push( + http.post( + `${BASE_URL}/api/apps/${this.appId}/payments/stripe/install`, + () => HttpResponse.json(response), + ), + ); + return this; + } + + mockStripeStatus(response: StripeStatusResponse): this { + this.handlers.push( + http.get( + `${BASE_URL}/api/apps/${this.appId}/payments/stripe/status`, + () => HttpResponse.json(response), + ), + ); + return this; + } + + mockStripeRemove(response: StripeRemoveResponse): this { + this.handlers.push( + http.delete(`${BASE_URL}/api/apps/${this.appId}/payments/stripe`, () => + HttpResponse.json(response), + ), + ); + return this; + } + // ─── SECRETS ENDPOINTS ────────────────────────────────────── /** Mock GET /api/apps/{appId}/secrets - List secrets */ @@ -313,6 +358,14 @@ export class Base44APIMock { return this; } + mockStripeInstallError(error: ErrorResponse): this { + return this.mockError( + "post", + `/api/apps/${this.appId}/payments/stripe/install`, + error, + ); + } + // ─── GENERAL ENDPOINTS ───────────────────────────────────── /** Mock POST /api/apps - Create new app */ diff --git a/packages/cli/tests/core/connectors.spec.ts b/packages/cli/tests/core/connectors.spec.ts index 9b3ba90c..1171994b 100644 --- a/packages/cli/tests/core/connectors.spec.ts +++ b/packages/cli/tests/core/connectors.spec.ts @@ -8,6 +8,7 @@ import { readAllConnectors, writeConnectors, } from "../../src/core/resources/connector/config.js"; +import { pullAllConnectors } from "../../src/core/resources/connector/pull.js"; import { pushConnectors } from "../../src/core/resources/connector/push.js"; import type { ConnectorResource } from "../../src/core/resources/connector/schema.js"; @@ -70,11 +71,11 @@ describe("writeConnectors", () => { try { const remoteConnectors = [ { - integrationType: "gmail", + type: "gmail", scopes: ["https://mail.google.com/"], }, { - integrationType: "slack", + type: "slack", scopes: ["chat:write", "channels:read"], }, ]; @@ -106,11 +107,11 @@ describe("writeConnectors", () => { try { const initialConnectors = [ { - integrationType: "gmail", + type: "gmail", scopes: ["https://mail.google.com/"], }, { - integrationType: "slack", + type: "slack", scopes: ["chat:write"], }, ]; @@ -118,7 +119,7 @@ describe("writeConnectors", () => { const { written, deleted } = await writeConnectors(tmpDir, [ { - integrationType: "gmail", + type: "gmail", scopes: ["https://mail.google.com/"], }, ]); @@ -140,7 +141,7 @@ describe("writeConnectors", () => { try { const initialConnectors = [ { - integrationType: "gmail", + type: "gmail", scopes: ["https://mail.google.com/"], }, ]; @@ -169,7 +170,7 @@ describe("writeConnectors", () => { const remoteConnectors = [ { - integrationType: "slack", + type: "slack", scopes: ["chat:write", "channels:read"], }, ]; @@ -209,7 +210,7 @@ describe("writeConnectors", () => { const remoteConnectors = [ { - integrationType: "gmail", + type: "gmail", scopes: ["https://mail.google.com/"], }, ]; @@ -238,7 +239,7 @@ describe("writeConnectors", () => { const remoteConnectors = [ { - integrationType: "slack", + type: "slack", scopes: ["chat:write"], }, ]; @@ -265,7 +266,7 @@ describe("writeConnectors", () => { try { const remoteConnectors = [ { - integrationType: "notion", + type: "notion", scopes: [] as string[], }, ]; @@ -290,11 +291,18 @@ describe("writeConnectors", () => { const mockListConnectors = vi.mocked(api.listConnectors); const mockSetConnector = vi.mocked(api.setConnector); const mockRemoveConnector = vi.mocked(api.removeConnector); +const mockGetStripeStatus = vi.mocked(api.getStripeStatus); +const mockInstallStripe = vi.mocked(api.installStripe); +const mockRemoveStripe = vi.mocked(api.removeStripe); describe("pushConnectors", () => { beforeEach(() => { vi.resetAllMocks(); mockListConnectors.mockResolvedValue({ integrations: [] }); + mockGetStripeStatus.mockResolvedValue({ + stripe_mode: null, + sandbox_claim_url: null, + }); }); it("returns empty results when no local or upstream connectors", async () => { @@ -586,4 +594,249 @@ describe("pushConnectors", () => { { type: "slack", action: "synced" }, ]); }); + + describe("stripe", () => { + it("installs Stripe when local exists and remote is not installed", async () => { + const local: ConnectorResource[] = [{ type: "stripe", scopes: [] }]; + mockInstallStripe.mockResolvedValue({ + already_installed: false, + claim_url: null, + }); + + const result = await pushConnectors(local); + + expect(mockInstallStripe).toHaveBeenCalledOnce(); + expect(result.results).toEqual([ + { type: "stripe", action: "provisioned" }, + ]); + }); + + it("returns provisioned with claimUrl when Stripe install provides one", async () => { + const local: ConnectorResource[] = [{ type: "stripe", scopes: [] }]; + mockInstallStripe.mockResolvedValue({ + already_installed: false, + claim_url: "https://connect.stripe.com/setup/claim/xxx", + }); + + const result = await pushConnectors(local); + + expect(result.results).toEqual([ + { + type: "stripe", + action: "provisioned", + claimUrl: "https://connect.stripe.com/setup/claim/xxx", + }, + ]); + }); + + it("returns synced when local Stripe exists and remote is already installed", async () => { + const local: ConnectorResource[] = [{ type: "stripe", scopes: [] }]; + mockGetStripeStatus.mockResolvedValue({ + stripe_mode: "sandbox", + sandbox_claim_url: null, + }); + + const result = await pushConnectors(local); + + expect(mockInstallStripe).not.toHaveBeenCalled(); + expect(result.results).toEqual([{ type: "stripe", action: "synced" }]); + }); + + it("removes Stripe when no local Stripe but remote is installed", async () => { + mockGetStripeStatus.mockResolvedValue({ + stripe_mode: "sandbox", + sandbox_claim_url: null, + }); + mockRemoveStripe.mockResolvedValue({ success: true }); + + const result = await pushConnectors([]); + + expect(mockRemoveStripe).toHaveBeenCalledOnce(); + expect(result.results).toEqual([{ type: "stripe", action: "removed" }]); + }); + + it("returns error when Stripe install fails", async () => { + const local: ConnectorResource[] = [{ type: "stripe", scopes: [] }]; + mockInstallStripe.mockRejectedValue(new Error("Stripe install timeout")); + + const result = await pushConnectors(local); + + expect(result.results).toEqual([ + { type: "stripe", action: "error", error: "Stripe install timeout" }, + ]); + }); + + it("returns error when Stripe removal fails", async () => { + mockGetStripeStatus.mockResolvedValue({ + stripe_mode: "live", + sandbox_claim_url: null, + }); + mockRemoveStripe.mockRejectedValue(new Error("Stripe remove failed")); + + const result = await pushConnectors([]); + + expect(result.results).toEqual([ + { type: "stripe", action: "error", error: "Stripe remove failed" }, + ]); + }); + + it("returns error when getStripeStatus fails and local Stripe exists", async () => { + const local: ConnectorResource[] = [{ type: "stripe", scopes: [] }]; + mockGetStripeStatus.mockRejectedValue(new Error("Status check failed")); + + const result = await pushConnectors(local); + + expect(result.results).toEqual([ + { + type: "stripe", + action: "error", + error: "Failed to check Stripe integration status", + }, + ]); + }); + + it("returns no Stripe result when getStripeStatus fails and no local Stripe", async () => { + mockGetStripeStatus.mockRejectedValue(new Error("Status check failed")); + + const result = await pushConnectors([]); + + expect(result.results).toEqual([]); + }); + + it("returns no Stripe result when no local and no remote Stripe", async () => { + const result = await pushConnectors([]); + + expect(mockInstallStripe).not.toHaveBeenCalled(); + expect(mockRemoveStripe).not.toHaveBeenCalled(); + expect(result.results).toEqual([]); + }); + + it("handles OAuth and Stripe connectors together", async () => { + const local: ConnectorResource[] = [ + { type: "gmail", scopes: ["https://mail.google.com/"] }, + { type: "stripe", scopes: [] }, + ]; + mockSetConnector.mockResolvedValue({ + redirectUrl: null, + connectionId: null, + alreadyAuthorized: true, + error: null, + errorMessage: null, + otherUserEmail: null, + }); + mockInstallStripe.mockResolvedValue({ + already_installed: false, + claim_url: "https://connect.stripe.com/setup/claim/xxx", + }); + + const result = await pushConnectors(local); + + expect(mockSetConnector).toHaveBeenCalledWith("gmail", [ + "https://mail.google.com/", + ]); + expect(mockInstallStripe).toHaveBeenCalledOnce(); + expect(result.results).toEqual([ + { type: "gmail", action: "synced" }, + { + type: "stripe", + action: "provisioned", + claimUrl: "https://connect.stripe.com/setup/claim/xxx", + }, + ]); + }); + }); +}); + +describe("pullAllConnectors", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("returns only OAuth connectors when Stripe is not installed", async () => { + mockListConnectors.mockResolvedValue({ + integrations: [ + { + integrationType: "gmail", + status: "active", + scopes: ["https://mail.google.com/"], + userEmail: undefined, + }, + ], + }); + mockGetStripeStatus.mockResolvedValue({ + stripe_mode: null, + sandbox_claim_url: null, + }); + + const result = await pullAllConnectors(); + + expect(result).toEqual([ + { type: "gmail", scopes: ["https://mail.google.com/"] }, + ]); + }); + + it("includes Stripe connector when remote has Stripe installed", async () => { + mockListConnectors.mockResolvedValue({ + integrations: [ + { + integrationType: "slack", + status: "active", + scopes: ["chat:write"], + userEmail: undefined, + }, + ], + }); + mockGetStripeStatus.mockResolvedValue({ + stripe_mode: "sandbox", + sandbox_claim_url: null, + }); + + const result = await pullAllConnectors(); + + expect(result).toEqual([ + { type: "slack", scopes: ["chat:write"] }, + { type: "stripe", scopes: [] }, + ]); + }); + + it("returns only Stripe when no OAuth connectors exist", async () => { + mockListConnectors.mockResolvedValue({ integrations: [] }); + mockGetStripeStatus.mockResolvedValue({ + stripe_mode: "live", + sandbox_claim_url: null, + }); + + const result = await pullAllConnectors(); + + expect(result).toEqual([{ type: "stripe", scopes: [] }]); + }); + + it("returns empty array when no connectors exist remotely", async () => { + mockListConnectors.mockResolvedValue({ integrations: [] }); + mockGetStripeStatus.mockResolvedValue({ + stripe_mode: null, + sandbox_claim_url: null, + }); + + const result = await pullAllConnectors(); + + expect(result).toEqual([]); + }); + + it("throws when getStripeStatus fails", async () => { + mockListConnectors.mockResolvedValue({ integrations: [] }); + mockGetStripeStatus.mockRejectedValue(new Error("Stripe API error")); + + await expect(pullAllConnectors()).rejects.toThrow("Stripe API error"); + }); + + it("throws when listConnectors fails", async () => { + mockListConnectors.mockRejectedValue(new Error("List API error")); + mockGetStripeStatus.mockResolvedValue({ + stripe_mode: null, + sandbox_claim_url: null, + }); + + await expect(pullAllConnectors()).rejects.toThrow("List API error"); + }); }); diff --git a/packages/cli/tests/fixtures/with-stripe-connector/base44/.app.jsonc b/packages/cli/tests/fixtures/with-stripe-connector/base44/.app.jsonc new file mode 100644 index 00000000..36428ee1 --- /dev/null +++ b/packages/cli/tests/fixtures/with-stripe-connector/base44/.app.jsonc @@ -0,0 +1,3 @@ +{ + "id": "test-app-id" +} diff --git a/packages/cli/tests/fixtures/with-stripe-connector/config.jsonc b/packages/cli/tests/fixtures/with-stripe-connector/config.jsonc new file mode 100644 index 00000000..c4cd9918 --- /dev/null +++ b/packages/cli/tests/fixtures/with-stripe-connector/config.jsonc @@ -0,0 +1,3 @@ +{ + "name": "Project with Stripe Connector" +} diff --git a/packages/cli/tests/fixtures/with-stripe-connector/connectors/slack.jsonc b/packages/cli/tests/fixtures/with-stripe-connector/connectors/slack.jsonc new file mode 100644 index 00000000..e1a4e781 --- /dev/null +++ b/packages/cli/tests/fixtures/with-stripe-connector/connectors/slack.jsonc @@ -0,0 +1,4 @@ +{ + "type": "slack", + "scopes": ["chat:write"] +} diff --git a/packages/cli/tests/fixtures/with-stripe-connector/connectors/stripe.jsonc b/packages/cli/tests/fixtures/with-stripe-connector/connectors/stripe.jsonc new file mode 100644 index 00000000..4f66bbfb --- /dev/null +++ b/packages/cli/tests/fixtures/with-stripe-connector/connectors/stripe.jsonc @@ -0,0 +1,4 @@ +{ + "type": "stripe", + "scopes": [] +} From 91e8e9ed5c045af80ca90dbac11452b28e48ead2 Mon Sep 17 00:00:00 2001 From: yardend Date: Tue, 10 Mar 2026 15:42:17 +0200 Subject: [PATCH 2/3] fix: camelCase transforms for Stripe schemas, revert empty string connectionId Co-Authored-By: Claude Opus 4.6 --- .../cli/commands/connectors/oauth-prompt.ts | 4 ++ .../cli/src/core/resources/connector/pull.ts | 2 +- .../cli/src/core/resources/connector/push.ts | 8 ++-- .../src/core/resources/connector/schema.ts | 26 ++++++---- packages/cli/tests/core/connectors.spec.ts | 48 +++++++++---------- 5 files changed, 51 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/cli/commands/connectors/oauth-prompt.ts b/packages/cli/src/cli/commands/connectors/oauth-prompt.ts index 3c9825d0..df83a0c5 100644 --- a/packages/cli/src/cli/commands/connectors/oauth-prompt.ts +++ b/packages/cli/src/cli/commands/connectors/oauth-prompt.ts @@ -56,6 +56,10 @@ async function runOAuthFlowWithSkip( finalStatus = "SKIPPED"; return true; } + if (!connector.connectionId) { + finalStatus = "FAILED"; + return true; + } const response = await getOAuthStatus( connector.type, connector.connectionId, diff --git a/packages/cli/src/core/resources/connector/pull.ts b/packages/cli/src/core/resources/connector/pull.ts index 6b90c87f..1c403035 100644 --- a/packages/cli/src/core/resources/connector/pull.ts +++ b/packages/cli/src/core/resources/connector/pull.ts @@ -15,7 +15,7 @@ export async function pullAllConnectors(): Promise { }), ); - if (stripeStatus.stripe_mode !== null) { + if (stripeStatus.stripeMode !== null) { connectors.push({ type: STRIPE_CONNECTOR_TYPE, scopes: [] }); } diff --git a/packages/cli/src/core/resources/connector/push.ts b/packages/cli/src/core/resources/connector/push.ts index e8c092c5..c1e5bcae 100644 --- a/packages/cli/src/core/resources/connector/push.ts +++ b/packages/cli/src/core/resources/connector/push.ts @@ -23,7 +23,7 @@ export type OAuthSyncResult = { type: IntegrationType; action: "needs_oauth"; redirectUrl: string; - connectionId: string; + connectionId?: string; }; export type StripeSyncResult = { @@ -119,7 +119,7 @@ async function syncStripeConnector( : null; } - const isRemoteInstalled = remoteStatus.stripe_mode !== null; + const isRemoteInstalled = remoteStatus.stripeMode !== null; const needsInstall = localStripe && !isRemoteInstalled; const alreadySynced = localStripe && isRemoteInstalled; const needsRemoval = !localStripe && isRemoteInstalled; @@ -152,7 +152,7 @@ async function fetchStripeRemoteStatus(): Promise< async function handleStripeInstall(): Promise { try { const result = await installStripe(); - return stripeProvisioned(result.claim_url ?? undefined); + return stripeProvisioned(result.claimUrl ?? undefined); } catch (err) { return stripeError(err instanceof Error ? err.message : String(err)); } @@ -206,7 +206,7 @@ function getConnectorSyncResult( type, action: "needs_oauth", redirectUrl: response.redirectUrl, - connectionId: response.connectionId ?? "", + connectionId: response.connectionId ?? undefined, }; } diff --git a/packages/cli/src/core/resources/connector/schema.ts b/packages/cli/src/core/resources/connector/schema.ts index 02c94059..db83597f 100644 --- a/packages/cli/src/core/resources/connector/schema.ts +++ b/packages/cli/src/core/resources/connector/schema.ts @@ -214,17 +214,27 @@ export type RemoveConnectorResponse = z.infer< export const STRIPE_CONNECTOR_TYPE = "stripe" as const; -export const InstallStripeResponseSchema = z.object({ - already_installed: z.boolean(), - claim_url: z.string().nullable(), -}); +export const InstallStripeResponseSchema = z + .object({ + already_installed: z.boolean(), + claim_url: z.string().nullable(), + }) + .transform((data) => ({ + alreadyInstalled: data.already_installed, + claimUrl: data.claim_url, + })); export type InstallStripeResponse = z.infer; -export const StripeStatusResponseSchema = z.object({ - stripe_mode: z.enum(["sandbox", "live"]).nullable(), - sandbox_claim_url: z.string().nullable().optional(), -}); +export const StripeStatusResponseSchema = z + .object({ + stripe_mode: z.enum(["sandbox", "live"]).nullable(), + sandbox_claim_url: z.string().nullable().optional(), + }) + .transform((data) => ({ + stripeMode: data.stripe_mode, + sandboxClaimUrl: data.sandbox_claim_url, + })); export type StripeStatusResponse = z.infer; diff --git a/packages/cli/tests/core/connectors.spec.ts b/packages/cli/tests/core/connectors.spec.ts index 1171994b..6da37e0a 100644 --- a/packages/cli/tests/core/connectors.spec.ts +++ b/packages/cli/tests/core/connectors.spec.ts @@ -300,8 +300,8 @@ describe("pushConnectors", () => { vi.resetAllMocks(); mockListConnectors.mockResolvedValue({ integrations: [] }); mockGetStripeStatus.mockResolvedValue({ - stripe_mode: null, - sandbox_claim_url: null, + stripeMode: null, + sandboxClaimUrl: null, }); }); @@ -599,8 +599,8 @@ describe("pushConnectors", () => { it("installs Stripe when local exists and remote is not installed", async () => { const local: ConnectorResource[] = [{ type: "stripe", scopes: [] }]; mockInstallStripe.mockResolvedValue({ - already_installed: false, - claim_url: null, + alreadyInstalled: false, + claimUrl: null, }); const result = await pushConnectors(local); @@ -614,8 +614,8 @@ describe("pushConnectors", () => { it("returns provisioned with claimUrl when Stripe install provides one", async () => { const local: ConnectorResource[] = [{ type: "stripe", scopes: [] }]; mockInstallStripe.mockResolvedValue({ - already_installed: false, - claim_url: "https://connect.stripe.com/setup/claim/xxx", + alreadyInstalled: false, + claimUrl: "https://connect.stripe.com/setup/claim/xxx", }); const result = await pushConnectors(local); @@ -632,8 +632,8 @@ describe("pushConnectors", () => { it("returns synced when local Stripe exists and remote is already installed", async () => { const local: ConnectorResource[] = [{ type: "stripe", scopes: [] }]; mockGetStripeStatus.mockResolvedValue({ - stripe_mode: "sandbox", - sandbox_claim_url: null, + stripeMode: "sandbox", + sandboxClaimUrl: null, }); const result = await pushConnectors(local); @@ -644,8 +644,8 @@ describe("pushConnectors", () => { it("removes Stripe when no local Stripe but remote is installed", async () => { mockGetStripeStatus.mockResolvedValue({ - stripe_mode: "sandbox", - sandbox_claim_url: null, + stripeMode: "sandbox", + sandboxClaimUrl: null, }); mockRemoveStripe.mockResolvedValue({ success: true }); @@ -668,8 +668,8 @@ describe("pushConnectors", () => { it("returns error when Stripe removal fails", async () => { mockGetStripeStatus.mockResolvedValue({ - stripe_mode: "live", - sandbox_claim_url: null, + stripeMode: "live", + sandboxClaimUrl: null, }); mockRemoveStripe.mockRejectedValue(new Error("Stripe remove failed")); @@ -725,8 +725,8 @@ describe("pushConnectors", () => { otherUserEmail: null, }); mockInstallStripe.mockResolvedValue({ - already_installed: false, - claim_url: "https://connect.stripe.com/setup/claim/xxx", + alreadyInstalled: false, + claimUrl: "https://connect.stripe.com/setup/claim/xxx", }); const result = await pushConnectors(local); @@ -764,8 +764,8 @@ describe("pullAllConnectors", () => { ], }); mockGetStripeStatus.mockResolvedValue({ - stripe_mode: null, - sandbox_claim_url: null, + stripeMode: null, + sandboxClaimUrl: null, }); const result = await pullAllConnectors(); @@ -787,8 +787,8 @@ describe("pullAllConnectors", () => { ], }); mockGetStripeStatus.mockResolvedValue({ - stripe_mode: "sandbox", - sandbox_claim_url: null, + stripeMode: "sandbox", + sandboxClaimUrl: null, }); const result = await pullAllConnectors(); @@ -802,8 +802,8 @@ describe("pullAllConnectors", () => { it("returns only Stripe when no OAuth connectors exist", async () => { mockListConnectors.mockResolvedValue({ integrations: [] }); mockGetStripeStatus.mockResolvedValue({ - stripe_mode: "live", - sandbox_claim_url: null, + stripeMode: "live", + sandboxClaimUrl: null, }); const result = await pullAllConnectors(); @@ -814,8 +814,8 @@ describe("pullAllConnectors", () => { it("returns empty array when no connectors exist remotely", async () => { mockListConnectors.mockResolvedValue({ integrations: [] }); mockGetStripeStatus.mockResolvedValue({ - stripe_mode: null, - sandbox_claim_url: null, + stripeMode: null, + sandboxClaimUrl: null, }); const result = await pullAllConnectors(); @@ -833,8 +833,8 @@ describe("pullAllConnectors", () => { it("throws when listConnectors fails", async () => { mockListConnectors.mockRejectedValue(new Error("List API error")); mockGetStripeStatus.mockResolvedValue({ - stripe_mode: null, - sandbox_claim_url: null, + stripeMode: null, + sandboxClaimUrl: null, }); await expect(pullAllConnectors()).rejects.toThrow("List API error"); From 19a9cdcf1b229efc4144cb88f5589797c0163449 Mon Sep 17 00:00:00 2001 From: yardend Date: Tue, 10 Mar 2026 16:21:51 +0200 Subject: [PATCH 3/3] refactor: extract stripe logic to stripe.ts, find stripe result by type Co-Authored-By: Claude Opus 4.6 --- .../cli/src/cli/commands/project/deploy.ts | 8 +- .../cli/src/core/resources/connector/index.ts | 1 + .../cli/src/core/resources/connector/pull.ts | 9 +- .../cli/src/core/resources/connector/push.ts | 86 +----------------- .../src/core/resources/connector/stripe.ts | 89 +++++++++++++++++++ 5 files changed, 100 insertions(+), 93 deletions(-) create mode 100644 packages/cli/src/core/resources/connector/stripe.ts diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 1dda3657..e528ccae 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -99,11 +99,9 @@ export async function deployAction( // Handle connector-specific post-deploy flows const connectorResults = result.connectorResults ?? []; await handleOAuthConnectors(connectorResults, options); - const stripeResult = connectorResults.find( - (r): r is StripeSyncResult => r.action === "provisioned", - ); - if (stripeResult) { - printStripeResult(stripeResult); + const stripeResult = connectorResults.find((r) => r.type === "stripe"); + if (stripeResult?.action === "provisioned") { + printStripeResult(stripeResult as StripeSyncResult); } log.message( diff --git a/packages/cli/src/core/resources/connector/index.ts b/packages/cli/src/core/resources/connector/index.ts index 1419e93b..c9b6fe31 100644 --- a/packages/cli/src/core/resources/connector/index.ts +++ b/packages/cli/src/core/resources/connector/index.ts @@ -4,3 +4,4 @@ export * from "./pull.js"; export * from "./push.js"; export * from "./resource.js"; export * from "./schema.js"; +export * from "./stripe.js"; diff --git a/packages/cli/src/core/resources/connector/pull.ts b/packages/cli/src/core/resources/connector/pull.ts index 1c403035..7401dfc3 100644 --- a/packages/cli/src/core/resources/connector/pull.ts +++ b/packages/cli/src/core/resources/connector/pull.ts @@ -1,11 +1,12 @@ -import { getStripeStatus, listConnectors } from "./api.js"; +import { listConnectors } from "./api.js"; import type { ConnectorResource } from "./schema.js"; import { STRIPE_CONNECTOR_TYPE } from "./schema.js"; +import { isStripeInstalled } from "./stripe.js"; export async function pullAllConnectors(): Promise { - const [oauthResponse, stripeStatus] = await Promise.all([ + const [oauthResponse, stripeInstalled] = await Promise.all([ listConnectors(), - getStripeStatus(), + isStripeInstalled(), ]); const connectors: ConnectorResource[] = oauthResponse.integrations.map( @@ -15,7 +16,7 @@ export async function pullAllConnectors(): Promise { }), ); - if (stripeStatus.stripeMode !== null) { + if (stripeInstalled) { connectors.push({ type: STRIPE_CONNECTOR_TYPE, scopes: [] }); } diff --git a/packages/cli/src/core/resources/connector/push.ts b/packages/cli/src/core/resources/connector/push.ts index c1e5bcae..f806b059 100644 --- a/packages/cli/src/core/resources/connector/push.ts +++ b/packages/cli/src/core/resources/connector/push.ts @@ -1,18 +1,11 @@ -import { - getStripeStatus, - installStripe, - listConnectors, - removeConnector, - removeStripe, - setConnector, -} from "./api.js"; +import { listConnectors, removeConnector, setConnector } from "./api.js"; import type { ConnectorResource, IntegrationType, SetConnectorResponse, - StripeStatusResponse, } from "./schema.js"; import { STRIPE_CONNECTOR_TYPE } from "./schema.js"; +import { syncStripeConnector } from "./stripe.js"; type SharedSyncResult = | { type: IntegrationType; action: "synced" } @@ -108,81 +101,6 @@ async function syncOAuthConnectors( return results; } -async function syncStripeConnector( - localStripe: ConnectorResource | undefined, -): Promise { - const remoteStatus = await fetchStripeRemoteStatus(); - - if (remoteStatus === "error") { - return localStripe - ? stripeError("Failed to check Stripe integration status") - : null; - } - - const isRemoteInstalled = remoteStatus.stripeMode !== null; - const needsInstall = localStripe && !isRemoteInstalled; - const alreadySynced = localStripe && isRemoteInstalled; - const needsRemoval = !localStripe && isRemoteInstalled; - - if (needsInstall) { - return handleStripeInstall(); - } - - if (alreadySynced) { - return stripeSynced(); - } - - if (needsRemoval) { - return handleStripeRemoval(); - } - - return null; -} - -async function fetchStripeRemoteStatus(): Promise< - StripeStatusResponse | "error" -> { - try { - return await getStripeStatus(); - } catch { - return "error"; - } -} - -async function handleStripeInstall(): Promise { - try { - const result = await installStripe(); - return stripeProvisioned(result.claimUrl ?? undefined); - } catch (err) { - return stripeError(err instanceof Error ? err.message : String(err)); - } -} - -async function handleStripeRemoval(): Promise { - try { - await removeStripe(); - return stripeRemoved(); - } catch (err) { - return stripeError(err instanceof Error ? err.message : String(err)); - } -} - -function stripeSynced(): SharedSyncResult { - return { type: STRIPE_CONNECTOR_TYPE, action: "synced" }; -} - -function stripeProvisioned(claimUrl?: string): StripeSyncResult { - return { type: STRIPE_CONNECTOR_TYPE, action: "provisioned", claimUrl }; -} - -function stripeRemoved(): SharedSyncResult { - return { type: STRIPE_CONNECTOR_TYPE, action: "removed" }; -} - -function stripeError(error: string): SharedSyncResult { - return { type: STRIPE_CONNECTOR_TYPE, action: "error", error }; -} - function getConnectorSyncResult( type: IntegrationType, response: SetConnectorResponse, diff --git a/packages/cli/src/core/resources/connector/stripe.ts b/packages/cli/src/core/resources/connector/stripe.ts new file mode 100644 index 00000000..29a39c02 --- /dev/null +++ b/packages/cli/src/core/resources/connector/stripe.ts @@ -0,0 +1,89 @@ +import { getStripeStatus, installStripe, removeStripe } from "./api.js"; +import type { ConnectorSyncResult, StripeSyncResult } from "./push.js"; +import type { ConnectorResource, StripeStatusResponse } from "./schema.js"; +import { STRIPE_CONNECTOR_TYPE } from "./schema.js"; + +type SharedSyncResult = Extract< + ConnectorSyncResult, + { action: "synced" } | { action: "removed" } | { action: "error" } +>; + +export async function syncStripeConnector( + localStripe: ConnectorResource | undefined, +): Promise { + const remoteStatus = await fetchStripeRemoteStatus(); + + if (remoteStatus === "error") { + return localStripe + ? stripeError("Failed to check Stripe integration status") + : null; + } + + const isRemoteInstalled = remoteStatus.stripeMode !== null; + const needsInstall = localStripe && !isRemoteInstalled; + const alreadySynced = localStripe && isRemoteInstalled; + const needsRemoval = !localStripe && isRemoteInstalled; + + if (needsInstall) { + return handleStripeInstall(); + } + + if (alreadySynced) { + return stripeSynced(); + } + + if (needsRemoval) { + return handleStripeRemoval(); + } + + return null; +} + +export async function isStripeInstalled(): Promise { + const status = await getStripeStatus(); + return status.stripeMode !== null; +} + +async function fetchStripeRemoteStatus(): Promise< + StripeStatusResponse | "error" +> { + try { + return await getStripeStatus(); + } catch { + return "error"; + } +} + +async function handleStripeInstall(): Promise { + try { + const result = await installStripe(); + return stripeProvisioned(result.claimUrl ?? undefined); + } catch (err) { + return stripeError(err instanceof Error ? err.message : String(err)); + } +} + +async function handleStripeRemoval(): Promise { + try { + await removeStripe(); + return stripeRemoved(); + } catch (err) { + return stripeError(err instanceof Error ? err.message : String(err)); + } +} + +function stripeSynced(): SharedSyncResult { + return { type: STRIPE_CONNECTOR_TYPE, action: "synced" }; +} + +function stripeProvisioned(claimUrl?: string): StripeSyncResult { + return { type: STRIPE_CONNECTOR_TYPE, action: "provisioned", claimUrl }; +} + +function stripeRemoved(): SharedSyncResult { + return { type: STRIPE_CONNECTOR_TYPE, action: "removed" }; +} + +function stripeError(error: string): SharedSyncResult { + return { type: STRIPE_CONNECTOR_TYPE, action: "error", error }; +}