diff --git a/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc new file mode 100644 index 00000000..b8100b77 --- /dev/null +++ b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc @@ -0,0 +1,111 @@ +--- +description: Use Bun instead of Node.js, npm, pnpm, or vite. +globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" +alwaysApply: false +--- + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; + +// import .css files directly and it works +import './index.css'; + +import { createRoot } from "react-dom/client"; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/.gitignore b/.gitignore index f10a8a14..33740c64 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,6 @@ coverage/ *.seed *.pid.lock +# Worktrees +.worktrees/ + diff --git a/AGENTS.md b/AGENTS.md index 43f242d1..70deb2ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # AI Agent Guidelines for Base44 CLI Development +@.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc + This document provides essential context and guidelines for AI agents working on the Base44 CLI project. **Important**: Keep this file updated when making significant architectural changes. @@ -78,6 +80,14 @@ cli/ │ │ │ │ ├── resource.ts │ │ │ │ ├── api.ts │ │ │ │ └── index.ts +│ │ │ ├── connector/ +│ │ │ │ ├── schema.ts +│ │ │ │ ├── config.ts +│ │ │ │ ├── resource.ts +│ │ │ │ ├── api.ts +│ │ │ │ ├── push.ts +│ │ │ │ ├── oauth.ts +│ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── site/ # Site deployment (NOT a Resource) │ │ │ ├── schema.ts # DeployResponse Zod schema @@ -118,6 +128,9 @@ cli/ │ │ │ ├── index.ts # getAgentsCommand(context) - parent command │ │ │ ├── pull.ts │ │ │ └── push.ts +│ │ ├── connectors/ +│ │ │ ├── index.ts # getConnectorsCommand(context) - parent command +│ │ │ └── push.ts │ │ ├── functions/ │ │ │ └── deploy.ts │ │ ├── site/ diff --git a/src/cli/commands/connectors/push.ts b/src/cli/commands/connectors/push.ts index 55f04e61..f03f0b75 100644 --- a/src/cli/commands/connectors/push.ts +++ b/src/cli/commands/connectors/push.ts @@ -1,8 +1,7 @@ import { confirm, isCancel, log } from "@clack/prompts"; -import chalk from "chalk"; import { Command } from "commander"; import type { CLIContext } from "@/cli/types.js"; -import { runCommand, runTask } from "@/cli/utils/index.js"; +import { runCommand, runTask, theme } from "@/cli/utils/index.js"; import type { RunCommandResult } from "@/cli/utils/runCommand.js"; import { readProjectConfig } from "@/core/index.js"; import { @@ -54,19 +53,19 @@ function printSummary( } log.info(""); - log.info(chalk.bold("Summary:")); + log.info(theme.styles.bold("Summary:")); if (synced.length > 0) { - log.info(chalk.green(` Synced: ${synced.join(", ")}`)); + log.success(`Synced: ${synced.join(", ")}`); } if (added.length > 0) { - log.info(chalk.green(` Added: ${added.join(", ")}`)); + log.success(`Added: ${added.join(", ")}`); } if (removed.length > 0) { - log.info(chalk.dim(` Removed: ${removed.join(", ")}`)); + log.info(theme.styles.dim(`Removed: ${removed.join(", ")}`)); } for (const r of failed) { - log.info(chalk.red(` Failed: ${r.type}${r.error ? ` - ${r.error}` : ""}`)); + log.error(`Failed: ${r.type}${r.error ? ` - ${r.error}` : ""}`); } } @@ -101,13 +100,11 @@ async function pushConnectorsAction(): Promise { if (needsOAuth.length > 0) { log.info(""); - log.info( - chalk.yellow( - `${needsOAuth.length} connector(s) require authorization in your browser:` - ) + log.warn( + `${needsOAuth.length} connector(s) require authorization in your browser:` ); for (const connector of needsOAuth) { - log.info(` ${connector.type}: ${chalk.dim(connector.redirectUrl)}`); + log.info(` ${connector.type}: ${theme.styles.dim(connector.redirectUrl)}`); } const pending = needsOAuth.map((c) => c.type).join(", "); diff --git a/src/core/resources/connector/config.ts b/src/core/resources/connector/config.ts index 78cdee84..fe593560 100644 --- a/src/core/resources/connector/config.ts +++ b/src/core/resources/connector/config.ts @@ -1,5 +1,5 @@ import { globby } from "globby"; -import { SchemaValidationError } from "@/core/errors.js"; +import { InvalidInputError, SchemaValidationError } from "@/core/errors.js"; import { CONFIG_FILE_EXTENSION_GLOB } from "../../consts.js"; import { pathExists, readJsonFile } from "../../utils/fs.js"; import type { ConnectorResource } from "./schema.js"; @@ -45,7 +45,16 @@ export async function readAllConnectors( const types = new Set(); for (const connector of connectors) { if (types.has(connector.type)) { - throw new Error(`Duplicate connector type "${connector.type}"`); + throw new InvalidInputError( + `Duplicate connector type "${connector.type}"`, + { + hints: [ + { + message: `Remove duplicate connectors with type "${connector.type}" - only one connector per type is allowed`, + }, + ], + } + ); } types.add(connector.type); } diff --git a/src/core/resources/connector/push.ts b/src/core/resources/connector/push.ts index f386447c..35b6fdfa 100644 --- a/src/core/resources/connector/push.ts +++ b/src/core/resources/connector/push.ts @@ -68,7 +68,7 @@ function setResponseToResult( action: "error", error: response.error_message || - `Already connected by ${response.other_user_email}`, + `Already connected by ${response.other_user_email ?? "another user"}`, }; } diff --git a/src/core/resources/connector/schema.ts b/src/core/resources/connector/schema.ts index ff5be687..61c0afd6 100644 --- a/src/core/resources/connector/schema.ts +++ b/src/core/resources/connector/schema.ts @@ -72,7 +72,17 @@ export const TikTokConnectorSchema = z.object({ scopes: z.array(z.string()).default([]), }); -export const ConnectorResourceSchema = z.discriminatedUnion("type", [ +/** Generic connector schema for arbitrary providers */ +const GenericConnectorSchema = z.object({ + type: z.string().min(1).regex(/^[a-z0-9_-]+$/i), + scopes: z.array(z.string()).default([]), +}); + +/** + * Connector resource schema that accepts both known providers (with specific schemas) + * and arbitrary provider strings (with generic schema). + */ +export const ConnectorResourceSchema = z.union([ GoogleCalendarConnectorSchema, GoogleDriveConnectorSchema, GmailConnectorSchema, @@ -85,11 +95,13 @@ export const ConnectorResourceSchema = z.discriminatedUnion("type", [ HubspotConnectorSchema, LinkedInConnectorSchema, TikTokConnectorSchema, + GenericConnectorSchema, ]); export type ConnectorResource = z.infer; -export const IntegrationTypeSchema = z.enum([ +/** Known integration types with first-class support */ +export const KnownIntegrationTypes = [ "googlecalendar", "googledrive", "gmail", @@ -102,6 +114,15 @@ export const IntegrationTypeSchema = z.enum([ "hubspot", "linkedin", "tiktok", +] as const; + +/** + * Integration type schema that accepts both known providers and arbitrary strings. + * This allows users to use custom OAuth providers not yet supported by Base44. + */ +export const IntegrationTypeSchema = z.union([ + z.enum(KnownIntegrationTypes), + z.string().min(1).regex(/^[a-z0-9_-]+$/i), ]); export type IntegrationType = z.infer; diff --git a/tests/cli/connectors_push.spec.ts b/tests/cli/connectors_push.spec.ts index 33fa391b..5c09e9fd 100644 --- a/tests/cli/connectors_push.spec.ts +++ b/tests/cli/connectors_push.spec.ts @@ -59,7 +59,7 @@ describe("connectors push command", () => { await t.givenLoggedInWithProject(fixture("basic")); t.api.mockConnectorsList({ integrations: [ - { integration_type: "slack", status: "ACTIVE", scopes: ["chat:write"] }, + { integration_type: "slack", status: "active", scopes: ["chat:write"] }, ], }); t.api.mockConnectorRemove({ status: "removed", integration_type: "slack" }); diff --git a/tests/core/connectors.spec.ts b/tests/core/connectors.spec.ts index 8bf6ea9b..b5160550 100644 --- a/tests/core/connectors.spec.ts +++ b/tests/core/connectors.spec.ts @@ -1,5 +1,6 @@ import { resolve } from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { InvalidInputError } from "../../src/core/errors.js"; import * as api from "../../src/core/resources/connector/api.js"; import { readAllConnectors } from "../../src/core/resources/connector/config.js"; import { @@ -40,10 +41,21 @@ describe("IntegrationTypeSchema", () => { } }); - it("rejects invalid integration types", () => { - const invalidTypes = ["invalid", "google", "facebook", "twitter", ""]; + it("accepts arbitrary integration types (including custom providers)", () => { + const arbitraryTypes = ["invalid", "google", "facebook", "twitter", "custom-oauth-provider"]; - for (const type of invalidTypes) { + for (const type of arbitraryTypes) { + expect(IntegrationTypeSchema.safeParse(type).success).toBe(true); + } + }); + + it("rejects empty strings", () => { + expect(IntegrationTypeSchema.safeParse("").success).toBe(false); + }); + + it("rejects path traversal strings", () => { + const malicious = ["../admin", "../../endpoint", "type/with/slashes", "type with spaces"]; + for (const type of malicious) { expect(IntegrationTypeSchema.safeParse(type).success).toBe(false); } }); @@ -92,14 +104,18 @@ describe("ConnectorResourceSchema", () => { } }); - it("rejects connector with invalid type", () => { + it("accepts connector with arbitrary provider type", () => { const connector = { - type: "invalid", - scopes: [], + type: "custom-oauth-provider", + scopes: ["scope1", "scope2"], }; const result = ConnectorResourceSchema.safeParse(connector); - expect(result.success).toBe(false); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("custom-oauth-provider"); + expect(result.data.scopes).toEqual(["scope1", "scope2"]); + } }); it("rejects connector without type", () => { @@ -144,6 +160,17 @@ describe("readAllConnectors", () => { "Invalid connector file" ); }); + + it("throws InvalidInputError for duplicate connector types", async () => { + const connectorsDir = resolve(FIXTURES_DIR, "duplicate-connectors/connectors"); + + await expect(readAllConnectors(connectorsDir)).rejects.toThrow( + InvalidInputError + ); + await expect(readAllConnectors(connectorsDir)).rejects.toThrow( + 'Duplicate connector type "slack"' + ); + }); }); const mockListConnectors = vi.mocked(api.listConnectors); @@ -183,7 +210,7 @@ describe("pushConnectors", () => { it("removes upstream-only connectors", async () => { mockListConnectors.mockResolvedValue({ integrations: [ - { integration_type: "slack", status: "ACTIVE", scopes: ["chat:write"] }, + { integration_type: "slack", status: "active", scopes: ["chat:write"] }, ], }); mockRemoveConnector.mockResolvedValue({ @@ -203,7 +230,7 @@ describe("pushConnectors", () => { ]; mockListConnectors.mockResolvedValue({ integrations: [ - { integration_type: "slack", status: "ACTIVE", scopes: ["chat:write"] }, + { integration_type: "slack", status: "active", scopes: ["chat:write"] }, ], }); mockSetConnector.mockResolvedValue({ @@ -236,7 +263,7 @@ describe("pushConnectors", () => { integrations: [ { integration_type: "gmail", - status: "ACTIVE", + status: "active", scopes: ["https://mail.google.com/"], }, ], @@ -299,6 +326,30 @@ describe("pushConnectors", () => { ]); }); + it("returns fallback message when different_user has no error_message or email", async () => { + const local: ConnectorResource[] = [ + { type: "gmail", scopes: ["https://mail.google.com/"] }, + ]; + mockSetConnector.mockResolvedValue({ + redirect_url: null, + connection_id: null, + already_authorized: false, + error: "different_user", + error_message: null, + other_user_email: null, + }); + + const result = await pushConnectors(local); + + expect(result.results).toEqual([ + { + type: "gmail", + action: "error", + error: "Already connected by another user", + }, + ]); + }); + it("handles sync errors gracefully", async () => { const local: ConnectorResource[] = [ { type: "gmail", scopes: ["https://mail.google.com/"] }, @@ -315,7 +366,7 @@ describe("pushConnectors", () => { it("handles remove errors gracefully", async () => { mockListConnectors.mockResolvedValue({ integrations: [ - { integration_type: "slack", status: "ACTIVE", scopes: ["chat:write"] }, + { integration_type: "slack", status: "active", scopes: ["chat:write"] }, ], }); mockRemoveConnector.mockRejectedValue(new Error("Remove failed")); diff --git a/tests/fixtures/duplicate-connectors/base44/.app.jsonc b/tests/fixtures/duplicate-connectors/base44/.app.jsonc new file mode 100644 index 00000000..d7852426 --- /dev/null +++ b/tests/fixtures/duplicate-connectors/base44/.app.jsonc @@ -0,0 +1,4 @@ +// Base44 App Configuration +{ + "id": "test-app-id" +} diff --git a/tests/fixtures/duplicate-connectors/config.jsonc b/tests/fixtures/duplicate-connectors/config.jsonc new file mode 100644 index 00000000..1a85a25d --- /dev/null +++ b/tests/fixtures/duplicate-connectors/config.jsonc @@ -0,0 +1,3 @@ +{ + "name": "Project with Duplicate Connectors" +} diff --git a/tests/fixtures/duplicate-connectors/connectors/slack1.jsonc b/tests/fixtures/duplicate-connectors/connectors/slack1.jsonc new file mode 100644 index 00000000..e1a4e781 --- /dev/null +++ b/tests/fixtures/duplicate-connectors/connectors/slack1.jsonc @@ -0,0 +1,4 @@ +{ + "type": "slack", + "scopes": ["chat:write"] +} diff --git a/tests/fixtures/duplicate-connectors/connectors/slack2.jsonc b/tests/fixtures/duplicate-connectors/connectors/slack2.jsonc new file mode 100644 index 00000000..f4d41f57 --- /dev/null +++ b/tests/fixtures/duplicate-connectors/connectors/slack2.jsonc @@ -0,0 +1,4 @@ +{ + "type": "slack", + "scopes": ["channels:read"] +} diff --git a/tests/fixtures/invalid-connector/connectors/invalid.jsonc b/tests/fixtures/invalid-connector/connectors/invalid.jsonc index 123665f8..7c08de16 100644 --- a/tests/fixtures/invalid-connector/connectors/invalid.jsonc +++ b/tests/fixtures/invalid-connector/connectors/invalid.jsonc @@ -1,5 +1,4 @@ -// Invalid connector - unknown integration type +// Invalid connector - missing type field { - "type": "invalid", "scopes": [] }