drop2run 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,9 @@ drop2run logout Remove the stored token
18
18
  drop2run init [dir] [--site <subdomain>] Tie this folder to a site
19
19
  drop2run deploy [dir] [--site <subdomain>] Publish a folder
20
20
  drop2run init|deploy --subdomain <name> Create a new site under a name you pick
21
+ drop2run init|deploy --folder <path> File the new site in one of your folders
21
22
  drop2run ls List your sites
23
+ drop2run folders List your folders, as --folder takes them
22
24
  drop2run open [site] Open a site in a browser
23
25
  drop2run rollback <deployId> [--site X] Put an earlier version back live
24
26
  drop2run rm <site> --yes Delete a site and everything on it
@@ -52,6 +54,17 @@ which is not undoable.
52
54
  `--site` never creates anything: it finds a site you already have, and fails when nothing matches. That
53
55
  is what keeps a subdomain typed one letter wrong from quietly becoming a second site.
54
56
 
57
+ `--folder` files the site being created in one of your dashboard folders — by path, as `drop2run folders`
58
+ prints it, or by folder id:
59
+
60
+ ```bash
61
+ drop2run init dist --folder Clients/Acme
62
+ ```
63
+
64
+ Case does not matter. It never creates a folder and never moves an existing site, so it cannot be given
65
+ with `--site`, or to `deploy` in a project whose `drop2run.json` already names a site; a folder that does
66
+ not exist is refused with the list of those that do, before any site is made.
67
+
55
68
  `deploy` never creates a second site while a `drop2run.json` sits next to it — that would leave the real
56
69
  site untouched and you looking at a URL you did not expect.
57
70
 
@@ -69,7 +82,7 @@ server on `127.0.0.1`, which is what keeps it out of clipboards and scrollback.
69
82
 
70
83
  ### `--device`, for a machine with no browser
71
84
 
72
- `drop2run login --device` prints a short code and waits. Enter it at <https://dropto.run/device> from any
85
+ `drop2run login --device` prints a short code and waits. Enter it at <https://app.dropto.run/device> from any
73
86
  machine you are already signed in on — a phone will do — and the terminal picks up its token.
74
87
 
75
88
  The short code is not the credential; it names the pending request, so somebody reading it over your
@@ -79,7 +92,7 @@ works once.
79
92
  ### CI
80
93
 
81
94
  Neither flow works in CI — nothing there can open a browser or approve anything. Create a token at
82
- <https://dropto.run/account/tokens> and set it in the environment:
95
+ <https://app.dropto.run/account/tokens> and set it in the environment:
83
96
 
84
97
  ```
85
98
  DROP2RUN_TOKEN=d2r_...
@@ -97,7 +110,7 @@ account" is answerable in an issue report or a CI log.
97
110
 
98
111
  Both need a browser session, and that is deliberate rather than missing. A token that can mint tokens is
99
112
  not a leaked credential but a permanent one: whoever takes it makes a second, and revoking the first
100
- changes nothing. Make and revoke tokens at <https://dropto.run/account/tokens>.
113
+ changes nothing. Make and revoke tokens at <https://app.dropto.run/account/tokens>.
101
114
 
102
115
  `token list` does exist and answers what a terminal can answer — which machines hold a credential, and
103
116
  which of them has not used it since it was made. It prints prefixes, never secrets.
package/dist/index.js CHANGED
@@ -5,75 +5,13 @@ import { spawn } from "node:child_process";
5
5
  import { randomBytes, createHash } from "node:crypto";
6
6
  import { createServer } from "node:http";
7
7
  import { realpath, readdir, stat, readFile } from "node:fs/promises";
8
- async function call(credentials, path, init2 = {}) {
9
- const response = await fetch(`${credentials.apiBaseUrl}/${path}`, {
10
- ...init2,
11
- headers: {
12
- ...init2.headers,
13
- Authorization: `Bearer ${credentials.token}`,
14
- ...init2.body === void 0 ? {} : { "Content-Type": "application/json" }
15
- }
16
- });
17
- if (!response.ok) {
18
- const problem = await response.json().catch(() => null);
19
- throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
20
- }
21
- return await response.json();
22
- }
23
- async function findSite(credentials, site) {
24
- const wanted = site.trim().toLowerCase();
25
- const found = (await listSites(credentials)).find(
26
- (candidate) => candidate.siteId.toLowerCase() === wanted || candidate.subdomain.toLowerCase() === wanted
27
- );
28
- if (found === void 0) {
29
- throw new Error(`No site of yours is called "${site}". \`drop2run ls\` shows what exists.`);
30
- }
31
- return found;
32
- }
33
- async function deleteSite(credentials, siteId) {
34
- const response = await fetch(`${credentials.apiBaseUrl}/sites/${encodeURIComponent(siteId)}`, {
35
- method: "DELETE",
36
- headers: { Authorization: `Bearer ${credentials.token}` }
37
- });
38
- if (!response.ok) {
39
- const problem = await response.json().catch(() => null);
40
- throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
41
- }
42
- }
43
- async function promoteDeploy(credentials, siteId, deployId) {
44
- return await call(
45
- credentials,
46
- `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/promote`,
47
- { method: "POST" }
48
- );
49
- }
50
- async function listTokens(credentials) {
51
- const body = await call(credentials, "tokens");
52
- return body.tokens;
53
- }
54
- async function listSites(credentials) {
55
- const body = await call(credentials, "sites");
56
- return body.sites;
57
- }
58
- async function createSite(credentials, subdomain) {
59
- const created = await call(
60
- credentials,
61
- "sites",
62
- {
63
- method: "POST",
64
- // An absent field rather than a null one, because the API reads null, empty and missing the
65
- // same way and a body carrying only what was asked for is the one that reads correctly in a
66
- // log.
67
- body: JSON.stringify(subdomain === void 0 ? {} : { subdomain })
68
- }
69
- );
70
- return { ...created, name: null };
71
- }
72
8
  const TOKEN_VARIABLE = "DROP2RUN_TOKEN";
73
9
  function configPath() {
74
10
  return join(homedir(), ".config", "drop2run", "config.json");
75
11
  }
76
12
  const DEFAULT_API_BASE_URL = "https://dropto.run/api";
13
+ const PRODUCTION_SITE_ORIGIN = "https://dropto.run";
14
+ const PRODUCTION_DASHBOARD_ORIGIN = "https://app.dropto.run";
77
15
  function loadCredentials(env = process.env, read = (path) => readFileSync(path, "utf8")) {
78
16
  let stored = {};
79
17
  try {
@@ -97,7 +35,8 @@ function resolveApiBaseUrl(env = process.env, read = (path) => readFileSync(path
97
35
  return url.replace(/\/+$/, "");
98
36
  }
99
37
  function dashboardUrlFor(apiBaseUrl) {
100
- return apiBaseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
38
+ const origin = apiBaseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
39
+ return origin === PRODUCTION_SITE_ORIGIN ? PRODUCTION_DASHBOARD_ORIGIN : origin;
101
40
  }
102
41
  function saveToken(token, path = configPath()) {
103
42
  let existing = {};
@@ -140,14 +79,14 @@ function missingCredentialsMessage(surface = "cli") {
140
79
  ] : ["Run `drop2run login` to sign in through a browser, which stores one for you."];
141
80
  const byHand = surface === "mcp" ? [
142
81
  "To set one without signing in here, create a token at",
143
- "https://dropto.run/account/tokens and either:",
82
+ "https://app.dropto.run/account/tokens and either:",
144
83
  ` - put it in ${configPath()} as {"token": "d2r_..."}, which this`,
145
84
  " server reads on every call and needs no restart, or",
146
85
  ` - set ${TOKEN_VARIABLE} in the environment and restart this server, since it reads`,
147
86
  " the environment it was started in."
148
87
  ] : [
149
88
  "For CI, or where no browser can be opened, create a token at",
150
- "https://dropto.run/account/tokens and either:",
89
+ "https://app.dropto.run/account/tokens and either:",
151
90
  ` - set ${TOKEN_VARIABLE} in the environment, or`,
152
91
  ` - put it in ${configPath()} as {"token": "d2r_..."}`
153
92
  ];
@@ -161,6 +100,142 @@ function missingCredentialsMessage(surface = "cli") {
161
100
  "The token is shown once when it is created and cannot be recovered afterwards."
162
101
  ].join("\n");
163
102
  }
103
+ async function call(credentials, path, init2 = {}) {
104
+ const response = await fetch(`${credentials.apiBaseUrl}/${path}`, {
105
+ ...init2,
106
+ headers: {
107
+ ...init2.headers,
108
+ Authorization: `Bearer ${credentials.token}`,
109
+ ...init2.body === void 0 ? {} : { "Content-Type": "application/json" }
110
+ }
111
+ });
112
+ if (!response.ok) {
113
+ const problem = await response.json().catch(() => null);
114
+ throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
115
+ }
116
+ return await response.json();
117
+ }
118
+ async function findSite(credentials, site) {
119
+ const found = await lookUpSite(credentials, site);
120
+ if (found === void 0) {
121
+ throw new Error(`No site of yours is called "${site}". \`drop2run ls\` shows what exists.`);
122
+ }
123
+ return found;
124
+ }
125
+ const SITE_ID_SHAPE = /^[0-9a-z]{26}$/i;
126
+ async function lookUpSite(credentials, site) {
127
+ const wanted = site.trim().toLowerCase();
128
+ if (wanted === "") return void 0;
129
+ const { sites } = await call(
130
+ credentials,
131
+ `sites?pageSize=${SITES_PER_REQUEST}&q=${encodeURIComponent(wanted)}`
132
+ );
133
+ const bySubdomain = sites.find((candidate) => candidate.subdomain.toLowerCase() === wanted);
134
+ if (bySubdomain !== void 0) return bySubdomain;
135
+ if (!SITE_ID_SHAPE.test(wanted)) return void 0;
136
+ const response = await fetch(
137
+ `${credentials.apiBaseUrl}/sites/${encodeURIComponent(wanted.toUpperCase())}`,
138
+ { headers: { Authorization: `Bearer ${credentials.token}` } }
139
+ );
140
+ if (response.status === 404) return void 0;
141
+ if (!response.ok) {
142
+ const problem = await response.json().catch(() => null);
143
+ throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
144
+ }
145
+ const detail = await response.json();
146
+ return { siteId: detail.siteId, subdomain: detail.subdomain, url: detail.url, name: detail.name };
147
+ }
148
+ async function deleteSite(credentials, siteId) {
149
+ const response = await fetch(`${credentials.apiBaseUrl}/sites/${encodeURIComponent(siteId)}`, {
150
+ method: "DELETE",
151
+ headers: { Authorization: `Bearer ${credentials.token}` }
152
+ });
153
+ if (!response.ok) {
154
+ const problem = await response.json().catch(() => null);
155
+ throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
156
+ }
157
+ }
158
+ async function promoteDeploy(credentials, siteId, deployId) {
159
+ return await call(
160
+ credentials,
161
+ `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/promote`,
162
+ { method: "POST" }
163
+ );
164
+ }
165
+ async function listTokens(credentials) {
166
+ const body = await call(credentials, "tokens");
167
+ return body.tokens;
168
+ }
169
+ const SITES_PER_REQUEST = 100;
170
+ async function listSites(credentials) {
171
+ const body = await call(
172
+ credentials,
173
+ `sites?pageSize=${SITES_PER_REQUEST}`
174
+ );
175
+ return { sites: body.sites, total: body.total };
176
+ }
177
+ async function createSite(credentials, subdomain, folderId) {
178
+ const created = await call(
179
+ credentials,
180
+ "sites",
181
+ {
182
+ method: "POST",
183
+ // Absent fields rather than null ones, because the API reads null, empty and missing the same
184
+ // way and a body carrying only what was asked for is the one that reads correctly in a log.
185
+ body: JSON.stringify({
186
+ ...subdomain === void 0 ? {} : { subdomain },
187
+ ...folderId === void 0 ? {} : { folderId }
188
+ })
189
+ }
190
+ );
191
+ return { ...created, name: null };
192
+ }
193
+ async function listFolders(credentials) {
194
+ const body = await call(credentials, "site-folders");
195
+ const byId = new Map(body.folders.map((folder) => [folder.folderId, folder]));
196
+ const pathOf = (folderId) => {
197
+ const names = [];
198
+ let cursor = byId.get(folderId);
199
+ while (cursor !== void 0 && names.length <= byId.size) {
200
+ names.unshift(cursor.name);
201
+ cursor = cursor.parentId === null ? void 0 : byId.get(cursor.parentId);
202
+ }
203
+ return names.join("/");
204
+ };
205
+ return body.folders.map((folder) => ({ folderId: folder.folderId, path: pathOf(folder.folderId) })).sort((a, b) => a.path.localeCompare(b.path));
206
+ }
207
+ function comparablePath(path) {
208
+ return path.split("/").map((name) => name.trim()).filter((name) => name !== "").join("/").toLowerCase();
209
+ }
210
+ const FOLDERS_LISTED_ON_MISS = 20;
211
+ async function resolveFolder(credentials, folder) {
212
+ const wanted = comparablePath(folder);
213
+ if (wanted === "") {
214
+ throw new Error(
215
+ `"${folder}" does not name a folder. Leave the folder out to put the site at the top level.`
216
+ );
217
+ }
218
+ const folders2 = await listFolders(credentials);
219
+ const byId = folders2.find((candidate) => candidate.folderId.toLowerCase() === wanted);
220
+ if (byId !== void 0) return byId.folderId;
221
+ const matches = folders2.filter((candidate) => comparablePath(candidate.path) === wanted);
222
+ if (matches.length === 1 && matches[0] !== void 0) return matches[0].folderId;
223
+ if (matches.length > 1) {
224
+ throw new Error(
225
+ `More than one folder is at "${folder}", because a folder name can contain "/". Name it by id instead: ${matches.map((match) => match.folderId).join(", ")}.`
226
+ );
227
+ }
228
+ if (folders2.length === 0) {
229
+ throw new Error(
230
+ `No folder of yours is called "${folder}" — this account has no folders yet. Make one in the dashboard at ${dashboardUrlFor(credentials.apiBaseUrl)}, or leave the folder out.`
231
+ );
232
+ }
233
+ const listed = folders2.slice(0, FOLDERS_LISTED_ON_MISS).map((candidate) => candidate.path);
234
+ const more = folders2.length - listed.length;
235
+ throw new Error(
236
+ `No folder of yours is called "${folder}". Your folders: ${listed.join(", ")}${more > 0 ? `, and ${more} more` : ""}.`
237
+ );
238
+ }
164
239
  const TIMEOUT_MS = 5 * 60 * 1e3;
165
240
  function newAttempt() {
166
241
  const verifier = randomBytes(32).toString("base64url");
@@ -337,6 +412,12 @@ function writeProject(project, directory = process.cwd()) {
337
412
  `);
338
413
  return path;
339
414
  }
415
+ function sizeOf(content) {
416
+ return ArrayBuffer.isView(content) ? content.byteLength : content.size;
417
+ }
418
+ async function bytesOf(content) {
419
+ return ArrayBuffer.isView(content) ? content : new Uint8Array(await content.arrayBuffer());
420
+ }
340
421
  class DeployError extends Error {
341
422
  /**
342
423
  * @param code Stable code, either an API error type (`quota_exceeded`, `unsafe_path`, …) or one of
@@ -373,20 +454,55 @@ const ClientErrorCode = {
373
454
  /** The user cancelled. */
374
455
  Cancelled: "cancelled",
375
456
  /** An upload kept failing after every retry. */
376
- UploadFailed: "upload_failed"
457
+ UploadFailed: "upload_failed",
458
+ /**
459
+ * The browser could not hold what was dropped in memory. Raised when allocating the buffer for a
460
+ * large zip or file fails, which a browser reports as a `RangeError` rather than as anything that
461
+ * names memory.
462
+ */
463
+ OutOfMemory: "out_of_memory",
464
+ /**
465
+ * A dropped file could not be read when it came to be hashed. Files from a folder stay on disk until
466
+ * then, so one moved, deleted or changed after the drop fails here rather than at the drop.
467
+ */
468
+ ReadFailed: "read_failed",
469
+ /**
470
+ * A control-plane call got no answer: the connection failed before a response arrived, or the
471
+ * request timeout passed. Distinct from an HTTP error, which is an answer — this one says nothing
472
+ * about whether the server acted, which is why only an idempotent call may retry on it.
473
+ */
474
+ NetworkFailed: "network_failed"
377
475
  };
476
+ function describeError(error) {
477
+ const parts = [];
478
+ let current = error;
479
+ for (let depth = 0; depth < 5 && current !== void 0 && current !== null; depth++) {
480
+ if (current instanceof Error) {
481
+ const code = current.code;
482
+ parts.push(typeof code === "string" ? `${current.message} (${code})` : current.message);
483
+ current = current.cause;
484
+ } else {
485
+ parts.push(String(current));
486
+ break;
487
+ }
488
+ }
489
+ return parts.join(" ← ");
490
+ }
378
491
  const ACCOUNT_HEADER = "X-Drop2Run-Account";
379
492
  const CLAIM_TOKEN_HEADER = "X-Claim-Token";
493
+ const DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
494
+ const COMPLETE_RETRY_DELAYS_MS = [1e3, 4e3];
380
495
  async function prepareDeploy(siteId, files, options, signal) {
381
496
  const manifest = files.map((file) => ({
382
497
  path: file.path,
383
498
  sha256: file.sha256,
384
- size: file.bytes.length
499
+ size: sizeOf(file.bytes)
385
500
  }));
386
501
  const prepared = await request(
387
502
  `sites/${encodeURIComponent(siteId)}/deploys/prepare`,
388
503
  { files: manifest },
389
- options
504
+ options,
505
+ signal
390
506
  );
391
507
  return { ...prepared, uploadUrl: absolute(prepared.uploadUrl, options.baseUrl) };
392
508
  }
@@ -396,31 +512,65 @@ function absolute(uploadUrl, baseUrl) {
396
512
  return new URL(uploadUrl, baseUrl).toString();
397
513
  }
398
514
  async function completeDeploy(siteId, deployId, options, signal, name) {
399
- return request(
400
- `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`,
401
- // A bodyless POST when there is no name to send, which is what this call has always been. The
402
- // server treats an absent body and an absent field identically, so nothing depends on which of
403
- // the two a client picks.
404
- name === null || name === void 0 ? void 0 : { name },
405
- options
406
- );
515
+ const path = `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`;
516
+ const body = name === null || name === void 0 ? void 0 : { name };
517
+ const delay = options.delay ?? wait;
518
+ for (let attempt = 0; ; attempt++) {
519
+ try {
520
+ return await request(path, body, options, signal);
521
+ } catch (error) {
522
+ const unanswered = error instanceof DeployError && error.code === ClientErrorCode.NetworkFailed;
523
+ if (!unanswered || attempt >= COMPLETE_RETRY_DELAYS_MS.length || signal?.aborted) throw error;
524
+ await delay(COMPLETE_RETRY_DELAYS_MS[attempt] ?? 1e3);
525
+ }
526
+ }
527
+ }
528
+ function wait(ms) {
529
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
407
530
  }
408
531
  async function request(path, body, options, signal) {
409
532
  const doFetch = options.fetch ?? globalFetch$1();
410
533
  const base = options.baseUrl ?? "/api";
534
+ const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
411
535
  const headers = {};
412
536
  if (body !== void 0) headers["Content-Type"] = "application/json";
413
537
  if (options.claimToken) headers[CLAIM_TOKEN_HEADER] = options.claimToken;
414
538
  if (options.token) headers.Authorization = `Bearer ${options.token}`;
415
539
  if (options.accountId) headers[ACCOUNT_HEADER] = options.accountId;
416
- const response = await doFetch(`${base}/${path}`, {
417
- method: "POST",
418
- headers,
419
- ...body === void 0 ? {} : { body: JSON.stringify(body) },
420
- ...{}
421
- });
422
- if (!response.ok) throw await problemToError(response);
423
- return await response.json();
540
+ const controller = new AbortController();
541
+ let timedOut = false;
542
+ const timer = setTimeout(() => {
543
+ timedOut = true;
544
+ controller.abort();
545
+ }, timeoutMs);
546
+ try {
547
+ const response = await doFetch(`${base}/${path}`, {
548
+ method: "POST",
549
+ headers,
550
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
551
+ signal: controller.signal
552
+ });
553
+ if (!response.ok) throw await problemToError(response);
554
+ return await response.json();
555
+ } catch (error) {
556
+ if (error instanceof DeployError || signal?.aborted) throw error;
557
+ if (timedOut) {
558
+ throw new DeployError(
559
+ ClientErrorCode.NetworkFailed,
560
+ `The server did not answer within ${Math.round(timeoutMs / 1e3)} seconds.`,
561
+ { path, cause: describeError(error) }
562
+ );
563
+ }
564
+ if (error instanceof TypeError) {
565
+ throw new DeployError(ClientErrorCode.NetworkFailed, "Could not reach the server.", {
566
+ path,
567
+ cause: describeError(error)
568
+ });
569
+ }
570
+ throw error;
571
+ } finally {
572
+ clearTimeout(timer);
573
+ }
424
574
  }
425
575
  function globalFetch$1() {
426
576
  return globalThis.fetch.bind(globalThis);
@@ -919,17 +1069,29 @@ function extensionOf(path) {
919
1069
  return dot <= 0 ? "" : name.slice(dot).toLowerCase();
920
1070
  }
921
1071
  async function sha256Hex(bytes) {
922
- const digest = await crypto.subtle.digest("SHA-256", bytes.slice().buffer);
1072
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
923
1073
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
924
1074
  }
925
1075
  async function hashAll(files, onProgress, signal) {
926
1076
  const hashed = [];
927
1077
  for (const [index, file] of files.entries()) {
928
- hashed.push({ ...file, sha256: await sha256Hex(file.bytes) });
1078
+ hashed.push({ ...file, sha256: await sha256Hex(await readForHash(file)) });
929
1079
  onProgress?.(index + 1, files.length);
930
1080
  }
931
1081
  return hashed;
932
1082
  }
1083
+ async function readForHash(file) {
1084
+ try {
1085
+ return await bytesOf(file.bytes);
1086
+ } catch (error) {
1087
+ if (error instanceof RangeError) throw error;
1088
+ throw new DeployError(
1089
+ ClientErrorCode.ReadFailed,
1090
+ `Could not read ${file.path}. It may have been moved, renamed or changed since you dropped it — drop the folder again.`,
1091
+ { path: file.path, cause: describeError(error) }
1092
+ );
1093
+ }
1094
+ }
933
1095
  const IGNORED_EXACT = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".gitignore", ".gitkeep"]);
934
1096
  const IGNORED_DIRECTORIES = [
935
1097
  "__MACOSX",
@@ -939,7 +1101,11 @@ const IGNORED_DIRECTORIES = [
939
1101
  ".hg",
940
1102
  ".idea",
941
1103
  ".vscode",
942
- ".claude"
1104
+ ".claude",
1105
+ ".agents",
1106
+ ".cursor",
1107
+ ".codex",
1108
+ ".gemini"
943
1109
  ];
944
1110
  function shouldIgnore(path) {
945
1111
  const segments = path.split("/");
@@ -1001,7 +1167,7 @@ function checkLimits(files, limits) {
1001
1167
  );
1002
1168
  }
1003
1169
  const refusal = sizeRefusal(
1004
- files.map((file) => ({ path: file.path, size: file.bytes.length })),
1170
+ files.map((file) => ({ path: file.path, size: sizeOf(file.bytes) })),
1005
1171
  limits
1006
1172
  );
1007
1173
  if (refusal) throw refusal;
@@ -1042,13 +1208,24 @@ const HEAD_BYTES = 64 * 1024;
1042
1208
  const TITLE_SEPARATORS = ["|", "·", "—", "–", " - "];
1043
1209
  function suggestSiteName(files) {
1044
1210
  const index = files.find((file) => file.path === "index.html");
1045
- if (index === void 0) return null;
1211
+ if (index === void 0 || !ArrayBuffer.isView(index.bytes)) return null;
1046
1212
  try {
1047
1213
  return nameFromHtml(decodeHead(index.bytes));
1048
1214
  } catch {
1049
1215
  return null;
1050
1216
  }
1051
1217
  }
1218
+ async function readSiteName(files) {
1219
+ const index = files.find((file) => file.path === "index.html");
1220
+ if (index === void 0) return null;
1221
+ if (ArrayBuffer.isView(index.bytes)) return suggestSiteName(files);
1222
+ try {
1223
+ const head = new Uint8Array(await index.bytes.slice(0, HEAD_BYTES).arrayBuffer());
1224
+ return suggestSiteName([{ path: index.path, bytes: head }]);
1225
+ } catch {
1226
+ return null;
1227
+ }
1228
+ }
1052
1229
  function decodeHead(bytes) {
1053
1230
  return new TextDecoder("utf-8", { fatal: false }).decode(
1054
1231
  bytes.subarray(0, HEAD_BYTES)
@@ -1080,7 +1257,7 @@ function matchTag(html, tag) {
1080
1257
  const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(
1081
1258
  html
1082
1259
  );
1083
- return match?.[1] ?? null;
1260
+ return match?.[1] === void 0 ? null : decodeEntities(match[1]);
1084
1261
  }
1085
1262
  function matchOgSiteName(html) {
1086
1263
  const patterns = [
@@ -1089,10 +1266,56 @@ function matchOgSiteName(html) {
1089
1266
  ];
1090
1267
  for (const pattern of patterns) {
1091
1268
  const match = pattern.exec(html);
1092
- if (match?.[1] !== void 0) return match[1];
1269
+ if (match?.[1] !== void 0) return decodeEntities(match[1]);
1093
1270
  }
1094
1271
  return null;
1095
1272
  }
1273
+ const LATIN1_ENTITIES = "nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr deg plusmn sup2 sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest Agrave Aacute Acirc Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml ETH Ntilde Ograve Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig agrave aacute acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute icirc iuml eth ntilde ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml yacute thorn yuml".split(" ");
1274
+ const NAMED_ENTITIES = new Map([
1275
+ ...LATIN1_ENTITIES.map((name, index) => [name, 160 + index]),
1276
+ ["amp", 38],
1277
+ ["lt", 60],
1278
+ ["gt", 62],
1279
+ ["quot", 34],
1280
+ ["apos", 39],
1281
+ ["ensp", 8194],
1282
+ ["emsp", 8195],
1283
+ ["thinsp", 8201],
1284
+ ["ndash", 8211],
1285
+ ["mdash", 8212],
1286
+ ["lsquo", 8216],
1287
+ ["rsquo", 8217],
1288
+ ["sbquo", 8218],
1289
+ ["ldquo", 8220],
1290
+ ["rdquo", 8221],
1291
+ ["bdquo", 8222],
1292
+ ["dagger", 8224],
1293
+ ["Dagger", 8225],
1294
+ ["bull", 8226],
1295
+ ["hellip", 8230],
1296
+ ["prime", 8242],
1297
+ ["lsaquo", 8249],
1298
+ ["rsaquo", 8250],
1299
+ ["euro", 8364],
1300
+ ["trade", 8482],
1301
+ ["larr", 8592],
1302
+ ["rarr", 8594],
1303
+ ["hearts", 9829]
1304
+ ]);
1305
+ function decodeEntities(text) {
1306
+ return text.replace(
1307
+ /&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{1,31}));/g,
1308
+ (whole, decimal, hex, name) => {
1309
+ if (name !== void 0) {
1310
+ const code2 = NAMED_ENTITIES.get(name);
1311
+ return code2 === void 0 ? whole : String.fromCodePoint(code2);
1312
+ }
1313
+ const code = decimal !== void 0 ? Number(decimal) : Number.parseInt(hex ?? "", 16);
1314
+ const valid = code > 0 && code <= 1114111 && (code < 55296 || code > 57343);
1315
+ return String.fromCodePoint(valid ? code : 65533);
1316
+ }
1317
+ );
1318
+ }
1096
1319
  const INVISIBLE = new RegExp(
1097
1320
  `[${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)}]`,
1098
1321
  "g"
@@ -1134,46 +1357,105 @@ function trimSuffix(title) {
1134
1357
  return title;
1135
1358
  }
1136
1359
  const DEFAULT_CONCURRENCY = 8;
1360
+ const DEFAULT_BYTES_IN_FLIGHT = 64 * 1024 * 1024;
1137
1361
  const DEFAULT_RETRIES = 3;
1138
1362
  const BACKOFF_MS = [1e3, 2e3, 4e3];
1139
- async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {}) {
1140
- const doFetch = deps.fetch ?? globalFetch();
1363
+ const DEFAULT_STALL_MS = 3e4;
1364
+ const PROGRESS_INTERVAL_MS = 200;
1365
+ class StalledError extends Error {
1366
+ /** @param seconds How long nothing moved. */
1367
+ constructor(seconds) {
1368
+ super(`No data moved for ${seconds}s.`);
1369
+ this.name = "StalledError";
1370
+ }
1371
+ }
1372
+ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {}, onRetry) {
1373
+ const put = deps.put ?? defaultTransport(deps.fetch);
1141
1374
  const delay = deps.delay ?? defaultDelay;
1142
1375
  const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
1143
1376
  const retries = deps.retries ?? DEFAULT_RETRIES;
1377
+ const stallMs = deps.stallMs ?? DEFAULT_STALL_MS;
1378
+ const now = deps.now ?? Date.now;
1379
+ const maxBytesInFlight = deps.maxBytesInFlight ?? DEFAULT_BYTES_IN_FLIGHT;
1144
1380
  const byPath = new Map(files.map((file) => [file.path, file]));
1145
1381
  let nextIndex = 0;
1146
1382
  let done = 0;
1147
- let uploadedBytes = 0;
1383
+ let failed = false;
1384
+ let reserved = 0;
1385
+ let waiting = [];
1386
+ const acquire = async (size) => {
1387
+ while (reserved > 0 && reserved + size > maxBytesInFlight) {
1388
+ await new Promise((resolve2) => waiting.push(resolve2));
1389
+ }
1390
+ reserved += size;
1391
+ };
1392
+ const release = (size) => {
1393
+ reserved -= size;
1394
+ const woken = waiting;
1395
+ waiting = [];
1396
+ for (const wake of woken) wake();
1397
+ };
1398
+ let landedBytes = 0;
1399
+ const inFlight = /* @__PURE__ */ new Map();
1400
+ let lastReport = Number.NEGATIVE_INFINITY;
1401
+ const report = (path) => {
1402
+ if (onProgress === void 0) return;
1403
+ const at = now();
1404
+ if (path === void 0 && at - lastReport < PROGRESS_INTERVAL_MS) return;
1405
+ lastReport = at;
1406
+ let bytes = landedBytes;
1407
+ for (const sent of inFlight.values()) bytes += sent;
1408
+ onProgress(done, targets.length, bytes, path);
1409
+ };
1148
1410
  const worker = async () => {
1149
1411
  for (; ; ) {
1412
+ if (failed) return;
1150
1413
  const index = nextIndex++;
1151
1414
  const target = targets[index];
1152
1415
  if (target === void 0) return;
1153
1416
  const file = byPath.get(target.path);
1154
1417
  if (file === void 0) {
1418
+ failed = true;
1155
1419
  throw new DeployError(
1156
1420
  ClientErrorCode.UploadFailed,
1157
1421
  `The server asked for ${target.path}, which is not in the manifest.`,
1158
1422
  { path: target.path }
1159
1423
  );
1160
1424
  }
1161
- await uploadOne(target, file, {
1162
- doFetch,
1163
- delay,
1164
- retries,
1165
- signal,
1166
- uploadUrl
1167
- });
1425
+ const size = sizeOf(file.bytes);
1426
+ await acquire(size);
1427
+ try {
1428
+ if (failed) return;
1429
+ signal?.throwIfAborted();
1430
+ await uploadOne(target, file, {
1431
+ put,
1432
+ delay,
1433
+ retries,
1434
+ signal,
1435
+ uploadUrl,
1436
+ stallMs,
1437
+ onSent: (sent) => {
1438
+ inFlight.set(index, sent);
1439
+ report();
1440
+ },
1441
+ onRetry: (attempt, stalled) => {
1442
+ inFlight.set(index, 0);
1443
+ onRetry?.(target.path, attempt, retries + 1, stalled);
1444
+ }
1445
+ });
1446
+ } catch (error) {
1447
+ failed = true;
1448
+ throw error;
1449
+ } finally {
1450
+ release(size);
1451
+ }
1452
+ inFlight.delete(index);
1168
1453
  done += 1;
1169
- uploadedBytes += file.bytes.length;
1170
- onProgress?.(done, targets.length, uploadedBytes, file.path);
1454
+ landedBytes += size;
1455
+ report(file.path);
1171
1456
  }
1172
1457
  };
1173
- const workers = Array.from(
1174
- { length: Math.min(concurrency, targets.length) },
1175
- worker
1176
- );
1458
+ const workers = Array.from({ length: Math.min(concurrency, targets.length) }, worker);
1177
1459
  try {
1178
1460
  await Promise.all(workers);
1179
1461
  } catch (error) {
@@ -1185,39 +1467,122 @@ async function uploadOne(target, file, context) {
1185
1467
  for (let attempt = 0; attempt <= context.retries; attempt++) {
1186
1468
  context.signal?.throwIfAborted();
1187
1469
  if (attempt > 0) {
1470
+ context.onRetry(attempt + 1, lastError instanceof StalledError);
1188
1471
  await context.delay(BACKOFF_MS[attempt - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
1189
1472
  context.signal?.throwIfAborted();
1190
1473
  }
1191
1474
  try {
1192
- const response = await context.doFetch(context.uploadUrl, {
1193
- method: "PUT",
1194
- body: new Blob([file.bytes.slice().buffer]),
1195
- // The permit is the whole request. `Content-Length` is required by the Worker and cannot
1196
- // be set here — it is a forbidden header name — but the browser derives it from the body,
1197
- // which is exactly the length the token names.
1198
- headers: { Authorization: `Bearer ${target.token}` },
1199
- ...context.signal ? { signal: context.signal } : {}
1475
+ const status = await context.put({
1476
+ url: context.uploadUrl,
1477
+ token: target.token,
1478
+ body: untyped(file.bytes),
1479
+ signal: context.signal,
1480
+ onSent: context.onSent,
1481
+ stallMs: context.stallMs
1200
1482
  });
1201
- if (response.ok) return;
1202
- if (response.status === 401 || response.status === 403 || response.status === 429) {
1483
+ if (status >= 200 && status < 300) return;
1484
+ if (status === 401 || status === 403 || status === 429) {
1203
1485
  throw new DeployError(
1204
1486
  ClientErrorCode.UploadFailed,
1205
1487
  `Upload of ${target.path} was refused. The upload window may have expired — try deploying again.`,
1206
- { path: target.path, status: response.status }
1488
+ { path: target.path, status }
1489
+ );
1490
+ }
1491
+ if (status === 413) {
1492
+ throw new DeployError(
1493
+ ClientErrorCode.UploadFailed,
1494
+ `${target.path} is too large to upload in one request. Make it smaller, or leave it out of this publish.`,
1495
+ { path: target.path, status }
1207
1496
  );
1208
1497
  }
1209
- lastError = new Error(`HTTP ${response.status}`);
1498
+ lastError = new Error(`HTTP ${status}`);
1210
1499
  } catch (error) {
1211
1500
  if (error instanceof DeployError) throw error;
1212
1501
  if (isAbort(error)) throw cancelled();
1213
1502
  lastError = error;
1214
1503
  }
1215
1504
  }
1216
- throw new DeployError(
1217
- ClientErrorCode.UploadFailed,
1218
- `Could not upload ${target.path} after ${context.retries + 1} attempts.`,
1219
- { path: target.path, cause: String(lastError) }
1220
- );
1505
+ const message = lastError instanceof StalledError ? `Upload of ${target.path} stopped moving and did not recover after ${context.retries + 1} attempts. Check your connection and try again.` : `Could not upload ${target.path} after ${context.retries + 1} attempts.`;
1506
+ throw new DeployError(ClientErrorCode.UploadFailed, message, {
1507
+ path: target.path,
1508
+ cause: describeError(lastError)
1509
+ });
1510
+ }
1511
+ function untyped(content) {
1512
+ if (ArrayBuffer.isView(content) || content.type === "") return content;
1513
+ return content.slice(0, content.size);
1514
+ }
1515
+ function defaultTransport(doFetch) {
1516
+ if (doFetch !== void 0) return fetchTransport(doFetch);
1517
+ const Xhr = globalThis.XMLHttpRequest;
1518
+ return Xhr === void 0 ? fetchTransport(globalFetch()) : xhrTransport(Xhr);
1519
+ }
1520
+ function fetchTransport(doFetch) {
1521
+ return async ({ url, token, body, signal }) => {
1522
+ const response = await doFetch(url, {
1523
+ method: "PUT",
1524
+ // The view, not a copy. Wrapping it in `new Blob([body.slice().buffer])` made two extra
1525
+ // full-size copies of every file in flight — eight at once, so up to 400 MB on a drop of
1526
+ // 50 MB files. `fetch` sends exactly the range a view covers. The cast is TypeScript's
1527
+ // SharedArrayBuffer distinction again; see `sha256Hex`. A `Blob` goes as it is, and the runtime
1528
+ // streams it from disk.
1529
+ body,
1530
+ // The permit is the whole request. `Content-Length` is required by the Worker and cannot be
1531
+ // set here — it is a forbidden header name — but the runtime derives it from the body, which
1532
+ // is exactly the length the token names.
1533
+ headers: { Authorization: `Bearer ${token}` },
1534
+ ...signal ? { signal } : {}
1535
+ });
1536
+ return response.status;
1537
+ };
1538
+ }
1539
+ function xhrTransport(Xhr) {
1540
+ return ({ url, token, body, signal, onSent, stallMs }) => new Promise((resolve2, reject) => {
1541
+ const xhr = new Xhr();
1542
+ let settled = false;
1543
+ let timer;
1544
+ const settle = (finish) => {
1545
+ if (settled) return;
1546
+ settled = true;
1547
+ clearTimeout(timer);
1548
+ signal?.removeEventListener("abort", onAbort);
1549
+ finish();
1550
+ };
1551
+ const arm = () => {
1552
+ clearTimeout(timer);
1553
+ timer = setTimeout(() => {
1554
+ xhr.abort();
1555
+ settle(() => reject(new StalledError(Math.round(stallMs / 1e3))));
1556
+ }, stallMs);
1557
+ };
1558
+ function onAbort() {
1559
+ xhr.abort();
1560
+ settle(() => reject(abortReason(signal)));
1561
+ }
1562
+ if (signal?.aborted) {
1563
+ reject(abortReason(signal));
1564
+ return;
1565
+ }
1566
+ signal?.addEventListener("abort", onAbort, { once: true });
1567
+ xhr.open("PUT", url);
1568
+ xhr.setRequestHeader("Authorization", `Bearer ${token}`);
1569
+ xhr.upload.onprogress = (event) => {
1570
+ arm();
1571
+ onSent(event.loaded);
1572
+ };
1573
+ xhr.onprogress = arm;
1574
+ xhr.onload = () => settle(() => resolve2(xhr.status));
1575
+ xhr.onerror = () => settle(() => reject(new TypeError("Network error during upload.")));
1576
+ arm();
1577
+ xhr.send(body);
1578
+ });
1579
+ }
1580
+ function abortReason(signal) {
1581
+ const reason = signal?.reason;
1582
+ if (reason instanceof Error) return reason;
1583
+ const error = new Error("Aborted.");
1584
+ error.name = "AbortError";
1585
+ return error;
1221
1586
  }
1222
1587
  function globalFetch() {
1223
1588
  return globalThis.fetch.bind(globalThis);
@@ -1231,9 +1596,17 @@ function isAbort(error) {
1231
1596
  function cancelled() {
1232
1597
  return new DeployError(ClientErrorCode.Cancelled, "Deploy cancelled.");
1233
1598
  }
1599
+ function outOfMemory(detail) {
1600
+ return new DeployError(
1601
+ ClientErrorCode.OutOfMemory,
1602
+ "This browser ran out of memory reading what you dropped. Drop the unzipped folder instead, or try again in a desktop browser with fewer tabs open.",
1603
+ detail
1604
+ );
1605
+ }
1234
1606
  function asDeployError(error) {
1235
1607
  if (error instanceof DeployError) return error;
1236
1608
  if (isAbort(error)) return cancelled();
1609
+ if (error instanceof RangeError) return outOfMemory(error.message);
1237
1610
  return new DeployError(
1238
1611
  ClientErrorCode.UploadFailed,
1239
1612
  error instanceof Error ? error.message : "Upload failed.",
@@ -1242,7 +1615,11 @@ function asDeployError(error) {
1242
1615
  }
1243
1616
  async function deploy(source, siteId, onProgress, signal, options = {}) {
1244
1617
  try {
1245
- const collected = await source(signal);
1618
+ onProgress({ type: "reading", bytes: 0, total: 0 });
1619
+ const collected = await source(
1620
+ signal,
1621
+ (bytes, total) => onProgress({ type: "reading", bytes, total })
1622
+ );
1246
1623
  onProgress({ type: "collecting", files: collected.length });
1247
1624
  const rename = renameOfLoneHtmlPage(collected.map((file) => file.path));
1248
1625
  const files = rename === null ? collected : collected.map((file) => file.path === rename ? { ...file, path: "index.html" } : file);
@@ -1258,6 +1635,21 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1258
1635
  onProgress({ type: "unchanged", url: prepared.url ?? "" });
1259
1636
  return;
1260
1637
  }
1638
+ const sizes = new Map(hashed.map((file) => [file.path, sizeOf(file.bytes)]));
1639
+ const totalBytes = prepared.upload.reduce(
1640
+ (sum, target) => sum + (sizes.get(target.path) ?? 0),
1641
+ 0
1642
+ );
1643
+ if (prepared.upload.length > 0) {
1644
+ onProgress({
1645
+ type: "uploading",
1646
+ done: prepared.reused,
1647
+ total: prepared.total,
1648
+ bytes: 0,
1649
+ totalBytes,
1650
+ reused: prepared.reused
1651
+ });
1652
+ }
1261
1653
  await uploadAll(
1262
1654
  prepared.upload,
1263
1655
  prepared.uploadUrl,
@@ -1267,11 +1659,13 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1267
1659
  done: prepared.reused + done,
1268
1660
  total: prepared.total,
1269
1661
  bytes,
1662
+ totalBytes,
1270
1663
  reused: prepared.reused,
1271
- path
1664
+ ...path === void 0 ? {} : { path }
1272
1665
  }),
1273
1666
  signal,
1274
- options.upload
1667
+ options.upload,
1668
+ (path, attempt, attempts, stalled) => onProgress({ type: "retrying", path, attempt, attempts, stalled })
1275
1669
  );
1276
1670
  onProgress({ type: "completing", reused: prepared.reused });
1277
1671
  const completed = await completeDeploy(
@@ -1279,7 +1673,7 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1279
1673
  prepared.deployId,
1280
1674
  options,
1281
1675
  signal,
1282
- suggestSiteName(files)
1676
+ await readSiteName(files)
1283
1677
  );
1284
1678
  onProgress({ type: "done", url: completed.url, name: completed.name });
1285
1679
  } catch (error) {
@@ -1334,20 +1728,23 @@ function escapesRoot(root, candidate) {
1334
1728
  const inside = relative(root, candidate);
1335
1729
  return inside === "" || inside.startsWith("..");
1336
1730
  }
1337
- async function resolveSite(credentials, site, subdomain) {
1338
- if (subdomain !== void 0) {
1339
- if (site !== void 0) {
1340
- throw new Error(
1341
- "Give either an existing site or a subdomain for a new one, not both: publishing over a site and creating one are different requests."
1342
- );
1343
- }
1344
- return await createSite(credentials, subdomain);
1731
+ async function resolveSite(credentials, site, newSite) {
1732
+ const { subdomain, folder } = newSite;
1733
+ if (site !== void 0 && subdomain !== void 0) {
1734
+ throw new Error(
1735
+ "Give either an existing site or a subdomain for a new one, not both: publishing over a site and creating one are different requests."
1736
+ );
1345
1737
  }
1346
- if (site === void 0) return await createSite(credentials);
1347
- const wanted = site.trim().toLowerCase();
1348
- const found = (await listSites(credentials)).find(
1349
- (candidate) => candidate.siteId.toLowerCase() === wanted || candidate.subdomain.toLowerCase() === wanted
1350
- );
1738
+ if (site !== void 0 && folder !== void 0) {
1739
+ throw new Error(
1740
+ "A folder only says where a new site goes, so it cannot be given with an existing site. Leave the site out to create one in that folder, or move the existing site from the dashboard."
1741
+ );
1742
+ }
1743
+ if (site === void 0) {
1744
+ const folderId = folder === void 0 ? void 0 : await resolveFolder(credentials, folder);
1745
+ return await createSite(credentials, subdomain, folderId);
1746
+ }
1747
+ const found = await lookUpSite(credentials, site);
1351
1748
  if (found === void 0) {
1352
1749
  throw new Error(
1353
1750
  `No site of yours is called "${site}". Leave the site out to publish to a new one, or list your sites to see what exists.`
@@ -1355,8 +1752,8 @@ async function resolveSite(credentials, site, subdomain) {
1355
1752
  }
1356
1753
  return found;
1357
1754
  }
1358
- async function publish(credentials, source, site, onProgress, subdomain) {
1359
- const target = await resolveSite(credentials, site, subdomain);
1755
+ async function publish(credentials, source, site, onProgress, newSite = {}) {
1756
+ const target = await resolveSite(credentials, site, newSite);
1360
1757
  let files = 0;
1361
1758
  let url = target.url;
1362
1759
  let unchanged = false;
@@ -1387,11 +1784,11 @@ async function publish(credentials, source, site, onProgress, subdomain) {
1387
1784
  });
1388
1785
  return { url, siteId: target.siteId, subdomain: target.subdomain, files, unchanged };
1389
1786
  }
1390
- async function publishDirectory(credentials, directory, site, onProgress, subdomain) {
1787
+ async function publishDirectory(credentials, directory, site, onProgress, newSite = {}) {
1391
1788
  const found = await stat(directory).catch(() => null);
1392
1789
  if (found === null) throw new Error(`There is no folder at ${directory}.`);
1393
1790
  if (!found.isDirectory()) throw new Error(`${directory} is a file, not a folder to publish.`);
1394
- return await publish(credentials, directorySource(directory), site, onProgress, subdomain);
1791
+ return await publish(credentials, directorySource(directory), site, onProgress, newSite);
1395
1792
  }
1396
1793
  const silentProgress = Object.assign(() => {
1397
1794
  }, { done: () => {
@@ -1413,6 +1810,10 @@ function shortenPath(path, width) {
1413
1810
  }
1414
1811
  function describe(event, width = 80) {
1415
1812
  switch (event.type) {
1813
+ case "reading":
1814
+ return event.total > 0 ? `Reading ${formatBytes(event.bytes)}/${formatBytes(event.total)}` : "Reading files";
1815
+ case "retrying":
1816
+ return `${event.stalled ? "Upload stalled" : "Upload failed"}, retrying ${shortenPath(event.path, Math.max(12, width - 40))} (attempt ${event.attempt}/${event.attempts})`;
1416
1817
  case "collecting":
1417
1818
  return `Collected ${event.files.toLocaleString()} ${event.files === 1 ? "file" : "files"}`;
1418
1819
  case "hashing":
@@ -1573,7 +1974,7 @@ async function whoami() {
1573
1974
  });
1574
1975
  if (!response.ok) {
1575
1976
  return failure(
1576
- 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}.`
1977
+ response.status === 401 ? "This token is not valid any more. It may have been revoked or expired — create another at https://app.dropto.run/account/tokens." : `The API answered ${response.status}.`
1577
1978
  );
1578
1979
  }
1579
1980
  const me = await response.json();
@@ -1588,33 +1989,67 @@ API: ${found.credentials.apiBaseUrl}`,
1588
1989
  async function list() {
1589
1990
  const found = credentialsOr();
1590
1991
  if ("result" in found) return found.result;
1591
- const sites = await listSites(found.credentials);
1992
+ const { sites, total } = await listSites(found.credentials);
1592
1993
  if (sites.length === 0) {
1593
1994
  return {
1594
1995
  text: "No sites yet. `drop2run deploy` publishes one.",
1595
- json: { sites: [] },
1996
+ json: { sites: [], total: 0 },
1596
1997
  code: 0
1597
1998
  };
1598
1999
  }
2000
+ const lines = sites.map(
2001
+ (site) => `${site.subdomain} ${site.url}${site.name ? ` ${site.name}` : ""}`
2002
+ );
2003
+ if (total > sites.length) {
2004
+ lines.push(
2005
+ `… and ${(total - sites.length).toLocaleString()} more — this lists the newest ${sites.length.toLocaleString()} of ${total.toLocaleString()}.`
2006
+ );
2007
+ }
1599
2008
  return {
1600
- text: sites.map((site) => `${site.subdomain} ${site.url}${site.name ? ` ${site.name}` : ""}`).join("\n"),
1601
- json: { sites },
2009
+ text: lines.join("\n"),
2010
+ json: { sites, total },
1602
2011
  code: 0
1603
2012
  };
1604
2013
  }
1605
- async function deployCommand(directory, site, report = silentProgress, subdomain) {
2014
+ async function folders() {
2015
+ const found = credentialsOr();
2016
+ if ("result" in found) return found.result;
2017
+ try {
2018
+ const all = await listFolders(found.credentials);
2019
+ if (all.length === 0) {
2020
+ return {
2021
+ text: `No folders yet. Make one in the dashboard at ${dashboardUrlFor(found.credentials.apiBaseUrl)}, then file a new site in it with \`--folder\`.`,
2022
+ json: { folders: [] },
2023
+ code: 0
2024
+ };
2025
+ }
2026
+ return {
2027
+ text: all.map((folder) => `${folder.path} ${folder.folderId}`).join("\n"),
2028
+ json: { folders: all },
2029
+ code: 0
2030
+ };
2031
+ } catch (error) {
2032
+ return failure(error instanceof Error ? error.message : String(error));
2033
+ }
2034
+ }
2035
+ async function deployCommand(directory, site, report = silentProgress, newSite = {}) {
1606
2036
  const found = credentialsOr();
1607
2037
  if ("result" in found) return found.result;
1608
2038
  const project = readProject();
1609
2039
  const folder = directory ?? project?.dir ?? ".";
1610
- const target = subdomain === void 0 ? site ?? project?.siteId : void 0;
2040
+ const target = newSite.subdomain === void 0 ? site ?? project?.siteId : void 0;
2041
+ if (newSite.folder !== void 0 && target !== void 0 && project !== null) {
2042
+ return failure(
2043
+ `This folder's ${PROJECT_FILE} already publishes to ${project.subdomain || project.siteId}, and \`--folder\` only files a new site. Move that site from the dashboard, or add \`--subdomain\` to create a new one in the folder.`
2044
+ );
2045
+ }
1611
2046
  try {
1612
2047
  const result = await publishDirectory(
1613
2048
  found.credentials,
1614
2049
  resolve(folder),
1615
2050
  target,
1616
2051
  report,
1617
- subdomain
2052
+ newSite
1618
2053
  );
1619
2054
  const what = result.unchanged ? "Already up to date — nothing needed publishing." : `Published ${result.files.toLocaleString()} ${result.files === 1 ? "file" : "files"}.`;
1620
2055
  return {
@@ -1629,7 +2064,7 @@ ${result.url}`,
1629
2064
  report.done();
1630
2065
  }
1631
2066
  }
1632
- async function init(directory, site, subdomain) {
2067
+ async function init(directory, site, newSite = {}) {
1633
2068
  const found = credentialsOr();
1634
2069
  if ("result" in found) return found.result;
1635
2070
  if (readProject() !== null) {
@@ -1638,7 +2073,11 @@ async function init(directory, site, subdomain) {
1638
2073
  );
1639
2074
  }
1640
2075
  try {
1641
- const target = site === void 0 ? await createSite(found.credentials, subdomain) : await findSite(found.credentials, site);
2076
+ const target = site === void 0 ? await createSite(
2077
+ found.credentials,
2078
+ newSite.subdomain,
2079
+ newSite.folder === void 0 ? void 0 : await resolveFolder(found.credentials, newSite.folder)
2080
+ ) : await findSite(found.credentials, site);
1642
2081
  const project = {
1643
2082
  siteId: target.siteId,
1644
2083
  subdomain: target.subdomain,
@@ -1777,7 +2216,9 @@ const HELP = `drop2run — publish a static site from the command line
1777
2216
  drop2run init [dir] [--site <subdomain>] Tie this folder to a site (writes drop2run.json)
1778
2217
  drop2run deploy [dir] [--site <subdomain>] Publish a folder (default: . or drop2run.json)
1779
2218
  drop2run init|deploy --subdomain <name> Create a new site under a name you pick
2219
+ drop2run init|deploy --folder <path> File the new site in one of your folders
1780
2220
  drop2run ls List your sites
2221
+ drop2run folders List your folders, as --folder takes them
1781
2222
  drop2run open [site] Open a site in a browser
1782
2223
  drop2run rollback <deployId> [--site X] Put an earlier version back live
1783
2224
  drop2run rm <site> --yes Delete a site and everything on it
@@ -1790,6 +2231,8 @@ Flags
1790
2231
  --json Print machine-readable output instead of text
1791
2232
  --site X Act on an existing site rather than the one in drop2run.json
1792
2233
  --subdomain X Create a new site under this name (init and deploy only)
2234
+ --folder X File the new site in this folder: a path such as Clients/Acme,
2235
+ or a folder id (init and deploy only)
1793
2236
  --device Sign in by approving a code on another machine
1794
2237
  --yes Confirm a deletion, which cannot be undone
1795
2238
 
@@ -1801,13 +2244,17 @@ Where a site comes from
1801
2244
  --site only ever finds a site you already have; it never creates one, so a
1802
2245
  subdomain typed wrong fails instead of quietly becoming a second site.
1803
2246
 
2247
+ --folder only applies to a site being created. It never creates a folder
2248
+ and never moves an existing site; make and rearrange folders in the
2249
+ dashboard.
2250
+
1804
2251
  Signing in
1805
2252
  \`login\` opens a browser and listens on 127.0.0.1, so it needs both on this
1806
2253
  machine. Over SSH, in a container or under WSL, use \`login --device\`: it
1807
- prints a short code to enter at https://dropto.run/device from anywhere.
2254
+ prints a short code to enter at https://app.dropto.run/device from anywhere.
1808
2255
 
1809
2256
  For CI, neither flow applies — create a token at
1810
- https://dropto.run/account/tokens and either set DROP2RUN_TOKEN in your
2257
+ https://app.dropto.run/account/tokens and either set DROP2RUN_TOKEN in your
1811
2258
  environment or put it in ~/.config/drop2run/config.json as {"token": "d2r_..."}.
1812
2259
  `;
1813
2260
  function flagValue(args, flag) {
@@ -1816,7 +2263,7 @@ function flagValue(args, flag) {
1816
2263
  const value = args[at + 1];
1817
2264
  return value === void 0 || value.startsWith("-") ? void 0 : value;
1818
2265
  }
1819
- const VALUE_FLAGS = ["--site", "--subdomain"];
2266
+ const VALUE_FLAGS = ["--site", "--subdomain", "--folder"];
1820
2267
  function positionals(argv) {
1821
2268
  const found = [];
1822
2269
  for (let at = 0; at < argv.length; at++) {
@@ -1831,16 +2278,29 @@ function positionals(argv) {
1831
2278
  }
1832
2279
  return found;
1833
2280
  }
1834
- function subdomainFlagProblem(argv, command, site, subdomain) {
1835
- if (!argv.includes("--subdomain")) return void 0;
1836
- if (subdomain === void 0) {
1837
- return "`--subdomain` needs a name after it, for example `--subdomain my-docs`.";
2281
+ const SUBDOMAIN_FLAG = {
2282
+ flag: "--subdomain",
2283
+ example: "my-docs",
2284
+ effect: "`--subdomain` creates a new one",
2285
+ elsewhere: (command) => ` Use \`--site\` to tell \`${command}\` which of your sites to act on.`
2286
+ };
2287
+ const FOLDER_FLAG = {
2288
+ flag: "--folder",
2289
+ example: "Clients/Acme",
2290
+ effect: "`--folder` only files a new one — move an existing site from the dashboard",
2291
+ elsewhere: (command) => command === "ls" || command === "list" ? " `drop2run folders` lists them." : ""
2292
+ };
2293
+ function newSiteFlagProblem(argv, command, site, value, described) {
2294
+ const { flag, example, effect, elsewhere } = described;
2295
+ if (!argv.includes(flag)) return void 0;
2296
+ if (value === void 0) {
2297
+ return `\`${flag}\` needs a value after it, for example \`${flag} ${example}\`.`;
1838
2298
  }
1839
2299
  if (site !== void 0) {
1840
- return "`--site` and `--subdomain` cannot both be given: `--site` publishes to a site you already have, and `--subdomain` creates a new one. Drop whichever is not what you meant.";
2300
+ return `\`--site\` and \`${flag}\` cannot both be given: \`--site\` publishes to a site you already have, and ${effect}. Drop whichever is not what you meant.`;
1841
2301
  }
1842
2302
  if (command !== "init" && command !== "deploy") {
1843
- return `\`--subdomain\` only applies to \`init\` and \`deploy\`, which can create a site. Use \`--site\` to tell \`${command}\` which of your sites to act on.`;
2303
+ return `\`${flag}\` only applies to \`init\` and \`deploy\`, which can create a site.` + elsewhere(command);
1844
2304
  }
1845
2305
  return void 0;
1846
2306
  }
@@ -1849,7 +2309,7 @@ async function run(argv) {
1849
2309
  const positional = positionals(argv);
1850
2310
  const [command, ...rest] = positional;
1851
2311
  if (argv.includes("--version")) {
1852
- const { version } = await import("./package-DXmy47q4.js").then(
2312
+ const { version } = await import("./package-D8mWS-nC.js").then(
1853
2313
  (module) => module.default
1854
2314
  );
1855
2315
  return { text: version, json: { version }, code: 0 };
@@ -1859,7 +2319,8 @@ async function run(argv) {
1859
2319
  if (command === void 0) return { text: HELP, json: { help: HELP }, code: 1 };
1860
2320
  const site = flagValue(argv, "--site");
1861
2321
  const subdomain = flagValue(argv, "--subdomain");
1862
- const refusal = subdomainFlagProblem(argv, command, site, subdomain);
2322
+ const folder = flagValue(argv, "--folder");
2323
+ const refusal = newSiteFlagProblem(argv, command, site, subdomain, SUBDOMAIN_FLAG) ?? newSiteFlagProblem(argv, command, site, folder, FOLDER_FLAG);
1863
2324
  if (refusal !== void 0) {
1864
2325
  const failed = { text: refusal, json: { error: refusal }, code: 1 };
1865
2326
  return json ? { ...failed, text: JSON.stringify(failed.json, null, 2) } : failed;
@@ -1871,11 +2332,11 @@ async function run(argv) {
1871
2332
  json,
1872
2333
  argv.includes("--device"),
1873
2334
  argv.includes("--yes"),
1874
- subdomain
2335
+ { subdomain, folder }
1875
2336
  );
1876
2337
  return json ? { ...result, text: JSON.stringify(result.json, null, 2) } : result;
1877
2338
  }
1878
- async function dispatch(command, rest, site, json, device, confirmed, subdomain) {
2339
+ async function dispatch(command, rest, site, json, device, confirmed, newSite = {}) {
1879
2340
  switch (command) {
1880
2341
  case "login": {
1881
2342
  const print = json ? () => {
@@ -1885,18 +2346,20 @@ async function dispatch(command, rest, site, json, device, confirmed, subdomain)
1885
2346
  case "logout":
1886
2347
  return logout();
1887
2348
  case "init":
1888
- return await init(rest[0] ?? ".", site, subdomain);
2349
+ return await init(rest[0] ?? ".", site, newSite);
1889
2350
  case "deploy": {
1890
2351
  const report = json ? silentProgress : progressWriter(
1891
2352
  (text) => process.stderr.write(text),
1892
2353
  process.stderr.isTTY === true,
1893
2354
  process.stderr.columns ?? 80
1894
2355
  );
1895
- return await deployCommand(rest[0], site, report, subdomain);
2356
+ return await deployCommand(rest[0], site, report, newSite);
1896
2357
  }
1897
2358
  case "ls":
1898
2359
  case "list":
1899
2360
  return await list();
2361
+ case "folders":
2362
+ return await folders();
1900
2363
  case "open":
1901
2364
  return await open(rest[0] ?? site);
1902
2365
  case "rollback":
@@ -1906,7 +2369,7 @@ async function dispatch(command, rest, site, json, device, confirmed, subdomain)
1906
2369
  case "token":
1907
2370
  case "tokens":
1908
2371
  return rest[0] === void 0 || rest[0] === "list" ? await tokens() : {
1909
- text: `\`token ${rest[0]}\` does not exist. Only \`token list\` does: creating and revoking a token need a browser session, at https://dropto.run/account/tokens.`,
2372
+ text: `\`token ${rest[0]}\` does not exist. Only \`token list\` does: creating and revoking a token need a browser session, at https://app.dropto.run/account/tokens.`,
1910
2373
  json: { error: `Unknown token subcommand "${rest[0]}".` },
1911
2374
  code: 1
1912
2375
  };
@@ -1,5 +1,5 @@
1
1
  const name = "drop2run";
2
- const version = "0.5.0";
2
+ const version = "0.7.0";
3
3
  const description = "Publish a page, a folder of documents or a built site to Drop2Run from the command line.";
4
4
  const license = "MIT";
5
5
  const type = "module";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drop2run",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Publish a page, a folder of documents or a built site to Drop2Run from the command line.",
5
5
  "license": "MIT",
6
6
  "type": "module",