MCP Server 2.0 (#452)
* MCP Server 2.0 - Updated application version from 2.23.14 to 2.24.0 in package.json. - MCP Server improved with new features - Migrated functionality from Dev Tools to MCP Server - Improved getMonitoringProvider not to crash application when misconfigured
This commit is contained in:
committed by
GitHub
parent
059408a70a
commit
f3ac595d06
@@ -4,9 +4,9 @@ import { DatabaseToolsInterface } from './_components/database-tools-interface';
|
||||
import { loadDatabaseToolsData } from './_lib/server/database-tools.loader';
|
||||
|
||||
interface DatabasePageProps {
|
||||
searchParams: {
|
||||
searchParams: Promise<{
|
||||
search?: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -16,7 +16,7 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
async function DatabasePage({ searchParams }: DatabasePageProps) {
|
||||
const searchTerm = searchParams.search || '';
|
||||
const searchTerm = (await searchParams).search || '';
|
||||
|
||||
// Load all database data server-side
|
||||
const databaseData = await loadDatabaseToolsData();
|
||||
@@ -1,10 +1,12 @@
|
||||
import { EmailTesterForm } from '@/app/emails/[id]/components/email-tester-form';
|
||||
import { loadEmailTemplate } from '@/app/emails/lib/email-loader';
|
||||
import { getVariable } from '@/app/variables/lib/env-scanner';
|
||||
import { EnvMode } from '@/app/variables/lib/types';
|
||||
import { EnvModeSelector } from '@/components/env-mode-selector';
|
||||
import { IFrame } from '@/components/iframe';
|
||||
|
||||
import {
|
||||
createKitEmailsDeps,
|
||||
createKitEmailsService,
|
||||
} from '@kit/mcp-server/emails';
|
||||
import { findWorkspaceRoot, getVariable } from '@kit/mcp-server/env';
|
||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
@@ -15,6 +17,8 @@ import {
|
||||
} from '@kit/ui/dialog';
|
||||
import { Page, PageBody, PageHeader } from '@kit/ui/page';
|
||||
|
||||
type EnvMode = 'development' | 'production';
|
||||
|
||||
type EmailPageProps = React.PropsWithChildren<{
|
||||
params: Promise<{
|
||||
id: string;
|
||||
@@ -31,25 +35,28 @@ export default async function EmailPage(props: EmailPageProps) {
|
||||
const { id } = await props.params;
|
||||
const mode = (await props.searchParams).mode ?? 'development';
|
||||
|
||||
const template = await loadEmailTemplate(id);
|
||||
const emailSettings = await getEmailSettings(mode);
|
||||
const rootPath = findWorkspaceRoot(process.cwd());
|
||||
const service = createKitEmailsService(createKitEmailsDeps(rootPath));
|
||||
|
||||
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',
|
||||
};
|
||||
const [result, { templates }, emailSettings] = await Promise.all([
|
||||
service.read({ id }),
|
||||
service.list(),
|
||||
getEmailSettings(mode),
|
||||
]);
|
||||
|
||||
const html = result.renderedHtml ?? result.source;
|
||||
|
||||
const values: Record<string, string> = { emails: 'Emails' };
|
||||
|
||||
for (const t of templates) {
|
||||
values[t.id] = t.name;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page style={'custom'}>
|
||||
<PageHeader
|
||||
displaySidebarTrigger={false}
|
||||
title={values[id]}
|
||||
title={values[id] ?? id}
|
||||
description={<AppBreadcrumbs values={values} />}
|
||||
>
|
||||
<EnvModeSelector mode={mode} />
|
||||
@@ -77,7 +84,7 @@ export default async function EmailPage(props: EmailPageProps) {
|
||||
<IFrame className={'flex flex-1 flex-col'}>
|
||||
<div
|
||||
className={'flex flex-1 flex-col'}
|
||||
dangerouslySetInnerHTML={{ __html: template.html }}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</IFrame>
|
||||
</PageBody>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import {
|
||||
renderAccountDeleteEmail,
|
||||
renderInviteEmail,
|
||||
renderOtpEmail,
|
||||
} from '@kit/email-templates';
|
||||
|
||||
export async function loadEmailTemplate(id: string) {
|
||||
switch (id) {
|
||||
case 'account-delete-email':
|
||||
return renderAccountDeleteEmail({
|
||||
productName: 'Makerkit',
|
||||
userDisplayName: 'Giancarlo',
|
||||
});
|
||||
|
||||
case 'invite-email':
|
||||
return renderInviteEmail({
|
||||
teamName: 'Makerkit',
|
||||
teamLogo: '',
|
||||
inviter: 'Giancarlo',
|
||||
invitedUserEmail: 'test@makerkit.dev',
|
||||
link: 'https://makerkit.dev',
|
||||
productName: 'Makerkit',
|
||||
});
|
||||
|
||||
case 'otp-email':
|
||||
return renderOtpEmail({
|
||||
productName: 'Makerkit',
|
||||
otp: '123456',
|
||||
});
|
||||
|
||||
case 'magic-link-email':
|
||||
return loadFromFileSystem('magic-link');
|
||||
|
||||
case 'reset-password-email':
|
||||
return loadFromFileSystem('reset-password');
|
||||
|
||||
case 'change-email-address-email':
|
||||
return loadFromFileSystem('change-email-address');
|
||||
|
||||
case 'confirm-email':
|
||||
return loadFromFileSystem('confirm-email');
|
||||
|
||||
default:
|
||||
throw new Error(`Email template not found: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFromFileSystem(fileName: string) {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const { join } = await import('node:path');
|
||||
|
||||
const filePath = join(
|
||||
process.cwd(),
|
||||
`../web/supabase/templates/${fileName}.html`,
|
||||
);
|
||||
|
||||
return {
|
||||
html: readFileSync(filePath, 'utf8'),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
'use server';
|
||||
|
||||
import { loadEmailTemplate } from '@/app/emails/lib/email-loader';
|
||||
import {
|
||||
createKitEmailsDeps,
|
||||
createKitEmailsService,
|
||||
} from '@kit/mcp-server/emails';
|
||||
import { findWorkspaceRoot } from '@kit/mcp-server/env';
|
||||
|
||||
export async function sendEmailAction(params: {
|
||||
template: string;
|
||||
@@ -27,7 +31,10 @@ export async function sendEmailAction(params: {
|
||||
},
|
||||
});
|
||||
|
||||
const { html } = await loadEmailTemplate(params.template);
|
||||
const rootPath = findWorkspaceRoot(process.cwd());
|
||||
const service = createKitEmailsService(createKitEmailsDeps(rootPath));
|
||||
const result = await service.read({ id: params.template });
|
||||
const html = result.renderedHtml ?? result.source;
|
||||
|
||||
return transporter.sendMail({
|
||||
html,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
createKitEmailsDeps,
|
||||
createKitEmailsService,
|
||||
} from '@kit/mcp-server/emails';
|
||||
import { findWorkspaceRoot } from '@kit/mcp-server/env';
|
||||
import {
|
||||
CardButton,
|
||||
CardButtonHeader,
|
||||
@@ -12,7 +17,16 @@ export const metadata = {
|
||||
title: 'Emails',
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
'supabase-auth': 'Supabase Auth Emails',
|
||||
transactional: 'Transactional Emails',
|
||||
};
|
||||
|
||||
export default async function EmailsPage() {
|
||||
const rootPath = findWorkspaceRoot(process.cwd());
|
||||
const service = createKitEmailsService(createKitEmailsDeps(rootPath));
|
||||
const { templates, categories } = await service.list();
|
||||
|
||||
return (
|
||||
<Page style={'custom'}>
|
||||
<PageHeader
|
||||
@@ -22,73 +36,31 @@ export default async function EmailsPage() {
|
||||
/>
|
||||
|
||||
<PageBody className={'gap-y-8'}>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Heading level={5}>Supabase Auth Emails</Heading>
|
||||
{categories.map((category) => {
|
||||
const categoryTemplates = templates.filter(
|
||||
(t) => t.category === category,
|
||||
);
|
||||
|
||||
<div className={'grid grid-cols-1 gap-4 md:grid-cols-4'}>
|
||||
<CardButton asChild>
|
||||
<Link href={'/emails/confirm-email'}>
|
||||
<CardButtonHeader>
|
||||
<CardButtonTitle>Confirm Email</CardButtonTitle>
|
||||
</CardButtonHeader>
|
||||
</Link>
|
||||
</CardButton>
|
||||
return (
|
||||
<div key={category} className={'flex flex-col space-y-4'}>
|
||||
<Heading level={5}>
|
||||
{CATEGORY_LABELS[category] ?? category}
|
||||
</Heading>
|
||||
|
||||
<CardButton asChild>
|
||||
<Link href={'/emails/change-email-address-email'}>
|
||||
<CardButtonHeader>
|
||||
<CardButtonTitle>Change Email Address Email</CardButtonTitle>
|
||||
</CardButtonHeader>
|
||||
</Link>
|
||||
</CardButton>
|
||||
|
||||
<CardButton asChild>
|
||||
<Link href={'/emails/reset-password-email'}>
|
||||
<CardButtonHeader>
|
||||
<CardButtonTitle>Reset Password Email</CardButtonTitle>
|
||||
</CardButtonHeader>
|
||||
</Link>
|
||||
</CardButton>
|
||||
|
||||
<CardButton asChild>
|
||||
<Link href={'/emails/magic-link-email'}>
|
||||
<CardButtonHeader>
|
||||
<CardButtonTitle>Magic Link Email</CardButtonTitle>
|
||||
</CardButtonHeader>
|
||||
</Link>
|
||||
</CardButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Heading level={5}>Transactional Emails</Heading>
|
||||
|
||||
<div className={'grid grid-cols-1 gap-4 md:grid-cols-4'}>
|
||||
<CardButton asChild>
|
||||
<Link href={'/emails/account-delete-email'}>
|
||||
<CardButtonHeader>
|
||||
<CardButtonTitle>Account Delete Email</CardButtonTitle>
|
||||
</CardButtonHeader>
|
||||
</Link>
|
||||
</CardButton>
|
||||
|
||||
<CardButton asChild>
|
||||
<Link href={'/emails/invite-email'}>
|
||||
<CardButtonHeader>
|
||||
<CardButtonTitle>Invite Email</CardButtonTitle>
|
||||
</CardButtonHeader>
|
||||
</Link>
|
||||
</CardButton>
|
||||
|
||||
<CardButton asChild>
|
||||
<Link href={'/emails/otp-email'}>
|
||||
<CardButtonHeader>
|
||||
<CardButtonTitle>OTP Email</CardButtonTitle>
|
||||
</CardButtonHeader>
|
||||
</Link>
|
||||
</CardButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'grid grid-cols-1 gap-4 md:grid-cols-4'}>
|
||||
{categoryTemplates.map((template) => (
|
||||
<CardButton key={template.id} asChild>
|
||||
<Link href={`/emails/${template.id}`}>
|
||||
<CardButtonHeader>
|
||||
<CardButtonTitle>{template.name}</CardButtonTitle>
|
||||
</CardButtonHeader>
|
||||
</Link>
|
||||
</CardButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</PageBody>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import { EnvMode } from '@/app/variables/lib/types';
|
||||
|
||||
import { getVariable } from '../variables/lib/env-scanner';
|
||||
|
||||
export function createConnectivityService(mode: EnvMode) {
|
||||
return new ConnectivityService(mode);
|
||||
}
|
||||
|
||||
class ConnectivityService {
|
||||
constructor(private mode: EnvMode = 'development') {}
|
||||
|
||||
async checkSupabaseConnectivity() {
|
||||
const url = await getVariable('NEXT_PUBLIC_SUPABASE_URL', this.mode);
|
||||
|
||||
if (!url) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'No Supabase URL found in environment variables',
|
||||
};
|
||||
}
|
||||
|
||||
const anonKey =
|
||||
(await getVariable('NEXT_PUBLIC_SUPABASE_ANON_KEY', this.mode)) ||
|
||||
(await getVariable('NEXT_PUBLIC_SUPABASE_PUBLIC_KEY', this.mode));
|
||||
|
||||
if (!anonKey) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'No Supabase Anon Key found in environment variables',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${url}/auth/v1/health`, {
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
Authorization: `Bearer ${anonKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
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. ${error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async checkSupabaseAdminConnectivity() {
|
||||
const url = await getVariable('NEXT_PUBLIC_SUPABASE_URL', this.mode);
|
||||
|
||||
if (!url) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'No Supabase URL found in environment variables',
|
||||
};
|
||||
}
|
||||
|
||||
const endpoint = `${url}/rest/v1/accounts`;
|
||||
|
||||
const apikey =
|
||||
(await getVariable('NEXT_PUBLIC_SUPABASE_ANON_KEY', this.mode)) ||
|
||||
(await getVariable('NEXT_PUBLIC_SUPABASE_PUBLIC_KEY', this.mode));
|
||||
|
||||
if (!apikey) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'No Supabase Anon Key found in environment variables',
|
||||
};
|
||||
}
|
||||
|
||||
const adminKey =
|
||||
(await getVariable('SUPABASE_SERVICE_ROLE_KEY', this.mode)) ||
|
||||
(await getVariable('SUPABASE_SECRET_KEY', this.mode));
|
||||
|
||||
if (!adminKey) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'No Supabase Service Role Key found in environment variables',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
apikey: adminKey,
|
||||
},
|
||||
});
|
||||
|
||||
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. ${error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async checkStripeWebhookEndpoints() {
|
||||
const secretKey = await getVariable('STRIPE_SECRET_KEY', this.mode);
|
||||
|
||||
if (!secretKey) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'No Stripe Secret Key found in environment variables',
|
||||
};
|
||||
}
|
||||
|
||||
const webhooksSecret = await getVariable(
|
||||
'STRIPE_WEBHOOK_SECRET',
|
||||
this.mode,
|
||||
);
|
||||
|
||||
if (!webhooksSecret) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'No Webhooks secret found in environment variables',
|
||||
};
|
||||
}
|
||||
|
||||
const url = `https://api.stripe.com`;
|
||||
|
||||
const request = await fetch(`${url}/v1/webhook_endpoints`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!request.ok) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message:
|
||||
'Failed to connect to Stripe. The Stripe Webhook Secret is not valid.',
|
||||
};
|
||||
}
|
||||
|
||||
const webhooksResponse = await request.json();
|
||||
const webhooks = webhooksResponse.data ?? [];
|
||||
|
||||
if (webhooks.length === 0) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'No webhooks found in Stripe',
|
||||
};
|
||||
}
|
||||
|
||||
const allWebhooksShareTheSameSecret = webhooks.every(
|
||||
(webhook: { secret: string }) => webhook.secret === webhooksSecret,
|
||||
);
|
||||
|
||||
if (!allWebhooksShareTheSameSecret) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'All webhooks do not share the same secret',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success' as const,
|
||||
message: 'All webhooks share the same Webhooks secret',
|
||||
};
|
||||
}
|
||||
|
||||
async checkStripeConnected() {
|
||||
const secretKey = await getVariable('STRIPE_SECRET_KEY', this.mode);
|
||||
|
||||
if (!secretKey) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message: 'No Stripe Secret Key found in environment variables',
|
||||
};
|
||||
}
|
||||
|
||||
const url = `https://api.stripe.com`;
|
||||
|
||||
const request = await fetch(`${url}/v1/prices`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!request.ok) {
|
||||
return {
|
||||
status: 'error' as const,
|
||||
message:
|
||||
'Failed to connect to Stripe. The Stripe Secret Key is not valid.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success' as const,
|
||||
message: 'Connected to Stripe',
|
||||
};
|
||||
}
|
||||
}
|
||||
154
apps/dev-tool/app/lib/prerequisites-dashboard.loader.ts
Normal file
154
apps/dev-tool/app/lib/prerequisites-dashboard.loader.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import {
|
||||
type KitPrerequisitesDeps,
|
||||
createKitPrerequisitesService,
|
||||
} from '@kit/mcp-server/prerequisites';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export async function loadDashboardKitPrerequisites() {
|
||||
const rootPath = await findWorkspaceRoot(process.cwd());
|
||||
const service = createKitPrerequisitesService(
|
||||
createKitPrerequisitesDeps(rootPath),
|
||||
);
|
||||
return service.check({});
|
||||
}
|
||||
|
||||
function createKitPrerequisitesDeps(rootPath: string): KitPrerequisitesDeps {
|
||||
return {
|
||||
async getVariantFamily() {
|
||||
const variant = await resolveVariant(rootPath);
|
||||
return variant.includes('supabase') ? 'supabase' : 'orm';
|
||||
},
|
||||
async executeCommand(command: string, args: string[]) {
|
||||
const result = await executeWithFallback(rootPath, command, args);
|
||||
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function executeWithFallback(
|
||||
rootPath: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
) {
|
||||
try {
|
||||
return await execFileAsync(command, args, {
|
||||
cwd: rootPath,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isLocalCliCandidate(command)) {
|
||||
const localBinCandidates = [
|
||||
join(rootPath, 'node_modules', '.bin', command),
|
||||
join(rootPath, 'apps', 'web', 'node_modules', '.bin', command),
|
||||
];
|
||||
|
||||
for (const localBin of localBinCandidates) {
|
||||
try {
|
||||
return await execFileAsync(localBin, args, {
|
||||
cwd: rootPath,
|
||||
});
|
||||
} catch {
|
||||
// Try next local binary candidate.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await execFileAsync('pnpm', ['exec', command, ...args], {
|
||||
cwd: rootPath,
|
||||
});
|
||||
} catch {
|
||||
return execFileAsync(
|
||||
'pnpm',
|
||||
['--filter', 'web', 'exec', command, ...args],
|
||||
{
|
||||
cwd: rootPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalCliCandidate(command: string) {
|
||||
return command === 'supabase' || command === 'stripe';
|
||||
}
|
||||
|
||||
async function resolveVariant(rootPath: string) {
|
||||
const configPath = join(rootPath, '.makerkit', 'config.json');
|
||||
|
||||
try {
|
||||
await access(configPath);
|
||||
const config = JSON.parse(await readFile(configPath, 'utf8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const variant =
|
||||
readString(config, 'variant') ??
|
||||
readString(config, 'template') ??
|
||||
readString(config, 'kitVariant');
|
||||
|
||||
if (variant) {
|
||||
return variant;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to heuristic.
|
||||
}
|
||||
|
||||
if (await pathExists(join(rootPath, 'apps', 'web', 'supabase'))) {
|
||||
return 'next-supabase';
|
||||
}
|
||||
|
||||
return 'next-drizzle';
|
||||
}
|
||||
|
||||
function readString(obj: Record<string, unknown>, key: string) {
|
||||
const value = obj[key];
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
async function pathExists(path: string) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function findWorkspaceRoot(startPath: string) {
|
||||
let current = startPath;
|
||||
|
||||
for (let depth = 0; depth < 6; depth++) {
|
||||
const workspaceManifest = join(current, 'pnpm-workspace.yaml');
|
||||
|
||||
try {
|
||||
await access(workspaceManifest);
|
||||
return current;
|
||||
} catch {
|
||||
// Continue to parent path.
|
||||
}
|
||||
|
||||
const parent = join(current, '..');
|
||||
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return startPath;
|
||||
}
|
||||
116
apps/dev-tool/app/lib/status-dashboard.loader.ts
Normal file
116
apps/dev-tool/app/lib/status-dashboard.loader.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access, readFile, stat } from 'node:fs/promises';
|
||||
import { Socket } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import {
|
||||
type KitStatusDeps,
|
||||
createKitStatusService,
|
||||
} from '@kit/mcp-server/status';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export async function loadDashboardKitStatus() {
|
||||
const rootPath = await findWorkspaceRoot(process.cwd());
|
||||
const service = createKitStatusService(createKitStatusDeps(rootPath));
|
||||
return service.getStatus({});
|
||||
}
|
||||
|
||||
function createKitStatusDeps(rootPath: string): KitStatusDeps {
|
||||
return {
|
||||
rootPath,
|
||||
async readJsonFile(path: string): Promise<unknown> {
|
||||
const filePath = join(rootPath, path);
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
return JSON.parse(content) as unknown;
|
||||
},
|
||||
async pathExists(path: string) {
|
||||
const fullPath = join(rootPath, path);
|
||||
|
||||
try {
|
||||
await access(fullPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async isDirectory(path: string) {
|
||||
const fullPath = join(rootPath, path);
|
||||
|
||||
try {
|
||||
const stats = await stat(fullPath);
|
||||
return stats.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async executeCommand(command: string, args: string[]) {
|
||||
const result = await execFileAsync(command, args, {
|
||||
cwd: rootPath,
|
||||
});
|
||||
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: 0,
|
||||
};
|
||||
},
|
||||
async isPortOpen(port: number) {
|
||||
return checkPort(port);
|
||||
},
|
||||
getNodeVersion() {
|
||||
return process.version;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function findWorkspaceRoot(startPath: string) {
|
||||
let current = startPath;
|
||||
|
||||
for (let depth = 0; depth < 6; depth++) {
|
||||
const workspaceManifest = join(current, 'pnpm-workspace.yaml');
|
||||
|
||||
try {
|
||||
await access(workspaceManifest);
|
||||
return current;
|
||||
} catch {
|
||||
// Continue to parent path.
|
||||
}
|
||||
|
||||
const parent = join(current, '..');
|
||||
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return startPath;
|
||||
}
|
||||
|
||||
async function checkPort(port: number) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const socket = new Socket();
|
||||
|
||||
socket.setTimeout(200);
|
||||
|
||||
socket.once('connect', () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
socket.once('timeout', () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.once('error', () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.connect(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { loadPRDs } from '../_lib/server/prd-loader';
|
||||
import { McpServerTabs } from './mcp-server-tabs';
|
||||
import { PRDManagerClient } from './prd-manager-client';
|
||||
|
||||
export async function McpServerInterface() {
|
||||
const initialPrds = await loadPRDs();
|
||||
|
||||
return (
|
||||
<McpServerTabs
|
||||
prdManagerContent={<PRDManagerClient initialPrds={initialPrds} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { DatabaseIcon, FileTextIcon } from 'lucide-react';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs';
|
||||
|
||||
interface McpServerTabsProps {
|
||||
prdManagerContent: React.ReactNode;
|
||||
databaseToolsContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function McpServerTabs({
|
||||
prdManagerContent,
|
||||
databaseToolsContent,
|
||||
}: McpServerTabsProps) {
|
||||
return (
|
||||
<div className="h-full">
|
||||
<Tabs defaultValue="database-tools" className="flex h-full flex-col">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger
|
||||
value="database-tools"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<DatabaseIcon className="h-4 w-4" />
|
||||
Database Tools
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="prd-manager" className="flex items-center gap-2">
|
||||
<FileTextIcon className="h-4 w-4" />
|
||||
PRD Manager
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="database-tools" className="flex-1 space-y-4">
|
||||
{databaseToolsContent || (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<DatabaseIcon className="text-muted-foreground mx-auto h-12 w-12" />
|
||||
<h3 className="mt-4 text-lg font-semibold">Database Tools</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Explore database schemas, tables, functions, and enums
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="prd-manager" className="flex-1 space-y-4">
|
||||
{prdManagerContent}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CalendarIcon, FileTextIcon, PlusIcon, SearchIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Progress } from '@kit/ui/progress';
|
||||
|
||||
import type { CreatePRDData } from '../_lib/schemas/create-prd.schema';
|
||||
import { createPRDAction } from '../_lib/server/prd-server-actions';
|
||||
import { CreatePRDForm } from './create-prd-form';
|
||||
|
||||
interface PRDSummary {
|
||||
filename: string;
|
||||
title: string;
|
||||
lastUpdated: string;
|
||||
progress: number;
|
||||
totalStories: number;
|
||||
completedStories: number;
|
||||
}
|
||||
|
||||
interface PRDManagerClientProps {
|
||||
initialPrds: PRDSummary[];
|
||||
}
|
||||
|
||||
export function PRDManagerClient({ initialPrds }: PRDManagerClientProps) {
|
||||
const [prds, setPrds] = useState<PRDSummary[]>(initialPrds);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
const handleCreatePRD = async (data: CreatePRDData) => {
|
||||
const result = await createPRDAction(data);
|
||||
|
||||
if (result.success && result.data) {
|
||||
const newPRD: PRDSummary = {
|
||||
filename: result.data.filename,
|
||||
title: result.data.title,
|
||||
lastUpdated: result.data.lastUpdated,
|
||||
progress: result.data.progress,
|
||||
totalStories: result.data.totalStories,
|
||||
completedStories: result.data.completedStories,
|
||||
};
|
||||
|
||||
setPrds((prev) => [...prev, newPRD]);
|
||||
setShowCreateForm(false);
|
||||
|
||||
// Note: In a production app, you might want to trigger a router.refresh()
|
||||
// to reload the server component and get the most up-to-date data
|
||||
} else {
|
||||
// Error handling will be managed by the form component via the action result
|
||||
throw new Error(result.error || 'Failed to create PRD');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPrds = prds.filter(
|
||||
(prd) =>
|
||||
prd.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
prd.filename.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with search and create button */}
|
||||
<div className="flex w-full flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative flex-1">
|
||||
<SearchIcon className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
|
||||
<Input
|
||||
placeholder="Search PRDs by title or filename..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Create New PRD
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* PRD List */}
|
||||
{filteredPrds.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex h-32 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<FileTextIcon className="text-muted-foreground mx-auto h-8 w-8" />
|
||||
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{searchTerm ? 'No PRDs match your search' : 'No PRDs found'}
|
||||
</p>
|
||||
|
||||
{!searchTerm && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="mt-2"
|
||||
>
|
||||
Create your first PRD
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredPrds.map((prd) => (
|
||||
<Link
|
||||
key={prd.filename}
|
||||
href={`/mcp-server/prds/${prd.filename}`}
|
||||
className="block"
|
||||
>
|
||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-start gap-2 text-sm">
|
||||
<FileTextIcon className="text-muted-foreground mt-0.5 h-4 w-4" />
|
||||
<span className="line-clamp-2">{prd.title}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Progress */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>Progress</span>
|
||||
<span>
|
||||
{prd.completedStories}/{prd.totalStories} stories
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={prd.progress} className="h-2" />
|
||||
<div className="text-right text-xs font-medium">
|
||||
{prd.progress}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<CalendarIcon className="h-3 w-3" />
|
||||
<span>Updated {prd.lastUpdated}</span>
|
||||
</div>
|
||||
|
||||
{/* Filename */}
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{prd.filename}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create PRD Form Modal */}
|
||||
{showCreateForm && (
|
||||
<Dialog open={showCreateForm} onOpenChange={setShowCreateForm}>
|
||||
<DialogContent className="max-w-4xl overflow-y-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New PRD</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
className="overflow-y-auto p-0.5"
|
||||
style={{
|
||||
maxHeight: '800px',
|
||||
}}
|
||||
>
|
||||
<CreatePRDForm onSubmit={handleCreatePRD} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { withI18n } from '@/lib/i18n/with-i18n';
|
||||
import { DatabaseIcon, FileTextIcon, ServerIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Page, PageBody, PageHeader } from '@kit/ui/page';
|
||||
|
||||
export const metadata = {
|
||||
title: 'MCP Server',
|
||||
description:
|
||||
'MCP Server development interface for database exploration and PRD management',
|
||||
};
|
||||
|
||||
function McpServerPage() {
|
||||
return (
|
||||
<Page style={'custom'}>
|
||||
<div className={'flex h-screen flex-col overflow-hidden'}>
|
||||
<PageHeader
|
||||
displaySidebarTrigger={false}
|
||||
title={'MCP Server'}
|
||||
description={
|
||||
'Access MCP Server tools for database exploration and PRD management.'
|
||||
}
|
||||
/>
|
||||
|
||||
<PageBody className={'overflow-hidden'}>
|
||||
<div className={'flex h-full flex-1 flex-col p-6'}>
|
||||
<div className="space-y-6">
|
||||
{/* Welcome Section */}
|
||||
<div className="text-center">
|
||||
<ServerIcon className="text-muted-foreground mx-auto mb-4 h-16 w-16" />
|
||||
<h2 className="mb-2 text-2xl font-bold">
|
||||
Welcome to MCP Server Tools
|
||||
</h2>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl">
|
||||
Choose from the tools below to explore your database schema or
|
||||
manage your Product Requirements Documents. Use the sidebar
|
||||
navigation for quick access to specific tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tool Cards */}
|
||||
<div className="mx-auto grid max-w-4xl gap-6 md:grid-cols-2">
|
||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<DatabaseIcon className="h-6 w-6" />
|
||||
Database Tools
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
Explore database schemas, tables, functions, and enums
|
||||
through an intuitive interface.
|
||||
</p>
|
||||
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||
<li>• Browse database schemas and their structure</li>
|
||||
<li>• Explore tables with columns and relationships</li>
|
||||
<li>
|
||||
• Discover database functions and their parameters
|
||||
</li>
|
||||
<li>• View enum types and their values</li>
|
||||
</ul>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/mcp-server/database">
|
||||
Open Database Tools
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<FileTextIcon className="h-6 w-6" />
|
||||
PRD Manager
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
Create and manage Product Requirements Documents with user
|
||||
stories and progress tracking.
|
||||
</p>
|
||||
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||
<li>• Create and edit PRDs with structured templates</li>
|
||||
<li>• Manage user stories with priority tracking</li>
|
||||
<li>• Track progress and project status</li>
|
||||
<li>• Export PRDs to markdown format</li>
|
||||
</ul>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/mcp-server/prd">Open PRD Manager</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(McpServerPage);
|
||||
@@ -1,25 +1,37 @@
|
||||
import { EnvMode } from '@/app/variables/lib/types';
|
||||
import { EnvModeSelector } from '@/components/env-mode-selector';
|
||||
import { ServiceCard } from '@/components/status-tile';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Page, PageBody, PageHeader } from '@kit/ui/page';
|
||||
|
||||
import { createConnectivityService } from './lib/connectivity-service';
|
||||
import { loadDashboardKitPrerequisites } from './lib/prerequisites-dashboard.loader';
|
||||
import { loadDashboardKitStatus } from './lib/status-dashboard.loader';
|
||||
|
||||
type DashboardPageProps = React.PropsWithChildren<{
|
||||
searchParams: Promise<{ mode?: EnvMode }>;
|
||||
}>;
|
||||
export default async function DashboardPage() {
|
||||
const [status, prerequisites] = await Promise.all([
|
||||
loadDashboardKitStatus(),
|
||||
loadDashboardKitPrerequisites(),
|
||||
]);
|
||||
|
||||
export default async function DashboardPage(props: DashboardPageProps) {
|
||||
const mode = (await props.searchParams).mode ?? 'development';
|
||||
const connectivityService = createConnectivityService(mode);
|
||||
const failedRequiredCount = prerequisites.prerequisites.filter(
|
||||
(item) => item.required && item.status === 'fail',
|
||||
).length;
|
||||
|
||||
const [supabaseStatus, supabaseAdminStatus, stripeStatus] = await Promise.all(
|
||||
[
|
||||
connectivityService.checkSupabaseConnectivity(),
|
||||
connectivityService.checkSupabaseAdminConnectivity(),
|
||||
connectivityService.checkStripeConnected(),
|
||||
],
|
||||
const warnCount = prerequisites.prerequisites.filter(
|
||||
(item) => item.status === 'warn',
|
||||
).length;
|
||||
|
||||
const failedRequired = prerequisites.prerequisites.filter(
|
||||
(item) => item.required && item.status === 'fail',
|
||||
);
|
||||
|
||||
const prerequisiteRemedies = Array.from(
|
||||
new Set(
|
||||
failedRequired.flatMap((item) => [
|
||||
...(item.remedies ?? []),
|
||||
...(item.install_command ? [item.install_command] : []),
|
||||
...(item.install_url ? [item.install_url] : []),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -27,17 +39,148 @@ export default async function DashboardPage(props: DashboardPageProps) {
|
||||
<PageHeader
|
||||
displaySidebarTrigger={false}
|
||||
title={'Dev Tool'}
|
||||
description={'Check the status of your Supabase and Stripe services'}
|
||||
>
|
||||
<EnvModeSelector mode={mode} />
|
||||
</PageHeader>
|
||||
description={'Kit MCP status for this workspace'}
|
||||
/>
|
||||
|
||||
<PageBody className={'space-y-8 py-2'}>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<ServiceCard name={'Supabase API'} status={supabaseStatus} />
|
||||
<ServiceCard name={'Supabase Admin'} status={supabaseAdminStatus} />
|
||||
<ServiceCard name={'Stripe API'} status={stripeStatus} />
|
||||
<ServiceCard
|
||||
name={'Variant'}
|
||||
status={{
|
||||
status: 'success',
|
||||
message: `${status.variant} (${status.variant_family})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
name={'Runtime'}
|
||||
status={{
|
||||
status: 'success',
|
||||
message: `${status.framework} • Node ${status.node_version} • ${status.package_manager}`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
name={'Dependencies'}
|
||||
status={{
|
||||
status: status.deps_installed ? 'success' : 'error',
|
||||
message: status.deps_installed
|
||||
? 'Dependencies installed'
|
||||
: 'node_modules not found',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
name={'Git'}
|
||||
status={{
|
||||
status:
|
||||
status.git_branch === 'unknown'
|
||||
? 'info'
|
||||
: status.git_clean
|
||||
? 'success'
|
||||
: 'warning',
|
||||
message: `${status.git_branch} (${status.git_clean ? 'clean' : 'dirty'}) • ${status.git_modified_files.length} modified • ${status.git_untracked_files.length} untracked`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
name={'Dev Server'}
|
||||
status={{
|
||||
status: status.services.app.running ? 'success' : 'error',
|
||||
message: status.services.app.running
|
||||
? `Running on port ${status.services.app.port}`
|
||||
: 'Not running',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
name={'Supabase'}
|
||||
status={{
|
||||
status: status.services.supabase.running ? 'success' : 'error',
|
||||
message: status.services.supabase.running
|
||||
? `Running${status.services.supabase.api_port ? ` (API ${status.services.supabase.api_port})` : ''}${status.services.supabase.studio_port ? ` (Studio ${status.services.supabase.studio_port})` : ''}`
|
||||
: 'Not running',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
name={'Merge Check'}
|
||||
status={{
|
||||
status:
|
||||
status.git_merge_check.has_conflicts === true
|
||||
? 'warning'
|
||||
: status.git_merge_check.detectable
|
||||
? 'success'
|
||||
: 'info',
|
||||
message: status.git_merge_check.detectable
|
||||
? status.git_merge_check.has_conflicts
|
||||
? `${status.git_merge_check.conflict_files.length} potential conflicts vs ${status.git_merge_check.target_branch}`
|
||||
: `No conflicts vs ${status.git_merge_check.target_branch}`
|
||||
: status.git_merge_check.message,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
name={'Prerequisites'}
|
||||
status={{
|
||||
status:
|
||||
prerequisites.overall === 'fail'
|
||||
? 'error'
|
||||
: prerequisites.overall === 'warn'
|
||||
? 'warning'
|
||||
: 'success',
|
||||
message:
|
||||
prerequisites.overall === 'fail'
|
||||
? `${failedRequiredCount} required tools missing/mismatched`
|
||||
: prerequisites.overall === 'warn'
|
||||
? `${warnCount} optional warnings`
|
||||
: 'All prerequisites satisfied',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{failedRequired.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Prerequisites Details</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className={'space-y-4'}>
|
||||
<div className={'space-y-2'}>
|
||||
<p className={'text-sm font-medium'}>Missing or Mismatched</p>
|
||||
<ul
|
||||
className={
|
||||
'text-muted-foreground list-disc space-y-1 pl-5 text-sm'
|
||||
}
|
||||
>
|
||||
{failedRequired.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.name}
|
||||
{item.version
|
||||
? ` (installed ${item.version}, requires >= ${item.minimum_version})`
|
||||
: ' (not installed)'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={'space-y-2'}>
|
||||
<p className={'text-sm font-medium'}>Remediation</p>
|
||||
<ul
|
||||
className={
|
||||
'text-muted-foreground list-disc space-y-1 pl-5 text-sm'
|
||||
}
|
||||
>
|
||||
{prerequisiteRemedies.map((remedy) => (
|
||||
<li key={remedy}>
|
||||
<code>{remedy}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</PageBody>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -8,8 +8,8 @@ import { Progress } from '@kit/ui/progress';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs';
|
||||
|
||||
import { UserStoryDisplay } from '../../../_components/user-story-display';
|
||||
import type { PRDData } from '../../../_lib/server/prd-page.loader';
|
||||
import { UserStoryDisplay } from '../../_components/user-story-display';
|
||||
import type { PRDData } from '../../_lib/server/prd-page.loader';
|
||||
|
||||
interface PRDDetailViewProps {
|
||||
filename: string;
|
||||
@@ -2,7 +2,7 @@ import { Metadata } from 'next';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { loadPRDPageData } from '../../_lib/server/prd-page.loader';
|
||||
import { loadPRDPageData } from '../_lib/server/prd-page.loader';
|
||||
import { PRDDetailView } from './_components/prd-detail-view';
|
||||
|
||||
interface PRDPageProps {
|
||||
@@ -79,7 +79,7 @@ export function PRDsListInterface({ initialPrds }: PRDsListInterfaceProps) {
|
||||
{filteredPrds.map((prd) => (
|
||||
<Link
|
||||
key={prd.filename}
|
||||
href={`/mcp-server/prds/${prd.filename}`}
|
||||
href={`/prds/${prd.filename}`}
|
||||
className="block"
|
||||
>
|
||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import { loadPRDs } from '../_lib/server/prd-loader';
|
||||
import { PRDsListInterface } from './_components/prds-list-interface';
|
||||
import { loadPRDs } from './_lib/server/prd-loader';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'PRDs - MCP Server',
|
||||
@@ -33,27 +33,7 @@ import {
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { updateTranslationAction } from '../lib/server-actions';
|
||||
import type { TranslationData, Translations } from '../lib/translations-loader';
|
||||
|
||||
function flattenTranslations(
|
||||
obj: TranslationData,
|
||||
prefix = '',
|
||||
result: Record<string, string> = {},
|
||||
) {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
result[newKey] = value;
|
||||
} else {
|
||||
flattenTranslations(value, newKey, result);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
type FlattenedTranslations = Record<string, Record<string, string>>;
|
||||
import type { Translations } from '../lib/translations-loader';
|
||||
|
||||
export function TranslationsComparison({
|
||||
translations,
|
||||
@@ -74,35 +54,24 @@ export function TranslationsComparison({
|
||||
[],
|
||||
);
|
||||
|
||||
const locales = Object.keys(translations);
|
||||
const baseLocale = locales[0]!;
|
||||
const namespaces = Object.keys(translations[baseLocale] || {});
|
||||
const { base_locale, locales, namespaces } = translations;
|
||||
|
||||
const [selectedLocales, setSelectedLocales] = useState<Set<string>>(
|
||||
new Set(locales),
|
||||
);
|
||||
|
||||
// Flatten translations for the selected namespace
|
||||
const flattenedTranslations: FlattenedTranslations = {};
|
||||
|
||||
const [selectedNamespace, setSelectedNamespace] = useState(
|
||||
namespaces[0] as string,
|
||||
namespaces[0] ?? '',
|
||||
);
|
||||
|
||||
for (const locale of locales) {
|
||||
const namespaceData = translations[locale]?.[selectedNamespace];
|
||||
|
||||
if (namespaceData) {
|
||||
flattenedTranslations[locale] = flattenTranslations(namespaceData);
|
||||
} else {
|
||||
flattenedTranslations[locale] = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Get all unique keys across all translations
|
||||
const allKeys = Array.from(
|
||||
new Set(
|
||||
Object.values(flattenedTranslations).flatMap((data) => Object.keys(data)),
|
||||
locales.flatMap((locale) =>
|
||||
Object.keys(
|
||||
translations.translations[locale]?.[selectedNamespace] ?? {},
|
||||
),
|
||||
),
|
||||
),
|
||||
).sort();
|
||||
|
||||
@@ -143,7 +112,7 @@ export function TranslationsComparison({
|
||||
return () => subscription.unsubscribe();
|
||||
}, [subject$]);
|
||||
|
||||
if (locales.length === 0) {
|
||||
if (locales.length === 0 || !base_locale) {
|
||||
return <div>No translations found</div>;
|
||||
}
|
||||
|
||||
@@ -228,12 +197,16 @@ export function TranslationsComparison({
|
||||
</TableCell>
|
||||
|
||||
{visibleLocales.map((locale) => {
|
||||
const translations = flattenedTranslations[locale] ?? {};
|
||||
const translationsForLocale =
|
||||
translations.translations[locale]?.[selectedNamespace] ??
|
||||
{};
|
||||
|
||||
const baseTranslations =
|
||||
flattenedTranslations[baseLocale] ?? {};
|
||||
translations.translations[base_locale]?.[
|
||||
selectedNamespace
|
||||
] ?? {};
|
||||
|
||||
const value = translations[key];
|
||||
const value = translationsForLocale[key];
|
||||
const baseValue = baseTranslations[key];
|
||||
const isMissing = !value;
|
||||
const isDifferent = value !== baseValue;
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:url';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { findWorkspaceRoot } from '@kit/mcp-server/env';
|
||||
import {
|
||||
createKitTranslationsDeps,
|
||||
createKitTranslationsService,
|
||||
} from '@kit/mcp-server/translations';
|
||||
|
||||
const Schema = z.object({
|
||||
locale: z.string().min(1),
|
||||
namespace: z.string().min(1),
|
||||
@@ -20,40 +24,18 @@ const Schema = z.object({
|
||||
export async function updateTranslationAction(props: z.infer<typeof Schema>) {
|
||||
// Validate the input
|
||||
const { locale, namespace, key, value } = Schema.parse(props);
|
||||
const rootPath = findWorkspaceRoot(process.cwd());
|
||||
|
||||
const root = resolve(process.cwd(), '..');
|
||||
const filePath = `${root}apps/web/public/locales/${locale}/${namespace}.json`;
|
||||
const service = createKitTranslationsService(
|
||||
createKitTranslationsDeps(rootPath),
|
||||
);
|
||||
|
||||
try {
|
||||
// Read the current translations file
|
||||
const translationsFile = readFileSync(filePath, 'utf8');
|
||||
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), 'utf8');
|
||||
const result = await service.update({ locale, namespace, key, value });
|
||||
|
||||
revalidatePath(`/translations`);
|
||||
|
||||
return { success: true };
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to update translation:', error);
|
||||
throw new Error('Failed to update translation');
|
||||
|
||||
@@ -1,50 +1,21 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export type TranslationData = {
|
||||
[key: string]: string | TranslationData;
|
||||
};
|
||||
import { findWorkspaceRoot } from '@kit/mcp-server/env';
|
||||
import {
|
||||
createKitTranslationsDeps,
|
||||
createKitTranslationsService,
|
||||
} from '@kit/mcp-server/translations';
|
||||
|
||||
export type Translations = {
|
||||
[locale: string]: {
|
||||
[namespace: string]: TranslationData;
|
||||
};
|
||||
base_locale: string;
|
||||
locales: string[];
|
||||
namespaces: string[];
|
||||
translations: Record<string, Record<string, Record<string, string>>>;
|
||||
};
|
||||
|
||||
export async function loadTranslations() {
|
||||
const localesPath = join(process.cwd(), '../web/public/locales');
|
||||
const localesDirents = readdirSync(localesPath, { withFileTypes: true });
|
||||
export async function loadTranslations(): Promise<Translations> {
|
||||
const rootPath = findWorkspaceRoot(process.cwd());
|
||||
const service = createKitTranslationsService(
|
||||
createKitTranslationsDeps(rootPath),
|
||||
);
|
||||
|
||||
const locales = localesDirents
|
||||
.filter((dirent) => dirent.isDirectory())
|
||||
.map((dirent) => dirent.name);
|
||||
|
||||
const translations: Translations = {};
|
||||
|
||||
for (const locale of locales) {
|
||||
translations[locale] = {};
|
||||
|
||||
const namespaces = readdirSync(join(localesPath, locale)).filter((file) =>
|
||||
file.endsWith('.json'),
|
||||
);
|
||||
|
||||
for (const namespace of namespaces) {
|
||||
const namespaceName = namespace.replace('.json', '');
|
||||
|
||||
try {
|
||||
const filePath = join(localesPath, locale, namespace);
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
|
||||
translations[locale][namespaceName] = JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Warning: Translation file not found for locale "${locale}" and namespace "${namespaceName}"`,
|
||||
);
|
||||
|
||||
translations[locale][namespaceName] = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return translations;
|
||||
return service.list();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||
import { Page, PageBody, PageHeader } from '@kit/ui/page';
|
||||
|
||||
import { TranslationsComparison } from './components/translations-comparison';
|
||||
|
||||
@@ -1,471 +1,6 @@
|
||||
import 'server-only';
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
import { envVariables } from './env-variables-model';
|
||||
import {
|
||||
AppEnvState,
|
||||
EnvFileInfo,
|
||||
EnvMode,
|
||||
EnvVariableState,
|
||||
ScanOptions,
|
||||
} from './types';
|
||||
|
||||
// Define precedence order for each mode
|
||||
const ENV_FILE_PRECEDENCE: Record<EnvMode, string[]> = {
|
||||
development: [
|
||||
'.env',
|
||||
'.env.development',
|
||||
'.env.local',
|
||||
'.env.development.local',
|
||||
],
|
||||
production: [
|
||||
'.env',
|
||||
'.env.production',
|
||||
'.env.local',
|
||||
'.env.production.local',
|
||||
],
|
||||
};
|
||||
|
||||
function getSourcePrecedence(source: string, mode: EnvMode): number {
|
||||
return ENV_FILE_PRECEDENCE[mode].indexOf(source);
|
||||
}
|
||||
|
||||
export async function scanMonorepoEnv(
|
||||
options: ScanOptions,
|
||||
): Promise<EnvFileInfo[]> {
|
||||
const {
|
||||
rootDir = path.resolve(process.cwd(), '../..'),
|
||||
apps = ['web'],
|
||||
mode,
|
||||
} = options;
|
||||
|
||||
const envTypes = ENV_FILE_PRECEDENCE[mode];
|
||||
const appsDir = path.join(rootDir, 'apps');
|
||||
const results: EnvFileInfo[] = [];
|
||||
|
||||
try {
|
||||
const appDirs = await fs.readdir(appsDir);
|
||||
|
||||
for (const appName of appDirs) {
|
||||
if (apps.length > 0 && !apps.includes(appName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const appDir = path.join(appsDir, appName);
|
||||
const stat = await fs.stat(appDir);
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const appInfo: EnvFileInfo = {
|
||||
appName,
|
||||
filePath: appDir,
|
||||
variables: [],
|
||||
};
|
||||
|
||||
for (const envType of envTypes) {
|
||||
const envPath = path.join(appDir, envType);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(envPath, 'utf-8');
|
||||
const vars = parseEnvFile(content, envType);
|
||||
|
||||
appInfo.variables.push(...vars);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.warn(`Error reading ${envPath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (appInfo.variables.length > 0) {
|
||||
results.push(appInfo);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error scanning monorepo:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function parseEnvFile(content: string, source: string) {
|
||||
const variables: Array<{ key: string; value: string; source: string }> = [];
|
||||
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip comments and empty lines
|
||||
if (line.trim().startsWith('#') || !line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match KEY=VALUE pattern, handling quotes
|
||||
const match = line.match(/^([^=]+)=(.*)$/);
|
||||
if (match) {
|
||||
const [, key = '', rawValue] = match;
|
||||
let value = rawValue ?? '';
|
||||
|
||||
// Remove quotes if present
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
// Handle escaped quotes within the value
|
||||
value = value
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\\\\/g, '\\');
|
||||
|
||||
variables.push({
|
||||
key: key.trim(),
|
||||
value: value.trim(),
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
export function processEnvDefinitions(
|
||||
envInfo: EnvFileInfo,
|
||||
mode: EnvMode,
|
||||
): AppEnvState {
|
||||
const variableMap: Record<string, EnvVariableState> = {};
|
||||
|
||||
// First pass: Collect all definitions
|
||||
for (const variable of envInfo.variables) {
|
||||
if (!variable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const model = envVariables.find((v) => variable.key === v.name);
|
||||
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const varState = variableMap[variable.key];
|
||||
|
||||
if (!varState) {
|
||||
continue;
|
||||
}
|
||||
|
||||
varState.definitions.push({
|
||||
key: variable.key,
|
||||
value: variable.value,
|
||||
source: variable.source,
|
||||
});
|
||||
}
|
||||
|
||||
// Second pass: Determine effective values and override status
|
||||
for (const key in variableMap) {
|
||||
const varState = variableMap[key];
|
||||
|
||||
if (!varState) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sort definitions by mode-specific precedence
|
||||
varState.definitions.sort(
|
||||
(a, b) =>
|
||||
getSourcePrecedence(a.source, mode) -
|
||||
getSourcePrecedence(b.source, mode),
|
||||
);
|
||||
|
||||
if (varState.definitions.length > 1) {
|
||||
const lastDef = varState.definitions[varState.definitions.length - 1];
|
||||
|
||||
if (!lastDef) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const highestPrecedence = getSourcePrecedence(lastDef.source, mode);
|
||||
|
||||
varState.isOverridden = true;
|
||||
varState.effectiveValue = lastDef.value;
|
||||
varState.effectiveSource = lastDef.source;
|
||||
|
||||
// Check for conflicts at highest precedence
|
||||
const conflictingDefs = varState.definitions.filter(
|
||||
(def) => getSourcePrecedence(def.source, mode) === highestPrecedence,
|
||||
);
|
||||
|
||||
if (conflictingDefs.length > 1) {
|
||||
varState.effectiveSource = `${varState.effectiveSource}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
mode,
|
||||
variables: variableMap,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEnvState(
|
||||
options: ScanOptions,
|
||||
): Promise<AppEnvState[]> {
|
||||
const envInfos = await scanMonorepoEnv(options);
|
||||
return envInfos.map((info) => processEnvDefinitions(info, options.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'] });
|
||||
|
||||
// Find the state for the "web" app.
|
||||
const webState = envStates.find((state) => state.appName === 'web');
|
||||
|
||||
// Return the effectiveValue based on override status.
|
||||
return webState?.variables[key]?.effectiveValue ?? '';
|
||||
}
|
||||
export {
|
||||
getEnvState,
|
||||
getVariable,
|
||||
processEnvDefinitions,
|
||||
scanMonorepoEnv,
|
||||
} from '@kit/mcp-server/env';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,83 +2,35 @@
|
||||
|
||||
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';
|
||||
|
||||
import {
|
||||
createKitEnvDeps,
|
||||
createKitEnvService,
|
||||
findWorkspaceRoot,
|
||||
} from '@kit/mcp-server/env';
|
||||
|
||||
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;
|
||||
const rootPath = findWorkspaceRoot(process.cwd());
|
||||
const service = createKitEnvService(createKitEnvDeps(rootPath));
|
||||
|
||||
switch (mode) {
|
||||
case 'development':
|
||||
if (isSecret) {
|
||||
return '.env.local';
|
||||
} else {
|
||||
return '.env.development';
|
||||
}
|
||||
const result = await service.update({
|
||||
key: name,
|
||||
value,
|
||||
mode,
|
||||
});
|
||||
|
||||
case 'production':
|
||||
if (isSecret) {
|
||||
return '.env.production.local';
|
||||
} else {
|
||||
return '.env.production';
|
||||
}
|
||||
revalidatePath('/variables');
|
||||
|
||||
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, '', 'utf8');
|
||||
}
|
||||
|
||||
const sourceEnvFile = readFileSync(`${root}apps/web/${source}`, 'utf8');
|
||||
|
||||
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, 'utf8');
|
||||
|
||||
revalidatePath(`/variables`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Updated ${name} in "${source}"`,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import {
|
||||
processEnvDefinitions,
|
||||
scanMonorepoEnv,
|
||||
} from '@/app/variables/lib/env-scanner';
|
||||
import { EnvMode } from '@/app/variables/lib/types';
|
||||
|
||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||
import {
|
||||
createKitEnvDeps,
|
||||
createKitEnvService,
|
||||
findWorkspaceRoot,
|
||||
} from '@kit/mcp-server/env';
|
||||
import { Page, PageBody, PageHeader } from '@kit/ui/page';
|
||||
|
||||
import { AppEnvironmentVariablesManager } from './components/app-environment-variables-manager';
|
||||
@@ -21,7 +21,7 @@ export const metadata = {
|
||||
|
||||
export default function VariablesPage({ searchParams }: VariablesPageProps) {
|
||||
const { mode = 'development' } = use(searchParams);
|
||||
const apps = use(scanMonorepoEnv({ mode }));
|
||||
const apps = use(loadEnvStates(mode));
|
||||
|
||||
return (
|
||||
<Page style={'custom'}>
|
||||
@@ -36,19 +36,18 @@ export default function VariablesPage({ searchParams }: VariablesPageProps) {
|
||||
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{apps.map((app) => (
|
||||
<AppEnvironmentVariablesManager key={app.appName} state={app} />
|
||||
))}
|
||||
</div>
|
||||
</PageBody>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadEnvStates(mode: EnvMode) {
|
||||
const rootPath = findWorkspaceRoot(process.cwd());
|
||||
const service = createKitEnvService(createKitEnvDeps(rootPath));
|
||||
return service.getAppState(mode);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
LanguagesIcon,
|
||||
LayoutDashboardIcon,
|
||||
MailIcon,
|
||||
ServerIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -55,20 +54,14 @@ const routes = [
|
||||
Icon: LanguagesIcon,
|
||||
},
|
||||
{
|
||||
label: 'MCP Server',
|
||||
Icon: ServerIcon,
|
||||
children: [
|
||||
{
|
||||
label: 'Database',
|
||||
path: '/mcp-server/database',
|
||||
Icon: DatabaseIcon,
|
||||
},
|
||||
{
|
||||
label: 'PRD Manager',
|
||||
path: '/mcp-server/prds',
|
||||
Icon: FileTextIcon,
|
||||
},
|
||||
],
|
||||
label: 'Database',
|
||||
path: '/database',
|
||||
Icon: DatabaseIcon,
|
||||
},
|
||||
{
|
||||
label: 'PRD Manager',
|
||||
path: '/prds',
|
||||
Icon: FileTextIcon,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { AlertCircle, CheckCircle2, XCircle } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Card, CardContent } from '@kit/ui/card';
|
||||
|
||||
export const ServiceStatus = {
|
||||
CHECKING: 'checking',
|
||||
SUCCESS: 'success',
|
||||
WARNING: 'warning',
|
||||
INFO: 'info',
|
||||
ERROR: 'error',
|
||||
} as const;
|
||||
|
||||
@@ -16,6 +17,8 @@ type ServiceStatusType = (typeof ServiceStatus)[keyof typeof ServiceStatus];
|
||||
const StatusIcons = {
|
||||
[ServiceStatus.CHECKING]: <AlertCircle className="h-6 w-6 text-yellow-500" />,
|
||||
[ServiceStatus.SUCCESS]: <CheckCircle2 className="h-6 w-6 text-green-500" />,
|
||||
[ServiceStatus.WARNING]: <AlertCircle className="h-6 w-6 text-amber-500" />,
|
||||
[ServiceStatus.INFO]: <AlertCircle className="h-6 w-6 text-blue-500" />,
|
||||
[ServiceStatus.ERROR]: <XCircle className="h-6 w-6 text-red-500" />,
|
||||
};
|
||||
|
||||
@@ -30,7 +33,7 @@ interface ServiceCardProps {
|
||||
export const ServiceCard = ({ name, status }: ServiceCardProps) => {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardContent className="pt-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"author": "Makerkit",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"dotenv": "17.2.4",
|
||||
|
||||
@@ -22,6 +22,7 @@ app/
|
||||
|
||||
For specialized implementation:
|
||||
- `/feature-builder` - End-to-end feature implementation
|
||||
- `/service-builder` - Server side services
|
||||
- `/server-action-builder` - Server actions
|
||||
- `/forms-builder` - Forms with validation
|
||||
- `/navigation-config` - Adding routes and menu items
|
||||
|
||||
@@ -72,10 +72,10 @@ export async function generateMetadata({
|
||||
url: data.entry.url,
|
||||
images: image
|
||||
? [
|
||||
{
|
||||
url: image,
|
||||
},
|
||||
]
|
||||
{
|
||||
url: image,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
twitter: {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
- **ALWAYS** validate admin status before operations
|
||||
- **NEVER** bypass authentication or authorization
|
||||
- **ALWAYS** audit admin operations with logging
|
||||
- **ALWAYS** use `adminAction` to wrap admin actions @packages/features/admin/src/lib/server/utils/admin-action.ts
|
||||
|
||||
## Page Structure
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export function ErrorPageContent({
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link href={backLink}>
|
||||
<ArrowLeft className={'h-4 w-4 mr-1'} />
|
||||
<ArrowLeft className={'mr-1 h-4 w-4'} />
|
||||
<Trans i18nKey={backLabel} />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -77,7 +77,7 @@ export function ErrorPageContent({
|
||||
|
||||
<Button asChild variant={'ghost'}>
|
||||
<Link href={'/contact'}>
|
||||
<MessageCircleQuestion className={'h-4 w-4 mr-1'} />
|
||||
<MessageCircleQuestion className={'mr-1 h-4 w-4'} />
|
||||
<Trans i18nKey={contactLabel} />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -34,7 +34,7 @@ const config = {
|
||||
fullUrl: true,
|
||||
},
|
||||
},
|
||||
serverExternalPackages: ['pino', 'thread-stream'],
|
||||
serverExternalPackages: [],
|
||||
// needed for supporting dynamic imports for local content
|
||||
outputFileTracingIncludes: {
|
||||
'/*': ['./content/**/*'],
|
||||
|
||||
Reference in New Issue
Block a user