Phase5
This commit is contained in:
@@ -9,13 +9,14 @@
|
||||
"start:prod": "node dist/main.js",
|
||||
"lint": "node -e \"process.exit(0)\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsc && node --test test/**/*.test.js"
|
||||
"test": "tsc -p tsconfig.build.json && node --test test/**/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/static": "^8.0.4",
|
||||
"@fastify/websocket": "^11.0.2",
|
||||
"@hexahost/auth": "workspace:*",
|
||||
"@hexahost/catalog": "workspace:*",
|
||||
"@hexahost/config": "workspace:*",
|
||||
"@hexahost/contracts": "workspace:*",
|
||||
"@hexahost/database": "workspace:*",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { CatalogModule } from './catalog/catalog.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { NodesModule } from './nodes/nodes.module';
|
||||
import { ServersModule } from './servers/servers.module';
|
||||
@@ -40,6 +41,7 @@ import { LoggerModule } from 'nestjs-pino';
|
||||
AppConfigModule,
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
CatalogModule,
|
||||
ServersModule,
|
||||
NodesModule,
|
||||
AdminModule,
|
||||
|
||||
67
apps/api/src/catalog/catalog.controller.ts
Normal file
67
apps/api/src/catalog/catalog.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { BadRequestException, Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { catalogSearchQuerySchema } from '@hexahost/contracts';
|
||||
|
||||
import { SessionGuard } from '../auth/guards/session.guard';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
import { CatalogService } from './catalog.service';
|
||||
|
||||
@ApiTags('catalog')
|
||||
@Controller('catalog')
|
||||
@UseGuards(SessionGuard)
|
||||
export class CatalogController {
|
||||
constructor(
|
||||
private readonly catalogService: CatalogService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get('projects')
|
||||
@ApiOperation({ summary: 'Search Modrinth projects' })
|
||||
search(@Query() query: Record<string, string | undefined>) {
|
||||
const input = catalogSearchQuerySchema.parse({
|
||||
q: query['q'],
|
||||
gameVersion: query['gameVersion'],
|
||||
softwareFamily: query['softwareFamily'],
|
||||
limit: query['limit'],
|
||||
});
|
||||
|
||||
return this.catalogService.searchProjects({
|
||||
query: input.q,
|
||||
gameVersion: input.gameVersion,
|
||||
softwareFamily: input.softwareFamily,
|
||||
limit: input.limit,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('projects/:projectId/versions')
|
||||
@ApiOperation({ summary: 'List Modrinth project versions' })
|
||||
async versions(
|
||||
@Param('projectId') projectId: string,
|
||||
@Query('serverId') serverId?: string,
|
||||
@Query('gameVersion') gameVersion?: string,
|
||||
@Query('softwareFamily') softwareFamily?: string,
|
||||
) {
|
||||
let resolvedGameVersion = gameVersion;
|
||||
let resolvedSoftwareFamily = softwareFamily;
|
||||
|
||||
if (serverId) {
|
||||
const server = await this.prisma.gameServer.findUniqueOrThrow({
|
||||
where: { id: serverId },
|
||||
});
|
||||
resolvedGameVersion = server.minecraftVersion;
|
||||
resolvedSoftwareFamily = server.softwareFamily;
|
||||
}
|
||||
|
||||
if (!resolvedGameVersion || !resolvedSoftwareFamily) {
|
||||
throw new BadRequestException('gameVersion and softwareFamily are required');
|
||||
}
|
||||
|
||||
return this.catalogService.listProjectVersions(
|
||||
projectId,
|
||||
resolvedGameVersion,
|
||||
resolvedSoftwareFamily as 'PAPER' | 'FABRIC' | 'PURPUR' | 'FORGE' | 'NEOFORGE' | 'QUILT',
|
||||
);
|
||||
}
|
||||
}
|
||||
15
apps/api/src/catalog/catalog.module.ts
Normal file
15
apps/api/src/catalog/catalog.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
import { CatalogController } from './catalog.controller';
|
||||
import { CatalogService } from './catalog.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule],
|
||||
controllers: [CatalogController],
|
||||
providers: [CatalogService],
|
||||
exports: [CatalogService],
|
||||
})
|
||||
export class CatalogModule {}
|
||||
81
apps/api/src/catalog/catalog.service.ts
Normal file
81
apps/api/src/catalog/catalog.service.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
CatalogSearchResponse,
|
||||
CatalogVersionsResponse,
|
||||
} from '@hexahost/contracts';
|
||||
import {
|
||||
getModrinthProjectVersions,
|
||||
isVersionCompatible,
|
||||
resolveModrinthLoader,
|
||||
resolveProjectType,
|
||||
searchModrinthProjects,
|
||||
type SoftwareFamily,
|
||||
} from '@hexahost/catalog';
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
async searchProjects(input: {
|
||||
query: string;
|
||||
gameVersion: string;
|
||||
softwareFamily: SoftwareFamily;
|
||||
limit?: number;
|
||||
}): Promise<CatalogSearchResponse> {
|
||||
const projectType = resolveProjectType(input.softwareFamily);
|
||||
const loader = resolveModrinthLoader(input.softwareFamily);
|
||||
|
||||
if (!projectType || !loader) {
|
||||
throw new BadRequestException(
|
||||
'This server software does not support catalog add-ons',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await searchModrinthProjects({
|
||||
query: input.query,
|
||||
projectType,
|
||||
gameVersion: input.gameVersion,
|
||||
loader,
|
||||
limit: input.limit,
|
||||
});
|
||||
|
||||
return {
|
||||
projects: result.hits.map((project) => ({
|
||||
id: project.project_id,
|
||||
slug: project.slug,
|
||||
name: project.title,
|
||||
description: project.description,
|
||||
projectType: project.project_type,
|
||||
downloads: project.downloads,
|
||||
iconUrl: project.icon_url ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async listProjectVersions(
|
||||
projectId: string,
|
||||
gameVersion: string,
|
||||
softwareFamily: SoftwareFamily,
|
||||
): Promise<CatalogVersionsResponse> {
|
||||
const loader = resolveModrinthLoader(softwareFamily);
|
||||
if (!loader) {
|
||||
throw new BadRequestException(
|
||||
'This server software does not support catalog add-ons',
|
||||
);
|
||||
}
|
||||
|
||||
const versions = await getModrinthProjectVersions(projectId);
|
||||
|
||||
return {
|
||||
versions: versions.map((version) => ({
|
||||
id: version.id,
|
||||
projectId: version.project_id,
|
||||
name: version.name,
|
||||
versionNumber: version.version_number,
|
||||
gameVersions: version.game_versions,
|
||||
loaders: version.loaders,
|
||||
compatible: isVersionCompatible(version, gameVersion, loader),
|
||||
publishedAt: version.date_published,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -327,7 +327,9 @@ export class NodesService {
|
||||
normalized === 'world.archive' ||
|
||||
normalized === 'world.replace' ||
|
||||
normalized === 'storage.upload' ||
|
||||
normalized === 'storage.download'
|
||||
normalized === 'storage.download' ||
|
||||
normalized === 'addon.install' ||
|
||||
normalized === 'addon.remove'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
56
apps/api/src/servers/addons/addons.controller.ts
Normal file
56
apps/api/src/servers/addons/addons.controller.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import type { AddonListResponse, InstallAddonResponse } from '@hexahost/contracts';
|
||||
import { installAddonRequestSchema } from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../../auth/guards/session.guard';
|
||||
|
||||
import { AddonsService } from './addons.service';
|
||||
|
||||
@ApiTags('servers')
|
||||
@Controller('servers/:serverId/addons')
|
||||
@UseGuards(SessionGuard)
|
||||
export class AddonsController {
|
||||
constructor(private readonly addonsService: AddonsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List installed add-ons' })
|
||||
list(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
): Promise<AddonListResponse> {
|
||||
return this.addonsService.listAddons(user.id, serverId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Install an add-on from the catalog' })
|
||||
install(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
const input = installAddonRequestSchema.parse(body);
|
||||
return this.addonsService.installAddon(user.id, serverId, input);
|
||||
}
|
||||
|
||||
@Delete(':addonId')
|
||||
@ApiOperation({ summary: 'Remove an installed add-on' })
|
||||
remove(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Param('addonId', ParseUUIDPipe) addonId: string,
|
||||
): Promise<InstallAddonResponse> {
|
||||
return this.addonsService.removeAddon(user.id, serverId, addonId);
|
||||
}
|
||||
}
|
||||
34
apps/api/src/servers/addons/addons.module.ts
Normal file
34
apps/api/src/servers/addons/addons.module.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { ServerStateService } from '../server-state.service';
|
||||
|
||||
import { AddonsController } from './addons.controller';
|
||||
import { AddonsService, SERVER_ADDONS_QUEUE } from './addons.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule],
|
||||
controllers: [AddonsController],
|
||||
providers: [
|
||||
AddonsService,
|
||||
ServerStateService,
|
||||
{
|
||||
provide: SERVER_ADDONS_QUEUE,
|
||||
useFactory: () => {
|
||||
const config = getConfig();
|
||||
return new Queue('server-addons', {
|
||||
connection: {
|
||||
url: config.REDIS_URL,
|
||||
maxRetriesPerRequest: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [AddonsService],
|
||||
})
|
||||
export class AddonsModule {}
|
||||
229
apps/api/src/servers/addons/addons.service.ts
Normal file
229
apps/api/src/servers/addons/addons.service.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
import type {
|
||||
AddonListResponse,
|
||||
InstallAddonRequest,
|
||||
InstallAddonResponse,
|
||||
InstalledAddon,
|
||||
} from '@hexahost/contracts';
|
||||
import type { InstalledAddon as InstalledAddonRecord } from '@hexahost/database';
|
||||
import {
|
||||
getModrinthProject,
|
||||
getModrinthVersion,
|
||||
isVersionCompatible,
|
||||
pickPrimaryFile,
|
||||
resolveInstallDirectory,
|
||||
resolveModrinthLoader,
|
||||
resolveProjectType,
|
||||
} from '@hexahost/catalog';
|
||||
|
||||
import { AuditService } from '../../auth/audit.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { findOwnedServer } from '../shared/server-ownership.util';
|
||||
import { ServerStateService } from '../server-state.service';
|
||||
|
||||
export const SERVER_ADDONS_QUEUE = Symbol('SERVER_ADDONS_QUEUE');
|
||||
|
||||
const STOPPED_ONLY = new Set(['STOPPED']);
|
||||
|
||||
@Injectable()
|
||||
export class AddonsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly serverState: ServerStateService,
|
||||
private readonly auditService: AuditService,
|
||||
@Inject(SERVER_ADDONS_QUEUE) private readonly addonsQueue: Queue,
|
||||
) {}
|
||||
|
||||
async listAddons(userId: string, serverId: string): Promise<AddonListResponse> {
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
const addons = await this.prisma.installedAddon.findMany({
|
||||
where: {
|
||||
serverId,
|
||||
status: { not: 'REMOVED' },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return { addons: addons.map((addon) => this.toResponse(addon)) };
|
||||
}
|
||||
|
||||
async installAddon(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
input: InstallAddonRequest,
|
||||
): Promise<InstallAddonResponse> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
this.serverState.assertNotInProgress(server.status);
|
||||
|
||||
if (!server.nodeId) {
|
||||
throw new BadRequestException('Server is not assigned to a node');
|
||||
}
|
||||
|
||||
if (!STOPPED_ONLY.has(server.status)) {
|
||||
throw new BadRequestException(
|
||||
'Addons can only be installed while the server is STOPPED',
|
||||
);
|
||||
}
|
||||
|
||||
const projectType = resolveProjectType(server.softwareFamily);
|
||||
const loader = resolveModrinthLoader(server.softwareFamily);
|
||||
|
||||
if (!projectType || !loader) {
|
||||
throw new BadRequestException(
|
||||
'This server software does not support add-on installation',
|
||||
);
|
||||
}
|
||||
|
||||
const version = await getModrinthVersion(input.versionId);
|
||||
if (version.project_id !== input.projectId) {
|
||||
throw new BadRequestException('Version does not belong to the selected project');
|
||||
}
|
||||
|
||||
if (!isVersionCompatible(version, server.minecraftVersion, loader)) {
|
||||
throw new BadRequestException(
|
||||
'This version is not compatible with your server version and loader',
|
||||
);
|
||||
}
|
||||
|
||||
const file = pickPrimaryFile(version);
|
||||
if (!file) {
|
||||
throw new BadRequestException('No installable file found for this version');
|
||||
}
|
||||
|
||||
const project = await getModrinthProject(input.projectId);
|
||||
const projectName = project.title;
|
||||
const projectSlug = project.slug;
|
||||
|
||||
const installDir = resolveInstallDirectory(projectType);
|
||||
const filePath = `${installDir}/${file.filename}`;
|
||||
|
||||
const existing = await this.prisma.installedAddon.findFirst({
|
||||
where: {
|
||||
serverId,
|
||||
projectId: input.projectId,
|
||||
status: { in: ['PENDING', 'INSTALLING', 'INSTALLED'] },
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('This project is already installed on the server');
|
||||
}
|
||||
|
||||
const addonType =
|
||||
projectType === 'plugin'
|
||||
? 'PLUGIN'
|
||||
: projectType === 'mod'
|
||||
? 'MOD'
|
||||
: projectType === 'modpack'
|
||||
? 'MODPACK'
|
||||
: 'DATAPACK';
|
||||
|
||||
const addon = await this.prisma.installedAddon.create({
|
||||
data: {
|
||||
serverId,
|
||||
projectId: input.projectId,
|
||||
projectSlug,
|
||||
projectName,
|
||||
versionId: input.versionId,
|
||||
versionNumber: version.version_number,
|
||||
addonType,
|
||||
fileName: file.filename,
|
||||
filePath,
|
||||
fileSha512: file.hashes['sha512'] ?? null,
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
const job = await this.addonsQueue.add('install-addon', {
|
||||
addonId: addon.id,
|
||||
serverId,
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'server.addon.install',
|
||||
userId,
|
||||
entityType: 'game_server',
|
||||
entityId: serverId,
|
||||
metadata: {
|
||||
addonId: addon.id,
|
||||
projectId: input.projectId,
|
||||
versionId: input.versionId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
addon: this.toResponse(addon),
|
||||
jobId: job.id?.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async removeAddon(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
addonId: string,
|
||||
): Promise<InstallAddonResponse> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
this.serverState.assertNotInProgress(server.status);
|
||||
|
||||
if (!STOPPED_ONLY.has(server.status)) {
|
||||
throw new BadRequestException(
|
||||
'Addons can only be removed while the server is STOPPED',
|
||||
);
|
||||
}
|
||||
|
||||
const addon = await this.prisma.installedAddon.findFirstOrThrow({
|
||||
where: { id: addonId, serverId },
|
||||
});
|
||||
|
||||
await this.prisma.installedAddon.update({
|
||||
where: { id: addonId },
|
||||
data: { status: 'REMOVING' },
|
||||
});
|
||||
|
||||
const job = await this.addonsQueue.add('remove-addon', {
|
||||
addonId,
|
||||
serverId,
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'server.addon.remove',
|
||||
userId,
|
||||
entityType: 'game_server',
|
||||
entityId: serverId,
|
||||
metadata: { addonId },
|
||||
});
|
||||
|
||||
return {
|
||||
addon: this.toResponse({ ...addon, status: 'REMOVING' }),
|
||||
jobId: job.id?.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toResponse(addon: InstalledAddonRecord): InstalledAddon {
|
||||
return {
|
||||
id: addon.id,
|
||||
serverId: addon.serverId,
|
||||
provider: addon.provider,
|
||||
projectId: addon.projectId,
|
||||
projectSlug: addon.projectSlug,
|
||||
projectName: addon.projectName,
|
||||
versionId: addon.versionId,
|
||||
versionNumber: addon.versionNumber,
|
||||
addonType: addon.addonType as InstalledAddon['addonType'],
|
||||
fileName: addon.fileName,
|
||||
filePath: addon.filePath,
|
||||
status: addon.status as InstalledAddon['status'],
|
||||
failureReason: addon.failureReason,
|
||||
installedAt: addon.installedAt?.toISOString() ?? null,
|
||||
createdAt: addon.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { NodeBridgeModule } from '../nodes/node-bridge.module';
|
||||
import { AppConfigModule } from '../config/app-config.module';
|
||||
|
||||
import { ConsoleModule } from './console/console.module';
|
||||
import { AddonsModule } from './addons/addons.module';
|
||||
import { BackupsModule } from './backups/backups.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { WorldsModule } from './worlds/worlds.module';
|
||||
@@ -30,6 +31,7 @@ import { PlansService } from './plans.service';
|
||||
AppConfigModule,
|
||||
AuthModule,
|
||||
forwardRef(() => ConsoleModule),
|
||||
forwardRef(() => AddonsModule),
|
||||
forwardRef(() => BackupsModule),
|
||||
forwardRef(() => WorldsModule),
|
||||
forwardRef(() => FilesModule),
|
||||
|
||||
42
apps/api/test/phase5.test.js
Normal file
42
apps/api/test/phase5.test.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
describe('phase 5 api contract', () => {
|
||||
it('documents catalog and add-ons routes', () => {
|
||||
const files = [
|
||||
'catalog/catalog.controller.ts',
|
||||
'servers/addons/addons.controller.ts',
|
||||
'servers/addons/addons.service.ts',
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
const source = readFileSync(join(process.cwd(), 'src', file), 'utf8');
|
||||
assert.ok(source.length > 0, `${file} should exist`);
|
||||
}
|
||||
|
||||
const catalogSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'catalog/catalog.controller.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(catalogSource.includes("@Get('projects')"));
|
||||
assert.ok(catalogSource.includes('projects/:projectId/versions'));
|
||||
|
||||
const addonsSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'servers/addons/addons.controller.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(addonsSource.includes('servers/:serverId/addons'));
|
||||
assert.ok(addonsSource.includes('@Post()'));
|
||||
assert.ok(addonsSource.includes("@Delete(':addonId')"));
|
||||
|
||||
const serviceSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'servers/addons/addons.service.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(serviceSource.includes('installAddon'));
|
||||
assert.ok(serviceSource.includes('removeAddon'));
|
||||
assert.ok(serviceSource.includes('AddonListResponse'));
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -3,7 +3,8 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./"
|
||||
"baseUrl": "./",
|
||||
"incremental": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user