@drop2run/mcp 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.
Files changed (3) hide show
  1. package/README.md +33 -4
  2. package/dist/index.js +850 -75
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @drop2run/mcp
2
2
 
3
- An MCP server that lets Claude — or any MCP client — publish what it made to
3
+ An MCP server that lets any MCP client — Claude, Cursor, VS Code, Codex, Gemini CLI — publish what it made to
4
4
  [Drop2Run](https://dropto.run): an HTML page, a markdown note, a folder of documents or a built site. It
5
5
  returns a live HTTPS URL.
6
6
 
@@ -35,11 +35,29 @@ add this to `claude_desktop_config.json` and restart the app:
35
35
  That file is at `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, and
36
36
  `%APPDATA%\Claude\claude_desktop_config.json` on Windows.
37
37
 
38
+ **Cursor** — the same `mcpServers` block with `"type": "stdio"` added, in `~/.cursor/mcp.json` (or `.cursor/mcp.json` for one
39
+ project). **Gemini CLI** — the same block, in `~/.gemini/settings.json`.
40
+
41
+ **VS Code** — one command:
42
+
43
+ ```bash
44
+ code --add-mcp '{"name":"drop2run","command":"npx","args":["-y","@drop2run/mcp"]}'
45
+ ```
46
+
47
+ **Codex** — one command:
48
+
49
+ ```bash
50
+ codex mcp add drop2run -- npx -y @drop2run/mcp
51
+ ```
52
+
53
+ Zed and the rest: [dropto.run/docs/agents](https://dropto.run/docs/agents) has the file and the shape
54
+ for each client.
55
+
38
56
  Nothing is installed globally either way — `npx` fetches the package when the client starts it.
39
57
 
40
58
  ## Sign in
41
59
 
42
- Ask for a publish. If there is no token yet, Claude calls the `login` tool, a browser tab opens, you
60
+ Ask for a publish. If there is no token yet, the agent calls the `login` tool, a browser tab opens, you
43
61
  approve the sign-in, and the publish carries on. Nothing to install, nothing to paste.
44
62
 
45
63
  The token lands in `~/.config/drop2run/config.json` with mode `0600` — the same file the `drop2run`
@@ -47,9 +65,9 @@ CLI uses, so signing in once covers both. It is read on every call rather than a
47
65
  effect without restarting the client.
48
66
 
49
67
  **No browser on this machine?** In a container, over SSH, on a remote host, `login_code` gives a short
50
- code to enter at <https://dropto.run/device> from any other device.
68
+ code to enter at <https://app.dropto.run/device> from any other device.
51
69
 
52
- **Or set a token yourself.** Create one at <https://dropto.run/account/tokens> and either put it in
70
+ **Or set a token yourself.** Create one at <https://app.dropto.run/account/tokens> and either put it in
53
71
  `~/.config/drop2run/config.json` as `{"token": "d2r_..."}`, which needs no restart, or set
54
72
  `DROP2RUN_TOKEN` in the environment the client starts the server in. The environment takes precedence
55
73
  over the file — and because it is read once at startup, setting it in a shell afterwards changes
@@ -69,6 +87,11 @@ lose it.
69
87
  | `list_sites` | Lists the sites on the account |
70
88
  | `list_folders` | Lists the account's folders, each with the path `folder` accepts |
71
89
  | `delete_site` | Deletes a site permanently, subdomain included — requires the subdomain repeated as `confirm` |
90
+ | `get_site` | Reads a site's settings, whether it is paused, and its versions with the live one marked |
91
+ | `update_site` | Changes a site's name, serving mode, password, forms, scheduled takedown or folder |
92
+ | `pause_site` | Takes a site off the air, keeping its files, versions and subdomain |
93
+ | `resume_site` | Puts a paused site back on the air |
94
+ | `rollback_site` | Serves an earlier version again, by its id from `get_site` |
72
95
 
73
96
  Both publish tools take an optional `site` — a subdomain or site id to publish over. Leave it out and a
74
97
  new site is created.
@@ -84,6 +107,12 @@ by path such as `Clients/Acme`, or by the id `list_folders` shows. Like `subdoma
84
107
  site, so it cannot be combined with `site`. It never creates a folder: one that does not exist is refused
85
108
  with the list of those that do, and nothing is published.
86
109
 
110
+ `update_site` changes only the settings it is given. An empty `name`, `password` or `expiresAt` clears
111
+ that setting, and `folder: "root"` moves the site back to the top level. A takedown pauses the site by
112
+ default; `expiryAction: "delete"` removes it instead, and asks for the subdomain as `confirm` the way
113
+ `delete_site` does. Settings your plan does not include are refused with the reason, and `get_site` says
114
+ which ones those are.
115
+
87
116
  Both sign-in tools answer "not approved yet" rather than failing while they wait, and calling them
88
117
  again keeps waiting on the same sign-in. Neither replaces a token that is already stored unless asked
89
118
  to with `replace`.
package/dist/index.js CHANGED
@@ -13,6 +13,8 @@ function configPath() {
13
13
  return join(homedir(), ".config", "drop2run", "config.json");
14
14
  }
15
15
  const DEFAULT_API_BASE_URL = "https://dropto.run/api";
16
+ const PRODUCTION_SITE_ORIGIN = "https://dropto.run";
17
+ const PRODUCTION_DASHBOARD_ORIGIN = "https://app.dropto.run";
16
18
  function loadCredentials(env = process.env, read = (path) => readFileSync(path, "utf8")) {
17
19
  let stored = {};
18
20
  try {
@@ -36,7 +38,8 @@ function resolveApiBaseUrl(env = process.env, read = (path) => readFileSync(path
36
38
  return url.replace(/\/+$/, "");
37
39
  }
38
40
  function dashboardUrlFor(apiBaseUrl) {
39
- return apiBaseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
41
+ const origin = apiBaseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
42
+ return origin === PRODUCTION_SITE_ORIGIN ? PRODUCTION_DASHBOARD_ORIGIN : origin;
40
43
  }
41
44
  function saveToken(token, path = configPath()) {
42
45
  let existing = {};
@@ -63,14 +66,14 @@ function missingCredentialsMessage(surface = "cli") {
63
66
  ] : ["Run `drop2run login` to sign in through a browser, which stores one for you."];
64
67
  const byHand = surface === "mcp" ? [
65
68
  "To set one without signing in here, create a token at",
66
- "https://dropto.run/account/tokens and either:",
69
+ "https://app.dropto.run/account/tokens and either:",
67
70
  ` - put it in ${configPath()} as {"token": "d2r_..."}, which this`,
68
71
  " server reads on every call and needs no restart, or",
69
72
  ` - set ${TOKEN_VARIABLE} in the environment and restart this server, since it reads`,
70
73
  " the environment it was started in."
71
74
  ] : [
72
75
  "For CI, or where no browser can be opened, create a token at",
73
- "https://dropto.run/account/tokens and either:",
76
+ "https://app.dropto.run/account/tokens and either:",
74
77
  ` - set ${TOKEN_VARIABLE} in the environment, or`,
75
78
  ` - put it in ${configPath()} as {"token": "d2r_..."}`
76
79
  ];
@@ -102,7 +105,9 @@ async function call(credentials, path, init = {}) {
102
105
  async function findSite(credentials, site) {
103
106
  const found = await lookUpSite(credentials, site);
104
107
  if (found === void 0) {
105
- throw new Error(`No site of yours is called "${site}". \`drop2run ls\` shows what exists.`);
108
+ throw new Error(
109
+ `No site of yours is called "${site}". \`drop2run ls\` or the list_sites tool shows what exists.`
110
+ );
106
111
  }
107
112
  return found;
108
113
  }
@@ -139,6 +144,115 @@ async function deleteSite(credentials, siteId) {
139
144
  throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
140
145
  }
141
146
  }
147
+ async function promoteDeploy(credentials, siteId, deployId) {
148
+ return await call(
149
+ credentials,
150
+ `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/promote`,
151
+ { method: "POST" }
152
+ );
153
+ }
154
+ function enumName(value) {
155
+ return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
156
+ }
157
+ function actionOf(expiresAt, action) {
158
+ return expiresAt === null ? null : enumName(action);
159
+ }
160
+ async function readInvitedOnly(credentials, siteId) {
161
+ try {
162
+ const page = await call(
163
+ credentials,
164
+ `sites/${encodeURIComponent(siteId)}/viewers?pageSize=1`
165
+ );
166
+ return typeof page.invitedOnly === "boolean" ? page.invitedOnly : null;
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+ async function folderFromListing(credentials, siteId, subdomain) {
172
+ const { sites } = await call(
173
+ credentials,
174
+ `sites?pageSize=${SITES_PER_REQUEST}&q=${encodeURIComponent(subdomain)}`
175
+ );
176
+ return sites.find((candidate) => candidate.siteId === siteId)?.folderId ?? null;
177
+ }
178
+ async function getSite(credentials, siteId) {
179
+ const [site, invitedOnly] = await Promise.all([
180
+ call(
181
+ credentials,
182
+ `sites/${encodeURIComponent(siteId)}`
183
+ ),
184
+ readInvitedOnly(credentials, siteId)
185
+ ]);
186
+ const folderId = site.folderId !== void 0 ? site.folderId : await folderFromListing(credentials, site.siteId, site.subdomain);
187
+ return {
188
+ siteId: site.siteId,
189
+ subdomain: site.subdomain,
190
+ url: site.url,
191
+ name: site.name,
192
+ status: enumName(site.status),
193
+ invitedOnly,
194
+ liveDeployId: site.liveDeployId,
195
+ spaMode: site.spaMode,
196
+ docsMode: site.docsMode,
197
+ modeIsManual: site.modeIsManual,
198
+ passwordProtected: site.passwordProtected,
199
+ passwordProtectionAvailable: site.passwordProtectionAvailable,
200
+ formsEnabled: site.formsEnabled,
201
+ formsAvailable: site.formsAvailable,
202
+ expiresAt: site.expiresAt,
203
+ expiryAction: actionOf(site.expiresAt, site.expiryAction ?? ""),
204
+ scheduledExpiryAvailable: site.scheduledExpiryAvailable,
205
+ folderId,
206
+ deploys: site.deploys.map((deploy2) => ({
207
+ deployId: deploy2.deployId,
208
+ status: deploy2.status,
209
+ fileCount: deploy2.fileCount,
210
+ totalBytes: deploy2.totalBytes,
211
+ createdAt: deploy2.createdAt,
212
+ completedAt: deploy2.completedAt,
213
+ filesKept: deploy2.filesKept
214
+ }))
215
+ };
216
+ }
217
+ async function updateSiteSettings(credentials, siteId, change) {
218
+ const body = {};
219
+ if (change.name !== void 0) body.name = change.name;
220
+ if (change.mode !== void 0) {
221
+ body.spaMode = change.mode === "spa";
222
+ body.docsMode = change.mode === "docs";
223
+ }
224
+ if (change.password !== void 0) body.password = change.password;
225
+ if (change.expiresAt !== void 0) body.expiresAt = change.expiresAt;
226
+ if (change.expiryAction !== void 0) body.expiryAction = change.expiryAction;
227
+ if (change.formsEnabled !== void 0) body.formsEnabled = change.formsEnabled;
228
+ if (change.folderId !== void 0) body.folderId = change.folderId;
229
+ if (Object.keys(body).length === 0) throw new Error("Nothing to change — name a setting.");
230
+ const wasInvitedOnly = change.password !== void 0 && change.password !== "" ? await readInvitedOnly(credentials, siteId) : null;
231
+ const settings = await call(credentials, `sites/${encodeURIComponent(siteId)}`, {
232
+ method: "PATCH",
233
+ body: JSON.stringify(body)
234
+ });
235
+ const invitedOnly = typeof settings.invitedOnly === "boolean" ? settings.invitedOnly : null;
236
+ return {
237
+ ...settings,
238
+ expiryAction: actionOf(settings.expiresAt, settings.expiryAction ?? ""),
239
+ invitedOnly,
240
+ replacedInviteOnly: wasInvitedOnly === true && invitedOnly === false
241
+ };
242
+ }
243
+ async function setSitePaused(credentials, siteId, action) {
244
+ const state = await call(
245
+ credentials,
246
+ `sites/${encodeURIComponent(siteId)}/${action}`,
247
+ { method: "POST" }
248
+ );
249
+ return {
250
+ siteId: state.siteId,
251
+ subdomain: state.subdomain,
252
+ status: enumName(state.status),
253
+ ownerPaused: state.ownerPaused
254
+ };
255
+ }
142
256
  const SITES_PER_REQUEST = 100;
143
257
  async function listSites(credentials) {
144
258
  const body = await call(
@@ -209,6 +323,46 @@ async function resolveFolder(credentials, folder) {
209
323
  `No folder of yours is called "${folder}". Your folders: ${listed.join(", ")}${more > 0 ? `, and ${more} more` : ""}.`
210
324
  );
211
325
  }
326
+ const ROOT_FOLDER = "root";
327
+ async function resolveFolderTarget(credentials, folder) {
328
+ return folder.trim().toLowerCase() === ROOT_FOLDER ? ROOT_FOLDER : await resolveFolder(credentials, folder);
329
+ }
330
+ const VERSIONS_LISTED = 10;
331
+ function servingModeOf$1(site) {
332
+ if (site.spaMode) return "spa";
333
+ if (site.docsMode) return "docs";
334
+ return "static";
335
+ }
336
+ function accessOf(site) {
337
+ if (site.passwordProtected) return "password";
338
+ if (site.invitedOnly === true) return "invite-only";
339
+ if (site.invitedOnly === false) return "public";
340
+ return "no password (whether it is invite-only could not be read)";
341
+ }
342
+ function describeSite(site, folderPath) {
343
+ const unavailable = (available) => available ? "" : " (not on this plan)";
344
+ const versions = site.deploys.slice(0, VERSIONS_LISTED).map((deploy2) => {
345
+ const live = deploy2.deployId === site.liveDeployId ? " ← served" : "";
346
+ const kept = deploy2.filesKept ? "" : ", files collected — cannot be rolled back to";
347
+ return ` ${deploy2.deployId} ${deploy2.createdAt} ${deploy2.status}, ${deploy2.fileCount} files${kept}${live}`;
348
+ });
349
+ const more = site.deploys.length - versions.length;
350
+ return [
351
+ `${site.subdomain} — ${site.url}`,
352
+ ` name ${site.name ?? "(none)"}`,
353
+ ` status ${site.status}`,
354
+ ` access ${accessOf(site)}`,
355
+ ` mode ${servingModeOf$1(site)}${site.modeIsManual ? "" : " (detected on each publish)"}`,
356
+ ` password ${site.passwordProtected ? "on" : "off"}${unavailable(site.passwordProtectionAvailable)}`,
357
+ ` forms ${site.formsEnabled ? "on" : "off"}${unavailable(site.formsAvailable)}`,
358
+ ` takedown ${site.expiresAt === null ? "none" : `${site.expiresAt} (${site.expiryAction})`}${unavailable(site.scheduledExpiryAvailable)}`,
359
+ ` folder ${site.folderId === null ? "(top level)" : folderPath ?? site.folderId}`,
360
+ "",
361
+ site.deploys.length === 0 ? "No versions yet." : "Versions, newest first:",
362
+ ...versions,
363
+ ...more > 0 ? [` … and ${more} older`] : []
364
+ ].join("\n");
365
+ }
212
366
  function newAttempt() {
213
367
  const verifier = randomBytes(32).toString("base64url");
214
368
  return {
@@ -344,6 +498,12 @@ async function exchange(apiBaseUrl, code, verifier) {
344
498
  const body = await response.json();
345
499
  return { token: body.token, name: body.name, email: body.email };
346
500
  }
501
+ function sizeOf(content) {
502
+ return ArrayBuffer.isView(content) ? content.byteLength : content.size;
503
+ }
504
+ async function bytesOf(content) {
505
+ return ArrayBuffer.isView(content) ? content : new Uint8Array(await content.arrayBuffer());
506
+ }
347
507
  class DeployError extends Error {
348
508
  /**
349
509
  * @param code Stable code, either an API error type (`quota_exceeded`, `unsafe_path`, …) or one of
@@ -380,20 +540,55 @@ const ClientErrorCode = {
380
540
  /** The user cancelled. */
381
541
  Cancelled: "cancelled",
382
542
  /** An upload kept failing after every retry. */
383
- UploadFailed: "upload_failed"
543
+ UploadFailed: "upload_failed",
544
+ /**
545
+ * The browser could not hold what was dropped in memory. Raised when allocating the buffer for a
546
+ * large zip or file fails, which a browser reports as a `RangeError` rather than as anything that
547
+ * names memory.
548
+ */
549
+ OutOfMemory: "out_of_memory",
550
+ /**
551
+ * A dropped file could not be read when it came to be hashed. Files from a folder stay on disk until
552
+ * then, so one moved, deleted or changed after the drop fails here rather than at the drop.
553
+ */
554
+ ReadFailed: "read_failed",
555
+ /**
556
+ * A control-plane call got no answer: the connection failed before a response arrived, or the
557
+ * request timeout passed. Distinct from an HTTP error, which is an answer — this one says nothing
558
+ * about whether the server acted, which is why only an idempotent call may retry on it.
559
+ */
560
+ NetworkFailed: "network_failed"
384
561
  };
562
+ function describeError(error) {
563
+ const parts = [];
564
+ let current = error;
565
+ for (let depth = 0; depth < 5 && current !== void 0 && current !== null; depth++) {
566
+ if (current instanceof Error) {
567
+ const code = current.code;
568
+ parts.push(typeof code === "string" ? `${current.message} (${code})` : current.message);
569
+ current = current.cause;
570
+ } else {
571
+ parts.push(String(current));
572
+ break;
573
+ }
574
+ }
575
+ return parts.join(" ← ");
576
+ }
385
577
  const ACCOUNT_HEADER = "X-Drop2Run-Account";
386
578
  const CLAIM_TOKEN_HEADER = "X-Claim-Token";
579
+ const DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
580
+ const COMPLETE_RETRY_DELAYS_MS = [1e3, 4e3];
387
581
  async function prepareDeploy(siteId, files, options, signal) {
388
582
  const manifest = files.map((file) => ({
389
583
  path: file.path,
390
584
  sha256: file.sha256,
391
- size: file.bytes.length
585
+ size: sizeOf(file.bytes)
392
586
  }));
393
587
  const prepared = await request(
394
588
  `sites/${encodeURIComponent(siteId)}/deploys/prepare`,
395
589
  { files: manifest },
396
- options
590
+ options,
591
+ signal
397
592
  );
398
593
  return { ...prepared, uploadUrl: absolute(prepared.uploadUrl, options.baseUrl) };
399
594
  }
@@ -403,31 +598,65 @@ function absolute(uploadUrl, baseUrl) {
403
598
  return new URL(uploadUrl, baseUrl).toString();
404
599
  }
405
600
  async function completeDeploy(siteId, deployId, options, signal, name) {
406
- return request(
407
- `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`,
408
- // A bodyless POST when there is no name to send, which is what this call has always been. The
409
- // server treats an absent body and an absent field identically, so nothing depends on which of
410
- // the two a client picks.
411
- name === null || name === void 0 ? void 0 : { name },
412
- options
413
- );
601
+ const path = `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`;
602
+ const body = name === null || name === void 0 ? void 0 : { name };
603
+ const delay = options.delay ?? wait;
604
+ for (let attempt2 = 0; ; attempt2++) {
605
+ try {
606
+ return await request(path, body, options, signal);
607
+ } catch (error) {
608
+ const unanswered = error instanceof DeployError && error.code === ClientErrorCode.NetworkFailed;
609
+ if (!unanswered || attempt2 >= COMPLETE_RETRY_DELAYS_MS.length || signal?.aborted) throw error;
610
+ await delay(COMPLETE_RETRY_DELAYS_MS[attempt2] ?? 1e3);
611
+ }
612
+ }
613
+ }
614
+ function wait(ms) {
615
+ return new Promise((resolve) => setTimeout(resolve, ms));
414
616
  }
415
617
  async function request(path, body, options, signal) {
416
618
  const doFetch = options.fetch ?? globalFetch$1();
417
619
  const base = options.baseUrl ?? "/api";
620
+ const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
418
621
  const headers = {};
419
622
  if (body !== void 0) headers["Content-Type"] = "application/json";
420
623
  if (options.claimToken) headers[CLAIM_TOKEN_HEADER] = options.claimToken;
421
624
  if (options.token) headers.Authorization = `Bearer ${options.token}`;
422
625
  if (options.accountId) headers[ACCOUNT_HEADER] = options.accountId;
423
- const response = await doFetch(`${base}/${path}`, {
424
- method: "POST",
425
- headers,
426
- ...body === void 0 ? {} : { body: JSON.stringify(body) },
427
- ...{}
428
- });
429
- if (!response.ok) throw await problemToError(response);
430
- return await response.json();
626
+ const controller = new AbortController();
627
+ let timedOut = false;
628
+ const timer = setTimeout(() => {
629
+ timedOut = true;
630
+ controller.abort();
631
+ }, timeoutMs);
632
+ try {
633
+ const response = await doFetch(`${base}/${path}`, {
634
+ method: "POST",
635
+ headers,
636
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
637
+ signal: controller.signal
638
+ });
639
+ if (!response.ok) throw await problemToError(response);
640
+ return await response.json();
641
+ } catch (error) {
642
+ if (error instanceof DeployError || signal?.aborted) throw error;
643
+ if (timedOut) {
644
+ throw new DeployError(
645
+ ClientErrorCode.NetworkFailed,
646
+ `The server did not answer within ${Math.round(timeoutMs / 1e3)} seconds.`,
647
+ { path, cause: describeError(error) }
648
+ );
649
+ }
650
+ if (error instanceof TypeError) {
651
+ throw new DeployError(ClientErrorCode.NetworkFailed, "Could not reach the server.", {
652
+ path,
653
+ cause: describeError(error)
654
+ });
655
+ }
656
+ throw error;
657
+ } finally {
658
+ clearTimeout(timer);
659
+ }
431
660
  }
432
661
  function globalFetch$1() {
433
662
  return globalThis.fetch.bind(globalThis);
@@ -926,17 +1155,29 @@ function extensionOf(path) {
926
1155
  return dot <= 0 ? "" : name.slice(dot).toLowerCase();
927
1156
  }
928
1157
  async function sha256Hex(bytes) {
929
- const digest = await crypto.subtle.digest("SHA-256", bytes.slice().buffer);
1158
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
930
1159
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
931
1160
  }
932
1161
  async function hashAll(files, onProgress, signal) {
933
1162
  const hashed = [];
934
1163
  for (const [index, file] of files.entries()) {
935
- hashed.push({ ...file, sha256: await sha256Hex(file.bytes) });
1164
+ hashed.push({ ...file, sha256: await sha256Hex(await readForHash(file)) });
936
1165
  onProgress?.(index + 1, files.length);
937
1166
  }
938
1167
  return hashed;
939
1168
  }
1169
+ async function readForHash(file) {
1170
+ try {
1171
+ return await bytesOf(file.bytes);
1172
+ } catch (error) {
1173
+ if (error instanceof RangeError) throw error;
1174
+ throw new DeployError(
1175
+ ClientErrorCode.ReadFailed,
1176
+ `Could not read ${file.path}. It may have been moved, renamed or changed since you dropped it — drop the folder again.`,
1177
+ { path: file.path, cause: describeError(error) }
1178
+ );
1179
+ }
1180
+ }
940
1181
  const IGNORED_EXACT = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".gitignore", ".gitkeep"]);
941
1182
  const IGNORED_DIRECTORIES = [
942
1183
  "__MACOSX",
@@ -946,7 +1187,11 @@ const IGNORED_DIRECTORIES = [
946
1187
  ".hg",
947
1188
  ".idea",
948
1189
  ".vscode",
949
- ".claude"
1190
+ ".claude",
1191
+ ".agents",
1192
+ ".cursor",
1193
+ ".codex",
1194
+ ".gemini"
950
1195
  ];
951
1196
  function shouldIgnore(path) {
952
1197
  const segments = path.split("/");
@@ -981,6 +1226,24 @@ function canPublish(paths) {
981
1226
  if (isImageFolder(paths)) return true;
982
1227
  return paths.length === 1 && paths[0] !== void 0 && isViewablePath(paths[0]);
983
1228
  }
1229
+ function servingModeOf(flags) {
1230
+ if (flags.docsMode) return "docs";
1231
+ if (flags.spaMode) return "spa";
1232
+ return "static";
1233
+ }
1234
+ function predictedModeOf(paths) {
1235
+ if (paths.some((path) => path === REQUIRED_INDEX)) return "static";
1236
+ if (paths.some(isDocumentPath)) return "docs";
1237
+ if (isImageFolder(paths)) return "docs";
1238
+ if (paths.length === 1 && paths[0] !== void 0 && isViewablePath(paths[0])) return "docs";
1239
+ return "static";
1240
+ }
1241
+ function nestedIndexOf(paths) {
1242
+ if (predictedModeOf(paths) !== "docs") return null;
1243
+ const suffix = `/${REQUIRED_INDEX}`;
1244
+ const folders = 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));
1245
+ return folders[0] ?? null;
1246
+ }
984
1247
  function renameOfLoneHtmlPage(paths) {
985
1248
  if (paths.length !== 1) return null;
986
1249
  const [only] = paths;
@@ -1008,7 +1271,7 @@ function checkLimits(files, limits) {
1008
1271
  );
1009
1272
  }
1010
1273
  const refusal = sizeRefusal(
1011
- files.map((file) => ({ path: file.path, size: file.bytes.length })),
1274
+ files.map((file) => ({ path: file.path, size: sizeOf(file.bytes) })),
1012
1275
  limits
1013
1276
  );
1014
1277
  if (refusal) throw refusal;
@@ -1049,13 +1312,24 @@ const HEAD_BYTES = 64 * 1024;
1049
1312
  const TITLE_SEPARATORS = ["|", "·", "—", "–", " - "];
1050
1313
  function suggestSiteName(files) {
1051
1314
  const index = files.find((file) => file.path === "index.html");
1052
- if (index === void 0) return null;
1315
+ if (index === void 0 || !ArrayBuffer.isView(index.bytes)) return null;
1053
1316
  try {
1054
1317
  return nameFromHtml(decodeHead(index.bytes));
1055
1318
  } catch {
1056
1319
  return null;
1057
1320
  }
1058
1321
  }
1322
+ async function readSiteName(files) {
1323
+ const index = files.find((file) => file.path === "index.html");
1324
+ if (index === void 0) return null;
1325
+ if (ArrayBuffer.isView(index.bytes)) return suggestSiteName(files);
1326
+ try {
1327
+ const head = new Uint8Array(await index.bytes.slice(0, HEAD_BYTES).arrayBuffer());
1328
+ return suggestSiteName([{ path: index.path, bytes: head }]);
1329
+ } catch {
1330
+ return null;
1331
+ }
1332
+ }
1059
1333
  function decodeHead(bytes) {
1060
1334
  return new TextDecoder("utf-8", { fatal: false }).decode(
1061
1335
  bytes.subarray(0, HEAD_BYTES)
@@ -1087,7 +1361,7 @@ function matchTag(html, tag) {
1087
1361
  const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(
1088
1362
  html
1089
1363
  );
1090
- return match?.[1] ?? null;
1364
+ return match?.[1] === void 0 ? null : decodeEntities(match[1]);
1091
1365
  }
1092
1366
  function matchOgSiteName(html) {
1093
1367
  const patterns = [
@@ -1096,10 +1370,56 @@ function matchOgSiteName(html) {
1096
1370
  ];
1097
1371
  for (const pattern of patterns) {
1098
1372
  const match = pattern.exec(html);
1099
- if (match?.[1] !== void 0) return match[1];
1373
+ if (match?.[1] !== void 0) return decodeEntities(match[1]);
1100
1374
  }
1101
1375
  return null;
1102
1376
  }
1377
+ const LATIN1_ENTITIES = "nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr deg plusmn sup2 sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest Agrave Aacute Acirc Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml ETH Ntilde Ograve Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig agrave aacute acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute icirc iuml eth ntilde ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml yacute thorn yuml".split(" ");
1378
+ const NAMED_ENTITIES = new Map([
1379
+ ...LATIN1_ENTITIES.map((name, index) => [name, 160 + index]),
1380
+ ["amp", 38],
1381
+ ["lt", 60],
1382
+ ["gt", 62],
1383
+ ["quot", 34],
1384
+ ["apos", 39],
1385
+ ["ensp", 8194],
1386
+ ["emsp", 8195],
1387
+ ["thinsp", 8201],
1388
+ ["ndash", 8211],
1389
+ ["mdash", 8212],
1390
+ ["lsquo", 8216],
1391
+ ["rsquo", 8217],
1392
+ ["sbquo", 8218],
1393
+ ["ldquo", 8220],
1394
+ ["rdquo", 8221],
1395
+ ["bdquo", 8222],
1396
+ ["dagger", 8224],
1397
+ ["Dagger", 8225],
1398
+ ["bull", 8226],
1399
+ ["hellip", 8230],
1400
+ ["prime", 8242],
1401
+ ["lsaquo", 8249],
1402
+ ["rsaquo", 8250],
1403
+ ["euro", 8364],
1404
+ ["trade", 8482],
1405
+ ["larr", 8592],
1406
+ ["rarr", 8594],
1407
+ ["hearts", 9829]
1408
+ ]);
1409
+ function decodeEntities(text) {
1410
+ return text.replace(
1411
+ /&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{1,31}));/g,
1412
+ (whole, decimal, hex, name) => {
1413
+ if (name !== void 0) {
1414
+ const code2 = NAMED_ENTITIES.get(name);
1415
+ return code2 === void 0 ? whole : String.fromCodePoint(code2);
1416
+ }
1417
+ const code = decimal !== void 0 ? Number(decimal) : Number.parseInt(hex ?? "", 16);
1418
+ const valid = code > 0 && code <= 1114111 && (code < 55296 || code > 57343);
1419
+ return String.fromCodePoint(valid ? code : 65533);
1420
+ }
1421
+ );
1422
+ }
1103
1423
  const INVISIBLE = new RegExp(
1104
1424
  `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}${String.fromCharCode(8203)}-${String.fromCharCode(8207)}${String.fromCharCode(8232)}-${String.fromCharCode(8238)}${String.fromCharCode(8288)}-${String.fromCharCode(8292)}${String.fromCharCode(8294)}-${String.fromCharCode(8303)}${String.fromCharCode(65279)}]`,
1105
1425
  "g"
@@ -1141,46 +1461,105 @@ function trimSuffix(title) {
1141
1461
  return title;
1142
1462
  }
1143
1463
  const DEFAULT_CONCURRENCY = 8;
1464
+ const DEFAULT_BYTES_IN_FLIGHT = 64 * 1024 * 1024;
1144
1465
  const DEFAULT_RETRIES = 3;
1145
1466
  const BACKOFF_MS = [1e3, 2e3, 4e3];
1146
- async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {}) {
1147
- const doFetch = deps.fetch ?? globalFetch();
1467
+ const DEFAULT_STALL_MS = 3e4;
1468
+ const PROGRESS_INTERVAL_MS = 200;
1469
+ class StalledError extends Error {
1470
+ /** @param seconds How long nothing moved. */
1471
+ constructor(seconds) {
1472
+ super(`No data moved for ${seconds}s.`);
1473
+ this.name = "StalledError";
1474
+ }
1475
+ }
1476
+ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {}, onRetry) {
1477
+ const put = deps.put ?? defaultTransport(deps.fetch);
1148
1478
  const delay = deps.delay ?? defaultDelay;
1149
1479
  const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
1150
1480
  const retries = deps.retries ?? DEFAULT_RETRIES;
1481
+ const stallMs = deps.stallMs ?? DEFAULT_STALL_MS;
1482
+ const now = deps.now ?? Date.now;
1483
+ const maxBytesInFlight = deps.maxBytesInFlight ?? DEFAULT_BYTES_IN_FLIGHT;
1151
1484
  const byPath = new Map(files.map((file) => [file.path, file]));
1152
1485
  let nextIndex = 0;
1153
1486
  let done = 0;
1154
- let uploadedBytes = 0;
1487
+ let failed = false;
1488
+ let reserved = 0;
1489
+ let waiting = [];
1490
+ const acquire = async (size) => {
1491
+ while (reserved > 0 && reserved + size > maxBytesInFlight) {
1492
+ await new Promise((resolve) => waiting.push(resolve));
1493
+ }
1494
+ reserved += size;
1495
+ };
1496
+ const release = (size) => {
1497
+ reserved -= size;
1498
+ const woken = waiting;
1499
+ waiting = [];
1500
+ for (const wake of woken) wake();
1501
+ };
1502
+ let landedBytes = 0;
1503
+ const inFlight = /* @__PURE__ */ new Map();
1504
+ let lastReport = Number.NEGATIVE_INFINITY;
1505
+ const report2 = (path) => {
1506
+ if (onProgress === void 0) return;
1507
+ const at = now();
1508
+ if (path === void 0 && at - lastReport < PROGRESS_INTERVAL_MS) return;
1509
+ lastReport = at;
1510
+ let bytes = landedBytes;
1511
+ for (const sent of inFlight.values()) bytes += sent;
1512
+ onProgress(done, targets.length, bytes, path);
1513
+ };
1155
1514
  const worker = async () => {
1156
1515
  for (; ; ) {
1516
+ if (failed) return;
1157
1517
  const index = nextIndex++;
1158
1518
  const target = targets[index];
1159
1519
  if (target === void 0) return;
1160
1520
  const file = byPath.get(target.path);
1161
1521
  if (file === void 0) {
1522
+ failed = true;
1162
1523
  throw new DeployError(
1163
1524
  ClientErrorCode.UploadFailed,
1164
1525
  `The server asked for ${target.path}, which is not in the manifest.`,
1165
1526
  { path: target.path }
1166
1527
  );
1167
1528
  }
1168
- await uploadOne(target, file, {
1169
- doFetch,
1170
- delay,
1171
- retries,
1172
- signal,
1173
- uploadUrl
1174
- });
1529
+ const size = sizeOf(file.bytes);
1530
+ await acquire(size);
1531
+ try {
1532
+ if (failed) return;
1533
+ signal?.throwIfAborted();
1534
+ await uploadOne(target, file, {
1535
+ put,
1536
+ delay,
1537
+ retries,
1538
+ signal,
1539
+ uploadUrl,
1540
+ stallMs,
1541
+ onSent: (sent) => {
1542
+ inFlight.set(index, sent);
1543
+ report2();
1544
+ },
1545
+ onRetry: (attempt2, stalled) => {
1546
+ inFlight.set(index, 0);
1547
+ onRetry?.(target.path, attempt2, retries + 1, stalled);
1548
+ }
1549
+ });
1550
+ } catch (error) {
1551
+ failed = true;
1552
+ throw error;
1553
+ } finally {
1554
+ release(size);
1555
+ }
1556
+ inFlight.delete(index);
1175
1557
  done += 1;
1176
- uploadedBytes += file.bytes.length;
1177
- onProgress?.(done, targets.length, uploadedBytes, file.path);
1558
+ landedBytes += size;
1559
+ report2(file.path);
1178
1560
  }
1179
1561
  };
1180
- const workers = Array.from(
1181
- { length: Math.min(concurrency, targets.length) },
1182
- worker
1183
- );
1562
+ const workers = Array.from({ length: Math.min(concurrency, targets.length) }, worker);
1184
1563
  try {
1185
1564
  await Promise.all(workers);
1186
1565
  } catch (error) {
@@ -1192,39 +1571,122 @@ async function uploadOne(target, file, context) {
1192
1571
  for (let attempt2 = 0; attempt2 <= context.retries; attempt2++) {
1193
1572
  context.signal?.throwIfAborted();
1194
1573
  if (attempt2 > 0) {
1574
+ context.onRetry(attempt2 + 1, lastError instanceof StalledError);
1195
1575
  await context.delay(BACKOFF_MS[attempt2 - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
1196
1576
  context.signal?.throwIfAborted();
1197
1577
  }
1198
1578
  try {
1199
- const response = await context.doFetch(context.uploadUrl, {
1200
- method: "PUT",
1201
- body: new Blob([file.bytes.slice().buffer]),
1202
- // The permit is the whole request. `Content-Length` is required by the Worker and cannot
1203
- // be set here — it is a forbidden header name — but the browser derives it from the body,
1204
- // which is exactly the length the token names.
1205
- headers: { Authorization: `Bearer ${target.token}` },
1206
- ...context.signal ? { signal: context.signal } : {}
1579
+ const status = await context.put({
1580
+ url: context.uploadUrl,
1581
+ token: target.token,
1582
+ body: untyped(file.bytes),
1583
+ signal: context.signal,
1584
+ onSent: context.onSent,
1585
+ stallMs: context.stallMs
1207
1586
  });
1208
- if (response.ok) return;
1209
- if (response.status === 401 || response.status === 403 || response.status === 429) {
1587
+ if (status >= 200 && status < 300) return;
1588
+ if (status === 401 || status === 403 || status === 429) {
1210
1589
  throw new DeployError(
1211
1590
  ClientErrorCode.UploadFailed,
1212
1591
  `Upload of ${target.path} was refused. The upload window may have expired — try deploying again.`,
1213
- { path: target.path, status: response.status }
1592
+ { path: target.path, status }
1214
1593
  );
1215
1594
  }
1216
- lastError = new Error(`HTTP ${response.status}`);
1595
+ if (status === 413) {
1596
+ throw new DeployError(
1597
+ ClientErrorCode.UploadFailed,
1598
+ `${target.path} is too large to upload in one request. Make it smaller, or leave it out of this publish.`,
1599
+ { path: target.path, status }
1600
+ );
1601
+ }
1602
+ lastError = new Error(`HTTP ${status}`);
1217
1603
  } catch (error) {
1218
1604
  if (error instanceof DeployError) throw error;
1219
1605
  if (isAbort(error)) throw cancelled();
1220
1606
  lastError = error;
1221
1607
  }
1222
1608
  }
1223
- throw new DeployError(
1224
- ClientErrorCode.UploadFailed,
1225
- `Could not upload ${target.path} after ${context.retries + 1} attempts.`,
1226
- { path: target.path, cause: String(lastError) }
1227
- );
1609
+ const message = lastError instanceof StalledError ? `Upload of ${target.path} stopped moving and did not recover after ${context.retries + 1} attempts. Check your connection and try again.` : `Could not upload ${target.path} after ${context.retries + 1} attempts.`;
1610
+ throw new DeployError(ClientErrorCode.UploadFailed, message, {
1611
+ path: target.path,
1612
+ cause: describeError(lastError)
1613
+ });
1614
+ }
1615
+ function untyped(content) {
1616
+ if (ArrayBuffer.isView(content) || content.type === "") return content;
1617
+ return content.slice(0, content.size);
1618
+ }
1619
+ function defaultTransport(doFetch) {
1620
+ if (doFetch !== void 0) return fetchTransport(doFetch);
1621
+ const Xhr = globalThis.XMLHttpRequest;
1622
+ return Xhr === void 0 ? fetchTransport(globalFetch()) : xhrTransport(Xhr);
1623
+ }
1624
+ function fetchTransport(doFetch) {
1625
+ return async ({ url, token, body, signal }) => {
1626
+ const response = await doFetch(url, {
1627
+ method: "PUT",
1628
+ // The view, not a copy. Wrapping it in `new Blob([body.slice().buffer])` made two extra
1629
+ // full-size copies of every file in flight — eight at once, so up to 400 MB on a drop of
1630
+ // 50 MB files. `fetch` sends exactly the range a view covers. The cast is TypeScript's
1631
+ // SharedArrayBuffer distinction again; see `sha256Hex`. A `Blob` goes as it is, and the runtime
1632
+ // streams it from disk.
1633
+ body,
1634
+ // The permit is the whole request. `Content-Length` is required by the Worker and cannot be
1635
+ // set here — it is a forbidden header name — but the runtime derives it from the body, which
1636
+ // is exactly the length the token names.
1637
+ headers: { Authorization: `Bearer ${token}` },
1638
+ ...signal ? { signal } : {}
1639
+ });
1640
+ return response.status;
1641
+ };
1642
+ }
1643
+ function xhrTransport(Xhr) {
1644
+ return ({ url, token, body, signal, onSent, stallMs }) => new Promise((resolve, reject) => {
1645
+ const xhr = new Xhr();
1646
+ let settled = false;
1647
+ let timer;
1648
+ const settle = (finish) => {
1649
+ if (settled) return;
1650
+ settled = true;
1651
+ clearTimeout(timer);
1652
+ signal?.removeEventListener("abort", onAbort);
1653
+ finish();
1654
+ };
1655
+ const arm = () => {
1656
+ clearTimeout(timer);
1657
+ timer = setTimeout(() => {
1658
+ xhr.abort();
1659
+ settle(() => reject(new StalledError(Math.round(stallMs / 1e3))));
1660
+ }, stallMs);
1661
+ };
1662
+ function onAbort() {
1663
+ xhr.abort();
1664
+ settle(() => reject(abortReason(signal)));
1665
+ }
1666
+ if (signal?.aborted) {
1667
+ reject(abortReason(signal));
1668
+ return;
1669
+ }
1670
+ signal?.addEventListener("abort", onAbort, { once: true });
1671
+ xhr.open("PUT", url);
1672
+ xhr.setRequestHeader("Authorization", `Bearer ${token}`);
1673
+ xhr.upload.onprogress = (event) => {
1674
+ arm();
1675
+ onSent(event.loaded);
1676
+ };
1677
+ xhr.onprogress = arm;
1678
+ xhr.onload = () => settle(() => resolve(xhr.status));
1679
+ xhr.onerror = () => settle(() => reject(new TypeError("Network error during upload.")));
1680
+ arm();
1681
+ xhr.send(body);
1682
+ });
1683
+ }
1684
+ function abortReason(signal) {
1685
+ const reason = signal?.reason;
1686
+ if (reason instanceof Error) return reason;
1687
+ const error = new Error("Aborted.");
1688
+ error.name = "AbortError";
1689
+ return error;
1228
1690
  }
1229
1691
  function globalFetch() {
1230
1692
  return globalThis.fetch.bind(globalThis);
@@ -1238,9 +1700,17 @@ function isAbort(error) {
1238
1700
  function cancelled() {
1239
1701
  return new DeployError(ClientErrorCode.Cancelled, "Deploy cancelled.");
1240
1702
  }
1703
+ function outOfMemory(detail) {
1704
+ return new DeployError(
1705
+ ClientErrorCode.OutOfMemory,
1706
+ "This browser ran out of memory reading what you dropped. Drop the unzipped folder instead, or try again in a desktop browser with fewer tabs open.",
1707
+ detail
1708
+ );
1709
+ }
1241
1710
  function asDeployError(error) {
1242
1711
  if (error instanceof DeployError) return error;
1243
1712
  if (isAbort(error)) return cancelled();
1713
+ if (error instanceof RangeError) return outOfMemory(error.message);
1244
1714
  return new DeployError(
1245
1715
  ClientErrorCode.UploadFailed,
1246
1716
  error instanceof Error ? error.message : "Upload failed.",
@@ -1249,7 +1719,11 @@ function asDeployError(error) {
1249
1719
  }
1250
1720
  async function deploy(source, siteId, onProgress, signal, options = {}) {
1251
1721
  try {
1252
- const collected = await source(signal);
1722
+ onProgress({ type: "reading", bytes: 0, total: 0 });
1723
+ const collected = await source(
1724
+ signal,
1725
+ (bytes, total) => onProgress({ type: "reading", bytes, total })
1726
+ );
1253
1727
  onProgress({ type: "collecting", files: collected.length });
1254
1728
  const rename = renameOfLoneHtmlPage(collected.map((file) => file.path));
1255
1729
  const files = rename === null ? collected : collected.map((file) => file.path === rename ? { ...file, path: "index.html" } : file);
@@ -1265,6 +1739,21 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1265
1739
  onProgress({ type: "unchanged", url: prepared.url ?? "" });
1266
1740
  return;
1267
1741
  }
1742
+ const sizes = new Map(hashed.map((file) => [file.path, sizeOf(file.bytes)]));
1743
+ const totalBytes = prepared.upload.reduce(
1744
+ (sum, target) => sum + (sizes.get(target.path) ?? 0),
1745
+ 0
1746
+ );
1747
+ if (prepared.upload.length > 0) {
1748
+ onProgress({
1749
+ type: "uploading",
1750
+ done: prepared.reused,
1751
+ total: prepared.total,
1752
+ bytes: 0,
1753
+ totalBytes,
1754
+ reused: prepared.reused
1755
+ });
1756
+ }
1268
1757
  await uploadAll(
1269
1758
  prepared.upload,
1270
1759
  prepared.uploadUrl,
@@ -1274,11 +1763,13 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1274
1763
  done: prepared.reused + done,
1275
1764
  total: prepared.total,
1276
1765
  bytes,
1766
+ totalBytes,
1277
1767
  reused: prepared.reused,
1278
- path
1768
+ ...path === void 0 ? {} : { path }
1279
1769
  }),
1280
1770
  signal,
1281
- options.upload
1771
+ options.upload,
1772
+ (path, attempt2, attempts, stalled) => onProgress({ type: "retrying", path, attempt: attempt2, attempts, stalled })
1282
1773
  );
1283
1774
  onProgress({ type: "completing", reused: prepared.reused });
1284
1775
  const completed = await completeDeploy(
@@ -1286,9 +1777,15 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1286
1777
  prepared.deployId,
1287
1778
  options,
1288
1779
  signal,
1289
- suggestSiteName(files)
1780
+ await readSiteName(files)
1290
1781
  );
1291
- onProgress({ type: "done", url: completed.url, name: completed.name });
1782
+ onProgress({
1783
+ type: "done",
1784
+ url: completed.url,
1785
+ name: completed.name,
1786
+ mode: servingModeOf(completed),
1787
+ modeIsManual: completed.modeIsManual
1788
+ });
1292
1789
  } catch (error) {
1293
1790
  const failure = asDeployError(error);
1294
1791
  onProgress({
@@ -1341,6 +1838,15 @@ function escapesRoot(root, candidate) {
1341
1838
  const inside = relative(root, candidate);
1342
1839
  return inside === "" || inside.startsWith("..");
1343
1840
  }
1841
+ function describeMode(result) {
1842
+ if (result.mode === null) return null;
1843
+ if (result.nestedIndex !== null) {
1844
+ 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.`;
1845
+ }
1846
+ if (result.mode === "docs") return "Served as documents, in the documents reader.";
1847
+ if (result.mode === "spa") return "Served as a single-page app.";
1848
+ return "Served as a static site.";
1849
+ }
1344
1850
  async function resolveSite(credentials, site, newSite) {
1345
1851
  const { subdomain, folder } = newSite;
1346
1852
  if (site !== void 0 && subdomain !== void 0) {
@@ -1350,7 +1856,7 @@ async function resolveSite(credentials, site, newSite) {
1350
1856
  }
1351
1857
  if (site !== void 0 && folder !== void 0) {
1352
1858
  throw new Error(
1353
- "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."
1859
+ "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`."
1354
1860
  );
1355
1861
  }
1356
1862
  if (site === void 0) {
@@ -1370,6 +1876,8 @@ async function publish(credentials, source, site, onProgress, newSite = {}) {
1370
1876
  let files = 0;
1371
1877
  let url = target.url;
1372
1878
  let unchanged = false;
1879
+ let mode = null;
1880
+ let paths = [];
1373
1881
  let failure = null;
1374
1882
  let failureDetail;
1375
1883
  const record = (event) => {
@@ -1378,13 +1886,21 @@ async function publish(credentials, source, site, onProgress, newSite = {}) {
1378
1886
  unchanged = true;
1379
1887
  url = event.url || target.url;
1380
1888
  }
1381
- if (event.type === "done") url = event.url;
1889
+ if (event.type === "done") {
1890
+ url = event.url;
1891
+ mode = event.mode;
1892
+ }
1382
1893
  if (event.type === "error") {
1383
1894
  failure = event.message;
1384
1895
  failureDetail = event.detail;
1385
1896
  }
1386
1897
  };
1387
- await deploy(source, target.siteId, record, void 0, {
1898
+ const watched = async (signal, onRead) => {
1899
+ const collected = await source(signal, onRead);
1900
+ paths = collected.map((file) => file.path);
1901
+ return collected;
1902
+ };
1903
+ await deploy(watched, target.siteId, record, void 0, {
1388
1904
  baseUrl: credentials.apiBaseUrl,
1389
1905
  token: credentials.token
1390
1906
  }).catch((error) => {
@@ -1394,7 +1910,15 @@ async function publish(credentials, source, site, onProgress, newSite = {}) {
1394
1910
  thrown.detail = failureDetail ?? error?.detail;
1395
1911
  throw thrown;
1396
1912
  });
1397
- return { url, siteId: target.siteId, subdomain: target.subdomain, files, unchanged };
1913
+ return {
1914
+ url,
1915
+ siteId: target.siteId,
1916
+ subdomain: target.subdomain,
1917
+ files,
1918
+ unchanged,
1919
+ mode,
1920
+ nestedIndex: mode === "docs" ? nestedIndexOf(paths) : null
1921
+ };
1398
1922
  }
1399
1923
  async function publishDirectory(credentials, directory, site, onProgress, newSite = {}) {
1400
1924
  const found = await stat(directory).catch(() => null);
@@ -1603,7 +2127,7 @@ async function signInWithCode(waitSeconds, replace = false) {
1603
2127
  "Call this tool again to keep waiting. Nothing has been changed yet."
1604
2128
  ].join("\n");
1605
2129
  }
1606
- const SERVER_VERSION = "0.7.0";
2130
+ const SERVER_VERSION = "0.9.0";
1607
2131
  const CREDENTIAL_WRITE = {
1608
2132
  readOnlyHint: false,
1609
2133
  destructiveHint: false,
@@ -1635,6 +2159,83 @@ const REMOVES_A_SITE = {
1635
2159
  idempotentHint: false,
1636
2160
  openWorldHint: true
1637
2161
  };
2162
+ const CHANGES_A_SITE = {
2163
+ readOnlyHint: false,
2164
+ destructiveHint: true,
2165
+ idempotentHint: true,
2166
+ openWorldHint: true
2167
+ };
2168
+ const RESTORES_A_SITE = {
2169
+ readOnlyHint: false,
2170
+ destructiveHint: false,
2171
+ idempotentHint: true,
2172
+ openWorldHint: true
2173
+ };
2174
+ const SITE_INPUT = z.string().min(1).describe("Subdomain or site id, from list_sites.");
2175
+ const VERSION_OUTPUT = z.object({
2176
+ deployId: z.string().describe("Identifier of the version, which rollback_site takes."),
2177
+ status: z.string().describe(
2178
+ "`live` for the one being served, `superseded` for one served before, `ready` for one that finished but was never served, `failed`, or a stage still under way. Whether it can be rolled back to is `filesKept`, not this."
2179
+ ),
2180
+ fileCount: z.number().int().describe("How many files it carries."),
2181
+ createdAt: z.string().describe("When it was published, ISO 8601."),
2182
+ filesKept: z.boolean().describe(
2183
+ "Whether its files are still stored; a version without them cannot be rolled back to."
2184
+ ),
2185
+ live: z.boolean().describe("Whether this is the version being served.")
2186
+ });
2187
+ const SETTINGS_OUTPUT = {
2188
+ siteId: z.string().describe("Identifier of the site."),
2189
+ name: z.string().nullable().describe("What it is called, or null."),
2190
+ mode: z.enum(["static", "spa", "docs"]).describe("How it serves its files."),
2191
+ passwordProtected: z.boolean().describe("Whether visitors must type a password."),
2192
+ invitedOnly: z.boolean().nullable().describe(
2193
+ "Whether only invited people can open it, or null when that could not be read. Null does not mean public."
2194
+ ),
2195
+ formsEnabled: z.boolean().describe("Whether it accepts form submissions."),
2196
+ expiresAt: z.string().nullable().describe("When it is scheduled to come down, or null."),
2197
+ expiryAction: z.string().nullable().describe("What the takedown does: `pause` or `delete`, or null when none is scheduled."),
2198
+ folderId: z.string().nullable().describe("The folder it is filed in, or null at the top level.")
2199
+ };
2200
+ const PAUSE_OUTPUT = {
2201
+ siteId: z.string().describe("Identifier of the site."),
2202
+ subdomain: z.string().describe("Its subdomain."),
2203
+ status: z.string().describe("`active` or `paused` afterwards."),
2204
+ ownerPaused: z.boolean().describe("Whether the pause is the owner's own.")
2205
+ };
2206
+ async function folderPathOf(credentials, folderId) {
2207
+ if (folderId === null) return void 0;
2208
+ return (await listFolders(credentials)).find((folder) => folder.folderId === folderId)?.path;
2209
+ }
2210
+ function structuredSite(site) {
2211
+ return {
2212
+ siteId: site.siteId,
2213
+ subdomain: site.subdomain,
2214
+ url: site.url,
2215
+ name: site.name,
2216
+ status: site.status,
2217
+ mode: servingModeOf$1(site),
2218
+ passwordProtected: site.passwordProtected,
2219
+ invitedOnly: site.invitedOnly,
2220
+ formsEnabled: site.formsEnabled,
2221
+ expiresAt: site.expiresAt,
2222
+ expiryAction: site.expiryAction,
2223
+ folderId: site.folderId,
2224
+ available: {
2225
+ password: site.passwordProtectionAvailable,
2226
+ forms: site.formsAvailable,
2227
+ scheduledTakedown: site.scheduledExpiryAvailable
2228
+ },
2229
+ versions: site.deploys.map((deploy2) => ({
2230
+ deployId: deploy2.deployId,
2231
+ status: deploy2.status,
2232
+ fileCount: deploy2.fileCount,
2233
+ createdAt: deploy2.createdAt,
2234
+ filesKept: deploy2.filesKept,
2235
+ live: deploy2.deployId === site.liveDeployId
2236
+ }))
2237
+ };
2238
+ }
1638
2239
  const SITE_OUTPUT = z.object({
1639
2240
  siteId: z.string().describe("Identifier of the site."),
1640
2241
  subdomain: z.string().describe("Its subdomain, which is what `site` accepts."),
@@ -1648,7 +2249,7 @@ const FOLDER_OUTPUT = z.object({
1648
2249
  )
1649
2250
  });
1650
2251
  const FOLDER_INPUT = z.string().optional().describe(
1651
- "Folder to file the new site in, when the person asked for one: its path of names such as Clients/Acme, or its id from list_folders. Only for a new site — do not pass it together with `site`; moving an existing site is done in the dashboard. It never creates a folder: a folder that does not exist is refused with the list of those that do, and nothing is published."
2252
+ "Folder to file the new site in, when the person asked for one: its path of names such as Clients/Acme, or its id from list_folders. Only for a new site — do not pass it together with `site`; moving an existing site is update_site's `folder`. It never creates a folder: a folder that does not exist is refused with the list of those that do, and nothing is published."
1652
2253
  );
1653
2254
  function say(text, isError = false) {
1654
2255
  return { content: [{ type: "text", text }], ...isError ? { isError: true } : {} };
@@ -1671,11 +2272,13 @@ async function attempt(work) {
1671
2272
  }
1672
2273
  function describe(result) {
1673
2274
  const what = result.unchanged ? "Already live — every file matched what the site already serves, so no new version was published." : `Published ${result.files.toLocaleString()} ${result.files === 1 ? "file" : "files"}.`;
2275
+ const mode = describeMode(result);
1674
2276
  return [
1675
2277
  what,
1676
2278
  "",
1677
2279
  result.url,
1678
2280
  "",
2281
+ ...mode === null ? [] : [mode, ""],
1679
2282
  `Site ${result.subdomain} (${result.siteId}). Reaching every edge takes up to about a minute.`
1680
2283
  ].join("\n");
1681
2284
  }
@@ -1715,7 +2318,14 @@ function createServer() {
1715
2318
  "",
1716
2319
  "delete_site is permanent and frees the subdomain for anybody to claim. Ask the person for",
1717
2320
  "the subdomain and pass what they say as `confirm` — do not fill it in from what you already",
1718
- "know, because being told it is the point of the step."
2321
+ "know, because being told it is the point of the step.",
2322
+ "",
2323
+ "An existing site's settings are read with get_site and changed with update_site: its name,",
2324
+ "serving mode (static, spa, docs), a password, whether forms accept submissions, a scheduled",
2325
+ "takedown, and its folder. pause_site and resume_site take it off the air and back without",
2326
+ "deleting anything, and rollback_site serves an earlier version from get_site's list. Use",
2327
+ "a password or a date only when the person gave one, and scheduling a deletion asks for",
2328
+ "`confirm` the way delete_site does."
1719
2329
  ].join("\n")
1720
2330
  }
1721
2331
  );
@@ -1866,6 +2476,171 @@ function createServer() {
1866
2476
  return report(text, { folders: folders.map((folder) => ({ ...folder })) });
1867
2477
  })
1868
2478
  );
2479
+ server.registerTool(
2480
+ "get_site",
2481
+ {
2482
+ title: "Read a site's settings",
2483
+ description: "Reads one site's settings and state — serving mode, password, forms, scheduled takedown, folder, whether it is paused — and its versions, newest first, with the one being served marked. Also says which settings the account's plan does not include.",
2484
+ inputSchema: { site: SITE_INPUT },
2485
+ outputSchema: {
2486
+ ...SETTINGS_OUTPUT,
2487
+ subdomain: z.string().describe("Its subdomain."),
2488
+ url: z.string().describe("Its live URL."),
2489
+ status: z.string().describe("`active`, `paused`, or a state the platform put it in."),
2490
+ available: z.object({
2491
+ password: z.boolean(),
2492
+ forms: z.boolean(),
2493
+ scheduledTakedown: z.boolean()
2494
+ }).describe("Which settings the account's plan includes."),
2495
+ versions: z.array(VERSION_OUTPUT).describe("Its versions, newest first.")
2496
+ },
2497
+ annotations: READS_ONLY
2498
+ },
2499
+ ({ site }) => withCredentials(async (credentials) => {
2500
+ const found = await findSite(credentials, site);
2501
+ const detail = await getSite(credentials, found.siteId);
2502
+ return report(
2503
+ describeSite(detail, await folderPathOf(credentials, detail.folderId)),
2504
+ structuredSite(detail)
2505
+ );
2506
+ })
2507
+ );
2508
+ server.registerTool(
2509
+ "update_site",
2510
+ {
2511
+ title: "Change a site's settings",
2512
+ description: "Changes one or more settings of an existing site; anything left out stays as it is. Takes effect on every edge within about a minute, without publishing again. Settings the plan does not include are refused with the reason. Only change what the person asked for — never invent a password or a date.",
2513
+ inputSchema: {
2514
+ site: SITE_INPUT,
2515
+ name: z.string().optional().describe("What to call the site in the dashboard. An empty string clears it."),
2516
+ mode: z.enum(["static", "spa", "docs"]).optional().describe(
2517
+ "How the site serves: `static` answers 404 for a missing path, `spa` falls back to index.html for a client-side router, `docs` reads documents through the viewer. Setting it stops the mode being detected on each publish."
2518
+ ),
2519
+ password: z.string().optional().describe(
2520
+ "A password visitors must type before seeing the site, as the person gave it. An empty string removes the password. On an invite-only site a password replaces invite-only and shuts out everyone on its list, so check get_site's `invitedOnly` and ask first."
2521
+ ),
2522
+ forms: z.boolean().optional().describe("Whether the site's forms (marked data-drop2run) accept submissions."),
2523
+ expiresAt: z.string().optional().describe(
2524
+ "When the site should come down, as an ISO 8601 instant in the future such as 2026-12-01T09:00:00Z. An empty string cancels a scheduled takedown."
2525
+ ),
2526
+ expiryAction: z.enum(["pause", "delete"]).optional().describe(
2527
+ "What the takedown does, read only with `expiresAt`: `pause` (the default) keeps the files and can be resumed; `delete` removes the site and frees its subdomain, and needs `confirm`."
2528
+ ),
2529
+ folder: z.string().optional().describe(
2530
+ "Folder to move the site to — a path such as Clients/Acme or an id from list_folders — or `root` for the top level. Never creates a folder."
2531
+ ),
2532
+ confirm: z.string().optional().describe(
2533
+ "Required only with `expiryAction: delete`: the site's subdomain, repeated exactly as the person said it. Ask for it rather than filling it in."
2534
+ )
2535
+ },
2536
+ outputSchema: SETTINGS_OUTPUT,
2537
+ annotations: CHANGES_A_SITE
2538
+ },
2539
+ ({ site, name, mode, password, forms, expiresAt, expiryAction, folder, confirm }) => withCredentials(async (credentials) => {
2540
+ const found = await findSite(credentials, site);
2541
+ if (expiryAction !== void 0 && expiresAt === void 0) {
2542
+ throw new Error("`expiryAction` only applies with `expiresAt` — send the date too.");
2543
+ }
2544
+ if (expiryAction === "delete" && expiresAt !== "" && confirm?.trim().toLowerCase() !== found.subdomain.toLowerCase()) {
2545
+ throw new Error(
2546
+ `Refusing to schedule ${found.subdomain} for deletion: \`confirm\` said "${confirm ?? ""}". Repeat the subdomain exactly to go ahead, or use \`expiryAction: pause\`, which keeps the files.`
2547
+ );
2548
+ }
2549
+ const change = {
2550
+ name,
2551
+ mode,
2552
+ password,
2553
+ expiresAt,
2554
+ // Dropped on a cancellation: the API reads an action only with a date, and sending
2555
+ // `delete` alongside an empty date would still run its owner-only check for nothing.
2556
+ expiryAction: expiresAt === "" ? void 0 : expiryAction,
2557
+ formsEnabled: forms,
2558
+ folderId: folder === void 0 ? void 0 : await resolveFolderTarget(credentials, folder)
2559
+ };
2560
+ const settings = await updateSiteSettings(credentials, found.siteId, change);
2561
+ const lines = [
2562
+ `Updated ${found.subdomain}.`,
2563
+ ` access ${accessOf(settings)}`,
2564
+ ` mode ${servingModeOf$1(settings)}`,
2565
+ ` forms ${settings.formsEnabled ? "on" : "off"}`,
2566
+ ` takedown ${settings.expiresAt === null ? "none" : `${settings.expiresAt} (${settings.expiryAction})`}`,
2567
+ ...settings.replacedInviteOnly ? [
2568
+ "The password replaced invite-only: the people on its list can no longer get in without it. The list is kept for switching back in the dashboard."
2569
+ ] : [],
2570
+ ...settings.live ? ["Reaching every edge takes up to about a minute."] : ["Nothing is published yet, so this applies from the first publish."]
2571
+ ];
2572
+ return report(lines.join("\n"), {
2573
+ siteId: settings.siteId,
2574
+ name: settings.name,
2575
+ mode: servingModeOf$1(settings),
2576
+ passwordProtected: settings.passwordProtected,
2577
+ invitedOnly: settings.invitedOnly,
2578
+ formsEnabled: settings.formsEnabled,
2579
+ expiresAt: settings.expiresAt,
2580
+ expiryAction: settings.expiryAction,
2581
+ folderId: settings.folderId
2582
+ });
2583
+ })
2584
+ );
2585
+ server.registerTool(
2586
+ "pause_site",
2587
+ {
2588
+ title: "Pause a site",
2589
+ description: "Takes a site off the air without deleting anything: visitors see that it is paused, and its files, versions and subdomain are kept. resume_site puts it back.",
2590
+ inputSchema: { site: SITE_INPUT },
2591
+ outputSchema: PAUSE_OUTPUT,
2592
+ annotations: CHANGES_A_SITE
2593
+ },
2594
+ ({ site }) => withCredentials(async (credentials) => {
2595
+ const found = await findSite(credentials, site);
2596
+ const state = await setSitePaused(credentials, found.siteId, "pause");
2597
+ return report(
2598
+ state.status === "paused" ? `Paused ${state.subdomain}. Its files and subdomain are kept; resume_site puts it back.` : `${state.subdomain} is ${state.status}, not paused by its owner, so resume_site cannot bring it back. The pause is recorded and applies if that is lifted.`,
2599
+ { ...state }
2600
+ );
2601
+ })
2602
+ );
2603
+ server.registerTool(
2604
+ "resume_site",
2605
+ {
2606
+ title: "Resume a paused site",
2607
+ description: "Puts a paused site back on the air. Refused, with the reason, when the plan has no room for another active site, the account is past due, or the site was suspended by Drop2Run.",
2608
+ inputSchema: { site: SITE_INPUT },
2609
+ outputSchema: PAUSE_OUTPUT,
2610
+ annotations: RESTORES_A_SITE
2611
+ },
2612
+ ({ site }) => withCredentials(async (credentials) => {
2613
+ const found = await findSite(credentials, site);
2614
+ const state = await setSitePaused(credentials, found.siteId, "resume");
2615
+ return report(`${state.subdomain} is back on the air.
2616
+ ${found.url}`, { ...state });
2617
+ })
2618
+ );
2619
+ server.registerTool(
2620
+ "rollback_site",
2621
+ {
2622
+ title: "Roll a site back",
2623
+ description: "Serves an earlier version of a site again, without publishing anything. get_site lists the versions; one whose files were collected cannot be rolled back to.",
2624
+ inputSchema: {
2625
+ site: SITE_INPUT,
2626
+ deployId: z.string().min(1).describe("The version to serve, from get_site's `versions`.")
2627
+ },
2628
+ outputSchema: {
2629
+ deployId: z.string().describe("The version now being served."),
2630
+ url: z.string().describe("The URL it is served on.")
2631
+ },
2632
+ annotations: CHANGES_A_SITE
2633
+ },
2634
+ ({ site, deployId }) => withCredentials(async (credentials) => {
2635
+ const found = await findSite(credentials, site);
2636
+ const promoted = await promoteDeploy(credentials, found.siteId, deployId);
2637
+ return report(
2638
+ `${found.subdomain} is back on ${promoted.deployId}. Reaching every edge takes up to about a minute.
2639
+ ${promoted.url}`,
2640
+ { deployId: promoted.deployId, url: promoted.url }
2641
+ );
2642
+ })
2643
+ );
1869
2644
  return server;
1870
2645
  }
1871
2646
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drop2run/mcp",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "MCP server that publishes what an AI made — a page, a document or a whole site — to Drop2Run and returns a live URL.",
5
5
  "mcpName": "run.dropto/drop2run",
6
6
  "license": "MIT",
@@ -46,14 +46,14 @@
46
46
  "build:mcpb": "node scripts/build-mcpb.mjs"
47
47
  },
48
48
  "dependencies": {
49
- "@modelcontextprotocol/sdk": "^1.30.0",
49
+ "@modelcontextprotocol/sdk": "^1.32.1",
50
50
  "zod": "^4.1.12"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@biomejs/biome": "^2.5.11",
54
54
  "@types/node": "^26.4.0",
55
55
  "typescript": "^5.9.3",
56
- "vitest": "^4.1.11",
57
- "vite": "^7.2.1"
56
+ "vite": "^7.2.1",
57
+ "vitest": "^4.1.11"
58
58
  }
59
59
  }