viveworker 0.5.5 → 0.6.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.
@@ -0,0 +1,576 @@
1
+ /**
2
+ * share-cli.mjs — CLI for viveworker's file-share hosting service.
3
+ *
4
+ * Reads credentials from `~/.viveworker/a2a.env` (same creds as the A2A relay).
5
+ *
6
+ * Accepted file types (mirrors share-worker/worker.js SHARE_TYPES):
7
+ * .html .htm .pdf .png .jpg .jpeg .gif .webp .csv
8
+ *
9
+ * Commands:
10
+ * viveworker share upload <file> [--password <pw>] [--expires-days <n>] [--json]
11
+ * viveworker share list [--json]
12
+ * viveworker share update <slug> [--password <pw>] [--no-password] [--expires-days <n>] [--json]
13
+ * viveworker share link <slug> --password <pw> [--ttl-hours <n>] [--json]
14
+ * viveworker share delete <slug>
15
+ *
16
+ * Environment overrides:
17
+ * VIVEWORKER_SHARE_URL — share worker base URL (default: https://share.viveworker.com)
18
+ */
19
+
20
+ import { promises as fs } from "node:fs";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import { Blob, File } from "node:buffer";
24
+
25
+ const A2A_ENV_FILE = path.join(os.homedir(), ".viveworker", "a2a.env");
26
+ const DEFAULT_SHARE_URL = "https://share.viveworker.com";
27
+ const MAX_FILE_SIZE = 5 * 1024 * 1024; // mirror worker
28
+
29
+ // Mirror share-worker/worker.js SHARE_TYPES. Keep in sync by inspection —
30
+ // scripts/ and share-worker/ don't share a module. Adding a new type here
31
+ // without the worker will cause 400 unsupported-extension on upload.
32
+ const SHARE_TYPES = {
33
+ ".html": { mime: "text/html; charset=utf-8" },
34
+ ".htm": { mime: "text/html; charset=utf-8" },
35
+ ".pdf": { mime: "application/pdf" },
36
+ ".png": { mime: "image/png" },
37
+ ".jpg": { mime: "image/jpeg" },
38
+ ".jpeg": { mime: "image/jpeg" },
39
+ ".gif": { mime: "image/gif" },
40
+ ".webp": { mime: "image/webp" },
41
+ ".csv": { mime: "text/csv; charset=utf-8" },
42
+ };
43
+ const ALLOWED_EXTENSIONS = Object.keys(SHARE_TYPES);
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // CLI entry point
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export async function runShareCli(args) {
50
+ const cmd = args[0];
51
+
52
+ switch (cmd) {
53
+ case "upload":
54
+ return handleUpload(args.slice(1));
55
+ case "list":
56
+ return handleList(args.slice(1));
57
+ case "update":
58
+ return handleUpdate(args.slice(1));
59
+ case "link":
60
+ return handleLink(args.slice(1));
61
+ case "delete":
62
+ case "rm":
63
+ return handleDelete(args.slice(1));
64
+ default:
65
+ printHelp();
66
+ if (cmd && cmd !== "help" && cmd !== "--help" && cmd !== "-h") {
67
+ throw new Error(`Unknown command: ${cmd}`);
68
+ }
69
+ }
70
+ }
71
+
72
+ function printHelp() {
73
+ console.log("Commands:");
74
+ console.log(" viveworker share upload <file> [--password <pw>] [--expires-days <n>] [--json]");
75
+ console.log(" viveworker share list [--json]");
76
+ console.log(" viveworker share update <slug> [--password <pw>] [--no-password] [--expires-days <n>] [--json]");
77
+ console.log(" viveworker share link <slug> --password <pw> [--ttl-hours <n>] [--json]");
78
+ console.log(" viveworker share delete <slug>");
79
+ console.log("");
80
+ console.log(`Accepted file types: ${ALLOWED_EXTENSIONS.join(" / ")}`);
81
+ console.log("CSV files are rendered as an HTML table on view; append ?raw=1 for bytes.");
82
+ console.log("");
83
+ console.log("Credentials are read from ~/.viveworker/a2a.env (same as `viveworker a2a`).");
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // upload
88
+ // ---------------------------------------------------------------------------
89
+
90
+ async function handleUpload(args) {
91
+ const flags = parseFlags(args);
92
+ const filePath = flags._[0];
93
+ if (!filePath) {
94
+ throw new Error("Usage: viveworker share upload <file> [--password <pw>] [--expires-days <n>]");
95
+ }
96
+
97
+ const absolute = path.resolve(filePath);
98
+ let stat;
99
+ try {
100
+ stat = await fs.stat(absolute);
101
+ } catch {
102
+ throw new Error(`File not found: ${filePath}`);
103
+ }
104
+ if (!stat.isFile()) {
105
+ throw new Error(`Not a regular file: ${filePath}`);
106
+ }
107
+ if (stat.size === 0) {
108
+ throw new Error(`File is empty: ${filePath}`);
109
+ }
110
+ if (stat.size > MAX_FILE_SIZE) {
111
+ throw new Error(`File too large (${stat.size} bytes, max ${MAX_FILE_SIZE})`);
112
+ }
113
+ const ext = path.extname(absolute).toLowerCase();
114
+ if (!ALLOWED_EXTENSIONS.includes(ext)) {
115
+ throw new Error(
116
+ `Accepted file types: ${ALLOWED_EXTENSIONS.join(" / ")}. ` +
117
+ `Got: ${ext || "(no extension)"}`
118
+ );
119
+ }
120
+ const { mime } = SHARE_TYPES[ext];
121
+
122
+ const password = flags["password"] || "";
123
+ const expiresDays = flags["expires-days"] || flags["expiresDays"] || "";
124
+
125
+ if (password && password.length > 256) {
126
+ throw new Error("Password too long (max 256 chars)");
127
+ }
128
+ if (expiresDays) {
129
+ const n = Number(expiresDays);
130
+ if (!Number.isFinite(n) || n <= 0 || n > 30) {
131
+ throw new Error("--expires-days must be a number between 1 and 30");
132
+ }
133
+ }
134
+
135
+ const { apiKey, userId, shareUrl } = await resolveCredentials();
136
+
137
+ const bytes = await fs.readFile(absolute);
138
+ const form = new FormData();
139
+ const blob = new Blob([bytes], { type: mime });
140
+ const file = new File([blob], path.basename(absolute), { type: mime });
141
+ form.set("file", file);
142
+ if (password) form.set("password", password);
143
+ if (expiresDays) form.set("expiresDays", String(expiresDays));
144
+
145
+ const res = await fetchWithTimeout(`${shareUrl}/api/upload`, {
146
+ method: "POST",
147
+ headers: {
148
+ "x-a2a-user": userId,
149
+ "x-a2a-key": apiKey,
150
+ },
151
+ body: form,
152
+ }, 60_000);
153
+
154
+ const body = await readJson(res);
155
+ if (!res.ok || body.error) {
156
+ throw new Error(formatApiError("Upload", res.status, body));
157
+ }
158
+
159
+ if (flags.json) {
160
+ console.log(JSON.stringify(body, null, 2));
161
+ return;
162
+ }
163
+
164
+ console.log("");
165
+ console.log(`✅ Uploaded ${body.originalName || path.basename(absolute)} (${formatSize(body.size)})`);
166
+ console.log("");
167
+ console.log(` ${body.url}`);
168
+ console.log("");
169
+ if (body.hasPassword) console.log(` 🔒 Password-protected`);
170
+ if (body.expiresAtMs) console.log(` ⏱ Expires ${new Date(body.expiresAtMs).toISOString()}`);
171
+ if (body.hasPassword || body.expiresAtMs) console.log("");
172
+ console.log(` Slug: ${body.slug}`);
173
+ console.log(` Delete: viveworker share delete ${body.slug}`);
174
+ if (body.quota) {
175
+ console.log(
176
+ ` Quota: ${formatSize(body.quota.bytes)} / ${formatSize(body.quota.maxBytes)} · ${body.quota.count}/${body.quota.maxCount} files`
177
+ );
178
+ }
179
+ console.log("");
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // list
184
+ // ---------------------------------------------------------------------------
185
+
186
+ async function handleList(args) {
187
+ const flags = parseFlags(args);
188
+ const { apiKey, userId, shareUrl } = await resolveCredentials();
189
+
190
+ const res = await fetchWithTimeout(`${shareUrl}/api/list`, {
191
+ method: "GET",
192
+ headers: {
193
+ "x-a2a-user": userId,
194
+ "x-a2a-key": apiKey,
195
+ },
196
+ }, 30_000);
197
+
198
+ const body = await readJson(res);
199
+ if (!res.ok || body.error) {
200
+ throw new Error(formatApiError("List", res.status, body));
201
+ }
202
+
203
+ const items = body.items || [];
204
+
205
+ if (flags.json) {
206
+ console.log(JSON.stringify(body, null, 2));
207
+ return;
208
+ }
209
+
210
+ if (items.length === 0) {
211
+ if (body.quota) {
212
+ console.log(
213
+ `(no uploads yet) — quota: ${formatSize(body.quota.bytes)} / ${formatSize(body.quota.maxBytes)} · ${body.quota.count}/${body.quota.maxCount} files`
214
+ );
215
+ } else {
216
+ console.log("(no uploads yet)");
217
+ }
218
+ return;
219
+ }
220
+
221
+ const now = Date.now();
222
+ const quotaLine = body.quota
223
+ ? ` — quota: ${formatSize(body.quota.bytes)} / ${formatSize(body.quota.maxBytes)} · ${body.quota.count}/${body.quota.maxCount} files`
224
+ : "";
225
+ console.log(`\nYour shared files (${items.length})${quotaLine}:\n`);
226
+ for (const item of items) {
227
+ const age = formatRelative(now - (item.createdAtMs || now));
228
+ const size = formatSize(item.size || 0);
229
+ const lockIcon = item.hasPassword ? "🔒" : " ";
230
+ const expiry = item.expiresAtMs ? ` · exp ${new Date(item.expiresAtMs).toISOString().slice(0, 10)}` : "";
231
+ console.log(` ${lockIcon} ${item.slug} ${size.padStart(8)} ${age.padStart(10)}${expiry}`);
232
+ console.log(` ${item.url}`);
233
+ if (item.originalName) console.log(` ${item.originalName}`);
234
+ console.log("");
235
+ }
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // update
240
+ // ---------------------------------------------------------------------------
241
+
242
+ async function handleUpdate(args) {
243
+ const flags = parseFlags(args);
244
+ const slug = flags._[0];
245
+ if (!slug) {
246
+ throw new Error(
247
+ "Usage: viveworker share update <slug> [--password <pw>] [--no-password] [--expires-days <n>]"
248
+ );
249
+ }
250
+ if (!/^[A-Za-z0-9]+$/.test(slug)) {
251
+ throw new Error(`Invalid slug: ${slug}`);
252
+ }
253
+
254
+ const hasPassword = Object.prototype.hasOwnProperty.call(flags, "password");
255
+ const hasNoPassword = Object.prototype.hasOwnProperty.call(flags, "no-password");
256
+ const hasExpires = Object.prototype.hasOwnProperty.call(flags, "expires-days") ||
257
+ Object.prototype.hasOwnProperty.call(flags, "expiresDays");
258
+
259
+ if (hasPassword && hasNoPassword) {
260
+ throw new Error("Pass either --password OR --no-password, not both");
261
+ }
262
+ if (!hasPassword && !hasNoPassword && !hasExpires) {
263
+ throw new Error(
264
+ "Nothing to update — specify at least one of --password <pw>, --no-password, --expires-days <n>"
265
+ );
266
+ }
267
+
268
+ const body = {};
269
+
270
+ if (hasPassword) {
271
+ const pw = flags.password;
272
+ if (typeof pw !== "string" || pw.length === 0) {
273
+ throw new Error("--password requires a non-empty value (use --no-password to clear)");
274
+ }
275
+ if (pw.length > 256) {
276
+ throw new Error("Password too long (max 256 chars)");
277
+ }
278
+ body.password = pw;
279
+ } else if (hasNoPassword) {
280
+ body.password = null;
281
+ }
282
+
283
+ if (hasExpires) {
284
+ const raw = flags["expires-days"] || flags["expiresDays"];
285
+ const n = Number(raw);
286
+ if (!Number.isFinite(n) || n <= 0 || n > 30) {
287
+ throw new Error("--expires-days must be a number between 1 and 30");
288
+ }
289
+ body.expiresDays = n;
290
+ }
291
+
292
+ const { apiKey, userId, shareUrl } = await resolveCredentials();
293
+
294
+ const res = await fetchWithTimeout(`${shareUrl}/api/share/${encodeURIComponent(slug)}`, {
295
+ method: "PATCH",
296
+ headers: {
297
+ "content-type": "application/json",
298
+ "x-a2a-user": userId,
299
+ "x-a2a-key": apiKey,
300
+ },
301
+ body: JSON.stringify(body),
302
+ }, 30_000);
303
+
304
+ const respBody = await readJson(res);
305
+ if (!res.ok || respBody.error) {
306
+ throw new Error(formatApiError("Update", res.status, respBody));
307
+ }
308
+
309
+ if (flags.json) {
310
+ console.log(JSON.stringify(respBody, null, 2));
311
+ return;
312
+ }
313
+
314
+ console.log("");
315
+ console.log(`✅ Updated ${slug}`);
316
+ console.log("");
317
+ console.log(` ${respBody.url}`);
318
+ console.log("");
319
+ if (respBody.hasPassword) {
320
+ console.log(` 🔒 Password-protected${hasPassword ? " (existing unlock cookies invalidated)" : ""}`);
321
+ } else if (hasNoPassword) {
322
+ console.log(` 🔓 Password removed`);
323
+ }
324
+ if (respBody.expiresAtMs) {
325
+ console.log(` ⏱ Expires ${new Date(respBody.expiresAtMs).toISOString()}`);
326
+ }
327
+ console.log("");
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // link — mint a short-lived `?t=<token>` URL for handing off a
332
+ // password-protected share to another agent without disclosing the password.
333
+ //
334
+ // The owner keeps the password on their side; the receiver only needs to GET
335
+ // the returned URL. Tokens default to 24h, capped at 168h (7d) and capped by
336
+ // the share's own `expiresAtMs`. Rotating the password via `share update
337
+ // --password ...` invalidates every outstanding token for the slug.
338
+ // ---------------------------------------------------------------------------
339
+
340
+ async function handleLink(args) {
341
+ const flags = parseFlags(args);
342
+ const slug = flags._[0];
343
+ if (!slug) {
344
+ throw new Error("Usage: viveworker share link <slug> --password <pw> [--ttl-hours <n>] [--json]");
345
+ }
346
+ if (!/^[A-Za-z0-9]+$/.test(slug)) {
347
+ throw new Error(`Invalid slug: ${slug}`);
348
+ }
349
+
350
+ const password = flags.password;
351
+ if (typeof password !== "string" || password.length === 0) {
352
+ throw new Error("--password is required (the share's current password)");
353
+ }
354
+ if (password.length > 256) {
355
+ throw new Error("Password too long (max 256 chars)");
356
+ }
357
+
358
+ const hasTtl = Object.prototype.hasOwnProperty.call(flags, "ttl-hours") ||
359
+ Object.prototype.hasOwnProperty.call(flags, "ttlHours");
360
+ let ttlHours;
361
+ if (hasTtl) {
362
+ const raw = flags["ttl-hours"] || flags["ttlHours"];
363
+ const n = Number(raw);
364
+ if (!Number.isFinite(n) || n <= 0 || n > 168) {
365
+ throw new Error("--ttl-hours must be a number between 1 and 168");
366
+ }
367
+ ttlHours = n;
368
+ }
369
+
370
+ const { apiKey, userId, shareUrl } = await resolveCredentials();
371
+
372
+ const payload = { password };
373
+ if (ttlHours !== undefined) payload.ttlHours = ttlHours;
374
+
375
+ const res = await fetchWithTimeout(`${shareUrl}/v/${encodeURIComponent(slug)}/unlock.json`, {
376
+ method: "POST",
377
+ headers: {
378
+ "content-type": "application/json",
379
+ "x-a2a-user": userId,
380
+ "x-a2a-key": apiKey,
381
+ },
382
+ body: JSON.stringify(payload),
383
+ }, 30_000);
384
+
385
+ const body = await readJson(res);
386
+ if (!res.ok || body.error) {
387
+ throw new Error(formatApiError("Link", res.status, body));
388
+ }
389
+
390
+ if (flags.json) {
391
+ console.log(JSON.stringify(body, null, 2));
392
+ return;
393
+ }
394
+
395
+ console.log("");
396
+ console.log(`🔗 ${body.url}`);
397
+ console.log("");
398
+ if (body.expiresAtMs) {
399
+ console.log(` ⏱ Expires ${new Date(body.expiresAtMs).toISOString()}`);
400
+ }
401
+ console.log(` Note: rotating the password via 'share update --password' invalidates this link.`);
402
+ console.log("");
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // delete
407
+ // ---------------------------------------------------------------------------
408
+
409
+ async function handleDelete(args) {
410
+ const flags = parseFlags(args);
411
+ const slug = flags._[0];
412
+ if (!slug) {
413
+ throw new Error("Usage: viveworker share delete <slug>");
414
+ }
415
+ if (!/^[A-Za-z0-9]+$/.test(slug)) {
416
+ throw new Error(`Invalid slug: ${slug}`);
417
+ }
418
+
419
+ const { apiKey, userId, shareUrl } = await resolveCredentials();
420
+
421
+ const res = await fetchWithTimeout(`${shareUrl}/api/share/${encodeURIComponent(slug)}`, {
422
+ method: "DELETE",
423
+ headers: {
424
+ "x-a2a-user": userId,
425
+ "x-a2a-key": apiKey,
426
+ },
427
+ }, 30_000);
428
+
429
+ const body = await readJson(res);
430
+ if (!res.ok || body.error) {
431
+ throw new Error(formatApiError("Delete", res.status, body));
432
+ }
433
+
434
+ if (flags.json) {
435
+ console.log(JSON.stringify(body, null, 2));
436
+ return;
437
+ }
438
+
439
+ console.log(`✅ Deleted ${slug}`);
440
+ }
441
+
442
+ function formatApiError(op, status, body) {
443
+ const code = body?.error || "";
444
+ switch (code) {
445
+ case "rate-limited":
446
+ return `${op} failed (${status}): rate limit — ${body.limit}/${Math.round((body.windowSec || 3600) / 60)}m, retry in ${body.retryAfterSec}s`;
447
+ case "quota-exceeded":
448
+ return `${op} failed (${status}): quota exceeded — used ${formatSize(body.currentBytes)} of ${formatSize(body.maxTotalBytes)}, file is ${formatSize(body.fileBytes)}`;
449
+ case "file-count-exceeded":
450
+ return `${op} failed (${status}): file count exceeded — ${body.current}/${body.max}. Delete something first.`;
451
+ case "file-too-large":
452
+ return `${op} failed (${status}): file too large (max ${formatSize(body.maxBytes)})`;
453
+ case "unsupported-extension":
454
+ return `${op} failed (${status}): unsupported file type. Accepted: ${(body.allowed || []).join(" / ") || "n/a"}`;
455
+ case "unsupported-content-type":
456
+ return `${op} failed (${status}): declared content-type ${body.declared || "?"} does not match ${body.expected || "the file extension"}`;
457
+ case "content-mismatch":
458
+ return `${op} failed (${status}): file body does not match its extension (kind=${body.kind || "?"})`;
459
+ case "expired-requires-expiresDays":
460
+ return `${op} failed (${status}): share is expired — pass --expires-days <1-${body.maxDays || 30}> to revive it`;
461
+ case "object-missing":
462
+ return `${op} failed (${status}): the R2 body is gone (90-day lifecycle reaped it). Re-upload instead.`;
463
+ case "invalid-password":
464
+ return `${op} failed (${status}): wrong password`;
465
+ case "not-password-protected":
466
+ return `${op} failed (${status}): share has no password — no link token needed, just share the URL directly`;
467
+ case "invalid-ttlHours":
468
+ return `${op} failed (${status}): --ttl-hours must be between 1 and ${body.maxHours || 168}`;
469
+ default:
470
+ return `${op} failed (${status}): ${code || body?.statusText || "unknown error"}`;
471
+ }
472
+ }
473
+
474
+ // ---------------------------------------------------------------------------
475
+ // Helpers
476
+ // ---------------------------------------------------------------------------
477
+
478
+ async function resolveCredentials() {
479
+ let text;
480
+ try {
481
+ text = await fs.readFile(A2A_ENV_FILE, "utf8");
482
+ } catch {
483
+ throw new Error(
484
+ `Missing ${A2A_ENV_FILE}.\n` +
485
+ `Run 'viveworker a2a setup --user-id <id>' first to provision credentials.`
486
+ );
487
+ }
488
+
489
+ const apiKey = envValue(text, "A2A_API_KEY");
490
+ const userId = envValue(text, "A2A_RELAY_USER_ID");
491
+ if (!apiKey || !userId) {
492
+ throw new Error(
493
+ `A2A_API_KEY and/or A2A_RELAY_USER_ID missing from ${A2A_ENV_FILE}.\n` +
494
+ `Re-run 'viveworker a2a setup --user-id <id>'.`
495
+ );
496
+ }
497
+
498
+ const shareUrl = (
499
+ process.env.VIVEWORKER_SHARE_URL ||
500
+ envValue(text, "VIVEWORKER_SHARE_URL") ||
501
+ DEFAULT_SHARE_URL
502
+ ).replace(/\/$/u, "");
503
+
504
+ return { apiKey, userId, shareUrl };
505
+ }
506
+
507
+ function envValue(text, key) {
508
+ for (const line of text.split(/\r?\n/)) {
509
+ const trimmed = line.trim();
510
+ if (!trimmed || trimmed.startsWith("#")) continue;
511
+ const eq = trimmed.indexOf("=");
512
+ if (eq === -1) continue;
513
+ if (trimmed.slice(0, eq) === key) return trimmed.slice(eq + 1);
514
+ }
515
+ return "";
516
+ }
517
+
518
+ function parseFlags(args) {
519
+ const flags = { _: [] };
520
+ for (let i = 0; i < args.length; i++) {
521
+ const arg = args[i];
522
+ if (arg.startsWith("--")) {
523
+ const eqIdx = arg.indexOf("=");
524
+ if (eqIdx !== -1) {
525
+ flags[arg.slice(2, eqIdx)] = arg.slice(eqIdx + 1);
526
+ } else if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
527
+ flags[arg.slice(2)] = args[++i];
528
+ } else {
529
+ flags[arg.slice(2)] = true;
530
+ }
531
+ } else {
532
+ flags._.push(arg);
533
+ }
534
+ }
535
+ return flags;
536
+ }
537
+
538
+ async function fetchWithTimeout(url, options, timeoutMs) {
539
+ const controller = new AbortController();
540
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
541
+ try {
542
+ return await fetch(url, { ...options, signal: controller.signal });
543
+ } finally {
544
+ clearTimeout(timer);
545
+ }
546
+ }
547
+
548
+ async function readJson(res) {
549
+ const text = await res.text();
550
+ if (!text) return {};
551
+ try {
552
+ return JSON.parse(text);
553
+ } catch {
554
+ return { error: text.slice(0, 200) };
555
+ }
556
+ }
557
+
558
+ function formatSize(bytes) {
559
+ if (bytes < 1024) return `${bytes} B`;
560
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
561
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
562
+ }
563
+
564
+ function formatRelative(ms) {
565
+ const sec = Math.floor(ms / 1000);
566
+ if (sec < 60) return `${sec}s ago`;
567
+ const min = Math.floor(sec / 60);
568
+ if (min < 60) return `${min}m ago`;
569
+ const hr = Math.floor(min / 60);
570
+ if (hr < 24) return `${hr}h ago`;
571
+ const day = Math.floor(hr / 24);
572
+ if (day < 30) return `${day}d ago`;
573
+ const mo = Math.floor(day / 30);
574
+ if (mo < 12) return `${mo}mo ago`;
575
+ return `${Math.floor(mo / 12)}y ago`;
576
+ }