Files
myeasycms-v2/packages/mcp-server/src/tools/status/kit-status.service.ts
Giancarlo Buomprisco 7ebff31475 Next.js Supabase V3 (#463)
Version 3 of the kit:
- Radix UI replaced with Base UI (using the Shadcn UI patterns)
- next-intl replaces react-i18next
- enhanceAction deprecated; usage moved to next-safe-action
- main layout now wrapped with [locale] path segment
- Teams only mode
- Layout updates
- Zod v4
- Next.js 16.2
- Typescript 6
- All other dependencies updated
- Removed deprecated Edge CSRF
- Dynamic Github Action runner
2026-03-24 13:40:38 +08:00

550 lines
14 KiB
TypeScript

import type { KitStatusInput, KitStatusOutput } from './schema';
import { join } from 'node:path';
interface VariantDescriptor {
variant: string;
variant_family: string;
framework: string;
database: string;
auth: string;
}
interface CommandResult {
stdout: string;
stderr: string;
exitCode: number;
}
interface ServicesStatus {
app: {
running: boolean;
port: number | null;
};
supabase: {
running: boolean;
api_port: number | null;
studio_port: number | null;
};
}
interface MergeCheckStatus {
target_branch: string | null;
detectable: boolean;
has_conflicts: boolean | null;
conflict_files: string[];
message: string;
}
export interface KitStatusDeps {
rootPath: string;
readJsonFile(path: string): Promise<unknown>;
pathExists(path: string): Promise<boolean>;
isDirectory(path: string): Promise<boolean>;
executeCommand(command: string, args: string[]): Promise<CommandResult>;
isPortOpen(port: number): Promise<boolean>;
getNodeVersion(): string;
}
export function createKitStatusService(deps: KitStatusDeps) {
return new KitStatusService(deps);
}
export class KitStatusService {
constructor(private readonly deps: KitStatusDeps) {}
async getStatus(_input: KitStatusInput): Promise<KitStatusOutput> {
const packageJson = await this.readObject('package.json');
const projectName = this.readString(packageJson, 'name') ?? 'unknown';
const packageManager = this.getPackageManager(packageJson);
const depsInstalled = await this.deps.isDirectory('node_modules');
const { gitBranch, gitClean, modifiedFiles, untrackedFiles, mergeCheck } =
await this.getGitStatus();
const variant = await this.resolveVariant();
const services = await this.getServicesStatus();
const diagnostics = this.buildDiagnostics({
depsInstalled,
gitBranch,
gitClean,
mergeCheck,
services,
});
return {
...variant,
project_name: projectName,
node_version: this.deps.getNodeVersion().replace(/^v/, ''),
package_manager: packageManager,
deps_installed: depsInstalled,
git_clean: gitClean,
git_branch: gitBranch,
git_modified_files: modifiedFiles,
git_untracked_files: untrackedFiles,
git_merge_check: mergeCheck,
services,
diagnostics,
};
}
private async getServicesStatus(): Promise<ServicesStatus> {
const app = await this.detectAppService();
const supabase = await this.detectSupabaseService();
return {
app,
supabase,
};
}
private async detectAppService() {
const commonDevPorts = [3000, 3001, 3002, 3003];
for (const port of commonDevPorts) {
if (await this.deps.isPortOpen(port)) {
return {
running: true,
port,
};
}
}
return {
running: false,
port: null,
};
}
private async detectSupabaseService() {
const apiPort = 54321;
const studioPort = 54323;
const [apiRunning, studioRunning] = await Promise.all([
this.deps.isPortOpen(apiPort),
this.deps.isPortOpen(studioPort),
]);
return {
running: apiRunning || studioRunning,
api_port: apiRunning ? apiPort : null,
studio_port: studioRunning ? studioPort : null,
};
}
private buildDiagnostics(params: {
depsInstalled: boolean;
gitBranch: string;
gitClean: boolean;
mergeCheck: MergeCheckStatus;
services: ServicesStatus;
}) {
const diagnostics: KitStatusOutput['diagnostics'] = [];
diagnostics.push({
id: 'dependencies',
status: params.depsInstalled ? 'pass' : 'fail',
message: params.depsInstalled
? 'Dependencies are installed.'
: 'Dependencies are missing.',
remedies: params.depsInstalled ? [] : ['Run pnpm install'],
});
diagnostics.push({
id: 'dev_server',
status: params.services.app.running ? 'pass' : 'fail',
message: params.services.app.running
? `Dev server is running on port ${params.services.app.port}.`
: 'Dev server is not running.',
remedies: params.services.app.running ? [] : ['Run pnpm dev'],
});
diagnostics.push({
id: 'supabase',
status: params.services.supabase.running ? 'pass' : 'fail',
message: params.services.supabase.running
? `Supabase is running${params.services.supabase.api_port ? ` (API ${params.services.supabase.api_port})` : ''}${params.services.supabase.studio_port ? ` (Studio ${params.services.supabase.studio_port})` : ''}.`
: 'Supabase is not running.',
remedies: params.services.supabase.running
? []
: ['Run pnpm supabase:web:start'],
});
diagnostics.push({
id: 'git',
status:
params.gitBranch === 'unknown'
? 'warn'
: params.gitClean
? 'pass'
: 'warn',
message:
params.gitBranch === 'unknown'
? 'Git status unavailable.'
: `Current branch ${params.gitBranch} is ${params.gitClean ? 'clean' : 'dirty'}.`,
remedies:
params.gitBranch === 'unknown' || params.gitClean
? []
: ['Commit or stash changes when you need a clean workspace'],
});
diagnostics.push({
id: 'merge_conflicts',
status:
params.mergeCheck.has_conflicts === true
? 'warn'
: params.mergeCheck.detectable
? 'pass'
: 'warn',
message: params.mergeCheck.message,
remedies:
params.mergeCheck.has_conflicts === true
? [
`Rebase or merge ${params.mergeCheck.target_branch} and resolve conflicts`,
]
: [],
});
return diagnostics;
}
private async getGitStatus() {
try {
const branchResult = await this.deps.executeCommand('git', [
'rev-parse',
'--abbrev-ref',
'HEAD',
]);
const statusResult = await this.deps.executeCommand('git', [
'status',
'--porcelain',
]);
const parsedStatus = this.parseGitStatus(statusResult.stdout);
const mergeCheck = await this.getMergeCheck();
return {
gitBranch: branchResult.stdout.trim() || 'unknown',
gitClean:
parsedStatus.modifiedFiles.length === 0 &&
parsedStatus.untrackedFiles.length === 0,
modifiedFiles: parsedStatus.modifiedFiles,
untrackedFiles: parsedStatus.untrackedFiles,
mergeCheck,
};
} catch {
return {
gitBranch: 'unknown',
gitClean: false,
modifiedFiles: [],
untrackedFiles: [],
mergeCheck: {
target_branch: null,
detectable: false,
has_conflicts: null,
conflict_files: [],
message: 'Git metadata unavailable.',
} satisfies MergeCheckStatus,
};
}
}
private parseGitStatus(output: string) {
const modifiedFiles: string[] = [];
const untrackedFiles: string[] = [];
const lines = output.split('\n').filter((line) => line.trim().length > 0);
for (const line of lines) {
if (line.startsWith('?? ')) {
const path = line.slice(3).trim();
if (path) {
untrackedFiles.push(path);
}
continue;
}
if (line.length >= 4) {
const path = line.slice(3).trim();
if (path) {
modifiedFiles.push(path);
}
}
}
return {
modifiedFiles,
untrackedFiles,
};
}
private async getMergeCheck(): Promise<MergeCheckStatus> {
const targetBranch = await this.resolveMergeTargetBranch();
if (!targetBranch) {
return {
target_branch: null,
detectable: false,
has_conflicts: null,
conflict_files: [],
message: 'No default target branch found for merge conflict checks.',
};
}
try {
const mergeBaseResult = await this.deps.executeCommand('git', [
'merge-base',
'HEAD',
targetBranch,
]);
const mergeBase = mergeBaseResult.stdout.trim();
if (!mergeBase) {
return {
target_branch: targetBranch,
detectable: false,
has_conflicts: null,
conflict_files: [],
message: 'Unable to compute merge base.',
};
}
const mergeTreeResult = await this.deps.executeCommand('git', [
'merge-tree',
mergeBase,
'HEAD',
targetBranch,
]);
const rawOutput = `${mergeTreeResult.stdout}\n${mergeTreeResult.stderr}`;
const conflictFiles = this.extractConflictFiles(rawOutput);
const hasConflictMarkers =
/CONFLICT|changed in both|both modified|both added/i.test(rawOutput);
const hasConflicts = conflictFiles.length > 0 || hasConflictMarkers;
return {
target_branch: targetBranch,
detectable: true,
has_conflicts: hasConflicts,
conflict_files: conflictFiles,
message: hasConflicts
? `Potential merge conflicts detected against ${targetBranch}.`
: `No merge conflicts detected against ${targetBranch}.`,
};
} catch {
return {
target_branch: targetBranch,
detectable: false,
has_conflicts: null,
conflict_files: [],
message: 'Merge conflict detection is not available in this git setup.',
};
}
}
private extractConflictFiles(rawOutput: string) {
const files = new Set<string>();
const lines = rawOutput.split('\n');
for (const line of lines) {
const conflictMatch = line.match(/CONFLICT .* in (.+)$/);
if (conflictMatch?.[1]) {
files.add(conflictMatch[1].trim());
}
}
return Array.from(files).sort((a, b) => a.localeCompare(b));
}
private async resolveMergeTargetBranch() {
try {
const originHead = await this.deps.executeCommand('git', [
'symbolic-ref',
'--quiet',
'--short',
'refs/remotes/origin/HEAD',
]);
const value = originHead.stdout.trim();
if (value) {
return value.replace(/^origin\//, '');
}
} catch {
// Fallback candidates below.
}
for (const candidate of ['main', 'master']) {
try {
await this.deps.executeCommand('git', [
'rev-parse',
'--verify',
candidate,
]);
return candidate;
} catch {
// Try next.
}
}
return null;
}
private async resolveVariant(): Promise<VariantDescriptor> {
const explicitVariant = await this.resolveConfiguredVariant();
if (explicitVariant) {
return explicitVariant;
}
const heuristicVariant = await this.resolveHeuristicVariant();
if (heuristicVariant) {
return heuristicVariant;
}
return this.mapVariant('next-supabase');
}
private async resolveConfiguredVariant(): Promise<VariantDescriptor | null> {
const configPath = '.makerkit/config.json';
if (!(await this.deps.pathExists(configPath))) {
return null;
}
const config = await this.readObject(configPath);
const value =
this.readString(config, 'variant') ??
this.readString(config, 'template') ??
this.readString(config, 'kitVariant');
if (!value) {
return null;
}
return this.mapVariant(value, {
preserveVariant: true,
});
}
private async resolveHeuristicVariant(): Promise<VariantDescriptor | null> {
const hasSupabaseFolder = await this.deps.pathExists('apps/web/supabase');
if (!hasSupabaseFolder) {
return null;
}
const appPackage = await this.readObject(
join('apps', 'web', 'package.json'),
);
const hasNextDependency = this.hasDependency(appPackage, 'next');
if (hasNextDependency) {
return this.mapVariant('next-supabase');
}
return null;
}
private hasDependency(json: Record<string, unknown>, dependency: string) {
const dependencies = this.readObjectValue(json, 'dependencies');
const devDependencies = this.readObjectValue(json, 'devDependencies');
return Boolean(
this.readString(dependencies, dependency) ||
this.readString(devDependencies, dependency),
);
}
private mapVariant(
variant: string,
options: {
preserveVariant?: boolean;
} = {},
): VariantDescriptor {
if (variant === 'next-drizzle') {
return {
variant,
variant_family: 'orm',
framework: 'nextjs',
database: 'postgresql',
auth: 'better-auth',
};
}
if (variant === 'next-prisma') {
return {
variant,
variant_family: 'orm',
framework: 'nextjs',
database: 'postgresql',
auth: 'better-auth',
};
}
if (variant === 'react-router-supabase') {
return {
variant,
variant_family: 'supabase',
framework: 'react-router',
database: 'supabase',
auth: 'supabase',
};
}
return {
variant: options.preserveVariant ? variant : 'next-supabase',
variant_family: 'supabase',
framework: 'nextjs',
database: 'supabase',
auth: 'supabase',
};
}
private async readObject(path: string): Promise<Record<string, unknown>> {
try {
const value = await this.deps.readJsonFile(path);
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
} catch {
return {};
}
}
private readString(obj: Record<string, unknown>, key: string) {
const value = obj[key];
return typeof value === 'string' && value.length > 0 ? value : null;
}
private readObjectValue(obj: Record<string, unknown>, key: string) {
const value = obj[key];
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
private getPackageManager(packageJson: Record<string, unknown>) {
const packageManager = this.readString(packageJson, 'packageManager');
if (!packageManager) {
return 'unknown';
}
const [name] = packageManager.split('@');
return name || 'unknown';
}
}