Dev Tools improvements (#247)

* Refactor environment variables UI and update validation logic

Enhanced the environment variables page layout for better responsiveness and structure by introducing new components and styles. Added `EnvListDisplay` for grouped variable display and adjusted several UI elements for clarity and consistency. Updated `NEXT_PUBLIC_SENTRY_ENVIRONMENT` validation to make it optional, aligning with updated requirements.

* Add environment variable validation and enhance page headers

Introduces robust validation for environment variables, ensuring correctness and contextual dependency checks. Updates page headers with titles and detailed descriptions for better usability and clarity.

* Refactor variable page layout and improve code readability

Rearranged className attributes in JSX for consistency and readability. Refactored map and enum validation logic for better formatting and maintainability. Applied minor corrections to types and formatting in other components.

* Refactor styles and simplify component logic

Updated badge variants to streamline styles and removed redundant hover states. Simplified logic in email page by extracting breadcrumb values and optimizing title rendering. Adjusted environment variables manager layout for cleaner rendering and removed unnecessary elements.

* Add real-time translation updates with RxJS and UI improvements

Introduced a Subject with debounce mechanism for handling translation updates, enhancing real-time editing in the translations comparison module. Improved UI components, including conditional rendering, better input handling, and layout adjustments. Implemented a server action for updating translations and streamlined type definitions in the emails page.

* Enhance environment variable copying functionality and improve user feedback

Updated the environment variables manager to copy structured environment variable data to the clipboard, improving usability. Adjusted toast notifications to provide clearer success and error messages during the copy process. Additionally, fixed a minor issue in the translations comparison component by ensuring proper filtering of keys based on the search input.

* Add AI translation functionality and update dependencies

Implemented a new action for translating missing strings using AI, enhancing the translations comparison component. Introduced a loading state during translation and improved error handling for translation updates. Updated package dependencies, including the addition of '@ai-sdk/openai' and 'ai' to facilitate AI-driven translations. Enhanced UI components for better user experience and streamlined translation management.
This commit is contained in:
Giancarlo Buomprisco
2025-04-29 09:11:12 +07:00
committed by GitHub
parent cea46b06a1
commit 76bfeddd32
20 changed files with 1730 additions and 679 deletions

View File

@@ -34,24 +34,23 @@ export default async function EmailPage(props: EmailPageProps) {
const template = await loadEmailTemplate(id);
const emailSettings = await getEmailSettings(mode);
const values: Record<string, string> = {
emails: 'Emails',
'invite-email': 'Invite Email',
'account-delete-email': 'Account Delete Email',
'confirm-email': 'Confirm Email',
'change-email-address-email': 'Change Email Address Email',
'reset-password-email': 'Reset Password Email',
'magic-link-email': 'Magic Link Email',
'otp-email': 'OTP Email',
};
return (
<Page style={'custom'}>
<PageHeader
displaySidebarTrigger={false}
description={
<AppBreadcrumbs
values={{
emails: 'Emails',
'invite-email': 'Invite Email',
'account-delete-email': 'Account Delete Email',
'confirm-email': 'Confirm Email',
'change-email-address-email': 'Change Email Address Email',
'reset-password-email': 'Reset Password Email',
'magic-link-email': 'Magic Link Email',
'otp-email': 'OTP Email',
}}
/>
}
title={values[id]}
description={<AppBreadcrumbs values={values} />}
>
<EnvModeSelector mode={mode} />
</PageHeader>

View File

@@ -15,7 +15,11 @@ export const metadata = {
export default async function EmailsPage() {
return (
<Page style={'custom'}>
<PageHeader displaySidebarTrigger={false} description="Emails" />
<PageHeader
displaySidebarTrigger={false}
title="Emails"
description={'Manage your application Email templates'}
/>
<PageBody className={'gap-y-8'}>
<div className={'flex flex-col space-y-4'}>

View File

@@ -31,25 +31,32 @@ class ConnectivityService {
};
}
const response = await fetch(`${url}/auth/v1/health`, {
headers: {
apikey: anonKey,
Authorization: `Bearer ${anonKey}`,
},
});
try {
const response = await fetch(`${url}/auth/v1/health`, {
headers: {
apikey: anonKey,
Authorization: `Bearer ${anonKey}`,
},
});
if (!response.ok) {
if (!response.ok) {
return {
status: 'error' as const,
message:
'Failed to connect to Supabase. The Supabase Anon Key or URL is not valid.',
};
}
return {
status: 'success' as const,
message: 'Connected to Supabase',
};
} catch (error) {
return {
status: 'error' as const,
message:
'Failed to connect to Supabase. The Supabase Anon Key or URL is not valid.',
message: `Failed to connect to Supabase. ${error}`,
};
}
return {
status: 'success' as const,
message: 'Connected to Supabase',
};
}
async checkSupabaseAdminConnectivity() {
@@ -85,35 +92,42 @@ class ConnectivityService {
};
}
const response = await fetch(endpoint, {
headers: {
apikey,
Authorization: `Bearer ${adminKey}`,
},
});
try {
const response = await fetch(endpoint, {
headers: {
apikey,
Authorization: `Bearer ${adminKey}`,
},
});
if (!response.ok) {
if (!response.ok) {
return {
status: 'error' as const,
message:
'Failed to connect to Supabase Admin. The Supabase Service Role Key is not valid.',
};
}
const data = await response.json();
if (data.length === 0) {
return {
status: 'error' as const,
message:
'No accounts found in Supabase Admin. The data may not be seeded. Please run `pnpm run supabase:web:reset` to reset the database.',
};
}
return {
status: 'success' as const,
message: 'Connected to Supabase Admin',
};
} catch (error) {
return {
status: 'error' as const,
message:
'Failed to connect to Supabase Admin. The Supabase Service Role Key is not valid.',
message: `Failed to connect to Supabase Admin. ${error}`,
};
}
const data = await response.json();
if (data.length === 0) {
return {
status: 'error' as const,
message:
'No accounts found in Supabase Admin. The data may not be seeded. Please run `pnpm run supabase:web:reset` to reset the database.',
};
}
return {
status: 'success' as const,
message: 'Connected to Supabase Admin',
};
}
async checkStripeWebhookEndpoints() {

View File

@@ -26,6 +26,7 @@ export default async function DashboardPage(props: DashboardPageProps) {
<Page style={'custom'}>
<PageHeader
displaySidebarTrigger={false}
title={'Dev Tool'}
description={'Check the status of your Supabase and Stripe services'}
>
<EnvModeSelector mode={mode} />

View File

@@ -1,8 +1,9 @@
'use client';
import { useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronDownIcon } from 'lucide-react';
import { ChevronDownIcon, Loader2Icon } from 'lucide-react';
import { Subject, debounceTime } from 'rxjs';
import { Button } from '@kit/ui/button';
import {
@@ -11,6 +12,7 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import {
Select,
@@ -19,6 +21,7 @@ import {
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
import { toast } from '@kit/ui/sonner';
import {
Table,
TableBody,
@@ -30,6 +33,10 @@ import {
import { cn } from '@kit/ui/utils';
import { defaultI18nNamespaces } from '../../../../web/lib/i18n/i18n.settings';
import {
translateWithAIAction,
updateTranslationAction,
} from '../lib/server-actions';
import type { TranslationData, Translations } from '../lib/translations-loader';
function flattenTranslations(
@@ -58,31 +65,37 @@ export function TranslationsComparison({
translations: Translations;
}) {
const [search, setSearch] = useState('');
const [selectedLocales, setSelectedLocales] = useState<Set<string>>();
const [isTranslating, setIsTranslating] = useState(false);
const [selectedNamespace, setSelectedNamespace] = useState(
defaultI18nNamespaces[0] as string,
);
// Create RxJS Subject for handling translation updates
const subject$ = useMemo(
() =>
new Subject<{
locale: string;
namespace: string;
key: string;
value: string;
}>(),
[],
);
const locales = Object.keys(translations);
if (locales.length === 0) {
return <div>No translations found</div>;
}
const baseLocale = locales[0]!;
// Initialize selected locales if not set
if (!selectedLocales) {
setSelectedLocales(new Set(locales));
return null;
}
const [selectedLocales, setSelectedLocales] = useState<Set<string>>(
new Set(locales),
);
// Flatten translations for the selected namespace
const flattenedTranslations: FlattenedTranslations = {};
for (const locale of locales) {
const namespaceData = translations[locale]?.[selectedNamespace];
if (namespaceData) {
flattenedTranslations[locale] = flattenTranslations(namespaceData);
} else {
@@ -105,14 +118,6 @@ export function TranslationsComparison({
selectedLocales.has(locale),
);
const copyTranslation = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch (error) {
console.error('Failed to copy text:', error);
}
};
const toggleLocale = (locale: string) => {
const newSelectedLocales = new Set(selectedLocales);
@@ -127,58 +132,152 @@ export function TranslationsComparison({
setSelectedLocales(newSelectedLocales);
};
const handleTranslateWithAI = useCallback(async () => {
try {
setIsTranslating(true);
// Get missing translations for the selected namespace
const missingTranslations: Record<string, string> = {};
const baseTranslations = flattenedTranslations[baseLocale] ?? {};
for (const locale of visibleLocales) {
if (locale === baseLocale) continue;
const localeTranslations = flattenedTranslations[locale] ?? {};
for (const [key, value] of Object.entries(baseTranslations)) {
if (!localeTranslations[key]) {
missingTranslations[key] = value;
}
}
if (Object.keys(missingTranslations).length > 0) {
await translateWithAIAction({
sourceLocale: baseLocale,
targetLocale: locale,
namespace: selectedNamespace,
translations: missingTranslations,
});
toast.success(`Translated missing strings to ${locale}`);
}
}
} catch (error) {
toast.error('Failed to translate: ' + (error as Error).message);
} finally {
setIsTranslating(false);
}
}, [flattenedTranslations, baseLocale, visibleLocales, selectedNamespace]);
// Calculate if there are any missing translations
const hasMissingTranslations = useMemo(() => {
if (!flattenedTranslations || !baseLocale || !visibleLocales) return false;
const baseTranslations = flattenedTranslations[baseLocale] ?? {};
return visibleLocales.some((locale) => {
if (locale === baseLocale) return false;
const localeTranslations = flattenedTranslations[locale] ?? {};
return Object.keys(baseTranslations).some(
(key) => !localeTranslations[key],
);
});
}, [flattenedTranslations, baseLocale, visibleLocales]);
// Set up subscription to handle debounced updates
useEffect(() => {
const subscription = subject$.pipe(debounceTime(500)).subscribe((props) => {
updateTranslationAction(props)
.then(() => {
toast.success(`Updated translation for ${props.key}`);
})
.catch((err) => {
toast.error(`Failed to update translation: ${err.message}`);
});
});
return () => subscription.unsubscribe();
}, [subject$]);
if (locales.length === 0) {
return <div>No translations found</div>;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<Input
type="search"
placeholder="Search translations..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm"
/>
<div className="space-y-4 pb-24">
<div className="flex w-full items-center">
<div className="flex w-full items-center justify-between gap-2.5">
<div className="flex items-center gap-2.5">
<Input
type="search"
placeholder="Search translations..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto">
Select Languages
<ChevronDownIcon className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<If condition={locales.length > 1}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto">
Select Languages
<ChevronDownIcon className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
{locales.map((locale) => (
<DropdownMenuCheckboxItem
key={locale}
checked={selectedLocales.has(locale)}
onCheckedChange={() => toggleLocale(locale)}
disabled={
selectedLocales.size === 1 && selectedLocales.has(locale)
}
>
{locale}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuContent align="end" className="w-[200px]">
{locales.map((locale) => (
<DropdownMenuCheckboxItem
key={locale}
checked={selectedLocales.has(locale)}
onCheckedChange={() => toggleLocale(locale)}
disabled={
selectedLocales.size === 1 &&
selectedLocales.has(locale)
}
>
{locale}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</If>
<Select
value={selectedNamespace}
onValueChange={setSelectedNamespace}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select namespace" />
</SelectTrigger>
<Select
value={selectedNamespace}
onValueChange={setSelectedNamespace}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select namespace" />
</SelectTrigger>
<SelectContent>
{defaultI18nNamespaces.map((namespace: string) => (
<SelectItem key={namespace} value={namespace}>
{namespace}
</SelectItem>
))}
</SelectContent>
</Select>
<SelectContent>
{defaultI18nNamespaces.map((namespace: string) => (
<SelectItem key={namespace} value={namespace}>
{namespace}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Button
onClick={handleTranslateWithAI}
disabled={isTranslating || !hasMissingTranslations}
>
{isTranslating ? (
<>
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
Translating...
</>
) : (
'Translate missing with AI'
)}
</Button>
</div>
</div>
</div>
@@ -196,7 +295,7 @@ export function TranslationsComparison({
<TableBody>
{filteredKeys.map((key) => (
<TableRow key={key}>
<TableCell className="font-mono text-sm">
<TableCell width={350} className="text-sm">
<div className="flex items-center justify-between">
<span>{key}</span>
</div>
@@ -222,11 +321,33 @@ export function TranslationsComparison({
})}
>
<div className="flex items-center justify-between">
<span>
{value || (
<span className="text-destructive">Missing</span>
)}
</span>
<Input
defaultValue={value || ''}
onChange={(e) => {
const value = e.target.value.trim();
if (value === '') {
toast.error('Translation cannot be empty');
return;
}
if (value === baseValue) {
toast.info('Translation is the same as base');
return;
}
subject$.next({
locale,
namespace: selectedNamespace,
key,
value,
});
}}
className="w-full font-mono text-sm"
placeholder={isMissing ? 'Missing translation' : ''}
/>
</div>
</TableCell>
);

View File

@@ -0,0 +1,161 @@
'use server';
import { revalidatePath } from 'next/cache';
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:url';
import { z } from 'zod';
import { getLogger } from '@kit/shared/logger';
const Schema = z.object({
locale: z.string().min(1),
namespace: z.string().min(1),
key: z.string().min(1),
value: z.string(),
});
const TranslateSchema = z.object({
sourceLocale: z.string(),
targetLocale: z.string(),
namespace: z.string(),
translations: z.record(z.string(), z.string()),
});
/**
* Update a translation value in the specified locale and namespace.
* @param props
*/
export async function updateTranslationAction(props: z.infer<typeof Schema>) {
// Validate the input
const { locale, namespace, key, value } = Schema.parse(props);
const root = resolve(process.cwd(), '..');
const filePath = `${root}apps/web/public/locales/${locale}/${namespace}.json`;
try {
// Read the current translations file
const translationsFile = readFileSync(filePath, 'utf-8');
const translations = JSON.parse(translationsFile) as Record<string, any>;
// Update the nested key value
const keys = key.split('.') as string[];
let current = translations;
// Navigate through nested objects until the second-to-last key
for (let i = 0; i < keys.length - 1; i++) {
const currentKey = keys[i] as string;
if (!current[currentKey]) {
current[currentKey] = {};
}
current = current[currentKey];
}
// Set the value at the final key
const finalKey = keys[keys.length - 1] as string;
current[finalKey] = value;
// Write the updated translations back to the file
writeFileSync(filePath, JSON.stringify(translations, null, 2), 'utf-8');
revalidatePath(`/translations`);
return { success: true };
} catch (error) {
console.error('Failed to update translation:', error);
throw new Error('Failed to update translation');
}
}
export const translateWithAIAction = async (
data: z.infer<typeof TranslateSchema>,
) => {
const logger = await getLogger();
z.string().min(1).parse(process.env.OPENAI_API_KEY);
try {
const { sourceLocale, targetLocale, namespace, translations } =
TranslateSchema.parse(data);
// if the path does not exist, create it using an empty object
const root = resolve(process.cwd(), '..');
const folderPath = `${root}apps/web/public/locales/${targetLocale}`;
if (!existsSync(folderPath)) {
// create the directory if it doesn't exist
mkdirSync(folderPath, { recursive: true });
}
const filePath = `${folderPath}/${namespace}.json`;
if (!existsSync(filePath)) {
// create the file if it doesn't exist
writeFileSync(filePath, JSON.stringify({}, null, 2), 'utf-8');
}
const results: Record<string, string> = {};
// Process translations in batches of 5 for efficiency
const entries = Object.entries(translations);
const batches = [];
for (let i = 0; i < entries.length; i += 5) {
batches.push(entries.slice(i, i + 5));
}
for (const batch of batches) {
const batchPromises = batch.map(async ([key, value]) => {
const prompt = `Translate the following text from ${sourceLocale} to ${targetLocale}. Maintain any placeholders (like {name} or %{count}) and HTML tags. Only return the translated text, nothing else.
Original text: ${value}`;
const MODEL_NAME = process.env.LLM_MODEL_NAME ?? 'gpt-4o-mini';
const model = openai(MODEL_NAME);
const { text } = await generateText({
model,
prompt,
temperature: 0.3,
maxTokens: 200,
});
return [key, text.trim()] as [string, string];
});
const batchResults = await Promise.all(batchPromises);
for (const [key, translation] of batchResults) {
results[key] = translation;
}
}
// Update each translation
for (const [key, translation] of Object.entries(results)) {
await updateTranslationAction({
locale: targetLocale,
namespace,
key,
value: translation,
});
}
logger.info('AI translation completed', {
sourceLocale,
targetLocale,
namespace,
count: Object.keys(results).length,
});
revalidatePath('/translations');
return { success: true, translations: results };
} catch (error) {
logger.error('AI translation failed', { error });
throw error;
}
};

View File

@@ -18,12 +18,9 @@ export default async function TranslationsPage() {
<Page style={'custom'}>
<PageHeader
displaySidebarTrigger={false}
title={'Translations'}
description={
<AppBreadcrumbs
values={{
translations: 'Translations',
}}
/>
'Compare translations across different languages. Ensure consistency and accuracy in your translations.'
}
/>

View File

@@ -0,0 +1,161 @@
import { useCallback } from 'react';
import { Input } from '@kit/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
import { Switch } from '@kit/ui/switch';
import { Textarea } from '@kit/ui/textarea';
type ModelType =
| 'string'
| 'longString'
| 'number'
| 'boolean'
| 'enum'
| 'url'
| 'email';
interface DynamicFormInputProps {
type: ModelType;
value: string;
name: string;
onChange: (props: { name: string; value: string }) => void;
placeholder?: string;
enumValues?: Array<string | null>;
className?: string;
}
export function DynamicFormInput({
type,
value,
name,
onChange,
placeholder,
enumValues = [],
className,
}: DynamicFormInputProps) {
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
onChange({
name,
value: e.target.value,
});
},
[name, onChange],
);
const handleSwitchChange = useCallback(
(checked: boolean) => {
onChange({
name,
value: checked ? 'true' : 'false',
});
},
[name, onChange],
);
const handleSelectChange = useCallback(
(value: string) => {
onChange({
name,
value: value === '' ? 'none' : value,
});
},
[name, onChange],
);
switch (type) {
case 'longString':
return (
<Textarea
defaultValue={value}
onChange={handleInputChange}
placeholder={placeholder}
className={className}
/>
);
case 'number':
return (
<Input
type="number"
defaultValue={value}
onChange={handleInputChange}
placeholder={placeholder}
className={className}
/>
);
case 'boolean':
return (
<label className="flex items-center gap-x-2">
<Switch
checked={value === 'true'}
onCheckedChange={handleSwitchChange}
/>
<span className={'text-sm uppercase'}>
{value === 'true' ? 'True' : 'False'}
</span>
</label>
);
case 'url':
return (
<Input
type="url"
defaultValue={value}
onChange={handleInputChange}
placeholder={placeholder}
className={className}
/>
);
case 'email':
return (
<Input
type="email"
defaultValue={value}
onChange={handleInputChange}
placeholder={placeholder}
className={className}
/>
);
case 'enum':
return (
<Select
value={value === 'none' ? '' : value}
onValueChange={handleSelectChange}
>
<SelectTrigger className={className}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{enumValues.map((enumValue) => (
<SelectItem key={enumValue} value={enumValue as string}>
{enumValue}
</SelectItem>
))}
</SelectContent>
</Select>
);
default:
return (
<Input
type="text"
defaultValue={value}
onChange={handleInputChange}
placeholder={placeholder}
className={className}
/>
);
}
}

View File

@@ -152,11 +152,18 @@ export function processEnvDefinitions(
if (!variableMap[variable.key]) {
variableMap[variable.key] = {
key: variable.key,
isVisible: true,
definitions: [],
effectiveValue: variable.value,
effectiveSource: variable.source,
isOverridden: false,
category: model ? model.category : 'Custom',
validation: {
success: true,
error: {
issues: [],
},
},
};
}
@@ -212,6 +219,231 @@ export function processEnvDefinitions(
}
}
// after computing the effective values, we can check for errors
for (const key in variableMap) {
const model = envVariables.find((v) => key === v.name);
const varState = variableMap[key];
if (!varState) {
continue;
}
let validation: {
success: boolean;
error: {
issues: string[];
};
} = { success: true, error: { issues: [] } };
if (model) {
const allVariables = Object.values(variableMap).reduce(
(acc, variable) => {
return {
...acc,
[variable.key]: variable.effectiveValue,
};
},
{} as Record<string, string>,
);
// First check if it's required but missing
if (model.required && !varState.effectiveValue) {
validation = {
success: false,
error: {
issues: [
`This variable is required but missing from your environment files`,
],
},
};
} else if (model.contextualValidation) {
// Then check contextual validation
const dependenciesMet = model.contextualValidation.dependencies.some(
(dep) => {
const dependencyValue = allVariables[dep.variable] ?? '';
return dep.condition(dependencyValue, allVariables);
},
);
if (dependenciesMet) {
// Only check for missing value or run validation if dependencies are met
if (!varState.effectiveValue) {
const dependencyErrors = model.contextualValidation.dependencies
.map((dep) => {
const dependencyValue = allVariables[dep.variable] ?? '';
const shouldValidate = dep.condition(
dependencyValue,
allVariables,
);
if (shouldValidate) {
const { success } = model.contextualValidation!.validate({
value: varState.effectiveValue,
variables: allVariables,
mode,
});
if (success) {
return null;
}
return dep.message;
}
return null;
})
.filter((message): message is string => message !== null);
validation = {
success: dependencyErrors.length === 0,
error: {
issues: dependencyErrors
.map((message) => message)
.filter((message) => !!message),
},
};
} else {
// If we have a value and dependencies are met, run contextual validation
const result = model.contextualValidation.validate({
value: varState.effectiveValue,
variables: allVariables,
mode,
});
if (!result.success) {
validation = {
success: false,
error: {
issues: result.error.issues
.map((issue) => issue.message)
.filter((message) => !!message),
},
};
}
}
}
} else if (model.validate && varState.effectiveValue) {
// Only run regular validation if:
// 1. There's no contextual validation
// 2. There's a value to validate
const result = model.validate({
value: varState.effectiveValue,
variables: allVariables,
mode,
});
if (!result.success) {
validation = {
success: false,
error: {
issues: result.error.issues
.map((issue) => issue.message)
.filter((message) => !!message),
},
};
}
}
}
varState.validation = validation;
}
// Final pass: Validate missing variables that are marked as required
// or as having contextual validation
for (const model of envVariables) {
// If the variable exists in appState, use that
const existingVar = variableMap[model.name];
if (existingVar) {
// If the variable is already in the map, skip it
continue;
}
if (model.required || model.contextualValidation) {
if (model.contextualValidation) {
const allVariables = Object.values(variableMap).reduce(
(acc, variable) => {
return {
...acc,
[variable.key]: variable.effectiveValue,
};
},
{} as Record<string, string>,
);
const errors =
model?.contextualValidation?.dependencies
.map((dep) => {
const dependencyValue = allVariables[dep.variable] ?? '';
const shouldValidate = dep.condition(
dependencyValue,
allVariables,
);
if (!shouldValidate) {
return [];
}
const effectiveValue = allVariables[dep.variable] ?? '';
const validation = model.contextualValidation!.validate({
value: effectiveValue,
variables: allVariables,
mode,
});
if (validation) {
return [dep.message];
}
return [];
})
.flat() ?? ([] as string[]);
if (errors.length === 0) {
continue;
} else {
variableMap[model.name] = {
key: model.name,
effectiveValue: '',
effectiveSource: 'MISSING',
isVisible: true,
category: model.category,
isOverridden: false,
definitions: [],
validation: {
success: false,
error: {
issues: errors.map((error) => error),
},
},
};
}
}
// If it doesn't exist but is required or has contextual validation, create an empty state
variableMap[model.name] = {
key: model.name,
effectiveValue: '',
effectiveSource: 'MISSING',
isVisible: true,
category: model.category,
isOverridden: false,
definitions: [],
validation: {
success: false,
error: {
issues: [
`This variable is required but missing from your environment files`,
],
},
},
};
}
}
return {
appName: envInfo.appName,
filePath: envInfo.filePath,
@@ -227,11 +459,6 @@ export async function getEnvState(
return envInfos.map((info) => processEnvDefinitions(info, options.mode));
}
// Utility function to get list of env files for current mode
export function getEnvFilesForMode(mode: EnvMode): string[] {
return ENV_FILE_PRECEDENCE[mode];
}
export async function getVariable(key: string, mode: EnvMode) {
// Get the processed environment state for all apps (you can limit to 'web' via options)
const envStates = await getEnvState({ mode, apps: ['web'] });

View File

@@ -1,34 +1,51 @@
import { EnvMode } from '@/app/variables/lib/types';
import { z } from 'zod';
type DependencyRule = {
variable: string;
condition: (value: string, variables: Record<string, string>) => boolean;
message: string;
};
type ModelType =
| 'string'
| 'longString'
| 'number'
| 'boolean'
| 'enum'
| 'url'
| 'email';
type ContextualValidation = {
dependencies: DependencyRule[];
validate: (props: {
value: string;
variables: Record<string, string>;
mode: EnvMode;
}) => z.SafeParseReturnType<unknown, unknown>;
};
type Values = Array<string | null>;
export type EnvVariableModel = {
name: string;
description: string;
hint?: string;
secret?: boolean;
required?: boolean;
type?: ModelType;
values?: Values;
category: string;
test?: (value: string) => Promise<boolean>;
validate?: (props: {
required?: boolean;
validate?: ({
value,
variables,
mode,
}: {
value: string;
variables: Record<string, string>;
mode: EnvMode;
}) => z.SafeParseReturnType<unknown, unknown>;
contextualValidation?: ContextualValidation;
contextualValidation?: {
dependencies: Array<{
variable: string;
condition: (value: string, variables: Record<string, string>) => boolean;
message: string;
}>;
validate: ({
value,
variables,
mode,
}: {
value: string;
variables: Record<string, string>;
mode: EnvMode;
}) => z.SafeParseReturnType<unknown, unknown>;
};
};
export const envVariables: EnvVariableModel[] = [
@@ -38,6 +55,8 @@ export const envVariables: EnvVariableModel[] = [
'The URL of your site, used for generating absolute URLs. Must include the protocol.',
category: 'Site Configuration',
required: true,
type: 'url',
hint: `Ex. https://example.com`,
validate: ({ value, mode }) => {
if (mode === 'development') {
return z
@@ -65,7 +84,9 @@ export const envVariables: EnvVariableModel[] = [
description:
"Your product's name, used consistently across the application interface.",
category: 'Site Configuration',
hint: `Ex. "My Product"`,
required: true,
type: 'string',
validate: ({ value }) => {
return z
.string()
@@ -82,6 +103,8 @@ export const envVariables: EnvVariableModel[] = [
"The site's title tag content, crucial for SEO and browser display.",
category: 'Site Configuration',
required: true,
hint: `Ex. "My Product, the best product ever"`,
type: 'string',
validate: ({ value }) => {
return z
.string()
@@ -94,6 +117,7 @@ export const envVariables: EnvVariableModel[] = [
},
{
name: 'NEXT_PUBLIC_SITE_DESCRIPTION',
type: 'longString',
description:
"Your site's meta description, important for SEO optimization.",
category: 'Site Configuration',
@@ -111,8 +135,10 @@ export const envVariables: EnvVariableModel[] = [
},
{
name: 'NEXT_PUBLIC_DEFAULT_LOCALE',
type: 'string',
description: 'Sets the default language for your application.',
category: 'Localization',
hint: `Ex. "en"`,
validate: ({ value }) => {
return z
.string()
@@ -128,6 +154,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_AUTH_PASSWORD',
description: 'Enables or disables password-based authentication.',
category: 'Authentication',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -136,6 +163,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_AUTH_MAGIC_LINK',
description: 'Enables or disables magic link authentication.',
category: 'Authentication',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -144,6 +172,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_CAPTCHA_SITE_KEY',
description: 'Your Cloudflare Captcha site key for form protection.',
category: 'Security',
type: 'string',
validate: ({ value }) => {
return z.string().optional().safeParse(value);
},
@@ -154,6 +183,7 @@ export const envVariables: EnvVariableModel[] = [
'Your Cloudflare Captcha secret token for backend verification.',
category: 'Security',
secret: true,
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -191,6 +221,8 @@ export const envVariables: EnvVariableModel[] = [
description:
'Controls user navigation layout. Options: sidebar, header, or custom.',
category: 'Navigation',
type: 'enum',
values: ['sidebar', 'header', 'custom'],
validate: ({ value }) => {
return z
.enum(['sidebar', 'header', 'custom'])
@@ -202,6 +234,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_HOME_SIDEBAR_COLLAPSED',
description: 'Sets the default state of the home sidebar.',
category: 'Navigation',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -211,6 +244,8 @@ export const envVariables: EnvVariableModel[] = [
description:
'Controls team navigation layout. Options: sidebar, header, or custom.',
category: 'Navigation',
type: 'enum',
values: ['sidebar', 'header', 'custom'],
validate: ({ value }) => {
return z
.enum(['sidebar', 'header', 'custom'])
@@ -222,6 +257,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED',
description: 'Sets the default state of the team sidebar.',
category: 'Navigation',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -231,6 +267,8 @@ export const envVariables: EnvVariableModel[] = [
description:
'Defines sidebar collapse behavior. Options: offscreen, icon, or none.',
category: 'Navigation',
type: 'enum',
values: ['offscreen', 'icon', 'none'],
validate: ({ value }) => {
return z.enum(['offscreen', 'icon', 'none']).optional().safeParse(value);
},
@@ -240,6 +278,8 @@ export const envVariables: EnvVariableModel[] = [
description:
'Controls the default theme appearance. Options: light, dark, or system.',
category: 'Theme',
type: 'enum',
values: ['light', 'dark', 'system'],
validate: ({ value }) => {
return z.enum(['light', 'dark', 'system']).optional().safeParse(value);
},
@@ -248,6 +288,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_ENABLE_THEME_TOGGLE',
description: 'Controls visibility of the theme toggle feature.',
category: 'Theme',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -256,6 +297,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER',
description: 'Controls visibility of the sidebar trigger feature.',
category: 'Navigation',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -264,6 +306,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION',
description: 'Allows users to delete their personal accounts.',
category: 'Features',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -272,6 +315,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING',
description: 'Enables billing features for personal accounts.',
category: 'Features',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -280,6 +324,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS',
description: 'Master switch for team account functionality.',
category: 'Features',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -288,6 +333,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION',
description: 'Controls ability to create new team accounts.',
category: 'Features',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -296,6 +342,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION',
description: 'Allows team account deletion.',
category: 'Features',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -304,6 +351,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING',
description: 'Enables billing features for team accounts.',
category: 'Features',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -312,6 +360,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_ENABLE_NOTIFICATIONS',
description: 'Controls the notification system.',
category: 'Notifications',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -320,6 +369,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_REALTIME_NOTIFICATIONS',
description: 'Enables real-time notifications using Supabase Realtime.',
category: 'Notifications',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -328,7 +378,9 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_SUPABASE_URL',
description: 'Your Supabase project URL.',
category: 'Supabase',
hint: `Ex. https://your-project.supabase.co`,
required: true,
type: 'url',
validate: ({ value, mode }) => {
if (mode === 'development') {
return z
@@ -356,6 +408,7 @@ export const envVariables: EnvVariableModel[] = [
description: 'Your Supabase anonymous API key.',
category: 'Supabase',
required: true,
type: 'string',
validate: ({ value }) => {
return z
.string()
@@ -372,6 +425,7 @@ export const envVariables: EnvVariableModel[] = [
category: 'Supabase',
secret: true,
required: true,
type: 'string',
validate: ({ value, variables }) => {
return z
.string()
@@ -396,6 +450,7 @@ export const envVariables: EnvVariableModel[] = [
category: 'Supabase',
secret: true,
required: true,
type: 'string',
validate: ({ value }) => {
return z
.string()
@@ -413,6 +468,8 @@ export const envVariables: EnvVariableModel[] = [
'Your chosen billing provider. Options: stripe or lemon-squeezy.',
category: 'Billing',
required: true,
type: 'enum',
values: ['stripe', 'lemon-squeezy'],
validate: ({ value }) => {
return z.enum(['stripe', 'lemon-squeezy']).optional().safeParse(value);
},
@@ -420,7 +477,9 @@ export const envVariables: EnvVariableModel[] = [
{
name: 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY',
description: 'Your Stripe publishable key.',
hint: `Ex. pk_test_123456789012345678901234`,
category: 'Billing',
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -463,7 +522,9 @@ export const envVariables: EnvVariableModel[] = [
name: 'STRIPE_SECRET_KEY',
description: 'Your Stripe secret key.',
category: 'Billing',
hint: `Ex. sk_test_123456789012345678901234`,
secret: true,
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -500,7 +561,9 @@ export const envVariables: EnvVariableModel[] = [
name: 'STRIPE_WEBHOOK_SECRET',
description: 'Your Stripe webhook secret.',
category: 'Billing',
hint: `Ex. whsec_123456789012345678901234`,
secret: true,
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -544,6 +607,7 @@ export const envVariables: EnvVariableModel[] = [
description: 'Your Lemon Squeezy secret key.',
category: 'Billing',
secret: true,
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -578,6 +642,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'LEMON_SQUEEZY_STORE_ID',
description: 'Your Lemon Squeezy store ID.',
category: 'Billing',
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -613,6 +678,7 @@ export const envVariables: EnvVariableModel[] = [
description: 'Your Lemon Squeezy signing secret.',
category: 'Billing',
secret: true,
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -648,6 +714,8 @@ export const envVariables: EnvVariableModel[] = [
description: 'Your email service provider. Options: nodemailer or resend.',
category: 'Email',
required: true,
type: 'enum',
values: ['nodemailer', 'resend'],
validate: ({ value }) => {
return z.enum(['nodemailer', 'resend']).safeParse(value);
},
@@ -656,7 +724,9 @@ export const envVariables: EnvVariableModel[] = [
name: 'EMAIL_SENDER',
description: 'Default sender email address.',
category: 'Email',
hint: `Ex. "Makerkit <admin@makerkit.dev>"`,
required: true,
type: 'string',
validate: ({ value }) => {
return z
.string()
@@ -668,7 +738,9 @@ export const envVariables: EnvVariableModel[] = [
name: 'CONTACT_EMAIL',
description: 'Email address for contact form submissions.',
category: 'Email',
hint: `Ex. "Makerkit <admin@makerkit.dev>"`,
required: true,
type: 'email',
validate: ({ value }) => {
return z
.string()
@@ -682,6 +754,7 @@ export const envVariables: EnvVariableModel[] = [
description: 'Your Resend API key.',
category: 'Email',
secret: true,
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -707,6 +780,8 @@ export const envVariables: EnvVariableModel[] = [
name: 'EMAIL_HOST',
description: 'SMTP host for Nodemailer configuration.',
category: 'Email',
type: 'string',
hint: `Ex. "smtp.example.com"`,
contextualValidation: {
dependencies: [
{
@@ -731,6 +806,8 @@ export const envVariables: EnvVariableModel[] = [
name: 'EMAIL_PORT',
description: 'SMTP port for Nodemailer configuration.',
category: 'Email',
type: 'number',
hint: `Ex. 587 or 465`,
contextualValidation: {
dependencies: [
{
@@ -756,6 +833,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'EMAIL_USER',
description: 'SMTP user for Nodemailer configuration.',
category: 'Email',
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -782,6 +860,7 @@ export const envVariables: EnvVariableModel[] = [
description: 'SMTP password for Nodemailer configuration.',
category: 'Email',
secret: true,
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -804,8 +883,9 @@ export const envVariables: EnvVariableModel[] = [
},
{
name: 'EMAIL_TLS',
description: 'Whether to use TLS for SMTP connection.',
description: 'Whether to use TLS for SMTP connection. Please check this in your SMTP provider settings.',
category: 'Email',
type: 'boolean',
contextualValidation: {
dependencies: [
{
@@ -830,6 +910,8 @@ export const envVariables: EnvVariableModel[] = [
name: 'CMS_CLIENT',
description: 'Your chosen CMS system. Options: wordpress or keystatic.',
category: 'CMS',
type: 'enum',
values: ['wordpress', 'keystatic'],
validate: ({ value }) => {
return z.enum(['wordpress', 'keystatic']).optional().safeParse(value);
},
@@ -838,6 +920,8 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND',
description: 'Your Keystatic storage kind. Options: local, cloud, github.',
category: 'CMS',
type: 'enum',
values: ['local', 'cloud', 'github'],
contextualValidation: {
dependencies: [
{
@@ -862,6 +946,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO',
description: 'Your Keystatic storage repo.',
category: 'CMS',
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -889,6 +974,7 @@ export const envVariables: EnvVariableModel[] = [
description: 'Your Keystatic GitHub token.',
category: 'CMS',
secret: true,
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -915,6 +1001,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'KEYSTATIC_PATH_PREFIX',
description: 'Your Keystatic path prefix.',
category: 'CMS',
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -933,6 +1020,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH',
description: 'Your Keystatic content path.',
category: 'CMS',
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -958,6 +1046,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'WORDPRESS_API_URL',
description: 'WordPress API URL when using WordPress as CMS.',
category: 'CMS',
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -981,6 +1070,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_LOCALES_PATH',
description: 'The path to your locales folder.',
category: 'Localization',
type: 'string',
validate: ({ value }) => {
return z
.string()
@@ -996,6 +1086,8 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_LANGUAGE_PRIORITY',
description: 'The priority setting as to how infer the language.',
category: 'Localization',
type: 'enum',
values: ['user', 'application'],
validate: ({ value }) => {
return z.enum(['user', 'application']).optional().safeParse(value);
},
@@ -1005,6 +1097,7 @@ export const envVariables: EnvVariableModel[] = [
description:
'Enables the version updater to poll the latest version and notify the user.',
category: 'Features',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -1013,6 +1106,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_VERSION_UPDATER_REFETCH_INTERVAL_SECONDS',
description: 'The interval in seconds to check for updates.',
category: 'Features',
type: 'number',
validate: ({ value }) => {
return z.coerce
.number()
@@ -1032,6 +1126,7 @@ export const envVariables: EnvVariableModel[] = [
name: `ENABLE_REACT_COMPILER`,
description: 'Enables the React compiler [experimental]',
category: 'Build',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -1040,7 +1135,8 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_MONITORING_PROVIDER',
description: 'The monitoring provider to use.',
category: 'Monitoring',
required: true,
type: 'enum',
values: ['baselime', 'sentry', 'none'],
validate: ({ value }) => {
return z.enum(['baselime', 'sentry', '']).optional().safeParse(value);
},
@@ -1049,6 +1145,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_SENTRY_DSN',
description: 'The Sentry DSN to use.',
category: 'Monitoring',
type: `string`,
contextualValidation: {
dependencies: [
{
@@ -1073,7 +1170,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_SENTRY_ENVIRONMENT',
description: 'The Sentry environment to use.',
category: 'Monitoring',
required: true,
type: 'string',
validate: ({ value }) => {
return z.string().optional().safeParse(value);
},
@@ -1082,6 +1179,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_BASELIME_KEY',
description: 'The Baselime key to use.',
category: 'Monitoring',
type: 'string',
contextualValidation: {
dependencies: [
{
@@ -1107,6 +1205,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'STRIPE_ENABLE_TRIAL_WITHOUT_CC',
description: 'Enables trial plans without credit card.',
category: 'Billing',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
@@ -1115,6 +1214,7 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_THEME_COLOR',
description: 'The default theme color.',
category: 'Theme',
type: 'string',
required: true,
validate: ({ value }) => {
return z
@@ -1132,6 +1232,7 @@ export const envVariables: EnvVariableModel[] = [
description: 'The default theme color for dark mode.',
category: 'Theme',
required: true,
type: 'string',
validate: ({ value }) => {
return z
.string()
@@ -1147,6 +1248,17 @@ export const envVariables: EnvVariableModel[] = [
name: 'NEXT_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX',
description: 'Whether to display the terms checkbox during sign-up.',
category: 'Features',
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},
},
{
name: 'ENABLE_STRICT_CSP',
description: 'Enables strict Content Security Policy (CSP) headers.',
category: 'Security',
required: false,
type: 'boolean',
validate: ({ value }) => {
return z.coerce.boolean().optional().safeParse(value);
},

View File

@@ -0,0 +1,84 @@
'use server';
import { revalidatePath } from 'next/cache';
import { envVariables } from '@/app/variables/lib/env-variables-model';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:url';
import { z } from 'zod';
const Schema = z.object({
name: z.string().min(1),
value: z.string(),
mode: z.enum(['development', 'production']),
});
/**
* Update the environment variable in the specified file.
* @param props
*/
export async function updateEnvironmentVariableAction(
props: z.infer<typeof Schema>,
) {
// Validate the input
const { name, mode, value } = Schema.parse(props);
const root = resolve(process.cwd(), '..');
const model = envVariables.find((item) => item.name === name);
// Determine the source file based on the mode
const source = (() => {
const isSecret = model?.secret ?? true;
switch (mode) {
case 'development':
if (isSecret) {
return '.env.local';
} else {
return '.env.development';
}
case 'production':
if (isSecret) {
return '.env.production.local';
} else {
return '.env.production';
}
default:
throw new Error(`Invalid mode: ${mode}`);
}
})();
// check file exists, if not, create it
const filePath = `${root}/apps/web/${source}`;
if (!existsSync(filePath)) {
writeFileSync(filePath, '', 'utf-8');
}
const sourceEnvFile = readFileSync(`${root}apps/web/${source}`, 'utf-8');
let updatedEnvFile = '';
const isInSourceFile = sourceEnvFile.includes(name);
const isCommentedOut = sourceEnvFile.includes(`#${name}=`);
if (isInSourceFile && !isCommentedOut) {
updatedEnvFile = sourceEnvFile.replace(
new RegExp(`^${name}=.*`, 'm'),
`${name}=${value}`,
);
} else {
// if the key does not exist, append it to the end of the file
updatedEnvFile = `${sourceEnvFile}\n${name}=${value}`;
}
// write the updated content back to the file
writeFileSync(`${root}/apps/web/${source}`, updatedEnvFile, 'utf-8');
revalidatePath(`/variables`);
return {
success: true,
message: `Updated ${name} in "${source}"`,
};
}

View File

@@ -19,6 +19,13 @@ export type EnvVariableState = {
effectiveValue: string;
isOverridden: boolean;
effectiveSource: string;
isVisible: boolean;
validation: {
success: boolean;
error: {
issues: string[];
};
};
};
export type AppEnvState = {

View File

@@ -25,31 +25,30 @@ export default function VariablesPage({ searchParams }: VariablesPageProps) {
return (
<Page style={'custom'}>
<PageHeader
displaySidebarTrigger={false}
description={
<AppBreadcrumbs
values={{
variables: 'Environment Variables',
}}
/>
}
/>
<div className={'flex h-screen flex-col overflow-hidden'}>
<PageHeader
displaySidebarTrigger={false}
title={'Environment Variables'}
description={
'Manage environment variables for your applications. Validate and set them up easily.'
}
/>
<PageBody>
<div className={'flex flex-col space-y-4 pb-16'}>
{apps.map((app) => {
const appEnvState = processEnvDefinitions(app, mode);
<PageBody className={'overflow-hidden'}>
<div className={'flex h-full flex-1 flex-col space-y-4'}>
{apps.map((app) => {
const appEnvState = processEnvDefinitions(app, mode);
return (
<AppEnvironmentVariablesManager
key={app.appName}
state={appEnvState}
/>
);
})}
</div>
</PageBody>
return (
<AppEnvironmentVariablesManager
key={app.appName}
state={appEnvState}
/>
);
})}
</div>
</PageBody>
</div>
</Page>
);
}

View File

@@ -8,13 +8,16 @@
"format": "prettier --check --write \"**/*.{js,cjs,mjs,ts,tsx,md,json}\""
},
"dependencies": {
"@ai-sdk/openai": "^1.3.20",
"@hookform/resolvers": "^5.0.1",
"@tanstack/react-query": "5.74.4",
"ai": "4.3.10",
"lucide-react": "^0.503.0",
"next": "15.3.1",
"nodemailer": "^6.10.1",
"react": "19.1.0",
"react-dom": "19.1.0"
"react-dom": "19.1.0",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@kit/email-templates": "workspace:*",

View File

@@ -12,7 +12,7 @@ const defaultLanguage = process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? 'en';
* By default, only the default language is supported.
* Add more languages here if needed.
*/
export const languages: string[] = [defaultLanguage];
export const languages: string[] = [defaultLanguage, 'it'];
/**
* The name of the cookie that stores the selected language.

View File

@@ -59,6 +59,7 @@
"@supabase/supabase-js": "2.49.4",
"@tanstack/react-query": "5.74.4",
"@tanstack/react-table": "^8.21.3",
"ai": "4.3.10",
"date-fns": "^4.1.0",
"lucide-react": "^0.503.0",
"next": "15.3.1",

View File

@@ -88,7 +88,7 @@
"errorSendingCode": "Error sending code. Please try again."
},
"cookieBanner": {
"title": "Hey, we use cookies \uD83C\uDF6A",
"title": "Hey, we use cookies 🍪",
"description": "This website uses cookies to ensure you get the best experience on our website.",
"reject": "Reject",
"accept": "Accept"

View File

@@ -5,22 +5,17 @@ import { type VariantProps, cva } from 'class-variance-authority';
import { cn } from '../lib/utils';
const badgeVariants = cva(
'focus:ring-ring inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-hidden',
'focus:ring-ring inline-flex items-center rounded-md border px-1.5 py-0.5 text-xs font-semibold transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-hidden',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground hover:bg-primary/80 border-transparent shadow-xs',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 border-transparent',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/80 border-transparent shadow-xs',
default: 'bg-primary text-primary-foreground border-transparent',
secondary: 'bg-secondary text-secondary-foreground border-transparent',
destructive: 'text-destructive border-destructive',
outline: 'text-foreground',
success:
'border-transparent bg-green-50 text-green-500 hover:bg-green-50 dark:bg-green-500/20 dark:hover:bg-green-500/20',
warning:
'border-transparent bg-orange-50 text-orange-500 hover:bg-orange-50 dark:bg-orange-500/20 dark:hover:bg-orange-500/20',
info: 'border-transparent bg-blue-50 text-blue-500 hover:bg-blue-50 dark:bg-blue-500/20 dark:hover:bg-blue-500/20',
success: 'border-green-500 text-green-500',
warning: 'border-orange-500 text-orange-500',
info: 'border-blue-500 text-blue-500',
},
},
defaultVariants: {

307
pnpm-lock.yaml generated
View File

@@ -33,12 +33,18 @@ importers:
apps/dev-tool:
dependencies:
'@ai-sdk/openai':
specifier: ^1.3.20
version: 1.3.20(zod@3.24.3)
'@hookform/resolvers':
specifier: ^5.0.1
version: 5.0.1(react-hook-form@7.56.1(react@19.1.0))
'@tanstack/react-query':
specifier: 5.74.4
version: 5.74.4(react@19.1.0)
ai:
specifier: 4.3.10
version: 4.3.10(react@19.1.0)(zod@3.24.3)
lucide-react:
specifier: ^0.503.0
version: 0.503.0(react@19.1.0)
@@ -54,6 +60,9 @@ importers:
react-dom:
specifier: 19.1.0
version: 19.1.0(react@19.1.0)
rxjs:
specifier: ^7.8.2
version: 7.8.2
devDependencies:
'@kit/email-templates':
specifier: workspace:*
@@ -211,6 +220,9 @@ importers:
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
ai:
specifier: 4.3.10
version: 4.3.10(react@19.1.0)(zod@3.24.3)
date-fns:
specifier: ^4.1.0
version: 4.1.0
@@ -1559,13 +1571,13 @@ importers:
dependencies:
'@trivago/prettier-plugin-sort-imports':
specifier: 5.2.2
version: 5.2.2(prettier@3.5.3)
version: 5.2.2(@vue/compiler-sfc@3.5.13)(prettier@3.5.3)(svelte@4.2.19)
prettier:
specifier: ^3.5.3
version: 3.5.3
prettier-plugin-tailwindcss:
specifier: ^0.6.11
version: 0.6.11(@trivago/prettier-plugin-sort-imports@5.2.2(prettier@3.5.3))(prettier@3.5.3)
version: 0.6.11(@trivago/prettier-plugin-sort-imports@5.2.2(@vue/compiler-sfc@3.5.13)(prettier@3.5.3)(svelte@4.2.19))(prettier@3.5.3)
devDependencies:
'@kit/tsconfig':
specifier: workspace:*
@@ -1588,6 +1600,38 @@ packages:
graphql:
optional: true
'@ai-sdk/openai@1.3.20':
resolution: {integrity: sha512-/DflUy7ROG9k6n6YTXMBFPbujBKnbGY58f3CwvicLvDar9nDAloVnUWd3LUoOxpSVnX8vtQ7ngxF52SLWO6RwQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
'@ai-sdk/provider-utils@2.2.7':
resolution: {integrity: sha512-kM0xS3GWg3aMChh9zfeM+80vEZfXzR3JEUBdycZLtbRZ2TRT8xOj3WodGHPb06sUK5yD7pAXC/P7ctsi2fvUGQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.23.8
'@ai-sdk/provider@1.1.3':
resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==}
engines: {node: '>=18'}
'@ai-sdk/react@1.2.9':
resolution: {integrity: sha512-/VYm8xifyngaqFDLXACk/1czDRCefNCdALUyp+kIX6DUIYUWTM93ISoZ+qJ8+3E+FiJAKBQz61o8lIIl+vYtzg==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
zod: ^3.23.8
peerDependenciesMeta:
zod:
optional: true
'@ai-sdk/ui-utils@1.2.8':
resolution: {integrity: sha512-nls/IJCY+ks3Uj6G/agNhXqQeLVqhNfoJbuNgCny+nX2veY5ADB91EcZUqVeQ/ionul2SeUswPY6Q/DxteY29Q==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.23.8
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -4325,6 +4369,9 @@ packages:
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/diff-match-patch@1.0.36':
resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==}
'@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -4621,6 +4668,21 @@ packages:
peerDependencies:
'@urql/core': ^5.0.0
'@vue/compiler-core@3.5.13':
resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==}
'@vue/compiler-dom@3.5.13':
resolution: {integrity: sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==}
'@vue/compiler-sfc@3.5.13':
resolution: {integrity: sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==}
'@vue/compiler-ssr@3.5.13':
resolution: {integrity: sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==}
'@vue/shared@3.5.13':
resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==}
'@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@@ -4709,6 +4771,16 @@ packages:
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
engines: {node: '>=8'}
ai@4.3.10:
resolution: {integrity: sha512-jw+ahNu+T4SHj9gtraIKtYhanJI6gj2IZ5BFcfEHgoyQVMln5a5beGjzl/nQSX6FxyLqJ/UBpClRa279EEKK/Q==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
zod: ^3.23.8
peerDependenciesMeta:
react:
optional: true
ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
@@ -4930,6 +5002,10 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chalk@5.4.1:
resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
change-case@3.1.0:
resolution: {integrity: sha512-2AZp7uJZbYEzRPsFoa+ijKdvp9zsrnnt6+yFokfwEpeJm0xuJDVoxiRCAaTzyJND8GJkofo2IcKWaUZ/OECVzw==}
@@ -5007,6 +5083,9 @@ packages:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
code-red@1.0.4:
resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==}
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
@@ -5300,6 +5379,9 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
diff-match-patch@1.0.5:
resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
diff@4.0.2:
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
engines: {node: '>=0.3.1'}
@@ -5582,6 +5664,9 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
@@ -6129,6 +6214,9 @@ packages:
is-reference@1.2.1:
resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
is-reference@3.0.3:
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@@ -6242,6 +6330,9 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -6254,6 +6345,11 @@ packages:
engines: {node: '>=6'}
hasBin: true
jsondiffpatch@0.6.0:
resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
@@ -6365,6 +6461,9 @@ packages:
resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==}
engines: {node: '>=6.11.5'}
locate-character@3.0.0:
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -6938,6 +7037,9 @@ packages:
peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
periscopic@3.1.0:
resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
@@ -7851,6 +7953,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
svelte@4.2.19:
resolution: {integrity: sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==}
engines: {node: '>=16'}
svgo@3.3.2:
resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==}
engines: {node: '>=14.0.0'}
@@ -7859,6 +7965,11 @@ packages:
swap-case@1.1.2:
resolution: {integrity: sha512-BAmWG6/bx8syfc6qXPprof3Mn5vQgf5dwdUNJhsNqU9WdPt5P+ES/wQ5bxfijy8zwZgZZHslC3iAsxsuQMCzJQ==}
swr@2.3.3:
resolution: {integrity: sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==}
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
tabbable@6.2.0:
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
@@ -7905,6 +8016,10 @@ packages:
thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
throttleit@2.1.0:
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
engines: {node: '>=18'}
through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
@@ -8349,6 +8464,11 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zod-to-json-schema@3.24.5:
resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==}
peerDependencies:
zod: ^3.24.1
zod@3.24.3:
resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==}
@@ -8361,6 +8481,40 @@ snapshots:
optionalDependencies:
graphql: 16.10.0
'@ai-sdk/openai@1.3.20(zod@3.24.3)':
dependencies:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.7(zod@3.24.3)
zod: 3.24.3
'@ai-sdk/provider-utils@2.2.7(zod@3.24.3)':
dependencies:
'@ai-sdk/provider': 1.1.3
nanoid: 3.3.11
secure-json-parse: 2.7.0
zod: 3.24.3
'@ai-sdk/provider@1.1.3':
dependencies:
json-schema: 0.4.0
'@ai-sdk/react@1.2.9(react@19.1.0)(zod@3.24.3)':
dependencies:
'@ai-sdk/provider-utils': 2.2.7(zod@3.24.3)
'@ai-sdk/ui-utils': 1.2.8(zod@3.24.3)
react: 19.1.0
swr: 2.3.3(react@19.1.0)
throttleit: 2.1.0
optionalDependencies:
zod: 3.24.3
'@ai-sdk/ui-utils@1.2.8(zod@3.24.3)':
dependencies:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.7(zod@3.24.3)
zod: 3.24.3
zod-to-json-schema: 3.24.5(zod@3.24.3)
'@alloc/quick-lru@5.2.0': {}
'@ampproject/remapping@2.3.0':
@@ -11741,7 +11895,7 @@ snapshots:
'@tootallnate/quickjs-emscripten@0.23.0': {}
'@trivago/prettier-plugin-sort-imports@5.2.2(prettier@3.5.3)':
'@trivago/prettier-plugin-sort-imports@5.2.2(@vue/compiler-sfc@3.5.13)(prettier@3.5.3)(svelte@4.2.19)':
dependencies:
'@babel/generator': 7.27.0
'@babel/parser': 7.27.0
@@ -11750,6 +11904,9 @@ snapshots:
javascript-natural-sort: 0.7.1
lodash: 4.17.21
prettier: 3.5.3
optionalDependencies:
'@vue/compiler-sfc': 3.5.13
svelte: 4.2.19
transitivePeerDependencies:
- supports-color
@@ -11846,6 +12003,8 @@ snapshots:
dependencies:
'@types/ms': 2.1.0
'@types/diff-match-patch@1.0.36': {}
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
@@ -12189,6 +12348,43 @@ snapshots:
'@urql/core': 5.1.1(graphql@16.10.0)
wonka: 6.3.5
'@vue/compiler-core@3.5.13':
dependencies:
'@babel/parser': 7.27.0
'@vue/shared': 3.5.13
entities: 4.5.0
estree-walker: 2.0.2
source-map-js: 1.2.1
optional: true
'@vue/compiler-dom@3.5.13':
dependencies:
'@vue/compiler-core': 3.5.13
'@vue/shared': 3.5.13
optional: true
'@vue/compiler-sfc@3.5.13':
dependencies:
'@babel/parser': 7.27.0
'@vue/compiler-core': 3.5.13
'@vue/compiler-dom': 3.5.13
'@vue/compiler-ssr': 3.5.13
'@vue/shared': 3.5.13
estree-walker: 2.0.2
magic-string: 0.30.17
postcss: 8.5.3
source-map-js: 1.2.1
optional: true
'@vue/compiler-ssr@3.5.13':
dependencies:
'@vue/compiler-dom': 3.5.13
'@vue/shared': 3.5.13
optional: true
'@vue/shared@3.5.13':
optional: true
'@webassemblyjs/ast@1.14.1':
dependencies:
'@webassemblyjs/helper-numbers': 1.13.2
@@ -12300,6 +12496,18 @@ snapshots:
clean-stack: 2.2.0
indent-string: 4.0.0
ai@4.3.10(react@19.1.0)(zod@3.24.3):
dependencies:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.7(zod@3.24.3)
'@ai-sdk/react': 1.2.9(react@19.1.0)(zod@3.24.3)
'@ai-sdk/ui-utils': 1.2.8(zod@3.24.3)
'@opentelemetry/api': 1.9.0
jsondiffpatch: 0.6.0
zod: 3.24.3
optionalDependencies:
react: 19.1.0
ajv-formats@2.1.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
@@ -12565,6 +12773,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
chalk@5.4.1: {}
change-case@3.1.0:
dependencies:
camel-case: 3.0.0
@@ -12654,6 +12864,15 @@ snapshots:
- '@types/react'
- '@types/react-dom'
code-red@1.0.4:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
'@types/estree': 1.0.7
acorn: 8.14.1
estree-walker: 3.0.3
periscopic: 3.1.0
optional: true
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
@@ -12951,6 +13170,8 @@ snapshots:
dependencies:
dequal: 2.0.3
diff-match-patch@1.0.5: {}
diff@4.0.2: {}
dir-glob@3.0.1:
@@ -13151,8 +13372,8 @@ snapshots:
'@typescript-eslint/parser': 8.30.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3)
eslint: 9.25.1(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2))
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.30.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2))
eslint-import-resolver-typescript: 3.10.0(eslint-plugin-import@2.31.0)(eslint@9.25.1(jiti@2.4.2))
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.25.1(jiti@2.4.2))
eslint-plugin-react: 7.37.5(eslint@9.25.1(jiti@2.4.2))
eslint-plugin-react-hooks: 5.2.0(eslint@9.25.1(jiti@2.4.2))
@@ -13177,7 +13398,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2)):
eslint-import-resolver-typescript@3.10.0(eslint-plugin-import@2.31.0)(eslint@9.25.1(jiti@2.4.2)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.0
@@ -13188,22 +13409,22 @@ snapshots:
tinyglobby: 0.2.13
unrs-resolver: 1.6.1
optionalDependencies:
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.30.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2))
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2))
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.30.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2)):
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.30.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.0)(eslint@9.25.1(jiti@2.4.2)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.30.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3)
eslint: 9.25.1(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2))
eslint-import-resolver-typescript: 3.10.0(eslint-plugin-import@2.31.0)(eslint@9.25.1(jiti@2.4.2))
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2)):
eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.8
@@ -13214,7 +13435,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.25.1(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.30.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2))
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.30.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.0)(eslint@9.25.1(jiti@2.4.2))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -13226,7 +13447,7 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 8.30.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/parser': 8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@@ -13368,6 +13589,11 @@ snapshots:
estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.7
optional: true
esutils@2.0.3: {}
event-target-shim@6.0.2: {}
@@ -13952,6 +14178,11 @@ snapshots:
dependencies:
'@types/estree': 1.0.7
is-reference@3.0.3:
dependencies:
'@types/estree': 1.0.7
optional: true
is-regex@1.2.1:
dependencies:
call-bound: 1.0.4
@@ -14050,6 +14281,8 @@ snapshots:
json-schema-traverse@1.0.0: {}
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json5@1.0.2:
@@ -14058,6 +14291,12 @@ snapshots:
json5@2.2.3: {}
jsondiffpatch@0.6.0:
dependencies:
'@types/diff-match-patch': 1.0.36
chalk: 5.4.1
diff-match-patch: 1.0.5
jsonfile@6.1.0:
dependencies:
universalify: 2.0.1
@@ -14147,6 +14386,9 @@ snapshots:
loader-runner@4.3.0: {}
locate-character@3.0.0:
optional: true
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -14969,6 +15211,13 @@ snapshots:
peberminta@0.9.0: {}
periscopic@3.1.0:
dependencies:
'@types/estree': 1.0.7
estree-walker: 3.0.3
is-reference: 3.0.3
optional: true
pg-int8@1.0.1: {}
pg-protocol@1.8.0: {}
@@ -15220,11 +15469,11 @@ snapshots:
prelude-ls@1.2.1: {}
prettier-plugin-tailwindcss@0.6.11(@trivago/prettier-plugin-sort-imports@5.2.2(prettier@3.5.3))(prettier@3.5.3):
prettier-plugin-tailwindcss@0.6.11(@trivago/prettier-plugin-sort-imports@5.2.2(@vue/compiler-sfc@3.5.13)(prettier@3.5.3)(svelte@4.2.19))(prettier@3.5.3):
dependencies:
prettier: 3.5.3
optionalDependencies:
'@trivago/prettier-plugin-sort-imports': 5.2.2(prettier@3.5.3)
'@trivago/prettier-plugin-sort-imports': 5.2.2(@vue/compiler-sfc@3.5.13)(prettier@3.5.3)(svelte@4.2.19)
prettier@3.5.3: {}
@@ -15959,6 +16208,24 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
svelte@4.2.19:
dependencies:
'@ampproject/remapping': 2.3.0
'@jridgewell/sourcemap-codec': 1.5.0
'@jridgewell/trace-mapping': 0.3.25
'@types/estree': 1.0.7
acorn: 8.14.1
aria-query: 5.3.2
axobject-query: 4.1.0
code-red: 1.0.4
css-tree: 2.3.1
estree-walker: 3.0.3
is-reference: 3.0.3
locate-character: 3.0.0
magic-string: 0.30.17
periscopic: 3.1.0
optional: true
svgo@3.3.2:
dependencies:
'@trysound/sax': 0.2.0
@@ -15974,6 +16241,12 @@ snapshots:
lower-case: 1.1.4
upper-case: 1.1.3
swr@2.3.3(react@19.1.0):
dependencies:
dequal: 2.0.3
react: 19.1.0
use-sync-external-store: 1.5.0(react@19.1.0)
tabbable@6.2.0: {}
tailwind-merge@3.2.0: {}
@@ -16015,6 +16288,8 @@ snapshots:
dependencies:
real-require: 0.2.0
throttleit@2.1.0: {}
through@2.3.8: {}
tiny-invariant@1.0.6: {}
@@ -16519,6 +16794,10 @@ snapshots:
yocto-queue@0.1.0: {}
zod-to-json-schema@3.24.5(zod@3.24.3):
dependencies:
zod: 3.24.3
zod@3.24.3: {}
zwitch@2.0.4: {}