Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
fedb847
refactor: Replace add/remove commands with single push command for co…
github-actions[bot] Jan 29, 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
6 changes: 6 additions & 0 deletions src/cli/commands/connectors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Command } from "commander";
import { connectorsPushCommand } from "./push.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(connectorsPushCommand);
275 changes: 275 additions & 0 deletions src/cli/commands/connectors/push.ts
Original file line number Diff line number Diff line change
@@ -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";

Check warning on line 10 in src/cli/commands/connectors/push.ts

View workflow job for this annotation

GitHub Actions / lint

'Connector' is defined but never used. Allowed unused vars must match /^_/u
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<IntegrationType[]> {
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);

Check failure on line 42 in src/cli/commands/connectors/push.ts

View workflow job for this annotation

GitHub Actions / lint

This assertion is unnecessary since it does not change the type of the expression
} 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<Set<IntegrationType>> {
const connectors = await runTask(
"Fetching active connectors...",
async () => listConnectors(),
{
successMessage: "Active connectors loaded",
errorMessage: "Failed to fetch connectors",
}
);

const activeConnectors = new Set<IntegrationType>();

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<void> {
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<RunCommandResult> {
// 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(", ")}`);

Check failure on line 204 in src/cli/commands/connectors/push.ts

View workflow job for this annotation

GitHub Actions / typecheck

Property 'dim' does not exist on type '{ base44Orange: ChalkInstance; base44OrangeBackground: ChalkInstance; shinyOrange: ChalkInstance; links: ChalkInstance; white: ChalkInstance; success: ChalkInstance; warning: ChalkInstance; error: ChalkInstance; }'.
}

// 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,
});
});
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 };
9 changes: 6 additions & 3 deletions src/cli/utils/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
Loading
Loading