Skip to content
360 linesCodeBlameRaw
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 */
14import type { ExtensionInstall, ExtensionManifest, InstallRequest, InstallRequestStatus, ListingKind, ListingTier } from "@g1t/contracts";
15import { CONNECTOR_CATEGORIES, type Connector, type ConnectorCapability, type ConnectorScope, type ConnectorView, connectorPath, connectorView } from "@g1t/contracts/connectors";
16import { CONNECTOR_PUBLISHER, LISTING_TIERS, listingRef, parseListing } from "@g1t/contracts/marketplace";
17
18import type { ConnectedState } from "./connectors";
19
20/** The Marketplace's pages, under `/<workspace>/-/marketplace`. */
21export function marketplacePath(slug: string, page: "" | "extensions" | "integrations" | "requests" = ""): string {
22 return `/${slug}/-/marketplace${page ? `/${page}` : ""}`;
23}
24
25/**
26 * Who stands behind a listing: in a word, a sentence, and what a tier's
27 * section says while nothing in it is listed (`none`, by kind).
28 */
29export const TIERS: Record<ListingTier, { label: string; about: string; none: Record<ListingKind, string> }> = {
30 official: {
31 label: "Official",
32 about: "Built and supported by g1t.",
33 none: { extension: "No official extensions match.", integration: "No official integrations match." },
34 },
35 verified: {
36 label: "Verified",
37 about: "From a reviewed publisher. Its code and the scopes it asks for are checked before it is listed.",
38 none: { extension: "No verified publishers yet.", integration: "No verified publishers yet." },
39 },
40 community: {
41 label: "Community",
42 about: "From anyone. Its pages run sandboxed on the user-content domain, and its server code stays off until that sandbox is hardened.",
43 none: { extension: "No community extensions yet.", integration: "No community integrations yet." },
44 },
45 internal: {
46 label: "Internal",
47 about: "Built by your own people and agents, and promoted for this workspace only.",
48 none: { extension: "Nothing built in this workspace yet.", integration: "Nothing built in this workspace yet." },
49 },
50};
51
52/**
53 * Whether a listing can be added here, now: it can (`available`), the
54 * workspace has it (`added`: connected, or installed), it is planned and
55 * can't be added by anyone yet (`soon`), or this workspace or this g1t
56 * lacks something it needs (`unavailable`, with why).
57 */
58export type Availability = "available" | "added" | "soon" | "unavailable";
59
60export const AVAILABILITIES: readonly Availability[] = ["available", "added", "soon", "unavailable"];
61
62/** Each availability in words, and the sentence its hint shows. */
63export const AVAILABILITY: Record<Availability, { label: string; about: string }> = {
64 available: { label: "Available", about: "Can be added now. Owners add it for everyone; anyone else can ask an owner." },
65 added: { label: "Added", about: "This workspace has it." },
66 soon: { label: "Soon", about: "Planned, not built yet. Nobody can add it until it is released." },
67 unavailable: { label: "Not available here", about: "This workspace or this g1t lacks something it needs." },
68};
69
70/** What a workspace that has a listing calls it: an integration is connected, an extension installed. */
71export function addedWord(kind: ListingKind): string {
72 return kind === "integration" ? "Connected" : "Installed";
73}
74
75/** A filter's value: one tier or availability, or every one. */
76export type ListingFilters = { tier: ListingTier | "all"; availability: Availability | "all" };
77
78/** The filters a Marketplace page's address asks for (`?tier=verified&availability=soon`); anything unknown is All. */
79export function readFilters(params: URLSearchParams): ListingFilters {
80 const tier = params.get("tier");
81 const availability = params.get("availability");
82 return {
83 tier: LISTING_TIERS.includes(tier as ListingTier) ? (tier as ListingTier) : "all",
84 availability: AVAILABILITIES.includes(availability as Availability) ? (availability as Availability) : "all",
85 };
86}
87
88/** Whether a listing passes the filters. */
89export function passes(listing: { tier: ListingTier; availability: Availability }, filters: ListingFilters): boolean {
90 return (filters.tier === "all" || listing.tier === filters.tier) && (filters.availability === "all" || listing.availability === filters.availability);
91}
92
93/** The tiers a page shows sections for under `filters`: every one, or the one asked for. */
94export function tiersShown(filters: ListingFilters): readonly ListingTier[] {
95 return filters.tier === "all" ? LISTING_TIERS : [filters.tier];
96}
97
98/** Whether `request` is `username`'s and still waiting. */
99function openFor(request: InstallRequest, ref: string, username: string): boolean {
100 return request.status === "open" && request.listing === ref && request.requested_by.toLowerCase() === username.toLowerCase();
101}
102
103/** Open requests for `ref`. */
104function openCount(requests: InstallRequest[], ref: string): number {
105 return requests.filter((request) => request.status === "open" && request.listing === ref).length;
106}
107
108/** An integration in the Marketplace, with who stands behind it and whether the workspace has it. */
109export type IntegrationListing = {
110 ref: string;
111 view: ConnectorView;
112 /** Who connects it: an owner, once for the workspace, or each person for themselves. */
113 scope: ConnectorScope;
114 /** Every connector is g1t's own (CONNECTOR_PUBLISHER). */
115 tier: ListingTier;
116 publisher: string;
117 availability: Availability;
118 /** Why it can't be connected here, when it can't. */
119 why: string | null;
120 /** How it is doing, when it is connected. */
121 connected: ConnectedState | null;
122 /** Where an owner connects it, or manages it once connected. */
123 href: string | null;
124 /** Its page in the Marketplace. */
125 path: string;
126 requested: boolean;
127 waiting: number;
128};
129
130/** One connector as a listing in the workspace `slug`. */
131function integrationListing(
132 view: ConnectorView,
133 scope: ConnectorScope,
134 slug: string,
135 connected: Record<string, ConnectedState> = {},
136 unavailable: Record<string, string> = {},
137 requests: InstallRequest[] = [],
138 username = "",
139): IntegrationListing {
140 const ref = listingRef("integration", view.id);
141 const state = scope === "workspace" ? (connected[view.id] ?? null) : null;
142 const why = view.status === "available" && !state ? (unavailable[view.id] ?? null) : null;
143 const availability: Availability = state ? "added" : view.status !== "available" ? "soon" : why ? "unavailable" : "available";
144 const setup = view.href && !why ? connectorPath(view.href, slug) : null;
145 return {
146 ref,
147 view,
148 scope,
149 tier: CONNECTOR_PUBLISHER.tier,
150 publisher: CONNECTOR_PUBLISHER.name,
151 availability,
152 why,
153 connected: state,
154 href: state?.manage ?? setup,
155 path: integrationPath(slug, view.id),
156 requested: requests.some((request) => openFor(request, ref, username)),
157 waiting: openCount(requests, ref),
158 };
159}
160
161/**
162 * The integrations a workspace can connect today, connected ones first,
163 * each in catalog order. `connected` comes from the integrations service
164 * and the GitHub App (lib/connected.server.ts), as does `unavailable`:
165 * connectors this g1t can't connect, with why.
166 */
167export function integrationListings(
168 views: ConnectorView[],
169 connected: Record<string, ConnectedState>,
170 requests: InstallRequest[],
171 username: string,
172 slug: string,
173 unavailable: Record<string, string> = {},
174): IntegrationListing[] {
175 const listings = views.filter((view) => view.status === "available").map((view) => integrationListing(view, "workspace", slug, connected, unavailable, requests, username));
176 return [...listings.filter((l) => l.connected), ...listings.filter((l) => !l.connected)];
177}
178
179/** What each person connects for themselves, available today, as listings. */
180export function personalListings(views: ConnectorView[], slug: string, unavailable: Record<string, string> = {}): IntegrationListing[] {
181 return views.filter((view) => view.status === "available" && view.href != null).map((view) => integrationListing(view, "personal", slug, {}, unavailable));
182}
183
184/** The coming integrations (comingIntegrations), as listings: Soon, with nothing to press. */
185export function comingListings(views: ConnectorView[], personal: ConnectorView[], slug: string): IntegrationListing[] {
186 const workspace = new Set(views.map((view) => view.id));
187 return comingIntegrations(views, personal).map((view) => integrationListing(view, workspace.has(view.id) ? "workspace" : "personal", slug));
188}
189
190/** One integration's page in the Marketplace. */
191export function integrationPath(slug: string, id: string): string {
192 return `/${slug}/-/marketplace/integrations/${id}`;
193}
194
195/** One way an integration is connected, and what it does there. */
196export type IntegrationUse = {
197 scope: ConnectorScope;
198 /** Who connects it this way, in words. */
199 who: string;
200 description: string;
201 capabilities: ConnectorCapability[];
202};
203
204/**
205 * What an integration does, today and soon: each way it is connected (for
206 * the workspace, for each person) under whether that way is available yet,
207 * from the catalog. Linear, say, works for a workspace today, and for each
208 * person's own inbox soon.
209 */
210export function integrationUses(connector: Connector): { today: IntegrationUse[]; soon: IntegrationUse[] } {
211 const today: IntegrationUse[] = [];
212 const soon: IntegrationUse[] = [];
213 for (const scope of connector.scopes) {
214 const view = connectorView(connector, scope);
215 if (!view) continue;
216 const use = {
217 scope,
218 who: scope === "workspace" ? "For the whole workspace, connected once by an owner" : "For each person, with their own account",
219 description: view.description,
220 capabilities: view.capabilities,
221 };
222 (view.status === "available" ? today : soon).push(use);
223 }
224 return { today, soon };
225}
226
227/** A connector category's title: `issues` is `Issues & projects`. */
228export function categoryTitle(category: Connector["category"]): string {
229 return CONNECTOR_CATEGORIES.find((c) => c.id === category)?.title ?? category;
230}
231
232/**
233 * The integrations the catalog lists as coming: a workspace's, then those
234 * each person connects for themselves (a calendar, a mailbox), once each.
235 */
236export function comingIntegrations(views: ConnectorView[], personal: ConnectorView[] = []): ConnectorView[] {
237 const workspace = views.filter((view) => view.status === "soon");
238 const seen = new Set(workspace.map((view) => view.id));
239 const own = personal.filter((view) => view.status === "soon" && !seen.has(view.id) && !views.some((v) => v.id === view.id));
240 return [...workspace, ...own];
241}
242
243/** An extension in the Marketplace, with who stands behind it and whether the workspace has it. */
244export type ExtensionListing = {
245 ref: string;
246 manifest: ExtensionManifest;
247 /** Its publisher's tier. */
248 tier: ListingTier;
249 availability: Availability;
250 install: ExtensionInstall | null;
251 requested: boolean;
252 waiting: number;
253};
254
255/** Where extensions are grouped: connected systems apart from the rest. */
256export const CONNECTED_SYSTEMS = "Connected systems";
257
258/** Whether an extension bridges a system the team already runs. */
259export function isConnectedSystem(manifest: Pick<ExtensionManifest, "category">): boolean {
260 return manifest.category === CONNECTED_SYSTEMS;
261}
262
263/**
264 * A starting set of extensions for one kind of team, installed together
265 * once each is published. Ids are first-party extensions'.
266 */
267export type StarterKit = { id: string; name: string; about: string; extensions: string[] };
268
269export const STARTER_KITS: StarterKit[] = [
270 { id: "customers", name: "Customer team", about: "Mail on your domain, support conversations and a pipeline.", extensions: ["mail", "support", "crm"] },
271 { id: "engineering", name: "Engineering extras", about: "On-call rotations, and the helpdesk your customers already write to.", extensions: ["on-call", "helpdesk-bridge"] },
272 { id: "operations", name: "People and operations", about: "Hiring, and the orders and invoices in your ERP.", extensions: ["recruiting", "erp-bridge"] },
273];
274
275/** Every extension listed, published ones first, each with its install. */
276export function extensionListings(manifests: ExtensionManifest[], installs: ExtensionInstall[], requests: InstallRequest[], username: string): ExtensionListing[] {
277 const listings = manifests.map((manifest) => {
278 const ref = listingRef("extension", manifest.id);
279 const install = installs.find((install) => install.listing === ref) ?? null;
280 return {
281 ref,
282 manifest,
283 tier: manifest.publisher.tier,
284 availability: (install ? "added" : manifest.status === "available" ? "available" : "soon") as Availability,
285 install,
286 requested: requests.some((request) => openFor(request, ref, username)),
287 waiting: openCount(requests, ref),
288 };
289 });
290 return [...listings.filter((l) => l.manifest.status === "available"), ...listings.filter((l) => l.manifest.status !== "available")];
291}
292
293/** One extension's page in the Marketplace. */
294export function extensionPath(slug: string, id: string): string {
295 return `/${slug}/-/marketplace/extensions/${id}`;
296}
297
298/** Where an extension runs, in words. */
299export function runtimeWords(manifest: Pick<ExtensionManifest, "runtime" | "publisher">): string {
300 return manifest.runtime === "hosted" ? `Runs on g1t` : `Runs on ${manifest.publisher.name}'s servers`;
301}
302
303/** Whether an integration listing matches what someone typed: its name, category words, capabilities or keywords. */
304export function integrationMatches(listing: IntegrationListing, query: string): boolean {
305 const wanted = query.trim().toLowerCase();
306 if (!wanted) return true;
307 const { view } = listing;
308 return [view.name, view.description, view.category, ...view.capabilities, ...view.keywords].some((word) => word.toLowerCase().includes(wanted));
309}
310
311/** The requests still waiting on an owner. */
312export function openRequests(requests: InstallRequest[]): InstallRequest[] {
313 return requests.filter((request) => request.status === "open");
314}
315
316/** Where an owner goes to add what a request asks for: the extension's page, or the integration's setup page. */
317export function addPath(request: Pick<InstallRequest, "listing">, slug: string, views: ConnectorView[]): string | null {
318 const parsed = parseListing(request.listing);
319 if (!parsed) return null;
320 if (parsed.kind === "extension") return extensionPath(slug, parsed.id);
321 const view = views.find((v) => v.id === parsed.id);
322 return view?.href ? connectorPath(view.href, slug) : null;
323}
324
325/** What the Marketplace's forms ask for, read from one. */
326export type MarketplaceForm =
327 | { intent: "request"; listing: string; note: string | null }
328 | { intent: "resolve"; id: string; status: Exclude<InstallRequestStatus, "open"> }
329 | { intent: "install"; extension: string }
330 | { intent: "switch"; listing: string; enabled: boolean }
331 | { intent: "uninstall"; listing: string };
332
333/** A Marketplace form's request; null when it asks for nothing that makes sense. */
334export function marketplaceForm(form: FormData): MarketplaceForm | null {
335 const intent = form.get("intent");
336 if (intent === "request") {
337 const listing = String(form.get("listing") ?? "");
338 if (!parseListing(listing)) return null;
339 const note = String(form.get("note") ?? "").trim();
340 return { intent, listing, note: note || null };
341 }
342 if (intent === "resolve") {
343 const id = String(form.get("id") ?? "");
344 const status = form.get("status");
345 if (!id || (status !== "done" && status !== "declined")) return null;
346 return { intent, id, status };
347 }
348 const listing = String(form.get("listing") ?? "");
349 const extension = parseListing(listing)?.kind === "extension" ? listing : null;
350 if (intent === "install") {
351 const id = String(form.get("extension") ?? "");
352 return /^[a-z][a-z0-9-]{1,31}$/.test(id) ? { intent, extension: id } : null;
353 }
354 if (intent === "switch" && extension) {
355 const enabled = form.get("enabled");
356 return enabled === "on" || enabled === "off" ? { intent, listing: extension, enabled: enabled === "on" } : null;
357 }
358 if (intent === "uninstall" && extension) return { intent, listing: extension };
359 return null;
360}