drop2run 0.11.0 → 1.0.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/dist/index.js CHANGED
@@ -1,17 +1,21 @@
1
1
  import { join, dirname, relative, sep, resolve } from "node:path";
2
2
  import { readFileSync, writeFileSync, chmodSync, renameSync, mkdirSync } from "node:fs";
3
3
  import { homedir, hostname } from "node:os";
4
+ import { realpath, readdir, stat, readFile } from "node:fs/promises";
4
5
  import { spawn } from "node:child_process";
5
6
  import { randomBytes, createHash } from "node:crypto";
6
7
  import { createServer } from "node:http";
7
- import { realpath, readdir, stat, readFile } from "node:fs/promises";
8
+ const DEFAULT_API_BASE_URL = "https://dropto.run/api";
9
+ const PRODUCTION_SITE_ORIGIN = "https://dropto.run";
10
+ const PRODUCTION_DASHBOARD_ORIGIN = "https://app.dropto.run";
11
+ function dashboardUrlFor(apiBaseUrl) {
12
+ const origin = apiBaseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
13
+ return origin === PRODUCTION_SITE_ORIGIN ? PRODUCTION_DASHBOARD_ORIGIN : origin;
14
+ }
8
15
  const TOKEN_VARIABLE = "DROP2RUN_TOKEN";
9
16
  function configPath() {
10
17
  return join(homedir(), ".config", "drop2run", "config.json");
11
18
  }
12
- const DEFAULT_API_BASE_URL = "https://dropto.run/api";
13
- const PRODUCTION_SITE_ORIGIN = "https://dropto.run";
14
- const PRODUCTION_DASHBOARD_ORIGIN = "https://app.dropto.run";
15
19
  function loadCredentials(env = process.env, read = (path) => readFileSync(path, "utf8")) {
16
20
  let stored = {};
17
21
  try {
@@ -34,10 +38,6 @@ function resolveApiBaseUrl(env = process.env, read = (path) => readFileSync(path
34
38
  const url = env.DROP2RUN_API_URL?.trim() || stored.apiBaseUrl?.trim() || DEFAULT_API_BASE_URL;
35
39
  return url.replace(/\/+$/, "");
36
40
  }
37
- function dashboardUrlFor(apiBaseUrl) {
38
- const origin = apiBaseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
39
- return origin === PRODUCTION_SITE_ORIGIN ? PRODUCTION_DASHBOARD_ORIGIN : origin;
40
- }
41
41
  function saveToken(token, path = configPath()) {
42
42
  let existing = {};
43
43
  try {
@@ -100,723 +100,208 @@ function missingCredentialsMessage(surface = "cli") {
100
100
  "The token is shown once when it is created and cannot be recovered afterwards."
101
101
  ].join("\n");
102
102
  }
103
- async function call(credentials, path, init2 = {}) {
104
- const response = await fetch(`${credentials.apiBaseUrl}/${path}`, {
105
- ...init2,
106
- headers: {
107
- ...init2.headers,
108
- Authorization: `Bearer ${credentials.token}`,
109
- ...init2.body === void 0 ? {} : { "Content-Type": "application/json" }
110
- }
111
- });
112
- if (!response.ok) {
113
- const problem = await response.json().catch(() => null);
114
- throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
103
+ function sizeOf(content) {
104
+ if (ArrayBuffer.isView(content)) return content.byteLength;
105
+ if (isArrayBuffer(content)) return content.byteLength;
106
+ if (typeof content.size === "number") return content.size;
107
+ throw new TypeError("File contents must be a Uint8Array, an ArrayBuffer or a Blob.");
108
+ }
109
+ function isArrayBuffer(value) {
110
+ return Object.prototype.toString.call(value) === "[object ArrayBuffer]";
111
+ }
112
+ async function bytesOf(content) {
113
+ if (ArrayBuffer.isView(content)) return content;
114
+ if (isArrayBuffer(content)) return new Uint8Array(content);
115
+ return new Uint8Array(await content.arrayBuffer());
116
+ }
117
+ class DeployError extends Error {
118
+ /**
119
+ * @param code Stable code, either an API error type (`quota_exceeded`, `unsafe_path`, …) or one of
120
+ * the client-side codes in {@link ClientErrorCode}.
121
+ * @param message Text safe to show the user.
122
+ * @param detail Anything extra worth logging, such as the RFC 9457 body from the API.
123
+ */
124
+ constructor(code, message, detail) {
125
+ super(message);
126
+ this.code = code;
127
+ this.detail = detail;
128
+ this.name = "DeployError";
115
129
  }
116
- return await response.json();
117
130
  }
118
- async function findSite(credentials, site) {
119
- const found = await lookUpSite(credentials, site);
120
- if (found === void 0) {
121
- throw new Error(
122
- `No site of yours is called "${site}". \`drop2run ls\` or the list_sites tool shows what exists.`
123
- );
131
+ const ClientErrorCode = {
132
+ /** The drop held no files once junk was filtered out. */
133
+ Empty: "empty_drop",
134
+ /** No `index.html` at the root of the drop. */
135
+ MissingIndex: "missing_index",
136
+ /** The drop is larger than the plan allows; the server enforces this too. */
137
+ TooLarge: "too_large",
138
+ /** A single file is larger than the plan allows. */
139
+ FileTooLarge: "file_too_large",
140
+ /** More files than the plan allows. */
141
+ TooManyFiles: "too_many_files",
142
+ /**
143
+ * A zip claims to expand to more than the plan allows.
144
+ *
145
+ * Distinct from {@link TooLarge}, which is about a drop whose files have already been read. This one
146
+ * is raised from the archive's own directory before anything is decompressed, so the numbers in it
147
+ * are what the zip declared rather than what was measured.
148
+ */
149
+ ZipTooLarge: "zip_too_large",
150
+ /** The user cancelled. */
151
+ Cancelled: "cancelled",
152
+ /** An upload kept failing after every retry. */
153
+ UploadFailed: "upload_failed",
154
+ /**
155
+ * The browser could not hold what was dropped in memory. Raised when allocating the buffer for a
156
+ * large zip or file fails, which a browser reports as a `RangeError` rather than as anything that
157
+ * names memory.
158
+ */
159
+ OutOfMemory: "out_of_memory",
160
+ /**
161
+ * A dropped file could not be read when it came to be hashed. Files from a folder stay on disk until
162
+ * then, so one moved, deleted or changed after the drop fails here rather than at the drop.
163
+ */
164
+ ReadFailed: "read_failed",
165
+ /**
166
+ * A control-plane call got no answer: the connection failed before a response arrived, or the
167
+ * request timeout passed. Distinct from an HTTP error, which is an answer — this one says nothing
168
+ * about whether the server acted, which is why only an idempotent call may retry on it.
169
+ */
170
+ NetworkFailed: "network_failed"
171
+ };
172
+ function describeError(error) {
173
+ const parts = [];
174
+ let current = error;
175
+ for (let depth = 0; depth < 5 && current !== void 0 && current !== null; depth++) {
176
+ if (current instanceof Error) {
177
+ const code = current.code;
178
+ parts.push(typeof code === "string" ? `${current.message} (${code})` : current.message);
179
+ current = current.cause;
180
+ } else {
181
+ parts.push(String(current));
182
+ break;
183
+ }
124
184
  }
125
- return found;
185
+ return parts.join(" ← ");
126
186
  }
127
- const SITE_ID_SHAPE = /^[0-9a-z]{26}$/i;
128
- async function lookUpSite(credentials, site) {
129
- const wanted = site.trim().toLowerCase();
130
- if (wanted === "") return void 0;
131
- const { sites } = await call(
132
- credentials,
133
- `sites?pageSize=${SITES_PER_REQUEST}&q=${encodeURIComponent(wanted)}`
134
- );
135
- const bySubdomain = sites.find((candidate) => candidate.subdomain.toLowerCase() === wanted);
136
- if (bySubdomain !== void 0) return bySubdomain;
137
- if (!SITE_ID_SHAPE.test(wanted)) return void 0;
138
- const response = await fetch(
139
- `${credentials.apiBaseUrl}/sites/${encodeURIComponent(wanted.toUpperCase())}`,
140
- { headers: { Authorization: `Bearer ${credentials.token}` } }
187
+ const ACCOUNT_HEADER = "X-Drop2Run-Account";
188
+ const CLAIM_TOKEN_HEADER = "X-Claim-Token";
189
+ const DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
190
+ const COMPLETE_RETRY_DELAYS_MS = [1e3, 4e3];
191
+ async function prepareDeploy(siteId, files, options, signal) {
192
+ const manifest = files.map((file) => ({
193
+ path: file.path,
194
+ sha256: file.sha256,
195
+ size: sizeOf(file.bytes)
196
+ }));
197
+ const prepared = await request(
198
+ `sites/${encodeURIComponent(siteId)}/deploys/prepare`,
199
+ // `batches` asks for batch permits; an API that predates them ignores the field, and one that
200
+ // knows them answers with single permits until they are switched on.
201
+ { files: manifest, batches: true },
202
+ options,
203
+ signal
141
204
  );
142
- if (response.status === 404) return void 0;
143
- if (!response.ok) {
144
- const problem = await response.json().catch(() => null);
145
- throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
146
- }
147
- const detail = await response.json();
148
- return { siteId: detail.siteId, subdomain: detail.subdomain, url: detail.url, name: detail.name };
205
+ return { ...prepared, uploadUrl: absolute(prepared.uploadUrl, options.baseUrl) };
149
206
  }
150
- async function deleteSite(credentials, siteId) {
151
- const response = await fetch(`${credentials.apiBaseUrl}/sites/${encodeURIComponent(siteId)}`, {
152
- method: "DELETE",
153
- headers: { Authorization: `Bearer ${credentials.token}` }
154
- });
155
- if (!response.ok) {
156
- const problem = await response.json().catch(() => null);
157
- throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
207
+ function absolute(uploadUrl, baseUrl) {
208
+ if (/^[a-z][a-z0-9+.-]*:/i.test(uploadUrl)) return uploadUrl;
209
+ if (baseUrl === void 0 || !/^[a-z][a-z0-9+.-]*:/i.test(baseUrl)) return uploadUrl;
210
+ return new URL(uploadUrl, baseUrl).toString();
211
+ }
212
+ async function completeDeploy(siteId, deployId, options, signal, name) {
213
+ const path = `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`;
214
+ const body = name === null || name === void 0 ? void 0 : { name };
215
+ const delay = options.delay ?? wait;
216
+ for (let attempt = 0; ; attempt++) {
217
+ try {
218
+ return await request(path, body, options, signal);
219
+ } catch (error) {
220
+ const unanswered = error instanceof DeployError && error.code === ClientErrorCode.NetworkFailed;
221
+ if (!unanswered || attempt >= COMPLETE_RETRY_DELAYS_MS.length || signal?.aborted) throw error;
222
+ await delay(COMPLETE_RETRY_DELAYS_MS[attempt] ?? 1e3);
223
+ }
158
224
  }
159
225
  }
160
- async function promoteDeploy(credentials, siteId, deployId) {
161
- return await call(
162
- credentials,
163
- `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/promote`,
164
- { method: "POST" }
165
- );
226
+ function wait(ms) {
227
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
166
228
  }
167
- function enumName(value) {
168
- return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
229
+ async function request(path, body, options, signal) {
230
+ const doFetch = options.fetch ?? globalFetch$1();
231
+ const base = options.baseUrl ?? "/api";
232
+ const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
233
+ const headers = { ...options.headers };
234
+ if (body !== void 0) headers["Content-Type"] = "application/json";
235
+ if (options.claimToken) headers[CLAIM_TOKEN_HEADER] = options.claimToken;
236
+ if (options.token) headers.Authorization = `Bearer ${options.token}`;
237
+ if (options.accountId) headers[ACCOUNT_HEADER] = options.accountId;
238
+ const controller = new AbortController();
239
+ let timedOut = false;
240
+ const timer = setTimeout(() => {
241
+ timedOut = true;
242
+ controller.abort();
243
+ }, timeoutMs);
244
+ try {
245
+ const response = await doFetch(`${base}/${path}`, {
246
+ method: "POST",
247
+ headers,
248
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
249
+ signal: controller.signal
250
+ });
251
+ if (!response.ok) throw await problemToError(response);
252
+ return await response.json();
253
+ } catch (error) {
254
+ if (error instanceof DeployError || signal?.aborted) throw error;
255
+ if (timedOut) {
256
+ throw new DeployError(
257
+ ClientErrorCode.NetworkFailed,
258
+ `The server did not answer within ${Math.round(timeoutMs / 1e3)} seconds.`,
259
+ { path, cause: describeError(error) }
260
+ );
261
+ }
262
+ if (error instanceof TypeError) {
263
+ throw new DeployError(ClientErrorCode.NetworkFailed, "Could not reach the server.", {
264
+ path,
265
+ cause: describeError(error)
266
+ });
267
+ }
268
+ throw error;
269
+ } finally {
270
+ clearTimeout(timer);
271
+ }
169
272
  }
170
- function actionOf(expiresAt, action) {
171
- return expiresAt === null ? null : enumName(action);
273
+ function globalFetch$1() {
274
+ return globalThis.fetch.bind(globalThis);
172
275
  }
173
- async function readInvitedOnly(credentials, siteId) {
276
+ async function problemToError(response) {
277
+ let problem = {};
174
278
  try {
175
- const page = await call(
176
- credentials,
177
- `sites/${encodeURIComponent(siteId)}/viewers?pageSize=1`
279
+ problem = await response.json();
280
+ } catch {
281
+ return new DeployError(
282
+ `http_${response.status}`,
283
+ `The server responded with ${response.status}.`
178
284
  );
179
- return typeof page.invitedOnly === "boolean" ? page.invitedOnly : null;
180
- } catch {
181
- return null;
182
- }
183
- }
184
- async function folderFromListing(credentials, siteId, subdomain) {
185
- const { sites } = await call(
186
- credentials,
187
- `sites?pageSize=${SITES_PER_REQUEST}&q=${encodeURIComponent(subdomain)}`
188
- );
189
- return sites.find((candidate) => candidate.siteId === siteId)?.folderId ?? null;
190
- }
191
- async function getSite(credentials, siteId) {
192
- const [site, invitedOnly] = await Promise.all([
193
- call(credentials, `sites/${encodeURIComponent(siteId)}`),
194
- readInvitedOnly(credentials, siteId)
195
- ]);
196
- const folderId = site.folderId !== void 0 ? site.folderId : await folderFromListing(credentials, site.siteId, site.subdomain);
197
- return {
198
- siteId: site.siteId,
199
- subdomain: site.subdomain,
200
- url: site.url,
201
- name: site.name,
202
- status: enumName(site.status),
203
- invitedOnly,
204
- liveDeployId: site.liveDeployId,
205
- spaMode: site.spaMode,
206
- docsMode: site.docsMode,
207
- modeIsManual: site.modeIsManual,
208
- passwordProtected: site.passwordProtected,
209
- passwordProtectionAvailable: site.passwordProtectionAvailable,
210
- formsEnabled: site.formsEnabled,
211
- formsAvailable: site.formsAvailable,
212
- expiresAt: site.expiresAt,
213
- expiryAction: actionOf(site.expiresAt, site.expiryAction ?? ""),
214
- scheduledExpiryAvailable: site.scheduledExpiryAvailable,
215
- comments: audienceOf(site.commentAudience),
216
- commentsAvailable: site.feedbackAvailable === true,
217
- folderId,
218
- deploys: site.deploys.map((deploy2) => ({
219
- deployId: deploy2.deployId,
220
- status: deploy2.status,
221
- fileCount: deploy2.fileCount,
222
- totalBytes: deploy2.totalBytes,
223
- createdAt: deploy2.createdAt,
224
- completedAt: deploy2.completedAt,
225
- filesKept: deploy2.filesKept
226
- }))
227
- };
228
- }
229
- async function updateSiteSettings(credentials, siteId, change) {
230
- const body = {};
231
- if (change.name !== void 0) body.name = change.name;
232
- if (change.mode !== void 0) {
233
- body.spaMode = change.mode === "spa";
234
- body.docsMode = change.mode === "docs";
235
- }
236
- if (change.password !== void 0) body.password = change.password;
237
- if (change.expiresAt !== void 0) body.expiresAt = change.expiresAt;
238
- if (change.expiryAction !== void 0) body.expiryAction = change.expiryAction;
239
- if (change.formsEnabled !== void 0) body.formsEnabled = change.formsEnabled;
240
- if (change.folderId !== void 0) body.folderId = change.folderId;
241
- if (change.invitedOnly !== void 0) body.invitedOnly = change.invitedOnly;
242
- if (change.comments !== void 0) body.commentAudience = API_AUDIENCE[change.comments];
243
- if (Object.keys(body).length === 0) throw new Error("Nothing to change — name a setting.");
244
- const wasInvitedOnly = change.password !== void 0 && change.password !== "" ? await readInvitedOnly(credentials, siteId) : null;
245
- const { commentAudience, ...settings } = await call(credentials, `sites/${encodeURIComponent(siteId)}`, {
246
- method: "PATCH",
247
- body: JSON.stringify(body)
248
- });
249
- const invitedOnly = typeof settings.invitedOnly === "boolean" ? settings.invitedOnly : null;
250
- return {
251
- ...settings,
252
- expiryAction: actionOf(settings.expiresAt, settings.expiryAction ?? ""),
253
- invitedOnly,
254
- comments: audienceOf(commentAudience),
255
- replacedInviteOnly: wasInvitedOnly === true && invitedOnly === false
256
- };
257
- }
258
- const API_AUDIENCE = {
259
- off: "Off",
260
- invited: "Invited",
261
- anyone: "Anyone"
262
- };
263
- function audienceOf(value) {
264
- const lower = value?.toLowerCase();
265
- return lower === "off" || lower === "invited" || lower === "anyone" ? lower : null;
266
- }
267
- async function setSitePaused(credentials, siteId, action) {
268
- const state = await call(
269
- credentials,
270
- `sites/${encodeURIComponent(siteId)}/${action}`,
271
- { method: "POST" }
272
- );
273
- return {
274
- siteId: state.siteId,
275
- subdomain: state.subdomain,
276
- status: enumName(state.status),
277
- ownerPaused: state.ownerPaused
278
- };
279
- }
280
- async function listTokens(credentials) {
281
- const body = await call(credentials, "tokens");
282
- return body.tokens;
283
- }
284
- const SITES_PER_REQUEST = 100;
285
- async function listSites(credentials) {
286
- const body = await call(
287
- credentials,
288
- `sites?pageSize=${SITES_PER_REQUEST}`
289
- );
290
- return { sites: body.sites, total: body.total };
291
- }
292
- async function createSite(credentials, subdomain, folderId) {
293
- const created = await call(
294
- credentials,
295
- "sites",
296
- {
297
- method: "POST",
298
- // Absent fields rather than null ones, because the API reads null, empty and missing the same
299
- // way and a body carrying only what was asked for is the one that reads correctly in a log.
300
- body: JSON.stringify({
301
- ...subdomain === void 0 ? {} : { subdomain },
302
- ...folderId === void 0 ? {} : { folderId }
303
- })
304
- }
305
- );
306
- return { ...created, name: null };
307
- }
308
- async function listFolders(credentials) {
309
- const body = await call(credentials, "site-folders");
310
- const byId = new Map(body.folders.map((folder) => [folder.folderId, folder]));
311
- const pathOf = (folderId) => {
312
- const names = [];
313
- let cursor = byId.get(folderId);
314
- while (cursor !== void 0 && names.length <= byId.size) {
315
- names.unshift(cursor.name);
316
- cursor = cursor.parentId === null ? void 0 : byId.get(cursor.parentId);
317
- }
318
- return names.join("/");
319
- };
320
- return body.folders.map((folder) => ({ folderId: folder.folderId, path: pathOf(folder.folderId) })).sort((a, b) => a.path.localeCompare(b.path));
321
- }
322
- function comparablePath(path) {
323
- return path.split("/").map((name) => name.trim()).filter((name) => name !== "").join("/").toLowerCase();
324
- }
325
- const FOLDERS_LISTED_ON_MISS = 20;
326
- async function resolveFolder(credentials, folder) {
327
- const wanted = comparablePath(folder);
328
- if (wanted === "") {
329
- throw new Error(
330
- `"${folder}" does not name a folder. Leave the folder out to put the site at the top level.`
331
- );
332
- }
333
- const folders2 = await listFolders(credentials);
334
- const byId = folders2.find((candidate) => candidate.folderId.toLowerCase() === wanted);
335
- if (byId !== void 0) return byId.folderId;
336
- const matches = folders2.filter((candidate) => comparablePath(candidate.path) === wanted);
337
- if (matches.length === 1 && matches[0] !== void 0) return matches[0].folderId;
338
- if (matches.length > 1) {
339
- throw new Error(
340
- `More than one folder is at "${folder}", because a folder name can contain "/". Name it by id instead: ${matches.map((match) => match.folderId).join(", ")}.`
341
- );
342
- }
343
- if (folders2.length === 0) {
344
- throw new Error(
345
- `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.`
346
- );
347
- }
348
- const listed = folders2.slice(0, FOLDERS_LISTED_ON_MISS).map((candidate) => candidate.path);
349
- const more = folders2.length - listed.length;
350
- throw new Error(
351
- `No folder of yours is called "${folder}". Your folders: ${listed.join(", ")}${more > 0 ? `, and ${more} more` : ""}.`
352
- );
353
- }
354
- const ROOT_FOLDER = "root";
355
- async function resolveFolderTarget(credentials, folder) {
356
- return folder.trim().toLowerCase() === ROOT_FOLDER ? ROOT_FOLDER : await resolveFolder(credentials, folder);
357
- }
358
- async function listComments(credentials, siteId, query = {}) {
359
- const search = new URLSearchParams();
360
- if (query.status !== void 0) search.set("status", query.status);
361
- if (query.path !== void 0) search.set("path", query.path);
362
- if (query.since !== void 0) search.set("since", String(query.since));
363
- if (query.page !== void 0) search.set("page", String(query.page));
364
- const encoded = search.toString();
365
- const suffix = encoded === "" ? "" : `?${encoded}`;
366
- return await call(
367
- credentials,
368
- `sites/${encodeURIComponent(siteId)}/feedback/threads${suffix}`
369
- );
370
- }
371
- async function replyToComment(credentials, siteId, threadId, body, resolve2 = false) {
372
- return await call(
373
- credentials,
374
- `sites/${encodeURIComponent(siteId)}/feedback/threads/${encodeURIComponent(threadId)}/comments`,
375
- { method: "POST", body: JSON.stringify({ body, resolve: resolve2 }) }
376
- );
377
- }
378
- async function setCommentResolved(credentials, siteId, threadId, resolved) {
379
- return await call(
380
- credentials,
381
- `sites/${encodeURIComponent(siteId)}/feedback/threads/${encodeURIComponent(threadId)}/${resolved ? "resolve" : "reopen"}`,
382
- { method: "POST" }
383
- );
384
- }
385
- const PEOPLE_PER_REQUEST = 50;
386
- async function listPeople(credentials, siteId) {
387
- const page = await call(credentials, `sites/${encodeURIComponent(siteId)}/viewers?pageSize=${PEOPLE_PER_REQUEST}`);
388
- return {
389
- invitedOnly: page.invitedOnly,
390
- total: page.total,
391
- limit: page.limit,
392
- people: page.viewers.map((row) => ({
393
- email: row.email,
394
- role: enumName(row.role),
395
- owner: row.owner,
396
- status: enumName(row.status)
397
- }))
398
- };
399
- }
400
- async function invitePeople(credentials, siteId, emails, role) {
401
- return await call(credentials, `sites/${encodeURIComponent(siteId)}/viewers`, {
402
- method: "POST",
403
- body: JSON.stringify({ emails, role: role === "comment" ? "Comment" : "View", notify: true })
404
- });
405
- }
406
- async function removePerson(credentials, siteId, email) {
407
- await call(
408
- credentials,
409
- `sites/${encodeURIComponent(siteId)}/viewers?email=${encodeURIComponent(email)}`,
410
- { method: "DELETE" }
411
- );
412
- }
413
- const VERSIONS_LISTED = 10;
414
- function servingModeOf$1(site) {
415
- if (site.spaMode) return "spa";
416
- if (site.docsMode) return "docs";
417
- return "static";
418
- }
419
- function accessOf(site) {
420
- if (site.passwordProtected) return "password";
421
- if (site.invitedOnly === true) return "invite-only";
422
- if (site.invitedOnly === false) return "public";
423
- return "no password (whether it is invite-only could not be read)";
424
- }
425
- function describeSite(site, folderPath) {
426
- const unavailable = (available) => available ? "" : " (not on this plan)";
427
- const versions = site.deploys.slice(0, VERSIONS_LISTED).map((deploy2) => {
428
- const live = deploy2.deployId === site.liveDeployId ? " ← served" : "";
429
- const kept = deploy2.filesKept ? "" : ", files collected — cannot be rolled back to";
430
- return ` ${deploy2.deployId} ${deploy2.createdAt} ${deploy2.status}, ${deploy2.fileCount} files${kept}${live}`;
431
- });
432
- const more = site.deploys.length - versions.length;
433
- return [
434
- `${site.subdomain} — ${site.url}`,
435
- ` name ${site.name ?? "(none)"}`,
436
- ` status ${site.status}`,
437
- ` access ${accessOf(site)}`,
438
- ` mode ${servingModeOf$1(site)}${site.modeIsManual ? "" : " (detected on each publish)"}`,
439
- ` password ${site.passwordProtected ? "on" : "off"}${unavailable(site.passwordProtectionAvailable)}`,
440
- ` forms ${site.formsEnabled ? "on" : "off"}${unavailable(site.formsAvailable)}`,
441
- ` comments ${site.comments ?? "unknown"}${unavailable(site.commentsAvailable)}`,
442
- ` takedown ${site.expiresAt === null ? "none" : `${site.expiresAt} (${site.expiryAction})`}${unavailable(site.scheduledExpiryAvailable)}`,
443
- ` folder ${site.folderId === null ? "(top level)" : folderPath ?? site.folderId}`,
444
- "",
445
- site.deploys.length === 0 ? "No versions yet." : "Versions, newest first:",
446
- ...versions,
447
- ...more > 0 ? [` … and ${more} older`] : []
448
- ].join("\n");
449
- }
450
- const TIMEOUT_MS = 5 * 60 * 1e3;
451
- function newAttempt() {
452
- const verifier = randomBytes(32).toString("base64url");
453
- return {
454
- verifier,
455
- challenge: createHash("sha256").update(verifier).digest("base64url"),
456
- state: randomBytes(16).toString("base64url")
457
- };
458
- }
459
- function clientName(surface) {
460
- const clean2 = (value) => value.trim().replace(/[^A-Za-z0-9._-]/g, "-");
461
- const label = "";
462
- const raw = clean2(hostname()).slice(0, 64 - label.length);
463
- return `${raw === "" ? "drop2run" : raw}${label}`;
464
- }
465
- function openBrowser(url) {
466
- const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
467
- try {
468
- const child = spawn(command, [...args], { stdio: "ignore", detached: true });
469
- child.unref();
470
- child.on("error", () => {
471
- });
472
- return true;
473
- } catch {
474
- return false;
475
- }
476
- }
477
- function listen(state) {
478
- return new Promise((resolveListener, rejectListener) => {
479
- let settle = null;
480
- let fail = null;
481
- const received = new Promise((resolveCode, rejectCode) => {
482
- settle = resolveCode;
483
- fail = rejectCode;
484
- });
485
- const server = createServer((request2, response) => {
486
- const url = new URL(request2.url ?? "/", "http://127.0.0.1");
487
- if (url.pathname !== "/cb") {
488
- response.writeHead(404, { "Content-Type": "text/plain" });
489
- response.end("Not found.\n");
490
- return;
491
- }
492
- const code = url.searchParams.get("code") ?? "";
493
- const returned = url.searchParams.get("state") ?? "";
494
- if (returned !== state || code === "") {
495
- respond(
496
- response,
497
- 400,
498
- "Sign-in could not be completed",
499
- "This did not come from the sign-in that was started here. Nothing has been changed."
500
- );
501
- fail?.(new Error("The browser returned a response that did not match this sign-in."));
502
- return;
503
- }
504
- respond(response, 200, "Signed in", "You can close this tab now.");
505
- settle?.({ code });
506
- });
507
- server.on("error", (error) => {
508
- rejectListener(error);
509
- fail?.(error instanceof Error ? error : new Error(String(error)));
510
- });
511
- server.listen(0, "127.0.0.1", () => {
512
- const address = server.address();
513
- if (address === null) {
514
- rejectListener(new Error("Could not open a local port to receive the sign-in."));
515
- return;
516
- }
517
- resolveListener({
518
- port: address.port,
519
- received,
520
- close: () => server.close()
521
- });
522
- });
523
- });
524
- }
525
- function respond(response, status, title, detail) {
526
- response.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
527
- response.end(
528
- `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title} — drop2run</title><style>body{font:16px/1.6 system-ui,sans-serif;margin:20vh auto;max-width:28rem;padding:0 1.5rem;color:#12211f}h1{font-size:1.4rem;margin:0 0 .5rem}p{margin:0;color:#64706e}@media(prefers-color-scheme:dark){body{background:#0d1413;color:#e8efee}p{color:#9aa8a6}}</style></head><body><h1>${title}</h1><p>${detail}</p></body></html>
529
- `
530
- );
531
- }
532
- async function waitForCallback(received, timeoutMs = TIMEOUT_MS) {
533
- let timer;
534
- try {
535
- return await Promise.race([
536
- received,
537
- new Promise((_, reject) => {
538
- timer = setTimeout(
539
- () => reject(new Error("Timed out waiting for the browser. Nothing has been changed.")),
540
- timeoutMs
541
- );
542
- })
543
- ]);
544
- } finally {
545
- if (timer !== void 0) clearTimeout(timer);
546
- }
547
- }
548
- function consentUrl(dashboardUrl, attempt, port, name) {
549
- const url = new URL("/cli-auth", dashboardUrl);
550
- url.searchParams.set("challenge", attempt.challenge);
551
- url.searchParams.set("state", attempt.state);
552
- url.searchParams.set("port", String(port));
553
- url.searchParams.set("name", name);
554
- return url.toString();
555
- }
556
- async function startDevice(apiBaseUrl, clientName2) {
557
- const response = await fetch(`${apiBaseUrl}/auth/cli/device`, {
558
- method: "POST",
559
- headers: { "Content-Type": "application/json" },
560
- body: JSON.stringify({ clientName: clientName2 })
561
- });
562
- if (!response.ok) throw new Error(await problemDetail(response));
563
- return await response.json();
564
- }
565
- async function pollDevice(apiBaseUrl, deviceCode) {
566
- let response;
567
- try {
568
- response = await fetch(`${apiBaseUrl}/auth/cli/device/token`, {
569
- method: "POST",
570
- headers: { "Content-Type": "application/json" },
571
- body: JSON.stringify({ deviceCode })
572
- });
573
- } catch {
574
- return { state: "pending" };
575
- }
576
- if (response.ok) {
577
- const body = await response.json();
578
- return { state: "granted", token: { token: body.token, name: body.name, email: body.email } };
579
- }
580
- const problem = await response.json().catch(() => null);
581
- if (problem?.type === "authorization_pending") return { state: "pending" };
582
- if (problem?.type === "slow_down") return { state: "slow_down" };
583
- return {
584
- state: "dead",
585
- message: problem?.detail ?? `The API answered ${response.status}.`
586
- };
587
- }
588
- async function problemDetail(response) {
589
- const problem = await response.json().catch(() => null);
590
- return problem?.detail ?? `The API answered ${response.status}.`;
591
- }
592
- async function exchange(apiBaseUrl, code, verifier) {
593
- const response = await fetch(`${apiBaseUrl}/auth/cli/token`, {
594
- method: "POST",
595
- headers: { "Content-Type": "application/json" },
596
- body: JSON.stringify({ code, verifier })
597
- });
598
- if (!response.ok) throw new Error(await problemDetail(response));
599
- const body = await response.json();
600
- return { token: body.token, name: body.name, email: body.email };
601
- }
602
- const PROJECT_FILE = "drop2run.json";
603
- function projectPath(directory = process.cwd()) {
604
- return join(directory, PROJECT_FILE);
605
- }
606
- function readProject(directory = process.cwd(), read = (path) => readFileSync(path, "utf8")) {
607
- let parsed;
608
- try {
609
- parsed = JSON.parse(read(projectPath(directory)));
610
- } catch {
611
- return null;
612
- }
613
- if (typeof parsed.siteId !== "string" || parsed.siteId === "") return null;
614
- return {
615
- siteId: parsed.siteId,
616
- subdomain: typeof parsed.subdomain === "string" ? parsed.subdomain : "",
617
- dir: typeof parsed.dir === "string" && parsed.dir !== "" ? parsed.dir : "."
618
- };
619
- }
620
- function writeProject(project, directory = process.cwd()) {
621
- const path = projectPath(directory);
622
- writeFileSync(path, `${JSON.stringify(project, null, " ")}
623
- `);
624
- return path;
625
- }
626
- function sizeOf(content) {
627
- return ArrayBuffer.isView(content) ? content.byteLength : content.size;
628
- }
629
- async function bytesOf(content) {
630
- return ArrayBuffer.isView(content) ? content : new Uint8Array(await content.arrayBuffer());
631
- }
632
- class DeployError extends Error {
633
- /**
634
- * @param code Stable code, either an API error type (`quota_exceeded`, `unsafe_path`, …) or one of
635
- * the client-side codes in {@link ClientErrorCode}.
636
- * @param message Text safe to show the user.
637
- * @param detail Anything extra worth logging, such as the RFC 9457 body from the API.
638
- */
639
- constructor(code, message, detail) {
640
- super(message);
641
- this.code = code;
642
- this.detail = detail;
643
- this.name = "DeployError";
644
- }
645
- }
646
- const ClientErrorCode = {
647
- /** The drop held no files once junk was filtered out. */
648
- Empty: "empty_drop",
649
- /** No `index.html` at the root of the drop. */
650
- MissingIndex: "missing_index",
651
- /** The drop is larger than the plan allows; the server enforces this too. */
652
- TooLarge: "too_large",
653
- /** A single file is larger than the plan allows. */
654
- FileTooLarge: "file_too_large",
655
- /** More files than the plan allows. */
656
- TooManyFiles: "too_many_files",
657
- /**
658
- * A zip claims to expand to more than the plan allows.
659
- *
660
- * Distinct from {@link TooLarge}, which is about a drop whose files have already been read. This one
661
- * is raised from the archive's own directory before anything is decompressed, so the numbers in it
662
- * are what the zip declared rather than what was measured.
663
- */
664
- ZipTooLarge: "zip_too_large",
665
- /** The user cancelled. */
666
- Cancelled: "cancelled",
667
- /** An upload kept failing after every retry. */
668
- UploadFailed: "upload_failed",
669
- /**
670
- * The browser could not hold what was dropped in memory. Raised when allocating the buffer for a
671
- * large zip or file fails, which a browser reports as a `RangeError` rather than as anything that
672
- * names memory.
673
- */
674
- OutOfMemory: "out_of_memory",
675
- /**
676
- * A dropped file could not be read when it came to be hashed. Files from a folder stay on disk until
677
- * then, so one moved, deleted or changed after the drop fails here rather than at the drop.
678
- */
679
- ReadFailed: "read_failed",
680
- /**
681
- * A control-plane call got no answer: the connection failed before a response arrived, or the
682
- * request timeout passed. Distinct from an HTTP error, which is an answer — this one says nothing
683
- * about whether the server acted, which is why only an idempotent call may retry on it.
684
- */
685
- NetworkFailed: "network_failed"
686
- };
687
- function describeError(error) {
688
- const parts = [];
689
- let current = error;
690
- for (let depth = 0; depth < 5 && current !== void 0 && current !== null; depth++) {
691
- if (current instanceof Error) {
692
- const code = current.code;
693
- parts.push(typeof code === "string" ? `${current.message} (${code})` : current.message);
694
- current = current.cause;
695
- } else {
696
- parts.push(String(current));
697
- break;
698
- }
699
- }
700
- return parts.join(" ← ");
701
- }
702
- const ACCOUNT_HEADER = "X-Drop2Run-Account";
703
- const CLAIM_TOKEN_HEADER = "X-Claim-Token";
704
- const DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
705
- const COMPLETE_RETRY_DELAYS_MS = [1e3, 4e3];
706
- async function prepareDeploy(siteId, files, options, signal) {
707
- const manifest = files.map((file) => ({
708
- path: file.path,
709
- sha256: file.sha256,
710
- size: sizeOf(file.bytes)
711
- }));
712
- const prepared = await request(
713
- `sites/${encodeURIComponent(siteId)}/deploys/prepare`,
714
- // `batches` asks for batch permits; an API that predates them ignores the field, and one that
715
- // knows them answers with single permits until they are switched on.
716
- { files: manifest, batches: true },
717
- options,
718
- signal
719
- );
720
- return { ...prepared, uploadUrl: absolute(prepared.uploadUrl, options.baseUrl) };
721
- }
722
- function absolute(uploadUrl, baseUrl) {
723
- if (/^[a-z][a-z0-9+.-]*:/i.test(uploadUrl)) return uploadUrl;
724
- if (baseUrl === void 0 || !/^[a-z][a-z0-9+.-]*:/i.test(baseUrl)) return uploadUrl;
725
- return new URL(uploadUrl, baseUrl).toString();
726
- }
727
- async function completeDeploy(siteId, deployId, options, signal, name) {
728
- const path = `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`;
729
- const body = name === null || name === void 0 ? void 0 : { name };
730
- const delay = options.delay ?? wait;
731
- for (let attempt = 0; ; attempt++) {
732
- try {
733
- return await request(path, body, options, signal);
734
- } catch (error) {
735
- const unanswered = error instanceof DeployError && error.code === ClientErrorCode.NetworkFailed;
736
- if (!unanswered || attempt >= COMPLETE_RETRY_DELAYS_MS.length || signal?.aborted) throw error;
737
- await delay(COMPLETE_RETRY_DELAYS_MS[attempt] ?? 1e3);
738
- }
739
- }
740
- }
741
- function wait(ms) {
742
- return new Promise((resolve2) => setTimeout(resolve2, ms));
743
- }
744
- async function request(path, body, options, signal) {
745
- const doFetch = options.fetch ?? globalFetch$1();
746
- const base = options.baseUrl ?? "/api";
747
- const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
748
- const headers = {};
749
- if (body !== void 0) headers["Content-Type"] = "application/json";
750
- if (options.claimToken) headers[CLAIM_TOKEN_HEADER] = options.claimToken;
751
- if (options.token) headers.Authorization = `Bearer ${options.token}`;
752
- if (options.accountId) headers[ACCOUNT_HEADER] = options.accountId;
753
- const controller = new AbortController();
754
- let timedOut = false;
755
- const timer = setTimeout(() => {
756
- timedOut = true;
757
- controller.abort();
758
- }, timeoutMs);
759
- try {
760
- const response = await doFetch(`${base}/${path}`, {
761
- method: "POST",
762
- headers,
763
- ...body === void 0 ? {} : { body: JSON.stringify(body) },
764
- signal: controller.signal
765
- });
766
- if (!response.ok) throw await problemToError(response);
767
- return await response.json();
768
- } catch (error) {
769
- if (error instanceof DeployError || signal?.aborted) throw error;
770
- if (timedOut) {
771
- throw new DeployError(
772
- ClientErrorCode.NetworkFailed,
773
- `The server did not answer within ${Math.round(timeoutMs / 1e3)} seconds.`,
774
- { path, cause: describeError(error) }
775
- );
776
- }
777
- if (error instanceof TypeError) {
778
- throw new DeployError(ClientErrorCode.NetworkFailed, "Could not reach the server.", {
779
- path,
780
- cause: describeError(error)
781
- });
782
- }
783
- throw error;
784
- } finally {
785
- clearTimeout(timer);
786
- }
787
- }
788
- function globalFetch$1() {
789
- return globalThis.fetch.bind(globalThis);
790
- }
791
- async function problemToError(response) {
792
- let problem = {};
793
- try {
794
- problem = await response.json();
795
- } catch {
796
- return new DeployError(
797
- `http_${response.status}`,
798
- `The server responded with ${response.status}.`
799
- );
800
- }
801
- const code = typeof problem.type === "string" ? problem.type : `http_${response.status}`;
802
- const message = typeof problem.detail === "string" ? problem.detail : typeof problem.title === "string" ? problem.title : `The server responded with ${response.status}.`;
803
- return new DeployError(code, message, problem);
804
- }
805
- function batchLayout(members) {
806
- return members.map((member) => `${member.sha256} ${member.size}
807
- `).join("");
808
- }
809
- function isBatch(target) {
810
- return "files" in target;
811
- }
812
- function parseBatchResult(body) {
813
- try {
814
- const parsed = JSON.parse(body);
815
- if (parsed === null || typeof parsed !== "object") return null;
816
- const { stored, present, failed } = parsed;
817
- if (typeof stored !== "number" || typeof present !== "number" || !Array.isArray(failed))
818
- return null;
819
- return { stored, present, failed };
285
+ }
286
+ const code = typeof problem.type === "string" ? problem.type : `http_${response.status}`;
287
+ const message = typeof problem.detail === "string" ? problem.detail : typeof problem.title === "string" ? problem.title : `The server responded with ${response.status}.`;
288
+ return new DeployError(code, message, problem);
289
+ }
290
+ function batchLayout(members) {
291
+ return members.map((member) => `${member.sha256} ${member.size}
292
+ `).join("");
293
+ }
294
+ function isBatch(target) {
295
+ return "files" in target;
296
+ }
297
+ function parseBatchResult(body) {
298
+ try {
299
+ const parsed = JSON.parse(body);
300
+ if (parsed === null || typeof parsed !== "object") return null;
301
+ const { stored, present, failed } = parsed;
302
+ if (typeof stored !== "number" || typeof present !== "number" || !Array.isArray(failed))
303
+ return null;
304
+ return { stored, present, failed };
820
305
  } catch {
821
306
  return null;
822
307
  }
@@ -916,7 +401,7 @@ const FILE_TYPES = [
916
401
  {
917
402
  ext: ".csv",
918
403
  mime: "text/csv; charset=utf-8",
919
- kind: "text",
404
+ kind: "csv",
920
405
  document: false,
921
406
  reader: true,
922
407
  editable: true
@@ -1326,963 +811,1528 @@ async function sha256Hex(bytes) {
1326
811
  const digest = await crypto.subtle.digest("SHA-256", bytes);
1327
812
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
1328
813
  }
1329
- const HASH_CONCURRENCY = 4;
1330
- const HASH_BYTES_IN_FLIGHT = 16 * 1024 * 1024;
1331
- async function hashAll(files, onProgress, signal) {
1332
- const budget = byteBudget(HASH_BYTES_IN_FLIGHT);
1333
- let done = 0;
1334
- return mapInOrder(
1335
- files,
1336
- HASH_CONCURRENCY,
1337
- async (file) => {
1338
- const size = sizeOf(file.bytes);
1339
- await budget.acquire(size);
1340
- try {
1341
- signal?.throwIfAborted();
1342
- const hashed = { ...file, sha256: await sha256Hex(await readForHash(file)) };
1343
- done += 1;
1344
- onProgress?.(done, files.length);
1345
- return hashed;
1346
- } finally {
1347
- budget.release(size);
814
+ const HASH_CONCURRENCY = 4;
815
+ const HASH_BYTES_IN_FLIGHT = 16 * 1024 * 1024;
816
+ async function hashAll(files, onProgress, signal) {
817
+ const budget = byteBudget(HASH_BYTES_IN_FLIGHT);
818
+ let done = 0;
819
+ return mapInOrder(
820
+ files,
821
+ HASH_CONCURRENCY,
822
+ async (file) => {
823
+ const size = sizeOf(file.bytes);
824
+ await budget.acquire(size);
825
+ try {
826
+ signal?.throwIfAborted();
827
+ const hashed = { ...file, sha256: await sha256Hex(await readForHash(file)) };
828
+ done += 1;
829
+ onProgress?.(done, files.length);
830
+ return hashed;
831
+ } finally {
832
+ budget.release(size);
833
+ }
834
+ }
835
+ );
836
+ }
837
+ function byteBudget(limit) {
838
+ let held = 0;
839
+ let waiting = [];
840
+ return {
841
+ async acquire(size) {
842
+ while (held > 0 && held + size > limit) {
843
+ await new Promise((resolve2) => waiting.push(resolve2));
844
+ }
845
+ held += size;
846
+ },
847
+ release(size) {
848
+ held -= size;
849
+ const woken = waiting;
850
+ waiting = [];
851
+ for (const wake of woken) wake();
852
+ }
853
+ };
854
+ }
855
+ async function readForHash(file) {
856
+ try {
857
+ return await bytesOf(file.bytes);
858
+ } catch (error) {
859
+ if (error instanceof RangeError) throw error;
860
+ throw new DeployError(
861
+ ClientErrorCode.ReadFailed,
862
+ `Could not read ${file.path}. It may have been moved, renamed or changed since you dropped it — drop the folder again.`,
863
+ { path: file.path, cause: describeError(error) }
864
+ );
865
+ }
866
+ }
867
+ const IGNORED_EXACT = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".gitignore", ".gitkeep"]);
868
+ const IGNORED_DIRECTORIES = [
869
+ "__MACOSX",
870
+ ".git",
871
+ "node_modules",
872
+ ".svn",
873
+ ".hg",
874
+ ".idea",
875
+ ".vscode",
876
+ ".claude",
877
+ ".agents",
878
+ ".cursor",
879
+ ".codex",
880
+ ".gemini"
881
+ ];
882
+ function isEnvironmentFile(name) {
883
+ return name === ".env" || name === ".envrc" || name.startsWith(".env.");
884
+ }
885
+ function shouldIgnore(path) {
886
+ const segments = path.split("/");
887
+ const name = segments.at(-1) ?? "";
888
+ if (name === "" || IGNORED_EXACT.has(name) || isEnvironmentFile(name)) return true;
889
+ if (name.startsWith("._")) return true;
890
+ return segments.some((segment) => IGNORED_DIRECTORIES.includes(segment));
891
+ }
892
+ const REQUIRED_INDEX = "index.html";
893
+ const DOCUMENT_EXTENSIONS = DOCUMENT_EXTENSIONS$1;
894
+ function isDocumentPath(path) {
895
+ const lower = path.toLowerCase();
896
+ return DOCUMENT_EXTENSIONS.some((extension) => lower.endsWith(extension));
897
+ }
898
+ const VIEWABLE_EXTENSIONS = VIEWABLE_EXTENSIONS$1;
899
+ function isViewablePath(path) {
900
+ const lower = path.toLowerCase();
901
+ return VIEWABLE_EXTENSIONS.some((extension) => lower.endsWith(extension));
902
+ }
903
+ function isImageFolder(paths) {
904
+ let pictures = false;
905
+ for (const path of paths) {
906
+ const ext = extensionOf(path);
907
+ if (WEB_ASSET_EXTENSIONS.includes(ext)) return false;
908
+ if (GALLERY_EXTENSIONS.includes(ext)) pictures = true;
909
+ }
910
+ return pictures;
911
+ }
912
+ function canPublish(paths) {
913
+ if (paths.some((path) => path === REQUIRED_INDEX)) return true;
914
+ if (paths.some(isDocumentPath)) return true;
915
+ if (isImageFolder(paths)) return true;
916
+ return paths.length === 1 && paths[0] !== void 0 && isViewablePath(paths[0]);
917
+ }
918
+ function servingModeOf$1(flags) {
919
+ if (flags.docsMode) return "docs";
920
+ if (flags.spaMode) return "spa";
921
+ return "static";
922
+ }
923
+ function predictedModeOf(paths) {
924
+ if (paths.some((path) => path === REQUIRED_INDEX)) return "static";
925
+ if (paths.some(isDocumentPath)) return "docs";
926
+ if (isImageFolder(paths)) return "docs";
927
+ if (paths.length === 1 && paths[0] !== void 0 && isViewablePath(paths[0])) return "docs";
928
+ return "static";
929
+ }
930
+ function nestedIndexOf(paths) {
931
+ if (predictedModeOf(paths) !== "docs") return null;
932
+ const suffix = `/${REQUIRED_INDEX}`;
933
+ const folders2 = paths.filter((path) => path.endsWith(suffix)).map((path) => path.slice(0, -suffix.length)).sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b));
934
+ return folders2[0] ?? null;
935
+ }
936
+ function renameOfLoneHtmlPage(paths) {
937
+ if (paths.length !== 1) return null;
938
+ const [only] = paths;
939
+ if (only === void 0 || only === REQUIRED_INDEX) return null;
940
+ const lower = only.toLowerCase();
941
+ return lower.endsWith(".html") || lower.endsWith(".htm") ? only : null;
942
+ }
943
+ function formatBytes$1(bytes) {
944
+ if (bytes < 1024) return `${bytes} B`;
945
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
946
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
947
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
948
+ }
949
+ function checkLimits(files, limits) {
950
+ if (files.length === 0) {
951
+ throw new DeployError(
952
+ ClientErrorCode.Empty,
953
+ "That drop contained no files to deploy. Folders like .git and node_modules are skipped."
954
+ );
955
+ }
956
+ if (!canPublish(files.map((file) => file.path))) {
957
+ throw new DeployError(
958
+ ClientErrorCode.MissingIndex,
959
+ `A site needs an ${REQUIRED_INDEX} at its top level, or at least one .md or .pdf file to publish as a documents site — or be a single file that can be displayed on its own, such as a note, a PDF, a page or an image. Drop the folder that contains it, not the folder above.`
960
+ );
961
+ }
962
+ const refusal = sizeRefusal(
963
+ files.map((file) => ({ path: file.path, size: sizeOf(file.bytes) })),
964
+ limits
965
+ );
966
+ if (refusal) throw refusal;
967
+ }
968
+ function sizeRefusal(files, limits) {
969
+ if (!limits) return null;
970
+ if (files.length > limits.maxFiles) {
971
+ return new DeployError(
972
+ ClientErrorCode.TooManyFiles,
973
+ `That is ${files.length} files, and this plan allows ${limits.maxFiles}.`,
974
+ { limit: limits.maxFiles, actual: files.length }
975
+ );
976
+ }
977
+ const oversized = files.find((file) => file.size > limits.maxFileBytes);
978
+ if (oversized) {
979
+ return new DeployError(
980
+ ClientErrorCode.FileTooLarge,
981
+ `${oversized.path} is ${formatBytes$1(oversized.size)}, and this plan allows ${formatBytes$1(limits.maxFileBytes)} per file.`,
982
+ {
983
+ path: oversized.path,
984
+ limit: limits.maxFileBytes,
985
+ actual: oversized.size
986
+ }
987
+ );
988
+ }
989
+ const total = files.reduce((sum, file) => sum + file.size, 0);
990
+ if (limits.maxSiteBytes !== null && total > limits.maxSiteBytes) {
991
+ return new DeployError(
992
+ ClientErrorCode.TooLarge,
993
+ `That drop is ${formatBytes$1(total)}, and this plan allows ${formatBytes$1(limits.maxSiteBytes)}.`,
994
+ { limit: limits.maxSiteBytes, actual: total }
995
+ );
996
+ }
997
+ return null;
998
+ }
999
+ const MAX_NAME_LENGTH = 60;
1000
+ const HEAD_BYTES = 64 * 1024;
1001
+ const TITLE_SEPARATORS = ["|", "·", "—", "–", " - "];
1002
+ function suggestSiteName(files) {
1003
+ const index = files.find((file) => file.path === "index.html");
1004
+ if (index === void 0 || !ArrayBuffer.isView(index.bytes)) return null;
1005
+ try {
1006
+ return nameFromHtml(decodeHead(index.bytes));
1007
+ } catch {
1008
+ return null;
1009
+ }
1010
+ }
1011
+ async function readSiteName(files) {
1012
+ const index = files.find((file) => file.path === "index.html");
1013
+ if (index === void 0) return null;
1014
+ if (ArrayBuffer.isView(index.bytes)) return suggestSiteName(files);
1015
+ try {
1016
+ const head = new Uint8Array(await index.bytes.slice(0, HEAD_BYTES).arrayBuffer());
1017
+ return suggestSiteName([{ path: index.path, bytes: head }]);
1018
+ } catch {
1019
+ return null;
1020
+ }
1021
+ }
1022
+ function decodeHead(bytes) {
1023
+ return new TextDecoder("utf-8", { fatal: false, ignoreBOM: false }).decode(
1024
+ bytes.subarray(0, HEAD_BYTES)
1025
+ );
1026
+ }
1027
+ function nameFromHtml(html) {
1028
+ const document = parse(html);
1029
+ const candidates = document ? [
1030
+ document.querySelector('meta[property="og:site_name"]')?.getAttribute("content"),
1031
+ document.querySelector("title")?.textContent,
1032
+ document.querySelector("h1")?.textContent
1033
+ ] : [matchOgSiteName(html), matchTag(html, "title"), matchTag(html, "h1")];
1034
+ for (const candidate of candidates) {
1035
+ const name = clean(candidate);
1036
+ if (name !== null) return name;
1037
+ }
1038
+ return null;
1039
+ }
1040
+ function parse(html) {
1041
+ const parser = globalThis.DOMParser;
1042
+ if (parser === void 0) return null;
1043
+ try {
1044
+ return new parser().parseFromString(html, "text/html");
1045
+ } catch {
1046
+ return null;
1047
+ }
1048
+ }
1049
+ function matchTag(html, tag) {
1050
+ const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(html);
1051
+ return match?.[1] === void 0 ? null : decodeEntities(match[1]);
1052
+ }
1053
+ function matchOgSiteName(html) {
1054
+ const patterns = [
1055
+ /<meta[^>]*property\s*=\s*["']og:site_name["'][^>]*content\s*=\s*["']([^"']*)["']/i,
1056
+ /<meta[^>]*content\s*=\s*["']([^"']*)["'][^>]*property\s*=\s*["']og:site_name["']/i
1057
+ ];
1058
+ for (const pattern of patterns) {
1059
+ const match = pattern.exec(html);
1060
+ if (match?.[1] !== void 0) return decodeEntities(match[1]);
1061
+ }
1062
+ return null;
1063
+ }
1064
+ 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(" ");
1065
+ const NAMED_ENTITIES = new Map([
1066
+ ...LATIN1_ENTITIES.map((name, index) => [name, 160 + index]),
1067
+ ["amp", 38],
1068
+ ["lt", 60],
1069
+ ["gt", 62],
1070
+ ["quot", 34],
1071
+ ["apos", 39],
1072
+ ["ensp", 8194],
1073
+ ["emsp", 8195],
1074
+ ["thinsp", 8201],
1075
+ ["ndash", 8211],
1076
+ ["mdash", 8212],
1077
+ ["lsquo", 8216],
1078
+ ["rsquo", 8217],
1079
+ ["sbquo", 8218],
1080
+ ["ldquo", 8220],
1081
+ ["rdquo", 8221],
1082
+ ["bdquo", 8222],
1083
+ ["dagger", 8224],
1084
+ ["Dagger", 8225],
1085
+ ["bull", 8226],
1086
+ ["hellip", 8230],
1087
+ ["prime", 8242],
1088
+ ["lsaquo", 8249],
1089
+ ["rsaquo", 8250],
1090
+ ["euro", 8364],
1091
+ ["trade", 8482],
1092
+ ["larr", 8592],
1093
+ ["rarr", 8594],
1094
+ ["hearts", 9829]
1095
+ ]);
1096
+ function decodeEntities(text) {
1097
+ return text.replace(
1098
+ /&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{1,31}));/g,
1099
+ (whole, decimal, hex, name) => {
1100
+ if (name !== void 0) {
1101
+ const code2 = NAMED_ENTITIES.get(name);
1102
+ return code2 === void 0 ? whole : String.fromCodePoint(code2);
1348
1103
  }
1104
+ const code = decimal !== void 0 ? Number(decimal) : Number.parseInt(hex ?? "", 16);
1105
+ const valid = code > 0 && code <= 1114111 && (code < 55296 || code > 57343);
1106
+ return String.fromCodePoint(valid ? code : 65533);
1349
1107
  }
1350
1108
  );
1351
1109
  }
1352
- function byteBudget(limit) {
1353
- let held = 0;
1110
+ const INVISIBLE = new RegExp(
1111
+ `[${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)}]`,
1112
+ "g"
1113
+ );
1114
+ function clean(raw) {
1115
+ if (raw === null || raw === void 0) return null;
1116
+ if (raw.includes("<") || raw.includes(">")) return null;
1117
+ const collapsed = raw.replace(INVISIBLE, " ").replace(/\s+/g, " ").trim();
1118
+ const name = trimSuffix(collapsed);
1119
+ if (name.length === 0) return null;
1120
+ if (PLACEHOLDERS.has(name.toLowerCase())) return null;
1121
+ return name.length > MAX_NAME_LENGTH ? name.slice(0, MAX_NAME_LENGTH).trimEnd() : name;
1122
+ }
1123
+ const PLACEHOLDERS = /* @__PURE__ */ new Set([
1124
+ "document",
1125
+ "untitled",
1126
+ "untitled document",
1127
+ "index",
1128
+ "home",
1129
+ "new page",
1130
+ "my site",
1131
+ "vite app",
1132
+ "vite + react",
1133
+ "react app",
1134
+ "create next app",
1135
+ "svelte app",
1136
+ "webpack app",
1137
+ "hello world",
1138
+ "title"
1139
+ ]);
1140
+ function trimSuffix(title) {
1141
+ for (const separator of TITLE_SEPARATORS) {
1142
+ const at = title.lastIndexOf(separator);
1143
+ if (at <= 0) continue;
1144
+ const before = title.slice(0, at).trim();
1145
+ const after = title.slice(at + separator.length).trim();
1146
+ if (before.length > 0 && after.length > 0) return after;
1147
+ }
1148
+ return title;
1149
+ }
1150
+ const DEFAULT_CONCURRENCY = 8;
1151
+ const MAX_BATCH_CONCURRENCY = 64;
1152
+ const DEFAULT_BYTES_IN_FLIGHT = 64 * 1024 * 1024;
1153
+ const DEFAULT_RETRIES = 3;
1154
+ const BACKOFF_MS = [1e3, 2e3, 4e3];
1155
+ const DEFAULT_STALL_MS = 3e4;
1156
+ const PROGRESS_INTERVAL_MS = 200;
1157
+ class StalledError extends Error {
1158
+ /** @param seconds How long nothing moved. */
1159
+ constructor(seconds) {
1160
+ super(`No data moved for ${seconds}s.`);
1161
+ this.name = "StalledError";
1162
+ }
1163
+ }
1164
+ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {}, onRetry) {
1165
+ const put = deps.put ?? defaultTransport(deps.fetch);
1166
+ const delay = deps.delay ?? defaultDelay;
1167
+ const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
1168
+ const batchConcurrency = Math.max(
1169
+ 1,
1170
+ Math.min(MAX_BATCH_CONCURRENCY, Math.floor(deps.batchConcurrency ?? concurrency))
1171
+ );
1172
+ const retries = deps.retries ?? DEFAULT_RETRIES;
1173
+ const stallMs = deps.stallMs ?? DEFAULT_STALL_MS;
1174
+ const now = deps.now ?? Date.now;
1175
+ const maxBytesInFlight = deps.maxBytesInFlight ?? DEFAULT_BYTES_IN_FLIGHT;
1176
+ const byPath = new Map(files.map((file) => [file.path, file]));
1177
+ const fileCount = targets.reduce(
1178
+ (sum, target) => sum + (isBatch(target) ? target.files.length : 1),
1179
+ 0
1180
+ );
1181
+ let nextIndex = 0;
1182
+ let done = 0;
1183
+ let failed = false;
1184
+ let firstFailure;
1185
+ const local = new AbortController();
1186
+ const inner = local.signal;
1187
+ const fail = (error) => {
1188
+ if (failed) return;
1189
+ failed = true;
1190
+ firstFailure = error;
1191
+ local.abort();
1192
+ };
1193
+ let reserved = 0;
1354
1194
  let waiting = [];
1355
- return {
1356
- async acquire(size) {
1357
- while (held > 0 && held + size > limit) {
1358
- await new Promise((resolve2) => waiting.push(resolve2));
1195
+ const acquire = async (size) => {
1196
+ while (reserved > 0 && reserved + size > maxBytesInFlight) {
1197
+ await new Promise((resolve2) => waiting.push(resolve2));
1198
+ }
1199
+ reserved += size;
1200
+ };
1201
+ const release = (size) => {
1202
+ reserved -= size;
1203
+ const woken = waiting;
1204
+ waiting = [];
1205
+ for (const wake of woken) wake();
1206
+ };
1207
+ const running = { single: 0, batch: 0 };
1208
+ const limit = { single: concurrency, batch: batchConcurrency };
1209
+ let slotWaiting = [];
1210
+ const takeSlot = async (kind) => {
1211
+ while (running[kind] >= limit[kind]) {
1212
+ await new Promise((resolve2) => slotWaiting.push(resolve2));
1213
+ }
1214
+ running[kind] += 1;
1215
+ };
1216
+ const freeSlot = (kind) => {
1217
+ running[kind] -= 1;
1218
+ const woken = slotWaiting;
1219
+ slotWaiting = [];
1220
+ for (const wake of woken) wake();
1221
+ };
1222
+ let landedBytes = 0;
1223
+ const inFlight = /* @__PURE__ */ new Map();
1224
+ let lastReport = Number.NEGATIVE_INFINITY;
1225
+ const report = (path) => {
1226
+ if (onProgress === void 0 || failed) return;
1227
+ const at = now();
1228
+ if (path === void 0 && at - lastReport < PROGRESS_INTERVAL_MS) return;
1229
+ lastReport = at;
1230
+ let bytes = landedBytes;
1231
+ for (const sent of inFlight.values()) bytes += sent;
1232
+ onProgress(done, fileCount, bytes, path);
1233
+ };
1234
+ const worker = async () => {
1235
+ for (; ; ) {
1236
+ if (failed) return;
1237
+ const index = nextIndex++;
1238
+ const target = targets[index];
1239
+ if (target === void 0) return;
1240
+ inner.throwIfAborted();
1241
+ const refs = isBatch(target) ? target.files : [target];
1242
+ const members = [];
1243
+ for (const ref of refs) {
1244
+ const file = byPath.get(ref.path);
1245
+ if (file === void 0) {
1246
+ const missing = new DeployError(
1247
+ ClientErrorCode.UploadFailed,
1248
+ `The server asked for ${ref.path}, which is not in the manifest.`,
1249
+ { path: ref.path }
1250
+ );
1251
+ fail(missing);
1252
+ throw missing;
1253
+ }
1254
+ members.push(file);
1255
+ }
1256
+ const layout = isBatch(target) ? new TextEncoder().encode(
1257
+ batchLayout(
1258
+ target.files.map((ref, i) => ({
1259
+ sha256: ref.sha256,
1260
+ size: sizeOf(members[i].bytes)
1261
+ }))
1262
+ )
1263
+ ) : null;
1264
+ const size = members.reduce((sum, file) => sum + sizeOf(file.bytes), layout?.length ?? 0);
1265
+ const copied = isBatch(target) ? members.reduce(
1266
+ (sum, file) => sum + (file.bytes instanceof Blob ? 0 : sizeOf(file.bytes)),
1267
+ 0
1268
+ ) : 0;
1269
+ const reservation = size + copied;
1270
+ const first = members[0].path;
1271
+ const label = members.length === 1 ? first : `${first} and ${members.length - 1} more`;
1272
+ const kind = isBatch(target) ? "batch" : "single";
1273
+ await takeSlot(kind);
1274
+ await acquire(reservation);
1275
+ try {
1276
+ if (failed) return;
1277
+ inner.throwIfAborted();
1278
+ const context = {
1279
+ put,
1280
+ delay,
1281
+ retries,
1282
+ signal: inner,
1283
+ uploadUrl,
1284
+ stallMs,
1285
+ onSent: (sent) => {
1286
+ inFlight.set(index, sent);
1287
+ report();
1288
+ },
1289
+ onRetry: (attempt, stalled) => {
1290
+ inFlight.set(index, 0);
1291
+ onRetry?.(label, attempt, retries + 1, stalled);
1292
+ }
1293
+ };
1294
+ if (isBatch(target) && layout !== null)
1295
+ await uploadBatchOne(target, members, layout, context);
1296
+ else await uploadOne(target, members[0], context);
1297
+ } catch (error) {
1298
+ fail(error);
1299
+ throw error;
1300
+ } finally {
1301
+ release(reservation);
1302
+ freeSlot(kind);
1359
1303
  }
1360
- held += size;
1361
- },
1362
- release(size) {
1363
- held -= size;
1364
- const woken = waiting;
1365
- waiting = [];
1366
- for (const wake of woken) wake();
1304
+ inFlight.delete(index);
1305
+ done += members.length;
1306
+ landedBytes += size;
1307
+ report(members.at(-1).path);
1367
1308
  }
1368
1309
  };
1369
- }
1370
- async function readForHash(file) {
1310
+ const workers = Array.from(
1311
+ { length: Math.min(Math.max(concurrency, batchConcurrency), targets.length) },
1312
+ worker
1313
+ );
1371
1314
  try {
1372
- return await bytesOf(file.bytes);
1315
+ await Promise.all(workers);
1373
1316
  } catch (error) {
1374
- if (error instanceof RangeError) throw error;
1375
- throw new DeployError(
1376
- ClientErrorCode.ReadFailed,
1377
- `Could not read ${file.path}. It may have been moved, renamed or changed since you dropped it — drop the folder again.`,
1378
- { path: file.path, cause: describeError(error) }
1379
- );
1380
- }
1381
- }
1382
- const IGNORED_EXACT = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".gitignore", ".gitkeep"]);
1383
- const IGNORED_DIRECTORIES = [
1384
- "__MACOSX",
1385
- ".git",
1386
- "node_modules",
1387
- ".svn",
1388
- ".hg",
1389
- ".idea",
1390
- ".vscode",
1391
- ".claude",
1392
- ".agents",
1393
- ".cursor",
1394
- ".codex",
1395
- ".gemini"
1396
- ];
1397
- function isEnvironmentFile(name) {
1398
- return name === ".env" || name === ".envrc" || name.startsWith(".env.");
1399
- }
1400
- function shouldIgnore(path) {
1401
- const segments = path.split("/");
1402
- const name = segments.at(-1) ?? "";
1403
- if (name === "" || IGNORED_EXACT.has(name) || isEnvironmentFile(name)) return true;
1404
- if (name.startsWith("._")) return true;
1405
- return segments.some((segment) => IGNORED_DIRECTORIES.includes(segment));
1406
- }
1407
- const REQUIRED_INDEX = "index.html";
1408
- const DOCUMENT_EXTENSIONS = DOCUMENT_EXTENSIONS$1;
1409
- function isDocumentPath(path) {
1410
- const lower = path.toLowerCase();
1411
- return DOCUMENT_EXTENSIONS.some((extension) => lower.endsWith(extension));
1412
- }
1413
- const VIEWABLE_EXTENSIONS = VIEWABLE_EXTENSIONS$1;
1414
- function isViewablePath(path) {
1415
- const lower = path.toLowerCase();
1416
- return VIEWABLE_EXTENSIONS.some((extension) => lower.endsWith(extension));
1417
- }
1418
- function isImageFolder(paths) {
1419
- let pictures = false;
1420
- for (const path of paths) {
1421
- const ext = extensionOf(path);
1422
- if (WEB_ASSET_EXTENSIONS.includes(ext)) return false;
1423
- if (GALLERY_EXTENSIONS.includes(ext)) pictures = true;
1424
- }
1425
- return pictures;
1426
- }
1427
- function canPublish(paths) {
1428
- if (paths.some((path) => path === REQUIRED_INDEX)) return true;
1429
- if (paths.some(isDocumentPath)) return true;
1430
- if (isImageFolder(paths)) return true;
1431
- return paths.length === 1 && paths[0] !== void 0 && isViewablePath(paths[0]);
1432
- }
1433
- function servingModeOf(flags) {
1434
- if (flags.docsMode) return "docs";
1435
- if (flags.spaMode) return "spa";
1436
- return "static";
1437
- }
1438
- function predictedModeOf(paths) {
1439
- if (paths.some((path) => path === REQUIRED_INDEX)) return "static";
1440
- if (paths.some(isDocumentPath)) return "docs";
1441
- if (isImageFolder(paths)) return "docs";
1442
- if (paths.length === 1 && paths[0] !== void 0 && isViewablePath(paths[0])) return "docs";
1443
- return "static";
1444
- }
1445
- function nestedIndexOf(paths) {
1446
- if (predictedModeOf(paths) !== "docs") return null;
1447
- const suffix = `/${REQUIRED_INDEX}`;
1448
- const folders2 = paths.filter((path) => path.endsWith(suffix)).map((path) => path.slice(0, -suffix.length)).sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b));
1449
- return folders2[0] ?? null;
1450
- }
1451
- function renameOfLoneHtmlPage(paths) {
1452
- if (paths.length !== 1) return null;
1453
- const [only] = paths;
1454
- if (only === void 0 || only === REQUIRED_INDEX) return null;
1455
- const lower = only.toLowerCase();
1456
- return lower.endsWith(".html") || lower.endsWith(".htm") ? only : null;
1457
- }
1458
- function formatBytes$1(bytes) {
1459
- if (bytes < 1024) return `${bytes} B`;
1460
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1461
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1462
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
1463
- }
1464
- function checkLimits(files, limits) {
1465
- if (files.length === 0) {
1466
- throw new DeployError(
1467
- ClientErrorCode.Empty,
1468
- "That drop contained no files to deploy. Folders like .git and node_modules are skipped."
1469
- );
1470
- }
1471
- if (!canPublish(files.map((file) => file.path))) {
1472
- throw new DeployError(
1473
- ClientErrorCode.MissingIndex,
1474
- `A site needs an ${REQUIRED_INDEX} at its top level, or at least one .md or .pdf file to publish as a documents site — or be a single file that can be displayed on its own, such as a note, a PDF, a page or an image. Drop the folder that contains it, not the folder above.`
1475
- );
1317
+ throw asDeployError(firstFailure ?? error);
1318
+ } finally {
1476
1319
  }
1477
- const refusal = sizeRefusal(
1478
- files.map((file) => ({ path: file.path, size: sizeOf(file.bytes) })),
1479
- limits
1480
- );
1481
- if (refusal) throw refusal;
1482
1320
  }
1483
- function sizeRefusal(files, limits) {
1484
- if (!limits) return null;
1485
- if (files.length > limits.maxFiles) {
1486
- return new DeployError(
1487
- ClientErrorCode.TooManyFiles,
1488
- `That is ${files.length} files, and this plan allows ${limits.maxFiles}.`,
1489
- { limit: limits.maxFiles, actual: files.length }
1490
- );
1491
- }
1492
- const oversized = files.find((file) => file.size > limits.maxFileBytes);
1493
- if (oversized) {
1494
- return new DeployError(
1495
- ClientErrorCode.FileTooLarge,
1496
- `${oversized.path} is ${formatBytes$1(oversized.size)}, and this plan allows ${formatBytes$1(limits.maxFileBytes)} per file.`,
1497
- {
1498
- path: oversized.path,
1499
- limit: limits.maxFileBytes,
1500
- actual: oversized.size
1321
+ async function uploadOne(target, file, context) {
1322
+ let lastError;
1323
+ for (let attempt = 0; attempt <= context.retries; attempt++) {
1324
+ context.signal?.throwIfAborted();
1325
+ if (attempt > 0) {
1326
+ context.onRetry(attempt + 1, lastError instanceof StalledError);
1327
+ await context.delay(BACKOFF_MS[attempt - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
1328
+ context.signal?.throwIfAborted();
1329
+ }
1330
+ try {
1331
+ const answer = await context.put({
1332
+ url: context.uploadUrl,
1333
+ token: target.token,
1334
+ body: untyped(file.bytes),
1335
+ signal: context.signal,
1336
+ onSent: context.onSent,
1337
+ stallMs: context.stallMs
1338
+ });
1339
+ const status = typeof answer === "number" ? answer : answer.status;
1340
+ if (status >= 200 && status < 300) return;
1341
+ if (status === 401 || status === 403 || status === 429) {
1342
+ throw new DeployError(
1343
+ ClientErrorCode.UploadFailed,
1344
+ `Upload of ${target.path} was refused. The upload window may have expired — try deploying again.`,
1345
+ { path: target.path, status }
1346
+ );
1501
1347
  }
1502
- );
1503
- }
1504
- const total = files.reduce((sum, file) => sum + file.size, 0);
1505
- if (limits.maxSiteBytes !== null && total > limits.maxSiteBytes) {
1506
- return new DeployError(
1507
- ClientErrorCode.TooLarge,
1508
- `That drop is ${formatBytes$1(total)}, and this plan allows ${formatBytes$1(limits.maxSiteBytes)}.`,
1509
- { limit: limits.maxSiteBytes, actual: total }
1510
- );
1348
+ if (status === 413) {
1349
+ throw new DeployError(
1350
+ ClientErrorCode.UploadFailed,
1351
+ `${target.path} is too large to upload in one request. Make it smaller, or leave it out of this publish.`,
1352
+ { path: target.path, status }
1353
+ );
1354
+ }
1355
+ lastError = new Error(`HTTP ${status}`);
1356
+ } catch (error) {
1357
+ if (error instanceof DeployError) throw error;
1358
+ if (isAbort(error)) throw cancelled();
1359
+ lastError = error;
1360
+ }
1511
1361
  }
1512
- return null;
1362
+ 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.`;
1363
+ throw new DeployError(ClientErrorCode.UploadFailed, message, {
1364
+ path: target.path,
1365
+ cause: describeError(lastError)
1366
+ });
1513
1367
  }
1514
- const MAX_NAME_LENGTH = 60;
1515
- const HEAD_BYTES = 64 * 1024;
1516
- const TITLE_SEPARATORS = ["|", "·", "—", "–", " - "];
1517
- function suggestSiteName(files) {
1518
- const index = files.find((file) => file.path === "index.html");
1519
- if (index === void 0 || !ArrayBuffer.isView(index.bytes)) return null;
1520
- try {
1521
- return nameFromHtml(decodeHead(index.bytes));
1522
- } catch {
1523
- return null;
1368
+ async function uploadBatchOne(batch, members, layout, context) {
1369
+ const first = members[0].path;
1370
+ const label = members.length === 1 ? first : `${first} and ${members.length - 1} more`;
1371
+ let lastError;
1372
+ for (let attempt = 0; attempt <= context.retries; attempt++) {
1373
+ context.signal?.throwIfAborted();
1374
+ if (attempt > 0) {
1375
+ context.onRetry(attempt + 1, lastError instanceof StalledError);
1376
+ await context.delay(BACKOFF_MS[attempt - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
1377
+ context.signal?.throwIfAborted();
1378
+ }
1379
+ try {
1380
+ const body = new Blob([layout, ...members.map((file) => file.bytes)]);
1381
+ const answer = await context.put({
1382
+ url: context.uploadUrl,
1383
+ token: batch.token,
1384
+ body,
1385
+ signal: context.signal,
1386
+ onSent: context.onSent,
1387
+ stallMs: context.stallMs,
1388
+ wantsBody: true
1389
+ });
1390
+ const status = typeof answer === "number" ? answer : answer.status;
1391
+ if (status >= 200 && status < 300) {
1392
+ const result = typeof answer === "number" ? null : parseBatchResult(answer.body);
1393
+ if (result === null) {
1394
+ lastError = new Error(
1395
+ "The upload service answered with something that is not a batch result."
1396
+ );
1397
+ continue;
1398
+ }
1399
+ if (result.failed.length === 0) return;
1400
+ const changed = result.failed.find((failure2) => failure2.reason === "hash_mismatch");
1401
+ if (changed !== void 0) {
1402
+ const path = members[changed.i]?.path ?? first;
1403
+ throw new DeployError(
1404
+ ClientErrorCode.ReadFailed,
1405
+ `${path} changed after it was read, so it no longer matches what was checked. Drop the folder again.`,
1406
+ { path }
1407
+ );
1408
+ }
1409
+ lastError = new Error(
1410
+ `${result.failed.length} of ${members.length} files were not stored.`
1411
+ );
1412
+ continue;
1413
+ }
1414
+ if ([400, 401, 403, 411, 413, 429].includes(status)) {
1415
+ throw new DeployError(ClientErrorCode.UploadFailed, refusalMessage(status, label), {
1416
+ path: first,
1417
+ status
1418
+ });
1419
+ }
1420
+ lastError = new Error(`HTTP ${status}`);
1421
+ } catch (error) {
1422
+ if (error instanceof DeployError) throw error;
1423
+ if (isAbort(error)) throw cancelled();
1424
+ lastError = error;
1425
+ }
1524
1426
  }
1427
+ const message = lastError instanceof StalledError ? `Upload of ${label} stopped moving and did not recover after ${context.retries + 1} attempts. Check your connection and try again.` : `Could not upload ${label} after ${context.retries + 1} attempts.`;
1428
+ throw new DeployError(ClientErrorCode.UploadFailed, message, {
1429
+ path: first,
1430
+ cause: describeError(lastError)
1431
+ });
1525
1432
  }
1526
- async function readSiteName(files) {
1527
- const index = files.find((file) => file.path === "index.html");
1528
- if (index === void 0) return null;
1529
- if (ArrayBuffer.isView(index.bytes)) return suggestSiteName(files);
1530
- try {
1531
- const head = new Uint8Array(await index.bytes.slice(0, HEAD_BYTES).arrayBuffer());
1532
- return suggestSiteName([{ path: index.path, bytes: head }]);
1533
- } catch {
1534
- return null;
1433
+ function refusalMessage(status, label) {
1434
+ switch (status) {
1435
+ case 401:
1436
+ return `Upload of ${label} was refused because its upload window expired. Try deploying again.`;
1437
+ case 429:
1438
+ return `Upload of ${label} was refused: this deploy has used every upload it was given. Try deploying again.`;
1439
+ case 400:
1440
+ case 411:
1441
+ case 413:
1442
+ return `Upload of ${label} was refused because it no longer matches what was checked — a file may have changed since you dropped it. Drop the folder again.`;
1443
+ default:
1444
+ return `Upload of ${label} was refused by the upload service. Try deploying again.`;
1535
1445
  }
1536
1446
  }
1537
- function decodeHead(bytes) {
1538
- return new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(0, HEAD_BYTES));
1447
+ function untyped(content) {
1448
+ if (ArrayBuffer.isView(content) || content.type === "") return content;
1449
+ return content.slice(0, content.size);
1539
1450
  }
1540
- function nameFromHtml(html) {
1541
- const document = parse(html);
1542
- const candidates = document ? [
1543
- document.querySelector('meta[property="og:site_name"]')?.getAttribute("content"),
1544
- document.querySelector("title")?.textContent,
1545
- document.querySelector("h1")?.textContent
1546
- ] : [matchOgSiteName(html), matchTag(html, "title"), matchTag(html, "h1")];
1547
- for (const candidate of candidates) {
1548
- const name = clean(candidate);
1549
- if (name !== null) return name;
1550
- }
1551
- return null;
1451
+ function defaultTransport(doFetch) {
1452
+ if (doFetch !== void 0) return fetchTransport(doFetch);
1453
+ const Xhr = globalThis.XMLHttpRequest;
1454
+ return Xhr === void 0 ? fetchTransport(globalFetch()) : xhrTransport(Xhr);
1552
1455
  }
1553
- function parse(html) {
1554
- const parser = globalThis.DOMParser;
1555
- if (parser === void 0) return null;
1556
- try {
1557
- return new parser().parseFromString(html, "text/html");
1558
- } catch {
1559
- return null;
1560
- }
1456
+ function fetchTransport(doFetch) {
1457
+ return async (request2) => {
1458
+ const { url, token, body, signal } = request2;
1459
+ const response = await doFetch(url, {
1460
+ method: "PUT",
1461
+ // The view, not a copy. Wrapping it in `new Blob([body.slice().buffer])` made two extra
1462
+ // full-size copies of every file in flight — eight at once, so up to 400 MB on a drop of
1463
+ // 50 MB files. `fetch` sends exactly the range a view covers. The cast is TypeScript's
1464
+ // SharedArrayBuffer distinction again; see `sha256Hex`. A `Blob` goes as it is, and the runtime
1465
+ // streams it from disk.
1466
+ body,
1467
+ // The permit is the whole request. `Content-Length` is required by the Worker and cannot be
1468
+ // set here — it is a forbidden header name — but the runtime derives it from the body, which
1469
+ // is exactly the length the token names.
1470
+ headers: { Authorization: `Bearer ${token}` },
1471
+ ...signal ? { signal } : {}
1472
+ });
1473
+ return request2.wantsBody === true ? { status: response.status, body: await response.text() } : response.status;
1474
+ };
1561
1475
  }
1562
- function matchTag(html, tag) {
1563
- const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(html);
1564
- return match?.[1] === void 0 ? null : decodeEntities(match[1]);
1476
+ function xhrTransport(Xhr) {
1477
+ return ({ url, token, body, signal, onSent, stallMs, wantsBody }) => new Promise((resolve2, reject) => {
1478
+ const xhr = new Xhr();
1479
+ let settled = false;
1480
+ let timer;
1481
+ const settle = (finish) => {
1482
+ if (settled) return;
1483
+ settled = true;
1484
+ if (timer !== void 0) clearTimeout(timer);
1485
+ signal?.removeEventListener("abort", onAbort);
1486
+ finish();
1487
+ };
1488
+ const arm = () => {
1489
+ if (timer !== void 0) clearTimeout(timer);
1490
+ timer = setTimeout(() => {
1491
+ xhr.abort();
1492
+ settle(() => reject(new StalledError(Math.round(stallMs / 1e3))));
1493
+ }, stallMs);
1494
+ };
1495
+ function onAbort() {
1496
+ xhr.abort();
1497
+ settle(() => reject(abortReason(signal)));
1498
+ }
1499
+ if (signal?.aborted) {
1500
+ reject(abortReason(signal));
1501
+ return;
1502
+ }
1503
+ signal?.addEventListener("abort", onAbort, { once: true });
1504
+ xhr.open("PUT", url);
1505
+ xhr.setRequestHeader("Authorization", `Bearer ${token}`);
1506
+ xhr.upload.onprogress = (event) => {
1507
+ arm();
1508
+ onSent(event.loaded);
1509
+ };
1510
+ xhr.onprogress = arm;
1511
+ xhr.onload = () => settle(
1512
+ () => resolve2(
1513
+ wantsBody === true ? { status: xhr.status, body: xhr.responseText ?? "" } : xhr.status
1514
+ )
1515
+ );
1516
+ xhr.onerror = () => settle(() => reject(new TypeError("Network error during upload.")));
1517
+ arm();
1518
+ xhr.send(body);
1519
+ });
1565
1520
  }
1566
- function matchOgSiteName(html) {
1567
- const patterns = [
1568
- /<meta[^>]*property\s*=\s*["']og:site_name["'][^>]*content\s*=\s*["']([^"']*)["']/i,
1569
- /<meta[^>]*content\s*=\s*["']([^"']*)["'][^>]*property\s*=\s*["']og:site_name["']/i
1570
- ];
1571
- for (const pattern of patterns) {
1572
- const match = pattern.exec(html);
1573
- if (match?.[1] !== void 0) return decodeEntities(match[1]);
1574
- }
1575
- return null;
1521
+ function abortReason(signal) {
1522
+ const reason = signal?.reason;
1523
+ if (reason instanceof Error) return reason;
1524
+ const error = new Error("Aborted.");
1525
+ error.name = "AbortError";
1526
+ return error;
1576
1527
  }
1577
- 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(" ");
1578
- const NAMED_ENTITIES = new Map([
1579
- ...LATIN1_ENTITIES.map((name, index) => [name, 160 + index]),
1580
- ["amp", 38],
1581
- ["lt", 60],
1582
- ["gt", 62],
1583
- ["quot", 34],
1584
- ["apos", 39],
1585
- ["ensp", 8194],
1586
- ["emsp", 8195],
1587
- ["thinsp", 8201],
1588
- ["ndash", 8211],
1589
- ["mdash", 8212],
1590
- ["lsquo", 8216],
1591
- ["rsquo", 8217],
1592
- ["sbquo", 8218],
1593
- ["ldquo", 8220],
1594
- ["rdquo", 8221],
1595
- ["bdquo", 8222],
1596
- ["dagger", 8224],
1597
- ["Dagger", 8225],
1598
- ["bull", 8226],
1599
- ["hellip", 8230],
1600
- ["prime", 8242],
1601
- ["lsaquo", 8249],
1602
- ["rsaquo", 8250],
1603
- ["euro", 8364],
1604
- ["trade", 8482],
1605
- ["larr", 8592],
1606
- ["rarr", 8594],
1607
- ["hearts", 9829]
1608
- ]);
1609
- function decodeEntities(text) {
1610
- return text.replace(
1611
- /&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{1,31}));/g,
1612
- (whole, decimal, hex, name) => {
1613
- if (name !== void 0) {
1614
- const code2 = NAMED_ENTITIES.get(name);
1615
- return code2 === void 0 ? whole : String.fromCodePoint(code2);
1616
- }
1617
- const code = decimal !== void 0 ? Number(decimal) : Number.parseInt(hex ?? "", 16);
1618
- const valid = code > 0 && code <= 1114111 && (code < 55296 || code > 57343);
1619
- return String.fromCodePoint(valid ? code : 65533);
1620
- }
1621
- );
1528
+ function globalFetch() {
1529
+ return globalThis.fetch.bind(globalThis);
1622
1530
  }
1623
- const INVISIBLE = new RegExp(
1624
- `[${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)}]`,
1625
- "g"
1626
- );
1627
- function clean(raw) {
1628
- if (raw === null || raw === void 0) return null;
1629
- if (raw.includes("<") || raw.includes(">")) return null;
1630
- const collapsed = raw.replace(INVISIBLE, " ").replace(/\s+/g, " ").trim();
1631
- const name = trimSuffix(collapsed);
1632
- if (name.length === 0) return null;
1633
- if (PLACEHOLDERS.has(name.toLowerCase())) return null;
1634
- return name.length > MAX_NAME_LENGTH ? name.slice(0, MAX_NAME_LENGTH).trimEnd() : name;
1531
+ function defaultDelay(ms) {
1532
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1635
1533
  }
1636
- const PLACEHOLDERS = /* @__PURE__ */ new Set([
1637
- "document",
1638
- "untitled",
1639
- "untitled document",
1640
- "index",
1641
- "home",
1642
- "new page",
1643
- "my site",
1644
- "vite app",
1645
- "vite + react",
1646
- "react app",
1647
- "create next app",
1648
- "svelte app",
1649
- "webpack app",
1650
- "hello world",
1651
- "title"
1652
- ]);
1653
- function trimSuffix(title) {
1654
- for (const separator of TITLE_SEPARATORS) {
1655
- const at = title.lastIndexOf(separator);
1656
- if (at <= 0) continue;
1657
- const before = title.slice(0, at).trim();
1658
- const after = title.slice(at + separator.length).trim();
1659
- if (before.length > 0 && after.length > 0) return after;
1660
- }
1661
- return title;
1534
+ function isAbort(error) {
1535
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
1662
1536
  }
1663
- const DEFAULT_CONCURRENCY = 8;
1664
- const MAX_BATCH_CONCURRENCY = 64;
1665
- const DEFAULT_BYTES_IN_FLIGHT = 64 * 1024 * 1024;
1666
- const DEFAULT_RETRIES = 3;
1667
- const BACKOFF_MS = [1e3, 2e3, 4e3];
1668
- const DEFAULT_STALL_MS = 3e4;
1669
- const PROGRESS_INTERVAL_MS = 200;
1670
- class StalledError extends Error {
1671
- /** @param seconds How long nothing moved. */
1672
- constructor(seconds) {
1673
- super(`No data moved for ${seconds}s.`);
1674
- this.name = "StalledError";
1675
- }
1537
+ function cancelled() {
1538
+ return new DeployError(ClientErrorCode.Cancelled, "Deploy cancelled.");
1676
1539
  }
1677
- async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {}, onRetry) {
1678
- const put = deps.put ?? defaultTransport(deps.fetch);
1679
- const delay = deps.delay ?? defaultDelay;
1680
- const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
1681
- const batchConcurrency = Math.max(
1682
- 1,
1683
- Math.min(MAX_BATCH_CONCURRENCY, Math.floor(deps.batchConcurrency ?? concurrency))
1540
+ function outOfMemory(detail) {
1541
+ return new DeployError(
1542
+ ClientErrorCode.OutOfMemory,
1543
+ "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.",
1544
+ detail
1684
1545
  );
1685
- const retries = deps.retries ?? DEFAULT_RETRIES;
1686
- const stallMs = deps.stallMs ?? DEFAULT_STALL_MS;
1687
- const now = deps.now ?? Date.now;
1688
- const maxBytesInFlight = deps.maxBytesInFlight ?? DEFAULT_BYTES_IN_FLIGHT;
1689
- const byPath = new Map(files.map((file) => [file.path, file]));
1690
- const fileCount = targets.reduce(
1691
- (sum, target) => sum + (isBatch(target) ? target.files.length : 1),
1692
- 0
1546
+ }
1547
+ function asDeployError(error) {
1548
+ if (error instanceof DeployError) return error;
1549
+ if (isAbort(error)) return cancelled();
1550
+ if (error instanceof RangeError) return outOfMemory(error.message);
1551
+ return new DeployError(
1552
+ ClientErrorCode.UploadFailed,
1553
+ error instanceof Error ? error.message : "Upload failed.",
1554
+ error
1693
1555
  );
1694
- let nextIndex = 0;
1695
- let done = 0;
1696
- let failed = false;
1697
- let firstFailure;
1698
- const local = new AbortController();
1699
- const inner = local.signal;
1700
- const fail = (error) => {
1701
- if (failed) return;
1702
- failed = true;
1703
- firstFailure = error;
1704
- local.abort();
1705
- };
1706
- let reserved = 0;
1707
- let waiting = [];
1708
- const acquire = async (size) => {
1709
- while (reserved > 0 && reserved + size > maxBytesInFlight) {
1710
- await new Promise((resolve2) => waiting.push(resolve2));
1556
+ }
1557
+ async function deploy(source, siteId, onProgress, signal, options = {}) {
1558
+ try {
1559
+ onProgress({ type: "reading", bytes: 0, total: 0 });
1560
+ const collected = await source(
1561
+ signal,
1562
+ (bytes, total) => onProgress({ type: "reading", bytes, total })
1563
+ );
1564
+ onProgress({ type: "collecting", files: collected.length });
1565
+ const rename = renameOfLoneHtmlPage(collected.map((file) => file.path));
1566
+ const files = rename === null ? collected : collected.map((file) => file.path === rename ? { ...file, path: "index.html" } : file);
1567
+ const hashed = await hashAll(
1568
+ files,
1569
+ (done, total) => onProgress({ type: "hashing", done, total }),
1570
+ signal
1571
+ );
1572
+ checkLimits(hashed, options.limits ?? null);
1573
+ onProgress({ type: "preparing" });
1574
+ const prepared = await prepareDeploy(siteId, hashed, options, signal);
1575
+ if (prepared.unchanged) {
1576
+ onProgress({ type: "unchanged", url: prepared.url ?? "" });
1577
+ return;
1711
1578
  }
1712
- reserved += size;
1713
- };
1714
- const release = (size) => {
1715
- reserved -= size;
1716
- const woken = waiting;
1717
- waiting = [];
1718
- for (const wake of woken) wake();
1719
- };
1720
- const running = { single: 0, batch: 0 };
1721
- const limit = { single: concurrency, batch: batchConcurrency };
1722
- let slotWaiting = [];
1723
- const takeSlot = async (kind) => {
1724
- while (running[kind] >= limit[kind]) {
1725
- await new Promise((resolve2) => slotWaiting.push(resolve2));
1579
+ const sizes = new Map(hashed.map((file) => [file.path, sizeOf(file.bytes)]));
1580
+ const batches = prepared.batches ?? [];
1581
+ const targets = [...prepared.upload, ...batches];
1582
+ const totalBytes = prepared.upload.reduce((sum, target) => sum + (sizes.get(target.path) ?? 0), 0) + batches.reduce((sum, batch) => {
1583
+ const members = batch.files.map((ref) => ({
1584
+ sha256: ref.sha256,
1585
+ size: sizes.get(ref.path) ?? 0
1586
+ }));
1587
+ return sum + batchLayout(members).length + members.reduce((n, member) => n + member.size, 0);
1588
+ }, 0);
1589
+ if (targets.length > 0) {
1590
+ onProgress({
1591
+ type: "uploading",
1592
+ done: prepared.reused,
1593
+ total: prepared.total,
1594
+ bytes: 0,
1595
+ totalBytes,
1596
+ reused: prepared.reused
1597
+ });
1726
1598
  }
1727
- running[kind] += 1;
1728
- };
1729
- const freeSlot = (kind) => {
1730
- running[kind] -= 1;
1731
- const woken = slotWaiting;
1732
- slotWaiting = [];
1733
- for (const wake of woken) wake();
1734
- };
1735
- let landedBytes = 0;
1736
- const inFlight = /* @__PURE__ */ new Map();
1737
- let lastReport = Number.NEGATIVE_INFINITY;
1738
- const report = (path) => {
1739
- if (onProgress === void 0 || failed) return;
1740
- const at = now();
1741
- if (path === void 0 && at - lastReport < PROGRESS_INTERVAL_MS) return;
1742
- lastReport = at;
1743
- let bytes = landedBytes;
1744
- for (const sent of inFlight.values()) bytes += sent;
1745
- onProgress(done, fileCount, bytes, path);
1746
- };
1747
- const worker = async () => {
1748
- for (; ; ) {
1749
- if (failed) return;
1750
- const index = nextIndex++;
1751
- const target = targets[index];
1752
- if (target === void 0) return;
1753
- inner.throwIfAborted();
1754
- const refs = isBatch(target) ? target.files : [target];
1755
- const members = [];
1756
- for (const ref of refs) {
1757
- const file = byPath.get(ref.path);
1758
- if (file === void 0) {
1759
- const missing = new DeployError(
1760
- ClientErrorCode.UploadFailed,
1761
- `The server asked for ${ref.path}, which is not in the manifest.`,
1762
- { path: ref.path }
1763
- );
1764
- fail(missing);
1765
- throw missing;
1766
- }
1767
- members.push(file);
1768
- }
1769
- const layout = isBatch(target) ? new TextEncoder().encode(
1770
- batchLayout(
1771
- target.files.map((ref, i) => ({
1772
- sha256: ref.sha256,
1773
- size: sizeOf(members[i].bytes)
1774
- }))
1775
- )
1776
- ) : null;
1777
- const size = members.reduce((sum, file) => sum + sizeOf(file.bytes), layout?.length ?? 0);
1778
- const copied = isBatch(target) ? members.reduce(
1779
- (sum, file) => sum + (file.bytes instanceof Blob ? 0 : sizeOf(file.bytes)),
1780
- 0
1781
- ) : 0;
1782
- const reservation = size + copied;
1783
- const first = members[0].path;
1784
- const label = members.length === 1 ? first : `${first} and ${members.length - 1} more`;
1785
- const kind = isBatch(target) ? "batch" : "single";
1786
- await takeSlot(kind);
1787
- await acquire(reservation);
1788
- try {
1789
- if (failed) return;
1790
- inner.throwIfAborted();
1791
- const context = {
1792
- put,
1793
- delay,
1794
- retries,
1795
- signal: inner,
1796
- uploadUrl,
1797
- stallMs,
1798
- onSent: (sent) => {
1799
- inFlight.set(index, sent);
1800
- report();
1801
- },
1802
- onRetry: (attempt, stalled) => {
1803
- inFlight.set(index, 0);
1804
- onRetry?.(label, attempt, retries + 1, stalled);
1805
- }
1806
- };
1807
- if (isBatch(target) && layout !== null)
1808
- await uploadBatchOne(target, members, layout, context);
1809
- else await uploadOne(target, members[0], context);
1810
- } catch (error) {
1811
- fail(error);
1812
- throw error;
1813
- } finally {
1814
- release(reservation);
1815
- freeSlot(kind);
1816
- }
1817
- inFlight.delete(index);
1818
- done += members.length;
1819
- landedBytes += size;
1820
- report(members.at(-1).path);
1599
+ await uploadAll(
1600
+ targets,
1601
+ prepared.uploadUrl,
1602
+ hashed,
1603
+ (done, _total, bytes, path) => onProgress({
1604
+ type: "uploading",
1605
+ done: prepared.reused + done,
1606
+ total: prepared.total,
1607
+ bytes,
1608
+ totalBytes,
1609
+ reused: prepared.reused,
1610
+ ...path === void 0 ? {} : { path }
1611
+ }),
1612
+ signal,
1613
+ // The server's number unless the caller set one: it is tuned on the server so a deploy of many
1614
+ // small files speeds up without a CLI or MCP release.
1615
+ typeof prepared.batchConcurrency === "number" && options.upload?.batchConcurrency === void 0 ? { ...options.upload, batchConcurrency: prepared.batchConcurrency } : options.upload,
1616
+ (path, attempt, attempts, stalled) => onProgress({ type: "retrying", path, attempt, attempts, stalled })
1617
+ );
1618
+ onProgress({ type: "completing", reused: prepared.reused });
1619
+ const completed = await completeDeploy(
1620
+ siteId,
1621
+ prepared.deployId,
1622
+ options,
1623
+ signal,
1624
+ await readSiteName(files)
1625
+ );
1626
+ onProgress({
1627
+ type: "done",
1628
+ url: completed.url,
1629
+ name: completed.name,
1630
+ mode: servingModeOf$1(completed),
1631
+ modeIsManual: completed.modeIsManual
1632
+ });
1633
+ } catch (error) {
1634
+ const failure2 = asDeployError(error);
1635
+ onProgress({
1636
+ type: "error",
1637
+ code: failure2.code,
1638
+ message: failure2.message,
1639
+ ...failure2.detail === void 0 ? {} : { detail: failure2.detail }
1640
+ });
1641
+ throw failure2;
1642
+ }
1643
+ }
1644
+ async function call(credentials, path, init2 = {}) {
1645
+ const response = await fetch(`${credentials.apiBaseUrl}/${path}`, {
1646
+ ...init2,
1647
+ headers: {
1648
+ ...credentials.headers,
1649
+ ...init2.headers,
1650
+ Authorization: `Bearer ${credentials.token}`,
1651
+ ...init2.body === void 0 ? {} : { "Content-Type": "application/json" }
1821
1652
  }
1822
- };
1823
- const workers = Array.from(
1824
- { length: Math.min(Math.max(concurrency, batchConcurrency), targets.length) },
1825
- worker
1653
+ });
1654
+ if (!response.ok) throw await failureOf(response);
1655
+ return await response.json();
1656
+ }
1657
+ async function failureOf(response) {
1658
+ const problem = await response.json().catch(() => null);
1659
+ return Object.assign(new Error(problem?.detail ?? `The API answered ${response.status}.`), {
1660
+ status: response.status
1661
+ });
1662
+ }
1663
+ async function findSite(credentials, site) {
1664
+ const found = await lookUpSite(credentials, site);
1665
+ if (found === void 0) {
1666
+ throw new Error(
1667
+ `No site of yours is called "${site}". \`drop2run ls\` or the list_sites tool shows what exists.`
1668
+ );
1669
+ }
1670
+ return found;
1671
+ }
1672
+ const SITE_ID_SHAPE = /^[0-9a-z]{26}$/i;
1673
+ const FORMER_NAME_CANDIDATES = 3;
1674
+ async function lookUpSite(credentials, site) {
1675
+ const wanted = site.trim().toLowerCase();
1676
+ if (wanted === "") return void 0;
1677
+ const { sites } = await call(
1678
+ credentials,
1679
+ `sites?pageSize=${SITES_PER_REQUEST}&q=${encodeURIComponent(wanted)}`
1680
+ );
1681
+ const bySubdomain = sites.find((candidate) => candidate.subdomain.toLowerCase() === wanted);
1682
+ if (bySubdomain !== void 0) return bySubdomain;
1683
+ for (const candidate of sites.slice(0, FORMER_NAME_CANDIDATES)) {
1684
+ const detail2 = await call(
1685
+ credentials,
1686
+ `sites/${encodeURIComponent(candidate.siteId)}`
1687
+ );
1688
+ if (detail2.formerSubdomains?.some((former) => former.subdomain === wanted)) return candidate;
1689
+ }
1690
+ if (!SITE_ID_SHAPE.test(wanted)) return void 0;
1691
+ const response = await fetch(
1692
+ `${credentials.apiBaseUrl}/sites/${encodeURIComponent(wanted.toUpperCase())}`,
1693
+ { headers: { ...credentials.headers, Authorization: `Bearer ${credentials.token}` } }
1694
+ );
1695
+ if (response.status === 404) return void 0;
1696
+ if (!response.ok) throw await failureOf(response);
1697
+ const detail = await response.json();
1698
+ return { siteId: detail.siteId, subdomain: detail.subdomain, url: detail.url, name: detail.name };
1699
+ }
1700
+ async function deleteSite(credentials, siteId) {
1701
+ const response = await fetch(`${credentials.apiBaseUrl}/sites/${encodeURIComponent(siteId)}`, {
1702
+ method: "DELETE",
1703
+ headers: { ...credentials.headers, Authorization: `Bearer ${credentials.token}` }
1704
+ });
1705
+ if (!response.ok) throw await failureOf(response);
1706
+ }
1707
+ async function promoteDeploy(credentials, siteId, deployId) {
1708
+ return await call(
1709
+ credentials,
1710
+ `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/promote`,
1711
+ { method: "POST" }
1826
1712
  );
1713
+ }
1714
+ function enumName(value) {
1715
+ return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
1716
+ }
1717
+ function actionOf(expiresAt, action) {
1718
+ return expiresAt === null ? null : enumName(action);
1719
+ }
1720
+ async function readInvitedOnly(credentials, siteId) {
1827
1721
  try {
1828
- await Promise.all(workers);
1829
- } catch (error) {
1830
- throw asDeployError(firstFailure ?? error);
1831
- } finally {
1722
+ const page = await call(
1723
+ credentials,
1724
+ `sites/${encodeURIComponent(siteId)}/viewers?pageSize=1`
1725
+ );
1726
+ return typeof page.invitedOnly === "boolean" ? page.invitedOnly : null;
1727
+ } catch {
1728
+ return null;
1832
1729
  }
1833
1730
  }
1834
- async function uploadOne(target, file, context) {
1835
- let lastError;
1836
- for (let attempt = 0; attempt <= context.retries; attempt++) {
1837
- context.signal?.throwIfAborted();
1838
- if (attempt > 0) {
1839
- context.onRetry(attempt + 1, lastError instanceof StalledError);
1840
- await context.delay(BACKOFF_MS[attempt - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
1841
- context.signal?.throwIfAborted();
1842
- }
1843
- try {
1844
- const answer = await context.put({
1845
- url: context.uploadUrl,
1846
- token: target.token,
1847
- body: untyped(file.bytes),
1848
- signal: context.signal,
1849
- onSent: context.onSent,
1850
- stallMs: context.stallMs
1851
- });
1852
- const status = typeof answer === "number" ? answer : answer.status;
1853
- if (status >= 200 && status < 300) return;
1854
- if (status === 401 || status === 403 || status === 429) {
1855
- throw new DeployError(
1856
- ClientErrorCode.UploadFailed,
1857
- `Upload of ${target.path} was refused. The upload window may have expired — try deploying again.`,
1858
- { path: target.path, status }
1859
- );
1860
- }
1861
- if (status === 413) {
1862
- throw new DeployError(
1863
- ClientErrorCode.UploadFailed,
1864
- `${target.path} is too large to upload in one request. Make it smaller, or leave it out of this publish.`,
1865
- { path: target.path, status }
1866
- );
1867
- }
1868
- lastError = new Error(`HTTP ${status}`);
1869
- } catch (error) {
1870
- if (error instanceof DeployError) throw error;
1871
- if (isAbort(error)) throw cancelled();
1872
- lastError = error;
1873
- }
1731
+ async function folderFromListing(credentials, siteId, subdomain) {
1732
+ const { sites } = await call(
1733
+ credentials,
1734
+ `sites?pageSize=${SITES_PER_REQUEST}&q=${encodeURIComponent(subdomain)}`
1735
+ );
1736
+ return sites.find((candidate) => candidate.siteId === siteId)?.folderId ?? null;
1737
+ }
1738
+ async function getSite(credentials, siteId) {
1739
+ const [site, invitedOnly] = await Promise.all([
1740
+ call(credentials, `sites/${encodeURIComponent(siteId)}`),
1741
+ readInvitedOnly(credentials, siteId)
1742
+ ]);
1743
+ const folderId = site.folderId !== void 0 ? site.folderId : await folderFromListing(credentials, site.siteId, site.subdomain);
1744
+ return {
1745
+ siteId: site.siteId,
1746
+ subdomain: site.subdomain,
1747
+ url: site.url,
1748
+ name: site.name,
1749
+ status: enumName(site.status),
1750
+ invitedOnly,
1751
+ liveDeployId: site.liveDeployId,
1752
+ spaMode: site.spaMode,
1753
+ docsMode: site.docsMode,
1754
+ modeIsManual: site.modeIsManual,
1755
+ passwordProtected: site.passwordProtected,
1756
+ passwordProtectionAvailable: site.passwordProtectionAvailable,
1757
+ formsEnabled: site.formsEnabled,
1758
+ formsAvailable: site.formsAvailable,
1759
+ expiresAt: site.expiresAt,
1760
+ expiryAction: actionOf(site.expiresAt, site.expiryAction ?? ""),
1761
+ scheduledExpiryAvailable: site.scheduledExpiryAvailable,
1762
+ comments: audienceOf(site.commentAudience),
1763
+ commentsAvailable: site.feedbackAvailable === true,
1764
+ folderId,
1765
+ deploys: site.deploys.map((deploy2) => ({
1766
+ deployId: deploy2.deployId,
1767
+ status: deploy2.status,
1768
+ fileCount: deploy2.fileCount,
1769
+ totalBytes: deploy2.totalBytes,
1770
+ createdAt: deploy2.createdAt,
1771
+ completedAt: deploy2.completedAt,
1772
+ filesKept: deploy2.filesKept
1773
+ })),
1774
+ subdomainChangesLeft: site.subdomainChangesLeft ?? null,
1775
+ formerSubdomains: (site.formerSubdomains ?? []).map((former) => former.subdomain)
1776
+ };
1777
+ }
1778
+ async function changeSubdomain(credentials, siteId, subdomain) {
1779
+ const changed = await call(credentials, `sites/${encodeURIComponent(siteId)}/subdomain`, {
1780
+ method: "POST",
1781
+ body: JSON.stringify({ subdomain })
1782
+ });
1783
+ return {
1784
+ ...changed,
1785
+ formerSubdomains: changed.formerSubdomains.map((former) => former.subdomain)
1786
+ };
1787
+ }
1788
+ async function updateSiteSettings(credentials, siteId, change) {
1789
+ const body = {};
1790
+ if (change.name !== void 0) body.name = change.name;
1791
+ if (change.mode !== void 0) {
1792
+ body.spaMode = change.mode === "spa";
1793
+ body.docsMode = change.mode === "docs";
1874
1794
  }
1875
- 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.`;
1876
- throw new DeployError(ClientErrorCode.UploadFailed, message, {
1877
- path: target.path,
1878
- cause: describeError(lastError)
1795
+ if (change.password !== void 0) body.password = change.password;
1796
+ if (change.expiresAt !== void 0) body.expiresAt = change.expiresAt;
1797
+ if (change.expiryAction !== void 0) body.expiryAction = change.expiryAction;
1798
+ if (change.formsEnabled !== void 0) body.formsEnabled = change.formsEnabled;
1799
+ if (change.folderId !== void 0) body.folderId = change.folderId;
1800
+ if (change.invitedOnly !== void 0) body.invitedOnly = change.invitedOnly;
1801
+ if (change.comments !== void 0) body.commentAudience = API_AUDIENCE[change.comments];
1802
+ if (Object.keys(body).length === 0) throw new Error("Nothing to change — name a setting.");
1803
+ const wasInvitedOnly = change.password !== void 0 && change.password !== "" ? await readInvitedOnly(credentials, siteId) : null;
1804
+ const { commentAudience, ...settings } = await call(credentials, `sites/${encodeURIComponent(siteId)}`, {
1805
+ method: "PATCH",
1806
+ body: JSON.stringify(body)
1879
1807
  });
1808
+ const invitedOnly = typeof settings.invitedOnly === "boolean" ? settings.invitedOnly : null;
1809
+ return {
1810
+ ...settings,
1811
+ expiryAction: actionOf(settings.expiresAt, settings.expiryAction ?? ""),
1812
+ invitedOnly,
1813
+ comments: audienceOf(commentAudience),
1814
+ replacedInviteOnly: wasInvitedOnly === true && invitedOnly === false
1815
+ };
1880
1816
  }
1881
- async function uploadBatchOne(batch, members, layout, context) {
1882
- const first = members[0].path;
1883
- const label = members.length === 1 ? first : `${first} and ${members.length - 1} more`;
1884
- let lastError;
1885
- for (let attempt = 0; attempt <= context.retries; attempt++) {
1886
- context.signal?.throwIfAborted();
1887
- if (attempt > 0) {
1888
- context.onRetry(attempt + 1, lastError instanceof StalledError);
1889
- await context.delay(BACKOFF_MS[attempt - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
1890
- context.signal?.throwIfAborted();
1891
- }
1892
- try {
1893
- const body = new Blob([layout, ...members.map((file) => file.bytes)]);
1894
- const answer = await context.put({
1895
- url: context.uploadUrl,
1896
- token: batch.token,
1897
- body,
1898
- signal: context.signal,
1899
- onSent: context.onSent,
1900
- stallMs: context.stallMs,
1901
- wantsBody: true
1902
- });
1903
- const status = typeof answer === "number" ? answer : answer.status;
1904
- if (status >= 200 && status < 300) {
1905
- const result = typeof answer === "number" ? null : parseBatchResult(answer.body);
1906
- if (result === null) {
1907
- lastError = new Error(
1908
- "The upload service answered with something that is not a batch result."
1909
- );
1910
- continue;
1911
- }
1912
- if (result.failed.length === 0) return;
1913
- const changed = result.failed.find((failure2) => failure2.reason === "hash_mismatch");
1914
- if (changed !== void 0) {
1915
- const path = members[changed.i]?.path ?? first;
1916
- throw new DeployError(
1917
- ClientErrorCode.ReadFailed,
1918
- `${path} changed after it was read, so it no longer matches what was checked. Drop the folder again.`,
1919
- { path }
1920
- );
1921
- }
1922
- lastError = new Error(
1923
- `${result.failed.length} of ${members.length} files were not stored.`
1924
- );
1925
- continue;
1926
- }
1927
- if ([400, 401, 403, 411, 413, 429].includes(status)) {
1928
- throw new DeployError(ClientErrorCode.UploadFailed, refusalMessage(status, label), {
1929
- path: first,
1930
- status
1931
- });
1932
- }
1933
- lastError = new Error(`HTTP ${status}`);
1934
- } catch (error) {
1935
- if (error instanceof DeployError) throw error;
1936
- if (isAbort(error)) throw cancelled();
1937
- lastError = error;
1817
+ const API_AUDIENCE = {
1818
+ off: "Off",
1819
+ invited: "Invited",
1820
+ anyone: "Anyone"
1821
+ };
1822
+ function audienceOf(value) {
1823
+ const lower = value?.toLowerCase();
1824
+ return lower === "off" || lower === "invited" || lower === "anyone" ? lower : null;
1825
+ }
1826
+ async function setSitePaused(credentials, siteId, action) {
1827
+ const state = await call(
1828
+ credentials,
1829
+ `sites/${encodeURIComponent(siteId)}/${action}`,
1830
+ { method: "POST" }
1831
+ );
1832
+ return {
1833
+ siteId: state.siteId,
1834
+ subdomain: state.subdomain,
1835
+ status: enumName(state.status),
1836
+ ownerPaused: state.ownerPaused
1837
+ };
1838
+ }
1839
+ async function listTokens(credentials) {
1840
+ const body = await call(credentials, "tokens");
1841
+ return body.tokens;
1842
+ }
1843
+ const SITES_PER_REQUEST = 100;
1844
+ async function listSites(credentials) {
1845
+ const body = await call(
1846
+ credentials,
1847
+ `sites?pageSize=${SITES_PER_REQUEST}`
1848
+ );
1849
+ return { sites: body.sites, total: body.total };
1850
+ }
1851
+ async function createSite(credentials, subdomain, folderId) {
1852
+ const created = await call(
1853
+ credentials,
1854
+ "sites",
1855
+ {
1856
+ method: "POST",
1857
+ // Absent fields rather than null ones, because the API reads null, empty and missing the same
1858
+ // way and a body carrying only what was asked for is the one that reads correctly in a log.
1859
+ body: JSON.stringify({
1860
+ ...subdomain === void 0 ? {} : { subdomain },
1861
+ ...folderId === void 0 ? {} : { folderId }
1862
+ })
1938
1863
  }
1939
- }
1940
- const message = lastError instanceof StalledError ? `Upload of ${label} stopped moving and did not recover after ${context.retries + 1} attempts. Check your connection and try again.` : `Could not upload ${label} after ${context.retries + 1} attempts.`;
1941
- throw new DeployError(ClientErrorCode.UploadFailed, message, {
1942
- path: first,
1943
- cause: describeError(lastError)
1944
- });
1864
+ );
1865
+ return { ...created, name: null };
1945
1866
  }
1946
- function refusalMessage(status, label) {
1947
- switch (status) {
1948
- case 401:
1949
- return `Upload of ${label} was refused because its upload window expired. Try deploying again.`;
1950
- case 429:
1951
- return `Upload of ${label} was refused: this deploy has used every upload it was given. Try deploying again.`;
1952
- case 400:
1953
- case 411:
1954
- case 413:
1955
- return `Upload of ${label} was refused because it no longer matches what was checked — a file may have changed since you dropped it. Drop the folder again.`;
1956
- default:
1957
- return `Upload of ${label} was refused by the upload service. Try deploying again.`;
1867
+ async function listFolders(credentials) {
1868
+ const body = await call(credentials, "site-folders");
1869
+ const byId = new Map(body.folders.map((folder) => [folder.folderId, folder]));
1870
+ const pathOf = (folderId) => {
1871
+ const names = [];
1872
+ let cursor = byId.get(folderId);
1873
+ while (cursor !== void 0 && names.length <= byId.size) {
1874
+ names.unshift(cursor.name);
1875
+ cursor = cursor.parentId === null ? void 0 : byId.get(cursor.parentId);
1876
+ }
1877
+ return names.join("/");
1878
+ };
1879
+ return body.folders.map((folder) => ({ folderId: folder.folderId, path: pathOf(folder.folderId) })).sort((a, b) => a.path.localeCompare(b.path));
1880
+ }
1881
+ function comparablePath(path) {
1882
+ return path.split("/").map((name) => name.trim()).filter((name) => name !== "").join("/").toLowerCase();
1883
+ }
1884
+ const FOLDERS_LISTED_ON_MISS = 20;
1885
+ async function resolveFolder(credentials, folder) {
1886
+ const wanted = comparablePath(folder);
1887
+ if (wanted === "") {
1888
+ throw new Error(
1889
+ `"${folder}" does not name a folder. Leave the folder out to put the site at the top level.`
1890
+ );
1891
+ }
1892
+ const folders2 = await listFolders(credentials);
1893
+ const byId = folders2.find((candidate) => candidate.folderId.toLowerCase() === wanted);
1894
+ if (byId !== void 0) return byId.folderId;
1895
+ const matches = folders2.filter((candidate) => comparablePath(candidate.path) === wanted);
1896
+ if (matches.length === 1 && matches[0] !== void 0) return matches[0].folderId;
1897
+ if (matches.length > 1) {
1898
+ throw new Error(
1899
+ `More than one folder is at "${folder}", because a folder name can contain "/". Name it by id instead: ${matches.map((match) => match.folderId).join(", ")}.`
1900
+ );
1958
1901
  }
1902
+ if (folders2.length === 0) {
1903
+ throw new Error(
1904
+ `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.`
1905
+ );
1906
+ }
1907
+ const listed = folders2.slice(0, FOLDERS_LISTED_ON_MISS).map((candidate) => candidate.path);
1908
+ const more = folders2.length - listed.length;
1909
+ throw new Error(
1910
+ `No folder of yours is called "${folder}". Your folders: ${listed.join(", ")}${more > 0 ? `, and ${more} more` : ""}.`
1911
+ );
1959
1912
  }
1960
- function untyped(content) {
1961
- if (ArrayBuffer.isView(content) || content.type === "") return content;
1962
- return content.slice(0, content.size);
1913
+ const ROOT_FOLDER = "root";
1914
+ async function resolveFolderTarget(credentials, folder) {
1915
+ return folder.trim().toLowerCase() === ROOT_FOLDER ? ROOT_FOLDER : await resolveFolder(credentials, folder);
1963
1916
  }
1964
- function defaultTransport(doFetch) {
1965
- if (doFetch !== void 0) return fetchTransport(doFetch);
1966
- const Xhr = globalThis.XMLHttpRequest;
1967
- return Xhr === void 0 ? fetchTransport(globalFetch()) : xhrTransport(Xhr);
1917
+ async function listComments(credentials, siteId, query = {}) {
1918
+ const search = new URLSearchParams();
1919
+ if (query.status !== void 0) search.set("status", query.status);
1920
+ if (query.path !== void 0) search.set("path", query.path);
1921
+ if (query.since !== void 0) search.set("since", String(query.since));
1922
+ if (query.page !== void 0) search.set("page", String(query.page));
1923
+ const encoded = search.toString();
1924
+ const suffix = encoded === "" ? "" : `?${encoded}`;
1925
+ return await call(
1926
+ credentials,
1927
+ `sites/${encodeURIComponent(siteId)}/feedback/threads${suffix}`
1928
+ );
1968
1929
  }
1969
- function fetchTransport(doFetch) {
1970
- return async (request2) => {
1971
- const { url, token, body, signal } = request2;
1972
- const response = await doFetch(url, {
1973
- method: "PUT",
1974
- // The view, not a copy. Wrapping it in `new Blob([body.slice().buffer])` made two extra
1975
- // full-size copies of every file in flight — eight at once, so up to 400 MB on a drop of
1976
- // 50 MB files. `fetch` sends exactly the range a view covers. The cast is TypeScript's
1977
- // SharedArrayBuffer distinction again; see `sha256Hex`. A `Blob` goes as it is, and the runtime
1978
- // streams it from disk.
1979
- body,
1980
- // The permit is the whole request. `Content-Length` is required by the Worker and cannot be
1981
- // set here — it is a forbidden header name — but the runtime derives it from the body, which
1982
- // is exactly the length the token names.
1983
- headers: { Authorization: `Bearer ${token}` },
1984
- ...signal ? { signal } : {}
1985
- });
1986
- return request2.wantsBody === true ? { status: response.status, body: await response.text() } : response.status;
1930
+ async function replyToComment(credentials, siteId, threadId, body, resolve2 = false) {
1931
+ return await call(
1932
+ credentials,
1933
+ `sites/${encodeURIComponent(siteId)}/feedback/threads/${encodeURIComponent(threadId)}/comments`,
1934
+ { method: "POST", body: JSON.stringify({ body, resolve: resolve2 }) }
1935
+ );
1936
+ }
1937
+ async function setCommentResolved(credentials, siteId, threadId, resolved) {
1938
+ return await call(
1939
+ credentials,
1940
+ `sites/${encodeURIComponent(siteId)}/feedback/threads/${encodeURIComponent(threadId)}/${resolved ? "resolve" : "reopen"}`,
1941
+ { method: "POST" }
1942
+ );
1943
+ }
1944
+ const PEOPLE_PER_REQUEST = 50;
1945
+ async function listPeople(credentials, siteId) {
1946
+ const page = await call(credentials, `sites/${encodeURIComponent(siteId)}/viewers?pageSize=${PEOPLE_PER_REQUEST}`);
1947
+ return {
1948
+ invitedOnly: page.invitedOnly,
1949
+ total: page.total,
1950
+ limit: page.limit,
1951
+ people: page.viewers.map((row) => ({
1952
+ email: row.email,
1953
+ role: enumName(row.role),
1954
+ owner: row.owner,
1955
+ status: enumName(row.status)
1956
+ }))
1987
1957
  };
1988
1958
  }
1989
- function xhrTransport(Xhr) {
1990
- return ({ url, token, body, signal, onSent, stallMs, wantsBody }) => new Promise((resolve2, reject) => {
1991
- const xhr = new Xhr();
1992
- let settled = false;
1993
- let timer;
1994
- const settle = (finish) => {
1995
- if (settled) return;
1996
- settled = true;
1997
- clearTimeout(timer);
1998
- signal?.removeEventListener("abort", onAbort);
1999
- finish();
2000
- };
2001
- const arm = () => {
2002
- clearTimeout(timer);
2003
- timer = setTimeout(() => {
2004
- xhr.abort();
2005
- settle(() => reject(new StalledError(Math.round(stallMs / 1e3))));
2006
- }, stallMs);
2007
- };
2008
- function onAbort() {
2009
- xhr.abort();
2010
- settle(() => reject(abortReason(signal)));
2011
- }
2012
- if (signal?.aborted) {
2013
- reject(abortReason(signal));
2014
- return;
1959
+ async function invitePeople(credentials, siteId, emails, role) {
1960
+ return await call(credentials, `sites/${encodeURIComponent(siteId)}/viewers`, {
1961
+ method: "POST",
1962
+ body: JSON.stringify({ emails, role: role === "comment" ? "Comment" : "View", notify: true })
1963
+ });
1964
+ }
1965
+ async function removePerson(credentials, siteId, email) {
1966
+ await call(
1967
+ credentials,
1968
+ `sites/${encodeURIComponent(siteId)}/viewers?email=${encodeURIComponent(email)}`,
1969
+ { method: "DELETE" }
1970
+ );
1971
+ }
1972
+ function describeMode(result) {
1973
+ if (result.mode === null) return null;
1974
+ if (result.nestedIndex !== null) {
1975
+ 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.`;
1976
+ }
1977
+ if (result.mode === "docs") return "Served as documents, in the documents reader.";
1978
+ if (result.mode === "spa") return "Served as a single-page app.";
1979
+ return "Served as a static site.";
1980
+ }
1981
+ async function ownSiteAt(credentials, subdomain) {
1982
+ const found = await lookUpSite(credentials, subdomain);
1983
+ return found?.subdomain.toLowerCase() === subdomain.trim().toLowerCase() ? found : void 0;
1984
+ }
1985
+ async function resolveSite(credentials, site, newSite) {
1986
+ const { subdomain, folder } = newSite;
1987
+ if (site !== void 0 && subdomain !== void 0) {
1988
+ throw new Error(
1989
+ "Give either an existing site or a subdomain for a new one, not both: publishing over a site and creating one are different requests."
1990
+ );
1991
+ }
1992
+ if (site !== void 0 && folder !== void 0) {
1993
+ throw new Error(
1994
+ "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`."
1995
+ );
1996
+ }
1997
+ if (site === void 0) {
1998
+ const folderId = folder === void 0 ? void 0 : await resolveFolder(credentials, folder);
1999
+ const held = subdomain === void 0 ? void 0 : await ownSiteAt(credentials, subdomain);
2000
+ if (held !== void 0) {
2001
+ if (held.liveDeployId === null) return held;
2002
+ throw new Error(
2003
+ `You already have a site at ${held.subdomain}, and it is live. To replace what it serves, publish to it as an existing site (the \`site\` argument, or \`--site\` on the command line); to make a new site, choose another subdomain.`
2004
+ );
2015
2005
  }
2016
- signal?.addEventListener("abort", onAbort, { once: true });
2017
- xhr.open("PUT", url);
2018
- xhr.setRequestHeader("Authorization", `Bearer ${token}`);
2019
- xhr.upload.onprogress = (event) => {
2020
- arm();
2021
- onSent(event.loaded);
2022
- };
2023
- xhr.onprogress = arm;
2024
- xhr.onload = () => settle(
2025
- () => resolve2(
2026
- wantsBody === true ? { status: xhr.status, body: xhr.responseText ?? "" } : xhr.status
2027
- )
2006
+ return await createSite(credentials, subdomain, folderId);
2007
+ }
2008
+ const found = await lookUpSite(credentials, site);
2009
+ if (found === void 0) {
2010
+ throw new Error(
2011
+ `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.`
2012
+ );
2013
+ }
2014
+ return found;
2015
+ }
2016
+ async function publish(credentials, source, site, onProgress, newSite = {}) {
2017
+ const target = await resolveSite(credentials, site, newSite);
2018
+ let files = 0;
2019
+ let url = target.url;
2020
+ let unchanged = false;
2021
+ let mode = null;
2022
+ let paths = [];
2023
+ let failure2 = null;
2024
+ let failureCode;
2025
+ let failureDetail;
2026
+ const record = (event) => {
2027
+ if (event.type === "collecting") files = event.files;
2028
+ if (event.type === "unchanged") {
2029
+ unchanged = true;
2030
+ url = event.url || target.url;
2031
+ }
2032
+ if (event.type === "done") {
2033
+ url = event.url;
2034
+ mode = event.mode;
2035
+ }
2036
+ if (event.type === "error") {
2037
+ failure2 = event.message;
2038
+ failureCode = event.code;
2039
+ failureDetail = event.detail;
2040
+ }
2041
+ onProgress?.(event);
2042
+ };
2043
+ const watched = async (signal, onRead) => {
2044
+ const collected = await source(signal, onRead);
2045
+ paths = collected.map((file) => file.path);
2046
+ return collected;
2047
+ };
2048
+ await deploy(watched, target.siteId, record, void 0, {
2049
+ baseUrl: credentials.apiBaseUrl,
2050
+ token: credentials.token,
2051
+ ...credentials.headers === void 0 ? {} : { headers: credentials.headers }
2052
+ }).catch((error) => {
2053
+ const thrown = new Error(
2054
+ failure2 ?? (error instanceof Error ? error.message : String(error))
2028
2055
  );
2029
- xhr.onerror = () => settle(() => reject(new TypeError("Network error during upload.")));
2030
- arm();
2031
- xhr.send(body);
2056
+ thrown.detail = failureDetail ?? error?.detail;
2057
+ thrown.code = failureCode ?? error?.code;
2058
+ throw thrown;
2032
2059
  });
2060
+ return {
2061
+ url,
2062
+ siteId: target.siteId,
2063
+ subdomain: target.subdomain,
2064
+ files,
2065
+ unchanged,
2066
+ mode,
2067
+ nestedIndex: mode === "docs" ? nestedIndexOf(paths) : null
2068
+ };
2033
2069
  }
2034
- function abortReason(signal) {
2035
- const reason = signal?.reason;
2036
- if (reason instanceof Error) return reason;
2037
- const error = new Error("Aborted.");
2038
- error.name = "AbortError";
2039
- return error;
2040
- }
2041
- function globalFetch() {
2042
- return globalThis.fetch.bind(globalThis);
2070
+ const MAX_FILES = 2e4;
2071
+ function directorySource(root) {
2072
+ return async () => {
2073
+ const base = await realpath(root);
2074
+ const files = [];
2075
+ await walk(base, base, files);
2076
+ return files;
2077
+ };
2043
2078
  }
2044
- function defaultDelay(ms) {
2045
- return new Promise((resolve2) => setTimeout(resolve2, ms));
2079
+ async function walk(root, directory, into) {
2080
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
2081
+ const full = join(directory, entry.name);
2082
+ const path = relative(root, full).split(sep).join("/");
2083
+ if (shouldIgnore(path)) continue;
2084
+ if (entry.isSymbolicLink()) {
2085
+ const target = await realpath(full).catch(() => null);
2086
+ if (target === null || escapesRoot(root, target)) continue;
2087
+ const targeted = await stat(target);
2088
+ if (targeted.isDirectory()) {
2089
+ await walk(root, target, into);
2090
+ continue;
2091
+ }
2092
+ if (!targeted.isFile()) continue;
2093
+ } else if (entry.isDirectory()) {
2094
+ await walk(root, full, into);
2095
+ continue;
2096
+ } else if (!entry.isFile()) {
2097
+ continue;
2098
+ }
2099
+ if (into.length >= MAX_FILES) {
2100
+ throw new Error(
2101
+ `This folder holds more than ${MAX_FILES.toLocaleString()} files. Publish a build output directory rather than a whole project.`
2102
+ );
2103
+ }
2104
+ into.push({ path, bytes: new Uint8Array(await readFile(full)) });
2105
+ }
2046
2106
  }
2047
- function isAbort(error) {
2048
- return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
2107
+ function escapesRoot(root, candidate) {
2108
+ const inside = relative(root, candidate);
2109
+ return inside === "" || inside.startsWith("..");
2049
2110
  }
2050
- function cancelled() {
2051
- return new DeployError(ClientErrorCode.Cancelled, "Deploy cancelled.");
2111
+ async function publishDirectory(credentials, directory, site, onProgress, newSite = {}) {
2112
+ const found = await stat(directory).catch(() => null);
2113
+ if (found === null) throw new Error(`There is no folder at ${directory}.`);
2114
+ if (!found.isDirectory()) throw new Error(`${directory} is a file, not a folder to publish.`);
2115
+ return await publish(credentials, directorySource(directory), site, onProgress, newSite);
2052
2116
  }
2053
- function outOfMemory(detail) {
2054
- return new DeployError(
2055
- ClientErrorCode.OutOfMemory,
2056
- "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.",
2057
- detail
2058
- );
2117
+ const TIMEOUT_MS = 5 * 60 * 1e3;
2118
+ function newAttempt() {
2119
+ const verifier = randomBytes(32).toString("base64url");
2120
+ return {
2121
+ verifier,
2122
+ challenge: createHash("sha256").update(verifier).digest("base64url"),
2123
+ state: randomBytes(16).toString("base64url")
2124
+ };
2059
2125
  }
2060
- function asDeployError(error) {
2061
- if (error instanceof DeployError) return error;
2062
- if (isAbort(error)) return cancelled();
2063
- if (error instanceof RangeError) return outOfMemory(error.message);
2064
- return new DeployError(
2065
- ClientErrorCode.UploadFailed,
2066
- error instanceof Error ? error.message : "Upload failed.",
2067
- error
2068
- );
2126
+ function clientName(surface) {
2127
+ const clean2 = (value) => value.trim().replace(/[^A-Za-z0-9._-]/g, "-");
2128
+ const label = "";
2129
+ const raw = clean2(hostname()).slice(0, 64 - label.length);
2130
+ return `${raw === "" ? "drop2run" : raw}${label}`;
2069
2131
  }
2070
- async function deploy(source, siteId, onProgress, signal, options = {}) {
2132
+ function openBrowser(url) {
2133
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
2071
2134
  try {
2072
- onProgress({ type: "reading", bytes: 0, total: 0 });
2073
- const collected = await source(
2074
- signal,
2075
- (bytes, total) => onProgress({ type: "reading", bytes, total })
2076
- );
2077
- onProgress({ type: "collecting", files: collected.length });
2078
- const rename = renameOfLoneHtmlPage(collected.map((file) => file.path));
2079
- const files = rename === null ? collected : collected.map((file) => file.path === rename ? { ...file, path: "index.html" } : file);
2080
- const hashed = await hashAll(
2081
- files,
2082
- (done, total) => onProgress({ type: "hashing", done, total }),
2083
- signal
2084
- );
2085
- checkLimits(hashed, options.limits ?? null);
2086
- onProgress({ type: "preparing" });
2087
- const prepared = await prepareDeploy(siteId, hashed, options, signal);
2088
- if (prepared.unchanged) {
2089
- onProgress({ type: "unchanged", url: prepared.url ?? "" });
2090
- return;
2091
- }
2092
- const sizes = new Map(hashed.map((file) => [file.path, sizeOf(file.bytes)]));
2093
- const batches = prepared.batches ?? [];
2094
- const targets = [...prepared.upload, ...batches];
2095
- const totalBytes = prepared.upload.reduce((sum, target) => sum + (sizes.get(target.path) ?? 0), 0) + batches.reduce((sum, batch) => {
2096
- const members = batch.files.map((ref) => ({
2097
- sha256: ref.sha256,
2098
- size: sizes.get(ref.path) ?? 0
2099
- }));
2100
- return sum + batchLayout(members).length + members.reduce((n, member) => n + member.size, 0);
2101
- }, 0);
2102
- if (targets.length > 0) {
2103
- onProgress({
2104
- type: "uploading",
2105
- done: prepared.reused,
2106
- total: prepared.total,
2107
- bytes: 0,
2108
- totalBytes,
2109
- reused: prepared.reused
2110
- });
2111
- }
2112
- await uploadAll(
2113
- targets,
2114
- prepared.uploadUrl,
2115
- hashed,
2116
- (done, _total, bytes, path) => onProgress({
2117
- type: "uploading",
2118
- done: prepared.reused + done,
2119
- total: prepared.total,
2120
- bytes,
2121
- totalBytes,
2122
- reused: prepared.reused,
2123
- ...path === void 0 ? {} : { path }
2124
- }),
2125
- signal,
2126
- // The server's number unless the caller set one: it is tuned on the server so a deploy of many
2127
- // small files speeds up without a CLI or MCP release.
2128
- typeof prepared.batchConcurrency === "number" && options.upload?.batchConcurrency === void 0 ? { ...options.upload, batchConcurrency: prepared.batchConcurrency } : options.upload,
2129
- (path, attempt, attempts, stalled) => onProgress({ type: "retrying", path, attempt, attempts, stalled })
2130
- );
2131
- onProgress({ type: "completing", reused: prepared.reused });
2132
- const completed = await completeDeploy(
2133
- siteId,
2134
- prepared.deployId,
2135
- options,
2136
- signal,
2137
- await readSiteName(files)
2138
- );
2139
- onProgress({
2140
- type: "done",
2141
- url: completed.url,
2142
- name: completed.name,
2143
- mode: servingModeOf(completed),
2144
- modeIsManual: completed.modeIsManual
2135
+ const child = spawn(command, [...args], { stdio: "ignore", detached: true });
2136
+ child.unref();
2137
+ child.on("error", () => {
2145
2138
  });
2146
- } catch (error) {
2147
- const failure2 = asDeployError(error);
2148
- onProgress({
2149
- type: "error",
2150
- code: failure2.code,
2151
- message: failure2.message,
2152
- ...failure2.detail === void 0 ? {} : { detail: failure2.detail }
2139
+ return true;
2140
+ } catch {
2141
+ return false;
2142
+ }
2143
+ }
2144
+ function listen(state) {
2145
+ return new Promise((resolveListener, rejectListener) => {
2146
+ let settle = null;
2147
+ let fail = null;
2148
+ const received = new Promise((resolveCode, rejectCode) => {
2149
+ settle = resolveCode;
2150
+ fail = rejectCode;
2151
+ });
2152
+ const server = createServer((request2, response) => {
2153
+ const url = new URL(request2.url ?? "/", "http://127.0.0.1");
2154
+ if (url.pathname !== "/cb") {
2155
+ response.writeHead(404, { "Content-Type": "text/plain" });
2156
+ response.end("Not found.\n");
2157
+ return;
2158
+ }
2159
+ const code = url.searchParams.get("code") ?? "";
2160
+ const returned = url.searchParams.get("state") ?? "";
2161
+ if (returned !== state || code === "") {
2162
+ respond(
2163
+ response,
2164
+ 400,
2165
+ "Sign-in could not be completed",
2166
+ "This did not come from the sign-in that was started here. Nothing has been changed."
2167
+ );
2168
+ fail?.(new Error("The browser returned a response that did not match this sign-in."));
2169
+ return;
2170
+ }
2171
+ respond(response, 200, "Signed in", "You can close this tab now.");
2172
+ settle?.({ code });
2173
+ });
2174
+ server.on("error", (error) => {
2175
+ rejectListener(error);
2176
+ fail?.(error instanceof Error ? error : new Error(String(error)));
2177
+ });
2178
+ server.listen(0, "127.0.0.1", () => {
2179
+ const address = server.address();
2180
+ if (address === null) {
2181
+ rejectListener(new Error("Could not open a local port to receive the sign-in."));
2182
+ return;
2183
+ }
2184
+ resolveListener({
2185
+ port: address.port,
2186
+ received,
2187
+ close: () => server.close()
2188
+ });
2153
2189
  });
2154
- throw failure2;
2155
- }
2190
+ });
2156
2191
  }
2157
- const MAX_FILES = 2e4;
2158
- function directorySource(root) {
2159
- return async () => {
2160
- const base = await realpath(root);
2161
- const files = [];
2162
- await walk(base, base, files);
2163
- return files;
2164
- };
2192
+ function respond(response, status, title, detail) {
2193
+ response.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
2194
+ response.end(
2195
+ `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title} — drop2run</title><style>body{font:16px/1.6 system-ui,sans-serif;margin:20vh auto;max-width:28rem;padding:0 1.5rem;color:#12211f}h1{font-size:1.4rem;margin:0 0 .5rem}p{margin:0;color:#64706e}@media(prefers-color-scheme:dark){body{background:#0d1413;color:#e8efee}p{color:#9aa8a6}}</style></head><body><h1>${title}</h1><p>${detail}</p></body></html>
2196
+ `
2197
+ );
2165
2198
  }
2166
- async function walk(root, directory, into) {
2167
- for (const entry of await readdir(directory, { withFileTypes: true })) {
2168
- const full = join(directory, entry.name);
2169
- const path = relative(root, full).split(sep).join("/");
2170
- if (shouldIgnore(path)) continue;
2171
- if (entry.isSymbolicLink()) {
2172
- const target = await realpath(full).catch(() => null);
2173
- if (target === null || escapesRoot(root, target)) continue;
2174
- const targeted = await stat(target);
2175
- if (targeted.isDirectory()) {
2176
- await walk(root, target, into);
2177
- continue;
2178
- }
2179
- if (!targeted.isFile()) continue;
2180
- } else if (entry.isDirectory()) {
2181
- await walk(root, full, into);
2182
- continue;
2183
- } else if (!entry.isFile()) {
2184
- continue;
2185
- }
2186
- if (into.length >= MAX_FILES) {
2187
- throw new Error(
2188
- `This folder holds more than ${MAX_FILES.toLocaleString()} files. Publish a build output directory rather than a whole project.`
2189
- );
2190
- }
2191
- into.push({ path, bytes: new Uint8Array(await readFile(full)) });
2199
+ async function waitForCallback(received, timeoutMs = TIMEOUT_MS) {
2200
+ let timer;
2201
+ try {
2202
+ return await Promise.race([
2203
+ received,
2204
+ new Promise((_, reject) => {
2205
+ timer = setTimeout(
2206
+ () => reject(new Error("Timed out waiting for the browser. Nothing has been changed.")),
2207
+ timeoutMs
2208
+ );
2209
+ })
2210
+ ]);
2211
+ } finally {
2212
+ if (timer !== void 0) clearTimeout(timer);
2192
2213
  }
2193
2214
  }
2194
- function escapesRoot(root, candidate) {
2195
- const inside = relative(root, candidate);
2196
- return inside === "" || inside.startsWith("..");
2215
+ function consentUrl(dashboardUrl, attempt, port, name) {
2216
+ const url = new URL("/cli-auth", dashboardUrl);
2217
+ url.searchParams.set("challenge", attempt.challenge);
2218
+ url.searchParams.set("state", attempt.state);
2219
+ url.searchParams.set("port", String(port));
2220
+ url.searchParams.set("name", name);
2221
+ return url.toString();
2197
2222
  }
2198
- function describeMode(result) {
2199
- if (result.mode === null) return null;
2200
- if (result.nestedIndex !== null) {
2201
- 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.`;
2202
- }
2203
- if (result.mode === "docs") return "Served as documents, in the documents reader.";
2204
- if (result.mode === "spa") return "Served as a single-page app.";
2205
- return "Served as a static site.";
2223
+ async function startDevice(apiBaseUrl, clientName2) {
2224
+ const response = await fetch(`${apiBaseUrl}/auth/cli/device`, {
2225
+ method: "POST",
2226
+ headers: { "Content-Type": "application/json" },
2227
+ body: JSON.stringify({ clientName: clientName2 })
2228
+ });
2229
+ if (!response.ok) throw new Error(await problemDetail(response));
2230
+ return await response.json();
2206
2231
  }
2207
- async function resolveSite(credentials, site, newSite) {
2208
- const { subdomain, folder } = newSite;
2209
- if (site !== void 0 && subdomain !== void 0) {
2210
- throw new Error(
2211
- "Give either an existing site or a subdomain for a new one, not both: publishing over a site and creating one are different requests."
2212
- );
2213
- }
2214
- if (site !== void 0 && folder !== void 0) {
2215
- throw new Error(
2216
- "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`."
2217
- );
2218
- }
2219
- if (site === void 0) {
2220
- const folderId = folder === void 0 ? void 0 : await resolveFolder(credentials, folder);
2221
- return await createSite(credentials, subdomain, folderId);
2232
+ async function pollDevice(apiBaseUrl, deviceCode) {
2233
+ let response;
2234
+ try {
2235
+ response = await fetch(`${apiBaseUrl}/auth/cli/device/token`, {
2236
+ method: "POST",
2237
+ headers: { "Content-Type": "application/json" },
2238
+ body: JSON.stringify({ deviceCode })
2239
+ });
2240
+ } catch {
2241
+ return { state: "pending" };
2222
2242
  }
2223
- const found = await lookUpSite(credentials, site);
2224
- if (found === void 0) {
2225
- throw new Error(
2226
- `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.`
2227
- );
2243
+ if (response.ok) {
2244
+ const body = await response.json();
2245
+ return { state: "granted", token: { token: body.token, name: body.name, email: body.email } };
2228
2246
  }
2229
- return found;
2230
- }
2231
- async function publish(credentials, source, site, onProgress, newSite = {}) {
2232
- const target = await resolveSite(credentials, site, newSite);
2233
- let files = 0;
2234
- let url = target.url;
2235
- let unchanged = false;
2236
- let mode = null;
2237
- let paths = [];
2238
- let failure2 = null;
2239
- let failureDetail;
2240
- const record = (event) => {
2241
- if (event.type === "collecting") files = event.files;
2242
- if (event.type === "unchanged") {
2243
- unchanged = true;
2244
- url = event.url || target.url;
2245
- }
2246
- if (event.type === "done") {
2247
- url = event.url;
2248
- mode = event.mode;
2249
- }
2250
- if (event.type === "error") {
2251
- failure2 = event.message;
2252
- failureDetail = event.detail;
2253
- }
2254
- onProgress?.(event);
2255
- };
2256
- const watched = async (signal, onRead) => {
2257
- const collected = await source(signal, onRead);
2258
- paths = collected.map((file) => file.path);
2259
- return collected;
2247
+ const problem = await response.json().catch(() => null);
2248
+ if (problem?.type === "authorization_pending") return { state: "pending" };
2249
+ if (problem?.type === "slow_down") return { state: "slow_down" };
2250
+ return {
2251
+ state: "dead",
2252
+ message: problem?.detail ?? `The API answered ${response.status}.`
2260
2253
  };
2261
- await deploy(watched, target.siteId, record, void 0, {
2262
- baseUrl: credentials.apiBaseUrl,
2263
- token: credentials.token
2264
- }).catch((error) => {
2265
- const thrown = new Error(
2266
- failure2 ?? (error instanceof Error ? error.message : String(error))
2267
- );
2268
- thrown.detail = failureDetail ?? error?.detail;
2269
- throw thrown;
2254
+ }
2255
+ async function problemDetail(response) {
2256
+ const problem = await response.json().catch(() => null);
2257
+ return problem?.detail ?? `The API answered ${response.status}.`;
2258
+ }
2259
+ async function exchange(apiBaseUrl, code, verifier) {
2260
+ const response = await fetch(`${apiBaseUrl}/auth/cli/token`, {
2261
+ method: "POST",
2262
+ headers: { "Content-Type": "application/json" },
2263
+ body: JSON.stringify({ code, verifier })
2264
+ });
2265
+ if (!response.ok) throw new Error(await problemDetail(response));
2266
+ const body = await response.json();
2267
+ return { token: body.token, name: body.name, email: body.email };
2268
+ }
2269
+ const VERSIONS_LISTED = 10;
2270
+ function servingModeOf(site) {
2271
+ if (site.spaMode) return "spa";
2272
+ if (site.docsMode) return "docs";
2273
+ return "static";
2274
+ }
2275
+ function accessOf(site) {
2276
+ if (site.passwordProtected) return "password";
2277
+ if (site.invitedOnly === true) return "invite-only";
2278
+ if (site.invitedOnly === false) return "public";
2279
+ return "no password (whether it is invite-only could not be read)";
2280
+ }
2281
+ function addressLines(site) {
2282
+ return [
2283
+ ...site.formerSubdomains.length > 0 ? [` formerly ${site.formerSubdomains.join(", ")} (redirect here)`] : [],
2284
+ ...site.subdomainChangesLeft === null ? [] : [` address ${site.subdomainChangesLeft} change(s) left`]
2285
+ ];
2286
+ }
2287
+ function describeSite(site, folderPath) {
2288
+ const unavailable = (available) => available ? "" : " (not on this plan)";
2289
+ const versions = site.deploys.slice(0, VERSIONS_LISTED).map((deploy2) => {
2290
+ const live = deploy2.deployId === site.liveDeployId ? " ← served" : "";
2291
+ const kept = deploy2.filesKept ? "" : ", files collected — cannot be rolled back to";
2292
+ return ` ${deploy2.deployId} ${deploy2.createdAt} ${deploy2.status}, ${deploy2.fileCount} files${kept}${live}`;
2270
2293
  });
2294
+ const more = site.deploys.length - versions.length;
2295
+ return [
2296
+ `${site.subdomain} — ${site.url}`,
2297
+ ` name ${site.name ?? "(none)"}`,
2298
+ ` status ${site.status}`,
2299
+ ` access ${accessOf(site)}`,
2300
+ ` mode ${servingModeOf(site)}${site.modeIsManual ? "" : " (detected on each publish)"}`,
2301
+ ` password ${site.passwordProtected ? "on" : "off"}${unavailable(site.passwordProtectionAvailable)}`,
2302
+ ` forms ${site.formsEnabled ? "on" : "off"}${unavailable(site.formsAvailable)}`,
2303
+ ` comments ${site.comments ?? "unknown"}${unavailable(site.commentsAvailable)}`,
2304
+ ` takedown ${site.expiresAt === null ? "none" : `${site.expiresAt} (${site.expiryAction})`}${unavailable(site.scheduledExpiryAvailable)}`,
2305
+ ` folder ${site.folderId === null ? "(top level)" : folderPath ?? site.folderId}`,
2306
+ ...addressLines(site),
2307
+ "",
2308
+ site.deploys.length === 0 ? "No versions yet." : "Versions, newest first:",
2309
+ ...versions,
2310
+ ...more > 0 ? [` … and ${more} older`] : []
2311
+ ].join("\n");
2312
+ }
2313
+ const PROJECT_FILE = "drop2run.json";
2314
+ function projectPath(directory = process.cwd()) {
2315
+ return join(directory, PROJECT_FILE);
2316
+ }
2317
+ function readProject(directory = process.cwd(), read = (path) => readFileSync(path, "utf8")) {
2318
+ let parsed;
2319
+ try {
2320
+ parsed = JSON.parse(read(projectPath(directory)));
2321
+ } catch {
2322
+ return null;
2323
+ }
2324
+ if (typeof parsed.siteId !== "string" || parsed.siteId === "") return null;
2271
2325
  return {
2272
- url,
2273
- siteId: target.siteId,
2274
- subdomain: target.subdomain,
2275
- files,
2276
- unchanged,
2277
- mode,
2278
- nestedIndex: mode === "docs" ? nestedIndexOf(paths) : null
2326
+ siteId: parsed.siteId,
2327
+ subdomain: typeof parsed.subdomain === "string" ? parsed.subdomain : "",
2328
+ dir: typeof parsed.dir === "string" && parsed.dir !== "" ? parsed.dir : "."
2279
2329
  };
2280
2330
  }
2281
- async function publishDirectory(credentials, directory, site, onProgress, newSite = {}) {
2282
- const found = await stat(directory).catch(() => null);
2283
- if (found === null) throw new Error(`There is no folder at ${directory}.`);
2284
- if (!found.isDirectory()) throw new Error(`${directory} is a file, not a folder to publish.`);
2285
- return await publish(credentials, directorySource(directory), site, onProgress, newSite);
2331
+ function writeProject(project, directory = process.cwd()) {
2332
+ const path = projectPath(directory);
2333
+ writeFileSync(path, `${JSON.stringify(project, null, " ")}
2334
+ `);
2335
+ return path;
2286
2336
  }
2287
2337
  const silentProgress = Object.assign(() => {
2288
2338
  }, { done: () => {
@@ -2762,7 +2812,7 @@ async function info(site) {
2762
2812
  )?.path;
2763
2813
  return {
2764
2814
  text: describeSite(detail, folderPath),
2765
- json: { ...detail, mode: servingModeOf$1(detail), folderPath: folderPath ?? null },
2815
+ json: { ...detail, mode: servingModeOf(detail), folderPath: folderPath ?? null },
2766
2816
  code: 0
2767
2817
  };
2768
2818
  } catch (error) {
@@ -2799,7 +2849,8 @@ const SETTINGS_USAGE = [
2799
2849
  " expires <ISO 8601 instant> When it comes down; add --then delete to delete rather than pause",
2800
2850
  " folder <path|id|root> Which folder it is filed in",
2801
2851
  " invite-only on|off Whether only the people on its list can open it",
2802
- " comments off|invited|anyone Who may comment on its pages"
2852
+ " comments off|invited|anyone Who may comment on its pages",
2853
+ " subdomain <name> Its address; needs --yes, the old one keeps redirecting"
2803
2854
  ].join("\n");
2804
2855
  function settingChange(setting, values, then) {
2805
2856
  const value = setting === "name" ? values.join(" ").trim() : values[0];
@@ -2847,6 +2898,8 @@ function settingChange(setting, values, then) {
2847
2898
  }
2848
2899
  case "folder":
2849
2900
  return value ? { folder: value } : needs("Clients/Acme");
2901
+ case "subdomain":
2902
+ return value ? { subdomain: value } : needs("tidy-otter --yes");
2850
2903
  case "invite-only":
2851
2904
  if (value === "on" || value === "off") return { change: { invitedOnly: value === "on" } };
2852
2905
  return { refusal: "`set invite-only` takes `on` or `off`." };
@@ -2872,6 +2925,15 @@ async function set(site, setting, values, options, readPassword = () => readSecr
2872
2925
  try {
2873
2926
  const summary = await findSite(found.credentials, target.named);
2874
2927
  let change;
2928
+ if ("subdomain" in parsed) {
2929
+ return await moved(
2930
+ found.credentials,
2931
+ summary.siteId,
2932
+ summary.subdomain,
2933
+ parsed.subdomain,
2934
+ options.confirmed
2935
+ );
2936
+ }
2875
2937
  if ("folder" in parsed) {
2876
2938
  change = { folderId: await resolveFolderTarget(found.credentials, parsed.folder) };
2877
2939
  } else if ("password" in parsed) {
@@ -2894,6 +2956,29 @@ Add --yes if that is what you want, or leave out --then to pause it instead.`
2894
2956
  return failure(error instanceof Error ? error.message : String(error));
2895
2957
  }
2896
2958
  }
2959
+ async function moved(credentials, siteId, current, wanted, confirmed) {
2960
+ if (!confirmed) {
2961
+ const site = await getSite(credentials, siteId);
2962
+ const left2 = site.subdomainChangesLeft === null ? "Your plan does not limit how often it changes." : `It spends one of the ${site.subdomainChangesLeft} address change(s) this site has left, a change back to an old name included.`;
2963
+ return failure(
2964
+ `This moves ${current} to ${wanted}. ${current} keeps working and redirects there, and nobody else can take it. ${left2}
2965
+ Add --yes if that is what you want.`
2966
+ );
2967
+ }
2968
+ const change = await changeSubdomain(credentials, siteId, wanted);
2969
+ const project = readProject();
2970
+ if (project?.siteId === siteId && project.subdomain !== change.subdomain) {
2971
+ writeProject({ ...project, subdomain: change.subdomain });
2972
+ }
2973
+ const left = change.subdomainChangesLeft === null ? "" : `
2974
+ ${change.subdomainChangesLeft} address change(s) left.`;
2975
+ return {
2976
+ text: `${current} is now ${change.url}
2977
+ The old address redirects there.${left}`,
2978
+ json: change,
2979
+ code: 0
2980
+ };
2981
+ }
2897
2982
  async function unset(site, setting, extra = []) {
2898
2983
  if (extra.length > 0) {
2899
2984
  return failure(
@@ -2930,7 +3015,7 @@ async function applied(credentials, siteId, subdomain, change) {
2930
3015
  `Updated ${subdomain}.`,
2931
3016
  ` name ${settings.name ?? "(none)"}`,
2932
3017
  ` access ${accessOf(settings)}`,
2933
- ` mode ${servingModeOf$1(settings)}`,
3018
+ ` mode ${servingModeOf(settings)}`,
2934
3019
  ` forms ${settings.formsEnabled ? "on" : "off"}`,
2935
3020
  ` comments ${settings.comments ?? "unknown"}`,
2936
3021
  ` takedown ${takedown}`,
@@ -2943,7 +3028,7 @@ async function applied(credentials, siteId, subdomain, change) {
2943
3028
  ] : [],
2944
3029
  settings.live ? "Reaching every edge takes up to about a minute." : "Nothing is published yet, so this applies from the first publish."
2945
3030
  ].join("\n"),
2946
- json: { ...settings, mode: servingModeOf$1(settings) },
3031
+ json: { ...settings, mode: servingModeOf(settings) },
2947
3032
  code: 0
2948
3033
  };
2949
3034
  }
@@ -3290,7 +3375,7 @@ async function run(commandLine) {
3290
3375
  const positional = [...positionals(argv), ...literal];
3291
3376
  const [command, ...rest] = positional;
3292
3377
  if (argv.includes("--version")) {
3293
- const { version } = await import("./package-B79z0E4v.js").then(
3378
+ const { version } = await import("./package-DgeW4aj9.js").then(
3294
3379
  (module) => module.default
3295
3380
  );
3296
3381
  return { text: version, json: { version }, code: 0 };