-
Notifications
You must be signed in to change notification settings - Fork 3
feat(connectors): add pull command #214
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b0c6fa8
4c15401
f766595
d8c9428
6552c85
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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)); | ||
| } |
| 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); | ||
| }); | ||
| } |
| 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"; | ||
|
|
||
|
|
@@ -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 []; | ||
| } | ||
|
|
@@ -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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes exactly, we do validation here
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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 }; | ||
| } | ||
| 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(); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.