drop2run 0.10.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,705 +100,233 @@ 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;
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 };
180
305
  } catch {
181
306
  return null;
182
307
  }
183
308
  }
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
- { files: manifest },
715
- options,
716
- signal
717
- );
718
- return { ...prepared, uploadUrl: absolute(prepared.uploadUrl, options.baseUrl) };
719
- }
720
- function absolute(uploadUrl, baseUrl) {
721
- if (/^[a-z][a-z0-9+.-]*:/i.test(uploadUrl)) return uploadUrl;
722
- if (baseUrl === void 0 || !/^[a-z][a-z0-9+.-]*:/i.test(baseUrl)) return uploadUrl;
723
- return new URL(uploadUrl, baseUrl).toString();
724
- }
725
- async function completeDeploy(siteId, deployId, options, signal, name) {
726
- const path = `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`;
727
- const body = name === null || name === void 0 ? void 0 : { name };
728
- const delay = options.delay ?? wait;
729
- for (let attempt = 0; ; attempt++) {
730
- try {
731
- return await request(path, body, options, signal);
732
- } catch (error) {
733
- const unanswered = error instanceof DeployError && error.code === ClientErrorCode.NetworkFailed;
734
- if (!unanswered || attempt >= COMPLETE_RETRY_DELAYS_MS.length || signal?.aborted) throw error;
735
- await delay(COMPLETE_RETRY_DELAYS_MS[attempt] ?? 1e3);
736
- }
737
- }
738
- }
739
- function wait(ms) {
740
- return new Promise((resolve2) => setTimeout(resolve2, ms));
741
- }
742
- async function request(path, body, options, signal) {
743
- const doFetch = options.fetch ?? globalFetch$1();
744
- const base = options.baseUrl ?? "/api";
745
- const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
746
- const headers = {};
747
- if (body !== void 0) headers["Content-Type"] = "application/json";
748
- if (options.claimToken) headers[CLAIM_TOKEN_HEADER] = options.claimToken;
749
- if (options.token) headers.Authorization = `Bearer ${options.token}`;
750
- if (options.accountId) headers[ACCOUNT_HEADER] = options.accountId;
751
- const controller = new AbortController();
752
- let timedOut = false;
753
- const timer = setTimeout(() => {
754
- timedOut = true;
755
- controller.abort();
756
- }, timeoutMs);
757
- try {
758
- const response = await doFetch(`${base}/${path}`, {
759
- method: "POST",
760
- headers,
761
- ...body === void 0 ? {} : { body: JSON.stringify(body) },
762
- signal: controller.signal
763
- });
764
- if (!response.ok) throw await problemToError(response);
765
- return await response.json();
766
- } catch (error) {
767
- if (error instanceof DeployError || signal?.aborted) throw error;
768
- if (timedOut) {
769
- throw new DeployError(
770
- ClientErrorCode.NetworkFailed,
771
- `The server did not answer within ${Math.round(timeoutMs / 1e3)} seconds.`,
772
- { path, cause: describeError(error) }
773
- );
774
- }
775
- if (error instanceof TypeError) {
776
- throw new DeployError(ClientErrorCode.NetworkFailed, "Could not reach the server.", {
777
- path,
778
- cause: describeError(error)
779
- });
780
- }
781
- throw error;
782
- } finally {
783
- clearTimeout(timer);
784
- }
785
- }
786
- function globalFetch$1() {
787
- return globalThis.fetch.bind(globalThis);
788
- }
789
- async function problemToError(response) {
790
- let problem = {};
791
- try {
792
- problem = await response.json();
793
- } catch {
794
- return new DeployError(
795
- `http_${response.status}`,
796
- `The server responded with ${response.status}.`
797
- );
798
- }
799
- const code = typeof problem.type === "string" ? problem.type : `http_${response.status}`;
800
- const message = typeof problem.detail === "string" ? problem.detail : typeof problem.title === "string" ? problem.title : `The server responded with ${response.status}.`;
801
- return new DeployError(code, message, problem);
309
+ async function mapInOrder(items, limit, fn, signal) {
310
+ const results = new Array(items.length);
311
+ let next = 0;
312
+ let failure2 = null;
313
+ const lane = async () => {
314
+ for (; ; ) {
315
+ if (failure2 !== null) return;
316
+ const index = next++;
317
+ if (index >= items.length) return;
318
+ try {
319
+ results[index] = await fn(items[index], index);
320
+ } catch (error) {
321
+ failure2 ??= { error };
322
+ return;
323
+ }
324
+ }
325
+ };
326
+ const lanes = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, lane);
327
+ await Promise.all(lanes);
328
+ if (failure2 !== null) throw failure2.error;
329
+ return results;
802
330
  }
803
331
  const FILE_TYPES = [
804
332
  // Documents. The three extensions that make a folder a documents site.
@@ -873,7 +401,7 @@ const FILE_TYPES = [
873
401
  {
874
402
  ext: ".csv",
875
403
  mime: "text/csv; charset=utf-8",
876
- kind: "text",
404
+ kind: "csv",
877
405
  document: false,
878
406
  reader: true,
879
407
  editable: true
@@ -1250,807 +778,1561 @@ const FILE_TYPES = [
1250
778
  reader: false,
1251
779
  editable: false
1252
780
  }
1253
- ];
1254
- function extensionsWhere(include) {
1255
- return FILE_TYPES.filter(include).map((type) => type.ext);
781
+ ];
782
+ function extensionsWhere(include) {
783
+ return FILE_TYPES.filter(include).map((type) => type.ext);
784
+ }
785
+ const DOCUMENT_EXTENSIONS$1 = extensionsWhere((type) => type.document);
786
+ const VIEWABLE_EXTENSIONS$1 = extensionsWhere(
787
+ (type) => type.kind !== "binary"
788
+ );
789
+ extensionsWhere((type) => type.reader);
790
+ extensionsWhere((type) => type.editable);
791
+ const WEB_ASSET_EXTENSIONS = [
792
+ ".html",
793
+ ".htm",
794
+ ".js",
795
+ ".mjs",
796
+ ".cjs",
797
+ ".css"
798
+ ];
799
+ const GALLERY_EXTENSIONS = extensionsWhere(
800
+ (type) => type.kind === "image"
801
+ );
802
+ Object.fromEntries(
803
+ FILE_TYPES.filter((type) => type.mime !== null).map((type) => [type.ext, type.mime])
804
+ );
805
+ function extensionOf(path) {
806
+ const name = path.slice(path.lastIndexOf("/") + 1);
807
+ const dot = name.lastIndexOf(".");
808
+ return dot <= 0 ? "" : name.slice(dot).toLowerCase();
809
+ }
810
+ async function sha256Hex(bytes) {
811
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
812
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
813
+ }
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;
1256
998
  }
1257
- const DOCUMENT_EXTENSIONS$1 = extensionsWhere((type) => type.document);
1258
- const VIEWABLE_EXTENSIONS$1 = extensionsWhere(
1259
- (type) => type.kind !== "binary"
1260
- );
1261
- extensionsWhere((type) => type.reader);
1262
- extensionsWhere((type) => type.editable);
1263
- const WEB_ASSET_EXTENSIONS = [
1264
- ".html",
1265
- ".htm",
1266
- ".js",
1267
- ".mjs",
1268
- ".cjs",
1269
- ".css"
1270
- ];
1271
- const GALLERY_EXTENSIONS = extensionsWhere(
1272
- (type) => type.kind === "image"
1273
- );
1274
- Object.fromEntries(
1275
- FILE_TYPES.filter((type) => type.mime !== null).map((type) => [type.ext, type.mime])
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);
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);
1107
+ }
1108
+ );
1109
+ }
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"
1276
1113
  );
1277
- function extensionOf(path) {
1278
- const name = path.slice(path.lastIndexOf("/") + 1);
1279
- const dot = name.lastIndexOf(".");
1280
- return dot <= 0 ? "" : name.slice(dot).toLowerCase();
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;
1281
1122
  }
1282
- async function sha256Hex(bytes) {
1283
- const digest = await crypto.subtle.digest("SHA-256", bytes);
1284
- return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
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;
1285
1149
  }
1286
- async function hashAll(files, onProgress, signal) {
1287
- const hashed = [];
1288
- for (const [index, file] of files.entries()) {
1289
- hashed.push({ ...file, sha256: await sha256Hex(await readForHash(file)) });
1290
- onProgress?.(index + 1, files.length);
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";
1291
1162
  }
1292
- return hashed;
1293
1163
  }
1294
- async function readForHash(file) {
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;
1194
+ let waiting = [];
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);
1303
+ }
1304
+ inFlight.delete(index);
1305
+ done += members.length;
1306
+ landedBytes += size;
1307
+ report(members.at(-1).path);
1308
+ }
1309
+ };
1310
+ const workers = Array.from(
1311
+ { length: Math.min(Math.max(concurrency, batchConcurrency), targets.length) },
1312
+ worker
1313
+ );
1295
1314
  try {
1296
- return await bytesOf(file.bytes);
1315
+ await Promise.all(workers);
1297
1316
  } catch (error) {
1298
- if (error instanceof RangeError) throw error;
1299
- throw new DeployError(
1300
- ClientErrorCode.ReadFailed,
1301
- `Could not read ${file.path}. It may have been moved, renamed or changed since you dropped it — drop the folder again.`,
1302
- { path: file.path, cause: describeError(error) }
1303
- );
1317
+ throw asDeployError(firstFailure ?? error);
1318
+ } finally {
1304
1319
  }
1305
1320
  }
1306
- const IGNORED_EXACT = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".gitignore", ".gitkeep"]);
1307
- const IGNORED_DIRECTORIES = [
1308
- "__MACOSX",
1309
- ".git",
1310
- "node_modules",
1311
- ".svn",
1312
- ".hg",
1313
- ".idea",
1314
- ".vscode",
1315
- ".claude",
1316
- ".agents",
1317
- ".cursor",
1318
- ".codex",
1319
- ".gemini"
1320
- ];
1321
- function shouldIgnore(path) {
1322
- const segments = path.split("/");
1323
- const name = segments.at(-1) ?? "";
1324
- if (name === "" || IGNORED_EXACT.has(name)) return true;
1325
- if (name.startsWith("._")) return true;
1326
- return segments.some((segment) => IGNORED_DIRECTORIES.includes(segment));
1327
- }
1328
- const REQUIRED_INDEX = "index.html";
1329
- const DOCUMENT_EXTENSIONS = DOCUMENT_EXTENSIONS$1;
1330
- function isDocumentPath(path) {
1331
- const lower = path.toLowerCase();
1332
- return DOCUMENT_EXTENSIONS.some((extension) => lower.endsWith(extension));
1333
- }
1334
- const VIEWABLE_EXTENSIONS = VIEWABLE_EXTENSIONS$1;
1335
- function isViewablePath(path) {
1336
- const lower = path.toLowerCase();
1337
- return VIEWABLE_EXTENSIONS.some((extension) => lower.endsWith(extension));
1338
- }
1339
- function isImageFolder(paths) {
1340
- let pictures = false;
1341
- for (const path of paths) {
1342
- const ext = extensionOf(path);
1343
- if (WEB_ASSET_EXTENSIONS.includes(ext)) return false;
1344
- if (GALLERY_EXTENSIONS.includes(ext)) pictures = true;
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
+ );
1347
+ }
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
+ }
1345
1361
  }
1346
- return pictures;
1347
- }
1348
- function canPublish(paths) {
1349
- if (paths.some((path) => path === REQUIRED_INDEX)) return true;
1350
- if (paths.some(isDocumentPath)) return true;
1351
- if (isImageFolder(paths)) return true;
1352
- return paths.length === 1 && paths[0] !== void 0 && isViewablePath(paths[0]);
1353
- }
1354
- function servingModeOf(flags) {
1355
- if (flags.docsMode) return "docs";
1356
- if (flags.spaMode) return "spa";
1357
- return "static";
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
+ });
1358
1367
  }
1359
- function predictedModeOf(paths) {
1360
- if (paths.some((path) => path === REQUIRED_INDEX)) return "static";
1361
- if (paths.some(isDocumentPath)) return "docs";
1362
- if (isImageFolder(paths)) return "docs";
1363
- if (paths.length === 1 && paths[0] !== void 0 && isViewablePath(paths[0])) return "docs";
1364
- return "static";
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
+ }
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
+ });
1365
1432
  }
1366
- function nestedIndexOf(paths) {
1367
- if (predictedModeOf(paths) !== "docs") return null;
1368
- const suffix = `/${REQUIRED_INDEX}`;
1369
- 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));
1370
- return folders2[0] ?? 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.`;
1445
+ }
1371
1446
  }
1372
- function renameOfLoneHtmlPage(paths) {
1373
- if (paths.length !== 1) return null;
1374
- const [only] = paths;
1375
- if (only === void 0 || only === REQUIRED_INDEX) return null;
1376
- const lower = only.toLowerCase();
1377
- return lower.endsWith(".html") || lower.endsWith(".htm") ? only : null;
1447
+ function untyped(content) {
1448
+ if (ArrayBuffer.isView(content) || content.type === "") return content;
1449
+ return content.slice(0, content.size);
1378
1450
  }
1379
- function formatBytes$1(bytes) {
1380
- if (bytes < 1024) return `${bytes} B`;
1381
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1382
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1383
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
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);
1384
1455
  }
1385
- function checkLimits(files, limits) {
1386
- if (files.length === 0) {
1387
- throw new DeployError(
1388
- ClientErrorCode.Empty,
1389
- "That drop contained no files to deploy. Folders like .git and node_modules are skipped."
1390
- );
1391
- }
1392
- if (!canPublish(files.map((file) => file.path))) {
1393
- throw new DeployError(
1394
- ClientErrorCode.MissingIndex,
1395
- `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.`
1396
- );
1397
- }
1398
- const refusal = sizeRefusal(
1399
- files.map((file) => ({ path: file.path, size: sizeOf(file.bytes) })),
1400
- limits
1401
- );
1402
- if (refusal) throw refusal;
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
+ };
1403
1475
  }
1404
- function sizeRefusal(files, limits) {
1405
- if (!limits) return null;
1406
- if (files.length > limits.maxFiles) {
1407
- return new DeployError(
1408
- ClientErrorCode.TooManyFiles,
1409
- `That is ${files.length} files, and this plan allows ${limits.maxFiles}.`,
1410
- { limit: limits.maxFiles, actual: files.length }
1411
- );
1412
- }
1413
- const oversized = files.find((file) => file.size > limits.maxFileBytes);
1414
- if (oversized) {
1415
- return new DeployError(
1416
- ClientErrorCode.FileTooLarge,
1417
- `${oversized.path} is ${formatBytes$1(oversized.size)}, and this plan allows ${formatBytes$1(limits.maxFileBytes)} per file.`,
1418
- {
1419
- path: oversized.path,
1420
- limit: limits.maxFileBytes,
1421
- actual: oversized.size
1422
- }
1423
- );
1424
- }
1425
- const total = files.reduce((sum, file) => sum + file.size, 0);
1426
- if (limits.maxSiteBytes !== null && total > limits.maxSiteBytes) {
1427
- return new DeployError(
1428
- ClientErrorCode.TooLarge,
1429
- `That drop is ${formatBytes$1(total)}, and this plan allows ${formatBytes$1(limits.maxSiteBytes)}.`,
1430
- { limit: limits.maxSiteBytes, actual: total }
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
+ )
1431
1515
  );
1432
- }
1433
- return null;
1516
+ xhr.onerror = () => settle(() => reject(new TypeError("Network error during upload.")));
1517
+ arm();
1518
+ xhr.send(body);
1519
+ });
1434
1520
  }
1435
- const MAX_NAME_LENGTH = 60;
1436
- const HEAD_BYTES = 64 * 1024;
1437
- const TITLE_SEPARATORS = ["|", "·", "—", "–", " - "];
1438
- function suggestSiteName(files) {
1439
- const index = files.find((file) => file.path === "index.html");
1440
- if (index === void 0 || !ArrayBuffer.isView(index.bytes)) return null;
1441
- try {
1442
- return nameFromHtml(decodeHead(index.bytes));
1443
- } catch {
1444
- return null;
1445
- }
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;
1446
1527
  }
1447
- async function readSiteName(files) {
1448
- const index = files.find((file) => file.path === "index.html");
1449
- if (index === void 0) return null;
1450
- if (ArrayBuffer.isView(index.bytes)) return suggestSiteName(files);
1451
- try {
1452
- const head = new Uint8Array(await index.bytes.slice(0, HEAD_BYTES).arrayBuffer());
1453
- return suggestSiteName([{ path: index.path, bytes: head }]);
1454
- } catch {
1455
- return null;
1456
- }
1528
+ function globalFetch() {
1529
+ return globalThis.fetch.bind(globalThis);
1457
1530
  }
1458
- function decodeHead(bytes) {
1459
- return new TextDecoder("utf-8", { fatal: false }).decode(
1460
- bytes.subarray(0, HEAD_BYTES)
1531
+ function defaultDelay(ms) {
1532
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1533
+ }
1534
+ function isAbort(error) {
1535
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
1536
+ }
1537
+ function cancelled() {
1538
+ return new DeployError(ClientErrorCode.Cancelled, "Deploy cancelled.");
1539
+ }
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
1461
1545
  );
1462
1546
  }
1463
- function nameFromHtml(html) {
1464
- const document = parse(html);
1465
- const candidates = document ? [
1466
- document.querySelector('meta[property="og:site_name"]')?.getAttribute("content"),
1467
- document.querySelector("title")?.textContent,
1468
- document.querySelector("h1")?.textContent
1469
- ] : [matchOgSiteName(html), matchTag(html, "title"), matchTag(html, "h1")];
1470
- for (const candidate of candidates) {
1471
- const name = clean(candidate);
1472
- if (name !== null) return name;
1473
- }
1474
- return null;
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
1555
+ );
1475
1556
  }
1476
- function parse(html) {
1477
- const parser = globalThis.DOMParser;
1478
- if (parser === void 0) return null;
1557
+ async function deploy(source, siteId, onProgress, signal, options = {}) {
1479
1558
  try {
1480
- return new parser().parseFromString(html, "text/html");
1481
- } catch {
1482
- return null;
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;
1578
+ }
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
+ });
1598
+ }
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;
1483
1642
  }
1484
1643
  }
1485
- function matchTag(html, tag) {
1486
- const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(
1487
- html
1488
- );
1489
- return match?.[1] === void 0 ? null : decodeEntities(match[1]);
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" }
1652
+ }
1653
+ });
1654
+ if (!response.ok) throw await failureOf(response);
1655
+ return await response.json();
1490
1656
  }
1491
- function matchOgSiteName(html) {
1492
- const patterns = [
1493
- /<meta[^>]*property\s*=\s*["']og:site_name["'][^>]*content\s*=\s*["']([^"']*)["']/i,
1494
- /<meta[^>]*content\s*=\s*["']([^"']*)["'][^>]*property\s*=\s*["']og:site_name["']/i
1495
- ];
1496
- for (const pattern of patterns) {
1497
- const match = pattern.exec(html);
1498
- if (match?.[1] !== void 0) return decodeEntities(match[1]);
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
+ );
1499
1669
  }
1500
- return null;
1670
+ return found;
1501
1671
  }
1502
- 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(" ");
1503
- const NAMED_ENTITIES = new Map([
1504
- ...LATIN1_ENTITIES.map((name, index) => [name, 160 + index]),
1505
- ["amp", 38],
1506
- ["lt", 60],
1507
- ["gt", 62],
1508
- ["quot", 34],
1509
- ["apos", 39],
1510
- ["ensp", 8194],
1511
- ["emsp", 8195],
1512
- ["thinsp", 8201],
1513
- ["ndash", 8211],
1514
- ["mdash", 8212],
1515
- ["lsquo", 8216],
1516
- ["rsquo", 8217],
1517
- ["sbquo", 8218],
1518
- ["ldquo", 8220],
1519
- ["rdquo", 8221],
1520
- ["bdquo", 8222],
1521
- ["dagger", 8224],
1522
- ["Dagger", 8225],
1523
- ["bull", 8226],
1524
- ["hellip", 8230],
1525
- ["prime", 8242],
1526
- ["lsaquo", 8249],
1527
- ["rsaquo", 8250],
1528
- ["euro", 8364],
1529
- ["trade", 8482],
1530
- ["larr", 8592],
1531
- ["rarr", 8594],
1532
- ["hearts", 9829]
1533
- ]);
1534
- function decodeEntities(text) {
1535
- return text.replace(
1536
- /&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{1,31}));/g,
1537
- (whole, decimal, hex, name) => {
1538
- if (name !== void 0) {
1539
- const code2 = NAMED_ENTITIES.get(name);
1540
- return code2 === void 0 ? whole : String.fromCodePoint(code2);
1541
- }
1542
- const code = decimal !== void 0 ? Number(decimal) : Number.parseInt(hex ?? "", 16);
1543
- const valid = code > 0 && code <= 1114111 && (code < 55296 || code > 57343);
1544
- return String.fromCodePoint(valid ? code : 65533);
1545
- }
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}` } }
1546
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 };
1547
1699
  }
1548
- const INVISIBLE = new RegExp(
1549
- `[${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)}]`,
1550
- "g"
1551
- );
1552
- function clean(raw) {
1553
- if (raw === null || raw === void 0) return null;
1554
- if (raw.includes("<") || raw.includes(">")) return null;
1555
- const collapsed = raw.replace(INVISIBLE, " ").replace(/\s+/g, " ").trim();
1556
- const name = trimSuffix(collapsed);
1557
- if (name.length === 0) return null;
1558
- if (PLACEHOLDERS.has(name.toLowerCase())) return null;
1559
- return name.length > MAX_NAME_LENGTH ? name.slice(0, MAX_NAME_LENGTH).trimEnd() : name;
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);
1560
1706
  }
1561
- const PLACEHOLDERS = /* @__PURE__ */ new Set([
1562
- "document",
1563
- "untitled",
1564
- "untitled document",
1565
- "index",
1566
- "home",
1567
- "new page",
1568
- "my site",
1569
- "vite app",
1570
- "vite + react",
1571
- "react app",
1572
- "create next app",
1573
- "svelte app",
1574
- "webpack app",
1575
- "hello world",
1576
- "title"
1577
- ]);
1578
- function trimSuffix(title) {
1579
- for (const separator of TITLE_SEPARATORS) {
1580
- const at = title.lastIndexOf(separator);
1581
- if (at <= 0) continue;
1582
- const before = title.slice(0, at).trim();
1583
- const after = title.slice(at + separator.length).trim();
1584
- if (before.length > 0 && after.length > 0) return after;
1585
- }
1586
- return title;
1707
+ async function promoteDeploy(credentials, siteId, deployId) {
1708
+ return await call(
1709
+ credentials,
1710
+ `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/promote`,
1711
+ { method: "POST" }
1712
+ );
1587
1713
  }
1588
- const DEFAULT_CONCURRENCY = 8;
1589
- const DEFAULT_BYTES_IN_FLIGHT = 64 * 1024 * 1024;
1590
- const DEFAULT_RETRIES = 3;
1591
- const BACKOFF_MS = [1e3, 2e3, 4e3];
1592
- const DEFAULT_STALL_MS = 3e4;
1593
- const PROGRESS_INTERVAL_MS = 200;
1594
- class StalledError extends Error {
1595
- /** @param seconds How long nothing moved. */
1596
- constructor(seconds) {
1597
- super(`No data moved for ${seconds}s.`);
1598
- this.name = "StalledError";
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) {
1721
+ try {
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;
1599
1729
  }
1600
1730
  }
1601
- async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {}, onRetry) {
1602
- const put = deps.put ?? defaultTransport(deps.fetch);
1603
- const delay = deps.delay ?? defaultDelay;
1604
- const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
1605
- const retries = deps.retries ?? DEFAULT_RETRIES;
1606
- const stallMs = deps.stallMs ?? DEFAULT_STALL_MS;
1607
- const now = deps.now ?? Date.now;
1608
- const maxBytesInFlight = deps.maxBytesInFlight ?? DEFAULT_BYTES_IN_FLIGHT;
1609
- const byPath = new Map(files.map((file) => [file.path, file]));
1610
- let nextIndex = 0;
1611
- let done = 0;
1612
- let failed = false;
1613
- let reserved = 0;
1614
- let waiting = [];
1615
- const acquire = async (size) => {
1616
- while (reserved > 0 && reserved + size > maxBytesInFlight) {
1617
- await new Promise((resolve2) => waiting.push(resolve2));
1618
- }
1619
- reserved += size;
1620
- };
1621
- const release = (size) => {
1622
- reserved -= size;
1623
- const woken = waiting;
1624
- waiting = [];
1625
- for (const wake of woken) wake();
1626
- };
1627
- let landedBytes = 0;
1628
- const inFlight = /* @__PURE__ */ new Map();
1629
- let lastReport = Number.NEGATIVE_INFINITY;
1630
- const report = (path) => {
1631
- if (onProgress === void 0) return;
1632
- const at = now();
1633
- if (path === void 0 && at - lastReport < PROGRESS_INTERVAL_MS) return;
1634
- lastReport = at;
1635
- let bytes = landedBytes;
1636
- for (const sent of inFlight.values()) bytes += sent;
1637
- onProgress(done, targets.length, bytes, path);
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)
1638
1776
  };
1639
- const worker = async () => {
1640
- for (; ; ) {
1641
- if (failed) return;
1642
- const index = nextIndex++;
1643
- const target = targets[index];
1644
- if (target === void 0) return;
1645
- const file = byPath.get(target.path);
1646
- if (file === void 0) {
1647
- failed = true;
1648
- throw new DeployError(
1649
- ClientErrorCode.UploadFailed,
1650
- `The server asked for ${target.path}, which is not in the manifest.`,
1651
- { path: target.path }
1652
- );
1653
- }
1654
- const size = sizeOf(file.bytes);
1655
- await acquire(size);
1656
- try {
1657
- if (failed) return;
1658
- signal?.throwIfAborted();
1659
- await uploadOne(target, file, {
1660
- put,
1661
- delay,
1662
- retries,
1663
- signal,
1664
- uploadUrl,
1665
- stallMs,
1666
- onSent: (sent) => {
1667
- inFlight.set(index, sent);
1668
- report();
1669
- },
1670
- onRetry: (attempt, stalled) => {
1671
- inFlight.set(index, 0);
1672
- onRetry?.(target.path, attempt, retries + 1, stalled);
1673
- }
1674
- });
1675
- } catch (error) {
1676
- failed = true;
1677
- throw error;
1678
- } finally {
1679
- release(size);
1680
- }
1681
- inFlight.delete(index);
1682
- done += 1;
1683
- landedBytes += size;
1684
- report(file.path);
1685
- }
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)
1686
1786
  };
1687
- const workers = Array.from({ length: Math.min(concurrency, targets.length) }, worker);
1688
- try {
1689
- await Promise.all(workers);
1690
- } catch (error) {
1691
- throw asDeployError(error);
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";
1692
1794
  }
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)
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
+ };
1816
+ }
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
+ };
1693
1838
  }
1694
- async function uploadOne(target, file, context) {
1695
- let lastError;
1696
- for (let attempt = 0; attempt <= context.retries; attempt++) {
1697
- context.signal?.throwIfAborted();
1698
- if (attempt > 0) {
1699
- context.onRetry(attempt + 1, lastError instanceof StalledError);
1700
- await context.delay(BACKOFF_MS[attempt - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
1701
- context.signal?.throwIfAborted();
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
+ })
1702
1863
  }
1703
- try {
1704
- const status = await context.put({
1705
- url: context.uploadUrl,
1706
- token: target.token,
1707
- body: untyped(file.bytes),
1708
- signal: context.signal,
1709
- onSent: context.onSent,
1710
- stallMs: context.stallMs
1711
- });
1712
- if (status >= 200 && status < 300) return;
1713
- if (status === 401 || status === 403 || status === 429) {
1714
- throw new DeployError(
1715
- ClientErrorCode.UploadFailed,
1716
- `Upload of ${target.path} was refused. The upload window may have expired — try deploying again.`,
1717
- { path: target.path, status }
1718
- );
1719
- }
1720
- if (status === 413) {
1721
- throw new DeployError(
1722
- ClientErrorCode.UploadFailed,
1723
- `${target.path} is too large to upload in one request. Make it smaller, or leave it out of this publish.`,
1724
- { path: target.path, status }
1725
- );
1726
- }
1727
- lastError = new Error(`HTTP ${status}`);
1728
- } catch (error) {
1729
- if (error instanceof DeployError) throw error;
1730
- if (isAbort(error)) throw cancelled();
1731
- lastError = error;
1864
+ );
1865
+ return { ...created, name: null };
1866
+ }
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);
1732
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
+ );
1733
1891
  }
1734
- 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.`;
1735
- throw new DeployError(ClientErrorCode.UploadFailed, message, {
1736
- path: target.path,
1737
- cause: describeError(lastError)
1738
- });
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
+ );
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
+ );
1739
1912
  }
1740
- function untyped(content) {
1741
- if (ArrayBuffer.isView(content) || content.type === "") return content;
1742
- 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);
1743
1916
  }
1744
- function defaultTransport(doFetch) {
1745
- if (doFetch !== void 0) return fetchTransport(doFetch);
1746
- const Xhr = globalThis.XMLHttpRequest;
1747
- 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
+ );
1748
1929
  }
1749
- function fetchTransport(doFetch) {
1750
- return async ({ url, token, body, signal }) => {
1751
- const response = await doFetch(url, {
1752
- method: "PUT",
1753
- // The view, not a copy. Wrapping it in `new Blob([body.slice().buffer])` made two extra
1754
- // full-size copies of every file in flight — eight at once, so up to 400 MB on a drop of
1755
- // 50 MB files. `fetch` sends exactly the range a view covers. The cast is TypeScript's
1756
- // SharedArrayBuffer distinction again; see `sha256Hex`. A `Blob` goes as it is, and the runtime
1757
- // streams it from disk.
1758
- body,
1759
- // The permit is the whole request. `Content-Length` is required by the Worker and cannot be
1760
- // set here — it is a forbidden header name — but the runtime derives it from the body, which
1761
- // is exactly the length the token names.
1762
- headers: { Authorization: `Bearer ${token}` },
1763
- ...signal ? { signal } : {}
1764
- });
1765
- return 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
+ }))
1766
1957
  };
1767
1958
  }
1768
- function xhrTransport(Xhr) {
1769
- return ({ url, token, body, signal, onSent, stallMs }) => new Promise((resolve2, reject) => {
1770
- const xhr = new Xhr();
1771
- let settled = false;
1772
- let timer;
1773
- const settle = (finish) => {
1774
- if (settled) return;
1775
- settled = true;
1776
- clearTimeout(timer);
1777
- signal?.removeEventListener("abort", onAbort);
1778
- finish();
1779
- };
1780
- const arm = () => {
1781
- clearTimeout(timer);
1782
- timer = setTimeout(() => {
1783
- xhr.abort();
1784
- settle(() => reject(new StalledError(Math.round(stallMs / 1e3))));
1785
- }, stallMs);
1786
- };
1787
- function onAbort() {
1788
- xhr.abort();
1789
- settle(() => reject(abortReason(signal)));
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
+ );
2005
+ }
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;
1790
2031
  }
1791
- if (signal?.aborted) {
1792
- reject(abortReason(signal));
1793
- return;
2032
+ if (event.type === "done") {
2033
+ url = event.url;
2034
+ mode = event.mode;
1794
2035
  }
1795
- signal?.addEventListener("abort", onAbort, { once: true });
1796
- xhr.open("PUT", url);
1797
- xhr.setRequestHeader("Authorization", `Bearer ${token}`);
1798
- xhr.upload.onprogress = (event) => {
1799
- arm();
1800
- onSent(event.loaded);
1801
- };
1802
- xhr.onprogress = arm;
1803
- xhr.onload = () => settle(() => resolve2(xhr.status));
1804
- xhr.onerror = () => settle(() => reject(new TypeError("Network error during upload.")));
1805
- arm();
1806
- xhr.send(body);
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))
2055
+ );
2056
+ thrown.detail = failureDetail ?? error?.detail;
2057
+ thrown.code = failureCode ?? error?.code;
2058
+ throw thrown;
1807
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
+ };
1808
2069
  }
1809
- function abortReason(signal) {
1810
- const reason = signal?.reason;
1811
- if (reason instanceof Error) return reason;
1812
- const error = new Error("Aborted.");
1813
- error.name = "AbortError";
1814
- return error;
1815
- }
1816
- function globalFetch() {
1817
- 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
+ };
1818
2078
  }
1819
- function defaultDelay(ms) {
1820
- 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
+ }
1821
2106
  }
1822
- function isAbort(error) {
1823
- 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("..");
1824
2110
  }
1825
- function cancelled() {
1826
- 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);
1827
2116
  }
1828
- function outOfMemory(detail) {
1829
- return new DeployError(
1830
- ClientErrorCode.OutOfMemory,
1831
- "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.",
1832
- detail
1833
- );
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
+ };
1834
2125
  }
1835
- function asDeployError(error) {
1836
- if (error instanceof DeployError) return error;
1837
- if (isAbort(error)) return cancelled();
1838
- if (error instanceof RangeError) return outOfMemory(error.message);
1839
- return new DeployError(
1840
- ClientErrorCode.UploadFailed,
1841
- error instanceof Error ? error.message : "Upload failed.",
1842
- error
1843
- );
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}`;
1844
2131
  }
1845
- 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]];
1846
2134
  try {
1847
- onProgress({ type: "reading", bytes: 0, total: 0 });
1848
- const collected = await source(
1849
- signal,
1850
- (bytes, total) => onProgress({ type: "reading", bytes, total })
1851
- );
1852
- onProgress({ type: "collecting", files: collected.length });
1853
- const rename = renameOfLoneHtmlPage(collected.map((file) => file.path));
1854
- const files = rename === null ? collected : collected.map((file) => file.path === rename ? { ...file, path: "index.html" } : file);
1855
- const hashed = await hashAll(
1856
- files,
1857
- (done, total) => onProgress({ type: "hashing", done, total }),
1858
- signal
1859
- );
1860
- checkLimits(hashed, options.limits ?? null);
1861
- onProgress({ type: "preparing" });
1862
- const prepared = await prepareDeploy(siteId, hashed, options, signal);
1863
- if (prepared.unchanged) {
1864
- onProgress({ type: "unchanged", url: prepared.url ?? "" });
1865
- return;
1866
- }
1867
- const sizes = new Map(hashed.map((file) => [file.path, sizeOf(file.bytes)]));
1868
- const totalBytes = prepared.upload.reduce(
1869
- (sum, target) => sum + (sizes.get(target.path) ?? 0),
1870
- 0
1871
- );
1872
- if (prepared.upload.length > 0) {
1873
- onProgress({
1874
- type: "uploading",
1875
- done: prepared.reused,
1876
- total: prepared.total,
1877
- bytes: 0,
1878
- totalBytes,
1879
- reused: prepared.reused
1880
- });
1881
- }
1882
- await uploadAll(
1883
- prepared.upload,
1884
- prepared.uploadUrl,
1885
- hashed,
1886
- (done, _total, bytes, path) => onProgress({
1887
- type: "uploading",
1888
- done: prepared.reused + done,
1889
- total: prepared.total,
1890
- bytes,
1891
- totalBytes,
1892
- reused: prepared.reused,
1893
- ...path === void 0 ? {} : { path }
1894
- }),
1895
- signal,
1896
- options.upload,
1897
- (path, attempt, attempts, stalled) => onProgress({ type: "retrying", path, attempt, attempts, stalled })
1898
- );
1899
- onProgress({ type: "completing", reused: prepared.reused });
1900
- const completed = await completeDeploy(
1901
- siteId,
1902
- prepared.deployId,
1903
- options,
1904
- signal,
1905
- await readSiteName(files)
1906
- );
1907
- onProgress({
1908
- type: "done",
1909
- url: completed.url,
1910
- name: completed.name,
1911
- mode: servingModeOf(completed),
1912
- modeIsManual: completed.modeIsManual
2135
+ const child = spawn(command, [...args], { stdio: "ignore", detached: true });
2136
+ child.unref();
2137
+ child.on("error", () => {
1913
2138
  });
1914
- } catch (error) {
1915
- const failure2 = asDeployError(error);
1916
- onProgress({
1917
- type: "error",
1918
- code: failure2.code,
1919
- message: failure2.message,
1920
- ...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
+ });
1921
2189
  });
1922
- throw failure2;
1923
- }
2190
+ });
1924
2191
  }
1925
- const MAX_FILES = 2e4;
1926
- function directorySource(root) {
1927
- return async () => {
1928
- const base = await realpath(root);
1929
- const files = [];
1930
- await walk(base, base, files);
1931
- return files;
1932
- };
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
+ );
1933
2198
  }
1934
- async function walk(root, directory, into) {
1935
- for (const entry of await readdir(directory, { withFileTypes: true })) {
1936
- const full = join(directory, entry.name);
1937
- const path = relative(root, full).split(sep).join("/");
1938
- if (shouldIgnore(path)) continue;
1939
- if (entry.isSymbolicLink()) {
1940
- const target = await realpath(full).catch(() => null);
1941
- if (target === null || escapesRoot(root, target)) continue;
1942
- const targeted = await stat(target);
1943
- if (targeted.isDirectory()) {
1944
- await walk(root, target, into);
1945
- continue;
1946
- }
1947
- if (!targeted.isFile()) continue;
1948
- } else if (entry.isDirectory()) {
1949
- await walk(root, full, into);
1950
- continue;
1951
- } else if (!entry.isFile()) {
1952
- continue;
1953
- }
1954
- if (into.length >= MAX_FILES) {
1955
- throw new Error(
1956
- `This folder holds more than ${MAX_FILES.toLocaleString()} files. Publish a build output directory rather than a whole project.`
1957
- );
1958
- }
1959
- 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);
1960
2213
  }
1961
2214
  }
1962
- function escapesRoot(root, candidate) {
1963
- const inside = relative(root, candidate);
1964
- 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();
1965
2222
  }
1966
- function describeMode(result) {
1967
- if (result.mode === null) return null;
1968
- if (result.nestedIndex !== null) {
1969
- 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.`;
1970
- }
1971
- if (result.mode === "docs") return "Served as documents, in the documents reader.";
1972
- if (result.mode === "spa") return "Served as a single-page app.";
1973
- 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();
1974
2231
  }
1975
- async function resolveSite(credentials, site, newSite) {
1976
- const { subdomain, folder } = newSite;
1977
- if (site !== void 0 && subdomain !== void 0) {
1978
- throw new Error(
1979
- "Give either an existing site or a subdomain for a new one, not both: publishing over a site and creating one are different requests."
1980
- );
1981
- }
1982
- if (site !== void 0 && folder !== void 0) {
1983
- throw new Error(
1984
- "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`."
1985
- );
1986
- }
1987
- if (site === void 0) {
1988
- const folderId = folder === void 0 ? void 0 : await resolveFolder(credentials, folder);
1989
- 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" };
1990
2242
  }
1991
- const found = await lookUpSite(credentials, site);
1992
- if (found === void 0) {
1993
- throw new Error(
1994
- `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.`
1995
- );
2243
+ if (response.ok) {
2244
+ const body = await response.json();
2245
+ return { state: "granted", token: { token: body.token, name: body.name, email: body.email } };
1996
2246
  }
1997
- return found;
1998
- }
1999
- async function publish(credentials, source, site, onProgress, newSite = {}) {
2000
- const target = await resolveSite(credentials, site, newSite);
2001
- let files = 0;
2002
- let url = target.url;
2003
- let unchanged = false;
2004
- let mode = null;
2005
- let paths = [];
2006
- let failure2 = null;
2007
- let failureDetail;
2008
- const record = (event) => {
2009
- if (event.type === "collecting") files = event.files;
2010
- if (event.type === "unchanged") {
2011
- unchanged = true;
2012
- url = event.url || target.url;
2013
- }
2014
- if (event.type === "done") {
2015
- url = event.url;
2016
- mode = event.mode;
2017
- }
2018
- if (event.type === "error") {
2019
- failure2 = event.message;
2020
- failureDetail = event.detail;
2021
- }
2022
- onProgress?.(event);
2023
- };
2024
- const watched = async (signal, onRead) => {
2025
- const collected = await source(signal, onRead);
2026
- paths = collected.map((file) => file.path);
2027
- 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}.`
2028
2253
  };
2029
- await deploy(watched, target.siteId, record, void 0, {
2030
- baseUrl: credentials.apiBaseUrl,
2031
- token: credentials.token
2032
- }).catch((error) => {
2033
- const thrown = new Error(
2034
- failure2 ?? (error instanceof Error ? error.message : String(error))
2035
- );
2036
- thrown.detail = failureDetail ?? error?.detail;
2037
- 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}`;
2038
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;
2039
2325
  return {
2040
- url,
2041
- siteId: target.siteId,
2042
- subdomain: target.subdomain,
2043
- files,
2044
- unchanged,
2045
- mode,
2046
- 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 : "."
2047
2329
  };
2048
2330
  }
2049
- async function publishDirectory(credentials, directory, site, onProgress, newSite = {}) {
2050
- const found = await stat(directory).catch(() => null);
2051
- if (found === null) throw new Error(`There is no folder at ${directory}.`);
2052
- if (!found.isDirectory()) throw new Error(`${directory} is a file, not a folder to publish.`);
2053
- 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;
2054
2336
  }
2055
2337
  const silentProgress = Object.assign(() => {
2056
2338
  }, { done: () => {
@@ -2530,7 +2812,7 @@ async function info(site) {
2530
2812
  )?.path;
2531
2813
  return {
2532
2814
  text: describeSite(detail, folderPath),
2533
- json: { ...detail, mode: servingModeOf$1(detail), folderPath: folderPath ?? null },
2815
+ json: { ...detail, mode: servingModeOf(detail), folderPath: folderPath ?? null },
2534
2816
  code: 0
2535
2817
  };
2536
2818
  } catch (error) {
@@ -2567,7 +2849,8 @@ const SETTINGS_USAGE = [
2567
2849
  " expires <ISO 8601 instant> When it comes down; add --then delete to delete rather than pause",
2568
2850
  " folder <path|id|root> Which folder it is filed in",
2569
2851
  " invite-only on|off Whether only the people on its list can open it",
2570
- " 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"
2571
2854
  ].join("\n");
2572
2855
  function settingChange(setting, values, then) {
2573
2856
  const value = setting === "name" ? values.join(" ").trim() : values[0];
@@ -2615,6 +2898,8 @@ function settingChange(setting, values, then) {
2615
2898
  }
2616
2899
  case "folder":
2617
2900
  return value ? { folder: value } : needs("Clients/Acme");
2901
+ case "subdomain":
2902
+ return value ? { subdomain: value } : needs("tidy-otter --yes");
2618
2903
  case "invite-only":
2619
2904
  if (value === "on" || value === "off") return { change: { invitedOnly: value === "on" } };
2620
2905
  return { refusal: "`set invite-only` takes `on` or `off`." };
@@ -2640,6 +2925,15 @@ async function set(site, setting, values, options, readPassword = () => readSecr
2640
2925
  try {
2641
2926
  const summary = await findSite(found.credentials, target.named);
2642
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
+ }
2643
2937
  if ("folder" in parsed) {
2644
2938
  change = { folderId: await resolveFolderTarget(found.credentials, parsed.folder) };
2645
2939
  } else if ("password" in parsed) {
@@ -2662,6 +2956,29 @@ Add --yes if that is what you want, or leave out --then to pause it instead.`
2662
2956
  return failure(error instanceof Error ? error.message : String(error));
2663
2957
  }
2664
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
+ }
2665
2982
  async function unset(site, setting, extra = []) {
2666
2983
  if (extra.length > 0) {
2667
2984
  return failure(
@@ -2698,7 +3015,7 @@ async function applied(credentials, siteId, subdomain, change) {
2698
3015
  `Updated ${subdomain}.`,
2699
3016
  ` name ${settings.name ?? "(none)"}`,
2700
3017
  ` access ${accessOf(settings)}`,
2701
- ` mode ${servingModeOf$1(settings)}`,
3018
+ ` mode ${servingModeOf(settings)}`,
2702
3019
  ` forms ${settings.formsEnabled ? "on" : "off"}`,
2703
3020
  ` comments ${settings.comments ?? "unknown"}`,
2704
3021
  ` takedown ${takedown}`,
@@ -2711,7 +3028,7 @@ async function applied(credentials, siteId, subdomain, change) {
2711
3028
  ] : [],
2712
3029
  settings.live ? "Reaching every edge takes up to about a minute." : "Nothing is published yet, so this applies from the first publish."
2713
3030
  ].join("\n"),
2714
- json: { ...settings, mode: servingModeOf$1(settings) },
3031
+ json: { ...settings, mode: servingModeOf(settings) },
2715
3032
  code: 0
2716
3033
  };
2717
3034
  }
@@ -3058,7 +3375,7 @@ async function run(commandLine) {
3058
3375
  const positional = [...positionals(argv), ...literal];
3059
3376
  const [command, ...rest] = positional;
3060
3377
  if (argv.includes("--version")) {
3061
- const { version } = await import("./package-CGI7Yt81.js").then(
3378
+ const { version } = await import("./package-DgeW4aj9.js").then(
3062
3379
  (module) => module.default
3063
3380
  );
3064
3381
  return { text: version, json: { version }, code: 0 };