@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.
- package/README.md +33 -4
- package/dist/index.js +850 -75
- 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
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
|
|
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
|
|
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
|
|
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
|
|
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]
|
|
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
|
-
|
|
1147
|
-
|
|
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
|
|
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
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
signal
|
|
1173
|
-
|
|
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
|
-
|
|
1177
|
-
|
|
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
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
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 (
|
|
1209
|
-
if (
|
|
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
|
|
1592
|
+
{ path: target.path, status }
|
|
1214
1593
|
);
|
|
1215
1594
|
}
|
|
1216
|
-
|
|
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
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1780
|
+
await readSiteName(files)
|
|
1290
1781
|
);
|
|
1291
|
-
onProgress({
|
|
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
|
|
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")
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
57
|
-
"
|
|
56
|
+
"vite": "^7.2.1",
|
|
57
|
+
"vitest": "^4.1.11"
|
|
58
58
|
}
|
|
59
59
|
}
|