From cbdf7c4b151251640b0c95ce4a308d012546991f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Jan 2026 20:16:00 +0000 Subject: [PATCH 01/20] feat: Add OAuth connectors management commands Add new CLI commands for managing OAuth integrations: - `connectors:add [type]` - Connect an OAuth integration (Slack, Google, etc.) - `connectors:list` - List all connected integrations - `connectors:remove [type]` - Disconnect an integration The implementation uses the existing base44 external-auth API endpoints and stores tokens via Composio (the backend OAuth token storage vendor). Supported integrations: Slack, Google Calendar, Google Drive, Gmail, Google Sheets, Google Docs, Google Slides, Notion, Salesforce, HubSpot, LinkedIn, and TikTok. https://claude.ai/code/session_013jLN9iB2EPcrwsWaUNovsJ --- src/cli/commands/connectors/add.ts | 175 +++++++++++++++++++++++++ src/cli/commands/connectors/list.ts | 96 ++++++++++++++ src/cli/commands/connectors/remove.ts | 126 ++++++++++++++++++ src/cli/program.ts | 4 + src/cli/utils/theme.ts | 9 +- src/core/connectors/api.ts | 182 ++++++++++++++++++++++++++ src/core/connectors/constants.ts | 50 +++++++ src/core/connectors/index.ts | 35 +++++ src/core/connectors/schema.ts | 68 ++++++++++ src/core/errors.ts | 14 ++ 10 files changed, 756 insertions(+), 3 deletions(-) create mode 100644 src/cli/commands/connectors/add.ts create mode 100644 src/cli/commands/connectors/list.ts create mode 100644 src/cli/commands/connectors/remove.ts create mode 100644 src/core/connectors/api.ts create mode 100644 src/core/connectors/constants.ts create mode 100644 src/core/connectors/index.ts create mode 100644 src/core/connectors/schema.ts diff --git a/src/cli/commands/connectors/add.ts b/src/cli/commands/connectors/add.ts new file mode 100644 index 00000000..bf9f5d69 --- /dev/null +++ b/src/cli/commands/connectors/add.ts @@ -0,0 +1,175 @@ +import { Command } from "commander"; +import { log, select, isCancel } from "@clack/prompts"; +import pWaitFor from "p-wait-for"; +import open from "open"; +import { + initiateOAuth, + checkOAuthStatus, + SUPPORTED_INTEGRATIONS, + isValidIntegration, + getIntegrationDisplayName, +} from "@core/connectors/index.js"; +import type { IntegrationType } from "@core/connectors/index.js"; +import { runCommand, runTask } from "../../utils/index.js"; +import type { RunCommandResult } from "../../utils/runCommand.js"; +import { theme } from "../../utils/theme.js"; + +const POLL_INTERVAL_MS = 2000; +const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +async function promptForIntegrationType(): Promise { + const options = SUPPORTED_INTEGRATIONS.map((type) => ({ + value: type, + label: getIntegrationDisplayName(type), + })); + + const selected = await select({ + message: "Select an integration to connect:", + options, + }); + + if (isCancel(selected)) { + return null; + } + + return selected; +} + +async function waitForOAuthCompletion( + integrationType: IntegrationType, + connectionId: string +): Promise<{ success: boolean; accountEmail?: string; error?: string }> { + let accountEmail: string | undefined; + let error: string | undefined; + + try { + await runTask( + "Waiting for authorization...", + async (updateMessage) => { + await pWaitFor( + async () => { + const status = await checkOAuthStatus(integrationType, connectionId); + + if (status.status === "ACTIVE") { + accountEmail = status.account_email; + return true; + } + + if (status.status === "FAILED") { + error = status.error || "Authorization failed"; + throw new Error(error); + } + + // PENDING - continue polling + updateMessage("Waiting for authorization in browser..."); + return false; + }, + { + interval: POLL_INTERVAL_MS, + timeout: POLL_TIMEOUT_MS, + } + ); + }, + { + successMessage: "Authorization completed!", + errorMessage: "Authorization failed", + } + ); + + 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") }; + } +} + +export async function addConnector( + integrationType?: string +): Promise { + // If no type provided, prompt for selection + let selectedType: IntegrationType; + + if (!integrationType) { + const prompted = await promptForIntegrationType(); + if (!prompted) { + return { outroMessage: "Cancelled" }; + } + selectedType = prompted; + } else { + // Validate the provided integration type + if (!isValidIntegration(integrationType)) { + const supportedList = SUPPORTED_INTEGRATIONS.join(", "); + throw new Error( + `Unsupported connector: ${integrationType}\nSupported connectors: ${supportedList}` + ); + } + selectedType = integrationType; + } + + const displayName = getIntegrationDisplayName(selectedType); + + // Initiate OAuth flow + const initiateResponse = await runTask( + `Initiating ${displayName} connection...`, + async () => { + return await initiateOAuth(selectedType); + }, + { + successMessage: `${displayName} OAuth initiated`, + errorMessage: `Failed to initiate ${displayName} connection`, + } + ); + + // Check if already authorized + if (initiateResponse.already_authorized) { + return { + outroMessage: `Already connected to ${theme.styles.bold(displayName)}`, + }; + } + + // Check if connected by different user + if (initiateResponse.error === "different_user" && initiateResponse.other_user_email) { + throw new Error( + `This app is already connected to ${displayName} by ${initiateResponse.other_user_email}` + ); + } + + // Validate we have required fields + if (!initiateResponse.redirect_url || !initiateResponse.connection_id) { + throw new Error("Invalid response from server: missing redirect URL or connection ID"); + } + + // Open browser for OAuth + log.info(`Opening browser for ${displayName} authorization...`); + await open(initiateResponse.redirect_url); + + // Poll for completion + const result = await waitForOAuthCompletion( + selectedType, + initiateResponse.connection_id + ); + + if (!result.success) { + throw new Error(result.error || "Authorization failed"); + } + + const accountInfo = result.accountEmail + ? ` as ${theme.styles.bold(result.accountEmail)}` + : ""; + + return { + outroMessage: `Successfully connected to ${theme.styles.bold(displayName)}${accountInfo}`, + }; +} + +export const connectorsAddCommand = new Command("connectors:add") + .argument("[type]", "Integration type (e.g., slack, notion, googlecalendar)") + .description("Connect an OAuth integration") + .action(async (type?: string) => { + await runCommand(() => addConnector(type), { + requireAuth: true, + requireAppConfig: true, + }); + }); diff --git a/src/cli/commands/connectors/list.ts b/src/cli/commands/connectors/list.ts new file mode 100644 index 00000000..1b64e8e5 --- /dev/null +++ b/src/cli/commands/connectors/list.ts @@ -0,0 +1,96 @@ +import { Command } from "commander"; +import { log } from "@clack/prompts"; +import { listConnectors, getIntegrationDisplayName } from "@core/connectors/index.js"; +import { runCommand, runTask } from "../../utils/index.js"; +import type { RunCommandResult } from "../../utils/runCommand.js"; +import { theme } from "../../utils/theme.js"; + +function formatDate(dateString?: string): string { + if (!dateString) { + return "-"; + } + try { + const date = new Date(dateString); + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + } catch { + return dateString; + } +} + +function formatStatus(status: string): string { + const normalized = status.toLowerCase(); + if (normalized === "active" || normalized === "connected") { + return theme.colors.success("● active"); + } + if (normalized === "expired") { + return theme.colors.warning("● expired"); + } + if (normalized === "failed" || normalized === "disconnected") { + return theme.colors.error("● disconnected"); + } + return status; +} + +export async function listConnectorsCommand(): Promise { + const connectors = await runTask( + "Fetching connectors...", + async () => { + return await listConnectors(); + }, + { + successMessage: "Connectors loaded", + errorMessage: "Failed to fetch connectors", + } + ); + + if (connectors.length === 0) { + log.info("No connectors configured for this app."); + log.info(`Run ${theme.styles.bold("base44 connectors:add")} to connect an integration.`); + return { outroMessage: "" }; + } + + // Display as formatted list + console.log(); + console.log(theme.styles.bold("Connected Integrations:")); + console.log(); + + // Table header + const headers = ["Type", "Account", "Status", "Connected"]; + const colWidths = [20, 30, 15, 15]; + + const headerRow = headers + .map((h, i) => h.padEnd(colWidths[i])) + .join(" "); + console.log(theme.styles.dim(headerRow)); + console.log(theme.styles.dim("─".repeat(headerRow.length))); + + // Table rows + for (const connector of connectors) { + const type = getIntegrationDisplayName(connector.integrationType).padEnd(colWidths[0]); + const account = (connector.accountInfo?.email || connector.accountInfo?.name || "-").padEnd(colWidths[1]); + const status = formatStatus(connector.status); + const connected = formatDate(connector.connectedAt).padEnd(colWidths[3]); + + // Status has ANSI codes so we handle it separately + console.log(`${type} ${account} ${status.padEnd(colWidths[2] + 10)} ${connected}`); + } + + console.log(); + + return { + outroMessage: `${connectors.length} connector${connectors.length === 1 ? "" : "s"} configured`, + }; +} + +export const connectorsListCommand = new Command("connectors:list") + .description("List all connected OAuth integrations") + .action(async () => { + await runCommand(listConnectorsCommand, { + requireAuth: true, + requireAppConfig: true, + }); + }); diff --git a/src/cli/commands/connectors/remove.ts b/src/cli/commands/connectors/remove.ts new file mode 100644 index 00000000..8b51f3e9 --- /dev/null +++ b/src/cli/commands/connectors/remove.ts @@ -0,0 +1,126 @@ +import { Command } from "commander"; +import { confirm, select, isCancel } from "@clack/prompts"; +import { + listConnectors, + disconnectConnector, + isValidIntegration, + getIntegrationDisplayName, +} from "@core/connectors/index.js"; +import type { IntegrationType, Connector } from "@core/connectors/index.js"; +import { runCommand, runTask } from "../../utils/index.js"; +import type { RunCommandResult } from "../../utils/runCommand.js"; +import { theme } from "../../utils/theme.js"; + +async function promptForConnectorToRemove( + connectors: Connector[] +): Promise { + const options = connectors.map((c) => ({ + value: c.integrationType as IntegrationType, + label: `${getIntegrationDisplayName(c.integrationType)}${c.accountInfo?.email ? ` (${c.accountInfo.email})` : ""}`, + })); + + const selected = await select({ + message: "Select a connector to remove:", + options, + }); + + if (isCancel(selected)) { + return null; + } + + return selected; +} + +export async function removeConnectorCommand( + integrationType?: string +): Promise { + // Fetch current connectors to validate selection + const connectors = await runTask( + "Fetching connectors...", + async () => { + return await listConnectors(); + }, + { + successMessage: "Connectors loaded", + errorMessage: "Failed to fetch connectors", + } + ); + + if (connectors.length === 0) { + return { + outroMessage: "No connectors to remove", + }; + } + + // If no type provided, prompt for selection + let selectedType: IntegrationType; + + if (!integrationType) { + const prompted = await promptForConnectorToRemove(connectors); + if (!prompted) { + return { outroMessage: "Cancelled" }; + } + selectedType = prompted; + } else { + // Validate the provided integration type + if (!isValidIntegration(integrationType)) { + throw new Error(`Invalid connector type: ${integrationType}`); + } + + // Check if this connector is actually connected + const isConnected = connectors.some( + (c) => c.integrationType === integrationType + ); + if (!isConnected) { + throw new Error( + `No ${getIntegrationDisplayName(integrationType)} connector found for this app` + ); + } + + selectedType = integrationType; + } + + const displayName = getIntegrationDisplayName(selectedType); + + // Find connector info for display + const connector = connectors.find((c) => c.integrationType === selectedType); + const accountInfo = connector?.accountInfo?.email + ? ` (${connector.accountInfo.email})` + : ""; + + // Confirm removal + const shouldRemove = await confirm({ + message: `Disconnect ${displayName}${accountInfo}?`, + initialValue: false, + }); + + if (isCancel(shouldRemove) || !shouldRemove) { + return { outroMessage: "Cancelled" }; + } + + // Perform disconnection + await runTask( + `Disconnecting ${displayName}...`, + async () => { + await disconnectConnector(selectedType); + }, + { + successMessage: `${displayName} disconnected`, + errorMessage: `Failed to disconnect ${displayName}`, + } + ); + + return { + outroMessage: `Successfully disconnected ${theme.styles.bold(displayName)}`, + }; +} + +export const connectorsRemoveCommand = new Command("connectors:remove") + .argument("[type]", "Integration type to remove (e.g., slack, notion)") + .description("Disconnect an OAuth integration") + .action(async (type?: string) => { + await runCommand(() => removeConnectorCommand(type), { + requireAuth: true, + requireAppConfig: true, + }); + }); diff --git a/src/cli/program.ts b/src/cli/program.ts index ff62ed05..9f685f48 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 540d9581..19390274 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 00000000..6bb1db0a --- /dev/null +++ b/src/core/connectors/api.ts @@ -0,0 +1,182 @@ +import { getAppClient } from "../clients/index.js"; +import { ConnectorApiError, ConnectorValidationError } from "../errors.js"; +import { + InitiateResponseSchema, + StatusResponseSchema, + ListResponseSchema, + ApiErrorSchema, +} from "./schema.js"; +import type { + InitiateResponse, + StatusResponse, + Connector, +} from "./schema.js"; +import type { IntegrationType } from "./constants.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, + }, + throwHttpErrors: false, + }); + + const json = await response.json(); + + if (!response.ok) { + const errorResult = ApiErrorSchema.safeParse(json); + if (errorResult.success) { + throw new ConnectorApiError(errorResult.data.error); + } + throw new ConnectorApiError( + `Failed to initiate OAuth: ${response.status} ${response.statusText}` + ); + } + + const result = InitiateResponseSchema.safeParse(json); + + if (!result.success) { + throw new ConnectorValidationError( + `Invalid initiate response from server: ${result.error.message}` + ); + } + + return result.data; +} + +/** + * 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, + }, + throwHttpErrors: false, + }); + + const json = await response.json(); + + if (!response.ok) { + const errorResult = ApiErrorSchema.safeParse(json); + if (errorResult.success) { + throw new ConnectorApiError(errorResult.data.error); + } + throw new ConnectorApiError( + `Failed to check OAuth status: ${response.status} ${response.statusText}` + ); + } + + const result = StatusResponseSchema.safeParse(json); + + if (!result.success) { + throw new ConnectorValidationError( + `Invalid status response from server: ${result.error.message}` + ); + } + + return result.data; +} + +/** + * Lists all connected integrations for the current app. + */ +export async function listConnectors(): Promise { + const appClient = getAppClient(); + + const response = await appClient.get("external-auth/list", { + throwHttpErrors: false, + }); + + const json = await response.json(); + + if (!response.ok) { + const errorResult = ApiErrorSchema.safeParse(json); + if (errorResult.success) { + throw new ConnectorApiError(errorResult.data.error); + } + throw new ConnectorApiError( + `Failed to list connectors: ${response.status} ${response.statusText}` + ); + } + + const result = ListResponseSchema.safeParse(json); + + if (!result.success) { + throw new ConnectorValidationError( + `Invalid list response from server: ${result.error.message}` + ); + } + + return result.data.integrations; +} + +/** + * Disconnects (soft delete) a connector integration. + */ +export async function disconnectConnector( + integrationType: IntegrationType +): Promise { + const appClient = getAppClient(); + + const response = await appClient.delete( + `external-auth/integrations/${integrationType}`, + { + throwHttpErrors: false, + } + ); + + if (!response.ok) { + const json = await response.json(); + const errorResult = ApiErrorSchema.safeParse(json); + if (errorResult.success) { + throw new ConnectorApiError(errorResult.data.error); + } + throw new ConnectorApiError( + `Failed to disconnect connector: ${response.status} ${response.statusText}` + ); + } +} + +/** + * Removes (hard delete) a connector integration. + */ +export async function removeConnector( + integrationType: IntegrationType +): Promise { + const appClient = getAppClient(); + + const response = await appClient.delete( + `external-auth/integrations/${integrationType}/remove`, + { + throwHttpErrors: false, + } + ); + + if (!response.ok) { + const json = await response.json(); + const errorResult = ApiErrorSchema.safeParse(json); + if (errorResult.success) { + throw new ConnectorApiError(errorResult.data.error); + } + throw new ConnectorApiError( + `Failed to remove connector: ${response.status} ${response.statusText}` + ); + } +} diff --git a/src/core/connectors/constants.ts b/src/core/connectors/constants.ts new file mode 100644 index 00000000..12e8f27e --- /dev/null +++ b/src/core/connectors/constants.ts @@ -0,0 +1,50 @@ +/** + * 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]; + +/** + * 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", +}; + +export function isValidIntegration(type: string): type is IntegrationType { + return SUPPORTED_INTEGRATIONS.includes(type as IntegrationType); +} + +export function getIntegrationDisplayName(type: string): string { + if (isValidIntegration(type)) { + return INTEGRATION_DISPLAY_NAMES[type]; + } + return type; +} diff --git a/src/core/connectors/index.ts b/src/core/connectors/index.ts new file mode 100644 index 00000000..63be06ab --- /dev/null +++ b/src/core/connectors/index.ts @@ -0,0 +1,35 @@ +// API functions +export { + initiateOAuth, + checkOAuthStatus, + listConnectors, + disconnectConnector, + removeConnector, +} from "./api.js"; + +// Schemas and types +export { + InitiateResponseSchema, + StatusResponseSchema, + ConnectorSchema, + ListResponseSchema, + ApiErrorSchema, +} from "./schema.js"; + +export type { + InitiateResponse, + StatusResponse, + Connector, + ListResponse, + ApiError, +} from "./schema.js"; + +// Constants +export { + SUPPORTED_INTEGRATIONS, + INTEGRATION_DISPLAY_NAMES, + isValidIntegration, + getIntegrationDisplayName, +} from "./constants.js"; + +export type { IntegrationType } from "./constants.js"; diff --git a/src/core/connectors/schema.ts b/src/core/connectors/schema.ts new file mode 100644 index 00000000..f6e4e826 --- /dev/null +++ b/src/core/connectors/schema.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +/** + * Response from POST /api/apps/{app_id}/external-auth/initiate + */ +export const InitiateResponseSchema = z.object({ + redirect_url: z.string().optional(), + connection_id: z.string().optional(), + already_authorized: z.boolean().optional(), + other_user_email: z.string().optional(), + error: z.string().optional(), +}); + +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().optional(), + error: z.string().optional(), +}); + +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().optional(), + account_info: z + .object({ + email: z.string().optional(), + name: z.string().optional(), + }) + .optional(), + }) + .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; + +/** + * Generic API error response + */ +export const ApiErrorSchema = z.object({ + error: z.string(), + detail: z.string().optional(), +}); + +export type ApiError = z.infer; diff --git a/src/core/errors.ts b/src/core/errors.ts index c9a64640..7c0977c3 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -11,3 +11,17 @@ export class AuthValidationError extends Error { this.name = "AuthValidationError"; } } + +export class ConnectorApiError extends Error { + constructor(message: string, public readonly cause?: unknown) { + super(message); + this.name = "ConnectorApiError"; + } +} + +export class ConnectorValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ConnectorValidationError"; + } +} From d8a74188fab530b4d8566e604a2b47834499538c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Jan 2026 07:15:09 +0000 Subject: [PATCH 02/20] fix: Address code review feedback - Transform StatusResponseSchema to camelCase for consistency with other schemas - Remove unused removeConnector function (hard delete not needed for CLI) https://claude.ai/code/session_013jLN9iB2EPcrwsWaUNovsJ --- src/cli/commands/connectors/add.ts | 2 +- src/core/connectors/api.ts | 26 -------------------------- src/core/connectors/index.ts | 1 - src/core/connectors/schema.ts | 16 +++++++++++----- 4 files changed, 12 insertions(+), 33 deletions(-) diff --git a/src/cli/commands/connectors/add.ts b/src/cli/commands/connectors/add.ts index bf9f5d69..358eb741 100644 --- a/src/cli/commands/connectors/add.ts +++ b/src/cli/commands/connectors/add.ts @@ -51,7 +51,7 @@ async function waitForOAuthCompletion( const status = await checkOAuthStatus(integrationType, connectionId); if (status.status === "ACTIVE") { - accountEmail = status.account_email; + accountEmail = status.accountEmail; return true; } diff --git a/src/core/connectors/api.ts b/src/core/connectors/api.ts index 6bb1db0a..b2379531 100644 --- a/src/core/connectors/api.ts +++ b/src/core/connectors/api.ts @@ -154,29 +154,3 @@ export async function disconnectConnector( } } -/** - * Removes (hard delete) a connector integration. - */ -export async function removeConnector( - integrationType: IntegrationType -): Promise { - const appClient = getAppClient(); - - const response = await appClient.delete( - `external-auth/integrations/${integrationType}/remove`, - { - throwHttpErrors: false, - } - ); - - if (!response.ok) { - const json = await response.json(); - const errorResult = ApiErrorSchema.safeParse(json); - if (errorResult.success) { - throw new ConnectorApiError(errorResult.data.error); - } - throw new ConnectorApiError( - `Failed to remove connector: ${response.status} ${response.statusText}` - ); - } -} diff --git a/src/core/connectors/index.ts b/src/core/connectors/index.ts index 63be06ab..35a477a3 100644 --- a/src/core/connectors/index.ts +++ b/src/core/connectors/index.ts @@ -4,7 +4,6 @@ export { checkOAuthStatus, listConnectors, disconnectConnector, - removeConnector, } from "./api.js"; // Schemas and types diff --git a/src/core/connectors/schema.ts b/src/core/connectors/schema.ts index f6e4e826..f6f15e2d 100644 --- a/src/core/connectors/schema.ts +++ b/src/core/connectors/schema.ts @@ -16,11 +16,17 @@ 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().optional(), - error: z.string().optional(), -}); +export const StatusResponseSchema = z + .object({ + status: z.enum(["ACTIVE", "PENDING", "FAILED"]), + account_email: z.string().optional(), + error: z.string().optional(), + }) + .transform((data) => ({ + status: data.status, + accountEmail: data.account_email, + error: data.error, + })); export type StatusResponse = z.infer; From 5790f15bb37cb88ddb26b7b0a6d6b7c3a8c3c8e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Jan 2026 07:16:33 +0000 Subject: [PATCH 03/20] feat: Add --hard flag for permanent connector removal - Re-add removeConnector function for hard delete - Add --hard option to connectors:remove command - Soft delete (disconnect) remains the default behavior - Hard delete permanently removes the connector Usage: base44 connectors:remove slack # Soft delete (disconnect) base44 connectors:remove slack --hard # Hard delete (permanent) https://claude.ai/code/session_013jLN9iB2EPcrwsWaUNovsJ --- src/cli/commands/connectors/remove.ts | 47 ++++++++++++++++++++------- src/core/connectors/api.ts | 28 ++++++++++++++++ src/core/connectors/index.ts | 1 + 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/cli/commands/connectors/remove.ts b/src/cli/commands/connectors/remove.ts index 8b51f3e9..5da49a7a 100644 --- a/src/cli/commands/connectors/remove.ts +++ b/src/cli/commands/connectors/remove.ts @@ -3,6 +3,7 @@ import { confirm, select, isCancel } from "@clack/prompts"; import { listConnectors, disconnectConnector, + removeConnector, isValidIntegration, getIntegrationDisplayName, } from "@core/connectors/index.js"; @@ -11,6 +12,10 @@ import { runCommand, runTask } from "../../utils/index.js"; import type { RunCommandResult } from "../../utils/runCommand.js"; import { theme } from "../../utils/theme.js"; +interface RemoveOptions { + hard?: boolean; +} + async function promptForConnectorToRemove( connectors: Connector[] ): Promise { @@ -32,8 +37,11 @@ async function promptForConnectorToRemove( } export async function removeConnectorCommand( - integrationType?: string + integrationType?: string, + options: RemoveOptions = {} ): Promise { + const isHardDelete = options.hard === true; + // Fetch current connectors to validate selection const connectors = await runTask( "Fetching connectors...", @@ -88,9 +96,10 @@ export async function removeConnectorCommand( ? ` (${connector.accountInfo.email})` : ""; - // Confirm removal + // Confirm removal with appropriate message + const actionWord = isHardDelete ? "Permanently remove" : "Disconnect"; const shouldRemove = await confirm({ - message: `Disconnect ${displayName}${accountInfo}?`, + message: `${actionWord} ${displayName}${accountInfo}?`, initialValue: false, }); @@ -98,28 +107,44 @@ export async function removeConnectorCommand( return { outroMessage: "Cancelled" }; } - // Perform disconnection + // Perform disconnection or removal + const taskMessage = isHardDelete + ? `Removing ${displayName}...` + : `Disconnecting ${displayName}...`; + const successMessage = isHardDelete + ? `${displayName} removed` + : `${displayName} disconnected`; + const errorMessage = isHardDelete + ? `Failed to remove ${displayName}` + : `Failed to disconnect ${displayName}`; + await runTask( - `Disconnecting ${displayName}...`, + taskMessage, async () => { - await disconnectConnector(selectedType); + if (isHardDelete) { + await removeConnector(selectedType); + } else { + await disconnectConnector(selectedType); + } }, { - successMessage: `${displayName} disconnected`, - errorMessage: `Failed to disconnect ${displayName}`, + successMessage, + errorMessage, } ); + const outroAction = isHardDelete ? "removed" : "disconnected"; return { - outroMessage: `Successfully disconnected ${theme.styles.bold(displayName)}`, + outroMessage: `Successfully ${outroAction} ${theme.styles.bold(displayName)}`, }; } export const connectorsRemoveCommand = new Command("connectors:remove") .argument("[type]", "Integration type to remove (e.g., slack, notion)") + .option("--hard", "Permanently remove the connector (cannot be undone)") .description("Disconnect an OAuth integration") - .action(async (type?: string) => { - await runCommand(() => removeConnectorCommand(type), { + .action(async (type: string | undefined, options: RemoveOptions) => { + await runCommand(() => removeConnectorCommand(type, options), { requireAuth: true, requireAppConfig: true, }); diff --git a/src/core/connectors/api.ts b/src/core/connectors/api.ts index b2379531..fb236036 100644 --- a/src/core/connectors/api.ts +++ b/src/core/connectors/api.ts @@ -154,3 +154,31 @@ export async function disconnectConnector( } } +/** + * 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(); + + const response = await appClient.delete( + `external-auth/integrations/${integrationType}/remove`, + { + throwHttpErrors: false, + } + ); + + if (!response.ok) { + const json = await response.json(); + const errorResult = ApiErrorSchema.safeParse(json); + if (errorResult.success) { + throw new ConnectorApiError(errorResult.data.error); + } + throw new ConnectorApiError( + `Failed to remove connector: ${response.status} ${response.statusText}` + ); + } +} + diff --git a/src/core/connectors/index.ts b/src/core/connectors/index.ts index 35a477a3..63be06ab 100644 --- a/src/core/connectors/index.ts +++ b/src/core/connectors/index.ts @@ -4,6 +4,7 @@ export { checkOAuthStatus, listConnectors, disconnectConnector, + removeConnector, } from "./api.js"; // Schemas and types From 276f74cee6b2f59419f604de6f67c11666349cb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Jan 2026 07:27:56 +0000 Subject: [PATCH 04/20] refactor: Simplify connectors list to use simple format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace table format with simple list: - ● Slack - user@example.com - ○ Notion (disconnected) This removes column width calculations and ANSI padding complexity. https://claude.ai/code/session_013jLN9iB2EPcrwsWaUNovsJ --- src/cli/commands/connectors/list.ts | 61 ++++++----------------------- 1 file changed, 11 insertions(+), 50 deletions(-) diff --git a/src/cli/commands/connectors/list.ts b/src/cli/commands/connectors/list.ts index 1b64e8e5..d8b9f4e8 100644 --- a/src/cli/commands/connectors/list.ts +++ b/src/cli/commands/connectors/list.ts @@ -1,38 +1,21 @@ import { Command } from "commander"; import { log } from "@clack/prompts"; import { listConnectors, getIntegrationDisplayName } from "@core/connectors/index.js"; +import type { Connector } from "@core/connectors/index.js"; import { runCommand, runTask } from "../../utils/index.js"; import type { RunCommandResult } from "../../utils/runCommand.js"; import { theme } from "../../utils/theme.js"; -function formatDate(dateString?: string): string { - if (!dateString) { - return "-"; - } - try { - const date = new Date(dateString); - return date.toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); - } catch { - return dateString; - } -} +function formatConnectorLine(connector: Connector): string { + const name = getIntegrationDisplayName(connector.integrationType); + const account = connector.accountInfo?.email || connector.accountInfo?.name; + const status = connector.status.toLowerCase(); -function formatStatus(status: string): string { - const normalized = status.toLowerCase(); - if (normalized === "active" || normalized === "connected") { - return theme.colors.success("● active"); - } - if (normalized === "expired") { - return theme.colors.warning("● expired"); - } - if (normalized === "failed" || normalized === "disconnected") { - return theme.colors.error("● disconnected"); - } - return status; + const bullet = status === "active" ? theme.colors.success("●") : theme.colors.error("○"); + const accountPart = account ? ` - ${account}` : ""; + const statusPart = status !== "active" ? theme.styles.dim(` (${status})`) : ""; + + return `${bullet} ${name}${accountPart}${statusPart}`; } export async function listConnectorsCommand(): Promise { @@ -53,32 +36,10 @@ export async function listConnectorsCommand(): Promise { return { outroMessage: "" }; } - // Display as formatted list - console.log(); - console.log(theme.styles.bold("Connected Integrations:")); console.log(); - - // Table header - const headers = ["Type", "Account", "Status", "Connected"]; - const colWidths = [20, 30, 15, 15]; - - const headerRow = headers - .map((h, i) => h.padEnd(colWidths[i])) - .join(" "); - console.log(theme.styles.dim(headerRow)); - console.log(theme.styles.dim("─".repeat(headerRow.length))); - - // Table rows for (const connector of connectors) { - const type = getIntegrationDisplayName(connector.integrationType).padEnd(colWidths[0]); - const account = (connector.accountInfo?.email || connector.accountInfo?.name || "-").padEnd(colWidths[1]); - const status = formatStatus(connector.status); - const connected = formatDate(connector.connectedAt).padEnd(colWidths[3]); - - // Status has ANSI codes so we handle it separately - console.log(`${type} ${account} ${status.padEnd(colWidths[2] + 10)} ${connected}`); + console.log(formatConnectorLine(connector)); } - console.log(); return { From 81b48bc275799f74c2ac8aa532634a64aa0aadd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Jan 2026 12:51:02 +0000 Subject: [PATCH 05/20] docs: Add connectors commands to README Document the new OAuth connectors management commands: - connectors:add - Connect integrations via OAuth - connectors:list - List connected integrations - connectors:remove - Disconnect/remove integrations https://claude.ai/code/session_013jLN9iB2EPcrwsWaUNovsJ --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index cc1f176b..ecfeb388 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,10 @@ The CLI will guide you through project setup. For step-by-step tutorials, see th | [`entities push`](https://docs.base44.com/developers/references/cli/commands/entities-push) | Push local entity schemas to Base44 | | [`functions deploy`](https://docs.base44.com/developers/references/cli/commands/functions-deploy) | Deploy local functions to Base44 | | [`site deploy`](https://docs.base44.com/developers/references/cli/commands/site-deploy) | Deploy built site files to Base44 hosting | +| `connectors add [type]` | Connect an OAuth integration | +| `connectors list` | List all connected integrations | +| `connectors push` | Sync connectors with backend (connect new, remove missing) | +| `connectors remove [type]` | Disconnect an integration | From 82df3518f0063f569de2fb7e4888e25c3be1cea2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Jan 2026 14:29:45 +0000 Subject: [PATCH 06/20] feat: Add local connectors.jsonc config file support Connectors are now tracked in a local `base44/connectors.jsonc` file, similar to how entities are managed. This enables: - Team collaboration: commit connector config to git - Declarative setup: define required integrations in code - Status tracking: see which connectors need to be connected Changes: - Add connectors config module for reading/writing local file - connectors:add now saves to connectors.jsonc after OAuth - connectors:list shows both local and backend state - connectors:remove updates both local file and backend - New connectors:push command to connect pending integrations Example connectors.jsonc: ```json { "slack": {}, "googlecalendar": { "scopes": ["calendar.readonly"] } } ``` https://claude.ai/code/session_013jLN9iB2EPcrwsWaUNovsJ --- src/cli/commands/connectors/add.ts | 8 +- src/cli/commands/connectors/list.ts | 119 +++++++++++++--- src/cli/commands/connectors/push.ts | 192 ++++++++++++++++++++++++++ src/cli/commands/connectors/remove.ts | 139 +++++++++++++------ src/core/connectors/config.ts | 176 +++++++++++++++++++++++ src/core/connectors/index.ts | 13 ++ 6 files changed, 589 insertions(+), 58 deletions(-) create mode 100644 src/cli/commands/connectors/push.ts create mode 100644 src/core/connectors/config.ts diff --git a/src/cli/commands/connectors/add.ts b/src/cli/commands/connectors/add.ts index 358eb741..40e578a8 100644 --- a/src/cli/commands/connectors/add.ts +++ b/src/cli/commands/connectors/add.ts @@ -5,6 +5,7 @@ import open from "open"; import { initiateOAuth, checkOAuthStatus, + addLocalConnector, SUPPORTED_INTEGRATIONS, isValidIntegration, getIntegrationDisplayName, @@ -124,8 +125,10 @@ export async function addConnector( // Check if already authorized if (initiateResponse.already_authorized) { + // Still add to local config file + await addLocalConnector(selectedType); return { - outroMessage: `Already connected to ${theme.styles.bold(displayName)}`, + outroMessage: `Already connected to ${theme.styles.bold(displayName)} (added to connectors.jsonc)`, }; } @@ -155,6 +158,9 @@ export async function addConnector( throw new Error(result.error || "Authorization failed"); } + // Add to local config file after successful OAuth + await addLocalConnector(selectedType); + const accountInfo = result.accountEmail ? ` as ${theme.styles.bold(result.accountEmail)}` : ""; diff --git a/src/cli/commands/connectors/list.ts b/src/cli/commands/connectors/list.ts index d8b9f4e8..9f856736 100644 --- a/src/cli/commands/connectors/list.ts +++ b/src/cli/commands/connectors/list.ts @@ -1,28 +1,103 @@ import { Command } from "commander"; import { log } from "@clack/prompts"; -import { listConnectors, getIntegrationDisplayName } from "@core/connectors/index.js"; -import type { Connector } from "@core/connectors/index.js"; +import { + listConnectors, + readLocalConnectors, + getIntegrationDisplayName, +} from "@core/connectors/index.js"; +import type { Connector, LocalConnector } from "@core/connectors/index.js"; import { runCommand, runTask } from "../../utils/index.js"; import type { RunCommandResult } from "../../utils/runCommand.js"; import { theme } from "../../utils/theme.js"; -function formatConnectorLine(connector: Connector): string { - const name = getIntegrationDisplayName(connector.integrationType); - const account = connector.accountInfo?.email || connector.accountInfo?.name; - const status = connector.status.toLowerCase(); +interface MergedConnector { + type: string; + displayName: string; + inLocal: boolean; + inBackend: boolean; + status?: string; + accountEmail?: string; +} + +function mergeConnectors( + local: LocalConnector[], + backend: Connector[] +): MergedConnector[] { + const merged = new Map(); + + // Add local connectors + for (const connector of local) { + merged.set(connector.type, { + type: connector.type, + displayName: getIntegrationDisplayName(connector.type), + inLocal: true, + inBackend: false, + }); + } - const bullet = status === "active" ? theme.colors.success("●") : theme.colors.error("○"); - const accountPart = account ? ` - ${account}` : ""; - const statusPart = status !== "active" ? theme.styles.dim(` (${status})`) : ""; + // Add/update with backend connectors + for (const connector of backend) { + const existing = merged.get(connector.integrationType); + if (existing) { + existing.inBackend = true; + existing.status = connector.status; + existing.accountEmail = connector.accountInfo?.email || connector.accountInfo?.name; + } else { + merged.set(connector.integrationType, { + type: connector.integrationType, + displayName: getIntegrationDisplayName(connector.integrationType), + inLocal: false, + inBackend: true, + status: connector.status, + accountEmail: connector.accountInfo?.email || connector.accountInfo?.name, + }); + } + } - return `${bullet} ${name}${accountPart}${statusPart}`; + return Array.from(merged.values()); +} + +function formatConnectorLine(connector: MergedConnector): string { + const { displayName, inLocal, inBackend, status, accountEmail } = connector; + + // Determine state + const isConnected = inBackend && status?.toLowerCase() === "active"; + const isPending = inLocal && !inBackend; + const isOrphaned = inBackend && !inLocal; + + // Build the line + let bullet: string; + let statusText = ""; + + if (isConnected) { + bullet = theme.colors.success("●"); + if (accountEmail) { + statusText = ` - ${accountEmail}`; + } + } else if (isPending) { + bullet = theme.colors.warning("○"); + statusText = theme.styles.dim(" (not connected)"); + } else if (isOrphaned) { + bullet = theme.colors.error("○"); + statusText = theme.styles.dim(" (not in local config)"); + } else { + bullet = theme.colors.error("○"); + statusText = theme.styles.dim(` (${status || "disconnected"})`); + } + + return `${bullet} ${displayName}${statusText}`; } export async function listConnectorsCommand(): Promise { - const connectors = await runTask( + // Fetch both local and backend connectors in parallel + const [localConnectors, backendConnectors] = await runTask( "Fetching connectors...", async () => { - return await listConnectors(); + const [local, backend] = await Promise.all([ + readLocalConnectors().catch(() => [] as LocalConnector[]), + listConnectors().catch(() => [] as Connector[]), + ]); + return [local, backend] as const; }, { successMessage: "Connectors loaded", @@ -30,21 +105,31 @@ export async function listConnectorsCommand(): Promise { } ); - if (connectors.length === 0) { + const merged = mergeConnectors(localConnectors, backendConnectors); + + if (merged.length === 0) { log.info("No connectors configured for this app."); log.info(`Run ${theme.styles.bold("base44 connectors:add")} to connect an integration.`); return { outroMessage: "" }; } console.log(); - for (const connector of connectors) { + for (const connector of merged) { console.log(formatConnectorLine(connector)); } console.log(); - return { - outroMessage: `${connectors.length} connector${connectors.length === 1 ? "" : "s"} configured`, - }; + // Summary + const connected = merged.filter((c) => c.inBackend && c.status?.toLowerCase() === "active").length; + const pending = merged.filter((c) => c.inLocal && !c.inBackend).length; + + let summary = `${connected} connected`; + if (pending > 0) { + summary += `, ${pending} pending`; + log.info(`Run ${theme.styles.bold("base44 connectors:push")} to connect pending integrations.`); + } + + return { outroMessage: summary }; } export const connectorsListCommand = new Command("connectors:list") diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts new file mode 100644 index 00000000..b99fc732 --- /dev/null +++ b/src/cli/commands/connectors/push.ts @@ -0,0 +1,192 @@ +import { Command } from "commander"; +import { log, confirm, isCancel } from "@clack/prompts"; +import pWaitFor from "p-wait-for"; +import open from "open"; +import { + listConnectors, + readLocalConnectors, + initiateOAuth, + checkOAuthStatus, + getIntegrationDisplayName, +} from "@core/connectors/index.js"; +import type { IntegrationType, Connector, LocalConnector } from "@core/connectors/index.js"; +import { runCommand, runTask } from "../../utils/index.js"; +import type { RunCommandResult } from "../../utils/runCommand.js"; +import { theme } from "../../utils/theme.js"; + +const POLL_INTERVAL_MS = 2000; +const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +interface PendingConnector { + type: IntegrationType; + displayName: string; + scopes?: string[]; +} + +function findPendingConnectors( + local: LocalConnector[], + backend: Connector[] +): PendingConnector[] { + const connectedTypes = new Set( + backend + .filter((c) => c.status.toLowerCase() === "active") + .map((c) => c.integrationType) + ); + + return local + .filter((c) => !connectedTypes.has(c.type)) + .map((c) => ({ + type: c.type, + displayName: getIntegrationDisplayName(c.type), + scopes: c.scopes, + })); +} + +async function connectSingleConnector( + connector: PendingConnector +): Promise<{ success: boolean; accountEmail?: string; error?: string }> { + const { type, displayName, scopes } = connector; + + // Initiate OAuth + const initiateResponse = await initiateOAuth(type, scopes || null); + + // Check if already authorized + if (initiateResponse.already_authorized) { + return { success: true }; + } + + // Check if connected by different user + if (initiateResponse.error === "different_user") { + return { + success: false, + error: `Already connected by ${initiateResponse.other_user_email}`, + }; + } + + // Validate we have required fields + if (!initiateResponse.redirect_url || !initiateResponse.connection_id) { + return { success: false, error: "Invalid response from server" }; + } + + // Open browser for OAuth + log.info(`Opening browser for ${displayName} authorization...`); + await open(initiateResponse.redirect_url); + + // Poll for completion + let accountEmail: string | undefined; + + try { + await pWaitFor( + async () => { + const status = await checkOAuthStatus(type, initiateResponse.connection_id!); + + if (status.status === "ACTIVE") { + accountEmail = status.accountEmail; + return true; + } + + if (status.status === "FAILED") { + throw new Error(status.error || "Authorization failed"); + } + + return false; + }, + { + interval: POLL_INTERVAL_MS, + timeout: 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" }; + } + return { success: false, error: err instanceof Error ? err.message : "Unknown error" }; + } +} + +export async function pushConnectorsCommand(): Promise { + // Fetch both local and backend connectors + const [localConnectors, backendConnectors] = await runTask( + "Checking connector status...", + async () => { + const [local, backend] = await Promise.all([ + readLocalConnectors(), + listConnectors().catch(() => [] as Connector[]), + ]); + return [local, backend] as const; + }, + { + successMessage: "Status checked", + errorMessage: "Failed to check status", + } + ); + + if (localConnectors.length === 0) { + log.info("No connectors defined in connectors.jsonc"); + log.info(`Run ${theme.styles.bold("base44 connectors:add")} to add a connector.`); + return { outroMessage: "" }; + } + + const pending = findPendingConnectors(localConnectors, backendConnectors); + + if (pending.length === 0) { + return { outroMessage: "All connectors are already connected" }; + } + + // Show what needs to be connected + console.log(); + log.info(`${pending.length} connector${pending.length === 1 ? "" : "s"} need${pending.length === 1 ? "s" : ""} to be connected:`); + for (const c of pending) { + console.log(` ${theme.colors.warning("○")} ${c.displayName}`); + } + console.log(); + + // Confirm + const shouldProceed = await confirm({ + message: `Connect ${pending.length} integration${pending.length === 1 ? "" : "s"}?`, + initialValue: true, + }); + + if (isCancel(shouldProceed) || !shouldProceed) { + return { outroMessage: "Cancelled" }; + } + + // Connect each one + let connected = 0; + let failed = 0; + + for (const connector of pending) { + console.log(); + log.info(`Connecting ${theme.styles.bold(connector.displayName)}...`); + + const result = await connectSingleConnector(connector); + + if (result.success) { + const accountInfo = result.accountEmail ? ` as ${result.accountEmail}` : ""; + log.success(`${connector.displayName} connected${accountInfo}`); + connected++; + } else { + log.error(`${connector.displayName} failed: ${result.error}`); + failed++; + } + } + + console.log(); + + if (failed === 0) { + return { outroMessage: `Successfully connected ${connected} integration${connected === 1 ? "" : "s"}` }; + } + + return { outroMessage: `Connected ${connected}, failed ${failed}` }; +} + +export const connectorsPushCommand = new Command("connectors:push") + .description("Connect all pending integrations from connectors.jsonc") + .action(async () => { + await runCommand(pushConnectorsCommand, { + requireAuth: true, + requireAppConfig: true, + }); + }); diff --git a/src/cli/commands/connectors/remove.ts b/src/cli/commands/connectors/remove.ts index 5da49a7a..2c579111 100644 --- a/src/cli/commands/connectors/remove.ts +++ b/src/cli/commands/connectors/remove.ts @@ -2,12 +2,14 @@ import { Command } from "commander"; import { confirm, select, isCancel } from "@clack/prompts"; import { listConnectors, + readLocalConnectors, + removeLocalConnector, disconnectConnector, removeConnector, isValidIntegration, getIntegrationDisplayName, } from "@core/connectors/index.js"; -import type { IntegrationType, Connector } from "@core/connectors/index.js"; +import type { IntegrationType, Connector, LocalConnector } from "@core/connectors/index.js"; import { runCommand, runTask } from "../../utils/index.js"; import type { RunCommandResult } from "../../utils/runCommand.js"; import { theme } from "../../utils/theme.js"; @@ -16,13 +18,69 @@ interface RemoveOptions { hard?: boolean; } +interface MergedConnector { + type: IntegrationType; + displayName: string; + inLocal: boolean; + inBackend: boolean; + accountEmail?: string; +} + +function mergeConnectorsForRemoval( + local: LocalConnector[], + backend: Connector[] +): MergedConnector[] { + const merged = new Map(); + + // Add local connectors + for (const connector of local) { + merged.set(connector.type, { + type: connector.type, + displayName: getIntegrationDisplayName(connector.type), + inLocal: true, + inBackend: false, + }); + } + + // Add/update with backend connectors + for (const connector of backend) { + if (!isValidIntegration(connector.integrationType)) { + continue; + } + + const existing = merged.get(connector.integrationType); + if (existing) { + existing.inBackend = true; + existing.accountEmail = connector.accountInfo?.email || connector.accountInfo?.name; + } else { + merged.set(connector.integrationType, { + type: connector.integrationType, + displayName: getIntegrationDisplayName(connector.integrationType), + inLocal: false, + inBackend: true, + accountEmail: connector.accountInfo?.email || connector.accountInfo?.name, + }); + } + } + + return Array.from(merged.values()); +} + async function promptForConnectorToRemove( - connectors: Connector[] + connectors: MergedConnector[] ): Promise { - const options = connectors.map((c) => ({ - value: c.integrationType as IntegrationType, - label: `${getIntegrationDisplayName(c.integrationType)}${c.accountInfo?.email ? ` (${c.accountInfo.email})` : ""}`, - })); + const options = connectors.map((c) => { + let label = c.displayName; + if (c.accountEmail) { + label += ` (${c.accountEmail})`; + } else if (c.inLocal && !c.inBackend) { + label += " (not connected)"; + } + return { + value: c.type, + label, + }; + }); const selected = await select({ message: "Select a connector to remove:", @@ -42,11 +100,15 @@ export async function removeConnectorCommand( ): Promise { const isHardDelete = options.hard === true; - // Fetch current connectors to validate selection - const connectors = await runTask( + // Fetch both local and backend connectors + const [localConnectors, backendConnectors] = await runTask( "Fetching connectors...", async () => { - return await listConnectors(); + const [local, backend] = await Promise.all([ + readLocalConnectors().catch(() => [] as LocalConnector[]), + listConnectors().catch(() => [] as Connector[]), + ]); + return [local, backend] as const; }, { successMessage: "Connectors loaded", @@ -54,7 +116,9 @@ export async function removeConnectorCommand( } ); - if (connectors.length === 0) { + const merged = mergeConnectorsForRemoval(localConnectors, backendConnectors); + + if (merged.length === 0) { return { outroMessage: "No connectors to remove", }; @@ -62,26 +126,25 @@ export async function removeConnectorCommand( // If no type provided, prompt for selection let selectedType: IntegrationType; + let selectedConnector: MergedConnector | undefined; if (!integrationType) { - const prompted = await promptForConnectorToRemove(connectors); + const prompted = await promptForConnectorToRemove(merged); if (!prompted) { return { outroMessage: "Cancelled" }; } selectedType = prompted; + selectedConnector = merged.find((c) => c.type === selectedType); } else { // Validate the provided integration type if (!isValidIntegration(integrationType)) { throw new Error(`Invalid connector type: ${integrationType}`); } - // Check if this connector is actually connected - const isConnected = connectors.some( - (c) => c.integrationType === integrationType - ); - if (!isConnected) { + selectedConnector = merged.find((c) => c.type === integrationType); + if (!selectedConnector) { throw new Error( - `No ${getIntegrationDisplayName(integrationType)} connector found for this app` + `No ${getIntegrationDisplayName(integrationType)} connector found` ); } @@ -89,15 +152,12 @@ export async function removeConnectorCommand( } const displayName = getIntegrationDisplayName(selectedType); - - // Find connector info for display - const connector = connectors.find((c) => c.integrationType === selectedType); - const accountInfo = connector?.accountInfo?.email - ? ` (${connector.accountInfo.email})` + const accountInfo = selectedConnector?.accountEmail + ? ` (${selectedConnector.accountEmail})` : ""; // Confirm removal with appropriate message - const actionWord = isHardDelete ? "Permanently remove" : "Disconnect"; + const actionWord = isHardDelete ? "Permanently remove" : "Remove"; const shouldRemove = await confirm({ message: `${actionWord} ${displayName}${accountInfo}?`, initialValue: false, @@ -107,42 +167,41 @@ export async function removeConnectorCommand( return { outroMessage: "Cancelled" }; } - // Perform disconnection or removal + // Perform removal const taskMessage = isHardDelete ? `Removing ${displayName}...` - : `Disconnecting ${displayName}...`; - const successMessage = isHardDelete - ? `${displayName} removed` - : `${displayName} disconnected`; - const errorMessage = isHardDelete - ? `Failed to remove ${displayName}` - : `Failed to disconnect ${displayName}`; + : `Removing ${displayName}...`; await runTask( taskMessage, async () => { - if (isHardDelete) { - await removeConnector(selectedType); - } else { - await disconnectConnector(selectedType); + // Remove from backend if it exists there + if (selectedConnector?.inBackend) { + if (isHardDelete) { + await removeConnector(selectedType); + } else { + await disconnectConnector(selectedType); + } } + + // Remove from local config + await removeLocalConnector(selectedType); }, { - successMessage, - errorMessage, + successMessage: `${displayName} removed`, + errorMessage: `Failed to remove ${displayName}`, } ); - const outroAction = isHardDelete ? "removed" : "disconnected"; return { - outroMessage: `Successfully ${outroAction} ${theme.styles.bold(displayName)}`, + outroMessage: `Successfully removed ${theme.styles.bold(displayName)}`, }; } export const connectorsRemoveCommand = new Command("connectors:remove") .argument("[type]", "Integration type to remove (e.g., slack, notion)") .option("--hard", "Permanently remove the connector (cannot be undone)") - .description("Disconnect an OAuth integration") + .description("Remove an OAuth integration") .action(async (type: string | undefined, options: RemoveOptions) => { await runCommand(() => removeConnectorCommand(type, options), { requireAuth: true, diff --git a/src/core/connectors/config.ts b/src/core/connectors/config.ts new file mode 100644 index 00000000..c952dfa5 --- /dev/null +++ b/src/core/connectors/config.ts @@ -0,0 +1,176 @@ +import { join } from "node:path"; +import { globby } from "globby"; +import { z } from "zod"; +import { readJsonFile, writeJsonFile } from "../utils/fs.js"; +import { PROJECT_SUBDIR, CONFIG_FILE_EXTENSION_GLOB } from "../consts.js"; +import { isValidIntegration } from "./constants.js"; +import type { IntegrationType } from "./constants.js"; + +/** + * Schema for a single connector configuration + */ +export const ConnectorConfigSchema = z.object({ + scopes: z.array(z.string()).optional(), +}); + +export type ConnectorConfig = z.infer; + +/** + * Schema for the connectors.jsonc file + */ +export const ConnectorsFileSchema = z.record(z.string(), ConnectorConfigSchema); + +export type ConnectorsFile = z.infer; + +/** + * Parsed connector with its type + */ +export interface LocalConnector { + type: IntegrationType; + scopes?: string[]; +} + +const CONNECTORS_FILE_PATTERNS = [ + `${PROJECT_SUBDIR}/connectors.${CONFIG_FILE_EXTENSION_GLOB}`, + `connectors.${CONFIG_FILE_EXTENSION_GLOB}`, +]; + +/** + * Find the connectors config file in the project + */ +export async function findConnectorsFile( + startPath?: string +): Promise { + const cwd = startPath || process.cwd(); + + const files = await globby(CONNECTORS_FILE_PATTERNS, { + cwd, + absolute: true, + }); + + return files[0] ?? null; +} + +/** + * Get the default path for the connectors file + */ +export function getDefaultConnectorsPath(projectRoot?: string): string { + const root = projectRoot || process.cwd(); + return join(root, PROJECT_SUBDIR, "connectors.jsonc"); +} + +/** + * Read all connectors from the local config file + */ +export async function readLocalConnectors( + projectRoot?: string +): Promise { + const filePath = await findConnectorsFile(projectRoot); + + if (!filePath) { + return []; + } + + const parsed = await readJsonFile(filePath); + const result = ConnectorsFileSchema.safeParse(parsed); + + if (!result.success) { + throw new Error( + `Invalid connectors configuration: ${result.error.message}` + ); + } + + const connectors: LocalConnector[] = []; + + for (const [type, config] of Object.entries(result.data)) { + if (!isValidIntegration(type)) { + throw new Error(`Unknown connector type: ${type}`); + } + + connectors.push({ + type, + scopes: config.scopes, + }); + } + + return connectors; +} + +/** + * Write connectors to the local config file + */ +export async function writeLocalConnectors( + connectors: LocalConnector[], + projectRoot?: string +): Promise { + let filePath = await findConnectorsFile(projectRoot); + + if (!filePath) { + filePath = getDefaultConnectorsPath(projectRoot); + } + + const data: ConnectorsFile = {}; + + for (const connector of connectors) { + data[connector.type] = { + ...(connector.scopes && { scopes: connector.scopes }), + }; + } + + await writeJsonFile(filePath, data); + + return filePath; +} + +/** + * Add a connector to the local config file + */ +export async function addLocalConnector( + type: IntegrationType, + scopes?: string[], + projectRoot?: string +): Promise { + const connectors = await readLocalConnectors(projectRoot); + + // Check if already exists + const existing = connectors.find((c) => c.type === type); + if (existing) { + // Update scopes if provided + if (scopes) { + existing.scopes = scopes; + } + } else { + connectors.push({ type, scopes }); + } + + return await writeLocalConnectors(connectors, projectRoot); +} + +/** + * Remove a connector from the local config file + */ +export async function removeLocalConnector( + type: IntegrationType, + projectRoot?: string +): Promise { + const connectors = await readLocalConnectors(projectRoot); + const filtered = connectors.filter((c) => c.type !== type); + + if (filtered.length === connectors.length) { + // Connector wasn't in the file + return null; + } + + return await writeLocalConnectors(filtered, projectRoot); +} + +/** + * Check if a connector exists in the local config file + */ +export async function hasLocalConnector( + type: IntegrationType, + projectRoot?: string +): Promise { + const connectors = await readLocalConnectors(projectRoot); + return connectors.some((c) => c.type === type); +} diff --git a/src/core/connectors/index.ts b/src/core/connectors/index.ts index 63be06ab..851239ae 100644 --- a/src/core/connectors/index.ts +++ b/src/core/connectors/index.ts @@ -7,6 +7,19 @@ export { removeConnector, } from "./api.js"; +// Local config functions +export { + readLocalConnectors, + writeLocalConnectors, + addLocalConnector, + removeLocalConnector, + hasLocalConnector, + findConnectorsFile, + getDefaultConnectorsPath, +} from "./config.js"; + +export type { LocalConnector, ConnectorConfig, ConnectorsFile } from "./config.js"; + // Schemas and types export { InitiateResponseSchema, From 31d0fcc062bfee390fc115683104d4d07ecc40b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Jan 2026 19:44:30 +0000 Subject: [PATCH 07/20] fix: Handle null values in API responses Change Zod schemas to use .nullish() instead of .optional() to handle API responses that return null for optional fields. https://claude.ai/code/session_013jLN9iB2EPcrwsWaUNovsJ --- src/cli/commands/connectors/add.ts | 2 +- src/cli/commands/connectors/list.ts | 5 +++-- src/cli/commands/connectors/push.ts | 2 +- src/cli/commands/connectors/remove.ts | 5 +++-- src/core/connectors/schema.ts | 24 ++++++++++++------------ 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/cli/commands/connectors/add.ts b/src/cli/commands/connectors/add.ts index 40e578a8..5e26b410 100644 --- a/src/cli/commands/connectors/add.ts +++ b/src/cli/commands/connectors/add.ts @@ -52,7 +52,7 @@ async function waitForOAuthCompletion( const status = await checkOAuthStatus(integrationType, connectionId); if (status.status === "ACTIVE") { - accountEmail = status.accountEmail; + accountEmail = status.accountEmail ?? undefined; return true; } diff --git a/src/cli/commands/connectors/list.ts b/src/cli/commands/connectors/list.ts index 9f856736..93f793be 100644 --- a/src/cli/commands/connectors/list.ts +++ b/src/cli/commands/connectors/list.ts @@ -38,10 +38,11 @@ function mergeConnectors( // Add/update with backend connectors for (const connector of backend) { const existing = merged.get(connector.integrationType); + const accountEmail = (connector.accountInfo?.email || connector.accountInfo?.name) ?? undefined; if (existing) { existing.inBackend = true; existing.status = connector.status; - existing.accountEmail = connector.accountInfo?.email || connector.accountInfo?.name; + existing.accountEmail = accountEmail; } else { merged.set(connector.integrationType, { type: connector.integrationType, @@ -49,7 +50,7 @@ function mergeConnectors( inLocal: false, inBackend: true, status: connector.status, - accountEmail: connector.accountInfo?.email || connector.accountInfo?.name, + accountEmail, }); } } diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts index b99fc732..864748c8 100644 --- a/src/cli/commands/connectors/push.ts +++ b/src/cli/commands/connectors/push.ts @@ -81,7 +81,7 @@ async function connectSingleConnector( const status = await checkOAuthStatus(type, initiateResponse.connection_id!); if (status.status === "ACTIVE") { - accountEmail = status.accountEmail; + accountEmail = status.accountEmail ?? undefined; return true; } diff --git a/src/cli/commands/connectors/remove.ts b/src/cli/commands/connectors/remove.ts index 2c579111..ee802df6 100644 --- a/src/cli/commands/connectors/remove.ts +++ b/src/cli/commands/connectors/remove.ts @@ -49,16 +49,17 @@ function mergeConnectorsForRemoval( } const existing = merged.get(connector.integrationType); + const accountEmail = (connector.accountInfo?.email || connector.accountInfo?.name) ?? undefined; if (existing) { existing.inBackend = true; - existing.accountEmail = connector.accountInfo?.email || connector.accountInfo?.name; + existing.accountEmail = accountEmail; } else { merged.set(connector.integrationType, { type: connector.integrationType, displayName: getIntegrationDisplayName(connector.integrationType), inLocal: false, inBackend: true, - accountEmail: connector.accountInfo?.email || connector.accountInfo?.name, + accountEmail, }); } } diff --git a/src/core/connectors/schema.ts b/src/core/connectors/schema.ts index f6f15e2d..0bc75c9d 100644 --- a/src/core/connectors/schema.ts +++ b/src/core/connectors/schema.ts @@ -4,11 +4,11 @@ import { z } from "zod"; * Response from POST /api/apps/{app_id}/external-auth/initiate */ export const InitiateResponseSchema = z.object({ - redirect_url: z.string().optional(), - connection_id: z.string().optional(), - already_authorized: z.boolean().optional(), - other_user_email: z.string().optional(), - error: z.string().optional(), + 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(), }); export type InitiateResponse = z.infer; @@ -19,8 +19,8 @@ export type InitiateResponse = z.infer; export const StatusResponseSchema = z .object({ status: z.enum(["ACTIVE", "PENDING", "FAILED"]), - account_email: z.string().optional(), - error: z.string().optional(), + account_email: z.string().nullish(), + error: z.string().nullish(), }) .transform((data) => ({ status: data.status, @@ -37,13 +37,13 @@ export const ConnectorSchema = z .object({ integration_type: z.string(), status: z.string(), - connected_at: z.string().optional(), + connected_at: z.string().nullish(), account_info: z .object({ - email: z.string().optional(), - name: z.string().optional(), + email: z.string().nullish(), + name: z.string().nullish(), }) - .optional(), + .nullish(), }) .transform((data) => ({ integrationType: data.integration_type, @@ -68,7 +68,7 @@ export type ListResponse = z.infer; */ export const ApiErrorSchema = z.object({ error: z.string(), - detail: z.string().optional(), + detail: z.string().nullish(), }); export type ApiError = z.infer; From 55d53ce70bbc8f826b5fc448fcafff9a0342f87e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Jan 2026 19:50:35 +0000 Subject: [PATCH 08/20] fix: Show OAuth URL instead of auto-opening browser Display the authorization URL for users to click manually, similar to how the login command works. This is more reliable across different environments. https://claude.ai/code/session_013jLN9iB2EPcrwsWaUNovsJ --- src/cli/commands/connectors/add.ts | 8 ++++---- src/cli/commands/connectors/push.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/cli/commands/connectors/add.ts b/src/cli/commands/connectors/add.ts index 5e26b410..a8b3aace 100644 --- a/src/cli/commands/connectors/add.ts +++ b/src/cli/commands/connectors/add.ts @@ -1,7 +1,6 @@ import { Command } from "commander"; import { log, select, isCancel } from "@clack/prompts"; import pWaitFor from "p-wait-for"; -import open from "open"; import { initiateOAuth, checkOAuthStatus, @@ -144,9 +143,10 @@ export async function addConnector( throw new Error("Invalid response from server: missing redirect URL or connection ID"); } - // Open browser for OAuth - log.info(`Opening browser for ${displayName} authorization...`); - await open(initiateResponse.redirect_url); + // Show authorization URL + log.info( + `Please authorize ${displayName} at:\n${theme.colors.links(initiateResponse.redirect_url)}` + ); // Poll for completion const result = await waitForOAuthCompletion( diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts index 864748c8..457afe04 100644 --- a/src/cli/commands/connectors/push.ts +++ b/src/cli/commands/connectors/push.ts @@ -1,7 +1,6 @@ import { Command } from "commander"; import { log, confirm, isCancel } from "@clack/prompts"; import pWaitFor from "p-wait-for"; -import open from "open"; import { listConnectors, readLocalConnectors, @@ -68,9 +67,10 @@ async function connectSingleConnector( return { success: false, error: "Invalid response from server" }; } - // Open browser for OAuth - log.info(`Opening browser for ${displayName} authorization...`); - await open(initiateResponse.redirect_url); + // Show authorization URL + log.info( + `Please authorize ${displayName} at:\n${theme.colors.links(initiateResponse.redirect_url)}` + ); // Poll for completion let accountEmail: string | undefined; From 9f12d4d4199b6a977a6ea9a89151af3ef52f413b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Jan 2026 20:11:30 +0000 Subject: [PATCH 09/20] refactor: Use space-separated connectors commands like entities Changed from `connectors:add` pattern to `connectors add` pattern to match existing command structure (e.g., `entities push`). - Updated all connector subcommands to use Command("add") etc. - Created connectors/index.ts with parent "connectors" command - Updated help text references throughout - Updated README documentation https://claude.ai/code/session_013jLN9iB2EPcrwsWaUNovsJ --- src/cli/commands/connectors/add.ts | 2 +- src/cli/commands/connectors/index.ts | 12 ++++++++++++ src/cli/commands/connectors/list.ts | 6 +++--- src/cli/commands/connectors/push.ts | 4 ++-- src/cli/commands/connectors/remove.ts | 2 +- 5 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 src/cli/commands/connectors/index.ts diff --git a/src/cli/commands/connectors/add.ts b/src/cli/commands/connectors/add.ts index a8b3aace..b4f1ffd7 100644 --- a/src/cli/commands/connectors/add.ts +++ b/src/cli/commands/connectors/add.ts @@ -170,7 +170,7 @@ export async function addConnector( }; } -export const connectorsAddCommand = new Command("connectors:add") +export const connectorsAddCommand = new Command("add") .argument("[type]", "Integration type (e.g., slack, notion, googlecalendar)") .description("Connect an OAuth integration") .action(async (type?: string) => { diff --git a/src/cli/commands/connectors/index.ts b/src/cli/commands/connectors/index.ts new file mode 100644 index 00000000..5f175119 --- /dev/null +++ b/src/cli/commands/connectors/index.ts @@ -0,0 +1,12 @@ +import { Command } from "commander"; +import { connectorsAddCommand } from "./add.js"; +import { connectorsListCommand } from "./list.js"; +import { connectorsPushCommand } from "./push.js"; +import { connectorsRemoveCommand } from "./remove.js"; + +export const connectorsCommand = new Command("connectors") + .description("Manage OAuth connectors") + .addCommand(connectorsAddCommand) + .addCommand(connectorsListCommand) + .addCommand(connectorsPushCommand) + .addCommand(connectorsRemoveCommand); diff --git a/src/cli/commands/connectors/list.ts b/src/cli/commands/connectors/list.ts index 93f793be..e52b60b1 100644 --- a/src/cli/commands/connectors/list.ts +++ b/src/cli/commands/connectors/list.ts @@ -110,7 +110,7 @@ export async function listConnectorsCommand(): Promise { if (merged.length === 0) { log.info("No connectors configured for this app."); - log.info(`Run ${theme.styles.bold("base44 connectors:add")} to connect an integration.`); + log.info(`Run ${theme.styles.bold("base44 connectors add")} to connect an integration.`); return { outroMessage: "" }; } @@ -127,13 +127,13 @@ export async function listConnectorsCommand(): Promise { let summary = `${connected} connected`; if (pending > 0) { summary += `, ${pending} pending`; - log.info(`Run ${theme.styles.bold("base44 connectors:push")} to connect pending integrations.`); + log.info(`Run ${theme.styles.bold("base44 connectors push")} to connect pending integrations.`); } return { outroMessage: summary }; } -export const connectorsListCommand = new Command("connectors:list") +export const connectorsListCommand = new Command("list") .description("List all connected OAuth integrations") .action(async () => { await runCommand(listConnectorsCommand, { diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts index 457afe04..efe5b45e 100644 --- a/src/cli/commands/connectors/push.ts +++ b/src/cli/commands/connectors/push.ts @@ -125,7 +125,7 @@ export async function pushConnectorsCommand(): Promise { if (localConnectors.length === 0) { log.info("No connectors defined in connectors.jsonc"); - log.info(`Run ${theme.styles.bold("base44 connectors:add")} to add a connector.`); + log.info(`Run ${theme.styles.bold("base44 connectors add")} to add a connector.`); return { outroMessage: "" }; } @@ -182,7 +182,7 @@ export async function pushConnectorsCommand(): Promise { return { outroMessage: `Connected ${connected}, failed ${failed}` }; } -export const connectorsPushCommand = new Command("connectors:push") +export const connectorsPushCommand = new Command("push") .description("Connect all pending integrations from connectors.jsonc") .action(async () => { await runCommand(pushConnectorsCommand, { diff --git a/src/cli/commands/connectors/remove.ts b/src/cli/commands/connectors/remove.ts index ee802df6..5a808b10 100644 --- a/src/cli/commands/connectors/remove.ts +++ b/src/cli/commands/connectors/remove.ts @@ -199,7 +199,7 @@ export async function removeConnectorCommand( }; } -export const connectorsRemoveCommand = new Command("connectors:remove") +export const connectorsRemoveCommand = new Command("remove") .argument("[type]", "Integration type to remove (e.g., slack, notion)") .option("--hard", "Permanently remove the connector (cannot be undone)") .description("Remove an OAuth integration") From cb4332fb2034a8980bb8080891b106dc22f7b702 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Jan 2026 20:50:25 +0000 Subject: [PATCH 10/20] refactor: Move connectors config into main config.jsonc Connectors are now stored as a property in the project's config.jsonc file instead of a separate connectors.jsonc file. Example config: ```jsonc { "name": "my-app", "connectors": { "slack": {}, "googlecalendar": { "scopes": ["calendar.readonly"] } } } ``` - Added connectors property to ProjectConfigSchema - Updated connectors/config.ts to read/write from main config - Updated CLI messages and descriptions - Updated README documentation https://claude.ai/code/session_013jLN9iB2EPcrwsWaUNovsJ --- src/cli/commands/connectors/add.ts | 2 +- src/cli/commands/connectors/push.ts | 4 +- src/core/connectors/config.ts | 108 ++++++++++------------------ src/core/connectors/index.ts | 4 +- src/core/project/schema.ts | 9 +++ 5 files changed, 50 insertions(+), 77 deletions(-) diff --git a/src/cli/commands/connectors/add.ts b/src/cli/commands/connectors/add.ts index b4f1ffd7..e3c35847 100644 --- a/src/cli/commands/connectors/add.ts +++ b/src/cli/commands/connectors/add.ts @@ -127,7 +127,7 @@ export async function addConnector( // Still add to local config file await addLocalConnector(selectedType); return { - outroMessage: `Already connected to ${theme.styles.bold(displayName)} (added to connectors.jsonc)`, + outroMessage: `Already connected to ${theme.styles.bold(displayName)} (added to config)`, }; } diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts index efe5b45e..6353c61d 100644 --- a/src/cli/commands/connectors/push.ts +++ b/src/cli/commands/connectors/push.ts @@ -124,7 +124,7 @@ export async function pushConnectorsCommand(): Promise { ); if (localConnectors.length === 0) { - log.info("No connectors defined in connectors.jsonc"); + log.info("No connectors defined in config"); log.info(`Run ${theme.styles.bold("base44 connectors add")} to add a connector.`); return { outroMessage: "" }; } @@ -183,7 +183,7 @@ export async function pushConnectorsCommand(): Promise { } export const connectorsPushCommand = new Command("push") - .description("Connect all pending integrations from connectors.jsonc") + .description("Connect all pending integrations from config") .action(async () => { await runCommand(pushConnectorsCommand, { requireAuth: true, diff --git a/src/core/connectors/config.ts b/src/core/connectors/config.ts index c952dfa5..ed4d3325 100644 --- a/src/core/connectors/config.ts +++ b/src/core/connectors/config.ts @@ -1,26 +1,8 @@ -import { join } from "node:path"; -import { globby } from "globby"; -import { z } from "zod"; +import { findProjectRoot } from "../project/config.js"; import { readJsonFile, writeJsonFile } from "../utils/fs.js"; -import { PROJECT_SUBDIR, CONFIG_FILE_EXTENSION_GLOB } from "../consts.js"; import { isValidIntegration } from "./constants.js"; import type { IntegrationType } from "./constants.js"; - -/** - * Schema for a single connector configuration - */ -export const ConnectorConfigSchema = z.object({ - scopes: z.array(z.string()).optional(), -}); - -export type ConnectorConfig = z.infer; - -/** - * Schema for the connectors.jsonc file - */ -export const ConnectorsFileSchema = z.record(z.string(), ConnectorConfigSchema); - -export type ConnectorsFile = z.infer; +import type { ConnectorsConfig } from "../project/schema.js"; /** * Parsed connector with its type @@ -30,59 +12,28 @@ export interface LocalConnector { scopes?: string[]; } -const CONNECTORS_FILE_PATTERNS = [ - `${PROJECT_SUBDIR}/connectors.${CONFIG_FILE_EXTENSION_GLOB}`, - `connectors.${CONFIG_FILE_EXTENSION_GLOB}`, -]; - /** - * Find the connectors config file in the project - */ -export async function findConnectorsFile( - startPath?: string -): Promise { - const cwd = startPath || process.cwd(); - - const files = await globby(CONNECTORS_FILE_PATTERNS, { - cwd, - absolute: true, - }); - - return files[0] ?? null; -} - -/** - * Get the default path for the connectors file - */ -export function getDefaultConnectorsPath(projectRoot?: string): string { - const root = projectRoot || process.cwd(); - return join(root, PROJECT_SUBDIR, "connectors.jsonc"); -} - -/** - * Read all connectors from the local config file + * Read all connectors from the project config file */ export async function readLocalConnectors( projectRoot?: string ): Promise { - const filePath = await findConnectorsFile(projectRoot); + const found = await findProjectRoot(projectRoot); - if (!filePath) { + if (!found) { return []; } - const parsed = await readJsonFile(filePath); - const result = ConnectorsFileSchema.safeParse(parsed); + const parsed = (await readJsonFile(found.configPath)) as Record; + const connectorsData = parsed.connectors as ConnectorsConfig | undefined; - if (!result.success) { - throw new Error( - `Invalid connectors configuration: ${result.error.message}` - ); + if (!connectorsData) { + return []; } const connectors: LocalConnector[] = []; - for (const [type, config] of Object.entries(result.data)) { + for (const [type, config] of Object.entries(connectorsData)) { if (!isValidIntegration(type)) { throw new Error(`Unknown connector type: ${type}`); } @@ -97,33 +48,48 @@ export async function readLocalConnectors( } /** - * Write connectors to the local config file + * Write connectors to the project config file */ export async function writeLocalConnectors( connectors: LocalConnector[], projectRoot?: string ): Promise { - let filePath = await findConnectorsFile(projectRoot); + const found = await findProjectRoot(projectRoot); - if (!filePath) { - filePath = getDefaultConnectorsPath(projectRoot); + if (!found) { + throw new Error("Project config not found. Run this command from a Base44 project directory."); } - const data: ConnectorsFile = {}; + // Read existing config to preserve other fields + const existingConfig = (await readJsonFile(found.configPath)) as Record; + + // Build connectors object + const connectorsData: ConnectorsConfig = {}; for (const connector of connectors) { - data[connector.type] = { + connectorsData[connector.type] = { ...(connector.scopes && { scopes: connector.scopes }), }; } - await writeJsonFile(filePath, data); + // Update config with new connectors + const updatedConfig = { + ...existingConfig, + connectors: Object.keys(connectorsData).length > 0 ? connectorsData : undefined, + }; + + // Remove connectors key if empty + if (!updatedConfig.connectors) { + delete updatedConfig.connectors; + } + + await writeJsonFile(found.configPath, updatedConfig); - return filePath; + return found.configPath; } /** - * Add a connector to the local config file + * Add a connector to the project config file */ export async function addLocalConnector( type: IntegrationType, @@ -147,7 +113,7 @@ export async function addLocalConnector( } /** - * Remove a connector from the local config file + * Remove a connector from the project config file */ export async function removeLocalConnector( type: IntegrationType, @@ -157,7 +123,7 @@ export async function removeLocalConnector( const filtered = connectors.filter((c) => c.type !== type); if (filtered.length === connectors.length) { - // Connector wasn't in the file + // Connector wasn't in the config return null; } @@ -165,7 +131,7 @@ export async function removeLocalConnector( } /** - * Check if a connector exists in the local config file + * Check if a connector exists in the project config file */ export async function hasLocalConnector( type: IntegrationType, diff --git a/src/core/connectors/index.ts b/src/core/connectors/index.ts index 851239ae..f6548a72 100644 --- a/src/core/connectors/index.ts +++ b/src/core/connectors/index.ts @@ -14,11 +14,9 @@ export { addLocalConnector, removeLocalConnector, hasLocalConnector, - findConnectorsFile, - getDefaultConnectorsPath, } from "./config.js"; -export type { LocalConnector, ConnectorConfig, ConnectorsFile } from "./config.js"; +export type { LocalConnector } from "./config.js"; // Schemas and types export { diff --git a/src/core/project/schema.ts b/src/core/project/schema.ts index 18b65045..b318e6cb 100644 --- a/src/core/project/schema.ts +++ b/src/core/project/schema.ts @@ -21,16 +21,25 @@ const SiteConfigSchema = z.object({ installCommand: z.string().optional(), }); +const ConnectorConfigSchema = z.object({ + scopes: z.array(z.string()).optional(), +}); + +const ConnectorsConfigSchema = z.record(z.string(), ConnectorConfigSchema); + export const ProjectConfigSchema = z.object({ name: z.string().min(1, "App name cannot be empty"), description: z.string().optional(), site: SiteConfigSchema.optional(), + connectors: ConnectorsConfigSchema.optional(), entitiesDir: z.string().optional().default("entities"), functionsDir: z.string().optional().default("functions"), agentsDir: z.string().optional().default("agents"), }); export type SiteConfig = z.infer; +export type ConnectorConfig = z.infer; +export type ConnectorsConfig = z.infer; export type ProjectConfig = z.infer; export const AppConfigSchema = z.object({ From 9aa2e775c14bec458be6f70d06ecc2b83f46e988 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Jan 2026 21:15:37 +0000 Subject: [PATCH 11/20] feat: Add removal support to connectors push command Push now performs a full sync: - Connects connectors in local config but not in backend - Removes connectors in backend but not in local config Shows a preview of changes before applying: + Slack (to connect) - Notion (to remove) https://claude.ai/code/session_013jLN9iB2EPcrwsWaUNovsJ --- src/cli/commands/connectors/push.ts | 91 +++++++++++++++++++++++------ 1 file changed, 73 insertions(+), 18 deletions(-) diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts index 6353c61d..931c7bac 100644 --- a/src/cli/commands/connectors/push.ts +++ b/src/cli/commands/connectors/push.ts @@ -6,7 +6,9 @@ import { readLocalConnectors, initiateOAuth, checkOAuthStatus, + disconnectConnector, getIntegrationDisplayName, + isValidIntegration, } from "@core/connectors/index.js"; import type { IntegrationType, Connector, LocalConnector } from "@core/connectors/index.js"; import { runCommand, runTask } from "../../utils/index.js"; @@ -22,6 +24,12 @@ interface PendingConnector { scopes?: string[]; } +interface OrphanedConnector { + type: IntegrationType; + displayName: string; + accountEmail?: string; +} + function findPendingConnectors( local: LocalConnector[], backend: Connector[] @@ -41,6 +49,23 @@ function findPendingConnectors( })); } +function findOrphanedConnectors( + local: LocalConnector[], + backend: Connector[] +): OrphanedConnector[] { + const localTypes = new Set(local.map((c) => c.type)); + + return backend + .filter((c) => c.status.toLowerCase() === "active") + .filter((c) => !localTypes.has(c.integrationType)) + .filter((c) => isValidIntegration(c.integrationType)) + .map((c) => ({ + type: c.integrationType, + displayName: getIntegrationDisplayName(c.integrationType), + accountEmail: (c.accountInfo?.email || c.accountInfo?.name) ?? undefined, + })); +} + async function connectSingleConnector( connector: PendingConnector ): Promise<{ success: boolean; accountEmail?: string; error?: string }> { @@ -123,29 +148,38 @@ export async function pushConnectorsCommand(): Promise { } ); - if (localConnectors.length === 0) { - log.info("No connectors defined in config"); - log.info(`Run ${theme.styles.bold("base44 connectors add")} to add a connector.`); - return { outroMessage: "" }; - } - const pending = findPendingConnectors(localConnectors, backendConnectors); + const orphaned = findOrphanedConnectors(localConnectors, backendConnectors); - if (pending.length === 0) { - return { outroMessage: "All connectors are already connected" }; + // Nothing to do + if (pending.length === 0 && orphaned.length === 0) { + return { outroMessage: "All connectors are in sync" }; } - // Show what needs to be connected + // Show what will happen console.log(); - log.info(`${pending.length} connector${pending.length === 1 ? "" : "s"} need${pending.length === 1 ? "s" : ""} to be connected:`); - for (const c of pending) { - console.log(` ${theme.colors.warning("○")} ${c.displayName}`); + + if (pending.length > 0) { + log.info(`${pending.length} connector${pending.length === 1 ? "" : "s"} to connect:`); + for (const c of pending) { + console.log(` ${theme.colors.success("+")} ${c.displayName}`); + } + } + + if (orphaned.length > 0) { + log.info(`${orphaned.length} connector${orphaned.length === 1 ? "" : "s"} to remove:`); + for (const c of orphaned) { + const accountInfo = c.accountEmail ? ` (${c.accountEmail})` : ""; + console.log(` ${theme.colors.error("-")} ${c.displayName}${accountInfo}`); + } } + console.log(); // Confirm + const totalChanges = pending.length + orphaned.length; const shouldProceed = await confirm({ - message: `Connect ${pending.length} integration${pending.length === 1 ? "" : "s"}?`, + message: `Apply ${totalChanges} change${totalChanges === 1 ? "" : "s"}?`, initialValue: true, }); @@ -153,10 +187,23 @@ export async function pushConnectorsCommand(): Promise { return { outroMessage: "Cancelled" }; } - // Connect each one let connected = 0; + let removed = 0; let failed = 0; + // Remove orphaned connectors first + for (const connector of orphaned) { + try { + await disconnectConnector(connector.type); + log.success(`Removed ${connector.displayName}`); + removed++; + } catch (err) { + log.error(`Failed to remove ${connector.displayName}: ${err instanceof Error ? err.message : "Unknown error"}`); + failed++; + } + } + + // Connect pending connectors for (const connector of pending) { console.log(); log.info(`Connecting ${theme.styles.bold(connector.displayName)}...`); @@ -175,15 +222,23 @@ export async function pushConnectorsCommand(): Promise { console.log(); - if (failed === 0) { - return { outroMessage: `Successfully connected ${connected} integration${connected === 1 ? "" : "s"}` }; + // Build summary + const parts: string[] = []; + if (connected > 0) { + parts.push(`${connected} connected`); + } + if (removed > 0) { + parts.push(`${removed} removed`); + } + if (failed > 0) { + parts.push(`${failed} failed`); } - return { outroMessage: `Connected ${connected}, failed ${failed}` }; + return { outroMessage: parts.join(", ") }; } export const connectorsPushCommand = new Command("push") - .description("Connect all pending integrations from config") + .description("Sync connectors with backend (connect new, remove missing)") .action(async () => { await runCommand(pushConnectorsCommand, { requireAuth: true, From 710a296989283c198a99a7a622a9ee1c8724ddf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Jan 2026 07:23:20 +0000 Subject: [PATCH 12/20] refactor: Use runTask wrapper for OAuth polling in push command Aligns with the login flow pattern by wrapping pWaitFor in runTask for consistent spinner and success/error messaging. https://claude.ai/code/session_013jLN9iB2EPcrwsWaUNovsJ --- src/cli/commands/connectors/push.ts | 41 ++++++++++++++++++----------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts index 931c7bac..a149616f 100644 --- a/src/cli/commands/connectors/push.ts +++ b/src/cli/commands/connectors/push.ts @@ -101,31 +101,40 @@ async function connectSingleConnector( let accountEmail: string | undefined; try { - await pWaitFor( + await runTask( + "Waiting for authorization...", async () => { - const status = await checkOAuthStatus(type, initiateResponse.connection_id!); - - if (status.status === "ACTIVE") { - accountEmail = status.accountEmail ?? undefined; - return true; - } - - if (status.status === "FAILED") { - throw new Error(status.error || "Authorization failed"); - } - - return false; + await pWaitFor( + async () => { + const status = await checkOAuthStatus(type, initiateResponse.connection_id!); + + if (status.status === "ACTIVE") { + accountEmail = status.accountEmail ?? undefined; + return true; + } + + if (status.status === "FAILED") { + throw new Error(status.error || "Authorization failed"); + } + + return false; + }, + { + interval: POLL_INTERVAL_MS, + timeout: POLL_TIMEOUT_MS, + } + ); }, { - interval: POLL_INTERVAL_MS, - timeout: POLL_TIMEOUT_MS, + successMessage: "Authorization completed!", + errorMessage: "Authorization failed", } ); return { success: true, accountEmail }; } catch (err) { if (err instanceof Error && err.message.includes("timed out")) { - return { success: false, error: "Authorization timed out" }; + return { success: false, error: "Authorization timed out. Please try again." }; } return { success: false, error: err instanceof Error ? err.message : "Unknown error" }; } From fd39557ff88232def4eea4e8aeb5470fcb87700b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Jan 2026 07:26:24 +0000 Subject: [PATCH 13/20] refactor: Move OAuth polling constants to shared constants.ts Moved POLL_INTERVAL_MS and POLL_TIMEOUT_MS to core/connectors/constants.ts as OAUTH_POLL_INTERVAL_MS and OAUTH_POLL_TIMEOUT_MS for reuse across add.ts and push.ts. https://claude.ai/code/session_013jLN9iB2EPcrwsWaUNovsJ --- src/cli/commands/connectors/add.ts | 9 ++++----- src/cli/commands/connectors/push.ts | 9 ++++----- src/core/connectors/constants.ts | 6 ++++++ src/core/connectors/index.ts | 2 ++ 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/cli/commands/connectors/add.ts b/src/cli/commands/connectors/add.ts index e3c35847..e06bf89f 100644 --- a/src/cli/commands/connectors/add.ts +++ b/src/cli/commands/connectors/add.ts @@ -6,6 +6,8 @@ import { checkOAuthStatus, addLocalConnector, SUPPORTED_INTEGRATIONS, + OAUTH_POLL_INTERVAL_MS, + OAUTH_POLL_TIMEOUT_MS, isValidIntegration, getIntegrationDisplayName, } from "@core/connectors/index.js"; @@ -14,9 +16,6 @@ import { runCommand, runTask } from "../../utils/index.js"; import type { RunCommandResult } from "../../utils/runCommand.js"; import { theme } from "../../utils/theme.js"; -const POLL_INTERVAL_MS = 2000; -const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes - async function promptForIntegrationType(): Promise { const options = SUPPORTED_INTEGRATIONS.map((type) => ({ value: type, @@ -65,8 +64,8 @@ async function waitForOAuthCompletion( return false; }, { - interval: POLL_INTERVAL_MS, - timeout: POLL_TIMEOUT_MS, + interval: OAUTH_POLL_INTERVAL_MS, + timeout: OAUTH_POLL_TIMEOUT_MS, } ); }, diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts index a149616f..be875cdb 100644 --- a/src/cli/commands/connectors/push.ts +++ b/src/cli/commands/connectors/push.ts @@ -9,15 +9,14 @@ import { disconnectConnector, getIntegrationDisplayName, isValidIntegration, + OAUTH_POLL_INTERVAL_MS, + OAUTH_POLL_TIMEOUT_MS, } from "@core/connectors/index.js"; import type { IntegrationType, Connector, LocalConnector } from "@core/connectors/index.js"; import { runCommand, runTask } from "../../utils/index.js"; import type { RunCommandResult } from "../../utils/runCommand.js"; import { theme } from "../../utils/theme.js"; -const POLL_INTERVAL_MS = 2000; -const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes - interface PendingConnector { type: IntegrationType; displayName: string; @@ -120,8 +119,8 @@ async function connectSingleConnector( return false; }, { - interval: POLL_INTERVAL_MS, - timeout: POLL_TIMEOUT_MS, + interval: OAUTH_POLL_INTERVAL_MS, + timeout: OAUTH_POLL_TIMEOUT_MS, } ); }, diff --git a/src/core/connectors/constants.ts b/src/core/connectors/constants.ts index 12e8f27e..3242dbe4 100644 --- a/src/core/connectors/constants.ts +++ b/src/core/connectors/constants.ts @@ -1,3 +1,9 @@ +/** + * OAuth polling configuration + */ +export const OAUTH_POLL_INTERVAL_MS = 2000; +export const OAUTH_POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + /** * Supported OAuth connector integrations. * Based on apper/backend/app/external_auth/models/constants.py diff --git a/src/core/connectors/index.ts b/src/core/connectors/index.ts index f6548a72..56e934ca 100644 --- a/src/core/connectors/index.ts +++ b/src/core/connectors/index.ts @@ -37,6 +37,8 @@ export type { // Constants export { + OAUTH_POLL_INTERVAL_MS, + OAUTH_POLL_TIMEOUT_MS, SUPPORTED_INTEGRATIONS, INTEGRATION_DISPLAY_NAMES, isValidIntegration, From 29af2a0899ca03eae3075d51d91e96c874afbe82 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Jan 2026 10:11:05 +0000 Subject: [PATCH 14/20] refactor: Follow interactive/non-interactive command pattern Updated add.ts and remove.ts to follow the unified command pattern: - Use ternary-driven flow for flag vs prompt values - Extract validation into helper functions - Use cancel() + process.exit(0) for cancelled prompts - Added --yes flag to remove command to skip confirmation https://claude.ai/code/session_013jLN9iB2EPcrwsWaUNovsJ --- src/cli/commands/connectors/add.ts | 40 ++++++------ src/cli/commands/connectors/remove.ts | 87 +++++++++++++-------------- 2 files changed, 60 insertions(+), 67 deletions(-) diff --git a/src/cli/commands/connectors/add.ts b/src/cli/commands/connectors/add.ts index e06bf89f..cd63aefe 100644 --- a/src/cli/commands/connectors/add.ts +++ b/src/cli/commands/connectors/add.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { log, select, isCancel } from "@clack/prompts"; +import { cancel, log, select, isCancel } from "@clack/prompts"; import pWaitFor from "p-wait-for"; import { initiateOAuth, @@ -16,7 +16,17 @@ import { runCommand, runTask } from "../../utils/index.js"; import type { RunCommandResult } from "../../utils/runCommand.js"; import { theme } from "../../utils/theme.js"; -async function promptForIntegrationType(): Promise { +function validateIntegrationType(type: string): IntegrationType { + if (!isValidIntegration(type)) { + const supportedList = SUPPORTED_INTEGRATIONS.join(", "); + throw new Error( + `Unsupported connector: ${type}\nSupported connectors: ${supportedList}` + ); + } + return type; +} + +async function promptForIntegrationType(): Promise { const options = SUPPORTED_INTEGRATIONS.map((type) => ({ value: type, label: getIntegrationDisplayName(type), @@ -28,7 +38,8 @@ async function promptForIntegrationType(): Promise { }); if (isCancel(selected)) { - return null; + cancel("Operation cancelled."); + process.exit(0); } return selected; @@ -87,25 +98,10 @@ async function waitForOAuthCompletion( export async function addConnector( integrationType?: string ): Promise { - // If no type provided, prompt for selection - let selectedType: IntegrationType; - - if (!integrationType) { - const prompted = await promptForIntegrationType(); - if (!prompted) { - return { outroMessage: "Cancelled" }; - } - selectedType = prompted; - } else { - // Validate the provided integration type - if (!isValidIntegration(integrationType)) { - const supportedList = SUPPORTED_INTEGRATIONS.join(", "); - throw new Error( - `Unsupported connector: ${integrationType}\nSupported connectors: ${supportedList}` - ); - } - selectedType = integrationType; - } + // Get type from argument or prompt + const selectedType = integrationType + ? validateIntegrationType(integrationType) + : await promptForIntegrationType(); const displayName = getIntegrationDisplayName(selectedType); diff --git a/src/cli/commands/connectors/remove.ts b/src/cli/commands/connectors/remove.ts index 5a808b10..e4cd55cf 100644 --- a/src/cli/commands/connectors/remove.ts +++ b/src/cli/commands/connectors/remove.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { confirm, select, isCancel } from "@clack/prompts"; +import { cancel, confirm, select, isCancel } from "@clack/prompts"; import { listConnectors, readLocalConnectors, @@ -16,6 +16,7 @@ import { theme } from "../../utils/theme.js"; interface RemoveOptions { hard?: boolean; + yes?: boolean; } interface MergedConnector { @@ -67,9 +68,25 @@ function mergeConnectorsForRemoval( return Array.from(merged.values()); } +function validateConnectorType( + type: string, + merged: MergedConnector[] +): { type: IntegrationType; connector: MergedConnector } { + if (!isValidIntegration(type)) { + throw new Error(`Invalid connector type: ${type}`); + } + + const connector = merged.find((c) => c.type === type); + if (!connector) { + throw new Error(`No ${getIntegrationDisplayName(type)} connector found`); + } + + return { type, connector }; +} + async function promptForConnectorToRemove( connectors: MergedConnector[] -): Promise { +): Promise<{ type: IntegrationType; connector: MergedConnector }> { const options = connectors.map((c) => { let label = c.displayName; if (c.accountEmail) { @@ -89,10 +106,12 @@ async function promptForConnectorToRemove( }); if (isCancel(selected)) { - return null; + cancel("Operation cancelled."); + process.exit(0); } - return selected; + const connector = connectors.find((c) => c.type === selected)!; + return { type: selected, connector }; } export async function removeConnectorCommand( @@ -125,59 +144,36 @@ export async function removeConnectorCommand( }; } - // If no type provided, prompt for selection - let selectedType: IntegrationType; - let selectedConnector: MergedConnector | undefined; - - if (!integrationType) { - const prompted = await promptForConnectorToRemove(merged); - if (!prompted) { - return { outroMessage: "Cancelled" }; - } - selectedType = prompted; - selectedConnector = merged.find((c) => c.type === selectedType); - } else { - // Validate the provided integration type - if (!isValidIntegration(integrationType)) { - throw new Error(`Invalid connector type: ${integrationType}`); - } - - selectedConnector = merged.find((c) => c.type === integrationType); - if (!selectedConnector) { - throw new Error( - `No ${getIntegrationDisplayName(integrationType)} connector found` - ); - } - - selectedType = integrationType; - } + // Get type from argument or prompt + const { type: selectedType, connector: selectedConnector } = integrationType + ? validateConnectorType(integrationType, merged) + : await promptForConnectorToRemove(merged); const displayName = getIntegrationDisplayName(selectedType); - const accountInfo = selectedConnector?.accountEmail + const accountInfo = selectedConnector.accountEmail ? ` (${selectedConnector.accountEmail})` : ""; - // Confirm removal with appropriate message - const actionWord = isHardDelete ? "Permanently remove" : "Remove"; - const shouldRemove = await confirm({ - message: `${actionWord} ${displayName}${accountInfo}?`, - initialValue: false, - }); + // Confirm removal (skip if --yes flag is provided) + if (!options.yes) { + const actionWord = isHardDelete ? "Permanently remove" : "Remove"; + const shouldRemove = await confirm({ + message: `${actionWord} ${displayName}${accountInfo}?`, + initialValue: false, + }); - if (isCancel(shouldRemove) || !shouldRemove) { - return { outroMessage: "Cancelled" }; + if (isCancel(shouldRemove) || !shouldRemove) { + cancel("Operation cancelled."); + process.exit(0); + } } // Perform removal - const taskMessage = isHardDelete - ? `Removing ${displayName}...` - : `Removing ${displayName}...`; - await runTask( - taskMessage, + `Removing ${displayName}...`, async () => { // Remove from backend if it exists there - if (selectedConnector?.inBackend) { + if (selectedConnector.inBackend) { if (isHardDelete) { await removeConnector(selectedType); } else { @@ -202,6 +198,7 @@ export async function removeConnectorCommand( export const connectorsRemoveCommand = new Command("remove") .argument("[type]", "Integration type to remove (e.g., slack, notion)") .option("--hard", "Permanently remove the connector (cannot be undone)") + .option("-y, --yes", "Skip confirmation prompt") .description("Remove an OAuth integration") .action(async (type: string | undefined, options: RemoveOptions) => { await runCommand(() => removeConnectorCommand(type, options), { From 4b9a80b6012bccb7ad02fd833d8476805541d40d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Jan 2026 11:35:12 +0000 Subject: [PATCH 15/20] refactor: Extract shared OAuth polling utility Move duplicate waitForOAuthCompletion logic from add.ts and push.ts into a shared utility at @core/connectors/oauth.ts. This reduces code duplication while keeping the polling pattern separate from login (which has different timeout sources and error handling). https://claude.ai/code/session_013jLN9iB2EPcrwsWaUNovsJ --- src/cli/commands/connectors/add.ts | 61 ++++++----------------------- src/cli/commands/connectors/push.ts | 55 ++++++-------------------- src/core/connectors/index.ts | 4 ++ src/core/connectors/oauth.ts | 58 +++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 93 deletions(-) create mode 100644 src/core/connectors/oauth.ts diff --git a/src/cli/commands/connectors/add.ts b/src/cli/commands/connectors/add.ts index cd63aefe..8e93b175 100644 --- a/src/cli/commands/connectors/add.ts +++ b/src/cli/commands/connectors/add.ts @@ -1,13 +1,10 @@ import { Command } from "commander"; import { cancel, log, select, isCancel } from "@clack/prompts"; -import pWaitFor from "p-wait-for"; import { initiateOAuth, - checkOAuthStatus, addLocalConnector, + waitForOAuthCompletion, SUPPORTED_INTEGRATIONS, - OAUTH_POLL_INTERVAL_MS, - OAUTH_POLL_TIMEOUT_MS, isValidIntegration, getIntegrationDisplayName, } from "@core/connectors/index.js"; @@ -45,54 +42,20 @@ async function promptForIntegrationType(): Promise { return selected; } -async function waitForOAuthCompletion( +async function pollForOAuthCompletion( integrationType: IntegrationType, connectionId: string ): Promise<{ success: boolean; accountEmail?: string; error?: string }> { - let accountEmail: string | undefined; - let error: string | undefined; - - try { - await runTask( - "Waiting for authorization...", - async (updateMessage) => { - 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 - updateMessage("Waiting for authorization in browser..."); - return false; - }, - { - interval: OAUTH_POLL_INTERVAL_MS, - timeout: OAUTH_POLL_TIMEOUT_MS, - } - ); - }, - { - successMessage: "Authorization completed!", - errorMessage: "Authorization failed", - } - ); - - 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 await runTask( + "Waiting for authorization...", + async () => { + return await waitForOAuthCompletion(integrationType, connectionId); + }, + { + successMessage: "Authorization completed!", + errorMessage: "Authorization failed", } - return { success: false, error: error || (err instanceof Error ? err.message : "Unknown error") }; - } + ); } export async function addConnector( @@ -144,7 +107,7 @@ export async function addConnector( ); // Poll for completion - const result = await waitForOAuthCompletion( + const result = await pollForOAuthCompletion( selectedType, initiateResponse.connection_id ); diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts index be875cdb..09703cbb 100644 --- a/src/cli/commands/connectors/push.ts +++ b/src/cli/commands/connectors/push.ts @@ -1,16 +1,13 @@ import { Command } from "commander"; import { log, confirm, isCancel } from "@clack/prompts"; -import pWaitFor from "p-wait-for"; import { listConnectors, readLocalConnectors, initiateOAuth, - checkOAuthStatus, + waitForOAuthCompletion, disconnectConnector, getIntegrationDisplayName, isValidIntegration, - OAUTH_POLL_INTERVAL_MS, - OAUTH_POLL_TIMEOUT_MS, } from "@core/connectors/index.js"; import type { IntegrationType, Connector, LocalConnector } from "@core/connectors/index.js"; import { runCommand, runTask } from "../../utils/index.js"; @@ -96,47 +93,17 @@ async function connectSingleConnector( `Please authorize ${displayName} at:\n${theme.colors.links(initiateResponse.redirect_url)}` ); - // Poll for completion - let accountEmail: string | undefined; - - try { - await runTask( - "Waiting for authorization...", - async () => { - await pWaitFor( - async () => { - const status = await checkOAuthStatus(type, initiateResponse.connection_id!); - - if (status.status === "ACTIVE") { - accountEmail = status.accountEmail ?? undefined; - return true; - } - - if (status.status === "FAILED") { - throw new Error(status.error || "Authorization failed"); - } - - return false; - }, - { - interval: OAUTH_POLL_INTERVAL_MS, - timeout: OAUTH_POLL_TIMEOUT_MS, - } - ); - }, - { - successMessage: "Authorization completed!", - errorMessage: "Authorization failed", - } - ); - - 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." }; + // Poll for completion using shared utility + return await runTask( + "Waiting for authorization...", + async () => { + return await waitForOAuthCompletion(type, initiateResponse.connection_id!); + }, + { + successMessage: "Authorization completed!", + errorMessage: "Authorization failed", } - return { success: false, error: err instanceof Error ? err.message : "Unknown error" }; - } + ); } export async function pushConnectorsCommand(): Promise { diff --git a/src/core/connectors/index.ts b/src/core/connectors/index.ts index 56e934ca..5b2d16c3 100644 --- a/src/core/connectors/index.ts +++ b/src/core/connectors/index.ts @@ -7,6 +7,10 @@ export { removeConnector, } from "./api.js"; +// OAuth flow utilities +export { waitForOAuthCompletion } from "./oauth.js"; +export type { OAuthCompletionResult } from "./oauth.js"; + // Local config functions export { readLocalConnectors, diff --git a/src/core/connectors/oauth.ts b/src/core/connectors/oauth.ts new file mode 100644 index 00000000..934c4d42 --- /dev/null +++ b/src/core/connectors/oauth.ts @@ -0,0 +1,58 @@ +import pWaitFor from "p-wait-for"; +import { checkOAuthStatus } from "./api.js"; +import { OAUTH_POLL_INTERVAL_MS, OAUTH_POLL_TIMEOUT_MS } from "./constants.js"; +import type { IntegrationType } from "./constants.js"; + +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") }; + } +} From e6c3f3f6b41f458b99a7c328569e29aa9b3a204b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Jan 2026 11:41:42 +0000 Subject: [PATCH 16/20] refactor: Extract shared fetchConnectorState utility Move duplicate fetch logic for local + backend connectors from list.ts, push.ts, and remove.ts into a shared utility at @core/connectors/state.ts. Centralizes error handling and parallel fetching in one place. https://claude.ai/code/session_013jLN9iB2EPcrwsWaUNovsJ --- src/cli/commands/connectors/list.ts | 13 +++---------- src/cli/commands/connectors/push.ts | 13 +++---------- src/cli/commands/connectors/remove.ts | 13 +++---------- src/core/connectors/index.ts | 4 ++++ src/core/connectors/state.ts | 21 +++++++++++++++++++++ 5 files changed, 34 insertions(+), 30 deletions(-) create mode 100644 src/core/connectors/state.ts diff --git a/src/cli/commands/connectors/list.ts b/src/cli/commands/connectors/list.ts index e52b60b1..2df76316 100644 --- a/src/cli/commands/connectors/list.ts +++ b/src/cli/commands/connectors/list.ts @@ -1,8 +1,7 @@ import { Command } from "commander"; import { log } from "@clack/prompts"; import { - listConnectors, - readLocalConnectors, + fetchConnectorState, getIntegrationDisplayName, } from "@core/connectors/index.js"; import type { Connector, LocalConnector } from "@core/connectors/index.js"; @@ -91,15 +90,9 @@ function formatConnectorLine(connector: MergedConnector): string { export async function listConnectorsCommand(): Promise { // Fetch both local and backend connectors in parallel - const [localConnectors, backendConnectors] = await runTask( + const { local: localConnectors, backend: backendConnectors } = await runTask( "Fetching connectors...", - async () => { - const [local, backend] = await Promise.all([ - readLocalConnectors().catch(() => [] as LocalConnector[]), - listConnectors().catch(() => [] as Connector[]), - ]); - return [local, backend] as const; - }, + fetchConnectorState, { successMessage: "Connectors loaded", errorMessage: "Failed to fetch connectors", diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts index 09703cbb..80245639 100644 --- a/src/cli/commands/connectors/push.ts +++ b/src/cli/commands/connectors/push.ts @@ -1,8 +1,7 @@ import { Command } from "commander"; import { log, confirm, isCancel } from "@clack/prompts"; import { - listConnectors, - readLocalConnectors, + fetchConnectorState, initiateOAuth, waitForOAuthCompletion, disconnectConnector, @@ -108,15 +107,9 @@ async function connectSingleConnector( export async function pushConnectorsCommand(): Promise { // Fetch both local and backend connectors - const [localConnectors, backendConnectors] = await runTask( + const { local: localConnectors, backend: backendConnectors } = await runTask( "Checking connector status...", - async () => { - const [local, backend] = await Promise.all([ - readLocalConnectors(), - listConnectors().catch(() => [] as Connector[]), - ]); - return [local, backend] as const; - }, + fetchConnectorState, { successMessage: "Status checked", errorMessage: "Failed to check status", diff --git a/src/cli/commands/connectors/remove.ts b/src/cli/commands/connectors/remove.ts index e4cd55cf..d0b918e3 100644 --- a/src/cli/commands/connectors/remove.ts +++ b/src/cli/commands/connectors/remove.ts @@ -1,8 +1,7 @@ import { Command } from "commander"; import { cancel, confirm, select, isCancel } from "@clack/prompts"; import { - listConnectors, - readLocalConnectors, + fetchConnectorState, removeLocalConnector, disconnectConnector, removeConnector, @@ -121,15 +120,9 @@ export async function removeConnectorCommand( const isHardDelete = options.hard === true; // Fetch both local and backend connectors - const [localConnectors, backendConnectors] = await runTask( + const { local: localConnectors, backend: backendConnectors } = await runTask( "Fetching connectors...", - async () => { - const [local, backend] = await Promise.all([ - readLocalConnectors().catch(() => [] as LocalConnector[]), - listConnectors().catch(() => [] as Connector[]), - ]); - return [local, backend] as const; - }, + fetchConnectorState, { successMessage: "Connectors loaded", errorMessage: "Failed to fetch connectors", diff --git a/src/core/connectors/index.ts b/src/core/connectors/index.ts index 5b2d16c3..2a4002ff 100644 --- a/src/core/connectors/index.ts +++ b/src/core/connectors/index.ts @@ -11,6 +11,10 @@ export { export { waitForOAuthCompletion } from "./oauth.js"; export type { OAuthCompletionResult } from "./oauth.js"; +// State utilities +export { fetchConnectorState } from "./state.js"; +export type { ConnectorState } from "./state.js"; + // Local config functions export { readLocalConnectors, diff --git a/src/core/connectors/state.ts b/src/core/connectors/state.ts new file mode 100644 index 00000000..155f83aa --- /dev/null +++ b/src/core/connectors/state.ts @@ -0,0 +1,21 @@ +import { readLocalConnectors } from "./config.js"; +import { listConnectors } from "./api.js"; +import type { LocalConnector } from "./config.js"; +import type { Connector } from "./schema.js"; + +export interface ConnectorState { + local: LocalConnector[]; + backend: Connector[]; +} + +/** + * Fetches both local config and backend connectors in parallel. + * Gracefully handles failures by returning empty arrays. + */ +export async function fetchConnectorState(): Promise { + const [local, backend] = await Promise.all([ + readLocalConnectors().catch(() => [] as LocalConnector[]), + listConnectors().catch(() => [] as Connector[]), + ]); + return { local, backend }; +} From e736a7729d02a03a8a3cd443c7bf8e4432a5cd71 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 18:17:28 +0000 Subject: [PATCH 17/20] refactor: Simplify connectors to add/remove only, no local config - Remove list and push commands (only add/remove remain) - Remove local config storage (no connectors.jsonc or config.jsonc property) - Remove unused files: config.ts, state.ts - Simplify remove command to only show backend connectors - Update README to reflect only add/remove commands https://claude.ai/code/session_013jLN9iB2EPcrwsWaUNovsJ --- README.md | 2 - src/cli/commands/connectors/add.ts | 12 +- src/cli/commands/connectors/index.ts | 4 - src/cli/commands/connectors/list.ts | 136 ---------------- src/cli/commands/connectors/push.ts | 215 -------------------------- src/cli/commands/connectors/remove.ts | 107 ++++--------- src/core/connectors/config.ts | 142 ----------------- src/core/connectors/index.ts | 15 -- src/core/connectors/state.ts | 21 --- src/core/project/schema.ts | 9 -- 10 files changed, 35 insertions(+), 628 deletions(-) delete mode 100644 src/cli/commands/connectors/list.ts delete mode 100644 src/cli/commands/connectors/push.ts delete mode 100644 src/core/connectors/config.ts delete mode 100644 src/core/connectors/state.ts diff --git a/README.md b/README.md index ecfeb388..c9751d46 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,6 @@ The CLI will guide you through project setup. For step-by-step tutorials, see th | [`functions deploy`](https://docs.base44.com/developers/references/cli/commands/functions-deploy) | Deploy local functions to Base44 | | [`site deploy`](https://docs.base44.com/developers/references/cli/commands/site-deploy) | Deploy built site files to Base44 hosting | | `connectors add [type]` | Connect an OAuth integration | -| `connectors list` | List all connected integrations | -| `connectors push` | Sync connectors with backend (connect new, remove missing) | | `connectors remove [type]` | Disconnect an integration | diff --git a/src/cli/commands/connectors/add.ts b/src/cli/commands/connectors/add.ts index 8e93b175..229a1f2f 100644 --- a/src/cli/commands/connectors/add.ts +++ b/src/cli/commands/connectors/add.ts @@ -2,13 +2,12 @@ import { Command } from "commander"; import { cancel, log, select, isCancel } from "@clack/prompts"; import { initiateOAuth, - addLocalConnector, waitForOAuthCompletion, SUPPORTED_INTEGRATIONS, isValidIntegration, getIntegrationDisplayName, -} from "@core/connectors/index.js"; -import type { IntegrationType } from "@core/connectors/index.js"; +} from "@/core/connectors/index.js"; +import type { IntegrationType } from "@/core/connectors/index.js"; import { runCommand, runTask } from "../../utils/index.js"; import type { RunCommandResult } from "../../utils/runCommand.js"; import { theme } from "../../utils/theme.js"; @@ -82,10 +81,8 @@ export async function addConnector( // Check if already authorized if (initiateResponse.already_authorized) { - // Still add to local config file - await addLocalConnector(selectedType); return { - outroMessage: `Already connected to ${theme.styles.bold(displayName)} (added to config)`, + outroMessage: `Already connected to ${theme.styles.bold(displayName)}`, }; } @@ -116,9 +113,6 @@ export async function addConnector( throw new Error(result.error || "Authorization failed"); } - // Add to local config file after successful OAuth - await addLocalConnector(selectedType); - const accountInfo = result.accountEmail ? ` as ${theme.styles.bold(result.accountEmail)}` : ""; diff --git a/src/cli/commands/connectors/index.ts b/src/cli/commands/connectors/index.ts index 5f175119..a18fc185 100644 --- a/src/cli/commands/connectors/index.ts +++ b/src/cli/commands/connectors/index.ts @@ -1,12 +1,8 @@ import { Command } from "commander"; import { connectorsAddCommand } from "./add.js"; -import { connectorsListCommand } from "./list.js"; -import { connectorsPushCommand } from "./push.js"; import { connectorsRemoveCommand } from "./remove.js"; export const connectorsCommand = new Command("connectors") .description("Manage OAuth connectors") .addCommand(connectorsAddCommand) - .addCommand(connectorsListCommand) - .addCommand(connectorsPushCommand) .addCommand(connectorsRemoveCommand); diff --git a/src/cli/commands/connectors/list.ts b/src/cli/commands/connectors/list.ts deleted file mode 100644 index 2df76316..00000000 --- a/src/cli/commands/connectors/list.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { Command } from "commander"; -import { log } from "@clack/prompts"; -import { - fetchConnectorState, - getIntegrationDisplayName, -} from "@core/connectors/index.js"; -import type { Connector, LocalConnector } from "@core/connectors/index.js"; -import { runCommand, runTask } from "../../utils/index.js"; -import type { RunCommandResult } from "../../utils/runCommand.js"; -import { theme } from "../../utils/theme.js"; - -interface MergedConnector { - type: string; - displayName: string; - inLocal: boolean; - inBackend: boolean; - status?: string; - accountEmail?: string; -} - -function mergeConnectors( - local: LocalConnector[], - backend: Connector[] -): MergedConnector[] { - const merged = new Map(); - - // Add local connectors - for (const connector of local) { - merged.set(connector.type, { - type: connector.type, - displayName: getIntegrationDisplayName(connector.type), - inLocal: true, - inBackend: false, - }); - } - - // Add/update with backend connectors - for (const connector of backend) { - const existing = merged.get(connector.integrationType); - const accountEmail = (connector.accountInfo?.email || connector.accountInfo?.name) ?? undefined; - if (existing) { - existing.inBackend = true; - existing.status = connector.status; - existing.accountEmail = accountEmail; - } else { - merged.set(connector.integrationType, { - type: connector.integrationType, - displayName: getIntegrationDisplayName(connector.integrationType), - inLocal: false, - inBackend: true, - status: connector.status, - accountEmail, - }); - } - } - - return Array.from(merged.values()); -} - -function formatConnectorLine(connector: MergedConnector): string { - const { displayName, inLocal, inBackend, status, accountEmail } = connector; - - // Determine state - const isConnected = inBackend && status?.toLowerCase() === "active"; - const isPending = inLocal && !inBackend; - const isOrphaned = inBackend && !inLocal; - - // Build the line - let bullet: string; - let statusText = ""; - - if (isConnected) { - bullet = theme.colors.success("●"); - if (accountEmail) { - statusText = ` - ${accountEmail}`; - } - } else if (isPending) { - bullet = theme.colors.warning("○"); - statusText = theme.styles.dim(" (not connected)"); - } else if (isOrphaned) { - bullet = theme.colors.error("○"); - statusText = theme.styles.dim(" (not in local config)"); - } else { - bullet = theme.colors.error("○"); - statusText = theme.styles.dim(` (${status || "disconnected"})`); - } - - return `${bullet} ${displayName}${statusText}`; -} - -export async function listConnectorsCommand(): Promise { - // Fetch both local and backend connectors in parallel - const { local: localConnectors, backend: backendConnectors } = await runTask( - "Fetching connectors...", - fetchConnectorState, - { - successMessage: "Connectors loaded", - errorMessage: "Failed to fetch connectors", - } - ); - - const merged = mergeConnectors(localConnectors, backendConnectors); - - if (merged.length === 0) { - log.info("No connectors configured for this app."); - log.info(`Run ${theme.styles.bold("base44 connectors add")} to connect an integration.`); - return { outroMessage: "" }; - } - - console.log(); - for (const connector of merged) { - console.log(formatConnectorLine(connector)); - } - console.log(); - - // Summary - const connected = merged.filter((c) => c.inBackend && c.status?.toLowerCase() === "active").length; - const pending = merged.filter((c) => c.inLocal && !c.inBackend).length; - - let summary = `${connected} connected`; - if (pending > 0) { - summary += `, ${pending} pending`; - log.info(`Run ${theme.styles.bold("base44 connectors push")} to connect pending integrations.`); - } - - return { outroMessage: summary }; -} - -export const connectorsListCommand = new Command("list") - .description("List all connected OAuth integrations") - .action(async () => { - await runCommand(listConnectorsCommand, { - requireAuth: true, - requireAppConfig: true, - }); - }); diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts deleted file mode 100644 index 80245639..00000000 --- a/src/cli/commands/connectors/push.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { Command } from "commander"; -import { log, confirm, isCancel } from "@clack/prompts"; -import { - fetchConnectorState, - initiateOAuth, - waitForOAuthCompletion, - disconnectConnector, - getIntegrationDisplayName, - isValidIntegration, -} from "@core/connectors/index.js"; -import type { IntegrationType, Connector, LocalConnector } from "@core/connectors/index.js"; -import { runCommand, runTask } from "../../utils/index.js"; -import type { RunCommandResult } from "../../utils/runCommand.js"; -import { theme } from "../../utils/theme.js"; - -interface PendingConnector { - type: IntegrationType; - displayName: string; - scopes?: string[]; -} - -interface OrphanedConnector { - type: IntegrationType; - displayName: string; - accountEmail?: string; -} - -function findPendingConnectors( - local: LocalConnector[], - backend: Connector[] -): PendingConnector[] { - const connectedTypes = new Set( - backend - .filter((c) => c.status.toLowerCase() === "active") - .map((c) => c.integrationType) - ); - - return local - .filter((c) => !connectedTypes.has(c.type)) - .map((c) => ({ - type: c.type, - displayName: getIntegrationDisplayName(c.type), - scopes: c.scopes, - })); -} - -function findOrphanedConnectors( - local: LocalConnector[], - backend: Connector[] -): OrphanedConnector[] { - const localTypes = new Set(local.map((c) => c.type)); - - return backend - .filter((c) => c.status.toLowerCase() === "active") - .filter((c) => !localTypes.has(c.integrationType)) - .filter((c) => isValidIntegration(c.integrationType)) - .map((c) => ({ - type: c.integrationType, - displayName: getIntegrationDisplayName(c.integrationType), - accountEmail: (c.accountInfo?.email || c.accountInfo?.name) ?? undefined, - })); -} - -async function connectSingleConnector( - connector: PendingConnector -): Promise<{ success: boolean; accountEmail?: string; error?: string }> { - const { type, displayName, scopes } = connector; - - // Initiate OAuth - const initiateResponse = await initiateOAuth(type, scopes || null); - - // Check if already authorized - if (initiateResponse.already_authorized) { - return { success: true }; - } - - // Check if connected by different user - if (initiateResponse.error === "different_user") { - return { - success: false, - error: `Already connected by ${initiateResponse.other_user_email}`, - }; - } - - // Validate we have required fields - if (!initiateResponse.redirect_url || !initiateResponse.connection_id) { - return { success: false, error: "Invalid response from server" }; - } - - // Show authorization URL - log.info( - `Please authorize ${displayName} at:\n${theme.colors.links(initiateResponse.redirect_url)}` - ); - - // Poll for completion using shared utility - return await runTask( - "Waiting for authorization...", - async () => { - return await waitForOAuthCompletion(type, initiateResponse.connection_id!); - }, - { - successMessage: "Authorization completed!", - errorMessage: "Authorization failed", - } - ); -} - -export async function pushConnectorsCommand(): Promise { - // Fetch both local and backend connectors - const { local: localConnectors, backend: backendConnectors } = await runTask( - "Checking connector status...", - fetchConnectorState, - { - successMessage: "Status checked", - errorMessage: "Failed to check status", - } - ); - - const pending = findPendingConnectors(localConnectors, backendConnectors); - const orphaned = findOrphanedConnectors(localConnectors, backendConnectors); - - // Nothing to do - if (pending.length === 0 && orphaned.length === 0) { - return { outroMessage: "All connectors are in sync" }; - } - - // Show what will happen - console.log(); - - if (pending.length > 0) { - log.info(`${pending.length} connector${pending.length === 1 ? "" : "s"} to connect:`); - for (const c of pending) { - console.log(` ${theme.colors.success("+")} ${c.displayName}`); - } - } - - if (orphaned.length > 0) { - log.info(`${orphaned.length} connector${orphaned.length === 1 ? "" : "s"} to remove:`); - for (const c of orphaned) { - const accountInfo = c.accountEmail ? ` (${c.accountEmail})` : ""; - console.log(` ${theme.colors.error("-")} ${c.displayName}${accountInfo}`); - } - } - - console.log(); - - // Confirm - const totalChanges = pending.length + orphaned.length; - const shouldProceed = await confirm({ - message: `Apply ${totalChanges} change${totalChanges === 1 ? "" : "s"}?`, - initialValue: true, - }); - - if (isCancel(shouldProceed) || !shouldProceed) { - return { outroMessage: "Cancelled" }; - } - - let connected = 0; - let removed = 0; - let failed = 0; - - // Remove orphaned connectors first - for (const connector of orphaned) { - try { - await disconnectConnector(connector.type); - log.success(`Removed ${connector.displayName}`); - removed++; - } catch (err) { - log.error(`Failed to remove ${connector.displayName}: ${err instanceof Error ? err.message : "Unknown error"}`); - failed++; - } - } - - // Connect pending connectors - for (const connector of pending) { - console.log(); - log.info(`Connecting ${theme.styles.bold(connector.displayName)}...`); - - const result = await connectSingleConnector(connector); - - if (result.success) { - const accountInfo = result.accountEmail ? ` as ${result.accountEmail}` : ""; - log.success(`${connector.displayName} connected${accountInfo}`); - connected++; - } else { - log.error(`${connector.displayName} failed: ${result.error}`); - failed++; - } - } - - console.log(); - - // Build summary - const parts: string[] = []; - if (connected > 0) { - parts.push(`${connected} connected`); - } - if (removed > 0) { - parts.push(`${removed} removed`); - } - if (failed > 0) { - parts.push(`${failed} failed`); - } - - return { outroMessage: parts.join(", ") }; -} - -export const connectorsPushCommand = new Command("push") - .description("Sync connectors with backend (connect new, remove missing)") - .action(async () => { - await runCommand(pushConnectorsCommand, { - requireAuth: true, - requireAppConfig: true, - }); - }); diff --git a/src/cli/commands/connectors/remove.ts b/src/cli/commands/connectors/remove.ts index d0b918e3..22a2dc1f 100644 --- a/src/cli/commands/connectors/remove.ts +++ b/src/cli/commands/connectors/remove.ts @@ -1,14 +1,13 @@ import { Command } from "commander"; import { cancel, confirm, select, isCancel } from "@clack/prompts"; import { - fetchConnectorState, - removeLocalConnector, + listConnectors, disconnectConnector, removeConnector, isValidIntegration, getIntegrationDisplayName, -} from "@core/connectors/index.js"; -import type { IntegrationType, Connector, LocalConnector } from "@core/connectors/index.js"; +} from "@/core/connectors/index.js"; +import type { IntegrationType, Connector } from "@/core/connectors/index.js"; import { runCommand, runTask } from "../../utils/index.js"; import type { RunCommandResult } from "../../utils/runCommand.js"; import { theme } from "../../utils/theme.js"; @@ -18,80 +17,45 @@ interface RemoveOptions { yes?: boolean; } -interface MergedConnector { +interface ConnectorInfo { type: IntegrationType; displayName: string; - inLocal: boolean; - inBackend: boolean; accountEmail?: string; } -function mergeConnectorsForRemoval( - local: LocalConnector[], - backend: Connector[] -): MergedConnector[] { - const merged = new Map(); - - // Add local connectors - for (const connector of local) { - merged.set(connector.type, { - type: connector.type, - displayName: getIntegrationDisplayName(connector.type), - inLocal: true, - inBackend: false, - }); - } - - // Add/update with backend connectors - for (const connector of backend) { - if (!isValidIntegration(connector.integrationType)) { - continue; - } - - const existing = merged.get(connector.integrationType); - const accountEmail = (connector.accountInfo?.email || connector.accountInfo?.name) ?? undefined; - if (existing) { - existing.inBackend = true; - existing.accountEmail = accountEmail; - } else { - merged.set(connector.integrationType, { - type: connector.integrationType, - displayName: getIntegrationDisplayName(connector.integrationType), - inLocal: false, - inBackend: true, - accountEmail, - }); - } - } - - return Array.from(merged.values()); +function mapBackendConnectors(connectors: Connector[]): ConnectorInfo[] { + return connectors + .filter((c) => isValidIntegration(c.integrationType)) + .map((c) => ({ + type: c.integrationType, + displayName: getIntegrationDisplayName(c.integrationType), + accountEmail: (c.accountInfo?.email || c.accountInfo?.name) ?? undefined, + })); } function validateConnectorType( type: string, - merged: MergedConnector[] -): { type: IntegrationType; connector: MergedConnector } { + connectors: ConnectorInfo[] +): ConnectorInfo { if (!isValidIntegration(type)) { throw new Error(`Invalid connector type: ${type}`); } - const connector = merged.find((c) => c.type === type); + const connector = connectors.find((c) => c.type === type); if (!connector) { throw new Error(`No ${getIntegrationDisplayName(type)} connector found`); } - return { type, connector }; + return connector; } async function promptForConnectorToRemove( - connectors: MergedConnector[] -): Promise<{ type: IntegrationType; connector: MergedConnector }> { + connectors: ConnectorInfo[] +): Promise { const options = connectors.map((c) => { let label = c.displayName; if (c.accountEmail) { label += ` (${c.accountEmail})`; - } else if (c.inLocal && !c.inBackend) { - label += " (not connected)"; } return { value: c.type, @@ -109,8 +73,7 @@ async function promptForConnectorToRemove( process.exit(0); } - const connector = connectors.find((c) => c.type === selected)!; - return { type: selected, connector }; + return connectors.find((c) => c.type === selected)!; } export async function removeConnectorCommand( @@ -119,30 +82,30 @@ export async function removeConnectorCommand( ): Promise { const isHardDelete = options.hard === true; - // Fetch both local and backend connectors - const { local: localConnectors, backend: backendConnectors } = await runTask( + // Fetch backend connectors + const backendConnectors = await runTask( "Fetching connectors...", - fetchConnectorState, + async () => listConnectors(), { successMessage: "Connectors loaded", errorMessage: "Failed to fetch connectors", } ); - const merged = mergeConnectorsForRemoval(localConnectors, backendConnectors); + const connectors = mapBackendConnectors(backendConnectors); - if (merged.length === 0) { + if (connectors.length === 0) { return { outroMessage: "No connectors to remove", }; } // Get type from argument or prompt - const { type: selectedType, connector: selectedConnector } = integrationType - ? validateConnectorType(integrationType, merged) - : await promptForConnectorToRemove(merged); + const selectedConnector = integrationType + ? validateConnectorType(integrationType, connectors) + : await promptForConnectorToRemove(connectors); - const displayName = getIntegrationDisplayName(selectedType); + const displayName = selectedConnector.displayName; const accountInfo = selectedConnector.accountEmail ? ` (${selectedConnector.accountEmail})` : ""; @@ -165,17 +128,11 @@ export async function removeConnectorCommand( await runTask( `Removing ${displayName}...`, async () => { - // Remove from backend if it exists there - if (selectedConnector.inBackend) { - if (isHardDelete) { - await removeConnector(selectedType); - } else { - await disconnectConnector(selectedType); - } + if (isHardDelete) { + await removeConnector(selectedConnector.type); + } else { + await disconnectConnector(selectedConnector.type); } - - // Remove from local config - await removeLocalConnector(selectedType); }, { successMessage: `${displayName} removed`, diff --git a/src/core/connectors/config.ts b/src/core/connectors/config.ts deleted file mode 100644 index ed4d3325..00000000 --- a/src/core/connectors/config.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { findProjectRoot } from "../project/config.js"; -import { readJsonFile, writeJsonFile } from "../utils/fs.js"; -import { isValidIntegration } from "./constants.js"; -import type { IntegrationType } from "./constants.js"; -import type { ConnectorsConfig } from "../project/schema.js"; - -/** - * Parsed connector with its type - */ -export interface LocalConnector { - type: IntegrationType; - scopes?: string[]; -} - -/** - * Read all connectors from the project config file - */ -export async function readLocalConnectors( - projectRoot?: string -): Promise { - const found = await findProjectRoot(projectRoot); - - if (!found) { - return []; - } - - const parsed = (await readJsonFile(found.configPath)) as Record; - const connectorsData = parsed.connectors as ConnectorsConfig | undefined; - - if (!connectorsData) { - return []; - } - - const connectors: LocalConnector[] = []; - - for (const [type, config] of Object.entries(connectorsData)) { - if (!isValidIntegration(type)) { - throw new Error(`Unknown connector type: ${type}`); - } - - connectors.push({ - type, - scopes: config.scopes, - }); - } - - return connectors; -} - -/** - * Write connectors to the project config file - */ -export async function writeLocalConnectors( - connectors: LocalConnector[], - projectRoot?: string -): Promise { - const found = await findProjectRoot(projectRoot); - - if (!found) { - throw new Error("Project config not found. Run this command from a Base44 project directory."); - } - - // Read existing config to preserve other fields - const existingConfig = (await readJsonFile(found.configPath)) as Record; - - // Build connectors object - const connectorsData: ConnectorsConfig = {}; - - for (const connector of connectors) { - connectorsData[connector.type] = { - ...(connector.scopes && { scopes: connector.scopes }), - }; - } - - // Update config with new connectors - const updatedConfig = { - ...existingConfig, - connectors: Object.keys(connectorsData).length > 0 ? connectorsData : undefined, - }; - - // Remove connectors key if empty - if (!updatedConfig.connectors) { - delete updatedConfig.connectors; - } - - await writeJsonFile(found.configPath, updatedConfig); - - return found.configPath; -} - -/** - * Add a connector to the project config file - */ -export async function addLocalConnector( - type: IntegrationType, - scopes?: string[], - projectRoot?: string -): Promise { - const connectors = await readLocalConnectors(projectRoot); - - // Check if already exists - const existing = connectors.find((c) => c.type === type); - if (existing) { - // Update scopes if provided - if (scopes) { - existing.scopes = scopes; - } - } else { - connectors.push({ type, scopes }); - } - - return await writeLocalConnectors(connectors, projectRoot); -} - -/** - * Remove a connector from the project config file - */ -export async function removeLocalConnector( - type: IntegrationType, - projectRoot?: string -): Promise { - const connectors = await readLocalConnectors(projectRoot); - const filtered = connectors.filter((c) => c.type !== type); - - if (filtered.length === connectors.length) { - // Connector wasn't in the config - return null; - } - - return await writeLocalConnectors(filtered, projectRoot); -} - -/** - * Check if a connector exists in the project config file - */ -export async function hasLocalConnector( - type: IntegrationType, - projectRoot?: string -): Promise { - const connectors = await readLocalConnectors(projectRoot); - return connectors.some((c) => c.type === type); -} diff --git a/src/core/connectors/index.ts b/src/core/connectors/index.ts index 2a4002ff..b9945a21 100644 --- a/src/core/connectors/index.ts +++ b/src/core/connectors/index.ts @@ -11,21 +11,6 @@ export { export { waitForOAuthCompletion } from "./oauth.js"; export type { OAuthCompletionResult } from "./oauth.js"; -// State utilities -export { fetchConnectorState } from "./state.js"; -export type { ConnectorState } from "./state.js"; - -// Local config functions -export { - readLocalConnectors, - writeLocalConnectors, - addLocalConnector, - removeLocalConnector, - hasLocalConnector, -} from "./config.js"; - -export type { LocalConnector } from "./config.js"; - // Schemas and types export { InitiateResponseSchema, diff --git a/src/core/connectors/state.ts b/src/core/connectors/state.ts deleted file mode 100644 index 155f83aa..00000000 --- a/src/core/connectors/state.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { readLocalConnectors } from "./config.js"; -import { listConnectors } from "./api.js"; -import type { LocalConnector } from "./config.js"; -import type { Connector } from "./schema.js"; - -export interface ConnectorState { - local: LocalConnector[]; - backend: Connector[]; -} - -/** - * Fetches both local config and backend connectors in parallel. - * Gracefully handles failures by returning empty arrays. - */ -export async function fetchConnectorState(): Promise { - const [local, backend] = await Promise.all([ - readLocalConnectors().catch(() => [] as LocalConnector[]), - listConnectors().catch(() => [] as Connector[]), - ]); - return { local, backend }; -} diff --git a/src/core/project/schema.ts b/src/core/project/schema.ts index b318e6cb..18b65045 100644 --- a/src/core/project/schema.ts +++ b/src/core/project/schema.ts @@ -21,25 +21,16 @@ const SiteConfigSchema = z.object({ installCommand: z.string().optional(), }); -const ConnectorConfigSchema = z.object({ - scopes: z.array(z.string()).optional(), -}); - -const ConnectorsConfigSchema = z.record(z.string(), ConnectorConfigSchema); - export const ProjectConfigSchema = z.object({ name: z.string().min(1, "App name cannot be empty"), description: z.string().optional(), site: SiteConfigSchema.optional(), - connectors: ConnectorsConfigSchema.optional(), entitiesDir: z.string().optional().default("entities"), functionsDir: z.string().optional().default("functions"), agentsDir: z.string().optional().default("agents"), }); export type SiteConfig = z.infer; -export type ConnectorConfig = z.infer; -export type ConnectorsConfig = z.infer; export type ProjectConfig = z.infer; export const AppConfigSchema = z.object({ From 28591aca80986909937399013830d373e5889a6c Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 28 Jan 2026 19:45:16 +0000 Subject: [PATCH 18/20] Address PR review comments: refactor connectors architecture - Rename constants.ts to consts.ts - Move OAuth polling from core/ to CLI (utils.ts) - Simplify API error handling (use .parse() directly, remove custom errors) - Add camelCase transforms to schemas - Replace validateIntegrationType with assertValidIntegrationType - Update commands to use Object.entries(INTEGRATION_DISPLAY_NAMES) - Remove initialValue from confirm prompt Co-authored-by: Gonen Jerbi --- src/cli/commands/connectors/add.ts | 48 ++++---- src/cli/commands/connectors/remove.ts | 1 - .../commands/connectors/utils.ts} | 23 +++- src/core/connectors/api.ts | 110 ++---------------- .../connectors/{constants.ts => consts.ts} | 13 +-- src/core/connectors/index.ts | 12 +- src/core/connectors/schema.ts | 32 +++-- src/core/errors.ts | 14 --- 8 files changed, 70 insertions(+), 183 deletions(-) rename src/{core/connectors/oauth.ts => cli/commands/connectors/utils.ts} (68%) rename src/core/connectors/{constants.ts => consts.ts} (76%) diff --git a/src/cli/commands/connectors/add.ts b/src/cli/commands/connectors/add.ts index 229a1f2f..03298e48 100644 --- a/src/cli/commands/connectors/add.ts +++ b/src/cli/commands/connectors/add.ts @@ -2,31 +2,23 @@ import { Command } from "commander"; import { cancel, log, select, isCancel } from "@clack/prompts"; import { initiateOAuth, - waitForOAuthCompletion, SUPPORTED_INTEGRATIONS, - isValidIntegration, + INTEGRATION_DISPLAY_NAMES, getIntegrationDisplayName, } from "@/core/connectors/index.js"; import type { IntegrationType } from "@/core/connectors/index.js"; import { runCommand, runTask } from "../../utils/index.js"; import type { RunCommandResult } from "../../utils/runCommand.js"; import { theme } from "../../utils/theme.js"; - -function validateIntegrationType(type: string): IntegrationType { - if (!isValidIntegration(type)) { - const supportedList = SUPPORTED_INTEGRATIONS.join(", "); - throw new Error( - `Unsupported connector: ${type}\nSupported connectors: ${supportedList}` - ); - } - return type; -} +import { assertValidIntegrationType, waitForOAuthCompletion } from "./utils.js"; async function promptForIntegrationType(): Promise { - const options = SUPPORTED_INTEGRATIONS.map((type) => ({ - value: type, - label: getIntegrationDisplayName(type), - })); + const options = Object.entries(INTEGRATION_DISPLAY_NAMES).map( + ([type, displayName]) => ({ + value: type, + label: displayName, + }) + ); const selected = await select({ message: "Select an integration to connect:", @@ -38,7 +30,7 @@ async function promptForIntegrationType(): Promise { process.exit(0); } - return selected; + return selected as IntegrationType; } async function pollForOAuthCompletion( @@ -61,9 +53,13 @@ export async function addConnector( integrationType?: string ): Promise { // Get type from argument or prompt - const selectedType = integrationType - ? validateIntegrationType(integrationType) - : await promptForIntegrationType(); + let selectedType: IntegrationType; + if (integrationType) { + assertValidIntegrationType(integrationType, SUPPORTED_INTEGRATIONS); + selectedType = integrationType; + } else { + selectedType = await promptForIntegrationType(); + } const displayName = getIntegrationDisplayName(selectedType); @@ -80,33 +76,33 @@ export async function addConnector( ); // Check if already authorized - if (initiateResponse.already_authorized) { + if (initiateResponse.alreadyAuthorized) { return { outroMessage: `Already connected to ${theme.styles.bold(displayName)}`, }; } // Check if connected by different user - if (initiateResponse.error === "different_user" && initiateResponse.other_user_email) { + if (initiateResponse.error === "different_user" && initiateResponse.otherUserEmail) { throw new Error( - `This app is already connected to ${displayName} by ${initiateResponse.other_user_email}` + `This app is already connected to ${displayName} by ${initiateResponse.otherUserEmail}` ); } // Validate we have required fields - if (!initiateResponse.redirect_url || !initiateResponse.connection_id) { + if (!initiateResponse.redirectUrl || !initiateResponse.connectionId) { throw new 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.redirect_url)}` + `Please authorize ${displayName} at:\n${theme.colors.links(initiateResponse.redirectUrl)}` ); // Poll for completion const result = await pollForOAuthCompletion( selectedType, - initiateResponse.connection_id + initiateResponse.connectionId ); if (!result.success) { diff --git a/src/cli/commands/connectors/remove.ts b/src/cli/commands/connectors/remove.ts index 22a2dc1f..6828b8e9 100644 --- a/src/cli/commands/connectors/remove.ts +++ b/src/cli/commands/connectors/remove.ts @@ -115,7 +115,6 @@ export async function removeConnectorCommand( const actionWord = isHardDelete ? "Permanently remove" : "Remove"; const shouldRemove = await confirm({ message: `${actionWord} ${displayName}${accountInfo}?`, - initialValue: false, }); if (isCancel(shouldRemove) || !shouldRemove) { diff --git a/src/core/connectors/oauth.ts b/src/cli/commands/connectors/utils.ts similarity index 68% rename from src/core/connectors/oauth.ts rename to src/cli/commands/connectors/utils.ts index 934c4d42..c9dfc54b 100644 --- a/src/core/connectors/oauth.ts +++ b/src/cli/commands/connectors/utils.ts @@ -1,7 +1,9 @@ import pWaitFor from "p-wait-for"; -import { checkOAuthStatus } from "./api.js"; -import { OAUTH_POLL_INTERVAL_MS, OAUTH_POLL_TIMEOUT_MS } from "./constants.js"; -import type { IntegrationType } from "./constants.js"; +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; @@ -56,3 +58,18 @@ export async function waitForOAuthCompletion( 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/core/connectors/api.ts b/src/core/connectors/api.ts index fb236036..da965065 100644 --- a/src/core/connectors/api.ts +++ b/src/core/connectors/api.ts @@ -1,17 +1,15 @@ import { getAppClient } from "../clients/index.js"; -import { ConnectorApiError, ConnectorValidationError } from "../errors.js"; import { InitiateResponseSchema, StatusResponseSchema, ListResponseSchema, - ApiErrorSchema, } from "./schema.js"; import type { InitiateResponse, StatusResponse, Connector, } from "./schema.js"; -import type { IntegrationType } from "./constants.js"; +import type { IntegrationType } from "./consts.js"; /** * Initiates OAuth flow for a connector integration. @@ -28,30 +26,10 @@ export async function initiateOAuth( integration_type: integrationType, scopes, }, - throwHttpErrors: false, }); const json = await response.json(); - - if (!response.ok) { - const errorResult = ApiErrorSchema.safeParse(json); - if (errorResult.success) { - throw new ConnectorApiError(errorResult.data.error); - } - throw new ConnectorApiError( - `Failed to initiate OAuth: ${response.status} ${response.statusText}` - ); - } - - const result = InitiateResponseSchema.safeParse(json); - - if (!result.success) { - throw new ConnectorValidationError( - `Invalid initiate response from server: ${result.error.message}` - ); - } - - return result.data; + return InitiateResponseSchema.parse(json); } /** @@ -68,30 +46,10 @@ export async function checkOAuthStatus( integration_type: integrationType, connection_id: connectionId, }, - throwHttpErrors: false, }); const json = await response.json(); - - if (!response.ok) { - const errorResult = ApiErrorSchema.safeParse(json); - if (errorResult.success) { - throw new ConnectorApiError(errorResult.data.error); - } - throw new ConnectorApiError( - `Failed to check OAuth status: ${response.status} ${response.statusText}` - ); - } - - const result = StatusResponseSchema.safeParse(json); - - if (!result.success) { - throw new ConnectorValidationError( - `Invalid status response from server: ${result.error.message}` - ); - } - - return result.data; + return StatusResponseSchema.parse(json); } /** @@ -100,31 +58,11 @@ export async function checkOAuthStatus( export async function listConnectors(): Promise { const appClient = getAppClient(); - const response = await appClient.get("external-auth/list", { - throwHttpErrors: false, - }); + const response = await appClient.get("external-auth/list"); const json = await response.json(); - - if (!response.ok) { - const errorResult = ApiErrorSchema.safeParse(json); - if (errorResult.success) { - throw new ConnectorApiError(errorResult.data.error); - } - throw new ConnectorApiError( - `Failed to list connectors: ${response.status} ${response.statusText}` - ); - } - - const result = ListResponseSchema.safeParse(json); - - if (!result.success) { - throw new ConnectorValidationError( - `Invalid list response from server: ${result.error.message}` - ); - } - - return result.data.integrations; + const result = ListResponseSchema.parse(json); + return result.integrations; } /** @@ -135,23 +73,7 @@ export async function disconnectConnector( ): Promise { const appClient = getAppClient(); - const response = await appClient.delete( - `external-auth/integrations/${integrationType}`, - { - throwHttpErrors: false, - } - ); - - if (!response.ok) { - const json = await response.json(); - const errorResult = ApiErrorSchema.safeParse(json); - if (errorResult.success) { - throw new ConnectorApiError(errorResult.data.error); - } - throw new ConnectorApiError( - `Failed to disconnect connector: ${response.status} ${response.statusText}` - ); - } + await appClient.delete(`external-auth/integrations/${integrationType}`); } /** @@ -163,22 +85,8 @@ export async function removeConnector( ): Promise { const appClient = getAppClient(); - const response = await appClient.delete( - `external-auth/integrations/${integrationType}/remove`, - { - throwHttpErrors: false, - } + await appClient.delete( + `external-auth/integrations/${integrationType}/remove` ); - - if (!response.ok) { - const json = await response.json(); - const errorResult = ApiErrorSchema.safeParse(json); - if (errorResult.success) { - throw new ConnectorApiError(errorResult.data.error); - } - throw new ConnectorApiError( - `Failed to remove connector: ${response.status} ${response.statusText}` - ); - } } diff --git a/src/core/connectors/constants.ts b/src/core/connectors/consts.ts similarity index 76% rename from src/core/connectors/constants.ts rename to src/core/connectors/consts.ts index 3242dbe4..d1364ae1 100644 --- a/src/core/connectors/constants.ts +++ b/src/core/connectors/consts.ts @@ -1,9 +1,3 @@ -/** - * OAuth polling configuration - */ -export const OAUTH_POLL_INTERVAL_MS = 2000; -export const OAUTH_POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes - /** * Supported OAuth connector integrations. * Based on apper/backend/app/external_auth/models/constants.py @@ -48,9 +42,6 @@ export function isValidIntegration(type: string): type is IntegrationType { return SUPPORTED_INTEGRATIONS.includes(type as IntegrationType); } -export function getIntegrationDisplayName(type: string): string { - if (isValidIntegration(type)) { - return INTEGRATION_DISPLAY_NAMES[type]; - } - return type; +export function getIntegrationDisplayName(type: IntegrationType): string { + return INTEGRATION_DISPLAY_NAMES[type] ?? type; } diff --git a/src/core/connectors/index.ts b/src/core/connectors/index.ts index b9945a21..e178e888 100644 --- a/src/core/connectors/index.ts +++ b/src/core/connectors/index.ts @@ -7,17 +7,12 @@ export { removeConnector, } from "./api.js"; -// OAuth flow utilities -export { waitForOAuthCompletion } from "./oauth.js"; -export type { OAuthCompletionResult } from "./oauth.js"; - // Schemas and types export { InitiateResponseSchema, StatusResponseSchema, ConnectorSchema, ListResponseSchema, - ApiErrorSchema, } from "./schema.js"; export type { @@ -25,17 +20,14 @@ export type { StatusResponse, Connector, ListResponse, - ApiError, } from "./schema.js"; // Constants export { - OAUTH_POLL_INTERVAL_MS, - OAUTH_POLL_TIMEOUT_MS, SUPPORTED_INTEGRATIONS, INTEGRATION_DISPLAY_NAMES, isValidIntegration, getIntegrationDisplayName, -} from "./constants.js"; +} from "./consts.js"; -export type { IntegrationType } from "./constants.js"; +export type { IntegrationType } from "./consts.js"; diff --git a/src/core/connectors/schema.ts b/src/core/connectors/schema.ts index 0bc75c9d..3c2ad341 100644 --- a/src/core/connectors/schema.ts +++ b/src/core/connectors/schema.ts @@ -3,13 +3,21 @@ 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(), -}); +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; @@ -62,13 +70,3 @@ export const ListResponseSchema = z.object({ }); export type ListResponse = z.infer; - -/** - * Generic API error response - */ -export const ApiErrorSchema = z.object({ - error: z.string(), - detail: z.string().nullish(), -}); - -export type ApiError = z.infer; diff --git a/src/core/errors.ts b/src/core/errors.ts index 7c0977c3..c9a64640 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -11,17 +11,3 @@ export class AuthValidationError extends Error { this.name = "AuthValidationError"; } } - -export class ConnectorApiError extends Error { - constructor(message: string, public readonly cause?: unknown) { - super(message); - this.name = "ConnectorApiError"; - } -} - -export class ConnectorValidationError extends Error { - constructor(message: string) { - super(message); - this.name = "ConnectorValidationError"; - } -} From 4b4f92ba3e84186dea383dfc030421b7fab17519 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 28 Jan 2026 19:45:36 +0000 Subject: [PATCH 19/20] Remove connector commands from README per review feedback Will be added back by Chris with proper doc links. Co-authored-by: Gonen Jerbi --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index c9751d46..cc1f176b 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,6 @@ The CLI will guide you through project setup. For step-by-step tutorials, see th | [`entities push`](https://docs.base44.com/developers/references/cli/commands/entities-push) | Push local entity schemas to Base44 | | [`functions deploy`](https://docs.base44.com/developers/references/cli/commands/functions-deploy) | Deploy local functions to Base44 | | [`site deploy`](https://docs.base44.com/developers/references/cli/commands/site-deploy) | Deploy built site files to Base44 hosting | -| `connectors add [type]` | Connect an OAuth integration | -| `connectors remove [type]` | Disconnect an integration | From fedb84742c12aa337a85593eaadc986bdf6d3810 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:14:57 +0000 Subject: [PATCH 20/20] refactor: Replace add/remove commands with single push command for connectors This refactoring changes the connector architecture to store connector metadata in config.jsonc and use a single `base44 connectors push` command instead of separate add/remove commands. Key changes: - Added optional `connectors` array to ProjectConfigSchema for storing connector types in config.jsonc - Added category metadata (Communication, Productivity, CRM, Social, Google) to connector constants - Created new `connectors push` command that syncs local config with backend - Removed `connectors add` and `connectors remove` commands - Updated removeConnector API to use correct DELETE endpoint without /remove suffix The push command: - Lists all active connectors from the API - Hard deletes any connector from backend that is no longer defined locally - Skips connectors that are already activated - For new connectors: prompts user for OAuth, polls until activated Co-Authored-By: Claude Sonnet 4.5 --- src/cli/commands/connectors/add.ts | 129 ------------ src/cli/commands/connectors/index.ts | 6 +- src/cli/commands/connectors/push.ts | 275 ++++++++++++++++++++++++++ src/cli/commands/connectors/remove.ts | 157 --------------- src/core/connectors/api.ts | 4 +- src/core/connectors/consts.ts | 27 +++ src/core/project/schema.ts | 1 + 7 files changed, 306 insertions(+), 293 deletions(-) delete mode 100644 src/cli/commands/connectors/add.ts create mode 100644 src/cli/commands/connectors/push.ts delete mode 100644 src/cli/commands/connectors/remove.ts diff --git a/src/cli/commands/connectors/add.ts b/src/cli/commands/connectors/add.ts deleted file mode 100644 index 03298e48..00000000 --- a/src/cli/commands/connectors/add.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Command } from "commander"; -import { cancel, log, select, isCancel } from "@clack/prompts"; -import { - initiateOAuth, - SUPPORTED_INTEGRATIONS, - INTEGRATION_DISPLAY_NAMES, - getIntegrationDisplayName, -} from "@/core/connectors/index.js"; -import type { IntegrationType } from "@/core/connectors/index.js"; -import { runCommand, runTask } from "../../utils/index.js"; -import type { RunCommandResult } from "../../utils/runCommand.js"; -import { theme } from "../../utils/theme.js"; -import { assertValidIntegrationType, waitForOAuthCompletion } from "./utils.js"; - -async function promptForIntegrationType(): Promise { - const options = Object.entries(INTEGRATION_DISPLAY_NAMES).map( - ([type, displayName]) => ({ - value: type, - label: displayName, - }) - ); - - const selected = await select({ - message: "Select an integration to connect:", - options, - }); - - if (isCancel(selected)) { - cancel("Operation cancelled."); - process.exit(0); - } - - return selected as IntegrationType; -} - -async function pollForOAuthCompletion( - integrationType: IntegrationType, - connectionId: string -): Promise<{ success: boolean; accountEmail?: string; error?: string }> { - return await runTask( - "Waiting for authorization...", - async () => { - return await waitForOAuthCompletion(integrationType, connectionId); - }, - { - successMessage: "Authorization completed!", - errorMessage: "Authorization failed", - } - ); -} - -export async function addConnector( - integrationType?: string -): Promise { - // Get type from argument or prompt - let selectedType: IntegrationType; - if (integrationType) { - assertValidIntegrationType(integrationType, SUPPORTED_INTEGRATIONS); - selectedType = integrationType; - } else { - selectedType = await promptForIntegrationType(); - } - - const displayName = getIntegrationDisplayName(selectedType); - - // Initiate OAuth flow - const initiateResponse = await runTask( - `Initiating ${displayName} connection...`, - async () => { - return await initiateOAuth(selectedType); - }, - { - successMessage: `${displayName} OAuth initiated`, - errorMessage: `Failed to initiate ${displayName} connection`, - } - ); - - // Check if already authorized - if (initiateResponse.alreadyAuthorized) { - return { - outroMessage: `Already connected to ${theme.styles.bold(displayName)}`, - }; - } - - // Check if connected by different user - if (initiateResponse.error === "different_user" && initiateResponse.otherUserEmail) { - throw new Error( - `This app is already connected to ${displayName} by ${initiateResponse.otherUserEmail}` - ); - } - - // Validate we have required fields - if (!initiateResponse.redirectUrl || !initiateResponse.connectionId) { - throw new 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 pollForOAuthCompletion( - selectedType, - initiateResponse.connectionId - ); - - if (!result.success) { - throw new Error(result.error || "Authorization failed"); - } - - const accountInfo = result.accountEmail - ? ` as ${theme.styles.bold(result.accountEmail)}` - : ""; - - return { - outroMessage: `Successfully connected to ${theme.styles.bold(displayName)}${accountInfo}`, - }; -} - -export const connectorsAddCommand = new Command("add") - .argument("[type]", "Integration type (e.g., slack, notion, googlecalendar)") - .description("Connect an OAuth integration") - .action(async (type?: string) => { - await runCommand(() => addConnector(type), { - requireAuth: true, - requireAppConfig: true, - }); - }); diff --git a/src/cli/commands/connectors/index.ts b/src/cli/commands/connectors/index.ts index a18fc185..9fa6ac0e 100644 --- a/src/cli/commands/connectors/index.ts +++ b/src/cli/commands/connectors/index.ts @@ -1,8 +1,6 @@ import { Command } from "commander"; -import { connectorsAddCommand } from "./add.js"; -import { connectorsRemoveCommand } from "./remove.js"; +import { connectorsPushCommand } from "./push.js"; export const connectorsCommand = new Command("connectors") .description("Manage OAuth connectors") - .addCommand(connectorsAddCommand) - .addCommand(connectorsRemoveCommand); + .addCommand(connectorsPushCommand); diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts new file mode 100644 index 00000000..6840cdc8 --- /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/remove.ts b/src/cli/commands/connectors/remove.ts deleted file mode 100644 index 6828b8e9..00000000 --- a/src/cli/commands/connectors/remove.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { Command } from "commander"; -import { cancel, confirm, select, isCancel } from "@clack/prompts"; -import { - listConnectors, - disconnectConnector, - removeConnector, - isValidIntegration, - getIntegrationDisplayName, -} from "@/core/connectors/index.js"; -import type { IntegrationType, Connector } from "@/core/connectors/index.js"; -import { runCommand, runTask } from "../../utils/index.js"; -import type { RunCommandResult } from "../../utils/runCommand.js"; -import { theme } from "../../utils/theme.js"; - -interface RemoveOptions { - hard?: boolean; - yes?: boolean; -} - -interface ConnectorInfo { - type: IntegrationType; - displayName: string; - accountEmail?: string; -} - -function mapBackendConnectors(connectors: Connector[]): ConnectorInfo[] { - return connectors - .filter((c) => isValidIntegration(c.integrationType)) - .map((c) => ({ - type: c.integrationType, - displayName: getIntegrationDisplayName(c.integrationType), - accountEmail: (c.accountInfo?.email || c.accountInfo?.name) ?? undefined, - })); -} - -function validateConnectorType( - type: string, - connectors: ConnectorInfo[] -): ConnectorInfo { - if (!isValidIntegration(type)) { - throw new Error(`Invalid connector type: ${type}`); - } - - const connector = connectors.find((c) => c.type === type); - if (!connector) { - throw new Error(`No ${getIntegrationDisplayName(type)} connector found`); - } - - return connector; -} - -async function promptForConnectorToRemove( - connectors: ConnectorInfo[] -): Promise { - const options = connectors.map((c) => { - let label = c.displayName; - if (c.accountEmail) { - label += ` (${c.accountEmail})`; - } - return { - value: c.type, - label, - }; - }); - - const selected = await select({ - message: "Select a connector to remove:", - options, - }); - - if (isCancel(selected)) { - cancel("Operation cancelled."); - process.exit(0); - } - - return connectors.find((c) => c.type === selected)!; -} - -export async function removeConnectorCommand( - integrationType?: string, - options: RemoveOptions = {} -): Promise { - const isHardDelete = options.hard === true; - - // Fetch backend connectors - const backendConnectors = await runTask( - "Fetching connectors...", - async () => listConnectors(), - { - successMessage: "Connectors loaded", - errorMessage: "Failed to fetch connectors", - } - ); - - const connectors = mapBackendConnectors(backendConnectors); - - if (connectors.length === 0) { - return { - outroMessage: "No connectors to remove", - }; - } - - // Get type from argument or prompt - const selectedConnector = integrationType - ? validateConnectorType(integrationType, connectors) - : await promptForConnectorToRemove(connectors); - - const displayName = selectedConnector.displayName; - const accountInfo = selectedConnector.accountEmail - ? ` (${selectedConnector.accountEmail})` - : ""; - - // Confirm removal (skip if --yes flag is provided) - if (!options.yes) { - const actionWord = isHardDelete ? "Permanently remove" : "Remove"; - const shouldRemove = await confirm({ - message: `${actionWord} ${displayName}${accountInfo}?`, - }); - - if (isCancel(shouldRemove) || !shouldRemove) { - cancel("Operation cancelled."); - process.exit(0); - } - } - - // Perform removal - await runTask( - `Removing ${displayName}...`, - async () => { - if (isHardDelete) { - await removeConnector(selectedConnector.type); - } else { - await disconnectConnector(selectedConnector.type); - } - }, - { - successMessage: `${displayName} removed`, - errorMessage: `Failed to remove ${displayName}`, - } - ); - - return { - outroMessage: `Successfully removed ${theme.styles.bold(displayName)}`, - }; -} - -export const connectorsRemoveCommand = new Command("remove") - .argument("[type]", "Integration type to remove (e.g., slack, notion)") - .option("--hard", "Permanently remove the connector (cannot be undone)") - .option("-y, --yes", "Skip confirmation prompt") - .description("Remove an OAuth integration") - .action(async (type: string | undefined, options: RemoveOptions) => { - await runCommand(() => removeConnectorCommand(type, options), { - requireAuth: true, - requireAppConfig: true, - }); - }); diff --git a/src/core/connectors/api.ts b/src/core/connectors/api.ts index da965065..33483068 100644 --- a/src/core/connectors/api.ts +++ b/src/core/connectors/api.ts @@ -85,8 +85,6 @@ export async function removeConnector( ): Promise { const appClient = getAppClient(); - await appClient.delete( - `external-auth/integrations/${integrationType}/remove` - ); + await appClient.delete(`external-auth/integrations/${integrationType}`); } diff --git a/src/core/connectors/consts.ts b/src/core/connectors/consts.ts index d1364ae1..c17945c3 100644 --- a/src/core/connectors/consts.ts +++ b/src/core/connectors/consts.ts @@ -20,6 +20,11 @@ export const SUPPORTED_INTEGRATIONS = [ export type IntegrationType = (typeof SUPPORTED_INTEGRATIONS)[number]; +/** + * Connector categories + */ +export type ConnectorCategory = "Communication" | "Productivity" | "CRM" | "Social" | "Google"; + /** * Display names for integrations (for CLI output) */ @@ -38,6 +43,24 @@ export const INTEGRATION_DISPLAY_NAMES: Record = { 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); } @@ -45,3 +68,7 @@ export function isValidIntegration(type: string): type is 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/project/schema.ts b/src/core/project/schema.ts index 18b65045..7896893a 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;