@nearform/fling 0.2.0 → 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,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import chalk9 from "chalk";
4
+ import chalk10 from "chalk";
5
5
  import { Command } from "commander";
6
6
 
7
7
  // src/commands/auth.ts
@@ -50,8 +50,8 @@ var SECRET_NAMES = /* @__PURE__ */ new Set([
50
50
  ".htpasswd"
51
51
  ]);
52
52
  var SSH_KEY_RE = /^id_(?:rsa|dsa|ecdsa|ed25519)(?:[._-]|$)/;
53
- function isSecretPath(path) {
54
- const name = (path.split(/[\\/]/).at(-1) ?? "").toLowerCase();
53
+ function isSecretPath(path3) {
54
+ const name = (path3.split(/[\\/]/).at(-1) ?? "").toLowerCase();
55
55
  if (!name) return false;
56
56
  if (SECRET_NAMES.has(name)) return true;
57
57
  if (SECRET_EXTENSIONS.some((ext) => name.endsWith(ext) && name.length > ext.length)) return true;
@@ -161,7 +161,7 @@ function lineAt(starts, index) {
161
161
  }
162
162
  return lo + 1;
163
163
  }
164
- function scanForSecrets(text2, path) {
164
+ function scanForSecrets(text2, path3) {
165
165
  let starts;
166
166
  const findings = [];
167
167
  const seen = /* @__PURE__ */ new Set();
@@ -178,7 +178,7 @@ function scanForSecrets(text2, path) {
178
178
  const key = `${rule.id}:${line}`;
179
179
  if (seen.has(key)) continue;
180
180
  seen.add(key);
181
- findings.push({ path, line, rule: rule.id });
181
+ findings.push({ path: path3, line, rule: rule.id });
182
182
  }
183
183
  }
184
184
  return findings.sort((a, b) => a.line - b.line || a.rule.localeCompare(b.rule));
@@ -186,8 +186,8 @@ function scanForSecrets(text2, path) {
186
186
  var BINARY_EXTENSIONS = new Set(
187
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)
188
188
  );
189
- function isScannablePath(path) {
190
- const name = path.split("/").at(-1) ?? "";
189
+ function isScannablePath(path3) {
190
+ const name = path3.split("/").at(-1) ?? "";
191
191
  const dot = name.lastIndexOf(".");
192
192
  return dot <= 0 || !BINARY_EXTENSIONS.has(name.slice(dot + 1).toLowerCase());
193
193
  }
@@ -196,10 +196,10 @@ function looksBinary(bytes) {
196
196
  for (let i = 0; i < end; i++) if (bytes[i] === 0) return true;
197
197
  return false;
198
198
  }
199
- function scanBytesForSecrets(bytes, path) {
200
- 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))
201
201
  return [];
202
- return scanForSecrets(new TextDecoder("utf-8").decode(bytes), path);
202
+ return scanForSecrets(new TextDecoder("utf-8").decode(bytes), path3);
203
203
  }
204
204
  function describeSecretFindings(findings, max = 20) {
205
205
  const lines = findings.slice(0, max).map((f) => `${f.path} line ${f.line}: ${SECRET_RULE_LABELS[f.rule] ?? f.rule}`);
@@ -209,10 +209,10 @@ function describeSecretFindings(findings, max = 20) {
209
209
  var NEVER_PUBLISHED_NOTE = "Fling never publishes dotfiles, gitignored files, secret files (keys, certificates, credentials) or files that contain secrets.";
210
210
 
211
211
  // ../../packages/shared/src/manifest.ts
212
- function isValidSitePath(path) {
213
- if (!path || path.length > MAX_PATH_LENGTH) return false;
214
- if (path.startsWith("/") || path.includes("\\") || path.includes("\0")) return false;
215
- 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("."));
216
216
  }
217
217
  var ManifestFileSchema = z.object({
218
218
  path: z.string().refine(isValidSitePath, "invalid path (dotfiles are never published)").refine((p) => !isSecretPath(p), "secret file (keys, certificates, credentials) never published"),
@@ -228,7 +228,13 @@ var MeSchema = z2.object({
228
228
  email: z2.string(),
229
229
  name: z2.string(),
230
230
  picture: z2.string().nullable(),
231
- 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()
232
238
  });
233
239
  var SiteSchema = z2.object({
234
240
  name: z2.string(),
@@ -330,8 +336,122 @@ var OAuthRequestInfoSchema = z2.object({
330
336
  email: z2.string()
331
337
  });
332
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) });
333
366
 
334
- // ../../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
335
455
  var SITE_NAME_MAX = 50;
336
456
  var SHARE_TOKEN_LENGTH = 20;
337
457
  var RESERVED_SITE_NAMES = [
@@ -352,26 +472,92 @@ var RESERVED_SITE_NAMES = [
352
472
  ];
353
473
  var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
354
474
  var SHARE_TOKEN_RE = new RegExp(`^[a-z0-9]{${SHARE_TOKEN_LENGTH}}$`);
355
- function slugify(input, max = SITE_NAME_MAX) {
356
- return input.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/g, "");
357
- }
358
475
  function isValidSiteName(name) {
359
476
  return name.length <= SITE_NAME_MAX && SLUG_RE.test(name) && !name.includes("--") && !RESERVED_SITE_NAMES.includes(name);
360
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
+ }
361
483
  function siteUrl(label, sitesDomain, protocol = "https:") {
362
484
  return `${protocol}//${label}.${sitesDomain}/`;
363
485
  }
364
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
+
365
551
  // ../../packages/shared/src/kvs.ts
366
- import { z as z3 } from "zod";
367
- var KvsSiteValueSchema = z3.object({
552
+ import { z as z5 } from "zod";
553
+ var KvsSiteValueSchema = z5.object({
368
554
  /** siteId — S3 prefix is `sites/<id>/<v>/` */
369
- id: z3.string(),
555
+ id: z5.string(),
370
556
  /** current versionId */
371
- v: z3.string(),
372
- vis: z3.enum(["private", "public"]),
557
+ v: z5.string(),
558
+ vis: z5.enum(["private", "public"]),
373
559
  /** serve /index.html for unknown extensionless paths */
374
- spa: z3.boolean()
560
+ spa: z5.boolean()
375
561
  });
376
562
 
377
563
  // ../../packages/shared/src/listing.ts
@@ -380,9 +566,9 @@ var escapeHtml = (s) => s.replace(
380
566
  /[&<>"']/g,
381
567
  (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]
382
568
  );
383
- var hrefFor = (path) => path.split("/").map(encodeURIComponent).join("/");
384
- function isHtmlPath(path) {
385
- return /\.html?$/i.test(path);
569
+ var hrefFor = (path3) => path3.split("/").map(encodeURIComponent).join("/");
570
+ function isHtmlPath(path3) {
571
+ return /\.html?$/i.test(path3);
386
572
  }
387
573
  function extractHtmlTitle(html) {
388
574
  const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html.slice(0, 65536));
@@ -455,181 +641,238 @@ ${body}
455
641
  }
456
642
 
457
643
  // ../../packages/shared/src/mcp-tools.ts
458
- import { z as z4 } from "zod";
644
+ import { z as z6 } from "zod";
459
645
  var MCP_PUBLISH_MAX_BYTES = 4 * 1024 * 1024;
460
646
  var MCP_UPLOAD_CHUNK_MAX_CHARS = 4 * 1024 * 1024;
461
647
  var MCP_UPLOAD_TTL_SECONDS = 60 * 60;
462
648
  var tool = (def) => def;
463
- 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
+ }
464
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.';
465
- 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.";
466
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.";
467
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.";
468
671
  var ENCODING = '`encoding` is "utf8" (default, for text: HTML, CSS, JS, SVG, JSON, Markdown\u2026) or "base64" (binary: images, fonts, PDFs).';
469
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.";
470
- var listHtml = z4.boolean().optional().describe(
673
+ var listHtml = z6.boolean().optional().describe(
471
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"
472
675
  );
473
- var siteName = z4.string().min(1).max(200).describe('Site name, e.g. "q3-report"');
474
- 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(
475
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")'
476
679
  );
477
- var encoding = z4.enum(["utf8", "base64"]).optional().describe('How `content` is encoded: "utf8" (default) for text, "base64" for binary files');
478
- 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(
479
684
  "Single-page app: serve index.html for unknown paths (client-side routing). Omit to keep the current setting"
480
685
  );
481
- var isPublic = z4.boolean().optional().describe(
482
- "true \u2192 anyone can view; false \u2192 Nearformers only. Omit to keep the current setting (new sites are private)"
483
- );
484
- var mcpTools = {
485
- whoami: tool({
486
- name: "fling_whoami",
487
- title: "Who am I",
488
- description: "Show the signed-in Nearform user that Fling acts as.",
489
- inputSchema: {},
490
- annotations: { readOnlyHint: true, openWorldHint: false }
491
- }),
492
- list: tool({
493
- name: "fling_list",
494
- title: "List sites",
495
- description: "List the user's Fling sites with URL, visibility and share link.",
496
- inputSchema: {
497
- archived: z4.boolean().optional().describe("List archived sites instead of live ones")
498
- },
499
- annotations: { readOnlyHint: true, openWorldHint: false }
500
- }),
501
- info: tool({
502
- name: "fling_info",
503
- title: "Site details",
504
- description: "Show one site: URL, visibility, share link, current version and version history (ids for fling_rollback).",
505
- inputSchema: { name: siteName },
506
- annotations: { readOnlyHint: true, openWorldHint: false }
507
- }),
508
- files: tool({
509
- name: "fling_files",
510
- title: "List site files",
511
- 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).",
512
- inputSchema: {
513
- name: siteName,
514
- versionId: z4.string().min(1).max(100).optional().describe("Version id (from fling_info); omit for the version being served")
515
- },
516
- annotations: { readOnlyHint: true, openWorldHint: false }
517
- }),
518
- checkName: tool({
519
- name: "fling_check_name",
520
- title: "Check a site name",
521
- 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,
522
- inputSchema: { name: z4.string().min(1).max(200).describe("Site name to check") },
523
- annotations: { readOnlyHint: true, openWorldHint: false }
524
- }),
525
- publish: tool({
526
- name: "fling_publish",
527
- title: "Publish a site",
528
- 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.`,
529
- inputSchema: {
530
- name: siteName,
531
- files: z4.array(
532
- z4.object({
533
- path: sitePath,
534
- content: z4.string().describe("The file content, encoded as `encoding` says"),
535
- encoding
536
- })
537
- ).min(1).max(MAX_FILES).describe("Every file of the site"),
538
- spa,
539
- public: isPublic,
540
- listHtml
541
- },
542
- annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true }
543
- }),
544
- uploadBegin: tool({
545
- name: "fling_upload_begin",
546
- title: "Start a chunked upload",
547
- 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}`,
548
- inputSchema: { name: siteName, spa, public: isPublic },
549
- annotations: { destructiveHint: false, openWorldHint: false }
550
- }),
551
- uploadFile: tool({
552
- name: "fling_upload_file",
553
- title: "Upload a file (chunked)",
554
- 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}`,
555
- inputSchema: {
556
- uploadId: z4.string().min(1).max(100).describe("From fling_upload_begin"),
557
- path: sitePath,
558
- content: z4.string().max(MCP_UPLOAD_CHUNK_MAX_CHARS).describe("The file content (or the next part of it, with `append`)"),
559
- encoding,
560
- append: z4.boolean().optional().describe("Append this content to the file sent earlier with the same path")
561
- },
562
- annotations: { destructiveHint: false, openWorldHint: false }
563
- }),
564
- uploadFinish: tool({
565
- name: "fling_upload_finish",
566
- title: "Finish a chunked upload",
567
- 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\`.`,
568
- inputSchema: {
569
- uploadId: z4.string().min(1).max(100).describe("From fling_upload_begin"),
570
- listHtml
571
- },
572
- annotations: { destructiveHint: false, openWorldHint: true }
573
- }),
574
- share: tool({
575
- name: "fling_share",
576
- title: "Share link",
577
- 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.",
578
- inputSchema: {
579
- name: siteName,
580
- refresh: z4.boolean().optional().describe("Rotate the link"),
581
- revoke: z4.boolean().optional().describe("Remove the link")
582
- },
583
- annotations: { destructiveHint: false, openWorldHint: true }
584
- }),
585
- setVisibility: tool({
586
- name: "fling_set_visibility",
587
- title: "Set visibility",
588
- description: "Make a site private (signed-in Nearformers only) or public (anyone).",
589
- inputSchema: { name: siteName, visibility: z4.enum(["private", "public"]) },
590
- annotations: { idempotentHint: true, openWorldHint: true }
591
- }),
592
- archive: tool({
593
- name: "fling_archive",
594
- title: "Archive or restore a site",
595
- 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.",
596
- inputSchema: {
597
- name: siteName,
598
- unarchive: z4.boolean().optional().describe("Restore an archived site")
599
- },
600
- annotations: { idempotentHint: true, openWorldHint: false }
601
- }),
602
- delete: tool({
603
- name: "fling_delete",
604
- title: "Delete a site",
605
- 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.",
606
- inputSchema: {
607
- name: siteName,
608
- confirm: z4.literal(true).describe("Must be true: the user has confirmed the deletion")
609
- },
610
- annotations: { destructiveHint: true, openWorldHint: false }
611
- }),
612
- rollback: tool({
613
- name: "fling_rollback",
614
- title: "Roll back",
615
- description: "Point a site at an earlier version (ids from fling_info). Instant; nothing is uploaded.",
616
- inputSchema: {
617
- name: siteName,
618
- versionId: z4.string().min(1).max(100).describe("Version id to serve")
619
- },
620
- annotations: { idempotentHint: true, openWorldHint: false }
621
- })
622
- };
623
- var MCP_REMOTE_INSTRUCTIONS = [
624
- "Fling publishes static sites (HTML/CSS/JS, images, documents) for Nearform staff at https://<name>.fling.labs.nearform.com.",
625
- "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.",
626
- "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.",
627
- "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.",
628
- "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.",
629
- NEVER_PUBLISHED,
630
- `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.`,
631
- "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)."
632
- ].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).`;
633
876
  var MCP_FILES_LISTED = 200;
634
877
  function describeVersionFiles(name, files, opts) {
635
878
  const kb = (n2) => n2 < 1024 ? `${n2} B` : n2 < 1024 * 1024 ? `${(n2 / 1024).toFixed(1)} KB` : `${(n2 / 1024 / 1024).toFixed(1)} MB`;
@@ -651,6 +894,24 @@ function describeVersionFiles(name, files, opts) {
651
894
  if (n > MCP_FILES_LISTED) lines.push(`\u2026 and ${n - MCP_FILES_LISTED} more`);
652
895
  return lines.join("\n");
653
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
+ }
654
915
 
655
916
  // ../../packages/shared/src/ready.ts
656
917
  var READY_TIMEOUT_MS = 15e3;
@@ -699,6 +960,95 @@ function readinessWaitDisabled(env) {
699
960
  return env.FLING_NO_WAIT === "1" || env.VITEST === "true" || env.NODE_ENV === "test";
700
961
  }
701
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
+
702
1052
  // src/errors.ts
703
1053
  var EXIT_ERROR = 1;
704
1054
  var EXIT_NOT_LOGGED_IN = 2;
@@ -723,6 +1073,14 @@ var NotLoggedInError = class extends CliError {
723
1073
  this.name = "NotLoggedInError";
724
1074
  }
725
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
+ };
726
1084
  function exitCodeFor(error) {
727
1085
  return error instanceof CliError ? error.exitCode : EXIT_ERROR;
728
1086
  }
@@ -730,9 +1088,9 @@ function exitCodeFor(error) {
730
1088
  // package.json
731
1089
  var package_default = {
732
1090
  name: "@nearform/fling",
733
- version: "0.2.0",
1091
+ version: "0.3.0",
734
1092
  license: "MIT",
735
- 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.",
736
1094
  type: "module",
737
1095
  bin: {
738
1096
  fling: "./dist/cli.js"
@@ -740,7 +1098,8 @@ var package_default = {
740
1098
  files: [
741
1099
  "dist/cli.js",
742
1100
  "dist/cli.js.map",
743
- "README.md"
1101
+ "README.md",
1102
+ "CHANGELOG.md"
744
1103
  ],
745
1104
  engines: {
746
1105
  node: ">=22"
@@ -814,8 +1173,8 @@ function defaultHint(status) {
814
1173
  if (status === 403) return "You are not allowed to do that.";
815
1174
  return void 0;
816
1175
  }
817
- function buildUrl(serverUrl, path, query) {
818
- 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}`}`);
819
1178
  for (const [key, value] of Object.entries(query ?? {})) {
820
1179
  if (value !== void 0 && value !== null) url.searchParams.set(key, String(value));
821
1180
  }
@@ -825,8 +1184,8 @@ function createApiClient(options) {
825
1184
  const serverUrl = options.serverUrl.replace(/\/+$/, "");
826
1185
  const fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
827
1186
  const timeoutMs = options.timeoutMs ?? 6e4;
828
- async function request(method, path, reqOptions = {}) {
829
- const url = buildUrl(serverUrl, path, reqOptions.query);
1187
+ async function request(method, path3, reqOptions = {}) {
1188
+ const url = buildUrl(serverUrl, path3, reqOptions.query);
830
1189
  const headers = {
831
1190
  Accept: "application/json",
832
1191
  "User-Agent": `${BIN_NAME}-cli/${VERSION}`
@@ -851,7 +1210,7 @@ function createApiClient(options) {
851
1210
  });
852
1211
  }
853
1212
  const raw = await readBody(response);
854
- if (!response.ok) throw errorFromResponse(response.status, raw, method, path);
1213
+ if (!response.ok) throw errorFromResponse(response.status, raw, method, path3);
855
1214
  if (!reqOptions.schema) return raw;
856
1215
  const parsed = reqOptions.schema.safeParse(raw);
857
1216
  if (!parsed.success) {
@@ -861,8 +1220,8 @@ function createApiClient(options) {
861
1220
  status: response.status,
862
1221
  code: "invalid_response",
863
1222
  body: raw,
864
- message: `Unexpected response from ${method} ${path}${where}: ${issue?.message ?? "invalid"}`,
865
- 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\`.`
866
1225
  });
867
1226
  }
868
1227
  return parsed.data;
@@ -903,7 +1262,7 @@ async function readBody(response) {
903
1262
  return text2;
904
1263
  }
905
1264
  }
906
- function errorFromResponse(status, body, method, path) {
1265
+ function errorFromResponse(status, body, method, path3) {
907
1266
  const envelope = ErrorEnvelopeSchema.safeParse(body);
908
1267
  if (envelope.success) {
909
1268
  const { code } = envelope.data.error;
@@ -919,12 +1278,12 @@ function errorFromResponse(status, body, method, path) {
919
1278
  if (Array.isArray(extra?.paths)) Object.assign(error, { paths: extra.paths });
920
1279
  return error;
921
1280
  }
922
- 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}` });
923
1282
  }
924
1283
  var NEVER_PUBLISHED_HINTS = {
925
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.",
926
1285
  secret_file: "Move the secret files out of the folder, or add them to .flingignore if the site does not need them.",
927
- 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.`
928
1287
  };
929
1288
  function describeCause(cause) {
930
1289
  if (cause instanceof Error) {
@@ -940,10 +1299,9 @@ function describeCause(cause) {
940
1299
  import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
941
1300
  import { homedir } from "node:os";
942
1301
  import { join } from "node:path";
943
- import { z as z5 } from "zod";
1302
+ import { z as z9 } from "zod";
944
1303
  var CONFIG_DIR_NAME = ".fling";
945
1304
  var CONFIG_FILE_NAME = "config.json";
946
- var DEFAULT_SERVER_URL = "https://fling.labs.nearform.com";
947
1305
  var ENV = {
948
1306
  apiKey: "FLING_API_KEY",
949
1307
  url: "FLING_URL",
@@ -952,13 +1310,15 @@ var ENV = {
952
1310
  /** Where to look for files given by name (the .mcpb extension sets it from its settings). */
953
1311
  downloadsDir: "FLING_DOWNLOADS_DIR"
954
1312
  };
955
- var cliConfigSchema = z5.object({
956
- serverUrl: z5.string().url().optional(),
957
- apiKey: z5.string().min(1).optional(),
958
- user: z5.object({
959
- email: z5.string().optional(),
960
- name: z5.string().optional(),
961
- 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()
962
1322
  }).optional()
963
1323
  });
964
1324
  function systemLocateOptions(env = process.env) {
@@ -1014,18 +1374,21 @@ function createConfigStore(options = {}) {
1014
1374
  return next;
1015
1375
  },
1016
1376
  async clearCredentials() {
1017
- const { serverUrl } = await load();
1018
- await save(serverUrl ? { serverUrl } : {});
1377
+ const { serverUrl, instance } = await load();
1378
+ await save({ serverUrl, instance });
1019
1379
  },
1020
1380
  async resolve(overrides = {}) {
1021
1381
  const config = await load();
1022
1382
  const envUrl = fromEnv(env[ENV.url]);
1023
1383
  const envKey = fromEnv(env[ENV.apiKey]);
1024
- 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);
1025
1386
  const [apiKey, apiKeySource] = envKey ? [envKey, "env"] : config.apiKey ? [config.apiKey, "config"] : [void 0, "none"];
1387
+ const stored = config.serverUrl && normalizeServerUrl(config.serverUrl);
1026
1388
  return {
1027
- serverUrl: serverUrl.replace(/\/+$/, ""),
1389
+ serverUrl,
1028
1390
  serverUrlSource,
1391
+ instance: serverUrl && stored === serverUrl ? config.instance : void 0,
1029
1392
  apiKey,
1030
1393
  apiKeySource,
1031
1394
  user: config.user,
@@ -1034,12 +1397,53 @@ function createConfigStore(options = {}) {
1034
1397
  }
1035
1398
  };
1036
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
+ }
1037
1409
  var REDACTED_KEY_CHARS = 10;
1038
1410
  function redactKey(key) {
1039
1411
  if (!key) return "-";
1040
1412
  return key.length <= REDACTED_KEY_CHARS + 8 ? "****" : `${key.slice(0, REDACTED_KEY_CHARS)}\u2026`;
1041
1413
  }
1042
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
+
1043
1447
  // src/auth.ts
1044
1448
  var CALLBACK_PORT_START = 8765;
1045
1449
  var CALLBACK_PORT_END = 8770;
@@ -1048,12 +1452,13 @@ var CALLBACK_PATH = "/callback";
1048
1452
  var AUTH_CLI_PATH = "/auth/cli";
1049
1453
  async function loginFlow(options) {
1050
1454
  const { serverUrl, store, log } = options;
1455
+ const instance = await fetchInstance(serverUrl, options.fetch ?? fetch);
1051
1456
  const state = randomBytes(16).toString("hex");
1052
1457
  const callback = await startCallbackServer({ state, timeoutMs: options.timeoutMs });
1053
1458
  try {
1054
1459
  const authUrl = buildAuthUrl(serverUrl, callback.url, options.hostname ?? osHostname(), state);
1055
1460
  options.onAuthUrl?.(authUrl);
1056
- log.info(`Opening your browser to sign in at ${serverUrl}`);
1461
+ log.info(`Opening your browser to sign in to ${instance.name} at ${serverUrl}`);
1057
1462
  log.hint(`If it does not open, visit: ${authUrl}`);
1058
1463
  try {
1059
1464
  await options.open(authUrl);
@@ -1066,6 +1471,7 @@ async function loginFlow(options) {
1066
1471
  const user = await client.request("GET", "/api/me", { schema: MeSchema });
1067
1472
  await store.update({
1068
1473
  serverUrl,
1474
+ instance,
1069
1475
  apiKey: key,
1070
1476
  user: { email: user.email, name: user.name, userSlug: user.userSlug }
1071
1477
  });
@@ -1073,7 +1479,7 @@ async function loginFlow(options) {
1073
1479
  log.success(`Signed in as ${user.email}`);
1074
1480
  log.hint(`Server: ${serverUrl}`);
1075
1481
  log.hint(`API key: ${keyPrefix} (stored in ${store.file})`);
1076
- return { serverUrl, keyPrefix, user };
1482
+ return { serverUrl, keyPrefix, user, instance };
1077
1483
  } finally {
1078
1484
  callback.close();
1079
1485
  }
@@ -1419,18 +1825,23 @@ async function ttyPrompt(question) {
1419
1825
  function ttyProgress(line) {
1420
1826
  process.stderr.write(`\r\x1B[2K${line ?? ""}`);
1421
1827
  }
1828
+ function requireServer(ctx) {
1829
+ if (!ctx.config.serverUrl) throw new NoServerError(ctx.binName);
1830
+ return ctx.config.serverUrl;
1831
+ }
1422
1832
  function requireClient(ctx) {
1833
+ const serverUrl = requireServer(ctx);
1423
1834
  if (!ctx.config.apiKey) throw new NotLoggedInError(ctx.binName);
1424
1835
  return createApiClient({
1425
- serverUrl: ctx.config.serverUrl,
1836
+ serverUrl,
1426
1837
  apiKey: ctx.config.apiKey,
1427
1838
  fetch: ctx.fetch
1428
1839
  });
1429
1840
  }
1430
1841
 
1431
1842
  // src/sites.ts
1432
- import { z as z6 } from "zod";
1433
- var ShareResponseSchema2 = z6.looseObject({ shareUrl: z6.string() });
1843
+ import { z as z10 } from "zod";
1844
+ var ShareResponseSchema2 = z10.looseObject({ shareUrl: z10.string() });
1434
1845
  var sitePath2 = (name) => `/api/sites/${encodeURIComponent(name)}`;
1435
1846
  function whoami(client) {
1436
1847
  return client.request("GET", "/api/me", { schema: MeSchema });
@@ -1440,32 +1851,8 @@ function checkName(client, name) {
1440
1851
  schema: NameCheckSchema
1441
1852
  });
1442
1853
  }
1443
- function sitesBaseFor(serverUrl) {
1444
- try {
1445
- const url = new URL(serverUrl);
1446
- const { hostname } = url;
1447
- if (url.port || !hostname.includes(".") || /^[\d.]+$/.test(hostname) || hostname.startsWith("["))
1448
- return null;
1449
- return { protocol: url.protocol, domain: hostname };
1450
- } catch {
1451
- return null;
1452
- }
1453
- }
1454
- function sitesBaseFromSiteUrl(siteUrl2) {
1455
- try {
1456
- const url = new URL(siteUrl2);
1457
- const dot = url.host.indexOf(".");
1458
- return dot > 0 ? { protocol: url.protocol, domain: url.host.slice(dot + 1) } : null;
1459
- } catch {
1460
- return null;
1461
- }
1462
- }
1463
- async function discoverSitesBase(client) {
1464
- const inferred = sitesBaseFor(client.serverUrl);
1465
- if (inferred) return inferred;
1466
- const sites = await listSites(client).catch(() => []);
1467
- const first = sites[0];
1468
- return first ? sitesBaseFromSiteUrl(first.url) : null;
1854
+ function sitesBaseOf(instance) {
1855
+ return instance ? { protocol: `${instance.siteScheme}:`, domain: instance.sitesDomain } : null;
1469
1856
  }
1470
1857
  function siteUrlFor(name, base) {
1471
1858
  return siteUrl(name, base.domain, base.protocol);
@@ -1535,11 +1922,47 @@ async function share(client, name, opts = {}) {
1535
1922
  });
1536
1923
  return { name, action: hadLink ? "refreshed" : "created", shareUrl: res.shareUrl };
1537
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
+ }
1538
1951
 
1539
1952
  // src/commands/auth.ts
1540
- 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) {
1541
1964
  const result = await loginFlow({
1542
- serverUrl: ctx.config.serverUrl,
1965
+ serverUrl: loginServer(ctx, url),
1543
1966
  store: ctx.store,
1544
1967
  log: ctx.log,
1545
1968
  open: ctx.open,
@@ -1555,32 +1978,109 @@ async function runLogout(ctx) {
1555
1978
  }
1556
1979
  await ctx.store.clearCredentials();
1557
1980
  ctx.log.success("Logged out; API key removed from this machine");
1558
- 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}`);
1559
1983
  }
1560
1984
  async function runWhoami(ctx) {
1561
1985
  const client = requireClient(ctx);
1562
1986
  const me = await whoami(client);
1563
- const base = ctx.json ? null : await discoverSitesBase(client);
1987
+ const instance = await loadInstance(ctx.store, ctx.config, ctx.fetch);
1564
1988
  ctx.out.data(
1565
1989
  {
1566
1990
  ...me,
1567
1991
  serverUrl: ctx.config.serverUrl,
1992
+ instance: instance?.name ?? null,
1568
1993
  apiKey: redactKey(ctx.config.apiKey),
1569
1994
  apiKeySource: ctx.config.apiKeySource
1570
1995
  },
1571
1996
  () => [
1572
1997
  `${chalk5.bold("User:")} ${me.name} <${me.email}>`,
1573
- `${chalk5.bold("Sites:")} ${sitesPattern(base)} ${chalk5.dim("(names are first come, first served)")}`,
1574
- `${chalk5.bold("Server:")} ${ctx.config.serverUrl}`,
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})`) : ""}`,
1575
2000
  `${chalk5.bold("API key:")} ${redactKey(ctx.config.apiKey)} ${chalk5.dim(`(from ${ctx.config.apiKeySource})`)}`
1576
2001
  ].join("\n")
1577
2002
  );
1578
2003
  }
1579
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
+
1580
2080
  // src/commands/deploy.ts
1581
2081
  import { stat as stat4 } from "node:fs/promises";
1582
2082
  import { resolve as resolve3 } from "node:path";
1583
- import chalk6 from "chalk";
2083
+ import chalk7 from "chalk";
1584
2084
 
1585
2085
  // src/deploy.ts
1586
2086
  import { readFile as readFile4 } from "node:fs/promises";
@@ -1744,19 +2244,19 @@ function describeSkipped(skipped) {
1744
2244
  lines.push(line(skipped.gitignored.length, "gitignored file", skipped.gitignored));
1745
2245
  return lines;
1746
2246
  }
1747
- function contentTypeFor(path) {
1748
- const type = mime.getType(path) ?? "application/octet-stream";
2247
+ function contentTypeFor(path3) {
2248
+ const type = mime.getType(path3) ?? "application/octet-stream";
1749
2249
  return type.startsWith("text/") || CHARSET_TYPES.has(type) ? `${type}; charset=utf-8` : type;
1750
2250
  }
1751
- async function sha256File(path) {
1752
- const hash = createHash("sha256");
1753
- for await (const chunk of createReadStream(path)) hash.update(chunk);
1754
- 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");
1755
2255
  }
1756
2256
  function toManifest(local) {
1757
2257
  return {
1758
- files: local.files.map(({ path, sha256, size, contentType }) => ({
1759
- path,
2258
+ files: local.files.map(({ path: path3, sha256, size, contentType }) => ({
2259
+ path: path3,
1760
2260
  sha256,
1761
2261
  size,
1762
2262
  contentType
@@ -2089,8 +2589,8 @@ async function deploy(deps, opts) {
2089
2589
  totalBytes: local.totalBytes,
2090
2590
  warnings: local.warnings
2091
2591
  };
2092
- const path = `/api/sites/${encodeURIComponent(name)}/deploys`;
2093
- 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, {
2094
2594
  schema: DeployResponseSchema,
2095
2595
  body: {
2096
2596
  manifest: toManifest(local),
@@ -2130,7 +2630,7 @@ async function deploy(deps, opts) {
2130
2630
  });
2131
2631
  const fin = await deps.client.request(
2132
2632
  "POST",
2133
- `${path}/${encodeURIComponent(res.versionId)}/finalize`,
2633
+ `${path3}/${encodeURIComponent(res.versionId)}/finalize`,
2134
2634
  { schema: FinalizeResponseSchema }
2135
2635
  );
2136
2636
  return {
@@ -2166,51 +2666,51 @@ import { copyFile, mkdir as mkdir2, mkdtemp, readFile as readFile5, rm, stat as
2166
2666
  import { tmpdir } from "node:os";
2167
2667
  import { basename as basename2, extname, join as join4, resolve as resolve2, sep as sep4 } from "node:path";
2168
2668
  import { unzipSync } from "fflate";
2169
- function inputKind(path, isDirectory) {
2669
+ function inputKind(path3, isDirectory) {
2170
2670
  if (isDirectory) return "folder";
2171
- const ext = extname(path).toLowerCase();
2671
+ const ext = extname(path3).toLowerCase();
2172
2672
  if (ext === ".html" || ext === ".htm") return "html";
2173
2673
  if (ext === ".zip") return "zip";
2174
2674
  return "file";
2175
2675
  }
2176
- function fileSiteName(path) {
2177
- const file = basename2(path.replace(/\\/g, "/"));
2676
+ function fileSiteName(path3) {
2677
+ const file = basename2(path3.replace(/\\/g, "/"));
2178
2678
  const stem = file.slice(0, file.length - extname(file).length) || file;
2179
2679
  return fitName([stem.replace(/\s*\(\d+\)$/, "")]);
2180
2680
  }
2181
- function defaultNameFor(path, isDirectory) {
2182
- return isDirectory ? defaultSiteName(path) : fileSiteName(path);
2681
+ function defaultNameFor(path3, isDirectory) {
2682
+ return isDirectory ? defaultSiteName(path3) : fileSiteName(path3);
2183
2683
  }
2184
2684
  async function tempFolder() {
2185
2685
  const dir = await mkdtemp(join4(tmpdir(), "fling-"));
2186
2686
  return { dir, cleanup: () => rm(dir, { recursive: true, force: true }) };
2187
2687
  }
2188
- async function prepareInput(path) {
2688
+ async function prepareInput(path3) {
2189
2689
  let info;
2190
2690
  try {
2191
- info = await stat3(path);
2691
+ info = await stat3(path3);
2192
2692
  } catch {
2193
- throw new CliError(`Not found: ${path}`);
2693
+ throw new CliError(`Not found: ${path3}`);
2194
2694
  }
2195
- const kind = inputKind(path, info.isDirectory());
2196
- const defaultName = defaultNameFor(path, info.isDirectory());
2695
+ const kind = inputKind(path3, info.isDirectory());
2696
+ const defaultName = defaultNameFor(path3, info.isDirectory());
2197
2697
  if (kind === "folder")
2198
2698
  return {
2199
2699
  kind,
2200
- source: path,
2201
- dir: path,
2700
+ source: path3,
2701
+ dir: path3,
2202
2702
  defaultName,
2203
2703
  skippedDotfiles: [],
2204
2704
  cleanup: async () => {
2205
2705
  }
2206
2706
  };
2207
- const file = basename2(path);
2707
+ const file = basename2(path3);
2208
2708
  if (file.startsWith("."))
2209
2709
  throw new CliError(`Not flung: ${file} is a dotfile. ${NEVER_PUBLISHED_NOTE}`);
2210
2710
  if (kind === "file" && isSecretPath(file)) throw new SecretFilesError([file]);
2211
2711
  if (info.size > MAX_DEPLOY_BYTES)
2212
2712
  throw new CliError(
2213
- `${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)}`
2214
2714
  );
2215
2715
  const tmp = await tempFolder();
2216
2716
  const skippedDotfiles = [];
@@ -2218,21 +2718,21 @@ async function prepareInput(path) {
2218
2718
  if (kind === "html") {
2219
2719
  if (info.size > MAX_FILE_BYTES)
2220
2720
  throw new CliError(
2221
- `${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`
2222
2722
  );
2223
- await copyFile(path, join4(tmp.dir, "index.html"));
2723
+ await copyFile(path3, join4(tmp.dir, "index.html"));
2224
2724
  } else if (kind === "zip") {
2225
- await extractZip(await readFile5(path), tmp.dir, skippedDotfiles);
2725
+ await extractZip(await readFile5(path3), tmp.dir, skippedDotfiles);
2226
2726
  } else {
2227
- const name = basename2(path);
2228
- await copyFile(path, join4(tmp.dir, name));
2727
+ const name = basename2(path3);
2728
+ await copyFile(path3, join4(tmp.dir, name));
2229
2729
  await writeFile2(join4(tmp.dir, "index.html"), wrapperPage(name));
2230
2730
  }
2231
2731
  } catch (error) {
2232
2732
  await tmp.cleanup();
2233
2733
  throw error;
2234
2734
  }
2235
- 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 };
2236
2736
  }
2237
2737
  var JUNK_FILES = /* @__PURE__ */ new Set(["thumbs.db", "desktop.ini"]);
2238
2738
  function zipEntryPath(name) {
@@ -2272,12 +2772,12 @@ async function extractZip(data, dest, skippedDotfiles = []) {
2272
2772
  try {
2273
2773
  entries = unzipSync(data, {
2274
2774
  filter: (file) => {
2275
- const path = zipEntryPath(file.name);
2276
- if (path === "unsafe") {
2775
+ const path3 = zipEntryPath(file.name);
2776
+ if (path3 === "unsafe") {
2277
2777
  unsafe.push(file.name);
2278
2778
  return false;
2279
2779
  }
2280
- if (path === null) {
2780
+ if (path3 === null) {
2281
2781
  const dot = zipDotEntry(file.name);
2282
2782
  if (dot && !skippedDotfiles.includes(dot)) skippedDotfiles.push(dot);
2283
2783
  return false;
@@ -2286,7 +2786,7 @@ async function extractZip(data, dest, skippedDotfiles = []) {
2286
2786
  declared += file.originalSize;
2287
2787
  if (file.originalSize > MAX_FILE_BYTES)
2288
2788
  throw new CliError(
2289
- `${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`
2290
2790
  );
2291
2791
  if (declared > MAX_DEPLOY_BYTES)
2292
2792
  throw new CliError(
@@ -2367,20 +2867,21 @@ function wrapperPage(file) {
2367
2867
  async function runDeploy(ctx, input, opts) {
2368
2868
  const client = requireClient(ctx);
2369
2869
  const ask = ctx.interactive && !opts.yes;
2370
- const base = ask ? await discoverSitesBase(client) : sitesBaseFor(client.serverUrl);
2371
- const path = resolve3(input ?? ".");
2372
- if (!await stat4(path).catch(() => null))
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))
2373
2874
  throw new CliError(`No such file or folder: ${input ?? "."}`, {
2374
2875
  hint: `Give the path to a folder, .html, .zip or other file, e.g. \`${ctx.binName} deploy ./dist\`.`
2375
2876
  });
2376
- const prepared = await prepareInput(path);
2877
+ const prepared = await prepareInput(path3);
2377
2878
  try {
2378
- await deployPrepared(ctx, client, base, prepared, opts, ask);
2879
+ await deployPrepared(ctx, client, instance, base, prepared, opts, ask);
2379
2880
  } finally {
2380
2881
  await prepared.cleanup();
2381
2882
  }
2382
2883
  }
2383
- async function deployPrepared(ctx, client, base, prepared, opts, ask) {
2884
+ async function deployPrepared(ctx, client, instance, base, prepared, opts, ask) {
2384
2885
  let local = await buildManifest(prepared.dir);
2385
2886
  local.skipped.dotfiles.unshift(...prepared.skippedDotfiles);
2386
2887
  for (const line of describeSkipped(local.skipped)) ctx.log.info(line);
@@ -2418,12 +2919,12 @@ async function deployPrepared(ctx, client, base, prepared, opts, ask) {
2418
2919
  });
2419
2920
  for (const warning of result.warnings) if (warning !== NO_INDEX_WARNING) ctx.log.warn(warning);
2420
2921
  const summary = `${result.fileCount} files, ${formatBytes(result.totalBytes)}`;
2421
- if (result.status === "unchanged") ctx.log.info(`Unchanged ${chalk6.dim(`(${summary})`)}`);
2922
+ if (result.status === "unchanged") ctx.log.info(`Unchanged ${chalk7.dim(`(${summary})`)}`);
2422
2923
  else if (result.status === "repointed")
2423
- ctx.log.success(`Restored version ${result.versionId} ${chalk6.dim(`(${summary})`)}`);
2924
+ ctx.log.success(`Restored version ${result.versionId} ${chalk7.dim(`(${summary})`)}`);
2424
2925
  else
2425
2926
  ctx.log.success(
2426
- `Deployed ${result.name} ${chalk6.dim(`(${summary}; uploaded ${result.uploaded}, version ${result.versionId})`)}`
2927
+ `Deployed ${result.name} ${chalk7.dim(`(${summary}; uploaded ${result.uploaded}, version ${result.versionId})`)}`
2427
2928
  );
2428
2929
  const ready = result.status === "unchanged" ? true : (await waitWithSpinner(
2429
2930
  ctx.waitReady,
@@ -2431,23 +2932,38 @@ async function deployPrepared(ctx, client, base, prepared, opts, ask) {
2431
2932
  local.files.map((f) => f.path),
2432
2933
  { progress: ctx.progress, warn: (m) => ctx.log.warn(m) }
2433
2934
  )).ready;
2434
- const privateNote = `Private: Nearform sign-in required. Share publicly with \`${ctx.binName} share ${result.name}\`.`;
2435
- ctx.out.data({ status: result.status, site: result.site, ready, skipped: local.skipped }, () => {
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, () => {
2436
2947
  const isPrivate = result.site.visibility === "private";
2437
2948
  if (ctx.out.tty)
2438
2949
  return box(
2439
- [brand.url(result.url), ...isPrivate ? [chalk6.dim(privateNote)] : []],
2440
- isPrivate ? chalk6.bold("flung \xB7 private") : chalk6.bold("flung \xB7 public")
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")
2441
2956
  );
2442
- const lines = [chalk6.bold(result.url)];
2443
- if (isPrivate) lines.push(chalk6.dim(privateNote));
2957
+ const lines = [chalk7.bold(result.url)];
2958
+ if (review) lines.push(chalk7.dim(`${reviewNote} ${review}`));
2959
+ if (isPrivate) lines.push(chalk7.dim(privateNote));
2444
2960
  return lines.join("\n");
2445
2961
  });
2446
2962
  }
2447
2963
  var BAR_WIDTH = 20;
2448
2964
  function progressBar(fraction) {
2449
2965
  const done = Math.round(Math.min(Math.max(fraction, 0), 1) * BAR_WIDTH);
2450
- return brand.primary("\u2501".repeat(done)) + chalk6.dim("\u2500".repeat(BAR_WIDTH - done));
2966
+ return brand.primary("\u2501".repeat(done)) + chalk7.dim("\u2500".repeat(BAR_WIDTH - done));
2451
2967
  }
2452
2968
  async function askNoIndex(ctx, pages) {
2453
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 ";
@@ -2463,20 +2979,20 @@ async function askNoIndex(ctx, pages) {
2463
2979
  }
2464
2980
  async function confirmSiteName(ctx, client, generated, base) {
2465
2981
  const where = (n) => base ? ` \u2192 ${siteUrlFor(n, base)}` : "";
2466
- const describe = (n, note) => ctx.log.info(`Site name: ${chalk6.bold(n)}${where(n)} ${chalk6.dim(`(${note})`)}`);
2982
+ const describe2 = (n, note) => ctx.log.info(`Site name: ${chalk7.bold(n)}${where(n)} ${chalk7.dim(`(${note})`)}`);
2467
2983
  let candidate = generated;
2468
2984
  for (; ; ) {
2469
2985
  const check2 = await checkName(client, candidate);
2470
2986
  let proposal;
2471
2987
  if (check2.status === "available" || check2.status === "yours") {
2472
2988
  proposal = candidate;
2473
- describe(candidate, check2.status === "yours" ? "updates your existing site" : "new");
2989
+ describe2(candidate, check2.status === "yours" ? "updates your existing site" : "new");
2474
2990
  } else {
2475
2991
  const why = check2.status === "taken" ? "is taken" : "is not a valid site name";
2476
2992
  const alternatives = check2.suggestions.length ? ` Suggestions: ${check2.suggestions.join(", ")}` : "";
2477
2993
  ctx.log.warn(`${candidate} ${why}.${alternatives}`);
2478
2994
  proposal = check2.suggestions[0];
2479
- if (proposal) describe(proposal, "new");
2995
+ if (proposal) describe2(proposal, "new");
2480
2996
  }
2481
2997
  for (; ; ) {
2482
2998
  const question = proposal ? `Use "${proposal}"? [Y/n or type another name] ` : "Type a site name (or n to abort): ";
@@ -2501,14 +3017,14 @@ async function confirmSiteName(ctx, client, generated, base) {
2501
3017
 
2502
3018
  // src/commands/setup.ts
2503
3019
  import { intro, outro, spinner } from "@clack/prompts";
2504
- import chalk7 from "chalk";
3020
+ import chalk8 from "chalk";
2505
3021
 
2506
3022
  // src/claude.ts
2507
3023
  import { execFile as execFile2 } from "node:child_process";
2508
3024
  import { access, mkdtemp as mkdtemp2, writeFile as writeFile3 } from "node:fs/promises";
2509
3025
  import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
2510
3026
  import { join as join5 } from "node:path";
2511
- var MARKETPLACE_SOURCE = "nearform/fling";
3027
+ var MARKETPLACE_SOURCE = REPO_SLUG;
2512
3028
  var MARKETPLACE_NAME = "fling";
2513
3029
  var PLUGIN_NAME = "fling";
2514
3030
  var PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
@@ -2563,12 +3079,12 @@ function check(result, what) {
2563
3079
  const detail = (result.stderr || result.stdout).trim().split("\n").at(-1);
2564
3080
  throw new Error(`Could not ${what}${detail ? `: ${detail}` : ""}`);
2565
3081
  }
2566
- async function findClaudeDesktop(platform = process.platform, env = process.env, exists = (path) => access(path).then(
3082
+ async function findClaudeDesktop(platform = process.platform, env = process.env, exists = (path3) => access(path3).then(
2567
3083
  () => true,
2568
3084
  () => false
2569
3085
  )) {
2570
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")] : [];
2571
- for (const path of candidates) if (await exists(path)) return path;
3087
+ for (const path3 of candidates) if (await exists(path3)) return path3;
2572
3088
  return null;
2573
3089
  }
2574
3090
  async function downloadExtension(serverUrl, fetch2) {
@@ -2594,36 +3110,38 @@ async function runSetup(baseCtx, opts, deps = {}) {
2594
3110
  ...baseCtx,
2595
3111
  log: createLogger({
2596
3112
  debug: baseCtx.config.debug,
2597
- write: (line) => process.stderr.write(`${chalk7.gray("\u2502")} ${line}
3113
+ write: (line) => process.stderr.write(`${chalk8.gray("\u2502")} ${line}
2598
3114
  `)
2599
3115
  })
2600
3116
  } : baseCtx;
2601
3117
  if (fancy) {
2602
3118
  process.stderr.write(`${logo()}
2603
3119
  `);
2604
- intro(chalk7.bold(" fling setup "), { output: process.stderr });
3120
+ intro(chalk8.bold(" fling setup "), { output: process.stderr });
2605
3121
  }
2606
3122
  const results = {
2607
- login: await stepLogin(ctx),
3123
+ login: await stepLogin(ctx, opts.url),
2608
3124
  claudeCode: "skipped",
2609
3125
  claudeDesktop: "skipped"
2610
3126
  };
2611
- results.claudeCode = await stepClaudeCode(ctx, run, scope, ask, fancy);
2612
- results.claudeDesktop = await stepClaudeDesktop(ctx, deps, ask, fancy);
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);
2613
3130
  const failed = Object.values(results).includes("failed");
2614
3131
  if (fancy)
2615
3132
  outro(
2616
- failed ? chalk7.yellow("Setup finished with problems \u2014 see above, then run `fling setup` again.") : `You're set. Try ${chalk7.bold(`${ctx.binName} deploy ./dist`)}, or ask Claude to \u201Cfling this\u201D.`,
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.`,
2617
3134
  { output: process.stderr }
2618
3135
  );
2619
3136
  ctx.out.data(results, () => "");
2620
3137
  if (failed) process.exitCode = 1;
2621
3138
  }
2622
- async function stepLogin(ctx) {
2623
- if (ctx.config.apiKey) {
3139
+ async function stepLogin(ctx, url) {
3140
+ const sameServer = url === void 0 || safeNormalize(url) === ctx.config.serverUrl;
3141
+ if (ctx.config.apiKey && sameServer) {
2624
3142
  try {
2625
3143
  const me = await whoami(requireClient(ctx));
2626
- ctx.log.success(`Signed in as ${chalk7.bold(me.name)} ${chalk7.dim(`<${me.email}>`)}`);
3144
+ ctx.log.success(`Signed in as ${chalk8.bold(me.name)} ${chalk8.dim(`<${me.email}>`)}`);
2627
3145
  return "already";
2628
3146
  } catch (error) {
2629
3147
  if (exitCodeFor(error) !== 2) {
@@ -2634,17 +3152,24 @@ async function stepLogin(ctx) {
2634
3152
  }
2635
3153
  }
2636
3154
  try {
2637
- await runLogin({ ...ctx, json: false });
3155
+ await runLogin({ ...ctx, json: false }, url);
2638
3156
  return "done";
2639
3157
  } catch (error) {
2640
3158
  ctx.log.error(error instanceof Error ? error.message : String(error));
2641
3159
  return "failed";
2642
3160
  }
2643
3161
  }
3162
+ function safeNormalize(url) {
3163
+ try {
3164
+ return normalizeServerUrl(url);
3165
+ } catch {
3166
+ return void 0;
3167
+ }
3168
+ }
2644
3169
  async function stepClaudeCode(ctx, run, scope, ask, fancy) {
2645
3170
  const status = await claudeCodeStatus(run);
2646
3171
  if (status.claude === "missing") {
2647
- ctx.log.info(`Claude Code not found ${chalk7.dim("(no `claude` on your PATH) \u2014 skipped")}`);
3172
+ ctx.log.info(`Claude Code not found ${chalk8.dim("(no `claude` on your PATH) \u2014 skipped")}`);
2648
3173
  ctx.log.hint(
2649
3174
  `Once it is installed, run \`${ctx.binName} setup\` again, or in Claude Code: /plugin marketplace add ${MARKETPLACE_SOURCE} then /plugin install ${PLUGIN_ID}`
2650
3175
  );
@@ -2653,7 +3178,7 @@ async function stepClaudeCode(ctx, run, scope, ask, fancy) {
2653
3178
  const installed = "installed" in status && status.installed;
2654
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?";
2655
3180
  if (ask && !await ctx.confirm(question)) {
2656
- ctx.log.info(chalk7.dim("Claude Code plugin: skipped"));
3181
+ ctx.log.info(chalk8.dim("Claude Code plugin: skipped"));
2657
3182
  return installed ? "already" : "skipped";
2658
3183
  }
2659
3184
  const s = fancy ? spinner({ output: process.stderr }) : null;
@@ -2667,7 +3192,7 @@ async function stepClaudeCode(ctx, run, scope, ask, fancy) {
2667
3192
  else ctx.log.error(message);
2668
3193
  return "failed";
2669
3194
  }
2670
- const done = installed ? `Claude Code plugin ${PLUGIN_NAME} is up to date` : `Claude Code plugin ${PLUGIN_NAME} installed ${chalk7.dim(`(${scope} scope)`)}`;
3195
+ const done = installed ? `Claude Code plugin ${PLUGIN_NAME} is up to date` : `Claude Code plugin ${PLUGIN_NAME} installed ${chalk8.dim(`(${scope} scope)`)}`;
2671
3196
  if (s) s.stop(done);
2672
3197
  else ctx.log.success(done);
2673
3198
  ctx.log.hint("Restart Claude Code (or run /reload-plugins) to pick it up.");
@@ -2677,13 +3202,13 @@ async function stepClaudeDesktop(ctx, deps, ask, fancy) {
2677
3202
  const app = await (deps.findClaudeDesktop ?? findClaudeDesktop)();
2678
3203
  if (!app) return "missing";
2679
3204
  if (ask && !await ctx.confirm("Add the Fling extension to Claude Desktop too?")) {
2680
- ctx.log.info(chalk7.dim("Claude Desktop extension: skipped"));
3205
+ ctx.log.info(chalk8.dim("Claude Desktop extension: skipped"));
2681
3206
  return "skipped";
2682
3207
  }
2683
3208
  const s = fancy ? spinner({ output: process.stderr }) : null;
2684
3209
  s?.start("Downloading the Claude Desktop extension");
2685
3210
  try {
2686
- const file = await (deps.downloadExtension ?? ((serverUrl) => downloadExtension(serverUrl, ctx.fetch)))(ctx.config.serverUrl);
3211
+ const file = await (deps.downloadExtension ?? ((serverUrl) => downloadExtension(serverUrl, ctx.fetch)))(requireServer(ctx));
2687
3212
  await ctx.open(file);
2688
3213
  const done = "Opened fling.mcpb in Claude Desktop \u2014 click Install there to finish";
2689
3214
  if (s) s.stop(done);
@@ -2698,7 +3223,7 @@ async function stepClaudeDesktop(ctx, deps, ask, fancy) {
2698
3223
  }
2699
3224
 
2700
3225
  // src/commands/sites.ts
2701
- import chalk8 from "chalk";
3226
+ import chalk9 from "chalk";
2702
3227
  async function runList(ctx, opts) {
2703
3228
  const sites = await listSites(requireClient(ctx), opts);
2704
3229
  ctx.out.data(
@@ -2716,11 +3241,11 @@ async function runList(ctx, opts) {
2716
3241
  );
2717
3242
  }
2718
3243
  function describeSite(site) {
2719
- const row = (label, value) => `${chalk8.bold(`${label}:`.padEnd(12))}${value}`;
3244
+ const row = (label, value) => `${chalk9.bold(`${label}:`.padEnd(12))}${value}`;
2720
3245
  return [
2721
3246
  row("Name", site.name),
2722
3247
  row("URL", site.url),
2723
- row("Visibility", site.visibility + (site.archived ? chalk8.yellow(" (archived)") : "")),
3248
+ row("Visibility", site.visibility + (site.archived ? chalk9.yellow(" (archived)") : "")),
2724
3249
  row("SPA", site.spa ? "yes" : "no"),
2725
3250
  row("Share link", site.shareUrl ?? "-"),
2726
3251
  row("Version", site.currentVersionId ?? "-"),
@@ -2742,7 +3267,7 @@ async function runInfo(ctx, name) {
2742
3267
  () => [
2743
3268
  ...describeSite(site),
2744
3269
  "",
2745
- chalk8.dim(
3270
+ chalk9.dim(
2746
3271
  `${site.versions.length} version(s) \u2014 \`fling versions ${name}\`; files \u2014 \`fling files ${name}\``
2747
3272
  )
2748
3273
  ].join("\n")
@@ -2768,7 +3293,7 @@ async function runFiles(ctx, name, versionId) {
2768
3293
  }
2769
3294
  ctx.out.data(result, () => {
2770
3295
  const n = result.files.length;
2771
- const header = chalk8.dim(
3296
+ const header = chalk9.dim(
2772
3297
  `${result.versionId}${result.current ? " (current)" : " (not served)"} \u2014 ${n} file${n === 1 ? "" : "s"}, ${formatBytes(result.totalBytes)}${result.generatedIndex ? ", generated index.html" : ""}`
2773
3298
  );
2774
3299
  const table = renderTable(result.files, [
@@ -2804,7 +3329,7 @@ async function runShare(ctx, name, opts) {
2804
3329
  ctx.log.success("Share link created \u2014 anyone with it can view the site");
2805
3330
  ctx.out.data(
2806
3331
  ready === void 0 ? result : { ...result, ready },
2807
- () => result.shareUrl ? result.shareUrl : chalk8.dim("(no share link)")
3332
+ () => result.shareUrl ? result.shareUrl : chalk9.dim("(no share link)")
2808
3333
  );
2809
3334
  }
2810
3335
  async function runVisibility(ctx, name, visibility) {
@@ -2813,7 +3338,7 @@ async function runVisibility(ctx, name, visibility) {
2813
3338
  }
2814
3339
  const site = await setVisibility(requireClient(ctx), name, visibility);
2815
3340
  ctx.log.success(
2816
- site.visibility === "public" ? `${name} is public \u2014 no sign-in needed` : `${name} is private \u2014 Nearform sign-in required`
3341
+ site.visibility === "public" ? `${name} is public \u2014 no sign-in needed` : `${name} is private \u2014 sign-in required`
2817
3342
  );
2818
3343
  ctx.out.data(site, () => site.url);
2819
3344
  }
@@ -2846,7 +3371,7 @@ import { tmpdir as tmpdir3 } from "node:os";
2846
3371
  import { basename as basename3, dirname as dirname2, join as join6 } from "node:path";
2847
3372
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2848
3373
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2849
- import { z as z7 } from "zod";
3374
+ import { z as z11 } from "zod";
2850
3375
 
2851
3376
  // src/locate.ts
2852
3377
  import { lstat, readdir as readdir2, readFile as readFile6, realpath as realpath3, stat as stat5 } from "node:fs/promises";
@@ -2860,21 +3385,21 @@ var SESSIONS_DEPTH = 4;
2860
3385
  var OUTPUTS_DEPTH = 4;
2861
3386
  var SANDBOX_PATH_RE = /^\/(mnt\/user-data|mnt\/data|sessions|home\/claude|tmp\/outputs)(\/|$)/;
2862
3387
  var nodeFs = {
2863
- async stat(path) {
3388
+ async stat(path3) {
2864
3389
  try {
2865
- const s = await stat5(path);
3390
+ const s = await stat5(path3);
2866
3391
  return { isFile: s.isFile(), isDirectory: s.isDirectory(), mtimeMs: s.mtimeMs, size: s.size };
2867
3392
  } catch {
2868
3393
  return null;
2869
3394
  }
2870
3395
  },
2871
- async readdir(path) {
3396
+ async readdir(path3) {
2872
3397
  try {
2873
- const entries = await readdir2(path, { withFileTypes: true });
3398
+ const entries = await readdir2(path3, { withFileTypes: true });
2874
3399
  const out = [];
2875
3400
  for (const e of entries) {
2876
3401
  if (e.isSymbolicLink()) {
2877
- const s = await stat5(`${path}/${e.name}`).catch(() => null);
3402
+ const s = await stat5(`${path3}/${e.name}`).catch(() => null);
2878
3403
  out.push({ name: e.name, isFile: Boolean(s?.isFile()), isDirectory: false });
2879
3404
  } else out.push({ name: e.name, isFile: e.isFile(), isDirectory: e.isDirectory() });
2880
3405
  }
@@ -2883,13 +3408,13 @@ var nodeFs = {
2883
3408
  return [];
2884
3409
  }
2885
3410
  },
2886
- async realpath(path) {
2887
- return realpath3(path).catch(() => null);
3411
+ async realpath(path3) {
3412
+ return realpath3(path3).catch(() => null);
2888
3413
  },
2889
- async readText(path) {
3414
+ async readText(path3) {
2890
3415
  try {
2891
- if (!(await lstat(path)).isFile()) return null;
2892
- return await readFile6(path, "utf8");
3416
+ if (!(await lstat(path3)).isFile()) return null;
3417
+ return await readFile6(path3, "utf8");
2893
3418
  } catch {
2894
3419
  return null;
2895
3420
  }
@@ -2912,9 +3437,9 @@ async function searchRoots(opts) {
2912
3437
  const { home, env, platform } = opts;
2913
3438
  const userHome = platform === "win32" && usable(env.USERPROFILE) ? env.USERPROFILE : home;
2914
3439
  const roots = [];
2915
- const add = (label, path, depth, sessions = false) => {
2916
- if (!path) return;
2917
- const norm = p.resolve(path);
3440
+ const add = (label, path3, depth, sessions = false) => {
3441
+ if (!path3) return;
3442
+ const norm = p.resolve(path3);
2918
3443
  if (roots.some((r) => r.path === norm)) return;
2919
3444
  roots.push({ label, path: norm, depth, ...sessions ? { sessions } : {} });
2920
3445
  };
@@ -2944,8 +3469,8 @@ async function searchRoots(opts) {
2944
3469
  if (!fs.realpath) return roots;
2945
3470
  const real = [];
2946
3471
  for (const root of roots) {
2947
- const path = await fs.realpath(root.path) ?? root.path;
2948
- if (!real.some((r) => r.path === path)) real.push({ ...root, path });
3472
+ const path3 = await fs.realpath(root.path) ?? root.path;
3473
+ if (!real.some((r) => r.path === path3)) real.push({ ...root, path: path3 });
2949
3474
  }
2950
3475
  return real;
2951
3476
  }
@@ -2993,10 +3518,10 @@ async function walk(roots, opts, visit) {
2993
3518
  budget -= entries.length;
2994
3519
  for (const entry of entries) {
2995
3520
  if (entry.name.startsWith(".")) continue;
2996
- const path = p.join(dir, entry.name);
2997
- const result = await visit({ root, path, entry, siblings: entries });
3521
+ const path3 = p.join(dir, entry.name);
3522
+ const result = await visit({ root, path: path3, entry, siblings: entries });
2998
3523
  if (entry.isDirectory && !skip(entry.name) && result !== "skip" && depth < root.depth)
2999
- next.push(path);
3524
+ next.push(path3);
3000
3525
  }
3001
3526
  }
3002
3527
  level = next;
@@ -3012,9 +3537,9 @@ function namesMatch(candidate, wanted) {
3012
3537
  const b = wanted.toLowerCase();
3013
3538
  return a === b || withoutDuplicateSuffix(a) === withoutDuplicateSuffix(b);
3014
3539
  }
3015
- var splitSegments = (path) => path.split(/[\\/]+/).filter((s) => s !== "" && s !== "." && s !== "~");
3016
- function suffixScore(path, wanted) {
3017
- const have = splitSegments(path);
3540
+ var splitSegments = (path3) => path3.split(/[\\/]+/).filter((s) => s !== "" && s !== "." && s !== "~");
3541
+ function suffixScore(path3, wanted) {
3542
+ const have = splitSegments(path3);
3018
3543
  let score = 1;
3019
3544
  for (let i = 2; i <= Math.min(have.length, wanted.length); i++) {
3020
3545
  if (have[have.length - i]?.toLowerCase() !== wanted[wanted.length - i]?.toLowerCase()) break;
@@ -3055,19 +3580,19 @@ async function resolveInput(input, opts) {
3055
3580
  if (!name) throw notFound(input, roots);
3056
3581
  const found = [];
3057
3582
  const seen = /* @__PURE__ */ new Set();
3058
- await walk(roots, opts, async ({ root, path, entry }) => {
3583
+ await walk(roots, opts, async ({ root, path: path3, entry }) => {
3059
3584
  if (!(entry.isFile || entry.isDirectory) || !namesMatch(entry.name, name)) return;
3060
- if (seen.has(path)) return;
3061
- seen.add(path);
3062
- const s = await fs.stat(path);
3585
+ if (seen.has(path3)) return;
3586
+ seen.add(path3);
3587
+ const s = await fs.stat(path3);
3063
3588
  if (!s) return;
3064
3589
  found.push({
3065
- path,
3590
+ path: path3,
3066
3591
  foundIn: root.label,
3067
3592
  kind: s.isDirectory ? "folder" : "file",
3068
3593
  modified: new Date(s.mtimeMs).toISOString(),
3069
3594
  size: s.size,
3070
- score: suffixScore(path, wanted),
3595
+ score: suffixScore(path3, wanted),
3071
3596
  mtimeMs: s.mtimeMs,
3072
3597
  isDirectory: s.isDirectory
3073
3598
  });
@@ -3101,15 +3626,15 @@ async function findFiles(opts, find = {}) {
3101
3626
  const roots = await searchRoots(opts);
3102
3627
  const found = /* @__PURE__ */ new Map();
3103
3628
  const rootPaths = new Set(roots.map((r) => r.path));
3104
- const { complete } = await walk(roots, opts, async ({ root, path, entry }) => {
3629
+ const { complete } = await walk(roots, opts, async ({ root, path: path3, entry }) => {
3105
3630
  if (entry.isDirectory) {
3106
- if (rootPaths.has(path)) return;
3107
- const index = (await fs.readdir(path)).find((e) => e.isFile && /^index\.html?$/i.test(e.name));
3631
+ if (rootPaths.has(path3)) return;
3632
+ const index = (await fs.readdir(path3)).find((e) => e.isFile && /^index\.html?$/i.test(e.name));
3108
3633
  if (!index) return;
3109
- const s2 = await fs.stat(p.join(path, index.name));
3634
+ const s2 = await fs.stat(p.join(path3, index.name));
3110
3635
  if (s2 && s2.mtimeMs >= since && (!query || entry.name.toLowerCase().includes(query)))
3111
- found.set(path, {
3112
- path,
3636
+ found.set(path3, {
3637
+ path: path3,
3113
3638
  foundIn: root.label,
3114
3639
  kind: "folder",
3115
3640
  modified: new Date(s2.mtimeMs).toISOString(),
@@ -3120,10 +3645,10 @@ async function findFiles(opts, find = {}) {
3120
3645
  }
3121
3646
  if (!entry.isFile || !LISTED_EXT.test(entry.name)) return;
3122
3647
  if (query && !entry.name.toLowerCase().includes(query)) return;
3123
- const s = await fs.stat(path);
3124
- if (!s || s.mtimeMs < since || found.has(path)) return;
3125
- found.set(path, {
3126
- path,
3648
+ const s = await fs.stat(path3);
3649
+ if (!s || s.mtimeMs < since || found.has(path3)) return;
3650
+ found.set(path3, {
3651
+ path: path3,
3127
3652
  foundIn: root.label,
3128
3653
  kind: "file",
3129
3654
  modified: new Date(s.mtimeMs).toISOString(),
@@ -3137,16 +3662,18 @@ async function findFiles(opts, find = {}) {
3137
3662
  }
3138
3663
 
3139
3664
  // src/mcp.ts
3140
- 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.`;
3141
- var MCP_LOCAL_INSTRUCTIONS = [
3142
- "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.`,
3143
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.",
3144
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.',
3145
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.",
3146
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.",
3147
3673
  NEVER_PUBLISHED,
3148
3674
  "Not logged in? Call fling_login, let the user finish signing in in the browser, then retry.",
3149
- "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)] : []
3150
3677
  ].join("\n\n");
3151
3678
  var toolDef = (def) => ({
3152
3679
  title: def.title,
@@ -3160,6 +3687,8 @@ function createMcpServer(deps = {}) {
3160
3687
  const cwd = deps.cwd ?? process.cwd();
3161
3688
  const locate = deps.locate ?? { ...systemLocateOptions(), cwd };
3162
3689
  const waitReady = deps.waitReady ?? createReadyWaiter();
3690
+ const wording = mcpWording(deps.instance);
3691
+ const mcpTools2 = mcpToolsFor(wording);
3163
3692
  async function edgeReady(url, paths) {
3164
3693
  const r = await waitReady(url, paths).catch(() => ({ ready: false, waitedMs: 0 }));
3165
3694
  return {
@@ -3170,9 +3699,14 @@ Not reachable yet at ${url}: ${NOT_READY_NOTE}.`
3170
3699
  }
3171
3700
  async function client() {
3172
3701
  const config = await store.resolve({ serverUrl: deps.server });
3702
+ if (!config.serverUrl) throw new NoServerError("fling");
3173
3703
  if (!config.apiKey) throw new CliError("Not logged in", { exitCode: EXIT_NOT_LOGGED_IN });
3174
3704
  return createApiClient({ serverUrl: config.serverUrl, apiKey: config.apiKey, fetch: fetchImpl });
3175
3705
  }
3706
+ async function sitesBase() {
3707
+ const config = await store.resolve({ serverUrl: deps.server });
3708
+ return sitesBaseOf(await loadInstance(store, config, fetchImpl));
3709
+ }
3176
3710
  async function run(op) {
3177
3711
  try {
3178
3712
  const { summary, data } = await op(await client());
@@ -3228,9 +3762,15 @@ Warnings:
3228
3762
  result.url,
3229
3763
  local.files.map((f) => f.path)
3230
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}`;
3231
3771
  return {
3232
3772
  kind: "deployed",
3233
- 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}
3234
3774
  To check exactly which files the site has, call fling_files with name "${result.name}".`,
3235
3775
  data: {
3236
3776
  status: result.status,
@@ -3239,6 +3779,7 @@ To check exactly which files the site has, call fling_files with name "${result.
3239
3779
  nameGenerated: !opts.nameGiven,
3240
3780
  versionId: result.versionId,
3241
3781
  site: result.site,
3782
+ ...review ? { reviewUrl: review } : {},
3242
3783
  generatedIndex,
3243
3784
  warnings: result.warnings,
3244
3785
  skipped: local.skipped
@@ -3247,23 +3788,27 @@ To check exactly which files the site has, call fling_files with name "${result.
3247
3788
  }
3248
3789
  const server = new McpServer(
3249
3790
  { name: "fling", version: VERSION },
3250
- { instructions: MCP_LOCAL_INSTRUCTIONS }
3791
+ {
3792
+ instructions: mcpLocalInstructions(wording, {
3793
+ comments: commentsEnabled(deps.instance)
3794
+ })
3795
+ }
3251
3796
  );
3252
- const { listHtml: listHtml2, spa: spa2, public: isPublic2 } = mcpTools.publish.inputSchema;
3797
+ const { listHtml: listHtml2, spa: spa2, public: isPublic } = mcpTools2.publish.inputSchema;
3253
3798
  server.registerTool(
3254
3799
  "fling_deploy",
3255
3800
  {
3256
3801
  title: "Deploy a file or folder from this computer",
3257
- 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,
3258
3803
  inputSchema: {
3259
- path: z7.string().min(1).describe(
3804
+ path: z11.string().min(1).describe(
3260
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)"
3261
3806
  ),
3262
- name: z7.string().optional().describe(
3807
+ name: z11.string().optional().describe(
3263
3808
  "Site name (lowercase letters, digits, single hyphens; max 50). Defaults to a name generated from the file or folder"
3264
3809
  ),
3265
3810
  spa: spa2,
3266
- public: isPublic2,
3811
+ public: isPublic,
3267
3812
  listHtml: listHtml2
3268
3813
  },
3269
3814
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true }
@@ -3308,9 +3853,9 @@ To check exactly which files the site has, call fling_files with name "${result.
3308
3853
  title: "Find files to fling",
3309
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.",
3310
3855
  inputSchema: {
3311
- query: z7.string().optional().describe('Only names containing this text (case-insensitive), e.g. "deck"'),
3312
- limit: z7.number().int().min(1).max(100).optional().describe("Max results (default 20)"),
3313
- 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)")
3314
3859
  },
3315
3860
  annotations: { readOnlyHint: true, openWorldHint: false }
3316
3861
  },
@@ -3337,7 +3882,7 @@ ${JSON.stringify(data, null, 2)}` }]
3337
3882
  }
3338
3883
  }
3339
3884
  );
3340
- const publishDef = mcpTools.publish;
3885
+ const publishDef = mcpTools2.publish;
3341
3886
  server.registerTool(
3342
3887
  publishDef.name,
3343
3888
  {
@@ -3371,11 +3916,11 @@ ${JSON.stringify(data, null, 2)}` }]
3371
3916
  server.registerTool(
3372
3917
  "fling_check_name",
3373
3918
  {
3374
- ...toolDef(mcpTools.checkName),
3375
- 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.`,
3376
3921
  inputSchema: {
3377
- name: z7.string().min(1).max(200).optional().describe("Site name to check"),
3378
- 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)")
3379
3924
  }
3380
3925
  },
3381
3926
  (args) => run(async (c) => {
@@ -3388,7 +3933,7 @@ ${JSON.stringify(data, null, 2)}` }]
3388
3933
  name = defaultNameFor(source.path, source.isDirectory);
3389
3934
  }
3390
3935
  const check2 = await checkName(c, name);
3391
- const base = check2.status === "invalid" ? null : await discoverSitesBase(c);
3936
+ const base = check2.status === "invalid" ? null : await sitesBase();
3392
3937
  const url = base ? siteUrlFor(name, base) : null;
3393
3938
  const suggestions = check2.suggestions.length ? ` Suggestions: ${check2.suggestions.join(", ")}.` : "";
3394
3939
  const summary = {
@@ -3412,17 +3957,19 @@ ${JSON.stringify(data, null, 2)}` }]
3412
3957
  "fling_login",
3413
3958
  {
3414
3959
  title: "Sign in to Fling",
3415
- 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.`,
3416
3961
  inputSchema: {},
3417
3962
  annotations: { readOnlyHint: false, openWorldHint: true }
3418
3963
  },
3419
3964
  async () => {
3420
3965
  try {
3421
3966
  const config = await store.resolve({ serverUrl: deps.server });
3967
+ const serverUrl = config.serverUrl;
3968
+ if (!serverUrl) throw new NoServerError("fling");
3422
3969
  if (config.apiKey && !pendingLogin) {
3423
3970
  const me = await whoami(
3424
3971
  createApiClient({
3425
- serverUrl: config.serverUrl,
3972
+ serverUrl,
3426
3973
  apiKey: config.apiKey,
3427
3974
  fetch: fetchImpl
3428
3975
  })
@@ -3434,7 +3981,7 @@ ${JSON.stringify(data, null, 2)}` }]
3434
3981
  done: Promise.resolve()
3435
3982
  };
3436
3983
  login.done = loginFlow({
3437
- serverUrl: config.serverUrl,
3984
+ serverUrl,
3438
3985
  store,
3439
3986
  log: silentLogger,
3440
3987
  open: deps.open ?? (async (url) => (await import("open")).default(url)),
@@ -3469,7 +4016,7 @@ ${JSON.stringify(data, null, 2)}` }]
3469
4016
  }
3470
4017
  }
3471
4018
  );
3472
- const t = mcpTools;
4019
+ const t = mcpTools2;
3473
4020
  server.registerTool(
3474
4021
  t.list.name,
3475
4022
  toolDef(t.list),
@@ -3554,12 +4101,66 @@ ${JSON.stringify(data, null, 2)}` }]
3554
4101
  };
3555
4102
  })
3556
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
+ }
3557
4158
  server.registerTool(
3558
4159
  t.whoami.name,
3559
4160
  toolDef(t.whoami),
3560
4161
  () => run(async (c) => {
3561
4162
  const me = await whoami(c);
3562
- const base = await discoverSitesBase(c);
4163
+ const base = await sitesBase();
3563
4164
  return {
3564
4165
  summary: `Signed in as ${me.email} (sites are served at ${sitesPattern(base)})`,
3565
4166
  data: me
@@ -3575,24 +4176,24 @@ async function writeInlineFiles(dir, files) {
3575
4176
  const secretFiles = [];
3576
4177
  let total = 0;
3577
4178
  for (const f of files) {
3578
- const path = f.path.replace(/^(\.\/)+/, "").replace(/^\/+/, "");
3579
- const segments = path.split("/");
3580
- if (path && !path.includes("\\") && segments.every((s) => s !== "" && s !== "." && s !== "..") && segments.some((s) => s.startsWith("."))) {
3581
- 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);
3582
4183
  continue;
3583
4184
  }
3584
- if (!isValidSitePath(path)) throw new CliError(`Invalid file path: "${f.path}"`);
3585
- if (isSecretPath(path)) {
3586
- secretFiles.push(path);
4185
+ if (!isValidSitePath(path3)) throw new CliError(`Invalid file path: "${f.path}"`);
4186
+ if (isSecretPath(path3)) {
4187
+ secretFiles.push(path3);
3587
4188
  continue;
3588
4189
  }
3589
- if (seen.has(path)) throw new CliError(`"${path}" is listed twice`);
3590
- seen.add(path);
4190
+ if (seen.has(path3)) throw new CliError(`"${path3}" is listed twice`);
4191
+ seen.add(path3);
3591
4192
  const bytes = Buffer.from(f.content, f.encoding === "base64" ? "base64" : "utf8");
3592
4193
  total += bytes.length;
3593
4194
  if (total > MAX_DEPLOY_BYTES)
3594
4195
  throw new CliError(`The site is larger than ${formatBytes(MAX_DEPLOY_BYTES)}`);
3595
- const target = join6(dir, ...path.split("/"));
4196
+ const target = join6(dir, ...path3.split("/"));
3596
4197
  await mkdir3(dirname2(target), { recursive: true });
3597
4198
  await writeFile4(target, bytes);
3598
4199
  }
@@ -3600,6 +4201,7 @@ async function writeInlineFiles(dir, files) {
3600
4201
  return { skippedDotfiles };
3601
4202
  }
3602
4203
  function describeError(error) {
4204
+ if (error instanceof NoServerError) return NO_SERVER_HINT;
3603
4205
  if (error instanceof CliError && error.exitCode === EXIT_NOT_LOGGED_IN) return LOGIN_HINT;
3604
4206
  if (error instanceof CliApiError && error.status === 401) return LOGIN_HINT;
3605
4207
  if (error instanceof CliError)
@@ -3608,7 +4210,9 @@ ${error.hint}` : error.message;
3608
4210
  return error instanceof Error ? error.message : String(error);
3609
4211
  }
3610
4212
  async function runMcpServer(deps = {}) {
3611
- 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 });
3612
4216
  await server.connect(new StdioServerTransport());
3613
4217
  }
3614
4218
 
@@ -3616,7 +4220,7 @@ async function runMcpServer(deps = {}) {
3616
4220
  import { stat as stat6 } from "node:fs/promises";
3617
4221
  import { resolve as resolve4 } from "node:path";
3618
4222
  var PASS_THROUGH = /* @__PURE__ */ new Set(["-h", "--help", "-V", "--version"]);
3619
- var pathExists = (path) => stat6(resolve4(path)).then(
4223
+ var pathExists = (path3) => stat6(resolve4(path3)).then(
3620
4224
  () => true,
3621
4225
  () => false
3622
4226
  );
@@ -3662,32 +4266,30 @@ function routeOptionsFor(program2) {
3662
4266
 
3663
4267
  // src/cli.ts
3664
4268
  var program = new Command();
3665
- program.name(BIN_NAME).description("Fling a folder of static files at Fling and get a link back").version(VERSION).option(
3666
- "--server <url>",
3667
- `server URL (default: $${ENV.url} or config, else ${DEFAULT_SERVER_URL})`
3668
- ).option("--json", "print JSON instead of human output", false).showHelpAfterError().configureHelp({
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({
3669
4270
  sortSubcommands: false,
3670
- styleTitle: (title) => chalk9.bold(brand.primary(title)),
3671
- styleCommandText: (text2) => chalk9.bold(text2),
4271
+ styleTitle: (title) => chalk10.bold(brand.primary(title)),
4272
+ styleCommandText: (text2) => chalk10.bold(text2),
3672
4273
  styleSubcommandText: (text2) => brand.accent(text2),
3673
4274
  styleOptionText: (text2) => brand.accent(text2),
3674
- styleArgumentText: (text2) => chalk9.dim(text2)
4275
+ styleArgumentText: (text2) => chalk10.dim(text2)
3675
4276
  }).addHelpText(
3676
4277
  "before",
3677
4278
  () => process.stdout.isTTY ? logo({ columns: process.stdout.columns }) : ""
3678
4279
  ).addHelpText(
3679
4280
  "after",
3680
4281
  () => `
3681
- ${chalk9.bold(brand.primary("Examples:"))}
4282
+ ${chalk10.bold(brand.primary("Examples:"))}
4283
+ $ ${BIN_NAME} login https://fling.example.com sign in to your Fling (once; there is no default)
3682
4284
  $ ${BIN_NAME} setup sign in and add Fling to Claude Code / Claude Desktop
3683
- $ ${BIN_NAME} deploy ./dist --name my-app \u2192 https://my-app.fling.labs.nearform.com
4285
+ $ ${BIN_NAME} deploy ./dist --name my-app \u2192 https://my-app.<your sites domain>
3684
4286
  $ ${BIN_NAME} ./dist the same: an existing path deploys
3685
4287
  $ ${BIN_NAME} ./dist --yes no prompts (CI): use the generated name, fail if it is taken
3686
4288
  $ ${BIN_NAME} "Pitch Deck.html" a single page
3687
4289
  $ ${BIN_NAME} site.zip a zip of a site
3688
- $ ${BIN_NAME} share my-app public link for people outside Nearform
4290
+ $ ${BIN_NAME} share my-app public link for people who can't sign in
3689
4291
 
3690
- ${chalk9.dim(`Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
4292
+ ${chalk10.dim(`Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
3691
4293
  Env: ${ENV.apiKey} \xB7 ${ENV.url} \xB7 ${ENV.configDir} \xB7 ${ENV.debug}`)}`
3692
4294
  );
3693
4295
  function action(handler) {
@@ -3731,20 +4333,26 @@ program.command("deploy [path]").helpGroup("Deploy:").description(
3731
4333
  ).option(
3732
4334
  "-n, --name <name>",
3733
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)"
3734
- ).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(
3735
4337
  "-y, --yes",
3736
4338
  "never prompt: accept the generated name; without index.html, list the HTML pages (or fling anyway if there are none)"
3737
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(
3738
4340
  action(
3739
- (ctx, path, opts) => runDeploy(ctx, path, opts)
4341
+ (ctx, path3, opts) => runDeploy(ctx, path3, opts)
3740
4342
  )
3741
4343
  );
3742
- program.command("setup").helpGroup("Setup:").description(
3743
- "sign in, then add the Fling plugin to Claude Code and the extension to Claude Desktop"
3744
- ).option("-y, --yes", "do every step without asking").option("-s, --scope <scope>", "Claude Code plugin scope: user, project or local", "user").action(action((ctx, opts) => runSetup(ctx, opts)));
3745
- program.command("login").helpGroup("Setup:").description("sign in through the browser and store an API key").action(
3746
- action(async (ctx) => {
3747
- await runLogin(ctx);
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 })
4349
+ )
4350
+ );
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);
3748
4356
  ctx.log.hint(`Next: \`${BIN_NAME} setup\` adds Fling to Claude Code and Claude Desktop.`);
3749
4357
  })
3750
4358
  );
@@ -3763,10 +4371,30 @@ program.command("share <name>").helpGroup("Sites:").description("print (or creat
3763
4371
  (ctx, name, opts) => runShare(ctx, name, opts)
3764
4372
  )
3765
4373
  );
3766
- program.command("visibility <name> <visibility>").helpGroup("Sites:").description("private (Nearform sign-in) or public (anyone)").action(action((ctx, name, visibility) => runVisibility(ctx, name, visibility)));
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)));
3767
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)));
3768
4376
  program.command("unarchive <name>").helpGroup("Sites:").description("bring an archived site back").action(action((ctx, name) => runArchive(ctx, name, true)));
3769
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
+ );
3770
4398
  program.command("mcp").helpGroup("Agents:").description("run the MCP server on stdio (used by the Claude Code plugin)").action(async (_opts, command) => {
3771
4399
  const globals = command.optsWithGlobals();
3772
4400
  await runMcpServer({ server: globals.server });