Skip to content
Closed
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
1 change: 1 addition & 0 deletions src/cli/commands/connectors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { getConnectorsPushCommand } from "./push.js";
159 changes: 159 additions & 0 deletions src/cli/commands/connectors/push.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { log } from "@clack/prompts";
import { Command } from "commander";
import type { CLIContext } from "@/cli/types.js";
import { runCommand } from "@/cli/utils/index.js";
import type { RunCommandResult } from "@/cli/utils/runCommand.js";
import { theme } from "@/cli/utils/theme.js";
import { readProjectConfig } from "@/core/index.js";
import type {
ConnectorPushResult,
ConnectorsPushSummary,
} from "@/core/resources/connector/index.js";
import { pushConnectors } from "@/core/resources/connector/index.js";

/**
* Format a push result status with appropriate styling
*/
function formatStatus(result: ConnectorPushResult): string {
switch (result.status) {
case "ACTIVE":
return "✓ ACTIVE";
case "SCOPE_MISMATCH":
return theme.colors.base44Orange("⚠ SCOPE_MISMATCH");
case "PENDING_AUTH":
return theme.colors.base44Orange("✗ PENDING_AUTH");
case "AUTH_FAILED":
return theme.colors.base44Orange("✗ AUTH_FAILED");
case "DELETED":
return theme.styles.dim("🗑 DELETED");
case "DIFFERENT_USER":
return theme.colors.base44Orange("✗ DIFFERENT_USER");
}
}

/**
* Print a summary table of push results
*/
function printSummaryTable(summary: ConnectorsPushSummary): void {
if (summary.results.length === 0) {
return;
}

log.message("");
log.message(
theme.styles.bold("┌─────────────────────────────────────────────────────────────────┐")
);
log.message(
theme.styles.bold("│ Connectors Push Summary │")
);
log.message(
theme.styles.bold("├──────────────────┬─────────────────┬────────────────────────────┤")
);
log.message(
theme.styles.bold("│ Connector │ Status │ Details │")
);
log.message(
theme.styles.bold("├──────────────────┼─────────────────┼────────────────────────────┤")
);

for (const result of summary.results) {
const connector = result.type.padEnd(16);
const statusText = formatStatus(result);
const status = statusText.padEnd(15);
const details = (result.details || result.error || "").substring(0, 28).padEnd(28);
log.message(`│ ${connector} │ ${status} │ ${details}│`);
}

log.message(
theme.styles.bold("└──────────────────┴─────────────────┴────────────────────────────┘")
);
log.message("");
}

/**
* Print warnings for connectors that need attention
*/
function printWarnings(summary: ConnectorsPushSummary): void {
const needsAttention = summary.results.filter(
(r) =>
r.status === "SCOPE_MISMATCH" ||
r.status === "PENDING_AUTH" ||
r.status === "AUTH_FAILED" ||
r.status === "DIFFERENT_USER"
);

if (needsAttention.length === 0) {
return;
}

log.warn("Some connectors need attention:");
for (const result of needsAttention) {
if (result.status === "SCOPE_MISMATCH") {
log.message(
` • ${result.type}: Approved scopes differ from requested. Adjust connectors/${result.type}.jsonc or run \`base44 connectors push\` again to re-auth.`
);
} else if (result.status === "PENDING_AUTH") {
log.message(
` • ${result.type}: Authentication not completed. Run \`base44 connectors push\` to retry.`
);
} else if (result.status === "AUTH_FAILED") {
log.message(
` • ${result.type}: OAuth flow failed${result.error ? `: ${result.error}` : ""}.`
);
} else if (result.status === "DIFFERENT_USER") {
log.message(
` • ${result.type}: Another user already authorized this connector.`
);
}
}
}

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

if (connectors.length === 0) {
return { outroMessage: "No connectors found in project" };
}

const connectorTypes = connectors.map((c) => c.type).join(", ");
log.info(`Found ${connectors.length} connectors to push: ${connectorTypes}`);

// Push connectors (this handles OAuth flows interactively)
const summary = await pushConnectors(connectors);

// Print summary table
printSummaryTable(summary);

// Print warnings if needed
printWarnings(summary);

if (summary.hasErrors) {
return {
outroMessage: theme.colors.base44Orange(
"Connectors push completed with errors"
),
};
}

if (summary.hasWarnings) {
return {
outroMessage: theme.colors.base44Orange(
"Connectors push completed with warnings"
),
};
}

return { outroMessage: "All connectors pushed successfully" };
}

export function getConnectorsPushCommand(context: CLIContext): Command {
return new Command("connectors")
.description("Manage OAuth connectors")
.addCommand(
new Command("push")
.description("Push local connectors to Base44")
.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 { getConnectorsPushCommand } 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(getConnectorsPushCommand(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,
};
}
9 changes: 6 additions & 3 deletions src/core/project/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { resolve } from "node:path";
import type { ProjectData } 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 { deploySite } from "@/core/site/index.js";
Expand All @@ -12,13 +13,14 @@ import { deploySite } from "@/core/site/index.js";
* @returns true if there are entities, functions, agents, or a configured site to deploy
*/
export function hasResourcesToDeploy(projectData: ProjectData): boolean {
const { project, entities, functions, agents } = projectData;
const { project, entities, functions, agents, connectors } = projectData;
const hasSite = Boolean(project.site?.outputDirectory);
const hasEntities = entities.length > 0;
const hasFunctions = functions.length > 0;
const hasAgents = agents.length > 0;
const hasConnectors = connectors.length > 0;

return hasEntities || hasFunctions || hasAgents || hasSite;
return hasEntities || hasFunctions || hasAgents || hasConnectors || hasSite;
}

/**
Expand All @@ -40,11 +42,12 @@ export interface DeployAllResult {
export async function deployAll(
projectData: ProjectData
): Promise<DeployAllResult> {
const { project, entities, functions, agents } = projectData;
const { project, entities, functions, agents, connectors } = projectData;

await entityResource.push(entities);
await functionResource.push(functions);
await agentResource.push(agents);
await connectorResource.push(connectors);

if (project.site?.outputDirectory) {
const outputDir = resolve(project.root, project.site.outputDirectory);
Expand Down
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[];
}
119 changes: 119 additions & 0 deletions src/core/resources/connector/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type { KyResponse } from "ky";
import { getAppClient } from "@/core/clients/index.js";
import { ApiError, SchemaValidationError } from "@/core/errors.js";
import type {
InitiateOAuthRequest,
InitiateOAuthResponse,
IntegrationType,
ListConnectorsResponse,
OAuthStatusResponse,
} from "@/core/resources/connector/schema.js";
import {
InitiateOAuthResponseSchema,
ListConnectorsResponseSchema,
OAuthStatusResponseSchema,
} from "@/core/resources/connector/schema.js";

/**
* List all connectors for the current app
*/
export async function listConnectors(): Promise<ListConnectorsResponse> {
const appClient = getAppClient();

let response: KyResponse;
try {
response = await appClient.get("external-auth/list");
} catch (error) {
throw await ApiError.fromHttpError(error, "listing connectors");
}

const result = ListConnectorsResponseSchema.safeParse(await response.json());

if (!result.success) {
throw new SchemaValidationError(
"Invalid response from server",
result.error
);
}

return result.data;
}

/**
* Initiate OAuth flow for a connector
*/
export async function initiateOAuth(
request: InitiateOAuthRequest
): Promise<InitiateOAuthResponse> {
const appClient = getAppClient();

let response: KyResponse;
try {
response = await appClient.post("external-auth/initiate", {
json: request,
});
} catch (error) {
throw await ApiError.fromHttpError(error, "initiating OAuth flow");
}

const result = InitiateOAuthResponseSchema.safeParse(await response.json());

if (!result.success) {
throw new SchemaValidationError(
"Invalid response from server",
result.error
);
}

return result.data;
}

/**
* Poll OAuth status
*/
export async function pollOAuthStatus(
integrationType: IntegrationType,
connectionId: string
): Promise<OAuthStatusResponse> {
const appClient = getAppClient();

let response: KyResponse;
try {
response = await appClient.get("external-auth/status", {
searchParams: {
integration_type: integrationType,
connection_id: connectionId,
},
});
} catch (error) {
throw await ApiError.fromHttpError(error, "polling OAuth status");
}

const result = OAuthStatusResponseSchema.safeParse(await response.json());

if (!result.success) {
throw new SchemaValidationError(
"Invalid response from server",
result.error
);
}

return result.data;
}

/**
* Hard delete a connector (removes completely from upstream)
*/
export async function deleteConnector(
integrationType: IntegrationType
): Promise<void> {
const appClient = getAppClient();

try {
await appClient.delete(
`external-auth/integrations/${integrationType}/remove`
);
} catch (error) {
throw await ApiError.fromHttpError(error, "deleting connector");
}
}
Loading
Loading