drops-mcp 0.1.4 → 0.2.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.
Files changed (3) hide show
  1. package/drop.mjs +275 -15
  2. package/mcp.mjs +47 -0
  3. package/package.json +1 -1
package/drop.mjs CHANGED
@@ -111,6 +111,27 @@ function genPassword() {
111
111
  return `${ADJ[buf[0] % ADJ.length]}-${NOUN[buf[1] % NOUN.length]}-${10 + (buf[2] % 90)}`;
112
112
  }
113
113
 
114
+ function luhn(s) {
115
+ let sum = 0, alt = false;
116
+ for (let i = s.length - 1; i >= 0; i--) { let n = +s[i]; if (alt) { n *= 2; if (n > 9) n -= 9; } sum += n; alt = !alt; }
117
+ return s.length >= 13 && sum % 10 === 0;
118
+ }
119
+ // Client-side secret/PII scan run BEFORE encryption+upload. High-signal patterns only
120
+ // (the real risk for AI artifacts is an embedded credential); emails warn only in bulk.
121
+ function scanPII(text) {
122
+ const hits = [];
123
+ const add = (label, re) => { if (re.test(text)) hits.push(label); };
124
+ add("AWS access key", /\bAKIA[0-9A-Z]{16}\b/);
125
+ add("private key block", /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/);
126
+ add("API token/secret", /\b(?:sk-[A-Za-z0-9]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|vercel_blob_rw_[A-Za-z0-9_]{20,})\b/);
127
+ add("JWT", /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/);
128
+ const cc = (text.match(/\b(?:\d[ -]?){13,16}\b/g) || []).map((c) => c.replace(/[ -]/g, ""));
129
+ if (cc.some(luhn)) hits.push("possible card number");
130
+ const emails = [...new Set(text.match(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g) || [])];
131
+ if (emails.length >= 5) hits.push(`${emails.length} email addresses`);
132
+ return [...new Set(hits)];
133
+ }
134
+
114
135
  async function dataUri(path) {
115
136
  const ext = extname(path).toLowerCase();
116
137
  const mime = ext === ".svg" ? "image/svg+xml" : ext === ".png" ? "image/png"
@@ -299,16 +320,33 @@ async function upload(key, body, contentType, token) {
299
320
 
300
321
  // Managed (zero-setup) publish: POST the already-branded/encrypted bytes to the host's
301
322
  // managed endpoint. No token, no Vercel setup — the server assigns a `u/` slug.
302
- async function managedPublish(body, contentType) {
323
+ async function managedPublish(body, contentType, { burn } = {}) {
303
324
  const endpoint = `${ORIGIN}/api/publish`;
325
+ const headers = { "content-type": "application/octet-stream", "x-drop-content-type": contentType };
326
+ if (burn) headers["x-drop-burn"] = "1";
304
327
  let r;
305
328
  try {
306
- r = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/octet-stream", "x-drop-content-type": contentType }, body });
329
+ r = await fetch(endpoint, { method: "POST", headers, body });
307
330
  } catch (e) { die(`managed publish failed: ${e.message}`); }
308
331
  if (!r.ok) { let m; try { m = (await r.json()).error; } catch {} die(`managed publish failed: ${m || r.status}`); }
309
332
  return r.json();
310
333
  }
311
334
 
335
+ // Hosted (logged-in free tier) publish: same client-side branded/encrypted bytes, but
336
+ // authenticated with the API key so the drop lands persistently at <handle>/<slug>.
337
+ async function hostedPublish(body, contentType, slug, locked, emailGate) {
338
+ const key = getApiKey();
339
+ const headers = { "content-type": "application/octet-stream", "x-drop-content-type": contentType, authorization: `Bearer ${key}` };
340
+ if (slug) headers["x-drop-slug"] = slug;
341
+ if (locked) headers["x-drop-locked"] = "1";
342
+ if (emailGate) headers["x-drop-email-gate"] = String(emailGate).replace(/^@/, "").toLowerCase();
343
+ let r;
344
+ try { r = await fetch(`${ORIGIN}/api/publish`, { method: "POST", headers, body }); }
345
+ catch (e) { die(`hosted publish failed: ${e.message}`); }
346
+ if (!r.ok) { let m; try { m = (await r.json()).error; } catch {} die(`hosted publish failed: ${m || r.status}`); }
347
+ return r.json();
348
+ }
349
+
312
350
  // Import an npm dep, auto-installing into the skill dir on first use (gitignored).
313
351
  async function ensureDep(name) {
314
352
  try { return await import(name); }
@@ -324,12 +362,24 @@ async function ensureDep(name) {
324
362
  async function cmdDrop(file, opts) {
325
363
  if (!file) die("usage: drop <file> [-p password] [-s slug] [--page] [--no-lock]");
326
364
  if (!existsSync(file)) die(`no such file: ${file}`);
327
- const token = opts.managed ? null : getToken();
328
- if (!opts.managed && !token) die("BLOB_READ_WRITE_TOKEN not set (env or ~/.drop/.env). Run 'drop setup' first — or use --managed for the zero-setup tier.");
329
365
 
330
366
  const ext = extname(file).toLowerCase();
331
367
  const isMd = ext === ".md" || ext === ".markdown";
332
368
  const isHtml = ext === ".html" || ext === ".htm" || isMd;
369
+
370
+ // Tier selection: --managed wins. When you're logged in, single HTML/Markdown drops
371
+ // go to your hosted account (the PRD default for logged-in users); zip sites & arbitrary
372
+ // files need a Blob, so they fall through to self-host. A Blob token alone (not logged
373
+ // in) = self-host. Nothing configured → nudge.
374
+ const token = opts.managed ? null : getToken();
375
+ const apiKey = getApiKey();
376
+ const hostedEligible = isHtml && !opts.page && ext !== ".zip";
377
+ const mode = opts.managed ? "managed"
378
+ : (apiKey && hostedEligible) ? "hosted"
379
+ : token ? "selfhost"
380
+ : apiKey ? "hosted"
381
+ : null;
382
+ if (!mode) die("not set up yet.\n Free hosted: drop login\n Self-host: drop setup (needs a Vercel Blob token)\n One-off 24h: drop <file> --managed");
333
383
  const logoUri = await dataUri(join(BRAND, "logo-white.png"));
334
384
  const faviconUri = await dataUri(join(BRAND, "favicon.png"));
335
385
  const ogImage = `${ORIGIN}/_brand/og.png`; // optional; harmless if absent
@@ -337,7 +387,7 @@ async function cmdDrop(file, opts) {
337
387
  const expiresAt = parseExpiry(opts.expire);
338
388
  if (opts.expire && !expiresAt) die(`bad --expire value: ${opts.expire} (use 7d, 24h, 30m, 2w, or a date)`);
339
389
 
340
- if (opts.managed && (opts.page || !isHtml)) die("--managed supports HTML/markdown only. Self-host ('drop deploy') for files, --page, and zip sites.");
390
+ if ((mode === "managed" || mode === "hosted") && (opts.page || !isHtml)) die(`the ${mode} tier supports HTML/markdown only. Self-host ('drop deploy') for files, --page, and zip sites.`);
341
391
 
342
392
  const manifest = await loadManifest();
343
393
  const now = new Date().toISOString();
@@ -383,6 +433,18 @@ async function cmdDrop(file, opts) {
383
433
  const title = opts.title || basename(file, ext).replace(/[-_]+/g, " ");
384
434
  let html = await readFile(file, "utf8");
385
435
  if (isMd) html = await mdToHtml(html, title);
436
+
437
+ // PII / secret scan on the RAW content (before brand + encrypt). Warn by default
438
+ // (agent-friendly); --block-pii refuses; --no-pii-check silences. Skipped for
439
+ // password-locked drops only when explicitly told to (locking already protects them).
440
+ if (!opts.noPiiCheck) {
441
+ const pii = scanPII(html);
442
+ if (pii.length) {
443
+ const msg = `possible sensitive data in ${basename(file)}: ${pii.join(", ")}`;
444
+ if (opts.blockPii) die(`⚠ ${msg}\n refusing to publish (--block-pii). Remove it, or drop --no-pii-check to publish anyway.`);
445
+ if (!JSON_OUT) console.error(`\x1b[33m⚠ ${msg}\x1b[0m\n \x1b[2mpublishing anyway — use --block-pii to refuse, --no-pii-check to silence${opts.noLock ? ", and note this drop is PUBLIC (unlocked)" : ""}\x1b[0m`);
446
+ }
447
+ }
386
448
  html = await brandHtml(html, { title, url, logoUri, ogImage, expiresAt });
387
449
 
388
450
  let password = null, body = html;
@@ -394,11 +456,18 @@ async function cmdDrop(file, opts) {
394
456
  body = await encrypt(inPath, { password, title, logoUri, faviconUri, ogImage, url });
395
457
  await rm(tmp, { recursive: true, force: true });
396
458
  }
397
- if (opts.managed) {
398
- const m = await managedPublish(body, "text/html; charset=utf-8");
399
- manifest.unshift({ slug: m.slug, url: m.url, type: "html", locked: !opts.noLock, password, file: basename(file), size: stat, managed: true, createdAt: now });
459
+ if (mode === "managed") {
460
+ const m = await managedPublish(body, "text/html; charset=utf-8", { burn: opts.burn });
461
+ manifest.unshift({ slug: m.slug, url: m.url, type: "html", locked: !opts.noLock, password, file: basename(file), size: stat, managed: true, burn: !!opts.burn, createdAt: now });
462
+ await saveManifest(manifest);
463
+ report({ url: m.url, password, locked: !opts.noLock, extra: opts.burn ? "managed · burns on first view" : "managed · auto-expires in 24h" });
464
+ return;
465
+ }
466
+ if (mode === "hosted") {
467
+ const m = await hostedPublish(body, "text/html; charset=utf-8", slug, !opts.noLock, opts.emailGate);
468
+ manifest.unshift({ slug: m.slug, url: m.url, type: "html", locked: !opts.noLock, password, file: basename(file), size: stat, hosted: true, handle: m.handle, createdAt: now });
400
469
  await saveManifest(manifest);
401
- report({ url: m.url, password, locked: !opts.noLock, extra: "managed · auto-expires in 24h" });
470
+ report({ url: m.url, password, locked: !opts.noLock, extra: `hosted · ${m.handle}/${m.slug}${opts.emailGate ? ` · @${String(opts.emailGate).replace(/^@/, "")} only` : ""}` });
402
471
  return;
403
472
  }
404
473
  const res = await upload(slug, body, "text/html; charset=utf-8", token);
@@ -469,9 +538,38 @@ function report({ url, password, locked, extra }) {
469
538
  console.log("");
470
539
  }
471
540
 
541
+ // Logged-in hosted account → list/remove via the API (not the BYO Blob store).
542
+ const useHosted = () => getApiKey() && !getToken();
543
+
544
+ async function hostedList() {
545
+ const key = getApiKey();
546
+ let r; try { r = await fetch(`${ORIGIN}/api/sites`, { headers: { authorization: `Bearer ${key}` } }); } catch (e) { die(e.message); }
547
+ if (!r.ok) die("could not list drops (key invalid? run `drop login`)");
548
+ const { sites } = await r.json();
549
+ const local = await loadManifest();
550
+ const pw = new Map(); for (const d of local) if (d.password) pw.set(d.slug, d.password);
551
+ if (JSON_OUT) { console.log(JSON.stringify(sites.map((s) => ({ ...s, password: pw.get(s.slug) || null })))); return; }
552
+ if (!sites.length) return console.log("no drops yet.");
553
+ for (const s of sites) {
554
+ const known = pw.get(s.slug);
555
+ const lock = known ? `🔒 ${known}` : (s.locked ? "🔒 locked" : "public");
556
+ console.log(`${s.url}\n ${humanSize(s.size)} · ${lock} · ${String(s.created_at).slice(0, 10)}`);
557
+ }
558
+ }
559
+
560
+ async function hostedRm(slug) {
561
+ const key = getApiKey();
562
+ let r; try { r = await fetch(`${ORIGIN}/api/sites?slug=${encodeURIComponent(slug)}`, { method: "DELETE", headers: { authorization: `Bearer ${key}` } }); } catch (e) { die(e.message); }
563
+ if (!r.ok) { let m; try { m = (await r.json()).error; } catch {} die(m || "remove failed"); }
564
+ const m = await loadManifest(); const kept = m.filter((d) => d.slug !== slug); if (kept.length !== m.length) await saveManifest(kept);
565
+ if (JSON_OUT) { console.log(JSON.stringify({ removed: slug })); return; }
566
+ ok(`removed ${slug}`);
567
+ }
568
+
472
569
  async function cmdList() {
570
+ if (useHosted()) return hostedList();
473
571
  const token = getToken();
474
- if (!token) die("BLOB_READ_WRITE_TOKEN not set. Run: drop setup");
572
+ if (!token) die("BLOB_READ_WRITE_TOKEN not set. Run: drop setup (self-host) or drop login (hosted)");
475
573
  const { list } = await loadBlob();
476
574
  const { blobs } = await list({ token });
477
575
  // overlay local passwords (kept on this machine only) keyed by pathname/slug
@@ -498,8 +596,9 @@ async function cmdList() {
498
596
 
499
597
  async function cmdRm(slug) {
500
598
  if (!slug) die("usage: drop rm <slug>");
599
+ if (useHosted()) return hostedRm(slug);
501
600
  const token = getToken();
502
- if (!token) die("BLOB_READ_WRITE_TOKEN not set. Run: drop setup");
601
+ if (!token) die("BLOB_READ_WRITE_TOKEN not set. Run: drop setup (self-host) or drop login (hosted)");
503
602
  const { list, del } = await loadBlob();
504
603
  const { blobs } = await list({ token });
505
604
  // match the page/file key (slug), a sibling like <slug>.<ext>, or a whole site under <slug>/
@@ -572,9 +671,13 @@ async function cmdDeploy(opts) {
572
671
  await del(probe.url, { token }).catch(() => {});
573
672
  ok(`blob host: ${blobHost}`);
574
673
 
575
- // 3. patch middleware.js (BLOB const) + vercel.json (fallback rewrite) idempotently
674
+ // 3. patch middleware.js fallback host + vercel.json (fallback rewrite) idempotently.
675
+ // (Middleware derives the host from BLOB_READ_WRITE_TOKEN at runtime, so this only
676
+ // pins the fallback for explicit BYO-host setups.)
576
677
  let mw = await readFile(mwPath, "utf8");
577
- const newMw = mw.replace(/const BLOB = "https:\/\/[^"]+";/, `const BLOB = "https://${blobHost}";`);
678
+ const newMw = mw
679
+ .replace(/const DEFAULT_BLOB_HOST = "[^"]+";/, `const DEFAULT_BLOB_HOST = "${blobHost}";`)
680
+ .replace(/const BLOB = "https:\/\/[^"]+";/, `const BLOB = "https://${blobHost}";`);
578
681
  let vj = await readFile(vjPath, "utf8");
579
682
  const newVj = vj.replace(/https:\/\/[a-z0-9]+\.public\.blob\.vercel-storage\.com/g, `https://${blobHost}`);
580
683
  const mwChanged = newMw !== mw, vjChanged = newVj !== vj;
@@ -698,6 +801,134 @@ async function pullTokenFromVercel() {
698
801
  finally { await rm(tmp, { recursive: true, force: true }); }
699
802
  }
700
803
 
804
+ // ---------- hosted accounts (drop login / whoami / logout) ----------
805
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
806
+ function getApiKey() {
807
+ if (process.env.DROP_API_KEY) return process.env.DROP_API_KEY;
808
+ try { return JSON.parse(readFileSync(CONFIG_FILE, "utf8")).apiKey || null; } catch { return null; }
809
+ }
810
+ async function saveApiKey(key) {
811
+ let cfg = {}; try { cfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8")); } catch {}
812
+ cfg.apiKey = key;
813
+ await mkdir(DROP_HOME, { recursive: true });
814
+ await writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n");
815
+ }
816
+ function openBrowser(url) {
817
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
818
+ try { spawnSync(cmd, [url], { stdio: "ignore", shell: process.platform === "win32" }); } catch {}
819
+ }
820
+ async function cmdLogin(opts) {
821
+ if (opts.token && opts.token.startsWith("drop_")) { await saveApiKey(opts.token); ok("API key saved"); return cmdWhoami(); }
822
+ let start;
823
+ try { start = await (await fetch(`${ORIGIN}/api/auth/device/start`, { method: "POST" })).json(); }
824
+ catch (e) { die(`could not reach ${ORIGIN}: ${e.message}`); }
825
+ if (!start?.code) die("login unavailable (hosted tier not configured yet)");
826
+ console.log(`\n opening ${start.verify_url}`);
827
+ console.log(` \x1b[2mif it doesn't open, paste that URL into your browser\x1b[0m\n`);
828
+ openBrowser(start.verify_url);
829
+ const deadline = Date.now() + 5 * 60 * 1000;
830
+ process.stdout.write(" waiting for you to approve");
831
+ while (Date.now() < deadline) {
832
+ await sleep((start.poll_interval || 2) * 1000);
833
+ process.stdout.write(".");
834
+ let p; try { p = await (await fetch(`${ORIGIN}/api/auth/device/poll?code=${start.code}`)).json(); } catch { continue; }
835
+ if (p.status === "approved" && p.api_key) { console.log(""); await saveApiKey(p.api_key); ok(`logged in — key saved to ${CONFIG_FILE}`); return cmdWhoami(); }
836
+ if (p.status === "denied") { console.log(""); die("authorization denied"); }
837
+ if (p.status === "expired") { console.log(""); die("code expired — run `drop login` again"); }
838
+ }
839
+ console.log(""); die("login timed out");
840
+ }
841
+ async function cmdWhoami() {
842
+ const key = getApiKey(); if (!key) die("not logged in. Run: drop login");
843
+ let r; try { r = await fetch(`${ORIGIN}/api/me`, { headers: { authorization: `Bearer ${key}` } }); } catch (e) { die(e.message); }
844
+ if (!r.ok) die("not authenticated (key invalid or revoked). Run: drop login");
845
+ const d = await r.json();
846
+ if (JSON_OUT) { console.log(JSON.stringify(d)); return; }
847
+ ok(`${d.email || "logged in"}${d.handle ? ` · handle: ${d.handle}` : ""}`);
848
+ }
849
+ async function cmdLogout() {
850
+ let cfg = {}; try { cfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8")); } catch {}
851
+ delete cfg.apiKey;
852
+ await mkdir(DROP_HOME, { recursive: true });
853
+ await writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n");
854
+ ok("logged out");
855
+ }
856
+
857
+ // ---------- hosted-drop management (Phase 2: set-* / claim / share / migrate) ----------
858
+ // Thin authenticated calls against the hosted API. Each needs `drop login` first.
859
+ async function apiCall(path, init = {}) {
860
+ const key = getApiKey(); if (!key) die("not logged in — run: drop login");
861
+ let r; try { r = await fetch(`${ORIGIN}${path}`, { ...init, headers: { authorization: `Bearer ${key}`, ...(init.headers || {}) } }); }
862
+ catch (e) { die(`request failed: ${e.message}`); }
863
+ let d = {}; try { d = await r.json(); } catch {}
864
+ if (!r.ok) die(d.error || `request failed (${r.status})`);
865
+ return d;
866
+ }
867
+ const patchSite = (slug, patch) => apiCall(`/api/sites?slug=${encodeURIComponent(slug)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(patch) });
868
+
869
+ async function cmdSetExpiry(slug, when) {
870
+ if (!slug || !when) die("usage: drop set-expiry <slug> <7d|24h|2w|date|off>");
871
+ const iso = when === "off" ? null : (() => { const at = parseExpiry(when); if (!at) die(`bad expiry: ${when}`); return new Date(at).toISOString(); })();
872
+ await patchSite(slug, { expires_at: iso });
873
+ if (JSON_OUT) return console.log(JSON.stringify({ slug, expires_at: iso }));
874
+ ok(iso ? `expiry set on ${slug} → ${iso.slice(0, 16).replace("T", " ")}` : `expiry cleared on ${slug}`);
875
+ }
876
+ async function cmdSetEmailGate(slug, domain) {
877
+ if (!slug) die("usage: drop set-email-gate <slug> <domain|off> (e.g. acme.com)");
878
+ const val = !domain || domain === "off" ? null : domain.replace(/^@/, "").toLowerCase();
879
+ await patchSite(slug, { email_gate: val });
880
+ if (JSON_OUT) return console.log(JSON.stringify({ slug, email_gate: val }));
881
+ ok(val ? `email-gate on ${slug} → only @${val} may open it` : `email-gate cleared on ${slug}`);
882
+ }
883
+ async function cmdSetFeedback(slug, state) {
884
+ if (!slug) die("usage: drop set-feedback <slug> [on|off]");
885
+ const on = state !== "off";
886
+ await patchSite(slug, { feedback: on });
887
+ if (JSON_OUT) return console.log(JSON.stringify({ slug, feedback: on }));
888
+ ok(`feedback ${on ? "enabled" : "disabled"} on ${slug}`);
889
+ }
890
+ async function cmdSetPassword(slug, file) {
891
+ // Zero-knowledge: changing the password re-encrypts, which needs the source again.
892
+ if (!slug || !file) die("usage: drop set-password <slug> <file.html> [-p <password>]\n (zero-knowledge: a password change re-encrypts, so the source file is required)");
893
+ if (!existsSync(file)) die(`no such file: ${file}`);
894
+ return cmdDrop(file, { ...opts, slug, noLock: false, password: opts.password || genPassword() });
895
+ }
896
+ async function cmdClaim(target) {
897
+ if (!target) die("usage: drop claim <url|slug> (move an anonymous /u/ drop into your account)");
898
+ if (!getApiKey()) die("not logged in — run: drop login");
899
+ const url = /^https?:/.test(target) ? target : `${ORIGIN}/u/${target.replace(/^u\//, "")}`;
900
+ let body; try { const r = await fetch(url); if (!r.ok) die(`could not fetch ${url} (${r.status})`); body = Buffer.from(await r.arrayBuffer()); }
901
+ catch (e) { die(`could not fetch ${url}: ${e.message}`); }
902
+ const slug = opts.slug || ("claimed-" + rand(4));
903
+ const m = await hostedPublish(body, "text/html; charset=utf-8", slug, false);
904
+ if (JSON_OUT) return console.log(JSON.stringify(m));
905
+ report({ url: m.url, password: null, locked: false, extra: `claimed → ${m.handle}/${m.slug}` });
906
+ }
907
+ async function cmdShare(slug) {
908
+ if (!slug) die("usage: drop share <slug> [--revoke]");
909
+ const d = await apiCall(`/api/share`, { method: opts.revoke ? "DELETE" : "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ slug }) });
910
+ if (JSON_OUT) return console.log(JSON.stringify(d));
911
+ if (opts.revoke) return ok(`share token revoked for ${slug}`);
912
+ ok(`revocable share link → ${d.url}\n revoke anytime: drop share ${slug} --revoke`);
913
+ }
914
+ async function cmdMigrate() {
915
+ // Re-home your hosted drops onto your own Vercel Blob, then rewrite config to self-host.
916
+ const sites = (await apiCall("/api/sites")).sites || [];
917
+ const token = getToken();
918
+ if (opts.dryRun || !token) {
919
+ const tgt = token ? "your Vercel Blob" : "(no blob token yet — run `drop setup` first)";
920
+ if (JSON_OUT) return console.log(JSON.stringify({ dryRun: true, count: sites.length, target: tgt }));
921
+ return console.log(`drop migrate (dry-run): would re-upload ${sites.length} hosted drop(s) to ${tgt}, then rewrite ~/.drop/config.json to self-host on your own domain.`);
922
+ }
923
+ let n = 0;
924
+ for (const s of sites) {
925
+ try { const body = Buffer.from(await (await fetch(s.url)).arrayBuffer()); await upload(`${s.slug}`, body, s.content_type || "text/html; charset=utf-8", token); n++; }
926
+ catch (e) { console.error(` skip ${s.slug}: ${e.message}`); }
927
+ }
928
+ if (JSON_OUT) return console.log(JSON.stringify({ migrated: n, of: sites.length }));
929
+ ok(`migrated ${n}/${sites.length} drops to your own blob. Run 'drop deploy' to finish self-hosting.`);
930
+ }
931
+
701
932
  // ---------- arg parsing ----------
702
933
  const { values, positionals } = parseArgs({
703
934
  allowPositionals: true,
@@ -717,12 +948,18 @@ const { values, positionals } = parseArgs({
717
948
  "dry-run": { type: "boolean" },
718
949
  "no-deploy": { type: "boolean" },
719
950
  managed: { type: "boolean" },
951
+ burn: { type: "boolean" },
952
+ "email-gate": { type: "string" },
953
+ revoke: { type: "boolean" },
954
+ "no-pii-check": { type: "boolean" },
955
+ "block-pii": { type: "boolean" },
956
+ help: { type: "boolean", short: "h" },
720
957
  },
721
958
  });
722
959
  const JSON_OUT = !!values.json;
723
960
 
724
961
  const [cmd, ...rest] = positionals;
725
- const opts = { password: values.password, slug: values.slug, title: values.title, page: values.page, noLock: values["no-lock"], token: values.token, domain: values.domain, blobHost: values["blob-host"], project: values.project, org: values.org, expire: values.expire, dryRun: values["dry-run"], noDeploy: values["no-deploy"], managed: values.managed };
962
+ const opts = { password: values.password, slug: values.slug, title: values.title, page: values.page, noLock: values["no-lock"], token: values.token, domain: values.domain, blobHost: values["blob-host"], project: values.project, org: values.org, expire: values.expire, dryRun: values["dry-run"], noDeploy: values["no-deploy"], managed: values.managed, burn: values.burn, emailGate: values["email-gate"], revoke: values.revoke, noPiiCheck: values["no-pii-check"], blockPii: values["block-pii"] };
726
963
 
727
964
  try {
728
965
  if (cmd === "init") await cmdInit(opts);
@@ -731,7 +968,17 @@ try {
731
968
  else if (cmd === "rm") await cmdRm(rest[0]);
732
969
  else if (cmd === "gc") await cmdGc();
733
970
  else if (cmd === "deploy") await cmdDeploy(opts);
734
- else if (cmd === "help" || cmd === "--help" || !cmd) {
971
+ else if (cmd === "login") await cmdLogin(opts);
972
+ else if (cmd === "logout") await cmdLogout();
973
+ else if (cmd === "whoami") await cmdWhoami();
974
+ else if (cmd === "claim") await cmdClaim(rest[0]);
975
+ else if (cmd === "share") await cmdShare(rest[0]);
976
+ else if (cmd === "migrate") await cmdMigrate();
977
+ else if (cmd === "set-expiry") await cmdSetExpiry(rest[0], rest[1]);
978
+ else if (cmd === "set-email-gate") await cmdSetEmailGate(rest[0], rest[1]);
979
+ else if (cmd === "set-feedback") await cmdSetFeedback(rest[0], rest[1]);
980
+ else if (cmd === "set-password") await cmdSetPassword(rest[0], rest[1]);
981
+ else if (values.help || cmd === "help" || cmd === "--help" || !cmd) {
735
982
  console.log(`drop — branded password-protected sharing on ${DOMAIN}
736
983
 
737
984
  drop <file.html|.md> brand + lock (auto password) + upload → clean URL
@@ -739,6 +986,9 @@ try {
739
986
  drop <file> -p secret use your own password
740
987
  drop <file> --no-lock brand only, no password (renders for anyone)
741
988
  drop <file> --expire 7d auto-expire (7d/24h/30m/2w/date); enforce with 'drop gc'
989
+ drop <file> --burn burn-after-read: the drop self-destructs on first view
990
+ drop <file> --email-gate co.com only viewers with that email domain may open it
991
+ drop <file> --block-pii refuse to publish if a secret/credential is detected
742
992
  drop <file> --page branded download page wrapping the file
743
993
  drop <file> --page -p secret password-protect the download page
744
994
  drop site.zip multi-file static site → /slug/ (public)
@@ -748,6 +998,16 @@ try {
748
998
  drop rm <slug> delete a drop
749
999
  drop gc delete drops whose --expire has passed (cron-friendly)
750
1000
  drop init --domain ... point drop at your own domain + Vercel Blob (BYO)
1001
+ drop login sign in (magic-link) to host on drops + persist your drops
1002
+ drop whoami show your hosted account (email + handle)
1003
+ drop logout clear your hosted account (forget the API key)
1004
+ drop claim <url|slug> move an anonymous /u/ drop into your account
1005
+ drop share <slug> [--revoke] mint (or revoke) a revocable guest share link
1006
+ drop migrate [--dry-run] re-home your hosted drops to your own blob (self-host)
1007
+ drop set-expiry <slug> <when> change a hosted drop's expiry (7d/24h/date/off)
1008
+ drop set-email-gate <slug> <domain> restrict a hosted drop to one email domain
1009
+ drop set-password <slug> <file> re-encrypt a hosted drop with a new password
1010
+ drop set-feedback <slug> [on|off] toggle the feedback widget on a hosted drop
751
1011
  drop setup [--token <tok>] provision a machine (deps + blob token)
752
1012
  drop deploy [--domain ...] wire backend (blob host → middleware/vercel.json) + deploy`);
753
1013
  } else {
package/mcp.mjs CHANGED
@@ -141,5 +141,52 @@ server.tool(
141
141
  }
142
142
  );
143
143
 
144
+ server.tool(
145
+ "set_password",
146
+ "Change the password gate on a hosted drop. Zero-knowledge means re-keying re-encrypts, so re-supply the HTML; returns the new URL + password.",
147
+ {
148
+ slug: z.string().describe("The slug to re-key."),
149
+ html: z.string().describe("The drop's HTML (required — content is re-encrypted under the new password)."),
150
+ password: z.string().optional().describe("New password (auto-generated if omitted)."),
151
+ },
152
+ async ({ slug, html, password }) => {
153
+ try {
154
+ const args = ["-s", slug];
155
+ if (password) args.push("-p", password);
156
+ return textResult(await withTempFile(slug + ".html", html, (p) => runDrop([p, ...args])));
157
+ } catch (e) { return errResult(e); }
158
+ }
159
+ );
160
+
161
+ server.tool(
162
+ "set_expiry",
163
+ "Set (or clear) the auto-expiry on a hosted drop. The host's gc cron deletes it after the deadline.",
164
+ { slug: z.string(), expire: z.string().describe("'7d' | '24h' | '2w' | a date | 'off' to clear.") },
165
+ async ({ slug, expire }) => {
166
+ try { return textResult(await runDrop(["set-expiry", slug, expire])); }
167
+ catch (e) { return errResult(e); }
168
+ }
169
+ );
170
+
171
+ server.tool(
172
+ "set_email_gate",
173
+ "Restrict a hosted drop so only viewers with a given email domain can open it (e.g. 'acme.com'); pass 'off' to remove.",
174
+ { slug: z.string(), domain: z.string().describe("Allowed email domain, or 'off'.") },
175
+ async ({ slug, domain }) => {
176
+ try { return textResult(await runDrop(["set-email-gate", slug, domain])); }
177
+ catch (e) { return errResult(e); }
178
+ }
179
+ );
180
+
181
+ server.tool(
182
+ "set_feedback",
183
+ "Toggle the viewer feedback widget on a hosted drop (collect reactions/comments from recipients).",
184
+ { slug: z.string(), enabled: z.boolean().optional().describe("true to enable (default), false to disable.") },
185
+ async ({ slug, enabled }) => {
186
+ try { return textResult(await runDrop(["set-feedback", slug, enabled === false ? "off" : "on"])); }
187
+ catch (e) { return errResult(e); }
188
+ }
189
+ );
190
+
144
191
  const transport = new StdioServerTransport();
145
192
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drops-mcp",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "description": "Open-source artifact sharing — publish HTML/Markdown/files as branded, password-protected, zero-knowledge links on your own domain, from any AI agent. CLI + MCP server. The open-source, self-hosted Stacktree alternative.",
5
5
  "type": "module",
6
6
  "bin": {