@skyhook-io/k8s-ui 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/package.json +2 -2
  2. package/src/components/resources/ResourcesSidebar.tsx +152 -57
  3. package/src/components/resources/ResourcesView.tsx +141 -49
  4. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +190 -28
  5. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +22 -0
  6. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +156 -5
  7. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +28 -1
  8. package/src/components/resources/renderers/CertificateRenderer.tsx +47 -8
  9. package/src/components/resources/renderers/ChallengeRenderer.tsx +22 -3
  10. package/src/components/resources/renderers/CiliumNetworkPolicyRenderer.tsx +262 -45
  11. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +81 -9
  12. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +10 -3
  13. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +59 -1
  14. package/src/components/resources/renderers/ClusterNetworkPolicyRenderer.tsx +7 -21
  15. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -5
  16. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +91 -3
  17. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -42
  18. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -41
  19. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +66 -2
  20. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +31 -1
  21. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +66 -1
  22. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -0
  23. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +55 -1
  24. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +23 -0
  25. package/src/components/resources/renderers/KustomizationRenderer.tsx +38 -3
  26. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +7 -56
  27. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -42
  28. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +3 -8
  29. package/src/components/resources/renderers/PodMonitorRenderer.tsx +5 -7
  30. package/src/components/resources/renderers/PodRenderer.tsx +22 -74
  31. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +178 -20
  32. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +2 -2
  33. package/src/components/resources/renderers/RolloutRenderer.tsx +105 -3
  34. package/src/components/resources/renderers/SbomReportRenderer.tsx +46 -5
  35. package/src/components/resources/renderers/SealedSecretRenderer.tsx +11 -2
  36. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +5 -7
  37. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +11 -3
  38. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +11 -3
  39. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +8 -16
  40. package/src/components/resources/renderers/WorkflowRenderer.tsx +71 -17
  41. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +46 -1
  42. package/src/components/resources/renderers/karpenter-cells.tsx +18 -3
  43. package/src/components/resources/resource-utils-cnpg.ts +47 -0
  44. package/src/components/resources/resource-utils-karpenter.ts +6 -0
  45. package/src/components/resources/resource-utils-prometheus.ts +50 -6
  46. package/src/components/resources/resource-utils.ts +131 -33
  47. package/src/components/shared/EditableYamlView.tsx +38 -3
  48. package/src/components/shared/ResourceRendererDispatch.tsx +4 -4
  49. package/src/components/timeline/TimelineList.tsx +11 -4
  50. package/src/components/ui/Tooltip.tsx +16 -0
  51. package/src/components/ui/drawer-components.tsx +48 -1
  52. package/src/theme/variables.css +1 -1
  53. package/src/types/gitops.ts +8 -0
  54. package/src/utils/navigation.test.ts +29 -2
  55. package/src/utils/navigation.ts +45 -24
@@ -1,4 +1,4 @@
1
- import { useState, useCallback } from 'react'
1
+ import { useState, useCallback, useEffect, useRef } from 'react'
2
2
  import {
3
3
  Copy,
4
4
  CopyPlus,
@@ -91,6 +91,18 @@ function formatSaveError(error: string): { summary: string; details?: string } {
91
91
  return { summary: error }
92
92
  }
93
93
 
94
+ // Safe sessionStorage wrappers — storage can throw QuotaExceededError or be
95
+ // blocked by browser security policies. Draft persistence is best-effort.
96
+ function safeSessionGet(key: string): string | null {
97
+ try { return sessionStorage.getItem(key) } catch { return null }
98
+ }
99
+ function safeSessionSet(key: string, value: string): void {
100
+ try { sessionStorage.setItem(key, value) } catch { /* best-effort */ }
101
+ }
102
+ function safeSessionRemove(key: string): void {
103
+ try { sessionStorage.removeItem(key) } catch { /* best-effort */ }
104
+ }
105
+
94
106
  interface EditableYamlViewProps {
95
107
  resource: SelectedResource
96
108
  data: any
@@ -109,11 +121,34 @@ interface EditableYamlViewProps {
109
121
  }
110
122
 
111
123
  export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSave, isSaving, saveError, onDuplicate }: EditableYamlViewProps) {
112
- const [isEditing, setIsEditing] = useState(false)
113
- const [editedYaml, setEditedYaml] = useState('')
124
+ const draftKey = `radar_yaml_draft:${resource.kind}/${resource.namespace}/${resource.name}`
125
+
126
+ // Restore draft from sessionStorage (e.g., after session-expiry redirect).
127
+ // All sessionStorage calls are wrapped in try-catch — storage can throw
128
+ // QuotaExceededError or be blocked by browser security policies.
129
+ const savedDraft = useRef(safeSessionGet(draftKey))
130
+ const [isEditing, setIsEditing] = useState(savedDraft.current !== null)
131
+ const [editedYaml, setEditedYaml] = useState(savedDraft.current ?? '')
114
132
  const [yamlErrors, setYamlErrors] = useState<string[]>([])
115
133
  const [showErrorDetails, setShowErrorDetails] = useState(false)
116
134
 
135
+ // Clean up restored draft flag
136
+ useEffect(() => {
137
+ if (typeof savedDraft.current === 'string') {
138
+ safeSessionRemove(draftKey)
139
+ savedDraft.current = null
140
+ }
141
+ }, [draftKey])
142
+
143
+ // Autosave draft to sessionStorage while editing (best-effort)
144
+ useEffect(() => {
145
+ if (isEditing && editedYaml) {
146
+ safeSessionSet(draftKey, editedYaml)
147
+ } else {
148
+ safeSessionRemove(draftKey)
149
+ }
150
+ }, [isEditing, editedYaml, draftKey])
151
+
117
152
  // Convert resource to YAML for editing
118
153
  const convertToYaml = useCallback((d: any) => {
119
154
  if (!d) return ''
@@ -353,7 +353,7 @@ export function ResourceRendererDispatch({
353
353
  {kind === 'grpcroutes' && <GRPCRouteRenderer data={data} onNavigate={onNavigate} />}
354
354
  {kind === 'tcproutes' && <SimpleRouteRenderer data={data} kind="TCPRoute" onNavigate={onNavigate} />}
355
355
  {kind === 'tlsroutes' && <SimpleRouteRenderer data={data} kind="TLSRoute" onNavigate={onNavigate} />}
356
- {kind === 'sealedsecrets' && <SealedSecretRenderer data={data} />}
356
+ {kind === 'sealedsecrets' && <SealedSecretRenderer data={data} onNavigate={onNavigate} />}
357
357
  {kind === 'workflowtemplates' && <WorkflowTemplateRenderer data={data} />}
358
358
  {(kind === 'networkpolicies' || kind === 'networkpolicy') && <NetworkPolicyRenderer data={data} />}
359
359
  {(kind === 'ciliumnetworkpolicies' || kind === 'ciliumnetworkpolicy' || kind === 'ciliumclusterwidenetworkpolicies' || kind === 'ciliumclusterwidenetworkpolicy') && <CiliumNetworkPolicyRenderer data={data} />}
@@ -366,8 +366,8 @@ export function ResourceRendererDispatch({
366
366
  {kind === 'gitrepositories' && <GitRepositoryRenderer data={data} />}
367
367
  {kind === 'ocirepositories' && <OCIRepositoryRenderer data={data} />}
368
368
  {kind === 'helmrepositories' && <HelmRepositoryRenderer data={data} />}
369
- {kind === 'kustomizations' && <KustomizationRenderer data={data} />}
370
- {kind === 'helmreleases' && <FluxHelmReleaseRenderer data={data} />}
369
+ {kind === 'kustomizations' && <KustomizationRenderer data={data} onNavigate={onNavigate} />}
370
+ {kind === 'helmreleases' && <FluxHelmReleaseRenderer data={data} onNavigate={onNavigate} />}
371
371
  {kind === 'alerts' && <AlertRenderer data={data} />}
372
372
  {kind === 'applications' && <ArgoApplicationRenderer data={data} />}
373
373
  {kind === 'nodepools' && <KarpenterNodePoolRenderer data={data} onNavigate={onNavigate} />}
@@ -395,7 +395,7 @@ export function ResourceRendererDispatch({
395
395
  {kind === 'backupstoragelocations' && <VeleroBSLRenderer data={data} />}
396
396
  {kind === 'volumesnapshotlocations' && <VeleroVSLRenderer data={data} />}
397
397
  {kind === 'externalsecrets' && <ExternalSecretRenderer data={data} onNavigate={onNavigate} />}
398
- {kind === 'clusterexternalsecrets' && <ClusterExternalSecretRenderer data={data} />}
398
+ {kind === 'clusterexternalsecrets' && <ClusterExternalSecretRenderer data={data} onNavigate={onNavigate} />}
399
399
  {(kind === 'secretstores' || kind === 'clustersecretstores') && <SecretStoreRenderer data={data} />}
400
400
  {kind === 'clusters' && <CNPGClusterRenderer data={data} onNavigate={onNavigate} />}
401
401
  {kind === 'scheduledbackups' && <CNPGScheduledBackupRenderer data={data} onNavigate={onNavigate} />}
@@ -603,10 +603,17 @@ function ActivityCard({ item, expanded, onToggle, onResourceClick }: ActivityCar
603
603
  {item.owner && (
604
604
  <span className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
605
605
  <span className="text-xs text-theme-text-quaternary">←</span>
606
- <ResourceRefBadge
607
- resourceRef={{ kind: item.owner.kind, namespace: item.namespace, name: item.owner.name }}
608
- onClick={(ref) => onResourceClick?.(refToSelectedResource(ref))}
609
- />
606
+ {item.owner.kind === 'ReplicaSet' ? (
607
+ <ResourceRefBadge
608
+ resourceRef={{ kind: 'Deployment', namespace: item.namespace, name: item.owner.name.replace(/-[a-z0-9]+$/, '') }}
609
+ onClick={(ref) => onResourceClick?.(refToSelectedResource(ref))}
610
+ />
611
+ ) : (
612
+ <ResourceRefBadge
613
+ resourceRef={{ kind: item.owner.kind, namespace: item.namespace, name: item.owner.name }}
614
+ onClick={(ref) => onResourceClick?.(refToSelectedResource(ref))}
615
+ />
616
+ )}
610
617
  </span>
611
618
  )}
612
619
  {item.createdAt && (
@@ -109,6 +109,22 @@ export function Tooltip({
109
109
  }
110
110
  }, [])
111
111
 
112
+ // When disabled flips true, proactively cancel any pending show timer and
113
+ // clear visible state. Without this, a tooltip that was visible (or armed)
114
+ // when disabled became true would pop back on as soon as disabled flips
115
+ // false — even though the cursor is elsewhere and no fresh mouseenter has
116
+ // fired. Also covers the case where the trigger becomes unreachable via
117
+ // pointer-events-none and never fires mouseleave.
118
+ useEffect(() => {
119
+ if (disabled) {
120
+ if (timeoutRef.current) {
121
+ clearTimeout(timeoutRef.current)
122
+ timeoutRef.current = null
123
+ }
124
+ setIsVisible(false)
125
+ }
126
+ }, [disabled])
127
+
112
128
  if (disabled || !content) {
113
129
  return <>{children}</>
114
130
  }
@@ -28,6 +28,53 @@ export function KeyValueBadgeList({ items }: { items: Record<string, unknown> |
28
28
  )
29
29
  }
30
30
 
31
+ /**
32
+ * Renders a Kubernetes label selector (matchLabels + matchExpressions).
33
+ * Accepts either a full selector object `{ matchLabels?, matchExpressions? }`
34
+ * or a flat `Record<string, string>` (e.g. Service.spec.selector).
35
+ * Pass `inline` for flow-inline rendering (e.g. within a sentence).
36
+ */
37
+ export function LabelSelectorDisplay({
38
+ selector,
39
+ emptyText = 'All',
40
+ inline = false,
41
+ }: {
42
+ selector: any
43
+ emptyText?: string
44
+ inline?: boolean
45
+ }) {
46
+ if (!selector) {
47
+ return <span className="text-xs text-theme-text-tertiary">{emptyText}</span>
48
+ }
49
+
50
+ // Detect flat selector (e.g. Service.spec.selector has no matchLabels wrapper)
51
+ const isFlat = typeof selector === 'object' && !Array.isArray(selector) && !selector.matchLabels && !selector.matchExpressions
52
+ const matchLabels: Record<string, unknown> = isFlat ? selector : (selector.matchLabels || {})
53
+ const matchExpressions: Array<{ key: string; operator: string; values?: string[] }> = selector.matchExpressions || []
54
+
55
+ const hasLabels = Object.keys(matchLabels).length > 0
56
+ const hasExpressions = matchExpressions.length > 0
57
+
58
+ if (!hasLabels && !hasExpressions) {
59
+ return <span className="text-xs text-theme-text-tertiary">{emptyText}</span>
60
+ }
61
+
62
+ const Wrapper = inline ? 'span' : 'div'
63
+
64
+ return (
65
+ <Wrapper className={inline ? 'inline-flex flex-wrap gap-1 align-middle' : 'flex flex-wrap gap-1'}>
66
+ {Object.entries(matchLabels).map(([k, v]) => (
67
+ <KeyValueBadge key={`l-${k}`} k={k} v={String(v)} />
68
+ ))}
69
+ {matchExpressions.map((expr, i) => (
70
+ <span key={`e-${i}`} className="badge bg-theme-elevated text-theme-text-secondary">
71
+ {expr.key} {expr.operator}{expr.values && expr.values.length > 0 ? ` ${expr.values.join(', ')}` : ''}
72
+ </span>
73
+ ))}
74
+ </Wrapper>
75
+ )
76
+ }
77
+
31
78
  interface SectionProps {
32
79
  title: string
33
80
  icon?: React.ComponentType<{ className?: string }>
@@ -234,7 +281,7 @@ export interface Problem {
234
281
  message: string
235
282
  }
236
283
 
237
- /** Displays a list of problem alerts (warnings and errors) for GitOps resources */
284
+ /** Displays a list of problem alerts (warnings and errors) */
238
285
  export function ProblemAlerts({ problems }: { problems: Problem[] }) {
239
286
  if (problems.length === 0) return null
240
287
 
@@ -43,7 +43,7 @@
43
43
  /* ── Text (cool neutral) ── */
44
44
  --text-primary: light-dark(#1A1C20, #F5F6F8);
45
45
  --text-secondary: light-dark(#555860, #D8DAE0);
46
- --text-tertiary: light-dark(#7E828A, #B0B5C0);
46
+ --text-tertiary: light-dark(#6B7080, #B0B5C0);
47
47
  --text-disabled: light-dark(#A0A4AC, #6A7080);
48
48
 
49
49
  /* ── Borders (lightly blue-tinted in light mode) ── */
@@ -230,7 +230,9 @@ export interface ArgoAppStatus {
230
230
  operationState?: {
231
231
  phase?: ArgoOperationPhase | string // Known types + allow unknown
232
232
  message?: string
233
+ startedAt?: string
233
234
  finishedAt?: string
235
+ retryCount?: number
234
236
  syncResult?: {
235
237
  revision?: string
236
238
  source?: ArgoSyncResultSource
@@ -238,6 +240,12 @@ export interface ArgoAppStatus {
238
240
  }
239
241
  }
240
242
  reconciledAt?: string
243
+ history?: Array<{
244
+ revision?: string
245
+ deployedAt?: string
246
+ id?: number
247
+ source?: { repoURL?: string; path?: string }
248
+ }>
241
249
  }
242
250
 
243
251
  /**
@@ -1,5 +1,9 @@
1
- import { describe, test, expect } from 'vitest'
2
- import { kindToPlural, pluralToKind, refToSelectedResource } from './navigation'
1
+ import { describe, test, expect, afterEach } from 'vitest'
2
+ import { kindToPlural, pluralToKind, refToSelectedResource, initNavigationMap, resetNavigationMap } from './navigation'
3
+
4
+ afterEach(() => {
5
+ resetNavigationMap()
6
+ })
3
7
 
4
8
  describe('kindToPlural', () => {
5
9
  test('singular PascalCase to plural lowercase', () => {
@@ -110,6 +114,29 @@ describe('pluralToKind', () => {
110
114
  })
111
115
  })
112
116
 
117
+ describe('initNavigationMap', () => {
118
+ test('discovered API resources override heuristic pluralization', () => {
119
+ // Before init, an unknown CRD would hit the heuristic fallback
120
+ // After init, it uses the discovered plural name
121
+ initNavigationMap([
122
+ { group: 'external-secrets.io', version: 'v1', kind: 'SecretStore', name: 'secretstores', namespaced: true, isCrd: true, verbs: ['get'] },
123
+ { group: 'external-secrets.io', version: 'v1', kind: 'ClusterSecretStore', name: 'clustersecretstores', namespaced: false, isCrd: true, verbs: ['get'] },
124
+ ])
125
+ expect(kindToPlural('SecretStore')).toBe('secretstores')
126
+ expect(kindToPlural('ClusterSecretStore')).toBe('clustersecretstores')
127
+ expect(pluralToKind('secretstores')).toBe('SecretStore')
128
+ expect(pluralToKind('clustersecretstores')).toBe('ClusterSecretStore')
129
+ })
130
+
131
+ test('prevents double-pluralization of discovered plurals', () => {
132
+ initNavigationMap([
133
+ { group: 'external-secrets.io', version: 'v1', kind: 'SecretStore', name: 'secretstores', namespaced: true, isCrd: true, verbs: ['get'] },
134
+ ])
135
+ // Passing an already-plural kind should be idempotent
136
+ expect(kindToPlural('secretstores')).toBe('secretstores')
137
+ })
138
+ })
139
+
113
140
  describe('refToSelectedResource', () => {
114
141
  test('converts singular kind to plural for navigation', () => {
115
142
  const result = refToSelectedResource({
@@ -1,4 +1,4 @@
1
- import type { SelectedResource, ResourceRef } from '../types/core'
1
+ import type { SelectedResource, ResourceRef, APIResource } from '../types/core'
2
2
 
3
3
  /**
4
4
  * Canonical callback type for navigating to a resource.
@@ -6,9 +6,8 @@ import type { SelectedResource, ResourceRef } from '../types/core'
6
6
  */
7
7
  export type NavigateToResource = (resource: SelectedResource) => void
8
8
 
9
- // Known plural API resource names singular PascalCase kind.
10
- // Shared between kindToPlural (idempotency guard) and pluralToKind (reverse lookup).
11
- const PLURAL_TO_KIND: Record<string, string> = {
9
+ // Fallback map for core K8s resources used before API discovery completes.
10
+ const BUILTIN_PLURAL_TO_KIND: Record<string, string> = {
12
11
  pods: 'Pod',
13
12
  services: 'Service',
14
13
  deployments: 'Deployment',
@@ -16,11 +15,6 @@ const PLURAL_TO_KIND: Record<string, string> = {
16
15
  statefulsets: 'StatefulSet',
17
16
  replicasets: 'ReplicaSet',
18
17
  ingresses: 'Ingress',
19
- gateways: 'Gateway',
20
- httproutes: 'HTTPRoute',
21
- grpcroutes: 'GRPCRoute',
22
- tcproutes: 'TCPRoute',
23
- tlsroutes: 'TLSRoute',
24
18
  configmaps: 'ConfigMap',
25
19
  secrets: 'Secret',
26
20
  namespaces: 'Namespace',
@@ -33,24 +27,44 @@ const PLURAL_TO_KIND: Record<string, string> = {
33
27
  persistentvolumes: 'PersistentVolume',
34
28
  storageclasses: 'StorageClass',
35
29
  poddisruptionbudgets: 'PodDisruptionBudget',
36
- rollouts: 'Rollout',
37
- applications: 'Application',
38
- kustomizations: 'Kustomization',
39
- helmreleases: 'HelmRelease',
40
- gitrepositories: 'GitRepository',
41
- certificates: 'Certificate',
42
30
  roles: 'Role',
43
31
  clusterroles: 'ClusterRole',
44
32
  rolebindings: 'RoleBinding',
45
33
  clusterrolebindings: 'ClusterRoleBinding',
46
34
  serviceaccounts: 'ServiceAccount',
47
35
  networkpolicies: 'NetworkPolicy',
48
- verticalpodautoscalers: 'VerticalPodAutoscaler',
49
- virtualservices: 'VirtualService',
50
- destinationrules: 'DestinationRule',
51
- serviceentries: 'ServiceEntry',
52
- peerauthentications: 'PeerAuthentication',
53
- authorizationpolicies: 'AuthorizationPolicy',
36
+ }
37
+
38
+ // Dynamic map built from API discovery — populated by initNavigationMap().
39
+ // Once populated, this is the source of truth for all kind↔plural lookups.
40
+ let discoveredPluralToKind: Record<string, string> | null = null
41
+ let discoveredKindToPlural: Record<string, string> | null = null
42
+
43
+ /**
44
+ * Initialize navigation maps from discovered API resources.
45
+ * Call once when API resources are fetched. After this, kindToPlural/pluralToKind
46
+ * use the real cluster data instead of heuristics.
47
+ */
48
+ export function initNavigationMap(resources: APIResource[]) {
49
+ const p2k: Record<string, string> = { ...BUILTIN_PLURAL_TO_KIND }
50
+ const k2p: Record<string, string> = {}
51
+ for (const r of resources) {
52
+ const plural = r.name.toLowerCase()
53
+ p2k[plural] = r.kind
54
+ k2p[r.kind.toLowerCase()] = plural
55
+ }
56
+ discoveredPluralToKind = p2k
57
+ discoveredKindToPlural = k2p
58
+ }
59
+
60
+ /** Reset navigation maps to builtin-only state. For testing. */
61
+ export function resetNavigationMap() {
62
+ discoveredPluralToKind = null
63
+ discoveredKindToPlural = null
64
+ }
65
+
66
+ function getPluralToKind(): Record<string, string> {
67
+ return discoveredPluralToKind || BUILTIN_PLURAL_TO_KIND
54
68
  }
55
69
 
56
70
  /**
@@ -61,9 +75,15 @@ const PLURAL_TO_KIND: Record<string, string> = {
61
75
  */
62
76
  export function kindToPlural(kind: string): string {
63
77
  const kindLower = kind.toLowerCase()
78
+ const pluralToKindMap = getPluralToKind()
64
79
 
65
80
  // Already a known plural — return as-is to prevent double-pluralization
66
- if (kindLower in PLURAL_TO_KIND) return kindLower
81
+ if (kindLower in pluralToKindMap) return kindLower
82
+
83
+ // Lookup from discovered API resources (singular kind → plural name)
84
+ if (discoveredKindToPlural && kindLower in discoveredKindToPlural) {
85
+ return discoveredKindToPlural[kindLower]
86
+ }
67
87
 
68
88
  // Aliases: abbreviations or mappings to a different resource name
69
89
  const aliases: Record<string, string> = {
@@ -73,7 +93,7 @@ export function kindToPlural(kind: string): string {
73
93
  }
74
94
  if (aliases[kindLower]) return aliases[kindLower]
75
95
 
76
- // English pluralization rules (covers *Class→*classes, *Policy→*policies, *Repository→*repositories, etc.)
96
+ // Fallback: English pluralization rules (covers *Class→*classes, *Policy→*policies, *Repository→*repositories, etc.)
77
97
  if (kindLower.endsWith('s') || kindLower.endsWith('x') || kindLower.endsWith('ch') || kindLower.endsWith('sh')) {
78
98
  return kindLower + 'es'
79
99
  }
@@ -90,8 +110,9 @@ export function kindToPlural(kind: string): string {
90
110
  */
91
111
  export function pluralToKind(plural: string): string {
92
112
  const lower = plural.toLowerCase()
113
+ const pluralToKindMap = getPluralToKind()
93
114
 
94
- if (PLURAL_TO_KIND[lower]) return PLURAL_TO_KIND[lower]
115
+ if (pluralToKindMap[lower]) return pluralToKindMap[lower]
95
116
 
96
117
  // If it already looks like a singular PascalCase kind (starts with uppercase), return as-is
97
118
  if (plural[0] === plural[0].toUpperCase() && plural[0] !== plural[0].toLowerCase()) {