@tpsdev-ai/flair 0.6.1 → 0.7.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/README.md +7 -5
- package/dist/bridges/runtime/allow-list.js +198 -0
- package/dist/bridges/runtime/import-runner.js +7 -3
- package/dist/bridges/runtime/load-bridge.js +126 -0
- package/dist/bridges/runtime/load-plugin.js +127 -0
- package/dist/cli.js +1895 -227
- package/dist/install/clients.js +225 -0
- package/dist/resources/Federation.js +21 -15
- package/dist/resources/auth-middleware.js +44 -11
- package/dist/resources/health.js +1 -1
- package/package.json +3 -1
- package/ui/observation-center.html +356 -62
- package/dist/bridges/runtime/load-descriptor.js +0 -46
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// ─── Client Detection & Wiring ──────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Detects locally installed MCP clients and wires them to Flair.
|
|
4
|
+
// Each client has:
|
|
5
|
+
// - detect(): boolean - returns true if client is installed
|
|
6
|
+
// - wire(options: { agentId: string; flairUrl: string }): { ok: boolean; message: string }
|
|
7
|
+
//
|
|
8
|
+
// ---- Detection helpers ----------------------------------------------------------
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { accessSync, constants } from "node:fs";
|
|
11
|
+
/**
|
|
12
|
+
* Check if a command exists in PATH (cross-platform alternative to `which`).
|
|
13
|
+
* Does not spawn a child process — pure filesystem check.
|
|
14
|
+
*/
|
|
15
|
+
function binInPath(name) {
|
|
16
|
+
try {
|
|
17
|
+
const sep = process.platform === "win32" ? ";" : ":";
|
|
18
|
+
const dirs = (process.env.PATH || "").split(sep);
|
|
19
|
+
const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ".ps1"] : [];
|
|
20
|
+
for (const dir of dirs) {
|
|
21
|
+
if (!dir)
|
|
22
|
+
continue;
|
|
23
|
+
const base = `${dir}/${name}`;
|
|
24
|
+
try {
|
|
25
|
+
accessSync(base, constants.X_OK);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch { /* not here */ }
|
|
29
|
+
for (const ext of exts) {
|
|
30
|
+
try {
|
|
31
|
+
accessSync(`${base}${ext}`, constants.X_OK);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch { /* not here */ }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function claudeCodeDetect() {
|
|
44
|
+
try {
|
|
45
|
+
const result = spawnSync("npm", ["list", "-g", "@anthropic-ai/claude-code"], {
|
|
46
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
47
|
+
});
|
|
48
|
+
return result.status === 0;
|
|
49
|
+
}
|
|
50
|
+
catch (_e) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function codexDetect() {
|
|
55
|
+
try {
|
|
56
|
+
if (binInPath("codex"))
|
|
57
|
+
return true;
|
|
58
|
+
const npmResult = spawnSync("npm", ["list", "-g", "@openai/codex"], {
|
|
59
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
60
|
+
});
|
|
61
|
+
return npmResult.status === 0;
|
|
62
|
+
}
|
|
63
|
+
catch (_e) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function geminiDetect() {
|
|
68
|
+
try {
|
|
69
|
+
if (binInPath("gemini"))
|
|
70
|
+
return true;
|
|
71
|
+
const npmResult = spawnSync("npm", ["list", "-g", "@google/generative-ai"], {
|
|
72
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
73
|
+
});
|
|
74
|
+
return npmResult.status === 0;
|
|
75
|
+
}
|
|
76
|
+
catch (_e) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function cursorDetect() {
|
|
81
|
+
try {
|
|
82
|
+
return binInPath("cursor");
|
|
83
|
+
}
|
|
84
|
+
catch (_e) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// ---- Internal wiring functions --------------------------------------------------
|
|
89
|
+
function _wireClaudeCode(env) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
message: `Manual wiring required for Claude Code:\n` +
|
|
93
|
+
`1. Locate Claude Code's settings file (usually ~/Library/Application Support/Claude/settings.json on macOS or %APPDATA%/Claude/settings.json on Windows)\n` +
|
|
94
|
+
`2. Add or update the "mcpServers" section:\n` +
|
|
95
|
+
` {\n` +
|
|
96
|
+
` "mcpServers": {\n` +
|
|
97
|
+
` "flair": {\n` +
|
|
98
|
+
` "command": "npx",\n` +
|
|
99
|
+
` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
|
|
100
|
+
` "env": {\n` +
|
|
101
|
+
` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
|
|
102
|
+
` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
|
|
103
|
+
` }\n` +
|
|
104
|
+
` }\n` +
|
|
105
|
+
` }\n` +
|
|
106
|
+
` }\n` +
|
|
107
|
+
`3. Restart Claude Code\n` +
|
|
108
|
+
`Note: This is a manual step - the Flair CLI cannot automatically modify Claude Code's settings due to security restrictions.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function _wireCodex(env) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
message: `Manual wiring required for Codex:\n` +
|
|
115
|
+
`1. Locate Codex's configuration (check ~/.codex/config or similar)\n` +
|
|
116
|
+
`2. Add the Flair MCP server configuration:\n` +
|
|
117
|
+
` {\n` +
|
|
118
|
+
` "mcpServers": {\n` +
|
|
119
|
+
` "flair": {\n` +
|
|
120
|
+
` "command": "npx",\n` +
|
|
121
|
+
` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
|
|
122
|
+
` "env": {\n` +
|
|
123
|
+
` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
|
|
124
|
+
` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
|
|
125
|
+
` }\n` +
|
|
126
|
+
` }\n` +
|
|
127
|
+
` }\n` +
|
|
128
|
+
` }\n` +
|
|
129
|
+
`3. Restart Codex\n` +
|
|
130
|
+
`Note: This is a manual step - the Flair CLI cannot automatically modify Codex's configuration due to security restrictions.`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function _wireGemini(env) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
message: `Manual wiring required for Gemini:\n` +
|
|
137
|
+
`1. Locate Gemini's configuration (check ~/.gemini/config or similar)\n` +
|
|
138
|
+
`2. Add the Flair MCP server configuration:\n` +
|
|
139
|
+
` {\n` +
|
|
140
|
+
` "mcpServers": {\n` +
|
|
141
|
+
` "flair": {\n` +
|
|
142
|
+
` "command": "npx",\n` +
|
|
143
|
+
` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
|
|
144
|
+
` "env": {\n` +
|
|
145
|
+
` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
|
|
146
|
+
` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
|
|
147
|
+
` }\n` +
|
|
148
|
+
` }\n` +
|
|
149
|
+
` }\n` +
|
|
150
|
+
` }\n` +
|
|
151
|
+
`3. Restart Gemini\n` +
|
|
152
|
+
`Note: This is a manual step - the Flair CLI cannot automatically modify Gemini's configuration due to security restrictions.`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function _wireCursor(env) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
message: `Manual wiring required for Cursor:\n` +
|
|
159
|
+
`1. Locate Cursor's settings file (usually ~/.cursor/settings.json)\n` +
|
|
160
|
+
`2. Add or update the "mcpServers" section:\n` +
|
|
161
|
+
` {\n` +
|
|
162
|
+
` "mcpServers": {\n` +
|
|
163
|
+
` "flair": {\n` +
|
|
164
|
+
` "command": "npx",\n` +
|
|
165
|
+
` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
|
|
166
|
+
` "env": {\n` +
|
|
167
|
+
` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
|
|
168
|
+
` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
|
|
169
|
+
` }\n` +
|
|
170
|
+
` }\n` +
|
|
171
|
+
` }\n` +
|
|
172
|
+
` }\n` +
|
|
173
|
+
`3. Restart Cursor\n` +
|
|
174
|
+
`Note: This is a manual step - the Flair CLI cannot automatically modify Cursor's settings due to security restrictions.`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
// ---- Exported detection & wiring array ------------------------------------------
|
|
178
|
+
export const ALL_CLIENTS = [
|
|
179
|
+
{
|
|
180
|
+
id: "claude-code",
|
|
181
|
+
label: "Claude Code",
|
|
182
|
+
wire: _wireClaudeCode,
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: "codex",
|
|
186
|
+
label: "Codex",
|
|
187
|
+
wire: _wireCodex,
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
id: "gemini",
|
|
191
|
+
label: "Gemini",
|
|
192
|
+
wire: _wireGemini,
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: "cursor",
|
|
196
|
+
label: "Cursor",
|
|
197
|
+
wire: _wireCursor,
|
|
198
|
+
},
|
|
199
|
+
];
|
|
200
|
+
export function detectClients() {
|
|
201
|
+
return ALL_CLIENTS.map((client) => ({
|
|
202
|
+
...client,
|
|
203
|
+
detected: client.id === "claude-code"
|
|
204
|
+
? claudeCodeDetect()
|
|
205
|
+
: client.id === "codex"
|
|
206
|
+
? codexDetect()
|
|
207
|
+
: client.id === "gemini"
|
|
208
|
+
? geminiDetect()
|
|
209
|
+
: client.id === "cursor"
|
|
210
|
+
? cursorDetect()
|
|
211
|
+
: false,
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
export function wireClaudeCode(env) {
|
|
215
|
+
return _wireClaudeCode(env);
|
|
216
|
+
}
|
|
217
|
+
export function wireCodex(env) {
|
|
218
|
+
return _wireCodex(env);
|
|
219
|
+
}
|
|
220
|
+
export function wireGemini(env) {
|
|
221
|
+
return _wireGemini(env);
|
|
222
|
+
}
|
|
223
|
+
export function wireCursor(env) {
|
|
224
|
+
return _wireCursor(env);
|
|
225
|
+
}
|
|
@@ -82,6 +82,14 @@ export class FederationInstance extends Resource {
|
|
|
82
82
|
* Requires a one-time pairing token generated by `flair federation token`.
|
|
83
83
|
*/
|
|
84
84
|
export class FederationPair extends Resource {
|
|
85
|
+
// Pairing is a public endpoint by design — spokes have no Harper credentials
|
|
86
|
+
// on the hub yet. The handler self-validates via PairingToken + Ed25519
|
|
87
|
+
// body-signature. Bypassing Harper's role gate is required because Harper's
|
|
88
|
+
// auth layer (security/auth.js) claims any "Bearer X" header for itself,
|
|
89
|
+
// so we route the token through the body instead.
|
|
90
|
+
allowCreate(_user, _record, _context) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
85
93
|
async post(data) {
|
|
86
94
|
const { instanceId, publicKey, role, endpoint, signature, pairingToken } = data || {};
|
|
87
95
|
if (!instanceId || !publicKey) {
|
|
@@ -119,36 +127,34 @@ export class FederationPair extends Resource {
|
|
|
119
127
|
});
|
|
120
128
|
}
|
|
121
129
|
else {
|
|
122
|
-
// New peer — require a valid pairing token
|
|
130
|
+
// New peer — require a valid pairing token in the request body.
|
|
131
|
+
// The handler self-validates because the endpoint is public at the
|
|
132
|
+
// Harper-auth and middleware layers (Bearer is not usable on Harper —
|
|
133
|
+
// its auth layer claims unknown Bearer values and 401s).
|
|
123
134
|
if (!pairingToken) {
|
|
124
135
|
return new Response(JSON.stringify({
|
|
125
|
-
error: "pairingToken required
|
|
136
|
+
error: "pairingToken required in body for first-time pair",
|
|
126
137
|
}), { status: 401, headers: { "content-type": "application/json" } });
|
|
127
138
|
}
|
|
128
|
-
//
|
|
129
|
-
let
|
|
139
|
+
// Fetch the token record for consumption
|
|
140
|
+
let tokenRecord = null;
|
|
130
141
|
try {
|
|
131
|
-
|
|
142
|
+
tokenRecord = await databases.flair.PairingToken.get(pairingToken);
|
|
132
143
|
}
|
|
133
144
|
catch { /* table may not exist */ }
|
|
134
|
-
if (!
|
|
135
|
-
return new Response(JSON.stringify({ error: "
|
|
136
|
-
status: 401, headers: { "content-type": "application/json" },
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
if (token.consumedBy) {
|
|
140
|
-
return new Response(JSON.stringify({ error: "pairing token already used" }), {
|
|
145
|
+
if (!tokenRecord || tokenRecord.consumedBy) {
|
|
146
|
+
return new Response(JSON.stringify({ error: "invalid_or_expired_pairing_token" }), {
|
|
141
147
|
status: 401, headers: { "content-type": "application/json" },
|
|
142
148
|
});
|
|
143
149
|
}
|
|
144
|
-
if (
|
|
145
|
-
return new Response(JSON.stringify({ error: "
|
|
150
|
+
if (tokenRecord.expiresAt && new Date(tokenRecord.expiresAt) < new Date()) {
|
|
151
|
+
return new Response(JSON.stringify({ error: "invalid_or_expired_pairing_token" }), {
|
|
146
152
|
status: 401, headers: { "content-type": "application/json" },
|
|
147
153
|
});
|
|
148
154
|
}
|
|
149
155
|
// Consume the token
|
|
150
156
|
await databases.flair.PairingToken.put({
|
|
151
|
-
...
|
|
157
|
+
...tokenRecord,
|
|
152
158
|
consumedBy: instanceId,
|
|
153
159
|
consumedAt: new Date().toISOString(),
|
|
154
160
|
});
|
|
@@ -117,28 +117,49 @@ server.http(async (request, nextLayer) => {
|
|
|
117
117
|
url.pathname === "/AgentCard" ||
|
|
118
118
|
url.pathname.startsWith("/A2AAdapter/") ||
|
|
119
119
|
url.pathname.startsWith("/AgentCard/") ||
|
|
120
|
-
//
|
|
121
|
-
url.pathname === "/FederationPair" ||
|
|
120
|
+
// FederationSync uses Ed25519 body-signature auth (handled by the resource)
|
|
122
121
|
url.pathname === "/FederationSync" ||
|
|
122
|
+
// FederationPair uses one-time PairingToken in the request body, validated
|
|
123
|
+
// by the resource itself (allowCreate=true on the Resource lets anonymous
|
|
124
|
+
// POST through Harper's role gate). Bearer can't be used here because
|
|
125
|
+
// Harper's auth layer claims any "Bearer X" Authorization header for itself.
|
|
126
|
+
url.pathname === "/FederationPair" ||
|
|
123
127
|
// OAuth 2.1 public endpoints (spec requires no pre-auth)
|
|
124
128
|
url.pathname === "/OAuthRegister" ||
|
|
125
129
|
url.pathname === "/OAuthAuthorize" ||
|
|
126
130
|
url.pathname === "/OAuthToken" ||
|
|
127
131
|
url.pathname === "/OAuthRevoke" ||
|
|
128
132
|
url.pathname === "/.well-known/oauth-authorization-server" ||
|
|
129
|
-
url.pathname === "/OAuthMetadata"
|
|
133
|
+
url.pathname === "/OAuthMetadata" ||
|
|
134
|
+
// ObservationCenter HTML shell is public — the page itself is just markup
|
|
135
|
+
// and inline JS, with no embedded data. The JS prompts for admin-pass and
|
|
136
|
+
// auths every API call (/Agent, /SemanticSearch, /FederationPeers, etc).
|
|
137
|
+
// Without this allow-list entry, the HTML is 401-blocked on hosted Flair
|
|
138
|
+
// instances (rockit-local works only because authorizeLocal=true).
|
|
139
|
+
url.pathname === "/ObservationCenter")
|
|
140
|
+
return nextLayer(request);
|
|
141
|
+
// If Harper has already authorized this request (e.g. authorizeLocal=true on localhost),
|
|
142
|
+
// trust Harper's auth decision and pass through without requiring additional headers.
|
|
143
|
+
if (request.user?.role?.permission?.super_user === true) {
|
|
130
144
|
return nextLayer(request);
|
|
145
|
+
}
|
|
131
146
|
// Skip re-entry: if we already swapped auth to Basic, pass through
|
|
132
147
|
if (request._tpsAuthVerified)
|
|
133
148
|
return nextLayer(request);
|
|
134
149
|
const header = request.headers.get("authorization") || request.headers?.asObject?.authorization || "";
|
|
135
|
-
// ── Basic admin auth
|
|
136
|
-
// Allow Basic auth
|
|
137
|
-
//
|
|
150
|
+
// ── Basic admin / super_user auth ──────────────────────────────────────────
|
|
151
|
+
// Allow Basic auth for CLI operations (backup, etc.). Two paths:
|
|
152
|
+
// 1. HDB_ADMIN_PASSWORD env-var fast-path (user must be "admin" with exact pass)
|
|
153
|
+
// 2. Harper super_user check — any user with super_user:true permission accepted
|
|
154
|
+
// Checked BEFORE Ed25519 so admin tools can use simple auth.
|
|
138
155
|
if (header.startsWith("Basic ")) {
|
|
139
156
|
try {
|
|
140
157
|
const decoded = Buffer.from(header.slice(6), "base64").toString("utf-8");
|
|
141
|
-
const
|
|
158
|
+
const colonIdx = decoded.indexOf(":");
|
|
159
|
+
const user = colonIdx >= 0 ? decoded.slice(0, colonIdx) : decoded;
|
|
160
|
+
const pass = colonIdx >= 0 ? decoded.slice(colonIdx + 1) : "";
|
|
161
|
+
// Path 1: Env-var fast-path (back-compat). Only matches user==="admin"
|
|
162
|
+
// with exact HDB_ADMIN_PASSWORD. Non-match falls through to Path 2.
|
|
142
163
|
const adminPass = getAdminPass();
|
|
143
164
|
if (adminPass !== null && user === "admin" && pass === adminPass) {
|
|
144
165
|
// Mark as verified and set Harper user directly
|
|
@@ -150,14 +171,26 @@ server.http(async (request, nextLayer) => {
|
|
|
150
171
|
request.headers.set("x-tps-agent", "admin");
|
|
151
172
|
if (request.headers.asObject)
|
|
152
173
|
request.headers.asObject["x-tps-agent"] = "admin";
|
|
153
|
-
// Basic admin auth IS admin — resource-level checks (SemanticSearch,
|
|
154
|
-
// MemoryBootstrap, MemoryReflect, MemoryConsolidate) gate cross-agent
|
|
155
|
-
// access on this flag. Prior to 0.5.5 the check was a no-op so this
|
|
156
|
-
// was never needed; now it must be set.
|
|
157
174
|
request.tpsAgent = "admin";
|
|
158
175
|
request.tpsAgentIsAdmin = true;
|
|
159
176
|
return nextLayer(request);
|
|
160
177
|
}
|
|
178
|
+
// Path 2: Harper super_user check — any user with super_user:true
|
|
179
|
+
let harperUser = null;
|
|
180
|
+
try {
|
|
181
|
+
harperUser = await server.getUser(user, pass, request);
|
|
182
|
+
}
|
|
183
|
+
catch { /* fall through — invalid creds, non-existent user, etc. */ }
|
|
184
|
+
if (harperUser?.role?.permission?.super_user === true) {
|
|
185
|
+
request._tpsAuthVerified = true;
|
|
186
|
+
request.user = harperUser;
|
|
187
|
+
request.headers.set("x-tps-agent", user);
|
|
188
|
+
if (request.headers.asObject)
|
|
189
|
+
request.headers.asObject["x-tps-agent"] = user;
|
|
190
|
+
request.tpsAgent = user;
|
|
191
|
+
request.tpsAgentIsAdmin = true;
|
|
192
|
+
return nextLayer(request);
|
|
193
|
+
}
|
|
161
194
|
}
|
|
162
195
|
catch { /* fall through to Ed25519 check */ }
|
|
163
196
|
return new Response(JSON.stringify({ error: "invalid_admin_credentials" }), { status: 401 });
|
package/dist/resources/health.js
CHANGED
|
@@ -150,7 +150,7 @@ export class HealthDetail extends Resource {
|
|
|
150
150
|
const perAgentFull = Array.from(perAgentMap.values()).sort((a, b) => b.memoryCount - a.memoryCount);
|
|
151
151
|
const perAgent = isAdmin
|
|
152
152
|
? perAgentFull
|
|
153
|
-
: perAgentFull.filter((r) => r.id === callerAgent);
|
|
153
|
+
: perAgentFull.filter((r) => r.id === callerAgent || r.memoryCount > 0);
|
|
154
154
|
stats.agents = {
|
|
155
155
|
count: agents.length,
|
|
156
156
|
names: isAdmin ? agents.map((a) => a.id).filter(Boolean) : undefined,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -59,11 +59,13 @@
|
|
|
59
59
|
"harper-fabric-embeddings": "0.2.3",
|
|
60
60
|
"jose": "^6.2.2",
|
|
61
61
|
"js-yaml": "^4.1.1",
|
|
62
|
+
"tar": "^7.5.13",
|
|
62
63
|
"tweetnacl": "1.0.3"
|
|
63
64
|
},
|
|
64
65
|
"devDependencies": {
|
|
65
66
|
"@playwright/test": "^1.59.1",
|
|
66
67
|
"@types/node": "24.11.0",
|
|
68
|
+
"@types/tar": "^7.0.87",
|
|
67
69
|
"bun-types": "1.3.11",
|
|
68
70
|
"typescript": "5.9.3"
|
|
69
71
|
},
|