#!/usr/bin/env node /** * @termix-ssh/plugin-sdk/ui is public API, implemented by * src/ui/plugin-host/sdk-ui.ts. Adding to it is a contract change and * removing from it is a breaking one, so neither should happen by accident. * * Resolves every name the module exports (through its `export *` lines too) * and fails when that differs from ALLOWED. A new export goes on the list in * the same change, which is what makes it a deliberate one. Only generic * shell pieces belong here: anything one feature needs lives in its plugin. * * --list prints what the module exports now. */ const path = require("node:path"); const ROOT = path.resolve(__dirname, ".."); const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts"); const TSCONFIG = path.join(ROOT, "tsconfig.app.json"); const ALLOWED = [ "ActionSlot", "AddButton", "Alert", "AlertDescription", "AlertDialog", "AlertDialogAction", "AlertDialogCancel", "AlertDialogContent", "AlertDialogDescription", "AlertDialogFooter", "AlertDialogHeader", "AlertDialogOverlay", "AlertDialogPortal", "AlertDialogTitle", "AlertDialogTrigger", "AlertTitle", "BackButton", "Badge", "BarSeries", "BarSeriesItem", "BarSeriesProps", "BrowserSignInDialog", "Button", "ButtonProps", "Card", "CardAction", "CardContent", "CardDescription", "CardFooter", "CardGridCanvas", "CardHeader", "CardMasonry", "CardTitle", "Checkbox", "ColumnCountStepper", "ComponentSlot", "ConfirmOptions", "ConnectionGate", "ConnectionGateController", "ConnectionGateProps", "ConnectionLogPanel", "ConnectionLogProvider", "ConnectionOrigin", "ConnectionScreen", "ConnectionStage", "ConnectionStatus", "ConnectionUnavailable", "CustomKeybinding", "DataColumn", "DataView", "Dialog", "DialogClose", "DialogContent", "DialogDescription", "DialogFooter", "DialogHeader", "DialogOverlay", "DialogPortal", "DialogTitle", "DialogTrigger", "DocsLink", "DocsLinkProps", "DropdownMenu", "DropdownMenuCheckboxItem", "DropdownMenuContent", "DropdownMenuGroup", "DropdownMenuItem", "DropdownMenuLabel", "DropdownMenuPortal", "DropdownMenuRadioGroup", "DropdownMenuRadioItem", "DropdownMenuSeparator", "DropdownMenuShortcut", "DropdownMenuSub", "DropdownMenuSubContent", "DropdownMenuSubTrigger", "DropdownMenuTrigger", "EditingWidth", "EmptyState", "FOLDER_COLORS", "Facts", "FakeSwitch", "Field", "FieldPair", "FormFooter", "FullScreenAppPhase", "FullScreenAppWrapper", "GridCardCatalogEntry", "GridColSpan", "GridColumns", "GridLayout", "GridSlot", "GroupHeading", "HostDefaultBadge", "HostDefaultField", "HostFeatureFields", "HostFeatureFieldsProps", "HostKeyVerificationDialog", "InlineView", "Input", "Kbd", "KbdKey", "KbdSeparator", "KeyCombo", "KeybindingAction", "KeybindingActionType", "Label", "LineChart", "LineChartSeries", "ListBadge", "ListRow", "ListRowAction", "ListRowFolder", "ListRowTone", "LogContext", "LogEntry", "MFAPromptMode", "ManagerCardError", "ManagerCardShell", "ManagerSearch", "Meter", "MetricCard", "MiniStat", "NumberField", "PANEL", "PanePrompt", "PROMPT_BUTTON", "PROMPT_DESTRUCTIVE_BUTTON", "PROMPT_PRIMARY_BUTTON", "PanelDensity", "PanelList", "PanelSearch", "PanelShell", "PanelViewMode", "PassphraseDialog", "PasswordInput", "PluginComponent", "PluginViewPlaceholder", "Popover", "PopoverAnchor", "PopoverContent", "PopoverTrigger", "RadialGauge", "RadialGaugeProps", "RecentActivityItem", "Repeater", "RobustClipboardProvider", "SSHAuthDialog", "ScrollArea", "ScrollBar", "SecretField", "SectionCard", "Segmented", "Select", "Select2", "SelectContent", "SelectField", "SelectGroup", "SelectItem", "SelectLabel", "SelectScrollDownButton", "SelectScrollUpButton", "SelectSeparator", "SelectTrigger", "SelectValue", "Separator", "SettingRow", "Sheet", "SheetClose", "SheetContent", "SheetDescription", "SheetFooter", "SheetHeader", "SheetTitle", "SheetTrigger", "Skeleton", "Slider", "Sparkline", "SparklineProps", "StatRow", "SurfaceKind", "SurfaceScope", "Switch", "SwitchRow", "TOTPDialog", "TabStrip", "TabStripItem", "TagInput", "TextAreaField", "TextField", "Textarea", "TextareaProps", "Tooltip", "TooltipContent", "TooltipProvider", "TooltipTrigger", "UptimeInfo", "UsagePair", "VersionInfo", "ViewToggle", "WebSocketConnectionTarget", "WidgetTitle", "buildOriginWsUrl", "cn", "copyToClipboard", "createFrontendLogger", "createQuickConnectHost", "findMatchingKeybinding", "getAdaptiveResourceBudget", "getBasePath", "getDatabaseHealth", "getDeviceId", "getPollingEnvironmentMultiplier", "getRecentActivity", "getUptime", "getVersionInfo", "globalShortcutHandler", "hydrateLocalSharedHostAuth", "isElectron", "isMacPlatform", "isTabJumpHotkey", "linkedServerUrl", "markAdaptiveResourceUsed", "pluginWsUrl", "readFromClipboard", "resolveConnectionOrigin", "resolveRemoteHostId", "runAdaptiveBackgroundTask", "runAdaptivePolling", "runVisibleInterval", "usageBarColor", "usageColor", "useAdaptivePolling", "useAppTheme", "useConfirm", "useConnectionGate", "useConnectionLog", "useIsDefaultsEditor", "useIsMobile", "useOptionalConnectionLog", "useSurfaceKind", "useTabs", "useTabsSafe", ]; /** Every name a module exports, resolved the way the compiler sees it. */ function exportedNames(file = SDK_UI, tsconfig = TSCONFIG) { const ts = require("typescript"); const parsed = ts.getParsedCommandLineOfConfigFile( tsconfig, {}, { ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} }, ); const program = ts.createProgram([file], { ...(parsed?.options ?? {}), noEmit: true, }); const checker = program.getTypeChecker(); const source = program.getSourceFile(file); const symbol = source && checker.getSymbolAtLocation(source); if (!symbol) throw new Error(`Cannot read the exports of ${file}`); return checker .getExportsOfModule(symbol) .map((entry) => entry.getName()) .sort(); } const SNAPSHOT = path.join(ROOT, "packages", "plugin-sdk", "ui.api.txt"); /** * One line per export with its type as the compiler prints it. Committed as * packages/plugin-sdk/ui.api.txt, so a changed prop or signature shows up as * a diff instead of reaching plugins unnoticed. */ function exportSignatures(file = SDK_UI, tsconfig = TSCONFIG) { const ts = require("typescript"); const parsed = ts.getParsedCommandLineOfConfigFile( tsconfig, {}, { ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} }, ); const program = ts.createProgram([file], { ...(parsed?.options ?? {}), noEmit: true, }); const checker = program.getTypeChecker(); const source = program.getSourceFile(file); const symbol = source && checker.getSymbolAtLocation(source); const flags = ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.WriteArrowStyleSignature; return checker .getExportsOfModule(symbol) .map((entry) => { const target = entry.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(entry) : entry; const isValue = !!(target.flags & ts.SymbolFlags.Value); const declaration = target.declarations?.[0] ?? source; const type = isValue ? checker.getTypeOfSymbolAtLocation(target, declaration) : checker.getDeclaredTypeOfSymbol(target); const text = checker .typeToString(type, undefined, flags) .replace(/import\("[^"]*"\)\./g, "") .replace(/\s+/g, " "); return `${entry.getName()} (${isValue ? "value" : "type"}): ${text}`; }) .sort() .join("\n") .concat("\n"); } /** What the module adds to, and drops from, the allowlist. */ function compare(names, allowed = ALLOWED) { const allowedSet = new Set(allowed); const nameSet = new Set(names); return { added: names.filter((name) => !allowedSet.has(name)), removed: allowed.filter((name) => !nameSet.has(name)), }; } function main() { const fs = require("node:fs"); if (/^export (type )?\*/m.test(fs.readFileSync(SDK_UI, "utf8"))) { console.error( "sdk-ui.ts uses export *; name each export so nothing becomes public API by accident.", ); process.exit(1); } if (process.argv.includes("--update-snapshot")) { fs.writeFileSync(SNAPSHOT, exportSignatures()); console.log(`Wrote ${path.relative(ROOT, SNAPSHOT)}`); return; } const saved = fs.existsSync(SNAPSHOT) ? fs.readFileSync(SNAPSHOT, "utf8") : ""; const current = exportSignatures(); if (current !== saved) { const before = new Set(saved.split("\n")); const after = new Set(current.split("\n")); console.error( "@termix-ssh/plugin-sdk/ui changed shape. If that is deliberate, run: node scripts/check-sdk-ui-exports.cjs --update-snapshot", ); for (const line of saved.split("\n")) { if (line && !after.has(line)) console.error(` - ${line.slice(0, 200)}`); } for (const line of current.split("\n")) { if (line && !before.has(line)) console.error(` + ${line.slice(0, 200)}`); } process.exitCode = 1; } const names = exportedNames(); if (process.argv.includes("--list")) { console.log(names.join("\n")); return; } const { added, removed } = compare(names); if (added.length === 0 && removed.length === 0) return; if (added.length > 0) { console.error( "@termix-ssh/plugin-sdk/ui exports names that are not on the allowlist in scripts/check-sdk-ui-exports.cjs:", ); for (const name of added) console.error(` + ${name}`); } if (removed.length > 0) { console.error( "@termix-ssh/plugin-sdk/ui no longer exports these allowlisted names (a breaking change for plugins):", ); for (const name of removed) console.error(` - ${name}`); } process.exit(1); } if (require.main === module) main(); module.exports = { ALLOWED, compare, exportedNames, exportSignatures };