| 1 | /** |
| 2 | * The Marketplace (routes/workspace/marketplace/): what adds functionality |
| 3 | * to a workspace, read against what it has and what its people asked for. |
| 4 | * |
| 5 | * Two kinds of listing: extensions, which add pages, data, cards and agent |
| 6 | * roles (g1t's own are listed before their first release), and |
| 7 | * integrations, which connect what a team already uses so agents can work |
| 8 | * with it. Owners add them; anyone else asks, and the agents service keeps |
| 9 | * the request (`install_requests`). Agents aren't listed: they start from |
| 10 | * templates in Agents mode (lib/agent-templates.ts). |
| 11 | * |
| 12 | * No Workers or React imports, so it can be tested under Node. |
| 13 | */ |
| 14 | import type { ExtensionInstall, ExtensionManifest, InstallRequest, InstallRequestStatus, ListingTier } from "@g1t/contracts"; |
| 15 | import { type ConnectorView, connectorPath } from "@g1t/contracts/connectors"; |
| 16 | import { listingRef, parseListing } from "@g1t/contracts/marketplace"; |
| 17 | |
| 18 | import type { ConnectedState } from "./connectors"; |
| 19 | |
| 20 | /** The Marketplace's pages, under `/<workspace>/-/marketplace`. */ |
| 21 | export function marketplacePath(slug: string, page: "" | "extensions" | "integrations" | "requests" = ""): string { |
| 22 | return `/${slug}/-/marketplace${page ? `/${page}` : ""}`; |
| 23 | } |
| 24 | |
| 25 | /** Who stands behind a listing, in a word and a sentence. */ |
| 26 | export const TIERS: Record<ListingTier, { label: string; about: string }> = { |
| 27 | official: { label: "Official", about: "Built and supported by g1t." }, |
| 28 | verified: { label: "Verified", about: "From a reviewed publisher, whose code and permissions are checked before it is listed." }, |
| 29 | community: { label: "Community", about: "From anyone, shared from a public repository. Pages run sandboxed; server code waits for an isolated sandbox." }, |
| 30 | internal: { label: "Internal", about: "Built by your own people and agents, and seen only by your workspace." }, |
| 31 | }; |
| 32 | |
| 33 | /** Whether `request` is `username`'s and still waiting. */ |
| 34 | function openFor(request: InstallRequest, ref: string, username: string): boolean { |
| 35 | return request.status === "open" && request.listing === ref && request.requested_by.toLowerCase() === username.toLowerCase(); |
| 36 | } |
| 37 | |
| 38 | /** Open requests for `ref`. */ |
| 39 | function openCount(requests: InstallRequest[], ref: string): number { |
| 40 | return requests.filter((request) => request.status === "open" && request.listing === ref).length; |
| 41 | } |
| 42 | |
| 43 | /** An integration a workspace can connect, with whether it has. */ |
| 44 | export type IntegrationListing = { |
| 45 | ref: string; |
| 46 | view: ConnectorView; |
| 47 | /** How it is doing, when it is connected. */ |
| 48 | connected: ConnectedState | null; |
| 49 | /** Where an owner connects it, or manages it once connected. */ |
| 50 | href: string | null; |
| 51 | requested: boolean; |
| 52 | waiting: number; |
| 53 | }; |
| 54 | |
| 55 | /** |
| 56 | * The integrations a workspace can connect today, connected ones first, |
| 57 | * each in catalog order. `connected` comes from the integrations service |
| 58 | * and the GitHub App (lib/connected.server.ts). |
| 59 | */ |
| 60 | export function integrationListings( |
| 61 | views: ConnectorView[], |
| 62 | connected: Record<string, ConnectedState>, |
| 63 | requests: InstallRequest[], |
| 64 | username: string, |
| 65 | slug: string, |
| 66 | ): IntegrationListing[] { |
| 67 | const listings = views |
| 68 | .filter((view) => view.status === "available") |
| 69 | .map((view) => { |
| 70 | const ref = listingRef("integration", view.id); |
| 71 | const state = connected[view.id] ?? null; |
| 72 | const setup = view.href ? connectorPath(view.href, slug) : null; |
| 73 | return { |
| 74 | ref, |
| 75 | view, |
| 76 | connected: state, |
| 77 | href: state?.manage ?? setup, |
| 78 | requested: requests.some((request) => openFor(request, ref, username)), |
| 79 | waiting: openCount(requests, ref), |
| 80 | }; |
| 81 | }); |
| 82 | return [...listings.filter((l) => l.connected), ...listings.filter((l) => !l.connected)]; |
| 83 | } |
| 84 | |
| 85 | /** |
| 86 | * The integrations the catalog lists as coming: a workspace's, then those |
| 87 | * each person connects for themselves (a calendar, a mailbox), once each. |
| 88 | */ |
| 89 | export function comingIntegrations(views: ConnectorView[], personal: ConnectorView[] = []): ConnectorView[] { |
| 90 | const workspace = views.filter((view) => view.status === "soon"); |
| 91 | const seen = new Set(workspace.map((view) => view.id)); |
| 92 | const own = personal.filter((view) => view.status === "soon" && !seen.has(view.id) && !views.some((v) => v.id === view.id)); |
| 93 | return [...workspace, ...own]; |
| 94 | } |
| 95 | |
| 96 | /** An extension in the Marketplace, with whether the workspace has it. */ |
| 97 | export type ExtensionListing = { |
| 98 | ref: string; |
| 99 | manifest: ExtensionManifest; |
| 100 | install: ExtensionInstall | null; |
| 101 | requested: boolean; |
| 102 | waiting: number; |
| 103 | }; |
| 104 | |
| 105 | /** Where extensions are grouped: connected systems apart from the rest. */ |
| 106 | export const CONNECTED_SYSTEMS = "Connected systems"; |
| 107 | |
| 108 | /** Whether an extension bridges a system the team already runs. */ |
| 109 | export function isConnectedSystem(manifest: Pick<ExtensionManifest, "category">): boolean { |
| 110 | return manifest.category === CONNECTED_SYSTEMS; |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * A starting set of extensions for one kind of team, installed together |
| 115 | * once each is published. Ids are first-party extensions'. |
| 116 | */ |
| 117 | export type StarterKit = { id: string; name: string; about: string; extensions: string[] }; |
| 118 | |
| 119 | export const STARTER_KITS: StarterKit[] = [ |
| 120 | { id: "customers", name: "Customer team", about: "Mail on your domain, support conversations and a pipeline.", extensions: ["mail", "support", "crm"] }, |
| 121 | { id: "engineering", name: "Engineering extras", about: "On-call rotations, and the helpdesk your customers already write to.", extensions: ["on-call", "helpdesk-bridge"] }, |
| 122 | { id: "operations", name: "People and operations", about: "Hiring, and the orders and invoices in your ERP.", extensions: ["recruiting", "erp-bridge"] }, |
| 123 | ]; |
| 124 | |
| 125 | /** Every extension listed, published ones first, each with its install. */ |
| 126 | export function extensionListings(manifests: ExtensionManifest[], installs: ExtensionInstall[], requests: InstallRequest[], username: string): ExtensionListing[] { |
| 127 | const listings = manifests.map((manifest) => { |
| 128 | const ref = listingRef("extension", manifest.id); |
| 129 | return { |
| 130 | ref, |
| 131 | manifest, |
| 132 | install: installs.find((install) => install.listing === ref) ?? null, |
| 133 | requested: requests.some((request) => openFor(request, ref, username)), |
| 134 | waiting: openCount(requests, ref), |
| 135 | }; |
| 136 | }); |
| 137 | return [...listings.filter((l) => l.manifest.status === "available"), ...listings.filter((l) => l.manifest.status !== "available")]; |
| 138 | } |
| 139 | |
| 140 | /** One extension's page in the Marketplace. */ |
| 141 | export function extensionPath(slug: string, id: string): string { |
| 142 | return `/${slug}/-/marketplace/extensions/${id}`; |
| 143 | } |
| 144 | |
| 145 | /** Where an extension runs, in words. */ |
| 146 | export function runtimeWords(manifest: Pick<ExtensionManifest, "runtime" | "publisher">): string { |
| 147 | return manifest.runtime === "hosted" ? `Runs on g1t` : `Runs on ${manifest.publisher.name}'s servers`; |
| 148 | } |
| 149 | |
| 150 | /** Whether an integration listing matches what someone typed: its name, category words, capabilities or keywords. */ |
| 151 | export function integrationMatches(listing: IntegrationListing, query: string): boolean { |
| 152 | const wanted = query.trim().toLowerCase(); |
| 153 | if (!wanted) return true; |
| 154 | const { view } = listing; |
| 155 | return [view.name, view.description, view.category, ...view.capabilities, ...view.keywords].some((word) => word.toLowerCase().includes(wanted)); |
| 156 | } |
| 157 | |
| 158 | /** The requests still waiting on an owner. */ |
| 159 | export function openRequests(requests: InstallRequest[]): InstallRequest[] { |
| 160 | return requests.filter((request) => request.status === "open"); |
| 161 | } |
| 162 | |
| 163 | /** Where an owner goes to add what a request asks for: the extension's page, or the integration's setup page. */ |
| 164 | export function addPath(request: Pick<InstallRequest, "listing">, slug: string, views: ConnectorView[]): string | null { |
| 165 | const parsed = parseListing(request.listing); |
| 166 | if (!parsed) return null; |
| 167 | if (parsed.kind === "extension") return extensionPath(slug, parsed.id); |
| 168 | const view = views.find((v) => v.id === parsed.id); |
| 169 | return view?.href ? connectorPath(view.href, slug) : null; |
| 170 | } |
| 171 | |
| 172 | /** What the Marketplace's forms ask for, read from one. */ |
| 173 | export type MarketplaceForm = |
| 174 | | { intent: "request"; listing: string; note: string | null } |
| 175 | | { intent: "resolve"; id: string; status: Exclude<InstallRequestStatus, "open"> } |
| 176 | | { intent: "install"; extension: string } |
| 177 | | { intent: "switch"; listing: string; enabled: boolean } |
| 178 | | { intent: "uninstall"; listing: string }; |
| 179 | |
| 180 | /** A Marketplace form's request; null when it asks for nothing that makes sense. */ |
| 181 | export function marketplaceForm(form: FormData): MarketplaceForm | null { |
| 182 | const intent = form.get("intent"); |
| 183 | if (intent === "request") { |
| 184 | const listing = String(form.get("listing") ?? ""); |
| 185 | if (!parseListing(listing)) return null; |
| 186 | const note = String(form.get("note") ?? "").trim(); |
| 187 | return { intent, listing, note: note || null }; |
| 188 | } |
| 189 | if (intent === "resolve") { |
| 190 | const id = String(form.get("id") ?? ""); |
| 191 | const status = form.get("status"); |
| 192 | if (!id || (status !== "done" && status !== "declined")) return null; |
| 193 | return { intent, id, status }; |
| 194 | } |
| 195 | const listing = String(form.get("listing") ?? ""); |
| 196 | const extension = parseListing(listing)?.kind === "extension" ? listing : null; |
| 197 | if (intent === "install") { |
| 198 | const id = String(form.get("extension") ?? ""); |
| 199 | return /^[a-z][a-z0-9-]{1,31}$/.test(id) ? { intent, extension: id } : null; |
| 200 | } |
| 201 | if (intent === "switch" && extension) { |
| 202 | const enabled = form.get("enabled"); |
| 203 | return enabled === "on" || enabled === "off" ? { intent, listing: extension, enabled: enabled === "on" } : null; |
| 204 | } |
| 205 | if (intent === "uninstall" && extension) return { intent, listing: extension }; |
| 206 | return null; |
| 207 | } |