@drop2run/mcp 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +28 -4
  2. package/dist/index.js +585 -149
  3. package/package.json +1 -1
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
@@ -67,6 +85,7 @@ lose it.
67
85
  | `publish_files` | Publishes files Claude wrote — a page, a markdown note, several files together |
68
86
  | `publish_dir` | Publishes a folder, given its absolute path |
69
87
  | `list_sites` | Lists the sites on the account |
88
+ | `list_folders` | Lists the account's folders, each with the path `folder` accepts |
70
89
  | `delete_site` | Deletes a site permanently, subdomain included — requires the subdomain repeated as `confirm` |
71
90
 
72
91
  Both publish tools take an optional `site` — a subdomain or site id to publish over. Leave it out and a
@@ -78,6 +97,11 @@ afterwards, and a site made under a guessed one has to be deleted by hand. `site
78
97
  opposites — one publishes over a site that exists, the other creates one that does not — so passing both
79
98
  is refused rather than resolved.
80
99
 
100
+ And both take an optional `folder`, which files the new site in one of the account's dashboard folders —
101
+ by path such as `Clients/Acme`, or by the id `list_folders` shows. Like `subdomain` it only describes a new
102
+ site, so it cannot be combined with `site`. It never creates a folder: one that does not exist is refused
103
+ with the list of those that do, and nothing is published.
104
+
81
105
  Both sign-in tools answer "not approved yet" rather than failing while they wait, and calling them
82
106
  again keeps waiting on the same sign-in. Neither replaces a token that is already stored unless asked
83
107
  to with `replace`.
package/dist/index.js CHANGED
@@ -8,64 +8,13 @@ import { createServer as createServer$1 } from "node:http";
8
8
  import { realpath, readdir, stat, readFile } from "node:fs/promises";
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { z } from "zod";
11
- async function call(credentials, path, init = {}) {
12
- const response = await fetch(`${credentials.apiBaseUrl}/${path}`, {
13
- ...init,
14
- headers: {
15
- ...init.headers,
16
- Authorization: `Bearer ${credentials.token}`,
17
- ...init.body === void 0 ? {} : { "Content-Type": "application/json" }
18
- }
19
- });
20
- if (!response.ok) {
21
- const problem = await response.json().catch(() => null);
22
- throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
23
- }
24
- return await response.json();
25
- }
26
- async function findSite(credentials, site) {
27
- const wanted = site.trim().toLowerCase();
28
- const found = (await listSites(credentials)).find(
29
- (candidate) => candidate.siteId.toLowerCase() === wanted || candidate.subdomain.toLowerCase() === wanted
30
- );
31
- if (found === void 0) {
32
- throw new Error(`No site of yours is called "${site}". \`drop2run ls\` shows what exists.`);
33
- }
34
- return found;
35
- }
36
- async function deleteSite(credentials, siteId) {
37
- const response = await fetch(`${credentials.apiBaseUrl}/sites/${encodeURIComponent(siteId)}`, {
38
- method: "DELETE",
39
- headers: { Authorization: `Bearer ${credentials.token}` }
40
- });
41
- if (!response.ok) {
42
- const problem = await response.json().catch(() => null);
43
- throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
44
- }
45
- }
46
- async function listSites(credentials) {
47
- const body = await call(credentials, "sites");
48
- return body.sites;
49
- }
50
- async function createSite(credentials, subdomain) {
51
- const created = await call(
52
- credentials,
53
- "sites",
54
- {
55
- method: "POST",
56
- // An absent field rather than a null one, because the API reads null, empty and missing the
57
- // same way and a body carrying only what was asked for is the one that reads correctly in a
58
- // log.
59
- body: JSON.stringify(subdomain === void 0 ? {} : { subdomain })
60
- }
61
- );
62
- return { ...created, name: null };
63
- }
64
11
  const TOKEN_VARIABLE = "DROP2RUN_TOKEN";
65
12
  function configPath() {
66
13
  return join(homedir(), ".config", "drop2run", "config.json");
67
14
  }
68
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";
69
18
  function loadCredentials(env = process.env, read = (path) => readFileSync(path, "utf8")) {
70
19
  let stored = {};
71
20
  try {
@@ -89,7 +38,8 @@ function resolveApiBaseUrl(env = process.env, read = (path) => readFileSync(path
89
38
  return url.replace(/\/+$/, "");
90
39
  }
91
40
  function dashboardUrlFor(apiBaseUrl) {
92
- return apiBaseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
41
+ const origin = apiBaseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
42
+ return origin === PRODUCTION_SITE_ORIGIN ? PRODUCTION_DASHBOARD_ORIGIN : origin;
93
43
  }
94
44
  function saveToken(token, path = configPath()) {
95
45
  let existing = {};
@@ -116,14 +66,14 @@ function missingCredentialsMessage(surface = "cli") {
116
66
  ] : ["Run `drop2run login` to sign in through a browser, which stores one for you."];
117
67
  const byHand = surface === "mcp" ? [
118
68
  "To set one without signing in here, create a token at",
119
- "https://dropto.run/account/tokens and either:",
69
+ "https://app.dropto.run/account/tokens and either:",
120
70
  ` - put it in ${configPath()} as {"token": "d2r_..."}, which this`,
121
71
  " server reads on every call and needs no restart, or",
122
72
  ` - set ${TOKEN_VARIABLE} in the environment and restart this server, since it reads`,
123
73
  " the environment it was started in."
124
74
  ] : [
125
75
  "For CI, or where no browser can be opened, create a token at",
126
- "https://dropto.run/account/tokens and either:",
76
+ "https://app.dropto.run/account/tokens and either:",
127
77
  ` - set ${TOKEN_VARIABLE} in the environment, or`,
128
78
  ` - put it in ${configPath()} as {"token": "d2r_..."}`
129
79
  ];
@@ -137,6 +87,131 @@ function missingCredentialsMessage(surface = "cli") {
137
87
  "The token is shown once when it is created and cannot be recovered afterwards."
138
88
  ].join("\n");
139
89
  }
90
+ async function call(credentials, path, init = {}) {
91
+ const response = await fetch(`${credentials.apiBaseUrl}/${path}`, {
92
+ ...init,
93
+ headers: {
94
+ ...init.headers,
95
+ Authorization: `Bearer ${credentials.token}`,
96
+ ...init.body === void 0 ? {} : { "Content-Type": "application/json" }
97
+ }
98
+ });
99
+ if (!response.ok) {
100
+ const problem = await response.json().catch(() => null);
101
+ throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
102
+ }
103
+ return await response.json();
104
+ }
105
+ async function findSite(credentials, site) {
106
+ const found = await lookUpSite(credentials, site);
107
+ if (found === void 0) {
108
+ throw new Error(`No site of yours is called "${site}". \`drop2run ls\` shows what exists.`);
109
+ }
110
+ return found;
111
+ }
112
+ const SITE_ID_SHAPE = /^[0-9a-z]{26}$/i;
113
+ async function lookUpSite(credentials, site) {
114
+ const wanted = site.trim().toLowerCase();
115
+ if (wanted === "") return void 0;
116
+ const { sites } = await call(
117
+ credentials,
118
+ `sites?pageSize=${SITES_PER_REQUEST}&q=${encodeURIComponent(wanted)}`
119
+ );
120
+ const bySubdomain = sites.find((candidate) => candidate.subdomain.toLowerCase() === wanted);
121
+ if (bySubdomain !== void 0) return bySubdomain;
122
+ if (!SITE_ID_SHAPE.test(wanted)) return void 0;
123
+ const response = await fetch(
124
+ `${credentials.apiBaseUrl}/sites/${encodeURIComponent(wanted.toUpperCase())}`,
125
+ { headers: { Authorization: `Bearer ${credentials.token}` } }
126
+ );
127
+ if (response.status === 404) return void 0;
128
+ if (!response.ok) {
129
+ const problem = await response.json().catch(() => null);
130
+ throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
131
+ }
132
+ const detail = await response.json();
133
+ return { siteId: detail.siteId, subdomain: detail.subdomain, url: detail.url, name: detail.name };
134
+ }
135
+ async function deleteSite(credentials, siteId) {
136
+ const response = await fetch(`${credentials.apiBaseUrl}/sites/${encodeURIComponent(siteId)}`, {
137
+ method: "DELETE",
138
+ headers: { Authorization: `Bearer ${credentials.token}` }
139
+ });
140
+ if (!response.ok) {
141
+ const problem = await response.json().catch(() => null);
142
+ throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
143
+ }
144
+ }
145
+ const SITES_PER_REQUEST = 100;
146
+ async function listSites(credentials) {
147
+ const body = await call(
148
+ credentials,
149
+ `sites?pageSize=${SITES_PER_REQUEST}`
150
+ );
151
+ return { sites: body.sites, total: body.total };
152
+ }
153
+ async function createSite(credentials, subdomain, folderId) {
154
+ const created = await call(
155
+ credentials,
156
+ "sites",
157
+ {
158
+ method: "POST",
159
+ // Absent fields rather than null ones, because the API reads null, empty and missing the same
160
+ // way and a body carrying only what was asked for is the one that reads correctly in a log.
161
+ body: JSON.stringify({
162
+ ...subdomain === void 0 ? {} : { subdomain },
163
+ ...folderId === void 0 ? {} : { folderId }
164
+ })
165
+ }
166
+ );
167
+ return { ...created, name: null };
168
+ }
169
+ async function listFolders(credentials) {
170
+ const body = await call(credentials, "site-folders");
171
+ const byId = new Map(body.folders.map((folder) => [folder.folderId, folder]));
172
+ const pathOf = (folderId) => {
173
+ const names = [];
174
+ let cursor = byId.get(folderId);
175
+ while (cursor !== void 0 && names.length <= byId.size) {
176
+ names.unshift(cursor.name);
177
+ cursor = cursor.parentId === null ? void 0 : byId.get(cursor.parentId);
178
+ }
179
+ return names.join("/");
180
+ };
181
+ return body.folders.map((folder) => ({ folderId: folder.folderId, path: pathOf(folder.folderId) })).sort((a, b) => a.path.localeCompare(b.path));
182
+ }
183
+ function comparablePath(path) {
184
+ return path.split("/").map((name) => name.trim()).filter((name) => name !== "").join("/").toLowerCase();
185
+ }
186
+ const FOLDERS_LISTED_ON_MISS = 20;
187
+ async function resolveFolder(credentials, folder) {
188
+ const wanted = comparablePath(folder);
189
+ if (wanted === "") {
190
+ throw new Error(
191
+ `"${folder}" does not name a folder. Leave the folder out to put the site at the top level.`
192
+ );
193
+ }
194
+ const folders = await listFolders(credentials);
195
+ const byId = folders.find((candidate) => candidate.folderId.toLowerCase() === wanted);
196
+ if (byId !== void 0) return byId.folderId;
197
+ const matches = folders.filter((candidate) => comparablePath(candidate.path) === wanted);
198
+ if (matches.length === 1 && matches[0] !== void 0) return matches[0].folderId;
199
+ if (matches.length > 1) {
200
+ throw new Error(
201
+ `More than one folder is at "${folder}", because a folder name can contain "/". Name it by id instead: ${matches.map((match) => match.folderId).join(", ")}.`
202
+ );
203
+ }
204
+ if (folders.length === 0) {
205
+ throw new Error(
206
+ `No folder of yours is called "${folder}" — this account has no folders yet. Make one in the dashboard at ${dashboardUrlFor(credentials.apiBaseUrl)}, or leave the folder out.`
207
+ );
208
+ }
209
+ const listed = folders.slice(0, FOLDERS_LISTED_ON_MISS).map((candidate) => candidate.path);
210
+ const more = folders.length - listed.length;
211
+ throw new Error(
212
+ `No folder of yours is called "${folder}". Your folders: ${listed.join(", ")}${more > 0 ? `, and ${more} more` : ""}.`
213
+ );
214
+ }
140
215
  function newAttempt() {
141
216
  const verifier = randomBytes(32).toString("base64url");
142
217
  return {
@@ -272,6 +347,12 @@ async function exchange(apiBaseUrl, code, verifier) {
272
347
  const body = await response.json();
273
348
  return { token: body.token, name: body.name, email: body.email };
274
349
  }
350
+ function sizeOf(content) {
351
+ return ArrayBuffer.isView(content) ? content.byteLength : content.size;
352
+ }
353
+ async function bytesOf(content) {
354
+ return ArrayBuffer.isView(content) ? content : new Uint8Array(await content.arrayBuffer());
355
+ }
275
356
  class DeployError extends Error {
276
357
  /**
277
358
  * @param code Stable code, either an API error type (`quota_exceeded`, `unsafe_path`, …) or one of
@@ -308,20 +389,55 @@ const ClientErrorCode = {
308
389
  /** The user cancelled. */
309
390
  Cancelled: "cancelled",
310
391
  /** An upload kept failing after every retry. */
311
- UploadFailed: "upload_failed"
392
+ UploadFailed: "upload_failed",
393
+ /**
394
+ * The browser could not hold what was dropped in memory. Raised when allocating the buffer for a
395
+ * large zip or file fails, which a browser reports as a `RangeError` rather than as anything that
396
+ * names memory.
397
+ */
398
+ OutOfMemory: "out_of_memory",
399
+ /**
400
+ * A dropped file could not be read when it came to be hashed. Files from a folder stay on disk until
401
+ * then, so one moved, deleted or changed after the drop fails here rather than at the drop.
402
+ */
403
+ ReadFailed: "read_failed",
404
+ /**
405
+ * A control-plane call got no answer: the connection failed before a response arrived, or the
406
+ * request timeout passed. Distinct from an HTTP error, which is an answer — this one says nothing
407
+ * about whether the server acted, which is why only an idempotent call may retry on it.
408
+ */
409
+ NetworkFailed: "network_failed"
312
410
  };
411
+ function describeError(error) {
412
+ const parts = [];
413
+ let current = error;
414
+ for (let depth = 0; depth < 5 && current !== void 0 && current !== null; depth++) {
415
+ if (current instanceof Error) {
416
+ const code = current.code;
417
+ parts.push(typeof code === "string" ? `${current.message} (${code})` : current.message);
418
+ current = current.cause;
419
+ } else {
420
+ parts.push(String(current));
421
+ break;
422
+ }
423
+ }
424
+ return parts.join(" ← ");
425
+ }
313
426
  const ACCOUNT_HEADER = "X-Drop2Run-Account";
314
427
  const CLAIM_TOKEN_HEADER = "X-Claim-Token";
428
+ const DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
429
+ const COMPLETE_RETRY_DELAYS_MS = [1e3, 4e3];
315
430
  async function prepareDeploy(siteId, files, options, signal) {
316
431
  const manifest = files.map((file) => ({
317
432
  path: file.path,
318
433
  sha256: file.sha256,
319
- size: file.bytes.length
434
+ size: sizeOf(file.bytes)
320
435
  }));
321
436
  const prepared = await request(
322
437
  `sites/${encodeURIComponent(siteId)}/deploys/prepare`,
323
438
  { files: manifest },
324
- options
439
+ options,
440
+ signal
325
441
  );
326
442
  return { ...prepared, uploadUrl: absolute(prepared.uploadUrl, options.baseUrl) };
327
443
  }
@@ -331,31 +447,65 @@ function absolute(uploadUrl, baseUrl) {
331
447
  return new URL(uploadUrl, baseUrl).toString();
332
448
  }
333
449
  async function completeDeploy(siteId, deployId, options, signal, name) {
334
- return request(
335
- `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`,
336
- // A bodyless POST when there is no name to send, which is what this call has always been. The
337
- // server treats an absent body and an absent field identically, so nothing depends on which of
338
- // the two a client picks.
339
- name === null || name === void 0 ? void 0 : { name },
340
- options
341
- );
450
+ const path = `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`;
451
+ const body = name === null || name === void 0 ? void 0 : { name };
452
+ const delay = options.delay ?? wait;
453
+ for (let attempt2 = 0; ; attempt2++) {
454
+ try {
455
+ return await request(path, body, options, signal);
456
+ } catch (error) {
457
+ const unanswered = error instanceof DeployError && error.code === ClientErrorCode.NetworkFailed;
458
+ if (!unanswered || attempt2 >= COMPLETE_RETRY_DELAYS_MS.length || signal?.aborted) throw error;
459
+ await delay(COMPLETE_RETRY_DELAYS_MS[attempt2] ?? 1e3);
460
+ }
461
+ }
462
+ }
463
+ function wait(ms) {
464
+ return new Promise((resolve) => setTimeout(resolve, ms));
342
465
  }
343
466
  async function request(path, body, options, signal) {
344
467
  const doFetch = options.fetch ?? globalFetch$1();
345
468
  const base = options.baseUrl ?? "/api";
469
+ const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
346
470
  const headers = {};
347
471
  if (body !== void 0) headers["Content-Type"] = "application/json";
348
472
  if (options.claimToken) headers[CLAIM_TOKEN_HEADER] = options.claimToken;
349
473
  if (options.token) headers.Authorization = `Bearer ${options.token}`;
350
474
  if (options.accountId) headers[ACCOUNT_HEADER] = options.accountId;
351
- const response = await doFetch(`${base}/${path}`, {
352
- method: "POST",
353
- headers,
354
- ...body === void 0 ? {} : { body: JSON.stringify(body) },
355
- ...{}
356
- });
357
- if (!response.ok) throw await problemToError(response);
358
- return await response.json();
475
+ const controller = new AbortController();
476
+ let timedOut = false;
477
+ const timer = setTimeout(() => {
478
+ timedOut = true;
479
+ controller.abort();
480
+ }, timeoutMs);
481
+ try {
482
+ const response = await doFetch(`${base}/${path}`, {
483
+ method: "POST",
484
+ headers,
485
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
486
+ signal: controller.signal
487
+ });
488
+ if (!response.ok) throw await problemToError(response);
489
+ return await response.json();
490
+ } catch (error) {
491
+ if (error instanceof DeployError || signal?.aborted) throw error;
492
+ if (timedOut) {
493
+ throw new DeployError(
494
+ ClientErrorCode.NetworkFailed,
495
+ `The server did not answer within ${Math.round(timeoutMs / 1e3)} seconds.`,
496
+ { path, cause: describeError(error) }
497
+ );
498
+ }
499
+ if (error instanceof TypeError) {
500
+ throw new DeployError(ClientErrorCode.NetworkFailed, "Could not reach the server.", {
501
+ path,
502
+ cause: describeError(error)
503
+ });
504
+ }
505
+ throw error;
506
+ } finally {
507
+ clearTimeout(timer);
508
+ }
359
509
  }
360
510
  function globalFetch$1() {
361
511
  return globalThis.fetch.bind(globalThis);
@@ -854,17 +1004,29 @@ function extensionOf(path) {
854
1004
  return dot <= 0 ? "" : name.slice(dot).toLowerCase();
855
1005
  }
856
1006
  async function sha256Hex(bytes) {
857
- const digest = await crypto.subtle.digest("SHA-256", bytes.slice().buffer);
1007
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
858
1008
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
859
1009
  }
860
1010
  async function hashAll(files, onProgress, signal) {
861
1011
  const hashed = [];
862
1012
  for (const [index, file] of files.entries()) {
863
- hashed.push({ ...file, sha256: await sha256Hex(file.bytes) });
1013
+ hashed.push({ ...file, sha256: await sha256Hex(await readForHash(file)) });
864
1014
  onProgress?.(index + 1, files.length);
865
1015
  }
866
1016
  return hashed;
867
1017
  }
1018
+ async function readForHash(file) {
1019
+ try {
1020
+ return await bytesOf(file.bytes);
1021
+ } catch (error) {
1022
+ if (error instanceof RangeError) throw error;
1023
+ throw new DeployError(
1024
+ ClientErrorCode.ReadFailed,
1025
+ `Could not read ${file.path}. It may have been moved, renamed or changed since you dropped it — drop the folder again.`,
1026
+ { path: file.path, cause: describeError(error) }
1027
+ );
1028
+ }
1029
+ }
868
1030
  const IGNORED_EXACT = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".gitignore", ".gitkeep"]);
869
1031
  const IGNORED_DIRECTORIES = [
870
1032
  "__MACOSX",
@@ -874,7 +1036,11 @@ const IGNORED_DIRECTORIES = [
874
1036
  ".hg",
875
1037
  ".idea",
876
1038
  ".vscode",
877
- ".claude"
1039
+ ".claude",
1040
+ ".agents",
1041
+ ".cursor",
1042
+ ".codex",
1043
+ ".gemini"
878
1044
  ];
879
1045
  function shouldIgnore(path) {
880
1046
  const segments = path.split("/");
@@ -936,7 +1102,7 @@ function checkLimits(files, limits) {
936
1102
  );
937
1103
  }
938
1104
  const refusal = sizeRefusal(
939
- files.map((file) => ({ path: file.path, size: file.bytes.length })),
1105
+ files.map((file) => ({ path: file.path, size: sizeOf(file.bytes) })),
940
1106
  limits
941
1107
  );
942
1108
  if (refusal) throw refusal;
@@ -977,13 +1143,24 @@ const HEAD_BYTES = 64 * 1024;
977
1143
  const TITLE_SEPARATORS = ["|", "·", "—", "–", " - "];
978
1144
  function suggestSiteName(files) {
979
1145
  const index = files.find((file) => file.path === "index.html");
980
- if (index === void 0) return null;
1146
+ if (index === void 0 || !ArrayBuffer.isView(index.bytes)) return null;
981
1147
  try {
982
1148
  return nameFromHtml(decodeHead(index.bytes));
983
1149
  } catch {
984
1150
  return null;
985
1151
  }
986
1152
  }
1153
+ async function readSiteName(files) {
1154
+ const index = files.find((file) => file.path === "index.html");
1155
+ if (index === void 0) return null;
1156
+ if (ArrayBuffer.isView(index.bytes)) return suggestSiteName(files);
1157
+ try {
1158
+ const head = new Uint8Array(await index.bytes.slice(0, HEAD_BYTES).arrayBuffer());
1159
+ return suggestSiteName([{ path: index.path, bytes: head }]);
1160
+ } catch {
1161
+ return null;
1162
+ }
1163
+ }
987
1164
  function decodeHead(bytes) {
988
1165
  return new TextDecoder("utf-8", { fatal: false }).decode(
989
1166
  bytes.subarray(0, HEAD_BYTES)
@@ -1015,7 +1192,7 @@ function matchTag(html, tag) {
1015
1192
  const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(
1016
1193
  html
1017
1194
  );
1018
- return match?.[1] ?? null;
1195
+ return match?.[1] === void 0 ? null : decodeEntities(match[1]);
1019
1196
  }
1020
1197
  function matchOgSiteName(html) {
1021
1198
  const patterns = [
@@ -1024,10 +1201,56 @@ function matchOgSiteName(html) {
1024
1201
  ];
1025
1202
  for (const pattern of patterns) {
1026
1203
  const match = pattern.exec(html);
1027
- if (match?.[1] !== void 0) return match[1];
1204
+ if (match?.[1] !== void 0) return decodeEntities(match[1]);
1028
1205
  }
1029
1206
  return null;
1030
1207
  }
1208
+ 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(" ");
1209
+ const NAMED_ENTITIES = new Map([
1210
+ ...LATIN1_ENTITIES.map((name, index) => [name, 160 + index]),
1211
+ ["amp", 38],
1212
+ ["lt", 60],
1213
+ ["gt", 62],
1214
+ ["quot", 34],
1215
+ ["apos", 39],
1216
+ ["ensp", 8194],
1217
+ ["emsp", 8195],
1218
+ ["thinsp", 8201],
1219
+ ["ndash", 8211],
1220
+ ["mdash", 8212],
1221
+ ["lsquo", 8216],
1222
+ ["rsquo", 8217],
1223
+ ["sbquo", 8218],
1224
+ ["ldquo", 8220],
1225
+ ["rdquo", 8221],
1226
+ ["bdquo", 8222],
1227
+ ["dagger", 8224],
1228
+ ["Dagger", 8225],
1229
+ ["bull", 8226],
1230
+ ["hellip", 8230],
1231
+ ["prime", 8242],
1232
+ ["lsaquo", 8249],
1233
+ ["rsaquo", 8250],
1234
+ ["euro", 8364],
1235
+ ["trade", 8482],
1236
+ ["larr", 8592],
1237
+ ["rarr", 8594],
1238
+ ["hearts", 9829]
1239
+ ]);
1240
+ function decodeEntities(text) {
1241
+ return text.replace(
1242
+ /&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{1,31}));/g,
1243
+ (whole, decimal, hex, name) => {
1244
+ if (name !== void 0) {
1245
+ const code2 = NAMED_ENTITIES.get(name);
1246
+ return code2 === void 0 ? whole : String.fromCodePoint(code2);
1247
+ }
1248
+ const code = decimal !== void 0 ? Number(decimal) : Number.parseInt(hex ?? "", 16);
1249
+ const valid = code > 0 && code <= 1114111 && (code < 55296 || code > 57343);
1250
+ return String.fromCodePoint(valid ? code : 65533);
1251
+ }
1252
+ );
1253
+ }
1031
1254
  const INVISIBLE = new RegExp(
1032
1255
  `[${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)}]`,
1033
1256
  "g"
@@ -1069,46 +1292,105 @@ function trimSuffix(title) {
1069
1292
  return title;
1070
1293
  }
1071
1294
  const DEFAULT_CONCURRENCY = 8;
1295
+ const DEFAULT_BYTES_IN_FLIGHT = 64 * 1024 * 1024;
1072
1296
  const DEFAULT_RETRIES = 3;
1073
1297
  const BACKOFF_MS = [1e3, 2e3, 4e3];
1074
- async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {}) {
1075
- const doFetch = deps.fetch ?? globalFetch();
1298
+ const DEFAULT_STALL_MS = 3e4;
1299
+ const PROGRESS_INTERVAL_MS = 200;
1300
+ class StalledError extends Error {
1301
+ /** @param seconds How long nothing moved. */
1302
+ constructor(seconds) {
1303
+ super(`No data moved for ${seconds}s.`);
1304
+ this.name = "StalledError";
1305
+ }
1306
+ }
1307
+ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {}, onRetry) {
1308
+ const put = deps.put ?? defaultTransport(deps.fetch);
1076
1309
  const delay = deps.delay ?? defaultDelay;
1077
1310
  const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
1078
1311
  const retries = deps.retries ?? DEFAULT_RETRIES;
1312
+ const stallMs = deps.stallMs ?? DEFAULT_STALL_MS;
1313
+ const now = deps.now ?? Date.now;
1314
+ const maxBytesInFlight = deps.maxBytesInFlight ?? DEFAULT_BYTES_IN_FLIGHT;
1079
1315
  const byPath = new Map(files.map((file) => [file.path, file]));
1080
1316
  let nextIndex = 0;
1081
1317
  let done = 0;
1082
- let uploadedBytes = 0;
1318
+ let failed = false;
1319
+ let reserved = 0;
1320
+ let waiting = [];
1321
+ const acquire = async (size) => {
1322
+ while (reserved > 0 && reserved + size > maxBytesInFlight) {
1323
+ await new Promise((resolve) => waiting.push(resolve));
1324
+ }
1325
+ reserved += size;
1326
+ };
1327
+ const release = (size) => {
1328
+ reserved -= size;
1329
+ const woken = waiting;
1330
+ waiting = [];
1331
+ for (const wake of woken) wake();
1332
+ };
1333
+ let landedBytes = 0;
1334
+ const inFlight = /* @__PURE__ */ new Map();
1335
+ let lastReport = Number.NEGATIVE_INFINITY;
1336
+ const report2 = (path) => {
1337
+ if (onProgress === void 0) return;
1338
+ const at = now();
1339
+ if (path === void 0 && at - lastReport < PROGRESS_INTERVAL_MS) return;
1340
+ lastReport = at;
1341
+ let bytes = landedBytes;
1342
+ for (const sent of inFlight.values()) bytes += sent;
1343
+ onProgress(done, targets.length, bytes, path);
1344
+ };
1083
1345
  const worker = async () => {
1084
1346
  for (; ; ) {
1347
+ if (failed) return;
1085
1348
  const index = nextIndex++;
1086
1349
  const target = targets[index];
1087
1350
  if (target === void 0) return;
1088
1351
  const file = byPath.get(target.path);
1089
1352
  if (file === void 0) {
1353
+ failed = true;
1090
1354
  throw new DeployError(
1091
1355
  ClientErrorCode.UploadFailed,
1092
1356
  `The server asked for ${target.path}, which is not in the manifest.`,
1093
1357
  { path: target.path }
1094
1358
  );
1095
1359
  }
1096
- await uploadOne(target, file, {
1097
- doFetch,
1098
- delay,
1099
- retries,
1100
- signal,
1101
- uploadUrl
1102
- });
1360
+ const size = sizeOf(file.bytes);
1361
+ await acquire(size);
1362
+ try {
1363
+ if (failed) return;
1364
+ signal?.throwIfAborted();
1365
+ await uploadOne(target, file, {
1366
+ put,
1367
+ delay,
1368
+ retries,
1369
+ signal,
1370
+ uploadUrl,
1371
+ stallMs,
1372
+ onSent: (sent) => {
1373
+ inFlight.set(index, sent);
1374
+ report2();
1375
+ },
1376
+ onRetry: (attempt2, stalled) => {
1377
+ inFlight.set(index, 0);
1378
+ onRetry?.(target.path, attempt2, retries + 1, stalled);
1379
+ }
1380
+ });
1381
+ } catch (error) {
1382
+ failed = true;
1383
+ throw error;
1384
+ } finally {
1385
+ release(size);
1386
+ }
1387
+ inFlight.delete(index);
1103
1388
  done += 1;
1104
- uploadedBytes += file.bytes.length;
1105
- onProgress?.(done, targets.length, uploadedBytes, file.path);
1389
+ landedBytes += size;
1390
+ report2(file.path);
1106
1391
  }
1107
1392
  };
1108
- const workers = Array.from(
1109
- { length: Math.min(concurrency, targets.length) },
1110
- worker
1111
- );
1393
+ const workers = Array.from({ length: Math.min(concurrency, targets.length) }, worker);
1112
1394
  try {
1113
1395
  await Promise.all(workers);
1114
1396
  } catch (error) {
@@ -1120,39 +1402,122 @@ async function uploadOne(target, file, context) {
1120
1402
  for (let attempt2 = 0; attempt2 <= context.retries; attempt2++) {
1121
1403
  context.signal?.throwIfAborted();
1122
1404
  if (attempt2 > 0) {
1405
+ context.onRetry(attempt2 + 1, lastError instanceof StalledError);
1123
1406
  await context.delay(BACKOFF_MS[attempt2 - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
1124
1407
  context.signal?.throwIfAborted();
1125
1408
  }
1126
1409
  try {
1127
- const response = await context.doFetch(context.uploadUrl, {
1128
- method: "PUT",
1129
- body: new Blob([file.bytes.slice().buffer]),
1130
- // The permit is the whole request. `Content-Length` is required by the Worker and cannot
1131
- // be set here — it is a forbidden header name — but the browser derives it from the body,
1132
- // which is exactly the length the token names.
1133
- headers: { Authorization: `Bearer ${target.token}` },
1134
- ...context.signal ? { signal: context.signal } : {}
1410
+ const status = await context.put({
1411
+ url: context.uploadUrl,
1412
+ token: target.token,
1413
+ body: untyped(file.bytes),
1414
+ signal: context.signal,
1415
+ onSent: context.onSent,
1416
+ stallMs: context.stallMs
1135
1417
  });
1136
- if (response.ok) return;
1137
- if (response.status === 401 || response.status === 403 || response.status === 429) {
1418
+ if (status >= 200 && status < 300) return;
1419
+ if (status === 401 || status === 403 || status === 429) {
1138
1420
  throw new DeployError(
1139
1421
  ClientErrorCode.UploadFailed,
1140
1422
  `Upload of ${target.path} was refused. The upload window may have expired — try deploying again.`,
1141
- { path: target.path, status: response.status }
1423
+ { path: target.path, status }
1142
1424
  );
1143
1425
  }
1144
- lastError = new Error(`HTTP ${response.status}`);
1426
+ if (status === 413) {
1427
+ throw new DeployError(
1428
+ ClientErrorCode.UploadFailed,
1429
+ `${target.path} is too large to upload in one request. Make it smaller, or leave it out of this publish.`,
1430
+ { path: target.path, status }
1431
+ );
1432
+ }
1433
+ lastError = new Error(`HTTP ${status}`);
1145
1434
  } catch (error) {
1146
1435
  if (error instanceof DeployError) throw error;
1147
1436
  if (isAbort(error)) throw cancelled();
1148
1437
  lastError = error;
1149
1438
  }
1150
1439
  }
1151
- throw new DeployError(
1152
- ClientErrorCode.UploadFailed,
1153
- `Could not upload ${target.path} after ${context.retries + 1} attempts.`,
1154
- { path: target.path, cause: String(lastError) }
1155
- );
1440
+ 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.`;
1441
+ throw new DeployError(ClientErrorCode.UploadFailed, message, {
1442
+ path: target.path,
1443
+ cause: describeError(lastError)
1444
+ });
1445
+ }
1446
+ function untyped(content) {
1447
+ if (ArrayBuffer.isView(content) || content.type === "") return content;
1448
+ return content.slice(0, content.size);
1449
+ }
1450
+ function defaultTransport(doFetch) {
1451
+ if (doFetch !== void 0) return fetchTransport(doFetch);
1452
+ const Xhr = globalThis.XMLHttpRequest;
1453
+ return Xhr === void 0 ? fetchTransport(globalFetch()) : xhrTransport(Xhr);
1454
+ }
1455
+ function fetchTransport(doFetch) {
1456
+ return async ({ url, token, body, signal }) => {
1457
+ const response = await doFetch(url, {
1458
+ method: "PUT",
1459
+ // The view, not a copy. Wrapping it in `new Blob([body.slice().buffer])` made two extra
1460
+ // full-size copies of every file in flight — eight at once, so up to 400 MB on a drop of
1461
+ // 50 MB files. `fetch` sends exactly the range a view covers. The cast is TypeScript's
1462
+ // SharedArrayBuffer distinction again; see `sha256Hex`. A `Blob` goes as it is, and the runtime
1463
+ // streams it from disk.
1464
+ body,
1465
+ // The permit is the whole request. `Content-Length` is required by the Worker and cannot be
1466
+ // set here — it is a forbidden header name — but the runtime derives it from the body, which
1467
+ // is exactly the length the token names.
1468
+ headers: { Authorization: `Bearer ${token}` },
1469
+ ...signal ? { signal } : {}
1470
+ });
1471
+ return response.status;
1472
+ };
1473
+ }
1474
+ function xhrTransport(Xhr) {
1475
+ return ({ url, token, body, signal, onSent, stallMs }) => new Promise((resolve, reject) => {
1476
+ const xhr = new Xhr();
1477
+ let settled = false;
1478
+ let timer;
1479
+ const settle = (finish) => {
1480
+ if (settled) return;
1481
+ settled = true;
1482
+ clearTimeout(timer);
1483
+ signal?.removeEventListener("abort", onAbort);
1484
+ finish();
1485
+ };
1486
+ const arm = () => {
1487
+ clearTimeout(timer);
1488
+ timer = setTimeout(() => {
1489
+ xhr.abort();
1490
+ settle(() => reject(new StalledError(Math.round(stallMs / 1e3))));
1491
+ }, stallMs);
1492
+ };
1493
+ function onAbort() {
1494
+ xhr.abort();
1495
+ settle(() => reject(abortReason(signal)));
1496
+ }
1497
+ if (signal?.aborted) {
1498
+ reject(abortReason(signal));
1499
+ return;
1500
+ }
1501
+ signal?.addEventListener("abort", onAbort, { once: true });
1502
+ xhr.open("PUT", url);
1503
+ xhr.setRequestHeader("Authorization", `Bearer ${token}`);
1504
+ xhr.upload.onprogress = (event) => {
1505
+ arm();
1506
+ onSent(event.loaded);
1507
+ };
1508
+ xhr.onprogress = arm;
1509
+ xhr.onload = () => settle(() => resolve(xhr.status));
1510
+ xhr.onerror = () => settle(() => reject(new TypeError("Network error during upload.")));
1511
+ arm();
1512
+ xhr.send(body);
1513
+ });
1514
+ }
1515
+ function abortReason(signal) {
1516
+ const reason = signal?.reason;
1517
+ if (reason instanceof Error) return reason;
1518
+ const error = new Error("Aborted.");
1519
+ error.name = "AbortError";
1520
+ return error;
1156
1521
  }
1157
1522
  function globalFetch() {
1158
1523
  return globalThis.fetch.bind(globalThis);
@@ -1166,9 +1531,17 @@ function isAbort(error) {
1166
1531
  function cancelled() {
1167
1532
  return new DeployError(ClientErrorCode.Cancelled, "Deploy cancelled.");
1168
1533
  }
1534
+ function outOfMemory(detail) {
1535
+ return new DeployError(
1536
+ ClientErrorCode.OutOfMemory,
1537
+ "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.",
1538
+ detail
1539
+ );
1540
+ }
1169
1541
  function asDeployError(error) {
1170
1542
  if (error instanceof DeployError) return error;
1171
1543
  if (isAbort(error)) return cancelled();
1544
+ if (error instanceof RangeError) return outOfMemory(error.message);
1172
1545
  return new DeployError(
1173
1546
  ClientErrorCode.UploadFailed,
1174
1547
  error instanceof Error ? error.message : "Upload failed.",
@@ -1177,7 +1550,11 @@ function asDeployError(error) {
1177
1550
  }
1178
1551
  async function deploy(source, siteId, onProgress, signal, options = {}) {
1179
1552
  try {
1180
- const collected = await source(signal);
1553
+ onProgress({ type: "reading", bytes: 0, total: 0 });
1554
+ const collected = await source(
1555
+ signal,
1556
+ (bytes, total) => onProgress({ type: "reading", bytes, total })
1557
+ );
1181
1558
  onProgress({ type: "collecting", files: collected.length });
1182
1559
  const rename = renameOfLoneHtmlPage(collected.map((file) => file.path));
1183
1560
  const files = rename === null ? collected : collected.map((file) => file.path === rename ? { ...file, path: "index.html" } : file);
@@ -1193,6 +1570,21 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1193
1570
  onProgress({ type: "unchanged", url: prepared.url ?? "" });
1194
1571
  return;
1195
1572
  }
1573
+ const sizes = new Map(hashed.map((file) => [file.path, sizeOf(file.bytes)]));
1574
+ const totalBytes = prepared.upload.reduce(
1575
+ (sum, target) => sum + (sizes.get(target.path) ?? 0),
1576
+ 0
1577
+ );
1578
+ if (prepared.upload.length > 0) {
1579
+ onProgress({
1580
+ type: "uploading",
1581
+ done: prepared.reused,
1582
+ total: prepared.total,
1583
+ bytes: 0,
1584
+ totalBytes,
1585
+ reused: prepared.reused
1586
+ });
1587
+ }
1196
1588
  await uploadAll(
1197
1589
  prepared.upload,
1198
1590
  prepared.uploadUrl,
@@ -1202,11 +1594,13 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1202
1594
  done: prepared.reused + done,
1203
1595
  total: prepared.total,
1204
1596
  bytes,
1597
+ totalBytes,
1205
1598
  reused: prepared.reused,
1206
- path
1599
+ ...path === void 0 ? {} : { path }
1207
1600
  }),
1208
1601
  signal,
1209
- options.upload
1602
+ options.upload,
1603
+ (path, attempt2, attempts, stalled) => onProgress({ type: "retrying", path, attempt: attempt2, attempts, stalled })
1210
1604
  );
1211
1605
  onProgress({ type: "completing", reused: prepared.reused });
1212
1606
  const completed = await completeDeploy(
@@ -1214,7 +1608,7 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1214
1608
  prepared.deployId,
1215
1609
  options,
1216
1610
  signal,
1217
- suggestSiteName(files)
1611
+ await readSiteName(files)
1218
1612
  );
1219
1613
  onProgress({ type: "done", url: completed.url, name: completed.name });
1220
1614
  } catch (error) {
@@ -1269,20 +1663,23 @@ function escapesRoot(root, candidate) {
1269
1663
  const inside = relative(root, candidate);
1270
1664
  return inside === "" || inside.startsWith("..");
1271
1665
  }
1272
- async function resolveSite(credentials, site, subdomain) {
1273
- if (subdomain !== void 0) {
1274
- if (site !== void 0) {
1275
- throw new Error(
1276
- "Give either an existing site or a subdomain for a new one, not both: publishing over a site and creating one are different requests."
1277
- );
1278
- }
1279
- return await createSite(credentials, subdomain);
1666
+ async function resolveSite(credentials, site, newSite) {
1667
+ const { subdomain, folder } = newSite;
1668
+ if (site !== void 0 && subdomain !== void 0) {
1669
+ throw new Error(
1670
+ "Give either an existing site or a subdomain for a new one, not both: publishing over a site and creating one are different requests."
1671
+ );
1280
1672
  }
1281
- if (site === void 0) return await createSite(credentials);
1282
- const wanted = site.trim().toLowerCase();
1283
- const found = (await listSites(credentials)).find(
1284
- (candidate) => candidate.siteId.toLowerCase() === wanted || candidate.subdomain.toLowerCase() === wanted
1285
- );
1673
+ if (site !== void 0 && folder !== void 0) {
1674
+ throw new Error(
1675
+ "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."
1676
+ );
1677
+ }
1678
+ if (site === void 0) {
1679
+ const folderId = folder === void 0 ? void 0 : await resolveFolder(credentials, folder);
1680
+ return await createSite(credentials, subdomain, folderId);
1681
+ }
1682
+ const found = await lookUpSite(credentials, site);
1286
1683
  if (found === void 0) {
1287
1684
  throw new Error(
1288
1685
  `No site of yours is called "${site}". Leave the site out to publish to a new one, or list your sites to see what exists.`
@@ -1290,8 +1687,8 @@ async function resolveSite(credentials, site, subdomain) {
1290
1687
  }
1291
1688
  return found;
1292
1689
  }
1293
- async function publish(credentials, source, site, onProgress, subdomain) {
1294
- const target = await resolveSite(credentials, site, subdomain);
1690
+ async function publish(credentials, source, site, onProgress, newSite = {}) {
1691
+ const target = await resolveSite(credentials, site, newSite);
1295
1692
  let files = 0;
1296
1693
  let url = target.url;
1297
1694
  let unchanged = false;
@@ -1321,11 +1718,11 @@ async function publish(credentials, source, site, onProgress, subdomain) {
1321
1718
  });
1322
1719
  return { url, siteId: target.siteId, subdomain: target.subdomain, files, unchanged };
1323
1720
  }
1324
- async function publishDirectory(credentials, directory, site, onProgress, subdomain) {
1721
+ async function publishDirectory(credentials, directory, site, onProgress, newSite = {}) {
1325
1722
  const found = await stat(directory).catch(() => null);
1326
1723
  if (found === null) throw new Error(`There is no folder at ${directory}.`);
1327
1724
  if (!found.isDirectory()) throw new Error(`${directory} is a file, not a folder to publish.`);
1328
- return await publish(credentials, directorySource(directory), site, onProgress, subdomain);
1725
+ return await publish(credentials, directorySource(directory), site, onProgress, newSite);
1329
1726
  }
1330
1727
  function cleanPath(path) {
1331
1728
  const cleaned = path.trim().replaceAll("\\", "/").replace(/^\.?\//, "");
@@ -1339,7 +1736,7 @@ function cleanPath(path) {
1339
1736
  }
1340
1737
  return cleaned;
1341
1738
  }
1342
- async function publishFiles(credentials, files, site, subdomain) {
1739
+ async function publishFiles(credentials, files, site, newSite = {}) {
1343
1740
  const encoder = new TextEncoder();
1344
1741
  const collected = files.map((file) => ({
1345
1742
  path: cleanPath(file.path),
@@ -1350,7 +1747,7 @@ async function publishFiles(credentials, files, site, subdomain) {
1350
1747
  if (seen.has(file.path)) throw new Error(`Two files were given the same path: ${file.path}`);
1351
1748
  seen.add(file.path);
1352
1749
  }
1353
- return publish(credentials, async () => collected, site, void 0, subdomain);
1750
+ return publish(credentials, async () => collected, site, void 0, newSite);
1354
1751
  }
1355
1752
  const DEFAULT_WAIT_SECONDS = 120;
1356
1753
  const MAX_WAIT_SECONDS = 240;
@@ -1528,7 +1925,7 @@ async function signInWithCode(waitSeconds, replace = false) {
1528
1925
  "Call this tool again to keep waiting. Nothing has been changed yet."
1529
1926
  ].join("\n");
1530
1927
  }
1531
- const SERVER_VERSION = "0.6.0";
1928
+ const SERVER_VERSION = "0.8.0";
1532
1929
  const CREDENTIAL_WRITE = {
1533
1930
  readOnlyHint: false,
1534
1931
  destructiveHint: false,
@@ -1566,6 +1963,15 @@ const SITE_OUTPUT = z.object({
1566
1963
  url: z.string().describe("Its live URL."),
1567
1964
  name: z.string().nullable().describe("What it is called, or null when it has no name.")
1568
1965
  });
1966
+ const FOLDER_OUTPUT = z.object({
1967
+ folderId: z.string().describe("Identifier of the folder, which `folder` accepts."),
1968
+ path: z.string().describe(
1969
+ "Every name from the top level down to it, joined with /, which `folder` accepts too."
1970
+ )
1971
+ });
1972
+ const FOLDER_INPUT = z.string().optional().describe(
1973
+ "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."
1974
+ );
1569
1975
  function say(text, isError = false) {
1570
1976
  return { content: [{ type: "text", text }], ...isError ? { isError: true } : {} };
1571
1977
  }
@@ -1613,6 +2019,10 @@ function createServer() {
1613
2019
  "be deleted by hand. `site` and `subdomain` are opposites: one publishes over a site that",
1614
2020
  "exists, the other creates one that does not. Never pass both.",
1615
2021
  "",
2022
+ "A new site goes at the top level of the dashboard unless `folder` files it somewhere. Pass",
2023
+ "it when the person named a folder, as its path (Clients/Acme) or its id; list_folders shows",
2024
+ "what exists. Like `subdomain`, it only describes a new site, so never pass it with `site`.",
2025
+ "",
1616
2026
  "A site needs an index.html at its top level, or at least one document (.md, .markdown,",
1617
2027
  ".pdf, .docx, .xlsx or .epub), which publishes as a documents site and is read through a",
1618
2028
  "viewer. So one page goes at index.html, and a single note is a whole publish that needs",
@@ -1674,13 +2084,14 @@ function createServer() {
1674
2084
  site: z.string().optional().describe("Subdomain or site id to publish over. Omit to create a new site."),
1675
2085
  subdomain: z.string().optional().describe(
1676
2086
  "Subdomain to create the new site under, when the person asked for a particular address. Only for a new site: leave it out to publish over an existing one, and do not pass it together with `site`. Ask rather than inventing one — the name is the URL somebody will be given, it cannot be changed afterwards, and an unused site made under a guessed name has to be deleted by hand."
1677
- )
2087
+ ),
2088
+ folder: FOLDER_INPUT
1678
2089
  },
1679
2090
  outputSchema: PUBLISH_OUTPUT,
1680
2091
  annotations: REPLACES_A_SITE
1681
2092
  },
1682
- ({ files, site, subdomain }) => withCredentials(async (credentials) => {
1683
- const result = await publishFiles(credentials, files, site, subdomain);
2093
+ ({ files, site, subdomain, folder }) => withCredentials(async (credentials) => {
2094
+ const result = await publishFiles(credentials, files, site, { subdomain, folder });
1684
2095
  return report(describe(result), { ...result });
1685
2096
  })
1686
2097
  );
@@ -1694,13 +2105,17 @@ function createServer() {
1694
2105
  site: z.string().optional().describe("Subdomain or site id to publish over. Omit to create a new site."),
1695
2106
  subdomain: z.string().optional().describe(
1696
2107
  "Subdomain to create the new site under, when the person asked for a particular address. Only for a new site: leave it out to publish over an existing one, and do not pass it together with `site`. Ask rather than inventing one — the name is the URL somebody will be given, it cannot be changed afterwards, and an unused site made under a guessed name has to be deleted by hand."
1697
- )
2108
+ ),
2109
+ folder: FOLDER_INPUT
1698
2110
  },
1699
2111
  outputSchema: PUBLISH_OUTPUT,
1700
2112
  annotations: REPLACES_A_SITE
1701
2113
  },
1702
- ({ path, site, subdomain }) => withCredentials(async (credentials) => {
1703
- const result = await publishDirectory(credentials, path, site, void 0, subdomain);
2114
+ ({ path, site, subdomain, folder }) => withCredentials(async (credentials) => {
2115
+ const result = await publishDirectory(credentials, path, site, void 0, {
2116
+ subdomain,
2117
+ folder
2118
+ });
1704
2119
  return report(describe(result), { ...result });
1705
2120
  })
1706
2121
  );
@@ -1741,15 +2156,36 @@ function createServer() {
1741
2156
  title: "List sites",
1742
2157
  description: "Lists the sites on this account, so a publish can go to one of them.",
1743
2158
  inputSchema: {},
1744
- outputSchema: { sites: z.array(SITE_OUTPUT).describe("Every site on this account.") },
2159
+ outputSchema: {
2160
+ sites: z.array(SITE_OUTPUT).describe("The account's sites, newest first — every one unless `total` is larger."),
2161
+ total: z.number().int().describe("How many sites the account holds. Larger than `sites` only past 100.")
2162
+ },
1745
2163
  annotations: READS_ONLY
1746
2164
  },
1747
2165
  () => withCredentials(async (credentials) => {
1748
- const sites = await listSites(credentials);
2166
+ const { sites, total } = await listSites(credentials);
1749
2167
  const text = sites.length === 0 ? "No sites on this account yet. Publishing without a `site` creates one." : sites.map(
1750
2168
  (site) => `${site.subdomain} — ${site.url}${site.name ? ` — ${site.name}` : ""}`
1751
- ).join("\n");
1752
- return report(text, { sites: sites.map((site) => ({ ...site })) });
2169
+ ).join("\n") + (total > sites.length ? `
2170
+ … and ${total - sites.length} more; these are the newest ${sites.length} of ${total}.` : "");
2171
+ return report(text, { sites: sites.map((site) => ({ ...site })), total });
2172
+ })
2173
+ );
2174
+ server.registerTool(
2175
+ "list_folders",
2176
+ {
2177
+ title: "List folders",
2178
+ description: "Lists the folders on this account, each with the path a publish's `folder` accepts. Folders are made and rearranged in the dashboard; no tool here creates one.",
2179
+ inputSchema: {},
2180
+ outputSchema: {
2181
+ folders: z.array(FOLDER_OUTPUT).describe("Every folder on this account, ordered by path.")
2182
+ },
2183
+ annotations: READS_ONLY
2184
+ },
2185
+ () => withCredentials(async (credentials) => {
2186
+ const folders = await listFolders(credentials);
2187
+ const text = folders.length === 0 ? "No folders on this account. A new site goes at the top level; folders are made in the dashboard." : folders.map((folder) => `${folder.path} — ${folder.folderId}`).join("\n");
2188
+ return report(text, { folders: folders.map((folder) => ({ ...folder })) });
1753
2189
  })
1754
2190
  );
1755
2191
  return server;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drop2run/mcp",
3
- "version": "0.6.0",
3
+ "version": "0.8.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",