drops-mcp 0.1.4 → 0.2.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/drop.mjs +267 -14
- package/mcp.mjs +47 -0
- 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
|
|
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,8 +362,13 @@ 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}`);
|
|
365
|
+
|
|
366
|
+
// Tier selection: explicit --managed wins; then self-host if a Blob token is set
|
|
367
|
+
// (you brought your own infra); then hosted if you're logged in; else nudge.
|
|
327
368
|
const token = opts.managed ? null : getToken();
|
|
328
|
-
|
|
369
|
+
const apiKey = getApiKey();
|
|
370
|
+
const mode = opts.managed ? "managed" : token ? "selfhost" : apiKey ? "hosted" : null;
|
|
371
|
+
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");
|
|
329
372
|
|
|
330
373
|
const ext = extname(file).toLowerCase();
|
|
331
374
|
const isMd = ext === ".md" || ext === ".markdown";
|
|
@@ -337,7 +380,7 @@ async function cmdDrop(file, opts) {
|
|
|
337
380
|
const expiresAt = parseExpiry(opts.expire);
|
|
338
381
|
if (opts.expire && !expiresAt) die(`bad --expire value: ${opts.expire} (use 7d, 24h, 30m, 2w, or a date)`);
|
|
339
382
|
|
|
340
|
-
if (
|
|
383
|
+
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
384
|
|
|
342
385
|
const manifest = await loadManifest();
|
|
343
386
|
const now = new Date().toISOString();
|
|
@@ -383,6 +426,18 @@ async function cmdDrop(file, opts) {
|
|
|
383
426
|
const title = opts.title || basename(file, ext).replace(/[-_]+/g, " ");
|
|
384
427
|
let html = await readFile(file, "utf8");
|
|
385
428
|
if (isMd) html = await mdToHtml(html, title);
|
|
429
|
+
|
|
430
|
+
// PII / secret scan on the RAW content (before brand + encrypt). Warn by default
|
|
431
|
+
// (agent-friendly); --block-pii refuses; --no-pii-check silences. Skipped for
|
|
432
|
+
// password-locked drops only when explicitly told to (locking already protects them).
|
|
433
|
+
if (!opts.noPiiCheck) {
|
|
434
|
+
const pii = scanPII(html);
|
|
435
|
+
if (pii.length) {
|
|
436
|
+
const msg = `possible sensitive data in ${basename(file)}: ${pii.join(", ")}`;
|
|
437
|
+
if (opts.blockPii) die(`⚠ ${msg}\n refusing to publish (--block-pii). Remove it, or drop --no-pii-check to publish anyway.`);
|
|
438
|
+
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`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
386
441
|
html = await brandHtml(html, { title, url, logoUri, ogImage, expiresAt });
|
|
387
442
|
|
|
388
443
|
let password = null, body = html;
|
|
@@ -394,11 +449,18 @@ async function cmdDrop(file, opts) {
|
|
|
394
449
|
body = await encrypt(inPath, { password, title, logoUri, faviconUri, ogImage, url });
|
|
395
450
|
await rm(tmp, { recursive: true, force: true });
|
|
396
451
|
}
|
|
397
|
-
if (
|
|
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 });
|
|
452
|
+
if (mode === "managed") {
|
|
453
|
+
const m = await managedPublish(body, "text/html; charset=utf-8", { burn: opts.burn });
|
|
454
|
+
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 });
|
|
455
|
+
await saveManifest(manifest);
|
|
456
|
+
report({ url: m.url, password, locked: !opts.noLock, extra: opts.burn ? "managed · burns on first view" : "managed · auto-expires in 24h" });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (mode === "hosted") {
|
|
460
|
+
const m = await hostedPublish(body, "text/html; charset=utf-8", slug, !opts.noLock, opts.emailGate);
|
|
461
|
+
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
462
|
await saveManifest(manifest);
|
|
401
|
-
report({ url: m.url, password, locked: !opts.noLock, extra:
|
|
463
|
+
report({ url: m.url, password, locked: !opts.noLock, extra: `hosted · ${m.handle}/${m.slug}${opts.emailGate ? ` · @${String(opts.emailGate).replace(/^@/, "")} only` : ""}` });
|
|
402
464
|
return;
|
|
403
465
|
}
|
|
404
466
|
const res = await upload(slug, body, "text/html; charset=utf-8", token);
|
|
@@ -469,9 +531,38 @@ function report({ url, password, locked, extra }) {
|
|
|
469
531
|
console.log("");
|
|
470
532
|
}
|
|
471
533
|
|
|
534
|
+
// Logged-in hosted account → list/remove via the API (not the BYO Blob store).
|
|
535
|
+
const useHosted = () => getApiKey() && !getToken();
|
|
536
|
+
|
|
537
|
+
async function hostedList() {
|
|
538
|
+
const key = getApiKey();
|
|
539
|
+
let r; try { r = await fetch(`${ORIGIN}/api/sites`, { headers: { authorization: `Bearer ${key}` } }); } catch (e) { die(e.message); }
|
|
540
|
+
if (!r.ok) die("could not list drops (key invalid? run `drop login`)");
|
|
541
|
+
const { sites } = await r.json();
|
|
542
|
+
const local = await loadManifest();
|
|
543
|
+
const pw = new Map(); for (const d of local) if (d.password) pw.set(d.slug, d.password);
|
|
544
|
+
if (JSON_OUT) { console.log(JSON.stringify(sites.map((s) => ({ ...s, password: pw.get(s.slug) || null })))); return; }
|
|
545
|
+
if (!sites.length) return console.log("no drops yet.");
|
|
546
|
+
for (const s of sites) {
|
|
547
|
+
const known = pw.get(s.slug);
|
|
548
|
+
const lock = known ? `🔒 ${known}` : (s.locked ? "🔒 locked" : "public");
|
|
549
|
+
console.log(`${s.url}\n ${humanSize(s.size)} · ${lock} · ${String(s.created_at).slice(0, 10)}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function hostedRm(slug) {
|
|
554
|
+
const key = getApiKey();
|
|
555
|
+
let r; try { r = await fetch(`${ORIGIN}/api/sites?slug=${encodeURIComponent(slug)}`, { method: "DELETE", headers: { authorization: `Bearer ${key}` } }); } catch (e) { die(e.message); }
|
|
556
|
+
if (!r.ok) { let m; try { m = (await r.json()).error; } catch {} die(m || "remove failed"); }
|
|
557
|
+
const m = await loadManifest(); const kept = m.filter((d) => d.slug !== slug); if (kept.length !== m.length) await saveManifest(kept);
|
|
558
|
+
if (JSON_OUT) { console.log(JSON.stringify({ removed: slug })); return; }
|
|
559
|
+
ok(`removed ${slug}`);
|
|
560
|
+
}
|
|
561
|
+
|
|
472
562
|
async function cmdList() {
|
|
563
|
+
if (useHosted()) return hostedList();
|
|
473
564
|
const token = getToken();
|
|
474
|
-
if (!token) die("BLOB_READ_WRITE_TOKEN not set. Run: drop setup");
|
|
565
|
+
if (!token) die("BLOB_READ_WRITE_TOKEN not set. Run: drop setup (self-host) or drop login (hosted)");
|
|
475
566
|
const { list } = await loadBlob();
|
|
476
567
|
const { blobs } = await list({ token });
|
|
477
568
|
// overlay local passwords (kept on this machine only) keyed by pathname/slug
|
|
@@ -498,8 +589,9 @@ async function cmdList() {
|
|
|
498
589
|
|
|
499
590
|
async function cmdRm(slug) {
|
|
500
591
|
if (!slug) die("usage: drop rm <slug>");
|
|
592
|
+
if (useHosted()) return hostedRm(slug);
|
|
501
593
|
const token = getToken();
|
|
502
|
-
if (!token) die("BLOB_READ_WRITE_TOKEN not set. Run: drop setup");
|
|
594
|
+
if (!token) die("BLOB_READ_WRITE_TOKEN not set. Run: drop setup (self-host) or drop login (hosted)");
|
|
503
595
|
const { list, del } = await loadBlob();
|
|
504
596
|
const { blobs } = await list({ token });
|
|
505
597
|
// match the page/file key (slug), a sibling like <slug>.<ext>, or a whole site under <slug>/
|
|
@@ -572,9 +664,13 @@ async function cmdDeploy(opts) {
|
|
|
572
664
|
await del(probe.url, { token }).catch(() => {});
|
|
573
665
|
ok(`blob host: ${blobHost}`);
|
|
574
666
|
|
|
575
|
-
// 3. patch middleware.js
|
|
667
|
+
// 3. patch middleware.js fallback host + vercel.json (fallback rewrite) idempotently.
|
|
668
|
+
// (Middleware derives the host from BLOB_READ_WRITE_TOKEN at runtime, so this only
|
|
669
|
+
// pins the fallback for explicit BYO-host setups.)
|
|
576
670
|
let mw = await readFile(mwPath, "utf8");
|
|
577
|
-
const newMw = mw
|
|
671
|
+
const newMw = mw
|
|
672
|
+
.replace(/const DEFAULT_BLOB_HOST = "[^"]+";/, `const DEFAULT_BLOB_HOST = "${blobHost}";`)
|
|
673
|
+
.replace(/const BLOB = "https:\/\/[^"]+";/, `const BLOB = "https://${blobHost}";`);
|
|
578
674
|
let vj = await readFile(vjPath, "utf8");
|
|
579
675
|
const newVj = vj.replace(/https:\/\/[a-z0-9]+\.public\.blob\.vercel-storage\.com/g, `https://${blobHost}`);
|
|
580
676
|
const mwChanged = newMw !== mw, vjChanged = newVj !== vj;
|
|
@@ -698,6 +794,134 @@ async function pullTokenFromVercel() {
|
|
|
698
794
|
finally { await rm(tmp, { recursive: true, force: true }); }
|
|
699
795
|
}
|
|
700
796
|
|
|
797
|
+
// ---------- hosted accounts (drop login / whoami / logout) ----------
|
|
798
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
799
|
+
function getApiKey() {
|
|
800
|
+
if (process.env.DROP_API_KEY) return process.env.DROP_API_KEY;
|
|
801
|
+
try { return JSON.parse(readFileSync(CONFIG_FILE, "utf8")).apiKey || null; } catch { return null; }
|
|
802
|
+
}
|
|
803
|
+
async function saveApiKey(key) {
|
|
804
|
+
let cfg = {}; try { cfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8")); } catch {}
|
|
805
|
+
cfg.apiKey = key;
|
|
806
|
+
await mkdir(DROP_HOME, { recursive: true });
|
|
807
|
+
await writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n");
|
|
808
|
+
}
|
|
809
|
+
function openBrowser(url) {
|
|
810
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
811
|
+
try { spawnSync(cmd, [url], { stdio: "ignore", shell: process.platform === "win32" }); } catch {}
|
|
812
|
+
}
|
|
813
|
+
async function cmdLogin(opts) {
|
|
814
|
+
if (opts.token && opts.token.startsWith("drop_")) { await saveApiKey(opts.token); ok("API key saved"); return cmdWhoami(); }
|
|
815
|
+
let start;
|
|
816
|
+
try { start = await (await fetch(`${ORIGIN}/api/auth/device/start`, { method: "POST" })).json(); }
|
|
817
|
+
catch (e) { die(`could not reach ${ORIGIN}: ${e.message}`); }
|
|
818
|
+
if (!start?.code) die("login unavailable (hosted tier not configured yet)");
|
|
819
|
+
console.log(`\n opening ${start.verify_url}`);
|
|
820
|
+
console.log(` \x1b[2mif it doesn't open, paste that URL into your browser\x1b[0m\n`);
|
|
821
|
+
openBrowser(start.verify_url);
|
|
822
|
+
const deadline = Date.now() + 5 * 60 * 1000;
|
|
823
|
+
process.stdout.write(" waiting for you to approve");
|
|
824
|
+
while (Date.now() < deadline) {
|
|
825
|
+
await sleep((start.poll_interval || 2) * 1000);
|
|
826
|
+
process.stdout.write(".");
|
|
827
|
+
let p; try { p = await (await fetch(`${ORIGIN}/api/auth/device/poll?code=${start.code}`)).json(); } catch { continue; }
|
|
828
|
+
if (p.status === "approved" && p.api_key) { console.log(""); await saveApiKey(p.api_key); ok(`logged in — key saved to ${CONFIG_FILE}`); return cmdWhoami(); }
|
|
829
|
+
if (p.status === "denied") { console.log(""); die("authorization denied"); }
|
|
830
|
+
if (p.status === "expired") { console.log(""); die("code expired — run `drop login` again"); }
|
|
831
|
+
}
|
|
832
|
+
console.log(""); die("login timed out");
|
|
833
|
+
}
|
|
834
|
+
async function cmdWhoami() {
|
|
835
|
+
const key = getApiKey(); if (!key) die("not logged in. Run: drop login");
|
|
836
|
+
let r; try { r = await fetch(`${ORIGIN}/api/me`, { headers: { authorization: `Bearer ${key}` } }); } catch (e) { die(e.message); }
|
|
837
|
+
if (!r.ok) die("not authenticated (key invalid or revoked). Run: drop login");
|
|
838
|
+
const d = await r.json();
|
|
839
|
+
if (JSON_OUT) { console.log(JSON.stringify(d)); return; }
|
|
840
|
+
ok(`${d.email || "logged in"}${d.handle ? ` · handle: ${d.handle}` : ""}`);
|
|
841
|
+
}
|
|
842
|
+
async function cmdLogout() {
|
|
843
|
+
let cfg = {}; try { cfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8")); } catch {}
|
|
844
|
+
delete cfg.apiKey;
|
|
845
|
+
await mkdir(DROP_HOME, { recursive: true });
|
|
846
|
+
await writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n");
|
|
847
|
+
ok("logged out");
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// ---------- hosted-drop management (Phase 2: set-* / claim / share / migrate) ----------
|
|
851
|
+
// Thin authenticated calls against the hosted API. Each needs `drop login` first.
|
|
852
|
+
async function apiCall(path, init = {}) {
|
|
853
|
+
const key = getApiKey(); if (!key) die("not logged in — run: drop login");
|
|
854
|
+
let r; try { r = await fetch(`${ORIGIN}${path}`, { ...init, headers: { authorization: `Bearer ${key}`, ...(init.headers || {}) } }); }
|
|
855
|
+
catch (e) { die(`request failed: ${e.message}`); }
|
|
856
|
+
let d = {}; try { d = await r.json(); } catch {}
|
|
857
|
+
if (!r.ok) die(d.error || `request failed (${r.status})`);
|
|
858
|
+
return d;
|
|
859
|
+
}
|
|
860
|
+
const patchSite = (slug, patch) => apiCall(`/api/sites?slug=${encodeURIComponent(slug)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(patch) });
|
|
861
|
+
|
|
862
|
+
async function cmdSetExpiry(slug, when) {
|
|
863
|
+
if (!slug || !when) die("usage: drop set-expiry <slug> <7d|24h|2w|date|off>");
|
|
864
|
+
const iso = when === "off" ? null : (() => { const at = parseExpiry(when); if (!at) die(`bad expiry: ${when}`); return new Date(at).toISOString(); })();
|
|
865
|
+
await patchSite(slug, { expires_at: iso });
|
|
866
|
+
if (JSON_OUT) return console.log(JSON.stringify({ slug, expires_at: iso }));
|
|
867
|
+
ok(iso ? `expiry set on ${slug} → ${iso.slice(0, 16).replace("T", " ")}` : `expiry cleared on ${slug}`);
|
|
868
|
+
}
|
|
869
|
+
async function cmdSetEmailGate(slug, domain) {
|
|
870
|
+
if (!slug) die("usage: drop set-email-gate <slug> <domain|off> (e.g. acme.com)");
|
|
871
|
+
const val = !domain || domain === "off" ? null : domain.replace(/^@/, "").toLowerCase();
|
|
872
|
+
await patchSite(slug, { email_gate: val });
|
|
873
|
+
if (JSON_OUT) return console.log(JSON.stringify({ slug, email_gate: val }));
|
|
874
|
+
ok(val ? `email-gate on ${slug} → only @${val} may open it` : `email-gate cleared on ${slug}`);
|
|
875
|
+
}
|
|
876
|
+
async function cmdSetFeedback(slug, state) {
|
|
877
|
+
if (!slug) die("usage: drop set-feedback <slug> [on|off]");
|
|
878
|
+
const on = state !== "off";
|
|
879
|
+
await patchSite(slug, { feedback: on });
|
|
880
|
+
if (JSON_OUT) return console.log(JSON.stringify({ slug, feedback: on }));
|
|
881
|
+
ok(`feedback ${on ? "enabled" : "disabled"} on ${slug}`);
|
|
882
|
+
}
|
|
883
|
+
async function cmdSetPassword(slug, file) {
|
|
884
|
+
// Zero-knowledge: changing the password re-encrypts, which needs the source again.
|
|
885
|
+
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)");
|
|
886
|
+
if (!existsSync(file)) die(`no such file: ${file}`);
|
|
887
|
+
return cmdDrop(file, { ...opts, slug, noLock: false, password: opts.password || genPassword() });
|
|
888
|
+
}
|
|
889
|
+
async function cmdClaim(target) {
|
|
890
|
+
if (!target) die("usage: drop claim <url|slug> (move an anonymous /u/ drop into your account)");
|
|
891
|
+
if (!getApiKey()) die("not logged in — run: drop login");
|
|
892
|
+
const url = /^https?:/.test(target) ? target : `${ORIGIN}/u/${target.replace(/^u\//, "")}`;
|
|
893
|
+
let body; try { const r = await fetch(url); if (!r.ok) die(`could not fetch ${url} (${r.status})`); body = Buffer.from(await r.arrayBuffer()); }
|
|
894
|
+
catch (e) { die(`could not fetch ${url}: ${e.message}`); }
|
|
895
|
+
const slug = opts.slug || ("claimed-" + rand(4));
|
|
896
|
+
const m = await hostedPublish(body, "text/html; charset=utf-8", slug, false);
|
|
897
|
+
if (JSON_OUT) return console.log(JSON.stringify(m));
|
|
898
|
+
report({ url: m.url, password: null, locked: false, extra: `claimed → ${m.handle}/${m.slug}` });
|
|
899
|
+
}
|
|
900
|
+
async function cmdShare(slug) {
|
|
901
|
+
if (!slug) die("usage: drop share <slug> [--revoke]");
|
|
902
|
+
const d = await apiCall(`/api/share`, { method: opts.revoke ? "DELETE" : "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ slug }) });
|
|
903
|
+
if (JSON_OUT) return console.log(JSON.stringify(d));
|
|
904
|
+
if (opts.revoke) return ok(`share token revoked for ${slug}`);
|
|
905
|
+
ok(`revocable share link → ${d.url}\n revoke anytime: drop share ${slug} --revoke`);
|
|
906
|
+
}
|
|
907
|
+
async function cmdMigrate() {
|
|
908
|
+
// Re-home your hosted drops onto your own Vercel Blob, then rewrite config to self-host.
|
|
909
|
+
const sites = (await apiCall("/api/sites")).sites || [];
|
|
910
|
+
const token = getToken();
|
|
911
|
+
if (opts.dryRun || !token) {
|
|
912
|
+
const tgt = token ? "your Vercel Blob" : "(no blob token yet — run `drop setup` first)";
|
|
913
|
+
if (JSON_OUT) return console.log(JSON.stringify({ dryRun: true, count: sites.length, target: tgt }));
|
|
914
|
+
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.`);
|
|
915
|
+
}
|
|
916
|
+
let n = 0;
|
|
917
|
+
for (const s of sites) {
|
|
918
|
+
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++; }
|
|
919
|
+
catch (e) { console.error(` skip ${s.slug}: ${e.message}`); }
|
|
920
|
+
}
|
|
921
|
+
if (JSON_OUT) return console.log(JSON.stringify({ migrated: n, of: sites.length }));
|
|
922
|
+
ok(`migrated ${n}/${sites.length} drops to your own blob. Run 'drop deploy' to finish self-hosting.`);
|
|
923
|
+
}
|
|
924
|
+
|
|
701
925
|
// ---------- arg parsing ----------
|
|
702
926
|
const { values, positionals } = parseArgs({
|
|
703
927
|
allowPositionals: true,
|
|
@@ -717,12 +941,18 @@ const { values, positionals } = parseArgs({
|
|
|
717
941
|
"dry-run": { type: "boolean" },
|
|
718
942
|
"no-deploy": { type: "boolean" },
|
|
719
943
|
managed: { type: "boolean" },
|
|
944
|
+
burn: { type: "boolean" },
|
|
945
|
+
"email-gate": { type: "string" },
|
|
946
|
+
revoke: { type: "boolean" },
|
|
947
|
+
"no-pii-check": { type: "boolean" },
|
|
948
|
+
"block-pii": { type: "boolean" },
|
|
949
|
+
help: { type: "boolean", short: "h" },
|
|
720
950
|
},
|
|
721
951
|
});
|
|
722
952
|
const JSON_OUT = !!values.json;
|
|
723
953
|
|
|
724
954
|
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 };
|
|
955
|
+
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
956
|
|
|
727
957
|
try {
|
|
728
958
|
if (cmd === "init") await cmdInit(opts);
|
|
@@ -731,7 +961,17 @@ try {
|
|
|
731
961
|
else if (cmd === "rm") await cmdRm(rest[0]);
|
|
732
962
|
else if (cmd === "gc") await cmdGc();
|
|
733
963
|
else if (cmd === "deploy") await cmdDeploy(opts);
|
|
734
|
-
else if (cmd === "
|
|
964
|
+
else if (cmd === "login") await cmdLogin(opts);
|
|
965
|
+
else if (cmd === "logout") await cmdLogout();
|
|
966
|
+
else if (cmd === "whoami") await cmdWhoami();
|
|
967
|
+
else if (cmd === "claim") await cmdClaim(rest[0]);
|
|
968
|
+
else if (cmd === "share") await cmdShare(rest[0]);
|
|
969
|
+
else if (cmd === "migrate") await cmdMigrate();
|
|
970
|
+
else if (cmd === "set-expiry") await cmdSetExpiry(rest[0], rest[1]);
|
|
971
|
+
else if (cmd === "set-email-gate") await cmdSetEmailGate(rest[0], rest[1]);
|
|
972
|
+
else if (cmd === "set-feedback") await cmdSetFeedback(rest[0], rest[1]);
|
|
973
|
+
else if (cmd === "set-password") await cmdSetPassword(rest[0], rest[1]);
|
|
974
|
+
else if (values.help || cmd === "help" || cmd === "--help" || !cmd) {
|
|
735
975
|
console.log(`drop — branded password-protected sharing on ${DOMAIN}
|
|
736
976
|
|
|
737
977
|
drop <file.html|.md> brand + lock (auto password) + upload → clean URL
|
|
@@ -739,6 +979,9 @@ try {
|
|
|
739
979
|
drop <file> -p secret use your own password
|
|
740
980
|
drop <file> --no-lock brand only, no password (renders for anyone)
|
|
741
981
|
drop <file> --expire 7d auto-expire (7d/24h/30m/2w/date); enforce with 'drop gc'
|
|
982
|
+
drop <file> --burn burn-after-read: the drop self-destructs on first view
|
|
983
|
+
drop <file> --email-gate co.com only viewers with that email domain may open it
|
|
984
|
+
drop <file> --block-pii refuse to publish if a secret/credential is detected
|
|
742
985
|
drop <file> --page branded download page wrapping the file
|
|
743
986
|
drop <file> --page -p secret password-protect the download page
|
|
744
987
|
drop site.zip multi-file static site → /slug/ (public)
|
|
@@ -748,6 +991,16 @@ try {
|
|
|
748
991
|
drop rm <slug> delete a drop
|
|
749
992
|
drop gc delete drops whose --expire has passed (cron-friendly)
|
|
750
993
|
drop init --domain ... point drop at your own domain + Vercel Blob (BYO)
|
|
994
|
+
drop login sign in (magic-link) to host on drops + persist your drops
|
|
995
|
+
drop whoami show your hosted account (email + handle)
|
|
996
|
+
drop logout clear your hosted account (forget the API key)
|
|
997
|
+
drop claim <url|slug> move an anonymous /u/ drop into your account
|
|
998
|
+
drop share <slug> [--revoke] mint (or revoke) a revocable guest share link
|
|
999
|
+
drop migrate [--dry-run] re-home your hosted drops to your own blob (self-host)
|
|
1000
|
+
drop set-expiry <slug> <when> change a hosted drop's expiry (7d/24h/date/off)
|
|
1001
|
+
drop set-email-gate <slug> <domain> restrict a hosted drop to one email domain
|
|
1002
|
+
drop set-password <slug> <file> re-encrypt a hosted drop with a new password
|
|
1003
|
+
drop set-feedback <slug> [on|off] toggle the feedback widget on a hosted drop
|
|
751
1004
|
drop setup [--token <tok>] provision a machine (deps + blob token)
|
|
752
1005
|
drop deploy [--domain ...] wire backend (blob host → middleware/vercel.json) + deploy`);
|
|
753
1006
|
} 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.
|
|
3
|
+
"version": "0.2.0",
|
|
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": {
|