drop2run 0.8.0 → 0.10.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
@@ -29,6 +29,12 @@ drop2run pause [site] Take a site off the air, keeping it
29
29
  drop2run resume [site] Put a paused site back on the air
30
30
  drop2run rollback <deployId> [--site X] Put an earlier version back live
31
31
  drop2run rm <site> --yes Delete a site and everything on it
32
+ drop2run comments [site] Read the comments left on a site's pages
33
+ drop2run comments reply <thread> <text> Answer a thread as the owner [--resolve]
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
32
38
  drop2run token list List your access tokens
33
39
  drop2run whoami Check the token and whose it is
34
40
  drop2run where Show which token source is in use
@@ -37,6 +43,35 @@ drop2run --version Print the version
37
43
 
38
44
  `--json` on any command prints machine-readable output instead of text.
39
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
+
58
+ ## Comments
59
+
60
+ On a plan that includes comments, people viewing a site can pin comments to its pages. `drop2run comments`
61
+ reads them — each thread's id, page, the words it points at, and every comment — with `--status
62
+ open|resolved|all` and `--path /page` to narrow it. The owner of the account can answer from here:
63
+
64
+ ```bash
65
+ drop2run comments calm-cedar # open threads
66
+ drop2run deploy dist # publish the fix first
67
+ drop2run comments reply 01JTHREAD... "Fixed — please check" --site calm-cedar
68
+ ```
69
+
70
+ The reply shows on the page under your name, as one typed there would, and the people in the thread are
71
+ emailed.
72
+ It leaves the thread open unless `--resolve` is given, so the person who raised it can check the fix and
73
+ resolve it themselves. `comments resolve` and `comments reopen` change a thread's state on its own.
74
+
40
75
  ## Which site a command acts on
41
76
 
42
77
  `--subdomain` creates one under the name you give; otherwise `--site`, then `drop2run.json`, then a new
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,
@@ -344,6 +355,61 @@ const ROOT_FOLDER = "root";
344
355
  async function resolveFolderTarget(credentials, folder) {
345
356
  return folder.trim().toLowerCase() === ROOT_FOLDER ? ROOT_FOLDER : await resolveFolder(credentials, folder);
346
357
  }
358
+ async function listComments(credentials, siteId, query = {}) {
359
+ const search = new URLSearchParams();
360
+ if (query.status !== void 0) search.set("status", query.status);
361
+ if (query.path !== void 0) search.set("path", query.path);
362
+ if (query.since !== void 0) search.set("since", String(query.since));
363
+ if (query.page !== void 0) search.set("page", String(query.page));
364
+ const encoded = search.toString();
365
+ const suffix = encoded === "" ? "" : `?${encoded}`;
366
+ return await call(
367
+ credentials,
368
+ `sites/${encodeURIComponent(siteId)}/feedback/threads${suffix}`
369
+ );
370
+ }
371
+ async function replyToComment(credentials, siteId, threadId, body, resolve2 = false) {
372
+ return await call(
373
+ credentials,
374
+ `sites/${encodeURIComponent(siteId)}/feedback/threads/${encodeURIComponent(threadId)}/comments`,
375
+ { method: "POST", body: JSON.stringify({ body, resolve: resolve2 }) }
376
+ );
377
+ }
378
+ async function setCommentResolved(credentials, siteId, threadId, resolved) {
379
+ return await call(
380
+ credentials,
381
+ `sites/${encodeURIComponent(siteId)}/feedback/threads/${encodeURIComponent(threadId)}/${resolved ? "resolve" : "reopen"}`,
382
+ { method: "POST" }
383
+ );
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
+ }
347
413
  const VERSIONS_LISTED = 10;
348
414
  function servingModeOf$1(site) {
349
415
  if (site.spaMode) return "spa";
@@ -372,6 +438,7 @@ function describeSite(site, folderPath) {
372
438
  ` mode ${servingModeOf$1(site)}${site.modeIsManual ? "" : " (detected on each publish)"}`,
373
439
  ` password ${site.passwordProtected ? "on" : "off"}${unavailable(site.passwordProtectionAvailable)}`,
374
440
  ` forms ${site.formsEnabled ? "on" : "off"}${unavailable(site.formsAvailable)}`,
441
+ ` comments ${site.comments ?? "unknown"}${unavailable(site.commentsAvailable)}`,
375
442
  ` takedown ${site.expiresAt === null ? "none" : `${site.expiresAt} (${site.expiryAction})`}${unavailable(site.scheduledExpiryAvailable)}`,
376
443
  ` folder ${site.folderId === null ? "(top level)" : folderPath ?? site.folderId}`,
377
444
  "",
@@ -847,13 +914,14 @@ const FILE_TYPES = [
847
914
  reader: true,
848
915
  editable: true
849
916
  },
850
- // Source the viewer highlights. A link straight to one of these hands over the file.
917
+ // Source the viewer highlights. Inside a documents site a link straight to one opens it in the
918
+ // reader; on an ordinary site, and with `?raw`, it hands over the file.
851
919
  {
852
920
  ext: ".css",
853
921
  mime: "text/css; charset=utf-8",
854
922
  kind: "code",
855
923
  document: false,
856
- reader: false,
924
+ reader: true,
857
925
  editable: true
858
926
  },
859
927
  {
@@ -861,7 +929,7 @@ const FILE_TYPES = [
861
929
  mime: "text/javascript; charset=utf-8",
862
930
  kind: "code",
863
931
  document: false,
864
- reader: false,
932
+ reader: true,
865
933
  editable: true
866
934
  },
867
935
  {
@@ -869,7 +937,7 @@ const FILE_TYPES = [
869
937
  mime: null,
870
938
  kind: "code",
871
939
  document: false,
872
- reader: false,
940
+ reader: true,
873
941
  editable: false
874
942
  },
875
943
  {
@@ -877,7 +945,7 @@ const FILE_TYPES = [
877
945
  mime: null,
878
946
  kind: "code",
879
947
  document: false,
880
- reader: false,
948
+ reader: true,
881
949
  editable: false
882
950
  },
883
951
  {
@@ -885,7 +953,7 @@ const FILE_TYPES = [
885
953
  mime: null,
886
954
  kind: "code",
887
955
  document: false,
888
- reader: false,
956
+ reader: true,
889
957
  editable: false
890
958
  },
891
959
  {
@@ -893,19 +961,18 @@ const FILE_TYPES = [
893
961
  mime: null,
894
962
  kind: "code",
895
963
  document: false,
896
- reader: false,
964
+ reader: true,
897
965
  editable: false
898
966
  },
899
967
  // The languages an assistant writes most, beyond the web's own. Same row as `.ts`: shown as source
900
- // when somebody opens one in the reader or publishes it on its own, handed over as a file when a
901
- // link names it directly, and no media type — adding one changes what a browser does with a
968
+ // 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
902
969
  // published file, which I10 makes a decision of its own rather than a side effect of this list.
903
970
  {
904
971
  ext: ".py",
905
972
  mime: null,
906
973
  kind: "code",
907
974
  document: false,
908
- reader: false,
975
+ reader: true,
909
976
  editable: false
910
977
  },
911
978
  {
@@ -913,7 +980,7 @@ const FILE_TYPES = [
913
980
  mime: null,
914
981
  kind: "code",
915
982
  document: false,
916
- reader: false,
983
+ reader: true,
917
984
  editable: false
918
985
  },
919
986
  {
@@ -921,7 +988,7 @@ const FILE_TYPES = [
921
988
  mime: null,
922
989
  kind: "code",
923
990
  document: false,
924
- reader: false,
991
+ reader: true,
925
992
  editable: false
926
993
  },
927
994
  {
@@ -929,7 +996,7 @@ const FILE_TYPES = [
929
996
  mime: null,
930
997
  kind: "code",
931
998
  document: false,
932
- reader: false,
999
+ reader: true,
933
1000
  editable: false
934
1001
  },
935
1002
  {
@@ -937,7 +1004,7 @@ const FILE_TYPES = [
937
1004
  mime: null,
938
1005
  kind: "code",
939
1006
  document: false,
940
- reader: false,
1007
+ reader: true,
941
1008
  editable: false
942
1009
  },
943
1010
  {
@@ -945,7 +1012,7 @@ const FILE_TYPES = [
945
1012
  mime: null,
946
1013
  kind: "code",
947
1014
  document: false,
948
- reader: false,
1015
+ reader: true,
949
1016
  editable: false
950
1017
  },
951
1018
  {
@@ -953,7 +1020,7 @@ const FILE_TYPES = [
953
1020
  mime: null,
954
1021
  kind: "code",
955
1022
  document: false,
956
- reader: false,
1023
+ reader: true,
957
1024
  editable: false
958
1025
  },
959
1026
  {
@@ -961,7 +1028,7 @@ const FILE_TYPES = [
961
1028
  mime: null,
962
1029
  kind: "code",
963
1030
  document: false,
964
- reader: false,
1031
+ reader: true,
965
1032
  editable: false
966
1033
  },
967
1034
  {
@@ -969,7 +1036,7 @@ const FILE_TYPES = [
969
1036
  mime: null,
970
1037
  kind: "code",
971
1038
  document: false,
972
- reader: false,
1039
+ reader: true,
973
1040
  editable: false
974
1041
  },
975
1042
  {
@@ -977,7 +1044,7 @@ const FILE_TYPES = [
977
1044
  mime: null,
978
1045
  kind: "code",
979
1046
  document: false,
980
- reader: false,
1047
+ reader: true,
981
1048
  editable: false
982
1049
  },
983
1050
  {
@@ -985,7 +1052,7 @@ const FILE_TYPES = [
985
1052
  mime: null,
986
1053
  kind: "code",
987
1054
  document: false,
988
- reader: false,
1055
+ reader: true,
989
1056
  editable: false
990
1057
  },
991
1058
  {
@@ -993,7 +1060,7 @@ const FILE_TYPES = [
993
1060
  mime: null,
994
1061
  kind: "code",
995
1062
  document: false,
996
- reader: false,
1063
+ reader: true,
997
1064
  editable: false
998
1065
  },
999
1066
  {
@@ -1001,7 +1068,7 @@ const FILE_TYPES = [
1001
1068
  mime: null,
1002
1069
  kind: "code",
1003
1070
  document: false,
1004
- reader: false,
1071
+ reader: true,
1005
1072
  editable: false
1006
1073
  },
1007
1074
  {
@@ -1009,7 +1076,7 @@ const FILE_TYPES = [
1009
1076
  mime: null,
1010
1077
  kind: "code",
1011
1078
  document: false,
1012
- reader: false,
1079
+ reader: true,
1013
1080
  editable: false
1014
1081
  },
1015
1082
  {
@@ -1017,7 +1084,7 @@ const FILE_TYPES = [
1017
1084
  mime: "application/xml",
1018
1085
  kind: "code",
1019
1086
  document: false,
1020
- reader: false,
1087
+ reader: true,
1021
1088
  editable: true
1022
1089
  },
1023
1090
  // Pictures, shown with an img element.
@@ -1088,25 +1155,25 @@ const FILE_TYPES = [
1088
1155
  reader: false,
1089
1156
  editable: false
1090
1157
  },
1091
- // A customer's own pages. Never rendered inside the reader.
1158
+ // A customer's own pages. Inside a documents site a navigation opens the reader, which frames the
1159
+ // page; `?raw` and every ordinary site serve the page itself.
1092
1160
  {
1093
1161
  ext: ".html",
1094
1162
  mime: "text/html; charset=utf-8",
1095
1163
  kind: "html",
1096
1164
  document: false,
1097
- reader: false,
1165
+ reader: true,
1098
1166
  editable: true
1099
1167
  },
1100
1168
  {
1101
- // ⚠️ No media type, and that is the state of the code rather than a decision: `TYPES` in
1102
- // `headers.ts` has never held `htm`, so a published `.htm` is served as octet-stream and
1103
- // downloads instead of rendering. Recorded here rather than fixed, because this table was
1104
- // introduced as a refactor and a refactor that changes what a URL answers is not one.
1169
+ // An HTML page in every respect the Worker decides by extension: media type, the revalidated
1170
+ // cache policy invariant I7 requires, and the badge's `isHtml`. Missing from `TYPES` it used to
1171
+ // download, which inside the reader meant a frame that downloaded instead of showing a page.
1105
1172
  ext: ".htm",
1106
- mime: null,
1173
+ mime: "text/html; charset=utf-8",
1107
1174
  kind: "html",
1108
1175
  document: false,
1109
- reader: false,
1176
+ reader: true,
1110
1177
  editable: true
1111
1178
  },
1112
1179
  // Everything below renders as nothing — the viewer offers a download — but still needs a media
@@ -2498,7 +2565,9 @@ const SETTINGS_USAGE = [
2498
2565
  " password Read from stdin, or typed at a hidden prompt",
2499
2566
  " forms on|off Whether its forms accept submissions",
2500
2567
  " 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"
2568
+ " folder <path|id|root> Which folder it is filed in",
2569
+ " invite-only on|off Whether only the people on its list can open it",
2570
+ " comments off|invited|anyone Who may comment on its pages"
2502
2571
  ].join("\n");
2503
2572
  function settingChange(setting, values, then) {
2504
2573
  const value = setting === "name" ? values.join(" ").trim() : values[0];
@@ -2546,6 +2615,14 @@ function settingChange(setting, values, then) {
2546
2615
  }
2547
2616
  case "folder":
2548
2617
  return value ? { folder: value } : needs("Clients/Acme");
2618
+ case "invite-only":
2619
+ if (value === "on" || value === "off") return { change: { invitedOnly: value === "on" } };
2620
+ return { refusal: "`set invite-only` takes `on` or `off`." };
2621
+ case "comments":
2622
+ if (value === "off" || value === "invited" || value === "anyone") {
2623
+ return { change: { comments: value } };
2624
+ }
2625
+ return { refusal: "`set comments` takes `off`, `invited` or `anyone`." };
2549
2626
  default:
2550
2627
  return {
2551
2628
  refusal: `${setting === void 0 ? "Name a setting." : `\`${setting}\` is not a setting.`} Settings:
@@ -2623,9 +2700,14 @@ async function applied(credentials, siteId, subdomain, change) {
2623
2700
  ` access ${accessOf(settings)}`,
2624
2701
  ` mode ${servingModeOf$1(settings)}`,
2625
2702
  ` forms ${settings.formsEnabled ? "on" : "off"}`,
2703
+ ` comments ${settings.comments ?? "unknown"}`,
2626
2704
  ` takedown ${takedown}`,
2627
2705
  ...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."
2706
+ "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."
2707
+ ] : [],
2708
+ // The one combination that reads as on and does nothing, which the API keeps on purpose.
2709
+ ...settings.comments === "invited" && settings.invitedOnly === false ? [
2710
+ "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`."
2629
2711
  ] : [],
2630
2712
  settings.live ? "Reaching every edge takes up to about a minute." : "Nothing is published yet, so this applies from the first publish."
2631
2713
  ].join("\n"),
@@ -2651,6 +2733,162 @@ async function tokens() {
2651
2733
  return failure(error instanceof Error ? error.message : String(error));
2652
2734
  }
2653
2735
  }
2736
+ async function people(site) {
2737
+ const found = credentialsOr();
2738
+ if ("result" in found) return found.result;
2739
+ const target = siteOr(site, "drop2run people <subdomain>");
2740
+ if ("result" in target) return target.result;
2741
+ try {
2742
+ const summary = await findSite(found.credentials, target.named);
2743
+ const list2 = await listPeople(found.credentials, summary.siteId);
2744
+ 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`.");
2745
+ const rows = list2.people.map(
2746
+ (person) => `${person.email} ${person.role}${person.owner ? ", owner" : ""} ${person.status}`
2747
+ );
2748
+ return { text: [head, ...rows].join("\n"), json: list2, code: 0 };
2749
+ } catch (error) {
2750
+ return failure(error instanceof Error ? error.message : String(error));
2751
+ }
2752
+ }
2753
+ async function invite(emails, site, role) {
2754
+ const found = credentialsOr();
2755
+ if ("result" in found) return found.result;
2756
+ if (emails.length === 0)
2757
+ return failure("Name at least one address: `drop2run invite dana@example.com`.");
2758
+ if (role !== void 0 && role !== "view" && role !== "comment") {
2759
+ return failure("`--role` is `view` or `comment`.");
2760
+ }
2761
+ const target = siteOr(site, "drop2run invite <email> --site <subdomain>");
2762
+ if ("result" in target) return target.result;
2763
+ try {
2764
+ const summary = await findSite(found.credentials, target.named);
2765
+ const invited = await invitePeople(
2766
+ found.credentials,
2767
+ summary.siteId,
2768
+ emails,
2769
+ role === "comment" ? "comment" : "view"
2770
+ );
2771
+ const list2 = await listPeople(found.credentials, summary.siteId);
2772
+ return {
2773
+ text: [
2774
+ invited.added.length === 0 ? `Nobody new on ${summary.subdomain}'s list.` : `Invited ${invited.added.join(", ")} to ${summary.subdomain} — each was emailed.`,
2775
+ ...invited.alreadyListed.length === 0 ? [] : [`Already on the list, not emailed again: ${invited.alreadyListed.join(", ")}.`],
2776
+ ...list2.invitedOnly ? [] : [
2777
+ "The site is not invite-only yet, so the list does not decide who opens it. `drop2run set invite-only on` does."
2778
+ ]
2779
+ ].join("\n"),
2780
+ json: invited,
2781
+ code: 0
2782
+ };
2783
+ } catch (error) {
2784
+ return failure(error instanceof Error ? error.message : String(error));
2785
+ }
2786
+ }
2787
+ async function uninvite(email, site) {
2788
+ const found = credentialsOr();
2789
+ if ("result" in found) return found.result;
2790
+ if (email === void 0)
2791
+ return failure("Name the address: `drop2run uninvite dana@example.com`.");
2792
+ const target = siteOr(site, "drop2run uninvite <email> --site <subdomain>");
2793
+ if ("result" in target) return target.result;
2794
+ try {
2795
+ const summary = await findSite(found.credentials, target.named);
2796
+ await removePerson(found.credentials, summary.siteId, email);
2797
+ return { text: `Removed ${email} from ${summary.subdomain}'s list.`, json: { email }, code: 0 };
2798
+ } catch (error) {
2799
+ return failure(error instanceof Error ? error.message : String(error));
2800
+ }
2801
+ }
2802
+ const COMMENTS_USAGE = [
2803
+ " drop2run comments [site] [--status open|resolved|all] [--path /page]",
2804
+ " drop2run comments reply <thread> <text> [--resolve] [--site X]",
2805
+ " drop2run comments resolve|reopen <thread> [--site X]"
2806
+ ].join("\n");
2807
+ async function comments(subcommand, rest, site, options) {
2808
+ const found = credentialsOr();
2809
+ if ("result" in found) return found.result;
2810
+ const acting = subcommand === "reply" || subcommand === "resolve" || subcommand === "reopen";
2811
+ if (!acting) {
2812
+ const status = options.status ?? "open";
2813
+ if (status !== "open" && status !== "resolved" && status !== "all") {
2814
+ return failure(
2815
+ `\`--status\` is open, resolved or all, not "${status}".
2816
+
2817
+ ${COMMENTS_USAGE}`
2818
+ );
2819
+ }
2820
+ const target2 = siteOr(subcommand ?? site, "drop2run comments <subdomain>");
2821
+ if ("result" in target2) return target2.result;
2822
+ try {
2823
+ const summary = await findSite(found.credentials, target2.named);
2824
+ const read = await listComments(found.credentials, summary.siteId, {
2825
+ status,
2826
+ path: options.path
2827
+ });
2828
+ return {
2829
+ text: describeComments(read.threads, status, summary.subdomain, read.more),
2830
+ json: read,
2831
+ code: 0
2832
+ };
2833
+ } catch (error) {
2834
+ return failure(error instanceof Error ? error.message : String(error));
2835
+ }
2836
+ }
2837
+ const [thread, ...words] = rest;
2838
+ if (thread === void 0)
2839
+ return failure(`Name the thread, from \`drop2run comments\`.
2840
+
2841
+ ${COMMENTS_USAGE}`);
2842
+ const body = words.join(" ").trim();
2843
+ if (subcommand === "reply" && body === "") {
2844
+ return failure(`Write the reply after the thread id.
2845
+
2846
+ ${COMMENTS_USAGE}`);
2847
+ }
2848
+ const target = siteOr(site, `drop2run comments ${subcommand} <thread> --site <subdomain>`);
2849
+ if ("result" in target) return target.result;
2850
+ try {
2851
+ const summary = await findSite(found.credentials, target.named);
2852
+ const answered = subcommand === "reply" ? await replyToComment(
2853
+ found.credentials,
2854
+ summary.siteId,
2855
+ thread,
2856
+ body,
2857
+ options.resolve === true
2858
+ ) : await setCommentResolved(
2859
+ found.credentials,
2860
+ summary.siteId,
2861
+ thread,
2862
+ subcommand === "resolve"
2863
+ );
2864
+ const done = subcommand === "reply" ? `Replied on ${answered.path}${answered.resolvedAt === null ? "" : " and resolved the thread"}.` : `${subcommand === "resolve" ? "Resolved" : "Reopened"} the thread on ${answered.path}.`;
2865
+ return { text: `${done}
2866
+ ${answered.url}`, json: answered, code: 0 };
2867
+ } catch (error) {
2868
+ return failure(error instanceof Error ? error.message : String(error));
2869
+ }
2870
+ }
2871
+ function describeComments(threads, status, subdomain, more) {
2872
+ const which = status === "all" ? "" : `${status} `;
2873
+ if (threads.length === 0) return `No ${which}comment threads on ${subdomain}.`;
2874
+ const heading = `${threads.length} ${which}comment ${threads.length === 1 ? "thread" : "threads"} on ${subdomain}` + (more ? " (more follow; `--json` shows the cursor)." : ".");
2875
+ const blocks = threads.map((thread) => {
2876
+ const where2 = thread.place.quote ?? (thread.place.snippet || null);
2877
+ const flags = [
2878
+ ...thread.resolvedAt === null ? [] : ["resolved"],
2879
+ ...thread.outdated ? ["earlier version"] : []
2880
+ ];
2881
+ return [
2882
+ `${thread.id} ${thread.path}${where2 === null ? "" : ` "${where2}"`}` + (flags.length === 0 ? "" : ` (${flags.join(", ")})`),
2883
+ ...thread.comments.map((comment) => {
2884
+ const who = comment.author.owner ? `${comment.author.name} (owner)` : comment.author.name;
2885
+ return ` ${who}: ${comment.body.replace(/\s*\n\s*/g, " ")}`;
2886
+ }),
2887
+ ` ${thread.url}`
2888
+ ].join("\n");
2889
+ });
2890
+ return [heading, ...blocks].join("\n\n");
2891
+ }
2654
2892
  function day(iso) {
2655
2893
  const parsed = new Date(iso);
2656
2894
  return Number.isNaN(parsed.getTime()) ? iso : parsed.toISOString().slice(0, 10);
@@ -2691,6 +2929,12 @@ const HELP = `drop2run — publish a static site from the command line
2691
2929
  drop2run resume [site] Put a paused site back on the air
2692
2930
  drop2run rollback <deployId> [--site X] Put an earlier version back live
2693
2931
  drop2run rm <site> --yes Delete a site and everything on it
2932
+ drop2run people [site] Who the site is shared with
2933
+ drop2run invite <email>... [--role comment] Share it with people; each is emailed
2934
+ drop2run uninvite <email> Take someone off the list
2935
+ drop2run comments [site] Read the comments left on a site's pages
2936
+ drop2run comments reply <thread> <text> Answer a thread as the owner [--resolve]
2937
+ drop2run comments resolve|reopen <thread> Resolve a thread, or open it again
2694
2938
  drop2run token list List your access tokens
2695
2939
  drop2run whoami Check the token and whose it is
2696
2940
  drop2run where Show which token source is in use
@@ -2706,6 +2950,17 @@ Flags
2706
2950
  --yes Confirm a deletion, which cannot be undone
2707
2951
  --then X What \`set expires\` does when the date arrives: pause
2708
2952
  (the default, keeps the files) or delete (needs --yes)
2953
+ --status X Which comment threads: open (the default), resolved or all
2954
+ --path X Only the comments on one page, such as /pricing
2955
+ --resolve Resolve the thread with the reply (\`comments reply\` only)
2956
+ --role X What invited people may do: view (the default) or comment
2957
+
2958
+ Comments
2959
+ \`comments reply\`, \`resolve\` and \`reopen\` act on --site, else the site in
2960
+ drop2run.json, and only the account's owner can use them. Deploy the fix
2961
+ before replying that it is fixed. A reply leaves the thread open unless
2962
+ --resolve is given, so the person who raised it can check the fix and
2963
+ resolve it themselves.
2709
2964
 
2710
2965
  Settings
2711
2966
  set name <text> What the dashboard calls the site; put a
@@ -2717,6 +2972,11 @@ Settings
2717
2972
  set forms on|off Whether its forms accept submissions
2718
2973
  set expires <ISO 8601> Take it down then, e.g. 2026-12-31T09:00:00Z
2719
2974
  set folder <path|id|root> Move it to a folder, or root for the top level
2975
+ set invite-only on|off Only the people on its list (\`invite\`) can open it;
2976
+ replaces a password
2977
+ set comments off|invited|anyone
2978
+ Who may comment: nobody, people invited to
2979
+ comment, or anybody signed in
2720
2980
 
2721
2981
  \`set\` and \`unset\` act on --site, else the site in drop2run.json. A
2722
2982
  setting your plan does not include is refused with the reason, and
@@ -2749,7 +3009,7 @@ function flagValue(args, flag) {
2749
3009
  const value = args[at + 1];
2750
3010
  return value === void 0 || value.startsWith("-") ? void 0 : value;
2751
3011
  }
2752
- const VALUE_FLAGS = ["--site", "--subdomain", "--folder", "--then"];
3012
+ const VALUE_FLAGS = ["--site", "--subdomain", "--folder", "--then", "--status", "--path", "--role"];
2753
3013
  function positionals(argv) {
2754
3014
  const found = [];
2755
3015
  for (let at = 0; at < argv.length; at++) {
@@ -2798,7 +3058,7 @@ async function run(commandLine) {
2798
3058
  const positional = [...positionals(argv), ...literal];
2799
3059
  const [command, ...rest] = positional;
2800
3060
  if (argv.includes("--version")) {
2801
- const { version } = await import("./package-BycuI1VJ.js").then(
3061
+ const { version } = await import("./package-CGI7Yt81.js").then(
2802
3062
  (module) => module.default
2803
3063
  );
2804
3064
  return { text: version, json: { version }, code: 0 };
@@ -2828,11 +3088,17 @@ async function run(commandLine) {
2828
3088
  argv.includes("--device"),
2829
3089
  argv.includes("--yes"),
2830
3090
  { subdomain, folder },
2831
- then
3091
+ then,
3092
+ {
3093
+ status: flagValue(argv, "--status"),
3094
+ path: flagValue(argv, "--path"),
3095
+ resolve: argv.includes("--resolve")
3096
+ },
3097
+ flagValue(argv, "--role")
2832
3098
  );
2833
3099
  return json ? { ...result, text: JSON.stringify(result.json, null, 2) } : result;
2834
3100
  }
2835
- async function dispatch(command, rest, site, json, device, confirmed, newSite = {}, then = void 0) {
3101
+ async function dispatch(command, rest, site, json, device, confirmed, newSite = {}, then = void 0, commentOptions = {}, role = void 0) {
2836
3102
  switch (command) {
2837
3103
  case "login": {
2838
3104
  const print = json ? () => {
@@ -2869,8 +3135,16 @@ async function dispatch(command, rest, site, json, device, confirmed, newSite =
2869
3135
  return await pauseOrResume(command, rest[0] ?? site);
2870
3136
  case "rollback":
2871
3137
  return await rollback(rest[0], site);
3138
+ case "people":
3139
+ return await people(rest[0] ?? site);
3140
+ case "invite":
3141
+ return await invite(rest, site, role);
3142
+ case "uninvite":
3143
+ return await uninvite(rest[0], site);
2872
3144
  case "rm":
2873
3145
  return await remove(rest[0] ?? site, confirmed);
3146
+ case "comments":
3147
+ return await comments(rest[0], rest.slice(1), site, commentOptions);
2874
3148
  case "token":
2875
3149
  case "tokens":
2876
3150
  return rest[0] === void 0 || rest[0] === "list" ? await tokens() : {
@@ -1,5 +1,5 @@
1
1
  const name = "drop2run";
2
- const version = "0.8.0";
2
+ const version = "0.10.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.8.0",
3
+ "version": "0.10.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",