Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
The tree was a debranded snapshot of upstream taken around 2026-02-20, and
upstream has moved about five months since. This replaces it with upstream
master, mechanically re-derived: bin/debrand does the rename, and the
pipeline and squashed migrations are restored from the shipped tree.

Not a merge. The previous import replaced the tree wholesale in a single
commit, so there is no shared content history to merge against. Reproducing
the rename on the new tree is what actually converges.

What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints,
compose files, LLM.md, and insights/migrations. The squashed initial
migration is load-bearing — the live database records it by name, so
upstream's ~918 individual migrations cannot come back.

Nine products are dropped as unreferenced. Twenty-one more that we do not run
are kept anyway because the core app still imports them; unpicking those is a
refactor and does not belong in a rebase.

This base does not build yet, and the reasons are not in the rename:
upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against
our 4.2, and the dependency manifests still name upstream packages that we
republish under our own names. Those are decisions, not renames.
2026-08-01 15:44:40 -07:00

608 lines
22 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Generates UI app entry points and resource registry from YAML definitions.
*
* Reads ui_apps sections from definitions/*.yaml and products/X/mcp/tools.yaml.
*
* Produces:
* - src/ui-apps/apps/generated/app-name.tsx — entry point per detail/list app
* - src/resources/ui-apps.generated.ts — URI constants, UiAppKey type, withUiApp, UI_APPS
*
* Custom apps (type: custom) get registry entries but no generated entry point.
*
* Convention-based defaults: most fields are derived from the app key + product dir.
* See resolveDetailApp() and resolveListApp() for the full derivation logic.
*/
import { spawnSync } from 'node:child_process'
import * as fs from 'node:fs'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse as parseYaml } from 'yaml'
import { discoverDefinitions, isToolsConfig } from './lib/definitions.mjs'
import { MCP_ROOT_DIR, ROOT_DIR } from './utils'
import {
type CategoryConfig,
CategoryConfigSchema,
type ResolvedCustomUiApp,
type ResolvedDetailUiApp,
type ResolvedListUiApp,
type UiAppConfig,
} from './yaml-config-schema'
const DEFINITIONS_DIR = path.resolve(MCP_ROOT_DIR, 'definitions')
const PRODUCTS_DIR = path.resolve(ROOT_DIR, 'products')
const GENERATED_APPS_DIR = path.resolve(MCP_ROOT_DIR, 'src/ui-apps/apps/generated')
const GENERATED_REGISTRY_PATH = path.resolve(MCP_ROOT_DIR, 'src/resources/ui-apps.generated.ts')
// The render-ui umbrella app dispatches a tool result to its matching view. This
// module imports React view components, so it must live OUTSIDE src/ui-apps/apps/
// (where `discoverApps` would otherwise build it as a standalone app bundle).
const GENERATED_DISPATCH_DIR = path.resolve(MCP_ROOT_DIR, 'src/ui-apps/generated')
const GENERATED_DISPATCH_PATH = path.resolve(GENERATED_DISPATCH_DIR, 'render-dispatch.generated.tsx')
const AUTO_GENERATED_HEADER = '// AUTO-GENERATED by generate-ui-apps.ts — do not edit\n'
// ------------------------------------------------------------------
// Convention-based default resolution
// ------------------------------------------------------------------
function kebabToPascal(s: string): string {
return s
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
}
function kebabToTitle(s: string): string {
return s
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
/**
* Derive the component import path from the YAML file location.
* For products/actions/mcp/tools.yaml -> products/actions/mcp/apps
* For services/mcp/definitions/core.yaml -> null (no product dir)
*/
function deriveComponentImport(yamlFilePath: string): string | null {
const mcpDir = path.dirname(yamlFilePath)
if (mcpDir.includes('/products/')) {
return path.relative(ROOT_DIR, path.join(mcpDir, 'apps'))
}
return null
}
function resolveDetailApp(
appKey: string,
raw: Extract<UiAppConfig, { type: 'detail' }>,
componentImport: string
): ResolvedDetailUiApp {
const pascal = kebabToPascal(appKey)
return {
type: 'detail',
view_prop: raw.view_prop,
app_name: raw.app_name ?? `Insights ${kebabToTitle(appKey)}`,
description: raw.description ?? `${kebabToTitle(appKey)} detail view`,
component_import: raw.component_import ?? componentImport,
data_type: raw.data_type ?? `${pascal}Data`,
view_component: raw.view_component ?? `${pascal}View`,
...(raw.link_prop ? { link_prop: raw.link_prop } : {}),
}
}
function resolveListApp(
appKey: string,
raw: Extract<UiAppConfig, { type: 'list' }>,
componentImport: string
): ResolvedListUiApp {
// Strip -list suffix to get the singular entity key
const singularKey = appKey.replace(/-list$/, '')
const singularPascal = kebabToPascal(singularKey)
const pascal = kebabToPascal(appKey)
return {
type: 'list',
detail_tool: raw.detail_tool,
detail_args: raw.detail_args ?? '{ id: item.id }',
item_name_field: raw.item_name_field ?? 'name',
click_prop: raw.click_prop ?? `on${singularPascal}Click`,
entity_label: raw.entity_label ?? singularKey.replace(/-/g, ' '),
app_name: raw.app_name ?? `Insights ${kebabToTitle(appKey)}`,
description: raw.description ?? `${kebabToTitle(appKey)} view`,
component_import: raw.component_import ?? componentImport,
list_data_type: raw.list_data_type ?? `${singularPascal}ListData`,
item_data_type: raw.item_data_type ?? `${singularPascal}Data`,
view_component: raw.view_component ?? `${pascal}View`,
}
}
function resolveCustomApp(raw: Extract<UiAppConfig, { type: 'custom' }>): ResolvedCustomUiApp {
return {
type: 'custom',
app_name: raw.app_name,
description: raw.description,
...(raw.render_ui ? { render_ui: raw.render_ui } : {}),
}
}
// ------------------------------------------------------------------
// Entry point generators
// ------------------------------------------------------------------
function generateDetailApp(appKey: string, config: ResolvedDetailUiApp): string {
const pascalName = kebabToPascal(appKey)
const renderProp = config.link_prop
? `({ data, openLink }) => <${config.view_component} ${config.view_prop}={data!} ${config.link_prop}={openLink} />`
: `({ data }) => <${config.view_component} ${config.view_prop}={data!} />`
return `${AUTO_GENERATED_HEADER}import '../../styles/tailwind.css'
import { createRoot } from 'react-dom/client'
import { type ${config.data_type}, ${config.view_component} } from '${config.component_import}'
import { AppWrapper } from '../../components/AppWrapper'
function ${pascalName}App(): JSX.Element {
return (
<AppWrapper<${config.data_type}> appName="${config.app_name}">
{${renderProp}}
</AppWrapper>
)
}
const container = document.getElementById('root')
if (container) {
createRoot(container).render(<${pascalName}App />)
}
`
}
/**
* The list-to-detail `Content` component for a list app — owns the click handler
* that fetches a single item via `callServerTool` and the chat fallback. Shared by
* the standalone list entry point and the render-ui dispatch module so the
* drill-down behavior can't drift between them.
*/
function buildListContentComponent(appKey: string, config: ResolvedListUiApp): string {
const pascalName = kebabToPascal(appKey)
return `function ${pascalName}Content({ data, app }: { data: ${config.list_data_type}; app: App | null }): JSX.Element {
const fallbackToChat = useCallback(
(name: string) => {
app?.sendMessage({
role: 'user',
content: [{ type: 'text', text: \`Show me the details for ${config.entity_label} "\${name}"\` }],
})
},
[app]
)
const handleClick = useCallback(
async (item: ${config.item_data_type}): Promise<${config.item_data_type} | null> => {
if (!app) {
fallbackToChat(item.${config.item_name_field})
return null
}
try {
const result = await app.callServerTool({
name: '${config.detail_tool}',
arguments: ${config.detail_args},
})
if (result.isError || !result.structuredContent) {
fallbackToChat(item.${config.item_name_field})
return null
}
return result.structuredContent as unknown as ${config.item_data_type}
} catch {
fallbackToChat(item.${config.item_name_field})
return null
}
},
[app, fallbackToChat]
)
return <${config.view_component} data={data} ${config.click_prop}={handleClick} />
}`
}
function generateListApp(appKey: string, config: ResolvedListUiApp): string {
const pascalName = kebabToPascal(appKey)
return `${AUTO_GENERATED_HEADER}import '../../styles/tailwind.css'
import type { App } from '@modelcontextprotocol/ext-apps'
import { useCallback } from 'react'
import { createRoot } from 'react-dom/client'
import { type ${config.item_data_type}, type ${config.list_data_type}, ${config.view_component} } from '${config.component_import}'
import { AppWrapper } from '../../components/AppWrapper'
function ${pascalName}App(): JSX.Element {
return (
<AppWrapper<${config.list_data_type}> appName="${config.app_name}">
{({ data, app }) => <${pascalName}Content data={data!} app={app} />}
</AppWrapper>
)
}
${buildListContentComponent(appKey, config)}
const container = document.getElementById('root')
if (container) {
createRoot(container).render(<${pascalName}App />)
}
`
}
// ------------------------------------------------------------------
// Render-ui dispatch module generator
// ------------------------------------------------------------------
type DispatchApp =
| { appKey: string; type: 'detail'; config: ResolvedDetailUiApp }
| { appKey: string; type: 'list'; config: ResolvedListUiApp }
| {
appKey: string
type: 'custom'
config: ResolvedCustomUiApp & { render_ui: NonNullable<ResolvedCustomUiApp['render_ui']> }
}
/**
* Generates the dispatch module consumed by the `render-ui` umbrella app. It maps
* each app key with a reusable view to a render function so `render-ui` can fetch
* a tool result and mount the matching view. Custom apps opt in by declaring
* `render_ui` because their entry points have no convention view component.
*/
function generateDispatchModule(apps: DispatchApp[]): string {
// Merge named imports per component_import path (multiple apps from the same
// product share one import line, e.g. survey + survey-list + survey-stats).
const importsByPath = new Map<string, Set<string>>()
const addImport = (importPath: string, spec: string): void => {
const specs = importsByPath.get(importPath) ?? new Set<string>()
specs.add(spec)
importsByPath.set(importPath, specs)
}
for (const app of apps) {
if (app.type === 'detail') {
addImport(app.config.component_import, `type ${app.config.data_type}`)
addImport(app.config.component_import, app.config.view_component)
} else if (app.type === 'list') {
addImport(app.config.component_import, `type ${app.config.item_data_type}`)
addImport(app.config.component_import, `type ${app.config.list_data_type}`)
addImport(app.config.component_import, app.config.view_component)
} else {
const { component_import, data_type, view_component } = app.config.render_ui
if (data_type) {
addImport(component_import, `type ${data_type}`)
}
addImport(component_import, view_component)
}
}
const componentImports = [...importsByPath.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([importPath, specs]) => `import { ${[...specs].sort().join(', ')} } from '${importPath}'`)
.join('\n')
const hasListApp = apps.some((app) => app.type === 'list')
const listContents = apps
.filter((app): app is Extract<DispatchApp, { type: 'list' }> => app.type === 'list')
.map((app) => buildListContentComponent(app.appKey, app.config))
.join('\n\n')
const entries = apps
.map((app) => {
if (app.type === 'detail') {
const { view_component, view_prop, data_type, link_prop } = app.config
if (link_prop) {
return ` '${app.appKey}': ({ data, openLink }) => <${view_component} ${view_prop}={data as ${data_type}} ${link_prop}={openLink} />,`
}
return ` '${app.appKey}': ({ data }) => <${view_component} ${view_prop}={data as ${data_type}} />,`
}
if (app.type === 'custom') {
const { data_type, view_component, view_prop } = app.config.render_ui
const dataExpression = data_type ? `data as ${data_type}` : 'data'
return ` '${app.appKey}': ({ data }) => <${view_component} ${view_prop}={${dataExpression}} />,`
}
const pascalName = kebabToPascal(app.appKey)
return ` '${app.appKey}': ({ data, app }) => <${pascalName}Content data={data as ${app.config.list_data_type}} app={app} />,`
})
.join('\n')
const reactImport = hasListApp ? "import { useCallback } from 'react'\n\n" : ''
const listBlock = listContents ? `${listContents}\n\n` : ''
return `${AUTO_GENERATED_HEADER}import type { App } from '@modelcontextprotocol/ext-apps'
${reactImport}${componentImports}
import type { UiAppKey } from '../../resources/ui-apps.generated'
export interface RenderDispatchProps {
data: unknown
app: App | null
openLink: (url: string) => void
}
${listBlock}export const RENDER_DISPATCH: Partial<Record<UiAppKey, (props: RenderDispatchProps) => JSX.Element>> = {
${entries}
}
`
}
// ------------------------------------------------------------------
// Registry generator
// ------------------------------------------------------------------
interface RegistryEntry {
appKey: string
appDir: string
constName: string
uri: string
name: string
description: string
}
function toConstName(appKey: string): string {
return appKey.toUpperCase().replace(/-/g, '_') + '_RESOURCE_URI'
}
function toUri(appKey: string): string {
return `ui://insights/${appKey}.html`
}
function generateRegistry(entries: RegistryEntry[], dispatchableKeys: string[]): string {
const constants = entries.map((e) => `export const ${e.constName} = '${e.uri}'`).join('\n')
const keyUnion = entries.map((e) => `'${e.appKey}'`).join(' | ')
const uriMapEntries = entries.map((e) => ` '${e.appKey}': ${e.constName}`).join(',\n')
const appEntries = entries
.map(
(e) =>
` {\n name: '${e.name}',\n uri: ${e.constName},\n description: '${e.description}',\n appDir: '${e.appDir}',\n }`
)
.join(',\n')
const dispatchableEntries = [...dispatchableKeys]
.sort()
.map((key) => ` '${key}'`)
.join(',\n')
return `${AUTO_GENERATED_HEADER}
${constants}
export type UiAppKey = ${keyUnion}
export const URI_MAP: Record<UiAppKey, string> = {
${uriMapEntries},
}
/**
* App keys with a reusable view that the \`render-ui\` umbrella tool can render.
* Custom apps are included only when they explicitly configure \`render_ui\`.
*/
export const DISPATCHABLE_APP_KEYS: UiAppKey[] = [
${dispatchableEntries},
]
export const UI_APPS: Array<{
name: string
uri: string
description: string
appDir: string
}> = [
${appEntries},
]
`
}
// ------------------------------------------------------------------
// Main
// ------------------------------------------------------------------
function main(): void {
const definitionSources = discoverDefinitions({ definitionsDir: DEFINITIONS_DIR, productsDir: PRODUCTS_DIR })
fs.mkdirSync(GENERATED_APPS_DIR, { recursive: true })
const registryEntries: RegistryEntry[] = []
const dispatchApps: DispatchApp[] = []
const generatedFiles: string[] = []
// Collect all ui_app keys for cross-referencing with tool ui_app fields
const allUiAppKeys = new Set<string>()
for (const def of definitionSources) {
const content = fs.readFileSync(def.filePath, 'utf-8')
const parsed = parseYaml(content)
// Only continue if we detect this is a tool config file
if (!isToolsConfig(parsed)) {
continue
}
const result = CategoryConfigSchema.safeParse(parsed)
if (!result.success) {
console.error(`Invalid YAML config in ${def.filePath}:`)
for (const issue of result.error.issues) {
console.error(` ${issue.path.join('.')}: ${issue.message}`)
}
process.exit(1)
}
const config: CategoryConfig = result.data
if (!config.ui_apps) {
continue
}
const componentImport = deriveComponentImport(def.filePath)
for (const [appKey, appConfig] of Object.entries(config.ui_apps)) {
allUiAppKeys.add(appKey)
const constName = toConstName(appKey)
const uri = toUri(appKey)
if (appConfig.type === 'detail') {
if (!componentImport && !appConfig.component_import) {
console.error(
`detail app "${appKey}" in ${def.filePath}: cannot derive component_import (not in a product dir). Set component_import explicitly.`
)
process.exit(1)
}
const resolved = resolveDetailApp(appKey, appConfig, componentImport!)
const code = generateDetailApp(appKey, resolved)
const outPath = path.join(GENERATED_APPS_DIR, `${appKey}.tsx`)
fs.writeFileSync(outPath, code)
generatedFiles.push(outPath)
dispatchApps.push({ appKey, type: 'detail', config: resolved })
registryEntries.push({
appKey,
appDir: `generated/${appKey}`,
constName,
uri,
name: resolved.app_name,
description: resolved.description,
})
} else if (appConfig.type === 'list') {
if (!componentImport && !appConfig.component_import) {
console.error(
`list app "${appKey}" in ${def.filePath}: cannot derive component_import (not in a product dir). Set component_import explicitly.`
)
process.exit(1)
}
const resolved = resolveListApp(appKey, appConfig, componentImport!)
const code = generateListApp(appKey, resolved)
const outPath = path.join(GENERATED_APPS_DIR, `${appKey}.tsx`)
fs.writeFileSync(outPath, code)
generatedFiles.push(outPath)
dispatchApps.push({ appKey, type: 'list', config: resolved })
registryEntries.push({
appKey,
appDir: `generated/${appKey}`,
constName,
uri,
name: resolved.app_name,
description: resolved.description,
})
} else {
// type: custom — entry point is handwritten, appDir matches appKey directly
const resolved = resolveCustomApp(appConfig)
if (resolved.render_ui) {
dispatchApps.push({
appKey,
type: 'custom',
config: { ...resolved, render_ui: resolved.render_ui },
})
}
registryEntries.push({
appKey,
appDir: appKey,
constName,
uri,
name: appConfig.app_name,
description: appConfig.description,
})
}
}
}
// Validate ui_app references in tools
for (const def of definitionSources) {
const content = fs.readFileSync(def.filePath, 'utf-8')
const parsed = parseYaml(content)
// Only continue if we detect this is a tool config file
if (!isToolsConfig(parsed)) {
continue
}
const config = CategoryConfigSchema.parse(parsed)
for (const [toolName, toolConfig] of Object.entries(config.tools)) {
if (toolConfig.ui_app && !allUiAppKeys.has(toolConfig.ui_app)) {
console.error(
`Tool "${toolName}" references ui_app "${toolConfig.ui_app}" which is not defined in any ui_apps section.`
)
process.exit(1)
}
}
}
// Sort entries alphabetically for stable output
registryEntries.sort((a, b) => a.appKey.localeCompare(b.appKey))
dispatchApps.sort((a, b) => a.appKey.localeCompare(b.appKey))
// Write registry (with the render-ui dispatchable key list)
const registryCode = generateRegistry(
registryEntries,
dispatchApps.map((a) => a.appKey)
)
fs.writeFileSync(GENERATED_REGISTRY_PATH, registryCode)
generatedFiles.push(GENERATED_REGISTRY_PATH)
// Write the render-ui dispatch module
fs.mkdirSync(GENERATED_DISPATCH_DIR, { recursive: true })
fs.writeFileSync(GENERATED_DISPATCH_PATH, generateDispatchModule(dispatchApps))
generatedFiles.push(GENERATED_DISPATCH_PATH)
process.stdout.write(
`Generated ${generatedFiles.length - 2} UI app entry point(s) + registry + dispatch (${registryEntries.length} app(s))\n`
)
formatOutput(generatedFiles)
}
function formatOutput(generatedFiles: string[]): void {
// Run oxlint + oxfmt directly instead of going through `bin/insightscli format:js`,
// which requires `uv` (Python) and fails in environments like Cloudflare Pages
// where the uv version doesn't match pyproject.toml's required-version.
const oxlintResult = spawnSync('pnpm', ['oxlint', '--fix', '--fix-suggestions', '--quiet', ...generatedFiles], {
stdio: 'inherit',
cwd: ROOT_DIR,
})
if (oxlintResult.status && oxlintResult.status !== 0) {
process.exit(oxlintResult.status)
}
const oxfmtResult = spawnSync('pnpm', ['exec', 'oxfmt', '--no-error-on-unmatched-pattern', ...generatedFiles], {
stdio: 'inherit',
cwd: ROOT_DIR,
})
if (oxfmtResult.status && oxfmtResult.status !== 0) {
process.exit(oxfmtResult.status)
}
}
// Export for testing
export {
type DispatchApp,
buildListContentComponent,
generateDetailApp,
generateDispatchModule,
generateListApp,
generateRegistry,
kebabToPascal,
resolveCustomApp,
resolveDetailApp,
resolveListApp,
toConstName,
toUri,
}
// Run main when executed directly
function stripExt(filePath: string): string {
return filePath.replace(/\.[jt]s$/, '')
}
const isDirectRun =
typeof process !== 'undefined' &&
process.argv[1] &&
stripExt(path.resolve(process.argv[1])) === stripExt(fileURLToPath(import.meta.url))
if (isDirectRun) {
main()
}