From 5d7fa7c9dfab35b5c9d523c60c1dcb68c7e3df43 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 9 Apr 2026 18:29:21 -0300 Subject: [PATCH 001/110] feat(schema): add githubRepo to virtual MCP metadata Co-Authored-By: Claude Sonnet 4.6 --- packages/mesh-sdk/src/types/virtual-mcp.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/mesh-sdk/src/types/virtual-mcp.ts b/packages/mesh-sdk/src/types/virtual-mcp.ts index 62beddf9be..17630bef03 100644 --- a/packages/mesh-sdk/src/types/virtual-mcp.ts +++ b/packages/mesh-sdk/src/types/virtual-mcp.ts @@ -84,6 +84,18 @@ const VirtualMcpUISchema = z.object({ export type VirtualMcpUI = z.infer; +/** + * GitHub repository linked to a virtual MCP + */ +const GithubRepoSchema = z.object({ + url: z.string().describe("GitHub repository URL"), + owner: z.string().describe("Repository owner"), + name: z.string().describe("Repository name"), + installationId: z.number().describe("GitHub App installation ID"), +}); + +export type GithubRepo = z.infer; + /** * Virtual MCP entity schema - single source of truth * Compliant with collections binding pattern @@ -122,6 +134,9 @@ export const VirtualMCPEntitySchema = z.object({ ui: VirtualMcpUISchema.nullable() .optional() .describe("UI customization settings"), + githubRepo: GithubRepoSchema.nullable() + .optional() + .describe("Linked GitHub repository"), }) .loose() .describe("Metadata"), @@ -168,6 +183,9 @@ export const VirtualMCPCreateDataSchema = z.object({ ui: VirtualMcpUISchema.nullable() .optional() .describe("UI customization settings"), + githubRepo: GithubRepoSchema.nullable() + .optional() + .describe("Linked GitHub repository"), }) .loose() .nullable() @@ -210,6 +228,9 @@ export const VirtualMCPUpdateDataSchema = z.object({ ui: VirtualMcpUISchema.nullable() .optional() .describe("UI customization settings"), + githubRepo: GithubRepoSchema.nullable() + .optional() + .describe("Linked GitHub repository"), }) .loose() .nullable() From 95c80b0e87a757720d535513c63d583ff944c23e Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 9 Apr 2026 18:32:39 -0300 Subject: [PATCH 002/110] feat(tools): add GITHUB_LIST_INSTALLATIONS app-only tool Co-Authored-By: Claude Sonnet 4.6 --- apps/mesh/src/storage/types.ts | 23 +++++ apps/mesh/src/tools/github/index.ts | 7 ++ .../src/tools/github/list-installations.ts | 95 +++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 apps/mesh/src/tools/github/index.ts create mode 100644 apps/mesh/src/tools/github/list-installations.ts diff --git a/apps/mesh/src/storage/types.ts b/apps/mesh/src/storage/types.ts index 00c535c72e..59925f8a98 100644 --- a/apps/mesh/src/storage/types.ts +++ b/apps/mesh/src/storage/types.ts @@ -474,6 +474,26 @@ export interface OrgSsoSession { // Better Auth Organization Tables (managed by Better Auth plugin) // ============================================================================ +/** + * Better Auth account table (OAuth / social provider accounts) + * Created by Better Auth migrations. + */ +export interface BetterAuthAccountTable { + id: string; + userId: string; + accountId: string; + providerId: string; + accessToken: string | null; + refreshToken: string | null; + accessTokenExpiresAt: string | null; + refreshTokenExpiresAt: string | null; + scope: string | null; + idToken: string | null; + password: string | null; + createdAt: ColumnType; + updatedAt: ColumnType; +} + /** * Better Auth organization table */ @@ -1018,6 +1038,9 @@ export interface Database { oauth_refresh_tokens: OAuthRefreshTokenTable; downstream_tokens: DownstreamTokenTable; + // Better Auth core tables (managed by Better Auth) + account: BetterAuthAccountTable; + // Better Auth organization tables (managed by Better Auth plugin) organization: BetterAuthOrganizationTable; member: BetterAuthMemberTable; diff --git a/apps/mesh/src/tools/github/index.ts b/apps/mesh/src/tools/github/index.ts new file mode 100644 index 0000000000..64b874d432 --- /dev/null +++ b/apps/mesh/src/tools/github/index.ts @@ -0,0 +1,7 @@ +/** + * GitHub Tools + * + * Tools for GitHub integration (app-only, not visible to AI models). + */ + +export { GITHUB_LIST_INSTALLATIONS } from "./list-installations"; diff --git a/apps/mesh/src/tools/github/list-installations.ts b/apps/mesh/src/tools/github/list-installations.ts new file mode 100644 index 0000000000..b71a7d22cf --- /dev/null +++ b/apps/mesh/src/tools/github/list-installations.ts @@ -0,0 +1,95 @@ +/** + * GITHUB_LIST_INSTALLATIONS Tool + * + * Lists GitHub App installations of the Deco CMS app for the current user. + * App-only tool — not visible to AI models. + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { getUserId, requireAuth } from "../../core/mesh-context"; + +const InstallationSchema = z.object({ + installationId: z.number(), + orgName: z.string(), + avatarUrl: z.string().nullable(), +}); + +export const GITHUB_LIST_INSTALLATIONS = defineTool({ + name: "GITHUB_LIST_INSTALLATIONS", + description: + "List GitHub App installations of the Deco CMS app for the current user.", + annotations: { + title: "List GitHub Installations", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({}), + outputSchema: z.object({ + installations: z.array(InstallationSchema), + hasGithubAccount: z.boolean(), + }), + + handler: async (_input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + + const userId = getUserId(ctx); + if (!userId) { + throw new Error("User ID required"); + } + + // Look up the user's GitHub account in Better Auth's account table + const accounts = await ctx.db + .selectFrom("account") + .selectAll() + .where("userId", "=", userId) + .where("providerId", "=", "github") + .execute(); + + if (accounts.length === 0) { + return { installations: [], hasGithubAccount: false }; + } + + const githubAccount = accounts[0]; + const accessToken = githubAccount.accessToken; + + if (!accessToken) { + return { installations: [], hasGithubAccount: true }; + } + + // Fetch user's installations from GitHub API + const response = await fetch("https://api.github.com/user/installations", { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status}`); + } + + const data = (await response.json()) as { + installations: Array<{ + id: number; + account: { + login: string; + avatar_url: string; + }; + }>; + }; + + const installations = data.installations.map((inst) => ({ + installationId: inst.id, + orgName: inst.account.login, + avatarUrl: inst.account.avatar_url, + })); + + return { installations, hasGithubAccount: true }; + }, +}); From 06396c73bfea109d0ce384240a9b9df1b6df7f71 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 9 Apr 2026 18:32:59 -0300 Subject: [PATCH 003/110] feat(tools): add GITHUB_LIST_REPOS app-only tool Co-Authored-By: Claude Sonnet 4.6 --- apps/mesh/src/tools/github/index.ts | 1 + apps/mesh/src/tools/github/list-repos.ts | 98 ++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 apps/mesh/src/tools/github/list-repos.ts diff --git a/apps/mesh/src/tools/github/index.ts b/apps/mesh/src/tools/github/index.ts index 64b874d432..0f272958cd 100644 --- a/apps/mesh/src/tools/github/index.ts +++ b/apps/mesh/src/tools/github/index.ts @@ -5,3 +5,4 @@ */ export { GITHUB_LIST_INSTALLATIONS } from "./list-installations"; +export { GITHUB_LIST_REPOS } from "./list-repos"; diff --git a/apps/mesh/src/tools/github/list-repos.ts b/apps/mesh/src/tools/github/list-repos.ts new file mode 100644 index 0000000000..8a7435bb2d --- /dev/null +++ b/apps/mesh/src/tools/github/list-repos.ts @@ -0,0 +1,98 @@ +/** + * GITHUB_LIST_REPOS Tool + * + * Lists repositories accessible under a GitHub App installation. + * App-only tool — not visible to AI models. + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { getUserId, requireAuth } from "../../core/mesh-context"; + +const RepoSchema = z.object({ + owner: z.string(), + name: z.string(), + fullName: z.string(), + url: z.string(), + private: z.boolean(), +}); + +export const GITHUB_LIST_REPOS = defineTool({ + name: "GITHUB_LIST_REPOS", + description: "List repositories accessible under a GitHub App installation.", + annotations: { + title: "List GitHub Repos", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + installationId: z.number().describe("GitHub App installation ID"), + }), + outputSchema: z.object({ + repos: z.array(RepoSchema), + }), + + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + + const userId = getUserId(ctx); + if (!userId) { + throw new Error("User ID required"); + } + + const accounts = await ctx.db + .selectFrom("account") + .selectAll() + .where("userId", "=", userId) + .where("providerId", "=", "github") + .execute(); + + if (accounts.length === 0) { + throw new Error("No GitHub account linked"); + } + + const accessToken = accounts[0].accessToken; + if (!accessToken) { + throw new Error("No GitHub access token available"); + } + + const response = await fetch( + `https://api.github.com/user/installations/${input.installationId}/repositories`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status}`); + } + + const data = (await response.json()) as { + repositories: Array<{ + owner: { login: string }; + name: string; + full_name: string; + html_url: string; + private: boolean; + }>; + }; + + const repos = data.repositories.map((repo) => ({ + owner: repo.owner.login, + name: repo.name, + fullName: repo.full_name, + url: repo.html_url, + private: repo.private, + })); + + return { repos }; + }, +}); From 6a7d83268fbe53a6360fd1c5dedeef9dd2db92b5 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 9 Apr 2026 18:35:43 -0300 Subject: [PATCH 004/110] feat(tools): register GitHub tools in tool registry Wires GITHUB_LIST_INSTALLATIONS and GITHUB_LIST_REPOS into the centralized tool registry (CORE_TOOLS array and ALL_TOOL_NAMES), adds the GitHub category to ToolCategory, and fixes TypeScript strict-null errors in the GitHub tool handlers. Co-Authored-By: Claude Sonnet 4.6 --- .../src/tools/github/list-installations.ts | 2 +- apps/mesh/src/tools/github/list-repos.ts | 2 +- apps/mesh/src/tools/index.ts | 5 ++++ apps/mesh/src/tools/registry-metadata.ts | 23 ++++++++++++++++++- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/mesh/src/tools/github/list-installations.ts b/apps/mesh/src/tools/github/list-installations.ts index b71a7d22cf..9b96bd9e23 100644 --- a/apps/mesh/src/tools/github/list-installations.ts +++ b/apps/mesh/src/tools/github/list-installations.ts @@ -54,7 +54,7 @@ export const GITHUB_LIST_INSTALLATIONS = defineTool({ return { installations: [], hasGithubAccount: false }; } - const githubAccount = accounts[0]; + const githubAccount = accounts[0]!; const accessToken = githubAccount.accessToken; if (!accessToken) { diff --git a/apps/mesh/src/tools/github/list-repos.ts b/apps/mesh/src/tools/github/list-repos.ts index 8a7435bb2d..94fe83a13b 100644 --- a/apps/mesh/src/tools/github/list-repos.ts +++ b/apps/mesh/src/tools/github/list-repos.ts @@ -55,7 +55,7 @@ export const GITHUB_LIST_REPOS = defineTool({ throw new Error("No GitHub account linked"); } - const accessToken = accounts[0].accessToken; + const accessToken = accounts[0]!.accessToken; if (!accessToken) { throw new Error("No GitHub access token available"); } diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index f0b21e5a70..8ebd85313c 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -33,6 +33,7 @@ import * as AiProvidersTools from "./ai-providers"; import { getPrompts, getResources } from "./guides"; import * as ObjectStorageTools from "./object-storage"; import * as RegistryTools from "./registry/index"; +import * as GitHubTools from "./github"; import { ToolName } from "./registry-metadata"; // Core tools - always available const CORE_TOOLS = [ @@ -152,6 +153,10 @@ const CORE_TOOLS = [ // Registry tools ...RegistryTools.tools, + + // GitHub tools (app-only) + GitHubTools.GITHUB_LIST_INSTALLATIONS, + GitHubTools.GITHUB_LIST_REPOS, ] as const satisfies { name: ToolName }[]; // Plugin tools - collected at startup, gated by org settings at runtime diff --git a/apps/mesh/src/tools/registry-metadata.ts b/apps/mesh/src/tools/registry-metadata.ts index dacc4a5ab4..5fe9f06a13 100644 --- a/apps/mesh/src/tools/registry-metadata.ts +++ b/apps/mesh/src/tools/registry-metadata.ts @@ -31,7 +31,8 @@ export type ToolCategory = | "AI Providers" | "Automations" | "Object Storage" - | "Registry"; + | "Registry" + | "GitHub"; /** * All tool names - keep in sync with ALL_TOOLS in index.ts @@ -174,6 +175,10 @@ const ALL_TOOL_NAMES = [ "REGISTRY_MONITOR_CONNECTION_UPDATE_AUTH", "REGISTRY_MONITOR_SCHEDULE_SET", "REGISTRY_MONITOR_SCHEDULE_CANCEL", + + // GitHub tools (app-only) + "GITHUB_LIST_INSTALLATIONS", + "GITHUB_LIST_REPOS", ] as const; /** @@ -838,6 +843,17 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ description: "Cancel monitor schedule", category: "Registry", }, + // GitHub tools + { + name: "GITHUB_LIST_INSTALLATIONS", + description: "List GitHub App installations", + category: "GitHub", + }, + { + name: "GITHUB_LIST_REPOS", + description: "List repositories for a GitHub App installation", + category: "GitHub", + }, ]; /** @@ -969,6 +985,10 @@ const TOOL_LABELS: Record = { REGISTRY_MONITOR_CONNECTION_UPDATE_AUTH: "Update connection auth", REGISTRY_MONITOR_SCHEDULE_SET: "Set monitor schedule", REGISTRY_MONITOR_SCHEDULE_CANCEL: "Cancel monitor schedule", + + // GitHub + GITHUB_LIST_INSTALLATIONS: "List GitHub App installations", + GITHUB_LIST_REPOS: "List GitHub repositories", }; // ============================================================================ @@ -993,6 +1013,7 @@ export function getToolsByCategory() { Automations: [], "Object Storage": [], Registry: [], + GitHub: [], }; for (const tool of MANAGEMENT_TOOLS) { From 3f6dc5fbe2974e930073cf1e95bfda0e5ab2b75b Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 9 Apr 2026 18:38:25 -0300 Subject: [PATCH 005/110] feat(ui): add GitHubRepoDialog for repo selection flow Co-Authored-By: Claude Sonnet 4.6 --- .../src/web/components/github-repo-dialog.tsx | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 apps/mesh/src/web/components/github-repo-dialog.tsx diff --git a/apps/mesh/src/web/components/github-repo-dialog.tsx b/apps/mesh/src/web/components/github-repo-dialog.tsx new file mode 100644 index 0000000000..550e1f30e2 --- /dev/null +++ b/apps/mesh/src/web/components/github-repo-dialog.tsx @@ -0,0 +1,292 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@deco/ui/components/dialog.tsx"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useProjectContext, + useMCPClient, + SELF_MCP_ALIAS_ID, +} from "@decocms/mesh-sdk"; +import { useInsetContext } from "@/web/layouts/agent-shell-layout"; +import { authClient } from "@/web/lib/auth-client"; +import { KEYS } from "@/web/lib/query-keys"; +import { toast } from "sonner"; +import { Loading01 } from "@untitledui/icons"; + +interface Installation { + installationId: number; + orgName: string; + avatarUrl: string | null; +} + +interface Repo { + owner: string; + name: string; + fullName: string; + url: string; + private: boolean; +} + +// GitHub App installation URL — replace slug with actual Deco CMS app slug +const GITHUB_APP_INSTALL_URL = + "https://github.com/apps/deco-cms/installations/new"; + +export function GitHubRepoDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { org } = useProjectContext(); + const inset = useInsetContext(); + const queryClient = useQueryClient(); + const [selectedInstallation, setSelectedInstallation] = + useState(null); + const [search, setSearch] = useState(""); + + const client = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + }); + + // Step 1: Check installations + const installationsQuery = useQuery({ + queryKey: ["github-installations", org.id] as const, + queryFn: async () => { + const result = await client.callTool({ + name: "GITHUB_LIST_INSTALLATIONS", + arguments: {}, + }); + const payload = + (result as { structuredContent?: unknown }).structuredContent ?? result; + return payload as { + installations: Installation[]; + hasGithubAccount: boolean; + }; + }, + enabled: open, + }); + + // Step 2: List repos for selected installation + const reposQuery = useQuery({ + queryKey: [ + "github-repos", + org.id, + String(selectedInstallation?.installationId), + ] as const, + queryFn: async () => { + if (!selectedInstallation) return { repos: [] }; + const result = await client.callTool({ + name: "GITHUB_LIST_REPOS", + arguments: { installationId: selectedInstallation.installationId }, + }); + const payload = + (result as { structuredContent?: unknown }).structuredContent ?? result; + return payload as { repos: Repo[] }; + }, + enabled: !!selectedInstallation, + }); + + // Step 3: Save selected repo to virtual MCP metadata + const saveMutation = useMutation({ + mutationFn: async (repo: Repo) => { + if (!inset?.entity) throw new Error("No virtual MCP context"); + await client.callTool({ + name: "COLLECTION_VIRTUAL_MCP_UPDATE", + arguments: { + id: inset.entity.id, + data: { + metadata: { + githubRepo: { + url: repo.url, + owner: repo.owner, + name: repo.name, + installationId: selectedInstallation!.installationId, + }, + }, + }, + }, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: KEYS.virtualMcp(org.id, inset?.entity?.id ?? ""), + }); + toast.success("GitHub repo connected"); + onOpenChange(false); + }, + onError: (error) => { + toast.error( + "Failed to connect repo: " + + (error instanceof Error ? error.message : "Unknown error"), + ); + }, + }); + + const handleGitHubSignIn = () => { + // Open GitHub OAuth in popup via Better Auth + authClient.signIn.social({ + provider: "github", + callbackURL: window.location.href, + }); + }; + + const handleInstallApp = () => { + // Open GitHub App installation page in popup + const popup = window.open( + GITHUB_APP_INSTALL_URL, + "github-app-install", + "width=800,height=600,popup=yes", + ); + // Poll for popup close, then refetch installations + const interval = setInterval(() => { + if (popup?.closed) { + clearInterval(interval); + installationsQuery.refetch(); + } + }, 1000); + }; + + const filteredRepos = + reposQuery.data?.repos.filter((repo) => + repo.fullName.toLowerCase().includes(search.toLowerCase()), + ) ?? []; + + // Render based on state + const renderContent = () => { + // Loading + if (installationsQuery.isLoading) { + return ( +
+ +
+ ); + } + + // No GitHub account linked — prompt OAuth + if (!installationsQuery.data?.hasGithubAccount) { + return ( +
+

+ Connect your GitHub account to link a repository. +

+ +
+ ); + } + + // No installations — prompt app install + if (installationsQuery.data.installations.length === 0) { + return ( +
+

+ Install the Deco CMS GitHub App on your organization to continue. +

+ +
+ ); + } + + // Has installations but none selected — show org picker (or auto-select if only one) + if (!selectedInstallation) { + const installations = installationsQuery.data.installations; + if (installations.length === 1) { + setSelectedInstallation(installations[0]); + return null; + } + return ( +
+

+ Select an organization: +

+ {installations.map((inst) => ( + + ))} +
+ ); + } + + // Installation selected — show repo picker + if (reposQuery.isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {installationsQuery.data.installations.length > 1 && ( + + )} + setSearch(e.target.value)} + autoFocus + /> +
+ {filteredRepos.length === 0 ? ( +

+ No repositories found +

+ ) : ( + filteredRepos.map((repo) => ( + + )) + )} +
+
+ ); + }; + + return ( + + + + Connect GitHub Repository + + {renderContent()} + + + ); +} From 691361288724a8fe09ae372b8a29c729ed99b6f6 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 9 Apr 2026 18:38:28 -0300 Subject: [PATCH 006/110] feat(ui): add GitHubRepoButton component Co-Authored-By: Claude Sonnet 4.6 --- .../src/web/components/github-repo-button.tsx | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 apps/mesh/src/web/components/github-repo-button.tsx diff --git a/apps/mesh/src/web/components/github-repo-button.tsx b/apps/mesh/src/web/components/github-repo-button.tsx new file mode 100644 index 0000000000..fd92b5a683 --- /dev/null +++ b/apps/mesh/src/web/components/github-repo-button.tsx @@ -0,0 +1,80 @@ +import { LinkExternal01 } from "@untitledui/icons"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@deco/ui/components/tooltip.tsx"; +import { useInsetContext } from "@/web/layouts/agent-shell-layout"; +import { useState } from "react"; +import { GitHubRepoDialog } from "./github-repo-dialog"; + +function GitHubIcon({ size = 16 }: { size?: number }) { + return ( + + ); +} + +export function GitHubRepoButton() { + const inset = useInsetContext(); + const [dialogOpen, setDialogOpen] = useState(false); + + if (!inset?.entity) return null; + + const githubRepo = ( + inset.entity.metadata as { + githubRepo?: { url: string; owner: string; name: string } | null; + } + )?.githubRepo; + + // Connected state: show owner/repo with external link + if (githubRepo) { + return ( + + + + + + {githubRepo.owner}/{githubRepo.name} + + + + + + Open {githubRepo.owner}/{githubRepo.name} on GitHub + + + ); + } + + // Unconnected state: show octocat icon button + return ( + <> + + + + + Connect GitHub repo + + + + ); +} From 21b986d1a453b613d4105f35cd848648ca3f4c6b Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 9 Apr 2026 18:38:30 -0300 Subject: [PATCH 007/110] feat(ui): add GitHub button to shell header Co-Authored-By: Claude Sonnet 4.6 --- apps/mesh/src/web/layouts/agent-shell-layout.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/mesh/src/web/layouts/agent-shell-layout.tsx b/apps/mesh/src/web/layouts/agent-shell-layout.tsx index 0d112b0f91..f2c2559d53 100644 --- a/apps/mesh/src/web/layouts/agent-shell-layout.tsx +++ b/apps/mesh/src/web/layouts/agent-shell-layout.tsx @@ -68,6 +68,7 @@ import { computeDefaultSizes, usePanelState, } from "@/web/hooks/use-layout-state"; +import { GitHubRepoButton } from "@/web/components/github-repo-button"; // --------------------------------------------------------------------------- // Types & Context @@ -565,6 +566,7 @@ function AgentInsetProvider() {
+ {showThreePanels && ( <> From 9e5e8e2aabd16f97712189605aba7e0b75dbfd84 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 9 Apr 2026 18:40:23 -0300 Subject: [PATCH 008/110] fix(ui): use KEYS constants for query keys, fix TS error in dialog --- apps/mesh/src/web/components/github-repo-dialog.tsx | 9 ++++----- apps/mesh/src/web/lib/query-keys.ts | 6 ++++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/mesh/src/web/components/github-repo-dialog.tsx b/apps/mesh/src/web/components/github-repo-dialog.tsx index 550e1f30e2..2d8697e632 100644 --- a/apps/mesh/src/web/components/github-repo-dialog.tsx +++ b/apps/mesh/src/web/components/github-repo-dialog.tsx @@ -58,7 +58,7 @@ export function GitHubRepoDialog({ // Step 1: Check installations const installationsQuery = useQuery({ - queryKey: ["github-installations", org.id] as const, + queryKey: KEYS.githubInstallations(org.id), queryFn: async () => { const result = await client.callTool({ name: "GITHUB_LIST_INSTALLATIONS", @@ -76,11 +76,10 @@ export function GitHubRepoDialog({ // Step 2: List repos for selected installation const reposQuery = useQuery({ - queryKey: [ - "github-repos", + queryKey: KEYS.githubRepos( org.id, String(selectedInstallation?.installationId), - ] as const, + ), queryFn: async () => { if (!selectedInstallation) return { repos: [] }; const result = await client.callTool({ @@ -198,7 +197,7 @@ export function GitHubRepoDialog({ if (!selectedInstallation) { const installations = installationsQuery.data.installations; if (installations.length === 1) { - setSelectedInstallation(installations[0]); + setSelectedInstallation(installations[0] ?? null); return null; } return ( diff --git a/apps/mesh/src/web/lib/query-keys.ts b/apps/mesh/src/web/lib/query-keys.ts index 7f7dd5ed50..cc45a3a0f4 100644 --- a/apps/mesh/src/web/lib/query-keys.ts +++ b/apps/mesh/src/web/lib/query-keys.ts @@ -319,4 +319,10 @@ export const KEYS = { // Deco sites (scoped by user email) decoSites: (email: string | undefined) => ["deco-sites", email] as const, + + // GitHub integration + githubInstallations: (orgId: string) => + ["github-installations", orgId] as const, + githubRepos: (orgId: string, installationId: string) => + ["github-repos", orgId, installationId] as const, } as const; From 0e182d73c81f502cdf3a3aa73f0d66c7d1bd7090 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 9 Apr 2026 18:55:09 -0300 Subject: [PATCH 009/110] fix(github): address code review findings - Fix state update during render by deriving effectiveInstallation - Add per_page=100 to GitHub API calls to avoid truncated results Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/tools/github/list-installations.ts | 15 ++++---- apps/mesh/src/tools/github/list-repos.ts | 2 +- .../src/web/components/github-repo-dialog.tsx | 34 ++++++++++--------- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/apps/mesh/src/tools/github/list-installations.ts b/apps/mesh/src/tools/github/list-installations.ts index 9b96bd9e23..e9940a0bad 100644 --- a/apps/mesh/src/tools/github/list-installations.ts +++ b/apps/mesh/src/tools/github/list-installations.ts @@ -62,13 +62,16 @@ export const GITHUB_LIST_INSTALLATIONS = defineTool({ } // Fetch user's installations from GitHub API - const response = await fetch("https://api.github.com/user/installations", { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", + const response = await fetch( + "https://api.github.com/user/installations?per_page=100", + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, }, - }); + ); if (!response.ok) { throw new Error(`GitHub API error: ${response.status}`); diff --git a/apps/mesh/src/tools/github/list-repos.ts b/apps/mesh/src/tools/github/list-repos.ts index 94fe83a13b..1d4f091518 100644 --- a/apps/mesh/src/tools/github/list-repos.ts +++ b/apps/mesh/src/tools/github/list-repos.ts @@ -61,7 +61,7 @@ export const GITHUB_LIST_REPOS = defineTool({ } const response = await fetch( - `https://api.github.com/user/installations/${input.installationId}/repositories`, + `https://api.github.com/user/installations/${input.installationId}/repositories?per_page=100`, { headers: { Authorization: `Bearer ${accessToken}`, diff --git a/apps/mesh/src/web/components/github-repo-dialog.tsx b/apps/mesh/src/web/components/github-repo-dialog.tsx index 2d8697e632..4d358863d4 100644 --- a/apps/mesh/src/web/components/github-repo-dialog.tsx +++ b/apps/mesh/src/web/components/github-repo-dialog.tsx @@ -74,23 +74,30 @@ export function GitHubRepoDialog({ enabled: open, }); - // Step 2: List repos for selected installation + // Derive effective installation: auto-select if only one, otherwise use user selection + const installations = installationsQuery.data?.installations ?? []; + const effectiveInstallation = + installations.length === 1 + ? (installations[0] ?? null) + : selectedInstallation; + + // Step 2: List repos for effective installation const reposQuery = useQuery({ queryKey: KEYS.githubRepos( org.id, - String(selectedInstallation?.installationId), + String(effectiveInstallation?.installationId), ), queryFn: async () => { - if (!selectedInstallation) return { repos: [] }; + if (!effectiveInstallation) return { repos: [] }; const result = await client.callTool({ name: "GITHUB_LIST_REPOS", - arguments: { installationId: selectedInstallation.installationId }, + arguments: { installationId: effectiveInstallation.installationId }, }); const payload = (result as { structuredContent?: unknown }).structuredContent ?? result; return payload as { repos: Repo[] }; }, - enabled: !!selectedInstallation, + enabled: !!effectiveInstallation, }); // Step 3: Save selected repo to virtual MCP metadata @@ -107,7 +114,7 @@ export function GitHubRepoDialog({ url: repo.url, owner: repo.owner, name: repo.name, - installationId: selectedInstallation!.installationId, + installationId: effectiveInstallation!.installationId, }, }, }, @@ -182,7 +189,7 @@ export function GitHubRepoDialog({ } // No installations — prompt app install - if (installationsQuery.data.installations.length === 0) { + if (installations.length === 0) { return (

@@ -193,13 +200,8 @@ export function GitHubRepoDialog({ ); } - // Has installations but none selected — show org picker (or auto-select if only one) - if (!selectedInstallation) { - const installations = installationsQuery.data.installations; - if (installations.length === 1) { - setSelectedInstallation(installations[0] ?? null); - return null; - } + // Multiple installations and none selected — show org picker + if (!effectiveInstallation) { return (

@@ -226,7 +228,7 @@ export function GitHubRepoDialog({ ); } - // Installation selected — show repo picker + // Installation resolved — show repo picker if (reposQuery.isLoading) { return (

@@ -237,7 +239,7 @@ export function GitHubRepoDialog({ return (
- {installationsQuery.data.installations.length > 1 && ( + {installations.length > 1 && ( +
+ ); + } + + // Loading installations if (installationsQuery.isLoading) { return (
@@ -176,14 +322,23 @@ export function GitHubRepoDialog({ ); } - // No GitHub account linked — prompt OAuth - if (!installationsQuery.data?.hasGithubAccount) { + // Error loading installations (token might be invalid) + if (installationsQuery.isError) { return (

- Connect your GitHub account to link a repository. + GitHub token may have expired.

- +
); } @@ -200,7 +355,7 @@ export function GitHubRepoDialog({ ); } - // Multiple installations and none selected — show org picker + // Multiple installations and none selected if (!effectiveInstallation) { return (
@@ -228,7 +383,7 @@ export function GitHubRepoDialog({ ); } - // Installation resolved — show repo picker + // Repo picker if (reposQuery.isLoading) { return (
From db35d0ab81fda5803f61ffce6920fc0fdbfd33b4 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 9 Apr 2026 19:24:02 -0300 Subject: [PATCH 011/110] fix(github): use correct Deco CMS GitHub App client ID, remove debug logs --- apps/mesh/src/tools/github/device-flow-poll.ts | 2 +- apps/mesh/src/tools/github/device-flow-start.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mesh/src/tools/github/device-flow-poll.ts b/apps/mesh/src/tools/github/device-flow-poll.ts index e75f3addcb..a9b3587716 100644 --- a/apps/mesh/src/tools/github/device-flow-poll.ts +++ b/apps/mesh/src/tools/github/device-flow-poll.ts @@ -9,7 +9,7 @@ import { z } from "zod"; import { defineTool } from "../../core/define-tool"; import { requireAuth } from "../../core/mesh-context"; -const GITHUB_CLIENT_ID = "Iv23liGOzVQfPCMOzkEG"; +const GITHUB_CLIENT_ID = "Iv23liLNDj260RBdPV7p"; export const GITHUB_DEVICE_FLOW_POLL = defineTool({ name: "GITHUB_DEVICE_FLOW_POLL", diff --git a/apps/mesh/src/tools/github/device-flow-start.ts b/apps/mesh/src/tools/github/device-flow-start.ts index c2aadb003f..89889f98ba 100644 --- a/apps/mesh/src/tools/github/device-flow-start.ts +++ b/apps/mesh/src/tools/github/device-flow-start.ts @@ -10,7 +10,7 @@ import { defineTool } from "../../core/define-tool"; import { requireAuth } from "../../core/mesh-context"; // Deco CMS GitHub App client ID (public, safe to hardcode) -const GITHUB_CLIENT_ID = "Iv23liGOzVQfPCMOzkEG"; +const GITHUB_CLIENT_ID = "Iv23liLNDj260RBdPV7p"; export const GITHUB_DEVICE_FLOW_START = defineTool({ name: "GITHUB_DEVICE_FLOW_START", From f95aecf780dfb20fc198d36d0d9ce6816a59584b Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 9 Apr 2026 19:53:03 -0300 Subject: [PATCH 012/110] feat(ui): start device flow immediately on button click, skip intermediate dialog Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/web/components/github-repo-button.tsx | 67 ++++++++++++++++- .../src/web/components/github-repo-dialog.tsx | 75 ++++++++----------- 2 files changed, 97 insertions(+), 45 deletions(-) diff --git a/apps/mesh/src/web/components/github-repo-button.tsx b/apps/mesh/src/web/components/github-repo-button.tsx index fd92b5a683..935e04156c 100644 --- a/apps/mesh/src/web/components/github-repo-button.tsx +++ b/apps/mesh/src/web/components/github-repo-button.tsx @@ -6,6 +6,12 @@ import { } from "@deco/ui/components/tooltip.tsx"; import { useInsetContext } from "@/web/layouts/agent-shell-layout"; import { useState } from "react"; +import { + useProjectContext, + useMCPClient, + SELF_MCP_ALIAS_ID, +} from "@decocms/mesh-sdk"; +import { toast } from "sonner"; import { GitHubRepoDialog } from "./github-repo-dialog"; function GitHubIcon({ size = 16 }: { size?: number }) { @@ -22,9 +28,26 @@ function GitHubIcon({ size = 16 }: { size?: number }) { ); } +const GITHUB_TOKEN_KEY = "deco:github-token"; + +function getStoredToken(): string | null { + try { + return localStorage.getItem(GITHUB_TOKEN_KEY); + } catch { + return null; + } +} + export function GitHubRepoButton() { const inset = useInsetContext(); + const { org } = useProjectContext(); const [dialogOpen, setDialogOpen] = useState(false); + const [starting, setStarting] = useState(false); + + const client = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + }); if (!inset?.entity) return null; @@ -59,6 +82,47 @@ export function GitHubRepoButton() { ); } + const handleClick = async () => { + // If user already has a token, skip device flow and go straight to repo picker + if (getStoredToken()) { + setDialogOpen(true); + return; + } + + // Start device flow immediately, then open dialog with the code + setStarting(true); + try { + const result = await client.callTool({ + name: "GITHUB_DEVICE_FLOW_START", + arguments: {}, + }); + const payload = + (result as { structuredContent?: unknown }).structuredContent ?? result; + const data = payload as { + userCode: string; + verificationUri: string; + deviceCode: string; + expiresIn: number; + interval: number; + }; + + // Auto-open GitHub authorization page + window.open(data.verificationUri, "_blank", "noopener"); + + // Open dialog with device flow data already available + setDialogOpen(true); + // Pass the device flow data via a ref on the dialog + window.__decoGithubDeviceFlow = data; + } catch (error) { + toast.error( + "Failed to start GitHub auth: " + + (error instanceof Error ? error.message : "Unknown error"), + ); + } finally { + setStarting(false); + } + }; + // Unconnected state: show octocat icon button return ( <> @@ -66,7 +130,8 @@ export function GitHubRepoButton() { +
+
); } From cbca5f097a94aa458d3512c4af30ca51a3d173b7 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 9 Apr 2026 20:01:50 -0300 Subject: [PATCH 013/110] feat(ui): auto-copy device code to clipboard, add copy button Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/web/components/github-repo-dialog.tsx | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/apps/mesh/src/web/components/github-repo-dialog.tsx b/apps/mesh/src/web/components/github-repo-dialog.tsx index 72316ec417..c2d4efe224 100644 --- a/apps/mesh/src/web/components/github-repo-dialog.tsx +++ b/apps/mesh/src/web/components/github-repo-dialog.tsx @@ -16,7 +16,7 @@ import { import { useInsetContext } from "@/web/layouts/agent-shell-layout"; import { KEYS } from "@/web/lib/query-keys"; import { toast } from "sonner"; -import { Loading01 } from "@untitledui/icons"; +import { Check, Copy01, Loading01 } from "@untitledui/icons"; interface Installation { installationId: number; @@ -95,6 +95,7 @@ export function GitHubRepoDialog({ interval: number; } | null>(null); const [polling, setPolling] = useState(false); + const [copied, setCopied] = useState(false); const pollTimerRef = useRef | null>(null); const pollingStartedRef = useRef(false); @@ -103,8 +104,17 @@ export function GitHubRepoDialog({ const data = window.__decoGithubDeviceFlow; delete window.__decoGithubDeviceFlow; setDeviceFlow(data); + // Auto-copy code to clipboard + navigator.clipboard.writeText(data.userCode).catch(() => {}); } + const handleCopyCode = (code: string) => { + navigator.clipboard.writeText(code).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + const client = useMCPClient({ connectionId: SELF_MCP_ALIAS_ID, orgId: org.id, @@ -271,9 +281,20 @@ export function GitHubRepoDialog({

Enter this code on the GitHub page:

- - {deviceFlow.userCode} - + Date: Thu, 9 Apr 2026 21:22:48 -0300 Subject: [PATCH 014/110] feat(github): auto-sync instructions and detect runtime on repo connect After connecting a GitHub repo, automatically fetch AGENTS.md (or CLAUDE.md fallback) to populate instructions, and detect the package manager from repo files to auto-fill install/dev scripts. Instructions become read-only when a GitHub repo is connected. Adds Repository tab to agent settings. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../mesh/src/tools/github/get-file-content.ts | 56 +++++ apps/mesh/src/tools/github/index.ts | 1 + apps/mesh/src/tools/index.ts | 1 + .../src/web/components/github-repo-dialog.tsx | 139 +++++++++++- .../src/web/views/settings/repository.tsx | 213 ++++++++++++++++++ apps/mesh/src/web/views/virtual-mcp/index.tsx | 14 +- 6 files changed, 420 insertions(+), 4 deletions(-) create mode 100644 apps/mesh/src/tools/github/get-file-content.ts create mode 100644 apps/mesh/src/web/views/settings/repository.tsx diff --git a/apps/mesh/src/tools/github/get-file-content.ts b/apps/mesh/src/tools/github/get-file-content.ts new file mode 100644 index 0000000000..b790bd99ec --- /dev/null +++ b/apps/mesh/src/tools/github/get-file-content.ts @@ -0,0 +1,56 @@ +/** + * GITHUB_GET_FILE_CONTENT Tool + * + * Fetches raw file content from a GitHub repository. + * App-only tool — not visible to AI models. + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { requireAuth } from "../../core/mesh-context"; + +export const GITHUB_GET_FILE_CONTENT = defineTool({ + name: "GITHUB_GET_FILE_CONTENT", + description: "Fetch raw file content from a GitHub repository by path.", + annotations: { + title: "Get GitHub File Content", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + token: z.string().describe("GitHub access token"), + owner: z.string().describe("Repository owner"), + repo: z.string().describe("Repository name"), + path: z.string().describe("File path within the repository"), + }), + outputSchema: z.object({ + content: z.string().nullable(), + found: z.boolean(), + }), + + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + + const url = `https://raw.githubusercontent.com/${input.owner}/${input.repo}/HEAD/${input.path}`; + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${input.token}`, + }, + }); + + if (response.status === 404) { + return { content: null, found: false }; + } + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status}`); + } + + const content = await response.text(); + return { content, found: true }; + }, +}); diff --git a/apps/mesh/src/tools/github/index.ts b/apps/mesh/src/tools/github/index.ts index 32f53c0bc1..eb174c7d52 100644 --- a/apps/mesh/src/tools/github/index.ts +++ b/apps/mesh/src/tools/github/index.ts @@ -8,3 +8,4 @@ export { GITHUB_LIST_INSTALLATIONS } from "./list-installations"; export { GITHUB_LIST_REPOS } from "./list-repos"; export { GITHUB_DEVICE_FLOW_START } from "./device-flow-start"; export { GITHUB_DEVICE_FLOW_POLL } from "./device-flow-poll"; +export { GITHUB_GET_FILE_CONTENT } from "./get-file-content"; diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index b124a190e5..95e997297d 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -159,6 +159,7 @@ const CORE_TOOLS = [ GitHubTools.GITHUB_LIST_REPOS, GitHubTools.GITHUB_DEVICE_FLOW_START, GitHubTools.GITHUB_DEVICE_FLOW_POLL, + GitHubTools.GITHUB_GET_FILE_CONTENT, ] as const satisfies { name: ToolName }[]; // Plugin tools - collected at startup, gated by org settings at runtime diff --git a/apps/mesh/src/web/components/github-repo-dialog.tsx b/apps/mesh/src/web/components/github-repo-dialog.tsx index c2d4efe224..76eace6d75 100644 --- a/apps/mesh/src/web/components/github-repo-dialog.tsx +++ b/apps/mesh/src/web/components/github-repo-dialog.tsx @@ -237,12 +237,147 @@ export function GitHubRepoDialog({ }, }); }, - onSuccess: () => { + onSuccess: (_data, repo) => { queryClient.invalidateQueries({ - queryKey: KEYS.virtualMcp(org.id, inset?.entity?.id ?? ""), + predicate: (query) => { + const key = query.queryKey; + return ( + key[1] === org.id && + key[3] === "collection" && + key[4] === "VIRTUAL_MCP" + ); + }, }); toast.success("GitHub repo connected"); onOpenChange(false); + + // Background: fetch instructions + detect runtime + const currentToken = getStoredToken(); + if (currentToken && inset?.entity) { + const entityId = inset.entity.id; + const invalidateVirtualMcp = () => + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + key[1] === org.id && + key[3] === "collection" && + key[4] === "VIRTUAL_MCP" + ); + }, + }); + + const checkFileExists = async (path: string) => { + const result = await client.callTool({ + name: "GITHUB_GET_FILE_CONTENT", + arguments: { + token: currentToken, + owner: repo.owner, + repo: repo.name, + path, + }, + }); + const payload = + (result as { structuredContent?: unknown }).structuredContent ?? + result; + return payload as { content: string | null; found: boolean }; + }; + + // Fetch AGENTS.md > CLAUDE.md + const fetchInstructions = async () => { + for (const path of ["AGENTS.md", "CLAUDE.md"]) { + const data = await checkFileExists(path); + if (data.found && data.content) { + await client.callTool({ + name: "COLLECTION_VIRTUAL_MCP_UPDATE", + arguments: { + id: entityId, + data: { metadata: { instructions: data.content } }, + }, + }); + invalidateVirtualMcp(); + return; + } + } + }; + + // Detect package manager and scripts + const detectRuntime = async () => { + const runtimeFiles: Array<{ file: string; runtime: string }> = [ + { file: "deno.json", runtime: "deno" }, + { file: "deno.jsonc", runtime: "deno" }, + { file: "bun.lock", runtime: "bun" }, + { file: "bunfig.toml", runtime: "bun" }, + { file: "pnpm-lock.yaml", runtime: "pnpm" }, + { file: "yarn.lock", runtime: "yarn" }, + { file: "package-lock.json", runtime: "npm" }, + { file: "package.json", runtime: "npm" }, + ]; + + const installCommands: Record = { + deno: "deno install", + bun: "bun install", + pnpm: "pnpm install", + yarn: "yarn install", + npm: "npm install", + }; + + let detected: string | null = null; + for (const { file, runtime } of runtimeFiles) { + const data = await checkFileExists(file); + if (data.found) { + detected = runtime; + break; + } + } + + if (!detected) return; + + const installScript = installCommands[detected] ?? ""; + + // Try to find dev script from package.json + let devScript = ""; + const pkgData = await checkFileExists("package.json"); + if (pkgData.found && pkgData.content) { + try { + const pkg = JSON.parse(pkgData.content) as { + scripts?: Record; + }; + const scripts = pkg.scripts ?? {}; + const runPrefix = + detected === "deno" ? "deno task" : `${detected} run`; + if (scripts.dev) { + devScript = `${runPrefix} dev`; + } else if (scripts.start) { + devScript = `${runPrefix} start`; + } + } catch { + // Invalid JSON, skip + } + } + + await client.callTool({ + name: "COLLECTION_VIRTUAL_MCP_UPDATE", + arguments: { + id: entityId, + data: { + metadata: { + runtime: { + detected, + selected: detected, + installScript, + devScript, + }, + }, + }, + }, + }); + invalidateVirtualMcp(); + }; + + fetchInstructions().catch(() => {}); + detectRuntime().catch(() => {}); + } }, onError: (error) => { toast.error( diff --git a/apps/mesh/src/web/views/settings/repository.tsx b/apps/mesh/src/web/views/settings/repository.tsx new file mode 100644 index 0000000000..81e0e6b909 --- /dev/null +++ b/apps/mesh/src/web/views/settings/repository.tsx @@ -0,0 +1,213 @@ +import { EmptyState } from "@/web/components/empty-state"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; +import { Label } from "@deco/ui/components/label.tsx"; +import { GitHubRepoDialog } from "@/web/components/github-repo-dialog"; +import { useInsetContext } from "@/web/layouts/agent-shell-layout"; +import { + useProjectContext, + useMCPClient, + SELF_MCP_ALIAS_ID, +} from "@decocms/mesh-sdk"; +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { LinkExternal01 } from "@untitledui/icons"; + +function GitHubIcon({ size = 48 }: { size?: number }) { + return ( + + ); +} + +const GITHUB_TOKEN_KEY = "deco:github-token"; + +function getStoredToken(): string | null { + try { + return localStorage.getItem(GITHUB_TOKEN_KEY); + } catch { + return null; + } +} + +export function RepositoryTabContent() { + const inset = useInsetContext(); + const { org } = useProjectContext(); + const [dialogOpen, setDialogOpen] = useState(false); + const [starting, setStarting] = useState(false); + + const client = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + }); + + const queryClient = useQueryClient(); + + const metadata = inset?.entity?.metadata as + | { + githubRepo?: { url: string; owner: string; name: string } | null; + runtime?: { + detected: string | null; + selected: string | null; + installScript?: string | null; + devScript?: string | null; + } | null; + } + | undefined; + + const githubRepo = metadata?.githubRepo; + const runtime = metadata?.runtime; + + const handleConnect = async () => { + if (getStoredToken()) { + setDialogOpen(true); + return; + } + + setStarting(true); + try { + const result = await client.callTool({ + name: "GITHUB_DEVICE_FLOW_START", + arguments: {}, + }); + const payload = + (result as { structuredContent?: unknown }).structuredContent ?? result; + const data = payload as { + userCode: string; + verificationUri: string; + deviceCode: string; + expiresIn: number; + interval: number; + }; + + window.open(data.verificationUri, "_blank", "noopener"); + setDialogOpen(true); + window.__decoGithubDeviceFlow = data; + } catch (error) { + toast.error( + "Failed to start GitHub auth: " + + (error instanceof Error ? error.message : "Unknown error"), + ); + } finally { + setStarting(false); + } + }; + + const invalidateVirtualMcp = () => + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + key[1] === org.id && + key[3] === "collection" && + key[4] === "VIRTUAL_MCP" + ); + }, + }); + + const handleScriptUpdate = async ( + field: "installScript" | "devScript", + value: string, + ) => { + if (!inset?.entity) return; + try { + await client.callTool({ + name: "COLLECTION_VIRTUAL_MCP_UPDATE", + arguments: { + id: inset.entity.id, + data: { + metadata: { + runtime: { + ...runtime, + [field]: value, + }, + }, + }, + }, + }); + invalidateVirtualMcp(); + } catch { + toast.error("Failed to update script"); + } + }; + + if (githubRepo) { + return ( +
+ + +
+ + {githubRepo.owner}/{githubRepo.name} + + + {githubRepo.url} + +
+ +
+ +
+ + handleScriptUpdate("installScript", e.target.value)} + /> +
+ +
+ + handleScriptUpdate("devScript", e.target.value)} + /> +
+
+ ); + } + + return ( + <> + + +
+ } + title="No repository connected" + description="Connect a GitHub repository to enable code sync and deployments." + actions={ + + } + /> + + + ); +} diff --git a/apps/mesh/src/web/views/virtual-mcp/index.tsx b/apps/mesh/src/web/views/virtual-mcp/index.tsx index 8fadfcf85b..aeee1ebe86 100644 --- a/apps/mesh/src/web/views/virtual-mcp/index.tsx +++ b/apps/mesh/src/web/views/virtual-mcp/index.tsx @@ -16,6 +16,7 @@ import { import { KEYS } from "@/web/lib/query-keys"; import { unwrapToolResult } from "@/web/lib/unwrap-tool-result"; import { getConnectionSlug } from "@/shared/utils/connection-slug"; +import { RepositoryTabContent } from "@/web/views/settings/repository"; import { AlertDialog, @@ -1044,6 +1045,11 @@ function VirtualMcpDetailViewWithData({ // Watch connections for reactive UI const connections = form.watch("connections"); + // GitHub repo connected — instructions become read-only + const hasGithubRepo = !!( + virtualMcp.metadata as { githubRepo?: unknown } | undefined + )?.githubRepo; + // Dialog states const [dialogState, dispatch] = useReducer(dialogReducer, { shareDialogOpen: false, @@ -1053,7 +1059,7 @@ function VirtualMcpDetailViewWithData({ }); // Tab state - const validTabIds = ["instructions", "connections", "layout"]; + const validTabIds = ["instructions", "connections", "repository", "layout"]; const [activeTab, setActiveTab] = useState(() => { const stored = localStorage.getItem("agent-detail-tab") || "instructions"; // Migrate old "sidebar" tab to "layout" @@ -1372,6 +1378,7 @@ Define step-by-step how the agent should handle requests. label: "Connections", count: connections.length || undefined, }, + { id: "repository", label: "Repository" }, { id: "layout", label: "Layout" }, ]; @@ -1454,7 +1461,7 @@ Define step-by-step how the agent should handle requests. Add )} - {activeTab === "instructions" && ( + {activeTab === "instructions" && !hasGithubRepo && (
{!form.watch("metadata.instructions")?.trim() && (
)} + {activeTab === "repository" && } + {activeTab === "layout" && ( )} From c2fcaf0866ad068f8c30d467ffe06ef852d03350 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 9 Apr 2026 22:02:10 -0300 Subject: [PATCH 015/110] feat(vm): add Freestyle VM preview with terminal and dev server iframe Create VM_START and VM_STOP app-only tools that spin up Freestyle VMs with the connected GitHub repo, web terminal (read-only), and systemd services for install + dev scripts. Add Preview panel to the tasks sidebar with terminal/preview iframe toggle and auto-detection of server readiness via polling. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/package.json | 4 + apps/mesh/src/tools/index.ts | 5 + apps/mesh/src/tools/registry-metadata.ts | 28 ++- apps/mesh/src/tools/vm/index.ts | 8 + apps/mesh/src/tools/vm/start.ts | 139 +++++++++++ apps/mesh/src/tools/vm/stop.ts | 43 ++++ .../web/components/chat/side-panel-tasks.tsx | 30 ++- apps/mesh/src/web/components/vm-preview.tsx | 217 ++++++++++++++++++ .../src/web/layouts/agent-shell-layout.tsx | 10 +- apps/mesh/src/web/routes/agent-home.tsx | 7 + bun.lock | 36 ++- 11 files changed, 521 insertions(+), 6 deletions(-) create mode 100644 apps/mesh/src/tools/vm/index.ts create mode 100644 apps/mesh/src/tools/vm/start.ts create mode 100644 apps/mesh/src/tools/vm/stop.ts create mode 100644 apps/mesh/src/web/components/vm-preview.tsx diff --git a/apps/mesh/package.json b/apps/mesh/package.json index 386d7fbe6d..3183779391 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -49,6 +49,9 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@freestyle-sh/with-bun": "^0.2.10", + "@freestyle-sh/with-nodejs": "^0.2.8", + "@freestyle-sh/with-web-terminal": "^0.0.7", "@inkjs/ui": "^2.0.0", "@modelcontextprotocol/ext-apps": "^1.2.2", "@openrouter/ai-sdk-provider": "^2.2.5", @@ -57,6 +60,7 @@ "ai-sdk-provider-claude-code": "^3.4.4", "ai-sdk-provider-codex-cli": "^1.1.0", "embedded-postgres": "^18.3.0-beta.16", + "freestyle-sandboxes": "^0.1.43", "ink": "^6.8.0", "kysely": "^0.28.12", "nats": "^2.29.3", diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 95e997297d..9759d9b106 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -34,6 +34,7 @@ import { getPrompts, getResources } from "./guides"; import * as ObjectStorageTools from "./object-storage"; import * as RegistryTools from "./registry/index"; import * as GitHubTools from "./github"; +import * as VmTools from "./vm"; import { ToolName } from "./registry-metadata"; // Core tools - always available const CORE_TOOLS = [ @@ -160,6 +161,10 @@ const CORE_TOOLS = [ GitHubTools.GITHUB_DEVICE_FLOW_START, GitHubTools.GITHUB_DEVICE_FLOW_POLL, GitHubTools.GITHUB_GET_FILE_CONTENT, + + // VM tools (app-only) + VmTools.VM_START, + VmTools.VM_STOP, ] as const satisfies { name: ToolName }[]; // Plugin tools - collected at startup, gated by org settings at runtime diff --git a/apps/mesh/src/tools/registry-metadata.ts b/apps/mesh/src/tools/registry-metadata.ts index 86bf523d6a..4bc7e92a76 100644 --- a/apps/mesh/src/tools/registry-metadata.ts +++ b/apps/mesh/src/tools/registry-metadata.ts @@ -32,7 +32,8 @@ export type ToolCategory = | "Automations" | "Object Storage" | "Registry" - | "GitHub"; + | "GitHub" + | "VM"; /** * All tool names - keep in sync with ALL_TOOLS in index.ts @@ -181,6 +182,11 @@ const ALL_TOOL_NAMES = [ "GITHUB_LIST_REPOS", "GITHUB_DEVICE_FLOW_START", "GITHUB_DEVICE_FLOW_POLL", + "GITHUB_GET_FILE_CONTENT", + + // VM tools (app-only) + "VM_START", + "VM_STOP", ] as const; /** @@ -866,6 +872,21 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ description: "Poll GitHub Device Flow for access token", category: "GitHub", }, + { + name: "GITHUB_GET_FILE_CONTENT", + description: "Fetch file content from a GitHub repository", + category: "GitHub", + }, + { + name: "VM_START", + description: "Start a Freestyle VM with dev server preview", + category: "VM", + }, + { + name: "VM_STOP", + description: "Stop and delete a Freestyle VM", + category: "VM", + }, ]; /** @@ -1003,6 +1024,11 @@ const TOOL_LABELS: Record = { GITHUB_LIST_REPOS: "List GitHub repositories", GITHUB_DEVICE_FLOW_START: "Start GitHub Device Flow", GITHUB_DEVICE_FLOW_POLL: "Poll GitHub Device Flow", + GITHUB_GET_FILE_CONTENT: "Get GitHub file content", + + // VM + VM_START: "Start VM preview", + VM_STOP: "Stop VM preview", }; // ============================================================================ diff --git a/apps/mesh/src/tools/vm/index.ts b/apps/mesh/src/tools/vm/index.ts new file mode 100644 index 0000000000..7aa9f25b5a --- /dev/null +++ b/apps/mesh/src/tools/vm/index.ts @@ -0,0 +1,8 @@ +/** + * VM Tools + * + * Tools for Freestyle VM management (app-only, not visible to AI models). + */ + +export { VM_START } from "./start"; +export { VM_STOP } from "./stop"; diff --git a/apps/mesh/src/tools/vm/start.ts b/apps/mesh/src/tools/vm/start.ts new file mode 100644 index 0000000000..38c760d371 --- /dev/null +++ b/apps/mesh/src/tools/vm/start.ts @@ -0,0 +1,139 @@ +/** + * VM_START Tool + * + * Creates a Freestyle VM with the connected GitHub repo, + * Web Terminal (read-only), and systemd services for install + dev. + * App-only tool — not visible to AI models. + * + * Freestyle docs: /v2/vms, /v2/vms/configuration/systemd-services, + * /v2/vms/integrations/web-terminal, /v2/vms/configuration/ports-networking + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { requireAuth } from "../../core/mesh-context"; +import { freestyle } from "freestyle-sandboxes"; +import { VmWebTerminal } from "@freestyle-sh/with-web-terminal"; +import { VmNodeJs } from "@freestyle-sh/with-nodejs"; +import { VmBun } from "@freestyle-sh/with-bun"; + +export const VM_START = defineTool({ + name: "VM_START", + description: + "Start a Freestyle VM with the connected GitHub repo and dev server.", + annotations: { + title: "Start VM Preview", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + virtualMcpId: z.string().describe("Virtual MCP ID"), + }), + outputSchema: z.object({ + terminalUrl: z.string(), + previewUrl: z.string(), + vmId: z.string(), + }), + + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + + const virtualMcp = await ctx.storage.virtualMcps.findById( + input.virtualMcpId, + ); + if (!virtualMcp) { + throw new Error("Virtual MCP not found"); + } + + const metadata = virtualMcp.metadata as { + githubRepo?: { + url: string; + owner: string; + name: string; + } | null; + runtime?: { + detected: string | null; + selected: string | null; + installScript?: string | null; + devScript?: string | null; + } | null; + }; + + if (!metadata.githubRepo) { + throw new Error("No GitHub repo connected"); + } + + const { owner, name } = metadata.githubRepo; + const installScript = metadata.runtime?.installScript ?? "npm install"; + const devScript = metadata.runtime?.devScript ?? "npm run dev"; + const detected = metadata.runtime?.detected ?? "npm"; + + // Select runtime integration based on detected package manager + const runtimeIntegration = + detected === "bun" ? new VmBun() : new VmNodeJs(); + + // Create the Freestyle Git repo reference + const { repoId } = await freestyle.git.repos.create({ + source: { + url: `https://github.com/${owner}/${name}`, + }, + }); + + // Create VM with runtime, web terminal, repo, and systemd services + // Freestyle docs: /v2/vms/configuration/systemd-services + const { vmId, vm, domains } = await freestyle.vms.create({ + with: { + runtime: runtimeIntegration, + terminal: new VmWebTerminal([ + { + id: "logs", + command: + "bash -lc 'journalctl -f -u install-deps -u dev-server --no-pager'", + readOnly: true, + cwd: "/app", + }, + ] as const), + }, + gitRepos: [{ repo: repoId, path: "/app" }], + workdir: "/app", + ports: [{ port: 443, targetPort: 3000 }], + systemd: { + services: [ + { + name: "install-deps", + mode: "oneshot" as const, + exec: [installScript], + workdir: "/app", + after: ["freestyle-git-sync.service"], + wantedBy: ["multi-user.target"], + timeoutSec: 300, + }, + { + name: "dev-server", + mode: "service" as const, + exec: [devScript], + workdir: "/app", + after: ["install-deps.service"], + env: { + HOST: "0.0.0.0", + PORT: "3000", + }, + }, + ], + }, + }); + + // Route the web terminal to a public domain + const terminalDomain = `${vmId}-terminal.style.dev`; + await vm.terminal.logs.route({ domain: terminalDomain }); + + const previewUrl = `https://${domains[0]}`; + const terminalUrl = `https://${terminalDomain}`; + + return { terminalUrl, previewUrl, vmId }; + }, +}); diff --git a/apps/mesh/src/tools/vm/stop.ts b/apps/mesh/src/tools/vm/stop.ts new file mode 100644 index 0000000000..eda758a1c9 --- /dev/null +++ b/apps/mesh/src/tools/vm/stop.ts @@ -0,0 +1,43 @@ +/** + * VM_STOP Tool + * + * Deletes a Freestyle VM. + * App-only tool — not visible to AI models. + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { requireAuth } from "../../core/mesh-context"; +import { freestyle } from "freestyle-sandboxes"; + +export const VM_STOP = defineTool({ + name: "VM_STOP", + description: "Stop and delete a Freestyle VM.", + annotations: { + title: "Stop VM Preview", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + vmId: z.string().describe("Freestyle VM ID"), + }), + outputSchema: z.object({ + success: z.boolean(), + }), + + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + + try { + await freestyle.vms.delete({ vmId: input.vmId }); + } catch { + // VM may already be deleted + } + + return { success: true }; + }, +}); diff --git a/apps/mesh/src/web/components/chat/side-panel-tasks.tsx b/apps/mesh/src/web/components/chat/side-panel-tasks.tsx index f56eece0e7..554f52d2fd 100644 --- a/apps/mesh/src/web/components/chat/side-panel-tasks.tsx +++ b/apps/mesh/src/web/components/chat/side-panel-tasks.tsx @@ -11,7 +11,13 @@ import { Page } from "@/web/components/page"; import { getIconComponent, parseIconString } from "../agent-icon"; import { usePanelActions } from "@/web/layouts/shell-layout"; -import { Edit05, LayoutLeft, Loading01, Settings02 } from "@untitledui/icons"; +import { + Edit05, + LayoutLeft, + Loading01, + Monitor04, + Settings02, +} from "@untitledui/icons"; import { useVirtualMCPActions, useVirtualMCP } from "@decocms/mesh-sdk"; import type { VirtualMCPEntity } from "@decocms/mesh-sdk/types"; import { Suspense, useEffect, useRef, useState, useTransition } from "react"; @@ -280,6 +286,11 @@ function TasksPanelContent({ }; const isSettingsActive = virtualMcpCtx?.mainView?.type === "settings"; + const isPreviewActive = virtualMcpCtx?.mainView?.type === "preview"; + + const hasGithubRepo = !!( + virtualMcp?.metadata as { githubRepo?: unknown } | undefined + )?.githubRepo; return (
@@ -324,6 +335,23 @@ function TasksPanelContent({ Settings )} + {virtualMcp && virtualMcpCtx && !hideProjectHeader && hasGithubRepo && ( + + )} {virtualMcp && !hideProjectHeader && ( )} diff --git a/apps/mesh/src/web/components/vm-preview.tsx b/apps/mesh/src/web/components/vm-preview.tsx new file mode 100644 index 0000000000..acacda7ef5 --- /dev/null +++ b/apps/mesh/src/web/components/vm-preview.tsx @@ -0,0 +1,217 @@ +import { useState, useRef } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { + useProjectContext, + useMCPClient, + SELF_MCP_ALIAS_ID, +} from "@decocms/mesh-sdk"; +import { useInsetContext } from "@/web/layouts/agent-shell-layout"; +import { Loading01, Monitor04, Terminal } from "@untitledui/icons"; +import { Button } from "@deco/ui/components/button.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; + +type PreviewState = + | { status: "idle" } + | { status: "starting" } + | { + status: "terminal"; + terminalUrl: string; + previewUrl: string; + vmId: string; + } + | { + status: "preview"; + terminalUrl: string; + previewUrl: string; + vmId: string; + } + | { status: "error"; message: string }; + +export function VmPreviewContent() { + const { org } = useProjectContext(); + const inset = useInsetContext(); + const [state, setState] = useState({ status: "idle" }); + const [activeView, setActiveView] = useState<"terminal" | "preview">( + "terminal", + ); + const pollRef = useRef | null>(null); + + const client = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + }); + + const startMutation = useMutation({ + mutationFn: async () => { + if (!inset?.entity) throw new Error("No virtual MCP context"); + const result = await client.callTool({ + name: "VM_START", + arguments: { virtualMcpId: inset.entity.id }, + }); + const payload = + (result as { structuredContent?: unknown }).structuredContent ?? result; + return payload as { + terminalUrl: string; + previewUrl: string; + vmId: string; + }; + }, + onSuccess: (data) => { + setState({ status: "terminal", ...data }); + startPolling(data.previewUrl, data); + }, + onError: (error) => { + setState({ + status: "error", + message: error instanceof Error ? error.message : "Failed to start VM", + }); + }, + }); + + const startPolling = ( + previewUrl: string, + data: { terminalUrl: string; previewUrl: string; vmId: string }, + ) => { + if (pollRef.current) clearInterval(pollRef.current); + pollRef.current = setInterval(async () => { + try { + const res = await fetch(previewUrl, { + method: "HEAD", + mode: "no-cors", + }); + // no-cors returns opaque response, status 0 means it loaded + if (res.status === 0 || res.ok) { + if (pollRef.current) clearInterval(pollRef.current); + pollRef.current = null; + setState({ status: "preview", ...data }); + setActiveView("preview"); + } + } catch { + // Not ready yet + } + }, 4000); + }; + + const stopVm = async (vmId: string) => { + try { + await client.callTool({ + name: "VM_STOP", + arguments: { vmId }, + }); + } catch { + // Best effort + } + }; + + const handleStart = () => { + setState({ status: "starting" }); + startMutation.mutate(); + }; + + const handleStop = () => { + if (state.status === "terminal" || state.status === "preview") { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + stopVm(state.vmId); + } + setState({ status: "idle" }); + }; + + if (state.status === "idle") { + return ( +
+ +

Preview

+

+ Start a development server from your connected GitHub repository. +

+ +
+ ); + } + + if (state.status === "starting") { + return ( +
+ +

+ Creating VM and starting dev server... +

+
+ ); + } + + if (state.status === "error") { + return ( +
+

{state.message}

+ +
+ ); + } + + // Terminal or Preview state + return ( +
+
+
+ + +
+ +
+ +
+