drop2run 0.9.0 → 0.11.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
@@ -32,6 +32,9 @@ drop2run rm <site> --yes Delete a site and everything on it
32
32
  drop2run comments [site] Read the comments left on a site's pages
33
33
  drop2run comments reply <thread> <text> Answer a thread as the owner [--resolve]
34
34
  drop2run comments resolve|reopen <thread> Resolve a thread, or open it again
35
+ drop2run people [site] Who the site is shared with
36
+ drop2run invite <email>... [--role comment] Share it with people; each is emailed
37
+ drop2run uninvite <email> Take someone off the list
35
38
  drop2run token list List your access tokens
36
39
  drop2run whoami Check the token and whose it is
37
40
  drop2run where Show which token source is in use
@@ -40,6 +43,18 @@ drop2run --version Print the version
40
43
 
41
44
  `--json` on any command prints machine-readable output instead of text.
42
45
 
46
+ ## Sharing for review
47
+
48
+ ```bash
49
+ drop2run invite dana@example.com --role comment --site calm-cedar # emailed an invitation
50
+ drop2run set invite-only on --site calm-cedar # only the list gets in
51
+ drop2run set comments invited --site calm-cedar # the people invited to comment can
52
+ ```
53
+
54
+ `drop2run people` shows the list and `drop2run uninvite` takes someone off it. `set comments anyone` lets
55
+ anybody signed in comment instead, on a site you keep public or behind a password. Both are plan features,
56
+ refused with the reason where the plan lacks them.
57
+
43
58
  ## Comments
44
59
 
45
60
  On a plan that includes comments, people viewing a site can pin comments to its pages. `drop2run comments`
package/dist/index.js CHANGED
@@ -190,10 +190,7 @@ async function folderFromListing(credentials, siteId, subdomain) {
190
190
  }
191
191
  async function getSite(credentials, siteId) {
192
192
  const [site, invitedOnly] = await Promise.all([
193
- call(
194
- credentials,
195
- `sites/${encodeURIComponent(siteId)}`
196
- ),
193
+ call(credentials, `sites/${encodeURIComponent(siteId)}`),
197
194
  readInvitedOnly(credentials, siteId)
198
195
  ]);
199
196
  const folderId = site.folderId !== void 0 ? site.folderId : await folderFromListing(credentials, site.siteId, site.subdomain);
@@ -215,6 +212,8 @@ async function getSite(credentials, siteId) {
215
212
  expiresAt: site.expiresAt,
216
213
  expiryAction: actionOf(site.expiresAt, site.expiryAction ?? ""),
217
214
  scheduledExpiryAvailable: site.scheduledExpiryAvailable,
215
+ comments: audienceOf(site.commentAudience),
216
+ commentsAvailable: site.feedbackAvailable === true,
218
217
  folderId,
219
218
  deploys: site.deploys.map((deploy2) => ({
220
219
  deployId: deploy2.deployId,
@@ -239,9 +238,11 @@ async function updateSiteSettings(credentials, siteId, change) {
239
238
  if (change.expiryAction !== void 0) body.expiryAction = change.expiryAction;
240
239
  if (change.formsEnabled !== void 0) body.formsEnabled = change.formsEnabled;
241
240
  if (change.folderId !== void 0) body.folderId = change.folderId;
241
+ if (change.invitedOnly !== void 0) body.invitedOnly = change.invitedOnly;
242
+ if (change.comments !== void 0) body.commentAudience = API_AUDIENCE[change.comments];
242
243
  if (Object.keys(body).length === 0) throw new Error("Nothing to change — name a setting.");
243
244
  const wasInvitedOnly = change.password !== void 0 && change.password !== "" ? await readInvitedOnly(credentials, siteId) : null;
244
- const settings = await call(credentials, `sites/${encodeURIComponent(siteId)}`, {
245
+ const { commentAudience, ...settings } = await call(credentials, `sites/${encodeURIComponent(siteId)}`, {
245
246
  method: "PATCH",
246
247
  body: JSON.stringify(body)
247
248
  });
@@ -250,9 +251,19 @@ async function updateSiteSettings(credentials, siteId, change) {
250
251
  ...settings,
251
252
  expiryAction: actionOf(settings.expiresAt, settings.expiryAction ?? ""),
252
253
  invitedOnly,
254
+ comments: audienceOf(commentAudience),
253
255
  replacedInviteOnly: wasInvitedOnly === true && invitedOnly === false
254
256
  };
255
257
  }
258
+ const API_AUDIENCE = {
259
+ off: "Off",
260
+ invited: "Invited",
261
+ anyone: "Anyone"
262
+ };
263
+ function audienceOf(value) {
264
+ const lower = value?.toLowerCase();
265
+ return lower === "off" || lower === "invited" || lower === "anyone" ? lower : null;
266
+ }
256
267
  async function setSitePaused(credentials, siteId, action) {
257
268
  const state = await call(
258
269
  credentials,
@@ -371,6 +382,34 @@ async function setCommentResolved(credentials, siteId, threadId, resolved) {
371
382
  { method: "POST" }
372
383
  );
373
384
  }
385
+ const PEOPLE_PER_REQUEST = 50;
386
+ async function listPeople(credentials, siteId) {
387
+ const page = await call(credentials, `sites/${encodeURIComponent(siteId)}/viewers?pageSize=${PEOPLE_PER_REQUEST}`);
388
+ return {
389
+ invitedOnly: page.invitedOnly,
390
+ total: page.total,
391
+ limit: page.limit,
392
+ people: page.viewers.map((row) => ({
393
+ email: row.email,
394
+ role: enumName(row.role),
395
+ owner: row.owner,
396
+ status: enumName(row.status)
397
+ }))
398
+ };
399
+ }
400
+ async function invitePeople(credentials, siteId, emails, role) {
401
+ return await call(credentials, `sites/${encodeURIComponent(siteId)}/viewers`, {
402
+ method: "POST",
403
+ body: JSON.stringify({ emails, role: role === "comment" ? "Comment" : "View", notify: true })
404
+ });
405
+ }
406
+ async function removePerson(credentials, siteId, email) {
407
+ await call(
408
+ credentials,
409
+ `sites/${encodeURIComponent(siteId)}/viewers?email=${encodeURIComponent(email)}`,
410
+ { method: "DELETE" }
411
+ );
412
+ }
374
413
  const VERSIONS_LISTED = 10;
375
414
  function servingModeOf$1(site) {
376
415
  if (site.spaMode) return "spa";
@@ -399,6 +438,7 @@ function describeSite(site, folderPath) {
399
438
  ` mode ${servingModeOf$1(site)}${site.modeIsManual ? "" : " (detected on each publish)"}`,
400
439
  ` password ${site.passwordProtected ? "on" : "off"}${unavailable(site.passwordProtectionAvailable)}`,
401
440
  ` forms ${site.formsEnabled ? "on" : "off"}${unavailable(site.formsAvailable)}`,
441
+ ` comments ${site.comments ?? "unknown"}${unavailable(site.commentsAvailable)}`,
402
442
  ` takedown ${site.expiresAt === null ? "none" : `${site.expiresAt} (${site.expiryAction})`}${unavailable(site.scheduledExpiryAvailable)}`,
403
443
  ` folder ${site.folderId === null ? "(top level)" : folderPath ?? site.folderId}`,
404
444
  "",
@@ -671,7 +711,9 @@ async function prepareDeploy(siteId, files, options, signal) {
671
711
  }));
672
712
  const prepared = await request(
673
713
  `sites/${encodeURIComponent(siteId)}/deploys/prepare`,
674
- { files: manifest },
714
+ // `batches` asks for batch permits; an API that predates them ignores the field, and one that
715
+ // knows them answers with single permits until they are switched on.
716
+ { files: manifest, batches: true },
675
717
  options,
676
718
  signal
677
719
  );
@@ -760,6 +802,47 @@ async function problemToError(response) {
760
802
  const message = typeof problem.detail === "string" ? problem.detail : typeof problem.title === "string" ? problem.title : `The server responded with ${response.status}.`;
761
803
  return new DeployError(code, message, problem);
762
804
  }
805
+ function batchLayout(members) {
806
+ return members.map((member) => `${member.sha256} ${member.size}
807
+ `).join("");
808
+ }
809
+ function isBatch(target) {
810
+ return "files" in target;
811
+ }
812
+ function parseBatchResult(body) {
813
+ try {
814
+ const parsed = JSON.parse(body);
815
+ if (parsed === null || typeof parsed !== "object") return null;
816
+ const { stored, present, failed } = parsed;
817
+ if (typeof stored !== "number" || typeof present !== "number" || !Array.isArray(failed))
818
+ return null;
819
+ return { stored, present, failed };
820
+ } catch {
821
+ return null;
822
+ }
823
+ }
824
+ async function mapInOrder(items, limit, fn, signal) {
825
+ const results = new Array(items.length);
826
+ let next = 0;
827
+ let failure2 = null;
828
+ const lane = async () => {
829
+ for (; ; ) {
830
+ if (failure2 !== null) return;
831
+ const index = next++;
832
+ if (index >= items.length) return;
833
+ try {
834
+ results[index] = await fn(items[index], index);
835
+ } catch (error) {
836
+ failure2 ??= { error };
837
+ return;
838
+ }
839
+ }
840
+ };
841
+ const lanes = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, lane);
842
+ await Promise.all(lanes);
843
+ if (failure2 !== null) throw failure2.error;
844
+ return results;
845
+ }
763
846
  const FILE_TYPES = [
764
847
  // Documents. The three extensions that make a folder a documents site.
765
848
  {
@@ -874,13 +957,14 @@ const FILE_TYPES = [
874
957
  reader: true,
875
958
  editable: true
876
959
  },
877
- // Source the viewer highlights. A link straight to one of these hands over the file.
960
+ // Source the viewer highlights. Inside a documents site a link straight to one opens it in the
961
+ // reader; on an ordinary site, and with `?raw`, it hands over the file.
878
962
  {
879
963
  ext: ".css",
880
964
  mime: "text/css; charset=utf-8",
881
965
  kind: "code",
882
966
  document: false,
883
- reader: false,
967
+ reader: true,
884
968
  editable: true
885
969
  },
886
970
  {
@@ -888,7 +972,7 @@ const FILE_TYPES = [
888
972
  mime: "text/javascript; charset=utf-8",
889
973
  kind: "code",
890
974
  document: false,
891
- reader: false,
975
+ reader: true,
892
976
  editable: true
893
977
  },
894
978
  {
@@ -896,7 +980,7 @@ const FILE_TYPES = [
896
980
  mime: null,
897
981
  kind: "code",
898
982
  document: false,
899
- reader: false,
983
+ reader: true,
900
984
  editable: false
901
985
  },
902
986
  {
@@ -904,7 +988,7 @@ const FILE_TYPES = [
904
988
  mime: null,
905
989
  kind: "code",
906
990
  document: false,
907
- reader: false,
991
+ reader: true,
908
992
  editable: false
909
993
  },
910
994
  {
@@ -912,7 +996,7 @@ const FILE_TYPES = [
912
996
  mime: null,
913
997
  kind: "code",
914
998
  document: false,
915
- reader: false,
999
+ reader: true,
916
1000
  editable: false
917
1001
  },
918
1002
  {
@@ -920,19 +1004,18 @@ const FILE_TYPES = [
920
1004
  mime: null,
921
1005
  kind: "code",
922
1006
  document: false,
923
- reader: false,
1007
+ reader: true,
924
1008
  editable: false
925
1009
  },
926
1010
  // The languages an assistant writes most, beyond the web's own. Same row as `.ts`: shown as source
927
- // when somebody opens one in the reader or publishes it on its own, handed over as a file when a
928
- // link names it directly, and no media type — adding one changes what a browser does with a
1011
+ // in the reader, handed over as a file with `?raw` or on an ordinary site, and no media type — adding one changes what a browser does with a
929
1012
  // published file, which I10 makes a decision of its own rather than a side effect of this list.
930
1013
  {
931
1014
  ext: ".py",
932
1015
  mime: null,
933
1016
  kind: "code",
934
1017
  document: false,
935
- reader: false,
1018
+ reader: true,
936
1019
  editable: false
937
1020
  },
938
1021
  {
@@ -940,7 +1023,7 @@ const FILE_TYPES = [
940
1023
  mime: null,
941
1024
  kind: "code",
942
1025
  document: false,
943
- reader: false,
1026
+ reader: true,
944
1027
  editable: false
945
1028
  },
946
1029
  {
@@ -948,7 +1031,7 @@ const FILE_TYPES = [
948
1031
  mime: null,
949
1032
  kind: "code",
950
1033
  document: false,
951
- reader: false,
1034
+ reader: true,
952
1035
  editable: false
953
1036
  },
954
1037
  {
@@ -956,7 +1039,7 @@ const FILE_TYPES = [
956
1039
  mime: null,
957
1040
  kind: "code",
958
1041
  document: false,
959
- reader: false,
1042
+ reader: true,
960
1043
  editable: false
961
1044
  },
962
1045
  {
@@ -964,7 +1047,7 @@ const FILE_TYPES = [
964
1047
  mime: null,
965
1048
  kind: "code",
966
1049
  document: false,
967
- reader: false,
1050
+ reader: true,
968
1051
  editable: false
969
1052
  },
970
1053
  {
@@ -972,7 +1055,7 @@ const FILE_TYPES = [
972
1055
  mime: null,
973
1056
  kind: "code",
974
1057
  document: false,
975
- reader: false,
1058
+ reader: true,
976
1059
  editable: false
977
1060
  },
978
1061
  {
@@ -980,7 +1063,7 @@ const FILE_TYPES = [
980
1063
  mime: null,
981
1064
  kind: "code",
982
1065
  document: false,
983
- reader: false,
1066
+ reader: true,
984
1067
  editable: false
985
1068
  },
986
1069
  {
@@ -988,7 +1071,7 @@ const FILE_TYPES = [
988
1071
  mime: null,
989
1072
  kind: "code",
990
1073
  document: false,
991
- reader: false,
1074
+ reader: true,
992
1075
  editable: false
993
1076
  },
994
1077
  {
@@ -996,7 +1079,7 @@ const FILE_TYPES = [
996
1079
  mime: null,
997
1080
  kind: "code",
998
1081
  document: false,
999
- reader: false,
1082
+ reader: true,
1000
1083
  editable: false
1001
1084
  },
1002
1085
  {
@@ -1004,7 +1087,7 @@ const FILE_TYPES = [
1004
1087
  mime: null,
1005
1088
  kind: "code",
1006
1089
  document: false,
1007
- reader: false,
1090
+ reader: true,
1008
1091
  editable: false
1009
1092
  },
1010
1093
  {
@@ -1012,7 +1095,7 @@ const FILE_TYPES = [
1012
1095
  mime: null,
1013
1096
  kind: "code",
1014
1097
  document: false,
1015
- reader: false,
1098
+ reader: true,
1016
1099
  editable: false
1017
1100
  },
1018
1101
  {
@@ -1020,7 +1103,7 @@ const FILE_TYPES = [
1020
1103
  mime: null,
1021
1104
  kind: "code",
1022
1105
  document: false,
1023
- reader: false,
1106
+ reader: true,
1024
1107
  editable: false
1025
1108
  },
1026
1109
  {
@@ -1028,7 +1111,7 @@ const FILE_TYPES = [
1028
1111
  mime: null,
1029
1112
  kind: "code",
1030
1113
  document: false,
1031
- reader: false,
1114
+ reader: true,
1032
1115
  editable: false
1033
1116
  },
1034
1117
  {
@@ -1036,7 +1119,7 @@ const FILE_TYPES = [
1036
1119
  mime: null,
1037
1120
  kind: "code",
1038
1121
  document: false,
1039
- reader: false,
1122
+ reader: true,
1040
1123
  editable: false
1041
1124
  },
1042
1125
  {
@@ -1044,7 +1127,7 @@ const FILE_TYPES = [
1044
1127
  mime: "application/xml",
1045
1128
  kind: "code",
1046
1129
  document: false,
1047
- reader: false,
1130
+ reader: true,
1048
1131
  editable: true
1049
1132
  },
1050
1133
  // Pictures, shown with an img element.
@@ -1115,25 +1198,25 @@ const FILE_TYPES = [
1115
1198
  reader: false,
1116
1199
  editable: false
1117
1200
  },
1118
- // A customer's own pages. Never rendered inside the reader.
1201
+ // A customer's own pages. Inside a documents site a navigation opens the reader, which frames the
1202
+ // page; `?raw` and every ordinary site serve the page itself.
1119
1203
  {
1120
1204
  ext: ".html",
1121
1205
  mime: "text/html; charset=utf-8",
1122
1206
  kind: "html",
1123
1207
  document: false,
1124
- reader: false,
1208
+ reader: true,
1125
1209
  editable: true
1126
1210
  },
1127
1211
  {
1128
- // ⚠️ No media type, and that is the state of the code rather than a decision: `TYPES` in
1129
- // `headers.ts` has never held `htm`, so a published `.htm` is served as octet-stream and
1130
- // downloads instead of rendering. Recorded here rather than fixed, because this table was
1131
- // introduced as a refactor and a refactor that changes what a URL answers is not one.
1212
+ // An HTML page in every respect the Worker decides by extension: media type, the revalidated
1213
+ // cache policy invariant I7 requires, and the badge's `isHtml`. Missing from `TYPES` it used to
1214
+ // download, which inside the reader meant a frame that downloaded instead of showing a page.
1132
1215
  ext: ".htm",
1133
- mime: null,
1216
+ mime: "text/html; charset=utf-8",
1134
1217
  kind: "html",
1135
1218
  document: false,
1136
- reader: false,
1219
+ reader: true,
1137
1220
  editable: true
1138
1221
  },
1139
1222
  // Everything below renders as nothing — the viewer offers a download — but still needs a media
@@ -1243,13 +1326,46 @@ async function sha256Hex(bytes) {
1243
1326
  const digest = await crypto.subtle.digest("SHA-256", bytes);
1244
1327
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
1245
1328
  }
1329
+ const HASH_CONCURRENCY = 4;
1330
+ const HASH_BYTES_IN_FLIGHT = 16 * 1024 * 1024;
1246
1331
  async function hashAll(files, onProgress, signal) {
1247
- const hashed = [];
1248
- for (const [index, file] of files.entries()) {
1249
- hashed.push({ ...file, sha256: await sha256Hex(await readForHash(file)) });
1250
- onProgress?.(index + 1, files.length);
1251
- }
1252
- return hashed;
1332
+ const budget = byteBudget(HASH_BYTES_IN_FLIGHT);
1333
+ let done = 0;
1334
+ return mapInOrder(
1335
+ files,
1336
+ HASH_CONCURRENCY,
1337
+ async (file) => {
1338
+ const size = sizeOf(file.bytes);
1339
+ await budget.acquire(size);
1340
+ try {
1341
+ signal?.throwIfAborted();
1342
+ const hashed = { ...file, sha256: await sha256Hex(await readForHash(file)) };
1343
+ done += 1;
1344
+ onProgress?.(done, files.length);
1345
+ return hashed;
1346
+ } finally {
1347
+ budget.release(size);
1348
+ }
1349
+ }
1350
+ );
1351
+ }
1352
+ function byteBudget(limit) {
1353
+ let held = 0;
1354
+ let waiting = [];
1355
+ return {
1356
+ async acquire(size) {
1357
+ while (held > 0 && held + size > limit) {
1358
+ await new Promise((resolve2) => waiting.push(resolve2));
1359
+ }
1360
+ held += size;
1361
+ },
1362
+ release(size) {
1363
+ held -= size;
1364
+ const woken = waiting;
1365
+ waiting = [];
1366
+ for (const wake of woken) wake();
1367
+ }
1368
+ };
1253
1369
  }
1254
1370
  async function readForHash(file) {
1255
1371
  try {
@@ -1278,10 +1394,13 @@ const IGNORED_DIRECTORIES = [
1278
1394
  ".codex",
1279
1395
  ".gemini"
1280
1396
  ];
1397
+ function isEnvironmentFile(name) {
1398
+ return name === ".env" || name === ".envrc" || name.startsWith(".env.");
1399
+ }
1281
1400
  function shouldIgnore(path) {
1282
1401
  const segments = path.split("/");
1283
1402
  const name = segments.at(-1) ?? "";
1284
- if (name === "" || IGNORED_EXACT.has(name)) return true;
1403
+ if (name === "" || IGNORED_EXACT.has(name) || isEnvironmentFile(name)) return true;
1285
1404
  if (name.startsWith("._")) return true;
1286
1405
  return segments.some((segment) => IGNORED_DIRECTORIES.includes(segment));
1287
1406
  }
@@ -1416,9 +1535,7 @@ async function readSiteName(files) {
1416
1535
  }
1417
1536
  }
1418
1537
  function decodeHead(bytes) {
1419
- return new TextDecoder("utf-8", { fatal: false }).decode(
1420
- bytes.subarray(0, HEAD_BYTES)
1421
- );
1538
+ return new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(0, HEAD_BYTES));
1422
1539
  }
1423
1540
  function nameFromHtml(html) {
1424
1541
  const document = parse(html);
@@ -1443,9 +1560,7 @@ function parse(html) {
1443
1560
  }
1444
1561
  }
1445
1562
  function matchTag(html, tag) {
1446
- const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(
1447
- html
1448
- );
1563
+ const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(html);
1449
1564
  return match?.[1] === void 0 ? null : decodeEntities(match[1]);
1450
1565
  }
1451
1566
  function matchOgSiteName(html) {
@@ -1546,6 +1661,7 @@ function trimSuffix(title) {
1546
1661
  return title;
1547
1662
  }
1548
1663
  const DEFAULT_CONCURRENCY = 8;
1664
+ const MAX_BATCH_CONCURRENCY = 64;
1549
1665
  const DEFAULT_BYTES_IN_FLIGHT = 64 * 1024 * 1024;
1550
1666
  const DEFAULT_RETRIES = 3;
1551
1667
  const BACKOFF_MS = [1e3, 2e3, 4e3];
@@ -1562,14 +1678,31 @@ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {
1562
1678
  const put = deps.put ?? defaultTransport(deps.fetch);
1563
1679
  const delay = deps.delay ?? defaultDelay;
1564
1680
  const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
1681
+ const batchConcurrency = Math.max(
1682
+ 1,
1683
+ Math.min(MAX_BATCH_CONCURRENCY, Math.floor(deps.batchConcurrency ?? concurrency))
1684
+ );
1565
1685
  const retries = deps.retries ?? DEFAULT_RETRIES;
1566
1686
  const stallMs = deps.stallMs ?? DEFAULT_STALL_MS;
1567
1687
  const now = deps.now ?? Date.now;
1568
1688
  const maxBytesInFlight = deps.maxBytesInFlight ?? DEFAULT_BYTES_IN_FLIGHT;
1569
1689
  const byPath = new Map(files.map((file) => [file.path, file]));
1690
+ const fileCount = targets.reduce(
1691
+ (sum, target) => sum + (isBatch(target) ? target.files.length : 1),
1692
+ 0
1693
+ );
1570
1694
  let nextIndex = 0;
1571
1695
  let done = 0;
1572
1696
  let failed = false;
1697
+ let firstFailure;
1698
+ const local = new AbortController();
1699
+ const inner = local.signal;
1700
+ const fail = (error) => {
1701
+ if (failed) return;
1702
+ failed = true;
1703
+ firstFailure = error;
1704
+ local.abort();
1705
+ };
1573
1706
  let reserved = 0;
1574
1707
  let waiting = [];
1575
1708
  const acquire = async (size) => {
@@ -1584,17 +1717,32 @@ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {
1584
1717
  waiting = [];
1585
1718
  for (const wake of woken) wake();
1586
1719
  };
1720
+ const running = { single: 0, batch: 0 };
1721
+ const limit = { single: concurrency, batch: batchConcurrency };
1722
+ let slotWaiting = [];
1723
+ const takeSlot = async (kind) => {
1724
+ while (running[kind] >= limit[kind]) {
1725
+ await new Promise((resolve2) => slotWaiting.push(resolve2));
1726
+ }
1727
+ running[kind] += 1;
1728
+ };
1729
+ const freeSlot = (kind) => {
1730
+ running[kind] -= 1;
1731
+ const woken = slotWaiting;
1732
+ slotWaiting = [];
1733
+ for (const wake of woken) wake();
1734
+ };
1587
1735
  let landedBytes = 0;
1588
1736
  const inFlight = /* @__PURE__ */ new Map();
1589
1737
  let lastReport = Number.NEGATIVE_INFINITY;
1590
1738
  const report = (path) => {
1591
- if (onProgress === void 0) return;
1739
+ if (onProgress === void 0 || failed) return;
1592
1740
  const at = now();
1593
1741
  if (path === void 0 && at - lastReport < PROGRESS_INTERVAL_MS) return;
1594
1742
  lastReport = at;
1595
1743
  let bytes = landedBytes;
1596
1744
  for (const sent of inFlight.values()) bytes += sent;
1597
- onProgress(done, targets.length, bytes, path);
1745
+ onProgress(done, fileCount, bytes, path);
1598
1746
  };
1599
1747
  const worker = async () => {
1600
1748
  for (; ; ) {
@@ -1602,25 +1750,49 @@ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {
1602
1750
  const index = nextIndex++;
1603
1751
  const target = targets[index];
1604
1752
  if (target === void 0) return;
1605
- const file = byPath.get(target.path);
1606
- if (file === void 0) {
1607
- failed = true;
1608
- throw new DeployError(
1609
- ClientErrorCode.UploadFailed,
1610
- `The server asked for ${target.path}, which is not in the manifest.`,
1611
- { path: target.path }
1612
- );
1753
+ inner.throwIfAborted();
1754
+ const refs = isBatch(target) ? target.files : [target];
1755
+ const members = [];
1756
+ for (const ref of refs) {
1757
+ const file = byPath.get(ref.path);
1758
+ if (file === void 0) {
1759
+ const missing = new DeployError(
1760
+ ClientErrorCode.UploadFailed,
1761
+ `The server asked for ${ref.path}, which is not in the manifest.`,
1762
+ { path: ref.path }
1763
+ );
1764
+ fail(missing);
1765
+ throw missing;
1766
+ }
1767
+ members.push(file);
1613
1768
  }
1614
- const size = sizeOf(file.bytes);
1615
- await acquire(size);
1769
+ const layout = isBatch(target) ? new TextEncoder().encode(
1770
+ batchLayout(
1771
+ target.files.map((ref, i) => ({
1772
+ sha256: ref.sha256,
1773
+ size: sizeOf(members[i].bytes)
1774
+ }))
1775
+ )
1776
+ ) : null;
1777
+ const size = members.reduce((sum, file) => sum + sizeOf(file.bytes), layout?.length ?? 0);
1778
+ const copied = isBatch(target) ? members.reduce(
1779
+ (sum, file) => sum + (file.bytes instanceof Blob ? 0 : sizeOf(file.bytes)),
1780
+ 0
1781
+ ) : 0;
1782
+ const reservation = size + copied;
1783
+ const first = members[0].path;
1784
+ const label = members.length === 1 ? first : `${first} and ${members.length - 1} more`;
1785
+ const kind = isBatch(target) ? "batch" : "single";
1786
+ await takeSlot(kind);
1787
+ await acquire(reservation);
1616
1788
  try {
1617
1789
  if (failed) return;
1618
- signal?.throwIfAborted();
1619
- await uploadOne(target, file, {
1790
+ inner.throwIfAborted();
1791
+ const context = {
1620
1792
  put,
1621
1793
  delay,
1622
1794
  retries,
1623
- signal,
1795
+ signal: inner,
1624
1796
  uploadUrl,
1625
1797
  stallMs,
1626
1798
  onSent: (sent) => {
@@ -1629,26 +1801,34 @@ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {
1629
1801
  },
1630
1802
  onRetry: (attempt, stalled) => {
1631
1803
  inFlight.set(index, 0);
1632
- onRetry?.(target.path, attempt, retries + 1, stalled);
1804
+ onRetry?.(label, attempt, retries + 1, stalled);
1633
1805
  }
1634
- });
1806
+ };
1807
+ if (isBatch(target) && layout !== null)
1808
+ await uploadBatchOne(target, members, layout, context);
1809
+ else await uploadOne(target, members[0], context);
1635
1810
  } catch (error) {
1636
- failed = true;
1811
+ fail(error);
1637
1812
  throw error;
1638
1813
  } finally {
1639
- release(size);
1814
+ release(reservation);
1815
+ freeSlot(kind);
1640
1816
  }
1641
1817
  inFlight.delete(index);
1642
- done += 1;
1818
+ done += members.length;
1643
1819
  landedBytes += size;
1644
- report(file.path);
1820
+ report(members.at(-1).path);
1645
1821
  }
1646
1822
  };
1647
- const workers = Array.from({ length: Math.min(concurrency, targets.length) }, worker);
1823
+ const workers = Array.from(
1824
+ { length: Math.min(Math.max(concurrency, batchConcurrency), targets.length) },
1825
+ worker
1826
+ );
1648
1827
  try {
1649
1828
  await Promise.all(workers);
1650
1829
  } catch (error) {
1651
- throw asDeployError(error);
1830
+ throw asDeployError(firstFailure ?? error);
1831
+ } finally {
1652
1832
  }
1653
1833
  }
1654
1834
  async function uploadOne(target, file, context) {
@@ -1661,7 +1841,7 @@ async function uploadOne(target, file, context) {
1661
1841
  context.signal?.throwIfAborted();
1662
1842
  }
1663
1843
  try {
1664
- const status = await context.put({
1844
+ const answer = await context.put({
1665
1845
  url: context.uploadUrl,
1666
1846
  token: target.token,
1667
1847
  body: untyped(file.bytes),
@@ -1669,6 +1849,7 @@ async function uploadOne(target, file, context) {
1669
1849
  onSent: context.onSent,
1670
1850
  stallMs: context.stallMs
1671
1851
  });
1852
+ const status = typeof answer === "number" ? answer : answer.status;
1672
1853
  if (status >= 200 && status < 300) return;
1673
1854
  if (status === 401 || status === 403 || status === 429) {
1674
1855
  throw new DeployError(
@@ -1697,6 +1878,85 @@ async function uploadOne(target, file, context) {
1697
1878
  cause: describeError(lastError)
1698
1879
  });
1699
1880
  }
1881
+ async function uploadBatchOne(batch, members, layout, context) {
1882
+ const first = members[0].path;
1883
+ const label = members.length === 1 ? first : `${first} and ${members.length - 1} more`;
1884
+ let lastError;
1885
+ for (let attempt = 0; attempt <= context.retries; attempt++) {
1886
+ context.signal?.throwIfAborted();
1887
+ if (attempt > 0) {
1888
+ context.onRetry(attempt + 1, lastError instanceof StalledError);
1889
+ await context.delay(BACKOFF_MS[attempt - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
1890
+ context.signal?.throwIfAborted();
1891
+ }
1892
+ try {
1893
+ const body = new Blob([layout, ...members.map((file) => file.bytes)]);
1894
+ const answer = await context.put({
1895
+ url: context.uploadUrl,
1896
+ token: batch.token,
1897
+ body,
1898
+ signal: context.signal,
1899
+ onSent: context.onSent,
1900
+ stallMs: context.stallMs,
1901
+ wantsBody: true
1902
+ });
1903
+ const status = typeof answer === "number" ? answer : answer.status;
1904
+ if (status >= 200 && status < 300) {
1905
+ const result = typeof answer === "number" ? null : parseBatchResult(answer.body);
1906
+ if (result === null) {
1907
+ lastError = new Error(
1908
+ "The upload service answered with something that is not a batch result."
1909
+ );
1910
+ continue;
1911
+ }
1912
+ if (result.failed.length === 0) return;
1913
+ const changed = result.failed.find((failure2) => failure2.reason === "hash_mismatch");
1914
+ if (changed !== void 0) {
1915
+ const path = members[changed.i]?.path ?? first;
1916
+ throw new DeployError(
1917
+ ClientErrorCode.ReadFailed,
1918
+ `${path} changed after it was read, so it no longer matches what was checked. Drop the folder again.`,
1919
+ { path }
1920
+ );
1921
+ }
1922
+ lastError = new Error(
1923
+ `${result.failed.length} of ${members.length} files were not stored.`
1924
+ );
1925
+ continue;
1926
+ }
1927
+ if ([400, 401, 403, 411, 413, 429].includes(status)) {
1928
+ throw new DeployError(ClientErrorCode.UploadFailed, refusalMessage(status, label), {
1929
+ path: first,
1930
+ status
1931
+ });
1932
+ }
1933
+ lastError = new Error(`HTTP ${status}`);
1934
+ } catch (error) {
1935
+ if (error instanceof DeployError) throw error;
1936
+ if (isAbort(error)) throw cancelled();
1937
+ lastError = error;
1938
+ }
1939
+ }
1940
+ const message = lastError instanceof StalledError ? `Upload of ${label} stopped moving and did not recover after ${context.retries + 1} attempts. Check your connection and try again.` : `Could not upload ${label} after ${context.retries + 1} attempts.`;
1941
+ throw new DeployError(ClientErrorCode.UploadFailed, message, {
1942
+ path: first,
1943
+ cause: describeError(lastError)
1944
+ });
1945
+ }
1946
+ function refusalMessage(status, label) {
1947
+ switch (status) {
1948
+ case 401:
1949
+ return `Upload of ${label} was refused because its upload window expired. Try deploying again.`;
1950
+ case 429:
1951
+ return `Upload of ${label} was refused: this deploy has used every upload it was given. Try deploying again.`;
1952
+ case 400:
1953
+ case 411:
1954
+ case 413:
1955
+ return `Upload of ${label} was refused because it no longer matches what was checked — a file may have changed since you dropped it. Drop the folder again.`;
1956
+ default:
1957
+ return `Upload of ${label} was refused by the upload service. Try deploying again.`;
1958
+ }
1959
+ }
1700
1960
  function untyped(content) {
1701
1961
  if (ArrayBuffer.isView(content) || content.type === "") return content;
1702
1962
  return content.slice(0, content.size);
@@ -1707,7 +1967,8 @@ function defaultTransport(doFetch) {
1707
1967
  return Xhr === void 0 ? fetchTransport(globalFetch()) : xhrTransport(Xhr);
1708
1968
  }
1709
1969
  function fetchTransport(doFetch) {
1710
- return async ({ url, token, body, signal }) => {
1970
+ return async (request2) => {
1971
+ const { url, token, body, signal } = request2;
1711
1972
  const response = await doFetch(url, {
1712
1973
  method: "PUT",
1713
1974
  // The view, not a copy. Wrapping it in `new Blob([body.slice().buffer])` made two extra
@@ -1722,11 +1983,11 @@ function fetchTransport(doFetch) {
1722
1983
  headers: { Authorization: `Bearer ${token}` },
1723
1984
  ...signal ? { signal } : {}
1724
1985
  });
1725
- return response.status;
1986
+ return request2.wantsBody === true ? { status: response.status, body: await response.text() } : response.status;
1726
1987
  };
1727
1988
  }
1728
1989
  function xhrTransport(Xhr) {
1729
- return ({ url, token, body, signal, onSent, stallMs }) => new Promise((resolve2, reject) => {
1990
+ return ({ url, token, body, signal, onSent, stallMs, wantsBody }) => new Promise((resolve2, reject) => {
1730
1991
  const xhr = new Xhr();
1731
1992
  let settled = false;
1732
1993
  let timer;
@@ -1760,7 +2021,11 @@ function xhrTransport(Xhr) {
1760
2021
  onSent(event.loaded);
1761
2022
  };
1762
2023
  xhr.onprogress = arm;
1763
- xhr.onload = () => settle(() => resolve2(xhr.status));
2024
+ xhr.onload = () => settle(
2025
+ () => resolve2(
2026
+ wantsBody === true ? { status: xhr.status, body: xhr.responseText ?? "" } : xhr.status
2027
+ )
2028
+ );
1764
2029
  xhr.onerror = () => settle(() => reject(new TypeError("Network error during upload.")));
1765
2030
  arm();
1766
2031
  xhr.send(body);
@@ -1825,11 +2090,16 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1825
2090
  return;
1826
2091
  }
1827
2092
  const sizes = new Map(hashed.map((file) => [file.path, sizeOf(file.bytes)]));
1828
- const totalBytes = prepared.upload.reduce(
1829
- (sum, target) => sum + (sizes.get(target.path) ?? 0),
1830
- 0
1831
- );
1832
- if (prepared.upload.length > 0) {
2093
+ const batches = prepared.batches ?? [];
2094
+ const targets = [...prepared.upload, ...batches];
2095
+ const totalBytes = prepared.upload.reduce((sum, target) => sum + (sizes.get(target.path) ?? 0), 0) + batches.reduce((sum, batch) => {
2096
+ const members = batch.files.map((ref) => ({
2097
+ sha256: ref.sha256,
2098
+ size: sizes.get(ref.path) ?? 0
2099
+ }));
2100
+ return sum + batchLayout(members).length + members.reduce((n, member) => n + member.size, 0);
2101
+ }, 0);
2102
+ if (targets.length > 0) {
1833
2103
  onProgress({
1834
2104
  type: "uploading",
1835
2105
  done: prepared.reused,
@@ -1840,7 +2110,7 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1840
2110
  });
1841
2111
  }
1842
2112
  await uploadAll(
1843
- prepared.upload,
2113
+ targets,
1844
2114
  prepared.uploadUrl,
1845
2115
  hashed,
1846
2116
  (done, _total, bytes, path) => onProgress({
@@ -1853,7 +2123,9 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1853
2123
  ...path === void 0 ? {} : { path }
1854
2124
  }),
1855
2125
  signal,
1856
- options.upload,
2126
+ // The server's number unless the caller set one: it is tuned on the server so a deploy of many
2127
+ // small files speeds up without a CLI or MCP release.
2128
+ typeof prepared.batchConcurrency === "number" && options.upload?.batchConcurrency === void 0 ? { ...options.upload, batchConcurrency: prepared.batchConcurrency } : options.upload,
1857
2129
  (path, attempt, attempts, stalled) => onProgress({ type: "retrying", path, attempt, attempts, stalled })
1858
2130
  );
1859
2131
  onProgress({ type: "completing", reused: prepared.reused });
@@ -2525,7 +2797,9 @@ const SETTINGS_USAGE = [
2525
2797
  " password Read from stdin, or typed at a hidden prompt",
2526
2798
  " forms on|off Whether its forms accept submissions",
2527
2799
  " expires <ISO 8601 instant> When it comes down; add --then delete to delete rather than pause",
2528
- " folder <path|id|root> Which folder it is filed in"
2800
+ " folder <path|id|root> Which folder it is filed in",
2801
+ " invite-only on|off Whether only the people on its list can open it",
2802
+ " comments off|invited|anyone Who may comment on its pages"
2529
2803
  ].join("\n");
2530
2804
  function settingChange(setting, values, then) {
2531
2805
  const value = setting === "name" ? values.join(" ").trim() : values[0];
@@ -2573,6 +2847,14 @@ function settingChange(setting, values, then) {
2573
2847
  }
2574
2848
  case "folder":
2575
2849
  return value ? { folder: value } : needs("Clients/Acme");
2850
+ case "invite-only":
2851
+ if (value === "on" || value === "off") return { change: { invitedOnly: value === "on" } };
2852
+ return { refusal: "`set invite-only` takes `on` or `off`." };
2853
+ case "comments":
2854
+ if (value === "off" || value === "invited" || value === "anyone") {
2855
+ return { change: { comments: value } };
2856
+ }
2857
+ return { refusal: "`set comments` takes `off`, `invited` or `anyone`." };
2576
2858
  default:
2577
2859
  return {
2578
2860
  refusal: `${setting === void 0 ? "Name a setting." : `\`${setting}\` is not a setting.`} Settings:
@@ -2650,9 +2932,14 @@ async function applied(credentials, siteId, subdomain, change) {
2650
2932
  ` access ${accessOf(settings)}`,
2651
2933
  ` mode ${servingModeOf$1(settings)}`,
2652
2934
  ` forms ${settings.formsEnabled ? "on" : "off"}`,
2935
+ ` comments ${settings.comments ?? "unknown"}`,
2653
2936
  ` takedown ${takedown}`,
2654
2937
  ...settings.replacedInviteOnly ? [
2655
- "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."
2938
+ "The password replaced invite-only: the people on its list can no longer get in without it. The list is kept; `drop2run set invite-only on` switches back."
2939
+ ] : [],
2940
+ // The one combination that reads as on and does nothing, which the API keeps on purpose.
2941
+ ...settings.comments === "invited" && settings.invitedOnly === false ? [
2942
+ "Comments are for invited people, but the site is not invite-only, so nobody can comment yet. Run `drop2run set invite-only on`, or `drop2run set comments anyone`."
2656
2943
  ] : [],
2657
2944
  settings.live ? "Reaching every edge takes up to about a minute." : "Nothing is published yet, so this applies from the first publish."
2658
2945
  ].join("\n"),
@@ -2678,6 +2965,72 @@ async function tokens() {
2678
2965
  return failure(error instanceof Error ? error.message : String(error));
2679
2966
  }
2680
2967
  }
2968
+ async function people(site) {
2969
+ const found = credentialsOr();
2970
+ if ("result" in found) return found.result;
2971
+ const target = siteOr(site, "drop2run people <subdomain>");
2972
+ if ("result" in target) return target.result;
2973
+ try {
2974
+ const summary = await findSite(found.credentials, target.named);
2975
+ const list2 = await listPeople(found.credentials, summary.siteId);
2976
+ const head = `${list2.total} of ${list2.limit} on ${summary.subdomain}'s list. ` + (list2.invitedOnly ? "Only they can open it." : "It is not invite-only, so anybody with the link can open it — `drop2run set invite-only on`.");
2977
+ const rows = list2.people.map(
2978
+ (person) => `${person.email} ${person.role}${person.owner ? ", owner" : ""} ${person.status}`
2979
+ );
2980
+ return { text: [head, ...rows].join("\n"), json: list2, code: 0 };
2981
+ } catch (error) {
2982
+ return failure(error instanceof Error ? error.message : String(error));
2983
+ }
2984
+ }
2985
+ async function invite(emails, site, role) {
2986
+ const found = credentialsOr();
2987
+ if ("result" in found) return found.result;
2988
+ if (emails.length === 0)
2989
+ return failure("Name at least one address: `drop2run invite dana@example.com`.");
2990
+ if (role !== void 0 && role !== "view" && role !== "comment") {
2991
+ return failure("`--role` is `view` or `comment`.");
2992
+ }
2993
+ const target = siteOr(site, "drop2run invite <email> --site <subdomain>");
2994
+ if ("result" in target) return target.result;
2995
+ try {
2996
+ const summary = await findSite(found.credentials, target.named);
2997
+ const invited = await invitePeople(
2998
+ found.credentials,
2999
+ summary.siteId,
3000
+ emails,
3001
+ role === "comment" ? "comment" : "view"
3002
+ );
3003
+ const list2 = await listPeople(found.credentials, summary.siteId);
3004
+ return {
3005
+ text: [
3006
+ invited.added.length === 0 ? `Nobody new on ${summary.subdomain}'s list.` : `Invited ${invited.added.join(", ")} to ${summary.subdomain} — each was emailed.`,
3007
+ ...invited.alreadyListed.length === 0 ? [] : [`Already on the list, not emailed again: ${invited.alreadyListed.join(", ")}.`],
3008
+ ...list2.invitedOnly ? [] : [
3009
+ "The site is not invite-only yet, so the list does not decide who opens it. `drop2run set invite-only on` does."
3010
+ ]
3011
+ ].join("\n"),
3012
+ json: invited,
3013
+ code: 0
3014
+ };
3015
+ } catch (error) {
3016
+ return failure(error instanceof Error ? error.message : String(error));
3017
+ }
3018
+ }
3019
+ async function uninvite(email, site) {
3020
+ const found = credentialsOr();
3021
+ if ("result" in found) return found.result;
3022
+ if (email === void 0)
3023
+ return failure("Name the address: `drop2run uninvite dana@example.com`.");
3024
+ const target = siteOr(site, "drop2run uninvite <email> --site <subdomain>");
3025
+ if ("result" in target) return target.result;
3026
+ try {
3027
+ const summary = await findSite(found.credentials, target.named);
3028
+ await removePerson(found.credentials, summary.siteId, email);
3029
+ return { text: `Removed ${email} from ${summary.subdomain}'s list.`, json: { email }, code: 0 };
3030
+ } catch (error) {
3031
+ return failure(error instanceof Error ? error.message : String(error));
3032
+ }
3033
+ }
2681
3034
  const COMMENTS_USAGE = [
2682
3035
  " drop2run comments [site] [--status open|resolved|all] [--path /page]",
2683
3036
  " drop2run comments reply <thread> <text> [--resolve] [--site X]",
@@ -2808,6 +3161,9 @@ const HELP = `drop2run — publish a static site from the command line
2808
3161
  drop2run resume [site] Put a paused site back on the air
2809
3162
  drop2run rollback <deployId> [--site X] Put an earlier version back live
2810
3163
  drop2run rm <site> --yes Delete a site and everything on it
3164
+ drop2run people [site] Who the site is shared with
3165
+ drop2run invite <email>... [--role comment] Share it with people; each is emailed
3166
+ drop2run uninvite <email> Take someone off the list
2811
3167
  drop2run comments [site] Read the comments left on a site's pages
2812
3168
  drop2run comments reply <thread> <text> Answer a thread as the owner [--resolve]
2813
3169
  drop2run comments resolve|reopen <thread> Resolve a thread, or open it again
@@ -2829,6 +3185,7 @@ Flags
2829
3185
  --status X Which comment threads: open (the default), resolved or all
2830
3186
  --path X Only the comments on one page, such as /pricing
2831
3187
  --resolve Resolve the thread with the reply (\`comments reply\` only)
3188
+ --role X What invited people may do: view (the default) or comment
2832
3189
 
2833
3190
  Comments
2834
3191
  \`comments reply\`, \`resolve\` and \`reopen\` act on --site, else the site in
@@ -2847,6 +3204,11 @@ Settings
2847
3204
  set forms on|off Whether its forms accept submissions
2848
3205
  set expires <ISO 8601> Take it down then, e.g. 2026-12-31T09:00:00Z
2849
3206
  set folder <path|id|root> Move it to a folder, or root for the top level
3207
+ set invite-only on|off Only the people on its list (\`invite\`) can open it;
3208
+ replaces a password
3209
+ set comments off|invited|anyone
3210
+ Who may comment: nobody, people invited to
3211
+ comment, or anybody signed in
2850
3212
 
2851
3213
  \`set\` and \`unset\` act on --site, else the site in drop2run.json. A
2852
3214
  setting your plan does not include is refused with the reason, and
@@ -2879,7 +3241,7 @@ function flagValue(args, flag) {
2879
3241
  const value = args[at + 1];
2880
3242
  return value === void 0 || value.startsWith("-") ? void 0 : value;
2881
3243
  }
2882
- const VALUE_FLAGS = ["--site", "--subdomain", "--folder", "--then", "--status", "--path"];
3244
+ const VALUE_FLAGS = ["--site", "--subdomain", "--folder", "--then", "--status", "--path", "--role"];
2883
3245
  function positionals(argv) {
2884
3246
  const found = [];
2885
3247
  for (let at = 0; at < argv.length; at++) {
@@ -2928,7 +3290,7 @@ async function run(commandLine) {
2928
3290
  const positional = [...positionals(argv), ...literal];
2929
3291
  const [command, ...rest] = positional;
2930
3292
  if (argv.includes("--version")) {
2931
- const { version } = await import("./package-LdzYr7IV.js").then(
3293
+ const { version } = await import("./package-B79z0E4v.js").then(
2932
3294
  (module) => module.default
2933
3295
  );
2934
3296
  return { text: version, json: { version }, code: 0 };
@@ -2963,11 +3325,12 @@ async function run(commandLine) {
2963
3325
  status: flagValue(argv, "--status"),
2964
3326
  path: flagValue(argv, "--path"),
2965
3327
  resolve: argv.includes("--resolve")
2966
- }
3328
+ },
3329
+ flagValue(argv, "--role")
2967
3330
  );
2968
3331
  return json ? { ...result, text: JSON.stringify(result.json, null, 2) } : result;
2969
3332
  }
2970
- async function dispatch(command, rest, site, json, device, confirmed, newSite = {}, then = void 0, commentOptions = {}) {
3333
+ async function dispatch(command, rest, site, json, device, confirmed, newSite = {}, then = void 0, commentOptions = {}, role = void 0) {
2971
3334
  switch (command) {
2972
3335
  case "login": {
2973
3336
  const print = json ? () => {
@@ -3004,6 +3367,12 @@ async function dispatch(command, rest, site, json, device, confirmed, newSite =
3004
3367
  return await pauseOrResume(command, rest[0] ?? site);
3005
3368
  case "rollback":
3006
3369
  return await rollback(rest[0], site);
3370
+ case "people":
3371
+ return await people(rest[0] ?? site);
3372
+ case "invite":
3373
+ return await invite(rest, site, role);
3374
+ case "uninvite":
3375
+ return await uninvite(rest[0], site);
3007
3376
  case "rm":
3008
3377
  return await remove(rest[0] ?? site, confirmed);
3009
3378
  case "comments":
@@ -1,5 +1,5 @@
1
1
  const name = "drop2run";
2
- const version = "0.9.0";
2
+ const version = "0.11.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.9.0",
3
+ "version": "0.11.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",