drops-mcp 0.1.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/README.md +40 -0
- package/SETUP.md +83 -0
- package/SKILL.md +93 -0
- package/brand/badge.html +15 -0
- package/brand/brand.json +13 -0
- package/brand/download-page.html +110 -0
- package/brand/favicon.png +0 -0
- package/brand/gate.html +327 -0
- package/brand/logo-black.png +0 -0
- package/brand/logo-white.png +0 -0
- package/brand/meta.html +13 -0
- package/drop.mjs +745 -0
- package/install.mjs +70 -0
- package/mcp.mjs +145 -0
- package/package.json +37 -0
package/drop.mjs
ADDED
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* drop — instant, branded, password-protected sharing on your own domain.
|
|
4
|
+
*
|
|
5
|
+
* Open-source artifact sharing: drop a file (HTML, PDF, zip, anything) and get a
|
|
6
|
+
* clean, optionally password-locked URL on YOUR domain in ~1s. Bring your own
|
|
7
|
+
* Vercel Blob store + domain via `drop init`; the defaults point at the public
|
|
8
|
+
* drops.maxtechera.dev example deployment.
|
|
9
|
+
*
|
|
10
|
+
* Pipeline (HTML): inject <head> branding/OG + corner badge → (optional) StatiCrypt
|
|
11
|
+
* with branded gate → upload to Vercel Blob → clean URL + password.
|
|
12
|
+
* Pipeline (file): upload raw (unguessable slug), or wrap in a branded download page.
|
|
13
|
+
*
|
|
14
|
+
* Serving is a dumb Vercel rewrite/edge-proxy (<domain>/<slug> → blob). No deploy per file.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFile, writeFile, mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import { homedir, tmpdir } from "node:os";
|
|
20
|
+
import { join, dirname, basename, extname } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { parseArgs } from "node:util";
|
|
23
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
24
|
+
|
|
25
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const DROP_HOME = join(homedir(), ".drop");
|
|
27
|
+
const MANIFEST = join(DROP_HOME, "manifest.json");
|
|
28
|
+
const CONFIG_FILE = join(DROP_HOME, "config.json");
|
|
29
|
+
|
|
30
|
+
// Default deployment = the public drops.maxtechera.dev example. These are NOT secrets
|
|
31
|
+
// (the blob host + project id are already public in vercel.json / the Vercel dashboard).
|
|
32
|
+
// Run `drop init` to point drop at your OWN domain + Vercel Blob store instead.
|
|
33
|
+
const DEFAULTS = {
|
|
34
|
+
domain: "drops.maxtechera.dev",
|
|
35
|
+
blobHost: "opzwhnf3xlqxnotd.public.blob.vercel-storage.com",
|
|
36
|
+
projectId: "prj_c7Yb2JKRvlBduAXyjiWfWEb9ZT9L",
|
|
37
|
+
orgId: "team_3dkH4OzC7klByvov3hsB7J40",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Merge: built-in defaults ← brand/brand.json (presentation) ← ~/.drop/config.json (infra) ← env.
|
|
41
|
+
function loadConfig() {
|
|
42
|
+
let cfg = { ...DEFAULTS, brand: {} };
|
|
43
|
+
try {
|
|
44
|
+
const b = JSON.parse(readFileSync(join(__dirname, "brand", "brand.json"), "utf8"));
|
|
45
|
+
cfg.brand = b;
|
|
46
|
+
if (b.domain) cfg.domain = b.domain;
|
|
47
|
+
} catch {}
|
|
48
|
+
try {
|
|
49
|
+
const c = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
|
|
50
|
+
cfg = { ...cfg, ...c, brand: { ...cfg.brand, ...(c.brand || {}) } };
|
|
51
|
+
} catch {}
|
|
52
|
+
if (process.env.DROP_DOMAIN) cfg.domain = process.env.DROP_DOMAIN;
|
|
53
|
+
if (process.env.DROP_BLOB_HOST) cfg.blobHost = process.env.DROP_BLOB_HOST;
|
|
54
|
+
cfg.brandDir = cfg.brandDir || join(__dirname, "brand");
|
|
55
|
+
cfg.links = cfg.brand?.links || {};
|
|
56
|
+
cfg.origin = `https://${cfg.domain}`;
|
|
57
|
+
return cfg;
|
|
58
|
+
}
|
|
59
|
+
const CONFIG = loadConfig();
|
|
60
|
+
const DOMAIN = CONFIG.domain;
|
|
61
|
+
const ORIGIN = CONFIG.origin;
|
|
62
|
+
const BRAND = CONFIG.brandDir;
|
|
63
|
+
|
|
64
|
+
// ---------- helpers ----------
|
|
65
|
+
const die = (msg) => { console.error(`\x1b[31m✗\x1b[0m ${msg}`); process.exit(1); };
|
|
66
|
+
const ok = (msg) => console.log(`\x1b[32m✓\x1b[0m ${msg}`);
|
|
67
|
+
|
|
68
|
+
function humanSize(n) {
|
|
69
|
+
const u = ["B", "KB", "MB", "GB"];
|
|
70
|
+
let i = 0;
|
|
71
|
+
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
|
72
|
+
return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${u[i]}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function mimeFor(ext) {
|
|
76
|
+
const m = {
|
|
77
|
+
".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8",
|
|
78
|
+
".pdf": "application/pdf", ".zip": "application/zip", ".png": "image/png",
|
|
79
|
+
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml",
|
|
80
|
+
".webp": "image/webp", ".txt": "text/plain; charset=utf-8", ".csv": "text/csv",
|
|
81
|
+
".json": "application/json", ".mp4": "video/mp4", ".mov": "video/quicktime",
|
|
82
|
+
".md": "text/markdown; charset=utf-8", ".gz": "application/gzip", ".tar": "application/x-tar",
|
|
83
|
+
".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8",
|
|
84
|
+
".mjs": "application/javascript; charset=utf-8", ".map": "application/json",
|
|
85
|
+
".ico": "image/x-icon", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf",
|
|
86
|
+
".wasm": "application/wasm", ".xml": "application/xml", ".webmanifest": "application/manifest+json",
|
|
87
|
+
};
|
|
88
|
+
return m[ext.toLowerCase()] || "application/octet-stream";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function slugify(name) {
|
|
92
|
+
return basename(name, extname(name))
|
|
93
|
+
.toLowerCase().normalize("NFKD").replace(/[^\w\s-]/g, "")
|
|
94
|
+
.trim().replace(/[\s_]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "file";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const rand = (n) => {
|
|
98
|
+
const a = "23456789abcdefghjkmnpqrstuvwxyz";
|
|
99
|
+
let s = "";
|
|
100
|
+
const buf = new Uint8Array(n);
|
|
101
|
+
(globalThis.crypto || require("node:crypto").webcrypto).getRandomValues(buf);
|
|
102
|
+
for (let i = 0; i < n; i++) s += a[buf[i] % a.length];
|
|
103
|
+
return s;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const ADJ = ["amber","brisk","clever","copper","dusky","ember","fleet","golden","hazel","ivory","jolly","keen","lunar","mellow","noble","opal","prime","quartz","rapid","silver","tidal","umber","vivid","witty","zesty","bold","crisp","swift","sunny","quiet"];
|
|
107
|
+
const NOUN = ["canyon","river","harbor","meadow","summit","orbit","cedar","falcon","lantern","compass","beacon","pebble","willow","cobalt","cypress","drift","ember","glacier","horizon","ridge","comet","delta","fjord","grove","atlas"];
|
|
108
|
+
function genPassword() {
|
|
109
|
+
const buf = new Uint8Array(3);
|
|
110
|
+
(globalThis.crypto || require("node:crypto").webcrypto).getRandomValues(buf);
|
|
111
|
+
return `${ADJ[buf[0] % ADJ.length]}-${NOUN[buf[1] % NOUN.length]}-${10 + (buf[2] % 90)}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function dataUri(path) {
|
|
115
|
+
const ext = extname(path).toLowerCase();
|
|
116
|
+
const mime = ext === ".svg" ? "image/svg+xml" : ext === ".png" ? "image/png"
|
|
117
|
+
: ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" : ext === ".ico" ? "image/x-icon" : "application/octet-stream";
|
|
118
|
+
const b = await readFile(path);
|
|
119
|
+
return `data:${mime};base64,${b.toString("base64")}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function clip(text) {
|
|
123
|
+
// pbcopy (mac) · clip.exe/clip (windows + WSL) · xclip/wl-copy (linux)
|
|
124
|
+
for (const cmd of [["pbcopy"], ["clip.exe"], ["clip"], ["xclip", ["-selection", "clipboard"]], ["wl-copy"]]) {
|
|
125
|
+
try {
|
|
126
|
+
const r = spawnSync(cmd[0], cmd[1] || [], { input: text, stdio: ["pipe", "ignore", "ignore"], shell: process.platform === "win32" });
|
|
127
|
+
if (r.status === 0 || (r.status === null && !r.error)) return true;
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Load @vercel/blob, auto-installing deps on a fresh machine (node_modules is gitignored).
|
|
134
|
+
let _blob = null;
|
|
135
|
+
async function loadBlob() {
|
|
136
|
+
if (_blob) return _blob;
|
|
137
|
+
try {
|
|
138
|
+
_blob = await import("@vercel/blob");
|
|
139
|
+
} catch (e) {
|
|
140
|
+
if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e;
|
|
141
|
+
console.error("\x1b[2m… first run on this machine: installing dependencies (npm install)\x1b[0m");
|
|
142
|
+
const r = spawnSync("npm", ["install", "--no-audit", "--no-fund", "--loglevel", "error"], {
|
|
143
|
+
cwd: __dirname, stdio: "inherit", shell: process.platform === "win32",
|
|
144
|
+
});
|
|
145
|
+
if (r.status !== 0) die("npm install failed in the skill dir. Run it manually: cd " + __dirname + " && npm install");
|
|
146
|
+
_blob = await import("@vercel/blob");
|
|
147
|
+
}
|
|
148
|
+
return _blob;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function loadManifest() {
|
|
152
|
+
try { return JSON.parse(await readFile(MANIFEST, "utf8")); } catch { return []; }
|
|
153
|
+
}
|
|
154
|
+
async function saveManifest(m) {
|
|
155
|
+
await mkdir(DROP_HOME, { recursive: true });
|
|
156
|
+
await writeFile(MANIFEST, JSON.stringify(m, null, 2));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getToken() {
|
|
160
|
+
if (process.env.BLOB_READ_WRITE_TOKEN) return process.env.BLOB_READ_WRITE_TOKEN;
|
|
161
|
+
// fallback: ~/.drop/.env with BLOB_READ_WRITE_TOKEN=...
|
|
162
|
+
const envFile = join(DROP_HOME, ".env");
|
|
163
|
+
if (existsSync(envFile)) {
|
|
164
|
+
const txt = spawnSync("cat", [envFile]).stdout?.toString() || "";
|
|
165
|
+
const match = txt.match(/^\s*BLOB_READ_WRITE_TOKEN\s*=\s*["']?([^"'\n]+)/m);
|
|
166
|
+
if (match) return match[1].trim();
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------- HTML branding ----------
|
|
172
|
+
async function buildMeta({ title, url, ogImage }) {
|
|
173
|
+
let meta = await readFile(join(BRAND, "meta.html"), "utf8");
|
|
174
|
+
return meta
|
|
175
|
+
.replace(/__DROP_DOMAIN__/g, DOMAIN)
|
|
176
|
+
.replace(/__DROP_TITLE__/g, escapeAttr(title))
|
|
177
|
+
.replace(/__DROP_URL__/g, url)
|
|
178
|
+
.replace(/__DROP_OG_IMAGE__/g, ogImage)
|
|
179
|
+
// favicon: served from the blob _brand path (gate pages override with a data URI)
|
|
180
|
+
.replace(/__DROP_FAVICON__/g, `${ORIGIN}/_brand/favicon.png`);
|
|
181
|
+
}
|
|
182
|
+
const escapeAttr = (s) => String(s).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
183
|
+
|
|
184
|
+
// Parse "7d" / "24h" / "30m" / "2w" / an ISO date into an absolute ms timestamp.
|
|
185
|
+
function parseExpiry(s) {
|
|
186
|
+
if (!s) return null;
|
|
187
|
+
const m = String(s).trim().match(/^(\d+)\s*(m|h|d|w)$/i);
|
|
188
|
+
if (m) {
|
|
189
|
+
const mult = { m: 60e3, h: 36e5, d: 864e5, w: 6048e5 }[m[2].toLowerCase()];
|
|
190
|
+
return Date.now() + Number(m[1]) * mult;
|
|
191
|
+
}
|
|
192
|
+
const t = Date.parse(s);
|
|
193
|
+
return Number.isNaN(t) ? null : t;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Client-side soft-expiry guard baked into the content (survives StatiCrypt decrypt).
|
|
197
|
+
// Real deletion is `drop gc` (manifest-driven); this hides the page after the deadline.
|
|
198
|
+
function expiryGuard(ts) {
|
|
199
|
+
return `<script>(function(){if(Date.now()>${ts}){document.body.innerHTML='<div style="font-family:system-ui,-apple-system,sans-serif;color:#f5f5f5;background:#0b0b0d;position:fixed;inset:0;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px"><div><h1 style="font-weight:600;margin:0 0 8px">This link has expired.</h1><p style="opacity:.55;margin:0">Ask the sender for a new one.</p></div></div>';}})();</script>`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Render Markdown → a clean, branded HTML document (marked auto-installed on demand).
|
|
203
|
+
async function mdToHtml(md, title) {
|
|
204
|
+
let marked;
|
|
205
|
+
try { ({ marked } = await import("marked")); }
|
|
206
|
+
catch {
|
|
207
|
+
spawnSync("npm", ["install", "marked", "--no-audit", "--no-fund", "--loglevel", "error"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
|
|
208
|
+
({ marked } = await import("marked"));
|
|
209
|
+
}
|
|
210
|
+
const body = marked.parse(md);
|
|
211
|
+
const accent = CONFIG.brand?.accentColor || "#ea580c";
|
|
212
|
+
const primary = CONFIG.brand?.primaryColor || "#ff6b35";
|
|
213
|
+
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>${escapeAttr(title)}</title>
|
|
214
|
+
<style>body{max-width:740px;margin:48px auto;padding:0 22px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;line-height:1.65;color:#1a1a1a}h1,h2,h3{letter-spacing:-.01em;line-height:1.2}pre{background:#f6f6f7;padding:14px 16px;border-radius:10px;overflow-x:auto}code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.92em}pre code{background:none}:not(pre)>code{background:#f0f0f1;padding:2px 6px;border-radius:5px}img{max-width:100%}a{color:${accent}}blockquote{border-left:3px solid ${primary};margin:0;padding:2px 0 2px 16px;color:#555}table{border-collapse:collapse}th,td{border:1px solid #e5e5e5;padding:8px 12px}hr{border:none;border-top:1px solid #e5e5e5}</style>
|
|
215
|
+
</head><body>${body}</body></html>`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Substitute owner/brand tokens (social links, colors, owner) shared across templates.
|
|
219
|
+
function applyBrandTokens(s) {
|
|
220
|
+
const L = CONFIG.links || {};
|
|
221
|
+
return String(s)
|
|
222
|
+
.replace(/__DROP_GITHUB__/g, L.github || "")
|
|
223
|
+
.replace(/__DROP_INSTAGRAM__/g, L.instagram || "")
|
|
224
|
+
.replace(/__DROP_WEBSITE__/g, L.website || "")
|
|
225
|
+
.replace(/__DROP_OWNER__/g, CONFIG.brand?.owner || "")
|
|
226
|
+
.replace(/__DROP_PRIMARY__/g, CONFIG.brand?.primaryColor || "#ff6b35")
|
|
227
|
+
.replace(/__DROP_ACCENT__/g, CONFIG.brand?.accentColor || "#ea580c");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function injectHead(html, snippet) {
|
|
231
|
+
if (/<\/head>/i.test(html)) return html.replace(/<\/head>/i, `${snippet}\n</head>`);
|
|
232
|
+
if (/<head[^>]*>/i.test(html)) return html.replace(/(<head[^>]*>)/i, `$1\n${snippet}`);
|
|
233
|
+
if (/<html[^>]*>/i.test(html)) return html.replace(/(<html[^>]*>)/i, `$1\n<head>\n${snippet}\n</head>`);
|
|
234
|
+
return `<head>\n${snippet}\n</head>\n${html}`;
|
|
235
|
+
}
|
|
236
|
+
function injectBody(html, snippet) {
|
|
237
|
+
if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, `${snippet}\n</body>`);
|
|
238
|
+
return `${html}\n${snippet}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function brandHtml(html, { title, url, logoUri, ogImage, expiresAt }) {
|
|
242
|
+
const meta = await buildMeta({ title, url, ogImage });
|
|
243
|
+
let badge = await readFile(join(BRAND, "badge.html"), "utf8");
|
|
244
|
+
badge = applyBrandTokens(badge).replace(/__DROP_LOGO__/g, logoUri).replace(/__DROP_DOMAIN__/g, DOMAIN);
|
|
245
|
+
html = injectHead(html, meta);
|
|
246
|
+
html = injectBody(html, badge);
|
|
247
|
+
if (expiresAt) html = injectBody(html, expiryGuard(expiresAt));
|
|
248
|
+
return html;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------- StatiCrypt ----------
|
|
252
|
+
async function encrypt(htmlPath, { password, title, logoUri, faviconUri, ogImage, url }) {
|
|
253
|
+
const work = await mkdtemp(join(tmpdir(), "drop-"));
|
|
254
|
+
const outDir = join(work, "out");
|
|
255
|
+
await mkdir(outDir, { recursive: true });
|
|
256
|
+
|
|
257
|
+
// build a branded gate template with brand tokens substituted (staticrypt fills its own /*[|..|]*/0)
|
|
258
|
+
let gate = await readFile(join(BRAND, "gate.html"), "utf8");
|
|
259
|
+
const meta = await buildMeta({ title, url, ogImage });
|
|
260
|
+
gate = applyBrandTokens(gate)
|
|
261
|
+
.replace(/__DROP_LOGO__/g, logoUri)
|
|
262
|
+
.replace(/__DROP_FAVICON__/g, faviconUri)
|
|
263
|
+
.replace(/__DROP_DOMAIN__/g, DOMAIN)
|
|
264
|
+
.replace(/__DROP_META__/g, meta);
|
|
265
|
+
const gatePath = join(work, "gate.html");
|
|
266
|
+
await writeFile(gatePath, gate);
|
|
267
|
+
|
|
268
|
+
await new Promise((resolve, reject) => {
|
|
269
|
+
const p = spawn("npx", [
|
|
270
|
+
"staticrypt", htmlPath,
|
|
271
|
+
"-p", password,
|
|
272
|
+
"-t", gatePath,
|
|
273
|
+
"-d", outDir,
|
|
274
|
+
"--remember", "false",
|
|
275
|
+
"--short",
|
|
276
|
+
"--template-title", title,
|
|
277
|
+
"--template-instructions", "This page is password-protected. Enter the password to view it.",
|
|
278
|
+
"--template-button", "UNLOCK",
|
|
279
|
+
"--template-placeholder", "Password",
|
|
280
|
+
], { stdio: ["ignore", "ignore", "inherit"] });
|
|
281
|
+
p.on("error", reject);
|
|
282
|
+
p.on("close", (code) => code === 0 ? resolve() : reject(new Error(`staticrypt exited ${code}`)));
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const files = (await readdir(outDir)).filter((f) => f.endsWith(".html"));
|
|
286
|
+
if (!files.length) throw new Error("staticrypt produced no output");
|
|
287
|
+
const encrypted = await readFile(join(outDir, files[0]), "utf8");
|
|
288
|
+
await rm(work, { recursive: true, force: true });
|
|
289
|
+
return encrypted;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------- upload ----------
|
|
293
|
+
async function upload(key, body, contentType, token) {
|
|
294
|
+
const { put } = await loadBlob();
|
|
295
|
+
return put(key, body, {
|
|
296
|
+
access: "public", token, addRandomSuffix: false, allowOverwrite: true, contentType,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Managed (zero-setup) publish: POST the already-branded/encrypted bytes to the host's
|
|
301
|
+
// managed endpoint. No token, no Vercel setup — the server assigns a `u/` slug.
|
|
302
|
+
async function managedPublish(body, contentType) {
|
|
303
|
+
const endpoint = `${ORIGIN}/api/publish`;
|
|
304
|
+
let r;
|
|
305
|
+
try {
|
|
306
|
+
r = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/octet-stream", "x-drop-content-type": contentType }, body });
|
|
307
|
+
} catch (e) { die(`managed publish failed: ${e.message}`); }
|
|
308
|
+
if (!r.ok) { let m; try { m = (await r.json()).error; } catch {} die(`managed publish failed: ${m || r.status}`); }
|
|
309
|
+
return r.json();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Import an npm dep, auto-installing into the skill dir on first use (gitignored).
|
|
313
|
+
async function ensureDep(name) {
|
|
314
|
+
try { return await import(name); }
|
|
315
|
+
catch (e) {
|
|
316
|
+
if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e;
|
|
317
|
+
console.error(`\x1b[2m… installing ${name}\x1b[0m`);
|
|
318
|
+
spawnSync("npm", ["install", name, "--no-audit", "--no-fund", "--loglevel", "error"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
|
|
319
|
+
return import(name);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---------- commands ----------
|
|
324
|
+
async function cmdDrop(file, opts) {
|
|
325
|
+
if (!file) die("usage: drop <file> [-p password] [-s slug] [--page] [--no-lock]");
|
|
326
|
+
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
|
+
|
|
330
|
+
const ext = extname(file).toLowerCase();
|
|
331
|
+
const isMd = ext === ".md" || ext === ".markdown";
|
|
332
|
+
const isHtml = ext === ".html" || ext === ".htm" || isMd;
|
|
333
|
+
const logoUri = await dataUri(join(BRAND, "logo-white.png"));
|
|
334
|
+
const faviconUri = await dataUri(join(BRAND, "favicon.png"));
|
|
335
|
+
const ogImage = `${ORIGIN}/_brand/og.png`; // optional; harmless if absent
|
|
336
|
+
const stat = (await readFile(file)).length;
|
|
337
|
+
const expiresAt = parseExpiry(opts.expire);
|
|
338
|
+
if (opts.expire && !expiresAt) die(`bad --expire value: ${opts.expire} (use 7d, 24h, 30m, 2w, or a date)`);
|
|
339
|
+
|
|
340
|
+
if (opts.managed && (opts.page || !isHtml)) die("--managed supports HTML/markdown only. Self-host ('drop deploy') for files, --page, and zip sites.");
|
|
341
|
+
|
|
342
|
+
const manifest = await loadManifest();
|
|
343
|
+
const now = new Date().toISOString();
|
|
344
|
+
|
|
345
|
+
// multi-file static site from a zip → uploaded under <slug>/, served at <slug>/
|
|
346
|
+
if (ext === ".zip") {
|
|
347
|
+
const { default: AdmZip } = await ensureDep("adm-zip");
|
|
348
|
+
const slug = opts.slug || slugify(file) + "-" + rand(4);
|
|
349
|
+
const entries = new AdmZip(file).getEntries().filter((e) => !e.isDirectory);
|
|
350
|
+
if (!entries.length) die("empty zip");
|
|
351
|
+
const indexEntry = entries
|
|
352
|
+
.filter((e) => /(^|\/)index\.html?$/i.test(e.entryName))
|
|
353
|
+
.sort((a, b) => a.entryName.split("/").length - b.entryName.split("/").length)[0];
|
|
354
|
+
if (!indexEntry) die("no index.html found in the zip");
|
|
355
|
+
// strip a single wrapping top-level folder if present (e.g. dist/)
|
|
356
|
+
const top = indexEntry.entryName.includes("/") ? indexEntry.entryName.slice(0, indexEntry.entryName.indexOf("/") + 1) : "";
|
|
357
|
+
let total = 0, count = 0;
|
|
358
|
+
for (const e of entries) {
|
|
359
|
+
const rel = top && e.entryName.startsWith(top) ? e.entryName.slice(top.length) : e.entryName;
|
|
360
|
+
if (!rel || rel.startsWith("..")) continue;
|
|
361
|
+
let data = e.getData();
|
|
362
|
+
if (/^index\.html?$/i.test(rel)) {
|
|
363
|
+
let html = data.toString("utf8");
|
|
364
|
+
// <base> so relative asset links resolve under /<slug>/ regardless of trailing slash
|
|
365
|
+
if (/<head[^>]*>/i.test(html)) html = html.replace(/<head([^>]*)>/i, `<head$1><base href="/${slug}/">`);
|
|
366
|
+
else html = `<base href="/${slug}/">\n` + html;
|
|
367
|
+
html = await brandHtml(html, { title: opts.title || basename(file, ext), url: `${ORIGIN}/${slug}/`, logoUri, ogImage, expiresAt });
|
|
368
|
+
data = Buffer.from(html, "utf8");
|
|
369
|
+
}
|
|
370
|
+
await upload(`${slug}/${rel}`, data, mimeFor(extname(rel)), token);
|
|
371
|
+
total += data.length; count++;
|
|
372
|
+
}
|
|
373
|
+
const url = `${ORIGIN}/${slug}/`;
|
|
374
|
+
manifest.unshift({ slug, url, type: "site", locked: false, password: null, file: basename(file), size: total, createdAt: now, expiresAt: expiresAt || null });
|
|
375
|
+
await saveManifest(manifest);
|
|
376
|
+
report({ url, password: null, locked: false, extra: `${count} files (multi-file site, public)` });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (isHtml && !opts.page) {
|
|
381
|
+
const slug = opts.slug || slugify(file) + "-" + rand(4);
|
|
382
|
+
const url = `${ORIGIN}/${slug}`;
|
|
383
|
+
const title = opts.title || basename(file, ext).replace(/[-_]+/g, " ");
|
|
384
|
+
let html = await readFile(file, "utf8");
|
|
385
|
+
if (isMd) html = await mdToHtml(html, title);
|
|
386
|
+
html = await brandHtml(html, { title, url, logoUri, ogImage, expiresAt });
|
|
387
|
+
|
|
388
|
+
let password = null, body = html;
|
|
389
|
+
if (!opts.noLock) {
|
|
390
|
+
password = opts.password || genPassword();
|
|
391
|
+
const tmp = await mkdtemp(join(tmpdir(), "drop-in-"));
|
|
392
|
+
const inPath = join(tmp, `${slug}.html`);
|
|
393
|
+
await writeFile(inPath, html);
|
|
394
|
+
body = await encrypt(inPath, { password, title, logoUri, faviconUri, ogImage, url });
|
|
395
|
+
await rm(tmp, { recursive: true, force: true });
|
|
396
|
+
}
|
|
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 });
|
|
400
|
+
await saveManifest(manifest);
|
|
401
|
+
report({ url: m.url, password, locked: !opts.noLock, extra: "managed · auto-expires in 24h" });
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const res = await upload(slug, body, "text/html; charset=utf-8", token);
|
|
405
|
+
manifest.unshift({ slug, url, type: "html", locked: !opts.noLock, password, file: basename(file), size: stat, blobUrl: res.url, createdAt: now, expiresAt: expiresAt || null });
|
|
406
|
+
await saveManifest(manifest);
|
|
407
|
+
report({ url, password, locked: !opts.noLock, extra: expiresAt ? `expires: ${new Date(expiresAt).toISOString().slice(0, 16).replace("T", " ")}` : undefined });
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (opts.page) {
|
|
412
|
+
// branded download page wrapping a (any) file
|
|
413
|
+
const slug = opts.slug || slugify(file) + "-" + rand(4);
|
|
414
|
+
const fileKey = `${slug}${ext || ""}`;
|
|
415
|
+
const fileUrl = `${ORIGIN}/${fileKey}`;
|
|
416
|
+
const pageUrl = `${ORIGIN}/${slug}`;
|
|
417
|
+
const title = opts.title || basename(file, ext).replace(/[-_]+/g, " ");
|
|
418
|
+
|
|
419
|
+
const fres = await upload(fileKey, await readFile(file), mimeFor(ext), token);
|
|
420
|
+
|
|
421
|
+
let page = await readFile(join(BRAND, "download-page.html"), "utf8");
|
|
422
|
+
const meta = await buildMeta({ title, url: pageUrl, ogImage });
|
|
423
|
+
page = applyBrandTokens(page)
|
|
424
|
+
.replace(/__DROP_META__/g, meta)
|
|
425
|
+
.replace(/__DROP_LOGO__/g, logoUri)
|
|
426
|
+
.replace(/__DROP_TITLE__/g, escapeAttr(title))
|
|
427
|
+
.replace(/__DROP_SUBTITLE__/g, "Click below to download.")
|
|
428
|
+
.replace(/__DROP_FILE_NAME__/g, escapeAttr(basename(file)))
|
|
429
|
+
.replace(/__DROP_FILE_SIZE__/g, humanSize(stat))
|
|
430
|
+
.replace(/__DROP_FILE_URL__/g, fileUrl)
|
|
431
|
+
.replace(/__DROP_DOMAIN__/g, DOMAIN);
|
|
432
|
+
|
|
433
|
+
let password = null, body = page;
|
|
434
|
+
if (!opts.noLock && opts.password) {
|
|
435
|
+
// only lock the page if a password was explicitly requested (the file URL itself stays unguessable)
|
|
436
|
+
password = opts.password;
|
|
437
|
+
const tmp = await mkdtemp(join(tmpdir(), "drop-in-"));
|
|
438
|
+
const inPath = join(tmp, `${slug}.html`);
|
|
439
|
+
await writeFile(inPath, page);
|
|
440
|
+
body = await encrypt(inPath, { password, title, logoUri, faviconUri, ogImage, url: pageUrl });
|
|
441
|
+
await rm(tmp, { recursive: true, force: true });
|
|
442
|
+
}
|
|
443
|
+
const pres = await upload(slug, body, "text/html; charset=utf-8", token);
|
|
444
|
+
manifest.unshift({ slug, url: pageUrl, type: "page", locked: !!password, password, file: basename(file), size: stat, blobUrl: pres.url, fileBlobUrl: fres.url, createdAt: now, expiresAt: expiresAt || null });
|
|
445
|
+
await saveManifest(manifest);
|
|
446
|
+
report({ url: pageUrl, password, locked: !!password, extra: `file: ${fileUrl}` });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// raw file — unguessable slug, no page
|
|
451
|
+
const slug = (opts.slug || slugify(file)) + "-" + rand(6);
|
|
452
|
+
const key = `${slug}${ext || ""}`;
|
|
453
|
+
const url = `${ORIGIN}/${key}`;
|
|
454
|
+
const res = await upload(key, await readFile(file), mimeFor(ext), token);
|
|
455
|
+
manifest.unshift({ slug, url, type: "file", locked: false, password: null, file: basename(file), size: stat, blobUrl: res.url, createdAt: now, expiresAt: expiresAt || null });
|
|
456
|
+
await saveManifest(manifest);
|
|
457
|
+
report({ url, password: null, locked: false, extra: expiresAt ? `expires: ${new Date(expiresAt).toISOString().slice(0, 16).replace("T", " ")}` : undefined });
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function report({ url, password, locked, extra }) {
|
|
461
|
+
if (JSON_OUT) { console.log(JSON.stringify({ url, password: password || null, locked: !!locked, extra: extra || null })); return; }
|
|
462
|
+
console.log("");
|
|
463
|
+
ok("live");
|
|
464
|
+
console.log(` \x1b[1m${url}\x1b[0m`);
|
|
465
|
+
if (extra) console.log(` ${extra}`);
|
|
466
|
+
if (locked && password) console.log(` password: \x1b[1m\x1b[33m${password}\x1b[0m`);
|
|
467
|
+
const payload = locked && password ? `${url}\npassword: ${password}` : url;
|
|
468
|
+
if (clip(payload)) console.log(` \x1b[2m(copied to clipboard)\x1b[0m`);
|
|
469
|
+
console.log("");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function cmdList() {
|
|
473
|
+
const token = getToken();
|
|
474
|
+
if (!token) die("BLOB_READ_WRITE_TOKEN not set. Run: drop setup");
|
|
475
|
+
const { list } = await loadBlob();
|
|
476
|
+
const { blobs } = await list({ token });
|
|
477
|
+
// overlay local passwords (kept on this machine only) keyed by pathname/slug
|
|
478
|
+
const local = await loadManifest();
|
|
479
|
+
const pw = new Map();
|
|
480
|
+
for (const d of local) { if (d.password) { pw.set(d.slug, d.password); } }
|
|
481
|
+
// sort newest first
|
|
482
|
+
blobs.sort((a, b) => new Date(b.uploadedAt) - new Date(a.uploadedAt));
|
|
483
|
+
if (JSON_OUT) {
|
|
484
|
+
console.log(JSON.stringify(blobs.map((b) => {
|
|
485
|
+
const slug = b.pathname.replace(/\.[^.]+$/, "");
|
|
486
|
+
return { slug: b.pathname, url: `${ORIGIN}/${b.pathname}`, size: b.size, password: pw.get(slug) || pw.get(b.pathname) || null, uploadedAt: b.uploadedAt };
|
|
487
|
+
})));
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (!blobs.length) return console.log("no drops yet.");
|
|
491
|
+
for (const b of blobs) {
|
|
492
|
+
const slug = b.pathname.replace(/\.[^.]+$/, "");
|
|
493
|
+
const known = pw.get(slug) || pw.get(b.pathname);
|
|
494
|
+
const lock = known ? `🔒 ${known}` : (b.pathname.includes(".") ? "public file" : "page");
|
|
495
|
+
console.log(`${ORIGIN}/${b.pathname}\n ${humanSize(b.size)} · ${lock} · ${String(b.uploadedAt).slice(0, 10)}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function cmdRm(slug) {
|
|
500
|
+
if (!slug) die("usage: drop rm <slug>");
|
|
501
|
+
const token = getToken();
|
|
502
|
+
if (!token) die("BLOB_READ_WRITE_TOKEN not set. Run: drop setup");
|
|
503
|
+
const { list, del } = await loadBlob();
|
|
504
|
+
const { blobs } = await list({ token });
|
|
505
|
+
// match the page/file key (slug), a sibling like <slug>.<ext>, or a whole site under <slug>/
|
|
506
|
+
const targets = blobs.filter((b) => b.pathname === slug || b.pathname.replace(/\.[^.]+$/, "") === slug || b.pathname.startsWith(slug + "/"));
|
|
507
|
+
if (!targets.length) die(`not found: ${slug}`);
|
|
508
|
+
await del(targets.map((b) => b.url), { token });
|
|
509
|
+
// prune local manifest if present
|
|
510
|
+
const m = await loadManifest();
|
|
511
|
+
const kept = m.filter((d) => d.slug !== slug);
|
|
512
|
+
if (kept.length !== m.length) await saveManifest(kept);
|
|
513
|
+
if (JSON_OUT) { console.log(JSON.stringify({ removed: targets.map((b) => b.pathname) })); return; }
|
|
514
|
+
ok(`removed ${targets.map((b) => b.pathname).join(", ")}`);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Delete drops whose expiry has passed (manifest-driven). Run via cron for true auto-expiry.
|
|
518
|
+
async function cmdGc() {
|
|
519
|
+
const m = await loadManifest();
|
|
520
|
+
const nowMs = Date.now();
|
|
521
|
+
const expired = m.filter((d) => d.expiresAt && d.expiresAt < nowMs);
|
|
522
|
+
if (!expired.length) { if (JSON_OUT) console.log("[]"); else ok("nothing expired"); return; }
|
|
523
|
+
const token = getToken();
|
|
524
|
+
if (!token) die("BLOB_READ_WRITE_TOKEN not set. Run: drop setup");
|
|
525
|
+
const { list, del } = await loadBlob();
|
|
526
|
+
const { blobs } = await list({ token });
|
|
527
|
+
const removed = [];
|
|
528
|
+
for (const d of expired) {
|
|
529
|
+
const targets = blobs.filter((b) => b.pathname === d.slug || b.pathname.replace(/\.[^.]+$/, "") === d.slug || b.pathname.startsWith(d.slug + "/"));
|
|
530
|
+
if (targets.length) { await del(targets.map((b) => b.url), { token }); removed.push(...targets.map((b) => b.pathname)); }
|
|
531
|
+
}
|
|
532
|
+
await saveManifest(m.filter((d) => !(d.expiresAt && d.expiresAt < nowMs)));
|
|
533
|
+
if (JSON_OUT) { console.log(JSON.stringify({ removed })); return; }
|
|
534
|
+
ok(removed.length ? `removed ${removed.length} expired: ${removed.join(", ")}` : "manifest pruned (blobs already gone)");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// One-command backend: discover the blob host, wire middleware.js + vercel.json,
|
|
538
|
+
// write ~/.drop/config.json, and deploy. Assumes a Vercel project + Blob store exist
|
|
539
|
+
// (run `vercel link` and `vercel blob store add drops` first, or let setup pull the token).
|
|
540
|
+
async function cmdDeploy(opts) {
|
|
541
|
+
const dry = !!opts.dryRun;
|
|
542
|
+
const repoRoot = existsSync(join(__dirname, "..", "middleware.js")) ? join(__dirname, "..") : process.cwd();
|
|
543
|
+
const mwPath = join(repoRoot, "middleware.js");
|
|
544
|
+
const vjPath = join(repoRoot, "vercel.json");
|
|
545
|
+
if (!existsSync(mwPath) || !existsSync(vjPath)) die(`run 'drop deploy' from the drops-share repo root (middleware.js + vercel.json not found in ${repoRoot})`);
|
|
546
|
+
|
|
547
|
+
console.log(`drop deploy — wiring backend in ${repoRoot}${dry ? " (dry run)" : ""}\n`);
|
|
548
|
+
|
|
549
|
+
// 1. token
|
|
550
|
+
let token = getToken() || opts.token;
|
|
551
|
+
if (!token) { token = await pullTokenFromVercel(); }
|
|
552
|
+
if (!token) die("no BLOB_READ_WRITE_TOKEN. Create a Blob store (vercel blob store add drops), then: drop deploy --token vercel_blob_rw_...");
|
|
553
|
+
|
|
554
|
+
// 2. discover the public blob host via a throwaway upload
|
|
555
|
+
const { put, del } = await loadBlob();
|
|
556
|
+
const probe = await put(".drop-probe", "ok", { access: "public", token, addRandomSuffix: false, allowOverwrite: true, contentType: "text/plain" });
|
|
557
|
+
const blobHost = new URL(probe.url).host;
|
|
558
|
+
await del(probe.url, { token }).catch(() => {});
|
|
559
|
+
ok(`blob host: ${blobHost}`);
|
|
560
|
+
|
|
561
|
+
// 3. patch middleware.js (BLOB const) + vercel.json (fallback rewrite) idempotently
|
|
562
|
+
let mw = await readFile(mwPath, "utf8");
|
|
563
|
+
const newMw = mw.replace(/const BLOB = "https:\/\/[^"]+";/, `const BLOB = "https://${blobHost}";`);
|
|
564
|
+
let vj = await readFile(vjPath, "utf8");
|
|
565
|
+
const newVj = vj.replace(/https:\/\/[a-z0-9]+\.public\.blob\.vercel-storage\.com/g, `https://${blobHost}`);
|
|
566
|
+
const mwChanged = newMw !== mw, vjChanged = newVj !== vj;
|
|
567
|
+
if (dry) {
|
|
568
|
+
ok(`middleware.js ${mwChanged ? "would update" : "already correct"}`);
|
|
569
|
+
ok(`vercel.json ${vjChanged ? "would update" : "already correct"}`);
|
|
570
|
+
} else {
|
|
571
|
+
if (mwChanged) await writeFile(mwPath, newMw);
|
|
572
|
+
if (vjChanged) await writeFile(vjPath, newVj);
|
|
573
|
+
ok(`middleware.js + vercel.json wired to ${blobHost}`);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// 4. write config (+ domain) and token
|
|
577
|
+
const cfg = { blobHost };
|
|
578
|
+
if (opts.domain) cfg.domain = opts.domain;
|
|
579
|
+
if (opts.project) cfg.projectId = opts.project;
|
|
580
|
+
if (opts.org) cfg.orgId = opts.org;
|
|
581
|
+
if (!dry) {
|
|
582
|
+
let existing = {};
|
|
583
|
+
try { existing = JSON.parse(readFileSync(CONFIG_FILE, "utf8")); } catch {}
|
|
584
|
+
await mkdir(DROP_HOME, { recursive: true });
|
|
585
|
+
await writeFile(CONFIG_FILE, JSON.stringify({ ...existing, ...cfg }, null, 2) + "\n");
|
|
586
|
+
await writeFile(join(DROP_HOME, ".env"), `BLOB_READ_WRITE_TOKEN=${token}\n`);
|
|
587
|
+
try { spawnSync("chmod", ["600", join(DROP_HOME, ".env")]); } catch {}
|
|
588
|
+
ok(`config + token saved to ${DROP_HOME}`);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// 5. deploy + domain
|
|
592
|
+
if (dry || opts.noDeploy) {
|
|
593
|
+
console.log(`\n${dry ? "Dry run — would run" : "Skipped deploy. Run"}: vercel deploy --prod${opts.domain ? ` && vercel domains add ${opts.domain}` : ""}`);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
console.log("\ndeploying to Vercel…");
|
|
597
|
+
const d = spawnSync("vercel", ["deploy", "--prod", "--yes"], { cwd: repoRoot, stdio: "inherit", shell: process.platform === "win32" });
|
|
598
|
+
if (d.status !== 0) die("vercel deploy failed — run it manually from the repo root.");
|
|
599
|
+
if (opts.domain) spawnSync("vercel", ["domains", "add", opts.domain], { cwd: repoRoot, stdio: "inherit", shell: process.platform === "win32" });
|
|
600
|
+
ok("deployed. Try: drop <file.html>");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Provision this machine: install deps + ensure the blob token at ~/.drop/.env.
|
|
604
|
+
async function cmdSetup(opts) {
|
|
605
|
+
console.log("drop setup — provisioning this machine\n");
|
|
606
|
+
// 1. deps
|
|
607
|
+
await loadBlob();
|
|
608
|
+
ok("dependencies installed");
|
|
609
|
+
|
|
610
|
+
// 2. token
|
|
611
|
+
let token = getToken();
|
|
612
|
+
if (!token && opts.token) {
|
|
613
|
+
token = opts.token;
|
|
614
|
+
}
|
|
615
|
+
if (!token) {
|
|
616
|
+
// try to pull from Vercel by the known drops-share project id (works on any vercel-authed machine on the team)
|
|
617
|
+
token = await pullTokenFromVercel();
|
|
618
|
+
}
|
|
619
|
+
if (!token) {
|
|
620
|
+
die("no BLOB_READ_WRITE_TOKEN found.\n Provide it directly: drop setup --token <vercel_blob_rw_...>\n Or authenticate Vercel: vercel login (then re-run drop setup)\n Find it at: Vercel → drops-share project → Storage → drops → .env.local");
|
|
621
|
+
}
|
|
622
|
+
await mkdir(DROP_HOME, { recursive: true });
|
|
623
|
+
await writeFile(MANIFEST.replace(/manifest\.json$/, ".env"), `BLOB_READ_WRITE_TOKEN=${token}\n`);
|
|
624
|
+
// chmod best-effort (no-op on windows)
|
|
625
|
+
try { spawnSync("chmod", ["600", join(DROP_HOME, ".env")]); } catch {}
|
|
626
|
+
ok(`token saved to ${join(DROP_HOME, ".env")}`);
|
|
627
|
+
|
|
628
|
+
// 3. verify
|
|
629
|
+
try {
|
|
630
|
+
const { list } = await loadBlob();
|
|
631
|
+
await list({ token });
|
|
632
|
+
ok("verified — blob store reachable");
|
|
633
|
+
console.log("\nReady. Try: drop <file.html>");
|
|
634
|
+
} catch (e) {
|
|
635
|
+
die("token saved but verification failed: " + (e.message || e));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Point drop at YOUR own deployment (domain + Vercel Blob). Writes ~/.drop/config.json.
|
|
640
|
+
async function cmdInit(opts) {
|
|
641
|
+
const cfg = {};
|
|
642
|
+
if (opts.domain) cfg.domain = opts.domain;
|
|
643
|
+
if (opts.blobHost) cfg.blobHost = opts.blobHost;
|
|
644
|
+
if (opts.project) cfg.projectId = opts.project;
|
|
645
|
+
if (opts.org) cfg.orgId = opts.org;
|
|
646
|
+
if (!Object.keys(cfg).length) {
|
|
647
|
+
die(`drop init — run drop on your own domain + Vercel Blob store.
|
|
648
|
+
|
|
649
|
+
drop init --domain drops.yoursite.com \\
|
|
650
|
+
--blob-host <id>.public.blob.vercel-storage.com \\
|
|
651
|
+
--project prj_xxx --org team_xxx
|
|
652
|
+
|
|
653
|
+
Writes ~/.drop/config.json (infra). Edit skill/brand/brand.json for your name,
|
|
654
|
+
colors and social links. Then: drop setup --token <vercel_blob_rw_...>
|
|
655
|
+
See SETUP.md to stand up the Blob store + domain rewrite first.`);
|
|
656
|
+
}
|
|
657
|
+
let existing = {};
|
|
658
|
+
try { existing = JSON.parse(readFileSync(CONFIG_FILE, "utf8")); } catch {}
|
|
659
|
+
const merged = { ...existing, ...cfg };
|
|
660
|
+
await mkdir(DROP_HOME, { recursive: true });
|
|
661
|
+
await writeFile(CONFIG_FILE, JSON.stringify(merged, null, 2) + "\n");
|
|
662
|
+
ok(`config saved to ${CONFIG_FILE}`);
|
|
663
|
+
console.log(` domain: ${merged.domain || DEFAULTS.domain}`);
|
|
664
|
+
console.log(` blob host: ${merged.blobHost || DEFAULTS.blobHost}`);
|
|
665
|
+
console.log(`\nNext: drop setup --token <vercel_blob_rw_...> (or: vercel login, then drop setup)`);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const KNOWN_PROJECT = { projectId: CONFIG.projectId, orgId: CONFIG.orgId };
|
|
669
|
+
async function pullTokenFromVercel() {
|
|
670
|
+
const which = spawnSync(process.platform === "win32" ? "where" : "which", ["vercel"], { stdio: ["ignore", "pipe", "ignore"], shell: process.platform === "win32" });
|
|
671
|
+
if (which.status !== 0) return null;
|
|
672
|
+
const tmp = await mkdtemp(join(tmpdir(), "drop-setup-"));
|
|
673
|
+
try {
|
|
674
|
+
await mkdir(join(tmp, ".vercel"), { recursive: true });
|
|
675
|
+
await writeFile(join(tmp, ".vercel", "project.json"), JSON.stringify(KNOWN_PROJECT));
|
|
676
|
+
const r = spawnSync("vercel", ["env", "pull", ".env.pull", "--environment=production", "--yes"], {
|
|
677
|
+
cwd: tmp, stdio: ["ignore", "ignore", "inherit"], shell: process.platform === "win32",
|
|
678
|
+
});
|
|
679
|
+
if (r.status !== 0) return null;
|
|
680
|
+
const txt = await readFile(join(tmp, ".env.pull"), "utf8");
|
|
681
|
+
const match = txt.match(/^\s*BLOB_READ_WRITE_TOKEN\s*=\s*["']?([^"'\n\r]+)/m);
|
|
682
|
+
return match ? match[1].trim() : null;
|
|
683
|
+
} catch { return null; }
|
|
684
|
+
finally { await rm(tmp, { recursive: true, force: true }); }
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ---------- arg parsing ----------
|
|
688
|
+
const { values, positionals } = parseArgs({
|
|
689
|
+
allowPositionals: true,
|
|
690
|
+
options: {
|
|
691
|
+
password: { type: "string", short: "p" },
|
|
692
|
+
slug: { type: "string", short: "s" },
|
|
693
|
+
title: { type: "string", short: "t" },
|
|
694
|
+
page: { type: "boolean" },
|
|
695
|
+
"no-lock": { type: "boolean" },
|
|
696
|
+
token: { type: "string" },
|
|
697
|
+
domain: { type: "string" },
|
|
698
|
+
"blob-host": { type: "string" },
|
|
699
|
+
project: { type: "string" },
|
|
700
|
+
org: { type: "string" },
|
|
701
|
+
json: { type: "boolean" },
|
|
702
|
+
expire: { type: "string" },
|
|
703
|
+
"dry-run": { type: "boolean" },
|
|
704
|
+
"no-deploy": { type: "boolean" },
|
|
705
|
+
managed: { type: "boolean" },
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
const JSON_OUT = !!values.json;
|
|
709
|
+
|
|
710
|
+
const [cmd, ...rest] = positionals;
|
|
711
|
+
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 };
|
|
712
|
+
|
|
713
|
+
try {
|
|
714
|
+
if (cmd === "init") await cmdInit(opts);
|
|
715
|
+
else if (cmd === "setup") await cmdSetup(opts);
|
|
716
|
+
else if (cmd === "list") await cmdList();
|
|
717
|
+
else if (cmd === "rm") await cmdRm(rest[0]);
|
|
718
|
+
else if (cmd === "gc") await cmdGc();
|
|
719
|
+
else if (cmd === "deploy") await cmdDeploy(opts);
|
|
720
|
+
else if (cmd === "help" || cmd === "--help" || !cmd) {
|
|
721
|
+
console.log(`drop — branded password-protected sharing on ${DOMAIN}
|
|
722
|
+
|
|
723
|
+
drop <file.html|.md> brand + lock (auto password) + upload → clean URL
|
|
724
|
+
drop <file> --managed zero-setup: publish to the managed tier (no Vercel needed)
|
|
725
|
+
drop <file> -p secret use your own password
|
|
726
|
+
drop <file> --no-lock brand only, no password (renders for anyone)
|
|
727
|
+
drop <file> --expire 7d auto-expire (7d/24h/30m/2w/date); enforce with 'drop gc'
|
|
728
|
+
drop <file> --page branded download page wrapping the file
|
|
729
|
+
drop <file> --page -p secret password-protect the download page
|
|
730
|
+
drop site.zip multi-file static site → /slug/ (public)
|
|
731
|
+
drop <file> raw file, unguessable URL
|
|
732
|
+
drop -s myslug <file> force the slug
|
|
733
|
+
drop list list live drops (from the store, cross-machine)
|
|
734
|
+
drop rm <slug> delete a drop
|
|
735
|
+
drop gc delete drops whose --expire has passed (cron-friendly)
|
|
736
|
+
drop init --domain ... point drop at your own domain + Vercel Blob (BYO)
|
|
737
|
+
drop setup [--token <tok>] provision a machine (deps + blob token)
|
|
738
|
+
drop deploy [--domain ...] wire backend (blob host → middleware/vercel.json) + deploy`);
|
|
739
|
+
} else {
|
|
740
|
+
// treat cmd as the file
|
|
741
|
+
await cmdDrop(cmd, opts);
|
|
742
|
+
}
|
|
743
|
+
} catch (e) {
|
|
744
|
+
die(e.message || String(e));
|
|
745
|
+
}
|