Skip to content

Commit 9c995ed

Browse files
Paveltarnoclaude
andcommitted
feat(connectors): implement push logic for syncing connectors (#191)
* feat(connectors): implement push logic for syncing connectors Add pushConnectors function that: - Syncs all local connectors via /sync endpoint - Removes upstream-only connectors not in local config - Returns typed results (synced, removed, needs_oauth, error) Includes unit tests covering all scenarios. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(connectors): add OAuth flow handling with browser redirect and polling (#192) * feat(connectors): add OAuth flow handling with browser redirect and polling Add runOAuthFlow function that: - Opens OAuth redirect URL in browser - Polls getOAuthStatus until ACTIVE or FAILED - Returns PENDING on timeout (5 minutes) Uses p-wait-for TimeoutError for robust timeout detection. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * connectors: base44 connectors push (#194) * final connector work sofi 1 * scopes --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5393c97 commit 9c995ed

19 files changed

Lines changed: 826 additions & 75 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Command } from "commander";
2+
import type { CLIContext } from "@/cli/types.js";
3+
import { getConnectorsPushCommand } from "./push.js";
4+
5+
export function getConnectorsCommand(context: CLIContext): Command {
6+
return new Command("connectors")
7+
.description("Manage project connectors (OAuth integrations)")
8+
.addCommand(getConnectorsPushCommand(context));
9+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { confirm, isCancel, log } from "@clack/prompts";
2+
import chalk from "chalk";
3+
import { Command } from "commander";
4+
import type { CLIContext } from "@/cli/types.js";
5+
import { runCommand, runTask } from "@/cli/utils/index.js";
6+
import type { RunCommandResult } from "@/cli/utils/runCommand.js";
7+
import { readProjectConfig } from "@/core/index.js";
8+
import {
9+
type ConnectorOAuthStatus,
10+
type ConnectorSyncResult,
11+
type IntegrationType,
12+
pushConnectors,
13+
runOAuthFlow,
14+
} from "@/core/resources/connector/index.js";
15+
16+
type PendingOAuthResult = ConnectorSyncResult & {
17+
redirectUrl: string;
18+
connectionId: string;
19+
};
20+
21+
function isPendingOAuth(r: ConnectorSyncResult): r is PendingOAuthResult {
22+
return r.action === "needs_oauth" && !!r.redirectUrl && !!r.connectionId;
23+
}
24+
25+
function printSummary(
26+
results: ConnectorSyncResult[],
27+
oauthOutcomes: Map<IntegrationType, ConnectorOAuthStatus>
28+
): void {
29+
const synced: IntegrationType[] = [];
30+
const added: IntegrationType[] = [];
31+
const removed: IntegrationType[] = [];
32+
const failed: { type: IntegrationType; error?: string }[] = [];
33+
34+
for (const r of results) {
35+
const oauthStatus = oauthOutcomes.get(r.type);
36+
37+
if (r.action === "synced") {
38+
synced.push(r.type);
39+
} else if (r.action === "removed") {
40+
removed.push(r.type);
41+
} else if (r.action === "error") {
42+
failed.push({ type: r.type, error: r.error });
43+
} else if (r.action === "needs_oauth") {
44+
if (oauthStatus === "ACTIVE") {
45+
added.push(r.type);
46+
} else if (oauthStatus === "PENDING") {
47+
failed.push({ type: r.type, error: "authorization timed out" });
48+
} else if (oauthStatus === "FAILED") {
49+
failed.push({ type: r.type, error: "authorization failed" });
50+
} else {
51+
failed.push({ type: r.type, error: "needs authorization" });
52+
}
53+
}
54+
}
55+
56+
log.info("");
57+
log.info(chalk.bold("Summary:"));
58+
59+
if (synced.length > 0) {
60+
log.info(chalk.green(` Synced: ${synced.join(", ")}`));
61+
}
62+
if (added.length > 0) {
63+
log.info(chalk.green(` Added: ${added.join(", ")}`));
64+
}
65+
if (removed.length > 0) {
66+
log.info(chalk.dim(` Removed: ${removed.join(", ")}`));
67+
}
68+
for (const r of failed) {
69+
log.info(chalk.red(` Failed: ${r.type}${r.error ? ` - ${r.error}` : ""}`));
70+
}
71+
}
72+
73+
async function pushConnectorsAction(): Promise<RunCommandResult> {
74+
const { connectors } = await readProjectConfig();
75+
76+
if (connectors.length === 0) {
77+
log.info(
78+
"No local connectors found - checking for remote connectors to remove"
79+
);
80+
} else {
81+
const connectorNames = connectors.map((c) => c.type).join(", ");
82+
log.info(
83+
`Found ${connectors.length} connectors to push: ${connectorNames}`
84+
);
85+
}
86+
87+
const { results } = await runTask(
88+
"Pushing connectors to Base44",
89+
async () => {
90+
return await pushConnectors(connectors);
91+
},
92+
{
93+
successMessage: "Connectors pushed",
94+
errorMessage: "Failed to push connectors",
95+
}
96+
);
97+
98+
const oauthOutcomes = new Map<IntegrationType, ConnectorOAuthStatus>();
99+
const needsOAuth = results.filter(isPendingOAuth);
100+
let outroMessage = "Connectors pushed to Base44";
101+
102+
if (needsOAuth.length > 0) {
103+
log.info("");
104+
log.info(
105+
chalk.yellow(
106+
`${needsOAuth.length} connector(s) require authorization in your browser:`
107+
)
108+
);
109+
for (const connector of needsOAuth) {
110+
log.info(` ${connector.type}: ${chalk.dim(connector.redirectUrl)}`);
111+
}
112+
113+
const pending = needsOAuth.map((c) => c.type).join(", ");
114+
115+
if (process.env.CI) {
116+
outroMessage = `Skipped OAuth in CI. Pending: ${pending}. Run 'base44 connectors push' locally to authorize.`;
117+
} else {
118+
const shouldAuth = await confirm({
119+
message: "Open browser to authorize now?",
120+
});
121+
122+
if (isCancel(shouldAuth) || !shouldAuth) {
123+
outroMessage = `Authorization skipped. Pending: ${pending}. Run 'base44 connectors push' again to complete.`;
124+
} else {
125+
for (const connector of needsOAuth) {
126+
log.info(`\nOpening browser for ${connector.type}...`);
127+
128+
const oauthResult = await runTask(
129+
`Waiting for ${connector.type} authorization...`,
130+
async () => {
131+
return await runOAuthFlow({
132+
type: connector.type,
133+
redirectUrl: connector.redirectUrl,
134+
connectionId: connector.connectionId,
135+
});
136+
},
137+
{
138+
successMessage: `${connector.type} authorization complete`,
139+
errorMessage: `${connector.type} authorization failed`,
140+
}
141+
);
142+
143+
oauthOutcomes.set(connector.type, oauthResult.status);
144+
}
145+
}
146+
}
147+
}
148+
149+
printSummary(results, oauthOutcomes);
150+
return { outroMessage };
151+
}
152+
153+
export function getConnectorsPushCommand(context: CLIContext): Command {
154+
return new Command("push")
155+
.description(
156+
"Push local connectors to Base44 (syncs scopes and removes unlisted)"
157+
)
158+
.action(async () => {
159+
await runCommand(pushConnectorsAction, { requireAuth: true }, context);
160+
});
161+
}

src/cli/program.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getAgentsCommand } from "@/cli/commands/agents/index.js";
33
import { getLoginCommand } from "@/cli/commands/auth/login.js";
44
import { getLogoutCommand } from "@/cli/commands/auth/logout.js";
55
import { getWhoamiCommand } from "@/cli/commands/auth/whoami.js";
6+
import { getConnectorsCommand } from "@/cli/commands/connectors/index.js";
67
import { getDashboardCommand } from "@/cli/commands/dashboard/index.js";
78
import { getEntitiesPushCommand } from "@/cli/commands/entities/push.js";
89
import { getFunctionsDeployCommand } from "@/cli/commands/functions/deploy.js";
@@ -48,6 +49,9 @@ export function createProgram(context: CLIContext): Command {
4849
// Register agents commands
4950
program.addCommand(getAgentsCommand(context));
5051

52+
// Register connectors commands
53+
program.addCommand(getConnectorsCommand(context));
54+
5155
// Register functions commands
5256
program.addCommand(getFunctionsDeployCommand(context));
5357

src/core/project/config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ConfigNotFoundError, SchemaValidationError } from "@/core/errors.js";
55
import { ProjectConfigSchema } from "@/core/project/schema.js";
66
import type { ProjectData, ProjectRoot } from "@/core/project/types.js";
77
import { agentResource } from "@/core/resources/agent/index.js";
8+
import { connectorResource } from "@/core/resources/connector/index.js";
89
import { entityResource } from "@/core/resources/entity/index.js";
910
import { functionResource } from "@/core/resources/function/index.js";
1011
import { readJsonFile } from "@/core/utils/fs.js";
@@ -91,16 +92,18 @@ export async function readProjectConfig(
9192
const project = result.data;
9293
const configDir = dirname(configPath);
9394

94-
const [entities, functions, agents] = await Promise.all([
95+
const [entities, functions, agents, connectors] = await Promise.all([
9596
entityResource.readAll(join(configDir, project.entitiesDir)),
9697
functionResource.readAll(join(configDir, project.functionsDir)),
9798
agentResource.readAll(join(configDir, project.agentsDir)),
99+
connectorResource.readAll(join(configDir, project.connectorsDir)),
98100
]);
99101

100102
return {
101103
project: { ...project, root, configPath },
102104
entities,
103105
functions,
104106
agents,
107+
connectors,
105108
};
106109
}

src/core/project/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const ProjectConfigSchema = z.object({
3030
entitiesDir: z.string().optional().default("entities"),
3131
functionsDir: z.string().optional().default("functions"),
3232
agentsDir: z.string().optional().default("agents"),
33+
connectorsDir: z.string().optional().default("connectors"),
3334
});
3435

3536
export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;

src/core/project/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ProjectConfig } from "@/core/project/schema.js";
22
import type { AgentConfig } from "@/core/resources/agent/index.js";
3+
import type { ConnectorResource } from "@/core/resources/connector/index.js";
34
import type { Entity } from "@/core/resources/entity/index.js";
45
import type { BackendFunction } from "@/core/resources/function/index.js";
56

@@ -18,4 +19,5 @@ export interface ProjectData {
1819
entities: Entity[];
1920
functions: BackendFunction[];
2021
agents: AgentConfig[];
22+
connectors: ConnectorResource[];
2123
}

src/core/resources/connector/api.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import type {
66
ListConnectorsResponse,
77
OAuthStatusResponse,
88
RemoveConnectorResponse,
9-
SyncConnectorResponse,
9+
SetConnectorResponse,
1010
} from "./schema.js";
1111
import {
1212
ListConnectorsResponseSchema,
1313
OAuthStatusResponseSchema,
1414
RemoveConnectorResponseSchema,
15-
SyncConnectorResponseSchema,
15+
SetConnectorResponseSchema,
1616
} from "./schema.js";
1717

1818
/**
@@ -41,25 +41,27 @@ export async function listConnectors(): Promise<ListConnectorsResponse> {
4141
return result.data;
4242
}
4343

44-
export async function syncConnector(
44+
export async function setConnector(
4545
integrationType: IntegrationType,
4646
scopes: string[]
47-
): Promise<SyncConnectorResponse> {
47+
): Promise<SetConnectorResponse> {
4848
const appClient = getAppClient();
4949

5050
let response: KyResponse;
5151
try {
52-
response = await appClient.post("external-auth/sync", {
53-
json: {
54-
integration_type: integrationType,
55-
scopes,
56-
},
57-
});
52+
response = await appClient.put(
53+
`external-auth/integrations/${integrationType}`,
54+
{
55+
json: {
56+
scopes,
57+
},
58+
}
59+
);
5860
} catch (error) {
59-
throw await ApiError.fromHttpError(error, "syncing connector");
61+
throw await ApiError.fromHttpError(error, "setting connector");
6062
}
6163

62-
const result = SyncConnectorResponseSchema.safeParse(await response.json());
64+
const result = SetConnectorResponseSchema.safeParse(await response.json());
6365

6466
if (!result.success) {
6567
throw new SchemaValidationError(

src/core/resources/connector/config.ts

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
1-
import { basename } from "node:path";
21
import { globby } from "globby";
32
import { SchemaValidationError } from "@/core/errors.js";
43
import { CONFIG_FILE_EXTENSION_GLOB } from "../../consts.js";
54
import { pathExists, readJsonFile } from "../../utils/fs.js";
65
import type { ConnectorResource } from "./schema.js";
7-
import { ConnectorResourceSchema, IntegrationTypeSchema } from "./schema.js";
6+
import { ConnectorResourceSchema } from "./schema.js";
87

9-
/**
10-
* Read and validate a single connector file.
11-
*/
128
async function readConnectorFile(
139
connectorPath: string
1410
): Promise<ConnectorResource> {
@@ -23,24 +19,6 @@ async function readConnectorFile(
2319
);
2420
}
2521

26-
// Validate that filename matches the type
27-
const filename = basename(connectorPath).replace(/\.(json|jsonc)$/, "");
28-
const typeResult = IntegrationTypeSchema.safeParse(filename);
29-
30-
if (!typeResult.success) {
31-
throw new SchemaValidationError(
32-
`Connector filename "${filename}" is not a valid integration type`,
33-
typeResult.error,
34-
connectorPath
35-
);
36-
}
37-
38-
if (filename !== result.data.type) {
39-
throw new Error(
40-
`Connector filename "${filename}" does not match type "${result.data.type}" in ${connectorPath}`
41-
);
42-
}
43-
4422
return result.data;
4523
}
4624

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

67-
// Check for duplicate types
6845
const types = new Set<string>();
6946
for (const connector of connectors) {
7047
if (types.has(connector.type)) {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export * from "./api.js";
22
export * from "./config.js";
3+
export * from "./oauth.js";
4+
export * from "./push.js";
35
export * from "./resource.js";
46
export * from "./schema.js";
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import open from "open";
2+
import pWaitFor, { TimeoutError } from "p-wait-for";
3+
import { getOAuthStatus } from "./api.js";
4+
import type { ConnectorOAuthStatus, IntegrationType } from "./schema.js";
5+
6+
const POLL_INTERVAL_MS = 2000;
7+
const POLL_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
8+
9+
export interface OAuthFlowParams {
10+
type: IntegrationType;
11+
redirectUrl: string;
12+
connectionId: string;
13+
}
14+
15+
export interface OAuthFlowResult {
16+
type: IntegrationType;
17+
status: ConnectorOAuthStatus;
18+
}
19+
20+
export async function runOAuthFlow(
21+
params: OAuthFlowParams
22+
): Promise<OAuthFlowResult> {
23+
await open(params.redirectUrl);
24+
25+
let finalStatus: ConnectorOAuthStatus = "PENDING";
26+
27+
await pWaitFor(
28+
async () => {
29+
const response = await getOAuthStatus(params.type, params.connectionId);
30+
finalStatus = response.status;
31+
return response.status !== "PENDING";
32+
},
33+
{
34+
interval: POLL_INTERVAL_MS,
35+
timeout: POLL_TIMEOUT_MS,
36+
}
37+
).catch((err) => {
38+
if (err instanceof TimeoutError) {
39+
finalStatus = "PENDING";
40+
} else {
41+
throw err;
42+
}
43+
});
44+
45+
return { type: params.type, status: finalStatus };
46+
}

0 commit comments

Comments
 (0)