Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions src/cli/commands/connectors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Command } from "commander";
import type { CLIContext } from "@/cli/types.js";
import { getConnectorsPushCommand } from "./push.js";

export function getConnectorsCommand(context: CLIContext): Command {
return new Command("connectors")
.description("Manage project connectors (OAuth integrations)")
.addCommand(getConnectorsPushCommand(context));
}
161 changes: 161 additions & 0 deletions src/cli/commands/connectors/push.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { confirm, isCancel, log } from "@clack/prompts";
import chalk from "chalk";
import { Command } from "commander";
import type { CLIContext } from "@/cli/types.js";
import { runCommand, runTask } from "@/cli/utils/index.js";
import type { RunCommandResult } from "@/cli/utils/runCommand.js";
import { readProjectConfig } from "@/core/index.js";
import {
type ConnectorOAuthStatus,
type ConnectorSyncResult,
type IntegrationType,
pushConnectors,
runOAuthFlow,
} from "@/core/resources/connector/index.js";

type PendingOAuthResult = ConnectorSyncResult & {
redirectUrl: string;
connectionId: string;
};

function isPendingOAuth(r: ConnectorSyncResult): r is PendingOAuthResult {
return r.action === "needs_oauth" && !!r.redirectUrl && !!r.connectionId;
}

function printSummary(
results: ConnectorSyncResult[],
oauthOutcomes: Map<IntegrationType, ConnectorOAuthStatus>
): void {
const synced: IntegrationType[] = [];
const added: IntegrationType[] = [];
const removed: IntegrationType[] = [];
const failed: { type: IntegrationType; error?: string }[] = [];

for (const r of results) {
const oauthStatus = oauthOutcomes.get(r.type);

if (r.action === "synced") {
synced.push(r.type);
} else if (r.action === "removed") {
removed.push(r.type);
} else if (r.action === "error") {
failed.push({ type: r.type, error: r.error });
} else if (r.action === "needs_oauth") {
if (oauthStatus === "ACTIVE") {
added.push(r.type);
} else if (oauthStatus === "PENDING") {
failed.push({ type: r.type, error: "authorization timed out" });
} else if (oauthStatus === "FAILED") {
failed.push({ type: r.type, error: "authorization failed" });
} else {
failed.push({ type: r.type, error: "needs authorization" });
}
}
}

log.info("");
log.info(chalk.bold("Summary:"));

if (synced.length > 0) {
log.info(chalk.green(` Synced: ${synced.join(", ")}`));
}
if (added.length > 0) {
log.info(chalk.green(` Added: ${added.join(", ")}`));
}
if (removed.length > 0) {
log.info(chalk.dim(` Removed: ${removed.join(", ")}`));
}
for (const r of failed) {
log.info(chalk.red(` Failed: ${r.type}${r.error ? ` - ${r.error}` : ""}`));
}
}

async function pushConnectorsAction(): Promise<RunCommandResult> {
const { connectors } = await readProjectConfig();

if (connectors.length === 0) {
log.info(
"No local connectors found - checking for remote connectors to remove"
);
} else {
const connectorNames = connectors.map((c) => c.type).join(", ");
log.info(
`Found ${connectors.length} connectors to push: ${connectorNames}`
);
}

const { results } = await runTask(
"Pushing connectors to Base44",
async () => {
return await pushConnectors(connectors);
},
{
successMessage: "Connectors pushed",
errorMessage: "Failed to push connectors",
}
);

const oauthOutcomes = new Map<IntegrationType, ConnectorOAuthStatus>();
const needsOAuth = results.filter(isPendingOAuth);
let outroMessage = "Connectors pushed to Base44";

if (needsOAuth.length > 0) {
log.info("");
log.info(
chalk.yellow(
`${needsOAuth.length} connector(s) require authorization in your browser:`
)
);
for (const connector of needsOAuth) {
log.info(` ${connector.type}: ${chalk.dim(connector.redirectUrl)}`);
}

const pending = needsOAuth.map((c) => c.type).join(", ");

if (process.env.CI) {
outroMessage = `Skipped OAuth in CI. Pending: ${pending}. Run 'base44 connectors push' locally to authorize.`;
} else {
const shouldAuth = await confirm({
message: "Open browser to authorize now?",
});

if (isCancel(shouldAuth) || !shouldAuth) {
outroMessage = `Authorization skipped. Pending: ${pending}. Run 'base44 connectors push' again to complete.`;
} else {
for (const connector of needsOAuth) {
log.info(`\nOpening browser for ${connector.type}...`);

const oauthResult = await runTask(
`Waiting for ${connector.type} authorization...`,
async () => {
return await runOAuthFlow({
type: connector.type,
redirectUrl: connector.redirectUrl,
connectionId: connector.connectionId,
});
},
{
successMessage: `${connector.type} authorization complete`,
errorMessage: `${connector.type} authorization failed`,
}
);

oauthOutcomes.set(connector.type, oauthResult.status);
}
}
}
}

printSummary(results, oauthOutcomes);
return { outroMessage };
}

export function getConnectorsPushCommand(context: CLIContext): Command {
return new Command("push")
.description(
"Push local connectors to Base44 (syncs scopes and removes unlisted)"
)
.action(async () => {
await runCommand(pushConnectorsAction, { requireAuth: true }, context);
});
}
4 changes: 4 additions & 0 deletions src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getAgentsCommand } from "@/cli/commands/agents/index.js";
import { getLoginCommand } from "@/cli/commands/auth/login.js";
import { getLogoutCommand } from "@/cli/commands/auth/logout.js";
import { getWhoamiCommand } from "@/cli/commands/auth/whoami.js";
import { getConnectorsCommand } from "@/cli/commands/connectors/index.js";
import { getDashboardCommand } from "@/cli/commands/dashboard/index.js";
import { getEntitiesPushCommand } from "@/cli/commands/entities/push.js";
import { getFunctionsDeployCommand } from "@/cli/commands/functions/deploy.js";
Expand Down Expand Up @@ -44,6 +45,9 @@ export function createProgram(context: CLIContext): Command {
// Register agents commands
program.addCommand(getAgentsCommand(context));

// Register connectors commands
program.addCommand(getConnectorsCommand(context));

// Register functions commands
program.addCommand(getFunctionsDeployCommand(context));

Expand Down
5 changes: 4 additions & 1 deletion src/core/project/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ConfigNotFoundError, SchemaValidationError } from "@/core/errors.js";
import { ProjectConfigSchema } from "@/core/project/schema.js";
import type { ProjectData, ProjectRoot } from "@/core/project/types.js";
import { agentResource } from "@/core/resources/agent/index.js";
import { connectorResource } from "@/core/resources/connector/index.js";
import { entityResource } from "@/core/resources/entity/index.js";
import { functionResource } from "@/core/resources/function/index.js";
import { readJsonFile } from "@/core/utils/fs.js";
Expand Down Expand Up @@ -91,16 +92,18 @@ export async function readProjectConfig(
const project = result.data;
const configDir = dirname(configPath);

const [entities, functions, agents] = await Promise.all([
const [entities, functions, agents, connectors] = await Promise.all([
entityResource.readAll(join(configDir, project.entitiesDir)),
functionResource.readAll(join(configDir, project.functionsDir)),
agentResource.readAll(join(configDir, project.agentsDir)),
connectorResource.readAll(join(configDir, project.connectorsDir)),
]);

return {
project: { ...project, root, configPath },
entities,
functions,
agents,
connectors,
};
}
1 change: 1 addition & 0 deletions src/core/project/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const ProjectConfigSchema = z.object({
entitiesDir: z.string().optional().default("entities"),
functionsDir: z.string().optional().default("functions"),
agentsDir: z.string().optional().default("agents"),
connectorsDir: z.string().optional().default("connectors"),
});

export type SiteConfig = z.infer<typeof SiteConfigSchema>;
Expand Down
2 changes: 2 additions & 0 deletions src/core/project/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ProjectConfig } from "@/core/project/schema.js";
import type { AgentConfig } from "@/core/resources/agent/index.js";
import type { ConnectorResource } from "@/core/resources/connector/index.js";
import type { Entity } from "@/core/resources/entity/index.js";
import type { BackendFunction } from "@/core/resources/function/index.js";

Expand All @@ -18,4 +19,5 @@ export interface ProjectData {
entities: Entity[];
functions: BackendFunction[];
agents: AgentConfig[];
connectors: ConnectorResource[];
}
26 changes: 14 additions & 12 deletions src/core/resources/connector/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import type {
ListConnectorsResponse,
OAuthStatusResponse,
RemoveConnectorResponse,
SyncConnectorResponse,
SetConnectorResponse,
} from "./schema.js";
import {
ListConnectorsResponseSchema,
OAuthStatusResponseSchema,
RemoveConnectorResponseSchema,
SyncConnectorResponseSchema,
SetConnectorResponseSchema,
} from "./schema.js";

/**
Expand Down Expand Up @@ -41,25 +41,27 @@ export async function listConnectors(): Promise<ListConnectorsResponse> {
return result.data;
}

export async function syncConnector(
export async function setConnector(
integrationType: IntegrationType,
scopes: string[]
): Promise<SyncConnectorResponse> {
): Promise<SetConnectorResponse> {
const appClient = getAppClient();

let response: KyResponse;
try {
response = await appClient.post("external-auth/sync", {
json: {
integration_type: integrationType,
scopes,
},
});
response = await appClient.put(
`external-auth/integrations/${integrationType}`,
{
json: {
scopes,
},
}
);
} catch (error) {
throw await ApiError.fromHttpError(error, "syncing connector");
throw await ApiError.fromHttpError(error, "setting connector");
}

const result = SyncConnectorResponseSchema.safeParse(await response.json());
const result = SetConnectorResponseSchema.safeParse(await response.json());

if (!result.success) {
throw new SchemaValidationError(
Expand Down
25 changes: 1 addition & 24 deletions src/core/resources/connector/config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import { basename } from "node:path";
import { globby } from "globby";
import { SchemaValidationError } from "@/core/errors.js";
import { CONFIG_FILE_EXTENSION_GLOB } from "../../consts.js";
import { pathExists, readJsonFile } from "../../utils/fs.js";
import type { ConnectorResource } from "./schema.js";
import { ConnectorResourceSchema, IntegrationTypeSchema } from "./schema.js";
import { ConnectorResourceSchema } from "./schema.js";

/**
* Read and validate a single connector file.
*/
async function readConnectorFile(
connectorPath: string
): Promise<ConnectorResource> {
Expand All @@ -23,24 +19,6 @@ async function readConnectorFile(
);
}

// Validate that filename matches the type
const filename = basename(connectorPath).replace(/\.(json|jsonc)$/, "");
const typeResult = IntegrationTypeSchema.safeParse(filename);

if (!typeResult.success) {
throw new SchemaValidationError(
`Connector filename "${filename}" is not a valid integration type`,
typeResult.error,
connectorPath
);
}

if (filename !== result.data.type) {
throw new Error(
`Connector filename "${filename}" does not match type "${result.data.type}" in ${connectorPath}`
);
}

return result.data;
}

Expand All @@ -64,7 +42,6 @@ export async function readAllConnectors(
files.map((filePath) => readConnectorFile(filePath))
);

// Check for duplicate types
const types = new Set<string>();
for (const connector of connectors) {
if (types.has(connector.type)) {
Expand Down
4 changes: 2 additions & 2 deletions src/core/resources/connector/oauth.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import open from "open";
import pWaitFor, { TimeoutError } from "p-wait-for";
import { getOAuthStatus } from "./api.js";
import type { IntegrationType, ConnectorOAuthStatus } from "./schema.js";
import type { ConnectorOAuthStatus, IntegrationType } from "./schema.js";

const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const POLL_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes

export interface OAuthFlowParams {
type: IntegrationType;
Expand Down
Loading