drop2run 0.6.0 → 0.8.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/dist/index.js CHANGED
@@ -10,6 +10,8 @@ function configPath() {
10
10
  return join(homedir(), ".config", "drop2run", "config.json");
11
11
  }
12
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";
13
15
  function loadCredentials(env = process.env, read = (path) => readFileSync(path, "utf8")) {
14
16
  let stored = {};
15
17
  try {
@@ -33,7 +35,8 @@ function resolveApiBaseUrl(env = process.env, read = (path) => readFileSync(path
33
35
  return url.replace(/\/+$/, "");
34
36
  }
35
37
  function dashboardUrlFor(apiBaseUrl) {
36
- return apiBaseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
38
+ const origin = apiBaseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
39
+ return origin === PRODUCTION_SITE_ORIGIN ? PRODUCTION_DASHBOARD_ORIGIN : origin;
37
40
  }
38
41
  function saveToken(token, path = configPath()) {
39
42
  let existing = {};
@@ -76,14 +79,14 @@ function missingCredentialsMessage(surface = "cli") {
76
79
  ] : ["Run `drop2run login` to sign in through a browser, which stores one for you."];
77
80
  const byHand = surface === "mcp" ? [
78
81
  "To set one without signing in here, create a token at",
79
- "https://dropto.run/account/tokens and either:",
82
+ "https://app.dropto.run/account/tokens and either:",
80
83
  ` - put it in ${configPath()} as {"token": "d2r_..."}, which this`,
81
84
  " server reads on every call and needs no restart, or",
82
85
  ` - set ${TOKEN_VARIABLE} in the environment and restart this server, since it reads`,
83
86
  " the environment it was started in."
84
87
  ] : [
85
88
  "For CI, or where no browser can be opened, create a token at",
86
- "https://dropto.run/account/tokens and either:",
89
+ "https://app.dropto.run/account/tokens and either:",
87
90
  ` - set ${TOKEN_VARIABLE} in the environment, or`,
88
91
  ` - put it in ${configPath()} as {"token": "d2r_..."}`
89
92
  ];
@@ -115,7 +118,9 @@ async function call(credentials, path, init2 = {}) {
115
118
  async function findSite(credentials, site) {
116
119
  const found = await lookUpSite(credentials, site);
117
120
  if (found === void 0) {
118
- throw new Error(`No site of yours is called "${site}". \`drop2run ls\` shows what exists.`);
121
+ throw new Error(
122
+ `No site of yours is called "${site}". \`drop2run ls\` or the list_sites tool shows what exists.`
123
+ );
119
124
  }
120
125
  return found;
121
126
  }
@@ -159,6 +164,108 @@ async function promoteDeploy(credentials, siteId, deployId) {
159
164
  { method: "POST" }
160
165
  );
161
166
  }
167
+ function enumName(value) {
168
+ return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
169
+ }
170
+ function actionOf(expiresAt, action) {
171
+ return expiresAt === null ? null : enumName(action);
172
+ }
173
+ async function readInvitedOnly(credentials, siteId) {
174
+ try {
175
+ const page = await call(
176
+ credentials,
177
+ `sites/${encodeURIComponent(siteId)}/viewers?pageSize=1`
178
+ );
179
+ return typeof page.invitedOnly === "boolean" ? page.invitedOnly : null;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+ async function folderFromListing(credentials, siteId, subdomain) {
185
+ const { sites } = await call(
186
+ credentials,
187
+ `sites?pageSize=${SITES_PER_REQUEST}&q=${encodeURIComponent(subdomain)}`
188
+ );
189
+ return sites.find((candidate) => candidate.siteId === siteId)?.folderId ?? null;
190
+ }
191
+ async function getSite(credentials, siteId) {
192
+ const [site, invitedOnly] = await Promise.all([
193
+ call(
194
+ credentials,
195
+ `sites/${encodeURIComponent(siteId)}`
196
+ ),
197
+ readInvitedOnly(credentials, siteId)
198
+ ]);
199
+ const folderId = site.folderId !== void 0 ? site.folderId : await folderFromListing(credentials, site.siteId, site.subdomain);
200
+ return {
201
+ siteId: site.siteId,
202
+ subdomain: site.subdomain,
203
+ url: site.url,
204
+ name: site.name,
205
+ status: enumName(site.status),
206
+ invitedOnly,
207
+ liveDeployId: site.liveDeployId,
208
+ spaMode: site.spaMode,
209
+ docsMode: site.docsMode,
210
+ modeIsManual: site.modeIsManual,
211
+ passwordProtected: site.passwordProtected,
212
+ passwordProtectionAvailable: site.passwordProtectionAvailable,
213
+ formsEnabled: site.formsEnabled,
214
+ formsAvailable: site.formsAvailable,
215
+ expiresAt: site.expiresAt,
216
+ expiryAction: actionOf(site.expiresAt, site.expiryAction ?? ""),
217
+ scheduledExpiryAvailable: site.scheduledExpiryAvailable,
218
+ folderId,
219
+ deploys: site.deploys.map((deploy2) => ({
220
+ deployId: deploy2.deployId,
221
+ status: deploy2.status,
222
+ fileCount: deploy2.fileCount,
223
+ totalBytes: deploy2.totalBytes,
224
+ createdAt: deploy2.createdAt,
225
+ completedAt: deploy2.completedAt,
226
+ filesKept: deploy2.filesKept
227
+ }))
228
+ };
229
+ }
230
+ async function updateSiteSettings(credentials, siteId, change) {
231
+ const body = {};
232
+ if (change.name !== void 0) body.name = change.name;
233
+ if (change.mode !== void 0) {
234
+ body.spaMode = change.mode === "spa";
235
+ body.docsMode = change.mode === "docs";
236
+ }
237
+ if (change.password !== void 0) body.password = change.password;
238
+ if (change.expiresAt !== void 0) body.expiresAt = change.expiresAt;
239
+ if (change.expiryAction !== void 0) body.expiryAction = change.expiryAction;
240
+ if (change.formsEnabled !== void 0) body.formsEnabled = change.formsEnabled;
241
+ if (change.folderId !== void 0) body.folderId = change.folderId;
242
+ if (Object.keys(body).length === 0) throw new Error("Nothing to change — name a setting.");
243
+ const wasInvitedOnly = change.password !== void 0 && change.password !== "" ? await readInvitedOnly(credentials, siteId) : null;
244
+ const settings = await call(credentials, `sites/${encodeURIComponent(siteId)}`, {
245
+ method: "PATCH",
246
+ body: JSON.stringify(body)
247
+ });
248
+ const invitedOnly = typeof settings.invitedOnly === "boolean" ? settings.invitedOnly : null;
249
+ return {
250
+ ...settings,
251
+ expiryAction: actionOf(settings.expiresAt, settings.expiryAction ?? ""),
252
+ invitedOnly,
253
+ replacedInviteOnly: wasInvitedOnly === true && invitedOnly === false
254
+ };
255
+ }
256
+ async function setSitePaused(credentials, siteId, action) {
257
+ const state = await call(
258
+ credentials,
259
+ `sites/${encodeURIComponent(siteId)}/${action}`,
260
+ { method: "POST" }
261
+ );
262
+ return {
263
+ siteId: state.siteId,
264
+ subdomain: state.subdomain,
265
+ status: enumName(state.status),
266
+ ownerPaused: state.ownerPaused
267
+ };
268
+ }
162
269
  async function listTokens(credentials) {
163
270
  const body = await call(credentials, "tokens");
164
271
  return body.tokens;
@@ -233,6 +340,46 @@ async function resolveFolder(credentials, folder) {
233
340
  `No folder of yours is called "${folder}". Your folders: ${listed.join(", ")}${more > 0 ? `, and ${more} more` : ""}.`
234
341
  );
235
342
  }
343
+ const ROOT_FOLDER = "root";
344
+ async function resolveFolderTarget(credentials, folder) {
345
+ return folder.trim().toLowerCase() === ROOT_FOLDER ? ROOT_FOLDER : await resolveFolder(credentials, folder);
346
+ }
347
+ const VERSIONS_LISTED = 10;
348
+ function servingModeOf$1(site) {
349
+ if (site.spaMode) return "spa";
350
+ if (site.docsMode) return "docs";
351
+ return "static";
352
+ }
353
+ function accessOf(site) {
354
+ if (site.passwordProtected) return "password";
355
+ if (site.invitedOnly === true) return "invite-only";
356
+ if (site.invitedOnly === false) return "public";
357
+ return "no password (whether it is invite-only could not be read)";
358
+ }
359
+ function describeSite(site, folderPath) {
360
+ const unavailable = (available) => available ? "" : " (not on this plan)";
361
+ const versions = site.deploys.slice(0, VERSIONS_LISTED).map((deploy2) => {
362
+ const live = deploy2.deployId === site.liveDeployId ? " ← served" : "";
363
+ const kept = deploy2.filesKept ? "" : ", files collected — cannot be rolled back to";
364
+ return ` ${deploy2.deployId} ${deploy2.createdAt} ${deploy2.status}, ${deploy2.fileCount} files${kept}${live}`;
365
+ });
366
+ const more = site.deploys.length - versions.length;
367
+ return [
368
+ `${site.subdomain} — ${site.url}`,
369
+ ` name ${site.name ?? "(none)"}`,
370
+ ` status ${site.status}`,
371
+ ` access ${accessOf(site)}`,
372
+ ` mode ${servingModeOf$1(site)}${site.modeIsManual ? "" : " (detected on each publish)"}`,
373
+ ` password ${site.passwordProtected ? "on" : "off"}${unavailable(site.passwordProtectionAvailable)}`,
374
+ ` forms ${site.formsEnabled ? "on" : "off"}${unavailable(site.formsAvailable)}`,
375
+ ` takedown ${site.expiresAt === null ? "none" : `${site.expiresAt} (${site.expiryAction})`}${unavailable(site.scheduledExpiryAvailable)}`,
376
+ ` folder ${site.folderId === null ? "(top level)" : folderPath ?? site.folderId}`,
377
+ "",
378
+ site.deploys.length === 0 ? "No versions yet." : "Versions, newest first:",
379
+ ...versions,
380
+ ...more > 0 ? [` … and ${more} older`] : []
381
+ ].join("\n");
382
+ }
236
383
  const TIMEOUT_MS = 5 * 60 * 1e3;
237
384
  function newAttempt() {
238
385
  const verifier = randomBytes(32).toString("base64url");
@@ -409,6 +556,12 @@ function writeProject(project, directory = process.cwd()) {
409
556
  `);
410
557
  return path;
411
558
  }
559
+ function sizeOf(content) {
560
+ return ArrayBuffer.isView(content) ? content.byteLength : content.size;
561
+ }
562
+ async function bytesOf(content) {
563
+ return ArrayBuffer.isView(content) ? content : new Uint8Array(await content.arrayBuffer());
564
+ }
412
565
  class DeployError extends Error {
413
566
  /**
414
567
  * @param code Stable code, either an API error type (`quota_exceeded`, `unsafe_path`, …) or one of
@@ -445,20 +598,55 @@ const ClientErrorCode = {
445
598
  /** The user cancelled. */
446
599
  Cancelled: "cancelled",
447
600
  /** An upload kept failing after every retry. */
448
- UploadFailed: "upload_failed"
601
+ UploadFailed: "upload_failed",
602
+ /**
603
+ * The browser could not hold what was dropped in memory. Raised when allocating the buffer for a
604
+ * large zip or file fails, which a browser reports as a `RangeError` rather than as anything that
605
+ * names memory.
606
+ */
607
+ OutOfMemory: "out_of_memory",
608
+ /**
609
+ * A dropped file could not be read when it came to be hashed. Files from a folder stay on disk until
610
+ * then, so one moved, deleted or changed after the drop fails here rather than at the drop.
611
+ */
612
+ ReadFailed: "read_failed",
613
+ /**
614
+ * A control-plane call got no answer: the connection failed before a response arrived, or the
615
+ * request timeout passed. Distinct from an HTTP error, which is an answer — this one says nothing
616
+ * about whether the server acted, which is why only an idempotent call may retry on it.
617
+ */
618
+ NetworkFailed: "network_failed"
449
619
  };
620
+ function describeError(error) {
621
+ const parts = [];
622
+ let current = error;
623
+ for (let depth = 0; depth < 5 && current !== void 0 && current !== null; depth++) {
624
+ if (current instanceof Error) {
625
+ const code = current.code;
626
+ parts.push(typeof code === "string" ? `${current.message} (${code})` : current.message);
627
+ current = current.cause;
628
+ } else {
629
+ parts.push(String(current));
630
+ break;
631
+ }
632
+ }
633
+ return parts.join(" ← ");
634
+ }
450
635
  const ACCOUNT_HEADER = "X-Drop2Run-Account";
451
636
  const CLAIM_TOKEN_HEADER = "X-Claim-Token";
637
+ const DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
638
+ const COMPLETE_RETRY_DELAYS_MS = [1e3, 4e3];
452
639
  async function prepareDeploy(siteId, files, options, signal) {
453
640
  const manifest = files.map((file) => ({
454
641
  path: file.path,
455
642
  sha256: file.sha256,
456
- size: file.bytes.length
643
+ size: sizeOf(file.bytes)
457
644
  }));
458
645
  const prepared = await request(
459
646
  `sites/${encodeURIComponent(siteId)}/deploys/prepare`,
460
647
  { files: manifest },
461
- options
648
+ options,
649
+ signal
462
650
  );
463
651
  return { ...prepared, uploadUrl: absolute(prepared.uploadUrl, options.baseUrl) };
464
652
  }
@@ -468,31 +656,65 @@ function absolute(uploadUrl, baseUrl) {
468
656
  return new URL(uploadUrl, baseUrl).toString();
469
657
  }
470
658
  async function completeDeploy(siteId, deployId, options, signal, name) {
471
- return request(
472
- `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`,
473
- // A bodyless POST when there is no name to send, which is what this call has always been. The
474
- // server treats an absent body and an absent field identically, so nothing depends on which of
475
- // the two a client picks.
476
- name === null || name === void 0 ? void 0 : { name },
477
- options
478
- );
659
+ const path = `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`;
660
+ const body = name === null || name === void 0 ? void 0 : { name };
661
+ const delay = options.delay ?? wait;
662
+ for (let attempt = 0; ; attempt++) {
663
+ try {
664
+ return await request(path, body, options, signal);
665
+ } catch (error) {
666
+ const unanswered = error instanceof DeployError && error.code === ClientErrorCode.NetworkFailed;
667
+ if (!unanswered || attempt >= COMPLETE_RETRY_DELAYS_MS.length || signal?.aborted) throw error;
668
+ await delay(COMPLETE_RETRY_DELAYS_MS[attempt] ?? 1e3);
669
+ }
670
+ }
671
+ }
672
+ function wait(ms) {
673
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
479
674
  }
480
675
  async function request(path, body, options, signal) {
481
676
  const doFetch = options.fetch ?? globalFetch$1();
482
677
  const base = options.baseUrl ?? "/api";
678
+ const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
483
679
  const headers = {};
484
680
  if (body !== void 0) headers["Content-Type"] = "application/json";
485
681
  if (options.claimToken) headers[CLAIM_TOKEN_HEADER] = options.claimToken;
486
682
  if (options.token) headers.Authorization = `Bearer ${options.token}`;
487
683
  if (options.accountId) headers[ACCOUNT_HEADER] = options.accountId;
488
- const response = await doFetch(`${base}/${path}`, {
489
- method: "POST",
490
- headers,
491
- ...body === void 0 ? {} : { body: JSON.stringify(body) },
492
- ...{}
493
- });
494
- if (!response.ok) throw await problemToError(response);
495
- return await response.json();
684
+ const controller = new AbortController();
685
+ let timedOut = false;
686
+ const timer = setTimeout(() => {
687
+ timedOut = true;
688
+ controller.abort();
689
+ }, timeoutMs);
690
+ try {
691
+ const response = await doFetch(`${base}/${path}`, {
692
+ method: "POST",
693
+ headers,
694
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
695
+ signal: controller.signal
696
+ });
697
+ if (!response.ok) throw await problemToError(response);
698
+ return await response.json();
699
+ } catch (error) {
700
+ if (error instanceof DeployError || signal?.aborted) throw error;
701
+ if (timedOut) {
702
+ throw new DeployError(
703
+ ClientErrorCode.NetworkFailed,
704
+ `The server did not answer within ${Math.round(timeoutMs / 1e3)} seconds.`,
705
+ { path, cause: describeError(error) }
706
+ );
707
+ }
708
+ if (error instanceof TypeError) {
709
+ throw new DeployError(ClientErrorCode.NetworkFailed, "Could not reach the server.", {
710
+ path,
711
+ cause: describeError(error)
712
+ });
713
+ }
714
+ throw error;
715
+ } finally {
716
+ clearTimeout(timer);
717
+ }
496
718
  }
497
719
  function globalFetch$1() {
498
720
  return globalThis.fetch.bind(globalThis);
@@ -991,17 +1213,29 @@ function extensionOf(path) {
991
1213
  return dot <= 0 ? "" : name.slice(dot).toLowerCase();
992
1214
  }
993
1215
  async function sha256Hex(bytes) {
994
- const digest = await crypto.subtle.digest("SHA-256", bytes.slice().buffer);
1216
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
995
1217
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
996
1218
  }
997
1219
  async function hashAll(files, onProgress, signal) {
998
1220
  const hashed = [];
999
1221
  for (const [index, file] of files.entries()) {
1000
- hashed.push({ ...file, sha256: await sha256Hex(file.bytes) });
1222
+ hashed.push({ ...file, sha256: await sha256Hex(await readForHash(file)) });
1001
1223
  onProgress?.(index + 1, files.length);
1002
1224
  }
1003
1225
  return hashed;
1004
1226
  }
1227
+ async function readForHash(file) {
1228
+ try {
1229
+ return await bytesOf(file.bytes);
1230
+ } catch (error) {
1231
+ if (error instanceof RangeError) throw error;
1232
+ throw new DeployError(
1233
+ ClientErrorCode.ReadFailed,
1234
+ `Could not read ${file.path}. It may have been moved, renamed or changed since you dropped it — drop the folder again.`,
1235
+ { path: file.path, cause: describeError(error) }
1236
+ );
1237
+ }
1238
+ }
1005
1239
  const IGNORED_EXACT = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".gitignore", ".gitkeep"]);
1006
1240
  const IGNORED_DIRECTORIES = [
1007
1241
  "__MACOSX",
@@ -1011,7 +1245,11 @@ const IGNORED_DIRECTORIES = [
1011
1245
  ".hg",
1012
1246
  ".idea",
1013
1247
  ".vscode",
1014
- ".claude"
1248
+ ".claude",
1249
+ ".agents",
1250
+ ".cursor",
1251
+ ".codex",
1252
+ ".gemini"
1015
1253
  ];
1016
1254
  function shouldIgnore(path) {
1017
1255
  const segments = path.split("/");
@@ -1046,6 +1284,24 @@ function canPublish(paths) {
1046
1284
  if (isImageFolder(paths)) return true;
1047
1285
  return paths.length === 1 && paths[0] !== void 0 && isViewablePath(paths[0]);
1048
1286
  }
1287
+ function servingModeOf(flags) {
1288
+ if (flags.docsMode) return "docs";
1289
+ if (flags.spaMode) return "spa";
1290
+ return "static";
1291
+ }
1292
+ function predictedModeOf(paths) {
1293
+ if (paths.some((path) => path === REQUIRED_INDEX)) return "static";
1294
+ if (paths.some(isDocumentPath)) return "docs";
1295
+ if (isImageFolder(paths)) return "docs";
1296
+ if (paths.length === 1 && paths[0] !== void 0 && isViewablePath(paths[0])) return "docs";
1297
+ return "static";
1298
+ }
1299
+ function nestedIndexOf(paths) {
1300
+ if (predictedModeOf(paths) !== "docs") return null;
1301
+ const suffix = `/${REQUIRED_INDEX}`;
1302
+ const folders2 = paths.filter((path) => path.endsWith(suffix)).map((path) => path.slice(0, -suffix.length)).sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b));
1303
+ return folders2[0] ?? null;
1304
+ }
1049
1305
  function renameOfLoneHtmlPage(paths) {
1050
1306
  if (paths.length !== 1) return null;
1051
1307
  const [only] = paths;
@@ -1073,7 +1329,7 @@ function checkLimits(files, limits) {
1073
1329
  );
1074
1330
  }
1075
1331
  const refusal = sizeRefusal(
1076
- files.map((file) => ({ path: file.path, size: file.bytes.length })),
1332
+ files.map((file) => ({ path: file.path, size: sizeOf(file.bytes) })),
1077
1333
  limits
1078
1334
  );
1079
1335
  if (refusal) throw refusal;
@@ -1114,13 +1370,24 @@ const HEAD_BYTES = 64 * 1024;
1114
1370
  const TITLE_SEPARATORS = ["|", "·", "—", "–", " - "];
1115
1371
  function suggestSiteName(files) {
1116
1372
  const index = files.find((file) => file.path === "index.html");
1117
- if (index === void 0) return null;
1373
+ if (index === void 0 || !ArrayBuffer.isView(index.bytes)) return null;
1118
1374
  try {
1119
1375
  return nameFromHtml(decodeHead(index.bytes));
1120
1376
  } catch {
1121
1377
  return null;
1122
1378
  }
1123
1379
  }
1380
+ async function readSiteName(files) {
1381
+ const index = files.find((file) => file.path === "index.html");
1382
+ if (index === void 0) return null;
1383
+ if (ArrayBuffer.isView(index.bytes)) return suggestSiteName(files);
1384
+ try {
1385
+ const head = new Uint8Array(await index.bytes.slice(0, HEAD_BYTES).arrayBuffer());
1386
+ return suggestSiteName([{ path: index.path, bytes: head }]);
1387
+ } catch {
1388
+ return null;
1389
+ }
1390
+ }
1124
1391
  function decodeHead(bytes) {
1125
1392
  return new TextDecoder("utf-8", { fatal: false }).decode(
1126
1393
  bytes.subarray(0, HEAD_BYTES)
@@ -1152,7 +1419,7 @@ function matchTag(html, tag) {
1152
1419
  const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(
1153
1420
  html
1154
1421
  );
1155
- return match?.[1] ?? null;
1422
+ return match?.[1] === void 0 ? null : decodeEntities(match[1]);
1156
1423
  }
1157
1424
  function matchOgSiteName(html) {
1158
1425
  const patterns = [
@@ -1161,10 +1428,56 @@ function matchOgSiteName(html) {
1161
1428
  ];
1162
1429
  for (const pattern of patterns) {
1163
1430
  const match = pattern.exec(html);
1164
- if (match?.[1] !== void 0) return match[1];
1431
+ if (match?.[1] !== void 0) return decodeEntities(match[1]);
1165
1432
  }
1166
1433
  return null;
1167
1434
  }
1435
+ 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(" ");
1436
+ const NAMED_ENTITIES = new Map([
1437
+ ...LATIN1_ENTITIES.map((name, index) => [name, 160 + index]),
1438
+ ["amp", 38],
1439
+ ["lt", 60],
1440
+ ["gt", 62],
1441
+ ["quot", 34],
1442
+ ["apos", 39],
1443
+ ["ensp", 8194],
1444
+ ["emsp", 8195],
1445
+ ["thinsp", 8201],
1446
+ ["ndash", 8211],
1447
+ ["mdash", 8212],
1448
+ ["lsquo", 8216],
1449
+ ["rsquo", 8217],
1450
+ ["sbquo", 8218],
1451
+ ["ldquo", 8220],
1452
+ ["rdquo", 8221],
1453
+ ["bdquo", 8222],
1454
+ ["dagger", 8224],
1455
+ ["Dagger", 8225],
1456
+ ["bull", 8226],
1457
+ ["hellip", 8230],
1458
+ ["prime", 8242],
1459
+ ["lsaquo", 8249],
1460
+ ["rsaquo", 8250],
1461
+ ["euro", 8364],
1462
+ ["trade", 8482],
1463
+ ["larr", 8592],
1464
+ ["rarr", 8594],
1465
+ ["hearts", 9829]
1466
+ ]);
1467
+ function decodeEntities(text) {
1468
+ return text.replace(
1469
+ /&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{1,31}));/g,
1470
+ (whole, decimal, hex, name) => {
1471
+ if (name !== void 0) {
1472
+ const code2 = NAMED_ENTITIES.get(name);
1473
+ return code2 === void 0 ? whole : String.fromCodePoint(code2);
1474
+ }
1475
+ const code = decimal !== void 0 ? Number(decimal) : Number.parseInt(hex ?? "", 16);
1476
+ const valid = code > 0 && code <= 1114111 && (code < 55296 || code > 57343);
1477
+ return String.fromCodePoint(valid ? code : 65533);
1478
+ }
1479
+ );
1480
+ }
1168
1481
  const INVISIBLE = new RegExp(
1169
1482
  `[${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)}]`,
1170
1483
  "g"
@@ -1206,46 +1519,105 @@ function trimSuffix(title) {
1206
1519
  return title;
1207
1520
  }
1208
1521
  const DEFAULT_CONCURRENCY = 8;
1522
+ const DEFAULT_BYTES_IN_FLIGHT = 64 * 1024 * 1024;
1209
1523
  const DEFAULT_RETRIES = 3;
1210
1524
  const BACKOFF_MS = [1e3, 2e3, 4e3];
1211
- async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {}) {
1212
- const doFetch = deps.fetch ?? globalFetch();
1525
+ const DEFAULT_STALL_MS = 3e4;
1526
+ const PROGRESS_INTERVAL_MS = 200;
1527
+ class StalledError extends Error {
1528
+ /** @param seconds How long nothing moved. */
1529
+ constructor(seconds) {
1530
+ super(`No data moved for ${seconds}s.`);
1531
+ this.name = "StalledError";
1532
+ }
1533
+ }
1534
+ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {}, onRetry) {
1535
+ const put = deps.put ?? defaultTransport(deps.fetch);
1213
1536
  const delay = deps.delay ?? defaultDelay;
1214
1537
  const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
1215
1538
  const retries = deps.retries ?? DEFAULT_RETRIES;
1539
+ const stallMs = deps.stallMs ?? DEFAULT_STALL_MS;
1540
+ const now = deps.now ?? Date.now;
1541
+ const maxBytesInFlight = deps.maxBytesInFlight ?? DEFAULT_BYTES_IN_FLIGHT;
1216
1542
  const byPath = new Map(files.map((file) => [file.path, file]));
1217
1543
  let nextIndex = 0;
1218
1544
  let done = 0;
1219
- let uploadedBytes = 0;
1545
+ let failed = false;
1546
+ let reserved = 0;
1547
+ let waiting = [];
1548
+ const acquire = async (size) => {
1549
+ while (reserved > 0 && reserved + size > maxBytesInFlight) {
1550
+ await new Promise((resolve2) => waiting.push(resolve2));
1551
+ }
1552
+ reserved += size;
1553
+ };
1554
+ const release = (size) => {
1555
+ reserved -= size;
1556
+ const woken = waiting;
1557
+ waiting = [];
1558
+ for (const wake of woken) wake();
1559
+ };
1560
+ let landedBytes = 0;
1561
+ const inFlight = /* @__PURE__ */ new Map();
1562
+ let lastReport = Number.NEGATIVE_INFINITY;
1563
+ const report = (path) => {
1564
+ if (onProgress === void 0) return;
1565
+ const at = now();
1566
+ if (path === void 0 && at - lastReport < PROGRESS_INTERVAL_MS) return;
1567
+ lastReport = at;
1568
+ let bytes = landedBytes;
1569
+ for (const sent of inFlight.values()) bytes += sent;
1570
+ onProgress(done, targets.length, bytes, path);
1571
+ };
1220
1572
  const worker = async () => {
1221
1573
  for (; ; ) {
1574
+ if (failed) return;
1222
1575
  const index = nextIndex++;
1223
1576
  const target = targets[index];
1224
1577
  if (target === void 0) return;
1225
1578
  const file = byPath.get(target.path);
1226
1579
  if (file === void 0) {
1580
+ failed = true;
1227
1581
  throw new DeployError(
1228
1582
  ClientErrorCode.UploadFailed,
1229
1583
  `The server asked for ${target.path}, which is not in the manifest.`,
1230
1584
  { path: target.path }
1231
1585
  );
1232
1586
  }
1233
- await uploadOne(target, file, {
1234
- doFetch,
1235
- delay,
1236
- retries,
1237
- signal,
1238
- uploadUrl
1239
- });
1587
+ const size = sizeOf(file.bytes);
1588
+ await acquire(size);
1589
+ try {
1590
+ if (failed) return;
1591
+ signal?.throwIfAborted();
1592
+ await uploadOne(target, file, {
1593
+ put,
1594
+ delay,
1595
+ retries,
1596
+ signal,
1597
+ uploadUrl,
1598
+ stallMs,
1599
+ onSent: (sent) => {
1600
+ inFlight.set(index, sent);
1601
+ report();
1602
+ },
1603
+ onRetry: (attempt, stalled) => {
1604
+ inFlight.set(index, 0);
1605
+ onRetry?.(target.path, attempt, retries + 1, stalled);
1606
+ }
1607
+ });
1608
+ } catch (error) {
1609
+ failed = true;
1610
+ throw error;
1611
+ } finally {
1612
+ release(size);
1613
+ }
1614
+ inFlight.delete(index);
1240
1615
  done += 1;
1241
- uploadedBytes += file.bytes.length;
1242
- onProgress?.(done, targets.length, uploadedBytes, file.path);
1616
+ landedBytes += size;
1617
+ report(file.path);
1243
1618
  }
1244
1619
  };
1245
- const workers = Array.from(
1246
- { length: Math.min(concurrency, targets.length) },
1247
- worker
1248
- );
1620
+ const workers = Array.from({ length: Math.min(concurrency, targets.length) }, worker);
1249
1621
  try {
1250
1622
  await Promise.all(workers);
1251
1623
  } catch (error) {
@@ -1257,39 +1629,122 @@ async function uploadOne(target, file, context) {
1257
1629
  for (let attempt = 0; attempt <= context.retries; attempt++) {
1258
1630
  context.signal?.throwIfAborted();
1259
1631
  if (attempt > 0) {
1632
+ context.onRetry(attempt + 1, lastError instanceof StalledError);
1260
1633
  await context.delay(BACKOFF_MS[attempt - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
1261
1634
  context.signal?.throwIfAborted();
1262
1635
  }
1263
1636
  try {
1264
- const response = await context.doFetch(context.uploadUrl, {
1265
- method: "PUT",
1266
- body: new Blob([file.bytes.slice().buffer]),
1267
- // The permit is the whole request. `Content-Length` is required by the Worker and cannot
1268
- // be set here — it is a forbidden header name — but the browser derives it from the body,
1269
- // which is exactly the length the token names.
1270
- headers: { Authorization: `Bearer ${target.token}` },
1271
- ...context.signal ? { signal: context.signal } : {}
1637
+ const status = await context.put({
1638
+ url: context.uploadUrl,
1639
+ token: target.token,
1640
+ body: untyped(file.bytes),
1641
+ signal: context.signal,
1642
+ onSent: context.onSent,
1643
+ stallMs: context.stallMs
1272
1644
  });
1273
- if (response.ok) return;
1274
- if (response.status === 401 || response.status === 403 || response.status === 429) {
1645
+ if (status >= 200 && status < 300) return;
1646
+ if (status === 401 || status === 403 || status === 429) {
1275
1647
  throw new DeployError(
1276
1648
  ClientErrorCode.UploadFailed,
1277
1649
  `Upload of ${target.path} was refused. The upload window may have expired — try deploying again.`,
1278
- { path: target.path, status: response.status }
1650
+ { path: target.path, status }
1279
1651
  );
1280
1652
  }
1281
- lastError = new Error(`HTTP ${response.status}`);
1653
+ if (status === 413) {
1654
+ throw new DeployError(
1655
+ ClientErrorCode.UploadFailed,
1656
+ `${target.path} is too large to upload in one request. Make it smaller, or leave it out of this publish.`,
1657
+ { path: target.path, status }
1658
+ );
1659
+ }
1660
+ lastError = new Error(`HTTP ${status}`);
1282
1661
  } catch (error) {
1283
1662
  if (error instanceof DeployError) throw error;
1284
1663
  if (isAbort(error)) throw cancelled();
1285
1664
  lastError = error;
1286
1665
  }
1287
1666
  }
1288
- throw new DeployError(
1289
- ClientErrorCode.UploadFailed,
1290
- `Could not upload ${target.path} after ${context.retries + 1} attempts.`,
1291
- { path: target.path, cause: String(lastError) }
1292
- );
1667
+ 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.`;
1668
+ throw new DeployError(ClientErrorCode.UploadFailed, message, {
1669
+ path: target.path,
1670
+ cause: describeError(lastError)
1671
+ });
1672
+ }
1673
+ function untyped(content) {
1674
+ if (ArrayBuffer.isView(content) || content.type === "") return content;
1675
+ return content.slice(0, content.size);
1676
+ }
1677
+ function defaultTransport(doFetch) {
1678
+ if (doFetch !== void 0) return fetchTransport(doFetch);
1679
+ const Xhr = globalThis.XMLHttpRequest;
1680
+ return Xhr === void 0 ? fetchTransport(globalFetch()) : xhrTransport(Xhr);
1681
+ }
1682
+ function fetchTransport(doFetch) {
1683
+ return async ({ url, token, body, signal }) => {
1684
+ const response = await doFetch(url, {
1685
+ method: "PUT",
1686
+ // The view, not a copy. Wrapping it in `new Blob([body.slice().buffer])` made two extra
1687
+ // full-size copies of every file in flight — eight at once, so up to 400 MB on a drop of
1688
+ // 50 MB files. `fetch` sends exactly the range a view covers. The cast is TypeScript's
1689
+ // SharedArrayBuffer distinction again; see `sha256Hex`. A `Blob` goes as it is, and the runtime
1690
+ // streams it from disk.
1691
+ body,
1692
+ // The permit is the whole request. `Content-Length` is required by the Worker and cannot be
1693
+ // set here — it is a forbidden header name — but the runtime derives it from the body, which
1694
+ // is exactly the length the token names.
1695
+ headers: { Authorization: `Bearer ${token}` },
1696
+ ...signal ? { signal } : {}
1697
+ });
1698
+ return response.status;
1699
+ };
1700
+ }
1701
+ function xhrTransport(Xhr) {
1702
+ return ({ url, token, body, signal, onSent, stallMs }) => new Promise((resolve2, reject) => {
1703
+ const xhr = new Xhr();
1704
+ let settled = false;
1705
+ let timer;
1706
+ const settle = (finish) => {
1707
+ if (settled) return;
1708
+ settled = true;
1709
+ clearTimeout(timer);
1710
+ signal?.removeEventListener("abort", onAbort);
1711
+ finish();
1712
+ };
1713
+ const arm = () => {
1714
+ clearTimeout(timer);
1715
+ timer = setTimeout(() => {
1716
+ xhr.abort();
1717
+ settle(() => reject(new StalledError(Math.round(stallMs / 1e3))));
1718
+ }, stallMs);
1719
+ };
1720
+ function onAbort() {
1721
+ xhr.abort();
1722
+ settle(() => reject(abortReason(signal)));
1723
+ }
1724
+ if (signal?.aborted) {
1725
+ reject(abortReason(signal));
1726
+ return;
1727
+ }
1728
+ signal?.addEventListener("abort", onAbort, { once: true });
1729
+ xhr.open("PUT", url);
1730
+ xhr.setRequestHeader("Authorization", `Bearer ${token}`);
1731
+ xhr.upload.onprogress = (event) => {
1732
+ arm();
1733
+ onSent(event.loaded);
1734
+ };
1735
+ xhr.onprogress = arm;
1736
+ xhr.onload = () => settle(() => resolve2(xhr.status));
1737
+ xhr.onerror = () => settle(() => reject(new TypeError("Network error during upload.")));
1738
+ arm();
1739
+ xhr.send(body);
1740
+ });
1741
+ }
1742
+ function abortReason(signal) {
1743
+ const reason = signal?.reason;
1744
+ if (reason instanceof Error) return reason;
1745
+ const error = new Error("Aborted.");
1746
+ error.name = "AbortError";
1747
+ return error;
1293
1748
  }
1294
1749
  function globalFetch() {
1295
1750
  return globalThis.fetch.bind(globalThis);
@@ -1303,9 +1758,17 @@ function isAbort(error) {
1303
1758
  function cancelled() {
1304
1759
  return new DeployError(ClientErrorCode.Cancelled, "Deploy cancelled.");
1305
1760
  }
1761
+ function outOfMemory(detail) {
1762
+ return new DeployError(
1763
+ ClientErrorCode.OutOfMemory,
1764
+ "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.",
1765
+ detail
1766
+ );
1767
+ }
1306
1768
  function asDeployError(error) {
1307
1769
  if (error instanceof DeployError) return error;
1308
1770
  if (isAbort(error)) return cancelled();
1771
+ if (error instanceof RangeError) return outOfMemory(error.message);
1309
1772
  return new DeployError(
1310
1773
  ClientErrorCode.UploadFailed,
1311
1774
  error instanceof Error ? error.message : "Upload failed.",
@@ -1314,7 +1777,11 @@ function asDeployError(error) {
1314
1777
  }
1315
1778
  async function deploy(source, siteId, onProgress, signal, options = {}) {
1316
1779
  try {
1317
- const collected = await source(signal);
1780
+ onProgress({ type: "reading", bytes: 0, total: 0 });
1781
+ const collected = await source(
1782
+ signal,
1783
+ (bytes, total) => onProgress({ type: "reading", bytes, total })
1784
+ );
1318
1785
  onProgress({ type: "collecting", files: collected.length });
1319
1786
  const rename = renameOfLoneHtmlPage(collected.map((file) => file.path));
1320
1787
  const files = rename === null ? collected : collected.map((file) => file.path === rename ? { ...file, path: "index.html" } : file);
@@ -1330,6 +1797,21 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1330
1797
  onProgress({ type: "unchanged", url: prepared.url ?? "" });
1331
1798
  return;
1332
1799
  }
1800
+ const sizes = new Map(hashed.map((file) => [file.path, sizeOf(file.bytes)]));
1801
+ const totalBytes = prepared.upload.reduce(
1802
+ (sum, target) => sum + (sizes.get(target.path) ?? 0),
1803
+ 0
1804
+ );
1805
+ if (prepared.upload.length > 0) {
1806
+ onProgress({
1807
+ type: "uploading",
1808
+ done: prepared.reused,
1809
+ total: prepared.total,
1810
+ bytes: 0,
1811
+ totalBytes,
1812
+ reused: prepared.reused
1813
+ });
1814
+ }
1333
1815
  await uploadAll(
1334
1816
  prepared.upload,
1335
1817
  prepared.uploadUrl,
@@ -1339,11 +1821,13 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1339
1821
  done: prepared.reused + done,
1340
1822
  total: prepared.total,
1341
1823
  bytes,
1824
+ totalBytes,
1342
1825
  reused: prepared.reused,
1343
- path
1826
+ ...path === void 0 ? {} : { path }
1344
1827
  }),
1345
1828
  signal,
1346
- options.upload
1829
+ options.upload,
1830
+ (path, attempt, attempts, stalled) => onProgress({ type: "retrying", path, attempt, attempts, stalled })
1347
1831
  );
1348
1832
  onProgress({ type: "completing", reused: prepared.reused });
1349
1833
  const completed = await completeDeploy(
@@ -1351,9 +1835,15 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1351
1835
  prepared.deployId,
1352
1836
  options,
1353
1837
  signal,
1354
- suggestSiteName(files)
1838
+ await readSiteName(files)
1355
1839
  );
1356
- onProgress({ type: "done", url: completed.url, name: completed.name });
1840
+ onProgress({
1841
+ type: "done",
1842
+ url: completed.url,
1843
+ name: completed.name,
1844
+ mode: servingModeOf(completed),
1845
+ modeIsManual: completed.modeIsManual
1846
+ });
1357
1847
  } catch (error) {
1358
1848
  const failure2 = asDeployError(error);
1359
1849
  onProgress({
@@ -1406,6 +1896,15 @@ function escapesRoot(root, candidate) {
1406
1896
  const inside = relative(root, candidate);
1407
1897
  return inside === "" || inside.startsWith("..");
1408
1898
  }
1899
+ function describeMode(result) {
1900
+ if (result.mode === null) return null;
1901
+ if (result.nestedIndex !== null) {
1902
+ return `Served as documents: there is no index.html at the top, but ${result.nestedIndex}/index.html is there. To publish the website, publish the ${result.nestedIndex} folder instead.`;
1903
+ }
1904
+ if (result.mode === "docs") return "Served as documents, in the documents reader.";
1905
+ if (result.mode === "spa") return "Served as a single-page app.";
1906
+ return "Served as a static site.";
1907
+ }
1409
1908
  async function resolveSite(credentials, site, newSite) {
1410
1909
  const { subdomain, folder } = newSite;
1411
1910
  if (site !== void 0 && subdomain !== void 0) {
@@ -1415,7 +1914,7 @@ async function resolveSite(credentials, site, newSite) {
1415
1914
  }
1416
1915
  if (site !== void 0 && folder !== void 0) {
1417
1916
  throw new Error(
1418
- "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."
1917
+ "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 with `drop2run set folder` or the `update_site` tool's `folder`."
1419
1918
  );
1420
1919
  }
1421
1920
  if (site === void 0) {
@@ -1435,6 +1934,8 @@ async function publish(credentials, source, site, onProgress, newSite = {}) {
1435
1934
  let files = 0;
1436
1935
  let url = target.url;
1437
1936
  let unchanged = false;
1937
+ let mode = null;
1938
+ let paths = [];
1438
1939
  let failure2 = null;
1439
1940
  let failureDetail;
1440
1941
  const record = (event) => {
@@ -1443,14 +1944,22 @@ async function publish(credentials, source, site, onProgress, newSite = {}) {
1443
1944
  unchanged = true;
1444
1945
  url = event.url || target.url;
1445
1946
  }
1446
- if (event.type === "done") url = event.url;
1947
+ if (event.type === "done") {
1948
+ url = event.url;
1949
+ mode = event.mode;
1950
+ }
1447
1951
  if (event.type === "error") {
1448
1952
  failure2 = event.message;
1449
1953
  failureDetail = event.detail;
1450
1954
  }
1451
1955
  onProgress?.(event);
1452
1956
  };
1453
- await deploy(source, target.siteId, record, void 0, {
1957
+ const watched = async (signal, onRead) => {
1958
+ const collected = await source(signal, onRead);
1959
+ paths = collected.map((file) => file.path);
1960
+ return collected;
1961
+ };
1962
+ await deploy(watched, target.siteId, record, void 0, {
1454
1963
  baseUrl: credentials.apiBaseUrl,
1455
1964
  token: credentials.token
1456
1965
  }).catch((error) => {
@@ -1460,7 +1969,15 @@ async function publish(credentials, source, site, onProgress, newSite = {}) {
1460
1969
  thrown.detail = failureDetail ?? error?.detail;
1461
1970
  throw thrown;
1462
1971
  });
1463
- return { url, siteId: target.siteId, subdomain: target.subdomain, files, unchanged };
1972
+ return {
1973
+ url,
1974
+ siteId: target.siteId,
1975
+ subdomain: target.subdomain,
1976
+ files,
1977
+ unchanged,
1978
+ mode,
1979
+ nestedIndex: mode === "docs" ? nestedIndexOf(paths) : null
1980
+ };
1464
1981
  }
1465
1982
  async function publishDirectory(credentials, directory, site, onProgress, newSite = {}) {
1466
1983
  const found = await stat(directory).catch(() => null);
@@ -1488,6 +2005,10 @@ function shortenPath(path, width) {
1488
2005
  }
1489
2006
  function describe(event, width = 80) {
1490
2007
  switch (event.type) {
2008
+ case "reading":
2009
+ return event.total > 0 ? `Reading ${formatBytes(event.bytes)}/${formatBytes(event.total)}` : "Reading files";
2010
+ case "retrying":
2011
+ return `${event.stalled ? "Upload stalled" : "Upload failed"}, retrying ${shortenPath(event.path, Math.max(12, width - 40))} (attempt ${event.attempt}/${event.attempts})`;
1491
2012
  case "collecting":
1492
2013
  return `Collected ${event.files.toLocaleString()} ${event.files === 1 ? "file" : "files"}`;
1493
2014
  case "hashing":
@@ -1535,6 +2056,82 @@ function progressWriter(write, isTerminal, width = 80) {
1535
2056
  };
1536
2057
  return writer;
1537
2058
  }
2059
+ async function readSecret(prompt, input = process.stdin, output = process.stderr) {
2060
+ if (input.isTTY !== true || input.setRawMode === void 0) {
2061
+ const parts = [];
2062
+ for await (const chunk of input)
2063
+ parts.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
2064
+ return parts.join("").replace(/\r?\n$/, "");
2065
+ }
2066
+ const setRawMode = input.setRawMode.bind(input);
2067
+ output.write(prompt);
2068
+ setRawMode(true);
2069
+ input.setEncoding("utf8");
2070
+ input.resume();
2071
+ return await new Promise((resolve2, reject) => {
2072
+ const line = new HiddenLine();
2073
+ const finish = () => {
2074
+ setRawMode(false);
2075
+ input.pause();
2076
+ input.off("data", onData);
2077
+ output.write("\n");
2078
+ };
2079
+ function onData(chunk) {
2080
+ const outcome = line.type(chunk);
2081
+ if (outcome === "more") return;
2082
+ finish();
2083
+ if (outcome === "cancelled") reject(new Error("Cancelled."));
2084
+ else resolve2(line.value);
2085
+ }
2086
+ input.on("data", onData);
2087
+ });
2088
+ }
2089
+ class HiddenLine {
2090
+ /** What has been typed, one entry per code point, so Backspace never splits a surrogate pair. */
2091
+ characters = [];
2092
+ /** Where an escape sequence has got to: none, just after ESC, or inside a `CSI`/`SS3` sequence. */
2093
+ escape = "none";
2094
+ /** The line so far. */
2095
+ get value() {
2096
+ return this.characters.join("");
2097
+ }
2098
+ /**
2099
+ * Takes the characters one chunk of input carries.
2100
+ *
2101
+ * @param chunk What arrived.
2102
+ * @returns `done` on Enter or Ctrl+D, `cancelled` on Ctrl+C, `more` otherwise.
2103
+ */
2104
+ type(chunk) {
2105
+ for (const character of chunk) {
2106
+ const code = character.codePointAt(0) ?? 0;
2107
+ if (this.escape === "started") {
2108
+ this.escape = character === "[" || character === "O" ? "sequence" : "none";
2109
+ continue;
2110
+ }
2111
+ if (this.escape === "sequence") {
2112
+ if (code >= 64 && code <= 126) this.escape = "none";
2113
+ continue;
2114
+ }
2115
+ if (character === "\r" || character === "\n" || character === "") return "done";
2116
+ if (character === "") return "cancelled";
2117
+ if (character === "\x1B") {
2118
+ this.escape = "started";
2119
+ continue;
2120
+ }
2121
+ if (character === "" || character === "\b") {
2122
+ this.characters.pop();
2123
+ continue;
2124
+ }
2125
+ if (character === "") {
2126
+ this.characters.length = 0;
2127
+ continue;
2128
+ }
2129
+ if (code < 32) continue;
2130
+ this.characters.push(character);
2131
+ }
2132
+ return "more";
2133
+ }
2134
+ }
1538
2135
  function failure(text) {
1539
2136
  return { text, json: { error: text }, code: 1 };
1540
2137
  }
@@ -1646,9 +2243,9 @@ async function whoami() {
1646
2243
  const response = await fetch(`${found.credentials.apiBaseUrl}/me`, {
1647
2244
  headers: { Authorization: `Bearer ${found.credentials.token}` }
1648
2245
  });
1649
- if (!response.ok) {
2246
+ if (!response.ok || response.status === 204) {
1650
2247
  return failure(
1651
- 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}.`
2248
+ response.status === 401 || response.status === 204 ? "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}.`
1652
2249
  );
1653
2250
  }
1654
2251
  const me = await response.json();
@@ -1714,7 +2311,7 @@ async function deployCommand(directory, site, report = silentProgress, newSite =
1714
2311
  const target = newSite.subdomain === void 0 ? site ?? project?.siteId : void 0;
1715
2312
  if (newSite.folder !== void 0 && target !== void 0 && project !== null) {
1716
2313
  return failure(
1717
- `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.`
2314
+ `This folder's ${PROJECT_FILE} already publishes to ${project.subdomain || project.siteId}, and \`--folder\` only files a new site. Move that site with \`drop2run set folder <path>\`, or add \`--subdomain\` to create a new one in the folder.`
1718
2315
  );
1719
2316
  }
1720
2317
  try {
@@ -1726,9 +2323,11 @@ async function deployCommand(directory, site, report = silentProgress, newSite =
1726
2323
  newSite
1727
2324
  );
1728
2325
  const what = result.unchanged ? "Already up to date — nothing needed publishing." : `Published ${result.files.toLocaleString()} ${result.files === 1 ? "file" : "files"}.`;
2326
+ const mode = describeMode(result);
1729
2327
  return {
1730
2328
  text: `${what}
1731
- ${result.url}`,
2329
+ ${result.url}${mode === null ? "" : `
2330
+ ${mode}`}`,
1732
2331
  json: result,
1733
2332
  code: 0
1734
2333
  };
@@ -1843,6 +2442,197 @@ async function open(site, launch = openBrowser) {
1843
2442
  return failure(error instanceof Error ? error.message : String(error));
1844
2443
  }
1845
2444
  }
2445
+ function siteOr(site, usage) {
2446
+ const named = site ?? readProject()?.siteId;
2447
+ return named === void 0 ? {
2448
+ result: failure(
2449
+ `Name the site: \`${usage}\`, or run this in a folder with a ${PROJECT_FILE}.`
2450
+ )
2451
+ } : { named };
2452
+ }
2453
+ async function info(site) {
2454
+ const found = credentialsOr();
2455
+ if ("result" in found) return found.result;
2456
+ const target = siteOr(site, "drop2run info <subdomain>");
2457
+ if ("result" in target) return target.result;
2458
+ try {
2459
+ const summary = await findSite(found.credentials, target.named);
2460
+ const detail = await getSite(found.credentials, summary.siteId);
2461
+ const folderPath = detail.folderId === null ? void 0 : (await listFolders(found.credentials)).find(
2462
+ (folder) => folder.folderId === detail.folderId
2463
+ )?.path;
2464
+ return {
2465
+ text: describeSite(detail, folderPath),
2466
+ json: { ...detail, mode: servingModeOf$1(detail), folderPath: folderPath ?? null },
2467
+ code: 0
2468
+ };
2469
+ } catch (error) {
2470
+ return failure(error instanceof Error ? error.message : String(error));
2471
+ }
2472
+ }
2473
+ async function pauseOrResume(action, site) {
2474
+ const found = credentialsOr();
2475
+ if ("result" in found) return found.result;
2476
+ const target = siteOr(site, `drop2run ${action} <subdomain>`);
2477
+ if ("result" in target) return target.result;
2478
+ try {
2479
+ const summary = await findSite(found.credentials, target.named);
2480
+ const state = await setSitePaused(found.credentials, summary.siteId, action);
2481
+ return {
2482
+ text: action === "resume" ? `${state.subdomain} is back on the air.
2483
+ ${summary.url}` : state.status === "paused" ? `Paused ${state.subdomain}. Its files and subdomain are kept; \`drop2run resume\` puts it back.` : (
2484
+ // A site Drop2Run suspended stays suspended: the pause is recorded for when that lifts,
2485
+ // and promising `resume` here would be promising something the API refuses.
2486
+ `${state.subdomain} is ${state.status}, not paused by you, so \`drop2run resume\` cannot bring it back. The pause is recorded and applies if that is lifted.`
2487
+ ),
2488
+ json: state,
2489
+ code: 0
2490
+ };
2491
+ } catch (error) {
2492
+ return failure(error instanceof Error ? error.message : String(error));
2493
+ }
2494
+ }
2495
+ const SETTINGS_USAGE = [
2496
+ " name <text> What the dashboard calls the site",
2497
+ " mode static|spa|docs How it serves files",
2498
+ " password Read from stdin, or typed at a hidden prompt",
2499
+ " forms on|off Whether its forms accept submissions",
2500
+ " expires <ISO 8601 instant> When it comes down; add --then delete to delete rather than pause",
2501
+ " folder <path|id|root> Which folder it is filed in"
2502
+ ].join("\n");
2503
+ function settingChange(setting, values, then) {
2504
+ const value = setting === "name" ? values.join(" ").trim() : values[0];
2505
+ const needs = (example) => ({
2506
+ refusal: `\`set ${setting}\` needs a value, for example \`drop2run set ${setting} ${example}\`.`
2507
+ });
2508
+ if (then !== void 0 && setting !== "expires") {
2509
+ return { refusal: "`--then` only applies to `set expires`." };
2510
+ }
2511
+ if (setting !== "name" && setting !== "password" && values.length > 1) {
2512
+ return {
2513
+ refusal: `\`set ${setting}\` takes one value, and "${values.slice(1).join(" ")}" is extra. Name the site with \`--site <subdomain>\`.`
2514
+ };
2515
+ }
2516
+ switch (setting) {
2517
+ case "name":
2518
+ return value ? { change: { name: value } } : needs('"Launch notes"');
2519
+ case "mode":
2520
+ if (value === "static" || value === "spa" || value === "docs") {
2521
+ return { change: { mode: value } };
2522
+ }
2523
+ return { refusal: "`set mode` takes `static`, `spa` or `docs`." };
2524
+ case "forms":
2525
+ if (value === "on" || value === "off") return { change: { formsEnabled: value === "on" } };
2526
+ return { refusal: "`set forms` takes `on` or `off`." };
2527
+ case "password": {
2528
+ if (value !== void 0) {
2529
+ return {
2530
+ refusal: '`set password` does not take the password as an argument, where it would stay in your shell history. Run `drop2run set password` and type it, or pipe it in: `printf %s "$SITE_PASSWORD" | drop2run set password`.'
2531
+ };
2532
+ }
2533
+ return { password: true };
2534
+ }
2535
+ case "expires": {
2536
+ if (value === void 0) return needs("2026-12-31T09:00:00Z");
2537
+ if (then !== void 0 && then !== "pause" && then !== "delete") {
2538
+ return { refusal: "`--then` takes `pause` or `delete`." };
2539
+ }
2540
+ return {
2541
+ change: {
2542
+ expiresAt: value,
2543
+ ...then === void 0 ? {} : { expiryAction: then }
2544
+ }
2545
+ };
2546
+ }
2547
+ case "folder":
2548
+ return value ? { folder: value } : needs("Clients/Acme");
2549
+ default:
2550
+ return {
2551
+ refusal: `${setting === void 0 ? "Name a setting." : `\`${setting}\` is not a setting.`} Settings:
2552
+ ${SETTINGS_USAGE}`
2553
+ };
2554
+ }
2555
+ }
2556
+ async function set(site, setting, values, options, readPassword = () => readSecret("Password for visitors: ")) {
2557
+ const parsed = settingChange(setting, values, options.then);
2558
+ if ("refusal" in parsed) return failure(parsed.refusal);
2559
+ const found = credentialsOr();
2560
+ if ("result" in found) return found.result;
2561
+ const target = siteOr(site, `drop2run set ${setting} … --site <subdomain>`);
2562
+ if ("result" in target) return target.result;
2563
+ try {
2564
+ const summary = await findSite(found.credentials, target.named);
2565
+ let change;
2566
+ if ("folder" in parsed) {
2567
+ change = { folderId: await resolveFolderTarget(found.credentials, parsed.folder) };
2568
+ } else if ("password" in parsed) {
2569
+ const password = await readPassword();
2570
+ if (password === "") {
2571
+ return failure("No password was given. To remove one, run `drop2run unset password`.");
2572
+ }
2573
+ change = { password };
2574
+ } else {
2575
+ change = parsed.change;
2576
+ }
2577
+ if (change.expiryAction === "delete" && !options.confirmed) {
2578
+ return failure(
2579
+ `This would delete ${summary.subdomain} and every version published to it when ${change.expiresAt} arrives, with no way back then.
2580
+ Add --yes if that is what you want, or leave out --then to pause it instead.`
2581
+ );
2582
+ }
2583
+ return await applied(found.credentials, summary.siteId, summary.subdomain, change);
2584
+ } catch (error) {
2585
+ return failure(error instanceof Error ? error.message : String(error));
2586
+ }
2587
+ }
2588
+ async function unset(site, setting, extra = []) {
2589
+ if (extra.length > 0) {
2590
+ return failure(
2591
+ `\`unset ${setting}\` takes nothing after it, and "${extra.join(" ")}" is extra. Name the site with \`--site <subdomain>\`.`
2592
+ );
2593
+ }
2594
+ const clear = /* @__PURE__ */ new Map([
2595
+ ["name", { name: "" }],
2596
+ ["password", { password: "" }],
2597
+ ["expires", { expiresAt: "" }]
2598
+ ]);
2599
+ const change = setting === void 0 ? void 0 : clear.get(setting);
2600
+ if (change === void 0) {
2601
+ return failure(
2602
+ "`unset` clears `name`, `password` or `expires`. To file the site at the top level, run `drop2run set folder root`."
2603
+ );
2604
+ }
2605
+ const found = credentialsOr();
2606
+ if ("result" in found) return found.result;
2607
+ const target = siteOr(site, `drop2run unset ${setting} --site <subdomain>`);
2608
+ if ("result" in target) return target.result;
2609
+ try {
2610
+ const summary = await findSite(found.credentials, target.named);
2611
+ return await applied(found.credentials, summary.siteId, summary.subdomain, change);
2612
+ } catch (error) {
2613
+ return failure(error instanceof Error ? error.message : String(error));
2614
+ }
2615
+ }
2616
+ async function applied(credentials, siteId, subdomain, change) {
2617
+ const settings = await updateSiteSettings(credentials, siteId, change);
2618
+ const takedown = settings.expiresAt === null ? "none" : `${settings.expiresAt} (${settings.expiryAction})`;
2619
+ return {
2620
+ text: [
2621
+ `Updated ${subdomain}.`,
2622
+ ` name ${settings.name ?? "(none)"}`,
2623
+ ` access ${accessOf(settings)}`,
2624
+ ` mode ${servingModeOf$1(settings)}`,
2625
+ ` forms ${settings.formsEnabled ? "on" : "off"}`,
2626
+ ` takedown ${takedown}`,
2627
+ ...settings.replacedInviteOnly ? [
2628
+ "The password replaced invite-only: the people on its list can no longer get in without it. The list is kept for if you switch back in the dashboard."
2629
+ ] : [],
2630
+ settings.live ? "Reaching every edge takes up to about a minute." : "Nothing is published yet, so this applies from the first publish."
2631
+ ].join("\n"),
2632
+ json: { ...settings, mode: servingModeOf$1(settings) },
2633
+ code: 0
2634
+ };
2635
+ }
1846
2636
  async function tokens() {
1847
2637
  const found = credentialsOr();
1848
2638
  if ("result" in found) return found.result;
@@ -1894,6 +2684,11 @@ const HELP = `drop2run — publish a static site from the command line
1894
2684
  drop2run ls List your sites
1895
2685
  drop2run folders List your folders, as --folder takes them
1896
2686
  drop2run open [site] Open a site in a browser
2687
+ drop2run info [site] Show a site's settings and versions
2688
+ drop2run set <setting> <value> [--site X] Change one setting (see Settings below)
2689
+ drop2run unset name|password|expires Clear a name, a password or a takedown
2690
+ drop2run pause [site] Take a site off the air, keeping its files
2691
+ drop2run resume [site] Put a paused site back on the air
1897
2692
  drop2run rollback <deployId> [--site X] Put an earlier version back live
1898
2693
  drop2run rm <site> --yes Delete a site and everything on it
1899
2694
  drop2run token list List your access tokens
@@ -1909,6 +2704,23 @@ Flags
1909
2704
  or a folder id (init and deploy only)
1910
2705
  --device Sign in by approving a code on another machine
1911
2706
  --yes Confirm a deletion, which cannot be undone
2707
+ --then X What \`set expires\` does when the date arrives: pause
2708
+ (the default, keeps the files) or delete (needs --yes)
2709
+
2710
+ Settings
2711
+ set name <text> What the dashboard calls the site; put a
2712
+ name with words starting "-" after --
2713
+ set mode static|spa|docs static answers 404 for a missing path, spa
2714
+ falls back to index.html, docs uses the viewer
2715
+ set password Typed at a hidden prompt, or piped in:
2716
+ printf %s "$PW" | drop2run set password
2717
+ set forms on|off Whether its forms accept submissions
2718
+ set expires <ISO 8601> Take it down then, e.g. 2026-12-31T09:00:00Z
2719
+ set folder <path|id|root> Move it to a folder, or root for the top level
2720
+
2721
+ \`set\` and \`unset\` act on --site, else the site in drop2run.json. A
2722
+ setting your plan does not include is refused with the reason, and
2723
+ \`info\` says which ones those are.
1912
2724
 
1913
2725
  Where a site comes from
1914
2726
  --subdomain creates one under the name you give. Otherwise: --site, then
@@ -1925,10 +2737,10 @@ Where a site comes from
1925
2737
  Signing in
1926
2738
  \`login\` opens a browser and listens on 127.0.0.1, so it needs both on this
1927
2739
  machine. Over SSH, in a container or under WSL, use \`login --device\`: it
1928
- prints a short code to enter at https://dropto.run/device from anywhere.
2740
+ prints a short code to enter at https://app.dropto.run/device from anywhere.
1929
2741
 
1930
2742
  For CI, neither flow applies — create a token at
1931
- https://dropto.run/account/tokens and either set DROP2RUN_TOKEN in your
2743
+ https://app.dropto.run/account/tokens and either set DROP2RUN_TOKEN in your
1932
2744
  environment or put it in ~/.config/drop2run/config.json as {"token": "d2r_..."}.
1933
2745
  `;
1934
2746
  function flagValue(args, flag) {
@@ -1937,7 +2749,7 @@ function flagValue(args, flag) {
1937
2749
  const value = args[at + 1];
1938
2750
  return value === void 0 || value.startsWith("-") ? void 0 : value;
1939
2751
  }
1940
- const VALUE_FLAGS = ["--site", "--subdomain", "--folder"];
2752
+ const VALUE_FLAGS = ["--site", "--subdomain", "--folder", "--then"];
1941
2753
  function positionals(argv) {
1942
2754
  const found = [];
1943
2755
  for (let at = 0; at < argv.length; at++) {
@@ -1961,7 +2773,7 @@ const SUBDOMAIN_FLAG = {
1961
2773
  const FOLDER_FLAG = {
1962
2774
  flag: "--folder",
1963
2775
  example: "Clients/Acme",
1964
- effect: "`--folder` only files a new one — move an existing site from the dashboard",
2776
+ effect: "`--folder` only files a new one — move an existing site with `drop2run set folder`",
1965
2777
  elsewhere: (command) => command === "ls" || command === "list" ? " `drop2run folders` lists them." : ""
1966
2778
  };
1967
2779
  function newSiteFlagProblem(argv, command, site, value, described) {
@@ -1978,12 +2790,15 @@ function newSiteFlagProblem(argv, command, site, value, described) {
1978
2790
  }
1979
2791
  return void 0;
1980
2792
  }
1981
- async function run(argv) {
2793
+ async function run(commandLine) {
2794
+ const end = commandLine.indexOf("--");
2795
+ const argv = end === -1 ? commandLine : commandLine.slice(0, end);
2796
+ const literal = end === -1 ? [] : commandLine.slice(end + 1);
1982
2797
  const json = argv.includes("--json");
1983
- const positional = positionals(argv);
2798
+ const positional = [...positionals(argv), ...literal];
1984
2799
  const [command, ...rest] = positional;
1985
2800
  if (argv.includes("--version")) {
1986
- const { version } = await import("./package-bzaCMW_E.js").then(
2801
+ const { version } = await import("./package-BycuI1VJ.js").then(
1987
2802
  (module) => module.default
1988
2803
  );
1989
2804
  return { text: version, json: { version }, code: 0 };
@@ -1999,6 +2814,12 @@ async function run(argv) {
1999
2814
  const failed = { text: refusal, json: { error: refusal }, code: 1 };
2000
2815
  return json ? { ...failed, text: JSON.stringify(failed.json, null, 2) } : failed;
2001
2816
  }
2817
+ const then = flagValue(argv, "--then");
2818
+ if (argv.includes("--then") && then === void 0) {
2819
+ const message = "`--then` needs a value: `--then pause` or `--then delete`.";
2820
+ const failed = { text: message, json: { error: message }, code: 1 };
2821
+ return json ? { ...failed, text: JSON.stringify(failed.json, null, 2) } : failed;
2822
+ }
2002
2823
  const result = await dispatch(
2003
2824
  command,
2004
2825
  rest,
@@ -2006,11 +2827,12 @@ async function run(argv) {
2006
2827
  json,
2007
2828
  argv.includes("--device"),
2008
2829
  argv.includes("--yes"),
2009
- { subdomain, folder }
2830
+ { subdomain, folder },
2831
+ then
2010
2832
  );
2011
2833
  return json ? { ...result, text: JSON.stringify(result.json, null, 2) } : result;
2012
2834
  }
2013
- async function dispatch(command, rest, site, json, device, confirmed, newSite = {}) {
2835
+ async function dispatch(command, rest, site, json, device, confirmed, newSite = {}, then = void 0) {
2014
2836
  switch (command) {
2015
2837
  case "login": {
2016
2838
  const print = json ? () => {
@@ -2036,6 +2858,15 @@ async function dispatch(command, rest, site, json, device, confirmed, newSite =
2036
2858
  return await folders();
2037
2859
  case "open":
2038
2860
  return await open(rest[0] ?? site);
2861
+ case "info":
2862
+ return await info(rest[0] ?? site);
2863
+ case "set":
2864
+ return await set(site, rest[0], rest.slice(1), { then, confirmed });
2865
+ case "unset":
2866
+ return await unset(site, rest[0], rest.slice(1));
2867
+ case "pause":
2868
+ case "resume":
2869
+ return await pauseOrResume(command, rest[0] ?? site);
2039
2870
  case "rollback":
2040
2871
  return await rollback(rest[0], site);
2041
2872
  case "rm":
@@ -2043,7 +2874,7 @@ async function dispatch(command, rest, site, json, device, confirmed, newSite =
2043
2874
  case "token":
2044
2875
  case "tokens":
2045
2876
  return rest[0] === void 0 || rest[0] === "list" ? await tokens() : {
2046
- 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.`,
2877
+ 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.`,
2047
2878
  json: { error: `Unknown token subcommand "${rest[0]}".` },
2048
2879
  code: 1
2049
2880
  };