@hypersocial/decks-cli 0.1.0 → 0.1.1

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 CHANGED
@@ -65,6 +65,6 @@ customer-facing share management.
65
65
  Use dev only for local implementation/testing:
66
66
 
67
67
  ```bash
68
- decks --dev --broker https://decks.localhost whoami
69
- HSDECKS_PROD=0 HSDECKS_BROKER_BASE=https://decks.localhost decks whoami
68
+ decks --dev --convex https://your-dev.convex.cloud --broker https://decks.localhost whoami
69
+ HSDECKS_PROD=0 HSDECKS_CONVEX_URL=https://your-dev.convex.cloud HSDECKS_BROKER_BASE=https://decks.localhost decks whoami
70
70
  ```
package/bin/decks.mjs CHANGED
@@ -1,500 +1,4 @@
1
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";
2
+ import { runCli } from "../src/commands.mjs";
10
3
 
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
- }
4
+ process.exitCode = await runCli(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "bin/",
12
+ "src/",
12
13
  "README.md"
13
14
  ],
14
15
  "engines": {
@@ -0,0 +1,212 @@
1
+ import { login, logout, openBrowser, parse, target, VERSION } from "./runtime.mjs";
2
+ import * as ops from "./ops.mjs";
3
+
4
+ const fmtMs = (ms) => (ms >= 60000 ? `${(ms / 60000).toFixed(1)}m` : `${Math.round(ms / 1000)}s`);
5
+
6
+ async function whoamiCmd(opts) {
7
+ const t = target(opts);
8
+ const who = await ops.whoami(opts);
9
+ if (opts.json) {
10
+ console.log(JSON.stringify({ ...(who || {}), deployment: t.name, convexUrl: t.convexUrl }, null, 2));
11
+ return;
12
+ }
13
+ if (!who) {
14
+ console.log(`not authenticated (${t.name})`);
15
+ return;
16
+ }
17
+ console.log(`email: ${who.email}`);
18
+ console.log(`subject: ${who.subject}`);
19
+ console.log(`isAdmin: ${who.isAdmin}`);
20
+ console.log(`deployment: ${t.name} (${t.convexUrl})`);
21
+ }
22
+
23
+ async function deckList(opts) {
24
+ const rows = await ops.listDecks(opts);
25
+ if (opts.json) {
26
+ console.log(JSON.stringify(rows, null, 2));
27
+ return;
28
+ }
29
+ for (const d of rows) console.log(`${d.slug}\t${d.status}\t${d.title}`);
30
+ if (!rows.length) console.log("(no decks)");
31
+ }
32
+
33
+ async function deckStats(pos, opts) {
34
+ const [slug] = pos;
35
+ if (!slug) throw new Error("usage: deck stats <slug>");
36
+ const s = await ops.deckStats(opts, slug);
37
+ if (opts.json) {
38
+ console.log(JSON.stringify(s, null, 2));
39
+ return;
40
+ }
41
+ console.log(`deck "${slug}"`);
42
+ console.log(` views: ${s.views}`);
43
+ console.log(` visits: ${s.visits}`);
44
+ console.log(` unique viewers: ${s.viewers}`);
45
+ console.log(` avg engaged: ${fmtMs(s.avgMs)}`);
46
+ console.log(` total engaged: ${fmtMs(s.totalMs)}`);
47
+ if (s.recent?.length) {
48
+ console.log(" recent:");
49
+ for (const r of s.recent) {
50
+ const where = [r.city, r.country].filter(Boolean).join(", ");
51
+ console.log(` ${new Date(r.lastSeenAt).toISOString()}\t${r.recipient}\t${fmtMs(r.totalMs)}\tslide ${r.maxSlide}${r.completed ? " complete" : ""}\t${where}`);
52
+ }
53
+ }
54
+ }
55
+
56
+ function deckOpen(pos, opts) {
57
+ const [slug] = pos;
58
+ if (!slug) throw new Error("usage: deck open <slug>");
59
+ const url = `${target(opts).brokerBase}/d/${slug}`;
60
+ console.log(url);
61
+ openBrowser(url);
62
+ }
63
+
64
+ function shareBase(opts) {
65
+ return target(opts).shareBase;
66
+ }
67
+
68
+ async function shareNew(pos, opts) {
69
+ const [slug, recipient] = pos;
70
+ if (!slug || !recipient) throw new Error("usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n]");
71
+ const { token } = await ops.createShare(opts, {
72
+ slug,
73
+ recipient,
74
+ email: opts.email,
75
+ note: opts.note,
76
+ requireEmail: opts["require-email"],
77
+ expires: opts.expires,
78
+ limit: opts.limit,
79
+ });
80
+ const url = `${shareBase(opts)}/v/${token}`;
81
+ if (opts.json) console.log(JSON.stringify({ token, url }, null, 2));
82
+ else console.log(`OK share for "${recipient}" -> ${url}`);
83
+ }
84
+
85
+ async function shareList(pos, opts) {
86
+ const [slug] = pos;
87
+ if (!slug) throw new Error("usage: share list <slug>");
88
+ const rows = await ops.listShares(opts, slug);
89
+ if (opts.json) {
90
+ console.log(JSON.stringify(rows, null, 2));
91
+ return;
92
+ }
93
+ for (const r of rows) console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${r.recipient}\topens:${r.opens}${r.archived ? "\t(archived)" : ""}`);
94
+ if (!rows.length) console.log("(no shares)");
95
+ }
96
+
97
+ async function shareToggle(pos, opts, enabled) {
98
+ const [token] = pos;
99
+ if (!token) throw new Error(`usage: share ${enabled ? "on" : "off"} <token>`);
100
+ await ops.setShareEnabled(opts, token, enabled);
101
+ console.log(`OK ${enabled ? "enabled" : "disabled"} ${token}`);
102
+ }
103
+
104
+ async function shareRm(pos, opts) {
105
+ const [token] = pos;
106
+ if (!token) throw new Error("usage: share rm <token>");
107
+ await ops.removeShare(opts, token);
108
+ console.log(`OK removed ${token}`);
109
+ }
110
+
111
+ function shareUrl(pos, opts) {
112
+ const [token] = pos;
113
+ if (!token) throw new Error("usage: share url <token>");
114
+ console.log(`${shareBase(opts)}/v/${token}`);
115
+ }
116
+
117
+ async function recipientArchive(pos, opts, archived) {
118
+ const [recipient] = pos;
119
+ if (!recipient) throw new Error(`usage: recipient ${archived ? "archive" : "unarchive"} <name>`);
120
+ await ops.setRecipientArchived(opts, recipient, archived);
121
+ console.log(`OK ${archived ? "archived" : "unarchived"} "${recipient}"`);
122
+ }
123
+
124
+ async function activity(pos, opts) {
125
+ const o = await ops.activityOverview(opts);
126
+ let feed = o.feed || [];
127
+ if (opts.deck) feed = feed.filter((f) => f.deckSlug === opts.deck);
128
+ if (opts.limit) feed = feed.slice(0, Number(opts.limit));
129
+ if (opts.json) {
130
+ console.log(JSON.stringify({ ...o, feed }, null, 2));
131
+ return;
132
+ }
133
+ if (!feed.length) {
134
+ console.log(opts.deck ? `(no activity for ${opts.deck})` : "(no activity)");
135
+ return;
136
+ }
137
+ for (const f of feed) {
138
+ const when = new Date(f.lastSeenAt).toISOString().replace("T", " ").slice(0, 16);
139
+ if (f.kind === "sent") {
140
+ console.log(`${when}\tsent\t${f.recipient}\t${f.deckTitle}`);
141
+ } else {
142
+ const where = [f.city, f.country].filter(Boolean).join(", ");
143
+ console.log(`${when}\topened\t${f.recipient}\t${f.deckTitle}\tslide ${f.maxSlide}\t${fmtMs(f.totalMs)}${f.completed ? " complete" : ""}\t${where}`);
144
+ }
145
+ }
146
+ }
147
+
148
+ export function help() {
149
+ console.log(`decks ${VERSION}
150
+
151
+ commands:
152
+ login [--token <pat>] [--broker <url>] [--dev]
153
+ logout [--dev]
154
+ whoami [--json] [--dev]
155
+ deck list|stats|open
156
+ share new|list|on|off|rm|url
157
+ recipient archive|unarchive
158
+ activity [--deck <slug>] [--limit <n>] [--json]
159
+
160
+ global:
161
+ --json
162
+ --prod production target (default)
163
+ --dev development target (requires Convex URL)
164
+ --broker URL override auth broker URL
165
+ `);
166
+ }
167
+
168
+ export async function runCli(argv = process.argv.slice(2)) {
169
+ const { pos, opts } = parse(argv);
170
+ if (opts.version) {
171
+ console.log(VERSION);
172
+ return 0;
173
+ }
174
+ if (opts.help || pos.length === 0) {
175
+ help();
176
+ return opts.help || pos.length === 0 ? 0 : 1;
177
+ }
178
+
179
+ const [group, cmd] = pos;
180
+ const rest = pos.slice(2);
181
+ const handlers = {
182
+ login: () => login(opts),
183
+ logout: () => logout(opts),
184
+ whoami: () => whoamiCmd(opts),
185
+ "deck list": () => deckList(opts),
186
+ "deck stats": () => deckStats(rest, opts),
187
+ "deck open": () => deckOpen(rest, opts),
188
+ "share new": () => shareNew(rest, opts),
189
+ "share list": () => shareList(rest, opts),
190
+ "share on": () => shareToggle(rest, opts, true),
191
+ "share off": () => shareToggle(rest, opts, false),
192
+ "share rm": () => shareRm(rest, opts),
193
+ "share url": () => shareUrl(rest, opts),
194
+ "recipient archive": () => recipientArchive(rest, opts, true),
195
+ "recipient unarchive": () => recipientArchive(rest, opts, false),
196
+ activity: () => activity(pos.slice(1), opts),
197
+ };
198
+
199
+ const handler = handlers[`${group} ${cmd}`] || handlers[group];
200
+ if (!handler) {
201
+ help();
202
+ return 1;
203
+ }
204
+
205
+ try {
206
+ await handler();
207
+ return 0;
208
+ } catch (e) {
209
+ console.error("Error:", e?.message || e);
210
+ return 1;
211
+ }
212
+ }
package/src/ops.mjs ADDED
@@ -0,0 +1,80 @@
1
+ import { api, client } from "./runtime.mjs";
2
+
3
+ export async function whoami(opts = {}) {
4
+ return (await client(opts)).query(api.decks.whoami, {});
5
+ }
6
+
7
+ export async function listDecks(opts = {}) {
8
+ const rows = await (await client(opts)).query(api.decks.listForViewer, {});
9
+ return rows.map((d) => ({
10
+ slug: d.slug,
11
+ title: d.title,
12
+ status: d.status,
13
+ ownerEmail: d.ownerEmail ?? null,
14
+ hasHtml: !!d.html,
15
+ }));
16
+ }
17
+
18
+ export async function deckStats(opts, slug) {
19
+ if (!slug) throw new Error("slug is required");
20
+ return (await client(opts)).query(api.shares.deckAnalytics, { deckSlug: slug });
21
+ }
22
+
23
+ export async function activityOverview(opts = {}) {
24
+ return (await client(opts)).query(api.activity.overview, {});
25
+ }
26
+
27
+ export async function listShares(opts, slug) {
28
+ if (!slug) throw new Error("slug is required");
29
+ const rows = await (await client(opts)).query(api.shares.listForDeck, { deckSlug: slug });
30
+ return rows.map((r) => ({
31
+ token: r.token,
32
+ recipient: r.recipient,
33
+ enabled: r.enabled,
34
+ opens: r.opens,
35
+ archived: !!r.archived,
36
+ lastOpenedAt: r.lastOpenedAt ?? null,
37
+ requireEmail: r.requireEmail,
38
+ recipientEmail: r.recipientEmail ?? null,
39
+ expiresAt: r.expiresAt ?? null,
40
+ viewLimit: r.viewLimit ?? null,
41
+ }));
42
+ }
43
+
44
+ export async function createShare(opts, fields) {
45
+ const { slug, recipient } = fields;
46
+ if (!slug || !recipient) throw new Error("slug and recipient are required");
47
+ const args = { deckSlug: slug, recipient };
48
+ if (fields.email) args.recipientEmail = fields.email;
49
+ if (fields.note) args.note = fields.note;
50
+ if (fields.requireEmail) args.requireEmail = true;
51
+ if (fields.expires) {
52
+ const t = Date.parse(fields.expires);
53
+ if (Number.isNaN(t)) throw new Error(`expires must be an ISO date; got ${fields.expires}`);
54
+ args.expiresAt = t;
55
+ }
56
+ if (fields.limit != null) {
57
+ const n = Number(fields.limit);
58
+ if (!Number.isFinite(n)) throw new Error(`limit must be a number; got ${fields.limit}`);
59
+ args.viewLimit = n;
60
+ }
61
+ return (await client(opts)).mutation(api.shares.create, args);
62
+ }
63
+
64
+ export async function setShareEnabled(opts, token, enabled) {
65
+ if (!token) throw new Error("token is required");
66
+ await (await client(opts)).mutation(api.shares.setEnabled, { token, enabled: !!enabled });
67
+ return { token, enabled: !!enabled };
68
+ }
69
+
70
+ export async function removeShare(opts, token) {
71
+ if (!token) throw new Error("token is required");
72
+ await (await client(opts)).mutation(api.shares.remove, { token });
73
+ return { token, removed: true };
74
+ }
75
+
76
+ export async function setRecipientArchived(opts, recipient, archived) {
77
+ if (!recipient) throw new Error("recipient is required");
78
+ await (await client(opts)).mutation(api.activity.setArchived, { recipient, archived });
79
+ return { recipient, archived: !!archived };
80
+ }
@@ -0,0 +1,268 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { createServer } from "node:http";
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { ConvexHttpClient } from "convex/browser";
8
+ import { makeFunctionReference } from "convex/server";
9
+
10
+ export const VERSION = "0.1.1";
11
+
12
+ export const PROD = {
13
+ name: "prod",
14
+ convexUrl: "https://dashing-snail-874.convex.cloud",
15
+ brokerBase: "https://decks.hypersocial.ai",
16
+ shareBase: "https://decks.hypersocial.ai",
17
+ };
18
+
19
+ export const api = {
20
+ activity: {
21
+ overview: makeFunctionReference("activity:overview"),
22
+ setArchived: makeFunctionReference("activity:setArchived"),
23
+ },
24
+ cliCredentials: {
25
+ revokeByHash: makeFunctionReference("cliCredentials:revokeByHash"),
26
+ },
27
+ decks: {
28
+ listForViewer: makeFunctionReference("decks:listForViewer"),
29
+ whoami: makeFunctionReference("decks:whoami"),
30
+ },
31
+ shares: {
32
+ create: makeFunctionReference("shares:create"),
33
+ deckAnalytics: makeFunctionReference("shares:deckAnalytics"),
34
+ listForDeck: makeFunctionReference("shares:listForDeck"),
35
+ remove: makeFunctionReference("shares:remove"),
36
+ setEnabled: makeFunctionReference("shares:setEnabled"),
37
+ },
38
+ };
39
+
40
+ const BOOL = new Set(["prod", "dev", "json", "require-email", "help", "version"]);
41
+
42
+ export const CRED_DIR = join(homedir(), ".config", "hypersocial-decks");
43
+ export const CRED_FILE = join(CRED_DIR, "credentials.json");
44
+
45
+ export function parse(args) {
46
+ const opts = {};
47
+ const pos = [];
48
+ for (let i = 0; i < args.length; i++) {
49
+ const a = args[i];
50
+ if (a.startsWith("--")) {
51
+ const k = a.slice(2);
52
+ if (BOOL.has(k)) opts[k] = true;
53
+ else opts[k] = args[++i];
54
+ } else {
55
+ pos.push(a);
56
+ }
57
+ }
58
+ return { pos, opts };
59
+ }
60
+
61
+ export function normalizeBase(url) {
62
+ return String(url || "").replace(/\/$/, "");
63
+ }
64
+
65
+ export function target(opts = {}) {
66
+ const envWantsDev = process.env.HSDECKS_PROD === "0";
67
+ const wantDev = opts.dev || (envWantsDev && !opts.prod);
68
+ if (!wantDev) {
69
+ return {
70
+ ...PROD,
71
+ brokerBase: normalizeBase(opts.broker || PROD.brokerBase),
72
+ };
73
+ }
74
+
75
+ const convexUrl = opts.convex || process.env.HSDECKS_CONVEX_URL || process.env.CONVEX_URL;
76
+ if (!convexUrl) {
77
+ throw new Error("dev target needs --convex <url>, HSDECKS_CONVEX_URL, or CONVEX_URL");
78
+ }
79
+ return {
80
+ name: "dev",
81
+ convexUrl,
82
+ brokerBase: normalizeBase(opts.broker || process.env.HSDECKS_BROKER_BASE || "http://localhost:3000"),
83
+ shareBase: normalizeBase(process.env.HSDECKS_SHARE_BASE || "https://decks.localhost"),
84
+ };
85
+ }
86
+
87
+ export function loadCreds() {
88
+ if (!existsSync(CRED_FILE)) return {};
89
+ try {
90
+ return JSON.parse(readFileSync(CRED_FILE, "utf8"));
91
+ } catch {
92
+ return {};
93
+ }
94
+ }
95
+
96
+ export function saveCred(name, cred) {
97
+ const all = loadCreds();
98
+ all[name] = cred;
99
+ mkdirSync(CRED_DIR, { recursive: true, mode: 0o700 });
100
+ writeFileSync(CRED_FILE, JSON.stringify(all, null, 2), { mode: 0o600 });
101
+ }
102
+
103
+ export function clearCred(name) {
104
+ const all = loadCreds();
105
+ const removed = all[name];
106
+ delete all[name];
107
+ mkdirSync(CRED_DIR, { recursive: true, mode: 0o700 });
108
+ writeFileSync(CRED_FILE, JSON.stringify(all, null, 2), { mode: 0o600 });
109
+ return removed;
110
+ }
111
+
112
+ export const sha256Hex = (s) => createHash("sha256").update(s, "utf8").digest("hex");
113
+
114
+ function resolveRefreshToken(t, opts) {
115
+ if (opts.token) return opts.token;
116
+ if (process.env.HSDECKS_TOKEN) return process.env.HSDECKS_TOKEN;
117
+ return loadCreds()[t.name]?.refreshToken || null;
118
+ }
119
+
120
+ async function mintJwt(brokerBase, refreshToken) {
121
+ let res;
122
+ try {
123
+ res = await fetch(`${brokerBase}/api/cli/token`, {
124
+ method: "POST",
125
+ headers: { authorization: `Bearer ${refreshToken}` },
126
+ signal: AbortSignal.timeout(15000),
127
+ });
128
+ } catch (e) {
129
+ const msg = String(e?.message || e);
130
+ if (e?.name === "TimeoutError" || /timeout/i.test(msg)) {
131
+ throw new Error(`broker at ${brokerBase} did not respond within 15s`);
132
+ }
133
+ throw new Error(`could not reach broker at ${brokerBase} (${msg})`);
134
+ }
135
+
136
+ if (!res.ok) {
137
+ const body = await res.text().catch(() => "");
138
+ throw new Error(`auth exchange failed (${res.status}) ${body.slice(0, 200)}`);
139
+ }
140
+ return res.json();
141
+ }
142
+
143
+ const jwtCache = new Map();
144
+
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
+ export 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
+ export 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
+ export 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
+ export 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
+ }