claudeboard 1.1.0 → 1.5.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/agents/board-client.js +27 -0
- package/agents/claude-api.js +24 -7
- package/agents/orchestrator.js +22 -4
- package/bin/cli.js +7 -3
- package/dashboard/index.html +1189 -572
- package/dashboard/server.js +234 -116
- package/package.json +2 -1
package/dashboard/server.js
CHANGED
|
@@ -6,192 +6,310 @@ import { createClient } from "@supabase/supabase-js";
|
|
|
6
6
|
import path from "path";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
8
8
|
import fs from "fs";
|
|
9
|
+
import { spawn } from "child_process";
|
|
10
|
+
import { createRequire } from "module";
|
|
9
11
|
|
|
10
12
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
11
14
|
|
|
12
15
|
const app = express();
|
|
13
16
|
const server = createServer(app);
|
|
14
|
-
|
|
17
|
+
|
|
18
|
+
// Two WS servers: one for board events, one for terminal
|
|
19
|
+
const boardWss = new WebSocketServer({ noServer: true });
|
|
20
|
+
const termWss = new WebSocketServer({ noServer: true });
|
|
21
|
+
|
|
22
|
+
// Route upgrade requests
|
|
23
|
+
server.on("upgrade", (req, socket, head) => {
|
|
24
|
+
if (req.url === "/terminal") {
|
|
25
|
+
termWss.handleUpgrade(req, socket, head, (ws) => termWss.emit("connection", ws, req));
|
|
26
|
+
} else {
|
|
27
|
+
boardWss.handleUpgrade(req, socket, head, (ws) => boardWss.emit("connection", ws, req));
|
|
28
|
+
}
|
|
29
|
+
});
|
|
15
30
|
|
|
16
31
|
app.use(cors());
|
|
17
32
|
app.use(express.json());
|
|
18
33
|
|
|
19
|
-
const PORT
|
|
20
|
-
const PROJECT
|
|
34
|
+
const PORT = process.env.PORT || 3131;
|
|
35
|
+
const PROJECT = process.env.PROJECT_NAME || "default";
|
|
36
|
+
const PROJECT_DIR = process.env.PROJECT_DIR || process.cwd();
|
|
37
|
+
const SUPABASE_URL = process.env.SUPABASE_URL;
|
|
38
|
+
const SUPABASE_KEY = process.env.SUPABASE_KEY;
|
|
21
39
|
|
|
22
|
-
const supabase = createClient(
|
|
23
|
-
process.env.SUPABASE_URL,
|
|
24
|
-
process.env.SUPABASE_KEY
|
|
25
|
-
);
|
|
40
|
+
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
|
|
26
41
|
|
|
27
|
-
//
|
|
42
|
+
// ── STATE ─────────────────────────────────────────────────────────────────────
|
|
43
|
+
let expoProcess = null;
|
|
44
|
+
let expoStatus = "stopped"; // stopped | installing | starting | running | error
|
|
45
|
+
let expoQR = null;
|
|
46
|
+
let expoUrl = null;
|
|
47
|
+
|
|
48
|
+
// ── BOARD BROADCAST ───────────────────────────────────────────────────────────
|
|
28
49
|
function broadcast(event, data) {
|
|
29
50
|
const msg = JSON.stringify({ event, data, ts: Date.now() });
|
|
30
|
-
|
|
31
|
-
if (client.readyState === 1) client.send(msg);
|
|
32
|
-
});
|
|
51
|
+
boardWss.clients.forEach((c) => { if (c.readyState === 1) c.send(msg); });
|
|
33
52
|
}
|
|
34
53
|
|
|
35
|
-
|
|
54
|
+
function broadcastExpoStatus() {
|
|
55
|
+
broadcast("expo_status", { status: expoStatus, qr: expoQR, url: expoUrl });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── SUPABASE REALTIME ─────────────────────────────────────────────────────────
|
|
36
59
|
supabase
|
|
37
60
|
.channel("cb_changes")
|
|
38
|
-
.on("postgres_changes", { event: "*", schema: "public", table: "cb_tasks" }, (
|
|
39
|
-
|
|
40
|
-
})
|
|
41
|
-
.on("postgres_changes", { event: "*", schema: "public", table: "cb_logs" }, (payload) => {
|
|
42
|
-
broadcast("log", payload.new);
|
|
43
|
-
})
|
|
61
|
+
.on("postgres_changes", { event: "*", schema: "public", table: "cb_tasks" }, (p) => broadcast("task_update", p))
|
|
62
|
+
.on("postgres_changes", { event: "*", schema: "public", table: "cb_logs" }, (p) => broadcast("log", p.new))
|
|
44
63
|
.subscribe();
|
|
45
64
|
|
|
46
|
-
//
|
|
65
|
+
// ── TERMINAL (xterm.js via WebSocket + node-pty) ──────────────────────────────
|
|
66
|
+
termWss.on("connection", (ws) => {
|
|
67
|
+
let pty = null;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
// Try node-pty for full PTY support
|
|
71
|
+
const nodePty = require("node-pty");
|
|
72
|
+
pty = nodePty.spawn(process.env.SHELL || "bash", [], {
|
|
73
|
+
name: "xterm-256color",
|
|
74
|
+
cols: 120,
|
|
75
|
+
rows: 40,
|
|
76
|
+
cwd: PROJECT_DIR,
|
|
77
|
+
env: {
|
|
78
|
+
...process.env,
|
|
79
|
+
SUPABASE_URL,
|
|
80
|
+
SUPABASE_ACCESS_TOKEN: process.env.SUPABASE_ACCESS_TOKEN || "",
|
|
81
|
+
TERM: "xterm-256color",
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
pty.onData((data) => { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "output", data })); });
|
|
86
|
+
pty.onExit(() => { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "exit" })); });
|
|
87
|
+
|
|
88
|
+
ws.on("message", (raw) => {
|
|
89
|
+
try {
|
|
90
|
+
const msg = JSON.parse(raw);
|
|
91
|
+
if (msg.type === "input") pty.write(msg.data);
|
|
92
|
+
if (msg.type === "resize") pty.resize(msg.cols, msg.rows);
|
|
93
|
+
} catch {}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
ws.on("close", () => { try { pty.kill(); } catch {} });
|
|
97
|
+
|
|
98
|
+
} catch {
|
|
99
|
+
// Fallback: simple shell without PTY (no colors but functional)
|
|
100
|
+
const shell = spawn(process.env.SHELL || "bash", [], {
|
|
101
|
+
cwd: PROJECT_DIR,
|
|
102
|
+
env: { ...process.env, SUPABASE_URL, TERM: "dumb" },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
shell.stdout.on("data", (d) => ws.send(JSON.stringify({ type: "output", data: d.toString() })));
|
|
106
|
+
shell.stderr.on("data", (d) => ws.send(JSON.stringify({ type: "output", data: d.toString() })));
|
|
107
|
+
shell.on("close", () => ws.send(JSON.stringify({ type: "exit" })));
|
|
108
|
+
|
|
109
|
+
ws.on("message", (raw) => {
|
|
110
|
+
try {
|
|
111
|
+
const msg = JSON.parse(raw);
|
|
112
|
+
if (msg.type === "input") shell.stdin.write(msg.data);
|
|
113
|
+
} catch {}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
ws.on("close", () => shell.kill());
|
|
117
|
+
|
|
118
|
+
// Send welcome message
|
|
119
|
+
ws.send(JSON.stringify({
|
|
120
|
+
type: "output",
|
|
121
|
+
data: `\r\n\x1b[33m[ClaudeBoard Terminal]\x1b[0m — Project: ${PROJECT_DIR}\r\n` +
|
|
122
|
+
`\x1b[2mTip: Run 'npx supabase ...' for Supabase CLI commands\x1b[0m\r\n\r\n`,
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── EXPO MANAGEMENT ───────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
// GET expo status
|
|
130
|
+
app.get("/api/expo/status", (req, res) => {
|
|
131
|
+
res.json({ status: expoStatus, qr: expoQR, url: expoUrl });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// POST expo/start — install deps + start Expo tunnel
|
|
135
|
+
app.post("/api/expo/start", async (req, res) => {
|
|
136
|
+
if (expoProcess) return res.json({ ok: false, error: "Expo already running" });
|
|
137
|
+
|
|
138
|
+
res.json({ ok: true, message: "Starting Expo..." });
|
|
139
|
+
_startExpo(PROJECT_DIR);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// POST expo/stop
|
|
143
|
+
app.post("/api/expo/stop", (req, res) => {
|
|
144
|
+
if (expoProcess) {
|
|
145
|
+
try { expoProcess.kill("SIGTERM"); } catch {}
|
|
146
|
+
expoProcess = null;
|
|
147
|
+
}
|
|
148
|
+
expoStatus = "stopped";
|
|
149
|
+
expoQR = null;
|
|
150
|
+
expoUrl = null;
|
|
151
|
+
broadcastExpoStatus();
|
|
152
|
+
res.json({ ok: true });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
async function _startExpo(projectDir) {
|
|
156
|
+
// Step 1: npm install
|
|
157
|
+
expoStatus = "installing";
|
|
158
|
+
broadcastExpoStatus();
|
|
159
|
+
broadcast("expo_log", { message: "Installing dependencies..." });
|
|
160
|
+
|
|
161
|
+
await new Promise((resolve) => {
|
|
162
|
+
const install = spawn("npm", ["install"], { cwd: projectDir, stdio: "pipe" });
|
|
163
|
+
install.stdout.on("data", (d) => broadcast("expo_log", { message: d.toString().trim() }));
|
|
164
|
+
install.stderr.on("data", (d) => broadcast("expo_log", { message: d.toString().trim() }));
|
|
165
|
+
install.on("close", resolve);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
broadcast("expo_log", { message: "Dependencies installed. Starting Expo..." });
|
|
169
|
+
|
|
170
|
+
// Step 2: expo start with tunnel
|
|
171
|
+
expoStatus = "starting";
|
|
172
|
+
broadcastExpoStatus();
|
|
173
|
+
|
|
174
|
+
const expo = spawn("npx", ["expo", "start", "--tunnel"], {
|
|
175
|
+
cwd: projectDir,
|
|
176
|
+
env: { ...process.env, CI: "false", EXPO_NO_DOTENV: "0" },
|
|
177
|
+
stdio: "pipe",
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expoProcess = expo;
|
|
181
|
+
|
|
182
|
+
expo.stdout.on("data", (d) => {
|
|
183
|
+
const text = d.toString();
|
|
184
|
+
broadcast("expo_log", { message: text.trim() });
|
|
185
|
+
|
|
186
|
+
// Detect QR code URL (exp:// or https://expo.dev)
|
|
187
|
+
const expUrl = text.match(/exp:\/\/[^\s]+/);
|
|
188
|
+
if (expUrl) {
|
|
189
|
+
expoUrl = expUrl[0];
|
|
190
|
+
expoStatus = "running";
|
|
191
|
+
broadcastExpoStatus();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Detect tunnel URL
|
|
195
|
+
const tunnel = text.match(/https:\/\/[a-z0-9-]+\.exp\.direct[^\s]*/);
|
|
196
|
+
if (tunnel) {
|
|
197
|
+
expoUrl = tunnel[0];
|
|
198
|
+
expoStatus = "running";
|
|
199
|
+
broadcastExpoStatus();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Detect QR data from expo output
|
|
203
|
+
if (text.includes("QR")) {
|
|
204
|
+
broadcast("expo_log", { message: "📱 QR code ready — scan with Expo Go" });
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expo.stderr.on("data", (d) => {
|
|
209
|
+
const text = d.toString().trim();
|
|
210
|
+
if (text) broadcast("expo_log", { message: text });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expo.on("close", (code) => {
|
|
214
|
+
expoProcess = null;
|
|
215
|
+
expoStatus = code === 0 ? "stopped" : "error";
|
|
216
|
+
expoQR = null;
|
|
217
|
+
broadcastExpoStatus();
|
|
218
|
+
broadcast("expo_log", { message: `Expo process exited (code ${code})` });
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── SUPABASE QUERY API ────────────────────────────────────────────────────────
|
|
223
|
+
app.post("/api/supabase/query", async (req, res) => {
|
|
224
|
+
const { sql } = req.body;
|
|
225
|
+
if (!sql) return res.status(400).json({ error: "No SQL provided" });
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const { data, error } = await supabase.rpc("execute_sql", { query: sql });
|
|
229
|
+
if (error) return res.status(400).json({ error: error.message });
|
|
230
|
+
res.json({ data });
|
|
231
|
+
} catch (err) {
|
|
232
|
+
res.status(500).json({ error: err.message });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ── BOARD API ROUTES (unchanged) ──────────────────────────────────────────────
|
|
47
237
|
|
|
48
|
-
// GET all tasks grouped by epic
|
|
49
238
|
app.get("/api/board", async (req, res) => {
|
|
50
239
|
const { data: epics } = await supabase
|
|
51
|
-
.from("cb_epics")
|
|
52
|
-
.select("*, cb_tasks(*)")
|
|
53
|
-
.eq("project", PROJECT)
|
|
54
|
-
.order("created_at");
|
|
55
|
-
|
|
240
|
+
.from("cb_epics").select("*, cb_tasks(*)").eq("project", PROJECT).order("created_at");
|
|
56
241
|
const { data: logs } = await supabase
|
|
57
|
-
.from("cb_logs")
|
|
58
|
-
.
|
|
59
|
-
.eq("project", PROJECT)
|
|
60
|
-
.order("created_at", { ascending: false })
|
|
61
|
-
.limit(50);
|
|
62
|
-
|
|
242
|
+
.from("cb_logs").select("*").eq("project", PROJECT)
|
|
243
|
+
.order("created_at", { ascending: false }).limit(50);
|
|
63
244
|
res.json({ epics: epics || [], logs: logs || [], project: PROJECT });
|
|
64
245
|
});
|
|
65
246
|
|
|
66
|
-
// GET next pending task
|
|
67
247
|
app.get("/api/tasks/next", async (req, res) => {
|
|
68
|
-
const { data } = await supabase
|
|
69
|
-
.
|
|
70
|
-
.
|
|
71
|
-
.eq("project", PROJECT)
|
|
72
|
-
.eq("status", "todo")
|
|
73
|
-
.order("priority_order", { ascending: true })
|
|
74
|
-
.limit(1)
|
|
75
|
-
.single();
|
|
76
|
-
|
|
248
|
+
const { data } = await supabase.from("cb_tasks").select("*")
|
|
249
|
+
.eq("project", PROJECT).eq("status", "todo")
|
|
250
|
+
.order("priority_order", { ascending: true }).limit(1).single();
|
|
77
251
|
if (!data) return res.json({ task: null, message: "All tasks complete! 🎉" });
|
|
78
252
|
res.json({ task: data });
|
|
79
253
|
});
|
|
80
254
|
|
|
81
|
-
// POST start task
|
|
82
255
|
app.post("/api/tasks/:id/start", async (req, res) => {
|
|
83
256
|
const { id } = req.params;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
await supabase
|
|
87
|
-
.from("cb_tasks")
|
|
88
|
-
.update({ status: "in_progress", started_at: new Date().toISOString() })
|
|
89
|
-
.eq("id", id);
|
|
90
|
-
|
|
91
|
-
if (log) await addLog(id, log, "start");
|
|
257
|
+
await supabase.from("cb_tasks").update({ status: "in_progress", started_at: new Date().toISOString() }).eq("id", id);
|
|
258
|
+
if (req.body.log) await addLog(id, req.body.log, "start");
|
|
92
259
|
broadcast("task_started", { id });
|
|
93
260
|
res.json({ ok: true });
|
|
94
261
|
});
|
|
95
262
|
|
|
96
|
-
// POST log progress
|
|
97
263
|
app.post("/api/tasks/:id/log", async (req, res) => {
|
|
98
|
-
|
|
99
|
-
const { message } = req.body;
|
|
100
|
-
await addLog(id, message, "progress");
|
|
264
|
+
await addLog(req.params.id, req.body.message, "progress");
|
|
101
265
|
res.json({ ok: true });
|
|
102
266
|
});
|
|
103
267
|
|
|
104
|
-
// POST complete task
|
|
105
268
|
app.post("/api/tasks/:id/complete", async (req, res) => {
|
|
106
269
|
const { id } = req.params;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
await supabase
|
|
110
|
-
.from("cb_tasks")
|
|
111
|
-
.update({ status: "done", completed_at: new Date().toISOString() })
|
|
112
|
-
.eq("id", id);
|
|
113
|
-
|
|
114
|
-
if (log) await addLog(id, log, "complete");
|
|
270
|
+
await supabase.from("cb_tasks").update({ status: "done", completed_at: new Date().toISOString() }).eq("id", id);
|
|
271
|
+
if (req.body.log) await addLog(id, req.body.log, "complete");
|
|
115
272
|
broadcast("task_complete", { id });
|
|
116
273
|
res.json({ ok: true });
|
|
117
274
|
});
|
|
118
275
|
|
|
119
|
-
// POST fail task
|
|
120
276
|
app.post("/api/tasks/:id/fail", async (req, res) => {
|
|
121
277
|
const { id } = req.params;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
await supabase
|
|
125
|
-
.from("cb_tasks")
|
|
126
|
-
.update({ status: "error" })
|
|
127
|
-
.eq("id", id);
|
|
128
|
-
|
|
129
|
-
if (log) await addLog(id, log, "error");
|
|
278
|
+
await supabase.from("cb_tasks").update({ status: "error" }).eq("id", id);
|
|
279
|
+
if (req.body.log) await addLog(id, req.body.log, "error");
|
|
130
280
|
broadcast("task_failed", { id });
|
|
131
281
|
res.json({ ok: true });
|
|
132
282
|
});
|
|
133
283
|
|
|
134
|
-
// POST add new task manually (from dashboard or agent)
|
|
135
284
|
app.post("/api/tasks", async (req, res) => {
|
|
136
285
|
const { title, description, priority, type, epic_id } = req.body;
|
|
137
|
-
|
|
138
286
|
const priorityOrder = { high: 1, medium: 2, low: 3 };
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
epic_id: epic_id || null,
|
|
145
|
-
title,
|
|
146
|
-
description,
|
|
147
|
-
priority: priority || "medium",
|
|
148
|
-
priority_order: priorityOrder[priority] || 2,
|
|
149
|
-
type: type || "feature",
|
|
150
|
-
status: "todo",
|
|
151
|
-
})
|
|
152
|
-
.select()
|
|
153
|
-
.single();
|
|
154
|
-
|
|
287
|
+
const { data, error } = await supabase.from("cb_tasks").insert({
|
|
288
|
+
project: PROJECT, epic_id: epic_id || null, title, description,
|
|
289
|
+
priority: priority || "medium", priority_order: priorityOrder[priority] || 2,
|
|
290
|
+
type: type || "feature", status: "todo",
|
|
291
|
+
}).select().single();
|
|
155
292
|
if (error) return res.status(400).json({ error: error.message });
|
|
156
293
|
broadcast("task_added", data);
|
|
157
294
|
res.json({ task: data });
|
|
158
295
|
});
|
|
159
296
|
|
|
160
|
-
// PATCH update task status manually
|
|
161
297
|
app.patch("/api/tasks/:id", async (req, res) => {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
await supabase.from("cb_tasks").update(updates).eq("id", id);
|
|
166
|
-
broadcast("task_update", { id, ...updates });
|
|
298
|
+
await supabase.from("cb_tasks").update(req.body).eq("id", req.params.id);
|
|
299
|
+
broadcast("task_update", { id: req.params.id, ...req.body });
|
|
167
300
|
res.json({ ok: true });
|
|
168
301
|
});
|
|
169
302
|
|
|
170
|
-
// GET logs for a specific task
|
|
171
303
|
app.get("/api/tasks/:id/logs", async (req, res) => {
|
|
172
|
-
const { data } = await supabase
|
|
173
|
-
.
|
|
174
|
-
.select("*")
|
|
175
|
-
.eq("task_id", req.params.id)
|
|
176
|
-
.order("created_at");
|
|
304
|
+
const { data } = await supabase.from("cb_logs").select("*")
|
|
305
|
+
.eq("task_id", req.params.id).order("created_at");
|
|
177
306
|
res.json({ logs: data || [] });
|
|
178
307
|
});
|
|
179
308
|
|
|
180
|
-
|
|
181
|
-
app.get("*", (req, res) => {
|
|
182
|
-
res.sendFile(path.join(__dirname, "index.html"));
|
|
183
|
-
});
|
|
309
|
+
app.get("*", (req, res) => res.sendFile(path.join(__dirname, "index.html")));
|
|
184
310
|
|
|
185
|
-
// ─── HELPERS ─────────────────────────────────────────────────────────────────
|
|
186
311
|
async function addLog(taskId, message, type = "info") {
|
|
187
|
-
await supabase.from("cb_logs").insert({
|
|
188
|
-
project: PROJECT,
|
|
189
|
-
task_id: taskId,
|
|
190
|
-
message,
|
|
191
|
-
type,
|
|
192
|
-
});
|
|
312
|
+
await supabase.from("cb_logs").insert({ project: PROJECT, task_id: taskId, message, type });
|
|
193
313
|
}
|
|
194
314
|
|
|
195
|
-
server.listen(PORT, () => {
|
|
196
|
-
console.log(`READY on port ${PORT}`);
|
|
197
|
-
});
|
|
315
|
+
server.listen(PORT, () => console.log(`READY on port ${PORT}`));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "AI engineering team — from PRD to working mobile app, autonomously",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"dotenv": "^16.4.5",
|
|
23
23
|
"enquirer": "^2.4.1",
|
|
24
24
|
"express": "^4.18.3",
|
|
25
|
+
"node-pty": "^1.0.0",
|
|
25
26
|
"open": "^10.1.0",
|
|
26
27
|
"ora": "^8.0.1",
|
|
27
28
|
"puppeteer": "^22.8.0",
|