@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.
- package/README.md +70 -0
- package/bin/decks.mjs +500 -0
- 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
|
+
}
|