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.
- package/drop.mjs +275 -15
- 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,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 (
|
|
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 (
|
|
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:
|
|
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
|
|
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
|
|
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 === "
|
|
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
|
|
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": {
|