diff --git a/src/cli/commands/connectors/oauth-prompt.ts b/src/cli/commands/connectors/oauth-prompt.ts new file mode 100644 index 00000000..dbd0118b --- /dev/null +++ b/src/cli/commands/connectors/oauth-prompt.ts @@ -0,0 +1,105 @@ +import { confirm, isCancel, log } from "@clack/prompts"; +import open from "open"; +import pWaitFor, { TimeoutError } from "p-wait-for"; +import { runTask, theme } from "@/cli/utils/index.js"; +import type { + ConnectorOAuthStatus, + ConnectorSyncResult, + IntegrationType, +} from "@/core/resources/connector/index.js"; +import { getOAuthStatus } from "@/core/resources/connector/index.js"; + +type PendingOAuthResult = ConnectorSyncResult & { + redirectUrl: string; + connectionId: string; +}; + +export function filterPendingOAuth( + results: ConnectorSyncResult[], +): PendingOAuthResult[] { + return results.filter( + (r): r is PendingOAuthResult => + r.action === "needs_oauth" && !!r.redirectUrl && !!r.connectionId, + ); +} + +interface OAuthPromptOptions { + skipPrompt?: boolean; +} + +/** + * Prompt the user to authorize connectors that need OAuth. + * Returns a map of connector type → final OAuth status for each connector + * that was processed. An empty map means either nothing needed OAuth or + * the prompt was skipped / declined. + */ +export async function promptOAuthFlows( + pending: PendingOAuthResult[], + options?: OAuthPromptOptions, +): Promise> { + const outcomes = new Map(); + + if (pending.length === 0) { + return outcomes; + } + + log.warn( + `${pending.length} connector(s) require authorization in your browser:`, + ); + for (const connector of pending) { + log.info(` ${connector.type}: ${theme.styles.dim(connector.redirectUrl)}`); + } + + if (options?.skipPrompt) { + return outcomes; + } + + const shouldAuth = await confirm({ + message: "Open browser to authorize now?", + }); + + if (isCancel(shouldAuth) || !shouldAuth) { + return outcomes; + } + + for (const connector of pending) { + log.info(`\nOpening browser for ${connector.type}...`); + await open(connector.redirectUrl); + + let finalStatus: ConnectorOAuthStatus = "PENDING"; + + await runTask( + `Waiting for ${connector.type} authorization...`, + async () => { + await pWaitFor( + async () => { + const response = await getOAuthStatus( + connector.type, + connector.connectionId, + ); + finalStatus = response.status; + return response.status !== "PENDING"; + }, + { + interval: 2000, + timeout: 2 * 60 * 1000, + }, + ); + }, + { + successMessage: `${connector.type} authorization complete`, + errorMessage: `${connector.type} authorization failed`, + }, + ).catch((err) => { + if (err instanceof TimeoutError) { + finalStatus = "PENDING"; + } else { + throw err; + } + }); + + outcomes.set(connector.type, finalStatus); + } + + return outcomes; +} diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts index ff4241d4..7e1c08cf 100644 --- a/src/cli/commands/connectors/push.ts +++ b/src/cli/commands/connectors/push.ts @@ -1,7 +1,5 @@ -import { confirm, isCancel, log } from "@clack/prompts"; +import { log } from "@clack/prompts"; import { Command } from "commander"; -import open from "open"; -import pWaitFor, { TimeoutError } from "p-wait-for"; import type { CLIContext } from "@/cli/types.js"; import { runCommand, runTask, theme } from "@/cli/utils/index.js"; import type { RunCommandResult } from "@/cli/utils/runCommand.js"; @@ -9,19 +7,10 @@ import { readProjectConfig } from "@/core/index.js"; import { type ConnectorOAuthStatus, type ConnectorSyncResult, - getOAuthStatus, type IntegrationType, pushConnectors, } from "@/core/resources/connector/index.js"; - -type PendingOAuthResult = ConnectorSyncResult & { - redirectUrl: string; - connectionId: string; -}; - -function isPendingOAuth(r: ConnectorSyncResult): r is PendingOAuthResult { - return r.action === "needs_oauth" && !!r.redirectUrl && !!r.connectionId; -} +import { filterPendingOAuth, promptOAuthFlows } from "./oauth-prompt.js"; function printSummary( results: ConnectorSyncResult[], @@ -54,7 +43,6 @@ function printSummary( } } - log.info(""); log.info(theme.styles.bold("Summary:")); if (synced.length > 0) { @@ -92,82 +80,17 @@ async function pushConnectorsAction(): Promise { }, ); - const oauthOutcomes = new Map(); - const needsOAuth = results.filter(isPendingOAuth); + const needsOAuth = filterPendingOAuth(results); let outroMessage = "Connectors pushed to Base44"; - if (needsOAuth.length === 0) { - printSummary(results, oauthOutcomes); - return { outroMessage }; - } - - log.warn( - `${needsOAuth.length} connector(s) require authorization in your browser:`, - ); - for (const connector of needsOAuth) { - log.info( - ` '${connector.type}': ${theme.styles.dim(connector.redirectUrl)}`, - ); - } - - const pending = needsOAuth.map((c) => c.type).join(", "); - - if (process.env.CI) { - outroMessage = `Skipped OAuth in CI. Pending: ${pending}. Run 'base44 connectors push' locally to authorize.`; - } else { - const shouldAuth = await confirm({ - message: "Open browser to authorize now?", - }); - - if (isCancel(shouldAuth) || !shouldAuth) { - outroMessage = `Authorization skipped. Pending: ${pending}. Run 'base44 connectors push' again to complete.`; - } else { - for (const connector of needsOAuth) { - try { - log.info(`\nOpening browser for '${connector.type}'...`); - await open(connector.redirectUrl); + const oauthOutcomes = await promptOAuthFlows(needsOAuth, { + skipPrompt: !!process.env.CI, + }); - let finalStatus: ConnectorOAuthStatus = "PENDING"; - - await runTask( - `Waiting for '${connector.type}' authorization...`, - async () => { - await pWaitFor( - async () => { - const response = await getOAuthStatus( - connector.type, - connector.connectionId, - ); - finalStatus = response.status; - return response.status !== "PENDING"; - }, - { - interval: 2000, - timeout: 2 * 60 * 1000, - }, - ); - }, - { - successMessage: `'${connector.type}' authorization complete`, - errorMessage: `'${connector.type}' authorization failed`, - }, - ).catch((err) => { - if (err instanceof TimeoutError) { - finalStatus = "PENDING"; - } else { - throw err; - } - }); - - oauthOutcomes.set(connector.type, finalStatus); - } catch (err) { - log.error( - `Failed to authorize '${connector.type}': ${err instanceof Error ? err.message : String(err)}`, - ); - oauthOutcomes.set(connector.type, "FAILED"); - } - } - } + if (needsOAuth.length > 0 && oauthOutcomes.size === 0) { + outroMessage = process.env.CI + ? "Skipped OAuth in CI. Run 'base44 connectors push' locally or open the links above to authorize." + : "Authorization skipped. Run 'base44 connectors push' or open the links above to authorize."; } printSummary(results, oauthOutcomes); diff --git a/src/cli/commands/project/deploy.ts b/src/cli/commands/project/deploy.ts index 2363a696..324cdae0 100644 --- a/src/cli/commands/project/deploy.ts +++ b/src/cli/commands/project/deploy.ts @@ -1,5 +1,9 @@ import { confirm, isCancel, log } from "@clack/prompts"; import { Command } from "commander"; +import { + filterPendingOAuth, + promptOAuthFlows, +} from "@/cli/commands/connectors/oauth-prompt.js"; import type { CLIContext } from "@/cli/types.js"; import { getDashboardUrl, @@ -30,7 +34,7 @@ export async function deployAction( }; } - const { project, entities, functions, agents } = projectData; + const { project, entities, functions, agents, connectors } = projectData; // Build summary of what will be deployed const summaryLines: string[] = []; @@ -49,6 +53,11 @@ export async function deployAction( ` - ${agents.length} ${agents.length === 1 ? "agent" : "agents"}`, ); } + if (connectors.length > 0) { + summaryLines.push( + ` - ${connectors.length} ${connectors.length === 1 ? "connector" : "connectors"}`, + ); + } if (project.site?.outputDirectory) { summaryLines.push(` - Site from ${project.site.outputDirectory}`); } @@ -81,6 +90,20 @@ 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 || !!process.env.CI, + }); + + if (oauthOutcomes.size === 0) { + log.info( + "To authorize, run 'base44 connectors push' or open the links above in your browser.", + ); + } + } + log.message( `${theme.styles.header("Dashboard")}: ${theme.colors.links(getDashboardUrl())}`, ); @@ -96,7 +119,7 @@ export async function deployAction( export function getDeployCommand(context: CLIContext): Command { return new Command("deploy") .description( - "Deploy all project resources (entities, functions, agents, and site)", + "Deploy all project resources (entities, functions, agents, connectors, and site)", ) .option("-y, --yes", "Skip confirmation prompt") .action(async (options: DeployOptions) => { diff --git a/src/core/project/deploy.ts b/src/core/project/deploy.ts index 2c925de4..9d6bff02 100644 --- a/src/core/project/deploy.ts +++ b/src/core/project/deploy.ts @@ -1,6 +1,10 @@ import { resolve } from "node:path"; import type { ProjectData } from "@/core/project/types.js"; import { agentResource } from "@/core/resources/agent/index.js"; +import { + type ConnectorSyncResult, + pushConnectors, +} from "@/core/resources/connector/index.js"; import { entityResource } from "@/core/resources/entity/index.js"; import { functionResource } from "@/core/resources/function/index.js"; import { deploySite } from "@/core/site/index.js"; @@ -9,16 +13,17 @@ import { deploySite } from "@/core/site/index.js"; * Checks if there are any resources to deploy in the project. * * @param projectData - The project configuration and resources - * @returns true if there are entities, functions, agents, or a configured site to deploy + * @returns true if there are entities, functions, agents, connectors, or a configured site to deploy */ export function hasResourcesToDeploy(projectData: ProjectData): boolean { - const { project, entities, functions, agents } = projectData; + const { project, entities, functions, agents, connectors } = projectData; const hasSite = Boolean(project.site?.outputDirectory); const hasEntities = entities.length > 0; const hasFunctions = functions.length > 0; const hasAgents = agents.length > 0; + const hasConnectors = connectors.length > 0; - return hasEntities || hasFunctions || hasAgents || hasSite; + return hasEntities || hasFunctions || hasAgents || hasConnectors || hasSite; } /** @@ -29,10 +34,14 @@ interface DeployAllResult { * The app URL if a site was deployed, undefined otherwise. */ appUrl?: string; + /** + * Results of connector push, including any that need OAuth. + */ + connectorResults?: ConnectorSyncResult[]; } /** - * Deploys all project resources (entities, functions, agents, and site) to Base44. + * Deploys all project resources (entities, functions, agents, connectors, and site) to Base44. * * @param projectData - The project configuration and resources to deploy * @returns The deployment result including app URL if site was deployed @@ -40,17 +49,18 @@ interface DeployAllResult { export async function deployAll( projectData: ProjectData, ): Promise { - const { project, entities, functions, agents } = projectData; + const { project, entities, functions, agents, connectors } = projectData; await entityResource.push(entities); await functionResource.push(functions); await agentResource.push(agents); + const { results: connectorResults } = await pushConnectors(connectors); if (project.site?.outputDirectory) { const outputDir = resolve(project.root, project.site.outputDirectory); const { appUrl } = await deploySite(outputDir); - return { appUrl }; + return { appUrl, connectorResults }; } - return {}; + return { connectorResults }; } diff --git a/tests/cli/deploy.spec.ts b/tests/cli/deploy.spec.ts index fa71395a..6f429625 100644 --- a/tests/cli/deploy.spec.ts +++ b/tests/cli/deploy.spec.ts @@ -30,6 +30,7 @@ describe("deploy command (unified)", () => { deleted: [], }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsList({ integrations: [] }); const result = await t.run("deploy", "-y"); @@ -46,6 +47,7 @@ describe("deploy command (unified)", () => { deleted: [], }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsList({ integrations: [] }); const result = await t.run("deploy", "--yes"); @@ -62,6 +64,7 @@ describe("deploy command (unified)", () => { errors: null, }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsList({ integrations: [] }); const result = await t.run("deploy", "-y"); @@ -74,6 +77,7 @@ describe("deploy command (unified)", () => { t.api.mockEntitiesPush({ created: ["Task"], updated: [], deleted: [] }); t.api.mockFunctionsPush({ deployed: ["hello"], deleted: [], errors: null }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsList({ integrations: [] }); t.api.mockSiteDeploy({ app_url: "https://full-project.base44.app" }); const result = await t.run("deploy", "-y"); @@ -92,6 +96,7 @@ describe("deploy command (unified)", () => { updated: [], deleted: [], }); + t.api.mockConnectorsList({ integrations: [] }); const result = await t.run("deploy", "-y"); @@ -109,10 +114,50 @@ describe("deploy command (unified)", () => { updated: ["order_assistant"], deleted: [], }); + t.api.mockConnectorsList({ integrations: [] }); const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Deployment completed"); }); + + it("deploys connectors successfully with -y flag", async () => { + await t.givenLoggedInWithProject(fixture("with-connectors")); + 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.mockConnectorSet({ + redirect_url: null, + connection_id: null, + already_authorized: true, + }); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Deployment completed"); + t.expectResult(result).toContain("3 connectors"); + }); + + it("shows OAuth info when connectors need authorization with -y flag", async () => { + await t.givenLoggedInWithProject(fixture("with-connectors")); + 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.mockConnectorSet({ + redirect_url: "https://accounts.google.com/oauth", + connection_id: "conn_123", + already_authorized: false, + }); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Deployment completed"); + t.expectResult(result).toContain("require authorization"); + t.expectResult(result).toContain("base44 connectors push"); + }); });