@nearform/fling 0.1.2 → 0.3.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 CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
+ import chalk10 from "chalk";
4
5
  import { Command } from "commander";
5
6
 
6
7
  // src/commands/auth.ts
7
- import chalk4 from "chalk";
8
+ import chalk5 from "chalk";
8
9
 
9
10
  // src/auth.ts
10
11
  import { randomBytes } from "node:crypto";
@@ -24,7 +25,7 @@ var MAX_FILES = 5e3;
24
25
  var MAX_PATH_LENGTH = 1024;
25
26
 
26
27
  // ../../packages/shared/src/secrets.ts
27
- var SECRET_SCAN_MAX_FILE_BYTES = 5 * 1024 * 1024;
28
+ var SECRET_SCAN_MAX_FILE_BYTES = MAX_FILE_BYTES;
28
29
  var BINARY_SNIFF_BYTES = 8192;
29
30
  var SECRET_EXTENSIONS = [
30
31
  ".pem",
@@ -49,8 +50,8 @@ var SECRET_NAMES = /* @__PURE__ */ new Set([
49
50
  ".htpasswd"
50
51
  ]);
51
52
  var SSH_KEY_RE = /^id_(?:rsa|dsa|ecdsa|ed25519)(?:[._-]|$)/;
52
- function isSecretPath(path) {
53
- const name = (path.split(/[\\/]/).at(-1) ?? "").toLowerCase();
53
+ function isSecretPath(path3) {
54
+ const name = (path3.split(/[\\/]/).at(-1) ?? "").toLowerCase();
54
55
  if (!name) return false;
55
56
  if (SECRET_NAMES.has(name)) return true;
56
57
  if (SECRET_EXTENSIONS.some((ext) => name.endsWith(ext) && name.length > ext.length)) return true;
@@ -160,24 +161,24 @@ function lineAt(starts, index) {
160
161
  }
161
162
  return lo + 1;
162
163
  }
163
- function scanForSecrets(text, path) {
164
+ function scanForSecrets(text2, path3) {
164
165
  let starts;
165
166
  const findings = [];
166
167
  const seen = /* @__PURE__ */ new Set();
167
168
  for (const rule of RULES) {
168
169
  rule.re.lastIndex = 0;
169
- for (let m = rule.re.exec(text); m !== null; m = rule.re.exec(text)) {
170
+ for (let m = rule.re.exec(text2); m !== null; m = rule.re.exec(text2)) {
170
171
  if (m[0].length === 0) rule.re.lastIndex++;
171
172
  if (rule.allow?.(m)) continue;
172
173
  if (!starts) {
173
174
  starts = [0];
174
- for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1)) starts.push(i + 1);
175
+ for (let i = text2.indexOf("\n"); i !== -1; i = text2.indexOf("\n", i + 1)) starts.push(i + 1);
175
176
  }
176
177
  const line = lineAt(starts, m.index);
177
178
  const key = `${rule.id}:${line}`;
178
179
  if (seen.has(key)) continue;
179
180
  seen.add(key);
180
- findings.push({ path, line, rule: rule.id });
181
+ findings.push({ path: path3, line, rule: rule.id });
181
182
  }
182
183
  }
183
184
  return findings.sort((a, b) => a.line - b.line || a.rule.localeCompare(b.rule));
@@ -185,8 +186,8 @@ function scanForSecrets(text, path) {
185
186
  var BINARY_EXTENSIONS = new Set(
186
187
  "png jpg jpeg gif webp avif ico bmp tif tiff heic psd woff woff2 ttf otf eot mp3 mp4 m4a m4v mov webm ogg oga ogv wav flac aac avi mkv zip gz tgz br zst bz2 xz 7z rar wasm pdf glb gltf-binary bin dat".split(/\s+/).filter(Boolean)
187
188
  );
188
- function isScannablePath(path) {
189
- const name = path.split("/").at(-1) ?? "";
189
+ function isScannablePath(path3) {
190
+ const name = path3.split("/").at(-1) ?? "";
190
191
  const dot = name.lastIndexOf(".");
191
192
  return dot <= 0 || !BINARY_EXTENSIONS.has(name.slice(dot + 1).toLowerCase());
192
193
  }
@@ -195,10 +196,10 @@ function looksBinary(bytes) {
195
196
  for (let i = 0; i < end; i++) if (bytes[i] === 0) return true;
196
197
  return false;
197
198
  }
198
- function scanBytesForSecrets(bytes, path) {
199
- if (!isScannablePath(path) || bytes.length > SECRET_SCAN_MAX_FILE_BYTES || looksBinary(bytes))
199
+ function scanBytesForSecrets(bytes, path3) {
200
+ if (!isScannablePath(path3) || bytes.length > SECRET_SCAN_MAX_FILE_BYTES || looksBinary(bytes))
200
201
  return [];
201
- return scanForSecrets(new TextDecoder("utf-8").decode(bytes), path);
202
+ return scanForSecrets(new TextDecoder("utf-8").decode(bytes), path3);
202
203
  }
203
204
  function describeSecretFindings(findings, max = 20) {
204
205
  const lines = findings.slice(0, max).map((f) => `${f.path} line ${f.line}: ${SECRET_RULE_LABELS[f.rule] ?? f.rule}`);
@@ -208,10 +209,10 @@ function describeSecretFindings(findings, max = 20) {
208
209
  var NEVER_PUBLISHED_NOTE = "Fling never publishes dotfiles, gitignored files, secret files (keys, certificates, credentials) or files that contain secrets.";
209
210
 
210
211
  // ../../packages/shared/src/manifest.ts
211
- function isValidSitePath(path) {
212
- if (!path || path.length > MAX_PATH_LENGTH) return false;
213
- if (path.startsWith("/") || path.includes("\\") || path.includes("\0")) return false;
214
- return path.split("/").every((seg) => seg !== "" && !seg.startsWith("."));
212
+ function isValidSitePath(path3) {
213
+ if (!path3 || path3.length > MAX_PATH_LENGTH) return false;
214
+ if (path3.startsWith("/") || path3.includes("\\") || path3.includes("\0")) return false;
215
+ return path3.split("/").every((seg) => seg !== "" && !seg.startsWith("."));
215
216
  }
216
217
  var ManifestFileSchema = z.object({
217
218
  path: z.string().refine(isValidSitePath, "invalid path (dotfiles are never published)").refine((p) => !isSecretPath(p), "secret file (keys, certificates, credentials) never published"),
@@ -227,7 +228,13 @@ var MeSchema = z2.object({
227
228
  email: z2.string(),
228
229
  name: z2.string(),
229
230
  picture: z2.string().nullable(),
230
- userSlug: z2.string()
231
+ userSlug: z2.string(),
232
+ /** one of the instance's admins (servers from before admins don't say: false) */
233
+ admin: z2.boolean().default(false),
234
+ /** while an admin impersonates this user: the admin (browser sessions only) */
235
+ impersonator: z2.string().optional(),
236
+ /** …and when the impersonation lapses */
237
+ impersonationEndsAt: z2.string().optional()
231
238
  });
232
239
  var SiteSchema = z2.object({
233
240
  name: z2.string(),
@@ -329,8 +336,122 @@ var OAuthRequestInfoSchema = z2.object({
329
336
  email: z2.string()
330
337
  });
331
338
  var OAuthDecisionSchema = z2.object({ redirectUrl: z2.string() });
339
+ var AdminUserSchema = z2.object({
340
+ email: z2.string(),
341
+ name: z2.string(),
342
+ picture: z2.string().nullable(),
343
+ userSlug: z2.string(),
344
+ createdAt: z2.string().nullable(),
345
+ lastSignInAt: z2.string().nullable(),
346
+ /** sites the user holds, archived ones included */
347
+ siteCount: z2.number(),
348
+ admin: z2.boolean()
349
+ });
350
+ var AdminUserListSchema = z2.object({
351
+ users: z2.array(AdminUserSchema),
352
+ /** pass back as `cursor` for the next page; null: no more */
353
+ cursor: z2.string().nullable()
354
+ });
355
+ var AuditEntrySchema = z2.object({
356
+ id: z2.string(),
357
+ at: z2.string(),
358
+ action: z2.enum(["impersonation.start", "impersonation.stop"]),
359
+ actor: z2.string(),
360
+ target: z2.string(),
361
+ until: z2.string().optional(),
362
+ lapsed: z2.boolean().optional()
363
+ });
364
+ var AuditListSchema = z2.object({ entries: z2.array(AuditEntrySchema) });
365
+ var ImpersonateRequestSchema = z2.object({ email: z2.string().trim().toLowerCase().min(3) });
332
366
 
333
- // ../../packages/shared/src/hosts.ts
367
+ // ../../packages/shared/src/comments.ts
368
+ import { z as z3 } from "zod";
369
+ var COMMENT_MAX_CHARS = 4e3;
370
+ var COMMENT_PATH_MAX = 1024;
371
+ var CommentAuthorSchema = z3.discriminatedUnion("kind", [
372
+ z3.object({ kind: z3.literal("user"), email: z3.string() }),
373
+ z3.object({ kind: z3.literal("guest"), name: z3.string() })
374
+ ]);
375
+ var ANCHOR_SELECTOR_MAX = 512;
376
+ var px = z3.number().finite().min(-1e7).max(1e7);
377
+ var percent = z3.number().min(0).max(100);
378
+ var CommentAnchorSchema = z3.object({
379
+ x: percent,
380
+ y: percent,
381
+ selector: z3.string().min(1).max(ANCHOR_SELECTOR_MAX).optional(),
382
+ ex: percent.optional(),
383
+ ey: percent.optional(),
384
+ docX: px.optional(),
385
+ docY: px.optional(),
386
+ scrollX: px.optional(),
387
+ scrollY: px.optional(),
388
+ vw: z3.number().finite().min(0).max(1e5).optional(),
389
+ vh: z3.number().finite().min(0).max(1e5).optional(),
390
+ hash: z3.string().max(COMMENT_PATH_MAX).optional()
391
+ });
392
+ var CommentSchema = z3.object({
393
+ id: z3.string(),
394
+ /** the thread this replies to; null for the comment that starts a thread */
395
+ parentId: z3.string().nullable(),
396
+ /** the page, as normalizeCommentPath() writes it ('/', '/docs/', '/about.html') */
397
+ path: z3.string(),
398
+ /** the version the site was serving when the comment was made (null: none was) */
399
+ versionId: z3.string().nullable(),
400
+ anchor: CommentAnchorSchema.nullable(),
401
+ body: z3.string(),
402
+ author: CommentAuthorSchema,
403
+ createdAt: z3.string(),
404
+ /** threads only: when and by whom it was resolved (null: open) */
405
+ resolvedAt: z3.string().nullable(),
406
+ resolvedBy: CommentAuthorSchema.nullable(),
407
+ /** whether the caller may delete it (policy's answer, so clients don't guess) */
408
+ canDelete: z3.boolean()
409
+ });
410
+ var CommentListSchema = z3.object({
411
+ site: z3.object({
412
+ name: z3.string(),
413
+ url: z3.string(),
414
+ currentVersionId: z3.string().nullable()
415
+ }),
416
+ comments: z3.array(CommentSchema),
417
+ can: z3.object({
418
+ create: z3.boolean(),
419
+ resolve: z3.boolean(),
420
+ /** delete anyone's comment (everyone may delete their own) */
421
+ deleteAny: z3.boolean()
422
+ })
423
+ });
424
+ var CreateCommentRequestSchema = z3.object({
425
+ body: z3.string().trim().min(1).max(COMMENT_MAX_CHARS),
426
+ path: z3.string().max(COMMENT_PATH_MAX).optional(),
427
+ parentId: z3.string().min(1).max(100).optional(),
428
+ anchor: CommentAnchorSchema.optional()
429
+ });
430
+ var UpdateCommentRequestSchema = z3.object({ resolved: z3.boolean() });
431
+ function commentThreads(comments) {
432
+ const threads = /* @__PURE__ */ new Map();
433
+ for (const c of comments) {
434
+ if (c.parentId === null) threads.set(c.id, { comment: c, replies: [] });
435
+ }
436
+ for (const c of comments) {
437
+ if (c.parentId !== null) threads.get(c.parentId)?.replies.push(c);
438
+ }
439
+ return [...threads.values()];
440
+ }
441
+ function authorLabel(author) {
442
+ return author.kind === "user" ? author.email : `${author.name} (guest)`;
443
+ }
444
+ function reviewUrl(appOrigin, name, path3) {
445
+ const base = `${appOrigin.replace(/\/+$/, "")}/review/${name === "<name>" ? name : encodeURIComponent(name)}`;
446
+ return path3 && path3 !== "/" ? `${base}?path=${encodeURIComponent(path3)}` : base;
447
+ }
448
+
449
+ // ../../packages/shared/src/distribution.ts
450
+ var CLI_PACKAGE = "@nearform/fling";
451
+ var REPO_SLUG = "nearform/fling";
452
+ var DEFAULT_INSTALL_COMMAND = `npm install -g ${CLI_PACKAGE}`;
453
+
454
+ // ../../packages/shared/src/site-labels.ts
334
455
  var SITE_NAME_MAX = 50;
335
456
  var SHARE_TOKEN_LENGTH = 20;
336
457
  var RESERVED_SITE_NAMES = [
@@ -351,26 +472,92 @@ var RESERVED_SITE_NAMES = [
351
472
  ];
352
473
  var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
353
474
  var SHARE_TOKEN_RE = new RegExp(`^[a-z0-9]{${SHARE_TOKEN_LENGTH}}$`);
354
- function slugify(input, max = SITE_NAME_MAX) {
355
- return input.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/g, "");
356
- }
357
475
  function isValidSiteName(name) {
358
476
  return name.length <= SITE_NAME_MAX && SLUG_RE.test(name) && !name.includes("--") && !RESERVED_SITE_NAMES.includes(name);
359
477
  }
478
+
479
+ // ../../packages/shared/src/hosts.ts
480
+ function slugify(input, max = SITE_NAME_MAX) {
481
+ return input.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/g, "");
482
+ }
360
483
  function siteUrl(label, sitesDomain, protocol = "https:") {
361
484
  return `${protocol}//${label}.${sitesDomain}/`;
362
485
  }
363
486
 
487
+ // ../../packages/shared/src/instance.ts
488
+ import { z as z4 } from "zod";
489
+ var INSTANCE_METADATA_PATH = "/.well-known/fling";
490
+ var HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
491
+ var color = z4.string().regex(HEX_COLOR_RE);
492
+ var InstanceAuthSchema = z4.looseObject({
493
+ kind: z4.string().min(1),
494
+ displayName: z4.string().min(1),
495
+ issuer: z4.string().optional(),
496
+ emailDomains: z4.array(z4.string()).default([]),
497
+ invitedEmails: z4.boolean().optional()
498
+ });
499
+ var BrandColorsSchema = z4.looseObject({
500
+ primary: color.optional(),
501
+ primaryContent: color.optional(),
502
+ accent: color.optional(),
503
+ ink: color.optional(),
504
+ highlight: color.optional()
505
+ });
506
+ var InstanceBrandSchema = z4.looseObject({
507
+ /** https: or data: URL of the logo mark shown before the name (light theme). */
508
+ logoUrl: z4.string().optional(),
509
+ /** The same for the dark theme; defaults to `logoUrl`. */
510
+ logoDarkUrl: z4.string().optional(),
511
+ faviconUrl: z4.string().optional(),
512
+ colors: BrandColorsSchema.default({})
513
+ });
514
+ var InstanceMetadataSchema = z4.looseObject({
515
+ version: z4.number().int().min(1),
516
+ /** Display name, e.g. "Fling". */
517
+ name: z4.string().min(1),
518
+ /** e.g. `https://fling.example.test` (no trailing slash) */
519
+ appOrigin: z4.string().url(),
520
+ /** host[:port] sites hang off: a site is `<name>.<sitesDomain>` */
521
+ sitesDomain: z4.string().min(1),
522
+ siteScheme: z4.enum(["http", "https"]),
523
+ auth: InstanceAuthSchema,
524
+ brand: InstanceBrandSchema.default({ colors: {} }),
525
+ /** Feature flags: a client hides what is missing or false. */
526
+ capabilities: z4.record(z4.string(), z4.boolean()).default({}),
527
+ /** How to install the CLI, e.g. `npm install -g <package>`. */
528
+ installCommand: z4.string().min(1)
529
+ });
530
+ function instanceSitesPattern(instance) {
531
+ return `${instance.siteScheme}://<name>.${instance.sitesDomain}`;
532
+ }
533
+ function emailDomainsPhrase(auth) {
534
+ const domains = auth.emailDomains.map((d) => `@${d}`);
535
+ if (domains.length === 0) return null;
536
+ return domains.length === 1 ? domains[0] : `${domains.slice(0, -1).join(", ")} or ${domains.at(-1)}`;
537
+ }
538
+ function allowedAccountsPhrase(auth) {
539
+ const domains = emailDomainsPhrase(auth);
540
+ const parts = [domains && `${domains} accounts`, auth.invitedEmails && "invited accounts"];
541
+ return parts.filter(Boolean).join(" and ") || null;
542
+ }
543
+ function privateAudience(instance) {
544
+ const accounts = allowedAccountsPhrase(instance.auth);
545
+ return `anyone signed in to ${instance.name}${accounts ? ` (${accounts})` : ""}`;
546
+ }
547
+ function instanceMetadataUrl(serverUrl) {
548
+ return `${serverUrl.replace(/\/+$/, "")}${INSTANCE_METADATA_PATH}`;
549
+ }
550
+
364
551
  // ../../packages/shared/src/kvs.ts
365
- import { z as z3 } from "zod";
366
- var KvsSiteValueSchema = z3.object({
552
+ import { z as z5 } from "zod";
553
+ var KvsSiteValueSchema = z5.object({
367
554
  /** siteId — S3 prefix is `sites/<id>/<v>/` */
368
- id: z3.string(),
555
+ id: z5.string(),
369
556
  /** current versionId */
370
- v: z3.string(),
371
- vis: z3.enum(["private", "public"]),
557
+ v: z5.string(),
558
+ vis: z5.enum(["private", "public"]),
372
559
  /** serve /index.html for unknown extensionless paths */
373
- spa: z3.boolean()
560
+ spa: z5.boolean()
374
561
  });
375
562
 
376
563
  // ../../packages/shared/src/listing.ts
@@ -379,9 +566,9 @@ var escapeHtml = (s) => s.replace(
379
566
  /[&<>"']/g,
380
567
  (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]
381
568
  );
382
- var hrefFor = (path) => path.split("/").map(encodeURIComponent).join("/");
383
- function isHtmlPath(path) {
384
- return /\.html?$/i.test(path);
569
+ var hrefFor = (path3) => path3.split("/").map(encodeURIComponent).join("/");
570
+ function isHtmlPath(path3) {
571
+ return /\.html?$/i.test(path3);
385
572
  }
386
573
  function extractHtmlTitle(html) {
387
574
  const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html.slice(0, 65536));
@@ -454,181 +641,238 @@ ${body}
454
641
  }
455
642
 
456
643
  // ../../packages/shared/src/mcp-tools.ts
457
- import { z as z4 } from "zod";
644
+ import { z as z6 } from "zod";
458
645
  var MCP_PUBLISH_MAX_BYTES = 4 * 1024 * 1024;
459
646
  var MCP_UPLOAD_CHUNK_MAX_CHARS = 4 * 1024 * 1024;
460
647
  var MCP_UPLOAD_TTL_SECONDS = 60 * 60;
461
648
  var tool = (def) => def;
462
- 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.`;
649
+ var GENERIC_REVIEW = "the site's review page in the Fling web app (/review/<name>)";
650
+ function mcpWording(instance) {
651
+ if (!instance) {
652
+ return {
653
+ name: "Fling",
654
+ sitesPattern: "https://<name>.<sites domain>",
655
+ privateAudience: "people signed in to this Fling",
656
+ signIn: "the Fling sign-in page",
657
+ reviewPattern: GENERIC_REVIEW
658
+ };
659
+ }
660
+ return {
661
+ name: instance.name,
662
+ sitesPattern: instanceSitesPattern(instance),
663
+ privateAudience: privateAudience(instance),
664
+ signIn: `their ${instance.auth.displayName} account`,
665
+ reviewPattern: instance.appOrigin ? reviewUrl(instance.appOrigin, "<name>") : GENERIC_REVIEW
666
+ };
667
+ }
463
668
  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.';
464
- 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.";
465
669
  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.";
466
670
  var NEVER_PUBLISHED = "Fling never publishes dotfiles, gitignored files, secret files (keys, certs, credentials) or files that contain secrets (API keys, tokens, private keys, passwords in database URLs); it stops and tells you which file and line. Remove the secret (or leave the file out) and retry \u2014 there is no way to override this.";
467
671
  var ENCODING = '`encoding` is "utf8" (default, for text: HTML, CSS, JS, SVG, JSON, Markdown\u2026) or "base64" (binary: images, fonts, PDFs).';
468
672
  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.";
469
- var listHtml = z4.boolean().optional().describe(
673
+ var listHtml = z6.boolean().optional().describe(
470
674
  "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"
471
675
  );
472
- var siteName = z4.string().min(1).max(200).describe('Site name, e.g. "q3-report"');
473
- var sitePath = z4.string().min(1).max(1024).describe(
676
+ var siteName = z6.string().min(1).max(200).describe('Site name, e.g. "q3-report"');
677
+ var sitePath = z6.string().min(1).max(1024).describe(
474
678
  'Site-relative path with forward slashes, e.g. "index.html", "css/app.css", "img/logo.png". The root page must be "index.html". No dotfiles (".env", ".well-known/\u2026")'
475
679
  );
476
- var encoding = z4.enum(["utf8", "base64"]).optional().describe('How `content` is encoded: "utf8" (default) for text, "base64" for binary files');
477
- var spa = z4.boolean().optional().describe(
680
+ var commentPath = z6.string().max(1024);
681
+ var commentId = z6.string().min(1).max(100);
682
+ var encoding = z6.enum(["utf8", "base64"]).optional().describe('How `content` is encoded: "utf8" (default) for text, "base64" for binary files');
683
+ var spa = z6.boolean().optional().describe(
478
684
  "Single-page app: serve index.html for unknown paths (client-side routing). Omit to keep the current setting"
479
685
  );
480
- var isPublic = z4.boolean().optional().describe(
481
- "true \u2192 anyone can view; false \u2192 Nearformers only. Omit to keep the current setting (new sites are private)"
482
- );
483
- var mcpTools = {
484
- whoami: tool({
485
- name: "fling_whoami",
486
- title: "Who am I",
487
- description: "Show the signed-in Nearform user that Fling acts as.",
488
- inputSchema: {},
489
- annotations: { readOnlyHint: true, openWorldHint: false }
490
- }),
491
- list: tool({
492
- name: "fling_list",
493
- title: "List sites",
494
- description: "List the user's Fling sites with URL, visibility and share link.",
495
- inputSchema: {
496
- archived: z4.boolean().optional().describe("List archived sites instead of live ones")
497
- },
498
- annotations: { readOnlyHint: true, openWorldHint: false }
499
- }),
500
- info: tool({
501
- name: "fling_info",
502
- title: "Site details",
503
- description: "Show one site: URL, visibility, share link, current version and version history (ids for fling_rollback).",
504
- inputSchema: { name: siteName },
505
- annotations: { readOnlyHint: true, openWorldHint: false }
506
- }),
507
- files: tool({
508
- name: "fling_files",
509
- title: "List site files",
510
- 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).",
511
- inputSchema: {
512
- name: siteName,
513
- versionId: z4.string().min(1).max(100).optional().describe("Version id (from fling_info); omit for the version being served")
514
- },
515
- annotations: { readOnlyHint: true, openWorldHint: false }
516
- }),
517
- checkName: tool({
518
- name: "fling_check_name",
519
- title: "Check a site name",
520
- 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,
521
- inputSchema: { name: z4.string().min(1).max(200).describe("Site name to check") },
522
- annotations: { readOnlyHint: true, openWorldHint: false }
523
- }),
524
- publish: tool({
525
- name: "fling_publish",
526
- title: "Publish a site",
527
- 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} ${NEVER_PUBLISHED} The result includes \`warnings\`. Share the returned URL with the user; fling_files shows exactly which files the site has.`,
528
- inputSchema: {
529
- name: siteName,
530
- files: z4.array(
531
- z4.object({
532
- path: sitePath,
533
- content: z4.string().describe("The file content, encoded as `encoding` says"),
534
- encoding
535
- })
536
- ).min(1).max(MAX_FILES).describe("Every file of the site"),
537
- spa,
538
- public: isPublic,
539
- listHtml
540
- },
541
- annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true }
542
- }),
543
- uploadBegin: tool({
544
- name: "fling_upload_begin",
545
- title: "Start a chunked upload",
546
- 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}`,
547
- inputSchema: { name: siteName, spa, public: isPublic },
548
- annotations: { destructiveHint: false, openWorldHint: false }
549
- }),
550
- uploadFile: tool({
551
- name: "fling_upload_file",
552
- title: "Upload a file (chunked)",
553
- 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} ${NEVER_PUBLISHED}`,
554
- inputSchema: {
555
- uploadId: z4.string().min(1).max(100).describe("From fling_upload_begin"),
556
- path: sitePath,
557
- content: z4.string().max(MCP_UPLOAD_CHUNK_MAX_CHARS).describe("The file content (or the next part of it, with `append`)"),
558
- encoding,
559
- append: z4.boolean().optional().describe("Append this content to the file sent earlier with the same path")
560
- },
561
- annotations: { destructiveHint: false, openWorldHint: false }
562
- }),
563
- uploadFinish: tool({
564
- name: "fling_upload_finish",
565
- title: "Finish a chunked upload",
566
- 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\`.`,
567
- inputSchema: {
568
- uploadId: z4.string().min(1).max(100).describe("From fling_upload_begin"),
569
- listHtml
570
- },
571
- annotations: { destructiveHint: false, openWorldHint: true }
572
- }),
573
- share: tool({
574
- name: "fling_share",
575
- title: "Share link",
576
- 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.",
577
- inputSchema: {
578
- name: siteName,
579
- refresh: z4.boolean().optional().describe("Rotate the link"),
580
- revoke: z4.boolean().optional().describe("Remove the link")
581
- },
582
- annotations: { destructiveHint: false, openWorldHint: true }
583
- }),
584
- setVisibility: tool({
585
- name: "fling_set_visibility",
586
- title: "Set visibility",
587
- description: "Make a site private (signed-in Nearformers only) or public (anyone).",
588
- inputSchema: { name: siteName, visibility: z4.enum(["private", "public"]) },
589
- annotations: { idempotentHint: true, openWorldHint: true }
590
- }),
591
- archive: tool({
592
- name: "fling_archive",
593
- title: "Archive or restore a site",
594
- 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.",
595
- inputSchema: {
596
- name: siteName,
597
- unarchive: z4.boolean().optional().describe("Restore an archived site")
598
- },
599
- annotations: { idempotentHint: true, openWorldHint: false }
600
- }),
601
- delete: tool({
602
- name: "fling_delete",
603
- title: "Delete a site",
604
- 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.",
605
- inputSchema: {
606
- name: siteName,
607
- confirm: z4.literal(true).describe("Must be true: the user has confirmed the deletion")
608
- },
609
- annotations: { destructiveHint: true, openWorldHint: false }
610
- }),
611
- rollback: tool({
612
- name: "fling_rollback",
613
- title: "Roll back",
614
- description: "Point a site at an earlier version (ids from fling_info). Instant; nothing is uploaded.",
615
- inputSchema: {
616
- name: siteName,
617
- versionId: z4.string().min(1).max(100).describe("Version id to serve")
618
- },
619
- annotations: { idempotentHint: true, openWorldHint: false }
620
- })
621
- };
622
- var MCP_REMOTE_INSTRUCTIONS = [
623
- "Fling publishes static sites (HTML/CSS/JS, images, documents) for Nearform staff at https://<name>.fling.labs.nearform.com.",
624
- "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.",
625
- "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.",
626
- "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.",
627
- "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.",
628
- NEVER_PUBLISHED,
629
- `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.`,
630
- "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)."
631
- ].join("\n\n");
686
+ function mcpToolsFor(w) {
687
+ const 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 ${w.sitesPattern}.`;
688
+ const VISIBILITY = `Sites are private by default (only ${w.privateAudience} 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.`;
689
+ const isPublic = z6.boolean().optional().describe(
690
+ `true \u2192 anyone can view; false \u2192 only ${w.privateAudience}. Omit to keep the current setting (new sites are private)`
691
+ );
692
+ return {
693
+ whoami: tool({
694
+ name: "fling_whoami",
695
+ title: "Who am I",
696
+ description: `Show the signed-in user that ${w.name} acts as.`,
697
+ inputSchema: {},
698
+ annotations: { readOnlyHint: true, openWorldHint: false }
699
+ }),
700
+ list: tool({
701
+ name: "fling_list",
702
+ title: "List sites",
703
+ description: "List the user's Fling sites with URL, visibility and share link.",
704
+ inputSchema: {
705
+ archived: z6.boolean().optional().describe("List archived sites instead of live ones")
706
+ },
707
+ annotations: { readOnlyHint: true, openWorldHint: false }
708
+ }),
709
+ info: tool({
710
+ name: "fling_info",
711
+ title: "Site details",
712
+ description: "Show one site: URL, visibility, share link, current version and version history (ids for fling_rollback).",
713
+ inputSchema: { name: siteName },
714
+ annotations: { readOnlyHint: true, openWorldHint: false }
715
+ }),
716
+ files: tool({
717
+ name: "fling_files",
718
+ title: "List site files",
719
+ 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).",
720
+ inputSchema: {
721
+ name: siteName,
722
+ versionId: z6.string().min(1).max(100).optional().describe("Version id (from fling_info); omit for the version being served")
723
+ },
724
+ annotations: { readOnlyHint: true, openWorldHint: false }
725
+ }),
726
+ checkName: tool({
727
+ name: "fling_check_name",
728
+ title: "Check a site name",
729
+ 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,
730
+ inputSchema: { name: z6.string().min(1).max(200).describe("Site name to check") },
731
+ annotations: { readOnlyHint: true, openWorldHint: false }
732
+ }),
733
+ publish: tool({
734
+ name: "fling_publish",
735
+ title: "Publish a site",
736
+ 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} ${NEVER_PUBLISHED} The result includes \`warnings\`. Share the returned URL with the user; fling_files shows exactly which files the site has.`,
737
+ inputSchema: {
738
+ name: siteName,
739
+ files: z6.array(
740
+ z6.object({
741
+ path: sitePath,
742
+ content: z6.string().describe("The file content, encoded as `encoding` says"),
743
+ encoding
744
+ })
745
+ ).min(1).max(MAX_FILES).describe("Every file of the site"),
746
+ spa,
747
+ public: isPublic,
748
+ listHtml
749
+ },
750
+ annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true }
751
+ }),
752
+ uploadBegin: tool({
753
+ name: "fling_upload_begin",
754
+ title: "Start a chunked upload",
755
+ 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}`,
756
+ inputSchema: { name: siteName, spa, public: isPublic },
757
+ annotations: { destructiveHint: false, openWorldHint: false }
758
+ }),
759
+ uploadFile: tool({
760
+ name: "fling_upload_file",
761
+ title: "Upload a file (chunked)",
762
+ 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} ${NEVER_PUBLISHED}`,
763
+ inputSchema: {
764
+ uploadId: z6.string().min(1).max(100).describe("From fling_upload_begin"),
765
+ path: sitePath,
766
+ content: z6.string().max(MCP_UPLOAD_CHUNK_MAX_CHARS).describe("The file content (or the next part of it, with `append`)"),
767
+ encoding,
768
+ append: z6.boolean().optional().describe("Append this content to the file sent earlier with the same path")
769
+ },
770
+ annotations: { destructiveHint: false, openWorldHint: false }
771
+ }),
772
+ uploadFinish: tool({
773
+ name: "fling_upload_finish",
774
+ title: "Finish a chunked upload",
775
+ 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\`.`,
776
+ inputSchema: {
777
+ uploadId: z6.string().min(1).max(100).describe("From fling_upload_begin"),
778
+ listHtml
779
+ },
780
+ annotations: { destructiveHint: false, openWorldHint: true }
781
+ }),
782
+ share: tool({
783
+ name: "fling_share",
784
+ title: "Share link",
785
+ 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 who cannot sign in). `refresh` replaces the link (the old one stops working); `revoke` removes it.",
786
+ inputSchema: {
787
+ name: siteName,
788
+ refresh: z6.boolean().optional().describe("Rotate the link"),
789
+ revoke: z6.boolean().optional().describe("Remove the link")
790
+ },
791
+ annotations: { destructiveHint: false, openWorldHint: true }
792
+ }),
793
+ setVisibility: tool({
794
+ name: "fling_set_visibility",
795
+ title: "Set visibility",
796
+ description: `Make a site private (only ${w.privateAudience}) or public (anyone).`,
797
+ inputSchema: { name: siteName, visibility: z6.enum(["private", "public"]) },
798
+ annotations: { idempotentHint: true, openWorldHint: true }
799
+ }),
800
+ archive: tool({
801
+ name: "fling_archive",
802
+ title: "Archive or restore a site",
803
+ 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.",
804
+ inputSchema: {
805
+ name: siteName,
806
+ unarchive: z6.boolean().optional().describe("Restore an archived site")
807
+ },
808
+ annotations: { idempotentHint: true, openWorldHint: false }
809
+ }),
810
+ delete: tool({
811
+ name: "fling_delete",
812
+ title: "Delete a site",
813
+ 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.",
814
+ inputSchema: {
815
+ name: siteName,
816
+ confirm: z6.literal(true).describe("Must be true: the user has confirmed the deletion")
817
+ },
818
+ annotations: { destructiveHint: true, openWorldHint: false }
819
+ }),
820
+ commentsList: tool({
821
+ name: "fling_comments_list",
822
+ title: "List comments",
823
+ description: `List the comments people left on a site while reviewing it (${w.reviewPattern}), as threads: each has the page path it is about, the version the site was serving when it was written, its replies, and whether it is resolved. Anyone signed in to ${w.name} can comment on a site they can open. Resolved threads are left out unless \`includeResolved\`. Use it to act on feedback: fix the site, republish, then resolve the threads with fling_comment_resolve.`,
824
+ inputSchema: {
825
+ name: siteName,
826
+ path: commentPath.optional().describe('Only this page, e.g. "/" or "/docs/"'),
827
+ includeResolved: z6.boolean().optional().describe("Include resolved threads")
828
+ },
829
+ annotations: { readOnlyHint: true, openWorldHint: false }
830
+ }),
831
+ commentAdd: tool({
832
+ name: "fling_comment_add",
833
+ title: "Add a comment",
834
+ description: `Comment on a page of a site (shown on its review page, ${w.reviewPattern}), or reply to a thread (\`replyTo\`: a thread id from fling_comments_list). Comments are signed with the user's name, so only write one the user asked for.`,
835
+ inputSchema: {
836
+ name: siteName,
837
+ body: z6.string().min(1).max(COMMENT_MAX_CHARS).describe("The comment (plain text)"),
838
+ path: commentPath.optional().describe('The page it is about (default "/"); ignored for a reply'),
839
+ replyTo: commentId.optional().describe("Reply to this thread instead of starting one")
840
+ },
841
+ annotations: { destructiveHint: false, openWorldHint: true }
842
+ }),
843
+ commentResolve: tool({
844
+ name: "fling_comment_resolve",
845
+ title: "Resolve a comment thread",
846
+ description: "Mark a comment thread resolved (e.g. after fixing what it asks for), or reopen it with `reopen: true`. Thread ids come from fling_comments_list.",
847
+ inputSchema: {
848
+ name: siteName,
849
+ id: commentId.describe("The thread id"),
850
+ reopen: z6.boolean().optional().describe("Reopen a resolved thread")
851
+ },
852
+ annotations: { idempotentHint: true, openWorldHint: false }
853
+ }),
854
+ commentDelete: tool({
855
+ name: "fling_comment_delete",
856
+ title: "Delete a comment",
857
+ description: "Delete a comment; deleting a thread deletes its replies. People can delete their own comments, and a site's owner anyone's. Cannot be undone: prefer fling_comment_resolve, and confirm with the user first.",
858
+ inputSchema: { name: siteName, id: commentId.describe("The comment id") },
859
+ annotations: { destructiveHint: true, openWorldHint: false }
860
+ }),
861
+ rollback: tool({
862
+ name: "fling_rollback",
863
+ title: "Roll back",
864
+ description: "Point a site at an earlier version (ids from fling_info). Instant; nothing is uploaded.",
865
+ inputSchema: {
866
+ name: siteName,
867
+ versionId: z6.string().min(1).max(100).describe("Version id to serve")
868
+ },
869
+ annotations: { idempotentHint: true, openWorldHint: false }
870
+ })
871
+ };
872
+ }
873
+ var mcpTools = mcpToolsFor(mcpWording());
874
+ var COMMENTS_NOTE = (w) => `People review sites at ${w.reviewPattern} and leave comments on its pages. Publishing returns this review link (\`reviewUrl\`): it is the one to share when the user wants feedback; reviewers sign in to use it. fling_comments_list shows the comments; after acting on feedback, resolve the threads with fling_comment_resolve. Only post (fling_comment_add) or delete (fling_comment_delete) comments when the user asks.`;
875
+ var shareForFeedback = (reviewPage2, instanceName) => `Share for feedback: ${reviewPage2} (reviewers sign in to ${instanceName} to see the site and comment).`;
632
876
  var MCP_FILES_LISTED = 200;
633
877
  function describeVersionFiles(name, files, opts) {
634
878
  const kb = (n2) => n2 < 1024 ? `${n2} B` : n2 < 1024 * 1024 ? `${(n2 / 1024).toFixed(1)} KB` : `${(n2 / 1024 / 1024).toFixed(1)} MB`;
@@ -650,12 +894,30 @@ function describeVersionFiles(name, files, opts) {
650
894
  if (n > MCP_FILES_LISTED) lines.push(`\u2026 and ${n - MCP_FILES_LISTED} more`);
651
895
  return lines.join("\n");
652
896
  }
897
+ function describeComments(list, opts = {}) {
898
+ const all = commentThreads(list.comments);
899
+ const threads = opts.includeResolved ? all : all.filter((t) => t.comment.resolvedAt === null);
900
+ const hidden = all.length - threads.length;
901
+ const name = list.site.name;
902
+ const version = (id) => id === null ? "" : id === list.site.currentVersionId ? ", current version" : `, version ${id}`;
903
+ const head = `${threads.length} ${opts.includeResolved ? "" : "open "}thread${threads.length === 1 ? "" : "s"} on ${name}` + (hidden ? ` (${hidden} resolved not shown)` : "") + (opts.reviewUrl ? `. Review page: ${opts.reviewUrl}` : "");
904
+ const blocks = threads.map(
905
+ ({ comment: c, replies }) => [
906
+ `- [${c.id}] ${c.resolvedAt ? "RESOLVED " : ""}${c.path}${version(c.versionId)} \u2014 ${authorLabel(c.author)}, ${c.createdAt}:`,
907
+ ` ${c.body.replace(/\n/g, "\n ")}`,
908
+ ...replies.map(
909
+ (r) => ` - [${r.id}] ${authorLabel(r.author)}, ${r.createdAt}: ${r.body.replace(/\n/g, "\n ")}`
910
+ )
911
+ ].join("\n")
912
+ );
913
+ return [head, ...blocks].join("\n");
914
+ }
653
915
 
654
916
  // ../../packages/shared/src/ready.ts
655
917
  var READY_TIMEOUT_MS = 15e3;
656
918
  var NOT_READY_NOTE = "it can take a few more seconds to appear everywhere; reload if you see a 404";
657
919
  var isReachableStatus = (status) => status >= 200 && status < 400 || status === 401 || status === 403;
658
- var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
920
+ var defaultSleep = (ms) => new Promise((resolve5) => setTimeout(resolve5, ms));
659
921
  async function waitUntilReachable(url, opts = {}) {
660
922
  const timeoutMs = opts.timeoutMs ?? READY_TIMEOUT_MS;
661
923
  const doFetch = opts.fetch ?? ((u, init) => fetch(u, init));
@@ -698,6 +960,95 @@ function readinessWaitDisabled(env) {
698
960
  return env.FLING_NO_WAIT === "1" || env.VITEST === "true" || env.NODE_ENV === "test";
699
961
  }
700
962
 
963
+ // ../../packages/shared/src/review-bridge.ts
964
+ import { z as z7 } from "zod";
965
+ var tag = { fling: z7.literal("review") };
966
+ var path = z7.string().max(COMMENT_PATH_MAX);
967
+ var hash = z7.string().max(COMMENT_PATH_MAX);
968
+ var BridgePinSchema = z7.object({
969
+ id: z7.string(),
970
+ n: z7.number().int().min(1),
971
+ anchor: CommentAnchorSchema,
972
+ selected: z7.boolean(),
973
+ resolved: z7.boolean()
974
+ });
975
+ var BridgeCursorSchema = z7.object({
976
+ id: z7.string(),
977
+ name: z7.string().max(100),
978
+ colour: z7.string().max(32),
979
+ anchor: CommentAnchorSchema
980
+ });
981
+ var ShellMessageSchema = z7.discriminatedUnion("type", [
982
+ z7.object({ ...tag, type: z7.literal("ready") }),
983
+ /** the frame is on `path` (normalised, see normalizeCommentPath) with fragment `hash` ('' or '#…') */
984
+ z7.object({ ...tag, type: z7.literal("location"), path, hash, title: z7.string().max(300) }),
985
+ z7.object({ ...tag, type: z7.literal("pinPlaced"), anchor: CommentAnchorSchema }),
986
+ z7.object({ ...tag, type: z7.literal("pinCancelled") }),
987
+ z7.object({ ...tag, type: z7.literal("pinClicked"), id: z7.string() }),
988
+ /** this reviewer's pointer on the page; null when it left the frame */
989
+ z7.object({ ...tag, type: z7.literal("cursor"), anchor: CommentAnchorSchema.nullable() }),
990
+ /** a link or form to another origin, stopped: the frame stays on the site */
991
+ z7.object({ ...tag, type: z7.literal("blocked"), href: z7.string().max(2048) }),
992
+ z7.object({ ...tag, type: z7.literal("escape") })
993
+ ]);
994
+ var PageMessageSchema = z7.discriminatedUnion("type", [
995
+ /** load `path` (site-relative, may carry `?query` and `#hash`) in the frame */
996
+ z7.object({ ...tag, type: z7.literal("navigate"), path: z7.string().max(COMMENT_PATH_MAX * 2) }),
997
+ z7.object({ ...tag, type: z7.literal("reload") }),
998
+ z7.object({ ...tag, type: z7.literal("placing"), on: z7.boolean() }),
999
+ /** the pins on the frame's current page */
1000
+ z7.object({ ...tag, type: z7.literal("pins"), pins: z7.array(BridgePinSchema) }),
1001
+ /** scroll the pin into view and draw attention to it */
1002
+ z7.object({ ...tag, type: z7.literal("focusPin"), id: z7.string() }),
1003
+ z7.object({ ...tag, type: z7.literal("cursors"), cursors: z7.array(BridgeCursorSchema) })
1004
+ ]);
1005
+
1006
+ // ../../packages/shared/src/review-live.ts
1007
+ import { z as z8 } from "zod";
1008
+ var LIVE_PICTURE_MAX = 1024;
1009
+ var LivePeerSchema = z8.object({
1010
+ id: z8.string(),
1011
+ name: z8.string().max(100),
1012
+ colour: z8.string().max(32),
1013
+ /** their profile picture (https), when sign-in gave one; else clients show initials */
1014
+ picture: z8.string().max(LIVE_PICTURE_MAX).optional()
1015
+ });
1016
+ var LiveTicketSchema = z8.object({
1017
+ url: z8.string(),
1018
+ ticket: z8.string(),
1019
+ /** seconds the ticket is good for: connect before then */
1020
+ expiresIn: z8.number().int()
1021
+ });
1022
+ var path2 = z8.string().max(COMMENT_PATH_MAX);
1023
+ var hash2 = z8.string().max(COMMENT_PATH_MAX);
1024
+ var LiveClientMessageSchema = z8.discriminatedUnion("type", [
1025
+ z8.object({ type: z8.literal("hello") }),
1026
+ /** where this peer's pointer is; `anchor` null when it left the page */
1027
+ z8.object({
1028
+ type: z8.literal("cursor"),
1029
+ path: path2,
1030
+ hash: hash2,
1031
+ anchor: CommentAnchorSchema.nullable()
1032
+ }),
1033
+ z8.object({ type: z8.literal("ping") })
1034
+ ]);
1035
+ var LiveServerMessageSchema = z8.discriminatedUnion("type", [
1036
+ /** the answer to `hello`: who this connection is, and who else is in the room */
1037
+ z8.object({ type: z8.literal("welcome"), you: LivePeerSchema, peers: z8.array(LivePeerSchema) }),
1038
+ z8.object({ type: z8.literal("join"), peer: LivePeerSchema }),
1039
+ z8.object({ type: z8.literal("leave"), peerId: z8.string() }),
1040
+ z8.object({
1041
+ type: z8.literal("cursor"),
1042
+ peerId: z8.string(),
1043
+ path: path2,
1044
+ hash: hash2,
1045
+ anchor: CommentAnchorSchema.nullable()
1046
+ }),
1047
+ /** the site's comments changed: refetch them */
1048
+ z8.object({ type: z8.literal("comments") }),
1049
+ z8.object({ type: z8.literal("pong") })
1050
+ ]);
1051
+
701
1052
  // src/errors.ts
702
1053
  var EXIT_ERROR = 1;
703
1054
  var EXIT_NOT_LOGGED_IN = 2;
@@ -722,6 +1073,14 @@ var NotLoggedInError = class extends CliError {
722
1073
  this.name = "NotLoggedInError";
723
1074
  }
724
1075
  };
1076
+ var NoServerError = class extends CliError {
1077
+ constructor(binName) {
1078
+ super(`No Fling server set \u2014 run \`${binName} login <url>\` with your Fling's address`, {
1079
+ exitCode: EXIT_NOT_LOGGED_IN
1080
+ });
1081
+ this.name = "NoServerError";
1082
+ }
1083
+ };
725
1084
  function exitCodeFor(error) {
726
1085
  return error instanceof CliError ? error.exitCode : EXIT_ERROR;
727
1086
  }
@@ -729,9 +1088,9 @@ function exitCodeFor(error) {
729
1088
  // package.json
730
1089
  var package_default = {
731
1090
  name: "@nearform/fling",
732
- version: "0.1.2",
1091
+ version: "0.3.0",
733
1092
  license: "MIT",
734
- description: "Fling a folder of static files at fling.labs.nearform.com and get a link back. CLI + MCP server.",
1093
+ description: "Fling a folder of static files at a Fling server and get a link back. CLI + MCP server.",
735
1094
  type: "module",
736
1095
  bin: {
737
1096
  fling: "./dist/cli.js"
@@ -739,7 +1098,8 @@ var package_default = {
739
1098
  files: [
740
1099
  "dist/cli.js",
741
1100
  "dist/cli.js.map",
742
- "README.md"
1101
+ "README.md",
1102
+ "CHANGELOG.md"
743
1103
  ],
744
1104
  engines: {
745
1105
  node: ">=22"
@@ -760,6 +1120,7 @@ var package_default = {
760
1120
  test: "vitest run"
761
1121
  },
762
1122
  dependencies: {
1123
+ "@clack/prompts": "^1.8.1",
763
1124
  "@modelcontextprotocol/sdk": "^1.20.0",
764
1125
  chalk: "^5.6.2",
765
1126
  commander: "^15.0.0",
@@ -812,8 +1173,8 @@ function defaultHint(status) {
812
1173
  if (status === 403) return "You are not allowed to do that.";
813
1174
  return void 0;
814
1175
  }
815
- function buildUrl(serverUrl, path, query) {
816
- const url = new URL(`${serverUrl.replace(/\/+$/, "")}${path.startsWith("/") ? path : `/${path}`}`);
1176
+ function buildUrl(serverUrl, path3, query) {
1177
+ const url = new URL(`${serverUrl.replace(/\/+$/, "")}${path3.startsWith("/") ? path3 : `/${path3}`}`);
817
1178
  for (const [key, value] of Object.entries(query ?? {})) {
818
1179
  if (value !== void 0 && value !== null) url.searchParams.set(key, String(value));
819
1180
  }
@@ -823,8 +1184,8 @@ function createApiClient(options) {
823
1184
  const serverUrl = options.serverUrl.replace(/\/+$/, "");
824
1185
  const fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
825
1186
  const timeoutMs = options.timeoutMs ?? 6e4;
826
- async function request(method, path, reqOptions = {}) {
827
- const url = buildUrl(serverUrl, path, reqOptions.query);
1187
+ async function request(method, path3, reqOptions = {}) {
1188
+ const url = buildUrl(serverUrl, path3, reqOptions.query);
828
1189
  const headers = {
829
1190
  Accept: "application/json",
830
1191
  "User-Agent": `${BIN_NAME}-cli/${VERSION}`
@@ -849,7 +1210,7 @@ function createApiClient(options) {
849
1210
  });
850
1211
  }
851
1212
  const raw = await readBody(response);
852
- if (!response.ok) throw errorFromResponse(response.status, raw, method, path);
1213
+ if (!response.ok) throw errorFromResponse(response.status, raw, method, path3);
853
1214
  if (!reqOptions.schema) return raw;
854
1215
  const parsed = reqOptions.schema.safeParse(raw);
855
1216
  if (!parsed.success) {
@@ -859,8 +1220,8 @@ function createApiClient(options) {
859
1220
  status: response.status,
860
1221
  code: "invalid_response",
861
1222
  body: raw,
862
- message: `Unexpected response from ${method} ${path}${where}: ${issue?.message ?? "invalid"}`,
863
- hint: `Your ${BIN_NAME} CLI may be out of date \u2014 try \`npm i -g @nearform/fling@latest\`.`
1223
+ message: `Unexpected response from ${method} ${path3}${where}: ${issue?.message ?? "invalid"}`,
1224
+ hint: `Your ${BIN_NAME} CLI may be out of date \u2014 try \`npm i -g ${PACKAGE_NAME}@latest\`.`
864
1225
  });
865
1226
  }
866
1227
  return parsed.data;
@@ -893,15 +1254,15 @@ async function putUpload(fetchImpl, url, headers, body, timeoutMs = 5 * 6e4) {
893
1254
  }
894
1255
  async function readBody(response) {
895
1256
  if (response.status === 204) return void 0;
896
- const text = await response.text();
897
- if (text === "") return void 0;
1257
+ const text2 = await response.text();
1258
+ if (text2 === "") return void 0;
898
1259
  try {
899
- return JSON.parse(text);
1260
+ return JSON.parse(text2);
900
1261
  } catch {
901
- return text;
1262
+ return text2;
902
1263
  }
903
1264
  }
904
- function errorFromResponse(status, body, method, path) {
1265
+ function errorFromResponse(status, body, method, path3) {
905
1266
  const envelope = ErrorEnvelopeSchema.safeParse(body);
906
1267
  if (envelope.success) {
907
1268
  const { code } = envelope.data.error;
@@ -917,12 +1278,12 @@ function errorFromResponse(status, body, method, path) {
917
1278
  if (Array.isArray(extra?.paths)) Object.assign(error, { paths: extra.paths });
918
1279
  return error;
919
1280
  }
920
- return new CliApiError({ status, body, message: `${method} ${path} failed with HTTP ${status}` });
1281
+ return new CliApiError({ status, body, message: `${method} ${path3} failed with HTTP ${status}` });
921
1282
  }
922
1283
  var NEVER_PUBLISHED_HINTS = {
923
1284
  secret_detected: "Remove the secret from the file (and rotate it if it was ever shared), or add the file to .flingignore if the site does not need it.",
924
1285
  secret_file: "Move the secret files out of the folder, or add them to .flingignore if the site does not need them.",
925
- dotfile: `Dotfiles are never flung. Update the CLI (\`npm i -g @nearform/fling@latest\`), which skips them.`
1286
+ dotfile: `Dotfiles are never flung. Update the CLI (\`npm i -g ${PACKAGE_NAME}@latest\`), which skips them.`
926
1287
  };
927
1288
  function describeCause(cause) {
928
1289
  if (cause instanceof Error) {
@@ -938,10 +1299,9 @@ function describeCause(cause) {
938
1299
  import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
939
1300
  import { homedir } from "node:os";
940
1301
  import { join } from "node:path";
941
- import { z as z5 } from "zod";
1302
+ import { z as z9 } from "zod";
942
1303
  var CONFIG_DIR_NAME = ".fling";
943
1304
  var CONFIG_FILE_NAME = "config.json";
944
- var DEFAULT_SERVER_URL = "https://fling.labs.nearform.com";
945
1305
  var ENV = {
946
1306
  apiKey: "FLING_API_KEY",
947
1307
  url: "FLING_URL",
@@ -950,13 +1310,15 @@ var ENV = {
950
1310
  /** Where to look for files given by name (the .mcpb extension sets it from its settings). */
951
1311
  downloadsDir: "FLING_DOWNLOADS_DIR"
952
1312
  };
953
- var cliConfigSchema = z5.object({
954
- serverUrl: z5.string().url().optional(),
955
- apiKey: z5.string().min(1).optional(),
956
- user: z5.object({
957
- email: z5.string().optional(),
958
- name: z5.string().optional(),
959
- userSlug: z5.string().optional()
1313
+ var cliConfigSchema = z9.object({
1314
+ serverUrl: z9.string().url().optional(),
1315
+ /** What `serverUrl` said about itself at login. A copy a newer CLI can't read is just refetched. */
1316
+ instance: InstanceMetadataSchema.optional().catch(void 0),
1317
+ apiKey: z9.string().min(1).optional(),
1318
+ user: z9.object({
1319
+ email: z9.string().optional(),
1320
+ name: z9.string().optional(),
1321
+ userSlug: z9.string().optional()
960
1322
  }).optional()
961
1323
  });
962
1324
  function systemLocateOptions(env = process.env) {
@@ -1012,18 +1374,21 @@ function createConfigStore(options = {}) {
1012
1374
  return next;
1013
1375
  },
1014
1376
  async clearCredentials() {
1015
- const { serverUrl } = await load();
1016
- await save(serverUrl ? { serverUrl } : {});
1377
+ const { serverUrl, instance } = await load();
1378
+ await save({ serverUrl, instance });
1017
1379
  },
1018
1380
  async resolve(overrides = {}) {
1019
1381
  const config = await load();
1020
1382
  const envUrl = fromEnv(env[ENV.url]);
1021
1383
  const envKey = fromEnv(env[ENV.apiKey]);
1022
- const [serverUrl, serverUrlSource] = overrides.serverUrl ? [overrides.serverUrl, "flag"] : envUrl ? [envUrl, "env"] : config.serverUrl ? [config.serverUrl, "config"] : [DEFAULT_SERVER_URL, "default"];
1384
+ const [rawUrl, serverUrlSource] = overrides.serverUrl ? [overrides.serverUrl, "flag"] : envUrl ? [envUrl, "env"] : config.serverUrl ? [config.serverUrl, "config"] : [void 0, "none"];
1385
+ const serverUrl = rawUrl === void 0 ? void 0 : normalizeServerUrl(rawUrl);
1023
1386
  const [apiKey, apiKeySource] = envKey ? [envKey, "env"] : config.apiKey ? [config.apiKey, "config"] : [void 0, "none"];
1387
+ const stored = config.serverUrl && normalizeServerUrl(config.serverUrl);
1024
1388
  return {
1025
- serverUrl: serverUrl.replace(/\/+$/, ""),
1389
+ serverUrl,
1026
1390
  serverUrlSource,
1391
+ instance: serverUrl && stored === serverUrl ? config.instance : void 0,
1027
1392
  apiKey,
1028
1393
  apiKeySource,
1029
1394
  user: config.user,
@@ -1032,12 +1397,53 @@ function createConfigStore(options = {}) {
1032
1397
  }
1033
1398
  };
1034
1399
  }
1400
+ function normalizeServerUrl(input) {
1401
+ const trimmed = input.trim();
1402
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
1403
+ const url = new URL(withScheme);
1404
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
1405
+ throw new Error(`Not an http(s) URL: ${input}`);
1406
+ }
1407
+ return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
1408
+ }
1035
1409
  var REDACTED_KEY_CHARS = 10;
1036
1410
  function redactKey(key) {
1037
1411
  if (!key) return "-";
1038
1412
  return key.length <= REDACTED_KEY_CHARS + 8 ? "****" : `${key.slice(0, REDACTED_KEY_CHARS)}\u2026`;
1039
1413
  }
1040
1414
 
1415
+ // src/instance.ts
1416
+ async function fetchInstance(serverUrl, fetch2) {
1417
+ const url = instanceMetadataUrl(serverUrl);
1418
+ let response;
1419
+ try {
1420
+ response = await fetch2(url, { headers: { Accept: "application/json" } });
1421
+ } catch (cause) {
1422
+ throw new CliError(`Could not reach ${serverUrl} (${cause.message ?? cause})`, {
1423
+ hint: "Check the address and your connection.",
1424
+ cause
1425
+ });
1426
+ }
1427
+ const notFling = () => new CliError(`${serverUrl} does not look like a Fling server (no valid ${url})`, {
1428
+ hint: "Use the address of your Fling, e.g. https://fling.example.com."
1429
+ });
1430
+ if (!response.ok) throw notFling();
1431
+ const parsed = InstanceMetadataSchema.safeParse(await response.json().catch(() => null));
1432
+ if (!parsed.success) throw notFling();
1433
+ return parsed.data;
1434
+ }
1435
+ async function loadInstance(store, config, fetch2) {
1436
+ if (config.instance) return config.instance;
1437
+ if (!config.serverUrl) return null;
1438
+ try {
1439
+ const instance = await fetchInstance(config.serverUrl, fetch2);
1440
+ if (config.serverUrlSource === "config") await store.update({ instance });
1441
+ return instance;
1442
+ } catch {
1443
+ return null;
1444
+ }
1445
+ }
1446
+
1041
1447
  // src/auth.ts
1042
1448
  var CALLBACK_PORT_START = 8765;
1043
1449
  var CALLBACK_PORT_END = 8770;
@@ -1046,12 +1452,13 @@ var CALLBACK_PATH = "/callback";
1046
1452
  var AUTH_CLI_PATH = "/auth/cli";
1047
1453
  async function loginFlow(options) {
1048
1454
  const { serverUrl, store, log } = options;
1455
+ const instance = await fetchInstance(serverUrl, options.fetch ?? fetch);
1049
1456
  const state = randomBytes(16).toString("hex");
1050
1457
  const callback = await startCallbackServer({ state, timeoutMs: options.timeoutMs });
1051
1458
  try {
1052
1459
  const authUrl = buildAuthUrl(serverUrl, callback.url, options.hostname ?? osHostname(), state);
1053
1460
  options.onAuthUrl?.(authUrl);
1054
- log.info(`Opening your browser to sign in at ${serverUrl}`);
1461
+ log.info(`Opening your browser to sign in to ${instance.name} at ${serverUrl}`);
1055
1462
  log.hint(`If it does not open, visit: ${authUrl}`);
1056
1463
  try {
1057
1464
  await options.open(authUrl);
@@ -1064,6 +1471,7 @@ async function loginFlow(options) {
1064
1471
  const user = await client.request("GET", "/api/me", { schema: MeSchema });
1065
1472
  await store.update({
1066
1473
  serverUrl,
1474
+ instance,
1067
1475
  apiKey: key,
1068
1476
  user: { email: user.email, name: user.name, userSlug: user.userSlug }
1069
1477
  });
@@ -1071,7 +1479,7 @@ async function loginFlow(options) {
1071
1479
  log.success(`Signed in as ${user.email}`);
1072
1480
  log.hint(`Server: ${serverUrl}`);
1073
1481
  log.hint(`API key: ${keyPrefix} (stored in ${store.file})`);
1074
- return { serverUrl, keyPrefix, user };
1482
+ return { serverUrl, keyPrefix, user, instance };
1075
1483
  } finally {
1076
1484
  callback.close();
1077
1485
  }
@@ -1101,8 +1509,8 @@ async function startCallbackServer(options = {}) {
1101
1509
  const timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS;
1102
1510
  let settle;
1103
1511
  let timer;
1104
- const result = new Promise((resolve3, reject) => {
1105
- settle = { resolve: resolve3, reject };
1512
+ const result = new Promise((resolve5, reject) => {
1513
+ settle = { resolve: resolve5, reject };
1106
1514
  }).finally(() => clearTimeout(timer));
1107
1515
  result.catch(() => {
1108
1516
  });
@@ -1163,7 +1571,7 @@ ${close ? "<script>setTimeout(function(){window.close()},800)</script>" : ""}
1163
1571
  res.end(html);
1164
1572
  }
1165
1573
  function listenOnFreePort(server) {
1166
- return new Promise((resolve3, reject) => {
1574
+ return new Promise((resolve5, reject) => {
1167
1575
  let port = CALLBACK_PORT_START;
1168
1576
  const tryNext = () => {
1169
1577
  if (port > CALLBACK_PORT_END) {
@@ -1185,18 +1593,18 @@ function listenOnFreePort(server) {
1185
1593
  });
1186
1594
  server.listen(port, "127.0.0.1", () => {
1187
1595
  server.removeAllListeners("error");
1188
- resolve3(port);
1596
+ resolve5(port);
1189
1597
  });
1190
1598
  };
1191
1599
  tryNext();
1192
1600
  });
1193
1601
  }
1194
- function escapeHtml2(text) {
1195
- return text.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
1602
+ function escapeHtml2(text2) {
1603
+ return text2.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
1196
1604
  }
1197
1605
 
1198
1606
  // src/context.ts
1199
- import { createInterface } from "node:readline/promises";
1607
+ import { confirm, isCancel, text } from "@clack/prompts";
1200
1608
 
1201
1609
  // src/ready.ts
1202
1610
  import chalk from "chalk";
@@ -1232,20 +1640,83 @@ async function waitWithSpinner(waiter, url, paths, io) {
1232
1640
  }
1233
1641
 
1234
1642
  // src/utils/logger.ts
1643
+ import chalk3 from "chalk";
1644
+
1645
+ // src/ui/theme.ts
1235
1646
  import chalk2 from "chalk";
1236
- var PREFIX = chalk2.cyan("fling");
1647
+ var STOPS = [
1648
+ [0, 229, 255],
1649
+ [124, 77, 255],
1650
+ [255, 78, 205]
1651
+ ];
1652
+ var WORDMARK = [
1653
+ "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 ",
1654
+ "\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D ",
1655
+ "\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2588\u2557",
1656
+ "\u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551",
1657
+ "\u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D",
1658
+ "\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D "
1659
+ ];
1660
+ var TRAILS = [" ", " \u2501\u2501\u2501 ", " \u2501\u2501\u2501\u2501 ", " \u2501\u2501 ", " ", " "];
1661
+ var LOGO_WIDTH = TRAILS[0].length + WORDMARK[0].length;
1662
+ var brand = {
1663
+ primary: chalk2.hex("#7C4DFF"),
1664
+ accent: chalk2.hex("#00E5FF"),
1665
+ url: (url) => chalk2.bold.hex("#00E5FF")(url)
1666
+ };
1667
+ var symbols = {
1668
+ info: chalk2.hex("#7C4DFF")("\u25C6"),
1669
+ success: chalk2.green("\u2714"),
1670
+ warn: chalk2.yellow("\u25B2"),
1671
+ error: chalk2.red("\u2716"),
1672
+ step: chalk2.dim("\u2502")
1673
+ };
1674
+ function colourAt(t) {
1675
+ const scaled = Math.min(Math.max(t, 0), 1) * (STOPS.length - 1);
1676
+ const i = Math.min(Math.floor(scaled), STOPS.length - 2);
1677
+ const f = scaled - i;
1678
+ const a = STOPS[i];
1679
+ const b = STOPS[i + 1];
1680
+ return [0, 1, 2].map((k) => Math.round(a[k] + (b[k] - a[k]) * f));
1681
+ }
1682
+ function gradient(text2, width = text2.length, offset = 0) {
1683
+ if (chalk2.level === 0) return text2;
1684
+ return [...text2].map((ch, i) => ch === " " ? ch : chalk2.rgb(...colourAt((offset + i) / (width - 1)))(ch)).join("");
1685
+ }
1686
+ function logo(options = {}) {
1687
+ const columns = options.columns || process.stderr.columns || 80;
1688
+ const tagline = options.tagline ?? "throw static files at the web, get a link back";
1689
+ if (columns < LOGO_WIDTH + 2) return `${gradient("\u25B6\u25B6 fling")} ${chalk2.dim(tagline)}`;
1690
+ const lines = WORDMARK.map(
1691
+ (row, i) => chalk2.dim(TRAILS[i] ?? "") + gradient(row, WORDMARK[0].length)
1692
+ );
1693
+ return ["", ...lines, `${" ".repeat(TRAILS[0].length)}${chalk2.dim(tagline)}`, ""].join("\n");
1694
+ }
1695
+ var ANSI = /\x1b\[[0-9;]*m/g;
1696
+ var visibleLength = (s) => s.replace(ANSI, "").length;
1697
+ function box(lines, title) {
1698
+ const width = Math.max(...lines.map(visibleLength), title ? visibleLength(title) + 2 : 0);
1699
+ const border = chalk2.dim;
1700
+ const top = title ? `${border("\u256D\u2500")} ${title} ${border(`${"\u2500".repeat(width - visibleLength(title) - 1)}\u256E`)}` : border(`\u256D${"\u2500".repeat(width + 2)}\u256E`);
1701
+ const body = lines.map(
1702
+ (line) => `${border("\u2502")} ${line}${" ".repeat(width - visibleLength(line))} ${border("\u2502")}`
1703
+ );
1704
+ return [top, ...body, border(`\u2570${"\u2500".repeat(width + 2)}\u256F`)].join("\n");
1705
+ }
1706
+
1707
+ // src/utils/logger.ts
1237
1708
  function createLogger(options = {}) {
1238
1709
  const write = options.write ?? ((line) => process.stderr.write(`${line}
1239
1710
  `));
1240
1711
  const debugEnabled = options.debug ?? false;
1241
1712
  return {
1242
- info: (message) => write(`${PREFIX} ${message}`),
1243
- success: (message) => write(`${PREFIX} ${chalk2.green("\u2713")} ${message}`),
1244
- warn: (message) => write(`${PREFIX} ${chalk2.yellow("!")} ${message}`),
1245
- error: (message) => write(`${PREFIX} ${chalk2.red("\u2717")} ${message}`),
1246
- hint: (message) => write(chalk2.dim(` ${message}`)),
1713
+ info: (message) => write(`${symbols.info} ${message}`),
1714
+ success: (message) => write(`${symbols.success} ${message}`),
1715
+ warn: (message) => write(`${symbols.warn} ${chalk3.yellow(message)}`),
1716
+ error: (message) => write(`${symbols.error} ${chalk3.red(message)}`),
1717
+ hint: (message) => write(chalk3.dim(` ${message}`)),
1247
1718
  debug: (message) => {
1248
- if (debugEnabled) write(chalk2.dim(`[debug] ${message}`));
1719
+ if (debugEnabled) write(chalk3.dim(`[debug] ${message}`));
1249
1720
  }
1250
1721
  };
1251
1722
  }
@@ -1265,12 +1736,13 @@ var silentLogger = {
1265
1736
  };
1266
1737
 
1267
1738
  // src/utils/output.ts
1268
- import chalk3 from "chalk";
1739
+ import chalk4 from "chalk";
1269
1740
  function createOutput(options = {}) {
1270
1741
  const json = options.json ?? false;
1271
1742
  const write = options.write ?? ((chunk) => process.stdout.write(chunk));
1272
1743
  return {
1273
1744
  json,
1745
+ tty: options.tty ?? (options.write ? false : Boolean(process.stdout.isTTY)),
1274
1746
  data: (raw, human) => write(`${json ? formatJson(raw) : human()}
1275
1747
  `),
1276
1748
  text: (line) => {
@@ -1283,15 +1755,15 @@ function formatJson(value) {
1283
1755
  return JSON.stringify(value, null, 2);
1284
1756
  }
1285
1757
  function renderTable(rows, columns) {
1286
- if (rows.length === 0) return chalk3.dim("(no results)");
1758
+ if (rows.length === 0) return chalk4.dim("(no results)");
1287
1759
  const cells = rows.map((row) => columns.map((column) => formatCell(column.value(row))));
1288
1760
  const widths = columns.map(
1289
1761
  (column, i) => Math.max(column.header.length, ...cells.map((line) => line[i]?.length ?? 0))
1290
1762
  );
1291
- const pad = (text, i) => i === columns.length - 1 ? text : text.padEnd(widths[i] ?? 0);
1763
+ const pad = (text2, i) => i === columns.length - 1 ? text2 : text2.padEnd(widths[i] ?? 0);
1292
1764
  const header = columns.map((column, i) => pad(column.header, i)).join(" ");
1293
1765
  const body = cells.map((line) => line.map(pad).join(" "));
1294
- return [chalk3.bold(header), ...body].join("\n");
1766
+ return [chalk4.bold(header), ...body].join("\n");
1295
1767
  }
1296
1768
  function formatCell(value) {
1297
1769
  if (value === null || value === void 0 || value === "") return "-";
@@ -1341,32 +1813,35 @@ async function createContext(options = {}) {
1341
1813
  };
1342
1814
  }
1343
1815
  async function ttyConfirm(question) {
1344
- const answer = await ttyPrompt(`${question} [y/N] `);
1345
- return /^y(es)?$/i.test(answer.trim());
1816
+ const answer = await confirm({ message: question, initialValue: false, output: process.stderr });
1817
+ if (isCancel(answer)) throw new CliError("Cancelled");
1818
+ return answer;
1346
1819
  }
1347
1820
  async function ttyPrompt(question) {
1348
- const rl = createInterface({ input: process.stdin, output: process.stderr });
1349
- try {
1350
- return await rl.question(question);
1351
- } finally {
1352
- rl.close();
1353
- }
1821
+ const answer = await text({ message: question.trim(), output: process.stderr });
1822
+ if (isCancel(answer)) throw new CliError("Cancelled");
1823
+ return answer ?? "";
1354
1824
  }
1355
1825
  function ttyProgress(line) {
1356
1826
  process.stderr.write(`\r\x1B[2K${line ?? ""}`);
1357
1827
  }
1828
+ function requireServer(ctx) {
1829
+ if (!ctx.config.serverUrl) throw new NoServerError(ctx.binName);
1830
+ return ctx.config.serverUrl;
1831
+ }
1358
1832
  function requireClient(ctx) {
1833
+ const serverUrl = requireServer(ctx);
1359
1834
  if (!ctx.config.apiKey) throw new NotLoggedInError(ctx.binName);
1360
1835
  return createApiClient({
1361
- serverUrl: ctx.config.serverUrl,
1836
+ serverUrl,
1362
1837
  apiKey: ctx.config.apiKey,
1363
1838
  fetch: ctx.fetch
1364
1839
  });
1365
1840
  }
1366
1841
 
1367
1842
  // src/sites.ts
1368
- import { z as z6 } from "zod";
1369
- var ShareResponseSchema2 = z6.looseObject({ shareUrl: z6.string() });
1843
+ import { z as z10 } from "zod";
1844
+ var ShareResponseSchema2 = z10.looseObject({ shareUrl: z10.string() });
1370
1845
  var sitePath2 = (name) => `/api/sites/${encodeURIComponent(name)}`;
1371
1846
  function whoami(client) {
1372
1847
  return client.request("GET", "/api/me", { schema: MeSchema });
@@ -1376,32 +1851,8 @@ function checkName(client, name) {
1376
1851
  schema: NameCheckSchema
1377
1852
  });
1378
1853
  }
1379
- function sitesBaseFor(serverUrl) {
1380
- try {
1381
- const url = new URL(serverUrl);
1382
- const { hostname } = url;
1383
- if (url.port || !hostname.includes(".") || /^[\d.]+$/.test(hostname) || hostname.startsWith("["))
1384
- return null;
1385
- return { protocol: url.protocol, domain: hostname };
1386
- } catch {
1387
- return null;
1388
- }
1389
- }
1390
- function sitesBaseFromSiteUrl(siteUrl2) {
1391
- try {
1392
- const url = new URL(siteUrl2);
1393
- const dot = url.host.indexOf(".");
1394
- return dot > 0 ? { protocol: url.protocol, domain: url.host.slice(dot + 1) } : null;
1395
- } catch {
1396
- return null;
1397
- }
1398
- }
1399
- async function discoverSitesBase(client) {
1400
- const inferred = sitesBaseFor(client.serverUrl);
1401
- if (inferred) return inferred;
1402
- const sites = await listSites(client).catch(() => []);
1403
- const first = sites[0];
1404
- return first ? sitesBaseFromSiteUrl(first.url) : null;
1854
+ function sitesBaseOf(instance) {
1855
+ return instance ? { protocol: `${instance.siteScheme}:`, domain: instance.sitesDomain } : null;
1405
1856
  }
1406
1857
  function siteUrlFor(name, base) {
1407
1858
  return siteUrl(name, base.domain, base.protocol);
@@ -1471,11 +1922,47 @@ async function share(client, name, opts = {}) {
1471
1922
  });
1472
1923
  return { name, action: hadLink ? "refreshed" : "created", shareUrl: res.shareUrl };
1473
1924
  }
1925
+ function listComments(client, name, opts = {}) {
1926
+ return client.request("GET", `${sitePath2(name)}/comments`, {
1927
+ schema: CommentListSchema,
1928
+ query: { path: opts.path }
1929
+ });
1930
+ }
1931
+ function addComment(client, name, input) {
1932
+ return client.request("POST", `${sitePath2(name)}/comments`, {
1933
+ schema: CommentSchema,
1934
+ body: input
1935
+ });
1936
+ }
1937
+ function resolveComment(client, name, id, resolved) {
1938
+ return client.request("PATCH", `${sitePath2(name)}/comments/${encodeURIComponent(id)}`, {
1939
+ schema: CommentSchema,
1940
+ body: { resolved }
1941
+ });
1942
+ }
1943
+ async function deleteComment(client, name, id) {
1944
+ await client.request("DELETE", `${sitePath2(name)}/comments/${encodeURIComponent(id)}`);
1945
+ return { name, id, deleted: true };
1946
+ }
1947
+ function commentsEnabled(instance) {
1948
+ if (!instance) return false;
1949
+ return instance.capabilities.comments ?? true;
1950
+ }
1474
1951
 
1475
1952
  // src/commands/auth.ts
1476
- async function runLogin(ctx) {
1953
+ function loginServer(ctx, url) {
1954
+ if (url === void 0) return requireServer(ctx);
1955
+ try {
1956
+ return normalizeServerUrl(url);
1957
+ } catch {
1958
+ throw new CliError(`"${url}" is not a server address`, {
1959
+ hint: `Run \`${ctx.binName} login <url>\` with your Fling's address, e.g. https://fling.example.com.`
1960
+ });
1961
+ }
1962
+ }
1963
+ async function runLogin(ctx, url) {
1477
1964
  const result = await loginFlow({
1478
- serverUrl: ctx.config.serverUrl,
1965
+ serverUrl: loginServer(ctx, url),
1479
1966
  store: ctx.store,
1480
1967
  log: ctx.log,
1481
1968
  open: ctx.open,
@@ -1491,30 +1978,109 @@ async function runLogout(ctx) {
1491
1978
  }
1492
1979
  await ctx.store.clearCredentials();
1493
1980
  ctx.log.success("Logged out; API key removed from this machine");
1494
- ctx.log.hint(`Revoke it on the server under API keys at ${ctx.config.serverUrl}`);
1981
+ if (ctx.config.serverUrl)
1982
+ ctx.log.hint(`Revoke it on the server under Connections at ${ctx.config.serverUrl}`);
1495
1983
  }
1496
1984
  async function runWhoami(ctx) {
1497
1985
  const client = requireClient(ctx);
1498
1986
  const me = await whoami(client);
1499
- const base = ctx.json ? null : await discoverSitesBase(client);
1987
+ const instance = await loadInstance(ctx.store, ctx.config, ctx.fetch);
1500
1988
  ctx.out.data(
1501
1989
  {
1502
1990
  ...me,
1503
1991
  serverUrl: ctx.config.serverUrl,
1992
+ instance: instance?.name ?? null,
1504
1993
  apiKey: redactKey(ctx.config.apiKey),
1505
1994
  apiKeySource: ctx.config.apiKeySource
1506
1995
  },
1507
1996
  () => [
1508
- `${chalk4.bold("User:")} ${me.name} <${me.email}>`,
1509
- `${chalk4.bold("Sites:")} ${sitesPattern(base)} ${chalk4.dim("(names are first come, first served)")}`,
1510
- `${chalk4.bold("Server:")} ${ctx.config.serverUrl}`,
1511
- `${chalk4.bold("API key:")} ${redactKey(ctx.config.apiKey)} ${chalk4.dim(`(from ${ctx.config.apiKeySource})`)}`
1997
+ `${chalk5.bold("User:")} ${me.name} <${me.email}>`,
1998
+ `${chalk5.bold("Sites:")} ${sitesPattern(sitesBaseOf(instance))} ${chalk5.dim("(names are first come, first served)")}`,
1999
+ `${chalk5.bold("Server:")} ${ctx.config.serverUrl}${instance ? chalk5.dim(` (${instance.name})`) : ""}`,
2000
+ `${chalk5.bold("API key:")} ${redactKey(ctx.config.apiKey)} ${chalk5.dim(`(from ${ctx.config.apiKeySource})`)}`
1512
2001
  ].join("\n")
1513
2002
  );
1514
2003
  }
1515
2004
 
2005
+ // src/commands/comments.ts
2006
+ import chalk6 from "chalk";
2007
+ function commentsClient(ctx) {
2008
+ const instance = ctx.config.instance;
2009
+ if (instance && !commentsEnabled(instance)) {
2010
+ throw new CliError(`${instance.name} has comments turned off`);
2011
+ }
2012
+ return requireClient(ctx);
2013
+ }
2014
+ var reviewPage = (ctx, name, path3) => reviewUrl(ctx.config.instance?.appOrigin ?? ctx.config.serverUrl ?? "", name, path3);
2015
+ function describe(c, currentVersionId) {
2016
+ const version = c.versionId === null ? "" : c.versionId === currentVersionId ? "" : chalk6.dim(` (on version ${c.versionId})`);
2017
+ return `${chalk6.bold(authorLabel(c.author))} ${chalk6.dim(formatDate(c.createdAt))}${version}`;
2018
+ }
2019
+ async function runComments(ctx, name, opts) {
2020
+ const list = await listComments(commentsClient(ctx), name, { path: opts.path });
2021
+ const all = commentThreads(list.comments);
2022
+ const threads = opts.all ? all : all.filter((t) => t.comment.resolvedAt === null);
2023
+ const hidden = all.length - threads.length;
2024
+ ctx.out.data({ ...list, reviewUrl: reviewPage(ctx, name) }, () => {
2025
+ const lines = [];
2026
+ let lastPath = null;
2027
+ for (const { comment: c, replies } of threads.sort(
2028
+ (a, b) => a.comment.path.localeCompare(b.comment.path)
2029
+ )) {
2030
+ if (c.path !== lastPath) {
2031
+ lines.push("", chalk6.bold.underline(c.path));
2032
+ lastPath = c.path;
2033
+ }
2034
+ const state = c.resolvedAt ? chalk6.green(" \u2713 resolved") : "";
2035
+ lines.push(`${chalk6.dim(c.id)}${state} ${describe(c, list.site.currentVersionId)}`);
2036
+ lines.push(...c.body.split("\n").map((l) => ` ${l}`));
2037
+ for (const r of replies) {
2038
+ lines.push(` ${chalk6.dim("\u21B3")} ${describe(r, list.site.currentVersionId)}`);
2039
+ lines.push(...r.body.split("\n").map((l) => ` ${l}`));
2040
+ }
2041
+ }
2042
+ const summary = chalk6.dim(
2043
+ `${threads.length} ${opts.all ? "" : "open "}thread${threads.length === 1 ? "" : "s"}` + (hidden ? ` (${hidden} resolved \u2014 \`--all\` shows them)` : "") + ` \xB7 review: ${reviewPage(ctx, name)}`
2044
+ );
2045
+ return [...lines, "", summary].join("\n").replace(/^\n/, "");
2046
+ });
2047
+ }
2048
+ async function runComment(ctx, name, text2, opts) {
2049
+ if (!text2.trim()) throw new CliError("The comment is empty");
2050
+ const comment = await addComment(commentsClient(ctx), name, {
2051
+ body: text2,
2052
+ path: opts.path,
2053
+ parentId: opts.reply
2054
+ });
2055
+ ctx.log.success(
2056
+ comment.parentId ? `Replied to ${comment.parentId} on ${name}` : `Comment added on ${name} ${comment.path}`
2057
+ );
2058
+ ctx.out.data(comment, () => reviewPage(ctx, name, comment.path));
2059
+ }
2060
+ async function runResolve(ctx, name, id, opts) {
2061
+ const comment = await resolveComment(commentsClient(ctx), name, id, !opts.reopen);
2062
+ ctx.log.success(opts.reopen ? `Thread ${id} reopened` : `Thread ${id} resolved`);
2063
+ if (ctx.json) ctx.out.data(comment, () => "");
2064
+ }
2065
+ async function runUncomment(ctx, name, id, opts) {
2066
+ if (!opts.yes) {
2067
+ if (!ctx.interactive) {
2068
+ throw new CliError(`Refusing to delete comment ${id} without confirmation`, {
2069
+ hint: "Pass --yes to delete."
2070
+ });
2071
+ }
2072
+ const ok = await ctx.confirm(`Delete comment ${id} (and its replies)? This cannot be undone.`);
2073
+ if (!ok) throw new CliError("Cancelled");
2074
+ }
2075
+ const result = await deleteComment(commentsClient(ctx), name, id);
2076
+ ctx.log.success(`Comment ${id} deleted`);
2077
+ if (ctx.json) ctx.out.data(result, () => "");
2078
+ }
2079
+
1516
2080
  // src/commands/deploy.ts
1517
- import chalk5 from "chalk";
2081
+ import { stat as stat4 } from "node:fs/promises";
2082
+ import { resolve as resolve3 } from "node:path";
2083
+ import chalk7 from "chalk";
1518
2084
 
1519
2085
  // src/deploy.ts
1520
2086
  import { readFile as readFile4 } from "node:fs/promises";
@@ -1534,7 +2100,7 @@ import { join as join2, relative, sep } from "node:path";
1534
2100
  import ignore from "ignore";
1535
2101
  var GIT_TIMEOUT_MS = 2e4;
1536
2102
  function git(cwd, args, input) {
1537
- return new Promise((resolve3) => {
2103
+ return new Promise((resolve5) => {
1538
2104
  try {
1539
2105
  const child = execFile(
1540
2106
  "git",
@@ -1542,14 +2108,14 @@ function git(cwd, args, input) {
1542
2108
  { timeout: GIT_TIMEOUT_MS, maxBuffer: 256 * 1024 * 1024, encoding: "utf8" },
1543
2109
  (error, stdout) => {
1544
2110
  const code = error?.code;
1545
- if (error && !(args[0] === "check-ignore" && code === 1)) resolve3(null);
1546
- else resolve3(stdout);
2111
+ if (error && !(args[0] === "check-ignore" && code === 1)) resolve5(null);
2112
+ else resolve5(stdout);
1547
2113
  }
1548
2114
  );
1549
- child.on("error", () => resolve3(null));
2115
+ child.on("error", () => resolve5(null));
1550
2116
  if (input !== void 0) child.stdin?.end(input);
1551
2117
  } catch {
1552
- resolve3(null);
2118
+ resolve5(null);
1553
2119
  }
1554
2120
  });
1555
2121
  }
@@ -1598,13 +2164,13 @@ function nestedMode() {
1598
2164
  return false;
1599
2165
  },
1600
2166
  async enterDir(absDir, relDir) {
1601
- let text;
2167
+ let text2;
1602
2168
  try {
1603
- text = await readFile2(join2(absDir, ".gitignore"), "utf8");
2169
+ text2 = await readFile2(join2(absDir, ".gitignore"), "utf8");
1604
2170
  } catch {
1605
2171
  return;
1606
2172
  }
1607
- levels.push({ base: relDir, ig: ignore().add(text) });
2173
+ levels.push({ base: relDir, ig: ignore().add(text2) });
1608
2174
  }
1609
2175
  };
1610
2176
  }
@@ -1678,19 +2244,19 @@ function describeSkipped(skipped) {
1678
2244
  lines.push(line(skipped.gitignored.length, "gitignored file", skipped.gitignored));
1679
2245
  return lines;
1680
2246
  }
1681
- function contentTypeFor(path) {
1682
- const type = mime.getType(path) ?? "application/octet-stream";
2247
+ function contentTypeFor(path3) {
2248
+ const type = mime.getType(path3) ?? "application/octet-stream";
1683
2249
  return type.startsWith("text/") || CHARSET_TYPES.has(type) ? `${type}; charset=utf-8` : type;
1684
2250
  }
1685
- async function sha256File(path) {
1686
- const hash = createHash("sha256");
1687
- for await (const chunk of createReadStream(path)) hash.update(chunk);
1688
- return hash.digest("hex");
2251
+ async function sha256File(path3) {
2252
+ const hash3 = createHash("sha256");
2253
+ for await (const chunk of createReadStream(path3)) hash3.update(chunk);
2254
+ return hash3.digest("hex");
1689
2255
  }
1690
2256
  function toManifest(local) {
1691
2257
  return {
1692
- files: local.files.map(({ path, sha256, size, contentType }) => ({
1693
- path,
2258
+ files: local.files.map(({ path: path3, sha256, size, contentType }) => ({
2259
+ path: path3,
1694
2260
  sha256,
1695
2261
  size,
1696
2262
  contentType
@@ -2023,8 +2589,8 @@ async function deploy(deps, opts) {
2023
2589
  totalBytes: local.totalBytes,
2024
2590
  warnings: local.warnings
2025
2591
  };
2026
- const path = `/api/sites/${encodeURIComponent(name)}/deploys`;
2027
- const res = await deps.client.request("POST", path, {
2592
+ const path3 = `/api/sites/${encodeURIComponent(name)}/deploys`;
2593
+ const res = await deps.client.request("POST", path3, {
2028
2594
  schema: DeployResponseSchema,
2029
2595
  body: {
2030
2596
  manifest: toManifest(local),
@@ -2064,7 +2630,7 @@ async function deploy(deps, opts) {
2064
2630
  });
2065
2631
  const fin = await deps.client.request(
2066
2632
  "POST",
2067
- `${path}/${encodeURIComponent(res.versionId)}/finalize`,
2633
+ `${path3}/${encodeURIComponent(res.versionId)}/finalize`,
2068
2634
  { schema: FinalizeResponseSchema }
2069
2635
  );
2070
2636
  return {
@@ -2100,51 +2666,51 @@ import { copyFile, mkdir as mkdir2, mkdtemp, readFile as readFile5, rm, stat as
2100
2666
  import { tmpdir } from "node:os";
2101
2667
  import { basename as basename2, extname, join as join4, resolve as resolve2, sep as sep4 } from "node:path";
2102
2668
  import { unzipSync } from "fflate";
2103
- function inputKind(path, isDirectory) {
2669
+ function inputKind(path3, isDirectory) {
2104
2670
  if (isDirectory) return "folder";
2105
- const ext = extname(path).toLowerCase();
2671
+ const ext = extname(path3).toLowerCase();
2106
2672
  if (ext === ".html" || ext === ".htm") return "html";
2107
2673
  if (ext === ".zip") return "zip";
2108
2674
  return "file";
2109
2675
  }
2110
- function fileSiteName(path) {
2111
- const file = basename2(path.replace(/\\/g, "/"));
2676
+ function fileSiteName(path3) {
2677
+ const file = basename2(path3.replace(/\\/g, "/"));
2112
2678
  const stem = file.slice(0, file.length - extname(file).length) || file;
2113
2679
  return fitName([stem.replace(/\s*\(\d+\)$/, "")]);
2114
2680
  }
2115
- function defaultNameFor(path, isDirectory) {
2116
- return isDirectory ? defaultSiteName(path) : fileSiteName(path);
2681
+ function defaultNameFor(path3, isDirectory) {
2682
+ return isDirectory ? defaultSiteName(path3) : fileSiteName(path3);
2117
2683
  }
2118
2684
  async function tempFolder() {
2119
2685
  const dir = await mkdtemp(join4(tmpdir(), "fling-"));
2120
2686
  return { dir, cleanup: () => rm(dir, { recursive: true, force: true }) };
2121
2687
  }
2122
- async function prepareInput(path) {
2688
+ async function prepareInput(path3) {
2123
2689
  let info;
2124
2690
  try {
2125
- info = await stat3(path);
2691
+ info = await stat3(path3);
2126
2692
  } catch {
2127
- throw new CliError(`Not found: ${path}`);
2693
+ throw new CliError(`Not found: ${path3}`);
2128
2694
  }
2129
- const kind = inputKind(path, info.isDirectory());
2130
- const defaultName = defaultNameFor(path, info.isDirectory());
2695
+ const kind = inputKind(path3, info.isDirectory());
2696
+ const defaultName = defaultNameFor(path3, info.isDirectory());
2131
2697
  if (kind === "folder")
2132
2698
  return {
2133
2699
  kind,
2134
- source: path,
2135
- dir: path,
2700
+ source: path3,
2701
+ dir: path3,
2136
2702
  defaultName,
2137
2703
  skippedDotfiles: [],
2138
2704
  cleanup: async () => {
2139
2705
  }
2140
2706
  };
2141
- const file = basename2(path);
2707
+ const file = basename2(path3);
2142
2708
  if (file.startsWith("."))
2143
2709
  throw new CliError(`Not flung: ${file} is a dotfile. ${NEVER_PUBLISHED_NOTE}`);
2144
2710
  if (kind === "file" && isSecretPath(file)) throw new SecretFilesError([file]);
2145
2711
  if (info.size > MAX_DEPLOY_BYTES)
2146
2712
  throw new CliError(
2147
- `${basename2(path)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_DEPLOY_BYTES)}`
2713
+ `${basename2(path3)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_DEPLOY_BYTES)}`
2148
2714
  );
2149
2715
  const tmp = await tempFolder();
2150
2716
  const skippedDotfiles = [];
@@ -2152,21 +2718,21 @@ async function prepareInput(path) {
2152
2718
  if (kind === "html") {
2153
2719
  if (info.size > MAX_FILE_BYTES)
2154
2720
  throw new CliError(
2155
- `${basename2(path)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`
2721
+ `${basename2(path3)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`
2156
2722
  );
2157
- await copyFile(path, join4(tmp.dir, "index.html"));
2723
+ await copyFile(path3, join4(tmp.dir, "index.html"));
2158
2724
  } else if (kind === "zip") {
2159
- await extractZip(await readFile5(path), tmp.dir, skippedDotfiles);
2725
+ await extractZip(await readFile5(path3), tmp.dir, skippedDotfiles);
2160
2726
  } else {
2161
- const name = basename2(path);
2162
- await copyFile(path, join4(tmp.dir, name));
2727
+ const name = basename2(path3);
2728
+ await copyFile(path3, join4(tmp.dir, name));
2163
2729
  await writeFile2(join4(tmp.dir, "index.html"), wrapperPage(name));
2164
2730
  }
2165
2731
  } catch (error) {
2166
2732
  await tmp.cleanup();
2167
2733
  throw error;
2168
2734
  }
2169
- return { kind, source: path, dir: tmp.dir, defaultName, skippedDotfiles, cleanup: tmp.cleanup };
2735
+ return { kind, source: path3, dir: tmp.dir, defaultName, skippedDotfiles, cleanup: tmp.cleanup };
2170
2736
  }
2171
2737
  var JUNK_FILES = /* @__PURE__ */ new Set(["thumbs.db", "desktop.ini"]);
2172
2738
  function zipEntryPath(name) {
@@ -2206,12 +2772,12 @@ async function extractZip(data, dest, skippedDotfiles = []) {
2206
2772
  try {
2207
2773
  entries = unzipSync(data, {
2208
2774
  filter: (file) => {
2209
- const path = zipEntryPath(file.name);
2210
- if (path === "unsafe") {
2775
+ const path3 = zipEntryPath(file.name);
2776
+ if (path3 === "unsafe") {
2211
2777
  unsafe.push(file.name);
2212
2778
  return false;
2213
2779
  }
2214
- if (path === null) {
2780
+ if (path3 === null) {
2215
2781
  const dot = zipDotEntry(file.name);
2216
2782
  if (dot && !skippedDotfiles.includes(dot)) skippedDotfiles.push(dot);
2217
2783
  return false;
@@ -2220,7 +2786,7 @@ async function extractZip(data, dest, skippedDotfiles = []) {
2220
2786
  declared += file.originalSize;
2221
2787
  if (file.originalSize > MAX_FILE_BYTES)
2222
2788
  throw new CliError(
2223
- `${path} in the zip is ${formatBytes(file.originalSize)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`
2789
+ `${path3} in the zip is ${formatBytes(file.originalSize)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`
2224
2790
  );
2225
2791
  if (declared > MAX_DEPLOY_BYTES)
2226
2792
  throw new CliError(
@@ -2297,315 +2863,25 @@ function wrapperPage(file) {
2297
2863
  `;
2298
2864
  }
2299
2865
 
2300
- // src/locate.ts
2301
- import { lstat, readdir as readdir2, readFile as readFile6, realpath as realpath3, stat as stat4 } from "node:fs/promises";
2302
- import { posix, win32 } from "node:path";
2303
- import { fileURLToPath } from "node:url";
2304
- var DOWNLOADS_DIR_ENV = "FLING_DOWNLOADS_DIR";
2305
- var FOUND_AS_GIVEN = "path";
2306
- var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "__MACOSX", "Library", "AppData", "$RECYCLE.BIN"]);
2307
- var SESSION_SKIP_DIRS = /* @__PURE__ */ new Set(["skills-plugin", "rpm", "uploads", "memory"]);
2308
- var SESSIONS_DEPTH = 4;
2309
- var OUTPUTS_DEPTH = 4;
2310
- var SANDBOX_PATH_RE = /^\/(mnt\/user-data|mnt\/data|sessions|home\/claude|tmp\/outputs)(\/|$)/;
2311
- var nodeFs = {
2312
- async stat(path) {
2313
- try {
2314
- const s = await stat4(path);
2315
- return { isFile: s.isFile(), isDirectory: s.isDirectory(), mtimeMs: s.mtimeMs, size: s.size };
2316
- } catch {
2317
- return null;
2318
- }
2319
- },
2320
- async readdir(path) {
2321
- try {
2322
- const entries = await readdir2(path, { withFileTypes: true });
2323
- const out = [];
2324
- for (const e of entries) {
2325
- if (e.isSymbolicLink()) {
2326
- const s = await stat4(`${path}/${e.name}`).catch(() => null);
2327
- out.push({ name: e.name, isFile: Boolean(s?.isFile()), isDirectory: false });
2328
- } else out.push({ name: e.name, isFile: e.isFile(), isDirectory: e.isDirectory() });
2329
- }
2330
- return out;
2331
- } catch {
2332
- return [];
2333
- }
2334
- },
2335
- async realpath(path) {
2336
- return realpath3(path).catch(() => null);
2337
- },
2338
- async readText(path) {
2339
- try {
2340
- if (!(await lstat(path)).isFile()) return null;
2341
- return await readFile6(path, "utf8");
2342
- } catch {
2343
- return null;
2344
- }
2345
- }
2346
- };
2347
- var pathFor = (platform) => platform === "win32" ? win32 : posix;
2348
- var usable = (value) => Boolean(value?.trim()) && !value?.trim().startsWith("${");
2349
- async function xdgUserDir(key, opts, fs) {
2350
- const fromEnv2 = opts.env[`XDG_${key}_DIR`];
2351
- if (usable(fromEnv2)) return fromEnv2;
2352
- const configHome = opts.env.XDG_CONFIG_HOME || posix.join(opts.home, ".config");
2353
- const text = await fs.readText(posix.join(configHome, "user-dirs.dirs"));
2354
- const match = text?.match(new RegExp(`^XDG_${key}_DIR="([^"]*)"`, "m"));
2355
- if (!match?.[1]) return void 0;
2356
- return match[1].replace(/^\$HOME(?=\/|$)/, opts.home);
2357
- }
2358
- async function searchRoots(opts) {
2359
- const fs = opts.fs ?? nodeFs;
2360
- const p = pathFor(opts.platform);
2361
- const { home, env, platform } = opts;
2362
- const userHome = platform === "win32" && usable(env.USERPROFILE) ? env.USERPROFILE : home;
2363
- const roots = [];
2364
- const add = (label, path, depth, sessions = false) => {
2365
- if (!path) return;
2366
- const norm = p.resolve(path);
2367
- if (roots.some((r) => r.path === norm)) return;
2368
- roots.push({ label, path: norm, depth, ...sessions ? { sessions } : {} });
2369
- };
2370
- const cwd = p.resolve(opts.cwd);
2371
- if (cwd !== p.parse(cwd).root && cwd !== p.resolve(userHome)) add("current folder", cwd, 4);
2372
- const configured = usable(opts.downloadsDir) ? opts.downloadsDir : usable(env[DOWNLOADS_DIR_ENV]) ? env[DOWNLOADS_DIR_ENV] : void 0;
2373
- const defaultDownloads = platform === "linux" ? await xdgUserDir("DOWNLOAD", opts, fs) ?? p.join(home, "Downloads") : p.join(userHome, "Downloads");
2374
- add("Downloads", configured ? expandHome(configured, home, platform) : defaultDownloads, 3);
2375
- add("Downloads", defaultDownloads, 3);
2376
- const desktop = platform === "linux" ? await xdgUserDir("DESKTOP", opts, fs) ?? p.join(home, "Desktop") : p.join(userHome, "Desktop");
2377
- add("Desktop", desktop, 3);
2378
- if (platform === "win32" && usable(env.OneDrive))
2379
- add("Desktop", p.join(env.OneDrive, "Desktop"), 3);
2380
- const documents = platform === "linux" ? await xdgUserDir("DOCUMENTS", opts, fs) ?? p.join(home, "Documents") : p.join(userHome, "Documents");
2381
- add("Documents/Claude", p.join(documents, "Claude"), 4);
2382
- add("~/Claude", p.join(userHome, "Claude"), 4);
2383
- const claudeConfig = platform === "darwin" ? p.join(home, "Library", "Application Support", "Claude") : platform === "win32" ? p.join(
2384
- usable(env.APPDATA) ? env.APPDATA : p.join(userHome, "AppData", "Roaming"),
2385
- "Claude"
2386
- ) : p.join(env.XDG_CONFIG_HOME || p.join(home, ".config"), "Claude");
2387
- add(
2388
- "Claude Desktop outputs",
2389
- p.join(claudeConfig, "local-agent-mode-sessions"),
2390
- SESSIONS_DEPTH,
2391
- true
2392
- );
2393
- if (!fs.realpath) return roots;
2394
- const real = [];
2395
- for (const root of roots) {
2396
- const path = await fs.realpath(root.path) ?? root.path;
2397
- if (!real.some((r) => r.path === path)) real.push({ ...root, path });
2398
- }
2399
- return real;
2400
- }
2401
- function expandHome(input, home, platform) {
2402
- if (input === "~") return home;
2403
- if (input.startsWith("~/") || platform === "win32" && input.startsWith("~\\"))
2404
- return pathFor(platform).join(home, input.slice(2));
2405
- return input;
2406
- }
2407
- async function walk(roots, opts, visit) {
2408
- const fs = opts.fs ?? nodeFs;
2409
- const p = pathFor(opts.platform);
2410
- const now = opts.now ?? Date.now;
2411
- const deadline = now() + (opts.timeBudgetMs ?? 4e3);
2412
- let budget = opts.entryBudget ?? 6e4;
2413
- const skip = (name) => name.startsWith(".") || SKIP_DIRS.has(name);
2414
- const expanded = [];
2415
- for (const root of roots) {
2416
- if (!root.sessions) {
2417
- expanded.push(root);
2418
- continue;
2419
- }
2420
- let level = [root.path];
2421
- for (let depth = 0; depth < root.depth && level.length; depth++) {
2422
- const next = [];
2423
- for (const dir of level) {
2424
- for (const e of await fs.readdir(dir)) {
2425
- if (!e.isDirectory || skip(e.name) || SESSION_SKIP_DIRS.has(e.name)) continue;
2426
- const child = p.join(dir, e.name);
2427
- if (e.name === "outputs")
2428
- expanded.push({ label: root.label, path: child, depth: OUTPUTS_DEPTH });
2429
- else next.push(child);
2430
- }
2431
- }
2432
- level = next;
2433
- }
2434
- }
2435
- for (const root of expanded) {
2436
- let level = [root.path];
2437
- for (let depth = 0; depth <= root.depth && level.length; depth++) {
2438
- const next = [];
2439
- for (const dir of level) {
2440
- if (now() > deadline || budget <= 0) return { complete: false };
2441
- const entries = await fs.readdir(dir);
2442
- budget -= entries.length;
2443
- for (const entry of entries) {
2444
- if (entry.name.startsWith(".")) continue;
2445
- const path = p.join(dir, entry.name);
2446
- const result = await visit({ root, path, entry, siblings: entries });
2447
- if (entry.isDirectory && !skip(entry.name) && result !== "skip" && depth < root.depth)
2448
- next.push(path);
2449
- }
2450
- }
2451
- level = next;
2452
- }
2453
- }
2454
- return { complete: true };
2455
- }
2456
- function withoutDuplicateSuffix(name) {
2457
- return name.replace(/ \(\d+\)(?=\.[^.]+$|$)/, "");
2458
- }
2459
- function namesMatch(candidate, wanted) {
2460
- const a = candidate.toLowerCase();
2461
- const b = wanted.toLowerCase();
2462
- return a === b || withoutDuplicateSuffix(a) === withoutDuplicateSuffix(b);
2463
- }
2464
- var splitSegments = (path) => path.split(/[\\/]+/).filter((s) => s !== "" && s !== "." && s !== "~");
2465
- function suffixScore(path, wanted) {
2466
- const have = splitSegments(path);
2467
- let score = 1;
2468
- for (let i = 2; i <= Math.min(have.length, wanted.length); i++) {
2469
- if (have[have.length - i]?.toLowerCase() !== wanted[wanted.length - i]?.toLowerCase()) break;
2470
- score++;
2471
- }
2472
- return score;
2473
- }
2474
- var looksLikeSandboxPath = (input) => SANDBOX_PATH_RE.test(input.replace(/\\/g, "/"));
2475
- function normaliseInput(raw, opts) {
2476
- let input = raw.trim().replace(/^(["'])(.*)\1$/, "$2");
2477
- if (input.startsWith("file://")) {
2478
- try {
2479
- input = fileURLToPath(input, { windows: opts.platform === "win32" });
2480
- } catch {
2481
- input = decodeURIComponent(input.slice("file://".length));
2482
- }
2483
- }
2484
- return expandHome(input, opts.home, opts.platform);
2485
- }
2486
- async function resolveInput(input, opts) {
2487
- const fs = opts.fs ?? nodeFs;
2488
- const p = pathFor(opts.platform);
2489
- const given = normaliseInput(input, opts);
2490
- if (!given) throw new CliError("No path given");
2491
- const direct = p.isAbsolute(given) ? p.normalize(given) : p.resolve(opts.cwd, given);
2492
- const directStat = await fs.stat(direct);
2493
- if (directStat && (directStat.isFile || directStat.isDirectory)) {
2494
- return {
2495
- path: direct,
2496
- foundIn: FOUND_AS_GIVEN,
2497
- isDirectory: directStat.isDirectory,
2498
- alternatives: []
2499
- };
2500
- }
2501
- const wanted = splitSegments(given);
2502
- const name = wanted.at(-1);
2503
- const roots = await searchRoots(opts);
2504
- if (!name) throw notFound(input, roots);
2505
- const found = [];
2506
- const seen = /* @__PURE__ */ new Set();
2507
- await walk(roots, opts, async ({ root, path, entry }) => {
2508
- if (!(entry.isFile || entry.isDirectory) || !namesMatch(entry.name, name)) return;
2509
- if (seen.has(path)) return;
2510
- seen.add(path);
2511
- const s = await fs.stat(path);
2512
- if (!s) return;
2513
- found.push({
2514
- path,
2515
- foundIn: root.label,
2516
- kind: s.isDirectory ? "folder" : "file",
2517
- modified: new Date(s.mtimeMs).toISOString(),
2518
- size: s.size,
2519
- score: suffixScore(path, wanted),
2520
- mtimeMs: s.mtimeMs,
2521
- isDirectory: s.isDirectory
2522
- });
2523
- return void 0;
2524
- });
2525
- if (found.length === 0) throw notFound(input, roots);
2526
- found.sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs);
2527
- const strip = ({ score: _s, mtimeMs: _m, isDirectory: _d, ...c }) => c;
2528
- const [best, ...rest] = found;
2529
- return {
2530
- path: best.path,
2531
- foundIn: best.foundIn,
2532
- isDirectory: best.isDirectory,
2533
- alternatives: rest.slice(0, 5).map(strip)
2534
- };
2535
- }
2536
- function notFound(input, roots) {
2537
- const where = roots.map((r) => `${r.label} (${r.path})`).join(", ");
2538
- 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." : "";
2539
- return new CliError(`Could not find "${input}". Looked for it by name in: ${where}.${sandbox}`, {
2540
- hint: "Pass the full path, or save the file to your Downloads folder and use just its name."
2541
- });
2542
- }
2543
- var LISTED_EXT = /\.(html?|zip)$/i;
2544
- async function findFiles(opts, find = {}) {
2545
- const fs = opts.fs ?? nodeFs;
2546
- const p = pathFor(opts.platform);
2547
- const now = (opts.now ?? Date.now)();
2548
- const since = now - (find.days ?? 14) * 24 * 60 * 60 * 1e3;
2549
- const query = find.query?.trim().toLowerCase();
2550
- const roots = await searchRoots(opts);
2551
- const found = /* @__PURE__ */ new Map();
2552
- const rootPaths = new Set(roots.map((r) => r.path));
2553
- const { complete } = await walk(roots, opts, async ({ root, path, entry }) => {
2554
- if (entry.isDirectory) {
2555
- if (rootPaths.has(path)) return;
2556
- const index = (await fs.readdir(path)).find((e) => e.isFile && /^index\.html?$/i.test(e.name));
2557
- if (!index) return;
2558
- const s2 = await fs.stat(p.join(path, index.name));
2559
- if (s2 && s2.mtimeMs >= since && (!query || entry.name.toLowerCase().includes(query)))
2560
- found.set(path, {
2561
- path,
2562
- foundIn: root.label,
2563
- kind: "folder",
2564
- modified: new Date(s2.mtimeMs).toISOString(),
2565
- size: s2.size,
2566
- mtimeMs: s2.mtimeMs
2567
- });
2568
- return "skip";
2569
- }
2570
- if (!entry.isFile || !LISTED_EXT.test(entry.name)) return;
2571
- if (query && !entry.name.toLowerCase().includes(query)) return;
2572
- const s = await fs.stat(path);
2573
- if (!s || s.mtimeMs < since || found.has(path)) return;
2574
- found.set(path, {
2575
- path,
2576
- foundIn: root.label,
2577
- kind: "file",
2578
- modified: new Date(s.mtimeMs).toISOString(),
2579
- size: s.size,
2580
- mtimeMs: s.mtimeMs
2581
- });
2582
- return void 0;
2583
- });
2584
- const files = [...found.values()].sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, find.limit ?? 20).map(({ mtimeMs: _m, ...c }) => c);
2585
- return { files, searched: roots, complete };
2586
- }
2587
-
2588
2866
  // src/commands/deploy.ts
2589
2867
  async function runDeploy(ctx, input, opts) {
2590
2868
  const client = requireClient(ctx);
2591
2869
  const ask = ctx.interactive && !opts.yes;
2592
- const base = ask ? await discoverSitesBase(client) : sitesBaseFor(client.serverUrl);
2593
- const located = await resolveInput(input ?? ".", ctx.locate ?? systemLocateOptions());
2594
- if (located.foundIn !== FOUND_AS_GIVEN) {
2595
- ctx.log.info(`Found in ${located.foundIn}: ${located.path}`);
2596
- if (located.alternatives.length)
2597
- ctx.log.hint(
2598
- `Also found (older): ${located.alternatives.map((a) => a.path).join(", ")} \u2014 pass the full path to fling another.`
2599
- );
2600
- }
2601
- const prepared = await prepareInput(located.path);
2870
+ const instance = await loadInstance(ctx.store, ctx.config, ctx.fetch);
2871
+ const base = sitesBaseOf(instance);
2872
+ const path3 = resolve3(input ?? ".");
2873
+ if (!await stat4(path3).catch(() => null))
2874
+ throw new CliError(`No such file or folder: ${input ?? "."}`, {
2875
+ hint: `Give the path to a folder, .html, .zip or other file, e.g. \`${ctx.binName} deploy ./dist\`.`
2876
+ });
2877
+ const prepared = await prepareInput(path3);
2602
2878
  try {
2603
- await deployPrepared(ctx, client, base, prepared, opts, ask);
2879
+ await deployPrepared(ctx, client, instance, base, prepared, opts, ask);
2604
2880
  } finally {
2605
2881
  await prepared.cleanup();
2606
2882
  }
2607
2883
  }
2608
- async function deployPrepared(ctx, client, base, prepared, opts, ask) {
2884
+ async function deployPrepared(ctx, client, instance, base, prepared, opts, ask) {
2609
2885
  let local = await buildManifest(prepared.dir);
2610
2886
  local.skipped.dotfiles.unshift(...prepared.skippedDotfiles);
2611
2887
  for (const line of describeSkipped(local.skipped)) ctx.log.info(line);
@@ -2634,7 +2910,7 @@ async function deployPrepared(ctx, client, base, prepared, opts, ask) {
2634
2910
  if (p.total === 0) return;
2635
2911
  progressShown = true;
2636
2912
  ctx.progress(
2637
- `${chalk5.cyan("fling")} uploading ${p.uploaded}/${p.total} files \xB7 ${formatBytes(p.bytes)}/${formatBytes(p.totalBytes)}`
2913
+ `${progressBar(p.totalBytes ? p.bytes / p.totalBytes : p.uploaded / p.total)} uploading ${p.uploaded}/${p.total} files \xB7 ${formatBytes(p.bytes)}/${formatBytes(p.totalBytes)}`
2638
2914
  );
2639
2915
  }
2640
2916
  }
@@ -2643,12 +2919,12 @@ async function deployPrepared(ctx, client, base, prepared, opts, ask) {
2643
2919
  });
2644
2920
  for (const warning of result.warnings) if (warning !== NO_INDEX_WARNING) ctx.log.warn(warning);
2645
2921
  const summary = `${result.fileCount} files, ${formatBytes(result.totalBytes)}`;
2646
- if (result.status === "unchanged") ctx.log.info(`Unchanged ${chalk5.dim(`(${summary})`)}`);
2922
+ if (result.status === "unchanged") ctx.log.info(`Unchanged ${chalk7.dim(`(${summary})`)}`);
2647
2923
  else if (result.status === "repointed")
2648
- ctx.log.success(`Restored version ${result.versionId} ${chalk5.dim(`(${summary})`)}`);
2924
+ ctx.log.success(`Restored version ${result.versionId} ${chalk7.dim(`(${summary})`)}`);
2649
2925
  else
2650
2926
  ctx.log.success(
2651
- `Deployed ${result.name} ${chalk5.dim(`(${summary}; uploaded ${result.uploaded}, version ${result.versionId})`)}`
2927
+ `Deployed ${result.name} ${chalk7.dim(`(${summary}; uploaded ${result.uploaded}, version ${result.versionId})`)}`
2652
2928
  );
2653
2929
  const ready = result.status === "unchanged" ? true : (await waitWithSpinner(
2654
2930
  ctx.waitReady,
@@ -2656,17 +2932,39 @@ async function deployPrepared(ctx, client, base, prepared, opts, ask) {
2656
2932
  local.files.map((f) => f.path),
2657
2933
  { progress: ctx.progress, warn: (m) => ctx.log.warn(m) }
2658
2934
  )).ready;
2659
- ctx.out.data({ status: result.status, site: result.site, ready, skipped: local.skipped }, () => {
2660
- const lines = [chalk5.bold(result.url)];
2661
- if (result.site.visibility === "private")
2662
- lines.push(
2663
- chalk5.dim(
2664
- `Private: Nearform sign-in required. Share publicly with \`${ctx.binName} share ${result.name}\`.`
2665
- )
2935
+ const who = instance ? `only ${privateAudience(instance)} can open it` : "sign-in required";
2936
+ const privateNote = `Private: ${who}. Share publicly with \`${ctx.binName} share ${result.name}\`.`;
2937
+ const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
2938
+ const reviewNote = instance ? `Share for feedback (reviewers sign in to ${instance.name}):` : "Share for feedback:";
2939
+ const data = {
2940
+ status: result.status,
2941
+ site: result.site,
2942
+ ...review ? { reviewUrl: review } : {},
2943
+ ready,
2944
+ skipped: local.skipped
2945
+ };
2946
+ ctx.out.data(data, () => {
2947
+ const isPrivate = result.site.visibility === "private";
2948
+ if (ctx.out.tty)
2949
+ return box(
2950
+ [
2951
+ ...review ? [chalk7.dim(reviewNote), brand.url(review), "", chalk7.dim("Site:")] : [],
2952
+ brand.url(result.url),
2953
+ ...isPrivate ? [chalk7.dim(privateNote)] : []
2954
+ ],
2955
+ isPrivate ? chalk7.bold("flung \xB7 private") : chalk7.bold("flung \xB7 public")
2666
2956
  );
2957
+ const lines = [chalk7.bold(result.url)];
2958
+ if (review) lines.push(chalk7.dim(`${reviewNote} ${review}`));
2959
+ if (isPrivate) lines.push(chalk7.dim(privateNote));
2667
2960
  return lines.join("\n");
2668
2961
  });
2669
2962
  }
2963
+ var BAR_WIDTH = 20;
2964
+ function progressBar(fraction) {
2965
+ const done = Math.round(Math.min(Math.max(fraction, 0), 1) * BAR_WIDTH);
2966
+ return brand.primary("\u2501".repeat(done)) + chalk7.dim("\u2500".repeat(BAR_WIDTH - done));
2967
+ }
2670
2968
  async function askNoIndex(ctx, pages) {
2671
2969
  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 ";
2672
2970
  for (; ; ) {
@@ -2681,20 +2979,20 @@ async function askNoIndex(ctx, pages) {
2681
2979
  }
2682
2980
  async function confirmSiteName(ctx, client, generated, base) {
2683
2981
  const where = (n) => base ? ` \u2192 ${siteUrlFor(n, base)}` : "";
2684
- const describe = (n, note) => ctx.log.info(`Site name: ${chalk5.bold(n)}${where(n)} ${chalk5.dim(`(${note})`)}`);
2982
+ const describe2 = (n, note) => ctx.log.info(`Site name: ${chalk7.bold(n)}${where(n)} ${chalk7.dim(`(${note})`)}`);
2685
2983
  let candidate = generated;
2686
2984
  for (; ; ) {
2687
- const check = await checkName(client, candidate);
2985
+ const check2 = await checkName(client, candidate);
2688
2986
  let proposal;
2689
- if (check.status === "available" || check.status === "yours") {
2987
+ if (check2.status === "available" || check2.status === "yours") {
2690
2988
  proposal = candidate;
2691
- describe(candidate, check.status === "yours" ? "updates your existing site" : "new");
2989
+ describe2(candidate, check2.status === "yours" ? "updates your existing site" : "new");
2692
2990
  } else {
2693
- const why = check.status === "taken" ? "is taken" : "is not a valid site name";
2694
- const alternatives = check.suggestions.length ? ` Suggestions: ${check.suggestions.join(", ")}` : "";
2991
+ const why = check2.status === "taken" ? "is taken" : "is not a valid site name";
2992
+ const alternatives = check2.suggestions.length ? ` Suggestions: ${check2.suggestions.join(", ")}` : "";
2695
2993
  ctx.log.warn(`${candidate} ${why}.${alternatives}`);
2696
- proposal = check.suggestions[0];
2697
- if (proposal) describe(proposal, "new");
2994
+ proposal = check2.suggestions[0];
2995
+ if (proposal) describe2(proposal, "new");
2698
2996
  }
2699
2997
  for (; ; ) {
2700
2998
  const question = proposal ? `Use "${proposal}"? [Y/n or type another name] ` : "Type a site name (or n to abort): ";
@@ -2717,8 +3015,215 @@ async function confirmSiteName(ctx, client, generated, base) {
2717
3015
  }
2718
3016
  }
2719
3017
 
3018
+ // src/commands/setup.ts
3019
+ import { intro, outro, spinner } from "@clack/prompts";
3020
+ import chalk8 from "chalk";
3021
+
3022
+ // src/claude.ts
3023
+ import { execFile as execFile2 } from "node:child_process";
3024
+ import { access, mkdtemp as mkdtemp2, writeFile as writeFile3 } from "node:fs/promises";
3025
+ import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
3026
+ import { join as join5 } from "node:path";
3027
+ var MARKETPLACE_SOURCE = REPO_SLUG;
3028
+ var MARKETPLACE_NAME = "fling";
3029
+ var PLUGIN_NAME = "fling";
3030
+ var PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
3031
+ var MCPB_PATH = "/downloads/fling.mcpb";
3032
+ var runClaude = (args) => new Promise((resolve5) => {
3033
+ execFile2(
3034
+ "claude",
3035
+ [...args],
3036
+ { timeout: 12e4, maxBuffer: 16 * 1024 * 1024, shell: process.platform === "win32" },
3037
+ (error, stdout, stderr) => {
3038
+ const missing = error?.code === "ENOENT";
3039
+ const code = !error ? 0 : missing ? -1 : typeof error.code === "number" ? error.code : 1;
3040
+ resolve5({ code, stdout: String(stdout), stderr: String(stderr) });
3041
+ }
3042
+ );
3043
+ });
3044
+ async function claudeCodeStatus(run) {
3045
+ const version = await run(["--version"]);
3046
+ if (version.code !== 0) return { claude: "missing" };
3047
+ const claude = version.stdout.trim().split(/\s+/)[0] ?? "unknown";
3048
+ const list = await run(["plugin", "list", "--json"]);
3049
+ let plugins = [];
3050
+ try {
3051
+ plugins = JSON.parse(list.stdout);
3052
+ } catch {
3053
+ }
3054
+ const fling = Array.isArray(plugins) ? plugins.find((p) => p.id === PLUGIN_ID) : void 0;
3055
+ if (!fling) return { claude, installed: false };
3056
+ return {
3057
+ claude,
3058
+ installed: true,
3059
+ version: fling.version,
3060
+ scope: fling.scope,
3061
+ enabled: fling.enabled
3062
+ };
3063
+ }
3064
+ async function installPlugin(run, scope) {
3065
+ check(
3066
+ await run(["plugin", "install", PLUGIN_NAME, "--marketplace", MARKETPLACE_SOURCE, "-s", scope]),
3067
+ "install the Fling plugin"
3068
+ );
3069
+ }
3070
+ async function updatePlugin(run, scope) {
3071
+ check(await run(["plugin", "marketplace", "update", MARKETPLACE_NAME]), "update the marketplace");
3072
+ check(
3073
+ await run(["plugin", "update", PLUGIN_ID, ...scope ? ["-s", scope] : []]),
3074
+ "update the Fling plugin"
3075
+ );
3076
+ }
3077
+ function check(result, what) {
3078
+ if (result.code === 0) return;
3079
+ const detail = (result.stderr || result.stdout).trim().split("\n").at(-1);
3080
+ throw new Error(`Could not ${what}${detail ? `: ${detail}` : ""}`);
3081
+ }
3082
+ async function findClaudeDesktop(platform = process.platform, env = process.env, exists = (path3) => access(path3).then(
3083
+ () => true,
3084
+ () => false
3085
+ )) {
3086
+ const candidates = platform === "darwin" ? ["/Applications/Claude.app", join5(homedir3(), "Applications", "Claude.app")] : platform === "win32" && env.LOCALAPPDATA ? [join5(env.LOCALAPPDATA, "AnthropicClaude"), join5(env.LOCALAPPDATA, "Programs", "Claude")] : [];
3087
+ for (const path3 of candidates) if (await exists(path3)) return path3;
3088
+ return null;
3089
+ }
3090
+ async function downloadExtension(serverUrl, fetch2) {
3091
+ const url = new URL(MCPB_PATH, serverUrl).toString();
3092
+ const response = await fetch2(url);
3093
+ if (!response.ok) throw new Error(`Could not download ${url} (HTTP ${response.status})`);
3094
+ const dir = await mkdtemp2(join5(tmpdir2(), "fling-mcpb-"));
3095
+ const file = join5(dir, "fling.mcpb");
3096
+ await writeFile3(file, Buffer.from(await response.arrayBuffer()));
3097
+ return file;
3098
+ }
3099
+
3100
+ // src/commands/setup.ts
3101
+ var SCOPES = ["user", "project", "local"];
3102
+ async function runSetup(baseCtx, opts, deps = {}) {
3103
+ const scope = opts.scope ?? "user";
3104
+ if (!SCOPES.includes(scope))
3105
+ throw new CliError(`--scope must be one of ${SCOPES.join(", ")}, not "${opts.scope}"`);
3106
+ const run = deps.runClaude ?? runClaude;
3107
+ const ask = baseCtx.interactive && !opts.yes;
3108
+ const fancy = baseCtx.interactive && !baseCtx.json;
3109
+ const ctx = fancy ? {
3110
+ ...baseCtx,
3111
+ log: createLogger({
3112
+ debug: baseCtx.config.debug,
3113
+ write: (line) => process.stderr.write(`${chalk8.gray("\u2502")} ${line}
3114
+ `)
3115
+ })
3116
+ } : baseCtx;
3117
+ if (fancy) {
3118
+ process.stderr.write(`${logo()}
3119
+ `);
3120
+ intro(chalk8.bold(" fling setup "), { output: process.stderr });
3121
+ }
3122
+ const results = {
3123
+ login: await stepLogin(ctx, opts.url),
3124
+ claudeCode: "skipped",
3125
+ claudeDesktop: "skipped"
3126
+ };
3127
+ const after = results.login === "done" ? { ...ctx, config: await ctx.store.resolve() } : ctx;
3128
+ results.claudeCode = await stepClaudeCode(after, run, scope, ask, fancy);
3129
+ results.claudeDesktop = await stepClaudeDesktop(after, deps, ask, fancy);
3130
+ const failed = Object.values(results).includes("failed");
3131
+ if (fancy)
3132
+ outro(
3133
+ failed ? chalk8.yellow("Setup finished with problems \u2014 see above, then run `fling setup` again.") : `You're set. Try ${chalk8.bold(`${ctx.binName} deploy ./dist`)}, or ask Claude to \u201Cfling this\u201D.`,
3134
+ { output: process.stderr }
3135
+ );
3136
+ ctx.out.data(results, () => "");
3137
+ if (failed) process.exitCode = 1;
3138
+ }
3139
+ async function stepLogin(ctx, url) {
3140
+ const sameServer = url === void 0 || safeNormalize(url) === ctx.config.serverUrl;
3141
+ if (ctx.config.apiKey && sameServer) {
3142
+ try {
3143
+ const me = await whoami(requireClient(ctx));
3144
+ ctx.log.success(`Signed in as ${chalk8.bold(me.name)} ${chalk8.dim(`<${me.email}>`)}`);
3145
+ return "already";
3146
+ } catch (error) {
3147
+ if (exitCodeFor(error) !== 2) {
3148
+ ctx.log.error(error instanceof Error ? error.message : String(error));
3149
+ return "failed";
3150
+ }
3151
+ ctx.log.warn("The stored API key no longer works \u2014 signing in again");
3152
+ }
3153
+ }
3154
+ try {
3155
+ await runLogin({ ...ctx, json: false }, url);
3156
+ return "done";
3157
+ } catch (error) {
3158
+ ctx.log.error(error instanceof Error ? error.message : String(error));
3159
+ return "failed";
3160
+ }
3161
+ }
3162
+ function safeNormalize(url) {
3163
+ try {
3164
+ return normalizeServerUrl(url);
3165
+ } catch {
3166
+ return void 0;
3167
+ }
3168
+ }
3169
+ async function stepClaudeCode(ctx, run, scope, ask, fancy) {
3170
+ const status = await claudeCodeStatus(run);
3171
+ if (status.claude === "missing") {
3172
+ ctx.log.info(`Claude Code not found ${chalk8.dim("(no `claude` on your PATH) \u2014 skipped")}`);
3173
+ ctx.log.hint(
3174
+ `Once it is installed, run \`${ctx.binName} setup\` again, or in Claude Code: /plugin marketplace add ${MARKETPLACE_SOURCE} then /plugin install ${PLUGIN_ID}`
3175
+ );
3176
+ return "missing";
3177
+ }
3178
+ const installed = "installed" in status && status.installed;
3179
+ const question = installed ? `The Fling plugin is installed in Claude Code${status.version ? ` (${status.version})` : ""}. Update it to the latest?` : "Install the Fling plugin (skill + MCP server) into Claude Code?";
3180
+ if (ask && !await ctx.confirm(question)) {
3181
+ ctx.log.info(chalk8.dim("Claude Code plugin: skipped"));
3182
+ return installed ? "already" : "skipped";
3183
+ }
3184
+ const s = fancy ? spinner({ output: process.stderr }) : null;
3185
+ s?.start(installed ? "Updating the Claude Code plugin" : "Installing the Claude Code plugin");
3186
+ try {
3187
+ if (installed) await updatePlugin(run, status.scope);
3188
+ else await installPlugin(run, scope);
3189
+ } catch (error) {
3190
+ const message = error instanceof Error ? error.message : String(error);
3191
+ if (s) s.error(message);
3192
+ else ctx.log.error(message);
3193
+ return "failed";
3194
+ }
3195
+ const done = installed ? `Claude Code plugin ${PLUGIN_NAME} is up to date` : `Claude Code plugin ${PLUGIN_NAME} installed ${chalk8.dim(`(${scope} scope)`)}`;
3196
+ if (s) s.stop(done);
3197
+ else ctx.log.success(done);
3198
+ ctx.log.hint("Restart Claude Code (or run /reload-plugins) to pick it up.");
3199
+ return installed ? "updated" : "done";
3200
+ }
3201
+ async function stepClaudeDesktop(ctx, deps, ask, fancy) {
3202
+ const app = await (deps.findClaudeDesktop ?? findClaudeDesktop)();
3203
+ if (!app) return "missing";
3204
+ if (ask && !await ctx.confirm("Add the Fling extension to Claude Desktop too?")) {
3205
+ ctx.log.info(chalk8.dim("Claude Desktop extension: skipped"));
3206
+ return "skipped";
3207
+ }
3208
+ const s = fancy ? spinner({ output: process.stderr }) : null;
3209
+ s?.start("Downloading the Claude Desktop extension");
3210
+ try {
3211
+ const file = await (deps.downloadExtension ?? ((serverUrl) => downloadExtension(serverUrl, ctx.fetch)))(requireServer(ctx));
3212
+ await ctx.open(file);
3213
+ const done = "Opened fling.mcpb in Claude Desktop \u2014 click Install there to finish";
3214
+ if (s) s.stop(done);
3215
+ else ctx.log.success(done);
3216
+ return "done";
3217
+ } catch (error) {
3218
+ const message = error instanceof Error ? error.message : String(error);
3219
+ if (s) s.error(message);
3220
+ else ctx.log.error(message);
3221
+ return "failed";
3222
+ }
3223
+ }
3224
+
2720
3225
  // src/commands/sites.ts
2721
- import chalk6 from "chalk";
3226
+ import chalk9 from "chalk";
2722
3227
  async function runList(ctx, opts) {
2723
3228
  const sites = await listSites(requireClient(ctx), opts);
2724
3229
  ctx.out.data(
@@ -2736,11 +3241,11 @@ async function runList(ctx, opts) {
2736
3241
  );
2737
3242
  }
2738
3243
  function describeSite(site) {
2739
- const row = (label, value) => `${chalk6.bold(`${label}:`.padEnd(12))}${value}`;
3244
+ const row = (label, value) => `${chalk9.bold(`${label}:`.padEnd(12))}${value}`;
2740
3245
  return [
2741
3246
  row("Name", site.name),
2742
3247
  row("URL", site.url),
2743
- row("Visibility", site.visibility + (site.archived ? chalk6.yellow(" (archived)") : "")),
3248
+ row("Visibility", site.visibility + (site.archived ? chalk9.yellow(" (archived)") : "")),
2744
3249
  row("SPA", site.spa ? "yes" : "no"),
2745
3250
  row("Share link", site.shareUrl ?? "-"),
2746
3251
  row("Version", site.currentVersionId ?? "-"),
@@ -2755,128 +3260,420 @@ function filesRow(site, row) {
2755
3260
  const n = current.fileCount;
2756
3261
  return [row("Files", `${n} file${n === 1 ? "" : "s"}, ${formatBytes(current.totalBytes)}`)];
2757
3262
  }
2758
- async function runInfo(ctx, name) {
2759
- const site = await getSite(requireClient(ctx), name);
2760
- ctx.out.data(
2761
- site,
2762
- () => [
2763
- ...describeSite(site),
2764
- "",
2765
- chalk6.dim(
2766
- `${site.versions.length} version(s) \u2014 \`fling versions ${name}\`; files \u2014 \`fling files ${name}\``
2767
- )
2768
- ].join("\n")
2769
- );
3263
+ async function runInfo(ctx, name) {
3264
+ const site = await getSite(requireClient(ctx), name);
3265
+ ctx.out.data(
3266
+ site,
3267
+ () => [
3268
+ ...describeSite(site),
3269
+ "",
3270
+ chalk9.dim(
3271
+ `${site.versions.length} version(s) \u2014 \`fling versions ${name}\`; files \u2014 \`fling files ${name}\``
3272
+ )
3273
+ ].join("\n")
3274
+ );
3275
+ }
3276
+ async function runVersions(ctx, name) {
3277
+ const site = await getSite(requireClient(ctx), name);
3278
+ ctx.out.data(
3279
+ { name: site.name, currentVersionId: site.currentVersionId, versions: site.versions },
3280
+ () => renderTable(site.versions, [
3281
+ { header: "", value: (v) => v.id === site.currentVersionId ? "*" : " " },
3282
+ { header: "VERSION", value: (v) => v.id },
3283
+ { header: "FILES", value: (v) => v.fileCount },
3284
+ { header: "SIZE", value: (v) => formatBytes(v.totalBytes) },
3285
+ { header: "CREATED", value: (v) => formatDate(v.createdAt) }
3286
+ ])
3287
+ );
3288
+ }
3289
+ async function runFiles(ctx, name, versionId) {
3290
+ const result = await versionFiles(requireClient(ctx), name, versionId);
3291
+ if (!result.files.some((f) => f.path === "index.html")) {
3292
+ ctx.log.warn("No index.html at the root \u2014 the site root URL will 404");
3293
+ }
3294
+ ctx.out.data(result, () => {
3295
+ const n = result.files.length;
3296
+ const header = chalk9.dim(
3297
+ `${result.versionId}${result.current ? " (current)" : " (not served)"} \u2014 ${n} file${n === 1 ? "" : "s"}, ${formatBytes(result.totalBytes)}${result.generatedIndex ? ", generated index.html" : ""}`
3298
+ );
3299
+ const table = renderTable(result.files, [
3300
+ { header: "PATH", value: (f) => f.path },
3301
+ { header: "SIZE", value: (f) => formatBytes(f.size) },
3302
+ { header: "TYPE", value: (f) => f.contentType }
3303
+ ]);
3304
+ return `${header}
3305
+ ${table}`;
3306
+ });
3307
+ }
3308
+ async function runRollback(ctx, name, versionId) {
3309
+ const site = await rollback(requireClient(ctx), name, versionId);
3310
+ ctx.log.success(`${name} now serves version ${site.currentVersionId ?? versionId}`);
3311
+ ctx.out.data(site, () => site.url);
3312
+ }
3313
+ async function runShare(ctx, name, opts) {
3314
+ if (opts.refresh && opts.revoke) throw new CliError("Use either --refresh or --revoke, not both");
3315
+ const client = requireClient(ctx);
3316
+ const result = await share(client, name, opts);
3317
+ let ready;
3318
+ if (result.action === "created" || result.action === "refreshed") {
3319
+ const files = ctx.waitReady ? await versionFiles(client, name).catch(() => null) : null;
3320
+ ready = (await waitWithSpinner(ctx.waitReady, result.shareUrl, files?.files.map((f) => f.path) ?? [], {
3321
+ progress: ctx.progress,
3322
+ warn: (m) => ctx.log.warn(m)
3323
+ })).ready;
3324
+ }
3325
+ if (result.action === "revoked") ctx.log.success(`Share link for ${name} revoked`);
3326
+ else if (result.action === "refreshed")
3327
+ ctx.log.success("New share link \u2014 the old one no longer works");
3328
+ else if (result.action === "created")
3329
+ ctx.log.success("Share link created \u2014 anyone with it can view the site");
3330
+ ctx.out.data(
3331
+ ready === void 0 ? result : { ...result, ready },
3332
+ () => result.shareUrl ? result.shareUrl : chalk9.dim("(no share link)")
3333
+ );
3334
+ }
3335
+ async function runVisibility(ctx, name, visibility) {
3336
+ if (visibility !== "private" && visibility !== "public") {
3337
+ throw new CliError(`Visibility must be "private" or "public", not "${visibility}"`);
3338
+ }
3339
+ const site = await setVisibility(requireClient(ctx), name, visibility);
3340
+ ctx.log.success(
3341
+ site.visibility === "public" ? `${name} is public \u2014 no sign-in needed` : `${name} is private \u2014 sign-in required`
3342
+ );
3343
+ ctx.out.data(site, () => site.url);
3344
+ }
3345
+ async function runArchive(ctx, name, unarchive = false) {
3346
+ const site = await archiveSite(requireClient(ctx), name, { unarchive });
3347
+ ctx.log.success(
3348
+ unarchive ? `${name} restored` : `${name} archived \u2014 it 404s until \`fling unarchive ${name}\``
3349
+ );
3350
+ if (ctx.json) ctx.out.data(site, () => "");
3351
+ else if (unarchive) ctx.out.text(site.url);
3352
+ }
3353
+ async function runRemove(ctx, name, opts) {
3354
+ if (!opts.yes) {
3355
+ if (!ctx.interactive) {
3356
+ throw new CliError(`Refusing to delete ${name} without confirmation`, {
3357
+ hint: "Pass --yes to delete."
3358
+ });
3359
+ }
3360
+ const ok = await ctx.confirm(`Delete ${name} and every version of it? This cannot be undone.`);
3361
+ if (!ok) throw new CliError("Cancelled");
3362
+ }
3363
+ const result = await deleteSite(requireClient(ctx), name);
3364
+ ctx.log.success(`${name} deleted`);
3365
+ if (ctx.json) ctx.out.data(result, () => "");
3366
+ }
3367
+
3368
+ // src/mcp.ts
3369
+ import { mkdir as mkdir3, mkdtemp as mkdtemp3, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
3370
+ import { tmpdir as tmpdir3 } from "node:os";
3371
+ import { basename as basename3, dirname as dirname2, join as join6 } from "node:path";
3372
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3373
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3374
+ import { z as z11 } from "zod";
3375
+
3376
+ // src/locate.ts
3377
+ import { lstat, readdir as readdir2, readFile as readFile6, realpath as realpath3, stat as stat5 } from "node:fs/promises";
3378
+ import { posix, win32 } from "node:path";
3379
+ import { fileURLToPath } from "node:url";
3380
+ var DOWNLOADS_DIR_ENV = "FLING_DOWNLOADS_DIR";
3381
+ var FOUND_AS_GIVEN = "path";
3382
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "__MACOSX", "Library", "AppData", "$RECYCLE.BIN"]);
3383
+ var SESSION_SKIP_DIRS = /* @__PURE__ */ new Set(["skills-plugin", "rpm", "uploads", "memory"]);
3384
+ var SESSIONS_DEPTH = 4;
3385
+ var OUTPUTS_DEPTH = 4;
3386
+ var SANDBOX_PATH_RE = /^\/(mnt\/user-data|mnt\/data|sessions|home\/claude|tmp\/outputs)(\/|$)/;
3387
+ var nodeFs = {
3388
+ async stat(path3) {
3389
+ try {
3390
+ const s = await stat5(path3);
3391
+ return { isFile: s.isFile(), isDirectory: s.isDirectory(), mtimeMs: s.mtimeMs, size: s.size };
3392
+ } catch {
3393
+ return null;
3394
+ }
3395
+ },
3396
+ async readdir(path3) {
3397
+ try {
3398
+ const entries = await readdir2(path3, { withFileTypes: true });
3399
+ const out = [];
3400
+ for (const e of entries) {
3401
+ if (e.isSymbolicLink()) {
3402
+ const s = await stat5(`${path3}/${e.name}`).catch(() => null);
3403
+ out.push({ name: e.name, isFile: Boolean(s?.isFile()), isDirectory: false });
3404
+ } else out.push({ name: e.name, isFile: e.isFile(), isDirectory: e.isDirectory() });
3405
+ }
3406
+ return out;
3407
+ } catch {
3408
+ return [];
3409
+ }
3410
+ },
3411
+ async realpath(path3) {
3412
+ return realpath3(path3).catch(() => null);
3413
+ },
3414
+ async readText(path3) {
3415
+ try {
3416
+ if (!(await lstat(path3)).isFile()) return null;
3417
+ return await readFile6(path3, "utf8");
3418
+ } catch {
3419
+ return null;
3420
+ }
3421
+ }
3422
+ };
3423
+ var pathFor = (platform) => platform === "win32" ? win32 : posix;
3424
+ var usable = (value) => Boolean(value?.trim()) && !value?.trim().startsWith("${");
3425
+ async function xdgUserDir(key, opts, fs) {
3426
+ const fromEnv2 = opts.env[`XDG_${key}_DIR`];
3427
+ if (usable(fromEnv2)) return fromEnv2;
3428
+ const configHome = opts.env.XDG_CONFIG_HOME || posix.join(opts.home, ".config");
3429
+ const text2 = await fs.readText(posix.join(configHome, "user-dirs.dirs"));
3430
+ const match = text2?.match(new RegExp(`^XDG_${key}_DIR="([^"]*)"`, "m"));
3431
+ if (!match?.[1]) return void 0;
3432
+ return match[1].replace(/^\$HOME(?=\/|$)/, opts.home);
3433
+ }
3434
+ async function searchRoots(opts) {
3435
+ const fs = opts.fs ?? nodeFs;
3436
+ const p = pathFor(opts.platform);
3437
+ const { home, env, platform } = opts;
3438
+ const userHome = platform === "win32" && usable(env.USERPROFILE) ? env.USERPROFILE : home;
3439
+ const roots = [];
3440
+ const add = (label, path3, depth, sessions = false) => {
3441
+ if (!path3) return;
3442
+ const norm = p.resolve(path3);
3443
+ if (roots.some((r) => r.path === norm)) return;
3444
+ roots.push({ label, path: norm, depth, ...sessions ? { sessions } : {} });
3445
+ };
3446
+ const cwd = p.resolve(opts.cwd);
3447
+ if (cwd !== p.parse(cwd).root && cwd !== p.resolve(userHome)) add("current folder", cwd, 4);
3448
+ const configured = usable(opts.downloadsDir) ? opts.downloadsDir : usable(env[DOWNLOADS_DIR_ENV]) ? env[DOWNLOADS_DIR_ENV] : void 0;
3449
+ const defaultDownloads = platform === "linux" ? await xdgUserDir("DOWNLOAD", opts, fs) ?? p.join(home, "Downloads") : p.join(userHome, "Downloads");
3450
+ add("Downloads", configured ? expandHome(configured, home, platform) : defaultDownloads, 3);
3451
+ add("Downloads", defaultDownloads, 3);
3452
+ const desktop = platform === "linux" ? await xdgUserDir("DESKTOP", opts, fs) ?? p.join(home, "Desktop") : p.join(userHome, "Desktop");
3453
+ add("Desktop", desktop, 3);
3454
+ if (platform === "win32" && usable(env.OneDrive))
3455
+ add("Desktop", p.join(env.OneDrive, "Desktop"), 3);
3456
+ const documents = platform === "linux" ? await xdgUserDir("DOCUMENTS", opts, fs) ?? p.join(home, "Documents") : p.join(userHome, "Documents");
3457
+ add("Documents/Claude", p.join(documents, "Claude"), 4);
3458
+ add("~/Claude", p.join(userHome, "Claude"), 4);
3459
+ const claudeConfig = platform === "darwin" ? p.join(home, "Library", "Application Support", "Claude") : platform === "win32" ? p.join(
3460
+ usable(env.APPDATA) ? env.APPDATA : p.join(userHome, "AppData", "Roaming"),
3461
+ "Claude"
3462
+ ) : p.join(env.XDG_CONFIG_HOME || p.join(home, ".config"), "Claude");
3463
+ add(
3464
+ "Claude Desktop outputs",
3465
+ p.join(claudeConfig, "local-agent-mode-sessions"),
3466
+ SESSIONS_DEPTH,
3467
+ true
3468
+ );
3469
+ if (!fs.realpath) return roots;
3470
+ const real = [];
3471
+ for (const root of roots) {
3472
+ const path3 = await fs.realpath(root.path) ?? root.path;
3473
+ if (!real.some((r) => r.path === path3)) real.push({ ...root, path: path3 });
3474
+ }
3475
+ return real;
3476
+ }
3477
+ function expandHome(input, home, platform) {
3478
+ if (input === "~") return home;
3479
+ if (input.startsWith("~/") || platform === "win32" && input.startsWith("~\\"))
3480
+ return pathFor(platform).join(home, input.slice(2));
3481
+ return input;
3482
+ }
3483
+ async function walk(roots, opts, visit) {
3484
+ const fs = opts.fs ?? nodeFs;
3485
+ const p = pathFor(opts.platform);
3486
+ const now = opts.now ?? Date.now;
3487
+ const deadline = now() + (opts.timeBudgetMs ?? 4e3);
3488
+ let budget = opts.entryBudget ?? 6e4;
3489
+ const skip = (name) => name.startsWith(".") || SKIP_DIRS.has(name);
3490
+ const expanded = [];
3491
+ for (const root of roots) {
3492
+ if (!root.sessions) {
3493
+ expanded.push(root);
3494
+ continue;
3495
+ }
3496
+ let level = [root.path];
3497
+ for (let depth = 0; depth < root.depth && level.length; depth++) {
3498
+ const next = [];
3499
+ for (const dir of level) {
3500
+ for (const e of await fs.readdir(dir)) {
3501
+ if (!e.isDirectory || skip(e.name) || SESSION_SKIP_DIRS.has(e.name)) continue;
3502
+ const child = p.join(dir, e.name);
3503
+ if (e.name === "outputs")
3504
+ expanded.push({ label: root.label, path: child, depth: OUTPUTS_DEPTH });
3505
+ else next.push(child);
3506
+ }
3507
+ }
3508
+ level = next;
3509
+ }
3510
+ }
3511
+ for (const root of expanded) {
3512
+ let level = [root.path];
3513
+ for (let depth = 0; depth <= root.depth && level.length; depth++) {
3514
+ const next = [];
3515
+ for (const dir of level) {
3516
+ if (now() > deadline || budget <= 0) return { complete: false };
3517
+ const entries = await fs.readdir(dir);
3518
+ budget -= entries.length;
3519
+ for (const entry of entries) {
3520
+ if (entry.name.startsWith(".")) continue;
3521
+ const path3 = p.join(dir, entry.name);
3522
+ const result = await visit({ root, path: path3, entry, siblings: entries });
3523
+ if (entry.isDirectory && !skip(entry.name) && result !== "skip" && depth < root.depth)
3524
+ next.push(path3);
3525
+ }
3526
+ }
3527
+ level = next;
3528
+ }
3529
+ }
3530
+ return { complete: true };
3531
+ }
3532
+ function withoutDuplicateSuffix(name) {
3533
+ return name.replace(/ \(\d+\)(?=\.[^.]+$|$)/, "");
2770
3534
  }
2771
- async function runVersions(ctx, name) {
2772
- const site = await getSite(requireClient(ctx), name);
2773
- ctx.out.data(
2774
- { name: site.name, currentVersionId: site.currentVersionId, versions: site.versions },
2775
- () => renderTable(site.versions, [
2776
- { header: "", value: (v) => v.id === site.currentVersionId ? "*" : " " },
2777
- { header: "VERSION", value: (v) => v.id },
2778
- { header: "FILES", value: (v) => v.fileCount },
2779
- { header: "SIZE", value: (v) => formatBytes(v.totalBytes) },
2780
- { header: "CREATED", value: (v) => formatDate(v.createdAt) }
2781
- ])
2782
- );
3535
+ function namesMatch(candidate, wanted) {
3536
+ const a = candidate.toLowerCase();
3537
+ const b = wanted.toLowerCase();
3538
+ return a === b || withoutDuplicateSuffix(a) === withoutDuplicateSuffix(b);
2783
3539
  }
2784
- async function runFiles(ctx, name, versionId) {
2785
- const result = await versionFiles(requireClient(ctx), name, versionId);
2786
- if (!result.files.some((f) => f.path === "index.html")) {
2787
- ctx.log.warn("No index.html at the root \u2014 the site root URL will 404");
3540
+ var splitSegments = (path3) => path3.split(/[\\/]+/).filter((s) => s !== "" && s !== "." && s !== "~");
3541
+ function suffixScore(path3, wanted) {
3542
+ const have = splitSegments(path3);
3543
+ let score = 1;
3544
+ for (let i = 2; i <= Math.min(have.length, wanted.length); i++) {
3545
+ if (have[have.length - i]?.toLowerCase() !== wanted[wanted.length - i]?.toLowerCase()) break;
3546
+ score++;
2788
3547
  }
2789
- ctx.out.data(result, () => {
2790
- const n = result.files.length;
2791
- const header = chalk6.dim(
2792
- `${result.versionId}${result.current ? " (current)" : " (not served)"} \u2014 ${n} file${n === 1 ? "" : "s"}, ${formatBytes(result.totalBytes)}${result.generatedIndex ? ", generated index.html" : ""}`
2793
- );
2794
- const table = renderTable(result.files, [
2795
- { header: "PATH", value: (f) => f.path },
2796
- { header: "SIZE", value: (f) => formatBytes(f.size) },
2797
- { header: "TYPE", value: (f) => f.contentType }
2798
- ]);
2799
- return `${header}
2800
- ${table}`;
2801
- });
2802
- }
2803
- async function runRollback(ctx, name, versionId) {
2804
- const site = await rollback(requireClient(ctx), name, versionId);
2805
- ctx.log.success(`${name} now serves version ${site.currentVersionId ?? versionId}`);
2806
- ctx.out.data(site, () => site.url);
3548
+ return score;
2807
3549
  }
2808
- async function runShare(ctx, name, opts) {
2809
- if (opts.refresh && opts.revoke) throw new CliError("Use either --refresh or --revoke, not both");
2810
- const client = requireClient(ctx);
2811
- const result = await share(client, name, opts);
2812
- let ready;
2813
- if (result.action === "created" || result.action === "refreshed") {
2814
- const files = ctx.waitReady ? await versionFiles(client, name).catch(() => null) : null;
2815
- ready = (await waitWithSpinner(ctx.waitReady, result.shareUrl, files?.files.map((f) => f.path) ?? [], {
2816
- progress: ctx.progress,
2817
- warn: (m) => ctx.log.warn(m)
2818
- })).ready;
3550
+ var looksLikeSandboxPath = (input) => SANDBOX_PATH_RE.test(input.replace(/\\/g, "/"));
3551
+ function normaliseInput(raw, opts) {
3552
+ let input = raw.trim().replace(/^(["'])(.*)\1$/, "$2");
3553
+ if (input.startsWith("file://")) {
3554
+ try {
3555
+ input = fileURLToPath(input, { windows: opts.platform === "win32" });
3556
+ } catch {
3557
+ input = decodeURIComponent(input.slice("file://".length));
3558
+ }
2819
3559
  }
2820
- if (result.action === "revoked") ctx.log.success(`Share link for ${name} revoked`);
2821
- else if (result.action === "refreshed")
2822
- ctx.log.success("New share link \u2014 the old one no longer works");
2823
- else if (result.action === "created")
2824
- ctx.log.success("Share link created \u2014 anyone with it can view the site");
2825
- ctx.out.data(
2826
- ready === void 0 ? result : { ...result, ready },
2827
- () => result.shareUrl ? result.shareUrl : chalk6.dim("(no share link)")
2828
- );
3560
+ return expandHome(input, opts.home, opts.platform);
2829
3561
  }
2830
- async function runVisibility(ctx, name, visibility) {
2831
- if (visibility !== "private" && visibility !== "public") {
2832
- throw new CliError(`Visibility must be "private" or "public", not "${visibility}"`);
3562
+ async function resolveInput(input, opts) {
3563
+ const fs = opts.fs ?? nodeFs;
3564
+ const p = pathFor(opts.platform);
3565
+ const given = normaliseInput(input, opts);
3566
+ if (!given) throw new CliError("No path given");
3567
+ const direct = p.isAbsolute(given) ? p.normalize(given) : p.resolve(opts.cwd, given);
3568
+ const directStat = await fs.stat(direct);
3569
+ if (directStat && (directStat.isFile || directStat.isDirectory)) {
3570
+ return {
3571
+ path: direct,
3572
+ foundIn: FOUND_AS_GIVEN,
3573
+ isDirectory: directStat.isDirectory,
3574
+ alternatives: []
3575
+ };
2833
3576
  }
2834
- const site = await setVisibility(requireClient(ctx), name, visibility);
2835
- ctx.log.success(
2836
- site.visibility === "public" ? `${name} is public \u2014 no sign-in needed` : `${name} is private \u2014 Nearform sign-in required`
2837
- );
2838
- ctx.out.data(site, () => site.url);
3577
+ const wanted = splitSegments(given);
3578
+ const name = wanted.at(-1);
3579
+ const roots = await searchRoots(opts);
3580
+ if (!name) throw notFound(input, roots);
3581
+ const found = [];
3582
+ const seen = /* @__PURE__ */ new Set();
3583
+ await walk(roots, opts, async ({ root, path: path3, entry }) => {
3584
+ if (!(entry.isFile || entry.isDirectory) || !namesMatch(entry.name, name)) return;
3585
+ if (seen.has(path3)) return;
3586
+ seen.add(path3);
3587
+ const s = await fs.stat(path3);
3588
+ if (!s) return;
3589
+ found.push({
3590
+ path: path3,
3591
+ foundIn: root.label,
3592
+ kind: s.isDirectory ? "folder" : "file",
3593
+ modified: new Date(s.mtimeMs).toISOString(),
3594
+ size: s.size,
3595
+ score: suffixScore(path3, wanted),
3596
+ mtimeMs: s.mtimeMs,
3597
+ isDirectory: s.isDirectory
3598
+ });
3599
+ return void 0;
3600
+ });
3601
+ if (found.length === 0) throw notFound(input, roots);
3602
+ found.sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs);
3603
+ const strip = ({ score: _s, mtimeMs: _m, isDirectory: _d, ...c }) => c;
3604
+ const [best, ...rest] = found;
3605
+ return {
3606
+ path: best.path,
3607
+ foundIn: best.foundIn,
3608
+ isDirectory: best.isDirectory,
3609
+ alternatives: rest.slice(0, 5).map(strip)
3610
+ };
2839
3611
  }
2840
- async function runArchive(ctx, name, unarchive = false) {
2841
- const site = await archiveSite(requireClient(ctx), name, { unarchive });
2842
- ctx.log.success(
2843
- unarchive ? `${name} restored` : `${name} archived \u2014 it 404s until \`fling unarchive ${name}\``
2844
- );
2845
- if (ctx.json) ctx.out.data(site, () => "");
2846
- else if (unarchive) ctx.out.text(site.url);
3612
+ function notFound(input, roots) {
3613
+ const where = roots.map((r) => `${r.label} (${r.path})`).join(", ");
3614
+ 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." : "";
3615
+ return new CliError(`Could not find "${input}". Looked for it by name in: ${where}.${sandbox}`, {
3616
+ hint: "Pass the full path, or save the file to your Downloads folder and use just its name."
3617
+ });
2847
3618
  }
2848
- async function runRemove(ctx, name, opts) {
2849
- if (!opts.yes) {
2850
- if (!ctx.interactive) {
2851
- throw new CliError(`Refusing to delete ${name} without confirmation`, {
2852
- hint: "Pass --yes to delete."
2853
- });
3619
+ var LISTED_EXT = /\.(html?|zip)$/i;
3620
+ async function findFiles(opts, find = {}) {
3621
+ const fs = opts.fs ?? nodeFs;
3622
+ const p = pathFor(opts.platform);
3623
+ const now = (opts.now ?? Date.now)();
3624
+ const since = now - (find.days ?? 14) * 24 * 60 * 60 * 1e3;
3625
+ const query = find.query?.trim().toLowerCase();
3626
+ const roots = await searchRoots(opts);
3627
+ const found = /* @__PURE__ */ new Map();
3628
+ const rootPaths = new Set(roots.map((r) => r.path));
3629
+ const { complete } = await walk(roots, opts, async ({ root, path: path3, entry }) => {
3630
+ if (entry.isDirectory) {
3631
+ if (rootPaths.has(path3)) return;
3632
+ const index = (await fs.readdir(path3)).find((e) => e.isFile && /^index\.html?$/i.test(e.name));
3633
+ if (!index) return;
3634
+ const s2 = await fs.stat(p.join(path3, index.name));
3635
+ if (s2 && s2.mtimeMs >= since && (!query || entry.name.toLowerCase().includes(query)))
3636
+ found.set(path3, {
3637
+ path: path3,
3638
+ foundIn: root.label,
3639
+ kind: "folder",
3640
+ modified: new Date(s2.mtimeMs).toISOString(),
3641
+ size: s2.size,
3642
+ mtimeMs: s2.mtimeMs
3643
+ });
3644
+ return "skip";
2854
3645
  }
2855
- const ok = await ctx.confirm(`Delete ${name} and every version of it? This cannot be undone.`);
2856
- if (!ok) throw new CliError("Cancelled");
2857
- }
2858
- const result = await deleteSite(requireClient(ctx), name);
2859
- ctx.log.success(`${name} deleted`);
2860
- if (ctx.json) ctx.out.data(result, () => "");
3646
+ if (!entry.isFile || !LISTED_EXT.test(entry.name)) return;
3647
+ if (query && !entry.name.toLowerCase().includes(query)) return;
3648
+ const s = await fs.stat(path3);
3649
+ if (!s || s.mtimeMs < since || found.has(path3)) return;
3650
+ found.set(path3, {
3651
+ path: path3,
3652
+ foundIn: root.label,
3653
+ kind: "file",
3654
+ modified: new Date(s.mtimeMs).toISOString(),
3655
+ size: s.size,
3656
+ mtimeMs: s.mtimeMs
3657
+ });
3658
+ return void 0;
3659
+ });
3660
+ const files = [...found.values()].sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, find.limit ?? 20).map(({ mtimeMs: _m, ...c }) => c);
3661
+ return { files, searched: roots, complete };
2861
3662
  }
2862
3663
 
2863
3664
  // src/mcp.ts
2864
- import { mkdir as mkdir3, mkdtemp as mkdtemp2, rm as rm2, writeFile as writeFile3 } from "node:fs/promises";
2865
- import { tmpdir as tmpdir2 } from "node:os";
2866
- import { basename as basename3, dirname as dirname2, join as join5 } from "node:path";
2867
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2868
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2869
- import { z as z7 } from "zod";
2870
- 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.`;
2871
- var MCP_LOCAL_INSTRUCTIONS = [
2872
- "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.",
3665
+ var LOGIN_HINT = `Not logged in to Fling. Call fling_login (it opens the browser to sign in), or ask the user to run \`${NPX_COMMAND} login\` in a terminal (in Claude Code: \`! ${NPX_COMMAND} login\`), then try again.`;
3666
+ var NO_SERVER_HINT = `No Fling server is set up on this computer. Ask the user for their Fling's address and to run \`${NPX_COMMAND} login <url>\` in a terminal (in Claude Code: \`! ${NPX_COMMAND} login <url>\`), then try again.`;
3667
+ var mcpLocalInstructions = (w, opts = {}) => [
3668
+ `${w.name} publishes static sites (HTML/CSS/JS, images, documents) at ${w.sitesPattern}. This server runs on the user's computer.`,
2873
3669
  "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.",
2874
3670
  '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.',
2875
3671
  "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.",
2876
3672
  "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.",
2877
3673
  NEVER_PUBLISHED,
2878
3674
  "Not logged in? Call fling_login, let the user finish signing in in the browser, then retry.",
2879
- "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."
3675
+ `Sites are private (only ${w.privateAudience}) by default; only make one public if asked. Use fling_share for a link people who cannot sign in can open. ${opts.comments ? "Always give the user the links that come back: the review link first, as the one to share for feedback, then the site URL." : "Always give the user the URL that comes back."}`,
3676
+ ...opts.comments ? [COMMENTS_NOTE(w)] : []
2880
3677
  ].join("\n\n");
2881
3678
  var toolDef = (def) => ({
2882
3679
  title: def.title,
@@ -2890,6 +3687,8 @@ function createMcpServer(deps = {}) {
2890
3687
  const cwd = deps.cwd ?? process.cwd();
2891
3688
  const locate = deps.locate ?? { ...systemLocateOptions(), cwd };
2892
3689
  const waitReady = deps.waitReady ?? createReadyWaiter();
3690
+ const wording = mcpWording(deps.instance);
3691
+ const mcpTools2 = mcpToolsFor(wording);
2893
3692
  async function edgeReady(url, paths) {
2894
3693
  const r = await waitReady(url, paths).catch(() => ({ ready: false, waitedMs: 0 }));
2895
3694
  return {
@@ -2900,9 +3699,14 @@ Not reachable yet at ${url}: ${NOT_READY_NOTE}.`
2900
3699
  }
2901
3700
  async function client() {
2902
3701
  const config = await store.resolve({ serverUrl: deps.server });
3702
+ if (!config.serverUrl) throw new NoServerError("fling");
2903
3703
  if (!config.apiKey) throw new CliError("Not logged in", { exitCode: EXIT_NOT_LOGGED_IN });
2904
3704
  return createApiClient({ serverUrl: config.serverUrl, apiKey: config.apiKey, fetch: fetchImpl });
2905
3705
  }
3706
+ async function sitesBase() {
3707
+ const config = await store.resolve({ serverUrl: deps.server });
3708
+ return sitesBaseOf(await loadInstance(store, config, fetchImpl));
3709
+ }
2906
3710
  async function run(op) {
2907
3711
  try {
2908
3712
  const { summary, data } = await op(await client());
@@ -2958,9 +3762,15 @@ Warnings:
2958
3762
  result.url,
2959
3763
  local.files.map((f) => f.path)
2960
3764
  );
3765
+ const instance = deps.instance;
3766
+ const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
3767
+ const listing = generatedIndex ? " \u2014 with a generated index.html listing the HTML pages" : "";
3768
+ const head = review ? `${what}${listing}.
3769
+ ${shareForFeedback(review, instance?.name ?? "Fling")}
3770
+ Site: ${result.url} (${result.site.visibility})` : `${what}: ${result.url} (${result.site.visibility})${listing}`;
2961
3771
  return {
2962
3772
  kind: "deployed",
2963
- summary: `${what}: ${result.url} (${result.site.visibility})${generatedIndex ? " \u2014 with a generated index.html listing the HTML pages" : ""}${warnings}${skippedNote}${edge.note}
3773
+ summary: `${head}${warnings}${skippedNote}${edge.note}
2964
3774
  To check exactly which files the site has, call fling_files with name "${result.name}".`,
2965
3775
  data: {
2966
3776
  status: result.status,
@@ -2969,6 +3779,7 @@ To check exactly which files the site has, call fling_files with name "${result.
2969
3779
  nameGenerated: !opts.nameGiven,
2970
3780
  versionId: result.versionId,
2971
3781
  site: result.site,
3782
+ ...review ? { reviewUrl: review } : {},
2972
3783
  generatedIndex,
2973
3784
  warnings: result.warnings,
2974
3785
  skipped: local.skipped
@@ -2977,23 +3788,27 @@ To check exactly which files the site has, call fling_files with name "${result.
2977
3788
  }
2978
3789
  const server = new McpServer(
2979
3790
  { name: "fling", version: VERSION },
2980
- { instructions: MCP_LOCAL_INSTRUCTIONS }
3791
+ {
3792
+ instructions: mcpLocalInstructions(wording, {
3793
+ comments: commentsEnabled(deps.instance)
3794
+ })
3795
+ }
2981
3796
  );
2982
- const { listHtml: listHtml2, spa: spa2, public: isPublic2 } = mcpTools.publish.inputSchema;
3797
+ const { listHtml: listHtml2, spa: spa2, public: isPublic } = mcpTools2.publish.inputSchema;
2983
3798
  server.registerTool(
2984
3799
  "fling_deploy",
2985
3800
  {
2986
3801
  title: "Deploy a file or folder from this computer",
2987
- 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. ' + NEVER_PUBLISHED,
3802
+ description: `Fling something that is on the user's computer and return its stable URL (${wording.sitesPattern}): 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 (only ${wording.privateAudience}) unless \`public\` is set. ` + NEVER_PUBLISHED,
2988
3803
  inputSchema: {
2989
- path: z7.string().min(1).describe(
3804
+ path: z11.string().min(1).describe(
2990
3805
  "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)"
2991
3806
  ),
2992
- name: z7.string().optional().describe(
3807
+ name: z11.string().optional().describe(
2993
3808
  "Site name (lowercase letters, digits, single hyphens; max 50). Defaults to a name generated from the file or folder"
2994
3809
  ),
2995
3810
  spa: spa2,
2996
- public: isPublic2,
3811
+ public: isPublic,
2997
3812
  listHtml: listHtml2
2998
3813
  },
2999
3814
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true }
@@ -3038,9 +3853,9 @@ To check exactly which files the site has, call fling_files with name "${result.
3038
3853
  title: "Find files to fling",
3039
3854
  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.",
3040
3855
  inputSchema: {
3041
- query: z7.string().optional().describe('Only names containing this text (case-insensitive), e.g. "deck"'),
3042
- limit: z7.number().int().min(1).max(100).optional().describe("Max results (default 20)"),
3043
- days: z7.number().int().min(1).max(3650).optional().describe("Only things modified in the last N days (default 14)")
3856
+ query: z11.string().optional().describe('Only names containing this text (case-insensitive), e.g. "deck"'),
3857
+ limit: z11.number().int().min(1).max(100).optional().describe("Max results (default 20)"),
3858
+ days: z11.number().int().min(1).max(3650).optional().describe("Only things modified in the last N days (default 14)")
3044
3859
  },
3045
3860
  annotations: { readOnlyHint: true, openWorldHint: false }
3046
3861
  },
@@ -3067,7 +3882,7 @@ ${JSON.stringify(data, null, 2)}` }]
3067
3882
  }
3068
3883
  }
3069
3884
  );
3070
- const publishDef = mcpTools.publish;
3885
+ const publishDef = mcpTools2.publish;
3071
3886
  server.registerTool(
3072
3887
  publishDef.name,
3073
3888
  {
@@ -3080,7 +3895,7 @@ ${JSON.stringify(data, null, 2)}` }]
3080
3895
  },
3081
3896
  (args) => run(async (c) => {
3082
3897
  const name = resolveSiteName(".", args.name);
3083
- const tmp = await mkdtemp2(join5(tmpdir2(), "fling-publish-"));
3898
+ const tmp = await mkdtemp3(join6(tmpdir3(), "fling-publish-"));
3084
3899
  try {
3085
3900
  const { skippedDotfiles } = await writeInlineFiles(tmp, args.files);
3086
3901
  const outcome = await deployFolder(c, tmp, {
@@ -3101,11 +3916,11 @@ ${JSON.stringify(data, null, 2)}` }]
3101
3916
  server.registerTool(
3102
3917
  "fling_check_name",
3103
3918
  {
3104
- ...toolDef(mcpTools.checkName),
3105
- 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.`,
3919
+ ...toolDef(mcpTools2.checkName),
3920
+ description: `${mcpTools2.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.`,
3106
3921
  inputSchema: {
3107
- name: z7.string().min(1).max(200).optional().describe("Site name to check"),
3108
- path: z7.string().optional().describe("File or folder whose generated default name to check (path or file name)")
3922
+ name: z11.string().min(1).max(200).optional().describe("Site name to check"),
3923
+ path: z11.string().optional().describe("File or folder whose generated default name to check (path or file name)")
3109
3924
  }
3110
3925
  },
3111
3926
  (args) => run(async (c) => {
@@ -3117,20 +3932,20 @@ ${JSON.stringify(data, null, 2)}` }]
3117
3932
  source = await resolveInput(args.path, locate);
3118
3933
  name = defaultNameFor(source.path, source.isDirectory);
3119
3934
  }
3120
- const check = await checkName(c, name);
3121
- const base = check.status === "invalid" ? null : await discoverSitesBase(c);
3935
+ const check2 = await checkName(c, name);
3936
+ const base = check2.status === "invalid" ? null : await sitesBase();
3122
3937
  const url = base ? siteUrlFor(name, base) : null;
3123
- const suggestions = check.suggestions.length ? ` Suggestions: ${check.suggestions.join(", ")}.` : "";
3938
+ const suggestions = check2.suggestions.length ? ` Suggestions: ${check2.suggestions.join(", ")}.` : "";
3124
3939
  const summary = {
3125
3940
  available: `"${name}" is available${url ? ` \u2192 ${url}` : ""}.`,
3126
3941
  yours: `"${name}" is the user's existing site${url ? ` (${url})` : ""} \u2014 deploying updates it.`,
3127
3942
  taken: `"${name}" is taken by someone else.${suggestions}`,
3128
3943
  invalid: `"${name}" is not a valid site name.${suggestions}`
3129
- }[check.status];
3944
+ }[check2.status];
3130
3945
  return {
3131
3946
  summary,
3132
3947
  data: {
3133
- ...check,
3948
+ ...check2,
3134
3949
  url,
3135
3950
  ...source ? { generated: true, source: { path: source.path, foundIn: source.foundIn } } : {}
3136
3951
  }
@@ -3142,17 +3957,19 @@ ${JSON.stringify(data, null, 2)}` }]
3142
3957
  "fling_login",
3143
3958
  {
3144
3959
  title: "Sign in to Fling",
3145
- 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.",
3960
+ description: `Sign the user in to ${wording.name}: opens their browser to sign in with ${wording.signIn} 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.`,
3146
3961
  inputSchema: {},
3147
3962
  annotations: { readOnlyHint: false, openWorldHint: true }
3148
3963
  },
3149
3964
  async () => {
3150
3965
  try {
3151
3966
  const config = await store.resolve({ serverUrl: deps.server });
3967
+ const serverUrl = config.serverUrl;
3968
+ if (!serverUrl) throw new NoServerError("fling");
3152
3969
  if (config.apiKey && !pendingLogin) {
3153
3970
  const me = await whoami(
3154
3971
  createApiClient({
3155
- serverUrl: config.serverUrl,
3972
+ serverUrl,
3156
3973
  apiKey: config.apiKey,
3157
3974
  fetch: fetchImpl
3158
3975
  })
@@ -3164,7 +3981,7 @@ ${JSON.stringify(data, null, 2)}` }]
3164
3981
  done: Promise.resolve()
3165
3982
  };
3166
3983
  login.done = loginFlow({
3167
- serverUrl: config.serverUrl,
3984
+ serverUrl,
3168
3985
  store,
3169
3986
  log: silentLogger,
3170
3987
  open: deps.open ?? (async (url) => (await import("open")).default(url)),
@@ -3199,7 +4016,7 @@ ${JSON.stringify(data, null, 2)}` }]
3199
4016
  }
3200
4017
  }
3201
4018
  );
3202
- const t = mcpTools;
4019
+ const t = mcpTools2;
3203
4020
  server.registerTool(
3204
4021
  t.list.name,
3205
4022
  toolDef(t.list),
@@ -3284,12 +4101,66 @@ ${JSON.stringify(data, null, 2)}` }]
3284
4101
  };
3285
4102
  })
3286
4103
  );
4104
+ if (deps.instance && commentsEnabled(deps.instance)) {
4105
+ const appOrigin = deps.instance.appOrigin;
4106
+ const review = (name) => reviewUrl(appOrigin, name);
4107
+ server.registerTool(
4108
+ t.commentsList.name,
4109
+ toolDef(t.commentsList),
4110
+ (args) => run(async (c) => {
4111
+ const list = await listComments(c, args.name, { path: args.path });
4112
+ const reviewPage2 = review(list.site.name);
4113
+ return {
4114
+ summary: describeComments(list, {
4115
+ includeResolved: args.includeResolved,
4116
+ reviewUrl: reviewPage2
4117
+ }),
4118
+ data: { ...list, reviewUrl: reviewPage2 }
4119
+ };
4120
+ })
4121
+ );
4122
+ server.registerTool(
4123
+ t.commentAdd.name,
4124
+ toolDef(t.commentAdd),
4125
+ (args) => run(async (c) => {
4126
+ const comment = await addComment(c, args.name, {
4127
+ body: args.body,
4128
+ path: args.path,
4129
+ parentId: args.replyTo
4130
+ });
4131
+ const what = comment.parentId ? `Replied to thread ${comment.parentId}` : "Comment added";
4132
+ return {
4133
+ summary: `${what} on ${args.name} (${comment.path}, id ${comment.id}). Review page: ${review(args.name)}`,
4134
+ data: comment
4135
+ };
4136
+ })
4137
+ );
4138
+ server.registerTool(
4139
+ t.commentResolve.name,
4140
+ toolDef(t.commentResolve),
4141
+ (args) => run(async (c) => {
4142
+ const comment = await resolveComment(c, args.name, args.id, !args.reopen);
4143
+ return {
4144
+ summary: `Thread ${comment.id} on ${args.name} ${args.reopen ? "reopened" : "resolved"}`,
4145
+ data: comment
4146
+ };
4147
+ })
4148
+ );
4149
+ server.registerTool(
4150
+ t.commentDelete.name,
4151
+ toolDef(t.commentDelete),
4152
+ (args) => run(async (c) => {
4153
+ const result = await deleteComment(c, args.name, args.id);
4154
+ return { summary: `Comment ${args.id} on ${args.name} deleted`, data: result };
4155
+ })
4156
+ );
4157
+ }
3287
4158
  server.registerTool(
3288
4159
  t.whoami.name,
3289
4160
  toolDef(t.whoami),
3290
4161
  () => run(async (c) => {
3291
4162
  const me = await whoami(c);
3292
- const base = await discoverSitesBase(c);
4163
+ const base = await sitesBase();
3293
4164
  return {
3294
4165
  summary: `Signed in as ${me.email} (sites are served at ${sitesPattern(base)})`,
3295
4166
  data: me
@@ -3298,38 +4169,39 @@ ${JSON.stringify(data, null, 2)}` }]
3298
4169
  );
3299
4170
  return server;
3300
4171
  }
3301
- var textResult = (text) => ({ content: [{ type: "text", text }] });
4172
+ var textResult = (text2) => ({ content: [{ type: "text", text: text2 }] });
3302
4173
  async function writeInlineFiles(dir, files) {
3303
4174
  const seen = /* @__PURE__ */ new Set();
3304
4175
  const skippedDotfiles = [];
3305
4176
  const secretFiles = [];
3306
4177
  let total = 0;
3307
4178
  for (const f of files) {
3308
- const path = f.path.replace(/^(\.\/)+/, "").replace(/^\/+/, "");
3309
- const segments = path.split("/");
3310
- if (path && !path.includes("\\") && segments.every((s) => s !== "" && s !== "." && s !== "..") && segments.some((s) => s.startsWith("."))) {
3311
- skippedDotfiles.push(path);
4179
+ const path3 = f.path.replace(/^(\.\/)+/, "").replace(/^\/+/, "");
4180
+ const segments = path3.split("/");
4181
+ if (path3 && !path3.includes("\\") && segments.every((s) => s !== "" && s !== "." && s !== "..") && segments.some((s) => s.startsWith("."))) {
4182
+ skippedDotfiles.push(path3);
3312
4183
  continue;
3313
4184
  }
3314
- if (!isValidSitePath(path)) throw new CliError(`Invalid file path: "${f.path}"`);
3315
- if (isSecretPath(path)) {
3316
- secretFiles.push(path);
4185
+ if (!isValidSitePath(path3)) throw new CliError(`Invalid file path: "${f.path}"`);
4186
+ if (isSecretPath(path3)) {
4187
+ secretFiles.push(path3);
3317
4188
  continue;
3318
4189
  }
3319
- if (seen.has(path)) throw new CliError(`"${path}" is listed twice`);
3320
- seen.add(path);
4190
+ if (seen.has(path3)) throw new CliError(`"${path3}" is listed twice`);
4191
+ seen.add(path3);
3321
4192
  const bytes = Buffer.from(f.content, f.encoding === "base64" ? "base64" : "utf8");
3322
4193
  total += bytes.length;
3323
4194
  if (total > MAX_DEPLOY_BYTES)
3324
4195
  throw new CliError(`The site is larger than ${formatBytes(MAX_DEPLOY_BYTES)}`);
3325
- const target = join5(dir, ...path.split("/"));
4196
+ const target = join6(dir, ...path3.split("/"));
3326
4197
  await mkdir3(dirname2(target), { recursive: true });
3327
- await writeFile3(target, bytes);
4198
+ await writeFile4(target, bytes);
3328
4199
  }
3329
4200
  if (secretFiles.length > 0) throw new SecretFilesError(secretFiles);
3330
4201
  return { skippedDotfiles };
3331
4202
  }
3332
4203
  function describeError(error) {
4204
+ if (error instanceof NoServerError) return NO_SERVER_HINT;
3333
4205
  if (error instanceof CliError && error.exitCode === EXIT_NOT_LOGGED_IN) return LOGIN_HINT;
3334
4206
  if (error instanceof CliApiError && error.status === 401) return LOGIN_HINT;
3335
4207
  if (error instanceof CliError)
@@ -3338,28 +4210,87 @@ ${error.hint}` : error.message;
3338
4210
  return error instanceof Error ? error.message : String(error);
3339
4211
  }
3340
4212
  async function runMcpServer(deps = {}) {
3341
- const server = createMcpServer(deps);
4213
+ const store = deps.store ?? createConfigStore();
4214
+ const instance = deps.instance ?? await store.resolve({ serverUrl: deps.server }).then((config) => loadInstance(store, config, deps.fetch ?? ((i, init) => fetch(i, init)))).catch(() => null);
4215
+ const server = createMcpServer({ ...deps, store, instance });
3342
4216
  await server.connect(new StdioServerTransport());
3343
4217
  }
3344
4218
 
4219
+ // src/route.ts
4220
+ import { stat as stat6 } from "node:fs/promises";
4221
+ import { resolve as resolve4 } from "node:path";
4222
+ var PASS_THROUGH = /* @__PURE__ */ new Set(["-h", "--help", "-V", "--version"]);
4223
+ var pathExists = (path3) => stat6(resolve4(path3)).then(
4224
+ () => true,
4225
+ () => false
4226
+ );
4227
+ async function routeArgs(args, opts) {
4228
+ const exists = opts.exists ?? pathExists;
4229
+ let first;
4230
+ for (let i = 0; i < args.length; i++) {
4231
+ const arg = args[i];
4232
+ if (arg === "--") {
4233
+ first = args[i + 1];
4234
+ break;
4235
+ }
4236
+ if (arg.startsWith("-")) {
4237
+ if (PASS_THROUGH.has(arg)) return { kind: "run", args: [...args] };
4238
+ if (opts.valueFlags.has(arg)) i++;
4239
+ continue;
4240
+ }
4241
+ first = arg;
4242
+ break;
4243
+ }
4244
+ if (first === void 0) return { kind: "help" };
4245
+ if (await exists(first)) return { kind: "run", args: ["deploy", ...args] };
4246
+ if (opts.commands.has(first)) return { kind: "run", args: [...args] };
4247
+ return { kind: "unknown", arg: first };
4248
+ }
4249
+ function routeOptionsFor(program2) {
4250
+ const commands = /* @__PURE__ */ new Set(["help"]);
4251
+ const valueFlags = /* @__PURE__ */ new Set();
4252
+ const addFlags = (command) => {
4253
+ for (const option of command.options)
4254
+ if (option.required || option.optional) {
4255
+ for (const flag of [option.short, option.long]) if (flag) valueFlags.add(flag);
4256
+ }
4257
+ };
4258
+ addFlags(program2);
4259
+ for (const command of program2.commands) {
4260
+ commands.add(command.name());
4261
+ for (const alias of command.aliases()) commands.add(alias);
4262
+ addFlags(command);
4263
+ }
4264
+ return { commands, valueFlags };
4265
+ }
4266
+
3345
4267
  // src/cli.ts
3346
4268
  var program = new Command();
3347
- program.name(BIN_NAME).description("Fling a folder of static files at Fling and get a link back").version(VERSION).option(
3348
- "--server <url>",
3349
- `server URL (default: $${ENV.url} or config, else ${DEFAULT_SERVER_URL})`
3350
- ).option("--json", "print JSON instead of human output", false).showHelpAfterError().addHelpText(
4269
+ program.name(BIN_NAME).description("Fling a folder of static files at Fling and get a link back").version(VERSION).option("--server <url>", `server URL (default: $${ENV.url}, else the one you logged in to)`).option("--json", "print JSON instead of human output", false).showHelpAfterError().configureHelp({
4270
+ sortSubcommands: false,
4271
+ styleTitle: (title) => chalk10.bold(brand.primary(title)),
4272
+ styleCommandText: (text2) => chalk10.bold(text2),
4273
+ styleSubcommandText: (text2) => brand.accent(text2),
4274
+ styleOptionText: (text2) => brand.accent(text2),
4275
+ styleArgumentText: (text2) => chalk10.dim(text2)
4276
+ }).addHelpText(
4277
+ "before",
4278
+ () => process.stdout.isTTY ? logo({ columns: process.stdout.columns }) : ""
4279
+ ).addHelpText(
3351
4280
  "after",
3352
- `
3353
- Examples:
3354
- $ ${BIN_NAME} login
3355
- $ ${BIN_NAME} ./dist --name my-app deploy \u2192 https://my-app.fling.labs.nearform.com
3356
- $ ${BIN_NAME} ./dist --yes no prompts (CI): use the generated name, fail if it is taken
3357
- $ ${BIN_NAME} "Pitch Deck.html" a single page; found in Downloads if it is not here
3358
- $ ${BIN_NAME} site.zip a zip of a site
3359
- $ ${BIN_NAME} share my-app public link for people outside Nearform
4281
+ () => `
4282
+ ${chalk10.bold(brand.primary("Examples:"))}
4283
+ $ ${BIN_NAME} login https://fling.example.com sign in to your Fling (once; there is no default)
4284
+ $ ${BIN_NAME} setup sign in and add Fling to Claude Code / Claude Desktop
4285
+ $ ${BIN_NAME} deploy ./dist --name my-app \u2192 https://my-app.<your sites domain>
4286
+ $ ${BIN_NAME} ./dist the same: an existing path deploys
4287
+ $ ${BIN_NAME} ./dist --yes no prompts (CI): use the generated name, fail if it is taken
4288
+ $ ${BIN_NAME} "Pitch Deck.html" a single page
4289
+ $ ${BIN_NAME} site.zip a zip of a site
4290
+ $ ${BIN_NAME} share my-app public link for people who can't sign in
3360
4291
 
3361
- Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
3362
- Env: ${ENV.apiKey} \xB7 ${ENV.url} \xB7 ${ENV.configDir} \xB7 ${ENV.debug}`
4292
+ ${chalk10.dim(`Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
4293
+ Env: ${ENV.apiKey} \xB7 ${ENV.url} \xB7 ${ENV.configDir} \xB7 ${ENV.debug}`)}`
3363
4294
  );
3364
4295
  function action(handler) {
3365
4296
  return async (...args) => {
@@ -3397,41 +4328,93 @@ function report(error, ctx) {
3397
4328
  );
3398
4329
  }
3399
4330
  }
3400
- program.command("deploy [path]", { isDefault: true }).description(
3401
- "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"
4331
+ program.command("deploy [path]").helpGroup("Deploy:").description(
4332
+ "deploy a folder (default: .), a single .html file, a .zip or any file. `fling <path>` is short for it"
3402
4333
  ).option(
3403
4334
  "-n, --name <name>",
3404
4335
  "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)"
3405
- ).option("--spa", "single-page app: serve index.html for unknown paths").option("--public", "make the site public (default: Nearform sign-in required)").option(
4336
+ ).option("--spa", "single-page app: serve index.html for unknown paths").option("--public", "make the site public (default: sign-in required)").option(
3406
4337
  "-y, --yes",
3407
4338
  "never prompt: accept the generated name; without index.html, list the HTML pages (or fling anyway if there are none)"
3408
4339
  ).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(
3409
4340
  action(
3410
- (ctx, path, opts) => runDeploy(ctx, path, opts)
4341
+ (ctx, path3, opts) => runDeploy(ctx, path3, opts)
4342
+ )
4343
+ );
4344
+ program.command("setup [url]").helpGroup("Setup:").description(
4345
+ "sign in (to the Fling at url, the first time), then add the Fling plugin to Claude Code and the extension to Claude Desktop"
4346
+ ).option("-y, --yes", "do every step without asking").option("-s, --scope <scope>", "Claude Code plugin scope: user, project or local", "user").action(
4347
+ action(
4348
+ (ctx, url, opts) => runSetup(ctx, { ...opts, url })
3411
4349
  )
3412
4350
  );
3413
- program.command("login").description("sign in through the browser and store an API key").action(action((ctx) => runLogin(ctx)));
3414
- program.command("logout").description("remove the stored API key").action(action((ctx) => runLogout(ctx)));
3415
- program.command("whoami").description("show the signed-in user").action(action((ctx) => runWhoami(ctx)));
3416
- program.command("ls").alias("list").description("list your sites").option("--archived", "list archived sites").action(action((ctx, opts) => runList(ctx, opts)));
3417
- program.command("info <name>").description("show a site").action(action((ctx, name) => runInfo(ctx, name)));
3418
- program.command("versions <name>").description("list a site's versions (* = live)").action(action((ctx, name) => runVersions(ctx, name)));
3419
- program.command("files <name> [versionId]").description("list the files in a site's live version (or in versionId)").action(
4351
+ program.command("login [url]").helpGroup("Setup:").description(
4352
+ "sign in through the browser to the Fling at url (remembered; later logins can omit it) and store an API key"
4353
+ ).action(
4354
+ action(async (ctx, url) => {
4355
+ await runLogin(ctx, url);
4356
+ ctx.log.hint(`Next: \`${BIN_NAME} setup\` adds Fling to Claude Code and Claude Desktop.`);
4357
+ })
4358
+ );
4359
+ program.command("logout").helpGroup("Setup:").description("remove the stored API key").action(action((ctx) => runLogout(ctx)));
4360
+ program.command("whoami").helpGroup("Setup:").description("show the signed-in user").action(action((ctx) => runWhoami(ctx)));
4361
+ program.commandsGroup("Setup:").helpCommand("help [command]", "show help for a command");
4362
+ program.command("ls").helpGroup("Sites:").alias("list").description("list your sites").option("--archived", "list archived sites").action(action((ctx, opts) => runList(ctx, opts)));
4363
+ program.command("info <name>").helpGroup("Sites:").description("show a site").action(action((ctx, name) => runInfo(ctx, name)));
4364
+ program.command("versions <name>").helpGroup("Sites:").description("list a site's versions (* = live)").action(action((ctx, name) => runVersions(ctx, name)));
4365
+ program.command("files <name> [versionId]").helpGroup("Sites:").description("list the files in a site's live version (or in versionId)").action(
3420
4366
  action((ctx, name, versionId) => runFiles(ctx, name, versionId))
3421
4367
  );
3422
- program.command("rollback <name> <versionId>").description("serve an earlier version again").action(action((ctx, name, versionId) => runRollback(ctx, name, versionId)));
3423
- 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(
4368
+ program.command("rollback <name> <versionId>").helpGroup("Sites:").description("serve an earlier version again").action(action((ctx, name, versionId) => runRollback(ctx, name, versionId)));
4369
+ program.command("share <name>").helpGroup("Sites:").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(
3424
4370
  action(
3425
4371
  (ctx, name, opts) => runShare(ctx, name, opts)
3426
4372
  )
3427
4373
  );
3428
- program.command("visibility <name> <visibility>").description("private (Nearform sign-in) or public (anyone)").action(action((ctx, name, visibility) => runVisibility(ctx, name, visibility)));
3429
- program.command("archive <name>").description("take a site offline (404) but keep its files; undo with unarchive").action(action((ctx, name) => runArchive(ctx, name)));
3430
- program.command("unarchive <name>").description("bring an archived site back").action(action((ctx, name) => runArchive(ctx, name, true)));
3431
- 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)));
3432
- program.command("mcp").description("run the MCP server on stdio (used by the Claude Code plugin)").action(async (_opts, command) => {
4374
+ program.command("visibility <name> <visibility>").helpGroup("Sites:").description("private (sign-in required) or public (anyone)").action(action((ctx, name, visibility) => runVisibility(ctx, name, visibility)));
4375
+ program.command("archive <name>").helpGroup("Sites:").description("take a site offline (404) but keep its files; undo with unarchive").action(action((ctx, name) => runArchive(ctx, name)));
4376
+ program.command("unarchive <name>").helpGroup("Sites:").description("bring an archived site back").action(action((ctx, name) => runArchive(ctx, name, true)));
4377
+ program.command("rm <name>").helpGroup("Sites:").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)));
4378
+ program.command("comments <name>").helpGroup("Comments:").description("list a site's open comment threads (people leave them on its review page)").option("-p, --path <path>", "only this page, e.g. / or /docs/").option("-a, --all", "include resolved threads").action(
4379
+ action(
4380
+ (ctx, name, opts) => runComments(ctx, name, opts)
4381
+ )
4382
+ );
4383
+ program.command("comment <name> <text>").helpGroup("Comments:").description("comment on a page of a site (default: its home page), or reply to a thread").option("-p, --path <path>", "the page, e.g. /docs/ or about.html").option("-r, --reply <id>", "reply to this thread").action(
4384
+ action(
4385
+ (ctx, name, text2, opts) => runComment(ctx, name, text2, opts)
4386
+ )
4387
+ );
4388
+ program.command("resolve <name> <id>").helpGroup("Comments:").description("mark a comment thread resolved").option("--reopen", "reopen it instead").action(
4389
+ action(
4390
+ (ctx, name, id, opts) => runResolve(ctx, name, id, opts)
4391
+ )
4392
+ );
4393
+ program.command("uncomment <name> <id>").helpGroup("Comments:").alias("delete-comment").description("delete a comment (a thread goes with its replies; asks first unless --yes)").option("-y, --yes", "do not ask for confirmation").action(
4394
+ action(
4395
+ (ctx, name, id, opts) => runUncomment(ctx, name, id, opts)
4396
+ )
4397
+ );
4398
+ program.command("mcp").helpGroup("Agents:").description("run the MCP server on stdio (used by the Claude Code plugin)").action(async (_opts, command) => {
3433
4399
  const globals = command.optsWithGlobals();
3434
4400
  await runMcpServer({ server: globals.server });
3435
4401
  });
3436
- program.parseAsync(process.argv);
4402
+ async function main() {
4403
+ const route = await routeArgs(process.argv.slice(2), routeOptionsFor(program));
4404
+ if (route.kind === "help") {
4405
+ program.outputHelp();
4406
+ return;
4407
+ }
4408
+ if (route.kind === "unknown") {
4409
+ const log = createLogger();
4410
+ log.error(`"${route.arg}" is not a command, file or folder`);
4411
+ log.hint(
4412
+ `Run \`${BIN_NAME} --help\` for the commands, or \`${BIN_NAME} deploy <path>\` to deploy.`
4413
+ );
4414
+ process.exitCode = 1;
4415
+ return;
4416
+ }
4417
+ await program.parseAsync(route.args, { from: "user" });
4418
+ }
4419
+ await main();
3437
4420
  //# sourceMappingURL=cli.js.map