Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/cli/commands/connectors/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Command } from "commander";
import type { CLIContext } from "@/cli/types.js";
import { getConnectorsPullCommand } from "./pull.js";
import { getConnectorsPushCommand } from "./push.js";

export function getConnectorsCommand(context: CLIContext): Command {
return new Command("connectors")
.description("Manage project connectors (OAuth integrations)")
.addCommand(getConnectorsPullCommand(context))
.addCommand(getConnectorsPushCommand(context));
}
67 changes: 67 additions & 0 deletions src/cli/commands/connectors/pull.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { dirname, join } from "node:path";
import { log } from "@clack/prompts";
import { Command } from "commander";
import type { CLIContext } from "@/cli/types.js";
import { readProjectConfig } from "@/core/index.js";
import {
listConnectors,
writeConnectors,
} from "@/core/resources/connector/index.js";
import { runCommand, runTask } from "../../utils/index.js";
import type { RunCommandResult } from "../../utils/runCommand.js";

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

const configDir = dirname(project.configPath);
const connectorsDir = join(configDir, project.connectorsDir);

const remoteConnectors = await runTask(
"Fetching connectors from Base44",
async () => {
return await listConnectors();
},
{
successMessage: "Connectors fetched successfully",
errorMessage: "Failed to fetch connectors",
},
);

const { written, deleted } = await runTask(
"Syncing connector files",
async () => {
return await writeConnectors(
connectorsDir,
remoteConnectors.integrations,
);
},
{
successMessage: "Connector files synced successfully",
errorMessage: "Failed to sync connector files",
},
);

if (written.length > 0) {
log.success(`Written: ${written.join(", ")}`);
}
if (deleted.length > 0) {
log.warn(`Deleted: ${deleted.join(", ")}`);
}
if (written.length === 0 && deleted.length === 0) {
log.info("All connectors are already up to date");
}

return {
outroMessage: `Pulled ${remoteConnectors.integrations.length} connectors to ${connectorsDir}`,
};
}

export function getConnectorsPullCommand(context: CLIContext): Command {
return new Command("pull")
.description(
"Pull connectors from Base44 to local files (replaces all local connector configs)",
)
.action(async () => {
await runCommand(pullConnectorsAction, { requireAuth: true }, context);
});
}
101 changes: 84 additions & 17 deletions src/core/resources/connector/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { join } from "node:path";
import { isDeepStrictEqual } from "node:util";
import { globby } from "globby";
import { InvalidInputError, SchemaValidationError } from "@/core/errors.js";
import { CONFIG_FILE_EXTENSION_GLOB } from "../../consts.js";
import { pathExists, readJsonFile } from "../../utils/fs.js";
import {
CONFIG_FILE_EXTENSION,
CONFIG_FILE_EXTENSION_GLOB,
} from "../../consts.js";
import {
deleteFile,
pathExists,
readJsonFile,
writeJsonFile,
} from "../../utils/fs.js";
import type { ConnectorResource } from "./schema.js";
import { ConnectorResourceSchema } from "./schema.js";

Expand All @@ -22,9 +32,14 @@ async function readConnectorFile(
return result.data;
}

export async function readAllConnectors(
interface ConnectorFileEntry {
data: ConnectorResource;
filePath: string;
}

async function readConnectorFiles(
connectorsDir: string,
): Promise<ConnectorResource[]> {
): Promise<ConnectorFileEntry[]> {
if (!(await pathExists(connectorsDir))) {
return [];
}
Expand All @@ -34,30 +49,82 @@ export async function readAllConnectors(
absolute: true,
});

const connectors = await Promise.all(
files.map((filePath) => readConnectorFile(filePath)),
return await Promise.all(
files.map(async (filePath) => ({
data: await readConnectorFile(filePath),
filePath,
})),
);

assertNoDuplicateConnectors(connectors);

return connectors;
}

function assertNoDuplicateConnectors(connectors: ConnectorResource[]): void {
const types = new Set<string>();
for (const connector of connectors) {
if (types.has(connector.type)) {
function buildTypeToEntryMap(
entries: ConnectorFileEntry[],
): Map<string, ConnectorFileEntry> {
const typeToEntry = new Map<string, ConnectorFileEntry>();
for (const entry of entries) {
if (typeToEntry.has(entry.data.type)) {
throw new InvalidInputError(
`Duplicate connector type "${connector.type}"`,
`Duplicate connector type "${entry.data.type}"`,
{
hints: [
{
message: `Remove duplicate connectors with type "${connector.type}" - only one connector per type is allowed`,
message: `Remove duplicate connectors with type "${entry.data.type}" - only one connector per type is allowed`,
},
],
},
);
}
types.add(connector.type);
typeToEntry.set(entry.data.type, entry);
}
return typeToEntry;
}

export async function readAllConnectors(
connectorsDir: string,
): Promise<ConnectorResource[]> {
const entries = await readConnectorFiles(connectorsDir);
const typeToEntry = buildTypeToEntryMap(entries);
Copy link
Collaborator

Choose a reason for hiding this comment

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

we don't really need this here, we can just grab the data form entries no?
Or is it here for validation there are no two of the same?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yes exactly, we do validation here

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

readConnectorFiles and buildTypeToEntryMap can be consolidated into one function but I thought it's nicer to separate the loading from the validation/indexation

return [...typeToEntry.values()].map((e) => e.data);
}

export async function writeConnectors(
connectorsDir: string,
remoteConnectors: { integrationType: string; scopes: string[] }[],
): Promise<{ written: string[]; deleted: string[] }> {
const entries = await readConnectorFiles(connectorsDir);
const typeToEntry = buildTypeToEntryMap(entries);

const newTypes = new Set(remoteConnectors.map((c) => c.integrationType));

const deleted: string[] = [];
for (const [type, entry] of typeToEntry) {
if (!newTypes.has(type)) {
await deleteFile(entry.filePath);
deleted.push(type);
}
}

const written: string[] = [];
for (const connector of remoteConnectors) {
const existing = typeToEntry.get(connector.integrationType);
const localConnector: ConnectorResource = {
type: connector.integrationType,
scopes: connector.scopes,
};

if (existing && isDeepStrictEqual(existing.data, localConnector)) {
continue;
}

const filePath =
existing?.filePath ??
join(
connectorsDir,
`${connector.integrationType}.${CONFIG_FILE_EXTENSION}`,
);
await writeJsonFile(filePath, localConnector);
written.push(connector.integrationType);
}

return { written, deleted };
}
63 changes: 63 additions & 0 deletions tests/cli/connectors_pull.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it } from "vitest";
import { fixture, setupCLITests } from "./testkit/index.js";

describe("connectors pull command", () => {
const t = setupCLITests();

it("syncs when remote has no connectors", async () => {
await t.givenLoggedInWithProject(fixture("basic"));
t.api.mockConnectorsList({ integrations: [] });

const result = await t.run("connectors", "pull");

t.expectResult(result).toSucceed();
t.expectResult(result).toContain("All connectors are already up to date");
});

it("fails when not in a project directory", async () => {
await t.givenLoggedIn({ email: "test@example.com", name: "Test User" });

const result = await t.run("connectors", "pull");

t.expectResult(result).toFail();
t.expectResult(result).toContain("No Base44 project found");
});

it("pulls connectors successfully", async () => {
await t.givenLoggedInWithProject(fixture("basic"));
t.api.mockConnectorsList({
integrations: [
{
integration_type: "gmail",
status: "active",
scopes: ["https://mail.google.com/"],
user_email: "test@example.com",
},
{
integration_type: "slack",
status: "active",
scopes: ["chat:write"],
},
],
});

const result = await t.run("connectors", "pull");

t.expectResult(result).toSucceed();
t.expectResult(result).toContain("Connectors fetched successfully");
t.expectResult(result).toContain("Connector files synced successfully");
t.expectResult(result).toContain("Pulled 2 connectors");
});

it("fails when API returns error", async () => {
await t.givenLoggedInWithProject(fixture("basic"));
t.api.mockConnectorsListError({
status: 500,
body: { error: "Server error" },
});

const result = await t.run("connectors", "pull");

t.expectResult(result).toFail();
});
});
Loading
Loading