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.
- package/LICENSE +21 -0
- package/README.md +59 -2
- package/dist/cli.js +645 -0
- 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
|
-
#
|
|
1
|
+
# slingdrop
|
|
2
2
|
|
|
3
|
-
|
|
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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
}
|