opencode-sonarqube 0.1.23 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +89 -155
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -17989,7 +17989,7 @@ class SourcesAPI {
|
|
|
17989
17989
|
this.formatFileLocation(filePath, issue2.line),
|
|
17990
17990
|
`**Rule:** \`${issue2.rule}\``
|
|
17991
17991
|
];
|
|
17992
|
-
const hasSourceContext = issue2.sourceContext
|
|
17992
|
+
const hasSourceContext = (issue2.sourceContext?.lines.length ?? 0) > 0;
|
|
17993
17993
|
if (hasSourceContext) {
|
|
17994
17994
|
lines.push(...this.formatSourceBlock(issue2.sourceContext, issue2.line));
|
|
17995
17995
|
}
|
|
@@ -18041,7 +18041,7 @@ class DuplicationsAPI {
|
|
|
18041
18041
|
});
|
|
18042
18042
|
const duplicatedComponents = components.components.filter((c) => {
|
|
18043
18043
|
const measure = c.measures.find((m) => m.metric === "duplicated_lines_density");
|
|
18044
|
-
return measure
|
|
18044
|
+
return measure ? Number.parseFloat(measure.value) > 0 : false;
|
|
18045
18045
|
}).slice(0, limit);
|
|
18046
18046
|
const allDuplications = [];
|
|
18047
18047
|
for (const component of duplicatedComponents) {
|
|
@@ -19178,33 +19178,43 @@ function generateTokenName(projectKey) {
|
|
|
19178
19178
|
const uuid3 = crypto.randomUUID().split("-")[0];
|
|
19179
19179
|
return `opencode-${projectKey}-${timestamp}-${uuid3}`;
|
|
19180
19180
|
}
|
|
19181
|
-
|
|
19182
|
-
|
|
19181
|
+
function isValidDirectory(dir) {
|
|
19182
|
+
return Boolean(dir && dir !== "/" && dir !== "." && dir.length >= 2);
|
|
19183
|
+
}
|
|
19184
|
+
function resolveDirectoryFromImportMeta() {
|
|
19183
19185
|
try {
|
|
19184
|
-
const
|
|
19185
|
-
|
|
19186
|
-
|
|
19187
|
-
|
|
19188
|
-
|
|
19189
|
-
|
|
19190
|
-
|
|
19191
|
-
|
|
19192
|
-
const pathParts = pluginPath.split("/");
|
|
19193
|
-
const nodeModulesIndex = pathParts.findIndex((p) => p === "node_modules");
|
|
19194
|
-
if (nodeModulesIndex > 0) {
|
|
19195
|
-
const projectPath = pathParts.slice(0, nodeModulesIndex).join("/");
|
|
19196
|
-
if (projectPath && projectPath !== "/" && projectPath.length > 1) {
|
|
19197
|
-
directory = projectPath;
|
|
19198
|
-
try {
|
|
19199
|
-
const { appendFileSync: appendFileSync4 } = await import("node:fs");
|
|
19200
|
-
appendFileSync4("/tmp/sonarqube-plugin-debug.log", `${new Date().toISOString()} [BOOTSTRAP] Fixed directory from import.meta.url: ${directory}
|
|
19201
|
-
`);
|
|
19202
|
-
} catch {}
|
|
19203
|
-
}
|
|
19186
|
+
const pluginUrl = import.meta.url;
|
|
19187
|
+
const pluginPath = decodeURIComponent(pluginUrl.replace("file://", ""));
|
|
19188
|
+
const pathParts = pluginPath.split("/");
|
|
19189
|
+
const nodeModulesIndex = pathParts.indexOf("node_modules");
|
|
19190
|
+
if (nodeModulesIndex > 0) {
|
|
19191
|
+
const projectPath = pathParts.slice(0, nodeModulesIndex).join("/");
|
|
19192
|
+
if (isValidDirectory(projectPath)) {
|
|
19193
|
+
return projectPath;
|
|
19204
19194
|
}
|
|
19205
|
-
}
|
|
19195
|
+
}
|
|
19196
|
+
} catch {}
|
|
19197
|
+
return null;
|
|
19198
|
+
}
|
|
19199
|
+
async function generateAnalysisToken(client, tokenName, projectKey) {
|
|
19200
|
+
try {
|
|
19201
|
+
return await client.post("/api/user_tokens/generate", { name: tokenName, type: "PROJECT_ANALYSIS_TOKEN", projectKey });
|
|
19202
|
+
} catch {
|
|
19203
|
+
logger5.warn("PROJECT_ANALYSIS_TOKEN not available, using GLOBAL_ANALYSIS_TOKEN");
|
|
19204
|
+
return await client.post("/api/user_tokens/generate", { name: tokenName, type: "GLOBAL_ANALYSIS_TOKEN" });
|
|
19206
19205
|
}
|
|
19207
|
-
|
|
19206
|
+
}
|
|
19207
|
+
async function bootstrap(options) {
|
|
19208
|
+
let { config: config2, directory, force = false } = options;
|
|
19209
|
+
logger5.info("Starting bootstrap", { directory, projectKey: config2.projectKey || "(auto)" });
|
|
19210
|
+
if (!isValidDirectory(directory)) {
|
|
19211
|
+
const resolved = resolveDirectoryFromImportMeta();
|
|
19212
|
+
if (resolved) {
|
|
19213
|
+
directory = resolved;
|
|
19214
|
+
logger5.info("Resolved directory from import.meta.url", { directory });
|
|
19215
|
+
}
|
|
19216
|
+
}
|
|
19217
|
+
if (!isValidDirectory(directory)) {
|
|
19208
19218
|
logger5.error("Invalid directory for bootstrap", { directory });
|
|
19209
19219
|
return {
|
|
19210
19220
|
success: false,
|
|
@@ -19212,21 +19222,20 @@ async function bootstrap(options) {
|
|
|
19212
19222
|
projectToken: "",
|
|
19213
19223
|
qualityGate: "",
|
|
19214
19224
|
languages: [],
|
|
19215
|
-
message: `Invalid directory: ${directory}
|
|
19225
|
+
message: `Invalid directory: ${directory}`,
|
|
19216
19226
|
isNewProject: false
|
|
19217
19227
|
};
|
|
19218
19228
|
}
|
|
19219
|
-
logger5.info("Starting SonarQube bootstrap", { directory });
|
|
19220
19229
|
if (!force) {
|
|
19221
|
-
const
|
|
19222
|
-
if (
|
|
19223
|
-
logger5.info("Project already bootstrapped", { projectKey:
|
|
19230
|
+
const existingState = await loadProjectState(directory);
|
|
19231
|
+
if (existingState?.setupComplete) {
|
|
19232
|
+
logger5.info("Project already bootstrapped", { projectKey: existingState.projectKey });
|
|
19224
19233
|
return {
|
|
19225
19234
|
success: true,
|
|
19226
|
-
projectKey:
|
|
19227
|
-
projectToken:
|
|
19228
|
-
qualityGate:
|
|
19229
|
-
languages:
|
|
19235
|
+
projectKey: existingState.projectKey,
|
|
19236
|
+
projectToken: existingState.projectToken,
|
|
19237
|
+
qualityGate: existingState.qualityGate,
|
|
19238
|
+
languages: existingState.languages,
|
|
19230
19239
|
message: "Project already configured",
|
|
19231
19240
|
isNewProject: false
|
|
19232
19241
|
};
|
|
@@ -19239,49 +19248,24 @@ async function bootstrap(options) {
|
|
|
19239
19248
|
}
|
|
19240
19249
|
logger5.info("Connected to SonarQube", { version: health.version });
|
|
19241
19250
|
const detection = await detectProjectType(directory);
|
|
19242
|
-
logger5.info("Detected project type", { languages: detection.languages });
|
|
19243
19251
|
const projectKey = config2.projectKey || sanitizeProjectKey(await deriveProjectKey(directory));
|
|
19244
19252
|
const projectName = config2.projectName || projectKey;
|
|
19245
|
-
logger5.info("Project identification", { projectKey, projectName });
|
|
19246
19253
|
const projectsApi = new ProjectsAPI(adminClient);
|
|
19247
|
-
let isNewProject = false;
|
|
19248
19254
|
const exists = await projectsApi.exists(projectKey);
|
|
19249
|
-
|
|
19250
|
-
|
|
19251
|
-
|
|
19252
|
-
logger5.info("
|
|
19253
|
-
await projectsApi.create({
|
|
19254
|
-
projectKey,
|
|
19255
|
-
name: projectName,
|
|
19256
|
-
visibility: "private"
|
|
19257
|
-
});
|
|
19258
|
-
isNewProject = true;
|
|
19255
|
+
const isNewProject = !exists;
|
|
19256
|
+
if (isNewProject) {
|
|
19257
|
+
await projectsApi.create({ projectKey, name: projectName, visibility: "private" });
|
|
19258
|
+
logger5.info("Created new project", { projectKey });
|
|
19259
19259
|
}
|
|
19260
19260
|
const tokenName = generateTokenName(projectKey);
|
|
19261
|
-
|
|
19262
|
-
let tokenResponse;
|
|
19263
|
-
try {
|
|
19264
|
-
tokenResponse = await adminClient.post("/api/user_tokens/generate", {
|
|
19265
|
-
name: tokenName,
|
|
19266
|
-
type: "PROJECT_ANALYSIS_TOKEN",
|
|
19267
|
-
projectKey
|
|
19268
|
-
});
|
|
19269
|
-
} catch {
|
|
19270
|
-
logger5.warn("PROJECT_ANALYSIS_TOKEN not available, using GLOBAL_ANALYSIS_TOKEN");
|
|
19271
|
-
tokenResponse = await adminClient.post("/api/user_tokens/generate", {
|
|
19272
|
-
name: tokenName,
|
|
19273
|
-
type: "GLOBAL_ANALYSIS_TOKEN"
|
|
19274
|
-
});
|
|
19275
|
-
}
|
|
19261
|
+
const tokenResponse = await generateAnalysisToken(adminClient, tokenName, projectKey);
|
|
19276
19262
|
const qualityGateName = config2.qualityGate ?? QUALITY_GATE_MAPPING[config2.level];
|
|
19277
19263
|
try {
|
|
19278
19264
|
await setProjectQualityGate(adminClient, projectKey, qualityGateName);
|
|
19279
|
-
logger5.info("Quality gate configured", { qualityGate: qualityGateName });
|
|
19280
19265
|
} catch (error45) {
|
|
19281
19266
|
logger5.warn("Failed to set quality gate", { error: String(error45) });
|
|
19282
19267
|
}
|
|
19283
19268
|
await configureProjectSettings(adminClient, projectKey, detection.languages, config2);
|
|
19284
|
-
logger5.info("Project settings configured");
|
|
19285
19269
|
const state = createInitialState({
|
|
19286
19270
|
projectKey,
|
|
19287
19271
|
projectToken: tokenResponse.token,
|
|
@@ -19298,7 +19282,7 @@ async function bootstrap(options) {
|
|
|
19298
19282
|
projectToken: tokenResponse.token,
|
|
19299
19283
|
qualityGate: qualityGateName,
|
|
19300
19284
|
languages: detection.languages,
|
|
19301
|
-
message: isNewProject ? `Created new project '${projectKey}'
|
|
19285
|
+
message: isNewProject ? `Created new project '${projectKey}'` : `Configured existing project '${projectKey}'`,
|
|
19302
19286
|
isNewProject
|
|
19303
19287
|
};
|
|
19304
19288
|
}
|
|
@@ -20112,26 +20096,6 @@ try {
|
|
|
20112
20096
|
appendFileSync4("/tmp/sonarqube-plugin-debug.log", `${new Date().toISOString()} [LOAD] Plugin module loaded! CWD=${process.cwd()}
|
|
20113
20097
|
`);
|
|
20114
20098
|
} catch {}
|
|
20115
|
-
var LOG_FILE4 = "/tmp/sonarqube-plugin-debug.log";
|
|
20116
|
-
var debugLog = {
|
|
20117
|
-
_write: (level, msg, extra) => {
|
|
20118
|
-
const timestamp = new Date().toISOString();
|
|
20119
|
-
const logLine = `${timestamp} [${level}] ${msg} ${extra ? JSON.stringify(extra) : ""}
|
|
20120
|
-
`;
|
|
20121
|
-
try {
|
|
20122
|
-
appendFileSync4(LOG_FILE4, logLine);
|
|
20123
|
-
} catch {}
|
|
20124
|
-
},
|
|
20125
|
-
info: (msg, extra) => {
|
|
20126
|
-
debugLog._write("INFO", msg, extra);
|
|
20127
|
-
},
|
|
20128
|
-
warn: (msg, extra) => {
|
|
20129
|
-
debugLog._write("WARN", msg, extra);
|
|
20130
|
-
},
|
|
20131
|
-
error: (msg, extra) => {
|
|
20132
|
-
debugLog._write("ERROR", msg, extra);
|
|
20133
|
-
}
|
|
20134
|
-
};
|
|
20135
20099
|
var IGNORED_FILE_PATTERNS2 = [
|
|
20136
20100
|
/node_modules/,
|
|
20137
20101
|
/\.git/,
|
|
@@ -20173,7 +20137,7 @@ var SonarQubePlugin = async ({ client, directory, worktree }) => {
|
|
|
20173
20137
|
const pluginUrl = import.meta.url;
|
|
20174
20138
|
const pluginPath = decodeURIComponent(pluginUrl.replace("file://", ""));
|
|
20175
20139
|
const pathParts = pluginPath.split("/");
|
|
20176
|
-
const nodeModulesIndex = pathParts.
|
|
20140
|
+
const nodeModulesIndex = pathParts.indexOf("node_modules");
|
|
20177
20141
|
if (nodeModulesIndex > 0) {
|
|
20178
20142
|
const projectPath = pathParts.slice(0, nodeModulesIndex).join("/");
|
|
20179
20143
|
if (projectPath && projectPath !== "/" && projectPath.length > 1) {
|
|
@@ -20205,98 +20169,69 @@ var SonarQubePlugin = async ({ client, directory, worktree }) => {
|
|
|
20205
20169
|
const getConfig = () => pluginConfig;
|
|
20206
20170
|
const getDirectory = () => effectiveDirectory;
|
|
20207
20171
|
const loadPluginConfig = async () => {
|
|
20208
|
-
|
|
20209
|
-
if (pluginConfig) {
|
|
20210
|
-
debugLog.info("Config already loaded, skipping");
|
|
20172
|
+
if (pluginConfig)
|
|
20211
20173
|
return;
|
|
20212
|
-
|
|
20174
|
+
const dir = getDirectory();
|
|
20175
|
+
const sonarConfigPath = `${dir}/.sonarqube/config.json`;
|
|
20213
20176
|
try {
|
|
20214
|
-
const
|
|
20215
|
-
debugLog.info("Loading config from", { configPath });
|
|
20216
|
-
const configFile = Bun.file(configPath);
|
|
20177
|
+
const configFile = Bun.file(sonarConfigPath);
|
|
20217
20178
|
if (await configFile.exists()) {
|
|
20218
|
-
|
|
20219
|
-
|
|
20220
|
-
|
|
20221
|
-
|
|
20179
|
+
const config2 = await configFile.json();
|
|
20180
|
+
pluginConfig = { sonarqube: config2 };
|
|
20181
|
+
safeLog(`Config loaded from ${sonarConfigPath}`);
|
|
20182
|
+
return;
|
|
20222
20183
|
}
|
|
20223
|
-
} catch
|
|
20224
|
-
|
|
20225
|
-
|
|
20184
|
+
} catch {}
|
|
20185
|
+
pluginConfig = {};
|
|
20186
|
+
safeLog("Using environment variables for config");
|
|
20226
20187
|
};
|
|
20227
20188
|
const hooks = createHooks(getConfig, getDirectory);
|
|
20228
20189
|
let currentSessionId;
|
|
20229
20190
|
let initialCheckDone = false;
|
|
20191
|
+
const buildQualityNotification = (qgStatus, counts, qgFailed) => {
|
|
20192
|
+
const statusEmoji = qgFailed ? "[FAIL]" : "[WARN]";
|
|
20193
|
+
const blockerNote = counts.blocker > 0 ? `**Action Required:** There are BLOCKER issues that should be fixed.
|
|
20194
|
+
` : "";
|
|
20195
|
+
return `## SonarQube Status: ${statusEmoji}
|
|
20196
|
+
|
|
20197
|
+
**Quality Gate:** ${qgStatus}
|
|
20198
|
+
**Outstanding Issues:** ${counts.blocker} blockers, ${counts.critical} critical, ${counts.major} major
|
|
20199
|
+
|
|
20200
|
+
${blockerNote}Use \`sonarqube({ action: "issues" })\` to see details or \`sonarqube({ action: "analyze" })\` to re-analyze.`;
|
|
20201
|
+
};
|
|
20230
20202
|
const performInitialQualityCheck = async (sessionId) => {
|
|
20231
|
-
|
|
20232
|
-
if (initialCheckDone) {
|
|
20233
|
-
debugLog.info("Initial check already done, skipping");
|
|
20203
|
+
if (initialCheckDone)
|
|
20234
20204
|
return;
|
|
20235
|
-
}
|
|
20236
20205
|
initialCheckDone = true;
|
|
20237
20206
|
try {
|
|
20238
20207
|
await loadPluginConfig();
|
|
20239
20208
|
const sonarConfig = pluginConfig?.["sonarqube"];
|
|
20240
|
-
debugLog.info("Loading SonarQube config", { hasSonarConfig: !!sonarConfig });
|
|
20241
20209
|
const config2 = loadConfig(sonarConfig);
|
|
20242
|
-
|
|
20243
|
-
if (!config2 || config2.level === "off") {
|
|
20244
|
-
debugLog.info("Config missing or level=off, skipping");
|
|
20210
|
+
if (!config2 || config2.level === "off")
|
|
20245
20211
|
return;
|
|
20246
|
-
}
|
|
20247
20212
|
const dir = getDirectory();
|
|
20248
|
-
|
|
20249
|
-
const needsBoot = await needsBootstrap(dir);
|
|
20250
|
-
debugLog.info("needsBootstrap result", { needsBoot });
|
|
20251
|
-
if (needsBoot) {
|
|
20252
|
-
debugLog.info("Bootstrap needed, skipping initial check");
|
|
20213
|
+
if (await needsBootstrap(dir))
|
|
20253
20214
|
return;
|
|
20254
|
-
}
|
|
20255
|
-
debugLog.info("Loading project state");
|
|
20256
20215
|
const state = await getProjectState(dir);
|
|
20257
|
-
|
|
20258
|
-
hasState: !!state,
|
|
20259
|
-
projectKey: state?.projectKey,
|
|
20260
|
-
hasToken: !!state?.projectToken
|
|
20261
|
-
});
|
|
20262
|
-
if (!state || !state.projectKey) {
|
|
20263
|
-
debugLog.info("No state or projectKey, skipping");
|
|
20216
|
+
if (!state?.projectKey)
|
|
20264
20217
|
return;
|
|
20265
|
-
}
|
|
20266
|
-
debugLog.info("Creating API and fetching quality status", { projectKey: state.projectKey });
|
|
20267
20218
|
const api2 = createSonarQubeAPI(config2, state);
|
|
20268
20219
|
const [qgStatus, counts] = await Promise.all([
|
|
20269
20220
|
api2.qualityGate.getStatus(state.projectKey),
|
|
20270
20221
|
api2.issues.getCounts(state.projectKey)
|
|
20271
20222
|
]);
|
|
20272
|
-
debugLog.info("Quality status fetched", { qgStatus: qgStatus.projectStatus.status, counts });
|
|
20273
20223
|
const hasIssues = counts.blocker > 0 || counts.critical > 0 || counts.major > 0;
|
|
20274
20224
|
const qgFailed = qgStatus.projectStatus.status !== "OK";
|
|
20275
|
-
if (hasIssues
|
|
20276
|
-
|
|
20277
|
-
|
|
20278
|
-
|
|
20279
|
-
|
|
20280
|
-
|
|
20281
|
-
|
|
20282
|
-
|
|
20283
|
-
` : ""}
|
|
20284
|
-
Use \`sonarqube({ action: "issues" })\` to see details or \`sonarqube({ action: "analyze" })\` to re-analyze.`;
|
|
20285
|
-
await client.session.prompt({
|
|
20286
|
-
path: { id: sessionId },
|
|
20287
|
-
body: {
|
|
20288
|
-
noReply: true,
|
|
20289
|
-
parts: [{ type: "text", text: notification }]
|
|
20290
|
-
}
|
|
20291
|
-
});
|
|
20292
|
-
await showToast(qgFailed ? "SonarQube: Quality Gate Failed" : "SonarQube: Issues Found", qgFailed ? "error" : "info");
|
|
20293
|
-
}
|
|
20225
|
+
if (!hasIssues && !qgFailed)
|
|
20226
|
+
return;
|
|
20227
|
+
const notification = buildQualityNotification(qgStatus.projectStatus.status, counts, qgFailed);
|
|
20228
|
+
await client.session.prompt({
|
|
20229
|
+
path: { id: sessionId },
|
|
20230
|
+
body: { noReply: true, parts: [{ type: "text", text: notification }] }
|
|
20231
|
+
});
|
|
20232
|
+
await showToast(qgFailed ? "SonarQube: Quality Gate Failed" : "SonarQube: Issues Found", qgFailed ? "error" : "info");
|
|
20294
20233
|
} catch (error45) {
|
|
20295
|
-
|
|
20296
|
-
const { appendFileSync: appendFileSync5 } = await import("node:fs");
|
|
20297
|
-
appendFileSync5("/tmp/sonarqube-plugin-debug.log", `${new Date().toISOString()} [ERROR] performInitialQualityCheck FAILED: ${error45}
|
|
20298
|
-
`);
|
|
20299
|
-
} catch {}
|
|
20234
|
+
safeLog(`performInitialQualityCheck error: ${error45}`);
|
|
20300
20235
|
}
|
|
20301
20236
|
};
|
|
20302
20237
|
const showToast = async (message, variant = "info") => {
|
|
@@ -20391,7 +20326,7 @@ After fixing, I will re-run the analysis to verify.`;
|
|
|
20391
20326
|
});
|
|
20392
20327
|
if (config2.autoFix && lastAnalysisResult) {
|
|
20393
20328
|
const state = await getProjectState(getDirectory());
|
|
20394
|
-
if (state
|
|
20329
|
+
if (state?.projectKey) {
|
|
20395
20330
|
const api2 = createSonarQubeAPI(config2, state);
|
|
20396
20331
|
const issues = await api2.issues.getFormattedIssues({
|
|
20397
20332
|
projectKey: state.projectKey,
|
|
@@ -20462,7 +20397,7 @@ ${statusNote}`;
|
|
|
20462
20397
|
}
|
|
20463
20398
|
try {
|
|
20464
20399
|
const state = await getProjectState(getDirectory());
|
|
20465
|
-
if (!state
|
|
20400
|
+
if (!state?.projectKey)
|
|
20466
20401
|
return;
|
|
20467
20402
|
const api2 = createSonarQubeAPI(config2, state);
|
|
20468
20403
|
const counts = await api2.issues.getCounts(state.projectKey);
|
|
@@ -20625,9 +20560,8 @@ Git operation completed with changes. Consider running:
|
|
|
20625
20560
|
}
|
|
20626
20561
|
const dir = getDirectory();
|
|
20627
20562
|
const state = await getProjectState(dir);
|
|
20628
|
-
if (!state
|
|
20563
|
+
if (!state?.projectKey)
|
|
20629
20564
|
return;
|
|
20630
|
-
}
|
|
20631
20565
|
const api2 = createSonarQubeAPI(config2, state);
|
|
20632
20566
|
const [qgStatus, counts, newCodeResponse] = await Promise.all([
|
|
20633
20567
|
api2.qualityGate.getStatus(state.projectKey),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-sonarqube",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "OpenCode Plugin for SonarQube integration - Enterprise-level code quality from the start",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"homepage": "https://github.com/mguttmann/opencode-sonarqube#readme",
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@opencode-ai/plugin": "^1.1.34",
|
|
41
|
-
"opencode-sonarqube": "0.1
|
|
41
|
+
"opencode-sonarqube": "0.2.1",
|
|
42
42
|
"zod": "^3.24.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|