@retasc/cli 1.2.4 → 1.3.0
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/api.js +61 -13
- package/dist/auth.js +36 -0
- package/dist/index.js +5 -1
- package/dist/lib/watchdog.js +53 -9
- package/dist/proxy.js +97 -6
- package/package.json +2 -2
package/dist/api.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ConvexHttpClient } from "convex/browser";
|
|
2
2
|
import { makeFunctionReference } from "convex/server";
|
|
3
3
|
import { loadConfig } from "./config.js";
|
|
4
|
+
import { refreshSession, deviceLogin } from "./auth.js";
|
|
4
5
|
// Typed-ish references to the public management functions in convex/manage.ts.
|
|
5
6
|
// The CLI is a standalone package, so we reference functions by name rather than
|
|
6
7
|
// importing the parent's generated api.
|
|
@@ -26,18 +27,65 @@ function client() {
|
|
|
26
27
|
c.setAuth(cfg.token);
|
|
27
28
|
return c;
|
|
28
29
|
}
|
|
30
|
+
// Does this error mean "the access token is missing/expired", i.e. a refresh
|
|
31
|
+
// might fix it? The access-token JWT lives ~1h, so any long-lived login trips
|
|
32
|
+
// this. Two shapes surface: our server functions throw `UNAUTHENTICATED …`
|
|
33
|
+
// (requireUser), and the Convex platform rejects a stale JWT with "Could not
|
|
34
|
+
// verify OIDC token"/"Unauthenticated". Match either, case-insensitively.
|
|
35
|
+
export function isAuthError(e) {
|
|
36
|
+
const msg = String(e?.message ?? e);
|
|
37
|
+
return /unauthenticated|could not verify oidc|oidc token/i.test(msg);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Run an authed management call, transparently recovering from an expired
|
|
41
|
+
* access token. The two-token design (short access JWT + long refresh token) is
|
|
42
|
+
* the point: the CLI silently rides the refresh token across the hourly access
|
|
43
|
+
* boundary instead of forcing `retasc login` every hour (RTSC-165).
|
|
44
|
+
*
|
|
45
|
+
* On an auth error we try ONE recovery, then retry the call ONCE:
|
|
46
|
+
* 1. If there is no stored token at all, the user was never signed in — this
|
|
47
|
+
* isn't an expiry, so rethrow the clean "sign in first" error rather than
|
|
48
|
+
* launching a surprise device flow under a misleading "session expired".
|
|
49
|
+
* 2. `refreshSession()` — swap the refresh token for a fresh access token.
|
|
50
|
+
* 3. If that fails (refresh token expired/invalid), fall back to a full
|
|
51
|
+
* `deviceLogin()` — but only when stdout is a TTY, since the device flow
|
|
52
|
+
* prints a code to stdout for a human to authorize; in a captured/piped
|
|
53
|
+
* context we surface a clear "run `retasc login`" error instead of hanging
|
|
54
|
+
* or spilling the prompt into someone's `$(retasc …)` capture.
|
|
55
|
+
* A second auth failure after recovery is real (rethrown) — we never loop.
|
|
56
|
+
*/
|
|
57
|
+
async function withAuth(call) {
|
|
58
|
+
try {
|
|
59
|
+
return await call();
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
if (!isAuthError(e))
|
|
63
|
+
throw e;
|
|
64
|
+
// No session to recover — never signed in. Surface the original error.
|
|
65
|
+
if (!loadConfig().token)
|
|
66
|
+
throw e;
|
|
67
|
+
if (await refreshSession())
|
|
68
|
+
return await call();
|
|
69
|
+
if (!process.stdout.isTTY) {
|
|
70
|
+
throw new Error("Session expired and could not refresh. Run `retasc login`.");
|
|
71
|
+
}
|
|
72
|
+
console.error("Session expired — re-authenticating with GitHub…");
|
|
73
|
+
await deviceLogin();
|
|
74
|
+
return await call();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
29
77
|
export const api = {
|
|
30
|
-
me: () => client().query(fns.me, {}),
|
|
31
|
-
listProjects: (args) => client().query(fns.listProjects, args),
|
|
32
|
-
createOrg: (args) => client().mutation(fns.createOrg, args),
|
|
33
|
-
createProject: (args) => client().mutation(fns.createProject, args),
|
|
34
|
-
renameProjectPrefix: (args) => client().mutation(fns.renameProjectPrefix, args),
|
|
35
|
-
listKeys: (args) => client().query(fns.listKeys, args),
|
|
36
|
-
mintKey: (args) => client().action(fns.mintKey, args),
|
|
37
|
-
rotateKey: (args) => client().action(fns.rotateKey, args),
|
|
38
|
-
revokeKey: (args) => client().mutation(fns.revokeKey, args),
|
|
39
|
-
createInvite: (args) => client().action(fns.createInvite, args),
|
|
40
|
-
acceptInvite: (args) => client().mutation(fns.acceptInvite, args),
|
|
41
|
-
listInvites: (args) => client().query(fns.listInvites, args),
|
|
42
|
-
revokeInvite: (args) => client().mutation(fns.revokeInvite, args),
|
|
78
|
+
me: () => withAuth(() => client().query(fns.me, {})),
|
|
79
|
+
listProjects: (args) => withAuth(() => client().query(fns.listProjects, args)),
|
|
80
|
+
createOrg: (args) => withAuth(() => client().mutation(fns.createOrg, args)),
|
|
81
|
+
createProject: (args) => withAuth(() => client().mutation(fns.createProject, args)),
|
|
82
|
+
renameProjectPrefix: (args) => withAuth(() => client().mutation(fns.renameProjectPrefix, args)),
|
|
83
|
+
listKeys: (args) => withAuth(() => client().query(fns.listKeys, args)),
|
|
84
|
+
mintKey: (args) => withAuth(() => client().action(fns.mintKey, args)),
|
|
85
|
+
rotateKey: (args) => withAuth(() => client().action(fns.rotateKey, args)),
|
|
86
|
+
revokeKey: (args) => withAuth(() => client().mutation(fns.revokeKey, args)),
|
|
87
|
+
createInvite: (args) => withAuth(() => client().action(fns.createInvite, args)),
|
|
88
|
+
acceptInvite: (args) => withAuth(() => client().mutation(fns.acceptInvite, args)),
|
|
89
|
+
listInvites: (args) => withAuth(() => client().query(fns.listInvites, args)),
|
|
90
|
+
revokeInvite: (args) => withAuth(() => client().mutation(fns.revokeInvite, args)),
|
|
43
91
|
};
|
package/dist/auth.js
CHANGED
|
@@ -76,3 +76,39 @@ export async function deviceLogin() {
|
|
|
76
76
|
}
|
|
77
77
|
patchConfig({ token: tokens.token, refreshToken: tokens.refreshToken });
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Silently mint a fresh access token from the stored refresh token and persist
|
|
81
|
+
* the rotated pair to ~/.retasc/config.json.
|
|
82
|
+
*
|
|
83
|
+
* Convex Auth access tokens (the JWT in `token`) live only ~1h; the refresh
|
|
84
|
+
* token is long-lived (session default ~30d) and exists precisely to swap for a
|
|
85
|
+
* new access token without re-running the device flow. Calling `auth:signIn`
|
|
86
|
+
* with `{ refreshToken }` (no provider/params) is Convex Auth's refresh grant —
|
|
87
|
+
* it returns `{ tokens: { token, refreshToken } }` with BOTH rotated (refresh
|
|
88
|
+
* tokens are single-use; the old one is invalidated), so we must write the new
|
|
89
|
+
* pair back immediately and never reuse the old refresh token.
|
|
90
|
+
*
|
|
91
|
+
* Returns true on success (config now holds a fresh session), false if there is
|
|
92
|
+
* no refresh token or the grant is rejected (expired/invalid/reused) — the
|
|
93
|
+
* caller then falls back to a full `deviceLogin()`. Never throws and never
|
|
94
|
+
* prints token values.
|
|
95
|
+
*/
|
|
96
|
+
export async function refreshSession() {
|
|
97
|
+
const cfg = loadConfig();
|
|
98
|
+
if (!cfg.refreshToken)
|
|
99
|
+
return false;
|
|
100
|
+
try {
|
|
101
|
+
const convex = new ConvexHttpClient(cfg.deploymentUrl);
|
|
102
|
+
const res = await convex.action(signIn, { refreshToken: cfg.refreshToken });
|
|
103
|
+
const tokens = res?.tokens;
|
|
104
|
+
if (!tokens?.token)
|
|
105
|
+
return false;
|
|
106
|
+
patchConfig({ token: tokens.token, refreshToken: tokens.refreshToken });
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Expired/invalid/reused refresh token, or a transient backend error. Treat
|
|
111
|
+
// as "cannot refresh" — the caller re-authenticates via the device flow.
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -434,7 +434,11 @@ program
|
|
|
434
434
|
.option("--prune", "Delete reapable branches + worktrees (default: dry-run, just report)")
|
|
435
435
|
.option("--force", "Also delete orphans (issue done but branch unmerged)")
|
|
436
436
|
.option("--json", "Emit the reconciled branch table as JSON")
|
|
437
|
-
.
|
|
437
|
+
.option("--only <RTSC-NN>", "Scope the sweep to a single issue's branch (used by the MCP auto-reap)")
|
|
438
|
+
.action((opts) =>
|
|
439
|
+
// Normalize --only to the canonical uppercase id so it matches the branch ids
|
|
440
|
+
// scan() derives (rtsc-181/… → RTSC-181), same as `done` does for --id.
|
|
441
|
+
tidyAction({ ...opts, only: opts.only ? String(opts.only).toUpperCase() : undefined }).catch(fail));
|
|
438
442
|
program
|
|
439
443
|
.command("done")
|
|
440
444
|
.description("Mark the current issue (rtsc-NN/ branch, or --id) done and tear down its worktree+branch.")
|
package/dist/lib/watchdog.js
CHANGED
|
@@ -38,15 +38,59 @@ export function applyObservation(leases, o) {
|
|
|
38
38
|
}
|
|
39
39
|
// Releases / terminal status drop the lease — the issue is named in the REQUEST.
|
|
40
40
|
const id = typeof o.args?.identifier === "string" ? o.args.identifier : undefined;
|
|
41
|
-
if (id)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
41
|
+
if (id && o.toolName === "release_issue")
|
|
42
|
+
leases.delete(id);
|
|
43
|
+
const closeId = terminalCloseId(o);
|
|
44
|
+
if (closeId)
|
|
45
|
+
leases.delete(closeId);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The issue id to reap if this observation is a terminal close (done|canceled) via
|
|
49
|
+
* `save_issue`, else undefined. This is the SAME rule applyObservation uses to drop
|
|
50
|
+
* the lease — factored out so "which calls end an issue" lives in one place and the
|
|
51
|
+
* proxy's auto-reap (RTSC-181) can't drift from the lease bookkeeping. The caller
|
|
52
|
+
* decides whether it actually held the lease and whether the call succeeded
|
|
53
|
+
* (`isToolError`) before acting — this only classifies the request shape.
|
|
54
|
+
*/
|
|
55
|
+
export function terminalCloseId(o) {
|
|
56
|
+
if (o.toolName !== "save_issue")
|
|
57
|
+
return undefined;
|
|
58
|
+
const id = typeof o.args?.identifier === "string" ? o.args.identifier : undefined;
|
|
59
|
+
if (!id)
|
|
60
|
+
return undefined;
|
|
61
|
+
const status = o.args?.status;
|
|
62
|
+
return status === "done" || status === "canceled" ? id : undefined;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Did a tools/call result come back as an MCP error envelope (`{ isError: true }`)?
|
|
66
|
+
* Used to gate side effects — the proxy must never reap a branch off a `save_issue`
|
|
67
|
+
* that actually FAILED server-side. Distinct from isClaimLost (which matches a
|
|
68
|
+
* specific error string); this is the generic "did the tool error at all" check.
|
|
69
|
+
*/
|
|
70
|
+
export function isToolError(result) {
|
|
71
|
+
return (!!result && typeof result === "object" && result.isError === true);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The full RTSC-181 reap gate as ONE pure decision: given an observed tool call and
|
|
75
|
+
* the leases this session holds, return the issue id to reap — or undefined. Reap ONLY
|
|
76
|
+
* when ALL hold:
|
|
77
|
+
* 1. it's a terminal close (save_issue done|canceled),
|
|
78
|
+
* 2. of an issue THIS session actually claimed (in `leases` — never someone else's),
|
|
79
|
+
* 3. and the call genuinely succeeded — a truthy payload that is not an error
|
|
80
|
+
* envelope. A transport-level failure yields an undefined result (no envelope to
|
|
81
|
+
* catch); requiring a truthy payload means we never reap off a failed close.
|
|
82
|
+
* This is the security-critical check — keeping it pure makes every branch testable
|
|
83
|
+
* (the proxy just spawns on a non-undefined return).
|
|
84
|
+
*/
|
|
85
|
+
export function shouldReapOnClose(o, leases) {
|
|
86
|
+
const id = terminalCloseId(o);
|
|
87
|
+
if (!id)
|
|
88
|
+
return undefined;
|
|
89
|
+
if (!leases.has(id))
|
|
90
|
+
return undefined;
|
|
91
|
+
if (!o.result || isToolError(o.result))
|
|
92
|
+
return undefined;
|
|
93
|
+
return id;
|
|
50
94
|
}
|
|
51
95
|
/** A JSON-RPC `heartbeat` tool call for one lease (the proxy's timer sends these). */
|
|
52
96
|
export function heartbeatRequest(rpcId, issueId, claimToken) {
|
package/dist/proxy.js
CHANGED
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
// no watchdog. Self-enforcing: no proxy → no Retasc tools → can't orphan a lease.
|
|
8
8
|
import { createInterface } from "node:readline";
|
|
9
9
|
import { hostname } from "node:os";
|
|
10
|
-
import {
|
|
10
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
11
|
+
import { dirname, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { applyObservation, heartbeatRequest, isClaimLost, shouldReapOnClose, } from "./lib/watchdog.js";
|
|
11
14
|
import { resolveConn } from "./lib/keystore.js";
|
|
12
15
|
import { toolResult as parseTool } from "./lib/toolresult.js";
|
|
13
16
|
import { mintSessionKey, appendFallbackNotice } from "./lib/session.js";
|
|
@@ -30,6 +33,87 @@ let sessionKeyFallback = false;
|
|
|
30
33
|
function log(msg) {
|
|
31
34
|
process.stderr.write(`[retasc-watchdog] ${msg}\n`);
|
|
32
35
|
}
|
|
36
|
+
// RTSC-181: closing an issue over MCP (save_issue status:done) marks it done on the
|
|
37
|
+
// control plane but the server never touches git — so the branch/worktree orphans.
|
|
38
|
+
// The proxy is the local hands: when it sees a session's own claim close terminally,
|
|
39
|
+
// it reaps that issue's branch out-of-band via the CLI's existing `tidy --prune`,
|
|
40
|
+
// which reuses every safety guard (skips dirty/unmerged/current-branch). Resolved
|
|
41
|
+
// once at startup; null if we're not in a git repo (nothing to reap).
|
|
42
|
+
const MAIN_CHECKOUT = resolveMainCheckout();
|
|
43
|
+
// The compiled CLI entrypoint (dist/index.js) — resolved relative to this module so
|
|
44
|
+
// it's correct however the proxy was launched (not process.argv, which a wrapper
|
|
45
|
+
// could rewrite). Reaps re-invoke it as `retasc tidy`.
|
|
46
|
+
const CLI_ENTRY = fileURLToPath(new URL("./index.js", import.meta.url));
|
|
47
|
+
// Issues whose reap child is in flight — so a duplicate/retried close doesn't spawn
|
|
48
|
+
// two concurrent teardowns racing on the same branch.
|
|
49
|
+
const reaping = new Set();
|
|
50
|
+
// Reaps are SERIALIZED through this chain: concurrent `tidy` children mutating the
|
|
51
|
+
// same main checkout contend on `.git` locks (index.lock, ref locks) — a wave-close
|
|
52
|
+
// (next_batch → N × save_issue done) would otherwise fire N parallel worktree/branch
|
|
53
|
+
// deletes and hit "cannot lock ref" errors + partial teardown. One at a time avoids it.
|
|
54
|
+
let reapChain = Promise.resolve();
|
|
55
|
+
const REAP_TIMEOUT_MS = 60_000; // a stuck git (e.g. a credential prompt) must not wedge the queue
|
|
56
|
+
/** The main checkout root (worktree/branch ops always target it), or null if not in
|
|
57
|
+
* a git repo. `--git-common-dir` resolves to the MAIN `.git` even from a worktree. */
|
|
58
|
+
function resolveMainCheckout() {
|
|
59
|
+
const r = spawnSync("git", ["rev-parse", "--git-common-dir"], { encoding: "utf8" });
|
|
60
|
+
if (r.status !== 0)
|
|
61
|
+
return null;
|
|
62
|
+
const commonDir = (r.stdout ?? "").trim();
|
|
63
|
+
return commonDir ? dirname(resolve(commonDir)) : null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Reap a terminally-closed issue's branch/worktree, out-of-band (RTSC-181). Enqueues
|
|
67
|
+
* a `retasc tidy --prune --only <id>` run (serialized — see `reapChain`) as a SEPARATE
|
|
68
|
+
* process rooted at the main checkout so (a) blocking git never stalls the proxy's
|
|
69
|
+
* stdio loop, and (b) cwd is the main checkout, not the agent's worktree — so `tidy`'s
|
|
70
|
+
* "you're inside its worktree" guard doesn't skip the very tree we mean to remove.
|
|
71
|
+
* `tidy` still enforces every safety check: an unmerged (or dirty) branch is left as an
|
|
72
|
+
* orphan, never force-deleted — so only a clean, merged worktree is ever removed.
|
|
73
|
+
*/
|
|
74
|
+
function reapClosedIssue(issueId) {
|
|
75
|
+
if (!MAIN_CHECKOUT)
|
|
76
|
+
return; // not in a git repo — nothing local to reap
|
|
77
|
+
if (reaping.has(issueId))
|
|
78
|
+
return; // a teardown for this issue is already queued/running
|
|
79
|
+
reaping.add(issueId);
|
|
80
|
+
// Chain after any in-flight reap so git ops on the shared checkout never overlap.
|
|
81
|
+
reapChain = reapChain.then(() => runReap(issueId));
|
|
82
|
+
}
|
|
83
|
+
/** Run one `tidy --prune --only` child to completion (resolve on exit/error/timeout).
|
|
84
|
+
* Never rejects — a failed reap is best-effort cleanup, logged, never fatal. */
|
|
85
|
+
function runReap(issueId) {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
log(`${issueId} closed → reaping its branch/worktree…`);
|
|
88
|
+
const child = spawn(process.execPath, [CLI_ENTRY, "tidy", "--prune", "--only", issueId], {
|
|
89
|
+
cwd: MAIN_CHECKOUT,
|
|
90
|
+
env: process.env,
|
|
91
|
+
// stdout is ignored (tidy reports on stderr); stderr inherits so its reap log
|
|
92
|
+
// lands in the same MCP stderr stream as the rest of the watchdog's output.
|
|
93
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
94
|
+
});
|
|
95
|
+
let settled = false;
|
|
96
|
+
const finish = () => {
|
|
97
|
+
if (settled)
|
|
98
|
+
return;
|
|
99
|
+
settled = true;
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
reaping.delete(issueId);
|
|
102
|
+
resolve();
|
|
103
|
+
};
|
|
104
|
+
const timer = setTimeout(() => {
|
|
105
|
+
log(`reap of ${issueId} timed out — killing it`);
|
|
106
|
+
child.kill("SIGKILL"); // 'exit' still fires → finish() runs
|
|
107
|
+
}, REAP_TIMEOUT_MS);
|
|
108
|
+
timer.unref?.();
|
|
109
|
+
child.on("error", (e) => {
|
|
110
|
+
log(`reap of ${issueId} couldn't start: ${String(e?.message ?? e)}`);
|
|
111
|
+
finish();
|
|
112
|
+
});
|
|
113
|
+
child.on("exit", finish);
|
|
114
|
+
child.unref?.(); // a pending reap must not keep the proxy alive
|
|
115
|
+
});
|
|
116
|
+
}
|
|
33
117
|
async function postRemote(body) {
|
|
34
118
|
const res = await fetch(MCP_URL, {
|
|
35
119
|
method: "POST",
|
|
@@ -120,18 +204,25 @@ async function handleLine(line) {
|
|
|
120
204
|
// Watch tools/call traffic for claims/releases (request args + result), and
|
|
121
205
|
// flag the workspace-key fallback on whoami so the AGENT sees the degraded
|
|
122
206
|
// state (RTSC-143) — the startup stderr warning only reaches the MCP logs.
|
|
207
|
+
let reapId;
|
|
123
208
|
if (msg.method === "tools/call" && resp) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
209
|
+
const result = toolResult(resp, msg.params?.name);
|
|
210
|
+
const obs = { toolName: msg.params?.name, args: msg.params?.arguments, result };
|
|
211
|
+
// Decide the reap BEFORE applyObservation drops the lease (the gate checks lease
|
|
212
|
+
// ownership). Pure decision — see shouldReapOnClose. We act on it AFTER relaying
|
|
213
|
+
// the response, below (RTSC-181).
|
|
214
|
+
reapId = shouldReapOnClose(obs, leases);
|
|
215
|
+
applyObservation(leases, obs);
|
|
129
216
|
appendFallbackNotice(msg.params?.name, resp, sessionKeyFallback);
|
|
130
217
|
}
|
|
131
218
|
// Relay the response (requests have an id; notifications don't).
|
|
132
219
|
if (resp != null && msg.id !== undefined) {
|
|
133
220
|
process.stdout.write(JSON.stringify(resp) + "\n");
|
|
134
221
|
}
|
|
222
|
+
// Reap only after the tool result is on the wire — spawns its own process, so git
|
|
223
|
+
// never delays the response the agent is blocked on (RTSC-181).
|
|
224
|
+
if (reapId)
|
|
225
|
+
reapClosedIssue(reapId);
|
|
135
226
|
}
|
|
136
227
|
async function heartbeatAll() {
|
|
137
228
|
for (const [issueId, token] of [...leases]) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Retasc CLI — sign in with GitHub, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"convex": "^1.41.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@types/node": "^
|
|
49
|
+
"@types/node": "^26.1.0",
|
|
50
50
|
"typescript": "^5.6.0"
|
|
51
51
|
}
|
|
52
52
|
}
|