@nearform/fling 0.0.0-stage → 0.1.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/cli.js ADDED
@@ -0,0 +1,3000 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/auth.ts
7
+ import chalk4 from "chalk";
8
+
9
+ // src/auth.ts
10
+ import { randomBytes } from "node:crypto";
11
+ import { createServer } from "node:http";
12
+ import { hostname as osHostname } from "node:os";
13
+
14
+ // ../../packages/shared/src/api.ts
15
+ import { z as z2 } from "zod";
16
+
17
+ // ../../packages/shared/src/manifest.ts
18
+ import { z } from "zod";
19
+
20
+ // ../../packages/shared/src/limits.ts
21
+ var MAX_DEPLOY_BYTES = 100 * 1024 * 1024;
22
+ var MAX_FILE_BYTES = 25 * 1024 * 1024;
23
+ var MAX_FILES = 5e3;
24
+ var MAX_PATH_LENGTH = 1024;
25
+
26
+ // ../../packages/shared/src/manifest.ts
27
+ function isValidSitePath(path) {
28
+ if (!path || path.length > MAX_PATH_LENGTH) return false;
29
+ if (path.startsWith("/") || path.includes("\\") || path.includes("\0")) return false;
30
+ return path.split("/").every((seg) => seg !== "" && seg !== "." && seg !== "..");
31
+ }
32
+ var ManifestFileSchema = z.object({
33
+ path: z.string().refine(isValidSitePath, "invalid path"),
34
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
35
+ size: z.number().int().min(0).max(MAX_FILE_BYTES),
36
+ contentType: z.string().min(1).max(255)
37
+ });
38
+ var ManifestSchema = z.object({ files: z.array(ManifestFileSchema).min(1).max(MAX_FILES) }).refine((m) => new Set(m.files.map((f) => f.path)).size === m.files.length, "duplicate paths").refine((m) => m.files.reduce((n, f) => n + f.size, 0) <= MAX_DEPLOY_BYTES, "deploy too large");
39
+
40
+ // ../../packages/shared/src/api.ts
41
+ var VisibilitySchema = z2.enum(["private", "public"]);
42
+ var MeSchema = z2.object({
43
+ email: z2.string(),
44
+ name: z2.string(),
45
+ picture: z2.string().nullable(),
46
+ userSlug: z2.string()
47
+ });
48
+ var SiteSchema = z2.object({
49
+ name: z2.string(),
50
+ url: z2.string(),
51
+ visibility: VisibilitySchema,
52
+ spa: z2.boolean(),
53
+ archived: z2.boolean(),
54
+ currentVersionId: z2.string().nullable(),
55
+ shareUrl: z2.string().nullable(),
56
+ createdAt: z2.string(),
57
+ updatedAt: z2.string()
58
+ });
59
+ var VersionSchema = z2.object({
60
+ id: z2.string(),
61
+ fileCount: z2.number(),
62
+ totalBytes: z2.number(),
63
+ createdAt: z2.string()
64
+ });
65
+ var SiteDetailSchema = SiteSchema.extend({ versions: z2.array(VersionSchema) });
66
+ var SiteListSchema = z2.object({ sites: z2.array(SiteSchema) });
67
+ var SiteFileSchema = z2.object({
68
+ path: z2.string(),
69
+ size: z2.number(),
70
+ contentType: z2.string()
71
+ });
72
+ var VersionFilesSchema = z2.object({
73
+ versionId: z2.string(),
74
+ files: z2.array(SiteFileSchema),
75
+ totalBytes: z2.number(),
76
+ generatedIndex: z2.boolean()
77
+ });
78
+ var NameCheckSchema = z2.object({
79
+ name: z2.string(),
80
+ status: z2.enum(["available", "yours", "taken", "invalid"]),
81
+ suggestions: z2.array(z2.string())
82
+ });
83
+ var DeployRequestSchema = z2.object({
84
+ manifest: ManifestSchema,
85
+ spa: z2.boolean().optional(),
86
+ visibility: VisibilitySchema.optional()
87
+ });
88
+ var UploadSchema = z2.object({
89
+ path: z2.string(),
90
+ url: z2.string(),
91
+ /** headers the client must send with the PUT (they are part of the signature) */
92
+ headers: z2.record(z2.string(), z2.string())
93
+ });
94
+ var DeployResponseSchema = z2.discriminatedUnion("status", [
95
+ /** content identical to the current version — nothing happened */
96
+ z2.object({ status: z2.literal("unchanged"), versionId: z2.string(), site: SiteSchema }),
97
+ /** this content was deployed before — site now points at that version again */
98
+ z2.object({ status: z2.literal("repointed"), versionId: z2.string(), site: SiteSchema }),
99
+ /** new content — PUT every upload, then POST /api/sites/:name/deploys/:versionId/finalize */
100
+ z2.object({
101
+ status: z2.literal("upload"),
102
+ versionId: z2.string(),
103
+ uploads: z2.array(UploadSchema)
104
+ })
105
+ ]);
106
+ var FinalizeResponseSchema = z2.object({
107
+ status: z2.literal("live"),
108
+ versionId: z2.string(),
109
+ site: SiteSchema
110
+ });
111
+ var RollbackRequestSchema = z2.object({ versionId: z2.string() });
112
+ var UpdateSiteRequestSchema = z2.object({
113
+ visibility: VisibilitySchema.optional(),
114
+ spa: z2.boolean().optional()
115
+ });
116
+ var ApiKeySchema = z2.object({
117
+ id: z2.string(),
118
+ name: z2.string(),
119
+ createdAt: z2.string(),
120
+ lastUsedAt: z2.string().nullable()
121
+ });
122
+ var ApiKeyListSchema = z2.object({ keys: z2.array(ApiKeySchema) });
123
+ var ErrorEnvelopeSchema = z2.object({
124
+ error: z2.object({ code: z2.string(), message: z2.string() })
125
+ });
126
+ var ShareResponseSchema = z2.object({ shareUrl: z2.string() });
127
+ var ConnectionSchema = z2.object({
128
+ id: z2.string(),
129
+ clientName: z2.string(),
130
+ /** host the client identifies as (CIMD client_id host or redirect URI host) */
131
+ clientHost: z2.string().nullable(),
132
+ createdAt: z2.string(),
133
+ lastUsedAt: z2.string().nullable()
134
+ });
135
+ var ConnectionListSchema = z2.object({ connections: z2.array(ConnectionSchema) });
136
+ var OAuthRequestInfoSchema = z2.object({
137
+ clientName: z2.string(),
138
+ clientUri: z2.string().nullable(),
139
+ /** host of the CIMD client_id URL, when the client identified itself that way */
140
+ clientIdHost: z2.string().nullable(),
141
+ redirectHost: z2.string(),
142
+ /** true when the code goes to a loopback address (any local program could be asking) */
143
+ loopback: z2.boolean(),
144
+ scopes: z2.array(z2.string()),
145
+ email: z2.string()
146
+ });
147
+ var OAuthDecisionSchema = z2.object({ redirectUrl: z2.string() });
148
+
149
+ // ../../packages/shared/src/hosts.ts
150
+ var SITE_NAME_MAX = 50;
151
+ var SHARE_TOKEN_LENGTH = 20;
152
+ var RESERVED_SITE_NAMES = [
153
+ "admin",
154
+ "api",
155
+ "app",
156
+ "assets",
157
+ "auth",
158
+ "cdn",
159
+ "docs-fling",
160
+ "fling",
161
+ "help",
162
+ "login",
163
+ "mail",
164
+ "static",
165
+ "status",
166
+ "www"
167
+ ];
168
+ var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
169
+ var SHARE_TOKEN_RE = new RegExp(`^[a-z0-9]{${SHARE_TOKEN_LENGTH}}$`);
170
+ function slugify(input, max = SITE_NAME_MAX) {
171
+ return input.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/g, "");
172
+ }
173
+ function isValidSiteName(name) {
174
+ return name.length <= SITE_NAME_MAX && SLUG_RE.test(name) && !name.includes("--") && !RESERVED_SITE_NAMES.includes(name);
175
+ }
176
+ function siteUrl(label, sitesDomain, protocol = "https:") {
177
+ return `${protocol}//${label}.${sitesDomain}/`;
178
+ }
179
+
180
+ // ../../packages/shared/src/kvs.ts
181
+ import { z as z3 } from "zod";
182
+ var KvsSiteValueSchema = z3.object({
183
+ /** siteId — S3 prefix is `sites/<id>/<v>/` */
184
+ id: z3.string(),
185
+ /** current versionId */
186
+ v: z3.string(),
187
+ vis: z3.enum(["private", "public"]),
188
+ /** serve /index.html for unknown extensionless paths */
189
+ spa: z3.boolean()
190
+ });
191
+
192
+ // ../../packages/shared/src/listing.ts
193
+ var LISTING_GENERATOR_META = "fling-listing";
194
+ var escapeHtml = (s) => s.replace(
195
+ /[&<>"']/g,
196
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]
197
+ );
198
+ var hrefFor = (path) => path.split("/").map(encodeURIComponent).join("/");
199
+ function isHtmlPath(path) {
200
+ return /\.html?$/i.test(path);
201
+ }
202
+ function extractHtmlTitle(html) {
203
+ const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html.slice(0, 65536));
204
+ const title = match?.[1]?.replace(/<[^>]*>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/\s+/g, " ").trim();
205
+ return title ? title : null;
206
+ }
207
+ function renderHtmlListing(siteName2, entries) {
208
+ const pages = entries.filter((e) => isHtmlPath(e.path) && e.path.toLowerCase() !== "index.html").sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
209
+ const groups = /* @__PURE__ */ new Map();
210
+ for (const page of pages) {
211
+ const slash = page.path.lastIndexOf("/");
212
+ const folder = slash === -1 ? "" : page.path.slice(0, slash);
213
+ groups.set(folder, [...groups.get(folder) ?? [], page]);
214
+ }
215
+ const sections = [...groups.entries()].sort(([a], [b]) => a === "" ? -1 : b === "" ? 1 : a < b ? -1 : 1).map(([folder, items]) => {
216
+ const heading = folder ? `<h2>${escapeHtml(folder)}/</h2>` : "";
217
+ const rows = items.map((item) => {
218
+ const file = item.path.slice(item.path.lastIndexOf("/") + 1);
219
+ const label = item.title ? escapeHtml(item.title) : escapeHtml(file);
220
+ const sub = item.title ? `<span class="file">${escapeHtml(file)}</span>` : "";
221
+ return `<li><a href="${hrefFor(item.path)}">${label}</a>${sub}</li>`;
222
+ }).join("\n");
223
+ return `${heading}<ul>
224
+ ${rows}
225
+ </ul>`;
226
+ }).join("\n");
227
+ const body = pages.length ? sections : '<p class="empty">No HTML pages in this fling.</p>';
228
+ const count = `${pages.length} page${pages.length === 1 ? "" : "s"}`;
229
+ return `<!doctype html>
230
+ <html lang="en">
231
+ <head>
232
+ <meta charset="utf-8">
233
+ <meta name="viewport" content="width=device-width, initial-scale=1">
234
+ <meta name="generator" content="${LISTING_GENERATOR_META}">
235
+ <title>${escapeHtml(siteName2)}</title>
236
+ <style>
237
+ :root { color-scheme: light dark; --fg: #000e38; --muted: #5b6480; --bg: #f7f8fb; --card: #fff;
238
+ --line: #e3e6ef; --accent: #166bff; --green: #00e5a4; }
239
+ @media (prefers-color-scheme: dark) { :root { --fg: #eef1f8; --muted: #9aa3bd; --bg: #000e38;
240
+ --card: #0b1a4a; --line: #1d2c5e; --accent: #6ea2ff; } }
241
+ * { box-sizing: border-box; }
242
+ body { margin: 0; font: 16px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
243
+ color: var(--fg); background: var(--bg); }
244
+ main { max-width: 760px; margin: 0 auto; padding: 48px 20px 64px; }
245
+ header { border-left: 4px solid var(--green); padding-left: 14px; margin-bottom: 28px; }
246
+ h1 { margin: 0; font-size: 1.6rem; }
247
+ header p, .file, footer { color: var(--muted); font-size: .875rem; }
248
+ header p { margin: 4px 0 0; }
249
+ h2 { font-size: .95rem; margin: 28px 0 8px; color: var(--muted); font-weight: 600; }
250
+ ul { list-style: none; margin: 0; padding: 0; background: var(--card); border: 1px solid var(--line);
251
+ border-radius: 10px; overflow: hidden; }
252
+ li { padding: 12px 16px; border-top: 1px solid var(--line); display: flex; gap: 12px;
253
+ justify-content: space-between; align-items: baseline; flex-wrap: wrap; }
254
+ li:first-child { border-top: 0; }
255
+ a { color: var(--accent); text-decoration: none; font-weight: 500; }
256
+ a:hover { text-decoration: underline; }
257
+ .empty { color: var(--muted); }
258
+ footer { margin-top: 40px; }
259
+ </style>
260
+ </head>
261
+ <body>
262
+ <main>
263
+ <header><h1>${escapeHtml(siteName2)}</h1><p>${count}</p></header>
264
+ ${body}
265
+ <footer>Flung with Fling</footer>
266
+ </main>
267
+ </body>
268
+ </html>
269
+ `;
270
+ }
271
+
272
+ // ../../packages/shared/src/mcp-tools.ts
273
+ import { z as z4 } from "zod";
274
+ var MCP_PUBLISH_MAX_BYTES = 4 * 1024 * 1024;
275
+ var MCP_UPLOAD_CHUNK_MAX_CHARS = 4 * 1024 * 1024;
276
+ var MCP_UPLOAD_TTL_SECONDS = 60 * 60;
277
+ var tool = (def) => def;
278
+ var NAME_RULES = `Site names are 1-${SITE_NAME_MAX} chars of lowercase letters, digits and single hyphens, and are one global namespace (first come, first served): the site is served at https://<name>.fling.labs.nearform.com.`;
279
+ var NAME_REQUIRED = 'Publishing needs a name: if the user did not give one, propose a short descriptive one (e.g. "q3-report") and confirm it with them first (fling_check_name tells you whether it is free). If the name is taken by someone else the call fails with free suggestions \u2014 offer those to the user and retry with the one they pick.';
280
+ var VISIBILITY = "Sites are private by default (only signed-in Nearformers can open them); pass `public: true` only if the user asks for a public site. Share links (fling_share) are public to anyone who has them.";
281
+ var INLINE_ONLY = "If you created the content in this conversation, pass it directly \u2014 never pass file paths from your sandbox (this server cannot read them) and never try to curl/upload from your sandbox.";
282
+ var ENCODING = '`encoding` is "utf8" (default, for text: HTML, CSS, JS, SVG, JSON, Markdown\u2026) or "base64" (binary: images, fonts, PDFs).';
283
+ var LIST_HTML = "If the files have no root index.html but do include HTML pages, the call does not publish: it reports how many HTML pages there are and asks you to check with the user. Ask them whether to generate an index.html listing those pages (retry with `listHtml: true`) or to publish without one (`listHtml: false` \u2014 the site root will 404, the pages still work at their own paths), then retry.";
284
+ var listHtml = z4.boolean().optional().describe(
285
+ "Only matters when there is no root index.html: true \u2192 generate an index.html linking every HTML page; false \u2192 publish without one (root 404s). Omit until the user has decided"
286
+ );
287
+ var siteName = z4.string().min(1).max(200).describe('Site name, e.g. "q3-report"');
288
+ var sitePath = z4.string().min(1).max(1024).describe(
289
+ 'Site-relative path with forward slashes, e.g. "index.html", "css/app.css", "img/logo.png". The root page must be "index.html".'
290
+ );
291
+ var encoding = z4.enum(["utf8", "base64"]).optional().describe('How `content` is encoded: "utf8" (default) for text, "base64" for binary files');
292
+ var spa = z4.boolean().optional().describe(
293
+ "Single-page app: serve index.html for unknown paths (client-side routing). Omit to keep the current setting"
294
+ );
295
+ var isPublic = z4.boolean().optional().describe(
296
+ "true \u2192 anyone can view; false \u2192 Nearformers only. Omit to keep the current setting (new sites are private)"
297
+ );
298
+ var mcpTools = {
299
+ whoami: tool({
300
+ name: "fling_whoami",
301
+ title: "Who am I",
302
+ description: "Show the signed-in Nearform user that Fling acts as.",
303
+ inputSchema: {},
304
+ annotations: { readOnlyHint: true, openWorldHint: false }
305
+ }),
306
+ list: tool({
307
+ name: "fling_list",
308
+ title: "List sites",
309
+ description: "List the user's Fling sites with URL, visibility and share link.",
310
+ inputSchema: {
311
+ archived: z4.boolean().optional().describe("List archived sites instead of live ones")
312
+ },
313
+ annotations: { readOnlyHint: true, openWorldHint: false }
314
+ }),
315
+ info: tool({
316
+ name: "fling_info",
317
+ title: "Site details",
318
+ description: "Show one site: URL, visibility, share link, current version and version history (ids for fling_rollback).",
319
+ inputSchema: { name: siteName },
320
+ annotations: { readOnlyHint: true, openWorldHint: false }
321
+ }),
322
+ files: tool({
323
+ name: "fling_files",
324
+ title: "List site files",
325
+ description: "List the files in a site's current version (or in `versionId`, from fling_info): path, size and content type, sorted by path. Use it to check what was actually published \u2014 e.g. when the user sees a 404, confirm the page exists at that path and that there is a root index.html (without one the site root 404s).",
326
+ inputSchema: {
327
+ name: siteName,
328
+ versionId: z4.string().min(1).max(100).optional().describe("Version id (from fling_info); omit for the version being served")
329
+ },
330
+ annotations: { readOnlyHint: true, openWorldHint: false }
331
+ }),
332
+ checkName: tool({
333
+ name: "fling_check_name",
334
+ title: "Check a site name",
335
+ description: "Check whether a site name can be used before publishing: `available` (a new site), `yours` (publishing updates the user's existing site), `taken` (someone else's \u2014 free `suggestions` are returned) or `invalid`. " + NAME_RULES,
336
+ inputSchema: { name: z4.string().min(1).max(200).describe("Site name to check") },
337
+ annotations: { readOnlyHint: true, openWorldHint: false }
338
+ }),
339
+ publish: tool({
340
+ name: "fling_publish",
341
+ title: "Publish a site",
342
+ description: `Publish a static site to Fling in one call and get back its URL. Pass every file of the site inline in \`files\` (whole site, up to ${MCP_PUBLISH_MAX_BYTES / 1024 / 1024} MB decoded in total; bigger sites: fling_upload_begin / fling_upload_file / fling_upload_finish). ${INLINE_ONLY} ${ENCODING} Publishing replaces the whole site with exactly these files (it is not a patch: include unchanged files too); publishing identical content again is a no-op. ${NAME_REQUIRED} ${NAME_RULES} ${VISIBILITY} ${LIST_HTML} The result includes \`warnings\`. Share the returned URL with the user; fling_files shows exactly which files the site has.`,
343
+ inputSchema: {
344
+ name: siteName,
345
+ files: z4.array(
346
+ z4.object({
347
+ path: sitePath,
348
+ content: z4.string().describe("The file content, encoded as `encoding` says"),
349
+ encoding
350
+ })
351
+ ).min(1).max(MAX_FILES).describe("Every file of the site"),
352
+ spa,
353
+ public: isPublic,
354
+ listHtml
355
+ },
356
+ annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true }
357
+ }),
358
+ uploadBegin: tool({
359
+ name: "fling_upload_begin",
360
+ title: "Start a chunked upload",
361
+ description: `Start publishing a site too big for one fling_publish call (over ${MCP_PUBLISH_MAX_BYTES / 1024 / 1024} MB, or large binary files). Returns an \`uploadId\`; then send each file with fling_upload_file and finish with fling_upload_finish within ${MCP_UPLOAD_TTL_SECONDS / 60} minutes. ${INLINE_ONLY} ${NAME_REQUIRED} ${VISIBILITY}`,
362
+ inputSchema: { name: siteName, spa, public: isPublic },
363
+ annotations: { destructiveHint: false, openWorldHint: false }
364
+ }),
365
+ uploadFile: tool({
366
+ name: "fling_upload_file",
367
+ title: "Upload a file (chunked)",
368
+ description: `Add one file to a chunked upload started with fling_upload_begin. Sending the same path again replaces it. A file larger than ${MCP_UPLOAD_CHUNK_MAX_CHARS / 1024 / 1024} MB of content can span calls: send the first part normally, then the following parts in order with \`append: true\` (same path and encoding; send appends one at a time, not in parallel). ${ENCODING}`,
369
+ inputSchema: {
370
+ uploadId: z4.string().min(1).max(100).describe("From fling_upload_begin"),
371
+ path: sitePath,
372
+ content: z4.string().max(MCP_UPLOAD_CHUNK_MAX_CHARS).describe("The file content (or the next part of it, with `append`)"),
373
+ encoding,
374
+ append: z4.boolean().optional().describe("Append this content to the file sent earlier with the same path")
375
+ },
376
+ annotations: { destructiveHint: false, openWorldHint: false }
377
+ }),
378
+ uploadFinish: tool({
379
+ name: "fling_upload_finish",
380
+ title: "Finish a chunked upload",
381
+ description: `Publish everything sent with fling_upload_file as the new version of the site and return its URL. The site becomes exactly the uploaded files. ${LIST_HTML} The upload stays open, so just call fling_upload_finish again with \`listHtml\`. The result includes \`warnings\`.`,
382
+ inputSchema: {
383
+ uploadId: z4.string().min(1).max(100).describe("From fling_upload_begin"),
384
+ listHtml
385
+ },
386
+ annotations: { destructiveHint: false, openWorldHint: true }
387
+ }),
388
+ share: tool({
389
+ name: "fling_share",
390
+ title: "Share link",
391
+ description: "Get or create a public share link for a site: anyone with the link can view it without signing in (use it to show a private site to someone outside Nearform). `refresh` replaces the link (the old one stops working); `revoke` removes it.",
392
+ inputSchema: {
393
+ name: siteName,
394
+ refresh: z4.boolean().optional().describe("Rotate the link"),
395
+ revoke: z4.boolean().optional().describe("Remove the link")
396
+ },
397
+ annotations: { destructiveHint: false, openWorldHint: true }
398
+ }),
399
+ setVisibility: tool({
400
+ name: "fling_set_visibility",
401
+ title: "Set visibility",
402
+ description: "Make a site private (signed-in Nearformers only) or public (anyone).",
403
+ inputSchema: { name: siteName, visibility: z4.enum(["private", "public"]) },
404
+ annotations: { idempotentHint: true, openWorldHint: true }
405
+ }),
406
+ archive: tool({
407
+ name: "fling_archive",
408
+ title: "Archive or restore a site",
409
+ description: "Archive a site (it stops being served and is hidden from lists, but its name, files and versions are kept) or, with `unarchive`, bring it back. Prefer this over deleting.",
410
+ inputSchema: {
411
+ name: siteName,
412
+ unarchive: z4.boolean().optional().describe("Restore an archived site")
413
+ },
414
+ annotations: { idempotentHint: true, openWorldHint: false }
415
+ }),
416
+ delete: tool({
417
+ name: "fling_delete",
418
+ title: "Delete a site",
419
+ description: "Permanently delete a site and every version of it, freeing its name. Cannot be undone \u2014 confirm with the user first and pass `confirm: true`. Consider fling_archive instead.",
420
+ inputSchema: {
421
+ name: siteName,
422
+ confirm: z4.literal(true).describe("Must be true: the user has confirmed the deletion")
423
+ },
424
+ annotations: { destructiveHint: true, openWorldHint: false }
425
+ }),
426
+ rollback: tool({
427
+ name: "fling_rollback",
428
+ title: "Roll back",
429
+ description: "Point a site at an earlier version (ids from fling_info). Instant; nothing is uploaded.",
430
+ inputSchema: {
431
+ name: siteName,
432
+ versionId: z4.string().min(1).max(100).describe("Version id to serve")
433
+ },
434
+ annotations: { idempotentHint: true, openWorldHint: false }
435
+ })
436
+ };
437
+ var MCP_REMOTE_INSTRUCTIONS = [
438
+ "Fling publishes static sites (HTML/CSS/JS, images, documents) for Nearform staff at https://<name>.fling.labs.nearform.com.",
439
+ "To publish content you created in this conversation (an HTML page, a report, a small app): call fling_publish with every file inline (text as utf8, binary as base64). This server cannot read files in your sandbox and your sandbox cannot reach Fling, so never pass sandbox file paths and never curl or upload from the sandbox. Make the site self-contained: an index.html at the root, relative links between files.",
440
+ "Every site needs a name. If the user did not give one, propose a short descriptive name and confirm it before publishing (fling_check_name shows whether it is free). Names are global and first come, first served; when a name is taken, offer the returned suggestions.",
441
+ "Sites are private (Nearformers only) by default. Only make one public if asked; to show a private site to someone outside Nearform, create a share link with fling_share.",
442
+ "If a site has HTML pages but no root index.html, fling_publish / fling_upload_finish stop and ask: check with the user whether to generate an index.html listing the pages (`listHtml: true`) or publish without one (`listHtml: false`, the root URL will 404), then retry.",
443
+ `For sites over ${MCP_PUBLISH_MAX_BYTES / 1024 / 1024} MB use fling_upload_begin, fling_upload_file (repeat, \`append\` for big files) and fling_upload_finish.`,
444
+ "Always give the user the URL that comes back. Use fling_list / fling_info to find existing sites, fling_rollback to restore a version, fling_archive rather than fling_delete unless the user insists. Use fling_files to see exactly which files a site has (e.g. when the user reports a 404)."
445
+ ].join("\n\n");
446
+ var MCP_FILES_LISTED = 200;
447
+ function describeVersionFiles(name, files, opts) {
448
+ const kb = (n2) => n2 < 1024 ? `${n2} B` : n2 < 1024 * 1024 ? `${(n2 / 1024).toFixed(1)} KB` : `${(n2 / 1024 / 1024).toFixed(1)} MB`;
449
+ const n = files.files.length;
450
+ const which = opts.current ? "current version" : "version";
451
+ const lines = [
452
+ `${name} ${which} ${files.versionId}: ${n} file${n === 1 ? "" : "s"}, ${kb(files.totalBytes)}` + (opts.current && opts.url ? ` \u2014 served at ${opts.url}` : "") + (opts.current ? "" : " (not the version being served)")
453
+ ];
454
+ if (!files.files.some((f) => f.path === "index.html")) {
455
+ lines.push(
456
+ "Warning: there is no index.html at the root, so the site root URL returns 404 (each file still works at its own path)."
457
+ );
458
+ } else if (files.generatedIndex) {
459
+ lines.push("index.html is a generated page listing the HTML pages.");
460
+ }
461
+ for (const f of files.files.slice(0, MCP_FILES_LISTED)) {
462
+ lines.push(`- ${f.path} (${kb(f.size)}, ${f.contentType})`);
463
+ }
464
+ if (n > MCP_FILES_LISTED) lines.push(`\u2026 and ${n - MCP_FILES_LISTED} more`);
465
+ return lines.join("\n");
466
+ }
467
+
468
+ // ../../packages/shared/src/ready.ts
469
+ var READY_TIMEOUT_MS = 15e3;
470
+ var NOT_READY_NOTE = "it can take a few more seconds to appear everywhere; reload if you see a 404";
471
+ var isReachableStatus = (status) => status >= 200 && status < 400 || status === 401 || status === 403;
472
+ var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
473
+ async function waitUntilReachable(url, opts = {}) {
474
+ const timeoutMs = opts.timeoutMs ?? READY_TIMEOUT_MS;
475
+ const doFetch = opts.fetch ?? ((u, init) => fetch(u, init));
476
+ const sleep = opts.sleep ?? defaultSleep;
477
+ const now = opts.now ?? (() => Date.now());
478
+ const start = now();
479
+ let interval = opts.intervalMs ?? 750;
480
+ let announced = false;
481
+ for (; ; ) {
482
+ const remaining = timeoutMs - (now() - start);
483
+ try {
484
+ const res = await doFetch(url, {
485
+ method: "GET",
486
+ redirect: "manual",
487
+ cache: "no-store",
488
+ signal: AbortSignal.timeout(Math.max(250, Math.min(remaining, 5e3)))
489
+ });
490
+ await res.body?.cancel().catch(() => {
491
+ });
492
+ if (isReachableStatus(res.status)) return { ready: true, waitedMs: now() - start };
493
+ } catch {
494
+ }
495
+ if (!announced) {
496
+ announced = true;
497
+ opts.onWaiting?.();
498
+ }
499
+ const left = timeoutMs - (now() - start);
500
+ if (left <= 0) return { ready: false, waitedMs: now() - start };
501
+ await sleep(Math.min(interval, left));
502
+ interval = Math.min(interval * 1.25, 2e3);
503
+ }
504
+ }
505
+ function probeUrl(siteUrl2, paths) {
506
+ const base = siteUrl2.endsWith("/") ? siteUrl2 : `${siteUrl2}/`;
507
+ if (paths.length === 0 || paths.includes("index.html")) return base;
508
+ const pick = paths.find((p) => /\.html?$/i.test(p)) ?? paths[0];
509
+ return base + pick.split("/").map(encodeURIComponent).join("/");
510
+ }
511
+ function readinessWaitDisabled(env) {
512
+ return env.FLING_NO_WAIT === "1" || env.VITEST === "true" || env.NODE_ENV === "test";
513
+ }
514
+
515
+ // src/errors.ts
516
+ var EXIT_ERROR = 1;
517
+ var EXIT_NOT_LOGGED_IN = 2;
518
+ var EXIT_FORBIDDEN = 3;
519
+ var CliError = class extends Error {
520
+ exitCode;
521
+ /** Extra dimmed line printed under the error (e.g. "run `fling login`"). */
522
+ hint;
523
+ constructor(message, options = {}) {
524
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
525
+ this.name = "CliError";
526
+ this.exitCode = options.exitCode ?? EXIT_ERROR;
527
+ this.hint = options.hint;
528
+ }
529
+ };
530
+ var NotLoggedInError = class extends CliError {
531
+ constructor(binName) {
532
+ super("Not logged in", {
533
+ exitCode: EXIT_NOT_LOGGED_IN,
534
+ hint: `Run \`${binName} login\` first.`
535
+ });
536
+ this.name = "NotLoggedInError";
537
+ }
538
+ };
539
+ function exitCodeFor(error) {
540
+ return error instanceof CliError ? error.exitCode : EXIT_ERROR;
541
+ }
542
+
543
+ // package.json
544
+ var package_default = {
545
+ name: "@nearform/fling",
546
+ version: "0.1.0",
547
+ license: "MIT",
548
+ description: "Fling a folder of static files at fling.labs.nearform.com and get a link back. CLI + MCP server.",
549
+ type: "module",
550
+ bin: {
551
+ fling: "./dist/cli.js"
552
+ },
553
+ files: [
554
+ "dist/cli.js",
555
+ "dist/cli.js.map",
556
+ "README.md"
557
+ ],
558
+ engines: {
559
+ node: ">=22"
560
+ },
561
+ repository: {
562
+ type: "git",
563
+ url: "git+https://github.com/nearform/fling.git",
564
+ directory: "apps/cli"
565
+ },
566
+ publishConfig: {
567
+ access: "public"
568
+ },
569
+ scripts: {
570
+ dev: "tsx src/cli.ts",
571
+ build: "node scripts/build.mjs",
572
+ "build:mcpb": "node scripts/build.mjs && node scripts/build-mcpb.mjs",
573
+ typecheck: "tsc --noEmit",
574
+ test: "vitest run"
575
+ },
576
+ dependencies: {
577
+ "@modelcontextprotocol/sdk": "^1.20.0",
578
+ chalk: "^5.6.2",
579
+ commander: "^15.0.0",
580
+ fflate: "^0.8.3",
581
+ ignore: "^7.0.5",
582
+ mime: "^4.1.0",
583
+ open: "^11.0.0",
584
+ zod: "^4.6.7"
585
+ },
586
+ devDependencies: {
587
+ "@fling/shared": "workspace:*",
588
+ "@types/node": "^24.0.0",
589
+ esbuild: "^0.25.12",
590
+ tsx: "^4.23.13",
591
+ typescript: "^5.9.3",
592
+ vitest: "^3.2.7"
593
+ }
594
+ };
595
+
596
+ // src/package-info.ts
597
+ var PACKAGE_NAME = package_default.name;
598
+ var VERSION = package_default.version;
599
+ var BIN_NAME = Object.keys(package_default.bin)[0] ?? "fling";
600
+ var NPX_COMMAND = `npx ${package_default.name}`;
601
+
602
+ // src/api.ts
603
+ var CliApiError = class extends CliError {
604
+ status;
605
+ code;
606
+ body;
607
+ constructor(options) {
608
+ super(options.message, {
609
+ exitCode: exitCodeForStatus(options.status),
610
+ hint: options.hint ?? defaultHint(options.status),
611
+ cause: options.cause
612
+ });
613
+ this.name = "CliApiError";
614
+ this.status = options.status;
615
+ this.code = options.code;
616
+ this.body = options.body;
617
+ }
618
+ };
619
+ function exitCodeForStatus(status) {
620
+ if (status === 401) return EXIT_NOT_LOGGED_IN;
621
+ if (status === 403) return EXIT_FORBIDDEN;
622
+ return EXIT_ERROR;
623
+ }
624
+ function defaultHint(status) {
625
+ if (status === 401) return `Run \`${BIN_NAME} login\` to authenticate.`;
626
+ if (status === 403) return "You are not allowed to do that.";
627
+ return void 0;
628
+ }
629
+ function buildUrl(serverUrl, path, query) {
630
+ const url = new URL(`${serverUrl.replace(/\/+$/, "")}${path.startsWith("/") ? path : `/${path}`}`);
631
+ for (const [key, value] of Object.entries(query ?? {})) {
632
+ if (value !== void 0 && value !== null) url.searchParams.set(key, String(value));
633
+ }
634
+ return url.toString();
635
+ }
636
+ function createApiClient(options) {
637
+ const serverUrl = options.serverUrl.replace(/\/+$/, "");
638
+ const fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
639
+ const timeoutMs = options.timeoutMs ?? 6e4;
640
+ async function request(method, path, reqOptions = {}) {
641
+ const url = buildUrl(serverUrl, path, reqOptions.query);
642
+ const headers = {
643
+ Accept: "application/json",
644
+ "User-Agent": `${BIN_NAME}-cli/${VERSION}`
645
+ };
646
+ if (options.apiKey) headers.Authorization = `Bearer ${options.apiKey}`;
647
+ if (reqOptions.body !== void 0) headers["Content-Type"] = "application/json";
648
+ let response;
649
+ try {
650
+ response = await fetchImpl(url, {
651
+ method,
652
+ headers,
653
+ body: reqOptions.body === void 0 ? void 0 : JSON.stringify(reqOptions.body),
654
+ signal: AbortSignal.timeout(timeoutMs)
655
+ });
656
+ } catch (cause) {
657
+ throw new CliApiError({
658
+ status: 0,
659
+ code: "network_error",
660
+ message: `Could not reach ${serverUrl} (${describeCause(cause)})`,
661
+ hint: "Check your connection, or the URL given by `--server` / `FLING_URL`.",
662
+ cause
663
+ });
664
+ }
665
+ const raw = await readBody(response);
666
+ if (!response.ok) throw errorFromResponse(response.status, raw, method, path);
667
+ if (!reqOptions.schema) return raw;
668
+ const parsed = reqOptions.schema.safeParse(raw);
669
+ if (!parsed.success) {
670
+ const issue = parsed.error.issues[0];
671
+ const where = issue?.path.length ? ` at ${issue.path.join(".")}` : "";
672
+ throw new CliApiError({
673
+ status: response.status,
674
+ code: "invalid_response",
675
+ body: raw,
676
+ message: `Unexpected response from ${method} ${path}${where}: ${issue?.message ?? "invalid"}`,
677
+ hint: `Your ${BIN_NAME} CLI may be out of date \u2014 try \`npm i -g @nearform/fling@latest\`.`
678
+ });
679
+ }
680
+ return parsed.data;
681
+ }
682
+ return { serverUrl, request };
683
+ }
684
+ var UploadError = class extends Error {
685
+ constructor(message, retryable) {
686
+ super(message);
687
+ this.retryable = retryable;
688
+ this.name = "UploadError";
689
+ }
690
+ };
691
+ async function putUpload(fetchImpl, url, headers, body, timeoutMs = 5 * 6e4) {
692
+ let response;
693
+ try {
694
+ response = await fetchImpl(url, {
695
+ method: "PUT",
696
+ headers,
697
+ body,
698
+ signal: AbortSignal.timeout(timeoutMs)
699
+ });
700
+ } catch (cause) {
701
+ throw new UploadError(`network error (${describeCause(cause)})`, true);
702
+ }
703
+ await response.text().catch(() => "");
704
+ if (response.ok) return;
705
+ const retryable = response.status >= 500 || response.status === 408 || response.status === 429;
706
+ throw new UploadError(`HTTP ${response.status}`, retryable);
707
+ }
708
+ async function readBody(response) {
709
+ if (response.status === 204) return void 0;
710
+ const text = await response.text();
711
+ if (text === "") return void 0;
712
+ try {
713
+ return JSON.parse(text);
714
+ } catch {
715
+ return text;
716
+ }
717
+ }
718
+ function errorFromResponse(status, body, method, path) {
719
+ const envelope = ErrorEnvelopeSchema.safeParse(body);
720
+ if (envelope.success) {
721
+ return new CliApiError({
722
+ status,
723
+ code: envelope.data.error.code,
724
+ body,
725
+ message: envelope.data.error.message
726
+ });
727
+ }
728
+ return new CliApiError({ status, body, message: `${method} ${path} failed with HTTP ${status}` });
729
+ }
730
+ function describeCause(cause) {
731
+ if (cause instanceof Error) {
732
+ const inner = cause.cause;
733
+ if (inner instanceof Error && "code" in inner) return String(inner.code);
734
+ if (cause.name === "TimeoutError") return "timed out";
735
+ return cause.message;
736
+ }
737
+ return String(cause);
738
+ }
739
+
740
+ // src/config.ts
741
+ import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
742
+ import { homedir } from "node:os";
743
+ import { join } from "node:path";
744
+ import { z as z5 } from "zod";
745
+ var CONFIG_DIR_NAME = ".fling";
746
+ var CONFIG_FILE_NAME = "config.json";
747
+ var DEFAULT_SERVER_URL = "https://fling.labs.nearform.com";
748
+ var ENV = {
749
+ apiKey: "FLING_API_KEY",
750
+ url: "FLING_URL",
751
+ configDir: "FLING_CONFIG_DIR",
752
+ debug: "FLING_DEBUG",
753
+ /** Where to look for files given by name (the .mcpb extension sets it from its settings). */
754
+ downloadsDir: "FLING_DOWNLOADS_DIR"
755
+ };
756
+ var cliConfigSchema = z5.object({
757
+ serverUrl: z5.string().url().optional(),
758
+ apiKey: z5.string().min(1).optional(),
759
+ user: z5.object({
760
+ email: z5.string().optional(),
761
+ name: z5.string().optional(),
762
+ userSlug: z5.string().optional()
763
+ }).optional()
764
+ });
765
+ function systemLocateOptions(env = process.env) {
766
+ return { cwd: process.cwd(), home: homedir(), platform: process.platform, env };
767
+ }
768
+ function fromEnv(value) {
769
+ const v = value?.trim();
770
+ return v && !v.startsWith("${") ? v : void 0;
771
+ }
772
+ function resolveConfigDir(env = process.env) {
773
+ return env[ENV.configDir] ?? join(env.HOME ?? homedir(), CONFIG_DIR_NAME);
774
+ }
775
+ function createConfigStore(options = {}) {
776
+ const env = options.env ?? process.env;
777
+ const dir = options.dir ?? resolveConfigDir(env);
778
+ const file = join(dir, CONFIG_FILE_NAME);
779
+ async function load() {
780
+ let raw;
781
+ try {
782
+ raw = await readFile(file, "utf8");
783
+ } catch (error) {
784
+ if (error.code === "ENOENT") return {};
785
+ throw error;
786
+ }
787
+ let json;
788
+ try {
789
+ json = JSON.parse(raw);
790
+ } catch {
791
+ throw new Error(`Config file ${file} is not valid JSON`);
792
+ }
793
+ const parsed = cliConfigSchema.safeParse(json);
794
+ if (!parsed.success) {
795
+ throw new Error(`Config file ${file} is invalid: ${parsed.error.issues[0]?.message}`);
796
+ }
797
+ return parsed.data;
798
+ }
799
+ async function save(config) {
800
+ await mkdir(dir, { recursive: true, mode: 448 });
801
+ await chmod(dir, 448).catch(() => {
802
+ });
803
+ await writeFile(file, `${JSON.stringify(config, null, 2)}
804
+ `, { mode: 384 });
805
+ await chmod(file, 384);
806
+ }
807
+ return {
808
+ dir,
809
+ file,
810
+ load,
811
+ save,
812
+ async update(patch) {
813
+ const next = { ...await load(), ...patch };
814
+ await save(next);
815
+ return next;
816
+ },
817
+ async clearCredentials() {
818
+ const { serverUrl } = await load();
819
+ await save(serverUrl ? { serverUrl } : {});
820
+ },
821
+ async resolve(overrides = {}) {
822
+ const config = await load();
823
+ const envUrl = fromEnv(env[ENV.url]);
824
+ const envKey = fromEnv(env[ENV.apiKey]);
825
+ const [serverUrl, serverUrlSource] = overrides.serverUrl ? [overrides.serverUrl, "flag"] : envUrl ? [envUrl, "env"] : config.serverUrl ? [config.serverUrl, "config"] : [DEFAULT_SERVER_URL, "default"];
826
+ const [apiKey, apiKeySource] = envKey ? [envKey, "env"] : config.apiKey ? [config.apiKey, "config"] : [void 0, "none"];
827
+ return {
828
+ serverUrl: serverUrl.replace(/\/+$/, ""),
829
+ serverUrlSource,
830
+ apiKey,
831
+ apiKeySource,
832
+ user: config.user,
833
+ debug: Boolean(env[ENV.debug])
834
+ };
835
+ }
836
+ };
837
+ }
838
+ var REDACTED_KEY_CHARS = 10;
839
+ function redactKey(key) {
840
+ if (!key) return "-";
841
+ return key.length <= REDACTED_KEY_CHARS + 8 ? "****" : `${key.slice(0, REDACTED_KEY_CHARS)}\u2026`;
842
+ }
843
+
844
+ // src/auth.ts
845
+ var CALLBACK_PORT_START = 8765;
846
+ var CALLBACK_PORT_END = 8770;
847
+ var LOGIN_TIMEOUT_MS = 5 * 60 * 1e3;
848
+ var CALLBACK_PATH = "/callback";
849
+ var AUTH_CLI_PATH = "/auth/cli";
850
+ async function loginFlow(options) {
851
+ const { serverUrl, store, log } = options;
852
+ const state = randomBytes(16).toString("hex");
853
+ const callback = await startCallbackServer({ state, timeoutMs: options.timeoutMs });
854
+ try {
855
+ const authUrl = buildAuthUrl(serverUrl, callback.url, options.hostname ?? osHostname(), state);
856
+ options.onAuthUrl?.(authUrl);
857
+ log.info(`Opening your browser to sign in at ${serverUrl}`);
858
+ log.hint(`If it does not open, visit: ${authUrl}`);
859
+ try {
860
+ await options.open(authUrl);
861
+ } catch (error) {
862
+ log.warn(`Could not open a browser (${error.message}); open the URL above.`);
863
+ }
864
+ log.info("Waiting for the browser to complete sign-in (5-minute timeout)\u2026");
865
+ const { key } = await callback.result;
866
+ const client = createApiClient({ serverUrl, apiKey: key, fetch: options.fetch });
867
+ const user = await client.request("GET", "/api/me", { schema: MeSchema });
868
+ await store.update({
869
+ serverUrl,
870
+ apiKey: key,
871
+ user: { email: user.email, name: user.name, userSlug: user.userSlug }
872
+ });
873
+ const keyPrefix = redactKey(key);
874
+ log.success(`Signed in as ${user.email}`);
875
+ log.hint(`Server: ${serverUrl}`);
876
+ log.hint(`API key: ${keyPrefix} (stored in ${store.file})`);
877
+ return { serverUrl, keyPrefix, user };
878
+ } finally {
879
+ callback.close();
880
+ }
881
+ }
882
+ function buildAuthUrl(serverUrl, callbackUrl, hostname, state) {
883
+ const url = new URL(AUTH_CLI_PATH, `${serverUrl.replace(/\/+$/, "")}/`);
884
+ url.searchParams.set("redirect_uri", callbackUrl);
885
+ url.searchParams.set("hostname", hostname);
886
+ url.searchParams.set("state", state);
887
+ return url.toString();
888
+ }
889
+ function parseCallback(url, expectedState) {
890
+ const error = url.searchParams.get("error");
891
+ if (error) return { ok: false, message: `Sign-in failed: ${error}` };
892
+ const state = url.searchParams.get("state");
893
+ if (state !== null && expectedState !== void 0 && state !== expectedState) {
894
+ return {
895
+ ok: false,
896
+ message: "Sign-in failed: state mismatch (the callback was not for this login)"
897
+ };
898
+ }
899
+ const key = url.searchParams.get("key");
900
+ if (!key) return { ok: false, message: "Callback missing required parameter: key" };
901
+ return { ok: true, key };
902
+ }
903
+ async function startCallbackServer(options = {}) {
904
+ const timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS;
905
+ let settle;
906
+ let timer;
907
+ const result = new Promise((resolve3, reject) => {
908
+ settle = { resolve: resolve3, reject };
909
+ }).finally(() => clearTimeout(timer));
910
+ result.catch(() => {
911
+ });
912
+ const server = createServer((req, res) => handleCallback(req, res, port, options.state, settle));
913
+ const port = await listenOnFreePort(server);
914
+ timer = setTimeout(() => {
915
+ settle?.reject(
916
+ new CliError(
917
+ `Timed out after ${Math.round(timeoutMs / 6e4) || 1} minute(s) waiting for the browser`,
918
+ { hint: `Run \`${BIN_NAME} login\` again and finish signing in within 5 minutes.` }
919
+ )
920
+ );
921
+ }, timeoutMs);
922
+ return {
923
+ port,
924
+ url: `http://127.0.0.1:${port}${CALLBACK_PATH}`,
925
+ result,
926
+ close: () => {
927
+ clearTimeout(timer);
928
+ server.close();
929
+ server.closeAllConnections?.();
930
+ }
931
+ };
932
+ }
933
+ function handleCallback(req, res, port, expectedState, settle) {
934
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
935
+ if (url.pathname !== CALLBACK_PATH) {
936
+ res.writeHead(404, { "Content-Type": "text/plain" }).end("Not found");
937
+ return;
938
+ }
939
+ const parsed = parseCallback(url, expectedState);
940
+ if (!parsed.ok) {
941
+ respondHtml(
942
+ res,
943
+ 400,
944
+ "Sign-in failed",
945
+ `${escapeHtml2(parsed.message)}. Return to the terminal and try again.`
946
+ );
947
+ settle?.reject(
948
+ new CliError(parsed.message, { hint: `Run \`${BIN_NAME} login\` to try again.` })
949
+ );
950
+ return;
951
+ }
952
+ respondHtml(res, 200, "Signed in", "You can close this tab and return to the terminal.", true);
953
+ settle?.resolve({ key: parsed.key });
954
+ }
955
+ function respondHtml(res, status, title, body, close = false) {
956
+ const html = `<!doctype html><html><head><meta charset="utf-8"><title>Fling \u2014 ${title}</title></head>
957
+ <body style="font-family:system-ui,sans-serif;padding:48px;text-align:center;color:#111">
958
+ <h1 style="font-size:1.5rem">${title}</h1><p>${body}</p>
959
+ ${close ? "<script>setTimeout(function(){window.close()},800)</script>" : ""}
960
+ </body></html>`;
961
+ res.writeHead(status, {
962
+ "Content-Type": "text/html; charset=utf-8",
963
+ "Cache-Control": "no-store",
964
+ Connection: "close"
965
+ });
966
+ res.end(html);
967
+ }
968
+ function listenOnFreePort(server) {
969
+ return new Promise((resolve3, reject) => {
970
+ let port = CALLBACK_PORT_START;
971
+ const tryNext = () => {
972
+ if (port > CALLBACK_PORT_END) {
973
+ reject(
974
+ new CliError(
975
+ `No free port in ${CALLBACK_PORT_START}\u2013${CALLBACK_PORT_END} for the login callback`,
976
+ {
977
+ hint: "Another login may be in progress; close it or free one of those ports."
978
+ }
979
+ )
980
+ );
981
+ return;
982
+ }
983
+ server.once("error", (error) => {
984
+ if (error.code === "EADDRINUSE" || error.code === "EACCES") {
985
+ port += 1;
986
+ tryNext();
987
+ } else reject(error);
988
+ });
989
+ server.listen(port, "127.0.0.1", () => {
990
+ server.removeAllListeners("error");
991
+ resolve3(port);
992
+ });
993
+ };
994
+ tryNext();
995
+ });
996
+ }
997
+ function escapeHtml2(text) {
998
+ return text.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
999
+ }
1000
+
1001
+ // src/context.ts
1002
+ import { createInterface } from "node:readline/promises";
1003
+
1004
+ // src/ready.ts
1005
+ import chalk from "chalk";
1006
+ var skipWait = async () => ({ ready: true, waitedMs: 0, skipped: true });
1007
+ function createReadyWaiter(options = {}) {
1008
+ if (readinessWaitDisabled(options.env ?? process.env)) return skipWait;
1009
+ return (url, paths, opts) => waitUntilReachable(probeUrl(url, paths), {
1010
+ timeoutMs: options.timeoutMs,
1011
+ fetch: options.fetch,
1012
+ onWaiting: opts?.onWaiting
1013
+ });
1014
+ }
1015
+ var SPINNER = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1016
+ async function waitWithSpinner(waiter, url, paths, io) {
1017
+ if (!waiter) return { ready: true, waitedMs: 0, skipped: true };
1018
+ let frame = 0;
1019
+ let timer;
1020
+ const draw = () => io.progress(`${chalk.cyan(SPINNER[frame++ % SPINNER.length])} Waiting for the edge\u2026`);
1021
+ try {
1022
+ const result = await waiter(url, paths, {
1023
+ onWaiting: () => {
1024
+ draw();
1025
+ timer = setInterval(draw, 100);
1026
+ timer.unref?.();
1027
+ }
1028
+ }).catch(() => ({ ready: false, waitedMs: 0 }));
1029
+ if (timer) io.progress(null);
1030
+ if (!result.ready) io.warn(`${url} \u2014 ${NOT_READY_NOTE}`);
1031
+ return result;
1032
+ } finally {
1033
+ if (timer) clearInterval(timer);
1034
+ }
1035
+ }
1036
+
1037
+ // src/utils/logger.ts
1038
+ import chalk2 from "chalk";
1039
+ var PREFIX = chalk2.cyan("fling");
1040
+ function createLogger(options = {}) {
1041
+ const write = options.write ?? ((line) => process.stderr.write(`${line}
1042
+ `));
1043
+ const debugEnabled = options.debug ?? false;
1044
+ return {
1045
+ info: (message) => write(`${PREFIX} ${message}`),
1046
+ success: (message) => write(`${PREFIX} ${chalk2.green("\u2713")} ${message}`),
1047
+ warn: (message) => write(`${PREFIX} ${chalk2.yellow("!")} ${message}`),
1048
+ error: (message) => write(`${PREFIX} ${chalk2.red("\u2717")} ${message}`),
1049
+ hint: (message) => write(chalk2.dim(` ${message}`)),
1050
+ debug: (message) => {
1051
+ if (debugEnabled) write(chalk2.dim(`[debug] ${message}`));
1052
+ }
1053
+ };
1054
+ }
1055
+ var silentLogger = {
1056
+ info: () => {
1057
+ },
1058
+ success: () => {
1059
+ },
1060
+ warn: () => {
1061
+ },
1062
+ error: () => {
1063
+ },
1064
+ hint: () => {
1065
+ },
1066
+ debug: () => {
1067
+ }
1068
+ };
1069
+
1070
+ // src/utils/output.ts
1071
+ import chalk3 from "chalk";
1072
+ function createOutput(options = {}) {
1073
+ const json = options.json ?? false;
1074
+ const write = options.write ?? ((chunk) => process.stdout.write(chunk));
1075
+ return {
1076
+ json,
1077
+ data: (raw, human) => write(`${json ? formatJson(raw) : human()}
1078
+ `),
1079
+ text: (line) => {
1080
+ if (!json) write(`${line}
1081
+ `);
1082
+ }
1083
+ };
1084
+ }
1085
+ function formatJson(value) {
1086
+ return JSON.stringify(value, null, 2);
1087
+ }
1088
+ function renderTable(rows, columns) {
1089
+ if (rows.length === 0) return chalk3.dim("(no results)");
1090
+ const cells = rows.map((row) => columns.map((column) => formatCell(column.value(row))));
1091
+ const widths = columns.map(
1092
+ (column, i) => Math.max(column.header.length, ...cells.map((line) => line[i]?.length ?? 0))
1093
+ );
1094
+ const pad = (text, i) => i === columns.length - 1 ? text : text.padEnd(widths[i] ?? 0);
1095
+ const header = columns.map((column, i) => pad(column.header, i)).join(" ");
1096
+ const body = cells.map((line) => line.map(pad).join(" "));
1097
+ return [chalk3.bold(header), ...body].join("\n");
1098
+ }
1099
+ function formatCell(value) {
1100
+ if (value === null || value === void 0 || value === "") return "-";
1101
+ if (typeof value === "object") return JSON.stringify(value);
1102
+ return String(value);
1103
+ }
1104
+ function formatDate(value) {
1105
+ if (!value) return "-";
1106
+ const date = new Date(value);
1107
+ if (Number.isNaN(date.getTime())) return String(value);
1108
+ const pad = (n) => String(n).padStart(2, "0");
1109
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
1110
+ }
1111
+ function formatBytes(bytes) {
1112
+ if (bytes < 1024) return `${bytes} B`;
1113
+ const units = ["KB", "MB", "GB"];
1114
+ let value = bytes / 1024;
1115
+ let unit = 0;
1116
+ while (value >= 1024 && unit < units.length - 1) {
1117
+ value /= 1024;
1118
+ unit++;
1119
+ }
1120
+ return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
1121
+ }
1122
+
1123
+ // src/context.ts
1124
+ async function createContext(options = {}) {
1125
+ const store = options.store ?? createConfigStore({ env: options.env });
1126
+ const json = options.json ?? false;
1127
+ const config = await store.resolve({ serverUrl: options.server });
1128
+ const interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
1129
+ return {
1130
+ store,
1131
+ config,
1132
+ json,
1133
+ log: options.log ?? createLogger({ debug: config.debug }),
1134
+ out: options.out ?? createOutput({ json }),
1135
+ fetch: options.fetch ?? ((input, init) => fetch(input, init)),
1136
+ open: options.open ?? (async (url) => (await import("open")).default(url)),
1137
+ interactive,
1138
+ confirm: ttyConfirm,
1139
+ prompt: ttyPrompt,
1140
+ progress: interactive ? ttyProgress : () => {
1141
+ },
1142
+ binName: BIN_NAME,
1143
+ waitReady: createReadyWaiter({ env: options.env })
1144
+ };
1145
+ }
1146
+ async function ttyConfirm(question) {
1147
+ const answer = await ttyPrompt(`${question} [y/N] `);
1148
+ return /^y(es)?$/i.test(answer.trim());
1149
+ }
1150
+ async function ttyPrompt(question) {
1151
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
1152
+ try {
1153
+ return await rl.question(question);
1154
+ } finally {
1155
+ rl.close();
1156
+ }
1157
+ }
1158
+ function ttyProgress(line) {
1159
+ process.stderr.write(`\r\x1B[2K${line ?? ""}`);
1160
+ }
1161
+ function requireClient(ctx) {
1162
+ if (!ctx.config.apiKey) throw new NotLoggedInError(ctx.binName);
1163
+ return createApiClient({
1164
+ serverUrl: ctx.config.serverUrl,
1165
+ apiKey: ctx.config.apiKey,
1166
+ fetch: ctx.fetch
1167
+ });
1168
+ }
1169
+
1170
+ // src/sites.ts
1171
+ import { z as z6 } from "zod";
1172
+ var ShareResponseSchema2 = z6.looseObject({ shareUrl: z6.string() });
1173
+ var sitePath2 = (name) => `/api/sites/${encodeURIComponent(name)}`;
1174
+ function whoami(client) {
1175
+ return client.request("GET", "/api/me", { schema: MeSchema });
1176
+ }
1177
+ function checkName(client, name) {
1178
+ return client.request("GET", `/api/names/${encodeURIComponent(name)}`, {
1179
+ schema: NameCheckSchema
1180
+ });
1181
+ }
1182
+ function sitesBaseFor(serverUrl) {
1183
+ try {
1184
+ const url = new URL(serverUrl);
1185
+ const { hostname } = url;
1186
+ if (url.port || !hostname.includes(".") || /^[\d.]+$/.test(hostname) || hostname.startsWith("["))
1187
+ return null;
1188
+ return { protocol: url.protocol, domain: hostname };
1189
+ } catch {
1190
+ return null;
1191
+ }
1192
+ }
1193
+ function sitesBaseFromSiteUrl(siteUrl2) {
1194
+ try {
1195
+ const url = new URL(siteUrl2);
1196
+ const dot = url.host.indexOf(".");
1197
+ return dot > 0 ? { protocol: url.protocol, domain: url.host.slice(dot + 1) } : null;
1198
+ } catch {
1199
+ return null;
1200
+ }
1201
+ }
1202
+ async function discoverSitesBase(client) {
1203
+ const inferred = sitesBaseFor(client.serverUrl);
1204
+ if (inferred) return inferred;
1205
+ const sites = await listSites(client).catch(() => []);
1206
+ const first = sites[0];
1207
+ return first ? sitesBaseFromSiteUrl(first.url) : null;
1208
+ }
1209
+ function siteUrlFor(name, base) {
1210
+ return siteUrl(name, base.domain, base.protocol);
1211
+ }
1212
+ function sitesPattern(base) {
1213
+ return base ? siteUrlFor("<name>", base) : "<name>.<sites domain>";
1214
+ }
1215
+ async function listSites(client, opts = {}) {
1216
+ const res = await client.request("GET", "/api/sites", {
1217
+ schema: SiteListSchema,
1218
+ query: { archived: opts.archived ? 1 : void 0 }
1219
+ });
1220
+ return res.sites;
1221
+ }
1222
+ function getSite(client, name) {
1223
+ return client.request("GET", sitePath2(name), { schema: SiteDetailSchema });
1224
+ }
1225
+ async function versionFiles(client, name, versionId) {
1226
+ const site = await getSite(client, name);
1227
+ const id = versionId ?? site.currentVersionId;
1228
+ if (!id) throw new CliError(`${name} has no published version yet`);
1229
+ const files = await client.request(
1230
+ "GET",
1231
+ `${sitePath2(name)}/versions/${encodeURIComponent(id)}/files`,
1232
+ { schema: VersionFilesSchema }
1233
+ );
1234
+ return { name: site.name, url: site.url, current: id === site.currentVersionId, ...files };
1235
+ }
1236
+ async function siteAfter(client, name, raw) {
1237
+ const parsed = SiteSchema.safeParse(raw);
1238
+ if (parsed.success) return parsed.data;
1239
+ const nested = SiteSchema.safeParse(raw?.site);
1240
+ if (nested.success) return nested.data;
1241
+ return getSite(client, name);
1242
+ }
1243
+ async function rollback(client, name, versionId) {
1244
+ const raw = await client.request("POST", `${sitePath2(name)}/current`, { body: { versionId } });
1245
+ return siteAfter(client, name, raw);
1246
+ }
1247
+ async function setVisibility(client, name, visibility) {
1248
+ const raw = await client.request("PATCH", sitePath2(name), { body: { visibility } });
1249
+ return siteAfter(client, name, raw);
1250
+ }
1251
+ async function archiveSite(client, name, opts = {}) {
1252
+ const raw = await client.request(
1253
+ "POST",
1254
+ `${sitePath2(name)}/${opts.unarchive ? "unarchive" : "archive"}`
1255
+ );
1256
+ return siteAfter(client, name, raw);
1257
+ }
1258
+ async function deleteSite(client, name) {
1259
+ await client.request("DELETE", sitePath2(name));
1260
+ return { name, deleted: true };
1261
+ }
1262
+ async function share(client, name, opts = {}) {
1263
+ if (opts.revoke) {
1264
+ await client.request("DELETE", `${sitePath2(name)}/share`);
1265
+ return { name, action: "revoked", shareUrl: null };
1266
+ }
1267
+ let hadLink = false;
1268
+ if (!opts.refresh) {
1269
+ const site = await getSite(client, name);
1270
+ if (site.shareUrl) return { name, action: "existing", shareUrl: site.shareUrl };
1271
+ } else hadLink = true;
1272
+ const res = await client.request("POST", `${sitePath2(name)}/share`, {
1273
+ schema: ShareResponseSchema2
1274
+ });
1275
+ return { name, action: hadLink ? "refreshed" : "created", shareUrl: res.shareUrl };
1276
+ }
1277
+
1278
+ // src/commands/auth.ts
1279
+ async function runLogin(ctx) {
1280
+ const result = await loginFlow({
1281
+ serverUrl: ctx.config.serverUrl,
1282
+ store: ctx.store,
1283
+ log: ctx.log,
1284
+ open: ctx.open,
1285
+ fetch: ctx.fetch
1286
+ });
1287
+ if (ctx.json) ctx.out.data(result, () => "");
1288
+ }
1289
+ async function runLogout(ctx) {
1290
+ const config = await ctx.store.load();
1291
+ if (!config.apiKey) {
1292
+ ctx.log.info("Not logged in \u2014 nothing to do");
1293
+ return;
1294
+ }
1295
+ await ctx.store.clearCredentials();
1296
+ ctx.log.success("Logged out; API key removed from this machine");
1297
+ ctx.log.hint(`Revoke it on the server under API keys at ${ctx.config.serverUrl}`);
1298
+ }
1299
+ async function runWhoami(ctx) {
1300
+ const client = requireClient(ctx);
1301
+ const me = await whoami(client);
1302
+ const base = ctx.json ? null : await discoverSitesBase(client);
1303
+ ctx.out.data(
1304
+ {
1305
+ ...me,
1306
+ serverUrl: ctx.config.serverUrl,
1307
+ apiKey: redactKey(ctx.config.apiKey),
1308
+ apiKeySource: ctx.config.apiKeySource
1309
+ },
1310
+ () => [
1311
+ `${chalk4.bold("User:")} ${me.name} <${me.email}>`,
1312
+ `${chalk4.bold("Sites:")} ${sitesPattern(base)} ${chalk4.dim("(names are first come, first served)")}`,
1313
+ `${chalk4.bold("Server:")} ${ctx.config.serverUrl}`,
1314
+ `${chalk4.bold("API key:")} ${redactKey(ctx.config.apiKey)} ${chalk4.dim(`(from ${ctx.config.apiKeySource})`)}`
1315
+ ].join("\n")
1316
+ );
1317
+ }
1318
+
1319
+ // src/commands/deploy.ts
1320
+ import chalk5 from "chalk";
1321
+
1322
+ // src/deploy.ts
1323
+ import { readFile as readFile3 } from "node:fs/promises";
1324
+
1325
+ // src/manifest.ts
1326
+ import { createHash } from "node:crypto";
1327
+ import { createReadStream } from "node:fs";
1328
+ import { readdir, readFile as readFile2, realpath, stat as stat2 } from "node:fs/promises";
1329
+ import { join as join2, sep } from "node:path";
1330
+ import ignore from "ignore";
1331
+ import mime from "mime";
1332
+ var IGNORE_FILE = ".flingignore";
1333
+ var NO_INDEX_WARNING = "No index.html at the top of the folder \u2014 the site root will 404.";
1334
+ var ALWAYS_SKIP = /* @__PURE__ */ new Set(["node_modules"]);
1335
+ var CHARSET_TYPES = /* @__PURE__ */ new Set([
1336
+ "application/javascript",
1337
+ "application/json",
1338
+ "application/manifest+json",
1339
+ "application/xml",
1340
+ "image/svg+xml"
1341
+ ]);
1342
+ function contentTypeFor(path) {
1343
+ const type = mime.getType(path) ?? "application/octet-stream";
1344
+ return type.startsWith("text/") || CHARSET_TYPES.has(type) ? `${type}; charset=utf-8` : type;
1345
+ }
1346
+ async function sha256File(path) {
1347
+ const hash = createHash("sha256");
1348
+ for await (const chunk of createReadStream(path)) hash.update(chunk);
1349
+ return hash.digest("hex");
1350
+ }
1351
+ function toManifest(local) {
1352
+ return {
1353
+ files: local.files.map(({ path, sha256, size, contentType }) => ({
1354
+ path,
1355
+ sha256,
1356
+ size,
1357
+ contentType
1358
+ }))
1359
+ };
1360
+ }
1361
+ async function buildManifest(dir) {
1362
+ let root;
1363
+ try {
1364
+ root = await realpath(dir);
1365
+ } catch {
1366
+ throw new CliError(`Folder not found: ${dir}`);
1367
+ }
1368
+ if (!(await stat2(root)).isDirectory()) throw new CliError(`Not a folder: ${dir}`);
1369
+ const ig = await loadIgnore(root);
1370
+ const warnings = [];
1371
+ const found = [];
1372
+ const visitedDirs = /* @__PURE__ */ new Set([root]);
1373
+ const inside = (real) => real === root || real.startsWith(root + sep);
1374
+ async function walk2(realDir, relDir) {
1375
+ const entries = await readdir(realDir, { withFileTypes: true });
1376
+ entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
1377
+ for (const entry of entries) {
1378
+ if (entry.name.startsWith(".") || ALWAYS_SKIP.has(entry.name)) continue;
1379
+ const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
1380
+ let abs = join2(realDir, entry.name);
1381
+ let isDir = entry.isDirectory();
1382
+ let isFile = entry.isFile();
1383
+ if (entry.isSymbolicLink()) {
1384
+ let target;
1385
+ try {
1386
+ target = await realpath(abs);
1387
+ } catch {
1388
+ warnings.push(`Skipped broken symlink: ${rel}`);
1389
+ continue;
1390
+ }
1391
+ if (!inside(target)) {
1392
+ warnings.push(`Skipped symlink pointing outside the folder: ${rel}`);
1393
+ continue;
1394
+ }
1395
+ const info = await stat2(target);
1396
+ abs = target;
1397
+ isDir = info.isDirectory();
1398
+ isFile = info.isFile();
1399
+ }
1400
+ if (isDir) {
1401
+ if (ig.ignores(`${rel}/`)) continue;
1402
+ if (visitedDirs.has(abs)) continue;
1403
+ visitedDirs.add(abs);
1404
+ await walk2(abs, rel);
1405
+ } else if (isFile) {
1406
+ if (ig.ignores(rel)) continue;
1407
+ const { size } = await stat2(abs);
1408
+ found.push({ path: rel, absPath: abs, size });
1409
+ if (found.length > MAX_FILES) {
1410
+ throw new CliError(`Too many files: more than ${MAX_FILES} in ${dir}`, {
1411
+ hint: `Add a ${IGNORE_FILE} to skip what the site does not need.`
1412
+ });
1413
+ }
1414
+ }
1415
+ }
1416
+ }
1417
+ await walk2(root, "");
1418
+ if (found.length === 0) {
1419
+ throw new CliError(
1420
+ `Nothing to deploy: ${dir} has no files (dotfiles and node_modules are skipped)`
1421
+ );
1422
+ }
1423
+ checkLimits(found, dir);
1424
+ if (!found.some((f) => f.path === "index.html")) {
1425
+ warnings.push(NO_INDEX_WARNING);
1426
+ }
1427
+ const files = await mapLimit(found, 16, async (f) => ({
1428
+ ...f,
1429
+ sha256: await sha256File(f.absPath),
1430
+ contentType: contentTypeFor(f.path)
1431
+ }));
1432
+ return { root, files, totalBytes: files.reduce((n, f) => n + f.size, 0), warnings };
1433
+ }
1434
+ function checkLimits(files, dir) {
1435
+ for (const f of files) {
1436
+ if (f.path.length > MAX_PATH_LENGTH) {
1437
+ throw new CliError(
1438
+ `Path too long (max ${MAX_PATH_LENGTH} characters): ${f.path.slice(0, 80)}\u2026`
1439
+ );
1440
+ }
1441
+ if (!isValidSitePath(f.path)) throw new CliError(`Unsupported file path: ${f.path}`);
1442
+ if (f.size > MAX_FILE_BYTES) {
1443
+ throw new CliError(
1444
+ `${f.path} is ${formatBytes(f.size)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`,
1445
+ { hint: `Add it to ${IGNORE_FILE} if the site does not need it.` }
1446
+ );
1447
+ }
1448
+ }
1449
+ const total = files.reduce((n, f) => n + f.size, 0);
1450
+ if (total > MAX_DEPLOY_BYTES) {
1451
+ throw new CliError(
1452
+ `${dir} is ${formatBytes(total)}; the limit is ${formatBytes(MAX_DEPLOY_BYTES)} per deploy`,
1453
+ { hint: `Add a ${IGNORE_FILE} to skip what the site does not need.` }
1454
+ );
1455
+ }
1456
+ }
1457
+ var hasRootIndex = (local) => local.files.some((f) => f.path === "index.html");
1458
+ var htmlPages = (local) => local.files.filter((f) => isHtmlPath(f.path) && f.path !== "index.html");
1459
+ var TITLE_BYTES = 65536;
1460
+ async function readHead(file) {
1461
+ if (file.content) return file.content.subarray(0, TITLE_BYTES).toString("utf8");
1462
+ const chunks = [];
1463
+ let size = 0;
1464
+ for await (const chunk of createReadStream(file.absPath, { end: TITLE_BYTES - 1 })) {
1465
+ chunks.push(chunk);
1466
+ size += chunk.length;
1467
+ }
1468
+ return Buffer.concat(chunks, size).toString("utf8");
1469
+ }
1470
+ async function withHtmlListing(local, siteName2) {
1471
+ const pages = htmlPages(local);
1472
+ const entries = await mapLimit(pages, 16, async (f) => ({
1473
+ path: f.path,
1474
+ title: extractHtmlTitle(await readHead(f))
1475
+ }));
1476
+ const content = Buffer.from(renderHtmlListing(siteName2, entries), "utf8");
1477
+ const index = {
1478
+ path: "index.html",
1479
+ absPath: "",
1480
+ content,
1481
+ sha256: createHash("sha256").update(content).digest("hex"),
1482
+ size: content.length,
1483
+ contentType: contentTypeFor("index.html")
1484
+ };
1485
+ const files = [...local.files.filter((f) => f.path !== "index.html"), index].sort(
1486
+ (a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0
1487
+ );
1488
+ return {
1489
+ ...local,
1490
+ files,
1491
+ totalBytes: files.reduce((n, f) => n + f.size, 0),
1492
+ warnings: local.warnings.filter((w) => w !== NO_INDEX_WARNING)
1493
+ };
1494
+ }
1495
+ async function loadIgnore(root) {
1496
+ const ig = ignore();
1497
+ try {
1498
+ ig.add(await readFile2(join2(root, IGNORE_FILE), "utf8"));
1499
+ } catch (error) {
1500
+ if (error.code !== "ENOENT") throw error;
1501
+ }
1502
+ return ig;
1503
+ }
1504
+ async function mapLimit(items, limit, fn) {
1505
+ const results = new Array(items.length);
1506
+ let next = 0;
1507
+ const worker = async () => {
1508
+ while (next < items.length) {
1509
+ const i = next++;
1510
+ results[i] = await fn(items[i], i);
1511
+ }
1512
+ };
1513
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
1514
+ return results;
1515
+ }
1516
+
1517
+ // src/naming.ts
1518
+ import { existsSync } from "node:fs";
1519
+ import { homedir as homedir2 } from "node:os";
1520
+ import { basename, dirname, isAbsolute, relative, resolve, sep as sep2 } from "node:path";
1521
+ var PROJECT_MARKERS = ["package.json", "pyproject.toml", "Cargo.toml", "go.mod"];
1522
+ var BUILD_OUTPUT_DIRS = /* @__PURE__ */ new Set([
1523
+ "dist",
1524
+ "build",
1525
+ "out",
1526
+ "output",
1527
+ "public",
1528
+ "www",
1529
+ "_site",
1530
+ "site",
1531
+ "html",
1532
+ "static",
1533
+ "export",
1534
+ "target"
1535
+ ]);
1536
+ var CONTAINER_DIRS = /* @__PURE__ */ new Set([
1537
+ "apps",
1538
+ "packages",
1539
+ "src",
1540
+ "sites",
1541
+ "libs",
1542
+ "projects",
1543
+ "services"
1544
+ ]);
1545
+ var BUILD_SUFFIX_RE = /[-_.](static|dist|build|out|output)$/i;
1546
+ var FALLBACK_NAME = "my-site";
1547
+ function findProjectRoot(dir, env = {}) {
1548
+ const exists = env.exists ?? existsSync;
1549
+ const home = resolve(env.home ?? homedir2());
1550
+ const candidates = [];
1551
+ for (let d = resolve(dir); d !== home && dirname(d) !== d; d = dirname(d)) candidates.push(d);
1552
+ return candidates.find((d) => exists(`${d}${sep2}.git`)) ?? candidates.find((d) => PROJECT_MARKERS.some((m) => exists(`${d}${sep2}${m}`))) ?? null;
1553
+ }
1554
+ var isGeneric = (segment) => segment.startsWith(".") || BUILD_OUTPUT_DIRS.has(segment.toLowerCase());
1555
+ function nameParts(dir, env = {}) {
1556
+ const abs = resolve(dir);
1557
+ const root = findProjectRoot(abs, env);
1558
+ if (!root) {
1559
+ const fromHome = relative(resolve(env.home ?? homedir2()), abs);
1560
+ if (fromHome === "") return [];
1561
+ const underHome = fromHome && !fromHome.startsWith("..") && !isAbsolute(fromHome);
1562
+ const segments = (underHome ? fromHome : abs).split(sep2).filter(Boolean);
1563
+ const pick = [...segments].reverse().find((s) => !isGeneric(s)) ?? segments.at(-1);
1564
+ return pick ? [pick] : [];
1565
+ }
1566
+ const parts = [basename(root)];
1567
+ const rel = relative(root, abs);
1568
+ for (const raw of rel ? rel.split(sep2) : []) {
1569
+ if (isGeneric(raw) || CONTAINER_DIRS.has(raw.toLowerCase())) continue;
1570
+ const segment = raw.replace(BUILD_SUFFIX_RE, "");
1571
+ const slug = slugify(segment, 200);
1572
+ if (!slug || slug === slugify(parts[0] ?? "", 200)) continue;
1573
+ if (slug === slugify(parts.at(-1) ?? "", 200)) continue;
1574
+ parts.push(segment);
1575
+ }
1576
+ return parts;
1577
+ }
1578
+ function fitName(parts) {
1579
+ const slugs = parts.map((p) => slugify(p, 200)).filter(Boolean);
1580
+ while (slugs.length > 2 && slugs.join("-").length > SITE_NAME_MAX) slugs.splice(1, 1);
1581
+ let name = slugify(slugs.join("-"));
1582
+ if (!name) return FALLBACK_NAME;
1583
+ if (!isValidSiteName(name)) {
1584
+ name = `${name.slice(0, SITE_NAME_MAX - 5).replace(/-+$/, "")}-site`;
1585
+ }
1586
+ return isValidSiteName(name) ? name : FALLBACK_NAME;
1587
+ }
1588
+ function defaultSiteName(dir, env = {}) {
1589
+ return fitName(nameParts(dir, env));
1590
+ }
1591
+
1592
+ // src/deploy.ts
1593
+ var NameTakenError = class extends CliError {
1594
+ constructor(siteName2, suggestions, hint) {
1595
+ super(
1596
+ `The site name "${siteName2}" is taken by someone else` + (suggestions.length ? `. Free alternatives: ${suggestions.join(", ")}` : ""),
1597
+ {
1598
+ hint: hint ?? `Pass --name <name> to choose another${suggestions[0] ? `, e.g. --name ${suggestions[0]}` : ""}.`
1599
+ }
1600
+ );
1601
+ this.siteName = siteName2;
1602
+ this.suggestions = suggestions;
1603
+ this.name = "NameTakenError";
1604
+ }
1605
+ code = "name_taken";
1606
+ };
1607
+ async function nameTakenError(client, name, body) {
1608
+ const raw = body?.error?.suggestions;
1609
+ let suggestions = Array.isArray(raw) ? raw.filter((s) => typeof s === "string") : [];
1610
+ if (suggestions.length === 0) {
1611
+ suggestions = await checkName(client, name).then((c) => c.suggestions).catch(() => []);
1612
+ }
1613
+ return new NameTakenError(name, suggestions);
1614
+ }
1615
+ var UPLOAD_CONCURRENCY = 8;
1616
+ var UPLOAD_RETRIES = 3;
1617
+ function resolveSiteName(dir, name) {
1618
+ if (name !== void 0) {
1619
+ if (!isValidSiteName(name)) {
1620
+ throw new CliError(`Invalid site name "${name}"`, {
1621
+ hint: `Use lowercase letters, digits and single hyphens, up to ${SITE_NAME_MAX} characters (e.g. "${slugify(name) || "my-site"}").`
1622
+ });
1623
+ }
1624
+ return name;
1625
+ }
1626
+ const derived = defaultSiteName(dir);
1627
+ if (!isValidSiteName(derived)) {
1628
+ throw new CliError(`Could not derive a site name from "${dir}"`, {
1629
+ hint: "Pass --name <name>."
1630
+ });
1631
+ }
1632
+ return derived;
1633
+ }
1634
+ var defaultSleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
1635
+ async function deploy(deps, opts) {
1636
+ const name = resolveSiteName(opts.dir, opts.name);
1637
+ const local = opts.manifest ?? await buildManifest(opts.dir);
1638
+ const base = {
1639
+ name,
1640
+ fileCount: local.files.length,
1641
+ totalBytes: local.totalBytes,
1642
+ warnings: local.warnings
1643
+ };
1644
+ const path = `/api/sites/${encodeURIComponent(name)}/deploys`;
1645
+ const res = await deps.client.request("POST", path, {
1646
+ schema: DeployResponseSchema,
1647
+ body: {
1648
+ manifest: toManifest(local),
1649
+ ...opts.spa !== void 0 ? { spa: opts.spa } : {},
1650
+ ...opts.public ? { visibility: "public" } : {}
1651
+ }
1652
+ }).catch(async (error) => {
1653
+ if (error instanceof CliApiError && error.status === 409 && error.code === "name_taken")
1654
+ throw await nameTakenError(deps.client, name, error.body);
1655
+ throw error;
1656
+ });
1657
+ if (res.status !== "upload") {
1658
+ return {
1659
+ ...base,
1660
+ status: res.status,
1661
+ url: res.site.url,
1662
+ versionId: res.versionId,
1663
+ site: res.site,
1664
+ uploaded: 0
1665
+ };
1666
+ }
1667
+ const byPath = new Map(local.files.map((f) => [f.path, f]));
1668
+ const uploads = res.uploads.map((u) => {
1669
+ const file = byPath.get(u.path);
1670
+ if (!file) throw new CliError(`Server asked for a file that is not in the manifest: ${u.path}`);
1671
+ return { ...u, file };
1672
+ });
1673
+ const totalBytes = uploads.reduce((n, u) => n + u.file.size, 0);
1674
+ let done = 0;
1675
+ let bytes = 0;
1676
+ opts.onProgress?.({ uploaded: 0, total: uploads.length, bytes: 0, totalBytes });
1677
+ await mapLimit(uploads, UPLOAD_CONCURRENCY, async (u) => {
1678
+ await uploadWithRetry(deps, u.file, u.url, u.headers);
1679
+ done++;
1680
+ bytes += u.file.size;
1681
+ opts.onProgress?.({ uploaded: done, total: uploads.length, bytes, totalBytes });
1682
+ });
1683
+ const fin = await deps.client.request(
1684
+ "POST",
1685
+ `${path}/${encodeURIComponent(res.versionId)}/finalize`,
1686
+ { schema: FinalizeResponseSchema }
1687
+ );
1688
+ return {
1689
+ ...base,
1690
+ status: "live",
1691
+ url: fin.site.url,
1692
+ versionId: fin.versionId,
1693
+ site: fin.site,
1694
+ uploaded: uploads.length
1695
+ };
1696
+ }
1697
+ async function uploadWithRetry(deps, file, url, headers) {
1698
+ const sleep = deps.sleep ?? defaultSleep2;
1699
+ const body = file.content ?? await readFile3(file.absPath);
1700
+ for (let attempt = 0; ; attempt++) {
1701
+ try {
1702
+ await putUpload(deps.fetch, url, headers, body);
1703
+ return;
1704
+ } catch (error) {
1705
+ const retryable = error instanceof UploadError && error.retryable;
1706
+ if (!retryable || attempt >= UPLOAD_RETRIES) {
1707
+ throw new CliError(`Upload of ${file.path} failed: ${error.message}`, {
1708
+ hint: retryable ? "Check your connection and run the deploy again." : void 0
1709
+ });
1710
+ }
1711
+ await sleep(500 * 2 ** attempt);
1712
+ }
1713
+ }
1714
+ }
1715
+
1716
+ // src/inputs.ts
1717
+ import { copyFile, mkdir as mkdir2, mkdtemp, readFile as readFile4, rm, stat as stat3, writeFile as writeFile2 } from "node:fs/promises";
1718
+ import { tmpdir } from "node:os";
1719
+ import { basename as basename2, extname, join as join3, resolve as resolve2, sep as sep3 } from "node:path";
1720
+ import { unzipSync } from "fflate";
1721
+ function inputKind(path, isDirectory) {
1722
+ if (isDirectory) return "folder";
1723
+ const ext = extname(path).toLowerCase();
1724
+ if (ext === ".html" || ext === ".htm") return "html";
1725
+ if (ext === ".zip") return "zip";
1726
+ return "file";
1727
+ }
1728
+ function fileSiteName(path) {
1729
+ const file = basename2(path.replace(/\\/g, "/"));
1730
+ const stem = file.slice(0, file.length - extname(file).length) || file;
1731
+ return fitName([stem.replace(/\s*\(\d+\)$/, "")]);
1732
+ }
1733
+ function defaultNameFor(path, isDirectory) {
1734
+ return isDirectory ? defaultSiteName(path) : fileSiteName(path);
1735
+ }
1736
+ async function tempFolder() {
1737
+ const dir = await mkdtemp(join3(tmpdir(), "fling-"));
1738
+ return { dir, cleanup: () => rm(dir, { recursive: true, force: true }) };
1739
+ }
1740
+ async function prepareInput(path) {
1741
+ let info;
1742
+ try {
1743
+ info = await stat3(path);
1744
+ } catch {
1745
+ throw new CliError(`Not found: ${path}`);
1746
+ }
1747
+ const kind = inputKind(path, info.isDirectory());
1748
+ const defaultName = defaultNameFor(path, info.isDirectory());
1749
+ if (kind === "folder")
1750
+ return { kind, source: path, dir: path, defaultName, cleanup: async () => {
1751
+ } };
1752
+ if (info.size > MAX_DEPLOY_BYTES)
1753
+ throw new CliError(
1754
+ `${basename2(path)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_DEPLOY_BYTES)}`
1755
+ );
1756
+ const tmp = await tempFolder();
1757
+ try {
1758
+ if (kind === "html") {
1759
+ if (info.size > MAX_FILE_BYTES)
1760
+ throw new CliError(
1761
+ `${basename2(path)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`
1762
+ );
1763
+ await copyFile(path, join3(tmp.dir, "index.html"));
1764
+ } else if (kind === "zip") {
1765
+ await extractZip(await readFile4(path), tmp.dir);
1766
+ } else {
1767
+ const name = basename2(path);
1768
+ await copyFile(path, join3(tmp.dir, name));
1769
+ await writeFile2(join3(tmp.dir, "index.html"), wrapperPage(name));
1770
+ }
1771
+ } catch (error) {
1772
+ await tmp.cleanup();
1773
+ throw error;
1774
+ }
1775
+ return { kind, source: path, dir: tmp.dir, defaultName, cleanup: tmp.cleanup };
1776
+ }
1777
+ var JUNK_FILES = /* @__PURE__ */ new Set(["thumbs.db", "desktop.ini"]);
1778
+ function zipEntryPath(name) {
1779
+ const normalised = name.replace(/\\/g, "/");
1780
+ if (normalised.includes("\0")) return "unsafe";
1781
+ if (normalised.startsWith("/") || /^[a-zA-Z]:/.test(normalised)) return "unsafe";
1782
+ const segments = normalised.split("/").filter((s) => s !== "" && s !== ".");
1783
+ if (segments.includes("..")) return "unsafe";
1784
+ if (normalised.endsWith("/") || segments.length === 0) return null;
1785
+ if (segments[0] === "__MACOSX") return null;
1786
+ if (segments.some((s) => s.startsWith("."))) return null;
1787
+ if (JUNK_FILES.has((segments.at(-1) ?? "").toLowerCase())) return null;
1788
+ return segments.join("/");
1789
+ }
1790
+ function unwrapSingleFolder(paths) {
1791
+ let current = paths;
1792
+ for (; ; ) {
1793
+ const first = current[0]?.split("/")[0];
1794
+ if (!first || !current.every((p) => p.includes("/") && p.split("/")[0] === first)) return current;
1795
+ current = current.map((p) => p.slice(first.length + 1));
1796
+ }
1797
+ }
1798
+ async function extractZip(data, dest) {
1799
+ const unsafe = [];
1800
+ let declared = 0;
1801
+ let count = 0;
1802
+ let entries;
1803
+ try {
1804
+ entries = unzipSync(data, {
1805
+ filter: (file) => {
1806
+ const path = zipEntryPath(file.name);
1807
+ if (path === "unsafe") {
1808
+ unsafe.push(file.name);
1809
+ return false;
1810
+ }
1811
+ if (path === null) return false;
1812
+ count++;
1813
+ declared += file.originalSize;
1814
+ if (file.originalSize > MAX_FILE_BYTES)
1815
+ throw new CliError(
1816
+ `${path} in the zip is ${formatBytes(file.originalSize)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`
1817
+ );
1818
+ if (declared > MAX_DEPLOY_BYTES)
1819
+ throw new CliError(
1820
+ `The zip expands to more than ${formatBytes(MAX_DEPLOY_BYTES)}, the limit per deploy`
1821
+ );
1822
+ if (count > MAX_FILES) throw new CliError(`The zip has more than ${MAX_FILES} files`);
1823
+ return true;
1824
+ }
1825
+ });
1826
+ } catch (error) {
1827
+ if (error instanceof CliError) throw error;
1828
+ throw new CliError(`Could not read the zip: ${error.message}`);
1829
+ }
1830
+ if (unsafe.length)
1831
+ throw new CliError(
1832
+ `Refusing to extract the zip: it has paths that escape its folder (${unsafe.slice(0, 3).join(", ")})`
1833
+ );
1834
+ const names = Object.keys(entries);
1835
+ const paths = /* @__PURE__ */ new Map();
1836
+ const kept = names.map((n) => zipEntryPath(n));
1837
+ const unwrapped = unwrapSingleFolder(kept);
1838
+ names.forEach((n, i) => {
1839
+ paths.set(n, unwrapped[i]);
1840
+ });
1841
+ if (names.length === 0) throw new CliError("The zip has no files to fling");
1842
+ const root = resolve2(dest);
1843
+ let total = 0;
1844
+ const written = [];
1845
+ for (const name of names) {
1846
+ const content = entries[name];
1847
+ total += content.length;
1848
+ if (content.length > MAX_FILE_BYTES || total > MAX_DEPLOY_BYTES)
1849
+ throw new CliError(`The zip expands to more than the limits allow`);
1850
+ const rel = paths.get(name);
1851
+ const target = resolve2(root, ...rel.split("/"));
1852
+ if (!target.startsWith(root + sep3)) throw new CliError(`Unsafe path in the zip: ${name}`);
1853
+ await mkdir2(join3(target, ".."), { recursive: true });
1854
+ await writeFile2(target, content);
1855
+ written.push(rel);
1856
+ }
1857
+ return written.sort();
1858
+ }
1859
+ var escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
1860
+ function wrapperPage(file) {
1861
+ const href = escapeHtml3(encodeURIComponent(file));
1862
+ const title = escapeHtml3(file);
1863
+ const ext = extname(file).toLowerCase();
1864
+ let body;
1865
+ if (/^\.(png|jpe?g|gif|webp|avif|svg|bmp|ico)$/.test(ext))
1866
+ body = `<img src="${href}" alt="${title}">`;
1867
+ else if (ext === ".pdf") body = `<iframe src="${href}" title="${title}"></iframe>`;
1868
+ else if (/^\.(mp4|webm|mov|m4v)$/.test(ext)) body = `<video src="${href}" controls></video>`;
1869
+ else if (/^\.(mp3|wav|ogg|m4a|flac)$/.test(ext)) body = `<audio src="${href}" controls></audio>`;
1870
+ else body = "";
1871
+ return `<!doctype html>
1872
+ <html lang="en">
1873
+ <head>
1874
+ <meta charset="utf-8">
1875
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1876
+ <title>${title}</title>
1877
+ <style>
1878
+ body { margin: 0; font: 16px/1.5 system-ui, sans-serif; display: flex; flex-direction: column; min-height: 100vh; }
1879
+ header { padding: 12px 20px; border-bottom: 1px solid #ddd; display: flex; gap: 16px; align-items: baseline; }
1880
+ main { flex: 1; display: flex; justify-content: center; align-items: center; padding: 20px; }
1881
+ img, video { max-width: 100%; max-height: 85vh; }
1882
+ iframe { width: 100%; height: 85vh; border: 0; }
1883
+ </style>
1884
+ </head>
1885
+ <body>
1886
+ <header><strong>${title}</strong> <a href="${href}" download>Download</a> <a href="${href}">Open</a></header>
1887
+ <main>${body}</main>
1888
+ </body>
1889
+ </html>
1890
+ `;
1891
+ }
1892
+
1893
+ // src/locate.ts
1894
+ import { lstat, readdir as readdir2, readFile as readFile5, realpath as realpath2, stat as stat4 } from "node:fs/promises";
1895
+ import { posix, win32 } from "node:path";
1896
+ import { fileURLToPath } from "node:url";
1897
+ var DOWNLOADS_DIR_ENV = "FLING_DOWNLOADS_DIR";
1898
+ var FOUND_AS_GIVEN = "path";
1899
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "__MACOSX", "Library", "AppData", "$RECYCLE.BIN"]);
1900
+ var SESSION_SKIP_DIRS = /* @__PURE__ */ new Set(["skills-plugin", "rpm", "uploads", "memory"]);
1901
+ var SESSIONS_DEPTH = 4;
1902
+ var OUTPUTS_DEPTH = 4;
1903
+ var SANDBOX_PATH_RE = /^\/(mnt\/user-data|mnt\/data|sessions|home\/claude|tmp\/outputs)(\/|$)/;
1904
+ var nodeFs = {
1905
+ async stat(path) {
1906
+ try {
1907
+ const s = await stat4(path);
1908
+ return { isFile: s.isFile(), isDirectory: s.isDirectory(), mtimeMs: s.mtimeMs, size: s.size };
1909
+ } catch {
1910
+ return null;
1911
+ }
1912
+ },
1913
+ async readdir(path) {
1914
+ try {
1915
+ const entries = await readdir2(path, { withFileTypes: true });
1916
+ const out = [];
1917
+ for (const e of entries) {
1918
+ if (e.isSymbolicLink()) {
1919
+ const s = await stat4(`${path}/${e.name}`).catch(() => null);
1920
+ out.push({ name: e.name, isFile: Boolean(s?.isFile()), isDirectory: false });
1921
+ } else out.push({ name: e.name, isFile: e.isFile(), isDirectory: e.isDirectory() });
1922
+ }
1923
+ return out;
1924
+ } catch {
1925
+ return [];
1926
+ }
1927
+ },
1928
+ async realpath(path) {
1929
+ return realpath2(path).catch(() => null);
1930
+ },
1931
+ async readText(path) {
1932
+ try {
1933
+ if (!(await lstat(path)).isFile()) return null;
1934
+ return await readFile5(path, "utf8");
1935
+ } catch {
1936
+ return null;
1937
+ }
1938
+ }
1939
+ };
1940
+ var pathFor = (platform) => platform === "win32" ? win32 : posix;
1941
+ var usable = (value) => Boolean(value?.trim()) && !value?.trim().startsWith("${");
1942
+ async function xdgUserDir(key, opts, fs) {
1943
+ const fromEnv2 = opts.env[`XDG_${key}_DIR`];
1944
+ if (usable(fromEnv2)) return fromEnv2;
1945
+ const configHome = opts.env.XDG_CONFIG_HOME || posix.join(opts.home, ".config");
1946
+ const text = await fs.readText(posix.join(configHome, "user-dirs.dirs"));
1947
+ const match = text?.match(new RegExp(`^XDG_${key}_DIR="([^"]*)"`, "m"));
1948
+ if (!match?.[1]) return void 0;
1949
+ return match[1].replace(/^\$HOME(?=\/|$)/, opts.home);
1950
+ }
1951
+ async function searchRoots(opts) {
1952
+ const fs = opts.fs ?? nodeFs;
1953
+ const p = pathFor(opts.platform);
1954
+ const { home, env, platform } = opts;
1955
+ const userHome = platform === "win32" && usable(env.USERPROFILE) ? env.USERPROFILE : home;
1956
+ const roots = [];
1957
+ const add = (label, path, depth, sessions = false) => {
1958
+ if (!path) return;
1959
+ const norm = p.resolve(path);
1960
+ if (roots.some((r) => r.path === norm)) return;
1961
+ roots.push({ label, path: norm, depth, ...sessions ? { sessions } : {} });
1962
+ };
1963
+ const cwd = p.resolve(opts.cwd);
1964
+ if (cwd !== p.parse(cwd).root && cwd !== p.resolve(userHome)) add("current folder", cwd, 4);
1965
+ const configured = usable(opts.downloadsDir) ? opts.downloadsDir : usable(env[DOWNLOADS_DIR_ENV]) ? env[DOWNLOADS_DIR_ENV] : void 0;
1966
+ const defaultDownloads = platform === "linux" ? await xdgUserDir("DOWNLOAD", opts, fs) ?? p.join(home, "Downloads") : p.join(userHome, "Downloads");
1967
+ add("Downloads", configured ? expandHome(configured, home, platform) : defaultDownloads, 3);
1968
+ add("Downloads", defaultDownloads, 3);
1969
+ const desktop = platform === "linux" ? await xdgUserDir("DESKTOP", opts, fs) ?? p.join(home, "Desktop") : p.join(userHome, "Desktop");
1970
+ add("Desktop", desktop, 3);
1971
+ if (platform === "win32" && usable(env.OneDrive))
1972
+ add("Desktop", p.join(env.OneDrive, "Desktop"), 3);
1973
+ const documents = platform === "linux" ? await xdgUserDir("DOCUMENTS", opts, fs) ?? p.join(home, "Documents") : p.join(userHome, "Documents");
1974
+ add("Documents/Claude", p.join(documents, "Claude"), 4);
1975
+ add("~/Claude", p.join(userHome, "Claude"), 4);
1976
+ const claudeConfig = platform === "darwin" ? p.join(home, "Library", "Application Support", "Claude") : platform === "win32" ? p.join(
1977
+ usable(env.APPDATA) ? env.APPDATA : p.join(userHome, "AppData", "Roaming"),
1978
+ "Claude"
1979
+ ) : p.join(env.XDG_CONFIG_HOME || p.join(home, ".config"), "Claude");
1980
+ add(
1981
+ "Claude Desktop outputs",
1982
+ p.join(claudeConfig, "local-agent-mode-sessions"),
1983
+ SESSIONS_DEPTH,
1984
+ true
1985
+ );
1986
+ if (!fs.realpath) return roots;
1987
+ const real = [];
1988
+ for (const root of roots) {
1989
+ const path = await fs.realpath(root.path) ?? root.path;
1990
+ if (!real.some((r) => r.path === path)) real.push({ ...root, path });
1991
+ }
1992
+ return real;
1993
+ }
1994
+ function expandHome(input, home, platform) {
1995
+ if (input === "~") return home;
1996
+ if (input.startsWith("~/") || platform === "win32" && input.startsWith("~\\"))
1997
+ return pathFor(platform).join(home, input.slice(2));
1998
+ return input;
1999
+ }
2000
+ async function walk(roots, opts, visit) {
2001
+ const fs = opts.fs ?? nodeFs;
2002
+ const p = pathFor(opts.platform);
2003
+ const now = opts.now ?? Date.now;
2004
+ const deadline = now() + (opts.timeBudgetMs ?? 4e3);
2005
+ let budget = opts.entryBudget ?? 6e4;
2006
+ const skip = (name) => name.startsWith(".") || SKIP_DIRS.has(name);
2007
+ const expanded = [];
2008
+ for (const root of roots) {
2009
+ if (!root.sessions) {
2010
+ expanded.push(root);
2011
+ continue;
2012
+ }
2013
+ let level = [root.path];
2014
+ for (let depth = 0; depth < root.depth && level.length; depth++) {
2015
+ const next = [];
2016
+ for (const dir of level) {
2017
+ for (const e of await fs.readdir(dir)) {
2018
+ if (!e.isDirectory || skip(e.name) || SESSION_SKIP_DIRS.has(e.name)) continue;
2019
+ const child = p.join(dir, e.name);
2020
+ if (e.name === "outputs")
2021
+ expanded.push({ label: root.label, path: child, depth: OUTPUTS_DEPTH });
2022
+ else next.push(child);
2023
+ }
2024
+ }
2025
+ level = next;
2026
+ }
2027
+ }
2028
+ for (const root of expanded) {
2029
+ let level = [root.path];
2030
+ for (let depth = 0; depth <= root.depth && level.length; depth++) {
2031
+ const next = [];
2032
+ for (const dir of level) {
2033
+ if (now() > deadline || budget <= 0) return { complete: false };
2034
+ const entries = await fs.readdir(dir);
2035
+ budget -= entries.length;
2036
+ for (const entry of entries) {
2037
+ if (entry.name.startsWith(".")) continue;
2038
+ const path = p.join(dir, entry.name);
2039
+ const result = await visit({ root, path, entry, siblings: entries });
2040
+ if (entry.isDirectory && !skip(entry.name) && result !== "skip" && depth < root.depth)
2041
+ next.push(path);
2042
+ }
2043
+ }
2044
+ level = next;
2045
+ }
2046
+ }
2047
+ return { complete: true };
2048
+ }
2049
+ function withoutDuplicateSuffix(name) {
2050
+ return name.replace(/ \(\d+\)(?=\.[^.]+$|$)/, "");
2051
+ }
2052
+ function namesMatch(candidate, wanted) {
2053
+ const a = candidate.toLowerCase();
2054
+ const b = wanted.toLowerCase();
2055
+ return a === b || withoutDuplicateSuffix(a) === withoutDuplicateSuffix(b);
2056
+ }
2057
+ var splitSegments = (path) => path.split(/[\\/]+/).filter((s) => s !== "" && s !== "." && s !== "~");
2058
+ function suffixScore(path, wanted) {
2059
+ const have = splitSegments(path);
2060
+ let score = 1;
2061
+ for (let i = 2; i <= Math.min(have.length, wanted.length); i++) {
2062
+ if (have[have.length - i]?.toLowerCase() !== wanted[wanted.length - i]?.toLowerCase()) break;
2063
+ score++;
2064
+ }
2065
+ return score;
2066
+ }
2067
+ var looksLikeSandboxPath = (input) => SANDBOX_PATH_RE.test(input.replace(/\\/g, "/"));
2068
+ function normaliseInput(raw, opts) {
2069
+ let input = raw.trim().replace(/^(["'])(.*)\1$/, "$2");
2070
+ if (input.startsWith("file://")) {
2071
+ try {
2072
+ input = fileURLToPath(input, { windows: opts.platform === "win32" });
2073
+ } catch {
2074
+ input = decodeURIComponent(input.slice("file://".length));
2075
+ }
2076
+ }
2077
+ return expandHome(input, opts.home, opts.platform);
2078
+ }
2079
+ async function resolveInput(input, opts) {
2080
+ const fs = opts.fs ?? nodeFs;
2081
+ const p = pathFor(opts.platform);
2082
+ const given = normaliseInput(input, opts);
2083
+ if (!given) throw new CliError("No path given");
2084
+ const direct = p.isAbsolute(given) ? p.normalize(given) : p.resolve(opts.cwd, given);
2085
+ const directStat = await fs.stat(direct);
2086
+ if (directStat && (directStat.isFile || directStat.isDirectory)) {
2087
+ return {
2088
+ path: direct,
2089
+ foundIn: FOUND_AS_GIVEN,
2090
+ isDirectory: directStat.isDirectory,
2091
+ alternatives: []
2092
+ };
2093
+ }
2094
+ const wanted = splitSegments(given);
2095
+ const name = wanted.at(-1);
2096
+ const roots = await searchRoots(opts);
2097
+ if (!name) throw notFound(input, roots);
2098
+ const found = [];
2099
+ const seen = /* @__PURE__ */ new Set();
2100
+ await walk(roots, opts, async ({ root, path, entry }) => {
2101
+ if (!(entry.isFile || entry.isDirectory) || !namesMatch(entry.name, name)) return;
2102
+ if (seen.has(path)) return;
2103
+ seen.add(path);
2104
+ const s = await fs.stat(path);
2105
+ if (!s) return;
2106
+ found.push({
2107
+ path,
2108
+ foundIn: root.label,
2109
+ kind: s.isDirectory ? "folder" : "file",
2110
+ modified: new Date(s.mtimeMs).toISOString(),
2111
+ size: s.size,
2112
+ score: suffixScore(path, wanted),
2113
+ mtimeMs: s.mtimeMs,
2114
+ isDirectory: s.isDirectory
2115
+ });
2116
+ return void 0;
2117
+ });
2118
+ if (found.length === 0) throw notFound(input, roots);
2119
+ found.sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs);
2120
+ const strip = ({ score: _s, mtimeMs: _m, isDirectory: _d, ...c }) => c;
2121
+ const [best, ...rest] = found;
2122
+ return {
2123
+ path: best.path,
2124
+ foundIn: best.foundIn,
2125
+ isDirectory: best.isDirectory,
2126
+ alternatives: rest.slice(0, 5).map(strip)
2127
+ };
2128
+ }
2129
+ function notFound(input, roots) {
2130
+ const where = roots.map((r) => `${r.label} (${r.path})`).join(", ");
2131
+ const sandbox = looksLikeSandboxPath(input) ? " That looks like a path inside Claude\u2019s sandbox, which this computer cannot see: publish the content inline with fling_publish, or save/download the file to this computer first." : "";
2132
+ return new CliError(`Could not find "${input}". Looked for it by name in: ${where}.${sandbox}`, {
2133
+ hint: "Pass the full path, or save the file to your Downloads folder and use just its name."
2134
+ });
2135
+ }
2136
+ var LISTED_EXT = /\.(html?|zip)$/i;
2137
+ async function findFiles(opts, find = {}) {
2138
+ const fs = opts.fs ?? nodeFs;
2139
+ const p = pathFor(opts.platform);
2140
+ const now = (opts.now ?? Date.now)();
2141
+ const since = now - (find.days ?? 14) * 24 * 60 * 60 * 1e3;
2142
+ const query = find.query?.trim().toLowerCase();
2143
+ const roots = await searchRoots(opts);
2144
+ const found = /* @__PURE__ */ new Map();
2145
+ const rootPaths = new Set(roots.map((r) => r.path));
2146
+ const { complete } = await walk(roots, opts, async ({ root, path, entry }) => {
2147
+ if (entry.isDirectory) {
2148
+ if (rootPaths.has(path)) return;
2149
+ const index = (await fs.readdir(path)).find((e) => e.isFile && /^index\.html?$/i.test(e.name));
2150
+ if (!index) return;
2151
+ const s2 = await fs.stat(p.join(path, index.name));
2152
+ if (s2 && s2.mtimeMs >= since && (!query || entry.name.toLowerCase().includes(query)))
2153
+ found.set(path, {
2154
+ path,
2155
+ foundIn: root.label,
2156
+ kind: "folder",
2157
+ modified: new Date(s2.mtimeMs).toISOString(),
2158
+ size: s2.size,
2159
+ mtimeMs: s2.mtimeMs
2160
+ });
2161
+ return "skip";
2162
+ }
2163
+ if (!entry.isFile || !LISTED_EXT.test(entry.name)) return;
2164
+ if (query && !entry.name.toLowerCase().includes(query)) return;
2165
+ const s = await fs.stat(path);
2166
+ if (!s || s.mtimeMs < since || found.has(path)) return;
2167
+ found.set(path, {
2168
+ path,
2169
+ foundIn: root.label,
2170
+ kind: "file",
2171
+ modified: new Date(s.mtimeMs).toISOString(),
2172
+ size: s.size,
2173
+ mtimeMs: s.mtimeMs
2174
+ });
2175
+ return void 0;
2176
+ });
2177
+ const files = [...found.values()].sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, find.limit ?? 20).map(({ mtimeMs: _m, ...c }) => c);
2178
+ return { files, searched: roots, complete };
2179
+ }
2180
+
2181
+ // src/commands/deploy.ts
2182
+ async function runDeploy(ctx, input, opts) {
2183
+ const client = requireClient(ctx);
2184
+ const ask = ctx.interactive && !opts.yes;
2185
+ const base = ask ? await discoverSitesBase(client) : sitesBaseFor(client.serverUrl);
2186
+ const located = await resolveInput(input ?? ".", ctx.locate ?? systemLocateOptions());
2187
+ if (located.foundIn !== FOUND_AS_GIVEN) {
2188
+ ctx.log.info(`Found in ${located.foundIn}: ${located.path}`);
2189
+ if (located.alternatives.length)
2190
+ ctx.log.hint(
2191
+ `Also found (older): ${located.alternatives.map((a) => a.path).join(", ")} \u2014 pass the full path to fling another.`
2192
+ );
2193
+ }
2194
+ const prepared = await prepareInput(located.path);
2195
+ try {
2196
+ await deployPrepared(ctx, client, base, prepared, opts, ask);
2197
+ } finally {
2198
+ await prepared.cleanup();
2199
+ }
2200
+ }
2201
+ async function deployPrepared(ctx, client, base, prepared, opts, ask) {
2202
+ let local = await buildManifest(prepared.dir);
2203
+ let name = opts.name !== void 0 ? resolveSiteName(prepared.dir, opts.name) : prepared.defaultName;
2204
+ if (opts.name === void 0 && ask) name = await confirmSiteName(ctx, client, name, base);
2205
+ if (!hasRootIndex(local)) {
2206
+ const pages = htmlPages(local).length;
2207
+ const root = base ? siteUrlFor(name, base) : "the site root";
2208
+ const warning = `No index.html at the root \u2014 ${root} will show a 404.`;
2209
+ const choice = opts.listHtml === true ? "list" : opts.listHtml === false ? "fling" : ask ? await askNoIndex(ctx, pages) : pages > 0 ? "list" : "fling";
2210
+ if (choice === "list") {
2211
+ local = await withHtmlListing(local, name);
2212
+ ctx.log.info(`Generated index.html listing ${pages} HTML page${pages === 1 ? "" : "s"}`);
2213
+ } else ctx.log.warn(warning);
2214
+ }
2215
+ let progressShown = false;
2216
+ const result = await deploy(
2217
+ { client, fetch: ctx.fetch },
2218
+ {
2219
+ dir: prepared.dir,
2220
+ name,
2221
+ manifest: local,
2222
+ spa: opts.spa ? true : void 0,
2223
+ public: opts.public,
2224
+ onProgress: (p) => {
2225
+ if (p.total === 0) return;
2226
+ progressShown = true;
2227
+ ctx.progress(
2228
+ `${chalk5.cyan("fling")} uploading ${p.uploaded}/${p.total} files \xB7 ${formatBytes(p.bytes)}/${formatBytes(p.totalBytes)}`
2229
+ );
2230
+ }
2231
+ }
2232
+ ).finally(() => {
2233
+ if (progressShown) ctx.progress(null);
2234
+ });
2235
+ for (const warning of result.warnings) if (warning !== NO_INDEX_WARNING) ctx.log.warn(warning);
2236
+ const summary = `${result.fileCount} files, ${formatBytes(result.totalBytes)}`;
2237
+ if (result.status === "unchanged") ctx.log.info(`Unchanged ${chalk5.dim(`(${summary})`)}`);
2238
+ else if (result.status === "repointed")
2239
+ ctx.log.success(`Restored version ${result.versionId} ${chalk5.dim(`(${summary})`)}`);
2240
+ else
2241
+ ctx.log.success(
2242
+ `Deployed ${result.name} ${chalk5.dim(`(${summary}; uploaded ${result.uploaded}, version ${result.versionId})`)}`
2243
+ );
2244
+ const ready = result.status === "unchanged" ? true : (await waitWithSpinner(
2245
+ ctx.waitReady,
2246
+ result.url,
2247
+ local.files.map((f) => f.path),
2248
+ { progress: ctx.progress, warn: (m) => ctx.log.warn(m) }
2249
+ )).ready;
2250
+ ctx.out.data({ status: result.status, site: result.site, ready }, () => {
2251
+ const lines = [chalk5.bold(result.url)];
2252
+ if (result.site.visibility === "private")
2253
+ lines.push(
2254
+ chalk5.dim(
2255
+ `Private: Nearform sign-in required. Share publicly with \`${ctx.binName} share ${result.name}\`.`
2256
+ )
2257
+ );
2258
+ return lines.join("\n");
2259
+ });
2260
+ }
2261
+ async function askNoIndex(ctx, pages) {
2262
+ const question = pages > 0 ? "No index.html at the root. [L]ist the HTML pages on a generated index page / [f]ling anyway (the root will 404) / [a]bort " : "No index.html at the root (and no HTML pages to list). [f]ling anyway (the root will 404) / [A]bort ";
2263
+ for (; ; ) {
2264
+ const answer = (await ctx.prompt(question)).trim().toLowerCase();
2265
+ if (pages > 0 && (answer === "" || answer === "l" || answer === "list")) return "list";
2266
+ if (answer === "f" || answer === "fling") return "fling";
2267
+ if (answer === "a" || answer === "abort" || answer === "n" || answer === "no" || answer === "")
2268
+ throw new CliError("Not flung: no index.html at the root", {
2269
+ hint: "Fling the folder that contains index.html (your build output, e.g. ./dist), or pass --list-html / --no-list-html."
2270
+ });
2271
+ }
2272
+ }
2273
+ async function confirmSiteName(ctx, client, generated, base) {
2274
+ const where = (n) => base ? ` \u2192 ${siteUrlFor(n, base)}` : "";
2275
+ const describe = (n, note) => ctx.log.info(`Site name: ${chalk5.bold(n)}${where(n)} ${chalk5.dim(`(${note})`)}`);
2276
+ let candidate = generated;
2277
+ for (; ; ) {
2278
+ const check = await checkName(client, candidate);
2279
+ let proposal;
2280
+ if (check.status === "available" || check.status === "yours") {
2281
+ proposal = candidate;
2282
+ describe(candidate, check.status === "yours" ? "updates your existing site" : "new");
2283
+ } else {
2284
+ const why = check.status === "taken" ? "is taken" : "is not a valid site name";
2285
+ const alternatives = check.suggestions.length ? ` Suggestions: ${check.suggestions.join(", ")}` : "";
2286
+ ctx.log.warn(`${candidate} ${why}.${alternatives}`);
2287
+ proposal = check.suggestions[0];
2288
+ if (proposal) describe(proposal, "new");
2289
+ }
2290
+ for (; ; ) {
2291
+ const question = proposal ? `Use "${proposal}"? [Y/n or type another name] ` : "Type a site name (or n to abort): ";
2292
+ const answer = (await ctx.prompt(question)).trim();
2293
+ if (proposal && (answer === "" || /^y(es)?$/i.test(answer))) return proposal;
2294
+ if (/^no?$/i.test(answer))
2295
+ throw new CliError("Aborted \u2014 nothing was flung", {
2296
+ hint: "Pass --name <name> to choose the site name up front."
2297
+ });
2298
+ if (answer === "") continue;
2299
+ if (!isValidSiteName(answer)) {
2300
+ ctx.log.warn(
2301
+ `"${answer}" is not a valid site name: use lowercase letters, digits and single hyphens (max ${SITE_NAME_MAX}); some names are reserved.`
2302
+ );
2303
+ continue;
2304
+ }
2305
+ candidate = answer;
2306
+ break;
2307
+ }
2308
+ }
2309
+ }
2310
+
2311
+ // src/commands/sites.ts
2312
+ import chalk6 from "chalk";
2313
+ async function runList(ctx, opts) {
2314
+ const sites = await listSites(requireClient(ctx), opts);
2315
+ ctx.out.data(
2316
+ { sites },
2317
+ () => renderTable(sites, [
2318
+ { header: "NAME", value: (s) => s.name },
2319
+ { header: "URL", value: (s) => s.url },
2320
+ {
2321
+ header: "VISIBILITY",
2322
+ value: (s) => s.archived ? `${s.visibility} (archived)` : s.visibility
2323
+ },
2324
+ { header: "SHARE", value: (s) => s.shareUrl },
2325
+ { header: "UPDATED", value: (s) => formatDate(s.updatedAt) }
2326
+ ])
2327
+ );
2328
+ }
2329
+ function describeSite(site) {
2330
+ const row = (label, value) => `${chalk6.bold(`${label}:`.padEnd(12))}${value}`;
2331
+ return [
2332
+ row("Name", site.name),
2333
+ row("URL", site.url),
2334
+ row("Visibility", site.visibility + (site.archived ? chalk6.yellow(" (archived)") : "")),
2335
+ row("SPA", site.spa ? "yes" : "no"),
2336
+ row("Share link", site.shareUrl ?? "-"),
2337
+ row("Version", site.currentVersionId ?? "-"),
2338
+ ...filesRow(site, row),
2339
+ row("Created", formatDate(site.createdAt)),
2340
+ row("Updated", formatDate(site.updatedAt))
2341
+ ];
2342
+ }
2343
+ function filesRow(site, row) {
2344
+ const current = site.versions?.find((v) => v.id === site.currentVersionId);
2345
+ if (!current) return [];
2346
+ const n = current.fileCount;
2347
+ return [row("Files", `${n} file${n === 1 ? "" : "s"}, ${formatBytes(current.totalBytes)}`)];
2348
+ }
2349
+ async function runInfo(ctx, name) {
2350
+ const site = await getSite(requireClient(ctx), name);
2351
+ ctx.out.data(
2352
+ site,
2353
+ () => [
2354
+ ...describeSite(site),
2355
+ "",
2356
+ chalk6.dim(
2357
+ `${site.versions.length} version(s) \u2014 \`fling versions ${name}\`; files \u2014 \`fling files ${name}\``
2358
+ )
2359
+ ].join("\n")
2360
+ );
2361
+ }
2362
+ async function runVersions(ctx, name) {
2363
+ const site = await getSite(requireClient(ctx), name);
2364
+ ctx.out.data(
2365
+ { name: site.name, currentVersionId: site.currentVersionId, versions: site.versions },
2366
+ () => renderTable(site.versions, [
2367
+ { header: "", value: (v) => v.id === site.currentVersionId ? "*" : " " },
2368
+ { header: "VERSION", value: (v) => v.id },
2369
+ { header: "FILES", value: (v) => v.fileCount },
2370
+ { header: "SIZE", value: (v) => formatBytes(v.totalBytes) },
2371
+ { header: "CREATED", value: (v) => formatDate(v.createdAt) }
2372
+ ])
2373
+ );
2374
+ }
2375
+ async function runFiles(ctx, name, versionId) {
2376
+ const result = await versionFiles(requireClient(ctx), name, versionId);
2377
+ if (!result.files.some((f) => f.path === "index.html")) {
2378
+ ctx.log.warn("No index.html at the root \u2014 the site root URL will 404");
2379
+ }
2380
+ ctx.out.data(result, () => {
2381
+ const n = result.files.length;
2382
+ const header = chalk6.dim(
2383
+ `${result.versionId}${result.current ? " (current)" : " (not served)"} \u2014 ${n} file${n === 1 ? "" : "s"}, ${formatBytes(result.totalBytes)}${result.generatedIndex ? ", generated index.html" : ""}`
2384
+ );
2385
+ const table = renderTable(result.files, [
2386
+ { header: "PATH", value: (f) => f.path },
2387
+ { header: "SIZE", value: (f) => formatBytes(f.size) },
2388
+ { header: "TYPE", value: (f) => f.contentType }
2389
+ ]);
2390
+ return `${header}
2391
+ ${table}`;
2392
+ });
2393
+ }
2394
+ async function runRollback(ctx, name, versionId) {
2395
+ const site = await rollback(requireClient(ctx), name, versionId);
2396
+ ctx.log.success(`${name} now serves version ${site.currentVersionId ?? versionId}`);
2397
+ ctx.out.data(site, () => site.url);
2398
+ }
2399
+ async function runShare(ctx, name, opts) {
2400
+ if (opts.refresh && opts.revoke) throw new CliError("Use either --refresh or --revoke, not both");
2401
+ const client = requireClient(ctx);
2402
+ const result = await share(client, name, opts);
2403
+ let ready;
2404
+ if (result.action === "created" || result.action === "refreshed") {
2405
+ const files = ctx.waitReady ? await versionFiles(client, name).catch(() => null) : null;
2406
+ ready = (await waitWithSpinner(ctx.waitReady, result.shareUrl, files?.files.map((f) => f.path) ?? [], {
2407
+ progress: ctx.progress,
2408
+ warn: (m) => ctx.log.warn(m)
2409
+ })).ready;
2410
+ }
2411
+ if (result.action === "revoked") ctx.log.success(`Share link for ${name} revoked`);
2412
+ else if (result.action === "refreshed")
2413
+ ctx.log.success("New share link \u2014 the old one no longer works");
2414
+ else if (result.action === "created")
2415
+ ctx.log.success("Share link created \u2014 anyone with it can view the site");
2416
+ ctx.out.data(
2417
+ ready === void 0 ? result : { ...result, ready },
2418
+ () => result.shareUrl ? result.shareUrl : chalk6.dim("(no share link)")
2419
+ );
2420
+ }
2421
+ async function runVisibility(ctx, name, visibility) {
2422
+ if (visibility !== "private" && visibility !== "public") {
2423
+ throw new CliError(`Visibility must be "private" or "public", not "${visibility}"`);
2424
+ }
2425
+ const site = await setVisibility(requireClient(ctx), name, visibility);
2426
+ ctx.log.success(
2427
+ site.visibility === "public" ? `${name} is public \u2014 no sign-in needed` : `${name} is private \u2014 Nearform sign-in required`
2428
+ );
2429
+ ctx.out.data(site, () => site.url);
2430
+ }
2431
+ async function runArchive(ctx, name, unarchive = false) {
2432
+ const site = await archiveSite(requireClient(ctx), name, { unarchive });
2433
+ ctx.log.success(
2434
+ unarchive ? `${name} restored` : `${name} archived \u2014 it 404s until \`fling unarchive ${name}\``
2435
+ );
2436
+ if (ctx.json) ctx.out.data(site, () => "");
2437
+ else if (unarchive) ctx.out.text(site.url);
2438
+ }
2439
+ async function runRemove(ctx, name, opts) {
2440
+ if (!opts.yes) {
2441
+ if (!ctx.interactive) {
2442
+ throw new CliError(`Refusing to delete ${name} without confirmation`, {
2443
+ hint: "Pass --yes to delete."
2444
+ });
2445
+ }
2446
+ const ok = await ctx.confirm(`Delete ${name} and every version of it? This cannot be undone.`);
2447
+ if (!ok) throw new CliError("Cancelled");
2448
+ }
2449
+ const result = await deleteSite(requireClient(ctx), name);
2450
+ ctx.log.success(`${name} deleted`);
2451
+ if (ctx.json) ctx.out.data(result, () => "");
2452
+ }
2453
+
2454
+ // src/mcp.ts
2455
+ import { mkdir as mkdir3, mkdtemp as mkdtemp2, rm as rm2, writeFile as writeFile3 } from "node:fs/promises";
2456
+ import { tmpdir as tmpdir2 } from "node:os";
2457
+ import { basename as basename3, dirname as dirname2, join as join4 } from "node:path";
2458
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2459
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2460
+ import { z as z7 } from "zod";
2461
+ var LOGIN_HINT = `Not logged in to Fling. Call fling_login (it opens the browser to sign in with Nearform Google), or ask the user to run \`${NPX_COMMAND} login\` in a terminal (in Claude Code: \`! ${NPX_COMMAND} login\`), then try again.`;
2462
+ var MCP_LOCAL_INSTRUCTIONS = [
2463
+ "Fling publishes static sites (HTML/CSS/JS, images, documents) for Nearform staff at https://<name>.fling.labs.nearform.com. This server runs on the user's computer.",
2464
+ "Content you created in this conversation (an HTML page, a report, a small app): call fling_publish with every file inline (text as utf8, binary as base64). Never pass file paths from your sandbox (e.g. /mnt/user-data/outputs/\u2026, /sessions/\u2026) \u2014 this computer cannot see them \u2014 and never curl or upload from the sandbox.",
2465
+ 'A file or folder the user has on this computer (a downloaded .html, a .zip, a build folder): call fling_deploy with its path, or just its file name (e.g. "Pitch Deck.html") \u2014 it is looked up in Downloads, Desktop, Documents/Claude and Claude Desktop output folders. Use fling_find_files to see recent candidates when unsure which file the user means.',
2466
+ "Confirm the site name with the user before publishing (fling_check_name shows whether it is free and, for a path, the generated name). Names are global, first come first served; when one is taken, offer the suggestions.",
2467
+ "When there is no index.html but there are HTML pages, fling_deploy and fling_publish do not publish: ask the user whether to generate an index page listing the pages (`listHtml: true`) or publish without one (`listHtml: false`, the root URL 404s), then retry.",
2468
+ "Not logged in? Call fling_login, let the user finish signing in in the browser, then retry.",
2469
+ "Sites are private (Nearformers only) by default; only make one public if asked. Use fling_share for a link people outside Nearform can open. Always give the user the URL that comes back."
2470
+ ].join("\n\n");
2471
+ var toolDef = (def) => ({
2472
+ title: def.title,
2473
+ description: def.description,
2474
+ inputSchema: def.inputSchema,
2475
+ annotations: def.annotations
2476
+ });
2477
+ function createMcpServer(deps = {}) {
2478
+ const store = deps.store ?? createConfigStore();
2479
+ const fetchImpl = deps.fetch ?? ((input, init) => fetch(input, init));
2480
+ const cwd = deps.cwd ?? process.cwd();
2481
+ const locate = deps.locate ?? { ...systemLocateOptions(), cwd };
2482
+ const waitReady = deps.waitReady ?? createReadyWaiter();
2483
+ async function edgeReady(url, paths) {
2484
+ const r = await waitReady(url, paths).catch(() => ({ ready: false, waitedMs: 0 }));
2485
+ return {
2486
+ ready: r.ready,
2487
+ note: r.ready ? "" : `
2488
+ Not reachable yet at ${url}: ${NOT_READY_NOTE}.`
2489
+ };
2490
+ }
2491
+ async function client() {
2492
+ const config = await store.resolve({ serverUrl: deps.server });
2493
+ if (!config.apiKey) throw new CliError("Not logged in", { exitCode: EXIT_NOT_LOGGED_IN });
2494
+ return createApiClient({ serverUrl: config.serverUrl, apiKey: config.apiKey, fetch: fetchImpl });
2495
+ }
2496
+ async function run(op) {
2497
+ try {
2498
+ const { summary, data } = await op(await client());
2499
+ return { content: [{ type: "text", text: `${summary}
2500
+
2501
+ ${JSON.stringify(data, null, 2)}` }] };
2502
+ } catch (error) {
2503
+ return { isError: true, content: [{ type: "text", text: describeError(error) }] };
2504
+ }
2505
+ }
2506
+ async function deployFolder(c, dir, opts) {
2507
+ let local = await buildManifest(dir);
2508
+ let generatedIndex = false;
2509
+ if (!hasRootIndex(local)) {
2510
+ const pages = htmlPages(local).map((f) => f.path);
2511
+ if (pages.length > 0 && opts.listHtml === void 0) {
2512
+ const shown = pages.slice(0, 20);
2513
+ return {
2514
+ kind: "needs_decision",
2515
+ summary: `Not published yet: there is no index.html at the root, but there ${pages.length === 1 ? "is 1 HTML page" : `are ${pages.length} HTML pages`}. Ask the user whether to generate an index.html listing them (call again with \`listHtml: true\`) or to publish without one (\`listHtml: false\` \u2014 the root URL will 404, the pages still work at their own paths). Then call ${opts.retryWith} again with the same arguments plus \`listHtml\`.`,
2516
+ data: {
2517
+ status: "needs_decision",
2518
+ htmlPages: shown,
2519
+ ...pages.length > shown.length ? { moreHtmlPages: pages.length - shown.length } : {},
2520
+ fileCount: local.files.length
2521
+ }
2522
+ };
2523
+ }
2524
+ if (opts.listHtml) {
2525
+ local = await withHtmlListing(local, opts.name);
2526
+ generatedIndex = true;
2527
+ }
2528
+ }
2529
+ const result = await deploy(
2530
+ { client: c, fetch: fetchImpl },
2531
+ { dir, name: opts.name, manifest: local, spa: opts.spa, public: opts.public }
2532
+ ).catch((error) => {
2533
+ if (!(error instanceof NameTakenError)) throw error;
2534
+ const how = opts.nameGiven ? "" : ` (generated from ${opts.nameFrom ?? "the folder"})`;
2535
+ const pick = error.suggestions.length ? ` Free alternatives: ${error.suggestions.join(", ")}.` : "";
2536
+ throw new CliError(
2537
+ `Not deployed: the site name "${error.siteName}"${how} is taken by someone else.${pick} Ask the user which name to use (one of these or their own), then call ${opts.retryWith} again with \`name\`.`
2538
+ );
2539
+ });
2540
+ const what = result.status === "unchanged" ? "Unchanged \u2014 already live" : result.status === "repointed" ? `Restored version ${result.versionId}` : `Deployed ${result.fileCount} files (uploaded ${result.uploaded})`;
2541
+ const warnings = result.warnings.length ? `
2542
+ Warnings:
2543
+ - ${result.warnings.join("\n- ")}` : "";
2544
+ const edge = result.status === "unchanged" ? { ready: true, note: "" } : await edgeReady(
2545
+ result.url,
2546
+ local.files.map((f) => f.path)
2547
+ );
2548
+ return {
2549
+ kind: "deployed",
2550
+ summary: `${what}: ${result.url} (${result.site.visibility})${generatedIndex ? " \u2014 with a generated index.html listing the HTML pages" : ""}${warnings}${edge.note}
2551
+ To check exactly which files the site has, call fling_files with name "${result.name}".`,
2552
+ data: {
2553
+ status: result.status,
2554
+ ready: edge.ready,
2555
+ name: result.name,
2556
+ nameGenerated: !opts.nameGiven,
2557
+ versionId: result.versionId,
2558
+ site: result.site,
2559
+ generatedIndex,
2560
+ warnings: result.warnings
2561
+ }
2562
+ };
2563
+ }
2564
+ const server = new McpServer(
2565
+ { name: "fling", version: VERSION },
2566
+ { instructions: MCP_LOCAL_INSTRUCTIONS }
2567
+ );
2568
+ const { listHtml: listHtml2, spa: spa2, public: isPublic2 } = mcpTools.publish.inputSchema;
2569
+ server.registerTool(
2570
+ "fling_deploy",
2571
+ {
2572
+ title: "Deploy a file or folder from this computer",
2573
+ description: 'Fling something that is on the user\'s computer and return its stable URL (https://<name>.fling.labs.nearform.com): a folder of static files (e.g. a build output like dist/), a single .html file (served as the site\'s index.html), a .zip of a site (extracted; a single top-level folder is unwrapped), or any other single file (.pdf, .svg, .png\u2026, served next to a generated page that shows it). `path` can be absolute, relative to the working directory, or just a file name ("Pitch Deck.html"): names are looked up, newest first, in Downloads, Desktop, Documents/Claude and Claude Desktop output folders, and the result says where the file was found. Never pass paths from your own sandbox (/mnt/user-data/\u2026, /sessions/\u2026): for content you created, use fling_publish. Without `name`, a name is generated: from the file name for files ("Pitch Deck (1).html" \u2192 pitch-deck), from the project and folder for folders (~/work/rocketpod/docs \u2192 rocketpod-docs). Site names are one global namespace, first come first served \u2014 confirm the name with the user first (fling_check_name with `path` shows the generated one). If the name is taken it fails with free suggestions \u2014 ask the user to pick one, then retry with `name`. If there is no index.html but there are HTML pages, it does not deploy: ask the user whether to generate an index page listing them (`listHtml: true`) or deploy without one (`listHtml: false`), then retry. Re-deploying identical content is a no-op. Sites are private to signed-in Nearformers unless `public` is set.',
2574
+ inputSchema: {
2575
+ path: z7.string().min(1).describe(
2576
+ "File or folder on this computer: absolute, relative to the working directory, or just its name (looked up in Downloads, Desktop and Claude output folders)"
2577
+ ),
2578
+ name: z7.string().optional().describe(
2579
+ "Site name (lowercase letters, digits, single hyphens; max 50). Defaults to a name generated from the file or folder"
2580
+ ),
2581
+ spa: spa2,
2582
+ public: isPublic2,
2583
+ listHtml: listHtml2
2584
+ },
2585
+ annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true }
2586
+ },
2587
+ (args) => run(async (c) => {
2588
+ const located = await resolveInput(args.path, locate);
2589
+ const prepared = await prepareInput(located.path);
2590
+ try {
2591
+ const name = args.name !== void 0 ? resolveSiteName(prepared.dir, args.name) : prepared.defaultName;
2592
+ const outcome = await deployFolder(c, prepared.dir, {
2593
+ name,
2594
+ nameGiven: args.name !== void 0,
2595
+ nameFrom: prepared.kind === "folder" ? "the folder" : "the file name",
2596
+ spa: args.spa,
2597
+ public: args.public,
2598
+ listHtml: args.listHtml,
2599
+ retryWith: "fling_deploy"
2600
+ });
2601
+ const where = located.foundIn === FOUND_AS_GIVEN ? "" : `Found "${basename3(located.path)}" in ${located.foundIn}: ${located.path}
2602
+ `;
2603
+ return {
2604
+ summary: `${where}${outcome.summary}`,
2605
+ data: {
2606
+ ...outcome.data,
2607
+ source: {
2608
+ path: located.path,
2609
+ foundIn: located.foundIn,
2610
+ kind: prepared.kind,
2611
+ ...located.alternatives.length ? { alternatives: located.alternatives } : {}
2612
+ }
2613
+ }
2614
+ };
2615
+ } finally {
2616
+ await prepared.cleanup();
2617
+ }
2618
+ })
2619
+ );
2620
+ server.registerTool(
2621
+ "fling_find_files",
2622
+ {
2623
+ title: "Find files to fling",
2624
+ description: "List recent things on this computer that could be flung, newest first: .html/.htm and .zip files, and folders containing an index.html, from the working directory, Downloads, Desktop, Documents/Claude, ~/Claude and Claude Desktop output folders. Each has its path, where it was found, size and modified time. Use it when the user refers to a file they downloaded or that Claude Desktop saved, then pass the chosen path (or name) to fling_deploy.",
2625
+ inputSchema: {
2626
+ query: z7.string().optional().describe('Only names containing this text (case-insensitive), e.g. "deck"'),
2627
+ limit: z7.number().int().min(1).max(100).optional().describe("Max results (default 20)"),
2628
+ days: z7.number().int().min(1).max(3650).optional().describe("Only things modified in the last N days (default 14)")
2629
+ },
2630
+ annotations: { readOnlyHint: true, openWorldHint: false }
2631
+ },
2632
+ async (args) => {
2633
+ try {
2634
+ const { files, searched, complete } = await findFiles(locate, args);
2635
+ const lines = files.map(
2636
+ (f) => `- ${f.path} (${f.kind}, ${f.foundIn}, ${formatBytes(f.size)}, modified ${f.modified})`
2637
+ );
2638
+ const summary = files.length ? `${files.length} recent item(s), newest first:
2639
+ ${lines.join("\n")}` : `Nothing found from the last ${args.days ?? 14} days${args.query ? ` matching "${args.query}"` : ""}.`;
2640
+ const data = {
2641
+ files,
2642
+ searched: searched.map((r) => ({ label: r.label, path: r.path })),
2643
+ ...complete ? {} : { truncated: true }
2644
+ };
2645
+ return {
2646
+ content: [{ type: "text", text: `${summary}
2647
+
2648
+ ${JSON.stringify(data, null, 2)}` }]
2649
+ };
2650
+ } catch (error) {
2651
+ return { isError: true, content: [{ type: "text", text: describeError(error) }] };
2652
+ }
2653
+ }
2654
+ );
2655
+ const publishDef = mcpTools.publish;
2656
+ server.registerTool(
2657
+ publishDef.name,
2658
+ {
2659
+ ...toolDef(publishDef),
2660
+ // This server has no chunked upload tools; big sites go through fling_deploy instead.
2661
+ description: publishDef.description.replace(
2662
+ /\(whole site, up to [^;]*; bigger sites: fling_upload_begin \/ fling_upload_file \/ fling_upload_finish\)/,
2663
+ "(whole site; for a big site that is already on this computer, use fling_deploy with its folder)"
2664
+ )
2665
+ },
2666
+ (args) => run(async (c) => {
2667
+ const name = resolveSiteName(".", args.name);
2668
+ const tmp = await mkdtemp2(join4(tmpdir2(), "fling-publish-"));
2669
+ try {
2670
+ await writeInlineFiles(tmp, args.files);
2671
+ const outcome = await deployFolder(c, tmp, {
2672
+ name,
2673
+ nameGiven: true,
2674
+ spa: args.spa,
2675
+ public: args.public,
2676
+ listHtml: args.listHtml,
2677
+ retryWith: "fling_publish"
2678
+ });
2679
+ return outcome;
2680
+ } finally {
2681
+ await rm2(tmp, { recursive: true, force: true });
2682
+ }
2683
+ })
2684
+ );
2685
+ server.registerTool(
2686
+ "fling_check_name",
2687
+ {
2688
+ ...toolDef(mcpTools.checkName),
2689
+ description: `${mcpTools.checkName.description} Pass \`path\` instead of \`name\` to see the name fling_deploy would generate for a file or folder on this computer (same lookup as fling_deploy), so you can confirm it with the user.`,
2690
+ inputSchema: {
2691
+ name: z7.string().min(1).max(200).optional().describe("Site name to check"),
2692
+ path: z7.string().optional().describe("File or folder whose generated default name to check (path or file name)")
2693
+ }
2694
+ },
2695
+ (args) => run(async (c) => {
2696
+ if (args.name === void 0 && args.path === void 0)
2697
+ throw new CliError("Pass `name` or `path`");
2698
+ let name = args.name;
2699
+ let source;
2700
+ if (name === void 0) {
2701
+ source = await resolveInput(args.path, locate);
2702
+ name = defaultNameFor(source.path, source.isDirectory);
2703
+ }
2704
+ const check = await checkName(c, name);
2705
+ const base = check.status === "invalid" ? null : await discoverSitesBase(c);
2706
+ const url = base ? siteUrlFor(name, base) : null;
2707
+ const suggestions = check.suggestions.length ? ` Suggestions: ${check.suggestions.join(", ")}.` : "";
2708
+ const summary = {
2709
+ available: `"${name}" is available${url ? ` \u2192 ${url}` : ""}.`,
2710
+ yours: `"${name}" is the user's existing site${url ? ` (${url})` : ""} \u2014 deploying updates it.`,
2711
+ taken: `"${name}" is taken by someone else.${suggestions}`,
2712
+ invalid: `"${name}" is not a valid site name.${suggestions}`
2713
+ }[check.status];
2714
+ return {
2715
+ summary,
2716
+ data: {
2717
+ ...check,
2718
+ url,
2719
+ ...source ? { generated: true, source: { path: source.path, foundIn: source.foundIn } } : {}
2720
+ }
2721
+ };
2722
+ })
2723
+ );
2724
+ let pendingLogin;
2725
+ server.registerTool(
2726
+ "fling_login",
2727
+ {
2728
+ title: "Sign in to Fling",
2729
+ description: "Sign the user in to Fling: opens their browser at the Fling sign-in page (Nearform Google account) and stores the key on this computer (shared with the fling CLI). Waits up to ~30 seconds; if sign-in is not finished by then, it keeps waiting in the background for up to 5 minutes \u2014 tell the user to finish signing in in the browser, then retry what you were doing. Call it when another tool says the user is not logged in.",
2730
+ inputSchema: {},
2731
+ annotations: { readOnlyHint: false, openWorldHint: true }
2732
+ },
2733
+ async () => {
2734
+ try {
2735
+ const config = await store.resolve({ serverUrl: deps.server });
2736
+ if (config.apiKey && !pendingLogin) {
2737
+ const me = await whoami(
2738
+ createApiClient({
2739
+ serverUrl: config.serverUrl,
2740
+ apiKey: config.apiKey,
2741
+ fetch: fetchImpl
2742
+ })
2743
+ ).catch(() => null);
2744
+ if (me) return textResult(`Already signed in to ${config.serverUrl} as ${me.email}.`);
2745
+ }
2746
+ if (!pendingLogin) {
2747
+ const login = {
2748
+ done: Promise.resolve()
2749
+ };
2750
+ login.done = loginFlow({
2751
+ serverUrl: config.serverUrl,
2752
+ store,
2753
+ log: silentLogger,
2754
+ open: deps.open ?? (async (url) => (await import("open")).default(url)),
2755
+ fetch: fetchImpl,
2756
+ timeoutMs: deps.loginTimeoutMs,
2757
+ onAuthUrl: (url) => {
2758
+ login.url = url;
2759
+ }
2760
+ });
2761
+ login.done.catch(() => {
2762
+ }).finally(() => {
2763
+ if (pendingLogin === login) pendingLogin = void 0;
2764
+ });
2765
+ pendingLogin = login;
2766
+ }
2767
+ const current = pendingLogin;
2768
+ const waited = await Promise.race([
2769
+ current.done.then(
2770
+ (r) => ({ ok: true, r }),
2771
+ (e) => ({ ok: false, e })
2772
+ ),
2773
+ new Promise((r) => setTimeout(() => r(null), deps.loginWaitMs ?? 3e4).unref?.())
2774
+ ]);
2775
+ if (waited === null)
2776
+ return textResult(
2777
+ `Finish signing in in your browser, then try again. (The sign-in page is open; if it did not open, visit: ${current.url ?? "the Fling site"} \u2014 this stays valid for 5 minutes.)`
2778
+ );
2779
+ if (!waited.ok) throw waited.e;
2780
+ return textResult(`Signed in as ${waited.r.user.email} (${waited.r.serverUrl}).`);
2781
+ } catch (error) {
2782
+ return { isError: true, content: [{ type: "text", text: describeError(error) }] };
2783
+ }
2784
+ }
2785
+ );
2786
+ const t = mcpTools;
2787
+ server.registerTool(
2788
+ t.list.name,
2789
+ toolDef(t.list),
2790
+ (args) => run(async (c) => {
2791
+ const sites = await listSites(c, { archived: args.archived });
2792
+ return { summary: `${sites.length} site(s)`, data: { sites } };
2793
+ })
2794
+ );
2795
+ server.registerTool(
2796
+ t.info.name,
2797
+ toolDef(t.info),
2798
+ (args) => run(async (c) => {
2799
+ const site = await getSite(c, args.name);
2800
+ return { summary: `${site.name}: ${site.url}`, data: site };
2801
+ })
2802
+ );
2803
+ server.registerTool(
2804
+ t.files.name,
2805
+ toolDef(t.files),
2806
+ (args) => run(async (c) => {
2807
+ const result = await versionFiles(c, args.name, args.versionId);
2808
+ return {
2809
+ summary: describeVersionFiles(result.name, result, {
2810
+ current: result.current,
2811
+ url: result.url
2812
+ }),
2813
+ data: result
2814
+ };
2815
+ })
2816
+ );
2817
+ server.registerTool(
2818
+ t.share.name,
2819
+ toolDef(t.share),
2820
+ (args) => run(async (c) => {
2821
+ if (args.refresh && args.revoke) throw new CliError("Use either refresh or revoke, not both");
2822
+ const result = await share(c, args.name, args);
2823
+ if (result.action === "revoked")
2824
+ return { summary: `Share link for ${args.name} revoked`, data: result };
2825
+ let edge = { ready: true, note: "" };
2826
+ if (result.action !== "existing") {
2827
+ const files = await versionFiles(c, args.name).catch(() => null);
2828
+ edge = await edgeReady(result.shareUrl, files?.files.map((f) => f.path) ?? []);
2829
+ }
2830
+ return {
2831
+ summary: `Share link (${result.action}): ${result.shareUrl} \u2014 anyone with it can view the site${edge.note}`,
2832
+ data: { ...result, ready: edge.ready }
2833
+ };
2834
+ })
2835
+ );
2836
+ server.registerTool(
2837
+ t.setVisibility.name,
2838
+ toolDef(t.setVisibility),
2839
+ (args) => run(async (c) => {
2840
+ const site = await setVisibility(c, args.name, args.visibility);
2841
+ return { summary: `${site.name} is now ${site.visibility}: ${site.url}`, data: site };
2842
+ })
2843
+ );
2844
+ server.registerTool(
2845
+ t.archive.name,
2846
+ toolDef(t.archive),
2847
+ (args) => run(async (c) => {
2848
+ const site = await archiveSite(c, args.name, { unarchive: args.unarchive });
2849
+ return { summary: `${site.name} ${args.unarchive ? "restored" : "archived"}`, data: site };
2850
+ })
2851
+ );
2852
+ server.registerTool(
2853
+ t.delete.name,
2854
+ toolDef(t.delete),
2855
+ (args) => run(async (c) => {
2856
+ const result = await deleteSite(c, args.name);
2857
+ return { summary: `${args.name} deleted`, data: result };
2858
+ })
2859
+ );
2860
+ server.registerTool(
2861
+ t.rollback.name,
2862
+ toolDef(t.rollback),
2863
+ (args) => run(async (c) => {
2864
+ const site = await rollback(c, args.name, args.versionId);
2865
+ return {
2866
+ summary: `${site.name} now serves version ${site.currentVersionId}: ${site.url}`,
2867
+ data: site
2868
+ };
2869
+ })
2870
+ );
2871
+ server.registerTool(
2872
+ t.whoami.name,
2873
+ toolDef(t.whoami),
2874
+ () => run(async (c) => {
2875
+ const me = await whoami(c);
2876
+ const base = await discoverSitesBase(c);
2877
+ return {
2878
+ summary: `Signed in as ${me.email} (sites are served at ${sitesPattern(base)})`,
2879
+ data: me
2880
+ };
2881
+ })
2882
+ );
2883
+ return server;
2884
+ }
2885
+ var textResult = (text) => ({ content: [{ type: "text", text }] });
2886
+ async function writeInlineFiles(dir, files) {
2887
+ const seen = /* @__PURE__ */ new Set();
2888
+ let total = 0;
2889
+ for (const f of files) {
2890
+ const path = f.path.replace(/^(\.\/)+/, "").replace(/^\/+/, "");
2891
+ if (!isValidSitePath(path)) throw new CliError(`Invalid file path: "${f.path}"`);
2892
+ if (seen.has(path)) throw new CliError(`"${path}" is listed twice`);
2893
+ seen.add(path);
2894
+ const bytes = Buffer.from(f.content, f.encoding === "base64" ? "base64" : "utf8");
2895
+ total += bytes.length;
2896
+ if (total > MAX_DEPLOY_BYTES)
2897
+ throw new CliError(`The site is larger than ${formatBytes(MAX_DEPLOY_BYTES)}`);
2898
+ const target = join4(dir, ...path.split("/"));
2899
+ await mkdir3(dirname2(target), { recursive: true });
2900
+ await writeFile3(target, bytes);
2901
+ }
2902
+ }
2903
+ function describeError(error) {
2904
+ if (error instanceof CliError && error.exitCode === EXIT_NOT_LOGGED_IN) return LOGIN_HINT;
2905
+ if (error instanceof CliApiError && error.status === 401) return LOGIN_HINT;
2906
+ if (error instanceof CliError)
2907
+ return error.hint ? `${error.message}
2908
+ ${error.hint}` : error.message;
2909
+ return error instanceof Error ? error.message : String(error);
2910
+ }
2911
+ async function runMcpServer(deps = {}) {
2912
+ const server = createMcpServer(deps);
2913
+ await server.connect(new StdioServerTransport());
2914
+ }
2915
+
2916
+ // src/cli.ts
2917
+ var program = new Command();
2918
+ program.name(BIN_NAME).description("Fling a folder of static files at Fling and get a link back").version(VERSION).option(
2919
+ "--server <url>",
2920
+ `server URL (default: $${ENV.url} or config, else ${DEFAULT_SERVER_URL})`
2921
+ ).option("--json", "print JSON instead of human output", false).showHelpAfterError().addHelpText(
2922
+ "after",
2923
+ `
2924
+ Examples:
2925
+ $ ${BIN_NAME} login
2926
+ $ ${BIN_NAME} ./dist --name my-app deploy \u2192 https://my-app.fling.labs.nearform.com
2927
+ $ ${BIN_NAME} ./dist --yes no prompts (CI): use the generated name, fail if it is taken
2928
+ $ ${BIN_NAME} "Pitch Deck.html" a single page; found in Downloads if it is not here
2929
+ $ ${BIN_NAME} site.zip a zip of a site
2930
+ $ ${BIN_NAME} share my-app public link for people outside Nearform
2931
+
2932
+ Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
2933
+ Env: ${ENV.apiKey} \xB7 ${ENV.url} \xB7 ${ENV.configDir} \xB7 ${ENV.debug}`
2934
+ );
2935
+ function action(handler) {
2936
+ return async (...args) => {
2937
+ const command = args.at(-1);
2938
+ const globals = command.optsWithGlobals();
2939
+ let ctx;
2940
+ try {
2941
+ ctx = await createContext({ server: globals.server, json: globals.json });
2942
+ await handler(ctx, ...args.slice(0, -1));
2943
+ } catch (error) {
2944
+ report(error, ctx);
2945
+ process.exitCode = exitCodeFor(error);
2946
+ }
2947
+ };
2948
+ }
2949
+ function report(error, ctx) {
2950
+ const log = ctx?.log ?? createLogger();
2951
+ const message = error instanceof Error ? error.message : String(error);
2952
+ log.error(message);
2953
+ if (error instanceof CliError && error.hint) log.hint(error.hint);
2954
+ if (ctx?.config.debug && error instanceof Error && error.stack) log.hint(error.stack);
2955
+ if (ctx?.json) {
2956
+ const code = error.code;
2957
+ process.stdout.write(
2958
+ `${formatJson({ error: { message, exitCode: exitCodeFor(error), ...typeof code === "string" ? { code } : {} } })}
2959
+ `
2960
+ );
2961
+ }
2962
+ }
2963
+ program.command("deploy [path]", { isDefault: true }).description(
2964
+ "deploy a folder (default: .), a single .html file, a .zip or any file \u2014 the default command, so `fling ./dist` works. A path that does not exist is looked up by name in Downloads, Desktop and Claude output folders"
2965
+ ).option(
2966
+ "-n, --name <name>",
2967
+ "site name \u2014 names are global, first come first served (default: generated from the project and folder, e.g. rocketpod-docs; you confirm it on a terminal)"
2968
+ ).option("--spa", "single-page app: serve index.html for unknown paths").option("--public", "make the site public (default: Nearform sign-in required)").option(
2969
+ "-y, --yes",
2970
+ "never prompt: accept the generated name; without index.html, list the HTML pages (or fling anyway if there are none)"
2971
+ ).option("--list-html", "no index.html at the root: generate one listing the HTML pages").option("--no-list-html", "no index.html at the root: fling anyway (the root will 404)").action(
2972
+ action(
2973
+ (ctx, path, opts) => runDeploy(ctx, path, opts)
2974
+ )
2975
+ );
2976
+ program.command("login").description("sign in through the browser and store an API key").action(action((ctx) => runLogin(ctx)));
2977
+ program.command("logout").description("remove the stored API key").action(action((ctx) => runLogout(ctx)));
2978
+ program.command("whoami").description("show the signed-in user").action(action((ctx) => runWhoami(ctx)));
2979
+ program.command("ls").alias("list").description("list your sites").option("--archived", "list archived sites").action(action((ctx, opts) => runList(ctx, opts)));
2980
+ program.command("info <name>").description("show a site").action(action((ctx, name) => runInfo(ctx, name)));
2981
+ program.command("versions <name>").description("list a site's versions (* = live)").action(action((ctx, name) => runVersions(ctx, name)));
2982
+ program.command("files <name> [versionId]").description("list the files in a site's live version (or in versionId)").action(
2983
+ action((ctx, name, versionId) => runFiles(ctx, name, versionId))
2984
+ );
2985
+ program.command("rollback <name> <versionId>").description("serve an earlier version again").action(action((ctx, name, versionId) => runRollback(ctx, name, versionId)));
2986
+ program.command("share <name>").description("print (or create) a public share link \u2014 anyone with it can view the site").option("--refresh", "replace the link; the old one stops working").option("--revoke", "remove the link").action(
2987
+ action(
2988
+ (ctx, name, opts) => runShare(ctx, name, opts)
2989
+ )
2990
+ );
2991
+ program.command("visibility <name> <visibility>").description("private (Nearform sign-in) or public (anyone)").action(action((ctx, name, visibility) => runVisibility(ctx, name, visibility)));
2992
+ program.command("archive <name>").description("take a site offline (404) but keep its files; undo with unarchive").action(action((ctx, name) => runArchive(ctx, name)));
2993
+ program.command("unarchive <name>").description("bring an archived site back").action(action((ctx, name) => runArchive(ctx, name, true)));
2994
+ program.command("rm <name>").alias("delete").description("delete a site and all its versions (asks first unless --yes)").option("-y, --yes", "do not ask for confirmation").action(action((ctx, name, opts) => runRemove(ctx, name, opts)));
2995
+ program.command("mcp").description("run the MCP server on stdio (used by the Claude Code plugin)").action(async (_opts, command) => {
2996
+ const globals = command.optsWithGlobals();
2997
+ await runMcpServer({ server: globals.server });
2998
+ });
2999
+ program.parseAsync(process.argv);
3000
+ //# sourceMappingURL=cli.js.map