@hypersocial/decks-cli 0.1.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.
Files changed (3) hide show
  1. package/README.md +70 -0
  2. package/bin/decks.mjs +500 -0
  3. package/package.json +23 -0
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # @hypersocial/decks-cli
2
+
3
+ Command-line client for Hypersocial Decks live analytics and share management.
4
+
5
+ This package targets production by default:
6
+
7
+ ```bash
8
+ pnpm dlx @hypersocial/decks-cli login
9
+ pnpm dlx @hypersocial/decks-cli whoami
10
+ pnpm dlx @hypersocial/decks-cli deck list
11
+ pnpm dlx @hypersocial/decks-cli deck stats netflix
12
+ pnpm dlx @hypersocial/decks-cli activity --limit 20
13
+ ```
14
+
15
+ Global install:
16
+
17
+ ```bash
18
+ pnpm add -g @hypersocial/decks-cli
19
+ decks login
20
+ decks activity
21
+ ```
22
+
23
+ ## Auth
24
+
25
+ `decks login` opens the production broker at `https://decks.hypersocial.ai` and
26
+ stores a refresh credential in:
27
+
28
+ ```text
29
+ ~/.config/hypersocial-decks/credentials.json
30
+ ```
31
+
32
+ Headless usage:
33
+
34
+ ```bash
35
+ decks login --token <refresh-or-pat>
36
+ HSDECKS_TOKEN=<refresh-or-pat> decks whoami
37
+ ```
38
+
39
+ ## Commands
40
+
41
+ ```text
42
+ login [--token <pat>] [--broker <url>]
43
+ logout
44
+ whoami [--json]
45
+
46
+ deck list [--json]
47
+ deck stats <slug> [--json]
48
+ deck open <slug>
49
+
50
+ share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n]
51
+ share list <slug> [--json]
52
+ share on|off <token>
53
+ share rm <token>
54
+ share url <token>
55
+
56
+ recipient archive|unarchive <name>
57
+ activity [--deck <slug>] [--limit <n>] [--json]
58
+ ```
59
+
60
+ ## Development Target
61
+
62
+ Production is the default because this CLI is for real recipient activity and
63
+ customer-facing share management.
64
+
65
+ Use dev only for local implementation/testing:
66
+
67
+ ```bash
68
+ decks --dev --broker https://decks.localhost whoami
69
+ HSDECKS_PROD=0 HSDECKS_BROKER_BASE=https://decks.localhost decks whoami
70
+ ```
package/bin/decks.mjs ADDED
@@ -0,0 +1,500 @@
1
+ #!/usr/bin/env node
2
+ import { execFile } from "node:child_process";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { createServer } from "node:http";
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { ConvexHttpClient } from "convex/browser";
9
+ import { makeFunctionReference } from "convex/server";
10
+
11
+ const VERSION = "0.1.0";
12
+
13
+ const PROD = {
14
+ name: "prod",
15
+ convexUrl: "https://dashing-snail-874.convex.cloud",
16
+ brokerBase: "https://decks.hypersocial.ai",
17
+ shareBase: "https://decks.hypersocial.ai",
18
+ };
19
+
20
+ const BOOL = new Set(["prod", "dev", "json", "require-email", "help", "version"]);
21
+
22
+ const api = {
23
+ activity: {
24
+ overview: makeFunctionReference("activity:overview"),
25
+ setArchived: makeFunctionReference("activity:setArchived"),
26
+ },
27
+ cliCredentials: {
28
+ revokeByHash: makeFunctionReference("cliCredentials:revokeByHash"),
29
+ },
30
+ decks: {
31
+ listForViewer: makeFunctionReference("decks:listForViewer"),
32
+ whoami: makeFunctionReference("decks:whoami"),
33
+ },
34
+ shares: {
35
+ create: makeFunctionReference("shares:create"),
36
+ deckAnalytics: makeFunctionReference("shares:deckAnalytics"),
37
+ listForDeck: makeFunctionReference("shares:listForDeck"),
38
+ remove: makeFunctionReference("shares:remove"),
39
+ setEnabled: makeFunctionReference("shares:setEnabled"),
40
+ },
41
+ };
42
+
43
+ const CRED_DIR = join(homedir(), ".config", "hypersocial-decks");
44
+ const CRED_FILE = join(CRED_DIR, "credentials.json");
45
+
46
+ function parse(args) {
47
+ const opts = {};
48
+ const pos = [];
49
+ for (let i = 0; i < args.length; i++) {
50
+ const a = args[i];
51
+ if (a.startsWith("--")) {
52
+ const k = a.slice(2);
53
+ if (BOOL.has(k)) opts[k] = true;
54
+ else opts[k] = args[++i];
55
+ } else {
56
+ pos.push(a);
57
+ }
58
+ }
59
+ return { pos, opts };
60
+ }
61
+
62
+ function normalizeBase(url) {
63
+ return String(url || "").replace(/\/$/, "");
64
+ }
65
+
66
+ function target(opts = {}) {
67
+ const envWantsDev = process.env.HSDECKS_PROD === "0";
68
+ const wantDev = opts.dev || (envWantsDev && !opts.prod);
69
+ if (!wantDev) {
70
+ return {
71
+ ...PROD,
72
+ brokerBase: normalizeBase(opts.broker || PROD.brokerBase),
73
+ };
74
+ }
75
+
76
+ const convexUrl = opts.convex || process.env.HSDECKS_CONVEX_URL || process.env.CONVEX_URL;
77
+ if (!convexUrl) {
78
+ throw new Error("dev target needs --convex <url>, HSDECKS_CONVEX_URL, or CONVEX_URL");
79
+ }
80
+ return {
81
+ name: "dev",
82
+ convexUrl,
83
+ brokerBase: normalizeBase(opts.broker || process.env.HSDECKS_BROKER_BASE || "http://localhost:3000"),
84
+ shareBase: normalizeBase(process.env.HSDECKS_SHARE_BASE || "https://decks.localhost"),
85
+ };
86
+ }
87
+
88
+ function loadCreds() {
89
+ if (!existsSync(CRED_FILE)) return {};
90
+ try {
91
+ return JSON.parse(readFileSync(CRED_FILE, "utf8"));
92
+ } catch {
93
+ return {};
94
+ }
95
+ }
96
+
97
+ function saveCred(name, cred) {
98
+ const all = loadCreds();
99
+ all[name] = cred;
100
+ mkdirSync(CRED_DIR, { recursive: true, mode: 0o700 });
101
+ writeFileSync(CRED_FILE, JSON.stringify(all, null, 2), { mode: 0o600 });
102
+ }
103
+
104
+ function clearCred(name) {
105
+ const all = loadCreds();
106
+ const removed = all[name];
107
+ delete all[name];
108
+ mkdirSync(CRED_DIR, { recursive: true, mode: 0o700 });
109
+ writeFileSync(CRED_FILE, JSON.stringify(all, null, 2), { mode: 0o600 });
110
+ return removed;
111
+ }
112
+
113
+ const sha256Hex = (s) => createHash("sha256").update(s, "utf8").digest("hex");
114
+
115
+ function resolveRefreshToken(t, opts) {
116
+ if (opts.token) return opts.token;
117
+ if (process.env.HSDECKS_TOKEN) return process.env.HSDECKS_TOKEN;
118
+ return loadCreds()[t.name]?.refreshToken || null;
119
+ }
120
+
121
+ async function mintJwt(brokerBase, refreshToken) {
122
+ let res;
123
+ try {
124
+ res = await fetch(`${brokerBase}/api/cli/token`, {
125
+ method: "POST",
126
+ headers: { authorization: `Bearer ${refreshToken}` },
127
+ signal: AbortSignal.timeout(15000),
128
+ });
129
+ } catch (e) {
130
+ const msg = String(e?.message || e);
131
+ if (e?.name === "TimeoutError" || /timeout/i.test(msg)) {
132
+ throw new Error(`broker at ${brokerBase} did not respond within 15s`);
133
+ }
134
+ throw new Error(`could not reach broker at ${brokerBase} (${msg})`);
135
+ }
136
+
137
+ if (!res.ok) {
138
+ const body = await res.text().catch(() => "");
139
+ throw new Error(`auth exchange failed (${res.status}) ${body.slice(0, 200)}`);
140
+ }
141
+ return res.json();
142
+ }
143
+
144
+ const jwtCache = new Map();
145
+ async function jwtFor(brokerBase, refreshToken) {
146
+ const hit = jwtCache.get(refreshToken);
147
+ if (hit && hit.expiresAt - Date.now() > 60000) return hit.token;
148
+ const { token, expiresAt } = await mintJwt(brokerBase, refreshToken);
149
+ jwtCache.set(refreshToken, { token, expiresAt });
150
+ return token;
151
+ }
152
+
153
+ async function client(opts = {}) {
154
+ const t = target(opts);
155
+ const c = new ConvexHttpClient(t.convexUrl);
156
+ const refreshToken = resolveRefreshToken(t, opts);
157
+ if (!refreshToken) {
158
+ throw new Error(`not authenticated for ${t.name} - run \`decks login${t.name === "dev" ? " --dev" : ""}\``);
159
+ }
160
+ c.setAuth(await jwtFor(t.brokerBase, refreshToken));
161
+ return c;
162
+ }
163
+
164
+ function openBrowser(url) {
165
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
166
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
167
+ execFile(cmd, args, () => {});
168
+ }
169
+
170
+ async function verifyAndStampEmail(t, opts) {
171
+ const c = await client(opts);
172
+ const who = await c.query(api.decks.whoami, {});
173
+ if (!who) throw new Error("credential was rejected by Convex");
174
+ const cur = loadCreds()[t.name] || {};
175
+ saveCred(t.name, { ...cur, email: who.email });
176
+ return who;
177
+ }
178
+
179
+ async function login(opts) {
180
+ const t = target(opts);
181
+
182
+ if (opts.token) {
183
+ saveCred(t.name, { refreshToken: opts.token, convexUrl: t.convexUrl, brokerBase: t.brokerBase });
184
+ try {
185
+ const who = await verifyAndStampEmail(t, opts);
186
+ console.log(`OK logged in to ${t.name}${who.email ? ` as ${who.email}` : ""}`);
187
+ } catch (e) {
188
+ clearCred(t.name);
189
+ throw e;
190
+ }
191
+ return;
192
+ }
193
+
194
+ const state = randomBytes(16).toString("hex");
195
+ await new Promise((resolve, reject) => {
196
+ let timer;
197
+ let done = false;
198
+ let server;
199
+ const finish = (err) => {
200
+ if (done) return;
201
+ done = true;
202
+ clearTimeout(timer);
203
+ server.close();
204
+ err ? reject(err) : resolve();
205
+ };
206
+
207
+ server = createServer((req, res) => {
208
+ res.setHeader("Access-Control-Allow-Origin", t.brokerBase);
209
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
210
+ res.setHeader("Access-Control-Allow-Headers", "content-type");
211
+ if (req.method === "OPTIONS") {
212
+ res.writeHead(204);
213
+ res.end();
214
+ return;
215
+ }
216
+ if (req.method === "POST" && (req.url || "").startsWith("/callback")) {
217
+ let body = "";
218
+ req.on("data", (d) => (body += d));
219
+ req.on("end", () => {
220
+ try {
221
+ const { refreshToken, state: got } = JSON.parse(body || "{}");
222
+ if (got !== state) throw new Error("state mismatch");
223
+ if (!refreshToken) throw new Error("no token returned");
224
+ saveCred(t.name, { refreshToken, convexUrl: t.convexUrl, brokerBase: t.brokerBase });
225
+ res.writeHead(200, { "content-type": "application/json" });
226
+ res.end(JSON.stringify({ ok: true }));
227
+ finish();
228
+ } catch (e) {
229
+ res.writeHead(400);
230
+ res.end(String(e?.message || e));
231
+ finish(e);
232
+ }
233
+ });
234
+ return;
235
+ }
236
+ res.writeHead(404);
237
+ res.end();
238
+ });
239
+
240
+ server.listen(0, "127.0.0.1", () => {
241
+ const { port } = server.address();
242
+ const url = `${t.brokerBase}/cli/authorize?port=${port}&state=${state}`;
243
+ console.log(`Opening your browser to sign in (${t.name})...\n ${url}\nIf it does not open, paste that URL into your browser.`);
244
+ openBrowser(url);
245
+ });
246
+ timer = setTimeout(() => finish(new Error("login timed out after 3 min")), 180000);
247
+ });
248
+
249
+ const who = await verifyAndStampEmail(t, opts);
250
+ console.log(`OK logged in to ${t.name}${who.email ? ` as ${who.email}` : ""}`);
251
+ }
252
+
253
+ async function logout(opts) {
254
+ const t = target(opts);
255
+ const cur = loadCreds()[t.name];
256
+ if (!cur?.refreshToken) {
257
+ console.log(`not logged in to ${t.name}`);
258
+ return;
259
+ }
260
+ try {
261
+ const anon = new ConvexHttpClient(t.convexUrl);
262
+ await anon.mutation(api.cliCredentials.revokeByHash, { tokenHash: sha256Hex(cur.refreshToken) });
263
+ } catch {
264
+ // Still clear local credential if the network is unavailable.
265
+ }
266
+ clearCred(t.name);
267
+ console.log(`OK logged out of ${t.name}`);
268
+ }
269
+
270
+ async function whoami(opts) {
271
+ const t = target(opts);
272
+ const who = await (await client(opts)).query(api.decks.whoami, {});
273
+ if (opts.json) {
274
+ console.log(JSON.stringify({ ...(who || {}), deployment: t.name, convexUrl: t.convexUrl }, null, 2));
275
+ return;
276
+ }
277
+ if (!who) {
278
+ console.log(`not authenticated (${t.name})`);
279
+ return;
280
+ }
281
+ console.log(`email: ${who.email}`);
282
+ console.log(`subject: ${who.subject}`);
283
+ console.log(`isAdmin: ${who.isAdmin}`);
284
+ console.log(`deployment: ${t.name} (${t.convexUrl})`);
285
+ }
286
+
287
+ const fmtMs = (ms) => (ms >= 60000 ? `${(ms / 60000).toFixed(1)}m` : `${Math.round(ms / 1000)}s`);
288
+
289
+ async function deckList(opts) {
290
+ const rows = await (await client(opts)).query(api.decks.listForViewer, {});
291
+ const slim = rows.map((d) => ({
292
+ slug: d.slug,
293
+ title: d.title,
294
+ status: d.status,
295
+ ownerEmail: d.ownerEmail ?? null,
296
+ hasHtml: !!d.html,
297
+ }));
298
+ if (opts.json) {
299
+ console.log(JSON.stringify(slim, null, 2));
300
+ return;
301
+ }
302
+ for (const d of slim) console.log(`${d.slug}\t${d.status}\t${d.title}`);
303
+ if (!slim.length) console.log("(no decks)");
304
+ }
305
+
306
+ async function deckStats(pos, opts) {
307
+ const [slug] = pos;
308
+ if (!slug) throw new Error("usage: deck stats <slug>");
309
+ const s = await (await client(opts)).query(api.shares.deckAnalytics, { deckSlug: slug });
310
+ if (opts.json) {
311
+ console.log(JSON.stringify(s, null, 2));
312
+ return;
313
+ }
314
+ console.log(`deck "${slug}"`);
315
+ console.log(` views: ${s.views}`);
316
+ console.log(` visits: ${s.visits}`);
317
+ console.log(` unique viewers: ${s.viewers}`);
318
+ console.log(` avg engaged: ${fmtMs(s.avgMs)}`);
319
+ console.log(` total engaged: ${fmtMs(s.totalMs)}`);
320
+ if (s.recent?.length) {
321
+ console.log(" recent:");
322
+ for (const r of s.recent) {
323
+ const where = [r.city, r.country].filter(Boolean).join(", ");
324
+ console.log(` ${new Date(r.lastSeenAt).toISOString()}\t${r.recipient}\t${fmtMs(r.totalMs)}\tslide ${r.maxSlide}${r.completed ? " complete" : ""}\t${where}`);
325
+ }
326
+ }
327
+ }
328
+
329
+ function deckOpen(pos, opts) {
330
+ const [slug] = pos;
331
+ if (!slug) throw new Error("usage: deck open <slug>");
332
+ const url = `${target(opts).brokerBase}/d/${slug}`;
333
+ console.log(url);
334
+ openBrowser(url);
335
+ }
336
+
337
+ function shareBase(opts) {
338
+ return target(opts).shareBase;
339
+ }
340
+
341
+ async function shareNew(pos, opts) {
342
+ const [slug, recipient] = pos;
343
+ if (!slug || !recipient) throw new Error("usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n]");
344
+ const args = { deckSlug: slug, recipient };
345
+ if (opts.email) args.recipientEmail = opts.email;
346
+ if (opts.note) args.note = opts.note;
347
+ if (opts["require-email"]) args.requireEmail = true;
348
+ if (opts.expires) {
349
+ const t = Date.parse(opts.expires);
350
+ if (Number.isNaN(t)) throw new Error(`expires must be an ISO date; got ${opts.expires}`);
351
+ args.expiresAt = t;
352
+ }
353
+ if (opts.limit != null) {
354
+ const n = Number(opts.limit);
355
+ if (!Number.isFinite(n)) throw new Error(`limit must be a number; got ${opts.limit}`);
356
+ args.viewLimit = n;
357
+ }
358
+ const { token } = await (await client(opts)).mutation(api.shares.create, args);
359
+ const url = `${shareBase(opts)}/v/${token}`;
360
+ if (opts.json) console.log(JSON.stringify({ token, url }, null, 2));
361
+ else console.log(`OK share for "${recipient}" -> ${url}`);
362
+ }
363
+
364
+ async function shareList(pos, opts) {
365
+ const [slug] = pos;
366
+ if (!slug) throw new Error("usage: share list <slug>");
367
+ const rows = await (await client(opts)).query(api.shares.listForDeck, { deckSlug: slug });
368
+ const slim = rows.map((r) => ({
369
+ token: r.token,
370
+ recipient: r.recipient,
371
+ enabled: r.enabled,
372
+ opens: r.opens,
373
+ archived: !!r.archived,
374
+ lastOpenedAt: r.lastOpenedAt ?? null,
375
+ requireEmail: r.requireEmail,
376
+ recipientEmail: r.recipientEmail ?? null,
377
+ expiresAt: r.expiresAt ?? null,
378
+ viewLimit: r.viewLimit ?? null,
379
+ }));
380
+ if (opts.json) {
381
+ console.log(JSON.stringify(slim, null, 2));
382
+ return;
383
+ }
384
+ for (const r of slim) console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${r.recipient}\topens:${r.opens}${r.archived ? "\t(archived)" : ""}`);
385
+ if (!slim.length) console.log("(no shares)");
386
+ }
387
+
388
+ async function shareToggle(pos, opts, enabled) {
389
+ const [token] = pos;
390
+ if (!token) throw new Error(`usage: share ${enabled ? "on" : "off"} <token>`);
391
+ await (await client(opts)).mutation(api.shares.setEnabled, { token, enabled });
392
+ console.log(`OK ${enabled ? "enabled" : "disabled"} ${token}`);
393
+ }
394
+
395
+ async function shareRm(pos, opts) {
396
+ const [token] = pos;
397
+ if (!token) throw new Error("usage: share rm <token>");
398
+ await (await client(opts)).mutation(api.shares.remove, { token });
399
+ console.log(`OK removed ${token}`);
400
+ }
401
+
402
+ function shareUrl(pos, opts) {
403
+ const [token] = pos;
404
+ if (!token) throw new Error("usage: share url <token>");
405
+ console.log(`${shareBase(opts)}/v/${token}`);
406
+ }
407
+
408
+ async function recipientArchive(pos, opts, archived) {
409
+ const [recipient] = pos;
410
+ if (!recipient) throw new Error(`usage: recipient ${archived ? "archive" : "unarchive"} <name>`);
411
+ await (await client(opts)).mutation(api.activity.setArchived, { recipient, archived });
412
+ console.log(`OK ${archived ? "archived" : "unarchived"} "${recipient}"`);
413
+ }
414
+
415
+ async function activity(pos, opts) {
416
+ const o = await (await client(opts)).query(api.activity.overview, {});
417
+ let feed = o.feed || [];
418
+ if (opts.deck) feed = feed.filter((f) => f.deckSlug === opts.deck);
419
+ if (opts.limit) feed = feed.slice(0, Number(opts.limit));
420
+ if (opts.json) {
421
+ console.log(JSON.stringify({ ...o, feed }, null, 2));
422
+ return;
423
+ }
424
+ if (!feed.length) {
425
+ console.log(opts.deck ? `(no activity for ${opts.deck})` : "(no activity)");
426
+ return;
427
+ }
428
+ for (const f of feed) {
429
+ const when = new Date(f.lastSeenAt).toISOString().replace("T", " ").slice(0, 16);
430
+ if (f.kind === "sent") {
431
+ console.log(`${when}\tsent\t${f.recipient}\t${f.deckTitle}`);
432
+ } else {
433
+ const where = [f.city, f.country].filter(Boolean).join(", ");
434
+ console.log(`${when}\topened\t${f.recipient}\t${f.deckTitle}\tslide ${f.maxSlide}\t${fmtMs(f.totalMs)}${f.completed ? " complete" : ""}\t${where}`);
435
+ }
436
+ }
437
+ }
438
+
439
+ function help() {
440
+ console.log(`decks ${VERSION}
441
+
442
+ commands:
443
+ login [--token <pat>] [--broker <url>] [--dev]
444
+ logout [--dev]
445
+ whoami [--json] [--dev]
446
+ deck list|stats|open
447
+ share new|list|on|off|rm|url
448
+ recipient archive|unarchive
449
+ activity [--deck <slug>] [--limit <n>] [--json]
450
+
451
+ global:
452
+ --json
453
+ --prod production target (default)
454
+ --dev development target (requires Convex URL)
455
+ --broker URL override auth broker URL
456
+ `);
457
+ }
458
+
459
+ const { pos, opts } = parse(process.argv.slice(2));
460
+ if (opts.version) {
461
+ console.log(VERSION);
462
+ process.exit(0);
463
+ }
464
+ if (opts.help || pos.length === 0) {
465
+ help();
466
+ process.exit(0);
467
+ }
468
+
469
+ const [group, cmd] = pos;
470
+ const rest = pos.slice(2);
471
+ const handlers = {
472
+ login: () => login(opts),
473
+ logout: () => logout(opts),
474
+ whoami: () => whoami(opts),
475
+ "deck list": () => deckList(opts),
476
+ "deck stats": () => deckStats(rest, opts),
477
+ "deck open": () => deckOpen(rest, opts),
478
+ "share new": () => shareNew(rest, opts),
479
+ "share list": () => shareList(rest, opts),
480
+ "share on": () => shareToggle(rest, opts, true),
481
+ "share off": () => shareToggle(rest, opts, false),
482
+ "share rm": () => shareRm(rest, opts),
483
+ "share url": () => shareUrl(rest, opts),
484
+ "recipient archive": () => recipientArchive(rest, opts, true),
485
+ "recipient unarchive": () => recipientArchive(rest, opts, false),
486
+ activity: () => activity(pos.slice(1), opts),
487
+ };
488
+
489
+ const handler = handlers[`${group} ${cmd}`] || handlers[group];
490
+ if (!handler) {
491
+ help();
492
+ process.exit(1);
493
+ }
494
+
495
+ try {
496
+ await handler();
497
+ } catch (e) {
498
+ console.error("Error:", e?.message || e);
499
+ process.exit(1);
500
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@hypersocial/decks-cli",
3
+ "version": "0.1.0",
4
+ "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "bin": {
8
+ "decks": "bin/decks.mjs"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "dependencies": {
18
+ "convex": "^1.41.0"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ }
23
+ }