mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: make each plugin an npm workspace built by a termix-plugin CLI
This commit is contained in:
1 parent
4758016f41
commit
5796186a1c
448 files changed
+3087
-2003
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
Bundled with Termix 2.9.0.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Docker
|
||||
|
||||
Container management over SSH: list, inspect, start, stop and view logs for Docker or Podman containers, plus an interactive container console.
|
||||
|
||||
Bundled with Termix and enabled by default.
|
||||
|
||||
## Layout
|
||||
|
||||
manifest.json id, capabilities and what this plugin contributes
|
||||
src/backend/ activate(ctx) / deactivate(), plus the routes
|
||||
src/frontend/ the UI it registers
|
||||
locales/en.json its strings (English only; the rest are translated)
|
||||
tests/backend/ vitest, node
|
||||
tests/frontend/ vitest, jsdom
|
||||
|
||||
## Commands
|
||||
|
||||
npm run build bundle into dist/
|
||||
npm run test run this plugin's tests
|
||||
npm run typecheck
|
||||
|
||||
See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows.
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Docker - first-party, in-process plugin.
|
||||
*
|
||||
* This plugin runs on the main thread rather than in a worker for the same
|
||||
* reason ssh-terminal does (see src/backend/plugins/first-party.ts): its
|
||||
* container-console WebSocket server holds long-lived ssh2.Client + PTY
|
||||
* streams, its session-manager reuses a live ssh2.Client across many REST
|
||||
* calls, and its SSH connect flow implements TOTP/Warpgate as a genuinely
|
||||
* stateful multi-request handshake. None of that can cross the
|
||||
* structured-clone postMessage boundary a worker plugin talks over.
|
||||
*
|
||||
* Unlike ssh-terminal, Docker's backend has no consumers outside itself, so
|
||||
* the whole implementation moved here rather than staying in core with only
|
||||
* a lifecycle wrapper. This file is what used to be
|
||||
* src/backend/hosts/docker/index.ts, rewritten from a module-scope
|
||||
* SIGINT/SIGTERM + listenOnServicePort() call into activate()/deactivate().
|
||||
*
|
||||
* The moved files (routes.ts, container-routes.ts, container-runtime.ts,
|
||||
* session-manager.ts, console.ts) are real TypeScript, compiled by
|
||||
* scripts/copy-bundled-plugins.cjs (see that script for why plugins/ needs
|
||||
* its own compile step) rather than hand-written like ssh-terminal's .mjs
|
||||
* wrapper. This file itself stays plain JS since it is a thin entry point
|
||||
* with no types worth checking.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
/**
|
||||
* Loads a module by its path relative to this file's own directory.
|
||||
*
|
||||
* The plugin ships beside dist/backend, so in a built server the compiled
|
||||
* .js for the moved docker files (routes.js, session-manager.js, ...) sits
|
||||
* right next to this .mjs, and core modules resolve via
|
||||
* ../../../backend/backend/... from there (dist/plugins/docker/backend ->
|
||||
* dist/backend/backend). Under vitest/dev nothing imports this plugin
|
||||
* directly (src/backend/plugins/loader.ts loads in-process plugins with a
|
||||
* plain dynamic import, no tsx loader involved), so the only layout that
|
||||
* needs to resolve here is the built one.
|
||||
*/
|
||||
async function loadRelative(relativePath) {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const target = path.join(here, `${relativePath}.js`);
|
||||
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
|
||||
}
|
||||
|
||||
let httpServer = null;
|
||||
let consoleModule = null;
|
||||
let authManagerInstance = null;
|
||||
|
||||
export async function activate(ctx) {
|
||||
const [
|
||||
express,
|
||||
cookieParser,
|
||||
{ createCorsMiddleware },
|
||||
{ createCompressionMiddleware },
|
||||
{ logger },
|
||||
{ AuthManager },
|
||||
{ registerDockerContainerRoutes },
|
||||
sessionManager,
|
||||
{ DOCKER_TIMESTAMP_RE, getRequestUserId, registerDockerSshRoutes },
|
||||
] = await Promise.all([
|
||||
import("express").then((m) => m.default),
|
||||
import("cookie-parser").then((m) => m.default),
|
||||
loadRelative("../../../backend/backend/utils/cors-config"),
|
||||
loadRelative("../../../backend/backend/utils/compression-config"),
|
||||
loadRelative("../../../backend/backend/utils/logger"),
|
||||
loadRelative("../../../backend/backend/utils/auth-manager"),
|
||||
loadRelative("container-routes"),
|
||||
loadRelative("session-manager"),
|
||||
loadRelative("routes"),
|
||||
]);
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", "loopback");
|
||||
|
||||
app.use(createCompressionMiddleware());
|
||||
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
||||
|
||||
app.use(cookieParser());
|
||||
authManagerInstance = AuthManager.getInstance();
|
||||
app.use(authManagerInstance.createAuthMiddleware());
|
||||
app.use(express.json({ limit: "100mb" }));
|
||||
app.use(express.urlencoded({ limit: "100mb", extended: true }));
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
|
||||
registerDockerSshRoutes(app);
|
||||
registerDockerContainerRoutes(app, {
|
||||
sshSessions: sessionManager.sshSessions,
|
||||
pendingTOTPSessions: sessionManager.pendingTOTPSessions,
|
||||
getRequestUserId,
|
||||
executeDockerCommand: sessionManager.executeDockerCommand,
|
||||
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
|
||||
});
|
||||
|
||||
const port = 30007;
|
||||
const server = createServer(app);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
httpServer = server;
|
||||
|
||||
try {
|
||||
await authManagerInstance.initialize();
|
||||
} catch (err) {
|
||||
logger.error("Failed to initialize Docker backend", err, {
|
||||
operation: "startup",
|
||||
});
|
||||
}
|
||||
|
||||
// Importing console.js no longer binds anything: the server is started
|
||||
// here so disabling the plugin frees port 30009 and re-enabling can take
|
||||
// it again, even though the module stays in the ESM cache.
|
||||
consoleModule = await loadRelative("console");
|
||||
await consoleModule.startConsoleServer();
|
||||
|
||||
ctx.log.info(`Docker plugin listening on ${port} (console on 30009)`);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
if (consoleModule?.closeConsoleServer) {
|
||||
await consoleModule.closeConsoleServer();
|
||||
}
|
||||
consoleModule = null;
|
||||
|
||||
if (httpServer) {
|
||||
const server = httpServer;
|
||||
httpServer = null;
|
||||
await new Promise((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
|
||||
authManagerInstance = null;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -37,8 +37,6 @@
|
||||
"editorTab": "docker"
|
||||
}
|
||||
},
|
||||
"backend": "backend/index.mjs",
|
||||
"frontend": "frontend/index.mjs",
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@termix-plugin/docker",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,35 @@
|
||||
// Core imports below point at TypeScript source so tsc can type-check them.
|
||||
// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled
|
||||
// output path after tsc -p tsconfig.plugins.json runs -- see that script.
|
||||
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
|
||||
// The plugin build rewrites the prefix to the compiled output path; see
|
||||
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
|
||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||
import { StringDecoder } from "string_decoder";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { SSH_ALGORITHMS } from "../../../src/backend/utils/ssh-algorithms.js";
|
||||
import { SSH_ALGORITHMS } from "../../../../src/backend/utils/ssh-algorithms.js";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
||||
import { createCurrentHostResolutionRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||
import { systemLogger } from "../../../src/backend/utils/logger.js";
|
||||
import type { SSHHost } from "../../../src/types/index.js";
|
||||
import { applyAgentAuth } from "../../../src/backend/hosts/terminal-auth-helpers.js";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import { createCurrentHostResolutionRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { systemLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import type { SSHHost } from "../../../../src/types/index.js";
|
||||
import { applyAgentAuth } from "../../../../src/backend/hosts/terminal-auth-helpers.js";
|
||||
import {
|
||||
containerCommand,
|
||||
getContainerRuntimeConfig,
|
||||
type ContainerRuntime,
|
||||
} from "./container-runtime.js";
|
||||
import { resolveSshConnectConfigHost } from "../../../src/backend/hosts/ssh-dns.js";
|
||||
import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js";
|
||||
import {
|
||||
hostAddressMismatch,
|
||||
HOST_ADDRESS_MISMATCH_MESSAGE,
|
||||
HOST_NOT_ON_THIS_SERVER_MESSAGE,
|
||||
} from "../../../src/backend/hosts/host-identity.js";
|
||||
import { extractWebSocketToken } from "../../../src/backend/utils/ws-auth.js";
|
||||
} from "../../../../src/backend/hosts/host-identity.js";
|
||||
import { extractWebSocketToken } from "../../../../src/backend/utils/ws-auth.js";
|
||||
import {
|
||||
asObject,
|
||||
asString,
|
||||
MAX_WS_MESSAGE_BYTES,
|
||||
parseWsMessage,
|
||||
toTerminalDimension,
|
||||
} from "../../../src/backend/utils/ws-message.js";
|
||||
} from "../../../../src/backend/utils/ws-message.js";
|
||||
|
||||
const sshLogger = systemLogger;
|
||||
|
||||
@@ -450,7 +450,7 @@ async function onConsoleConnection(
|
||||
try {
|
||||
// Resolve host with credentials server-side
|
||||
const { resolveHostById, resolveHostBySyncId } =
|
||||
await import("../../../src/backend/hosts/host-resolver.js");
|
||||
await import("../../../../src/backend/hosts/host-resolver.js");
|
||||
// syncId names the host on both sides of a sync pair; the numeric
|
||||
// id only names it in the database the client is displaying.
|
||||
const hostSyncId = hostConfig?.syncId;
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
// Core imports below point at TypeScript source so tsc can type-check them.
|
||||
// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled
|
||||
// output path after tsc -p tsconfig.plugins.json runs -- see that script.
|
||||
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
|
||||
// The plugin build rewrites the prefix to the compiled output path; see
|
||||
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
|
||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||
import type express from "express";
|
||||
import { logger } from "../../../src/backend/utils/logger.js";
|
||||
import { logger } from "../../../../src/backend/utils/logger.js";
|
||||
import {
|
||||
containerCommand,
|
||||
type ContainerRuntime,
|
||||
File renamed without changes.
@@ -0,0 +1,88 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { createCorsMiddleware } from "../../../../src/backend/utils/cors-config.js";
|
||||
import { createCompressionMiddleware } from "../../../../src/backend/utils/compression-config.js";
|
||||
import { logger } from "../../../../src/backend/utils/logger.js";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import { registerDockerContainerRoutes } from "./container-routes.js";
|
||||
import {
|
||||
sshSessions,
|
||||
pendingTOTPSessions,
|
||||
executeDockerCommand,
|
||||
} from "./session-manager.js";
|
||||
import {
|
||||
DOCKER_TIMESTAMP_RE,
|
||||
getRequestUserId,
|
||||
registerDockerSshRoutes,
|
||||
} from "./routes.js";
|
||||
import { startConsoleServer, closeConsoleServer } from "./console.js";
|
||||
|
||||
const PORT = 30007;
|
||||
|
||||
let httpServer: Server | null = null;
|
||||
let authManagerInstance: ReturnType<typeof AuthManager.getInstance> | null =
|
||||
null;
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const app = express();
|
||||
app.set("trust proxy", "loopback");
|
||||
app.use(createCompressionMiddleware());
|
||||
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
||||
app.use(cookieParser());
|
||||
|
||||
authManagerInstance = AuthManager.getInstance();
|
||||
app.use(authManagerInstance.createAuthMiddleware());
|
||||
app.use(express.json({ limit: "100mb" }));
|
||||
app.use(express.urlencoded({ limit: "100mb", extended: true }));
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
|
||||
registerDockerSshRoutes(app);
|
||||
registerDockerContainerRoutes(app, {
|
||||
sshSessions,
|
||||
pendingTOTPSessions,
|
||||
getRequestUserId,
|
||||
executeDockerCommand,
|
||||
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
|
||||
});
|
||||
|
||||
const server = createServer(app);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(PORT, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
httpServer = server;
|
||||
|
||||
try {
|
||||
await authManagerInstance.initialize();
|
||||
} catch (err) {
|
||||
logger.error("Failed to initialize Docker backend", err, {
|
||||
operation: "startup",
|
||||
});
|
||||
}
|
||||
|
||||
// The console server is started here rather than on import so disabling the
|
||||
// plugin frees port 30009 and re-enabling can take it again.
|
||||
await startConsoleServer();
|
||||
|
||||
ctx.log.info(`Docker plugin listening on ${PORT} (console on 30009)`);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
await closeConsoleServer();
|
||||
|
||||
if (httpServer) {
|
||||
const server = httpServer;
|
||||
httpServer = null;
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
|
||||
authManagerInstance = null;
|
||||
}
|
||||
@@ -1,45 +1,45 @@
|
||||
// Core imports below point at TypeScript source so tsc can type-check them.
|
||||
// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled
|
||||
// output path after tsc -p tsconfig.plugins.json runs -- see that script.
|
||||
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
|
||||
import { usesIssuedCertificate } from "../../../src/backend/hosts/issued-certificate-auth.js";
|
||||
// The plugin build rewrites the prefix to the compiled output path; see
|
||||
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
|
||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||
import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js";
|
||||
import express from "express";
|
||||
import axios from "axios";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { logger } from "../../../src/backend/utils/logger.js";
|
||||
import { logger } from "../../../../src/backend/utils/logger.js";
|
||||
import {
|
||||
logAudit,
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
} from "../../../src/backend/utils/audit-logger.js";
|
||||
import { createCurrentHostRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||
import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js";
|
||||
import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js";
|
||||
import { createConnectionLog } from "../../../src/backend/hosts/connection-log.js";
|
||||
import { DataCrypto } from "../../../src/backend/utils/data-crypto.js";
|
||||
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
||||
} from "../../../../src/backend/utils/audit-logger.js";
|
||||
import { createCurrentHostRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js";
|
||||
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
||||
import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js";
|
||||
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import type {
|
||||
AuthenticatedRequest,
|
||||
ProxyNode,
|
||||
SSHHost,
|
||||
} from "../../../src/types/index.js";
|
||||
} from "../../../../src/types/index.js";
|
||||
import {
|
||||
createSocks5Connection,
|
||||
type SOCKS5Config,
|
||||
} from "../../../src/backend/utils/socks5-helper.js";
|
||||
} from "../../../../src/backend/utils/socks5-helper.js";
|
||||
import type {
|
||||
LogEntry,
|
||||
ConnectionStage,
|
||||
} from "../../../src/types/connection-log.js";
|
||||
import { SSHHostKeyVerifier } from "../../../src/backend/hosts/host-key-verifier.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../../../src/backend/utils/ssh-key-utils.js";
|
||||
import { applyAgentAuth } from "../../../src/backend/hosts/terminal-auth-helpers.js";
|
||||
} from "../../../../src/types/connection-log.js";
|
||||
import { SSHHostKeyVerifier } from "../../../../src/backend/hosts/host-key-verifier.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../../../../src/backend/utils/ssh-key-utils.js";
|
||||
import { applyAgentAuth } from "../../../../src/backend/hosts/terminal-auth-helpers.js";
|
||||
import {
|
||||
containerCommand,
|
||||
getContainerRuntimeConfig,
|
||||
getRuntimeLabel,
|
||||
} from "./container-runtime.js";
|
||||
import { resolveSshConnectConfigHost } from "../../../src/backend/hosts/ssh-dns.js";
|
||||
import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js";
|
||||
import {
|
||||
type SSHSession,
|
||||
sshSessions,
|
||||
@@ -307,7 +307,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
|
||||
} else if (usesIssuedCertificate(resolvedCredentials.authType)) {
|
||||
try {
|
||||
const { getOPKSSHToken } =
|
||||
await import("../../../src/backend/hosts/opkssh-auth.js");
|
||||
await import("../../../../src/backend/hosts/opkssh-auth.js");
|
||||
const token = await getOPKSSHToken(userId, hostId);
|
||||
|
||||
if (!token) {
|
||||
@@ -327,7 +327,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
|
||||
}
|
||||
|
||||
const { setupOPKSSHCertAuth } =
|
||||
await import("../../../src/backend/hosts/opkssh-cert-auth.js");
|
||||
await import("../../../../src/backend/hosts/opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(
|
||||
config as import("ssh2").ConnectConfig,
|
||||
client,
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
// Core imports below point at TypeScript source so tsc can type-check them.
|
||||
// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled
|
||||
// output path after tsc -p tsconfig.plugins.json runs -- see that script.
|
||||
// The plugin build rewrites the prefix to the compiled output path; see
|
||||
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { logger } from "../../../src/backend/utils/logger.js";
|
||||
import { logger } from "../../../../src/backend/utils/logger.js";
|
||||
import type { ContainerRuntime } from "./container-runtime.js";
|
||||
|
||||
const sshLogger = logger;
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -46,7 +46,7 @@ export async function register({
|
||||
|
||||
if (registerHostEditorTab) {
|
||||
const { HostDockerTab } =
|
||||
await import("../../../src/ui/sidebar/HostEditorFeatureTabs.tsx");
|
||||
await import("../../../../src/ui/sidebar/HostEditorFeatureTabs.tsx");
|
||||
registerHostEditorTab({
|
||||
id: hostEditorTabId,
|
||||
labelKey: "hosts.tabDocker",
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/types/*": ["../../src/types/*"],
|
||||
"@/*": ["../../src/ui/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
Reference in new issue
Block a user