diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index d7bc60f3e..bd41ab776 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -156,7 +156,7 @@ jobs: timeout: 5m0s name: ${{ env.SERVICE_NAME }} chart-repository: https://techops-services.github.io/helm-charts - version: 0.0.33 + version: 0.2.1 atomic: true - name: Deploying Job Spawner to Kubernetes with Helm @@ -171,5 +171,5 @@ jobs: timeout: 5m0s name: keeperhub-job-spawner chart-repository: https://techops-services.github.io/helm-charts - version: 0.0.33 + version: 0.2.1 atomic: true diff --git a/Dockerfile b/Dockerfile index f2dead431..9b08d60be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,7 @@ COPY --from=deps /app/node_modules ./node_modules COPY --from=builder /app/drizzle ./drizzle COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts COPY --from=builder /app/lib ./lib +COPY --from=builder /app/keeperhub ./keeperhub COPY --from=builder /app/package.json ./package.json # This stage can be used to run migrations diff --git a/app/api/workflows/public/route.ts b/app/api/workflows/public/route.ts new file mode 100644 index 000000000..17f7f6b4b --- /dev/null +++ b/app/api/workflows/public/route.ts @@ -0,0 +1,35 @@ +import { desc, eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { workflows } from "@/lib/db/schema"; + +// start custom KeeperHub code +export async function GET() { + try { + const publicWorkflows = await db + .select() + .from(workflows) + .where(eq(workflows.visibility, "public")) + .orderBy(desc(workflows.updatedAt)); + + const mappedWorkflows = publicWorkflows.map((workflow) => ({ + ...workflow, + createdAt: workflow.createdAt.toISOString(), + updatedAt: workflow.updatedAt.toISOString(), + })); + + return NextResponse.json(mappedWorkflows); + } catch (error) { + console.error("Failed to get public workflows:", error); + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Failed to get public workflows", + }, + { status: 500 } + ); + } +} +// end custom KeeperHub code diff --git a/app/hub/page.tsx b/app/hub/page.tsx new file mode 100644 index 000000000..1f7bfc130 --- /dev/null +++ b/app/hub/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { WorkflowTemplateGrid } from "@/keeperhub/components/hub/workflow-template-grid"; +import { api, type SavedWorkflow } from "@/lib/api-client"; + +export default function HubPage() { + // start custom KeeperHub code + const [workflows, setWorkflows] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchPublicWorkflows = async () => { + try { + const publicWorkflows = await api.workflow.getPublic(); + setWorkflows(publicWorkflows); + } catch (error) { + console.error("Failed to fetch public workflows:", error); + } finally { + setIsLoading(false); + } + }; + + fetchPublicWorkflows(); + }, []); + // end custom KeeperHub code + + return ( +
+
+

Public Workflows

+ {/* start custom KeeperHub code */} + {isLoading ? ( +

Loading workflows...

+ ) : ( + + )} + {/* end custom KeeperHub code */} +
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index f78df8c77..369809a39 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,17 +2,16 @@ import type { Metadata, Viewport } from "next"; import "./globals.css"; import { Analytics } from "@vercel/analytics/react"; import { SpeedInsights } from "@vercel/speed-insights/next"; -import { ReactFlowProvider } from "@xyflow/react"; import { Provider } from "jotai"; import { type ReactNode, Suspense } from "react"; import { AuthProvider } from "@/components/auth/provider"; import { GitHubStarsLoader } from "@/components/github-stars-loader"; import { GitHubStarsProvider } from "@/components/github-stars-provider"; import { GlobalModals } from "@/components/global-modals"; +import { LayoutContent } from "@/components/layout-content"; import { OverlayProvider } from "@/components/overlays/overlay-provider"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; -import { PersistentCanvas } from "@/components/workflow/persistent-canvas"; import { KeeperHubExtensionLoader } from "@/keeperhub/components/extension-loader"; import { mono, sans } from "@/lib/fonts"; import { cn } from "@/lib/utils"; @@ -35,16 +34,6 @@ type RootLayoutProps = { children: ReactNode; }; -// Inner content wrapped by GitHubStarsProvider (used for both loading and loaded states) -function LayoutContent({ children }: { children: ReactNode }) { - return ( - - -
{children}
-
- ); -} - const RootLayout = ({ children }: RootLayoutProps) => ( diff --git a/components/layout-content.tsx b/components/layout-content.tsx new file mode 100644 index 000000000..f5cb7a01d --- /dev/null +++ b/components/layout-content.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { ReactFlowProvider } from "@xyflow/react"; +import type { ReactNode } from "react"; +import { PersistentCanvas } from "@/components/workflow/persistent-canvas"; +import { WorkflowToolbar } from "@/components/workflow/workflow-toolbar"; + +export function LayoutContent({ children }: { children: ReactNode }) { + return ( + + + +
{children}
+
+ ); +} diff --git a/components/overlays/add-connection-overlay.tsx b/components/overlays/add-connection-overlay.tsx index 999cc2db0..2449d7f55 100644 --- a/components/overlays/add-connection-overlay.tsx +++ b/components/overlays/add-connection-overlay.tsx @@ -14,6 +14,9 @@ import { aiGatewayTeamsLoadingAtom, } from "@/lib/ai-gateway/state"; import { api } from "@/lib/api-client"; +// start keeperhub +import { getCustomIntegrationFormHandler } from "@/lib/extension-registry"; +// end keeperhub import type { IntegrationType } from "@/lib/types/integration"; import { getIntegration, @@ -361,6 +364,18 @@ export function ConfigureConnectionOverlay({ // Render config fields const renderConfigFields = () => { + // start keeperhub - check for custom form handlers (e.g., web3 wallet) + const customHandler = getCustomIntegrationFormHandler(type); + if (customHandler) { + return customHandler({ + integrationType: type, + isEditMode: false, + config, + updateConfig, + }); + } + // end keeperhub + if (type === "database") { return ( { if (!(selectedNode && isOwner)) { return; @@ -465,17 +467,26 @@ export function ConfigurationOverlay({ overlayId }: ConfigurationOverlayProps) {

)} - {isOwner && ( + {isOwner ? (
-
+ ) : ( +
+ DEBUG: isOwner is false - Delete button not rendered +
)} )} diff --git a/components/overlays/edit-connection-overlay.tsx b/components/overlays/edit-connection-overlay.tsx index 10e7b7f6f..3d43c4b72 100644 --- a/components/overlays/edit-connection-overlay.tsx +++ b/components/overlays/edit-connection-overlay.tsx @@ -9,7 +9,10 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useIsMobile } from "@/hooks/use-mobile"; import { api, type Integration } from "@/lib/api-client"; +// start keeperhub +import { getCustomIntegrationFormHandler } from "@/lib/extension-registry"; import { getIntegration, getIntegrationLabels } from "@/plugins"; +// end keeperhub import { ConfirmOverlay } from "./confirm-overlay"; import { Overlay } from "./overlay"; import { useOverlay } from "./overlay-provider"; @@ -267,6 +270,18 @@ export function EditConnectionOverlay({ // Render config fields const renderConfigFields = () => { + // start keeperhub - check for custom form handlers (e.g., web3 wallet display) + const customHandler = getCustomIntegrationFormHandler(integration.type); + if (customHandler) { + return customHandler({ + integrationType: integration.type, + isEditMode: true, + config, + updateConfig, + }); + } + // end keeperhub + if (integration.type === "database") { return ( - {hasExistingConnections && ( - - )} + {/* start keeperhub - hide + button for singleConnection integrations */} + {hasExistingConnections && + !getIntegration(integrationType)?.singleConnection && ( + + )} + {/* end keeperhub */} { const deleteNode = useSetAtom(deleteNodeAtom); const deleteEdge = useSetAtom(deleteEdgeAtom); const deleteSelectedItems = useSetAtom(deleteSelectedItemsAtom); - const setShowClearDialog = useSetAtom(showClearDialogAtom); - const setShowDeleteDialog = useSetAtom(showDeleteDialogAtom); + const [showClearDialog, setShowClearDialog] = useAtom(showClearDialogAtom); + const [showDeleteDialog, setShowDeleteDialog] = useAtom(showDeleteDialogAtom); const clearNodeStatuses = useSetAtom(clearNodeStatusesAtom); + const clearWorkflow = useSetAtom(clearWorkflowAtom); + const { open: openOverlay } = useOverlay(); + + // Watch showDeleteDialog atom and open overlay when it becomes true + useEffect(() => { + if (showDeleteDialog) { + openOverlay(ConfirmOverlay, { + title: "Delete Workflow", + message: `Are you sure you want to delete "${currentWorkflowName}"? This will permanently delete the workflow. This cannot be undone.`, + confirmLabel: "Delete Workflow", + confirmVariant: "destructive" as const, + destructive: true, + onConfirm: async () => { + if (!currentWorkflowId) { + return; + } + try { + await api.workflow.delete(currentWorkflowId); + toast.success("Workflow deleted successfully"); + window.location.href = "/"; + } catch (_error) { + toast.error("Failed to delete workflow. Please try again."); + } + }, + }); + setShowDeleteDialog(false); + } + }, [ + showDeleteDialog, + currentWorkflowId, + currentWorkflowName, + openOverlay, + setShowDeleteDialog, + ]); + + // Watch showClearDialog atom and open overlay when it becomes true + useEffect(() => { + if (showClearDialog) { + openOverlay(ConfirmOverlay, { + title: "Clear Workflow", + message: + "Are you sure you want to clear all nodes and connections? This action cannot be undone.", + confirmLabel: "Clear Workflow", + confirmVariant: "destructive" as const, + destructive: true, + onConfirm: () => { + clearWorkflow(); + }, + }); + setShowClearDialog(false); + } + }, [showClearDialog, openOverlay, clearWorkflow, setShowClearDialog]); const setPendingIntegrationNodes = useSetAtom(pendingIntegrationNodesAtom); const [newlyCreatedNodeId, setNewlyCreatedNodeId] = useAtom( newlyCreatedNodeIdAtom @@ -632,7 +685,9 @@ export const PanelInner = () => { + + + ); + })} + + ); +} diff --git a/keeperhub/components/persistent-header.tsx b/keeperhub/components/persistent-header.tsx new file mode 100644 index 000000000..99b969603 --- /dev/null +++ b/keeperhub/components/persistent-header.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Check, ChevronDown } from "lucide-react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { WorkflowIcon } from "@/components/ui/workflow-icon"; +import { UserMenu } from "@/components/workflows/user-menu"; +import { getCustomLogo } from "@/lib/extension-registry"; + +export function PersistentHeader() { + const pathname = usePathname(); + const router = useRouter(); + + // Only show on non-workflow pages (workflow pages have their own toolbar) + const isWorkflowPage = pathname === "/" || pathname.startsWith("/workflows/"); + + if (isWorkflowPage) { + return null; + } + + const CustomLogo = getCustomLogo(); + + return ( +
+ {/* Left side: Logo + Menu */} +
+ {CustomLogo && } +
+ + + +

+ Menu + Menu +

+ +
+ + + New Workflow + + + router.push("/hub")} + > + Hub + {pathname === "/hub" && } + + +
+
+
+ + {/* Right side: User Menu */} +
+ +
+
+ ); +} diff --git a/keeperhub/db/schema-extensions.ts b/keeperhub/db/schema-extensions.ts index a83afd21f..ab7d34592 100644 --- a/keeperhub/db/schema-extensions.ts +++ b/keeperhub/db/schema-extensions.ts @@ -9,8 +9,9 @@ */ import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; -import { users } from "@/lib/db/schema"; -import { generateId } from "@/lib/utils/id"; +// Note: Using relative paths instead of @/ aliases for drizzle-kit compatibility +import { users } from "../../lib/db/schema"; +import { generateId } from "../../lib/utils/id"; /** * Para Wallets table diff --git a/keeperhub/plugins/web3/index.ts b/keeperhub/plugins/web3/index.ts index 9115e57a0..17b8b43aa 100644 --- a/keeperhub/plugins/web3/index.ts +++ b/keeperhub/plugins/web3/index.ts @@ -9,19 +9,11 @@ const web3Plugin: IntegrationPlugin = { icon: Web3Icon, - // Minimal form field - Web3 uses PARA wallet (user must create manually) - // This field is informational only and not used - formFields: [ - { - id: "info", - label: "Para Wallet", - type: "text", - placeholder: "Create a wallet to use Web3 actions", - configKey: "info", - helpText: - "You'll need to create a Para wallet to use Web3 actions in your workflows.", - }, - ], + // Web3 uses Para wallet - one wallet per user + singleConnection: true, + + // No form fields - wallet creation is handled by the custom form handler + formFields: [], // No test function needed - no credentials to test // testConfig is optional, so we omit it diff --git a/lib/api-client.ts b/lib/api-client.ts index 3b8b382d5..1418a0cb1 100644 --- a/lib/api-client.ts +++ b/lib/api-client.ts @@ -438,6 +438,10 @@ export const userApi = { export const workflowApi = { // Get all workflows getAll: () => apiCall("/api/workflows"), + // start custom KeeperHub code + // Get public workflows + getPublic: () => apiCall("/api/workflows/public"), + // end custom KeeperHub code // Get a specific workflow getById: (id: string) => apiCall(`/api/workflows/${id}`), diff --git a/lib/db/schema.ts b/lib/db/schema.ts index afc2b7886..bd34d5b4e 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -166,11 +166,12 @@ export const workflowExecutionLogs = pgTable("workflow_execution_logs", { // KeeperHub: Para Wallets table (imported from KeeperHub schema extensions) // biome-ignore lint/performance/noBarrelFile: Intentional re-export for schema extensions +// Note: Using relative path instead of @/ alias for drizzle-kit compatibility export { type NewParaWallet, type ParaWallet, paraWallets, -} from "@/keeperhub/db/schema-extensions"; +} from "../../keeperhub/db/schema-extensions"; // API Keys table for webhook authentication export const apiKeys = pgTable("api_keys", { diff --git a/lib/steps/index.ts b/lib/steps/index.ts index af9212496..34951994d 100644 --- a/lib/steps/index.ts +++ b/lib/steps/index.ts @@ -7,10 +7,10 @@ * This file is kept for backwards compatibility with system steps only. */ -import type { sendEmailStep } from "../../plugins/resend/steps/send-email"; -import type { sendSlackMessageStep } from "../../plugins/slack/steps/send-slack-message"; import type { checkBalanceStep } from "@/keeperhub/plugins/web3/steps/check-balance"; import type { transferFundsStep } from "@/keeperhub/plugins/web3/steps/transfer-funds"; +import type { sendEmailStep } from "../../plugins/resend/steps/send-email"; +import type { sendSlackMessageStep } from "../../plugins/slack/steps/send-slack-message"; import type { conditionStep } from "./condition"; import type { databaseQueryStep } from "./database-query"; import type { httpRequestStep } from "./http-request"; @@ -41,13 +41,13 @@ export const stepRegistry: Record = { await import("../../plugins/slack/steps/send-slack-message") ).sendSlackMessageStep(input as Parameters[0]), "Transfer Funds": async (input) => - (await import("@/keeperhub/plugins/web3/steps/transfer-funds")).transferFundsStep( - input as Parameters[0] - ), + ( + await import("@/keeperhub/plugins/web3/steps/transfer-funds") + ).transferFundsStep(input as Parameters[0]), "Check Balance": async (input) => - (await import("@/keeperhub/plugins/web3/steps/check-balance")).checkBalanceStep( - input as Parameters[0] - ), + ( + await import("@/keeperhub/plugins/web3/steps/check-balance") + ).checkBalanceStep(input as Parameters[0]), }; // Helper to check if a step exists diff --git a/package.json b/package.json index e8d776115..a42448514 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "description": "A template for building your own AI-driven workflow automation platform", "scripts": { "dev": "pnpm discover-plugins && next dev", - "build": "pnpm discover-plugins && next build", + "build": "tsx scripts/migrate-prod.ts && pnpm discover-plugins && next build", "start": "next start", "type-check": "tsc --noEmit", "check": "npx ultracite@latest check", diff --git a/plugins/registry.ts b/plugins/registry.ts index defe6738c..2766dc480 100644 --- a/plugins/registry.ts +++ b/plugins/registry.ts @@ -175,6 +175,11 @@ export type IntegrationPlugin = { // Defaults to true for backward compatibility requiresCredentials?: boolean; + // Whether only one connection is allowed per user + // Set to true for integrations with unique constraints (e.g., web3 wallet) + // When true, the "+" button to add more connections will be hidden + singleConnection?: boolean; + // Form fields for the integration dialog formFields: Array<{ id: string; diff --git a/scripts/job-spawner.ts b/scripts/job-spawner.ts index 3ebbc1bd1..f70945bf6 100644 --- a/scripts/job-spawner.ts +++ b/scripts/job-spawner.ts @@ -145,7 +145,10 @@ async function createWorkflowJob( { name: "WORKFLOW_INPUT", value: JSON.stringify(input) }, // Pass database URL and encryption key from job-spawner's environment { name: "DATABASE_URL", value: CONFIG.databaseUrl }, - { name: "INTEGRATION_ENCRYPTION_KEY", value: process.env.INTEGRATION_ENCRYPTION_KEY || "" }, + { + name: "INTEGRATION_ENCRYPTION_KEY", + value: process.env.INTEGRATION_ENCRYPTION_KEY || "", + }, ], resources: { requests: { diff --git a/scripts/migrate-prod.ts b/scripts/migrate-prod.ts new file mode 100644 index 000000000..e15c1f23b --- /dev/null +++ b/scripts/migrate-prod.ts @@ -0,0 +1,16 @@ +import { execSync } from "child_process"; + +const VERCEL_ENV = process.env.VERCEL_ENV; + +if (VERCEL_ENV === "production") { + console.log("Running database migrations for production..."); + try { + execSync("pnpm db:migrate", { stdio: "inherit" }); + console.log("Migrations completed successfully"); + } catch (error) { + console.error("Migration failed:", error); + process.exit(1); + } +} else { + console.log(`Skipping migrations (VERCEL_ENV=${VERCEL_ENV ?? "not set"})`); +}