clay-server 2.8.2 → 2.9.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 +2 -0
- package/bin/cli.js +122 -2
- package/lib/config.js +20 -1
- package/lib/daemon.js +40 -0
- package/lib/pages.js +670 -27
- package/lib/project.js +267 -16
- package/lib/public/app.js +74 -14
- package/lib/public/css/admin.css +576 -0
- package/lib/public/css/icon-strip.css +1 -0
- package/lib/public/css/menus.css +16 -11
- package/lib/public/css/overlays.css +2 -4
- package/lib/public/css/sidebar.css +49 -0
- package/lib/public/css/title-bar.css +45 -1
- package/lib/public/index.html +38 -8
- package/lib/public/modules/admin.js +631 -0
- package/lib/public/modules/markdown.js +9 -5
- package/lib/public/modules/profile.js +21 -0
- package/lib/public/modules/project-settings.js +4 -1
- package/lib/public/modules/server-settings.js +13 -0
- package/lib/public/modules/sidebar.js +111 -5
- package/lib/public/style.css +1 -0
- package/lib/push.js +6 -0
- package/lib/server.js +1075 -27
- package/lib/sessions.js +127 -41
- package/lib/smtp.js +221 -0
- package/lib/users.js +459 -0
- package/package.json +2 -1
package/lib/users.js
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
var fs = require("fs");
|
|
2
|
+
var path = require("path");
|
|
3
|
+
var crypto = require("crypto");
|
|
4
|
+
var { CONFIG_DIR } = require("./config");
|
|
5
|
+
|
|
6
|
+
var USERS_FILE = path.join(CONFIG_DIR, "users.json");
|
|
7
|
+
|
|
8
|
+
// --- Default data ---
|
|
9
|
+
|
|
10
|
+
function defaultData() {
|
|
11
|
+
return {
|
|
12
|
+
multiUser: false,
|
|
13
|
+
setupCode: null,
|
|
14
|
+
users: [],
|
|
15
|
+
invites: [],
|
|
16
|
+
smtp: null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// --- Load / Save ---
|
|
21
|
+
|
|
22
|
+
function loadUsers() {
|
|
23
|
+
try {
|
|
24
|
+
var raw = fs.readFileSync(USERS_FILE, "utf8");
|
|
25
|
+
var data = JSON.parse(raw);
|
|
26
|
+
// Ensure all required fields exist
|
|
27
|
+
if (!data.users) data.users = [];
|
|
28
|
+
if (!data.invites) data.invites = [];
|
|
29
|
+
if (data.multiUser === undefined) data.multiUser = false;
|
|
30
|
+
if (data.setupCode === undefined) data.setupCode = null;
|
|
31
|
+
if (data.smtp === undefined) data.smtp = null;
|
|
32
|
+
return data;
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return defaultData();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function saveUsers(data) {
|
|
39
|
+
fs.mkdirSync(path.dirname(USERS_FILE), { recursive: true });
|
|
40
|
+
var tmpPath = USERS_FILE + ".tmp";
|
|
41
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
42
|
+
fs.renameSync(tmpPath, USERS_FILE);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- Multi-user mode ---
|
|
46
|
+
|
|
47
|
+
function isMultiUser() {
|
|
48
|
+
var data = loadUsers();
|
|
49
|
+
return !!data.multiUser;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function enableMultiUser() {
|
|
53
|
+
var data = loadUsers();
|
|
54
|
+
if (data.multiUser) {
|
|
55
|
+
// Already enabled — check if admin exists
|
|
56
|
+
var admin = findAdmin(data);
|
|
57
|
+
if (admin) {
|
|
58
|
+
return { alreadyEnabled: true, hasAdmin: true, setupCode: null };
|
|
59
|
+
}
|
|
60
|
+
// Multi-user enabled but no admin — regenerate setup code
|
|
61
|
+
var code = generateSetupCode();
|
|
62
|
+
data.setupCode = code;
|
|
63
|
+
saveUsers(data);
|
|
64
|
+
return { alreadyEnabled: true, hasAdmin: false, setupCode: code };
|
|
65
|
+
}
|
|
66
|
+
var code = generateSetupCode();
|
|
67
|
+
data.multiUser = true;
|
|
68
|
+
data.setupCode = code;
|
|
69
|
+
saveUsers(data);
|
|
70
|
+
return { alreadyEnabled: false, hasAdmin: false, setupCode: code };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function disableMultiUser() {
|
|
74
|
+
var data = loadUsers();
|
|
75
|
+
data.multiUser = false;
|
|
76
|
+
data.setupCode = null;
|
|
77
|
+
saveUsers(data);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- Setup code ---
|
|
81
|
+
|
|
82
|
+
function generateSetupCode() {
|
|
83
|
+
var chars = "abcdefghijkmnpqrstuvwxyz23456789"; // no ambiguous chars
|
|
84
|
+
var code = "";
|
|
85
|
+
var bytes = crypto.randomBytes(6);
|
|
86
|
+
for (var i = 0; i < 6; i++) {
|
|
87
|
+
code += chars[bytes[i] % chars.length];
|
|
88
|
+
}
|
|
89
|
+
return code;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getSetupCode() {
|
|
93
|
+
var data = loadUsers();
|
|
94
|
+
return data.setupCode || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function clearSetupCode() {
|
|
98
|
+
var data = loadUsers();
|
|
99
|
+
data.setupCode = null;
|
|
100
|
+
saveUsers(data);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function validateSetupCode(code) {
|
|
104
|
+
var data = loadUsers();
|
|
105
|
+
if (!data.setupCode) return false;
|
|
106
|
+
return data.setupCode === code;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- User CRUD ---
|
|
110
|
+
|
|
111
|
+
function generateUserId() {
|
|
112
|
+
return crypto.randomUUID();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function hashPin(pin) {
|
|
116
|
+
return crypto.createHash("sha256").update("clay-user:" + pin).digest("hex");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function createUser(opts) {
|
|
120
|
+
var data = loadUsers();
|
|
121
|
+
// Check username uniqueness
|
|
122
|
+
for (var i = 0; i < data.users.length; i++) {
|
|
123
|
+
if (data.users[i].username.toLowerCase() === opts.username.toLowerCase()) {
|
|
124
|
+
return { error: "This username is already taken" };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Check email uniqueness (when provided)
|
|
128
|
+
if (opts.email) {
|
|
129
|
+
for (var i = 0; i < data.users.length; i++) {
|
|
130
|
+
if (data.users[i].email && data.users[i].email.toLowerCase() === opts.email.toLowerCase()) {
|
|
131
|
+
return { error: "This email is already registered" };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
var user = {
|
|
136
|
+
id: generateUserId(),
|
|
137
|
+
username: opts.username,
|
|
138
|
+
email: opts.email || null,
|
|
139
|
+
displayName: opts.displayName || opts.username,
|
|
140
|
+
pinHash: hashPin(opts.pin),
|
|
141
|
+
role: opts.role || "user",
|
|
142
|
+
createdAt: Date.now(),
|
|
143
|
+
profile: opts.profile || {
|
|
144
|
+
name: opts.displayName || opts.username,
|
|
145
|
+
lang: "en-US",
|
|
146
|
+
avatarColor: "#7c3aed",
|
|
147
|
+
avatarStyle: "thumbs",
|
|
148
|
+
avatarSeed: crypto.randomBytes(4).toString("hex"),
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
data.users.push(user);
|
|
152
|
+
saveUsers(data);
|
|
153
|
+
return { ok: true, user: user };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function createAdmin(opts) {
|
|
157
|
+
return createUser({
|
|
158
|
+
username: opts.username,
|
|
159
|
+
email: opts.email || null,
|
|
160
|
+
displayName: opts.displayName,
|
|
161
|
+
pin: opts.pin,
|
|
162
|
+
role: "admin",
|
|
163
|
+
profile: opts.profile,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function findAdmin(data) {
|
|
168
|
+
if (!data) data = loadUsers();
|
|
169
|
+
for (var i = 0; i < data.users.length; i++) {
|
|
170
|
+
if (data.users[i].role === "admin") return data.users[i];
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function hasAdmin() {
|
|
176
|
+
return !!findAdmin();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function findUserById(id) {
|
|
180
|
+
var data = loadUsers();
|
|
181
|
+
for (var i = 0; i < data.users.length; i++) {
|
|
182
|
+
if (data.users[i].id === id) return data.users[i];
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function findUserByUsername(username) {
|
|
188
|
+
var data = loadUsers();
|
|
189
|
+
for (var i = 0; i < data.users.length; i++) {
|
|
190
|
+
if (data.users[i].username.toLowerCase() === username.toLowerCase()) return data.users[i];
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function findUserByEmail(email) {
|
|
196
|
+
var data = loadUsers();
|
|
197
|
+
for (var i = 0; i < data.users.length; i++) {
|
|
198
|
+
if (data.users[i].email && data.users[i].email.toLowerCase() === email.toLowerCase()) return data.users[i];
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function authenticateUser(username, pin) {
|
|
204
|
+
var user = findUserByUsername(username);
|
|
205
|
+
if (!user) return null;
|
|
206
|
+
var pinH = hashPin(pin);
|
|
207
|
+
if (user.pinHash !== pinH) return null;
|
|
208
|
+
return user;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function getAllUsers() {
|
|
212
|
+
var data = loadUsers();
|
|
213
|
+
return data.users.map(function (u) {
|
|
214
|
+
return {
|
|
215
|
+
id: u.id,
|
|
216
|
+
username: u.username,
|
|
217
|
+
email: u.email || null,
|
|
218
|
+
displayName: u.displayName,
|
|
219
|
+
role: u.role,
|
|
220
|
+
createdAt: u.createdAt,
|
|
221
|
+
profile: u.profile,
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function removeUser(userId) {
|
|
227
|
+
var data = loadUsers();
|
|
228
|
+
var before = data.users.length;
|
|
229
|
+
data.users = data.users.filter(function (u) { return u.id !== userId; });
|
|
230
|
+
if (data.users.length === before) return { error: "User not found" };
|
|
231
|
+
saveUsers(data);
|
|
232
|
+
return { ok: true };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function updateUserProfile(userId, profile) {
|
|
236
|
+
var data = loadUsers();
|
|
237
|
+
for (var i = 0; i < data.users.length; i++) {
|
|
238
|
+
if (data.users[i].id === userId) {
|
|
239
|
+
data.users[i].profile = profile;
|
|
240
|
+
saveUsers(data);
|
|
241
|
+
return { ok: true, profile: profile };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { error: "User not found" };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function updateUserPin(userId, newPin) {
|
|
248
|
+
var data = loadUsers();
|
|
249
|
+
for (var i = 0; i < data.users.length; i++) {
|
|
250
|
+
if (data.users[i].id === userId) {
|
|
251
|
+
data.users[i].pinHash = hashPin(newPin);
|
|
252
|
+
saveUsers(data);
|
|
253
|
+
return { ok: true };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return { error: "User not found" };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// --- Auth tokens ---
|
|
260
|
+
|
|
261
|
+
function generateUserAuthToken(userId) {
|
|
262
|
+
var token = crypto.randomBytes(32).toString("hex");
|
|
263
|
+
return userId + ":" + token;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function parseAuthCookie(cookieValue) {
|
|
267
|
+
if (!cookieValue) return null;
|
|
268
|
+
var idx = cookieValue.indexOf(":");
|
|
269
|
+
if (idx < 0) return null;
|
|
270
|
+
return {
|
|
271
|
+
userId: cookieValue.substring(0, idx),
|
|
272
|
+
token: cookieValue.substring(idx + 1),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// --- Invite links ---
|
|
277
|
+
|
|
278
|
+
function createInvite(createdByUserId, targetEmail) {
|
|
279
|
+
var data = loadUsers();
|
|
280
|
+
var code = crypto.randomBytes(12).toString("hex");
|
|
281
|
+
var invite = {
|
|
282
|
+
code: code,
|
|
283
|
+
createdBy: createdByUserId,
|
|
284
|
+
createdAt: Date.now(),
|
|
285
|
+
expiresAt: Date.now() + (24 * 60 * 60 * 1000), // 24 hours
|
|
286
|
+
used: false,
|
|
287
|
+
};
|
|
288
|
+
if (targetEmail) invite.email = targetEmail;
|
|
289
|
+
data.invites.push(invite);
|
|
290
|
+
saveUsers(data);
|
|
291
|
+
return invite;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function createUserWithoutPin(opts) {
|
|
295
|
+
var data = loadUsers();
|
|
296
|
+
for (var i = 0; i < data.users.length; i++) {
|
|
297
|
+
if (data.users[i].username.toLowerCase() === opts.username.toLowerCase()) {
|
|
298
|
+
return { error: "This username is already taken" };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (opts.email) {
|
|
302
|
+
for (var i = 0; i < data.users.length; i++) {
|
|
303
|
+
if (data.users[i].email && data.users[i].email.toLowerCase() === opts.email.toLowerCase()) {
|
|
304
|
+
return { error: "This email is already registered" };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
var user = {
|
|
309
|
+
id: generateUserId(),
|
|
310
|
+
username: opts.username,
|
|
311
|
+
email: opts.email || null,
|
|
312
|
+
displayName: opts.displayName || opts.username,
|
|
313
|
+
pinHash: null,
|
|
314
|
+
role: opts.role || "user",
|
|
315
|
+
createdAt: Date.now(),
|
|
316
|
+
profile: opts.profile || {
|
|
317
|
+
name: opts.displayName || opts.username,
|
|
318
|
+
lang: "en-US",
|
|
319
|
+
avatarColor: "#7c3aed",
|
|
320
|
+
avatarStyle: "thumbs",
|
|
321
|
+
avatarSeed: crypto.randomBytes(4).toString("hex"),
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
data.users.push(user);
|
|
325
|
+
saveUsers(data);
|
|
326
|
+
return { ok: true, user: user };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function findInvite(code) {
|
|
330
|
+
var data = loadUsers();
|
|
331
|
+
for (var i = 0; i < data.invites.length; i++) {
|
|
332
|
+
if (data.invites[i].code === code) return data.invites[i];
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function validateInvite(code) {
|
|
338
|
+
var invite = findInvite(code);
|
|
339
|
+
if (!invite) return { valid: false, error: "Invite not found" };
|
|
340
|
+
if (invite.used) return { valid: false, error: "Invite already used" };
|
|
341
|
+
if (Date.now() > invite.expiresAt) return { valid: false, error: "Invite expired" };
|
|
342
|
+
return { valid: true, invite: invite };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function markInviteUsed(code) {
|
|
346
|
+
var data = loadUsers();
|
|
347
|
+
for (var i = 0; i < data.invites.length; i++) {
|
|
348
|
+
if (data.invites[i].code === code) {
|
|
349
|
+
data.invites[i].used = true;
|
|
350
|
+
saveUsers(data);
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function getInvites() {
|
|
358
|
+
var data = loadUsers();
|
|
359
|
+
return data.invites;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function revokeInvite(code) {
|
|
363
|
+
var data = loadUsers();
|
|
364
|
+
var before = data.invites.length;
|
|
365
|
+
data.invites = data.invites.filter(function (inv) {
|
|
366
|
+
return inv.code !== code;
|
|
367
|
+
});
|
|
368
|
+
if (data.invites.length === before) return { error: "Invite not found" };
|
|
369
|
+
saveUsers(data);
|
|
370
|
+
return { ok: true };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function removeExpiredInvites() {
|
|
374
|
+
var data = loadUsers();
|
|
375
|
+
var now = Date.now();
|
|
376
|
+
var before = data.invites.length;
|
|
377
|
+
data.invites = data.invites.filter(function (inv) {
|
|
378
|
+
return !inv.used && inv.expiresAt > now;
|
|
379
|
+
});
|
|
380
|
+
if (data.invites.length !== before) saveUsers(data);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// --- Project access helpers ---
|
|
384
|
+
|
|
385
|
+
function canAccessProject(userId, project) {
|
|
386
|
+
if (!project) return false;
|
|
387
|
+
// Public projects are accessible to all authenticated users
|
|
388
|
+
if (!project.visibility || project.visibility === "public") return true;
|
|
389
|
+
// Admin always has access
|
|
390
|
+
var user = findUserById(userId);
|
|
391
|
+
if (user && user.role === "admin") return true;
|
|
392
|
+
// Private project — check allowedUsers
|
|
393
|
+
var allowed = project.allowedUsers || [];
|
|
394
|
+
return allowed.indexOf(userId) >= 0;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function getAccessibleProjects(userId, projects) {
|
|
398
|
+
if (!projects) return [];
|
|
399
|
+
return projects.filter(function (p) {
|
|
400
|
+
return canAccessProject(userId, p);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// --- Session visibility helpers ---
|
|
405
|
+
|
|
406
|
+
function canAccessSession(userId, session, project) {
|
|
407
|
+
// Must have project access first
|
|
408
|
+
if (!canAccessProject(userId, project)) return false;
|
|
409
|
+
// Sessions without ownerId are legacy — only admin can see them
|
|
410
|
+
if (!session.ownerId) {
|
|
411
|
+
var user = findUserById(userId);
|
|
412
|
+
return !!(user && user.role === "admin");
|
|
413
|
+
}
|
|
414
|
+
// Owner can always see their own sessions
|
|
415
|
+
if (session.ownerId === userId) return true;
|
|
416
|
+
// Shared sessions are visible to all project members (default)
|
|
417
|
+
if (!session.sessionVisibility || session.sessionVisibility === "shared") return true;
|
|
418
|
+
// Private sessions are only visible to the owner
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
module.exports = {
|
|
423
|
+
USERS_FILE: USERS_FILE,
|
|
424
|
+
loadUsers: loadUsers,
|
|
425
|
+
saveUsers: saveUsers,
|
|
426
|
+
isMultiUser: isMultiUser,
|
|
427
|
+
enableMultiUser: enableMultiUser,
|
|
428
|
+
disableMultiUser: disableMultiUser,
|
|
429
|
+
getSetupCode: getSetupCode,
|
|
430
|
+
clearSetupCode: clearSetupCode,
|
|
431
|
+
validateSetupCode: validateSetupCode,
|
|
432
|
+
generateUserId: generateUserId,
|
|
433
|
+
hashPin: hashPin,
|
|
434
|
+
createUser: createUser,
|
|
435
|
+
createAdmin: createAdmin,
|
|
436
|
+
findAdmin: findAdmin,
|
|
437
|
+
hasAdmin: hasAdmin,
|
|
438
|
+
findUserById: findUserById,
|
|
439
|
+
findUserByUsername: findUserByUsername,
|
|
440
|
+
findUserByEmail: findUserByEmail,
|
|
441
|
+
authenticateUser: authenticateUser,
|
|
442
|
+
getAllUsers: getAllUsers,
|
|
443
|
+
removeUser: removeUser,
|
|
444
|
+
updateUserProfile: updateUserProfile,
|
|
445
|
+
updateUserPin: updateUserPin,
|
|
446
|
+
generateUserAuthToken: generateUserAuthToken,
|
|
447
|
+
parseAuthCookie: parseAuthCookie,
|
|
448
|
+
createUserWithoutPin: createUserWithoutPin,
|
|
449
|
+
createInvite: createInvite,
|
|
450
|
+
findInvite: findInvite,
|
|
451
|
+
validateInvite: validateInvite,
|
|
452
|
+
markInviteUsed: markInviteUsed,
|
|
453
|
+
revokeInvite: revokeInvite,
|
|
454
|
+
getInvites: getInvites,
|
|
455
|
+
removeExpiredInvites: removeExpiredInvites,
|
|
456
|
+
canAccessProject: canAccessProject,
|
|
457
|
+
getAccessibleProjects: getAccessibleProjects,
|
|
458
|
+
canAccessSession: canAccessSession,
|
|
459
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clay-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"description": "Web UI for Claude Code. Any device. Push notifications.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"clay-server": "./bin/cli.js",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@anthropic-ai/claude-agent-sdk": "^0.2.63",
|
|
39
39
|
"@lydell/node-pty": "^1.2.0-beta.3",
|
|
40
|
+
"nodemailer": "^6.10.1",
|
|
40
41
|
"qrcode-terminal": "^0.12.0",
|
|
41
42
|
"web-push": "^3.6.7",
|
|
42
43
|
"ws": "^8.18.0"
|