Skip to content

Commit 182bd37

Browse files
peppescgCopilot
andauthored
feat: configure custom registry (#719)
* feat: configure custom registry * chore: update openapi spec * refactor: registry type label * chore: rebase from main * Update renderer/src/common/mocks/fixtures/default_registry.ts Co-authored-by: Copilot <[email protected]> * fix: support only https remote registry * fix: remove http description --------- Co-authored-by: Copilot <[email protected]>
1 parent 8bbdc66 commit 182bd37

File tree

9 files changed

+773
-2
lines changed

9 files changed

+773
-2
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { UseFormReturn } from 'react-hook-form'
2+
import { Form } from '../../ui/form'
3+
import { RegistrySourceField } from './registry-source-field'
4+
import { Button } from '../../ui/button'
5+
import { RegistryTypeField } from './registry-type-field'
6+
import type { RegistryFormData } from './schema'
7+
8+
interface RegistryFormProps {
9+
form: UseFormReturn<RegistryFormData>
10+
onSubmit: (data: RegistryFormData) => void
11+
isLoading: boolean
12+
}
13+
14+
export function RegistryForm({ form, onSubmit, isLoading }: RegistryFormProps) {
15+
return (
16+
<Form {...form}>
17+
<form
18+
className="flex flex-col items-start gap-4"
19+
onSubmit={form.handleSubmit(onSubmit)}
20+
>
21+
<RegistryTypeField isPending={isLoading} form={form} />
22+
<RegistrySourceField isPending={isLoading} form={form} />
23+
<Button type="submit" disabled={isLoading}>
24+
{isLoading ? 'Saving...' : 'Save'}
25+
</Button>
26+
</form>
27+
</Form>
28+
)
29+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { UseFormReturn } from 'react-hook-form'
2+
import { Button } from '../../ui/button'
3+
import {
4+
FormField,
5+
FormItem,
6+
FormLabel,
7+
FormDescription,
8+
FormControl,
9+
FormMessage,
10+
} from '../../ui/form'
11+
import { Input } from '../../ui/input'
12+
import type { RegistryFormData } from './schema'
13+
14+
export function RegistrySourceField({
15+
isPending,
16+
form,
17+
}: {
18+
isPending: boolean
19+
form: UseFormReturn<RegistryFormData>
20+
}) {
21+
const registryType = form.watch('type')
22+
23+
if (registryType === 'default') {
24+
return null
25+
}
26+
27+
return (
28+
<FormField
29+
control={form.control}
30+
name="source"
31+
render={({ field }) => {
32+
const isRemote = registryType === 'url'
33+
34+
return (
35+
<FormItem className="w-full">
36+
<FormLabel required>
37+
{isRemote ? 'Registry URL' : 'Registry File Path'}
38+
</FormLabel>
39+
<FormDescription>
40+
{isRemote ? (
41+
<>
42+
Provide the HTTPS url of a remote registry (
43+
<Button
44+
asChild
45+
variant="link"
46+
size="sm"
47+
className="h-auto p-0"
48+
>
49+
<a
50+
href="https://raw.githubusercontent.com/stacklok/toolhive/refs/heads/main/pkg/registry/data/registry.json"
51+
target="_blank"
52+
rel="noopener noreferrer"
53+
>
54+
official ToolHive registry
55+
</a>
56+
</Button>
57+
).
58+
</>
59+
) : (
60+
'Provide the absolute path to a local registry JSON file on your system.'
61+
)}
62+
</FormDescription>
63+
<FormControl>
64+
<Input
65+
placeholder={
66+
isRemote
67+
? 'https://domain.com/registry.json'
68+
: '/path/to/registry.json'
69+
}
70+
{...field}
71+
disabled={isPending}
72+
/>
73+
</FormControl>
74+
<FormMessage />
75+
</FormItem>
76+
)
77+
}}
78+
/>
79+
)
80+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
import { useForm } from 'react-hook-form'
3+
4+
import {
5+
getApiV1BetaRegistryByName,
6+
putApiV1BetaRegistryByName,
7+
} from '@api/sdk.gen'
8+
import { zodV4Resolver } from '@/common/lib/zod-v4-resolver'
9+
import { useToastMutation } from '@/common/hooks/use-toast-mutation'
10+
import { useEffect } from 'react'
11+
import { registryFormSchema, type RegistryFormData } from './schema'
12+
import { RegistryForm } from './registry-form'
13+
import { delay } from '../../../../../../utils/delay'
14+
15+
export function RegistryTab() {
16+
const { isPending: isPendingRegistry, data: registry } = useQuery({
17+
queryKey: ['registry'],
18+
queryFn: () =>
19+
getApiV1BetaRegistryByName({
20+
path: {
21+
name: 'default',
22+
},
23+
}),
24+
})
25+
const registryData = registry?.data
26+
27+
const { mutateAsync: updateRegistry, isPending: isPendingUpdate } =
28+
useToastMutation({
29+
mutationFn: async (data: RegistryFormData) => {
30+
const body =
31+
data.type === 'url'
32+
? { url: data.source?.trim() }
33+
: { local_path: data.source?.trim() }
34+
await delay(500)
35+
return putApiV1BetaRegistryByName({
36+
path: {
37+
name: 'default',
38+
},
39+
body,
40+
})
41+
},
42+
successMsg: 'Registry updated successfully',
43+
errorMsg: 'Failed to update registry',
44+
loadingMsg: 'Updating registry...',
45+
})
46+
const isLoading = isPendingRegistry || isPendingUpdate
47+
48+
const form = useForm<RegistryFormData>({
49+
resolver: zodV4Resolver(registryFormSchema),
50+
defaultValues: {
51+
type: (registryData?.type as RegistryFormData['type']) ?? 'default',
52+
source: registryData?.source ?? '',
53+
},
54+
mode: 'onChange',
55+
reValidateMode: 'onChange',
56+
})
57+
58+
useEffect(() => {
59+
form.setValue(
60+
'type',
61+
(registryData?.type as RegistryFormData['type']) ?? 'default'
62+
)
63+
form.setValue('source', registryData?.source ?? '')
64+
form.trigger(['type', 'source'])
65+
}, [form, registryData])
66+
67+
const onSubmit = async (data: RegistryFormData) => {
68+
if (data.type === 'default') {
69+
await updateRegistry({ type: 'default' })
70+
form.setValue('type', 'default')
71+
form.setValue('source', '')
72+
form.trigger(['type', 'source'])
73+
} else {
74+
await updateRegistry(data)
75+
}
76+
}
77+
78+
return (
79+
<div className="space-y-6">
80+
<div className="space-y-4">
81+
<h2 className="text-lg font-semibold">Registry</h2>
82+
</div>
83+
<div className="space-y-4">
84+
<RegistryForm form={form} onSubmit={onSubmit} isLoading={isLoading} />
85+
</div>
86+
</div>
87+
)
88+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {
2+
Select,
3+
SelectTrigger,
4+
SelectValue,
5+
SelectContent,
6+
SelectItem,
7+
} from '../../ui/select'
8+
import type { UseFormReturn } from 'react-hook-form'
9+
import {
10+
FormField,
11+
FormItem,
12+
FormLabel,
13+
FormDescription,
14+
FormControl,
15+
FormMessage,
16+
} from '../../ui/form'
17+
import type { RegistryFormData } from './schema'
18+
19+
export function RegistryTypeField({
20+
isPending,
21+
form,
22+
}: {
23+
isPending: boolean
24+
form: UseFormReturn<RegistryFormData>
25+
}) {
26+
const { type, source } = form.formState.defaultValues ?? {
27+
type: 'default',
28+
source: '',
29+
}
30+
31+
return (
32+
<FormField
33+
control={form.control}
34+
name="type"
35+
render={({ field }) => (
36+
<FormItem className="w-full">
37+
<FormLabel required>Registry Type</FormLabel>
38+
<FormDescription>
39+
Choose between ToolHive default registry, a custom remote registry
40+
HTTPS url, or a custom local registry file.
41+
</FormDescription>
42+
<Select
43+
onValueChange={(value) => {
44+
field.onChange(value)
45+
46+
if (value === type) {
47+
form.setValue('source', source)
48+
} else {
49+
form.setValue('source', '')
50+
}
51+
52+
form.trigger('source')
53+
}}
54+
value={field.value}
55+
>
56+
<FormControl>
57+
<SelectTrigger disabled={isPending}>
58+
<SelectValue placeholder="Select registry type" />
59+
</SelectTrigger>
60+
</FormControl>
61+
<SelectContent>
62+
<SelectItem value="default">Default Registry</SelectItem>
63+
<SelectItem value="url">Remote Registry (URL)</SelectItem>
64+
<SelectItem value="file">Local Registry (File Path)</SelectItem>
65+
</SelectContent>
66+
</Select>
67+
<FormMessage />
68+
</FormItem>
69+
)}
70+
/>
71+
)
72+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { z } from 'zod/v4'
2+
3+
export const registryFormSchema = z
4+
.object({
5+
type: z.enum(['file', 'url', 'default']).default('default'),
6+
source: z.string().optional(),
7+
})
8+
.refine(
9+
(data) => {
10+
if (data.type === 'url' || data.type === 'file') {
11+
return data.source && data.source.trim().length > 0
12+
}
13+
return true
14+
},
15+
{
16+
message: 'Registry URL or file path is required',
17+
path: ['source'],
18+
}
19+
)
20+
.refine(
21+
(data) => {
22+
if (data.type === 'url' || data.type === 'file') {
23+
return data.source?.endsWith('.json')
24+
}
25+
return true
26+
},
27+
{
28+
message: 'Registry must be a .json file',
29+
path: ['source'],
30+
}
31+
)
32+
.refine(
33+
(data) => {
34+
if (data.type === 'url' && data.source) {
35+
if (!data.source.startsWith('https://')) {
36+
return false
37+
}
38+
try {
39+
new URL(data.source)
40+
return true
41+
} catch {
42+
return false
43+
}
44+
}
45+
return true
46+
},
47+
{
48+
message: 'Remote registry must be a valid HTTPS URL',
49+
path: ['source'],
50+
}
51+
)
52+
53+
export type RegistryFormData = z.infer<typeof registryFormSchema>

0 commit comments

Comments
 (0)