drop2run 0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Drop2Run
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 ADDED
@@ -0,0 +1,62 @@
1
+ # drop2run
2
+
3
+ Publish a static site to [Drop2Run](https://dropto.run) from the command line.
4
+
5
+ ```
6
+ drop2run deploy [dir] [--site <subdomain>] Publish a folder (default: .)
7
+ drop2run ls List your sites
8
+ drop2run whoami Check the token and whose it is
9
+ drop2run where Show which token source is in use
10
+ ```
11
+
12
+ `--json` on any command prints machine-readable output instead of text.
13
+
14
+ ## Signing in
15
+
16
+ **There is no `drop2run login` yet.** It needs endpoints the API does not have — the loopback PKCE flow
17
+ in §4 of `docs/briefs/DEVTOOLS-BRIEF.md` — and a `login` that printed "not implemented" would be worse
18
+ than none, so the command does not exist rather than existing and lying.
19
+
20
+ Until then, create a token at <https://dropto.run/account/tokens> and either set it in the environment:
21
+
22
+ ```
23
+ DROP2RUN_TOKEN=d2r_...
24
+ ```
25
+
26
+ or put it in `~/.config/drop2run/config.json`:
27
+
28
+ ```json
29
+ { "token": "d2r_..." }
30
+ ```
31
+
32
+ The environment wins. That is the same file and the same precedence `@drop2run/mcp` uses, so signing in
33
+ once covers both.
34
+
35
+ `drop2run where` says which source is in force without ever printing the token. That is deliberate: the
36
+ output of a command line ends up in issue reports, terminal recordings and CI logs, and "why is it using
37
+ the wrong account" is answerable without showing the secret.
38
+
39
+ ## What `token create` is not
40
+
41
+ The brief's §5 lists `token create|list|revoke`. **Create and revoke cannot exist here**, and that is a
42
+ decision rather than an omission: a token that can mint tokens is not a leaked credential but a permanent
43
+ one — whoever takes it makes a second, and revoking the first changes nothing because the replacement is
44
+ one the owner never made and will not recognise. Both endpoints require a browser session. `gh` and
45
+ `vercel` draw the line in the same place.
46
+
47
+ ## Not published yet
48
+
49
+ `private: true`, because P2 is not finished: without `login` this is a tool that installs and then asks
50
+ you to go and paste a token by hand. The npm name `drop2run` is still unheld — a known risk, recorded in
51
+ §8 of the brief.
52
+
53
+ The bundle itself would work. `@drop2run/core` and `@drop2run/node` are resolved by build aliases rather
54
+ than installed, and `vite build` folds both into `dist/index.js`, so the tarball has no import pointing
55
+ at something npm cannot fetch. What is missing is a reason to release, not a way to.
56
+
57
+ ## Running it from a checkout
58
+
59
+ ```bash
60
+ cd packages/cli && npm run build
61
+ node bin/drop2run.mjs --help
62
+ ```
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Entry point for `drop2run`.
4
+ *
5
+ * Thin on purpose: the shebang and the executable bit are the two things that have to survive being
6
+ * published, and `.gitignore` has already eaten this directory once — see the commit that added the
7
+ * negation for bin directories under packages.
8
+ */
9
+ import { main } from "../dist/index.js";
10
+
11
+ await main(process.argv.slice(2));
package/dist/index.js ADDED
@@ -0,0 +1,717 @@
1
+ import { join, relative, sep, resolve } from "node:path";
2
+ import { readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { realpath, readdir, stat, readFile } from "node:fs/promises";
5
+ async function call(credentials, path, init = {}) {
6
+ const response = await fetch(`${credentials.apiBaseUrl}/${path}`, {
7
+ ...init,
8
+ headers: {
9
+ ...init.headers,
10
+ Authorization: `Bearer ${credentials.token}`,
11
+ ...init.body === void 0 ? {} : { "Content-Type": "application/json" }
12
+ }
13
+ });
14
+ if (!response.ok) {
15
+ const problem = await response.json().catch(() => null);
16
+ throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
17
+ }
18
+ return await response.json();
19
+ }
20
+ async function listSites(credentials) {
21
+ const body = await call(credentials, "sites");
22
+ return body.sites;
23
+ }
24
+ async function createSite(credentials) {
25
+ const created = await call(
26
+ credentials,
27
+ "sites",
28
+ { method: "POST", body: JSON.stringify({}) }
29
+ );
30
+ return { ...created, name: null };
31
+ }
32
+ const TOKEN_VARIABLE = "DROP2RUN_TOKEN";
33
+ function configPath() {
34
+ return join(homedir(), ".config", "drop2run", "config.json");
35
+ }
36
+ const DEFAULT_API_BASE_URL = "https://dropto.run/api";
37
+ function loadCredentials(env = process.env, read = (path) => readFileSync(path, "utf8")) {
38
+ let stored = {};
39
+ try {
40
+ stored = JSON.parse(read(configPath()));
41
+ } catch {
42
+ }
43
+ const token = env[TOKEN_VARIABLE]?.trim() || stored.token?.trim();
44
+ if (!token) return null;
45
+ return {
46
+ token,
47
+ apiBaseUrl: env.DROP2RUN_API_URL?.trim() || stored.apiBaseUrl?.trim() || DEFAULT_API_BASE_URL
48
+ };
49
+ }
50
+ function missingCredentialsMessage() {
51
+ return [
52
+ "No Drop2Run access token.",
53
+ "",
54
+ "Create one at https://dropto.run/account/tokens, then either:",
55
+ ` - set ${TOKEN_VARIABLE} in the environment, or`,
56
+ ` - put it in ${configPath()} as {"token": "d2r_..."}`,
57
+ "",
58
+ "The token is shown once when it is created and cannot be recovered afterwards."
59
+ ].join("\n");
60
+ }
61
+ class DeployError extends Error {
62
+ /**
63
+ * @param code Stable code, either an API error type (`quota_exceeded`, `unsafe_path`, …) or one of
64
+ * the client-side codes in {@link ClientErrorCode}.
65
+ * @param message Text safe to show the user.
66
+ * @param detail Anything extra worth logging, such as the RFC 9457 body from the API.
67
+ */
68
+ constructor(code, message, detail) {
69
+ super(message);
70
+ this.code = code;
71
+ this.detail = detail;
72
+ this.name = "DeployError";
73
+ }
74
+ }
75
+ const ClientErrorCode = {
76
+ /** The drop held no files once junk was filtered out. */
77
+ Empty: "empty_drop",
78
+ /** No `index.html` at the root of the drop. */
79
+ MissingIndex: "missing_index",
80
+ /** The drop is larger than the plan allows; the server enforces this too. */
81
+ TooLarge: "too_large",
82
+ /** A single file is larger than the plan allows. */
83
+ FileTooLarge: "file_too_large",
84
+ /** More files than the plan allows. */
85
+ TooManyFiles: "too_many_files",
86
+ /**
87
+ * A zip claims to expand to more than the plan allows.
88
+ *
89
+ * Distinct from {@link TooLarge}, which is about a drop whose files have already been read. This one
90
+ * is raised from the archive's own directory before anything is decompressed, so the numbers in it
91
+ * are what the zip declared rather than what was measured.
92
+ */
93
+ ZipTooLarge: "zip_too_large",
94
+ /** The user cancelled. */
95
+ Cancelled: "cancelled",
96
+ /** An upload kept failing after every retry. */
97
+ UploadFailed: "upload_failed"
98
+ };
99
+ const CLAIM_TOKEN_HEADER = "X-Claim-Token";
100
+ async function prepareDeploy(siteId, files, options, signal) {
101
+ const manifest = files.map((file) => ({
102
+ path: file.path,
103
+ sha256: file.sha256,
104
+ size: file.bytes.length
105
+ }));
106
+ return request(
107
+ `sites/${encodeURIComponent(siteId)}/deploys/prepare`,
108
+ { files: manifest },
109
+ options
110
+ );
111
+ }
112
+ async function completeDeploy(siteId, deployId, options, signal, name) {
113
+ return request(
114
+ `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`,
115
+ // A bodyless POST when there is no name to send, which is what this call has always been. The
116
+ // server treats an absent body and an absent field identically, so nothing depends on which of
117
+ // the two a client picks.
118
+ name === null || name === void 0 ? void 0 : { name },
119
+ options
120
+ );
121
+ }
122
+ async function request(path, body, options, signal) {
123
+ const doFetch = options.fetch ?? globalFetch$1();
124
+ const base = options.baseUrl ?? "/api";
125
+ const headers = {};
126
+ if (body !== void 0) headers["Content-Type"] = "application/json";
127
+ if (options.claimToken) headers[CLAIM_TOKEN_HEADER] = options.claimToken;
128
+ if (options.token) headers.Authorization = `Bearer ${options.token}`;
129
+ const response = await doFetch(`${base}/${path}`, {
130
+ method: "POST",
131
+ headers,
132
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
133
+ ...{}
134
+ });
135
+ if (!response.ok) throw await problemToError(response);
136
+ return await response.json();
137
+ }
138
+ function globalFetch$1() {
139
+ return globalThis.fetch.bind(globalThis);
140
+ }
141
+ async function problemToError(response) {
142
+ let problem = {};
143
+ try {
144
+ problem = await response.json();
145
+ } catch {
146
+ return new DeployError(
147
+ `http_${response.status}`,
148
+ `The server responded with ${response.status}.`
149
+ );
150
+ }
151
+ const code = typeof problem.type === "string" ? problem.type : `http_${response.status}`;
152
+ const message = typeof problem.detail === "string" ? problem.detail : typeof problem.title === "string" ? problem.title : `The server responded with ${response.status}.`;
153
+ return new DeployError(code, message, problem);
154
+ }
155
+ async function sha256Hex(bytes) {
156
+ const digest = await crypto.subtle.digest("SHA-256", bytes.slice().buffer);
157
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
158
+ }
159
+ async function hashAll(files, onProgress, signal) {
160
+ const hashed = [];
161
+ for (const [index, file] of files.entries()) {
162
+ hashed.push({ ...file, sha256: await sha256Hex(file.bytes) });
163
+ onProgress?.(index + 1, files.length);
164
+ }
165
+ return hashed;
166
+ }
167
+ const IGNORED_EXACT = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".gitignore", ".gitkeep"]);
168
+ const IGNORED_DIRECTORIES = ["__MACOSX", ".git", "node_modules", ".svn", ".hg", ".idea", ".vscode"];
169
+ function shouldIgnore(path) {
170
+ const segments = path.split("/");
171
+ const name = segments.at(-1) ?? "";
172
+ if (name === "" || IGNORED_EXACT.has(name)) return true;
173
+ if (name.startsWith("._")) return true;
174
+ return segments.some((segment) => IGNORED_DIRECTORIES.includes(segment));
175
+ }
176
+ const REQUIRED_INDEX = "index.html";
177
+ const DOCUMENT_EXTENSIONS = [".md", ".markdown", ".pdf"];
178
+ function isDocumentPath(path) {
179
+ const lower = path.toLowerCase();
180
+ return DOCUMENT_EXTENSIONS.some((extension) => lower.endsWith(extension));
181
+ }
182
+ function formatBytes(bytes) {
183
+ if (bytes < 1024) return `${bytes} B`;
184
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
185
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
186
+ }
187
+ function checkLimits(files, limits) {
188
+ if (files.length === 0) {
189
+ throw new DeployError(
190
+ ClientErrorCode.Empty,
191
+ "That drop contained no files to deploy. Folders like .git and node_modules are skipped."
192
+ );
193
+ }
194
+ const hasIndex = files.some((file) => file.path === REQUIRED_INDEX);
195
+ if (!hasIndex && !files.some((file) => isDocumentPath(file.path))) {
196
+ throw new DeployError(
197
+ ClientErrorCode.MissingIndex,
198
+ `A site needs an ${REQUIRED_INDEX} at its top level, or at least one .md or .pdf file to publish as a documents site. Drop the folder that contains it, not the folder above.`
199
+ );
200
+ }
201
+ if (!limits) return;
202
+ if (files.length > limits.maxFiles) {
203
+ throw new DeployError(
204
+ ClientErrorCode.TooManyFiles,
205
+ `That is ${files.length} files, and this plan allows ${limits.maxFiles}.`,
206
+ { limit: limits.maxFiles, actual: files.length }
207
+ );
208
+ }
209
+ const oversized = files.find(
210
+ (file) => file.bytes.length > limits.maxFileBytes
211
+ );
212
+ if (oversized) {
213
+ throw new DeployError(
214
+ ClientErrorCode.FileTooLarge,
215
+ `${oversized.path} is ${formatBytes(oversized.bytes.length)}, and this plan allows ${formatBytes(limits.maxFileBytes)} per file.`,
216
+ {
217
+ path: oversized.path,
218
+ limit: limits.maxFileBytes,
219
+ actual: oversized.bytes.length
220
+ }
221
+ );
222
+ }
223
+ const total = totalBytes(files);
224
+ if (total > limits.maxSiteBytes) {
225
+ throw new DeployError(
226
+ ClientErrorCode.TooLarge,
227
+ `That drop is ${formatBytes(total)}, and this plan allows ${formatBytes(limits.maxSiteBytes)}.`,
228
+ { limit: limits.maxSiteBytes, actual: total }
229
+ );
230
+ }
231
+ }
232
+ function totalBytes(files) {
233
+ return files.reduce((sum, file) => sum + file.bytes.length, 0);
234
+ }
235
+ const MAX_NAME_LENGTH = 60;
236
+ const HEAD_BYTES = 64 * 1024;
237
+ const TITLE_SEPARATORS = ["|", "·", "—", "–", " - "];
238
+ function suggestSiteName(files) {
239
+ const index = files.find((file) => file.path === "index.html");
240
+ if (index === void 0) return null;
241
+ try {
242
+ return nameFromHtml(decodeHead(index.bytes));
243
+ } catch {
244
+ return null;
245
+ }
246
+ }
247
+ function decodeHead(bytes) {
248
+ return new TextDecoder("utf-8", { fatal: false }).decode(
249
+ bytes.subarray(0, HEAD_BYTES)
250
+ );
251
+ }
252
+ function nameFromHtml(html) {
253
+ const document = parse(html);
254
+ const candidates = document ? [
255
+ document.querySelector('meta[property="og:site_name"]')?.getAttribute("content"),
256
+ document.querySelector("title")?.textContent,
257
+ document.querySelector("h1")?.textContent
258
+ ] : [matchOgSiteName(html), matchTag(html, "title"), matchTag(html, "h1")];
259
+ for (const candidate of candidates) {
260
+ const name = clean(candidate);
261
+ if (name !== null) return name;
262
+ }
263
+ return null;
264
+ }
265
+ function parse(html) {
266
+ const parser = globalThis.DOMParser;
267
+ if (parser === void 0) return null;
268
+ try {
269
+ return new parser().parseFromString(html, "text/html");
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+ function matchTag(html, tag) {
275
+ const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(
276
+ html
277
+ );
278
+ return match?.[1] ?? null;
279
+ }
280
+ function matchOgSiteName(html) {
281
+ const patterns = [
282
+ /<meta[^>]*property\s*=\s*["']og:site_name["'][^>]*content\s*=\s*["']([^"']*)["']/i,
283
+ /<meta[^>]*content\s*=\s*["']([^"']*)["'][^>]*property\s*=\s*["']og:site_name["']/i
284
+ ];
285
+ for (const pattern of patterns) {
286
+ const match = pattern.exec(html);
287
+ if (match?.[1] !== void 0) return match[1];
288
+ }
289
+ return null;
290
+ }
291
+ const INVISIBLE = new RegExp(
292
+ `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}${String.fromCharCode(8203)}-${String.fromCharCode(8207)}${String.fromCharCode(8232)}-${String.fromCharCode(8238)}${String.fromCharCode(8288)}-${String.fromCharCode(8292)}${String.fromCharCode(8294)}-${String.fromCharCode(8303)}${String.fromCharCode(65279)}]`,
293
+ "g"
294
+ );
295
+ function clean(raw) {
296
+ if (raw === null || raw === void 0) return null;
297
+ if (raw.includes("<") || raw.includes(">")) return null;
298
+ const collapsed = raw.replace(INVISIBLE, " ").replace(/\s+/g, " ").trim();
299
+ const name = trimSuffix(collapsed);
300
+ if (name.length === 0) return null;
301
+ if (PLACEHOLDERS.has(name.toLowerCase())) return null;
302
+ return name.length > MAX_NAME_LENGTH ? name.slice(0, MAX_NAME_LENGTH).trimEnd() : name;
303
+ }
304
+ const PLACEHOLDERS = /* @__PURE__ */ new Set([
305
+ "document",
306
+ "untitled",
307
+ "untitled document",
308
+ "index",
309
+ "home",
310
+ "new page",
311
+ "my site",
312
+ "vite app",
313
+ "vite + react",
314
+ "react app",
315
+ "create next app",
316
+ "svelte app",
317
+ "webpack app",
318
+ "hello world",
319
+ "title"
320
+ ]);
321
+ function trimSuffix(title) {
322
+ for (const separator of TITLE_SEPARATORS) {
323
+ const at = title.lastIndexOf(separator);
324
+ if (at <= 0) continue;
325
+ const before = title.slice(0, at).trim();
326
+ const after = title.slice(at + separator.length).trim();
327
+ if (before.length > 0 && after.length > 0) return after;
328
+ }
329
+ return title;
330
+ }
331
+ const DEFAULT_CONCURRENCY = 8;
332
+ const DEFAULT_RETRIES = 3;
333
+ const BACKOFF_MS = [1e3, 2e3, 4e3];
334
+ async function uploadAll(targets, files, onProgress, signal, deps = {}) {
335
+ const doFetch = deps.fetch ?? globalFetch();
336
+ const delay = deps.delay ?? defaultDelay;
337
+ const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
338
+ const retries = deps.retries ?? DEFAULT_RETRIES;
339
+ const byPath = new Map(files.map((file) => [file.path, file]));
340
+ let nextIndex = 0;
341
+ let done = 0;
342
+ let uploadedBytes = 0;
343
+ const worker = async () => {
344
+ for (; ; ) {
345
+ const index = nextIndex++;
346
+ const target = targets[index];
347
+ if (target === void 0) return;
348
+ const file = byPath.get(target.path);
349
+ if (file === void 0) {
350
+ throw new DeployError(
351
+ ClientErrorCode.UploadFailed,
352
+ `The server asked for ${target.path}, which is not in the manifest.`,
353
+ { path: target.path }
354
+ );
355
+ }
356
+ await uploadOne(target, file, { doFetch, delay, retries, signal });
357
+ done += 1;
358
+ uploadedBytes += file.bytes.length;
359
+ onProgress?.(done, targets.length, uploadedBytes);
360
+ }
361
+ };
362
+ const workers = Array.from(
363
+ { length: Math.min(concurrency, targets.length) },
364
+ worker
365
+ );
366
+ try {
367
+ await Promise.all(workers);
368
+ } catch (error) {
369
+ throw asDeployError(error);
370
+ }
371
+ }
372
+ async function uploadOne(target, file, context) {
373
+ let lastError;
374
+ for (let attempt = 0; attempt <= context.retries; attempt++) {
375
+ context.signal?.throwIfAborted();
376
+ if (attempt > 0) {
377
+ await context.delay(BACKOFF_MS[attempt - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
378
+ context.signal?.throwIfAborted();
379
+ }
380
+ try {
381
+ const response = await context.doFetch(target.url, {
382
+ method: "PUT",
383
+ body: new Blob([file.bytes.slice().buffer]),
384
+ // Signed into the URL, so this is not optional: storage checks the bytes against it and
385
+ // rejects the PUT if they disagree. `Content-Length` is signed too but cannot be set
386
+ // here — it is a forbidden header name, and the browser derives it from the body, which
387
+ // is exactly the value the server signed.
388
+ headers: { "x-amz-checksum-sha256": toBase64Digest(target.sha256) },
389
+ ...context.signal ? { signal: context.signal } : {}
390
+ });
391
+ if (response.ok) return;
392
+ if (response.status === 403 || response.status === 401) {
393
+ throw new DeployError(
394
+ ClientErrorCode.UploadFailed,
395
+ `Upload of ${target.path} was rejected by storage. The upload window may have expired — try deploying again.`,
396
+ { path: target.path, status: response.status }
397
+ );
398
+ }
399
+ lastError = new Error(`HTTP ${response.status}`);
400
+ } catch (error) {
401
+ if (error instanceof DeployError) throw error;
402
+ if (isAbort(error)) throw cancelled();
403
+ lastError = error;
404
+ }
405
+ }
406
+ throw new DeployError(
407
+ ClientErrorCode.UploadFailed,
408
+ `Could not upload ${target.path} after ${context.retries + 1} attempts.`,
409
+ { path: target.path, cause: String(lastError) }
410
+ );
411
+ }
412
+ function toBase64Digest(sha256Hex2) {
413
+ const bytes = new Uint8Array(sha256Hex2.length / 2);
414
+ for (let i = 0; i < bytes.length; i++) {
415
+ bytes[i] = Number.parseInt(sha256Hex2.slice(i * 2, i * 2 + 2), 16);
416
+ }
417
+ return btoa(String.fromCharCode(...bytes));
418
+ }
419
+ function globalFetch() {
420
+ return globalThis.fetch.bind(globalThis);
421
+ }
422
+ function defaultDelay(ms) {
423
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
424
+ }
425
+ function isAbort(error) {
426
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
427
+ }
428
+ function cancelled() {
429
+ return new DeployError(ClientErrorCode.Cancelled, "Deploy cancelled.");
430
+ }
431
+ function asDeployError(error) {
432
+ if (error instanceof DeployError) return error;
433
+ if (isAbort(error)) return cancelled();
434
+ return new DeployError(
435
+ ClientErrorCode.UploadFailed,
436
+ error instanceof Error ? error.message : "Upload failed.",
437
+ error
438
+ );
439
+ }
440
+ async function deploy(source, siteId, onProgress, signal, options = {}) {
441
+ try {
442
+ const files = await source(signal);
443
+ onProgress({ type: "collecting", files: files.length });
444
+ const hashed = await hashAll(
445
+ files,
446
+ (done, total) => onProgress({ type: "hashing", done, total }),
447
+ signal
448
+ );
449
+ checkLimits(hashed, options.limits ?? null);
450
+ onProgress({ type: "preparing" });
451
+ const prepared = await prepareDeploy(siteId, hashed, options, signal);
452
+ if (prepared.unchanged) {
453
+ onProgress({ type: "unchanged", url: prepared.url ?? "" });
454
+ return;
455
+ }
456
+ await uploadAll(
457
+ prepared.upload,
458
+ hashed,
459
+ (done, _total, bytes) => onProgress({
460
+ type: "uploading",
461
+ done: prepared.reused + done,
462
+ total: prepared.total,
463
+ bytes,
464
+ reused: prepared.reused
465
+ }),
466
+ signal,
467
+ options.upload
468
+ );
469
+ onProgress({ type: "completing", reused: prepared.reused });
470
+ const completed = await completeDeploy(
471
+ siteId,
472
+ prepared.deployId,
473
+ options,
474
+ signal,
475
+ suggestSiteName(files)
476
+ );
477
+ onProgress({ type: "done", url: completed.url, name: completed.name });
478
+ } catch (error) {
479
+ const failure2 = asDeployError(error);
480
+ onProgress({
481
+ type: "error",
482
+ code: failure2.code,
483
+ message: failure2.message,
484
+ ...failure2.detail === void 0 ? {} : { detail: failure2.detail }
485
+ });
486
+ throw failure2;
487
+ }
488
+ }
489
+ const MAX_FILES = 2e4;
490
+ function directorySource(root) {
491
+ return async () => {
492
+ const base = await realpath(root);
493
+ const files = [];
494
+ await walk(base, base, files);
495
+ return files;
496
+ };
497
+ }
498
+ async function walk(root, directory, into) {
499
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
500
+ const full = join(directory, entry.name);
501
+ const path = relative(root, full).split(sep).join("/");
502
+ if (shouldIgnore(path)) continue;
503
+ if (entry.isSymbolicLink()) {
504
+ const target = await realpath(full).catch(() => null);
505
+ if (target === null || escapesRoot(root, target)) continue;
506
+ const targeted = await stat(target);
507
+ if (targeted.isDirectory()) {
508
+ await walk(root, target, into);
509
+ continue;
510
+ }
511
+ if (!targeted.isFile()) continue;
512
+ } else if (entry.isDirectory()) {
513
+ await walk(root, full, into);
514
+ continue;
515
+ } else if (!entry.isFile()) {
516
+ continue;
517
+ }
518
+ if (into.length >= MAX_FILES) {
519
+ throw new Error(
520
+ `This folder holds more than ${MAX_FILES.toLocaleString()} files. Publish a build output directory rather than a whole project.`
521
+ );
522
+ }
523
+ into.push({ path, bytes: new Uint8Array(await readFile(full)) });
524
+ }
525
+ }
526
+ function escapesRoot(root, candidate) {
527
+ const inside = relative(root, candidate);
528
+ return inside === "" || inside.startsWith("..");
529
+ }
530
+ async function resolveSite(credentials, site) {
531
+ if (site === void 0) return await createSite(credentials);
532
+ const wanted = site.trim().toLowerCase();
533
+ const found = (await listSites(credentials)).find(
534
+ (candidate) => candidate.siteId.toLowerCase() === wanted || candidate.subdomain.toLowerCase() === wanted
535
+ );
536
+ if (found === void 0) {
537
+ throw new Error(
538
+ `No site of yours is called "${site}". Leave the site out to publish to a new one, or use list_sites to see what exists.`
539
+ );
540
+ }
541
+ return found;
542
+ }
543
+ async function publish(credentials, source, site) {
544
+ const target = await resolveSite(credentials, site);
545
+ let files = 0;
546
+ let url = target.url;
547
+ let unchanged = false;
548
+ let failure2 = null;
549
+ const record = (event) => {
550
+ if (event.type === "collecting") files = event.files;
551
+ if (event.type === "unchanged") {
552
+ unchanged = true;
553
+ url = event.url || target.url;
554
+ }
555
+ if (event.type === "done") url = event.url;
556
+ if (event.type === "error") failure2 = event.message;
557
+ };
558
+ await deploy(source, target.siteId, record, void 0, {
559
+ baseUrl: credentials.apiBaseUrl,
560
+ token: credentials.token
561
+ }).catch((error) => {
562
+ throw new Error(failure2 ?? (error instanceof Error ? error.message : String(error)));
563
+ });
564
+ return { url, siteId: target.siteId, subdomain: target.subdomain, files, unchanged };
565
+ }
566
+ function publishDirectory(credentials, directory, site) {
567
+ return publish(credentials, directorySource(directory), site);
568
+ }
569
+ function failure(text) {
570
+ return { text, json: { error: text }, code: 1 };
571
+ }
572
+ function credentialsOr() {
573
+ const credentials = loadCredentials();
574
+ if (credentials === null) return { result: failure(missingCredentialsMessage()) };
575
+ return { credentials };
576
+ }
577
+ async function whoami() {
578
+ const found = credentialsOr();
579
+ if ("result" in found) return found.result;
580
+ const response = await fetch(`${found.credentials.apiBaseUrl}/me`, {
581
+ headers: { Authorization: `Bearer ${found.credentials.token}` }
582
+ });
583
+ if (!response.ok) {
584
+ return failure(
585
+ response.status === 401 ? "This token is not valid any more. It may have been revoked or expired — create another at https://dropto.run/account/tokens." : `The API answered ${response.status}.`
586
+ );
587
+ }
588
+ const me = await response.json();
589
+ const plan = me.plan?.name ? ` on ${me.plan.name}` : "";
590
+ return {
591
+ text: `${me.email}${plan}
592
+ API: ${found.credentials.apiBaseUrl}`,
593
+ json: me,
594
+ code: 0
595
+ };
596
+ }
597
+ async function list() {
598
+ const found = credentialsOr();
599
+ if ("result" in found) return found.result;
600
+ const sites = await listSites(found.credentials);
601
+ if (sites.length === 0) {
602
+ return {
603
+ text: "No sites yet. `drop2run deploy` publishes one.",
604
+ json: { sites: [] },
605
+ code: 0
606
+ };
607
+ }
608
+ return {
609
+ text: sites.map((site) => `${site.subdomain} ${site.url}${site.name ? ` ${site.name}` : ""}`).join("\n"),
610
+ json: { sites },
611
+ code: 0
612
+ };
613
+ }
614
+ async function deployCommand(directory, site) {
615
+ const found = credentialsOr();
616
+ if ("result" in found) return found.result;
617
+ try {
618
+ const result = await publishDirectory(found.credentials, resolve(directory), site);
619
+ const what = result.unchanged ? "Already up to date — nothing needed publishing." : `Published ${result.files.toLocaleString()} ${result.files === 1 ? "file" : "files"}.`;
620
+ return {
621
+ text: `${what}
622
+ ${result.url}`,
623
+ json: result,
624
+ code: 0
625
+ };
626
+ } catch (error) {
627
+ return failure(error instanceof Error ? error.message : String(error));
628
+ }
629
+ }
630
+ function where() {
631
+ const fromEnvironment = process.env[TOKEN_VARIABLE]?.trim();
632
+ const credentials = loadCredentials();
633
+ const source = fromEnvironment ? `${TOKEN_VARIABLE} (environment)` : credentials === null ? "nowhere — no token found" : configPath();
634
+ return {
635
+ text: [
636
+ `Token source: ${source}`,
637
+ `Config file: ${configPath()}`,
638
+ `API: ${credentials?.apiBaseUrl ?? "—"}`
639
+ ].join("\n"),
640
+ json: {
641
+ tokenSource: source,
642
+ configPath: configPath(),
643
+ apiBaseUrl: credentials?.apiBaseUrl ?? null
644
+ },
645
+ code: credentials === null ? 1 : 0
646
+ };
647
+ }
648
+ const HELP = `drop2run — publish a static site from the command line
649
+
650
+ drop2run deploy [dir] [--site <subdomain>] Publish a folder (default: .)
651
+ drop2run ls List your sites
652
+ drop2run whoami Check the token and whose it is
653
+ drop2run where Show which token source is in use
654
+ drop2run --version Print the version
655
+
656
+ Flags
657
+ --json Print machine-readable output instead of text
658
+ --site X Publish over an existing site rather than creating one
659
+
660
+ Signing in
661
+ There is no \`drop2run login\` yet. Create a token at
662
+ https://dropto.run/account/tokens and either set DROP2RUN_TOKEN in your
663
+ environment or put it in ~/.config/drop2run/config.json as {"token": "d2r_..."}.
664
+ `;
665
+ function flagValue(args, flag) {
666
+ const at = args.indexOf(flag);
667
+ if (at === -1) return void 0;
668
+ const value = args[at + 1];
669
+ return value === void 0 || value.startsWith("-") ? void 0 : value;
670
+ }
671
+ async function run(argv) {
672
+ const json = argv.includes("--json");
673
+ const positional = argv.filter((argument) => !argument.startsWith("-"));
674
+ const [command, ...rest] = positional;
675
+ if (argv.includes("--version")) {
676
+ const { version } = await import("./package-VuVin5yv.js").then(
677
+ (module) => module.default
678
+ );
679
+ return { text: version, json: { version }, code: 0 };
680
+ }
681
+ const askedForHelp = argv.includes("--help") || argv.includes("-h") || command === "help";
682
+ if (askedForHelp) return { text: HELP, json: { help: HELP }, code: 0 };
683
+ if (command === void 0) return { text: HELP, json: { help: HELP }, code: 1 };
684
+ const site = flagValue(argv, "--site");
685
+ const result = await dispatch(command, rest, site);
686
+ return json ? { ...result, text: JSON.stringify(result.json, null, 2) } : result;
687
+ }
688
+ async function dispatch(command, rest, site) {
689
+ switch (command) {
690
+ case "deploy":
691
+ return await deployCommand(rest[0] ?? ".", site);
692
+ case "ls":
693
+ case "list":
694
+ return await list();
695
+ case "whoami":
696
+ return await whoami();
697
+ case "where":
698
+ return where();
699
+ default:
700
+ return {
701
+ text: `Unknown command "${command}".
702
+
703
+ ${HELP}`,
704
+ json: { error: `Unknown command "${command}".` },
705
+ code: 1
706
+ };
707
+ }
708
+ }
709
+ async function main(argv) {
710
+ const result = await run(argv);
711
+ if (result.code === 0) console.log(result.text);
712
+ else console.error(result.text);
713
+ process.exit(result.code);
714
+ }
715
+ export {
716
+ main
717
+ };
@@ -0,0 +1,44 @@
1
+ const name = "drop2run";
2
+ const version = "0.0.0";
3
+ const description = "Publish a static site to Drop2Run from the command line.";
4
+ const license = "MIT";
5
+ const type = "module";
6
+ const bin = { "drop2run": "bin/drop2run.mjs" };
7
+ const files = ["bin", "dist"];
8
+ const engines = { "node": ">=20" };
9
+ const publishConfig = { "access": "public" };
10
+ const homepage = "https://dropto.run";
11
+ const keywords = ["static-site", "deploy", "hosting", "cli"];
12
+ const scripts = { "build": "vite build", "typecheck": "tsc --noEmit", "test": "vitest run", "lint": "biome check .", "format": "biome check --write ." };
13
+ const devDependencies = { "@biomejs/biome": "^2.5.11", "@types/node": "^26.4.0", "typescript": "^5.9.3", "vite": "^7.2.1", "vitest": "^4.1.11" };
14
+ const _package = {
15
+ name,
16
+ version,
17
+ description,
18
+ license,
19
+ type,
20
+ bin,
21
+ files,
22
+ engines,
23
+ publishConfig,
24
+ homepage,
25
+ keywords,
26
+ scripts,
27
+ devDependencies
28
+ };
29
+ export {
30
+ bin,
31
+ _package as default,
32
+ description,
33
+ devDependencies,
34
+ engines,
35
+ files,
36
+ homepage,
37
+ keywords,
38
+ license,
39
+ name,
40
+ publishConfig,
41
+ scripts,
42
+ type,
43
+ version
44
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "drop2run",
3
+ "version": "0.0.0",
4
+ "description": "Publish a static site to Drop2Run from the command line.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "drop2run": "bin/drop2run.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "dist"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "homepage": "https://dropto.run",
21
+ "keywords": [
22
+ "static-site",
23
+ "deploy",
24
+ "hosting",
25
+ "cli"
26
+ ],
27
+ "scripts": {
28
+ "build": "vite build",
29
+ "typecheck": "tsc --noEmit",
30
+ "test": "vitest run",
31
+ "lint": "biome check .",
32
+ "format": "biome check --write ."
33
+ },
34
+ "devDependencies": {
35
+ "@biomejs/biome": "^2.5.11",
36
+ "@types/node": "^26.4.0",
37
+ "typescript": "^5.9.3",
38
+ "vite": "^7.2.1",
39
+ "vitest": "^4.1.11"
40
+ }
41
+ }