mirror of
https://github.com/fccview/cronmaster.git
synced 2025-12-23 22:18:20 -05:00
remove the annoying HOST_PROJECT_DIR and DOCKER env variables
This commit is contained in:
@@ -22,7 +22,8 @@ import { CloneTaskModal } from "@/app/_components/FeatureComponents/Modals/Clone
|
||||
import { UserFilter } from "@/app/_components/FeatureComponents/User/UserFilter";
|
||||
import { ErrorBadge } from "@/app/_components/GlobalComponents/Badges/ErrorBadge";
|
||||
import { ErrorDetailsModal } from "@/app/_components/FeatureComponents/Modals/ErrorDetailsModal";
|
||||
import { type Script } from "@/app/_server/actions/scripts";
|
||||
import { Script } from "@/app/_utils/scriptScanner";
|
||||
|
||||
import {
|
||||
getJobErrorsByJobId,
|
||||
JobError,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { CronJobList } from "@/app/_components/FeatureComponents/Cronjobs/CronJobList";
|
||||
import { ScriptsManager } from "@/app/_components/FeatureComponents/Scripts/ScriptsManager";
|
||||
import { CronJob } from "@/app/_utils/system";
|
||||
import { type Script } from "@/app/_server/actions/scripts";
|
||||
import { Script } from "@/app/_utils/scriptScanner";
|
||||
import { Clock, FileText } from "lucide-react";
|
||||
|
||||
interface TabbedInterfaceProps {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Copy } from "lucide-react";
|
||||
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
|
||||
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
|
||||
import { Input } from "@/app/_components/GlobalComponents/FormElements/Input";
|
||||
import { type Script } from "@/app/_server/actions/scripts";
|
||||
import { Script } from "@/app/_utils/scriptScanner";
|
||||
|
||||
interface CloneScriptModalProps {
|
||||
script: Script | null;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
|
||||
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
|
||||
import { FileText, AlertCircle, Trash2 } from "lucide-react";
|
||||
import { type Script } from "@/app/_server/actions/scripts";
|
||||
import { Script } from "@/app/_utils/scriptScanner";
|
||||
|
||||
interface DeleteScriptModalProps {
|
||||
script: Script | null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Edit } from "lucide-react";
|
||||
import { type Script } from "@/app/_server/actions/scripts";
|
||||
import { Script } from "@/app/_utils/scriptScanner";
|
||||
import { ScriptModal } from "@/app/_components/FeatureComponents/Modals/ScriptModal";
|
||||
|
||||
interface EditScriptModalProps {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
|
||||
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
|
||||
import { Input } from "@/app/_components/GlobalComponents/FormElements/Input";
|
||||
import { FileText, Search, Check, Terminal } from "lucide-react";
|
||||
import { type Script } from "@/app/_server/actions/scripts";
|
||||
import { Script } from "@/app/_utils/scriptScanner";
|
||||
import { getScriptContent } from "@/app/_server/actions/scripts";
|
||||
import { getHostScriptPath } from "@/app/_utils/scripts";
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
CheckCircle,
|
||||
Files,
|
||||
} from "lucide-react";
|
||||
import { type Script } from "@/app/_server/actions/scripts";
|
||||
import { Script } from "@/app/_utils/scriptScanner";
|
||||
import {
|
||||
createScript,
|
||||
updateScript,
|
||||
|
||||
5
app/_consts/file.ts
Normal file
5
app/_consts/file.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import path from "path";
|
||||
|
||||
export const SCRIPTS_DIR = path.join("scripts");
|
||||
export const SNIPPETS_DIR = path.join("snippets");
|
||||
export const DATA_DIR = path.join("data");
|
||||
3
app/_consts/nsenter.ts
Normal file
3
app/_consts/nsenter.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const NSENTER_RUN_JOB = (executionUser: string, escapedCommand: string) => `nsenter -t 1 -m -u -i -n -p su - ${executionUser} -c '${escapedCommand}'`;
|
||||
|
||||
export const NSENTER_HOST_CRONTAB = (command: string) => `nsenter -t 1 -m -u -i -n -p sh -c "${command}"`;
|
||||
@@ -18,6 +18,8 @@ import { revalidatePath } from "next/cache";
|
||||
import { getScriptPath } from "@/app/_utils/scripts";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { isDocker } from "@/app/_server/actions/global";
|
||||
import { NSENTER_RUN_JOB } from "@/app/_consts/nsenter";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -260,11 +262,25 @@ export const runCronJob = async (
|
||||
return { success: false, message: "Cannot run paused cron job" };
|
||||
}
|
||||
|
||||
const userInfo = await getUserInfo(job.user);
|
||||
const executionUser = userInfo ? userInfo.username : "root";
|
||||
const escapedCommand = job.command.replace(/'/g, "'\\''");
|
||||
let command: string;
|
||||
const docker = await isDocker();
|
||||
|
||||
const command = `nsenter -t 1 -m -u -i -n -p su - ${executionUser} -c '${escapedCommand}'`;
|
||||
if (docker) {
|
||||
const userInfo = await getUserInfo(job.user);
|
||||
const executionUser = userInfo ? userInfo.username : "root";
|
||||
const escapedCommand = job.command.replace(/'/g, "'\\''");
|
||||
|
||||
command = NSENTER_RUN_JOB(executionUser, escapedCommand);
|
||||
} else {
|
||||
command = job.command;
|
||||
|
||||
const appUser = process.env.USER || "unknown";
|
||||
if (job.user !== appUser) {
|
||||
console.warn(
|
||||
`[Native Mode] Running job ${job.id} as current user (${appUser}) instead of target user (${job.user}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
timeout: 30000,
|
||||
@@ -285,7 +301,7 @@ export const runCronJob = async (
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to execute cron job",
|
||||
output: errorMessage,
|
||||
output: errorMessage.trim(),
|
||||
details: error.stack,
|
||||
};
|
||||
}
|
||||
|
||||
20
app/_server/actions/global/index.ts
Normal file
20
app/_server/actions/global/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
"use server";
|
||||
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
|
||||
export const isDocker = async (): Promise<boolean> => {
|
||||
try {
|
||||
if (existsSync("/.dockerenv")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (existsSync("/proc/1/cgroup")) {
|
||||
const cgroupContent = readFileSync("/proc/1/cgroup", "utf8");
|
||||
return cgroupContent.includes("/docker/");
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -6,13 +6,13 @@ import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { SCRIPTS_DIR, normalizeLineEndings } from "@/app/_utils/scripts";
|
||||
import { loadAllScripts, type Script } from "@/app/_utils/scriptScanner";
|
||||
import { normalizeLineEndings } from "@/app/_utils/scripts";
|
||||
import { SCRIPTS_DIR } from "@/app/_consts/file";
|
||||
import { loadAllScripts, Script } from "@/app/_utils/scriptScanner";
|
||||
import { isDocker } from "@/app/_server/actions/global";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export type { Script } from "@/app/_utils/scriptScanner";
|
||||
|
||||
const sanitizeScriptName = (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
@@ -37,24 +37,22 @@ const generateUniqueFilename = async (baseName: string): Promise<string> => {
|
||||
};
|
||||
|
||||
const ensureScriptsDirectory = async () => {
|
||||
const scriptsDir = await SCRIPTS_DIR();
|
||||
const scriptsDir = join(process.cwd(), SCRIPTS_DIR);
|
||||
if (!existsSync(scriptsDir)) {
|
||||
await mkdir(scriptsDir, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
const ensureHostScriptsDirectory = async () => {
|
||||
const hostProjectDir = process.env.HOST_PROJECT_DIR || process.cwd();
|
||||
|
||||
const hostScriptsDir = join(hostProjectDir, "scripts");
|
||||
const hostScriptsDir = join(process.cwd(), SCRIPTS_DIR);
|
||||
if (!existsSync(hostScriptsDir)) {
|
||||
await mkdir(hostScriptsDir, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
const saveScriptFile = async (filename: string, content: string) => {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const scriptsDir = isDocker ? "/app/scripts" : await SCRIPTS_DIR();
|
||||
const docker = await isDocker();
|
||||
const scriptsDir = docker ? "/app/scripts" : join(process.cwd(), SCRIPTS_DIR);
|
||||
await ensureScriptsDirectory();
|
||||
|
||||
const scriptPath = join(scriptsDir, filename);
|
||||
@@ -68,8 +66,8 @@ const saveScriptFile = async (filename: string, content: string) => {
|
||||
};
|
||||
|
||||
const deleteScriptFile = async (filename: string) => {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const scriptsDir = isDocker ? "/app/scripts" : await SCRIPTS_DIR();
|
||||
const docker = await isDocker();
|
||||
const scriptsDir = docker ? "/app/scripts" : join(process.cwd(), SCRIPTS_DIR);
|
||||
const scriptPath = join(scriptsDir, filename);
|
||||
if (existsSync(scriptPath)) {
|
||||
await unlink(scriptPath);
|
||||
@@ -240,8 +238,8 @@ export const cloneScript = async (
|
||||
|
||||
export const getScriptContent = async (filename: string): Promise<string> => {
|
||||
try {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const scriptPath = isDocker
|
||||
const docker = await isDocker();
|
||||
const scriptPath = docker
|
||||
? join("/app/scripts", filename)
|
||||
: join(process.cwd(), "scripts", filename);
|
||||
|
||||
@@ -280,8 +278,8 @@ export const executeScript = async (
|
||||
}> => {
|
||||
try {
|
||||
await ensureHostScriptsDirectory();
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const hostScriptPath = isDocker
|
||||
const docker = await isDocker();
|
||||
const hostScriptPath = docker
|
||||
? join("/app/scripts", filename)
|
||||
: join(process.cwd(), "scripts", filename);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { readHostCrontab, writeHostCrontab } from "@/app/_utils/system/hostCrontab";
|
||||
import { isDocker } from "@/app/_server/actions/global";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -27,9 +28,9 @@ export const cleanCrontabContent = async (content: string): Promise<string> => {
|
||||
}
|
||||
|
||||
export const readCronFiles = async (): Promise<string> => {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const docker = await isDocker();
|
||||
|
||||
if (!isDocker) {
|
||||
if (!docker) {
|
||||
try {
|
||||
const { stdout } = await execAsync('crontab -l 2>/dev/null || echo ""');
|
||||
return stdout;
|
||||
@@ -43,9 +44,9 @@ export const readCronFiles = async (): Promise<string> => {
|
||||
}
|
||||
|
||||
export const writeCronFiles = async (content: string): Promise<boolean> => {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const docker = await isDocker();
|
||||
|
||||
if (!isDocker) {
|
||||
if (!docker) {
|
||||
try {
|
||||
await execAsync('echo "' + content + '" | crontab -');
|
||||
return true;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { isDocker } from "../_server/actions/global";
|
||||
|
||||
export interface Script {
|
||||
id: string;
|
||||
@@ -67,8 +68,8 @@ const scanScriptsDirectory = async (dirPath: string): Promise<Script[]> => {
|
||||
}
|
||||
|
||||
export const loadAllScripts = async (): Promise<Script[]> => {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const scriptsDir = isDocker
|
||||
const docker = await isDocker();
|
||||
const scriptsDir = docker
|
||||
? "/app/scripts"
|
||||
: path.join(process.cwd(), "scripts");
|
||||
return await scanScriptsDirectory(scriptsDir);
|
||||
|
||||
@@ -1,28 +1,16 @@
|
||||
"use server";
|
||||
|
||||
import { join } from "path";
|
||||
import { SCRIPTS_DIR } from "@/app/_consts/file";
|
||||
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const SCRIPTS_DIR = async () => {
|
||||
if (isDocker && process.env.HOST_PROJECT_DIR) {
|
||||
return `${process.env.HOST_PROJECT_DIR}/scripts`;
|
||||
}
|
||||
return join(process.cwd(), "scripts");
|
||||
export const getScriptPath = (filename: string): string => {
|
||||
return join(process.cwd(), SCRIPTS_DIR, filename);
|
||||
};
|
||||
|
||||
export const getScriptPath = async (filename: string): Promise<string> => {
|
||||
return join(await SCRIPTS_DIR(), filename);
|
||||
}
|
||||
|
||||
export const getHostScriptPath = async (filename: string): Promise<string> => {
|
||||
const hostProjectDir = process.env.HOST_PROJECT_DIR || process.cwd();
|
||||
|
||||
const hostScriptsDir = join(hostProjectDir, "scripts");
|
||||
return `bash ${join(hostScriptsDir, filename)}`;
|
||||
}
|
||||
export const getHostScriptPath = (filename: string): string => {
|
||||
return `bash ${join(process.cwd(), SCRIPTS_DIR, filename)}`;
|
||||
};
|
||||
|
||||
export const normalizeLineEndings = (content: string): string => {
|
||||
return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
};
|
||||
|
||||
export { SCRIPTS_DIR };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { isDocker } from "../_server/actions/global";
|
||||
|
||||
export interface BashSnippet {
|
||||
id: string;
|
||||
@@ -118,12 +119,12 @@ const scanSnippetDirectory = async (
|
||||
}
|
||||
|
||||
export const loadAllSnippets = async (): Promise<BashSnippet[]> => {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const docker = await isDocker();
|
||||
|
||||
let builtinSnippetsPath: string;
|
||||
let userSnippetsPath: string;
|
||||
|
||||
if (isDocker) {
|
||||
if (docker) {
|
||||
builtinSnippetsPath = "/app/app/_utils/snippets";
|
||||
userSnippetsPath = "/app/snippets";
|
||||
} else {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/app/_utils/system/hostCrontab";
|
||||
import { parseJobsFromLines, deleteJobInLines, updateJobInLines, pauseJobInLines, resumeJobInLines } from "@/app/_utils/cron/line-manipulation";
|
||||
import { cleanCrontabContent, readCronFiles, writeCronFiles } from "@/app/_utils/cron/files-manipulation";
|
||||
import { isDocker } from "@/app/_server/actions/global";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -18,10 +19,8 @@ export interface CronJob {
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
const isDocker = (): boolean => process.env.DOCKER === "true";
|
||||
|
||||
const readUserCrontab = async (user: string): Promise<string> => {
|
||||
if (isDocker()) {
|
||||
if (await isDocker()) {
|
||||
const userCrontabs = await readAllHostCrontabs();
|
||||
const targetUserCrontab = userCrontabs.find((uc) => uc.user === user);
|
||||
return targetUserCrontab?.content || "";
|
||||
@@ -34,7 +33,7 @@ const readUserCrontab = async (user: string): Promise<string> => {
|
||||
};
|
||||
|
||||
const writeUserCrontab = async (user: string, content: string): Promise<boolean> => {
|
||||
if (isDocker()) {
|
||||
if (await isDocker()) {
|
||||
return await writeHostCrontabForUser(user, content);
|
||||
} else {
|
||||
try {
|
||||
@@ -48,7 +47,7 @@ const writeUserCrontab = async (user: string, content: string): Promise<boolean>
|
||||
};
|
||||
|
||||
const getAllUsers = async (): Promise<{ user: string; content: string }[]> => {
|
||||
if (isDocker()) {
|
||||
if (await isDocker()) {
|
||||
return await readAllHostCrontabs();
|
||||
} else {
|
||||
const { getAllTargetUsers } = await import("@/app/_utils/system/hostCrontab");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NSENTER_HOST_CRONTAB } from "@/app/_consts/nsenter";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
@@ -11,15 +12,13 @@ export interface UserInfo {
|
||||
|
||||
const execHostCrontab = async (command: string): Promise<string> => {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`nsenter -t 1 -m -u -i -n -p sh -c "${command}"`
|
||||
);
|
||||
const { stdout } = await execAsync(NSENTER_HOST_CRONTAB(command?.trim()));
|
||||
return stdout;
|
||||
} catch (error: any) {
|
||||
console.error("Error executing host crontab command:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getTargetUser = async (): Promise<string> => {
|
||||
try {
|
||||
@@ -31,19 +30,6 @@ const getTargetUser = async (): Promise<string> => {
|
||||
const dockerSocketOwner = stdout.trim();
|
||||
|
||||
if (dockerSocketOwner === "root") {
|
||||
try {
|
||||
const projectDir = process.env.HOST_PROJECT_DIR;
|
||||
|
||||
if (projectDir) {
|
||||
const dirOwner = await execHostCrontab(
|
||||
`stat -c "%U" "${projectDir}"`
|
||||
);
|
||||
return dirOwner.trim();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not detect user from project directory:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
const users = await execHostCrontab(
|
||||
'getent passwd | grep ":/home/" | head -1 | cut -d: -f1'
|
||||
@@ -64,7 +50,7 @@ const getTargetUser = async (): Promise<string> => {
|
||||
console.error("Error detecting target user:", error);
|
||||
return "root";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllTargetUsers = async (): Promise<string[]> => {
|
||||
try {
|
||||
@@ -91,7 +77,7 @@ export const getAllTargetUsers = async (): Promise<string[]> => {
|
||||
console.error("Error getting all target users:", error);
|
||||
return ["root"];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const readHostCrontab = async (): Promise<string> => {
|
||||
try {
|
||||
@@ -103,7 +89,7 @@ export const readHostCrontab = async (): Promise<string> => {
|
||||
console.error("Error reading host crontab:", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const readAllHostCrontabs = async (): Promise<
|
||||
{ user: string; content: string }[]
|
||||
@@ -129,7 +115,7 @@ export const readAllHostCrontabs = async (): Promise<
|
||||
console.error("Error reading all host crontabs:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const writeHostCrontab = async (content: string): Promise<boolean> => {
|
||||
try {
|
||||
@@ -148,7 +134,7 @@ export const writeHostCrontab = async (content: string): Promise<boolean> => {
|
||||
console.error("Error writing host crontab:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const writeHostCrontabForUser = async (
|
||||
user: string,
|
||||
@@ -169,7 +155,7 @@ export const writeHostCrontabForUser = async (
|
||||
console.error(`Error writing host crontab for user ${user}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function getUserInfo(username: string): Promise<UserInfo | null> {
|
||||
try {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"systeminformation": "^5.27.8",
|
||||
"systeminformation": "^5.27.11",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^5"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# @id: demo-script
|
||||
# @title: Hi, this is a demo script
|
||||
# @title: Hi, this is a demo scripttttt
|
||||
# @description: This script logs a "hello world" to teach you how scripts work.
|
||||
|
||||
#!/bin/bash
|
||||
|
||||
@@ -4708,10 +4708,10 @@ supports-preserve-symlinks-flag@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
|
||||
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
||||
|
||||
systeminformation@^5.27.8:
|
||||
version "5.27.8"
|
||||
resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-5.27.8.tgz#f13d180104a0df2e7222c5d4aa85aea147428de5"
|
||||
integrity sha512-d3Z0gaQO1MlUxzDUKsmXz5y4TOBCMZ8IyijzaYOykV3AcNOTQ7mT+tpndUOXYNSxzLK3la8G32xiUFvZ0/s6PA==
|
||||
systeminformation@^5.27.11:
|
||||
version "5.27.11"
|
||||
resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-5.27.11.tgz#286c8eca3947cfde0da684221ee9afb2ae313441"
|
||||
integrity sha512-K3Lto/2m3K2twmKHdgx5B+0in9qhXK4YnoT9rIlgwN/4v7OV5c8IjbeAUkuky/6VzCQC7iKCAqi8rZathCdjHg==
|
||||
|
||||
tailwind-merge@^2.0.0:
|
||||
version "2.6.0"
|
||||
|
||||
Reference in New Issue
Block a user