diff --git a/src/cli/commands/connectors/index.ts b/src/cli/commands/connectors/index.ts new file mode 100644 index 0000000..9fa6ac0 --- /dev/null +++ b/src/cli/commands/connectors/index.ts @@ -0,0 +1,6 @@ +import { Command } from "commander"; +import { connectorsPushCommand } from "./push.js"; + +export const connectorsCommand = new Command("connectors") + .description("Manage OAuth connectors") + .addCommand(connectorsPushCommand); diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts new file mode 100644 index 0000000..6840cdc --- /dev/null +++ b/src/cli/commands/connectors/push.ts @@ -0,0 +1,275 @@ +import { Command } from "commander"; +import { log, confirm, isCancel, cancel } from "@clack/prompts"; +import { + initiateOAuth, + listConnectors, + removeConnector, + isValidIntegration, + getIntegrationDisplayName, +} from "@/core/connectors/index.js"; +import type { IntegrationType, Connector } from "@/core/connectors/index.js"; +import { readProjectConfig } from "@/core/project/config.js"; +import { runCommand, runTask } from "../../utils/index.js"; +import type { RunCommandResult } from "../../utils/runCommand.js"; +import { theme } from "../../utils/theme.js"; +import { waitForOAuthCompletion } from "./utils.js"; + +interface PushOptions { + yes?: boolean; +} + +/** + * Get connectors defined in the local config.jsonc + */ +async function getLocalConnectors(): Promise { + const projectData = await runTask( + "Reading project config...", + async () => readProjectConfig(), + { + successMessage: "Project config loaded", + errorMessage: "Failed to read project config", + } + ); + + const connectors = projectData.project.connectors || []; + + // Validate all connectors are supported + const validConnectors: IntegrationType[] = []; + const invalidConnectors: string[] = []; + + for (const connector of connectors) { + if (isValidIntegration(connector)) { + validConnectors.push(connector as IntegrationType); + } else { + invalidConnectors.push(connector); + } + } + + if (invalidConnectors.length > 0) { + throw new Error( + `Invalid connectors found in config.jsonc: ${invalidConnectors.join(", ")}` + ); + } + + return validConnectors; +} + +/** + * Get active connectors from the backend + */ +async function getBackendConnectors(): Promise> { + const connectors = await runTask( + "Fetching active connectors...", + async () => listConnectors(), + { + successMessage: "Active connectors loaded", + errorMessage: "Failed to fetch connectors", + } + ); + + const activeConnectors = new Set(); + + for (const connector of connectors) { + if (isValidIntegration(connector.integrationType)) { + activeConnectors.add(connector.integrationType); + } + } + + return activeConnectors; +} + +/** + * Activate a connector by initiating OAuth and polling for completion + */ +async function activateConnector( + integrationType: IntegrationType +): Promise<{ success: boolean; accountEmail?: string; error?: string }> { + const displayName = getIntegrationDisplayName(integrationType); + + // Initiate OAuth flow + const initiateResponse = await runTask( + `Initiating ${displayName} connection...`, + async () => { + return await initiateOAuth(integrationType); + }, + { + successMessage: `${displayName} OAuth initiated`, + errorMessage: `Failed to initiate ${displayName} connection`, + } + ); + + // Check if already authorized + if (initiateResponse.alreadyAuthorized) { + return { success: true }; + } + + // Check if connected by different user + if (initiateResponse.error === "different_user" && initiateResponse.otherUserEmail) { + return { + success: false, + error: `Already connected by ${initiateResponse.otherUserEmail}`, + }; + } + + // Validate we have required fields + if (!initiateResponse.redirectUrl || !initiateResponse.connectionId) { + return { + success: false, + error: "Invalid response from server: missing redirect URL or connection ID", + }; + } + + // Show authorization URL + log.info( + `Please authorize ${displayName} at:\n${theme.colors.links(initiateResponse.redirectUrl)}` + ); + + // Poll for completion + const result = await runTask( + "Waiting for authorization...", + async () => { + return await waitForOAuthCompletion(integrationType, initiateResponse.connectionId!); + }, + { + successMessage: "Authorization completed!", + errorMessage: "Authorization failed", + } + ); + + return result; +} + +/** + * Delete a connector from the backend (hard delete) + */ +async function deleteConnector(integrationType: IntegrationType): Promise { + const displayName = getIntegrationDisplayName(integrationType); + + await runTask( + `Removing ${displayName}...`, + async () => { + await removeConnector(integrationType); + }, + { + successMessage: `${displayName} removed`, + errorMessage: `Failed to remove ${displayName}`, + } + ); +} + +export async function push(options: PushOptions = {}): Promise { + // Step 1: Get local and backend connectors + const localConnectors = await getLocalConnectors(); + const backendConnectors = await getBackendConnectors(); + + // Step 2: Determine what needs to be done + const toDelete: IntegrationType[] = []; + const toActivate: IntegrationType[] = []; + const alreadyActive: IntegrationType[] = []; + + // Find connectors to delete (in backend but not local) + for (const connector of backendConnectors) { + if (!localConnectors.includes(connector)) { + toDelete.push(connector); + } + } + + // Find connectors to activate or skip + for (const connector of localConnectors) { + if (backendConnectors.has(connector)) { + alreadyActive.push(connector); + } else { + toActivate.push(connector); + } + } + + // Step 3: Show summary and confirm if needed + if (toDelete.length === 0 && toActivate.length === 0) { + return { + outroMessage: "All connectors are in sync. Nothing to do.", + }; + } + + log.info("\nConnector sync summary:"); + + if (toDelete.length > 0) { + log.warn(` ${theme.colors.error("Delete from backend:")} ${toDelete.map(getIntegrationDisplayName).join(", ")}`); + } + + if (toActivate.length > 0) { + log.info(` ${theme.colors.success("Activate:")} ${toActivate.map(getIntegrationDisplayName).join(", ")}`); + } + + if (alreadyActive.length > 0) { + log.info(` ${theme.colors.dim("Already active:")} ${alreadyActive.map(getIntegrationDisplayName).join(", ")}`); + } + + // Confirm if not using --yes flag + if (!options.yes && toDelete.length > 0) { + const shouldContinue = await confirm({ + message: `Delete ${toDelete.length} connector(s) from backend?`, + }); + + if (isCancel(shouldContinue) || !shouldContinue) { + cancel("Operation cancelled."); + process.exit(0); + } + } + + // Step 4: Delete connectors no longer in config + const deleteResults: string[] = []; + for (const connector of toDelete) { + try { + await deleteConnector(connector); + deleteResults.push(`${theme.colors.success("✓")} Deleted ${getIntegrationDisplayName(connector)}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : "Unknown error"; + deleteResults.push(`${theme.colors.error("✗")} Failed to delete ${getIntegrationDisplayName(connector)}: ${errorMsg}`); + } + } + + // Step 5: Activate new connectors + const activationResults: string[] = []; + for (const connector of toActivate) { + try { + const result = await activateConnector(connector); + + if (result.success) { + const accountInfo = result.accountEmail ? ` as ${theme.styles.bold(result.accountEmail)}` : ""; + activationResults.push(`${theme.colors.success("✓")} Activated ${getIntegrationDisplayName(connector)}${accountInfo}`); + } else { + activationResults.push(`${theme.colors.error("✗")} Failed to activate ${getIntegrationDisplayName(connector)}: ${result.error}`); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : "Unknown error"; + activationResults.push(`${theme.colors.error("✗")} Failed to activate ${getIntegrationDisplayName(connector)}: ${errorMsg}`); + } + } + + // Step 6: Build summary message + const summaryLines: string[] = []; + + if (deleteResults.length > 0) { + summaryLines.push("\nDeleted connectors:"); + summaryLines.push(...deleteResults.map(r => ` ${r}`)); + } + + if (activationResults.length > 0) { + summaryLines.push("\nActivated connectors:"); + summaryLines.push(...activationResults.map(r => ` ${r}`)); + } + + return { + outroMessage: summaryLines.join("\n") || "Connector sync completed", + }; +} + +export const connectorsPushCommand = new Command("push") + .description("Sync connectors defined in config.jsonc with backend") + .option("-y, --yes", "Skip confirmation prompts") + .action(async (options: PushOptions) => { + await runCommand(() => push(options), { + requireAuth: true, + requireAppConfig: true, + }); + }); diff --git a/src/cli/commands/connectors/utils.ts b/src/cli/commands/connectors/utils.ts new file mode 100644 index 0000000..c9dfc54 --- /dev/null +++ b/src/cli/commands/connectors/utils.ts @@ -0,0 +1,75 @@ +import pWaitFor from "p-wait-for"; +import { checkOAuthStatus } from "@/core/connectors/api.js"; +import type { IntegrationType } from "@/core/connectors/consts.js"; + +const OAUTH_POLL_INTERVAL_MS = 2000; +const OAUTH_POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +export interface OAuthCompletionResult { + success: boolean; + accountEmail?: string; + error?: string; +} + +/** + * Polls for OAuth completion status. + * Returns when status becomes ACTIVE or FAILED, or times out. + */ +export async function waitForOAuthCompletion( + integrationType: IntegrationType, + connectionId: string, + options?: { + onPending?: () => void; + } +): Promise { + let accountEmail: string | undefined; + let error: string | undefined; + + try { + await pWaitFor( + async () => { + const status = await checkOAuthStatus(integrationType, connectionId); + + if (status.status === "ACTIVE") { + accountEmail = status.accountEmail ?? undefined; + return true; + } + + if (status.status === "FAILED") { + error = status.error || "Authorization failed"; + throw new Error(error); + } + + // PENDING - continue polling + options?.onPending?.(); + return false; + }, + { + interval: OAUTH_POLL_INTERVAL_MS, + timeout: OAUTH_POLL_TIMEOUT_MS, + } + ); + + return { success: true, accountEmail }; + } catch (err) { + if (err instanceof Error && err.message.includes("timed out")) { + return { success: false, error: "Authorization timed out. Please try again." }; + } + return { success: false, error: error || (err instanceof Error ? err.message : "Unknown error") }; + } +} + +/** + * Asserts that a string is a valid integration type, throwing if not. + */ +export function assertValidIntegrationType( + type: string, + supportedIntegrations: readonly string[] +): asserts type is IntegrationType { + if (!supportedIntegrations.includes(type)) { + const supportedList = supportedIntegrations.join(", "); + throw new Error( + `Unsupported connector: ${type}\nSupported connectors: ${supportedList}` + ); + } +} diff --git a/src/cli/program.ts b/src/cli/program.ts index ff62ed0..9f685f4 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -10,6 +10,7 @@ import { dashboardCommand } from "@/cli/commands/project/dashboard.js"; import { deployCommand } from "@/cli/commands/project/deploy.js"; import { linkCommand } from "@/cli/commands/project/link.js"; import { siteDeployCommand } from "@/cli/commands/site/deploy.js"; +import { connectorsCommand } from "@/cli/commands/connectors/index.js"; import packageJson from "../../package.json"; const program = new Command(); @@ -48,4 +49,7 @@ program.addCommand(functionsDeployCommand); // Register site commands program.addCommand(siteDeployCommand); +// Register connectors commands +program.addCommand(connectorsCommand); + export { program }; diff --git a/src/cli/utils/theme.ts b/src/cli/utils/theme.ts index 540d958..1939027 100644 --- a/src/cli/utils/theme.ts +++ b/src/cli/utils/theme.ts @@ -9,11 +9,14 @@ export const theme = { base44OrangeBackground: chalk.bgHex("#E86B3C"), shinyOrange: chalk.hex("#FFD700"), links: chalk.hex("#00D4FF"), - white: chalk.white + white: chalk.white, + success: chalk.green, + warning: chalk.yellow, + error: chalk.red, }, styles: { header: chalk.dim, bold: chalk.bold, - dim: chalk.dim - } + dim: chalk.dim, + }, }; diff --git a/src/core/connectors/api.ts b/src/core/connectors/api.ts new file mode 100644 index 0000000..3348306 --- /dev/null +++ b/src/core/connectors/api.ts @@ -0,0 +1,90 @@ +import { getAppClient } from "../clients/index.js"; +import { + InitiateResponseSchema, + StatusResponseSchema, + ListResponseSchema, +} from "./schema.js"; +import type { + InitiateResponse, + StatusResponse, + Connector, +} from "./schema.js"; +import type { IntegrationType } from "./consts.js"; + +/** + * Initiates OAuth flow for a connector integration. + * Returns a redirect URL to open in the browser. + */ +export async function initiateOAuth( + integrationType: IntegrationType, + scopes: string[] | null = null +): Promise { + const appClient = getAppClient(); + + const response = await appClient.post("external-auth/initiate", { + json: { + integration_type: integrationType, + scopes, + }, + }); + + const json = await response.json(); + return InitiateResponseSchema.parse(json); +} + +/** + * Checks the status of an OAuth connection attempt. + */ +export async function checkOAuthStatus( + integrationType: IntegrationType, + connectionId: string +): Promise { + const appClient = getAppClient(); + + const response = await appClient.get("external-auth/status", { + searchParams: { + integration_type: integrationType, + connection_id: connectionId, + }, + }); + + const json = await response.json(); + return StatusResponseSchema.parse(json); +} + +/** + * Lists all connected integrations for the current app. + */ +export async function listConnectors(): Promise { + const appClient = getAppClient(); + + const response = await appClient.get("external-auth/list"); + + const json = await response.json(); + const result = ListResponseSchema.parse(json); + return result.integrations; +} + +/** + * Disconnects (soft delete) a connector integration. + */ +export async function disconnectConnector( + integrationType: IntegrationType +): Promise { + const appClient = getAppClient(); + + await appClient.delete(`external-auth/integrations/${integrationType}`); +} + +/** + * Removes (hard delete) a connector integration. + * This permanently removes the connector and cannot be undone. + */ +export async function removeConnector( + integrationType: IntegrationType +): Promise { + const appClient = getAppClient(); + + await appClient.delete(`external-auth/integrations/${integrationType}`); +} + diff --git a/src/core/connectors/consts.ts b/src/core/connectors/consts.ts new file mode 100644 index 0000000..c17945c --- /dev/null +++ b/src/core/connectors/consts.ts @@ -0,0 +1,74 @@ +/** + * Supported OAuth connector integrations. + * Based on apper/backend/app/external_auth/models/constants.py + */ + +export const SUPPORTED_INTEGRATIONS = [ + "googlecalendar", + "googledrive", + "gmail", + "googlesheets", + "googledocs", + "googleslides", + "slack", + "notion", + "salesforce", + "hubspot", + "linkedin", + "tiktok", +] as const; + +export type IntegrationType = (typeof SUPPORTED_INTEGRATIONS)[number]; + +/** + * Connector categories + */ +export type ConnectorCategory = "Communication" | "Productivity" | "CRM" | "Social" | "Google"; + +/** + * Display names for integrations (for CLI output) + */ +export const INTEGRATION_DISPLAY_NAMES: Record = { + googlecalendar: "Google Calendar", + googledrive: "Google Drive", + gmail: "Gmail", + googlesheets: "Google Sheets", + googledocs: "Google Docs", + googleslides: "Google Slides", + slack: "Slack", + notion: "Notion", + salesforce: "Salesforce", + hubspot: "HubSpot", + linkedin: "LinkedIn", + tiktok: "TikTok", +}; + +/** + * Category metadata for each connector + */ +export const INTEGRATION_CATEGORIES: Record = { + slack: "Communication", + notion: "Productivity", + hubspot: "CRM", + salesforce: "CRM", + linkedin: "Social", + tiktok: "Social", + googlecalendar: "Google", + googledrive: "Google", + gmail: "Google", + googlesheets: "Google", + googledocs: "Google", + googleslides: "Google", +}; + +export function isValidIntegration(type: string): type is IntegrationType { + return SUPPORTED_INTEGRATIONS.includes(type as IntegrationType); +} + +export function getIntegrationDisplayName(type: IntegrationType): string { + return INTEGRATION_DISPLAY_NAMES[type] ?? type; +} + +export function getIntegrationCategory(type: IntegrationType): ConnectorCategory { + return INTEGRATION_CATEGORIES[type]; +} diff --git a/src/core/connectors/index.ts b/src/core/connectors/index.ts new file mode 100644 index 0000000..e178e88 --- /dev/null +++ b/src/core/connectors/index.ts @@ -0,0 +1,33 @@ +// API functions +export { + initiateOAuth, + checkOAuthStatus, + listConnectors, + disconnectConnector, + removeConnector, +} from "./api.js"; + +// Schemas and types +export { + InitiateResponseSchema, + StatusResponseSchema, + ConnectorSchema, + ListResponseSchema, +} from "./schema.js"; + +export type { + InitiateResponse, + StatusResponse, + Connector, + ListResponse, +} from "./schema.js"; + +// Constants +export { + SUPPORTED_INTEGRATIONS, + INTEGRATION_DISPLAY_NAMES, + isValidIntegration, + getIntegrationDisplayName, +} from "./consts.js"; + +export type { IntegrationType } from "./consts.js"; diff --git a/src/core/connectors/schema.ts b/src/core/connectors/schema.ts new file mode 100644 index 0000000..3c2ad34 --- /dev/null +++ b/src/core/connectors/schema.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; + +/** + * Response from POST /api/apps/{app_id}/external-auth/initiate + */ +export const InitiateResponseSchema = z + .object({ + redirect_url: z.string().nullish(), + connection_id: z.string().nullish(), + already_authorized: z.boolean().nullish(), + other_user_email: z.string().nullish(), + error: z.string().nullish(), + }) + .transform((data) => ({ + redirectUrl: data.redirect_url, + connectionId: data.connection_id, + alreadyAuthorized: data.already_authorized, + otherUserEmail: data.other_user_email, + error: data.error, + })); + +export type InitiateResponse = z.infer; + +/** + * Response from GET /api/apps/{app_id}/external-auth/status + */ +export const StatusResponseSchema = z + .object({ + status: z.enum(["ACTIVE", "PENDING", "FAILED"]), + account_email: z.string().nullish(), + error: z.string().nullish(), + }) + .transform((data) => ({ + status: data.status, + accountEmail: data.account_email, + error: data.error, + })); + +export type StatusResponse = z.infer; + +/** + * A connected integration from the list endpoint + */ +export const ConnectorSchema = z + .object({ + integration_type: z.string(), + status: z.string(), + connected_at: z.string().nullish(), + account_info: z + .object({ + email: z.string().nullish(), + name: z.string().nullish(), + }) + .nullish(), + }) + .transform((data) => ({ + integrationType: data.integration_type, + status: data.status, + connectedAt: data.connected_at, + accountInfo: data.account_info, + })); + +export type Connector = z.infer; + +/** + * Response from GET /api/apps/{app_id}/external-auth/list + */ +export const ListResponseSchema = z.object({ + integrations: z.array(ConnectorSchema), +}); + +export type ListResponse = z.infer; diff --git a/src/core/project/schema.ts b/src/core/project/schema.ts index 18b6504..7896893 100644 --- a/src/core/project/schema.ts +++ b/src/core/project/schema.ts @@ -28,6 +28,7 @@ export const ProjectConfigSchema = z.object({ entitiesDir: z.string().optional().default("entities"), functionsDir: z.string().optional().default("functions"), agentsDir: z.string().optional().default("agents"), + connectors: z.array(z.string()).optional(), }); export type SiteConfig = z.infer;