Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cbdf7c4
feat: Add OAuth connectors management commands
claude Jan 23, 2026
d8a7418
fix: Address code review feedback
claude Jan 24, 2026
5790f15
feat: Add --hard flag for permanent connector removal
claude Jan 24, 2026
276f74c
refactor: Simplify connectors list to use simple format
claude Jan 24, 2026
81b48bc
docs: Add connectors commands to README
claude Jan 24, 2026
82df351
feat: Add local connectors.jsonc config file support
claude Jan 24, 2026
31d0fcc
fix: Handle null values in API responses
claude Jan 24, 2026
55d53ce
fix: Show OAuth URL instead of auto-opening browser
claude Jan 24, 2026
9f12d4d
refactor: Use space-separated connectors commands like entities
claude Jan 24, 2026
cb4332f
refactor: Move connectors config into main config.jsonc
claude Jan 24, 2026
9aa2e77
feat: Add removal support to connectors push command
claude Jan 24, 2026
710a296
refactor: Use runTask wrapper for OAuth polling in push command
claude Jan 25, 2026
fd39557
refactor: Move OAuth polling constants to shared constants.ts
claude Jan 25, 2026
29af2a0
refactor: Follow interactive/non-interactive command pattern
claude Jan 25, 2026
4b9a80b
refactor: Extract shared OAuth polling utility
claude Jan 25, 2026
e6c3f3f
refactor: Extract shared fetchConnectorState utility
claude Jan 25, 2026
e736a77
refactor: Simplify connectors to add/remove only, no local config
claude Jan 28, 2026
28591ac
Address PR review comments: refactor connectors architecture
github-actions[bot] Jan 28, 2026
4b4f92b
Remove connector commands from README per review feedback
github-actions[bot] Jan 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions src/cli/commands/connectors/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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<IntegrationType> {
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just noting that i don't like that some commands use process.exit(0) in the middle of the code, can cause unexpected behavior (for tests for example)
We need to have a standard way to stop execution (maybe throw an Exception that is caught and stops the execution)

}

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<RunCommandResult> {
// 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)")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a new behavior, do we want the user / agent write base44 connectors add google?
Up untill now all of our commands we either showing the user a nice Select list (just running base44 connectors add and see the list) and we give the Agent the option to use flags to pass the integration their interested in.. wdyt?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just continued to read the code and i see you do prompt to the user if no argument specified.. cool 👍, i still think we should maybe align the flags since this is the behavior so far

.description("Connect an OAuth integration")
.action(async (type?: string) => {
await runCommand(() => addConnector(type), {
requireAuth: true,
requireAppConfig: true,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

default is already true

});
});
8 changes: 8 additions & 0 deletions src/cli/commands/connectors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Command } from "commander";
import { connectorsAddCommand } from "./add.js";
import { connectorsRemoveCommand } from "./remove.js";

export const connectorsCommand = new Command("connectors")
.description("Manage OAuth connectors")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: Is this the right description for the command? might be worth talking to Chris

.addCommand(connectorsAddCommand)
.addCommand(connectorsRemoveCommand);
157 changes: 157 additions & 0 deletions src/cli/commands/connectors/remove.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
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

Check failure on line 27 in src/cli/commands/connectors/remove.ts

View workflow job for this annotation

GitHub Actions / typecheck

Type '{ type: string; displayName: string; accountEmail: string | undefined; }[]' is not assignable to type 'ConnectorInfo[]'.
.filter((c) => isValidIntegration(c.integrationType))
.map((c) => ({
type: c.integrationType,
displayName: getIntegrationDisplayName(c.integrationType),

Check failure on line 31 in src/cli/commands/connectors/remove.ts

View workflow job for this annotation

GitHub Actions / typecheck

Argument of type 'string' is not assignable to parameter of type '"googlecalendar" | "googledrive" | "gmail" | "googlesheets" | "googledocs" | "googleslides" | "slack" | "notion" | "salesforce" | "hubspot" | "linkedin" | "tiktok"'.
accountEmail: (c.accountInfo?.email || c.accountInfo?.name) ?? undefined,
}));
}

function validateConnectorType(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

duplicate function like in add.ts file if i remember correctly, lets move to be reused or remove alltogether

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<ConnectorInfo> {
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<RunCommandResult> {
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,
});
});
75 changes: 75 additions & 0 deletions src/cli/commands/connectors/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import pWaitFor from "p-wait-for";
import { checkOAuthStatus } from "@/core/connectors/api.js";
import type { IntegrationType } from "@/core/connectors/consts.js";

const OAUTH_POLL_INTERVAL_MS = 2000;
const OAUTH_POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes

export interface OAuthCompletionResult {
success: boolean;
accountEmail?: string;
error?: string;
}

/**
* Polls for OAuth completion status.
* Returns when status becomes ACTIVE or FAILED, or times out.
*/
export async function waitForOAuthCompletion(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this function belongs to the core/ folder - doing pWaitFor is a CLI behavior - core package should expose all relevant method (api, config ...) that the CLI can use in order to give the user the right UX.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving this to the CLI will also simplify things like onPending on so on, so try to expose from core/ the right methods that are needed, and everything related to the experince should be in the CLI

integrationType: IntegrationType,
connectionId: string,
options?: {
onPending?: () => void;
}
): Promise<OAuthCompletionResult> {
let accountEmail: string | undefined;
let error: string | undefined;

try {
await pWaitFor(
async () => {
const status = await checkOAuthStatus(integrationType, connectionId);

if (status.status === "ACTIVE") {
accountEmail = status.accountEmail ?? undefined;
return true;
}

if (status.status === "FAILED") {
error = status.error || "Authorization failed";
throw new Error(error);
}

// PENDING - continue polling
options?.onPending?.();
return false;
},
{
interval: OAUTH_POLL_INTERVAL_MS,
timeout: OAUTH_POLL_TIMEOUT_MS,
}
);

return { success: true, accountEmail };
} catch (err) {
if (err instanceof Error && err.message.includes("timed out")) {
return { success: false, error: "Authorization timed out. Please try again." };
}
return { success: false, error: error || (err instanceof Error ? err.message : "Unknown error") };
}
}

/**
* Asserts that a string is a valid integration type, throwing if not.
*/
export function assertValidIntegrationType(
type: string,
supportedIntegrations: readonly string[]
): asserts type is IntegrationType {
if (!supportedIntegrations.includes(type)) {
const supportedList = supportedIntegrations.join(", ");
throw new Error(
`Unsupported connector: ${type}\nSupported connectors: ${supportedList}`
);
}
}
4 changes: 4 additions & 0 deletions src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -48,4 +49,7 @@ program.addCommand(functionsDeployCommand);
// Register site commands
program.addCommand(siteDeployCommand);

// Register connectors commands
program.addCommand(connectorsCommand);

export { program };
Loading
Loading