Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions packages/cli/src/cli/commands/connectors/oauth-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ConnectorOAuthStatus,
ConnectorSyncResult,
IntegrationType,
OAuthSyncResult,
} from "@/core/resources/connector/index.js";
import { getOAuthStatus } from "@/core/resources/connector/index.js";

Expand All @@ -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,
);
}

Expand All @@ -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<OAuthFlowStatus> {
await open(connector.redirectUrl);

Expand All @@ -61,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,
Expand Down Expand Up @@ -103,7 +102,7 @@ async function runOAuthFlowWithSkip(
* the prompt was skipped / declined.
*/
export async function promptOAuthFlows(
pending: PendingOAuthResult[],
pending: OAuthSyncResult[],
options?: OAuthPromptOptions,
): Promise<Map<IntegrationType, OAuthFlowStatus>> {
const outcomes = new Map<IntegrationType, OAuthFlowStatus>();
Expand Down
11 changes: 4 additions & 7 deletions packages/cli/src/cli/commands/connectors/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -19,7 +19,7 @@ async function pullConnectorsAction(): Promise<RunCommandResult> {
const remoteConnectors = await runTask(
"Fetching connectors from Base44",
async () => {
return await listConnectors();
return await pullAllConnectors();
},
{
successMessage: "Connectors fetched successfully",
Expand All @@ -30,10 +30,7 @@ async function pullConnectorsAction(): Promise<RunCommandResult> {
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",
Expand All @@ -52,7 +49,7 @@ async function pullConnectorsAction(): Promise<RunCommandResult> {
}

return {
outroMessage: `Pulled ${remoteConnectors.integrations.length} connectors to ${connectorsDir}`,
outroMessage: `Pulled ${remoteConnectors.length} connectors to ${connectorsDir}`,
};
}

Expand Down
64 changes: 43 additions & 21 deletions packages/cli/src/cli/commands/connectors/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(", ")}`);
}
Expand All @@ -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}`);
}
}

Expand Down
55 changes: 40 additions & 15 deletions packages/cli/src/cli/commands/project/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "@/cli/commands/connectors/oauth-prompt.js";
import type { CLIContext } from "@/cli/types.js";
import {
getConnectorsUrl,
getDashboardUrl,
runCommand,
runTask,
Expand All @@ -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;
Expand Down Expand Up @@ -91,21 +96,12 @@ 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.type === "stripe");
if (stripeResult?.action === "provisioned") {
printStripeResult(stripeResult as StripeSyncResult);
}

log.message(
Expand Down Expand Up @@ -138,3 +134,32 @@ export function getDeployCommand(context: CLIContext): Command {
);
});
}

async function handleOAuthConnectors(
connectorResults: ConnectorSyncResult[],
options: DeployOptions,
): Promise<void> {
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())}`);
}
5 changes: 5 additions & 0 deletions packages/cli/src/cli/utils/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}
83 changes: 83 additions & 0 deletions packages/cli/src/core/resources/connector/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,25 @@ import type { KyResponse } from "ky";
import { getAppClient } from "@/core/clients/index.js";
import { ApiError, SchemaValidationError } from "@/core/errors.js";
import type {
InstallStripeResponse,
IntegrationType,
ListAvailableIntegrationsResponse,
ListConnectorsResponse,
OAuthStatusResponse,
RemoveConnectorResponse,
RemoveStripeResponse,
SetConnectorResponse,
StripeStatusResponse,
} from "./schema.js";
import {
InstallStripeResponseSchema,
ListAvailableIntegrationsResponseSchema,
ListConnectorsResponseSchema,
OAuthStatusResponseSchema,
RemoveConnectorResponseSchema,
RemoveStripeResponseSchema,
SetConnectorResponseSchema,
StripeStatusResponseSchema,
} from "./schema.js";

export async function listConnectors(): Promise<ListConnectorsResponse> {
Expand Down Expand Up @@ -150,3 +156,80 @@ export async function removeConnector(

return result.data;
}

// ─── STRIPE-SPECIFIC ENDPOINTS ───────────────────────────────

export async function installStripe(): Promise<InstallStripeResponse> {
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<StripeStatusResponse> {
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<RemoveStripeResponse> {
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;
}
Loading