edsger 0.72.2 → 0.72.4
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/commands/quality-benchmark/index.d.ts +5 -6
- package/dist/commands/quality-benchmark/index.js +13 -13
- package/dist/services/quality-reports.d.ts +45 -0
- package/dist/services/quality-reports.js +93 -0
- package/dist/supabase/client.d.ts +11 -0
- package/dist/supabase/client.js +35 -2
- package/dist/system/session-manager.js +8 -2
- package/dist/utils/logger.js +8 -5
- package/package.json +3 -3
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI command: `edsger quality-benchmark <productId>`
|
|
3
3
|
*
|
|
4
|
-
* Runs the quality-benchmark phase against the product's GitHub repo
|
|
5
|
-
* writes the resulting JSON report to disk
|
|
6
|
-
* `quality_reports` table
|
|
7
|
-
*
|
|
8
|
-
* client, exactly the same pattern used by other CLI commands.
|
|
4
|
+
* Runs the quality-benchmark phase against the product's GitHub repo,
|
|
5
|
+
* writes the resulting JSON report to disk, and persists it to the
|
|
6
|
+
* `quality_reports` table via the supabase SDK (RLS-scoped; MCP
|
|
7
|
+
* fallback when no desktop session is synced) unless --no-save.
|
|
9
8
|
*
|
|
10
9
|
* Default invocation (desktop): no --repo, no --branch — the CLI fetches
|
|
11
10
|
* the product's GitHub config via MCP, clones (or reuses) the repo into
|
|
@@ -33,7 +32,7 @@ export interface QualityBenchmarkCliOptions {
|
|
|
33
32
|
install?: boolean;
|
|
34
33
|
output?: string;
|
|
35
34
|
verbose?: boolean;
|
|
36
|
-
/** When false, skip the
|
|
35
|
+
/** When false, skip persisting the report to quality_reports. */
|
|
37
36
|
save?: boolean;
|
|
38
37
|
/** Overwrite any existing report row for this (product, commit, rubric). */
|
|
39
38
|
force?: boolean;
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI command: `edsger quality-benchmark <productId>`
|
|
3
3
|
*
|
|
4
|
-
* Runs the quality-benchmark phase against the product's GitHub repo
|
|
5
|
-
* writes the resulting JSON report to disk
|
|
6
|
-
* `quality_reports` table
|
|
7
|
-
*
|
|
8
|
-
* client, exactly the same pattern used by other CLI commands.
|
|
4
|
+
* Runs the quality-benchmark phase against the product's GitHub repo,
|
|
5
|
+
* writes the resulting JSON report to disk, and persists it to the
|
|
6
|
+
* `quality_reports` table via the supabase SDK (RLS-scoped; MCP
|
|
7
|
+
* fallback when no desktop session is synced) unless --no-save.
|
|
9
8
|
*
|
|
10
9
|
* Default invocation (desktop): no --repo, no --branch — the CLI fetches
|
|
11
10
|
* the product's GitHub config via MCP, clones (or reuses) the repo into
|
|
@@ -26,10 +25,10 @@
|
|
|
26
25
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
27
26
|
import { dirname, resolve } from 'node:path';
|
|
28
27
|
import { getGitHubConfigByProduct, getGitHubConfigByRepository, getRepositoryBasics, } from '../../api/github.js';
|
|
29
|
-
import { callMcpEndpoint } from '../../api/mcp-client.js';
|
|
30
28
|
import { fetchProductBasics } from '../../phases/find-shared/mcp.js';
|
|
31
29
|
import { runQualityBenchmark, } from '../../phases/quality-benchmark/index.js';
|
|
32
30
|
import { prepareQualityWorkspace } from '../../phases/quality-benchmark/workspace.js';
|
|
31
|
+
import { saveQualityReport } from '../../services/quality-reports.js';
|
|
33
32
|
import { getSupabase } from '../../supabase/client.js';
|
|
34
33
|
import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
|
|
35
34
|
export async function runQualityBenchmarkCli(productId, options) {
|
|
@@ -166,10 +165,12 @@ export async function runQualityBenchmarkCli(productId, options) {
|
|
|
166
165
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
167
166
|
writeFileSync(outputPath, JSON.stringify(reportEnvelope, null, 2), 'utf8');
|
|
168
167
|
logSuccess(`Report written to ${outputPath}`);
|
|
169
|
-
// Persist
|
|
168
|
+
// Persist to quality_reports unless the caller opted out. Goes through
|
|
169
|
+
// the supabase SDK directly (RLS-scoped), with an MCP fallback for
|
|
170
|
+
// sessions authorized by an MCP token only.
|
|
170
171
|
if (options.save !== false) {
|
|
171
172
|
try {
|
|
172
|
-
const saved =
|
|
173
|
+
const saved = await saveQualityReport({
|
|
173
174
|
product_id: repoOnly ? null : productId,
|
|
174
175
|
repository_id: repositoryId,
|
|
175
176
|
commit_sha: outcome.commitSha,
|
|
@@ -193,14 +194,13 @@ export async function runQualityBenchmarkCli(productId, options) {
|
|
|
193
194
|
completed_at: outcome.completedAt,
|
|
194
195
|
duration_seconds: outcome.durationSeconds,
|
|
195
196
|
replace_existing: options.force === true,
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
logSuccess(`Saved to quality_reports.id = ${row.id}`);
|
|
197
|
+
});
|
|
198
|
+
if (saved.id) {
|
|
199
|
+
logSuccess(`Saved to quality_reports.id = ${saved.id}`);
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
202
|
catch (err) {
|
|
203
|
-
logWarning(`Failed to persist report
|
|
203
|
+
logWarning(`Failed to persist report (will keep local file): ${err instanceof Error ? err.message : String(err)}`);
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
logInfo(`Overall: grade ${outcome.report.overall_grade ?? '?'} (${outcome.report.overall_score ?? '?'})`);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quality reports service — persists `edsger quality-benchmark` results.
|
|
3
|
+
*
|
|
4
|
+
* Writes go through the supabase SDK directly when the desktop-synced
|
|
5
|
+
* session is available (RLS gates access: product owner / active team
|
|
6
|
+
* member, or repo team access via `user_has_repository_access` for
|
|
7
|
+
* repo-only reports). Falls back to the MCP edge function
|
|
8
|
+
* (`quality_reports/save`) when no usable session is synced — e.g.
|
|
9
|
+
* standalone CLI runs authorized by an MCP token.
|
|
10
|
+
*/
|
|
11
|
+
export interface SaveQualityReportParams {
|
|
12
|
+
/** Product scope; null for repo-only reports. */
|
|
13
|
+
product_id: string | null;
|
|
14
|
+
/** Repository scope; required when product_id is null. */
|
|
15
|
+
repository_id: string | null;
|
|
16
|
+
commit_sha: string;
|
|
17
|
+
rubric_version: string;
|
|
18
|
+
branch?: string | null;
|
|
19
|
+
repo_root?: string | null;
|
|
20
|
+
detected_context?: unknown;
|
|
21
|
+
tool_versions?: Record<string, string>;
|
|
22
|
+
unavailable_tools?: unknown[];
|
|
23
|
+
applied_checks?: unknown;
|
|
24
|
+
tool_outputs?: Record<string, unknown>;
|
|
25
|
+
external_signals?: unknown;
|
|
26
|
+
dropped_findings?: number;
|
|
27
|
+
dimension_scores?: unknown;
|
|
28
|
+
overall_score?: number | null;
|
|
29
|
+
overall_grade?: string | null;
|
|
30
|
+
executive_summary?: string | null;
|
|
31
|
+
low_confidence?: boolean;
|
|
32
|
+
status?: string;
|
|
33
|
+
started_at?: string | null;
|
|
34
|
+
completed_at?: string | null;
|
|
35
|
+
duration_seconds?: number | null;
|
|
36
|
+
/** Delete any existing row for this (scope, commit, rubric) before insert. */
|
|
37
|
+
replace_existing?: boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Insert a completed quality report row. Returns the new row id, or null
|
|
41
|
+
* if the saved row could not be parsed (MCP path only).
|
|
42
|
+
*/
|
|
43
|
+
export declare function saveQualityReport(params: SaveQualityReportParams): Promise<{
|
|
44
|
+
id: string | null;
|
|
45
|
+
}>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quality reports service — persists `edsger quality-benchmark` results.
|
|
3
|
+
*
|
|
4
|
+
* Writes go through the supabase SDK directly when the desktop-synced
|
|
5
|
+
* session is available (RLS gates access: product owner / active team
|
|
6
|
+
* member, or repo team access via `user_has_repository_access` for
|
|
7
|
+
* repo-only reports). Falls back to the MCP edge function
|
|
8
|
+
* (`quality_reports/save`) when no usable session is synced — e.g.
|
|
9
|
+
* standalone CLI runs authorized by an MCP token.
|
|
10
|
+
*/
|
|
11
|
+
import { callMcpEndpoint } from '../api/mcp-client.js';
|
|
12
|
+
import { ensureSupabaseSession, getSupabase } from '../supabase/client.js';
|
|
13
|
+
/**
|
|
14
|
+
* Insert a completed quality report row. Returns the new row id, or null
|
|
15
|
+
* if the saved row could not be parsed (MCP path only).
|
|
16
|
+
*/
|
|
17
|
+
export async function saveQualityReport(params) {
|
|
18
|
+
const { product_id, repository_id, commit_sha, rubric_version } = params;
|
|
19
|
+
if (!product_id && !repository_id) {
|
|
20
|
+
throw new Error('Either product_id or repository_id is required');
|
|
21
|
+
}
|
|
22
|
+
if (await ensureSupabaseSession()) {
|
|
23
|
+
const supabase = getSupabase();
|
|
24
|
+
if (params.replace_existing) {
|
|
25
|
+
// Clear the existing row on the natural cache key for this report's
|
|
26
|
+
// scope. Product-scoped: UNIQUE(product_id, commit_sha, rubric_version).
|
|
27
|
+
// Repo-only: UNIQUE(repository_id, commit_sha, rubric_version) WHERE
|
|
28
|
+
// product_id IS NULL — the repo-only delete MUST also require
|
|
29
|
+
// product_id IS NULL so it never clobbers a product report of the same
|
|
30
|
+
// linked repo at this commit.
|
|
31
|
+
let del = supabase
|
|
32
|
+
.from('quality_reports')
|
|
33
|
+
.delete()
|
|
34
|
+
.eq('commit_sha', commit_sha)
|
|
35
|
+
.eq('rubric_version', rubric_version);
|
|
36
|
+
del = product_id
|
|
37
|
+
? del.eq('product_id', product_id)
|
|
38
|
+
: del.eq('repository_id', repository_id).is('product_id', null);
|
|
39
|
+
const { error: delErr } = await del;
|
|
40
|
+
if (delErr) {
|
|
41
|
+
throw new Error(`Failed to clear existing report: ${delErr.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const { data: { user }, } = await supabase.auth.getUser();
|
|
45
|
+
const { data, error } = await supabase
|
|
46
|
+
.from('quality_reports')
|
|
47
|
+
.insert({
|
|
48
|
+
product_id: product_id ?? null,
|
|
49
|
+
repository_id: repository_id ?? null,
|
|
50
|
+
commit_sha,
|
|
51
|
+
rubric_version,
|
|
52
|
+
branch: params.branch ?? null,
|
|
53
|
+
repo_root: params.repo_root ?? null,
|
|
54
|
+
detected_context: params.detected_context ?? {},
|
|
55
|
+
tool_versions: params.tool_versions ?? {},
|
|
56
|
+
unavailable_tools: params.unavailable_tools ?? [],
|
|
57
|
+
applied_checks: params.applied_checks ?? {},
|
|
58
|
+
tool_outputs: params.tool_outputs ?? {},
|
|
59
|
+
external_signals: params.external_signals ?? {},
|
|
60
|
+
dropped_findings: params.dropped_findings ?? 0,
|
|
61
|
+
dimension_scores: params.dimension_scores ?? {},
|
|
62
|
+
overall_score: params.overall_score ?? null,
|
|
63
|
+
overall_grade: params.overall_grade ?? null,
|
|
64
|
+
executive_summary: params.executive_summary ?? null,
|
|
65
|
+
low_confidence: params.low_confidence ?? false,
|
|
66
|
+
status: params.status ?? 'completed',
|
|
67
|
+
started_at: params.started_at ?? null,
|
|
68
|
+
completed_at: params.completed_at ?? new Date().toISOString(),
|
|
69
|
+
duration_seconds: params.duration_seconds ?? null,
|
|
70
|
+
created_by: user?.id ?? null,
|
|
71
|
+
})
|
|
72
|
+
.select('id')
|
|
73
|
+
.single();
|
|
74
|
+
if (error) {
|
|
75
|
+
if (error.code === '23505') {
|
|
76
|
+
throw new Error(`quality report already exists for ` +
|
|
77
|
+
`(${product_id ? 'product' : 'repository'}, commit, rubric); ` +
|
|
78
|
+
`pass --force to overwrite`);
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`Failed to save quality report: ${error.message}`);
|
|
81
|
+
}
|
|
82
|
+
return { id: data?.id ?? null };
|
|
83
|
+
}
|
|
84
|
+
// No synced session — fall back to the MCP edge function (MCP-token auth).
|
|
85
|
+
const saved = (await callMcpEndpoint('quality_reports/save', params));
|
|
86
|
+
try {
|
|
87
|
+
const row = JSON.parse(saved.content?.[0]?.text ?? '{}');
|
|
88
|
+
return { id: row.id ?? null };
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { id: null };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -19,5 +19,16 @@ export declare function getSupabase(): SupabaseClient;
|
|
|
19
19
|
* usable for direct-SDK calls.
|
|
20
20
|
*/
|
|
21
21
|
export declare function hasSupabaseSession(): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Wait for the most recent setSession to finish applying, then report whether
|
|
24
|
+
* the client actually holds a usable user session.
|
|
25
|
+
*
|
|
26
|
+
* Returns false when the synced token was stale/expired and supabase-js
|
|
27
|
+
* dropped the session — in that state REST calls run as `anon` (auth.uid() is
|
|
28
|
+
* NULL) and any insert into an RLS table with `WITH CHECK (auth.uid() =
|
|
29
|
+
* user_id)` is rejected. Writers should gate the direct-SDK path on this and
|
|
30
|
+
* fall back to the MCP edge function (service-role) when it returns false.
|
|
31
|
+
*/
|
|
32
|
+
export declare function ensureSupabaseSession(): Promise<boolean>;
|
|
22
33
|
/** Reset module state. Test-only. */
|
|
23
34
|
export declare function resetSupabaseClient(): void;
|
package/dist/supabase/client.js
CHANGED
|
@@ -14,6 +14,8 @@ const AUTH_FILE = join(homedir(), '.edsger', 'auth.json');
|
|
|
14
14
|
let _client = null;
|
|
15
15
|
let _watcherInstalled = false;
|
|
16
16
|
let _lastAppliedAccessToken;
|
|
17
|
+
/** The in-flight setSession promise, awaited by ensureSupabaseSession(). */
|
|
18
|
+
let _sessionReady = null;
|
|
17
19
|
/**
|
|
18
20
|
* Get (or lazily create) the shared SupabaseClient.
|
|
19
21
|
*
|
|
@@ -39,7 +41,11 @@ export function getSupabase() {
|
|
|
39
41
|
detectSessionInUrl: false,
|
|
40
42
|
},
|
|
41
43
|
});
|
|
42
|
-
|
|
44
|
+
// Capture the setSession promise so writers can await it (via
|
|
45
|
+
// ensureSupabaseSession) before issuing inserts. Without this, the first
|
|
46
|
+
// REST call can race ahead of setSession and go out as `anon` (no user
|
|
47
|
+
// JWT), tripping the `auth.uid() = user_id` RLS check.
|
|
48
|
+
_sessionReady = _client.auth.setSession({
|
|
43
49
|
access_token: accessToken,
|
|
44
50
|
refresh_token: refreshToken ?? '',
|
|
45
51
|
});
|
|
@@ -54,6 +60,32 @@ export function getSupabase() {
|
|
|
54
60
|
export function hasSupabaseSession() {
|
|
55
61
|
return Boolean(getSupabaseUrl() && getSupabaseAnonKey() && getAccessToken());
|
|
56
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Wait for the most recent setSession to finish applying, then report whether
|
|
65
|
+
* the client actually holds a usable user session.
|
|
66
|
+
*
|
|
67
|
+
* Returns false when the synced token was stale/expired and supabase-js
|
|
68
|
+
* dropped the session — in that state REST calls run as `anon` (auth.uid() is
|
|
69
|
+
* NULL) and any insert into an RLS table with `WITH CHECK (auth.uid() =
|
|
70
|
+
* user_id)` is rejected. Writers should gate the direct-SDK path on this and
|
|
71
|
+
* fall back to the MCP edge function (service-role) when it returns false.
|
|
72
|
+
*/
|
|
73
|
+
export async function ensureSupabaseSession() {
|
|
74
|
+
if (!hasSupabaseSession()) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const client = getSupabase();
|
|
79
|
+
if (_sessionReady) {
|
|
80
|
+
await _sessionReady;
|
|
81
|
+
}
|
|
82
|
+
const { data } = await client.auth.getSession();
|
|
83
|
+
return Boolean(data.session?.access_token);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
57
89
|
function installAuthWatcher() {
|
|
58
90
|
if (_watcherInstalled) {
|
|
59
91
|
return;
|
|
@@ -70,7 +102,7 @@ function installAuthWatcher() {
|
|
|
70
102
|
return;
|
|
71
103
|
}
|
|
72
104
|
_lastAppliedAccessToken = nextAccess;
|
|
73
|
-
|
|
105
|
+
_sessionReady = _client.auth.setSession({
|
|
74
106
|
access_token: nextAccess,
|
|
75
107
|
refresh_token: nextRefresh ?? '',
|
|
76
108
|
});
|
|
@@ -87,4 +119,5 @@ export function resetSupabaseClient() {
|
|
|
87
119
|
_client = null;
|
|
88
120
|
_watcherInstalled = false;
|
|
89
121
|
_lastAppliedAccessToken = undefined;
|
|
122
|
+
_sessionReady = null;
|
|
90
123
|
}
|
|
@@ -10,7 +10,7 @@ import { hostname } from 'os';
|
|
|
10
10
|
import { callMcpEndpoint } from '../api/mcp-client.js';
|
|
11
11
|
import { getUserId } from '../auth/auth-store.js';
|
|
12
12
|
import { getVersion } from '../constants.js';
|
|
13
|
-
import { getSupabase, hasSupabaseSession } from '../supabase/client.js';
|
|
13
|
+
import { ensureSupabaseSession, getSupabase, hasSupabaseSession, } from '../supabase/client.js';
|
|
14
14
|
import { initLogSync, logInfo, logWarning, stopLogSync, } from '../utils/logger.js';
|
|
15
15
|
let currentSessionId = null;
|
|
16
16
|
let heartbeatTimer;
|
|
@@ -63,7 +63,13 @@ export async function registerSession(options) {
|
|
|
63
63
|
const invocation = process.argv.slice(2).join(' ') || undefined;
|
|
64
64
|
try {
|
|
65
65
|
const userId = getUserId();
|
|
66
|
-
|
|
66
|
+
// ensureSupabaseSession() awaits the in-flight setSession and confirms the
|
|
67
|
+
// client holds a live user session — so a token that hasn't applied yet
|
|
68
|
+
// (race) or was dropped (stale → anon) doesn't silently write as anon and
|
|
69
|
+
// trip the cli_sessions RLS check. The desktop refreshes the token into
|
|
70
|
+
// auth.json before spawning the CLI, so this should hold for app-spawned
|
|
71
|
+
// runs; the MCP branch stays for the legacy no-Supabase-session path.
|
|
72
|
+
if (userId && (await ensureSupabaseSession())) {
|
|
67
73
|
const row = {
|
|
68
74
|
session_id: sessionId,
|
|
69
75
|
user_id: userId,
|
package/dist/utils/logger.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { callMcpEndpoint } from '../api/mcp-client.js';
|
|
2
2
|
import { getUserId } from '../auth/auth-store.js';
|
|
3
3
|
import { getVersion } from '../constants.js';
|
|
4
|
-
import {
|
|
4
|
+
import { ensureSupabaseSession, getSupabase } from '../supabase/client.js';
|
|
5
5
|
export const colors = {
|
|
6
6
|
reset: '\x1b[0m',
|
|
7
7
|
bright: '\x1b[1m',
|
|
@@ -49,13 +49,16 @@ export async function flushLogs() {
|
|
|
49
49
|
return;
|
|
50
50
|
}
|
|
51
51
|
const batch = _logBuffer.splice(0);
|
|
52
|
+
const sessionId = _logSyncSessionId;
|
|
52
53
|
try {
|
|
53
54
|
const userId = getUserId();
|
|
54
|
-
if (
|
|
55
|
+
if (userId && (await ensureSupabaseSession())) {
|
|
55
56
|
// Direct-SDK path. user_id is set explicitly per row to satisfy the
|
|
56
|
-
// cli_logs RLS check (auth.uid() = user_id).
|
|
57
|
+
// cli_logs RLS check (auth.uid() = user_id). Gating on a confirmed
|
|
58
|
+
// session keeps a stale token (→ anon) from writing as anon and tripping
|
|
59
|
+
// RLS — desktop refreshes the token into auth.json before spawning.
|
|
57
60
|
const rows = batch.slice(0, 200).map((log) => ({
|
|
58
|
-
session_id:
|
|
61
|
+
session_id: sessionId,
|
|
59
62
|
user_id: userId,
|
|
60
63
|
level: log.level,
|
|
61
64
|
message: log.message,
|
|
@@ -68,7 +71,7 @@ export async function flushLogs() {
|
|
|
68
71
|
}
|
|
69
72
|
else {
|
|
70
73
|
await callMcpEndpoint('cli_logs/batch', {
|
|
71
|
-
session_id:
|
|
74
|
+
session_id: sessionId,
|
|
72
75
|
logs: batch,
|
|
73
76
|
});
|
|
74
77
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "edsger",
|
|
3
|
-
"version": "0.72.
|
|
3
|
+
"version": "0.72.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"edsger": "dist/index.js"
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
"commander": "^12.0.0",
|
|
55
55
|
"cosmiconfig": "^9.0.0",
|
|
56
56
|
"dotenv": "^16.4.5",
|
|
57
|
-
"edsger-contract": "0.9.
|
|
58
|
-
"edsger-tools": "0.9.
|
|
57
|
+
"edsger-contract": "0.9.2",
|
|
58
|
+
"edsger-tools": "0.9.2",
|
|
59
59
|
"gray-matter": "^4.0.3",
|
|
60
60
|
"zod": "^4.0.0"
|
|
61
61
|
},
|