Skip to content

Commit 81917a6

Browse files
authored
Merge 9ae9175 into 23f65f3
2 parents 23f65f3 + 9ae9175 commit 81917a6

12 files changed

Lines changed: 134 additions & 31 deletions

File tree

AGENTS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,14 @@ cli/
8080
│ │ │ │ ├── resource.ts
8181
│ │ │ │ ├── api.ts
8282
│ │ │ │ └── index.ts
83+
│ │ │ ├── connector/
84+
│ │ │ │ ├── schema.ts
85+
│ │ │ │ ├── config.ts
86+
│ │ │ │ ├── resource.ts
87+
│ │ │ │ ├── api.ts
88+
│ │ │ │ ├── push.ts
89+
│ │ │ │ ├── oauth.ts
90+
│ │ │ │ └── index.ts
8391
│ │ │ └── index.ts
8492
│ │ ├── site/ # Site deployment (NOT a Resource)
8593
│ │ │ ├── schema.ts # DeployResponse Zod schema
@@ -120,6 +128,9 @@ cli/
120128
│ │ │ ├── index.ts # getAgentsCommand(context) - parent command
121129
│ │ │ ├── pull.ts
122130
│ │ │ └── push.ts
131+
│ │ ├── connectors/
132+
│ │ │ ├── index.ts # getConnectorsCommand(context) - parent command
133+
│ │ │ └── push.ts
123134
│ │ ├── functions/
124135
│ │ │ └── deploy.ts
125136
│ │ ├── site/

src/cli/commands/connectors/push.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { confirm, isCancel, log } from "@clack/prompts";
2-
import chalk from "chalk";
32
import { Command } from "commander";
43
import type { CLIContext } from "@/cli/types.js";
5-
import { runCommand, runTask } from "@/cli/utils/index.js";
4+
import { runCommand, runTask, theme } from "@/cli/utils/index.js";
65
import type { RunCommandResult } from "@/cli/utils/runCommand.js";
76
import { readProjectConfig } from "@/core/index.js";
87
import {
@@ -54,19 +53,19 @@ function printSummary(
5453
}
5554

5655
log.info("");
57-
log.info(chalk.bold("Summary:"));
56+
log.info(theme.styles.bold("Summary:"));
5857

5958
if (synced.length > 0) {
60-
log.info(chalk.green(` Synced: ${synced.join(", ")}`));
59+
log.success(`Synced: ${synced.join(", ")}`);
6160
}
6261
if (added.length > 0) {
63-
log.info(chalk.green(` Added: ${added.join(", ")}`));
62+
log.success(`Added: ${added.join(", ")}`);
6463
}
6564
if (removed.length > 0) {
66-
log.info(chalk.dim(` Removed: ${removed.join(", ")}`));
65+
log.info(theme.styles.dim(`Removed: ${removed.join(", ")}`));
6766
}
6867
for (const r of failed) {
69-
log.info(chalk.red(` Failed: ${r.type}${r.error ? ` - ${r.error}` : ""}`));
68+
log.error(`Failed: ${r.type}${r.error ? ` - ${r.error}` : ""}`);
7069
}
7170
}
7271

@@ -101,13 +100,11 @@ async function pushConnectorsAction(): Promise<RunCommandResult> {
101100

102101
if (needsOAuth.length > 0) {
103102
log.info("");
104-
log.info(
105-
chalk.yellow(
106-
`${needsOAuth.length} connector(s) require authorization in your browser:`
107-
)
103+
log.warn(
104+
`${needsOAuth.length} connector(s) require authorization in your browser:`
108105
);
109106
for (const connector of needsOAuth) {
110-
log.info(` ${connector.type}: ${chalk.dim(connector.redirectUrl)}`);
107+
log.info(` ${connector.type}: ${theme.styles.dim(connector.redirectUrl)}`);
111108
}
112109

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

src/core/resources/connector/config.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { globby } from "globby";
2-
import { SchemaValidationError } from "@/core/errors.js";
2+
import { InvalidInputError, SchemaValidationError } from "@/core/errors.js";
33
import { CONFIG_FILE_EXTENSION_GLOB } from "../../consts.js";
44
import { pathExists, readJsonFile } from "../../utils/fs.js";
55
import type { ConnectorResource } from "./schema.js";
@@ -45,7 +45,16 @@ export async function readAllConnectors(
4545
const types = new Set<string>();
4646
for (const connector of connectors) {
4747
if (types.has(connector.type)) {
48-
throw new Error(`Duplicate connector type "${connector.type}"`);
48+
throw new InvalidInputError(
49+
`Duplicate connector type "${connector.type}"`,
50+
{
51+
hints: [
52+
{
53+
message: `Remove duplicate connectors with type "${connector.type}" - only one connector per type is allowed`,
54+
},
55+
],
56+
}
57+
);
4958
}
5059
types.add(connector.type);
5160
}

src/core/resources/connector/push.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ function setResponseToResult(
6868
action: "error",
6969
error:
7070
response.error_message ||
71-
`Already connected by ${response.other_user_email}`,
71+
`Already connected by ${response.other_user_email ?? "another user"}`,
7272
};
7373
}
7474

src/core/resources/connector/schema.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,17 @@ export const TikTokConnectorSchema = z.object({
7272
scopes: z.array(z.string()).default([]),
7373
});
7474

75-
export const ConnectorResourceSchema = z.discriminatedUnion("type", [
75+
/** Generic connector schema for arbitrary providers */
76+
const GenericConnectorSchema = z.object({
77+
type: z.string().min(1).regex(/^[a-z0-9_-]+$/i),
78+
scopes: z.array(z.string()).default([]),
79+
});
80+
81+
/**
82+
* Connector resource schema that accepts both known providers (with specific schemas)
83+
* and arbitrary provider strings (with generic schema).
84+
*/
85+
export const ConnectorResourceSchema = z.union([
7686
GoogleCalendarConnectorSchema,
7787
GoogleDriveConnectorSchema,
7888
GmailConnectorSchema,
@@ -85,11 +95,13 @@ export const ConnectorResourceSchema = z.discriminatedUnion("type", [
8595
HubspotConnectorSchema,
8696
LinkedInConnectorSchema,
8797
TikTokConnectorSchema,
98+
GenericConnectorSchema,
8899
]);
89100

90101
export type ConnectorResource = z.infer<typeof ConnectorResourceSchema>;
91102

92-
export const IntegrationTypeSchema = z.enum([
103+
/** Known integration types with first-class support */
104+
export const KnownIntegrationTypes = [
93105
"googlecalendar",
94106
"googledrive",
95107
"gmail",
@@ -102,6 +114,15 @@ export const IntegrationTypeSchema = z.enum([
102114
"hubspot",
103115
"linkedin",
104116
"tiktok",
117+
] as const;
118+
119+
/**
120+
* Integration type schema that accepts both known providers and arbitrary strings.
121+
* This allows users to use custom OAuth providers not yet supported by Base44.
122+
*/
123+
export const IntegrationTypeSchema = z.union([
124+
z.enum(KnownIntegrationTypes),
125+
z.string().min(1).regex(/^[a-z0-9_-]+$/i),
105126
]);
106127

107128
export type IntegrationType = z.infer<typeof IntegrationTypeSchema>;

tests/cli/connectors_push.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ describe("connectors push command", () => {
5959
await t.givenLoggedInWithProject(fixture("basic"));
6060
t.api.mockConnectorsList({
6161
integrations: [
62-
{ integration_type: "slack", status: "ACTIVE", scopes: ["chat:write"] },
62+
{ integration_type: "slack", status: "active", scopes: ["chat:write"] },
6363
],
6464
});
6565
t.api.mockConnectorRemove({ status: "removed", integration_type: "slack" });

tests/core/connectors.spec.ts

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { resolve } from "node:path";
22
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import { InvalidInputError } from "../../src/core/errors.js";
34
import * as api from "../../src/core/resources/connector/api.js";
45
import { readAllConnectors } from "../../src/core/resources/connector/config.js";
56
import {
@@ -40,10 +41,21 @@ describe("IntegrationTypeSchema", () => {
4041
}
4142
});
4243

43-
it("rejects invalid integration types", () => {
44-
const invalidTypes = ["invalid", "google", "facebook", "twitter", ""];
44+
it("accepts arbitrary integration types (including custom providers)", () => {
45+
const arbitraryTypes = ["invalid", "google", "facebook", "twitter", "custom-oauth-provider"];
4546

46-
for (const type of invalidTypes) {
47+
for (const type of arbitraryTypes) {
48+
expect(IntegrationTypeSchema.safeParse(type).success).toBe(true);
49+
}
50+
});
51+
52+
it("rejects empty strings", () => {
53+
expect(IntegrationTypeSchema.safeParse("").success).toBe(false);
54+
});
55+
56+
it("rejects path traversal strings", () => {
57+
const malicious = ["../admin", "../../endpoint", "type/with/slashes", "type with spaces"];
58+
for (const type of malicious) {
4759
expect(IntegrationTypeSchema.safeParse(type).success).toBe(false);
4860
}
4961
});
@@ -92,14 +104,18 @@ describe("ConnectorResourceSchema", () => {
92104
}
93105
});
94106

95-
it("rejects connector with invalid type", () => {
107+
it("accepts connector with arbitrary provider type", () => {
96108
const connector = {
97-
type: "invalid",
98-
scopes: [],
109+
type: "custom-oauth-provider",
110+
scopes: ["scope1", "scope2"],
99111
};
100112

101113
const result = ConnectorResourceSchema.safeParse(connector);
102-
expect(result.success).toBe(false);
114+
expect(result.success).toBe(true);
115+
if (result.success) {
116+
expect(result.data.type).toBe("custom-oauth-provider");
117+
expect(result.data.scopes).toEqual(["scope1", "scope2"]);
118+
}
103119
});
104120

105121
it("rejects connector without type", () => {
@@ -144,6 +160,17 @@ describe("readAllConnectors", () => {
144160
"Invalid connector file"
145161
);
146162
});
163+
164+
it("throws InvalidInputError for duplicate connector types", async () => {
165+
const connectorsDir = resolve(FIXTURES_DIR, "duplicate-connectors/connectors");
166+
167+
await expect(readAllConnectors(connectorsDir)).rejects.toThrow(
168+
InvalidInputError
169+
);
170+
await expect(readAllConnectors(connectorsDir)).rejects.toThrow(
171+
'Duplicate connector type "slack"'
172+
);
173+
});
147174
});
148175

149176
const mockListConnectors = vi.mocked(api.listConnectors);
@@ -183,7 +210,7 @@ describe("pushConnectors", () => {
183210
it("removes upstream-only connectors", async () => {
184211
mockListConnectors.mockResolvedValue({
185212
integrations: [
186-
{ integration_type: "slack", status: "ACTIVE", scopes: ["chat:write"] },
213+
{ integration_type: "slack", status: "active", scopes: ["chat:write"] },
187214
],
188215
});
189216
mockRemoveConnector.mockResolvedValue({
@@ -203,7 +230,7 @@ describe("pushConnectors", () => {
203230
];
204231
mockListConnectors.mockResolvedValue({
205232
integrations: [
206-
{ integration_type: "slack", status: "ACTIVE", scopes: ["chat:write"] },
233+
{ integration_type: "slack", status: "active", scopes: ["chat:write"] },
207234
],
208235
});
209236
mockSetConnector.mockResolvedValue({
@@ -236,7 +263,7 @@ describe("pushConnectors", () => {
236263
integrations: [
237264
{
238265
integration_type: "gmail",
239-
status: "ACTIVE",
266+
status: "active",
240267
scopes: ["https://mail.google.com/"],
241268
},
242269
],
@@ -299,6 +326,30 @@ describe("pushConnectors", () => {
299326
]);
300327
});
301328

329+
it("returns fallback message when different_user has no error_message or email", async () => {
330+
const local: ConnectorResource[] = [
331+
{ type: "gmail", scopes: ["https://mail.google.com/"] },
332+
];
333+
mockSetConnector.mockResolvedValue({
334+
redirect_url: null,
335+
connection_id: null,
336+
already_authorized: false,
337+
error: "different_user",
338+
error_message: null,
339+
other_user_email: null,
340+
});
341+
342+
const result = await pushConnectors(local);
343+
344+
expect(result.results).toEqual([
345+
{
346+
type: "gmail",
347+
action: "error",
348+
error: "Already connected by another user",
349+
},
350+
]);
351+
});
352+
302353
it("handles sync errors gracefully", async () => {
303354
const local: ConnectorResource[] = [
304355
{ type: "gmail", scopes: ["https://mail.google.com/"] },
@@ -315,7 +366,7 @@ describe("pushConnectors", () => {
315366
it("handles remove errors gracefully", async () => {
316367
mockListConnectors.mockResolvedValue({
317368
integrations: [
318-
{ integration_type: "slack", status: "ACTIVE", scopes: ["chat:write"] },
369+
{ integration_type: "slack", status: "active", scopes: ["chat:write"] },
319370
],
320371
});
321372
mockRemoveConnector.mockRejectedValue(new Error("Remove failed"));
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Base44 App Configuration
2+
{
3+
"id": "test-app-id"
4+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"name": "Project with Duplicate Connectors"
3+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "slack",
3+
"scopes": ["chat:write"]
4+
}

0 commit comments

Comments
 (0)