viveworker 0.5.5 → 0.7.0-beta.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.
@@ -0,0 +1,819 @@
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>] [--price <usd> --pay-to <0x…>] [--expires-days <n>] [--json]
11
+ * viveworker share list [--json]
12
+ * viveworker share update <slug> [--password <pw>] [--no-password] [--price <usd>] [--no-price] [--pay-to <0x…>] [--expires-days <n>] [--json]
13
+ * viveworker share link <slug> --password <pw> [--ttl-hours <n>] [--json]
14
+ * viveworker share delete <slug>
15
+ *
16
+ * Paid shares: `--price 0.10 --pay-to 0x…` attaches an x402 payment gate.
17
+ * Buyers hit HTTP 402 on first view, pay USDC on Base (testnet or mainnet
18
+ * depending on worker config), and the worker serves the content. Any
19
+ * x402-compatible client (e.g. `x402-fetch` on npm) can pay; viveworker
20
+ * itself does not currently ship a buyer wallet.
21
+ *
22
+ * Environment overrides:
23
+ * VIVEWORKER_SHARE_URL — share worker base URL (default: https://share.viveworker.com)
24
+ */
25
+
26
+ import { promises as fs } from "node:fs";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+ import { Blob, File } from "node:buffer";
30
+
31
+ const A2A_ENV_FILE = path.join(os.homedir(), ".viveworker", "a2a.env");
32
+ const DEFAULT_SHARE_URL = "https://share.viveworker.com";
33
+ const MAX_FILE_SIZE = 5 * 1024 * 1024; // mirror worker
34
+
35
+ // Mirror share-worker/worker.js SHARE_TYPES. Keep in sync by inspection —
36
+ // scripts/ and share-worker/ don't share a module. Adding a new type here
37
+ // without the worker will cause 400 unsupported-extension on upload.
38
+ const SHARE_TYPES = {
39
+ ".html": { mime: "text/html; charset=utf-8" },
40
+ ".htm": { mime: "text/html; charset=utf-8" },
41
+ ".pdf": { mime: "application/pdf" },
42
+ ".png": { mime: "image/png" },
43
+ ".jpg": { mime: "image/jpeg" },
44
+ ".jpeg": { mime: "image/jpeg" },
45
+ ".gif": { mime: "image/gif" },
46
+ ".webp": { mime: "image/webp" },
47
+ ".csv": { mime: "text/csv; charset=utf-8" },
48
+ };
49
+ const ALLOWED_EXTENSIONS = Object.keys(SHARE_TYPES);
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // CLI entry point
53
+ // ---------------------------------------------------------------------------
54
+
55
+ export async function runShareCli(args) {
56
+ const cmd = args[0];
57
+
58
+ switch (cmd) {
59
+ case "upload":
60
+ return handleUpload(args.slice(1));
61
+ case "list":
62
+ return handleList(args.slice(1));
63
+ case "update":
64
+ return handleUpdate(args.slice(1));
65
+ case "link":
66
+ return handleLink(args.slice(1));
67
+ case "delete":
68
+ case "rm":
69
+ return handleDelete(args.slice(1));
70
+ default:
71
+ printHelp();
72
+ if (cmd && cmd !== "help" && cmd !== "--help" && cmd !== "-h") {
73
+ throw new Error(`Unknown command: ${cmd}`);
74
+ }
75
+ }
76
+ }
77
+
78
+ function printHelp() {
79
+ console.log("Commands:");
80
+ console.log(" viveworker share upload <file> [--password <pw>] [--price <usd> --pay-to <0x…>] [--expires-days <n>] [--json]");
81
+ console.log(" viveworker share list [--metrics] [--json]");
82
+ console.log(" viveworker share update <slug> [--password <pw>] [--no-password] [--price <usd>] [--no-price] [--pay-to <0x…>] [--expires-days <n>] [--json]");
83
+ console.log(" viveworker share link <slug> --password <pw> [--ttl-hours <n>] [--json]");
84
+ console.log(" viveworker share delete <slug>");
85
+ console.log("");
86
+ console.log(`Accepted file types: ${ALLOWED_EXTENSIONS.join(" / ")}`);
87
+ console.log("CSV files are rendered as an HTML table on view; append ?raw=1 for bytes.");
88
+ console.log("");
89
+ console.log("Paid shares (x402 / USDC on Base — CLOSED BETA, testnet only): --price 0.10 --pay-to 0x…");
90
+ console.log(" Buyers use any x402-compatible client (e.g. `x402-fetch` on npm).");
91
+ console.log(" --price and --password are mutually exclusive on a single share.");
92
+ console.log(" `share list --metrics` prints 24h / 7d payment-flow stats for your shares.");
93
+ console.log("");
94
+ console.log("Credentials are read from ~/.viveworker/a2a.env (same as `viveworker a2a`).");
95
+ }
96
+
97
+ const PRICE_REGEX = /^\d+(\.\d{1,6})?$/;
98
+ const ETH_ADDR_REGEX = /^0x[0-9a-fA-F]{40}$/;
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // upload
102
+ // ---------------------------------------------------------------------------
103
+
104
+ async function handleUpload(args) {
105
+ const flags = parseFlags(args);
106
+ const filePath = flags._[0];
107
+ if (!filePath) {
108
+ throw new Error("Usage: viveworker share upload <file> [--password <pw>] [--expires-days <n>]");
109
+ }
110
+
111
+ const absolute = path.resolve(filePath);
112
+ let stat;
113
+ try {
114
+ stat = await fs.stat(absolute);
115
+ } catch {
116
+ throw new Error(`File not found: ${filePath}`);
117
+ }
118
+ if (!stat.isFile()) {
119
+ throw new Error(`Not a regular file: ${filePath}`);
120
+ }
121
+ if (stat.size === 0) {
122
+ throw new Error(`File is empty: ${filePath}`);
123
+ }
124
+ if (stat.size > MAX_FILE_SIZE) {
125
+ throw new Error(`File too large (${stat.size} bytes, max ${MAX_FILE_SIZE})`);
126
+ }
127
+ const ext = path.extname(absolute).toLowerCase();
128
+ if (!ALLOWED_EXTENSIONS.includes(ext)) {
129
+ throw new Error(
130
+ `Accepted file types: ${ALLOWED_EXTENSIONS.join(" / ")}. ` +
131
+ `Got: ${ext || "(no extension)"}`
132
+ );
133
+ }
134
+ const { mime } = SHARE_TYPES[ext];
135
+
136
+ const password = flags["password"] || "";
137
+ const expiresDays = flags["expires-days"] || flags["expiresDays"] || "";
138
+ const price = flags["price"] || "";
139
+ const payTo = flags["pay-to"] || flags["payTo"] || "";
140
+
141
+ if (password && password.length > 256) {
142
+ throw new Error("Password too long (max 256 chars)");
143
+ }
144
+ if (expiresDays) {
145
+ const n = Number(expiresDays);
146
+ if (!Number.isFinite(n) || n <= 0 || n > 30) {
147
+ throw new Error("--expires-days must be a number between 1 and 30");
148
+ }
149
+ }
150
+
151
+ // --price / --pay-to are both required or both absent.
152
+ const anyPrice = Boolean(price);
153
+ const anyPayTo = Boolean(payTo);
154
+ if (anyPrice !== anyPayTo) {
155
+ throw new Error("--price and --pay-to must be supplied together");
156
+ }
157
+ if (anyPrice) {
158
+ if (!PRICE_REGEX.test(price)) {
159
+ throw new Error("--price must be a decimal with ≤6 fractional digits (e.g. `0.10`)");
160
+ }
161
+ if (!ETH_ADDR_REGEX.test(payTo)) {
162
+ throw new Error("--pay-to must be a 0x-prefixed 40-hex-char EVM address");
163
+ }
164
+ if (password) {
165
+ throw new Error("--price and --password cannot be combined on a single share");
166
+ }
167
+ }
168
+
169
+ const { apiKey, userId, shareUrl } = await resolveCredentials();
170
+
171
+ const bytes = await fs.readFile(absolute);
172
+ const form = new FormData();
173
+ const blob = new Blob([bytes], { type: mime });
174
+ const file = new File([blob], path.basename(absolute), { type: mime });
175
+ form.set("file", file);
176
+ if (password) form.set("password", password);
177
+ if (expiresDays) form.set("expiresDays", String(expiresDays));
178
+ if (anyPrice) {
179
+ form.set("price", price);
180
+ form.set("payTo", payTo);
181
+ }
182
+
183
+ const res = await fetchWithTimeout(`${shareUrl}/api/upload`, {
184
+ method: "POST",
185
+ headers: {
186
+ "x-a2a-user": userId,
187
+ "x-a2a-key": apiKey,
188
+ },
189
+ body: form,
190
+ }, 60_000);
191
+
192
+ const body = await readJson(res);
193
+ if (!res.ok || body.error) {
194
+ throw new Error(formatApiError("Upload", res.status, body));
195
+ }
196
+
197
+ if (flags.json) {
198
+ console.log(JSON.stringify(body, null, 2));
199
+ return;
200
+ }
201
+
202
+ console.log("");
203
+ console.log(`✅ Uploaded ${body.originalName || path.basename(absolute)} (${formatSize(body.size)})`);
204
+ console.log("");
205
+ console.log(` ${body.url}`);
206
+ console.log("");
207
+ if (body.hasPassword) console.log(` 🔒 Password-protected`);
208
+ if (body.price && body.payTo) {
209
+ console.log(` 💰 Paid — ${formatUsdc(body.price)} USDC on ${body.network || "?"} → ${body.payTo}`);
210
+ }
211
+ if (body.expiresAtMs) console.log(` ⏱ Expires ${new Date(body.expiresAtMs).toISOString()}`);
212
+ if (body.hasPassword || body.price || body.expiresAtMs) console.log("");
213
+ console.log(` Slug: ${body.slug}`);
214
+ console.log(` Delete: viveworker share delete ${body.slug}`);
215
+ if (body.quota) {
216
+ console.log(
217
+ ` Quota: ${formatSize(body.quota.bytes)} / ${formatSize(body.quota.maxBytes)} · ${body.quota.count}/${body.quota.maxCount} files`
218
+ );
219
+ }
220
+ console.log("");
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // list
225
+ // ---------------------------------------------------------------------------
226
+
227
+ async function handleList(args) {
228
+ const flags = parseFlags(args);
229
+ const wantsMetrics = Boolean(flags.metrics);
230
+ const { apiKey, userId, shareUrl } = await resolveCredentials();
231
+
232
+ // Fire both requests in parallel when --metrics is set; the file list is
233
+ // authoritative, the metrics bucket is best-effort (it 501s when the
234
+ // worker's CF_ACCOUNT_ID / CF_API_TOKEN secrets aren't configured).
235
+ const listReq = fetchWithTimeout(`${shareUrl}/api/list`, {
236
+ method: "GET",
237
+ headers: { "x-a2a-user": userId, "x-a2a-key": apiKey },
238
+ }, 30_000);
239
+ const metricsReq = wantsMetrics
240
+ ? fetchWithTimeout(`${shareUrl}/api/metrics`, {
241
+ method: "GET",
242
+ headers: { "x-a2a-user": userId, "x-a2a-key": apiKey },
243
+ }, 30_000).catch((err) => ({ _netErr: String(err?.message || err) }))
244
+ : null;
245
+
246
+ const res = await listReq;
247
+ const body = await readJson(res);
248
+ if (!res.ok || body.error) {
249
+ throw new Error(formatApiError("List", res.status, body));
250
+ }
251
+
252
+ let metricsBody = null;
253
+ let metricsErr = null;
254
+ if (metricsReq) {
255
+ const mRes = await metricsReq;
256
+ if (mRes && mRes._netErr) {
257
+ metricsErr = `metrics fetch failed: ${mRes._netErr}`;
258
+ } else {
259
+ metricsBody = await readJson(mRes);
260
+ if (!mRes.ok || metricsBody?.error) {
261
+ metricsErr = formatApiError("Metrics", mRes.status, metricsBody);
262
+ metricsBody = null;
263
+ }
264
+ }
265
+ }
266
+
267
+ const items = body.items || [];
268
+
269
+ if (flags.json) {
270
+ const combined = wantsMetrics
271
+ ? { ...body, metrics: metricsBody, metricsError: metricsErr }
272
+ : body;
273
+ console.log(JSON.stringify(combined, null, 2));
274
+ return;
275
+ }
276
+
277
+ if (items.length === 0) {
278
+ if (body.quota) {
279
+ console.log(
280
+ `(no uploads yet) — quota: ${formatSize(body.quota.bytes)} / ${formatSize(body.quota.maxBytes)} · ${body.quota.count}/${body.quota.maxCount} files`
281
+ );
282
+ } else {
283
+ console.log("(no uploads yet)");
284
+ }
285
+ if (wantsMetrics) printMetricsSection(metricsBody, metricsErr);
286
+ return;
287
+ }
288
+
289
+ const now = Date.now();
290
+ const quotaLine = body.quota
291
+ ? ` — quota: ${formatSize(body.quota.bytes)} / ${formatSize(body.quota.maxBytes)} · ${body.quota.count}/${body.quota.maxCount} files`
292
+ : "";
293
+ console.log(`\nYour shared files (${items.length})${quotaLine}:\n`);
294
+ for (const item of items) {
295
+ const age = formatRelative(now - (item.createdAtMs || now));
296
+ const size = formatSize(item.size || 0);
297
+ const gateIcon = item.hasPassword
298
+ ? "🔒"
299
+ : item.price
300
+ ? "💰"
301
+ : " ";
302
+ const expiry = item.expiresAtMs ? ` · exp ${new Date(item.expiresAtMs).toISOString().slice(0, 10)}` : "";
303
+ console.log(` ${gateIcon} ${item.slug} ${size.padStart(8)} ${age.padStart(10)}${expiry}`);
304
+ console.log(` ${item.url}`);
305
+ if (item.originalName) console.log(` ${item.originalName}`);
306
+ if (item.price && item.payTo) {
307
+ console.log(` 💰 ${formatUsdc(item.price)} USDC on ${item.network || "?"} → ${item.payTo}`);
308
+ }
309
+ console.log("");
310
+ }
311
+
312
+ if (wantsMetrics) printMetricsSection(metricsBody, metricsErr);
313
+ }
314
+
315
+ // Pretty-prints the Analytics Engine summary returned by GET /api/metrics.
316
+ // Kept inline with handleList because it's only invoked from there; lifting it
317
+ // into its own subcommand would duplicate auth + formatters for no real gain.
318
+ function printMetricsSection(metrics, err) {
319
+ console.log("Paid-share metrics");
320
+ if (err) {
321
+ console.log(` ⚠ ${err}`);
322
+ console.log("");
323
+ return;
324
+ }
325
+ if (!metrics) {
326
+ console.log(" ⚠ no data returned from /api/metrics");
327
+ console.log("");
328
+ return;
329
+ }
330
+ const networkLabel = metrics.network ? ` on ${metrics.network}` : "";
331
+ console.log(` (x402 closed beta${networkLabel})`);
332
+ console.log("");
333
+ const labels = {
334
+ upload_paid: "paid shares uploaded",
335
+ "402_served": "402 responses served (GET)",
336
+ "402_served_head": "402 responses served (HEAD)",
337
+ paid_view: "verified paid views",
338
+ paid_cookie_hit: "paid session reloads",
339
+ verify_failed: "verification rejections",
340
+ facilitator_unavailable: "facilitator unreachable",
341
+ settle_failed: "async settle failures",
342
+ };
343
+ const pad = Math.max(...Object.values(labels).map((s) => s.length));
344
+ const row = (label, d, w) => {
345
+ const padded = label.padEnd(pad);
346
+ const dStr = String(d).padStart(6);
347
+ const wStr = String(w).padStart(6);
348
+ console.log(` ${padded} 24h ${dStr} · 7d ${wStr}`);
349
+ };
350
+ for (const [k, label] of Object.entries(labels)) {
351
+ row(label, metrics.last24h?.[k] ?? 0, metrics.last7d?.[k] ?? 0);
352
+ }
353
+ const perSlug = Array.isArray(metrics.perSlug7d) ? metrics.perSlug7d : [];
354
+ if (perSlug.length > 0) {
355
+ console.log("");
356
+ console.log(` Per-share (last 7d, top ${Math.min(5, perSlug.length)}):`);
357
+ for (const entry of perSlug.slice(0, 5)) {
358
+ const c = entry.counts || {};
359
+ const views = (c.paid_view || 0) + (c.paid_cookie_hit || 0);
360
+ const fails = (c.verify_failed || 0) + (c.facilitator_unavailable || 0) + (c.settle_failed || 0);
361
+ const fourOhTwos = (c["402_served"] || 0) + (c["402_served_head"] || 0);
362
+ console.log(
363
+ ` ${entry.slug} views=${views} 402=${fourOhTwos} fails=${fails}`
364
+ );
365
+ }
366
+ }
367
+ console.log("");
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // update
372
+ // ---------------------------------------------------------------------------
373
+
374
+ async function handleUpdate(args) {
375
+ const flags = parseFlags(args);
376
+ const slug = flags._[0];
377
+ if (!slug) {
378
+ throw new Error(
379
+ "Usage: viveworker share update <slug> [--password <pw>] [--no-password] [--price <usd>] [--no-price] [--pay-to <0x…>] [--expires-days <n>]"
380
+ );
381
+ }
382
+ if (!/^[A-Za-z0-9]+$/.test(slug)) {
383
+ throw new Error(`Invalid slug: ${slug}`);
384
+ }
385
+
386
+ const hasPassword = Object.prototype.hasOwnProperty.call(flags, "password");
387
+ const hasNoPassword = Object.prototype.hasOwnProperty.call(flags, "no-password");
388
+ const hasExpires = Object.prototype.hasOwnProperty.call(flags, "expires-days") ||
389
+ Object.prototype.hasOwnProperty.call(flags, "expiresDays");
390
+ const hasPrice = Object.prototype.hasOwnProperty.call(flags, "price");
391
+ const hasNoPrice = Object.prototype.hasOwnProperty.call(flags, "no-price") ||
392
+ Object.prototype.hasOwnProperty.call(flags, "noPrice");
393
+ const hasPayTo = Object.prototype.hasOwnProperty.call(flags, "pay-to") ||
394
+ Object.prototype.hasOwnProperty.call(flags, "payTo");
395
+
396
+ if (hasPassword && hasNoPassword) {
397
+ throw new Error("Pass either --password OR --no-password, not both");
398
+ }
399
+ if (hasPrice && hasNoPrice) {
400
+ throw new Error("Pass either --price OR --no-price, not both");
401
+ }
402
+ if (hasPrice && hasPassword) {
403
+ throw new Error("--price and --password cannot be combined on a single share");
404
+ }
405
+ if (!hasPassword && !hasNoPassword && !hasExpires && !hasPrice && !hasNoPrice && !hasPayTo) {
406
+ throw new Error(
407
+ "Nothing to update — specify at least one of --password <pw>, --no-password, --price <usd>, --no-price, --pay-to <0x…>, --expires-days <n>"
408
+ );
409
+ }
410
+
411
+ const body = {};
412
+
413
+ if (hasPassword) {
414
+ const pw = flags.password;
415
+ if (typeof pw !== "string" || pw.length === 0) {
416
+ throw new Error("--password requires a non-empty value (use --no-password to clear)");
417
+ }
418
+ if (pw.length > 256) {
419
+ throw new Error("Password too long (max 256 chars)");
420
+ }
421
+ body.password = pw;
422
+ } else if (hasNoPassword) {
423
+ body.password = null;
424
+ }
425
+
426
+ if (hasPrice) {
427
+ const pv = flags.price;
428
+ if (typeof pv !== "string" || pv.length === 0) {
429
+ throw new Error("--price requires a value (use --no-price to clear)");
430
+ }
431
+ if (!PRICE_REGEX.test(pv)) {
432
+ throw new Error("--price must be a decimal with ≤6 fractional digits (e.g. `0.10`)");
433
+ }
434
+ body.price = pv;
435
+ // --pay-to is only required on first set; the worker rejects with
436
+ // `payTo-required-on-first-price` if the share had no payTo before, so
437
+ // let it do the policy enforcement. But if the user passed --pay-to
438
+ // alongside --price, thread it through now.
439
+ if (hasPayTo) {
440
+ const addr = flags["pay-to"] || flags["payTo"];
441
+ if (typeof addr !== "string" || !ETH_ADDR_REGEX.test(addr)) {
442
+ throw new Error("--pay-to must be a 0x-prefixed 40-hex-char EVM address");
443
+ }
444
+ body.payTo = addr;
445
+ }
446
+ } else if (hasNoPrice) {
447
+ body.price = null;
448
+ if (hasPayTo) {
449
+ throw new Error("--no-price clears both price and payTo; drop --pay-to when removing the gate");
450
+ }
451
+ } else if (hasPayTo) {
452
+ // Change recipient only — does not rotate paymentSalt; existing paid
453
+ // sessions keep working. Worker rejects this when the share has no
454
+ // price gate attached.
455
+ const addr = flags["pay-to"] || flags["payTo"];
456
+ if (typeof addr !== "string" || !ETH_ADDR_REGEX.test(addr)) {
457
+ throw new Error("--pay-to must be a 0x-prefixed 40-hex-char EVM address");
458
+ }
459
+ body.payTo = addr;
460
+ }
461
+
462
+ if (hasExpires) {
463
+ const raw = flags["expires-days"] || flags["expiresDays"];
464
+ const n = Number(raw);
465
+ if (!Number.isFinite(n) || n <= 0 || n > 30) {
466
+ throw new Error("--expires-days must be a number between 1 and 30");
467
+ }
468
+ body.expiresDays = n;
469
+ }
470
+
471
+ const { apiKey, userId, shareUrl } = await resolveCredentials();
472
+
473
+ const res = await fetchWithTimeout(`${shareUrl}/api/share/${encodeURIComponent(slug)}`, {
474
+ method: "PATCH",
475
+ headers: {
476
+ "content-type": "application/json",
477
+ "x-a2a-user": userId,
478
+ "x-a2a-key": apiKey,
479
+ },
480
+ body: JSON.stringify(body),
481
+ }, 30_000);
482
+
483
+ const respBody = await readJson(res);
484
+ if (!res.ok || respBody.error) {
485
+ throw new Error(formatApiError("Update", res.status, respBody));
486
+ }
487
+
488
+ if (flags.json) {
489
+ console.log(JSON.stringify(respBody, null, 2));
490
+ return;
491
+ }
492
+
493
+ console.log("");
494
+ console.log(`✅ Updated ${slug}`);
495
+ console.log("");
496
+ console.log(` ${respBody.url}`);
497
+ console.log("");
498
+ if (respBody.hasPassword) {
499
+ console.log(` 🔒 Password-protected${hasPassword ? " (existing unlock cookies invalidated)" : ""}`);
500
+ } else if (hasNoPassword) {
501
+ console.log(` 🔓 Password removed`);
502
+ }
503
+ if (respBody.price && respBody.payTo) {
504
+ const rotated = hasPrice ? " (paid sessions invalidated)" : "";
505
+ console.log(
506
+ ` 💰 Paid — ${formatUsdc(respBody.price)} USDC on ${respBody.network || "?"} → ${respBody.payTo}${rotated}`
507
+ );
508
+ } else if (hasNoPrice) {
509
+ console.log(` 💸 Payment gate removed`);
510
+ }
511
+ if (respBody.expiresAtMs) {
512
+ console.log(` ⏱ Expires ${new Date(respBody.expiresAtMs).toISOString()}`);
513
+ }
514
+ console.log("");
515
+ }
516
+
517
+ // ---------------------------------------------------------------------------
518
+ // link — mint a short-lived `?t=<token>` URL for handing off a
519
+ // password-protected share to another agent without disclosing the password.
520
+ //
521
+ // The owner keeps the password on their side; the receiver only needs to GET
522
+ // the returned URL. Tokens default to 24h, capped at 168h (7d) and capped by
523
+ // the share's own `expiresAtMs`. Rotating the password via `share update
524
+ // --password ...` invalidates every outstanding token for the slug.
525
+ // ---------------------------------------------------------------------------
526
+
527
+ async function handleLink(args) {
528
+ const flags = parseFlags(args);
529
+ const slug = flags._[0];
530
+ if (!slug) {
531
+ throw new Error("Usage: viveworker share link <slug> --password <pw> [--ttl-hours <n>] [--json]");
532
+ }
533
+ if (!/^[A-Za-z0-9]+$/.test(slug)) {
534
+ throw new Error(`Invalid slug: ${slug}`);
535
+ }
536
+
537
+ const password = flags.password;
538
+ if (typeof password !== "string" || password.length === 0) {
539
+ throw new Error("--password is required (the share's current password)");
540
+ }
541
+ if (password.length > 256) {
542
+ throw new Error("Password too long (max 256 chars)");
543
+ }
544
+
545
+ const hasTtl = Object.prototype.hasOwnProperty.call(flags, "ttl-hours") ||
546
+ Object.prototype.hasOwnProperty.call(flags, "ttlHours");
547
+ let ttlHours;
548
+ if (hasTtl) {
549
+ const raw = flags["ttl-hours"] || flags["ttlHours"];
550
+ const n = Number(raw);
551
+ if (!Number.isFinite(n) || n <= 0 || n > 168) {
552
+ throw new Error("--ttl-hours must be a number between 1 and 168");
553
+ }
554
+ ttlHours = n;
555
+ }
556
+
557
+ const { apiKey, userId, shareUrl } = await resolveCredentials();
558
+
559
+ const payload = { password };
560
+ if (ttlHours !== undefined) payload.ttlHours = ttlHours;
561
+
562
+ const res = await fetchWithTimeout(`${shareUrl}/v/${encodeURIComponent(slug)}/unlock.json`, {
563
+ method: "POST",
564
+ headers: {
565
+ "content-type": "application/json",
566
+ "x-a2a-user": userId,
567
+ "x-a2a-key": apiKey,
568
+ },
569
+ body: JSON.stringify(payload),
570
+ }, 30_000);
571
+
572
+ const body = await readJson(res);
573
+ if (!res.ok || body.error) {
574
+ throw new Error(formatApiError("Link", res.status, body));
575
+ }
576
+
577
+ if (flags.json) {
578
+ console.log(JSON.stringify(body, null, 2));
579
+ return;
580
+ }
581
+
582
+ console.log("");
583
+ console.log(`🔗 ${body.url}`);
584
+ console.log("");
585
+ if (body.expiresAtMs) {
586
+ console.log(` ⏱ Expires ${new Date(body.expiresAtMs).toISOString()}`);
587
+ }
588
+ console.log(` Note: rotating the password via 'share update --password' invalidates this link.`);
589
+ console.log("");
590
+ }
591
+
592
+ // ---------------------------------------------------------------------------
593
+ // delete
594
+ // ---------------------------------------------------------------------------
595
+
596
+ async function handleDelete(args) {
597
+ const flags = parseFlags(args);
598
+ const slug = flags._[0];
599
+ if (!slug) {
600
+ throw new Error("Usage: viveworker share delete <slug>");
601
+ }
602
+ if (!/^[A-Za-z0-9]+$/.test(slug)) {
603
+ throw new Error(`Invalid slug: ${slug}`);
604
+ }
605
+
606
+ const { apiKey, userId, shareUrl } = await resolveCredentials();
607
+
608
+ const res = await fetchWithTimeout(`${shareUrl}/api/share/${encodeURIComponent(slug)}`, {
609
+ method: "DELETE",
610
+ headers: {
611
+ "x-a2a-user": userId,
612
+ "x-a2a-key": apiKey,
613
+ },
614
+ }, 30_000);
615
+
616
+ const body = await readJson(res);
617
+ if (!res.ok || body.error) {
618
+ throw new Error(formatApiError("Delete", res.status, body));
619
+ }
620
+
621
+ if (flags.json) {
622
+ console.log(JSON.stringify(body, null, 2));
623
+ return;
624
+ }
625
+
626
+ console.log(`✅ Deleted ${slug}`);
627
+ }
628
+
629
+ function formatApiError(op, status, body) {
630
+ const code = body?.error || "";
631
+ switch (code) {
632
+ case "rate-limited":
633
+ return `${op} failed (${status}): rate limit — ${body.limit}/${Math.round((body.windowSec || 3600) / 60)}m, retry in ${body.retryAfterSec}s`;
634
+ case "quota-exceeded":
635
+ return `${op} failed (${status}): quota exceeded — used ${formatSize(body.currentBytes)} of ${formatSize(body.maxTotalBytes)}, file is ${formatSize(body.fileBytes)}`;
636
+ case "file-count-exceeded":
637
+ return `${op} failed (${status}): file count exceeded — ${body.current}/${body.max}. Delete something first.`;
638
+ case "file-too-large":
639
+ return `${op} failed (${status}): file too large (max ${formatSize(body.maxBytes)})`;
640
+ case "unsupported-extension":
641
+ return `${op} failed (${status}): unsupported file type. Accepted: ${(body.allowed || []).join(" / ") || "n/a"}`;
642
+ case "unsupported-content-type":
643
+ return `${op} failed (${status}): declared content-type ${body.declared || "?"} does not match ${body.expected || "the file extension"}`;
644
+ case "content-mismatch":
645
+ return `${op} failed (${status}): file body does not match its extension (kind=${body.kind || "?"})`;
646
+ case "expired-requires-expiresDays":
647
+ return `${op} failed (${status}): share is expired — pass --expires-days <1-${body.maxDays || 30}> to revive it`;
648
+ case "object-missing":
649
+ return `${op} failed (${status}): the R2 body is gone (90-day lifecycle reaped it). Re-upload instead.`;
650
+ case "invalid-password":
651
+ return `${op} failed (${status}): wrong password`;
652
+ case "not-password-protected":
653
+ return `${op} failed (${status}): share has no password — no link token needed, just share the URL directly`;
654
+ case "invalid-ttlHours":
655
+ return `${op} failed (${status}): --ttl-hours must be between 1 and ${body.maxHours || 168}`;
656
+ // x402 / paid-share error codes — thrown by the worker on upload,
657
+ // PATCH, or view. Keep the messages actionable; agents read these
658
+ // back to the user verbatim.
659
+ case "invalid-price":
660
+ return `${op} failed (${status}): --price must be a decimal with ≤6 fractional digits (e.g. \`0.10\`)`;
661
+ case "price-out-of-range":
662
+ return `${op} failed (${status}): price must be between $${formatUsdc(body.minAtomic || "10000")} and $${formatUsdc(body.maxAtomic || "1000000000")}`;
663
+ case "invalid-payTo":
664
+ return `${op} failed (${status}): --pay-to must be a 0x-prefixed 40-hex-char EVM address`;
665
+ case "price-payTo-both-required":
666
+ return `${op} failed (${status}): both --price and --pay-to must be supplied together`;
667
+ case "price-and-password-mutually-exclusive":
668
+ return `${op} failed (${status}): --price and --password cannot be combined on a single share`;
669
+ case "payTo-without-price":
670
+ return `${op} failed (${status}): cannot set --pay-to on a free share — use --price together with --pay-to to add the gate first`;
671
+ case "payTo-cannot-be-cleared-alone":
672
+ return `${op} failed (${status}): --no-price clears both price and payTo; clearing payTo alone is not supported`;
673
+ case "payment-network-not-configured":
674
+ return `${op} failed (${status}): the worker's X402_NETWORK var is not set to a supported chain (base / base-sepolia)`;
675
+ case "payment-required":
676
+ return `${op} failed (${status}): this share requires payment (x402). Use an x402-compatible client (e.g. \`x402-fetch\` on npm) to pay.`;
677
+ case "payment-verification-failed":
678
+ case "malformed-x-payment":
679
+ return `${op} failed (${status}): payment verification failed — the facilitator rejected the X-PAYMENT header`;
680
+ case "facilitator-unavailable":
681
+ return `${op} failed (${status}): x402 facilitator is unreachable — try again shortly`;
682
+ case "paid-shares-closed-beta": {
683
+ // Closed-beta gate on --price / --pay-to uploads and PATCHes while x402
684
+ // is pinned to testnet. The hint + network come from the worker so the
685
+ // message stays accurate even if we flip to mainnet.
686
+ const net = body?.network ? ` (network: ${body.network})` : "";
687
+ const hint = body?.hint ? `\n hint: ${body.hint}` : "";
688
+ return `${op} failed (${status}): paid shares are in closed beta${net}.${hint}`;
689
+ }
690
+ case "metrics-not-configured":
691
+ return `${op} failed (${status}): metrics endpoint is not configured on the worker (missing CF_ACCOUNT_ID / CF_API_TOKEN secrets). Ask the operator to set them.`;
692
+ default:
693
+ return `${op} failed (${status}): ${code || body?.statusText || "unknown error"}`;
694
+ }
695
+ }
696
+
697
+ // ---------------------------------------------------------------------------
698
+ // Helpers
699
+ // ---------------------------------------------------------------------------
700
+
701
+ async function resolveCredentials() {
702
+ let text;
703
+ try {
704
+ text = await fs.readFile(A2A_ENV_FILE, "utf8");
705
+ } catch {
706
+ throw new Error(
707
+ `Missing ${A2A_ENV_FILE}.\n` +
708
+ `Run 'viveworker a2a setup --user-id <id>' first to provision credentials.`
709
+ );
710
+ }
711
+
712
+ const apiKey = envValue(text, "A2A_API_KEY");
713
+ const userId = envValue(text, "A2A_RELAY_USER_ID");
714
+ if (!apiKey || !userId) {
715
+ throw new Error(
716
+ `A2A_API_KEY and/or A2A_RELAY_USER_ID missing from ${A2A_ENV_FILE}.\n` +
717
+ `Re-run 'viveworker a2a setup --user-id <id>'.`
718
+ );
719
+ }
720
+
721
+ const shareUrl = (
722
+ process.env.VIVEWORKER_SHARE_URL ||
723
+ envValue(text, "VIVEWORKER_SHARE_URL") ||
724
+ DEFAULT_SHARE_URL
725
+ ).replace(/\/$/u, "");
726
+
727
+ return { apiKey, userId, shareUrl };
728
+ }
729
+
730
+ function envValue(text, key) {
731
+ for (const line of text.split(/\r?\n/)) {
732
+ const trimmed = line.trim();
733
+ if (!trimmed || trimmed.startsWith("#")) continue;
734
+ const eq = trimmed.indexOf("=");
735
+ if (eq === -1) continue;
736
+ if (trimmed.slice(0, eq) === key) return trimmed.slice(eq + 1);
737
+ }
738
+ return "";
739
+ }
740
+
741
+ function parseFlags(args) {
742
+ const flags = { _: [] };
743
+ for (let i = 0; i < args.length; i++) {
744
+ const arg = args[i];
745
+ if (arg.startsWith("--")) {
746
+ const eqIdx = arg.indexOf("=");
747
+ if (eqIdx !== -1) {
748
+ flags[arg.slice(2, eqIdx)] = arg.slice(eqIdx + 1);
749
+ } else if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
750
+ flags[arg.slice(2)] = args[++i];
751
+ } else {
752
+ flags[arg.slice(2)] = true;
753
+ }
754
+ } else {
755
+ flags._.push(arg);
756
+ }
757
+ }
758
+ return flags;
759
+ }
760
+
761
+ async function fetchWithTimeout(url, options, timeoutMs) {
762
+ const controller = new AbortController();
763
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
764
+ try {
765
+ return await fetch(url, { ...options, signal: controller.signal });
766
+ } finally {
767
+ clearTimeout(timer);
768
+ }
769
+ }
770
+
771
+ async function readJson(res) {
772
+ const text = await res.text();
773
+ if (!text) return {};
774
+ try {
775
+ return JSON.parse(text);
776
+ } catch {
777
+ return { error: text.slice(0, 200) };
778
+ }
779
+ }
780
+
781
+ function formatSize(bytes) {
782
+ if (bytes < 1024) return `${bytes} B`;
783
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
784
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
785
+ }
786
+
787
+ // USDC uses 6-decimals. `atomic` is a stringified integer ("100000" = $0.10).
788
+ // Returns a human-facing decimal with at least two fractional digits and
789
+ // trailing zeros beyond that trimmed (so "1000000" → "1.00", "123456" →
790
+ // "0.123456", "12" → "0.000012"). Accepts either strings or bigints; falls
791
+ // back to "0.00" on anything unparseable so we never crash the CLI output
792
+ // on a malformed server response.
793
+ function formatUsdc(atomic) {
794
+ let n;
795
+ try {
796
+ n = BigInt(String(atomic ?? "0"));
797
+ } catch {
798
+ return "0.00";
799
+ }
800
+ if (n < 0n) n = -n;
801
+ const whole = n / 1_000_000n;
802
+ const frac = (n % 1_000_000n).toString().padStart(6, "0").replace(/0+$/u, "");
803
+ if (!frac) return `${whole}.00`;
804
+ return `${whole}.${frac.padEnd(2, "0")}`;
805
+ }
806
+
807
+ function formatRelative(ms) {
808
+ const sec = Math.floor(ms / 1000);
809
+ if (sec < 60) return `${sec}s ago`;
810
+ const min = Math.floor(sec / 60);
811
+ if (min < 60) return `${min}m ago`;
812
+ const hr = Math.floor(min / 60);
813
+ if (hr < 24) return `${hr}h ago`;
814
+ const day = Math.floor(hr / 24);
815
+ if (day < 30) return `${day}d ago`;
816
+ const mo = Math.floor(day / 30);
817
+ if (mo < 12) return `${mo}mo ago`;
818
+ return `${Math.floor(mo / 12)}y ago`;
819
+ }