282 lines · 9.9 KB · TypeScript
RawDownload
import { useCallback, useState } from "react";import { parse } from "tldts"; import { CardStack, CardStackContent, CardStackEntryField } from "../components/card-stack";import { Input } from "../components/input";import { normalizeNamespaceInput, slugifyNamespace } from "./namespace";export { normalizeNamespaceInput, slugifyNamespace } from "./namespace"; /** * Derives a display-name candidate from a URL by extracting its apex domain * label (e.g. `https://api.shopify.com/graphql` → `"Shopify"`) and * title-casing it. Returns `null` if the URL has no parseable domain. */export function displayNameFromUrl(url: string): string | null {  const trimmed = url.trim();  if (!trimmed) return null;  const parsed = parse(trimmed);  const label = parsed.domainWithoutSuffix;  if (!label) return null;  return label.charAt(0).toUpperCase() + label.slice(1);} export function domainLabelFromUrl(url: string): string | null {  const trimmed = url.trim();  if (!trimmed) return null;  return parse(trimmed).domainWithoutSuffix ?? null;} export function pascalCaseDomainLabel(label: string): string | null {  const words = label    .split(/[^a-z0-9]+/i)    .map((word) => word.trim())    .filter(Boolean);  if (words.length === 0) return null;  return words    .map((word) => {      const normalized = word.toLowerCase();      return normalized.charAt(0).toUpperCase() + normalized.slice(1);    })    .join("");} export function integrationDisplayNameFromUrl(url: string, integrationKind: string): string | null {  const label = domainLabelFromUrl(url);  const displayLabel = label ? pascalCaseDomainLabel(label) : null;  return displayLabel ? `${displayLabel} ${integrationKind}` : null;} // Package runners whose own name is never the integration name: the meaningful// token is the package or module they execute, not the runner itself.const STDIO_RUNNERS = new Set([  "npx",  "bunx",  "pnpm",  "yarn",  "npm",  "uvx",  "uv",  "pipx",  "bun",  "deno",  "node",  "python",  "python3",]); // Subcommands that sit between a runner and its package: `pnpm dlx <pkg>`,// `npm exec <pkg>`, `uv run <pkg>`, `deno run <spec>`.const STDIO_RUNNER_SUBCOMMANDS = new Set(["dlx", "exec", "run", "x", "--"]); /** * Picks the package/module spec from a stdio launch command: the first * positional argument that isn't a runner flag or subcommand, falling back to * the command itself when it isn't a generic runner (a server invoked * directly). Returns `null` when only a bare runner is present. */export function stdioPackageToken(command: string, args: readonly string[]): string | null {  for (const raw of args) {    const arg = raw.trim();    if (!arg || arg.startsWith("-")) continue; // skip flags like -y, --yes    if (STDIO_RUNNER_SUBCOMMANDS.has(arg)) continue; // skip dlx / exec / run    return arg;  }  const cmd = command.trim();  if (!cmd || STDIO_RUNNERS.has(cmd.toLowerCase())) return null;  return cmd;} /** * Reduces a package/module spec to human words: drops an npm scope, a version * or tag suffix, a path and file extension, and the noise affixes MCP packages * conventionally carry (`mcp-server-`, `server-`, `-mcp`), then title-cases. * Returns `null` when nothing alphanumeric survives. */export function humanizeStdioToken(token: string): string | null {  let name = token.trim();  if (name.startsWith("@")) {    // Scoped npm package: @modelcontextprotocol/server-github → server-github.    name = name.split("/").pop() ?? name;  } else if (/[\\/]/.test(name)) {    // A filesystem path (node ./build/index.js) → its base filename.    name = name.split(/[\\/]/).pop() ?? name;    name = name.replace(/\.[a-z0-9]+$/i, ""); // strip extension  }  name = name.replace(/@[^@/]*$/, "") || name; // drop a trailing @version / @tag  name = name    .replace(/^mcp[-_]server[-_]/i, "")    .replace(/^mcp[-_]/i, "")    .replace(/^server[-_]/i, "")    .replace(/[-_]mcp[-_]server$/i, "")    .replace(/[-_]mcp$/i, "")    .replace(/[-_]server$/i, "");  const words = name    .split(/[^a-z0-9]+/i)    .map((word) => word.trim())    .filter(Boolean);  if (words.length === 0) return null;  return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(" ");} /** * Derives a display-name candidate from a stdio launch command by extracting * the package/module being run and humanizing it, e.g. * `npx -y @modelcontextprotocol/server-github` → `"Github MCP"`, * `uvx mcp-server-time` → `"Time MCP"`, `node ./build/index.js` → `"Index MCP"`. * Returns `null` when nothing meaningful can be extracted, so callers can fall * back to the raw command. */export function integrationDisplayNameFromStdio(  command: string,  args: readonly string[],  integrationKind: string,): string | null {  const token = stdioPackageToken(command, args);  const label = token ? humanizeStdioToken(token) : null;  return label ? `${label} ${integrationKind}` : null;} // ---------------------------------------------------------------------------// Hook — owns the name + namespace state with namespace auto-derivation// --------------------------------------------------------------------------- export interface IntegrationIdentity {  /** Display name — the user's override if they've typed one, otherwise the fallback. */  readonly name: string;  /** Namespace — the user's override if they've typed one, otherwise slugified from `name`. */  readonly namespace: string;  readonly setName: (name: string) => void;  readonly setNamespace: (namespace: string) => void;  /** Clears any user overrides so both fields return to deriving from the fallback. */  readonly reset: () => void;} export interface UseIntegrationIdentityOptions {  /**   * Fallback display name — used when the user hasn't typed one. Pass a   * value computed from the caller's reactive state (probe result, URL   * apex domain, template default, etc.) and it'll flow through to `name`   * automatically.   */  readonly fallbackName?: string;  /** Fallback namespace — defaults to `slugifyNamespace(fallbackName ?? "")`. */  readonly fallbackNamespace?: string;} /** * Manages a display name and a derived namespace. Both fields are pure * derived state: the user's `setName` / `setNamespace` call stores an * override, otherwise the hook returns the caller-supplied fallback * (passed fresh on every render). Call `reset()` to drop overrides. */export function useIntegrationIdentity(  options?: UseIntegrationIdentityOptions,): IntegrationIdentity {  const [nameOverride, setNameOverride] = useState<string | null>(null);  const [namespaceOverride, setNamespaceOverride] = useState<string | null>(null);   const fallbackName = options?.fallbackName ?? "";  const name = nameOverride ?? fallbackName;  const fallbackNamespace = options?.fallbackNamespace ?? slugifyNamespace(name);  const namespace = namespaceOverride ?? fallbackNamespace;   const setName = useCallback((next: string) => {    setNameOverride(next);  }, []);   const setNamespace = useCallback((next: string) => {    setNamespaceOverride(normalizeNamespaceInput(next));  }, []);   const reset = useCallback(() => {    setNameOverride(null);    setNamespaceOverride(null);  }, []);   return { name, namespace, setName, setNamespace, reset };} // ---------------------------------------------------------------------------// UI — two fields, wrapped in a shared CardStack// --------------------------------------------------------------------------- export interface IntegrationIdentityFieldsProps {  readonly identity: IntegrationIdentity;  readonly namePlaceholder?: string;  readonly namespacePlaceholder?: string;  readonly nameLabel?: string;  readonly namespaceHint?: string;  /**   * When true, the namespace field is rendered disabled — useful on Edit   * forms, where the namespace is the integration's identity and changing it   * would require a delete + recreate flow.   */  readonly namespaceReadOnly?: boolean;} export function IntegrationIdentityFields({  identity,  namePlaceholder = "e.g. Sentry API",  namespacePlaceholder = "sentry_api",  nameLabel = "Display Name",  namespaceHint,  namespaceReadOnly = false,}: IntegrationIdentityFieldsProps) {  const effectiveNamespaceHint =    namespaceHint ??    (namespaceReadOnly      ? "The namespace is part of the integration's identity and cannot be changed."      : undefined);   return (    <CardStack>      <CardStackContent className="border-t-0">        <IntegrationIdentityFieldRows          identity={identity}          namePlaceholder={namePlaceholder}          namespacePlaceholder={namespacePlaceholder}          nameLabel={nameLabel}          namespaceHint={effectiveNamespaceHint}          namespaceReadOnly={namespaceReadOnly}        />      </CardStackContent>    </CardStack>  );} export function IntegrationIdentityFieldRows({  identity,  namePlaceholder = "e.g. Sentry API",  namespacePlaceholder = "sentry_api",  nameLabel = "Display Name",  namespaceHint,  namespaceReadOnly = false,}: IntegrationIdentityFieldsProps) {  const effectiveNamespaceHint =    namespaceHint ??    (namespaceReadOnly      ? "The namespace is part of the integration's identity and cannot be changed."      : undefined);   return (    <div className="grid grid-cols-1 md:grid-cols-2">      <CardStackEntryField label={nameLabel}>        <Input          value={identity.name}          onChange={(e) => identity.setName((e.target as HTMLInputElement).value)}          placeholder={namePlaceholder}          className="text-sm"        />      </CardStackEntryField>      <CardStackEntryField label="Namespace" hint={effectiveNamespaceHint}>        <Input          value={identity.namespace}          onChange={(e) => identity.setNamespace((e.target as HTMLInputElement).value)}          placeholder={namespacePlaceholder}          className="font-mono text-sm"          disabled={namespaceReadOnly}        />      </CardStackEntryField>    </div>  );}