slingdrop 0.0.0-stage → 1.0.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -2
  3. package/dist/cli.js +645 -0
  4. package/package.json +48 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sansa Group AB
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,60 @@
1
- # Temporary Holding Version
1
+ # slingdrop
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Send anything from your terminal and get a link. Files, whole folders, or piped output. The link, a QR code and a copy on your clipboard come back in seconds.
4
+
5
+ ```bash
6
+ npx slingdrop deck.pdf
7
+ ```
8
+
9
+ Requires Node 20 or later.
10
+
11
+ ## Without an account
12
+
13
+ ```bash
14
+ npx slingdrop deck.pdf # public link, lasts 24 hours
15
+ npx slingdrop screenshot.png --passcode 4821
16
+ cat build.log | npx slingdrop --name build.log
17
+ ```
18
+
19
+ ## Signed in
20
+
21
+ Sign in once and links last longer. You also get Vault (end-to-end encryption), private drops and your drop list.
22
+
23
+ ```bash
24
+ npm i -g slingdrop
25
+ slingdrop login # opens your browser
26
+ slingdrop ./dist --expires 30d # a whole folder
27
+ slingdrop contract.pdf --vault # encrypted on this machine
28
+ slingdrop review.mp4 --private ana@studio.com,sam@client.com --approval
29
+ slingdrop ls
30
+ slingdrop rm <id> # Trash; add --forever to delete now
31
+ slingdrop request "Brand assets" # a link others can upload to
32
+ ```
33
+
34
+ `sling` works as a short alias. Run `slingdrop --help` for every option.
35
+
36
+ ## File hosting
37
+
38
+ Host images, fonts and other assets for your app on stable CDN URLs. Included in every plan (Free: 1 GB, 10 GB bandwidth a month).
39
+
40
+ ```bash
41
+ slingdrop host ./public/assets --space acme # prints a URL per file
42
+ slingdrop host ./dist/assets --prefix v42 --immutable # cache hashed files for a year
43
+ slingdrop host ./public/assets --json > assets.json # {path: url} manifest
44
+ slingdrop host ls | rm <path> | spaces | usage
45
+ ```
46
+
47
+ Files are served from `https://cdn.slingdropcontent.my/<space>/<path>`. Add `?w=400&format=auto` to an image URL to resize it.
48
+
49
+ ## Scripts and CI
50
+
51
+ ```bash
52
+ SLINGDROP_API_KEY=sd_... slingdrop report.pdf --json
53
+ # {"id":"...","url":"https://slingdrop.my/d/...","expiresAt":"..."}
54
+ ```
55
+
56
+ Create API keys in Settings → Developer at [slingdrop.my](https://slingdrop.my). The docs are at [slingdrop.my/developers](https://slingdrop.my/developers).
57
+
58
+ ## License
59
+
60
+ MIT © Sansavision
package/dist/cli.js ADDED
@@ -0,0 +1,645 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { openAsBlob } from "fs";
5
+ import { mkdir, readFile, readdir, stat, writeFile, rm } from "fs/promises";
6
+ import { homedir, hostname } from "os";
7
+ import { basename, join, relative } from "path";
8
+ import { spawn } from "child_process";
9
+ import QRCode from "qrcode";
10
+
11
+ // ../sdk/dist/index.js
12
+ var VAULT_CHUNK = 8 * 1024 * 1024;
13
+ var VAULT_OVERHEAD = 28;
14
+ var META_INDEX = 4294967295;
15
+ var enc = new TextEncoder();
16
+ var dec = new TextDecoder();
17
+ function toB64url(bytes) {
18
+ let s = "";
19
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
20
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
21
+ }
22
+ function aad(index) {
23
+ const b = new Uint8Array(4);
24
+ new DataView(b.buffer).setUint32(0, index >>> 0);
25
+ return b;
26
+ }
27
+ async function generateVaultKey() {
28
+ return await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
29
+ }
30
+ async function exportVaultKey(key) {
31
+ return toB64url(new Uint8Array(await crypto.subtle.exportKey("raw", key)));
32
+ }
33
+ async function encryptChunk(key, plaintext, index) {
34
+ const iv = crypto.getRandomValues(new Uint8Array(12));
35
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad(index) }, key, plaintext));
36
+ const out = new Uint8Array(12 + ct.length);
37
+ out.set(iv, 0);
38
+ out.set(ct, 12);
39
+ return out;
40
+ }
41
+ async function encryptText(key, text) {
42
+ return toB64url(await encryptChunk(key, enc.encode(text), META_INDEX));
43
+ }
44
+ function encryptedSize(plainSize) {
45
+ const chunks = Math.max(1, Math.ceil(plainSize / VAULT_CHUNK));
46
+ return plainSize + chunks * VAULT_OVERHEAD;
47
+ }
48
+ var SINGLE_PUT_MAX = 95 * 1024 ** 2;
49
+ var enc2 = (path) => path.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/");
50
+ function hostingApi(request) {
51
+ return {
52
+ spaces: () => request("/hosting/spaces").then((r) => r.data),
53
+ /** Creates a space with this name (or your default space when omitted). */
54
+ createSpace: (slug) => request("/hosting/spaces", { method: "POST", body: JSON.stringify({ slug }) }).then((r) => r.data),
55
+ deleteSpace: (space) => request(`/hosting/spaces/${space}`, { method: "DELETE" }),
56
+ usage: () => request("/hosting/usage").then((r) => r.data),
57
+ list: (space, opts = {}) => {
58
+ const q = new URLSearchParams(Object.entries(opts).filter(([, v]) => v !== void 0).map(([k, v]) => [k, String(v)]));
59
+ return request(`/hosting/${space}/objects${q.size ? `?${q}` : ""}`);
60
+ },
61
+ /**
62
+ * Uploads a file and returns its public URL. The space is created if the name is free. Files over
63
+ * 95 MB are sent in parts automatically. `immutable` caches the URL for a year: use it for
64
+ * content-hashed file names.
65
+ */
66
+ put: async (space, path, data, opts = {}) => {
67
+ const blob = data instanceof Blob ? data : new Blob([data]);
68
+ const type = opts.contentType ?? blob.type ?? "";
69
+ if (blob.size <= SINGLE_PUT_MAX) {
70
+ const r = await request(`/hosting/${space}/objects/${enc2(path)}${opts.immutable ? "?immutable=1" : ""}`, {
71
+ method: "PUT",
72
+ body: blob,
73
+ headers: { "content-type": type || "application/octet-stream" }
74
+ });
75
+ opts.onProgress?.(blob.size, blob.size);
76
+ return r.data;
77
+ }
78
+ const start = await request(`/hosting/${space}/multipart`, {
79
+ method: "POST",
80
+ body: JSON.stringify({ path, size: blob.size, contentType: type || void 0, immutable: opts.immutable })
81
+ });
82
+ const parts = [];
83
+ let loaded = 0;
84
+ const queue = Array.from({ length: start.parts }, (_, i) => i);
85
+ await Promise.all(
86
+ Array.from({ length: Math.min(3, start.parts) }, async () => {
87
+ for (let i = queue.shift(); i !== void 0; i = queue.shift()) {
88
+ const slice = blob.slice(i * start.partSize, (i + 1) * start.partSize);
89
+ parts.push(
90
+ await request(`/hosting/${space}/multipart/parts/${i + 1}`, {
91
+ method: "PUT",
92
+ body: slice,
93
+ headers: { "content-type": "application/octet-stream", "x-upload-token": start.token }
94
+ })
95
+ );
96
+ loaded += slice.size;
97
+ opts.onProgress?.(loaded, blob.size);
98
+ }
99
+ })
100
+ );
101
+ return request(`/hosting/${space}/multipart/complete`, { method: "POST", body: JSON.stringify({ token: start.token, parts }) }).then((r) => r.data);
102
+ },
103
+ delete: (space, path) => request(`/hosting/${space}/objects/${enc2(path)}`, { method: "DELETE" }),
104
+ /**
105
+ * A short-lived URL your own users can PUT one file to, straight from their browser. Limit it to a
106
+ * type ("image/*") and size. The response tells you the public URL the file will have.
107
+ */
108
+ createUploadUrl: (space, opts = {}) => request(`/hosting/${space}/upload-urls`, { method: "POST", body: JSON.stringify(opts) })
109
+ };
110
+ }
111
+ function reviewsApi(request) {
112
+ return {
113
+ /** Marks an uploaded drop as a review by your agent and notifies you. */
114
+ start: (dropId, opts = {}) => request(`/drops/${dropId}/review`, { method: "POST", body: JSON.stringify(opts) }).then((r) => r.data),
115
+ /** The current state. With `wait` (seconds, up to 60) it waits for new feedback after `since`. */
116
+ feedback: (dropId, opts = {}) => {
117
+ const q = new URLSearchParams();
118
+ if (opts.since) q.set("since", opts.since);
119
+ if (opts.wait) q.set("wait", String(Math.min(60, Math.max(0, Math.round(opts.wait)))));
120
+ return request(`/drops/${dropId}/feedback${q.size ? `?${q}` : ""}`).then((r) => r.data);
121
+ },
122
+ /** After uploading a new version with `drops.addVersion`, records the round and resolves what it addressed. */
123
+ round: (dropId, opts = {}) => request(`/drops/${dropId}/rounds`, { method: "POST", body: JSON.stringify(opts) }).then((r) => r.data),
124
+ reply: (dropId, commentId, text) => request(`/drops/${dropId}/comments/${commentId}/replies`, { method: "POST", body: JSON.stringify({ text }) }).then((r) => r.data),
125
+ resolve: (dropId, commentId, resolved = true) => request(`/drops/${dropId}/comments/${commentId}/resolve`, { method: "POST", body: JSON.stringify({ resolved }) }),
126
+ approvals: (dropId) => request(`/drops/${dropId}/approvals`).then((r) => r.data)
127
+ };
128
+ }
129
+ var SlingdropError = class extends Error {
130
+ constructor(message, status, code) {
131
+ super(message);
132
+ this.status = status;
133
+ this.code = code;
134
+ this.name = "SlingdropError";
135
+ }
136
+ status;
137
+ code;
138
+ };
139
+ var Slingdrop = class {
140
+ baseUrl;
141
+ apiKey;
142
+ bearer;
143
+ fetchImpl;
144
+ constructor(opts = {}) {
145
+ this.apiKey = opts.apiKey;
146
+ this.bearer = opts.bearer;
147
+ this.baseUrl = (opts.baseUrl ?? "https://slingdrop.my").replace(/\/$/, "");
148
+ this.fetchImpl = opts.fetch ?? ((input, init) => fetch(input, init));
149
+ }
150
+ /** Low-level request against /api/v1. */
151
+ async request(path, init = {}) {
152
+ const res = await this.fetchImpl(`${this.baseUrl}/api/v1${path}`, {
153
+ ...init,
154
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.apiKey ?? this.bearer ?? ""}`, ...init.headers }
155
+ });
156
+ const data = await res.json().catch(() => ({}));
157
+ if (!res.ok) throw new SlingdropError(data.error ?? `Request failed (${res.status})`, res.status, data.code);
158
+ return data;
159
+ }
160
+ /** True when no API key or session is set: only anonymous `drops.create` is available. */
161
+ get anonymous() {
162
+ return !this.apiKey && !this.bearer;
163
+ }
164
+ me() {
165
+ return this.request("/me");
166
+ }
167
+ drops = {
168
+ list: (status = "active") => this.request(`/drops?status=${status}`).then((r) => r.data),
169
+ get: (id) => this.request(`/drops/${id}`).then((r) => r.data),
170
+ update: (id, patch) => this.request(`/drops/${id}`, { method: "PATCH", body: JSON.stringify(patch) }).then((r) => r.data),
171
+ revoke: (id) => this.request(`/drops/${id}/revoke`, { method: "POST" }),
172
+ /** Moves the drop to Trash (restorable for 30 days), or deletes it right away with `{ forever: true }`. */
173
+ delete: (id, opts = {}) => this.request(`/drops/${id}${opts.forever ? "?forever=1" : ""}`, { method: "DELETE" }),
174
+ analytics: (id, days = 30) => this.request(`/drops/${id}/analytics?days=${days}`).then((r) => r.data),
175
+ comments: (id) => this.request(`/drops/${id}/comments`).then((r) => r.data),
176
+ /** Creates a drop and uploads every file. The link is usable as soon as this resolves. */
177
+ create: async (files, opts = {}) => {
178
+ if (files.length === 0) throw new SlingdropError("Add at least one file.", 400);
179
+ const key = opts.vault ? await generateVaultKey() : null;
180
+ const manifest = await Promise.all(
181
+ files.map(async (f) => {
182
+ const type = f.type || f.data.type || "application/octet-stream";
183
+ if (!key) return { name: f.name, size: f.data.size, type, relativePath: f.name.includes("/") ? f.name : void 0 };
184
+ return { name: await encryptText(key, JSON.stringify({ name: f.name, type })), size: encryptedSize(f.data.size), type: "application/octet-stream" };
185
+ })
186
+ );
187
+ const create = this.anonymous ? (init) => this.publicRequest("/drops", init) : (init) => this.request("/drops", init);
188
+ const created = await create({
189
+ method: "POST",
190
+ signal: opts.signal,
191
+ body: JSON.stringify({
192
+ title: key && opts.title ? await encryptText(key, opts.title) : opts.title,
193
+ visibility: opts.recipients?.length ? "private" : opts.passcode && !key ? "passcode" : "public",
194
+ encryption: key ? "vault" : "none",
195
+ passcode: key ? void 0 : opts.passcode,
196
+ recipients: (opts.recipients ?? []).map((email) => ({ email, role: "viewer" })),
197
+ allowDownload: opts.allowDownload ?? true,
198
+ purpose: opts.purpose ?? "share",
199
+ allowComment: opts.allowComment ?? opts.purpose === "feedback",
200
+ allowEdit: opts.allowEdit ?? false,
201
+ requireApproval: opts.requireApproval ?? false,
202
+ expiry: opts.expiry ?? (this.anonymous ? "24h" : opts.purpose === "feedback" ? "7d" : "forever"),
203
+ vaultKeyCheck: key ? await encryptText(key, "slingdrop-vault") : void 0,
204
+ organizationId: opts.organizationId,
205
+ files: manifest
206
+ })
207
+ });
208
+ const total = manifest.reduce((n, f) => n + f.size, 0);
209
+ const loaded = /* @__PURE__ */ new Map();
210
+ const report = () => opts.onProgress?.([...loaded.values()].reduce((a, b) => a + b, 0), total);
211
+ for (let i = 0; i < created.files.length; i++) {
212
+ await this.upload(created.files[i], files[i].data, key, (n) => {
213
+ loaded.set(created.files[i].id, n);
214
+ report();
215
+ }, opts.signal);
216
+ }
217
+ const shareUrl = key ? `${created.drop.url}#k=${await exportVaultKey(key)}` : created.drop.url;
218
+ return { drop: created.drop, shareUrl, ownerKey: created.ownerKey };
219
+ },
220
+ /** Publishes a new version behind the same link. */
221
+ addVersion: async (id, files, note) => {
222
+ const manifest = files.map((f) => ({ name: f.name, size: f.data.size, type: f.type || f.data.type || "application/octet-stream" }));
223
+ const created = await this.request(`/drops/${id}/versions`, { method: "POST", body: JSON.stringify({ note, files: manifest }) });
224
+ for (let i = 0; i < created.files.length; i++) await this.upload(created.files[i], files[i].data, null, () => {
225
+ });
226
+ return created.version;
227
+ }
228
+ };
229
+ requests = {
230
+ list: () => this.request("/requests").then((r) => r.data),
231
+ create: (input) => this.request("/requests", { method: "POST", body: JSON.stringify(input) })
232
+ };
233
+ /** Review loops: send work for review, read comments and the decision, send new versions. */
234
+ reviews = reviewsApi((path, init) => this.request(path, init));
235
+ /** Developer file hosting: stable public URLs for app assets and your users' uploads. */
236
+ hosting = hostingApi((path, init) => this.request(path, init));
237
+ webhooks = {
238
+ list: () => this.request("/webhooks"),
239
+ create: (url, events) => this.request("/webhooks", { method: "POST", body: JSON.stringify({ url, events }) }),
240
+ delete: (id) => this.request(`/webhooks/${id}`, { method: "DELETE" }),
241
+ deliveries: (id) => this.request(`/webhooks/${id}/deliveries`).then((r) => r.data)
242
+ };
243
+ async publicRequest(path, init = {}) {
244
+ const res = await this.fetchImpl(`${this.baseUrl}/api${path}`, { ...init, headers: { "content-type": "application/json", ...init.headers } });
245
+ const data = await res.json().catch(() => ({}));
246
+ if (!res.ok) throw new SlingdropError(data.error ?? `Request failed (${res.status})`, res.status, data.code);
247
+ return data;
248
+ }
249
+ async put(url, token, body, signal) {
250
+ for (let attempt = 0; ; attempt++) {
251
+ try {
252
+ const res = await this.fetchImpl(url, { method: "PUT", headers: { "x-upload-token": token }, body, signal });
253
+ if (res.ok) return res;
254
+ if (res.status < 500 && res.status !== 429) throw new SlingdropError((await res.json().catch(() => ({}))).error ?? "Upload failed", res.status);
255
+ } catch (err) {
256
+ if (err instanceof SlingdropError || signal?.aborted || attempt >= 6) throw err;
257
+ }
258
+ await new Promise((r) => setTimeout(r, Math.min(15e3, 500 * 2 ** attempt)));
259
+ }
260
+ }
261
+ async upload(plan, data, key, onLoaded, signal) {
262
+ const base = `${this.baseUrl}/api/uploads/${plan.id}`;
263
+ if (plan.mode === "single") {
264
+ let body = data;
265
+ if (key) {
266
+ const parts2 = [];
267
+ for (let i = 0, n = Math.max(1, Math.ceil(data.size / VAULT_CHUNK)); i < n; i++) parts2.push(await encryptChunk(key, new Uint8Array(await data.slice(i * VAULT_CHUNK, (i + 1) * VAULT_CHUNK).arrayBuffer()), i));
268
+ body = new Blob(parts2);
269
+ }
270
+ await this.put(base, plan.token, body, signal);
271
+ onLoaded(body instanceof Blob ? body.size : body.byteLength);
272
+ return;
273
+ }
274
+ const chunk = key ? VAULT_CHUNK : plan.partSize;
275
+ const count = Math.max(1, Math.ceil(data.size / chunk));
276
+ const parts = [];
277
+ let done = 0;
278
+ const queue = Array.from({ length: count }, (_, i) => i);
279
+ await Promise.all(
280
+ Array.from({ length: Math.min(4, count) }, async () => {
281
+ for (let i = queue.shift(); i !== void 0; i = queue.shift()) {
282
+ const slice = data.slice(i * chunk, (i + 1) * chunk);
283
+ const body = key ? await encryptChunk(key, new Uint8Array(await slice.arrayBuffer()), i) : slice;
284
+ const res2 = await this.put(`${base}/parts/${i + 1}`, plan.token, body, signal);
285
+ parts.push(await res2.json());
286
+ done += body instanceof Blob ? body.size : body.byteLength;
287
+ onLoaded(done);
288
+ }
289
+ })
290
+ );
291
+ const res = await this.fetchImpl(`${base}/complete`, { method: "POST", headers: { "content-type": "application/json", "x-upload-token": plan.token }, body: JSON.stringify({ parts }), signal });
292
+ if (!res.ok) throw new SlingdropError("Couldn't finish the upload", res.status);
293
+ }
294
+ };
295
+
296
+ // src/cli.ts
297
+ var VERSION = "1.0.0";
298
+ var CONFIG_DIR = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "slingdrop");
299
+ var CONFIG = join(CONFIG_DIR, "config.json");
300
+ var c = {
301
+ bold: (s) => `\x1B[1m${s}\x1B[22m`,
302
+ dim: (s) => `\x1B[2m${s}\x1B[22m`,
303
+ green: (s) => `\x1B[32m${s}\x1B[39m`,
304
+ cyan: (s) => `\x1B[36m${s}\x1B[39m`,
305
+ red: (s) => `\x1B[31m${s}\x1B[39m`
306
+ };
307
+ async function loadConfig() {
308
+ try {
309
+ return JSON.parse(await readFile(CONFIG, "utf8"));
310
+ } catch {
311
+ return {};
312
+ }
313
+ }
314
+ async function saveConfig(cfg) {
315
+ await mkdir(CONFIG_DIR, { recursive: true });
316
+ await writeFile(CONFIG, JSON.stringify(cfg, null, 2), { mode: 384 });
317
+ }
318
+ function anonymousOrClient(cfg) {
319
+ const apiKey = process.env.SLINGDROP_API_KEY ?? cfg.apiKey;
320
+ return new Slingdrop({ apiKey, baseUrl: process.env.SLINGDROP_URL ?? cfg.baseUrl });
321
+ }
322
+ function client(cfg) {
323
+ const apiKey = process.env.SLINGDROP_API_KEY ?? cfg.apiKey;
324
+ if (!apiKey) {
325
+ console.error(`Not signed in. Run ${c.bold("slingdrop login")} or set SLINGDROP_API_KEY.`);
326
+ process.exit(1);
327
+ }
328
+ return new Slingdrop({ apiKey, baseUrl: process.env.SLINGDROP_URL ?? cfg.baseUrl });
329
+ }
330
+ function parseArgs(argv) {
331
+ const flags = {};
332
+ const positional = [];
333
+ const aliases = { t: "title", p: "passcode", e: "expires", n: "name", h: "help", v: "version", j: "json" };
334
+ for (let i = 0; i < argv.length; i++) {
335
+ const a = argv[i];
336
+ if (a.startsWith("--no-")) flags[a.slice(5)] = false;
337
+ else if (a.startsWith("--")) {
338
+ const [k, v] = a.slice(2).split("=", 2);
339
+ const next = argv[i + 1];
340
+ flags[k] = v ?? (next && !next.startsWith("-") && ["title", "passcode", "expires", "private", "name", "url", "space", "prefix"].includes(k) ? (i++, next) : true);
341
+ } else if (a.startsWith("-") && a.length > 1) {
342
+ const k = aliases[a.slice(1)] ?? a.slice(1);
343
+ const next = argv[i + 1];
344
+ flags[k] = next && !next.startsWith("-") && ["title", "passcode", "expires", "name"].includes(k) ? (i++, next) : true;
345
+ } else positional.push(a);
346
+ }
347
+ return { flags, positional };
348
+ }
349
+ var HELP = `${c.bold("slingdrop")} ${c.dim(`v${VERSION}`)} \u2014 send anything, get a link.
350
+
351
+ ${c.bold("Usage")}
352
+ slingdrop <files or folders\u2026> [options]
353
+ cat build.log | slingdrop --name build.log
354
+
355
+ ${c.bold("Options")}
356
+ -t, --title <text> Title for the drop
357
+ -p, --passcode <code> Protect with a passcode (4\u201312 characters)
358
+ --feedback Ask for comments and approval (default: just share)
359
+ -e, --expires <when> 1h, 24h, 7d, 30d, 90d, forever, burn
360
+ (default: kept until you delete it when signed in,
361
+ 7 days for feedback, 24 hours signed out)
362
+ --private <emails> Only these people (comma-separated) can open it
363
+ --vault End-to-end encrypt on this machine
364
+ --edit Let recipients edit docs and notes
365
+ --approval Ask recipients to approve
366
+ --no-download View only
367
+ --no-qr Don't print a QR code
368
+ -j, --json Print JSON (for scripts)
369
+
370
+ ${c.bold("Commands")}
371
+ login Sign in with your browser
372
+ logout Forget this machine's key
373
+ whoami Show the signed-in account
374
+ ls List your active drops
375
+ open <id|url> Open a drop in your browser
376
+ revoke <id> Turn off a drop's link
377
+ rm <id> [--forever] Move a drop to Trash (or delete it now)
378
+ request <title> Create a file request link
379
+
380
+ ${c.bold("File hosting")} ${c.dim("(stable CDN links for your app)")}
381
+ host <files or folders\u2026> Upload and print public URLs
382
+ --space <name> Space to upload to (created if free; default: yours)
383
+ --prefix <path> Put files under this path, e.g. v2/
384
+ --immutable Cache for a year (use with hashed file names)
385
+ host ls [--prefix p] List hosted files
386
+ host rm <path\u2026> Delete hosted files
387
+ host spaces List your spaces
388
+ host usage Storage, bandwidth and resizes this month
389
+ `;
390
+ function openUrl(url) {
391
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
392
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
393
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
394
+ }
395
+ async function copy(text) {
396
+ const cmd = process.platform === "darwin" ? ["pbcopy"] : process.platform === "win32" ? ["clip"] : ["xclip", "-selection", "clipboard"];
397
+ await new Promise((resolve) => {
398
+ try {
399
+ const p = spawn(cmd[0], cmd.slice(1), { stdio: ["pipe", "ignore", "ignore"] });
400
+ p.on("error", () => resolve());
401
+ p.on("close", () => resolve());
402
+ p.stdin.end(text);
403
+ } catch {
404
+ resolve();
405
+ }
406
+ });
407
+ }
408
+ async function collect(paths) {
409
+ const out = [];
410
+ async function walk(path, root) {
411
+ const s = await stat(path);
412
+ if (s.isDirectory()) {
413
+ for (const entry of await readdir(path)) {
414
+ if (entry === ".DS_Store" || entry === ".git" || entry === "node_modules") continue;
415
+ await walk(join(path, entry), root ?? path);
416
+ }
417
+ } else if (s.isFile()) {
418
+ const name = root ? join(basename(root), relative(root, path)).split("\\").join("/") : basename(path);
419
+ out.push({ name, data: await openAsBlob(path) });
420
+ }
421
+ }
422
+ for (const p of paths) await walk(p, null);
423
+ return out;
424
+ }
425
+ async function readStdin() {
426
+ const chunks = [];
427
+ for await (const chunk of process.stdin) chunks.push(chunk);
428
+ return Buffer.concat(chunks);
429
+ }
430
+ function bar(loaded, total) {
431
+ const width = 28;
432
+ const pct = total ? loaded / total : 1;
433
+ const filled = Math.round(pct * width);
434
+ process.stderr.write(`\r ${c.cyan("\u2588".repeat(filled))}${c.dim("\u2591".repeat(width - filled))} ${Math.round(pct * 100)}%`);
435
+ }
436
+ async function login(cfg, baseUrl) {
437
+ const codeRes = await fetch(`${baseUrl}/api/auth/device/code`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ client_id: "slingdrop-cli", scope: "openid" }) });
438
+ if (!codeRes.ok) throw new Error(`Couldn't start sign-in (${codeRes.status})`);
439
+ const code = await codeRes.json();
440
+ const verify = code.verification_uri_complete ?? `${baseUrl}${code.verification_uri}`;
441
+ const verifyUrl = verify.startsWith("http") ? verify : `${baseUrl}${verify}`;
442
+ console.log(`
443
+ Your code: ${c.bold(code.user_code)}
444
+ Opening ${c.cyan(verifyUrl)} \u2026
445
+ `);
446
+ openUrl(verifyUrl);
447
+ let interval = (code.interval ?? 5) * 1e3;
448
+ for (; ; ) {
449
+ await new Promise((r) => setTimeout(r, interval));
450
+ const res = await fetch(`${baseUrl}/api/auth/device/token`, {
451
+ method: "POST",
452
+ headers: { "content-type": "application/json" },
453
+ body: JSON.stringify({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: code.device_code, client_id: "slingdrop-cli" })
454
+ });
455
+ const data = await res.json();
456
+ if (data.access_token) {
457
+ const keyRes = await fetch(`${baseUrl}/api/me/api-keys`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${data.access_token}` }, body: JSON.stringify({ name: `CLI on ${hostname()}` }) });
458
+ const key = await keyRes.json();
459
+ if (!key.key) throw new Error(key.error ?? "Couldn't create an API key");
460
+ const me = await new Slingdrop({ apiKey: key.key, baseUrl }).me();
461
+ await saveConfig({ ...cfg, apiKey: key.key, baseUrl, email: me.email });
462
+ console.log(` ${c.green("\u2713")} Signed in as ${c.bold(me.email)}`);
463
+ return;
464
+ }
465
+ if (data.error === "slow_down") interval += 2e3;
466
+ else if (data.error && data.error !== "authorization_pending") throw new Error(data.error === "expired_token" ? "The code expired. Run slingdrop login again." : data.error);
467
+ }
468
+ }
469
+ async function collectForHosting(paths, prefix) {
470
+ const out = [];
471
+ const base = prefix.replace(/^\/+|\/+$/g, "");
472
+ async function walk(path, root) {
473
+ const s = await stat(path);
474
+ if (s.isDirectory()) {
475
+ for (const entry of await readdir(path)) {
476
+ if (entry === ".DS_Store" || entry === ".git" || entry === "node_modules") continue;
477
+ await walk(join(path, entry), root ?? path);
478
+ }
479
+ } else if (s.isFile()) {
480
+ const rel = (root ? relative(root, path) : basename(path)).split("\\").join("/");
481
+ out.push({ path: base ? `${base}/${rel}` : rel, data: await openAsBlob(path) });
482
+ }
483
+ }
484
+ for (const p of paths) await walk(p, null);
485
+ return out;
486
+ }
487
+ async function host(sd, args, flags) {
488
+ const [sub, ...rest] = args;
489
+ const spaceFlag = typeof flags.space === "string" ? flags.space : void 0;
490
+ const space = async () => (await sd.hosting.createSpace(spaceFlag)).slug;
491
+ const mb = (n) => n >= 1024 ** 3 ? `${+(n / 1024 ** 3).toFixed(1)} GB` : n >= 1024 ** 2 ? `${+(n / 1024 ** 2).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`;
492
+ if (sub === "spaces") {
493
+ const spaces = await sd.hosting.spaces();
494
+ if (flags.json) return console.log(JSON.stringify(spaces, null, 2));
495
+ if (!spaces.length) return console.log(c.dim("No spaces yet. Run slingdrop host <files> to create one."));
496
+ for (const s of spaces) console.log(`${c.bold(s.slug)} ${c.cyan(s.url)} ${c.dim(`${s.objectCount} files \xB7 ${mb(s.bytesStored)}${s.status === "suspended" ? " \xB7 suspended" : ""}`)}`);
497
+ return;
498
+ }
499
+ if (sub === "usage") {
500
+ const u = await sd.hosting.usage();
501
+ if (flags.json) return console.log(JSON.stringify(u, null, 2));
502
+ const of = (used, limit, fmt = mb) => `${fmt(used)}${limit ? ` of ${fmt(limit)}` : ""}`;
503
+ console.log(`Storage ${of(u.storage.used, u.storage.limit)}${u.storage.sharedWithPlan ? c.dim(" (counts towards your plan storage)") : ""}`);
504
+ console.log(`Bandwidth ${of(u.bandwidth.used, u.bandwidth.limit)}${u.bandwidth.sharedWithDrops ? c.dim(" (shared with drops)") : ""} ${c.dim(`in ${u.month}`)}`);
505
+ console.log(`Resizes ${of(u.transforms.used, u.transforms.limit, (n) => n.toLocaleString())}`);
506
+ console.log(`Largest file ${mb(u.maxFileBytes)}`);
507
+ return;
508
+ }
509
+ if (sub === "ls") {
510
+ const name2 = await space();
511
+ let after;
512
+ const all = [];
513
+ do {
514
+ const page = await sd.hosting.list(name2, { prefix: typeof flags.prefix === "string" ? flags.prefix : void 0, after, limit: 1e3 });
515
+ all.push(...page.objects);
516
+ after = page.next ?? void 0;
517
+ } while (after);
518
+ if (flags.json) return console.log(JSON.stringify(all, null, 2));
519
+ if (!all.length) return console.log(c.dim(`No files in ${name2}.`));
520
+ for (const o of all) console.log(`${c.cyan(o.url)} ${c.dim(mb(o.size))}`);
521
+ return;
522
+ }
523
+ if (sub === "rm") {
524
+ if (!rest.length) throw new Error("Name the paths to delete, e.g. slingdrop host rm img/logo.png");
525
+ const name2 = await space();
526
+ for (const p of rest) {
527
+ await sd.hosting.delete(name2, p);
528
+ console.log(`${c.green("\u2713")} Deleted ${p}`);
529
+ }
530
+ return;
531
+ }
532
+ const inputs = sub ? [sub, ...rest] : [];
533
+ if (!inputs.length) throw new Error("Name files or folders to host, e.g. slingdrop host ./public/assets");
534
+ const files = await collectForHosting(inputs, typeof flags.prefix === "string" ? flags.prefix : "");
535
+ if (!files.length) throw new Error("Nothing to upload.");
536
+ const name = await space();
537
+ const total = files.reduce((n, f) => n + f.data.size, 0);
538
+ if (!flags.json) console.error(` Hosting ${files.length} file${files.length === 1 ? "" : "s"} in ${c.bold(name)} ${c.dim(`(${mb(total)})`)}`);
539
+ const manifest = {};
540
+ const failures = [];
541
+ let done = 0;
542
+ const queue = [...files];
543
+ await Promise.all(
544
+ Array.from({ length: Math.min(4, queue.length) }, async () => {
545
+ for (let f = queue.shift(); f; f = queue.shift()) {
546
+ try {
547
+ const o = await sd.hosting.put(name, f.path, f.data, { immutable: flags.immutable === true });
548
+ manifest[f.path] = o.url;
549
+ if (!flags.json) console.error(` ${c.green("\u2713")} ${o.url}`);
550
+ } catch (err) {
551
+ failures.push(f.path);
552
+ console.error(` ${c.red("\u2717")} ${f.path}: ${err instanceof Error ? err.message : String(err)}`);
553
+ }
554
+ done++;
555
+ if (!flags.json && files.length > 8 && done % 10 === 0) console.error(c.dim(` ${done}/${files.length}`));
556
+ }
557
+ })
558
+ );
559
+ if (flags.json) console.log(JSON.stringify(manifest, null, 2));
560
+ else {
561
+ const first = Object.values(manifest)[0];
562
+ if (first && files.length === 1) await copy(first);
563
+ console.error(`
564
+ ${c.green("\u2713")} ${Object.keys(manifest).length} hosted${failures.length ? `, ${c.red(`${failures.length} failed`)}` : ""}${first && files.length === 1 ? c.dim(" (URL copied)") : ""}`);
565
+ }
566
+ if (failures.length) process.exitCode = 1;
567
+ }
568
+ async function main() {
569
+ const { flags, positional } = parseArgs(process.argv.slice(2));
570
+ const cfg = await loadConfig();
571
+ const baseUrl = String(flags.url ?? process.env.SLINGDROP_URL ?? cfg.baseUrl ?? "https://slingdrop.my");
572
+ const [cmd, ...rest] = positional;
573
+ if (flags.version) return console.log(VERSION);
574
+ if (flags.help || !cmd && process.stdin.isTTY) return console.log(HELP);
575
+ switch (cmd) {
576
+ case "login":
577
+ return login(cfg, baseUrl);
578
+ case "logout":
579
+ await rm(CONFIG, { force: true });
580
+ return console.log("Signed out on this machine. Revoke the key in Settings \u2192 Developer if needed.");
581
+ case "whoami": {
582
+ const me = await client(cfg).me();
583
+ return console.log(`${me.email} ${c.dim(`(${me.plan} plan)`)}`);
584
+ }
585
+ case "ls": {
586
+ const drops = await client(cfg).drops.list();
587
+ if (flags.json) return console.log(JSON.stringify(drops, null, 2));
588
+ if (!drops.length) return console.log(c.dim("No active drops."));
589
+ for (const d of drops) console.log(`${c.dim(d.id)} ${d.title || "Untitled"} ${c.cyan(d.url)} ${c.dim(`${d.viewCount} views`)}`);
590
+ return;
591
+ }
592
+ case "open":
593
+ return openUrl(rest[0]?.startsWith("http") ? rest[0] : `${baseUrl}/d/${rest[0]}`);
594
+ case "revoke":
595
+ await client(cfg).drops.revoke(rest[0]);
596
+ return console.log(`${c.green("\u2713")} Link turned off`);
597
+ case "rm":
598
+ const r = await client(cfg).drops.delete(rest[0], { forever: !!flags.forever });
599
+ return console.log(`${c.green("\u2713")} ${r.trashed ? "Moved to Trash (restore within 30 days)" : "Deleted"}`);
600
+ case "request": {
601
+ const r2 = await client(cfg).requests.create({ title: rest.join(" ") || "Files" });
602
+ await copy(r2.url);
603
+ return console.log(`${c.green("\u2713")} Request link copied: ${c.cyan(r2.url)}`);
604
+ }
605
+ case "host":
606
+ return host(client(cfg), rest, flags);
607
+ }
608
+ let files;
609
+ if (positional.length) files = await collect(positional);
610
+ else {
611
+ const data = await readStdin();
612
+ files = [{ name: String(flags.name ?? "stdin.txt"), data: new Blob([new Uint8Array(data)]) }];
613
+ }
614
+ if (!files.length) throw new Error("Nothing to send.");
615
+ const total = files.reduce((n, f) => n + f.data.size, 0);
616
+ const sd = anonymousOrClient(cfg);
617
+ if (!flags.json) {
618
+ console.error(` Slinging ${files.length} file${files.length === 1 ? "" : "s"} ${c.dim(`(${(total / 1024 / 1024).toFixed(1)} MB)`)}`);
619
+ if (sd.anonymous) console.error(c.dim(` Not signed in: the link lasts up to 24 hours. Run ${c.bold("slingdrop login")} to keep files, use Vault and send private drops.`));
620
+ }
621
+ const result = await sd.drops.create(files, {
622
+ title: flags.title,
623
+ passcode: flags.passcode,
624
+ purpose: flags.feedback === true || flags.approval === true ? "feedback" : "share",
625
+ expiry: flags.expires,
626
+ recipients: typeof flags.private === "string" ? flags.private.split(",").map((s) => s.trim()) : void 0,
627
+ vault: flags.vault === true,
628
+ allowEdit: flags.edit === true,
629
+ requireApproval: flags.approval === true,
630
+ allowDownload: flags.download !== false,
631
+ onProgress: flags.json ? void 0 : bar
632
+ });
633
+ if (flags.json) return console.log(JSON.stringify({ id: result.drop.id, url: result.shareUrl, expiresAt: result.drop.expiresAt }));
634
+ process.stderr.write("\n");
635
+ await copy(result.shareUrl);
636
+ console.log(`
637
+ ${c.green("\u2713")} ${c.bold(result.shareUrl)} ${c.dim("(copied)")}`);
638
+ if (flags.qr !== false && process.stdout.isTTY) console.log(await QRCode.toString(result.shareUrl, { type: "terminal", small: true }));
639
+ }
640
+ main().catch((err) => {
641
+ console.error(`
642
+ ${c.red("\u2717")} ${err instanceof SlingdropError || err instanceof Error ? err.message : String(err)}`);
643
+ if (err instanceof SlingdropError && (err.code === "login_required" || err.code === "plan_required") && !process.env.SLINGDROP_API_KEY) console.error(c.dim(` Run ${c.bold("slingdrop login")} to sign in.`));
644
+ process.exit(1);
645
+ });
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "slingdrop",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.0",
4
+ "description": "Send files, folders or piped output from your terminal and get a Slingdrop link, QR code and clipboard copy.",
5
+ "keywords": [
6
+ "slingdrop",
7
+ "file-sharing",
8
+ "share",
9
+ "upload",
10
+ "cli",
11
+ "transfer",
12
+ "link",
13
+ "qr"
14
+ ],
15
+ "homepage": "https://slingdrop.my/developers",
16
+ "bugs": {
17
+ "email": "support@slingdrop.my"
18
+ },
19
+ "author": "Sansavision",
20
+ "license": "MIT",
21
+ "type": "module",
22
+ "bin": {
23
+ "slingdrop": "./dist/cli.js",
24
+ "sling": "./dist/cli.js"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "dependencies": {
38
+ "qrcode": "^1.5.4"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^26.6.5",
42
+ "@types/qrcode": "^1.5.5",
43
+ "tsup": "^9.0.0",
44
+ "typescript": "^6.0.3",
45
+ "@slingdrop/sdk": "1.0.0"
46
+ },
47
+ "scripts": {
48
+ "build": "tsup src/cli.ts --format esm --clean --target node20",
49
+ "typecheck": "tsc --noEmit"
50
+ }
6
51
  }