drop2run 0.7.0 → 0.9.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
@@ -22,8 +22,16 @@ drop2run init|deploy --folder <path> File the new site in one of your fo
22
22
  drop2run ls List your sites
23
23
  drop2run folders List your folders, as --folder takes them
24
24
  drop2run open [site] Open a site in a browser
25
+ drop2run info [site] Show a site's settings and versions
26
+ drop2run set <setting> <value> [--site X] Change one setting
27
+ drop2run unset name|password|expires Clear a name, a password or a takedown
28
+ drop2run pause [site] Take a site off the air, keeping its files
29
+ drop2run resume [site] Put a paused site back on the air
25
30
  drop2run rollback <deployId> [--site X] Put an earlier version back live
26
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
27
35
  drop2run token list List your access tokens
28
36
  drop2run whoami Check the token and whose it is
29
37
  drop2run where Show which token source is in use
@@ -32,6 +40,23 @@ drop2run --version Print the version
32
40
 
33
41
  `--json` on any command prints machine-readable output instead of text.
34
42
 
43
+ ## Comments
44
+
45
+ On a plan that includes comments, people viewing a site can pin comments to its pages. `drop2run comments`
46
+ reads them — each thread's id, page, the words it points at, and every comment — with `--status
47
+ open|resolved|all` and `--path /page` to narrow it. The owner of the account can answer from here:
48
+
49
+ ```bash
50
+ drop2run comments calm-cedar # open threads
51
+ drop2run deploy dist # publish the fix first
52
+ drop2run comments reply 01JTHREAD... "Fixed — please check" --site calm-cedar
53
+ ```
54
+
55
+ The reply shows on the page under your name, as one typed there would, and the people in the thread are
56
+ emailed.
57
+ It leaves the thread open unless `--resolve` is given, so the person who raised it can check the fix and
58
+ resolve it themselves. `comments resolve` and `comments reopen` change a thread's state on its own.
59
+
35
60
  ## Which site a command acts on
36
61
 
37
62
  `--subdomain` creates one under the name you give; otherwise `--site`, then `drop2run.json`, then a new
package/dist/index.js CHANGED
@@ -118,7 +118,9 @@ async function call(credentials, path, init2 = {}) {
118
118
  async function findSite(credentials, site) {
119
119
  const found = await lookUpSite(credentials, site);
120
120
  if (found === void 0) {
121
- 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
+ );
122
124
  }
123
125
  return found;
124
126
  }
@@ -162,6 +164,108 @@ async function promoteDeploy(credentials, siteId, deployId) {
162
164
  { method: "POST" }
163
165
  );
164
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
+ }
165
269
  async function listTokens(credentials) {
166
270
  const body = await call(credentials, "tokens");
167
271
  return body.tokens;
@@ -236,6 +340,73 @@ async function resolveFolder(credentials, folder) {
236
340
  `No folder of yours is called "${folder}". Your folders: ${listed.join(", ")}${more > 0 ? `, and ${more} more` : ""}.`
237
341
  );
238
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
+ async function listComments(credentials, siteId, query = {}) {
348
+ const search = new URLSearchParams();
349
+ if (query.status !== void 0) search.set("status", query.status);
350
+ if (query.path !== void 0) search.set("path", query.path);
351
+ if (query.since !== void 0) search.set("since", String(query.since));
352
+ if (query.page !== void 0) search.set("page", String(query.page));
353
+ const encoded = search.toString();
354
+ const suffix = encoded === "" ? "" : `?${encoded}`;
355
+ return await call(
356
+ credentials,
357
+ `sites/${encodeURIComponent(siteId)}/feedback/threads${suffix}`
358
+ );
359
+ }
360
+ async function replyToComment(credentials, siteId, threadId, body, resolve2 = false) {
361
+ return await call(
362
+ credentials,
363
+ `sites/${encodeURIComponent(siteId)}/feedback/threads/${encodeURIComponent(threadId)}/comments`,
364
+ { method: "POST", body: JSON.stringify({ body, resolve: resolve2 }) }
365
+ );
366
+ }
367
+ async function setCommentResolved(credentials, siteId, threadId, resolved) {
368
+ return await call(
369
+ credentials,
370
+ `sites/${encodeURIComponent(siteId)}/feedback/threads/${encodeURIComponent(threadId)}/${resolved ? "resolve" : "reopen"}`,
371
+ { method: "POST" }
372
+ );
373
+ }
374
+ const VERSIONS_LISTED = 10;
375
+ function servingModeOf$1(site) {
376
+ if (site.spaMode) return "spa";
377
+ if (site.docsMode) return "docs";
378
+ return "static";
379
+ }
380
+ function accessOf(site) {
381
+ if (site.passwordProtected) return "password";
382
+ if (site.invitedOnly === true) return "invite-only";
383
+ if (site.invitedOnly === false) return "public";
384
+ return "no password (whether it is invite-only could not be read)";
385
+ }
386
+ function describeSite(site, folderPath) {
387
+ const unavailable = (available) => available ? "" : " (not on this plan)";
388
+ const versions = site.deploys.slice(0, VERSIONS_LISTED).map((deploy2) => {
389
+ const live = deploy2.deployId === site.liveDeployId ? " ← served" : "";
390
+ const kept = deploy2.filesKept ? "" : ", files collected — cannot be rolled back to";
391
+ return ` ${deploy2.deployId} ${deploy2.createdAt} ${deploy2.status}, ${deploy2.fileCount} files${kept}${live}`;
392
+ });
393
+ const more = site.deploys.length - versions.length;
394
+ return [
395
+ `${site.subdomain} — ${site.url}`,
396
+ ` name ${site.name ?? "(none)"}`,
397
+ ` status ${site.status}`,
398
+ ` access ${accessOf(site)}`,
399
+ ` mode ${servingModeOf$1(site)}${site.modeIsManual ? "" : " (detected on each publish)"}`,
400
+ ` password ${site.passwordProtected ? "on" : "off"}${unavailable(site.passwordProtectionAvailable)}`,
401
+ ` forms ${site.formsEnabled ? "on" : "off"}${unavailable(site.formsAvailable)}`,
402
+ ` takedown ${site.expiresAt === null ? "none" : `${site.expiresAt} (${site.expiryAction})`}${unavailable(site.scheduledExpiryAvailable)}`,
403
+ ` folder ${site.folderId === null ? "(top level)" : folderPath ?? site.folderId}`,
404
+ "",
405
+ site.deploys.length === 0 ? "No versions yet." : "Versions, newest first:",
406
+ ...versions,
407
+ ...more > 0 ? [` … and ${more} older`] : []
408
+ ].join("\n");
409
+ }
239
410
  const TIMEOUT_MS = 5 * 60 * 1e3;
240
411
  function newAttempt() {
241
412
  const verifier = randomBytes(32).toString("base64url");
@@ -1140,6 +1311,24 @@ function canPublish(paths) {
1140
1311
  if (isImageFolder(paths)) return true;
1141
1312
  return paths.length === 1 && paths[0] !== void 0 && isViewablePath(paths[0]);
1142
1313
  }
1314
+ function servingModeOf(flags) {
1315
+ if (flags.docsMode) return "docs";
1316
+ if (flags.spaMode) return "spa";
1317
+ return "static";
1318
+ }
1319
+ function predictedModeOf(paths) {
1320
+ if (paths.some((path) => path === REQUIRED_INDEX)) return "static";
1321
+ if (paths.some(isDocumentPath)) return "docs";
1322
+ if (isImageFolder(paths)) return "docs";
1323
+ if (paths.length === 1 && paths[0] !== void 0 && isViewablePath(paths[0])) return "docs";
1324
+ return "static";
1325
+ }
1326
+ function nestedIndexOf(paths) {
1327
+ if (predictedModeOf(paths) !== "docs") return null;
1328
+ const suffix = `/${REQUIRED_INDEX}`;
1329
+ 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));
1330
+ return folders2[0] ?? null;
1331
+ }
1143
1332
  function renameOfLoneHtmlPage(paths) {
1144
1333
  if (paths.length !== 1) return null;
1145
1334
  const [only] = paths;
@@ -1675,7 +1864,13 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1675
1864
  signal,
1676
1865
  await readSiteName(files)
1677
1866
  );
1678
- onProgress({ type: "done", url: completed.url, name: completed.name });
1867
+ onProgress({
1868
+ type: "done",
1869
+ url: completed.url,
1870
+ name: completed.name,
1871
+ mode: servingModeOf(completed),
1872
+ modeIsManual: completed.modeIsManual
1873
+ });
1679
1874
  } catch (error) {
1680
1875
  const failure2 = asDeployError(error);
1681
1876
  onProgress({
@@ -1728,6 +1923,15 @@ function escapesRoot(root, candidate) {
1728
1923
  const inside = relative(root, candidate);
1729
1924
  return inside === "" || inside.startsWith("..");
1730
1925
  }
1926
+ function describeMode(result) {
1927
+ if (result.mode === null) return null;
1928
+ if (result.nestedIndex !== null) {
1929
+ 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.`;
1930
+ }
1931
+ if (result.mode === "docs") return "Served as documents, in the documents reader.";
1932
+ if (result.mode === "spa") return "Served as a single-page app.";
1933
+ return "Served as a static site.";
1934
+ }
1731
1935
  async function resolveSite(credentials, site, newSite) {
1732
1936
  const { subdomain, folder } = newSite;
1733
1937
  if (site !== void 0 && subdomain !== void 0) {
@@ -1737,7 +1941,7 @@ async function resolveSite(credentials, site, newSite) {
1737
1941
  }
1738
1942
  if (site !== void 0 && folder !== void 0) {
1739
1943
  throw new Error(
1740
- "A folder only says where a new site goes, so it cannot be given with an existing site. Leave the site out to create one in that folder, or move the existing site from the dashboard."
1944
+ "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`."
1741
1945
  );
1742
1946
  }
1743
1947
  if (site === void 0) {
@@ -1757,6 +1961,8 @@ async function publish(credentials, source, site, onProgress, newSite = {}) {
1757
1961
  let files = 0;
1758
1962
  let url = target.url;
1759
1963
  let unchanged = false;
1964
+ let mode = null;
1965
+ let paths = [];
1760
1966
  let failure2 = null;
1761
1967
  let failureDetail;
1762
1968
  const record = (event) => {
@@ -1765,14 +1971,22 @@ async function publish(credentials, source, site, onProgress, newSite = {}) {
1765
1971
  unchanged = true;
1766
1972
  url = event.url || target.url;
1767
1973
  }
1768
- if (event.type === "done") url = event.url;
1974
+ if (event.type === "done") {
1975
+ url = event.url;
1976
+ mode = event.mode;
1977
+ }
1769
1978
  if (event.type === "error") {
1770
1979
  failure2 = event.message;
1771
1980
  failureDetail = event.detail;
1772
1981
  }
1773
1982
  onProgress?.(event);
1774
1983
  };
1775
- await deploy(source, target.siteId, record, void 0, {
1984
+ const watched = async (signal, onRead) => {
1985
+ const collected = await source(signal, onRead);
1986
+ paths = collected.map((file) => file.path);
1987
+ return collected;
1988
+ };
1989
+ await deploy(watched, target.siteId, record, void 0, {
1776
1990
  baseUrl: credentials.apiBaseUrl,
1777
1991
  token: credentials.token
1778
1992
  }).catch((error) => {
@@ -1782,7 +1996,15 @@ async function publish(credentials, source, site, onProgress, newSite = {}) {
1782
1996
  thrown.detail = failureDetail ?? error?.detail;
1783
1997
  throw thrown;
1784
1998
  });
1785
- return { url, siteId: target.siteId, subdomain: target.subdomain, files, unchanged };
1999
+ return {
2000
+ url,
2001
+ siteId: target.siteId,
2002
+ subdomain: target.subdomain,
2003
+ files,
2004
+ unchanged,
2005
+ mode,
2006
+ nestedIndex: mode === "docs" ? nestedIndexOf(paths) : null
2007
+ };
1786
2008
  }
1787
2009
  async function publishDirectory(credentials, directory, site, onProgress, newSite = {}) {
1788
2010
  const found = await stat(directory).catch(() => null);
@@ -1861,6 +2083,82 @@ function progressWriter(write, isTerminal, width = 80) {
1861
2083
  };
1862
2084
  return writer;
1863
2085
  }
2086
+ async function readSecret(prompt, input = process.stdin, output = process.stderr) {
2087
+ if (input.isTTY !== true || input.setRawMode === void 0) {
2088
+ const parts = [];
2089
+ for await (const chunk of input)
2090
+ parts.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
2091
+ return parts.join("").replace(/\r?\n$/, "");
2092
+ }
2093
+ const setRawMode = input.setRawMode.bind(input);
2094
+ output.write(prompt);
2095
+ setRawMode(true);
2096
+ input.setEncoding("utf8");
2097
+ input.resume();
2098
+ return await new Promise((resolve2, reject) => {
2099
+ const line = new HiddenLine();
2100
+ const finish = () => {
2101
+ setRawMode(false);
2102
+ input.pause();
2103
+ input.off("data", onData);
2104
+ output.write("\n");
2105
+ };
2106
+ function onData(chunk) {
2107
+ const outcome = line.type(chunk);
2108
+ if (outcome === "more") return;
2109
+ finish();
2110
+ if (outcome === "cancelled") reject(new Error("Cancelled."));
2111
+ else resolve2(line.value);
2112
+ }
2113
+ input.on("data", onData);
2114
+ });
2115
+ }
2116
+ class HiddenLine {
2117
+ /** What has been typed, one entry per code point, so Backspace never splits a surrogate pair. */
2118
+ characters = [];
2119
+ /** Where an escape sequence has got to: none, just after ESC, or inside a `CSI`/`SS3` sequence. */
2120
+ escape = "none";
2121
+ /** The line so far. */
2122
+ get value() {
2123
+ return this.characters.join("");
2124
+ }
2125
+ /**
2126
+ * Takes the characters one chunk of input carries.
2127
+ *
2128
+ * @param chunk What arrived.
2129
+ * @returns `done` on Enter or Ctrl+D, `cancelled` on Ctrl+C, `more` otherwise.
2130
+ */
2131
+ type(chunk) {
2132
+ for (const character of chunk) {
2133
+ const code = character.codePointAt(0) ?? 0;
2134
+ if (this.escape === "started") {
2135
+ this.escape = character === "[" || character === "O" ? "sequence" : "none";
2136
+ continue;
2137
+ }
2138
+ if (this.escape === "sequence") {
2139
+ if (code >= 64 && code <= 126) this.escape = "none";
2140
+ continue;
2141
+ }
2142
+ if (character === "\r" || character === "\n" || character === "") return "done";
2143
+ if (character === "") return "cancelled";
2144
+ if (character === "\x1B") {
2145
+ this.escape = "started";
2146
+ continue;
2147
+ }
2148
+ if (character === "" || character === "\b") {
2149
+ this.characters.pop();
2150
+ continue;
2151
+ }
2152
+ if (character === "") {
2153
+ this.characters.length = 0;
2154
+ continue;
2155
+ }
2156
+ if (code < 32) continue;
2157
+ this.characters.push(character);
2158
+ }
2159
+ return "more";
2160
+ }
2161
+ }
1864
2162
  function failure(text) {
1865
2163
  return { text, json: { error: text }, code: 1 };
1866
2164
  }
@@ -1972,9 +2270,9 @@ async function whoami() {
1972
2270
  const response = await fetch(`${found.credentials.apiBaseUrl}/me`, {
1973
2271
  headers: { Authorization: `Bearer ${found.credentials.token}` }
1974
2272
  });
1975
- if (!response.ok) {
2273
+ if (!response.ok || response.status === 204) {
1976
2274
  return failure(
1977
- response.status === 401 ? "This token is not valid any more. It may have been revoked or expired — create another at https://app.dropto.run/account/tokens." : `The API answered ${response.status}.`
2275
+ 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}.`
1978
2276
  );
1979
2277
  }
1980
2278
  const me = await response.json();
@@ -2040,7 +2338,7 @@ async function deployCommand(directory, site, report = silentProgress, newSite =
2040
2338
  const target = newSite.subdomain === void 0 ? site ?? project?.siteId : void 0;
2041
2339
  if (newSite.folder !== void 0 && target !== void 0 && project !== null) {
2042
2340
  return failure(
2043
- `This folder's ${PROJECT_FILE} already publishes to ${project.subdomain || project.siteId}, and \`--folder\` only files a new site. Move that site from the dashboard, or add \`--subdomain\` to create a new one in the folder.`
2341
+ `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.`
2044
2342
  );
2045
2343
  }
2046
2344
  try {
@@ -2052,9 +2350,11 @@ async function deployCommand(directory, site, report = silentProgress, newSite =
2052
2350
  newSite
2053
2351
  );
2054
2352
  const what = result.unchanged ? "Already up to date — nothing needed publishing." : `Published ${result.files.toLocaleString()} ${result.files === 1 ? "file" : "files"}.`;
2353
+ const mode = describeMode(result);
2055
2354
  return {
2056
2355
  text: `${what}
2057
- ${result.url}`,
2356
+ ${result.url}${mode === null ? "" : `
2357
+ ${mode}`}`,
2058
2358
  json: result,
2059
2359
  code: 0
2060
2360
  };
@@ -2169,6 +2469,197 @@ async function open(site, launch = openBrowser) {
2169
2469
  return failure(error instanceof Error ? error.message : String(error));
2170
2470
  }
2171
2471
  }
2472
+ function siteOr(site, usage) {
2473
+ const named = site ?? readProject()?.siteId;
2474
+ return named === void 0 ? {
2475
+ result: failure(
2476
+ `Name the site: \`${usage}\`, or run this in a folder with a ${PROJECT_FILE}.`
2477
+ )
2478
+ } : { named };
2479
+ }
2480
+ async function info(site) {
2481
+ const found = credentialsOr();
2482
+ if ("result" in found) return found.result;
2483
+ const target = siteOr(site, "drop2run info <subdomain>");
2484
+ if ("result" in target) return target.result;
2485
+ try {
2486
+ const summary = await findSite(found.credentials, target.named);
2487
+ const detail = await getSite(found.credentials, summary.siteId);
2488
+ const folderPath = detail.folderId === null ? void 0 : (await listFolders(found.credentials)).find(
2489
+ (folder) => folder.folderId === detail.folderId
2490
+ )?.path;
2491
+ return {
2492
+ text: describeSite(detail, folderPath),
2493
+ json: { ...detail, mode: servingModeOf$1(detail), folderPath: folderPath ?? null },
2494
+ code: 0
2495
+ };
2496
+ } catch (error) {
2497
+ return failure(error instanceof Error ? error.message : String(error));
2498
+ }
2499
+ }
2500
+ async function pauseOrResume(action, site) {
2501
+ const found = credentialsOr();
2502
+ if ("result" in found) return found.result;
2503
+ const target = siteOr(site, `drop2run ${action} <subdomain>`);
2504
+ if ("result" in target) return target.result;
2505
+ try {
2506
+ const summary = await findSite(found.credentials, target.named);
2507
+ const state = await setSitePaused(found.credentials, summary.siteId, action);
2508
+ return {
2509
+ text: action === "resume" ? `${state.subdomain} is back on the air.
2510
+ ${summary.url}` : state.status === "paused" ? `Paused ${state.subdomain}. Its files and subdomain are kept; \`drop2run resume\` puts it back.` : (
2511
+ // A site Drop2Run suspended stays suspended: the pause is recorded for when that lifts,
2512
+ // and promising `resume` here would be promising something the API refuses.
2513
+ `${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.`
2514
+ ),
2515
+ json: state,
2516
+ code: 0
2517
+ };
2518
+ } catch (error) {
2519
+ return failure(error instanceof Error ? error.message : String(error));
2520
+ }
2521
+ }
2522
+ const SETTINGS_USAGE = [
2523
+ " name <text> What the dashboard calls the site",
2524
+ " mode static|spa|docs How it serves files",
2525
+ " password Read from stdin, or typed at a hidden prompt",
2526
+ " forms on|off Whether its forms accept submissions",
2527
+ " 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"
2529
+ ].join("\n");
2530
+ function settingChange(setting, values, then) {
2531
+ const value = setting === "name" ? values.join(" ").trim() : values[0];
2532
+ const needs = (example) => ({
2533
+ refusal: `\`set ${setting}\` needs a value, for example \`drop2run set ${setting} ${example}\`.`
2534
+ });
2535
+ if (then !== void 0 && setting !== "expires") {
2536
+ return { refusal: "`--then` only applies to `set expires`." };
2537
+ }
2538
+ if (setting !== "name" && setting !== "password" && values.length > 1) {
2539
+ return {
2540
+ refusal: `\`set ${setting}\` takes one value, and "${values.slice(1).join(" ")}" is extra. Name the site with \`--site <subdomain>\`.`
2541
+ };
2542
+ }
2543
+ switch (setting) {
2544
+ case "name":
2545
+ return value ? { change: { name: value } } : needs('"Launch notes"');
2546
+ case "mode":
2547
+ if (value === "static" || value === "spa" || value === "docs") {
2548
+ return { change: { mode: value } };
2549
+ }
2550
+ return { refusal: "`set mode` takes `static`, `spa` or `docs`." };
2551
+ case "forms":
2552
+ if (value === "on" || value === "off") return { change: { formsEnabled: value === "on" } };
2553
+ return { refusal: "`set forms` takes `on` or `off`." };
2554
+ case "password": {
2555
+ if (value !== void 0) {
2556
+ return {
2557
+ 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`.'
2558
+ };
2559
+ }
2560
+ return { password: true };
2561
+ }
2562
+ case "expires": {
2563
+ if (value === void 0) return needs("2026-12-31T09:00:00Z");
2564
+ if (then !== void 0 && then !== "pause" && then !== "delete") {
2565
+ return { refusal: "`--then` takes `pause` or `delete`." };
2566
+ }
2567
+ return {
2568
+ change: {
2569
+ expiresAt: value,
2570
+ ...then === void 0 ? {} : { expiryAction: then }
2571
+ }
2572
+ };
2573
+ }
2574
+ case "folder":
2575
+ return value ? { folder: value } : needs("Clients/Acme");
2576
+ default:
2577
+ return {
2578
+ refusal: `${setting === void 0 ? "Name a setting." : `\`${setting}\` is not a setting.`} Settings:
2579
+ ${SETTINGS_USAGE}`
2580
+ };
2581
+ }
2582
+ }
2583
+ async function set(site, setting, values, options, readPassword = () => readSecret("Password for visitors: ")) {
2584
+ const parsed = settingChange(setting, values, options.then);
2585
+ if ("refusal" in parsed) return failure(parsed.refusal);
2586
+ const found = credentialsOr();
2587
+ if ("result" in found) return found.result;
2588
+ const target = siteOr(site, `drop2run set ${setting} … --site <subdomain>`);
2589
+ if ("result" in target) return target.result;
2590
+ try {
2591
+ const summary = await findSite(found.credentials, target.named);
2592
+ let change;
2593
+ if ("folder" in parsed) {
2594
+ change = { folderId: await resolveFolderTarget(found.credentials, parsed.folder) };
2595
+ } else if ("password" in parsed) {
2596
+ const password = await readPassword();
2597
+ if (password === "") {
2598
+ return failure("No password was given. To remove one, run `drop2run unset password`.");
2599
+ }
2600
+ change = { password };
2601
+ } else {
2602
+ change = parsed.change;
2603
+ }
2604
+ if (change.expiryAction === "delete" && !options.confirmed) {
2605
+ return failure(
2606
+ `This would delete ${summary.subdomain} and every version published to it when ${change.expiresAt} arrives, with no way back then.
2607
+ Add --yes if that is what you want, or leave out --then to pause it instead.`
2608
+ );
2609
+ }
2610
+ return await applied(found.credentials, summary.siteId, summary.subdomain, change);
2611
+ } catch (error) {
2612
+ return failure(error instanceof Error ? error.message : String(error));
2613
+ }
2614
+ }
2615
+ async function unset(site, setting, extra = []) {
2616
+ if (extra.length > 0) {
2617
+ return failure(
2618
+ `\`unset ${setting}\` takes nothing after it, and "${extra.join(" ")}" is extra. Name the site with \`--site <subdomain>\`.`
2619
+ );
2620
+ }
2621
+ const clear = /* @__PURE__ */ new Map([
2622
+ ["name", { name: "" }],
2623
+ ["password", { password: "" }],
2624
+ ["expires", { expiresAt: "" }]
2625
+ ]);
2626
+ const change = setting === void 0 ? void 0 : clear.get(setting);
2627
+ if (change === void 0) {
2628
+ return failure(
2629
+ "`unset` clears `name`, `password` or `expires`. To file the site at the top level, run `drop2run set folder root`."
2630
+ );
2631
+ }
2632
+ const found = credentialsOr();
2633
+ if ("result" in found) return found.result;
2634
+ const target = siteOr(site, `drop2run unset ${setting} --site <subdomain>`);
2635
+ if ("result" in target) return target.result;
2636
+ try {
2637
+ const summary = await findSite(found.credentials, target.named);
2638
+ return await applied(found.credentials, summary.siteId, summary.subdomain, change);
2639
+ } catch (error) {
2640
+ return failure(error instanceof Error ? error.message : String(error));
2641
+ }
2642
+ }
2643
+ async function applied(credentials, siteId, subdomain, change) {
2644
+ const settings = await updateSiteSettings(credentials, siteId, change);
2645
+ const takedown = settings.expiresAt === null ? "none" : `${settings.expiresAt} (${settings.expiryAction})`;
2646
+ return {
2647
+ text: [
2648
+ `Updated ${subdomain}.`,
2649
+ ` name ${settings.name ?? "(none)"}`,
2650
+ ` access ${accessOf(settings)}`,
2651
+ ` mode ${servingModeOf$1(settings)}`,
2652
+ ` forms ${settings.formsEnabled ? "on" : "off"}`,
2653
+ ` takedown ${takedown}`,
2654
+ ...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."
2656
+ ] : [],
2657
+ settings.live ? "Reaching every edge takes up to about a minute." : "Nothing is published yet, so this applies from the first publish."
2658
+ ].join("\n"),
2659
+ json: { ...settings, mode: servingModeOf$1(settings) },
2660
+ code: 0
2661
+ };
2662
+ }
2172
2663
  async function tokens() {
2173
2664
  const found = credentialsOr();
2174
2665
  if ("result" in found) return found.result;
@@ -2187,6 +2678,96 @@ async function tokens() {
2187
2678
  return failure(error instanceof Error ? error.message : String(error));
2188
2679
  }
2189
2680
  }
2681
+ const COMMENTS_USAGE = [
2682
+ " drop2run comments [site] [--status open|resolved|all] [--path /page]",
2683
+ " drop2run comments reply <thread> <text> [--resolve] [--site X]",
2684
+ " drop2run comments resolve|reopen <thread> [--site X]"
2685
+ ].join("\n");
2686
+ async function comments(subcommand, rest, site, options) {
2687
+ const found = credentialsOr();
2688
+ if ("result" in found) return found.result;
2689
+ const acting = subcommand === "reply" || subcommand === "resolve" || subcommand === "reopen";
2690
+ if (!acting) {
2691
+ const status = options.status ?? "open";
2692
+ if (status !== "open" && status !== "resolved" && status !== "all") {
2693
+ return failure(
2694
+ `\`--status\` is open, resolved or all, not "${status}".
2695
+
2696
+ ${COMMENTS_USAGE}`
2697
+ );
2698
+ }
2699
+ const target2 = siteOr(subcommand ?? site, "drop2run comments <subdomain>");
2700
+ if ("result" in target2) return target2.result;
2701
+ try {
2702
+ const summary = await findSite(found.credentials, target2.named);
2703
+ const read = await listComments(found.credentials, summary.siteId, {
2704
+ status,
2705
+ path: options.path
2706
+ });
2707
+ return {
2708
+ text: describeComments(read.threads, status, summary.subdomain, read.more),
2709
+ json: read,
2710
+ code: 0
2711
+ };
2712
+ } catch (error) {
2713
+ return failure(error instanceof Error ? error.message : String(error));
2714
+ }
2715
+ }
2716
+ const [thread, ...words] = rest;
2717
+ if (thread === void 0)
2718
+ return failure(`Name the thread, from \`drop2run comments\`.
2719
+
2720
+ ${COMMENTS_USAGE}`);
2721
+ const body = words.join(" ").trim();
2722
+ if (subcommand === "reply" && body === "") {
2723
+ return failure(`Write the reply after the thread id.
2724
+
2725
+ ${COMMENTS_USAGE}`);
2726
+ }
2727
+ const target = siteOr(site, `drop2run comments ${subcommand} <thread> --site <subdomain>`);
2728
+ if ("result" in target) return target.result;
2729
+ try {
2730
+ const summary = await findSite(found.credentials, target.named);
2731
+ const answered = subcommand === "reply" ? await replyToComment(
2732
+ found.credentials,
2733
+ summary.siteId,
2734
+ thread,
2735
+ body,
2736
+ options.resolve === true
2737
+ ) : await setCommentResolved(
2738
+ found.credentials,
2739
+ summary.siteId,
2740
+ thread,
2741
+ subcommand === "resolve"
2742
+ );
2743
+ const done = subcommand === "reply" ? `Replied on ${answered.path}${answered.resolvedAt === null ? "" : " and resolved the thread"}.` : `${subcommand === "resolve" ? "Resolved" : "Reopened"} the thread on ${answered.path}.`;
2744
+ return { text: `${done}
2745
+ ${answered.url}`, json: answered, code: 0 };
2746
+ } catch (error) {
2747
+ return failure(error instanceof Error ? error.message : String(error));
2748
+ }
2749
+ }
2750
+ function describeComments(threads, status, subdomain, more) {
2751
+ const which = status === "all" ? "" : `${status} `;
2752
+ if (threads.length === 0) return `No ${which}comment threads on ${subdomain}.`;
2753
+ const heading = `${threads.length} ${which}comment ${threads.length === 1 ? "thread" : "threads"} on ${subdomain}` + (more ? " (more follow; `--json` shows the cursor)." : ".");
2754
+ const blocks = threads.map((thread) => {
2755
+ const where2 = thread.place.quote ?? (thread.place.snippet || null);
2756
+ const flags = [
2757
+ ...thread.resolvedAt === null ? [] : ["resolved"],
2758
+ ...thread.outdated ? ["earlier version"] : []
2759
+ ];
2760
+ return [
2761
+ `${thread.id} ${thread.path}${where2 === null ? "" : ` "${where2}"`}` + (flags.length === 0 ? "" : ` (${flags.join(", ")})`),
2762
+ ...thread.comments.map((comment) => {
2763
+ const who = comment.author.owner ? `${comment.author.name} (owner)` : comment.author.name;
2764
+ return ` ${who}: ${comment.body.replace(/\s*\n\s*/g, " ")}`;
2765
+ }),
2766
+ ` ${thread.url}`
2767
+ ].join("\n");
2768
+ });
2769
+ return [heading, ...blocks].join("\n\n");
2770
+ }
2190
2771
  function day(iso) {
2191
2772
  const parsed = new Date(iso);
2192
2773
  return Number.isNaN(parsed.getTime()) ? iso : parsed.toISOString().slice(0, 10);
@@ -2220,8 +2801,16 @@ const HELP = `drop2run — publish a static site from the command line
2220
2801
  drop2run ls List your sites
2221
2802
  drop2run folders List your folders, as --folder takes them
2222
2803
  drop2run open [site] Open a site in a browser
2804
+ drop2run info [site] Show a site's settings and versions
2805
+ drop2run set <setting> <value> [--site X] Change one setting (see Settings below)
2806
+ drop2run unset name|password|expires Clear a name, a password or a takedown
2807
+ drop2run pause [site] Take a site off the air, keeping its files
2808
+ drop2run resume [site] Put a paused site back on the air
2223
2809
  drop2run rollback <deployId> [--site X] Put an earlier version back live
2224
2810
  drop2run rm <site> --yes Delete a site and everything on it
2811
+ drop2run comments [site] Read the comments left on a site's pages
2812
+ drop2run comments reply <thread> <text> Answer a thread as the owner [--resolve]
2813
+ drop2run comments resolve|reopen <thread> Resolve a thread, or open it again
2225
2814
  drop2run token list List your access tokens
2226
2815
  drop2run whoami Check the token and whose it is
2227
2816
  drop2run where Show which token source is in use
@@ -2235,6 +2824,33 @@ Flags
2235
2824
  or a folder id (init and deploy only)
2236
2825
  --device Sign in by approving a code on another machine
2237
2826
  --yes Confirm a deletion, which cannot be undone
2827
+ --then X What \`set expires\` does when the date arrives: pause
2828
+ (the default, keeps the files) or delete (needs --yes)
2829
+ --status X Which comment threads: open (the default), resolved or all
2830
+ --path X Only the comments on one page, such as /pricing
2831
+ --resolve Resolve the thread with the reply (\`comments reply\` only)
2832
+
2833
+ Comments
2834
+ \`comments reply\`, \`resolve\` and \`reopen\` act on --site, else the site in
2835
+ drop2run.json, and only the account's owner can use them. Deploy the fix
2836
+ before replying that it is fixed. A reply leaves the thread open unless
2837
+ --resolve is given, so the person who raised it can check the fix and
2838
+ resolve it themselves.
2839
+
2840
+ Settings
2841
+ set name <text> What the dashboard calls the site; put a
2842
+ name with words starting "-" after --
2843
+ set mode static|spa|docs static answers 404 for a missing path, spa
2844
+ falls back to index.html, docs uses the viewer
2845
+ set password Typed at a hidden prompt, or piped in:
2846
+ printf %s "$PW" | drop2run set password
2847
+ set forms on|off Whether its forms accept submissions
2848
+ set expires <ISO 8601> Take it down then, e.g. 2026-12-31T09:00:00Z
2849
+ set folder <path|id|root> Move it to a folder, or root for the top level
2850
+
2851
+ \`set\` and \`unset\` act on --site, else the site in drop2run.json. A
2852
+ setting your plan does not include is refused with the reason, and
2853
+ \`info\` says which ones those are.
2238
2854
 
2239
2855
  Where a site comes from
2240
2856
  --subdomain creates one under the name you give. Otherwise: --site, then
@@ -2263,7 +2879,7 @@ function flagValue(args, flag) {
2263
2879
  const value = args[at + 1];
2264
2880
  return value === void 0 || value.startsWith("-") ? void 0 : value;
2265
2881
  }
2266
- const VALUE_FLAGS = ["--site", "--subdomain", "--folder"];
2882
+ const VALUE_FLAGS = ["--site", "--subdomain", "--folder", "--then", "--status", "--path"];
2267
2883
  function positionals(argv) {
2268
2884
  const found = [];
2269
2885
  for (let at = 0; at < argv.length; at++) {
@@ -2287,7 +2903,7 @@ const SUBDOMAIN_FLAG = {
2287
2903
  const FOLDER_FLAG = {
2288
2904
  flag: "--folder",
2289
2905
  example: "Clients/Acme",
2290
- effect: "`--folder` only files a new one — move an existing site from the dashboard",
2906
+ effect: "`--folder` only files a new one — move an existing site with `drop2run set folder`",
2291
2907
  elsewhere: (command) => command === "ls" || command === "list" ? " `drop2run folders` lists them." : ""
2292
2908
  };
2293
2909
  function newSiteFlagProblem(argv, command, site, value, described) {
@@ -2304,12 +2920,15 @@ function newSiteFlagProblem(argv, command, site, value, described) {
2304
2920
  }
2305
2921
  return void 0;
2306
2922
  }
2307
- async function run(argv) {
2923
+ async function run(commandLine) {
2924
+ const end = commandLine.indexOf("--");
2925
+ const argv = end === -1 ? commandLine : commandLine.slice(0, end);
2926
+ const literal = end === -1 ? [] : commandLine.slice(end + 1);
2308
2927
  const json = argv.includes("--json");
2309
- const positional = positionals(argv);
2928
+ const positional = [...positionals(argv), ...literal];
2310
2929
  const [command, ...rest] = positional;
2311
2930
  if (argv.includes("--version")) {
2312
- const { version } = await import("./package-D8mWS-nC.js").then(
2931
+ const { version } = await import("./package-LdzYr7IV.js").then(
2313
2932
  (module) => module.default
2314
2933
  );
2315
2934
  return { text: version, json: { version }, code: 0 };
@@ -2325,6 +2944,12 @@ async function run(argv) {
2325
2944
  const failed = { text: refusal, json: { error: refusal }, code: 1 };
2326
2945
  return json ? { ...failed, text: JSON.stringify(failed.json, null, 2) } : failed;
2327
2946
  }
2947
+ const then = flagValue(argv, "--then");
2948
+ if (argv.includes("--then") && then === void 0) {
2949
+ const message = "`--then` needs a value: `--then pause` or `--then delete`.";
2950
+ const failed = { text: message, json: { error: message }, code: 1 };
2951
+ return json ? { ...failed, text: JSON.stringify(failed.json, null, 2) } : failed;
2952
+ }
2328
2953
  const result = await dispatch(
2329
2954
  command,
2330
2955
  rest,
@@ -2332,11 +2957,17 @@ async function run(argv) {
2332
2957
  json,
2333
2958
  argv.includes("--device"),
2334
2959
  argv.includes("--yes"),
2335
- { subdomain, folder }
2960
+ { subdomain, folder },
2961
+ then,
2962
+ {
2963
+ status: flagValue(argv, "--status"),
2964
+ path: flagValue(argv, "--path"),
2965
+ resolve: argv.includes("--resolve")
2966
+ }
2336
2967
  );
2337
2968
  return json ? { ...result, text: JSON.stringify(result.json, null, 2) } : result;
2338
2969
  }
2339
- async function dispatch(command, rest, site, json, device, confirmed, newSite = {}) {
2970
+ async function dispatch(command, rest, site, json, device, confirmed, newSite = {}, then = void 0, commentOptions = {}) {
2340
2971
  switch (command) {
2341
2972
  case "login": {
2342
2973
  const print = json ? () => {
@@ -2362,10 +2993,21 @@ async function dispatch(command, rest, site, json, device, confirmed, newSite =
2362
2993
  return await folders();
2363
2994
  case "open":
2364
2995
  return await open(rest[0] ?? site);
2996
+ case "info":
2997
+ return await info(rest[0] ?? site);
2998
+ case "set":
2999
+ return await set(site, rest[0], rest.slice(1), { then, confirmed });
3000
+ case "unset":
3001
+ return await unset(site, rest[0], rest.slice(1));
3002
+ case "pause":
3003
+ case "resume":
3004
+ return await pauseOrResume(command, rest[0] ?? site);
2365
3005
  case "rollback":
2366
3006
  return await rollback(rest[0], site);
2367
3007
  case "rm":
2368
3008
  return await remove(rest[0] ?? site, confirmed);
3009
+ case "comments":
3010
+ return await comments(rest[0], rest.slice(1), site, commentOptions);
2369
3011
  case "token":
2370
3012
  case "tokens":
2371
3013
  return rest[0] === void 0 || rest[0] === "list" ? await tokens() : {
@@ -1,5 +1,5 @@
1
1
  const name = "drop2run";
2
- const version = "0.7.0";
2
+ const version = "0.9.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.7.0",
3
+ "version": "0.9.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",