@nearform/fling 0.2.0 → 0.4.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.4.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,14 +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()
962
- }).optional()
1313
+ var serverEntrySchema = z9.object({
1314
+ /** What the server said about itself at login. A copy this CLI can't read is just refetched. */
1315
+ instance: InstanceMetadataSchema.optional().catch(void 0),
1316
+ apiKey: z9.string().min(1).optional().catch(void 0),
1317
+ user: z9.object({
1318
+ email: z9.string().optional(),
1319
+ name: z9.string().optional(),
1320
+ userSlug: z9.string().optional()
1321
+ }).optional().catch(void 0)
963
1322
  });
964
1323
  function systemLocateOptions(env = process.env) {
965
1324
  return { cwd: process.cwd(), home: homedir(), platform: process.platform, env };
@@ -971,6 +1330,37 @@ function fromEnv(value) {
971
1330
  function resolveConfigDir(env = process.env) {
972
1331
  return env[ENV.configDir] ?? join(env.HOME ?? homedir(), CONFIG_DIR_NAME);
973
1332
  }
1333
+ function safeNormalize(input) {
1334
+ if (typeof input !== "string") return void 0;
1335
+ try {
1336
+ return normalizeServerUrl(input);
1337
+ } catch {
1338
+ return void 0;
1339
+ }
1340
+ }
1341
+ function isObject(value) {
1342
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1343
+ }
1344
+ function parseConfig(json) {
1345
+ if (!isObject(json)) throw new Error("expected a JSON object");
1346
+ const servers = {};
1347
+ let current;
1348
+ if ("servers" in json) {
1349
+ if (isObject(json.servers)) {
1350
+ for (const [key, value] of Object.entries(json.servers)) {
1351
+ const url = safeNormalize(key);
1352
+ const entry = isObject(value) ? serverEntrySchema.safeParse(value) : void 0;
1353
+ if (url && entry?.success) servers[url] = { ...servers[url], ...entry.data };
1354
+ }
1355
+ }
1356
+ current = safeNormalize(json.current);
1357
+ } else {
1358
+ current = safeNormalize(json.serverUrl);
1359
+ if (current) servers[current] = serverEntrySchema.parse(json);
1360
+ }
1361
+ if (current && !servers[current]) servers[current] = {};
1362
+ return current ? { current, servers } : { servers };
1363
+ }
974
1364
  function createConfigStore(options = {}) {
975
1365
  const env = options.env ?? process.env;
976
1366
  const dir = options.dir ?? resolveConfigDir(env);
@@ -980,7 +1370,7 @@ function createConfigStore(options = {}) {
980
1370
  try {
981
1371
  raw = await readFile(file, "utf8");
982
1372
  } catch (error) {
983
- if (error.code === "ENOENT") return {};
1373
+ if (error.code === "ENOENT") return { servers: {} };
984
1374
  throw error;
985
1375
  }
986
1376
  let json;
@@ -989,57 +1379,160 @@ function createConfigStore(options = {}) {
989
1379
  } catch {
990
1380
  throw new Error(`Config file ${file} is not valid JSON`);
991
1381
  }
992
- const parsed = cliConfigSchema.safeParse(json);
993
- if (!parsed.success) {
994
- throw new Error(`Config file ${file} is invalid: ${parsed.error.issues[0]?.message}`);
1382
+ try {
1383
+ return parseConfig(json);
1384
+ } catch (error) {
1385
+ throw new Error(`Config file ${file} is invalid: ${error.message}`);
995
1386
  }
996
- return parsed.data;
997
1387
  }
998
1388
  async function save(config) {
999
1389
  await mkdir(dir, { recursive: true, mode: 448 });
1000
1390
  await chmod(dir, 448).catch(() => {
1001
1391
  });
1002
- await writeFile(file, `${JSON.stringify(config, null, 2)}
1392
+ const body = { current: config.current, servers: config.servers };
1393
+ await writeFile(file, `${JSON.stringify(body, null, 2)}
1003
1394
  `, { mode: 384 });
1004
1395
  await chmod(file, 384);
1005
1396
  }
1397
+ async function edit(change) {
1398
+ const config = await load();
1399
+ change(config);
1400
+ await save(config);
1401
+ return config;
1402
+ }
1006
1403
  return {
1007
1404
  dir,
1008
1405
  file,
1009
1406
  load,
1010
1407
  save,
1011
- async update(patch) {
1012
- const next = { ...await load(), ...patch };
1013
- await save(next);
1014
- return next;
1408
+ putServer(serverUrl, patch, opts = {}) {
1409
+ const url = normalizeServerUrl(serverUrl);
1410
+ return edit((config) => {
1411
+ config.servers[url] = { ...config.servers[url], ...patch };
1412
+ if (opts.makeCurrent ?? !config.current) config.current = url;
1413
+ });
1015
1414
  },
1016
- async clearCredentials() {
1017
- const { serverUrl } = await load();
1018
- await save(serverUrl ? { serverUrl } : {});
1415
+ async setCurrent(serverUrl) {
1416
+ const url = normalizeServerUrl(serverUrl);
1417
+ await edit((config) => {
1418
+ config.servers[url] ??= {};
1419
+ config.current = url;
1420
+ });
1421
+ },
1422
+ async clearCredentials(serverUrl) {
1423
+ const url = normalizeServerUrl(serverUrl);
1424
+ await edit((config) => {
1425
+ const entry = config.servers[url];
1426
+ if (entry) config.servers[url] = entry.instance ? { instance: entry.instance } : {};
1427
+ });
1428
+ },
1429
+ async removeServer(serverUrl) {
1430
+ const url = normalizeServerUrl(serverUrl);
1431
+ await edit((config) => {
1432
+ delete config.servers[url];
1433
+ if (config.current === url) config.current = void 0;
1434
+ });
1019
1435
  },
1020
1436
  async resolve(overrides = {}) {
1021
1437
  const config = await load();
1022
1438
  const envUrl = fromEnv(env[ENV.url]);
1023
1439
  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"];
1025
- const [apiKey, apiKeySource] = envKey ? [envKey, "env"] : config.apiKey ? [config.apiKey, "config"] : [void 0, "none"];
1440
+ const [rawUrl, serverUrlSource] = overrides.serverUrl ? [overrides.serverUrl, "flag"] : envUrl ? [envUrl, "env"] : config.current ? [config.current, "config"] : [void 0, "none"];
1441
+ const serverUrl = rawUrl === void 0 ? void 0 : matchServer(Object.keys(config.servers), rawUrl, { loose: true }).url;
1442
+ const entry = serverUrl === void 0 ? void 0 : config.servers[serverUrl];
1443
+ const [apiKey, apiKeySource] = envKey ? [envKey, "env"] : entry?.apiKey ? [entry.apiKey, "config"] : [void 0, "none"];
1026
1444
  return {
1027
- serverUrl: serverUrl.replace(/\/+$/, ""),
1445
+ serverUrl,
1028
1446
  serverUrlSource,
1447
+ current: config.current,
1448
+ remembered: entry !== void 0,
1449
+ instance: entry?.instance,
1029
1450
  apiKey,
1030
1451
  apiKeySource,
1031
- user: config.user,
1452
+ user: entry?.user,
1032
1453
  debug: Boolean(env[ENV.debug])
1033
1454
  };
1034
1455
  }
1035
1456
  };
1036
1457
  }
1458
+ var HAS_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
1459
+ function matchServer(remembered, input, options = {}) {
1460
+ const trimmed = input.trim();
1461
+ const normalized = safeNormalize(trimmed);
1462
+ if (normalized && remembered.includes(normalized)) return { url: normalized, remembered: true };
1463
+ if (!HAS_SCHEME.test(trimmed)) {
1464
+ const bare = trimmed.replace(/\/+$/, "").toLowerCase();
1465
+ const parts = remembered.map((url) => {
1466
+ const u = new URL(url);
1467
+ return { url, host: u.host, hostname: u.hostname, address: `${u.host}${u.pathname}` };
1468
+ });
1469
+ const tiers = [
1470
+ parts.filter((p) => p.host === bare || p.address === bare),
1471
+ options.loose && bare ? parts.filter((p) => p.address.startsWith(bare) || p.hostname.endsWith(`.${bare}`)) : []
1472
+ ];
1473
+ for (const found of tiers) {
1474
+ const [only] = found;
1475
+ if (only && found.length === 1) return { url: only.url, remembered: true };
1476
+ if (found.length > 1)
1477
+ throw new CliError(`"${input}" matches more than one server you have signed in to`, {
1478
+ hint: `Use the full address: ${found.map((p) => p.url).join(", ")}`
1479
+ });
1480
+ }
1481
+ }
1482
+ if (!normalized)
1483
+ throw new CliError(`"${input}" is not a server address`, {
1484
+ hint: "Use your Fling's address, e.g. https://fling.example.com."
1485
+ });
1486
+ return { url: normalized, remembered: false };
1487
+ }
1488
+ function normalizeServerUrl(input) {
1489
+ const trimmed = input.trim();
1490
+ const withScheme = HAS_SCHEME.test(trimmed) ? trimmed : `https://${trimmed}`;
1491
+ const url = new URL(withScheme);
1492
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
1493
+ throw new Error(`Not an http(s) URL: ${input}`);
1494
+ }
1495
+ return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
1496
+ }
1037
1497
  var REDACTED_KEY_CHARS = 10;
1038
1498
  function redactKey(key) {
1039
1499
  if (!key) return "-";
1040
1500
  return key.length <= REDACTED_KEY_CHARS + 8 ? "****" : `${key.slice(0, REDACTED_KEY_CHARS)}\u2026`;
1041
1501
  }
1042
1502
 
1503
+ // src/instance.ts
1504
+ async function fetchInstance(serverUrl, fetch2) {
1505
+ const url = instanceMetadataUrl(serverUrl);
1506
+ let response;
1507
+ try {
1508
+ response = await fetch2(url, { headers: { Accept: "application/json" } });
1509
+ } catch (cause) {
1510
+ throw new CliError(`Could not reach ${serverUrl} (${cause.message ?? cause})`, {
1511
+ hint: "Check the address and your connection.",
1512
+ cause
1513
+ });
1514
+ }
1515
+ const notFling = () => new CliError(`${serverUrl} does not look like a Fling server (no valid ${url})`, {
1516
+ hint: "Use the address of your Fling, e.g. https://fling.example.com."
1517
+ });
1518
+ if (!response.ok) throw notFling();
1519
+ const parsed = InstanceMetadataSchema.safeParse(await response.json().catch(() => null));
1520
+ if (!parsed.success) throw notFling();
1521
+ return parsed.data;
1522
+ }
1523
+ async function loadInstance(store, config, fetch2) {
1524
+ if (config.instance) return config.instance;
1525
+ if (!config.serverUrl) return null;
1526
+ try {
1527
+ const instance = await fetchInstance(config.serverUrl, fetch2);
1528
+ if (config.remembered)
1529
+ await store.putServer(config.serverUrl, { instance }, { makeCurrent: false });
1530
+ return instance;
1531
+ } catch {
1532
+ return null;
1533
+ }
1534
+ }
1535
+
1043
1536
  // src/auth.ts
1044
1537
  var CALLBACK_PORT_START = 8765;
1045
1538
  var CALLBACK_PORT_END = 8770;
@@ -1048,12 +1541,13 @@ var CALLBACK_PATH = "/callback";
1048
1541
  var AUTH_CLI_PATH = "/auth/cli";
1049
1542
  async function loginFlow(options) {
1050
1543
  const { serverUrl, store, log } = options;
1544
+ const instance = await fetchInstance(serverUrl, options.fetch ?? fetch);
1051
1545
  const state = randomBytes(16).toString("hex");
1052
1546
  const callback = await startCallbackServer({ state, timeoutMs: options.timeoutMs });
1053
1547
  try {
1054
1548
  const authUrl = buildAuthUrl(serverUrl, callback.url, options.hostname ?? osHostname(), state);
1055
1549
  options.onAuthUrl?.(authUrl);
1056
- log.info(`Opening your browser to sign in at ${serverUrl}`);
1550
+ log.info(`Opening your browser to sign in to ${instance.name} at ${serverUrl}`);
1057
1551
  log.hint(`If it does not open, visit: ${authUrl}`);
1058
1552
  try {
1059
1553
  await options.open(authUrl);
@@ -1064,16 +1558,20 @@ async function loginFlow(options) {
1064
1558
  const { key } = await callback.result;
1065
1559
  const client = createApiClient({ serverUrl, apiKey: key, fetch: options.fetch });
1066
1560
  const user = await client.request("GET", "/api/me", { schema: MeSchema });
1067
- await store.update({
1561
+ await store.putServer(
1068
1562
  serverUrl,
1069
- apiKey: key,
1070
- user: { email: user.email, name: user.name, userSlug: user.userSlug }
1071
- });
1563
+ {
1564
+ instance,
1565
+ apiKey: key,
1566
+ user: { email: user.email, name: user.name, userSlug: user.userSlug }
1567
+ },
1568
+ { makeCurrent: options.makeCurrent ?? true }
1569
+ );
1072
1570
  const keyPrefix = redactKey(key);
1073
1571
  log.success(`Signed in as ${user.email}`);
1074
1572
  log.hint(`Server: ${serverUrl}`);
1075
1573
  log.hint(`API key: ${keyPrefix} (stored in ${store.file})`);
1076
- return { serverUrl, keyPrefix, user };
1574
+ return { serverUrl, keyPrefix, user, instance };
1077
1575
  } finally {
1078
1576
  callback.close();
1079
1577
  }
@@ -1419,18 +1917,23 @@ async function ttyPrompt(question) {
1419
1917
  function ttyProgress(line) {
1420
1918
  process.stderr.write(`\r\x1B[2K${line ?? ""}`);
1421
1919
  }
1920
+ function requireServer(ctx) {
1921
+ if (!ctx.config.serverUrl) throw new NoServerError(ctx.binName);
1922
+ return ctx.config.serverUrl;
1923
+ }
1422
1924
  function requireClient(ctx) {
1925
+ const serverUrl = requireServer(ctx);
1423
1926
  if (!ctx.config.apiKey) throw new NotLoggedInError(ctx.binName);
1424
1927
  return createApiClient({
1425
- serverUrl: ctx.config.serverUrl,
1928
+ serverUrl,
1426
1929
  apiKey: ctx.config.apiKey,
1427
1930
  fetch: ctx.fetch
1428
1931
  });
1429
1932
  }
1430
1933
 
1431
1934
  // src/sites.ts
1432
- import { z as z6 } from "zod";
1433
- var ShareResponseSchema2 = z6.looseObject({ shareUrl: z6.string() });
1935
+ import { z as z10 } from "zod";
1936
+ var ShareResponseSchema2 = z10.looseObject({ shareUrl: z10.string() });
1434
1937
  var sitePath2 = (name) => `/api/sites/${encodeURIComponent(name)}`;
1435
1938
  function whoami(client) {
1436
1939
  return client.request("GET", "/api/me", { schema: MeSchema });
@@ -1440,32 +1943,8 @@ function checkName(client, name) {
1440
1943
  schema: NameCheckSchema
1441
1944
  });
1442
1945
  }
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;
1946
+ function sitesBaseOf(instance) {
1947
+ return instance ? { protocol: `${instance.siteScheme}:`, domain: instance.sitesDomain } : null;
1469
1948
  }
1470
1949
  function siteUrlFor(name, base) {
1471
1950
  return siteUrl(name, base.domain, base.protocol);
@@ -1535,52 +2014,247 @@ async function share(client, name, opts = {}) {
1535
2014
  });
1536
2015
  return { name, action: hadLink ? "refreshed" : "created", shareUrl: res.shareUrl };
1537
2016
  }
2017
+ function listComments(client, name, opts = {}) {
2018
+ return client.request("GET", `${sitePath2(name)}/comments`, {
2019
+ schema: CommentListSchema,
2020
+ query: { path: opts.path }
2021
+ });
2022
+ }
2023
+ function addComment(client, name, input) {
2024
+ return client.request("POST", `${sitePath2(name)}/comments`, {
2025
+ schema: CommentSchema,
2026
+ body: input
2027
+ });
2028
+ }
2029
+ function resolveComment(client, name, id, resolved) {
2030
+ return client.request("PATCH", `${sitePath2(name)}/comments/${encodeURIComponent(id)}`, {
2031
+ schema: CommentSchema,
2032
+ body: { resolved }
2033
+ });
2034
+ }
2035
+ async function deleteComment(client, name, id) {
2036
+ await client.request("DELETE", `${sitePath2(name)}/comments/${encodeURIComponent(id)}`);
2037
+ return { name, id, deleted: true };
2038
+ }
2039
+ function commentsEnabled(instance) {
2040
+ if (!instance) return false;
2041
+ return instance.capabilities.comments ?? true;
2042
+ }
1538
2043
 
1539
2044
  // src/commands/auth.ts
1540
- async function runLogin(ctx) {
2045
+ async function loginServer(ctx, url) {
2046
+ if (url === void 0) return requireServer(ctx);
2047
+ const { servers } = await ctx.store.load();
2048
+ return matchServer(Object.keys(servers), url).url;
2049
+ }
2050
+ async function runLogin(ctx, url) {
1541
2051
  const result = await loginFlow({
1542
- serverUrl: ctx.config.serverUrl,
2052
+ serverUrl: await loginServer(ctx, url),
1543
2053
  store: ctx.store,
1544
2054
  log: ctx.log,
1545
2055
  open: ctx.open,
1546
- fetch: ctx.fetch
2056
+ fetch: ctx.fetch,
2057
+ makeCurrent: true
1547
2058
  });
1548
2059
  if (ctx.json) ctx.out.data(result, () => "");
1549
2060
  }
1550
- async function runLogout(ctx) {
2061
+ async function runUse(ctx, url) {
1551
2062
  const config = await ctx.store.load();
1552
- if (!config.apiKey) {
1553
- ctx.log.info("Not logged in \u2014 nothing to do");
2063
+ const urls = Object.keys(config.servers);
2064
+ const overridden = ctx.config.serverUrlSource === "env" || ctx.config.serverUrlSource === "flag" ? ctx.config.serverUrl : void 0;
2065
+ const overrideHint = () => {
2066
+ if (overridden)
2067
+ ctx.log.hint(
2068
+ `${ctx.config.serverUrlSource === "env" ? "FLING_URL" : "--server"} is set: commands run with it use ${overridden}.`
2069
+ );
2070
+ };
2071
+ if (url === void 0) {
2072
+ const servers = urls.map((server) => {
2073
+ const entry2 = config.servers[server] ?? {};
2074
+ return {
2075
+ url: server,
2076
+ current: server === config.current,
2077
+ instance: entry2.instance?.name ?? null,
2078
+ email: entry2.apiKey ? entry2.user?.email ?? null : null,
2079
+ signedIn: Boolean(entry2.apiKey)
2080
+ };
2081
+ });
2082
+ if (servers.length === 0 && !ctx.json) {
2083
+ ctx.log.info("No Fling servers yet");
2084
+ ctx.log.hint(`Run \`${ctx.binName} login <url>\` with your Fling's address.`);
2085
+ return;
2086
+ }
2087
+ const width = Math.max(...servers.map((s) => s.url.length));
2088
+ ctx.out.data(
2089
+ { current: config.current ?? null, servers },
2090
+ () => servers.map(
2091
+ (s) => [
2092
+ s.current ? chalk5.bold("*") : " ",
2093
+ s.current ? chalk5.bold(s.url.padEnd(width)) : s.url.padEnd(width),
2094
+ s.instance ?? chalk5.dim("(unknown)"),
2095
+ s.signedIn ? s.email ?? chalk5.dim("(signed in)") : chalk5.dim("(signed out)")
2096
+ ].join(" ")
2097
+ ).join("\n")
2098
+ );
2099
+ if (!ctx.json) {
2100
+ ctx.log.hint(`* = current. Switch with \`${ctx.binName} use <url>\`.`);
2101
+ overrideHint();
2102
+ }
1554
2103
  return;
1555
2104
  }
1556
- await ctx.store.clearCredentials();
1557
- 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}`);
2105
+ const match = matchServer(urls, url, { loose: true });
2106
+ if (!match.remembered)
2107
+ throw new CliError(`Not signed in to ${match.url} on this computer`, {
2108
+ hint: `Run \`${ctx.binName} login ${match.url}\` to add it, or \`${ctx.binName} use\` to list your servers.`
2109
+ });
2110
+ await ctx.store.setCurrent(match.url);
2111
+ const entry = config.servers[match.url] ?? {};
2112
+ const name = entry.instance?.name;
2113
+ const email = entry.apiKey ? entry.user?.email : void 0;
2114
+ ctx.out.data(
2115
+ { current: match.url, instance: name ?? null, email: email ?? null },
2116
+ () => `Now using ${chalk5.bold(match.url)}${name ? chalk5.dim(` (${name})`) : ""}`
2117
+ );
2118
+ if (!ctx.json) {
2119
+ if (entry.apiKey) ctx.log.hint(`Signed in as ${email ?? "an unknown user"}.`);
2120
+ else ctx.log.hint(`Signed out there: run \`${ctx.binName} login\` to sign in.`);
2121
+ overrideHint();
2122
+ }
2123
+ }
2124
+ async function runLogout(ctx, url) {
2125
+ const config = await ctx.store.load();
2126
+ const target = url === void 0 ? ctx.config.serverUrl : matchServer(Object.keys(config.servers), url, { loose: true }).url;
2127
+ const entry = target === void 0 ? void 0 : config.servers[target];
2128
+ if (target === void 0 || entry === void 0) {
2129
+ ctx.log.info(`Not logged in${target ? ` to ${target}` : ""} \u2014 nothing to do`);
2130
+ return;
2131
+ }
2132
+ if (target === config.current) {
2133
+ if (!entry.apiKey) {
2134
+ ctx.log.info("Not logged in \u2014 nothing to do");
2135
+ return;
2136
+ }
2137
+ await ctx.store.clearCredentials(target);
2138
+ ctx.log.success("Logged out; API key removed from this machine");
2139
+ } else {
2140
+ await ctx.store.removeServer(target);
2141
+ ctx.log.success(
2142
+ entry.apiKey ? `Logged out of ${target}; API key and server removed from this machine` : `Removed ${target} from this machine`
2143
+ );
2144
+ }
2145
+ if (entry.apiKey) ctx.log.hint(`Revoke it on the server under Connections at ${target}`);
1559
2146
  }
1560
2147
  async function runWhoami(ctx) {
1561
2148
  const client = requireClient(ctx);
1562
2149
  const me = await whoami(client);
1563
- const base = ctx.json ? null : await discoverSitesBase(client);
2150
+ const instance = await loadInstance(ctx.store, ctx.config, ctx.fetch);
2151
+ const { serverUrl, current } = ctx.config;
2152
+ const others = Object.keys((await ctx.store.load()).servers).filter((s) => s !== serverUrl);
1564
2153
  ctx.out.data(
1565
2154
  {
1566
2155
  ...me,
1567
- serverUrl: ctx.config.serverUrl,
2156
+ serverUrl,
2157
+ serverUrlSource: ctx.config.serverUrlSource,
2158
+ current: current ?? null,
2159
+ instance: instance?.name ?? null,
1568
2160
  apiKey: redactKey(ctx.config.apiKey),
1569
2161
  apiKeySource: ctx.config.apiKeySource
1570
2162
  },
1571
2163
  () => [
1572
2164
  `${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}`,
1575
- `${chalk5.bold("API key:")} ${redactKey(ctx.config.apiKey)} ${chalk5.dim(`(from ${ctx.config.apiKeySource})`)}`
2165
+ `${chalk5.bold("Sites:")} ${sitesPattern(sitesBaseOf(instance))} ${chalk5.dim("(names are first come, first served)")}`,
2166
+ `${chalk5.bold("Server:")} ${serverUrl}${instance ? chalk5.dim(` (${instance.name})`) : ""} ${chalk5.dim(
2167
+ serverUrl === current ? "(current)" : `(from ${ctx.config.serverUrlSource === "env" ? "FLING_URL" : "--server"}; current: ${current ?? "none"})`
2168
+ )}`,
2169
+ `${chalk5.bold("API key:")} ${redactKey(ctx.config.apiKey)} ${chalk5.dim(`(from ${ctx.config.apiKeySource})`)}`,
2170
+ ...others.length > 0 ? [
2171
+ chalk5.dim(
2172
+ `${others.length} other server${others.length === 1 ? "" : "s"} on this computer: \`${ctx.binName} use\` lists them`
2173
+ )
2174
+ ] : []
1576
2175
  ].join("\n")
1577
2176
  );
1578
2177
  }
1579
2178
 
2179
+ // src/commands/comments.ts
2180
+ import chalk6 from "chalk";
2181
+ function commentsClient(ctx) {
2182
+ const instance = ctx.config.instance;
2183
+ if (instance && !commentsEnabled(instance)) {
2184
+ throw new CliError(`${instance.name} has comments turned off`);
2185
+ }
2186
+ return requireClient(ctx);
2187
+ }
2188
+ var reviewPage = (ctx, name, path3) => reviewUrl(ctx.config.instance?.appOrigin ?? ctx.config.serverUrl ?? "", name, path3);
2189
+ function describe(c, currentVersionId) {
2190
+ const version = c.versionId === null ? "" : c.versionId === currentVersionId ? "" : chalk6.dim(` (on version ${c.versionId})`);
2191
+ return `${chalk6.bold(authorLabel(c.author))} ${chalk6.dim(formatDate(c.createdAt))}${version}`;
2192
+ }
2193
+ async function runComments(ctx, name, opts) {
2194
+ const list = await listComments(commentsClient(ctx), name, { path: opts.path });
2195
+ const all = commentThreads(list.comments);
2196
+ const threads = opts.all ? all : all.filter((t) => t.comment.resolvedAt === null);
2197
+ const hidden = all.length - threads.length;
2198
+ ctx.out.data({ ...list, reviewUrl: reviewPage(ctx, name) }, () => {
2199
+ const lines = [];
2200
+ let lastPath = null;
2201
+ for (const { comment: c, replies } of threads.sort(
2202
+ (a, b) => a.comment.path.localeCompare(b.comment.path)
2203
+ )) {
2204
+ if (c.path !== lastPath) {
2205
+ lines.push("", chalk6.bold.underline(c.path));
2206
+ lastPath = c.path;
2207
+ }
2208
+ const state = c.resolvedAt ? chalk6.green(" \u2713 resolved") : "";
2209
+ lines.push(`${chalk6.dim(c.id)}${state} ${describe(c, list.site.currentVersionId)}`);
2210
+ lines.push(...c.body.split("\n").map((l) => ` ${l}`));
2211
+ for (const r of replies) {
2212
+ lines.push(` ${chalk6.dim("\u21B3")} ${describe(r, list.site.currentVersionId)}`);
2213
+ lines.push(...r.body.split("\n").map((l) => ` ${l}`));
2214
+ }
2215
+ }
2216
+ const summary = chalk6.dim(
2217
+ `${threads.length} ${opts.all ? "" : "open "}thread${threads.length === 1 ? "" : "s"}` + (hidden ? ` (${hidden} resolved \u2014 \`--all\` shows them)` : "") + ` \xB7 review: ${reviewPage(ctx, name)}`
2218
+ );
2219
+ return [...lines, "", summary].join("\n").replace(/^\n/, "");
2220
+ });
2221
+ }
2222
+ async function runComment(ctx, name, text2, opts) {
2223
+ if (!text2.trim()) throw new CliError("The comment is empty");
2224
+ const comment = await addComment(commentsClient(ctx), name, {
2225
+ body: text2,
2226
+ path: opts.path,
2227
+ parentId: opts.reply
2228
+ });
2229
+ ctx.log.success(
2230
+ comment.parentId ? `Replied to ${comment.parentId} on ${name}` : `Comment added on ${name} ${comment.path}`
2231
+ );
2232
+ ctx.out.data(comment, () => reviewPage(ctx, name, comment.path));
2233
+ }
2234
+ async function runResolve(ctx, name, id, opts) {
2235
+ const comment = await resolveComment(commentsClient(ctx), name, id, !opts.reopen);
2236
+ ctx.log.success(opts.reopen ? `Thread ${id} reopened` : `Thread ${id} resolved`);
2237
+ if (ctx.json) ctx.out.data(comment, () => "");
2238
+ }
2239
+ async function runUncomment(ctx, name, id, opts) {
2240
+ if (!opts.yes) {
2241
+ if (!ctx.interactive) {
2242
+ throw new CliError(`Refusing to delete comment ${id} without confirmation`, {
2243
+ hint: "Pass --yes to delete."
2244
+ });
2245
+ }
2246
+ const ok = await ctx.confirm(`Delete comment ${id} (and its replies)? This cannot be undone.`);
2247
+ if (!ok) throw new CliError("Cancelled");
2248
+ }
2249
+ const result = await deleteComment(commentsClient(ctx), name, id);
2250
+ ctx.log.success(`Comment ${id} deleted`);
2251
+ if (ctx.json) ctx.out.data(result, () => "");
2252
+ }
2253
+
1580
2254
  // src/commands/deploy.ts
1581
2255
  import { stat as stat4 } from "node:fs/promises";
1582
2256
  import { resolve as resolve3 } from "node:path";
1583
- import chalk6 from "chalk";
2257
+ import chalk7 from "chalk";
1584
2258
 
1585
2259
  // src/deploy.ts
1586
2260
  import { readFile as readFile4 } from "node:fs/promises";
@@ -1744,19 +2418,19 @@ function describeSkipped(skipped) {
1744
2418
  lines.push(line(skipped.gitignored.length, "gitignored file", skipped.gitignored));
1745
2419
  return lines;
1746
2420
  }
1747
- function contentTypeFor(path) {
1748
- const type = mime.getType(path) ?? "application/octet-stream";
2421
+ function contentTypeFor(path3) {
2422
+ const type = mime.getType(path3) ?? "application/octet-stream";
1749
2423
  return type.startsWith("text/") || CHARSET_TYPES.has(type) ? `${type}; charset=utf-8` : type;
1750
2424
  }
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");
2425
+ async function sha256File(path3) {
2426
+ const hash3 = createHash("sha256");
2427
+ for await (const chunk of createReadStream(path3)) hash3.update(chunk);
2428
+ return hash3.digest("hex");
1755
2429
  }
1756
2430
  function toManifest(local) {
1757
2431
  return {
1758
- files: local.files.map(({ path, sha256, size, contentType }) => ({
1759
- path,
2432
+ files: local.files.map(({ path: path3, sha256, size, contentType }) => ({
2433
+ path: path3,
1760
2434
  sha256,
1761
2435
  size,
1762
2436
  contentType
@@ -2089,8 +2763,8 @@ async function deploy(deps, opts) {
2089
2763
  totalBytes: local.totalBytes,
2090
2764
  warnings: local.warnings
2091
2765
  };
2092
- const path = `/api/sites/${encodeURIComponent(name)}/deploys`;
2093
- const res = await deps.client.request("POST", path, {
2766
+ const path3 = `/api/sites/${encodeURIComponent(name)}/deploys`;
2767
+ const res = await deps.client.request("POST", path3, {
2094
2768
  schema: DeployResponseSchema,
2095
2769
  body: {
2096
2770
  manifest: toManifest(local),
@@ -2130,7 +2804,7 @@ async function deploy(deps, opts) {
2130
2804
  });
2131
2805
  const fin = await deps.client.request(
2132
2806
  "POST",
2133
- `${path}/${encodeURIComponent(res.versionId)}/finalize`,
2807
+ `${path3}/${encodeURIComponent(res.versionId)}/finalize`,
2134
2808
  { schema: FinalizeResponseSchema }
2135
2809
  );
2136
2810
  return {
@@ -2166,51 +2840,51 @@ import { copyFile, mkdir as mkdir2, mkdtemp, readFile as readFile5, rm, stat as
2166
2840
  import { tmpdir } from "node:os";
2167
2841
  import { basename as basename2, extname, join as join4, resolve as resolve2, sep as sep4 } from "node:path";
2168
2842
  import { unzipSync } from "fflate";
2169
- function inputKind(path, isDirectory) {
2843
+ function inputKind(path3, isDirectory) {
2170
2844
  if (isDirectory) return "folder";
2171
- const ext = extname(path).toLowerCase();
2845
+ const ext = extname(path3).toLowerCase();
2172
2846
  if (ext === ".html" || ext === ".htm") return "html";
2173
2847
  if (ext === ".zip") return "zip";
2174
2848
  return "file";
2175
2849
  }
2176
- function fileSiteName(path) {
2177
- const file = basename2(path.replace(/\\/g, "/"));
2850
+ function fileSiteName(path3) {
2851
+ const file = basename2(path3.replace(/\\/g, "/"));
2178
2852
  const stem = file.slice(0, file.length - extname(file).length) || file;
2179
2853
  return fitName([stem.replace(/\s*\(\d+\)$/, "")]);
2180
2854
  }
2181
- function defaultNameFor(path, isDirectory) {
2182
- return isDirectory ? defaultSiteName(path) : fileSiteName(path);
2855
+ function defaultNameFor(path3, isDirectory) {
2856
+ return isDirectory ? defaultSiteName(path3) : fileSiteName(path3);
2183
2857
  }
2184
2858
  async function tempFolder() {
2185
2859
  const dir = await mkdtemp(join4(tmpdir(), "fling-"));
2186
2860
  return { dir, cleanup: () => rm(dir, { recursive: true, force: true }) };
2187
2861
  }
2188
- async function prepareInput(path) {
2862
+ async function prepareInput(path3) {
2189
2863
  let info;
2190
2864
  try {
2191
- info = await stat3(path);
2865
+ info = await stat3(path3);
2192
2866
  } catch {
2193
- throw new CliError(`Not found: ${path}`);
2867
+ throw new CliError(`Not found: ${path3}`);
2194
2868
  }
2195
- const kind = inputKind(path, info.isDirectory());
2196
- const defaultName = defaultNameFor(path, info.isDirectory());
2869
+ const kind = inputKind(path3, info.isDirectory());
2870
+ const defaultName = defaultNameFor(path3, info.isDirectory());
2197
2871
  if (kind === "folder")
2198
2872
  return {
2199
2873
  kind,
2200
- source: path,
2201
- dir: path,
2874
+ source: path3,
2875
+ dir: path3,
2202
2876
  defaultName,
2203
2877
  skippedDotfiles: [],
2204
2878
  cleanup: async () => {
2205
2879
  }
2206
2880
  };
2207
- const file = basename2(path);
2881
+ const file = basename2(path3);
2208
2882
  if (file.startsWith("."))
2209
2883
  throw new CliError(`Not flung: ${file} is a dotfile. ${NEVER_PUBLISHED_NOTE}`);
2210
2884
  if (kind === "file" && isSecretPath(file)) throw new SecretFilesError([file]);
2211
2885
  if (info.size > MAX_DEPLOY_BYTES)
2212
2886
  throw new CliError(
2213
- `${basename2(path)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_DEPLOY_BYTES)}`
2887
+ `${basename2(path3)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_DEPLOY_BYTES)}`
2214
2888
  );
2215
2889
  const tmp = await tempFolder();
2216
2890
  const skippedDotfiles = [];
@@ -2218,21 +2892,21 @@ async function prepareInput(path) {
2218
2892
  if (kind === "html") {
2219
2893
  if (info.size > MAX_FILE_BYTES)
2220
2894
  throw new CliError(
2221
- `${basename2(path)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`
2895
+ `${basename2(path3)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`
2222
2896
  );
2223
- await copyFile(path, join4(tmp.dir, "index.html"));
2897
+ await copyFile(path3, join4(tmp.dir, "index.html"));
2224
2898
  } else if (kind === "zip") {
2225
- await extractZip(await readFile5(path), tmp.dir, skippedDotfiles);
2899
+ await extractZip(await readFile5(path3), tmp.dir, skippedDotfiles);
2226
2900
  } else {
2227
- const name = basename2(path);
2228
- await copyFile(path, join4(tmp.dir, name));
2901
+ const name = basename2(path3);
2902
+ await copyFile(path3, join4(tmp.dir, name));
2229
2903
  await writeFile2(join4(tmp.dir, "index.html"), wrapperPage(name));
2230
2904
  }
2231
2905
  } catch (error) {
2232
2906
  await tmp.cleanup();
2233
2907
  throw error;
2234
2908
  }
2235
- return { kind, source: path, dir: tmp.dir, defaultName, skippedDotfiles, cleanup: tmp.cleanup };
2909
+ return { kind, source: path3, dir: tmp.dir, defaultName, skippedDotfiles, cleanup: tmp.cleanup };
2236
2910
  }
2237
2911
  var JUNK_FILES = /* @__PURE__ */ new Set(["thumbs.db", "desktop.ini"]);
2238
2912
  function zipEntryPath(name) {
@@ -2272,12 +2946,12 @@ async function extractZip(data, dest, skippedDotfiles = []) {
2272
2946
  try {
2273
2947
  entries = unzipSync(data, {
2274
2948
  filter: (file) => {
2275
- const path = zipEntryPath(file.name);
2276
- if (path === "unsafe") {
2949
+ const path3 = zipEntryPath(file.name);
2950
+ if (path3 === "unsafe") {
2277
2951
  unsafe.push(file.name);
2278
2952
  return false;
2279
2953
  }
2280
- if (path === null) {
2954
+ if (path3 === null) {
2281
2955
  const dot = zipDotEntry(file.name);
2282
2956
  if (dot && !skippedDotfiles.includes(dot)) skippedDotfiles.push(dot);
2283
2957
  return false;
@@ -2286,7 +2960,7 @@ async function extractZip(data, dest, skippedDotfiles = []) {
2286
2960
  declared += file.originalSize;
2287
2961
  if (file.originalSize > MAX_FILE_BYTES)
2288
2962
  throw new CliError(
2289
- `${path} in the zip is ${formatBytes(file.originalSize)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`
2963
+ `${path3} in the zip is ${formatBytes(file.originalSize)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`
2290
2964
  );
2291
2965
  if (declared > MAX_DEPLOY_BYTES)
2292
2966
  throw new CliError(
@@ -2367,20 +3041,21 @@ function wrapperPage(file) {
2367
3041
  async function runDeploy(ctx, input, opts) {
2368
3042
  const client = requireClient(ctx);
2369
3043
  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))
3044
+ const instance = await loadInstance(ctx.store, ctx.config, ctx.fetch);
3045
+ const base = sitesBaseOf(instance);
3046
+ const path3 = resolve3(input ?? ".");
3047
+ if (!await stat4(path3).catch(() => null))
2373
3048
  throw new CliError(`No such file or folder: ${input ?? "."}`, {
2374
3049
  hint: `Give the path to a folder, .html, .zip or other file, e.g. \`${ctx.binName} deploy ./dist\`.`
2375
3050
  });
2376
- const prepared = await prepareInput(path);
3051
+ const prepared = await prepareInput(path3);
2377
3052
  try {
2378
- await deployPrepared(ctx, client, base, prepared, opts, ask);
3053
+ await deployPrepared(ctx, client, instance, base, prepared, opts, ask);
2379
3054
  } finally {
2380
3055
  await prepared.cleanup();
2381
3056
  }
2382
3057
  }
2383
- async function deployPrepared(ctx, client, base, prepared, opts, ask) {
3058
+ async function deployPrepared(ctx, client, instance, base, prepared, opts, ask) {
2384
3059
  let local = await buildManifest(prepared.dir);
2385
3060
  local.skipped.dotfiles.unshift(...prepared.skippedDotfiles);
2386
3061
  for (const line of describeSkipped(local.skipped)) ctx.log.info(line);
@@ -2418,12 +3093,12 @@ async function deployPrepared(ctx, client, base, prepared, opts, ask) {
2418
3093
  });
2419
3094
  for (const warning of result.warnings) if (warning !== NO_INDEX_WARNING) ctx.log.warn(warning);
2420
3095
  const summary = `${result.fileCount} files, ${formatBytes(result.totalBytes)}`;
2421
- if (result.status === "unchanged") ctx.log.info(`Unchanged ${chalk6.dim(`(${summary})`)}`);
3096
+ if (result.status === "unchanged") ctx.log.info(`Unchanged ${chalk7.dim(`(${summary})`)}`);
2422
3097
  else if (result.status === "repointed")
2423
- ctx.log.success(`Restored version ${result.versionId} ${chalk6.dim(`(${summary})`)}`);
3098
+ ctx.log.success(`Restored version ${result.versionId} ${chalk7.dim(`(${summary})`)}`);
2424
3099
  else
2425
3100
  ctx.log.success(
2426
- `Deployed ${result.name} ${chalk6.dim(`(${summary}; uploaded ${result.uploaded}, version ${result.versionId})`)}`
3101
+ `Deployed ${result.name} ${chalk7.dim(`(${summary}; uploaded ${result.uploaded}, version ${result.versionId})`)}`
2427
3102
  );
2428
3103
  const ready = result.status === "unchanged" ? true : (await waitWithSpinner(
2429
3104
  ctx.waitReady,
@@ -2431,23 +3106,38 @@ async function deployPrepared(ctx, client, base, prepared, opts, ask) {
2431
3106
  local.files.map((f) => f.path),
2432
3107
  { progress: ctx.progress, warn: (m) => ctx.log.warn(m) }
2433
3108
  )).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 }, () => {
3109
+ const who = instance ? `only ${privateAudience(instance)} can open it` : "sign-in required";
3110
+ const privateNote = `Private: ${who}. Share publicly with \`${ctx.binName} share ${result.name}\`.`;
3111
+ const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
3112
+ const reviewNote = instance ? `Share for feedback (reviewers sign in to ${instance.name}):` : "Share for feedback:";
3113
+ const data = {
3114
+ status: result.status,
3115
+ site: result.site,
3116
+ ...review ? { reviewUrl: review } : {},
3117
+ ready,
3118
+ skipped: local.skipped
3119
+ };
3120
+ ctx.out.data(data, () => {
2436
3121
  const isPrivate = result.site.visibility === "private";
2437
3122
  if (ctx.out.tty)
2438
3123
  return box(
2439
- [brand.url(result.url), ...isPrivate ? [chalk6.dim(privateNote)] : []],
2440
- isPrivate ? chalk6.bold("flung \xB7 private") : chalk6.bold("flung \xB7 public")
3124
+ [
3125
+ ...review ? [chalk7.dim(reviewNote), brand.url(review), "", chalk7.dim("Site:")] : [],
3126
+ brand.url(result.url),
3127
+ ...isPrivate ? [chalk7.dim(privateNote)] : []
3128
+ ],
3129
+ isPrivate ? chalk7.bold("flung \xB7 private") : chalk7.bold("flung \xB7 public")
2441
3130
  );
2442
- const lines = [chalk6.bold(result.url)];
2443
- if (isPrivate) lines.push(chalk6.dim(privateNote));
3131
+ const lines = [chalk7.bold(result.url)];
3132
+ if (review) lines.push(chalk7.dim(`${reviewNote} ${review}`));
3133
+ if (isPrivate) lines.push(chalk7.dim(privateNote));
2444
3134
  return lines.join("\n");
2445
3135
  });
2446
3136
  }
2447
3137
  var BAR_WIDTH = 20;
2448
3138
  function progressBar(fraction) {
2449
3139
  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));
3140
+ return brand.primary("\u2501".repeat(done)) + chalk7.dim("\u2500".repeat(BAR_WIDTH - done));
2451
3141
  }
2452
3142
  async function askNoIndex(ctx, pages) {
2453
3143
  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 +3153,20 @@ async function askNoIndex(ctx, pages) {
2463
3153
  }
2464
3154
  async function confirmSiteName(ctx, client, generated, base) {
2465
3155
  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})`)}`);
3156
+ const describe2 = (n, note) => ctx.log.info(`Site name: ${chalk7.bold(n)}${where(n)} ${chalk7.dim(`(${note})`)}`);
2467
3157
  let candidate = generated;
2468
3158
  for (; ; ) {
2469
3159
  const check2 = await checkName(client, candidate);
2470
3160
  let proposal;
2471
3161
  if (check2.status === "available" || check2.status === "yours") {
2472
3162
  proposal = candidate;
2473
- describe(candidate, check2.status === "yours" ? "updates your existing site" : "new");
3163
+ describe2(candidate, check2.status === "yours" ? "updates your existing site" : "new");
2474
3164
  } else {
2475
3165
  const why = check2.status === "taken" ? "is taken" : "is not a valid site name";
2476
3166
  const alternatives = check2.suggestions.length ? ` Suggestions: ${check2.suggestions.join(", ")}` : "";
2477
3167
  ctx.log.warn(`${candidate} ${why}.${alternatives}`);
2478
3168
  proposal = check2.suggestions[0];
2479
- if (proposal) describe(proposal, "new");
3169
+ if (proposal) describe2(proposal, "new");
2480
3170
  }
2481
3171
  for (; ; ) {
2482
3172
  const question = proposal ? `Use "${proposal}"? [Y/n or type another name] ` : "Type a site name (or n to abort): ";
@@ -2501,14 +3191,14 @@ async function confirmSiteName(ctx, client, generated, base) {
2501
3191
 
2502
3192
  // src/commands/setup.ts
2503
3193
  import { intro, outro, spinner } from "@clack/prompts";
2504
- import chalk7 from "chalk";
3194
+ import chalk8 from "chalk";
2505
3195
 
2506
3196
  // src/claude.ts
2507
3197
  import { execFile as execFile2 } from "node:child_process";
2508
3198
  import { access, mkdtemp as mkdtemp2, writeFile as writeFile3 } from "node:fs/promises";
2509
3199
  import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
2510
3200
  import { join as join5 } from "node:path";
2511
- var MARKETPLACE_SOURCE = "nearform/fling";
3201
+ var MARKETPLACE_SOURCE = REPO_SLUG;
2512
3202
  var MARKETPLACE_NAME = "fling";
2513
3203
  var PLUGIN_NAME = "fling";
2514
3204
  var PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
@@ -2563,12 +3253,12 @@ function check(result, what) {
2563
3253
  const detail = (result.stderr || result.stdout).trim().split("\n").at(-1);
2564
3254
  throw new Error(`Could not ${what}${detail ? `: ${detail}` : ""}`);
2565
3255
  }
2566
- async function findClaudeDesktop(platform = process.platform, env = process.env, exists = (path) => access(path).then(
3256
+ async function findClaudeDesktop(platform = process.platform, env = process.env, exists = (path3) => access(path3).then(
2567
3257
  () => true,
2568
3258
  () => false
2569
3259
  )) {
2570
3260
  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;
3261
+ for (const path3 of candidates) if (await exists(path3)) return path3;
2572
3262
  return null;
2573
3263
  }
2574
3264
  async function downloadExtension(serverUrl, fetch2) {
@@ -2594,36 +3284,51 @@ async function runSetup(baseCtx, opts, deps = {}) {
2594
3284
  ...baseCtx,
2595
3285
  log: createLogger({
2596
3286
  debug: baseCtx.config.debug,
2597
- write: (line) => process.stderr.write(`${chalk7.gray("\u2502")} ${line}
3287
+ write: (line) => process.stderr.write(`${chalk8.gray("\u2502")} ${line}
2598
3288
  `)
2599
3289
  })
2600
3290
  } : baseCtx;
2601
3291
  if (fancy) {
2602
3292
  process.stderr.write(`${logo()}
2603
3293
  `);
2604
- intro(chalk7.bold(" fling setup "), { output: process.stderr });
3294
+ intro(chalk8.bold(" fling setup "), { output: process.stderr });
2605
3295
  }
3296
+ const login = await stepLogin(ctx, opts.url);
2606
3297
  const results = {
2607
- login: await stepLogin(ctx),
3298
+ login: login.result,
2608
3299
  claudeCode: "skipped",
2609
3300
  claudeDesktop: "skipped"
2610
3301
  };
2611
- results.claudeCode = await stepClaudeCode(ctx, run, scope, ask, fancy);
2612
- results.claudeDesktop = await stepClaudeDesktop(ctx, deps, ask, fancy);
3302
+ const after = login.serverUrl ? { ...ctx, config: await ctx.store.resolve({ serverUrl: login.serverUrl }) } : ctx;
3303
+ results.claudeCode = await stepClaudeCode(after, run, scope, ask, fancy);
3304
+ results.claudeDesktop = await stepClaudeDesktop(after, deps, ask, fancy);
2613
3305
  const failed = Object.values(results).includes("failed");
2614
3306
  if (fancy)
2615
3307
  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.`,
3308
+ 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
3309
  { output: process.stderr }
2618
3310
  );
2619
3311
  ctx.out.data(results, () => "");
2620
3312
  if (failed) process.exitCode = 1;
2621
3313
  }
2622
- async function stepLogin(ctx) {
3314
+ async function stepLogin(ctx, url) {
3315
+ let serverUrl;
3316
+ let target;
3317
+ try {
3318
+ serverUrl = await loginServer(ctx, url);
3319
+ target = { ...ctx, config: await ctx.store.resolve({ serverUrl }) };
3320
+ } catch (error) {
3321
+ ctx.log.error(error instanceof Error ? error.message : String(error));
3322
+ return { result: "failed" };
3323
+ }
3324
+ return { result: await signIn(target, serverUrl), serverUrl };
3325
+ }
3326
+ async function signIn(ctx, serverUrl) {
2623
3327
  if (ctx.config.apiKey) {
2624
3328
  try {
2625
3329
  const me = await whoami(requireClient(ctx));
2626
- ctx.log.success(`Signed in as ${chalk7.bold(me.name)} ${chalk7.dim(`<${me.email}>`)}`);
3330
+ if (ctx.config.remembered) await ctx.store.setCurrent(serverUrl);
3331
+ ctx.log.success(`Signed in as ${chalk8.bold(me.name)} ${chalk8.dim(`<${me.email}>`)}`);
2627
3332
  return "already";
2628
3333
  } catch (error) {
2629
3334
  if (exitCodeFor(error) !== 2) {
@@ -2634,7 +3339,7 @@ async function stepLogin(ctx) {
2634
3339
  }
2635
3340
  }
2636
3341
  try {
2637
- await runLogin({ ...ctx, json: false });
3342
+ await runLogin({ ...ctx, json: false }, serverUrl);
2638
3343
  return "done";
2639
3344
  } catch (error) {
2640
3345
  ctx.log.error(error instanceof Error ? error.message : String(error));
@@ -2644,7 +3349,7 @@ async function stepLogin(ctx) {
2644
3349
  async function stepClaudeCode(ctx, run, scope, ask, fancy) {
2645
3350
  const status = await claudeCodeStatus(run);
2646
3351
  if (status.claude === "missing") {
2647
- ctx.log.info(`Claude Code not found ${chalk7.dim("(no `claude` on your PATH) \u2014 skipped")}`);
3352
+ ctx.log.info(`Claude Code not found ${chalk8.dim("(no `claude` on your PATH) \u2014 skipped")}`);
2648
3353
  ctx.log.hint(
2649
3354
  `Once it is installed, run \`${ctx.binName} setup\` again, or in Claude Code: /plugin marketplace add ${MARKETPLACE_SOURCE} then /plugin install ${PLUGIN_ID}`
2650
3355
  );
@@ -2653,7 +3358,7 @@ async function stepClaudeCode(ctx, run, scope, ask, fancy) {
2653
3358
  const installed = "installed" in status && status.installed;
2654
3359
  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
3360
  if (ask && !await ctx.confirm(question)) {
2656
- ctx.log.info(chalk7.dim("Claude Code plugin: skipped"));
3361
+ ctx.log.info(chalk8.dim("Claude Code plugin: skipped"));
2657
3362
  return installed ? "already" : "skipped";
2658
3363
  }
2659
3364
  const s = fancy ? spinner({ output: process.stderr }) : null;
@@ -2667,7 +3372,7 @@ async function stepClaudeCode(ctx, run, scope, ask, fancy) {
2667
3372
  else ctx.log.error(message);
2668
3373
  return "failed";
2669
3374
  }
2670
- const done = installed ? `Claude Code plugin ${PLUGIN_NAME} is up to date` : `Claude Code plugin ${PLUGIN_NAME} installed ${chalk7.dim(`(${scope} scope)`)}`;
3375
+ const done = installed ? `Claude Code plugin ${PLUGIN_NAME} is up to date` : `Claude Code plugin ${PLUGIN_NAME} installed ${chalk8.dim(`(${scope} scope)`)}`;
2671
3376
  if (s) s.stop(done);
2672
3377
  else ctx.log.success(done);
2673
3378
  ctx.log.hint("Restart Claude Code (or run /reload-plugins) to pick it up.");
@@ -2677,13 +3382,13 @@ async function stepClaudeDesktop(ctx, deps, ask, fancy) {
2677
3382
  const app = await (deps.findClaudeDesktop ?? findClaudeDesktop)();
2678
3383
  if (!app) return "missing";
2679
3384
  if (ask && !await ctx.confirm("Add the Fling extension to Claude Desktop too?")) {
2680
- ctx.log.info(chalk7.dim("Claude Desktop extension: skipped"));
3385
+ ctx.log.info(chalk8.dim("Claude Desktop extension: skipped"));
2681
3386
  return "skipped";
2682
3387
  }
2683
3388
  const s = fancy ? spinner({ output: process.stderr }) : null;
2684
3389
  s?.start("Downloading the Claude Desktop extension");
2685
3390
  try {
2686
- const file = await (deps.downloadExtension ?? ((serverUrl) => downloadExtension(serverUrl, ctx.fetch)))(ctx.config.serverUrl);
3391
+ const file = await (deps.downloadExtension ?? ((serverUrl) => downloadExtension(serverUrl, ctx.fetch)))(requireServer(ctx));
2687
3392
  await ctx.open(file);
2688
3393
  const done = "Opened fling.mcpb in Claude Desktop \u2014 click Install there to finish";
2689
3394
  if (s) s.stop(done);
@@ -2698,7 +3403,7 @@ async function stepClaudeDesktop(ctx, deps, ask, fancy) {
2698
3403
  }
2699
3404
 
2700
3405
  // src/commands/sites.ts
2701
- import chalk8 from "chalk";
3406
+ import chalk9 from "chalk";
2702
3407
  async function runList(ctx, opts) {
2703
3408
  const sites = await listSites(requireClient(ctx), opts);
2704
3409
  ctx.out.data(
@@ -2716,11 +3421,11 @@ async function runList(ctx, opts) {
2716
3421
  );
2717
3422
  }
2718
3423
  function describeSite(site) {
2719
- const row = (label, value) => `${chalk8.bold(`${label}:`.padEnd(12))}${value}`;
3424
+ const row = (label, value) => `${chalk9.bold(`${label}:`.padEnd(12))}${value}`;
2720
3425
  return [
2721
3426
  row("Name", site.name),
2722
3427
  row("URL", site.url),
2723
- row("Visibility", site.visibility + (site.archived ? chalk8.yellow(" (archived)") : "")),
3428
+ row("Visibility", site.visibility + (site.archived ? chalk9.yellow(" (archived)") : "")),
2724
3429
  row("SPA", site.spa ? "yes" : "no"),
2725
3430
  row("Share link", site.shareUrl ?? "-"),
2726
3431
  row("Version", site.currentVersionId ?? "-"),
@@ -2742,7 +3447,7 @@ async function runInfo(ctx, name) {
2742
3447
  () => [
2743
3448
  ...describeSite(site),
2744
3449
  "",
2745
- chalk8.dim(
3450
+ chalk9.dim(
2746
3451
  `${site.versions.length} version(s) \u2014 \`fling versions ${name}\`; files \u2014 \`fling files ${name}\``
2747
3452
  )
2748
3453
  ].join("\n")
@@ -2768,7 +3473,7 @@ async function runFiles(ctx, name, versionId) {
2768
3473
  }
2769
3474
  ctx.out.data(result, () => {
2770
3475
  const n = result.files.length;
2771
- const header = chalk8.dim(
3476
+ const header = chalk9.dim(
2772
3477
  `${result.versionId}${result.current ? " (current)" : " (not served)"} \u2014 ${n} file${n === 1 ? "" : "s"}, ${formatBytes(result.totalBytes)}${result.generatedIndex ? ", generated index.html" : ""}`
2773
3478
  );
2774
3479
  const table = renderTable(result.files, [
@@ -2804,7 +3509,7 @@ async function runShare(ctx, name, opts) {
2804
3509
  ctx.log.success("Share link created \u2014 anyone with it can view the site");
2805
3510
  ctx.out.data(
2806
3511
  ready === void 0 ? result : { ...result, ready },
2807
- () => result.shareUrl ? result.shareUrl : chalk8.dim("(no share link)")
3512
+ () => result.shareUrl ? result.shareUrl : chalk9.dim("(no share link)")
2808
3513
  );
2809
3514
  }
2810
3515
  async function runVisibility(ctx, name, visibility) {
@@ -2813,7 +3518,7 @@ async function runVisibility(ctx, name, visibility) {
2813
3518
  }
2814
3519
  const site = await setVisibility(requireClient(ctx), name, visibility);
2815
3520
  ctx.log.success(
2816
- site.visibility === "public" ? `${name} is public \u2014 no sign-in needed` : `${name} is private \u2014 Nearform sign-in required`
3521
+ site.visibility === "public" ? `${name} is public \u2014 no sign-in needed` : `${name} is private \u2014 sign-in required`
2817
3522
  );
2818
3523
  ctx.out.data(site, () => site.url);
2819
3524
  }
@@ -2846,7 +3551,7 @@ import { tmpdir as tmpdir3 } from "node:os";
2846
3551
  import { basename as basename3, dirname as dirname2, join as join6 } from "node:path";
2847
3552
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2848
3553
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2849
- import { z as z7 } from "zod";
3554
+ import { z as z11 } from "zod";
2850
3555
 
2851
3556
  // src/locate.ts
2852
3557
  import { lstat, readdir as readdir2, readFile as readFile6, realpath as realpath3, stat as stat5 } from "node:fs/promises";
@@ -2860,21 +3565,21 @@ var SESSIONS_DEPTH = 4;
2860
3565
  var OUTPUTS_DEPTH = 4;
2861
3566
  var SANDBOX_PATH_RE = /^\/(mnt\/user-data|mnt\/data|sessions|home\/claude|tmp\/outputs)(\/|$)/;
2862
3567
  var nodeFs = {
2863
- async stat(path) {
3568
+ async stat(path3) {
2864
3569
  try {
2865
- const s = await stat5(path);
3570
+ const s = await stat5(path3);
2866
3571
  return { isFile: s.isFile(), isDirectory: s.isDirectory(), mtimeMs: s.mtimeMs, size: s.size };
2867
3572
  } catch {
2868
3573
  return null;
2869
3574
  }
2870
3575
  },
2871
- async readdir(path) {
3576
+ async readdir(path3) {
2872
3577
  try {
2873
- const entries = await readdir2(path, { withFileTypes: true });
3578
+ const entries = await readdir2(path3, { withFileTypes: true });
2874
3579
  const out = [];
2875
3580
  for (const e of entries) {
2876
3581
  if (e.isSymbolicLink()) {
2877
- const s = await stat5(`${path}/${e.name}`).catch(() => null);
3582
+ const s = await stat5(`${path3}/${e.name}`).catch(() => null);
2878
3583
  out.push({ name: e.name, isFile: Boolean(s?.isFile()), isDirectory: false });
2879
3584
  } else out.push({ name: e.name, isFile: e.isFile(), isDirectory: e.isDirectory() });
2880
3585
  }
@@ -2883,13 +3588,13 @@ var nodeFs = {
2883
3588
  return [];
2884
3589
  }
2885
3590
  },
2886
- async realpath(path) {
2887
- return realpath3(path).catch(() => null);
3591
+ async realpath(path3) {
3592
+ return realpath3(path3).catch(() => null);
2888
3593
  },
2889
- async readText(path) {
3594
+ async readText(path3) {
2890
3595
  try {
2891
- if (!(await lstat(path)).isFile()) return null;
2892
- return await readFile6(path, "utf8");
3596
+ if (!(await lstat(path3)).isFile()) return null;
3597
+ return await readFile6(path3, "utf8");
2893
3598
  } catch {
2894
3599
  return null;
2895
3600
  }
@@ -2912,9 +3617,9 @@ async function searchRoots(opts) {
2912
3617
  const { home, env, platform } = opts;
2913
3618
  const userHome = platform === "win32" && usable(env.USERPROFILE) ? env.USERPROFILE : home;
2914
3619
  const roots = [];
2915
- const add = (label, path, depth, sessions = false) => {
2916
- if (!path) return;
2917
- const norm = p.resolve(path);
3620
+ const add = (label, path3, depth, sessions = false) => {
3621
+ if (!path3) return;
3622
+ const norm = p.resolve(path3);
2918
3623
  if (roots.some((r) => r.path === norm)) return;
2919
3624
  roots.push({ label, path: norm, depth, ...sessions ? { sessions } : {} });
2920
3625
  };
@@ -2944,8 +3649,8 @@ async function searchRoots(opts) {
2944
3649
  if (!fs.realpath) return roots;
2945
3650
  const real = [];
2946
3651
  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 });
3652
+ const path3 = await fs.realpath(root.path) ?? root.path;
3653
+ if (!real.some((r) => r.path === path3)) real.push({ ...root, path: path3 });
2949
3654
  }
2950
3655
  return real;
2951
3656
  }
@@ -2993,10 +3698,10 @@ async function walk(roots, opts, visit) {
2993
3698
  budget -= entries.length;
2994
3699
  for (const entry of entries) {
2995
3700
  if (entry.name.startsWith(".")) continue;
2996
- const path = p.join(dir, entry.name);
2997
- const result = await visit({ root, path, entry, siblings: entries });
3701
+ const path3 = p.join(dir, entry.name);
3702
+ const result = await visit({ root, path: path3, entry, siblings: entries });
2998
3703
  if (entry.isDirectory && !skip(entry.name) && result !== "skip" && depth < root.depth)
2999
- next.push(path);
3704
+ next.push(path3);
3000
3705
  }
3001
3706
  }
3002
3707
  level = next;
@@ -3012,9 +3717,9 @@ function namesMatch(candidate, wanted) {
3012
3717
  const b = wanted.toLowerCase();
3013
3718
  return a === b || withoutDuplicateSuffix(a) === withoutDuplicateSuffix(b);
3014
3719
  }
3015
- var splitSegments = (path) => path.split(/[\\/]+/).filter((s) => s !== "" && s !== "." && s !== "~");
3016
- function suffixScore(path, wanted) {
3017
- const have = splitSegments(path);
3720
+ var splitSegments = (path3) => path3.split(/[\\/]+/).filter((s) => s !== "" && s !== "." && s !== "~");
3721
+ function suffixScore(path3, wanted) {
3722
+ const have = splitSegments(path3);
3018
3723
  let score = 1;
3019
3724
  for (let i = 2; i <= Math.min(have.length, wanted.length); i++) {
3020
3725
  if (have[have.length - i]?.toLowerCase() !== wanted[wanted.length - i]?.toLowerCase()) break;
@@ -3055,19 +3760,19 @@ async function resolveInput(input, opts) {
3055
3760
  if (!name) throw notFound(input, roots);
3056
3761
  const found = [];
3057
3762
  const seen = /* @__PURE__ */ new Set();
3058
- await walk(roots, opts, async ({ root, path, entry }) => {
3763
+ await walk(roots, opts, async ({ root, path: path3, entry }) => {
3059
3764
  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);
3765
+ if (seen.has(path3)) return;
3766
+ seen.add(path3);
3767
+ const s = await fs.stat(path3);
3063
3768
  if (!s) return;
3064
3769
  found.push({
3065
- path,
3770
+ path: path3,
3066
3771
  foundIn: root.label,
3067
3772
  kind: s.isDirectory ? "folder" : "file",
3068
3773
  modified: new Date(s.mtimeMs).toISOString(),
3069
3774
  size: s.size,
3070
- score: suffixScore(path, wanted),
3775
+ score: suffixScore(path3, wanted),
3071
3776
  mtimeMs: s.mtimeMs,
3072
3777
  isDirectory: s.isDirectory
3073
3778
  });
@@ -3101,15 +3806,15 @@ async function findFiles(opts, find = {}) {
3101
3806
  const roots = await searchRoots(opts);
3102
3807
  const found = /* @__PURE__ */ new Map();
3103
3808
  const rootPaths = new Set(roots.map((r) => r.path));
3104
- const { complete } = await walk(roots, opts, async ({ root, path, entry }) => {
3809
+ const { complete } = await walk(roots, opts, async ({ root, path: path3, entry }) => {
3105
3810
  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));
3811
+ if (rootPaths.has(path3)) return;
3812
+ const index = (await fs.readdir(path3)).find((e) => e.isFile && /^index\.html?$/i.test(e.name));
3108
3813
  if (!index) return;
3109
- const s2 = await fs.stat(p.join(path, index.name));
3814
+ const s2 = await fs.stat(p.join(path3, index.name));
3110
3815
  if (s2 && s2.mtimeMs >= since && (!query || entry.name.toLowerCase().includes(query)))
3111
- found.set(path, {
3112
- path,
3816
+ found.set(path3, {
3817
+ path: path3,
3113
3818
  foundIn: root.label,
3114
3819
  kind: "folder",
3115
3820
  modified: new Date(s2.mtimeMs).toISOString(),
@@ -3120,10 +3825,10 @@ async function findFiles(opts, find = {}) {
3120
3825
  }
3121
3826
  if (!entry.isFile || !LISTED_EXT.test(entry.name)) return;
3122
3827
  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,
3828
+ const s = await fs.stat(path3);
3829
+ if (!s || s.mtimeMs < since || found.has(path3)) return;
3830
+ found.set(path3, {
3831
+ path: path3,
3127
3832
  foundIn: root.label,
3128
3833
  kind: "file",
3129
3834
  modified: new Date(s.mtimeMs).toISOString(),
@@ -3137,16 +3842,18 @@ async function findFiles(opts, find = {}) {
3137
3842
  }
3138
3843
 
3139
3844
  // 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.",
3845
+ 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.`;
3846
+ 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.`;
3847
+ var mcpLocalInstructions = (w, opts = {}) => [
3848
+ `${w.name} publishes static sites (HTML/CSS/JS, images, documents) at ${w.sitesPattern}. This server runs on the user's computer.`,
3143
3849
  "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
3850
  '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
3851
  "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
3852
  "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
3853
  NEVER_PUBLISHED,
3148
3854
  "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."
3855
+ `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."}`,
3856
+ ...opts.comments ? [COMMENTS_NOTE(w)] : []
3150
3857
  ].join("\n\n");
3151
3858
  var toolDef = (def) => ({
3152
3859
  title: def.title,
@@ -3160,6 +3867,8 @@ function createMcpServer(deps = {}) {
3160
3867
  const cwd = deps.cwd ?? process.cwd();
3161
3868
  const locate = deps.locate ?? { ...systemLocateOptions(), cwd };
3162
3869
  const waitReady = deps.waitReady ?? createReadyWaiter();
3870
+ const wording = mcpWording(deps.instance);
3871
+ const mcpTools2 = mcpToolsFor(wording);
3163
3872
  async function edgeReady(url, paths) {
3164
3873
  const r = await waitReady(url, paths).catch(() => ({ ready: false, waitedMs: 0 }));
3165
3874
  return {
@@ -3170,9 +3879,14 @@ Not reachable yet at ${url}: ${NOT_READY_NOTE}.`
3170
3879
  }
3171
3880
  async function client() {
3172
3881
  const config = await store.resolve({ serverUrl: deps.server });
3882
+ if (!config.serverUrl) throw new NoServerError("fling");
3173
3883
  if (!config.apiKey) throw new CliError("Not logged in", { exitCode: EXIT_NOT_LOGGED_IN });
3174
3884
  return createApiClient({ serverUrl: config.serverUrl, apiKey: config.apiKey, fetch: fetchImpl });
3175
3885
  }
3886
+ async function sitesBase() {
3887
+ const config = await store.resolve({ serverUrl: deps.server });
3888
+ return sitesBaseOf(await loadInstance(store, config, fetchImpl));
3889
+ }
3176
3890
  async function run(op) {
3177
3891
  try {
3178
3892
  const { summary, data } = await op(await client());
@@ -3228,9 +3942,15 @@ Warnings:
3228
3942
  result.url,
3229
3943
  local.files.map((f) => f.path)
3230
3944
  );
3945
+ const instance = deps.instance;
3946
+ const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
3947
+ const listing = generatedIndex ? " \u2014 with a generated index.html listing the HTML pages" : "";
3948
+ const head = review ? `${what}${listing}.
3949
+ ${shareForFeedback(review, instance?.name ?? "Fling")}
3950
+ Site: ${result.url} (${result.site.visibility})` : `${what}: ${result.url} (${result.site.visibility})${listing}`;
3231
3951
  return {
3232
3952
  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}
3953
+ summary: `${head}${warnings}${skippedNote}${edge.note}
3234
3954
  To check exactly which files the site has, call fling_files with name "${result.name}".`,
3235
3955
  data: {
3236
3956
  status: result.status,
@@ -3239,6 +3959,7 @@ To check exactly which files the site has, call fling_files with name "${result.
3239
3959
  nameGenerated: !opts.nameGiven,
3240
3960
  versionId: result.versionId,
3241
3961
  site: result.site,
3962
+ ...review ? { reviewUrl: review } : {},
3242
3963
  generatedIndex,
3243
3964
  warnings: result.warnings,
3244
3965
  skipped: local.skipped
@@ -3247,23 +3968,27 @@ To check exactly which files the site has, call fling_files with name "${result.
3247
3968
  }
3248
3969
  const server = new McpServer(
3249
3970
  { name: "fling", version: VERSION },
3250
- { instructions: MCP_LOCAL_INSTRUCTIONS }
3971
+ {
3972
+ instructions: mcpLocalInstructions(wording, {
3973
+ comments: commentsEnabled(deps.instance)
3974
+ })
3975
+ }
3251
3976
  );
3252
- const { listHtml: listHtml2, spa: spa2, public: isPublic2 } = mcpTools.publish.inputSchema;
3977
+ const { listHtml: listHtml2, spa: spa2, public: isPublic } = mcpTools2.publish.inputSchema;
3253
3978
  server.registerTool(
3254
3979
  "fling_deploy",
3255
3980
  {
3256
3981
  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,
3982
+ 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
3983
  inputSchema: {
3259
- path: z7.string().min(1).describe(
3984
+ path: z11.string().min(1).describe(
3260
3985
  "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
3986
  ),
3262
- name: z7.string().optional().describe(
3987
+ name: z11.string().optional().describe(
3263
3988
  "Site name (lowercase letters, digits, single hyphens; max 50). Defaults to a name generated from the file or folder"
3264
3989
  ),
3265
3990
  spa: spa2,
3266
- public: isPublic2,
3991
+ public: isPublic,
3267
3992
  listHtml: listHtml2
3268
3993
  },
3269
3994
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true }
@@ -3308,9 +4033,9 @@ To check exactly which files the site has, call fling_files with name "${result.
3308
4033
  title: "Find files to fling",
3309
4034
  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
4035
  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)")
4036
+ query: z11.string().optional().describe('Only names containing this text (case-insensitive), e.g. "deck"'),
4037
+ limit: z11.number().int().min(1).max(100).optional().describe("Max results (default 20)"),
4038
+ days: z11.number().int().min(1).max(3650).optional().describe("Only things modified in the last N days (default 14)")
3314
4039
  },
3315
4040
  annotations: { readOnlyHint: true, openWorldHint: false }
3316
4041
  },
@@ -3337,7 +4062,7 @@ ${JSON.stringify(data, null, 2)}` }]
3337
4062
  }
3338
4063
  }
3339
4064
  );
3340
- const publishDef = mcpTools.publish;
4065
+ const publishDef = mcpTools2.publish;
3341
4066
  server.registerTool(
3342
4067
  publishDef.name,
3343
4068
  {
@@ -3371,11 +4096,11 @@ ${JSON.stringify(data, null, 2)}` }]
3371
4096
  server.registerTool(
3372
4097
  "fling_check_name",
3373
4098
  {
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.`,
4099
+ ...toolDef(mcpTools2.checkName),
4100
+ 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
4101
  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)")
4102
+ name: z11.string().min(1).max(200).optional().describe("Site name to check"),
4103
+ path: z11.string().optional().describe("File or folder whose generated default name to check (path or file name)")
3379
4104
  }
3380
4105
  },
3381
4106
  (args) => run(async (c) => {
@@ -3388,7 +4113,7 @@ ${JSON.stringify(data, null, 2)}` }]
3388
4113
  name = defaultNameFor(source.path, source.isDirectory);
3389
4114
  }
3390
4115
  const check2 = await checkName(c, name);
3391
- const base = check2.status === "invalid" ? null : await discoverSitesBase(c);
4116
+ const base = check2.status === "invalid" ? null : await sitesBase();
3392
4117
  const url = base ? siteUrlFor(name, base) : null;
3393
4118
  const suggestions = check2.suggestions.length ? ` Suggestions: ${check2.suggestions.join(", ")}.` : "";
3394
4119
  const summary = {
@@ -3412,17 +4137,19 @@ ${JSON.stringify(data, null, 2)}` }]
3412
4137
  "fling_login",
3413
4138
  {
3414
4139
  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.",
4140
+ 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
4141
  inputSchema: {},
3417
4142
  annotations: { readOnlyHint: false, openWorldHint: true }
3418
4143
  },
3419
4144
  async () => {
3420
4145
  try {
3421
4146
  const config = await store.resolve({ serverUrl: deps.server });
4147
+ const serverUrl = config.serverUrl;
4148
+ if (!serverUrl) throw new NoServerError("fling");
3422
4149
  if (config.apiKey && !pendingLogin) {
3423
4150
  const me = await whoami(
3424
4151
  createApiClient({
3425
- serverUrl: config.serverUrl,
4152
+ serverUrl,
3426
4153
  apiKey: config.apiKey,
3427
4154
  fetch: fetchImpl
3428
4155
  })
@@ -3434,12 +4161,14 @@ ${JSON.stringify(data, null, 2)}` }]
3434
4161
  done: Promise.resolve()
3435
4162
  };
3436
4163
  login.done = loginFlow({
3437
- serverUrl: config.serverUrl,
4164
+ serverUrl,
3438
4165
  store,
3439
4166
  log: silentLogger,
3440
4167
  open: deps.open ?? (async (url) => (await import("open")).default(url)),
3441
4168
  fetch: fetchImpl,
3442
4169
  timeoutMs: deps.loginTimeoutMs,
4170
+ // Signing in to the extension's server leaves the CLI's current server as it is.
4171
+ makeCurrent: !config.current || config.current === serverUrl,
3443
4172
  onAuthUrl: (url) => {
3444
4173
  login.url = url;
3445
4174
  }
@@ -3469,7 +4198,7 @@ ${JSON.stringify(data, null, 2)}` }]
3469
4198
  }
3470
4199
  }
3471
4200
  );
3472
- const t = mcpTools;
4201
+ const t = mcpTools2;
3473
4202
  server.registerTool(
3474
4203
  t.list.name,
3475
4204
  toolDef(t.list),
@@ -3554,12 +4283,66 @@ ${JSON.stringify(data, null, 2)}` }]
3554
4283
  };
3555
4284
  })
3556
4285
  );
4286
+ if (deps.instance && commentsEnabled(deps.instance)) {
4287
+ const appOrigin = deps.instance.appOrigin;
4288
+ const review = (name) => reviewUrl(appOrigin, name);
4289
+ server.registerTool(
4290
+ t.commentsList.name,
4291
+ toolDef(t.commentsList),
4292
+ (args) => run(async (c) => {
4293
+ const list = await listComments(c, args.name, { path: args.path });
4294
+ const reviewPage2 = review(list.site.name);
4295
+ return {
4296
+ summary: describeComments(list, {
4297
+ includeResolved: args.includeResolved,
4298
+ reviewUrl: reviewPage2
4299
+ }),
4300
+ data: { ...list, reviewUrl: reviewPage2 }
4301
+ };
4302
+ })
4303
+ );
4304
+ server.registerTool(
4305
+ t.commentAdd.name,
4306
+ toolDef(t.commentAdd),
4307
+ (args) => run(async (c) => {
4308
+ const comment = await addComment(c, args.name, {
4309
+ body: args.body,
4310
+ path: args.path,
4311
+ parentId: args.replyTo
4312
+ });
4313
+ const what = comment.parentId ? `Replied to thread ${comment.parentId}` : "Comment added";
4314
+ return {
4315
+ summary: `${what} on ${args.name} (${comment.path}, id ${comment.id}). Review page: ${review(args.name)}`,
4316
+ data: comment
4317
+ };
4318
+ })
4319
+ );
4320
+ server.registerTool(
4321
+ t.commentResolve.name,
4322
+ toolDef(t.commentResolve),
4323
+ (args) => run(async (c) => {
4324
+ const comment = await resolveComment(c, args.name, args.id, !args.reopen);
4325
+ return {
4326
+ summary: `Thread ${comment.id} on ${args.name} ${args.reopen ? "reopened" : "resolved"}`,
4327
+ data: comment
4328
+ };
4329
+ })
4330
+ );
4331
+ server.registerTool(
4332
+ t.commentDelete.name,
4333
+ toolDef(t.commentDelete),
4334
+ (args) => run(async (c) => {
4335
+ const result = await deleteComment(c, args.name, args.id);
4336
+ return { summary: `Comment ${args.id} on ${args.name} deleted`, data: result };
4337
+ })
4338
+ );
4339
+ }
3557
4340
  server.registerTool(
3558
4341
  t.whoami.name,
3559
4342
  toolDef(t.whoami),
3560
4343
  () => run(async (c) => {
3561
4344
  const me = await whoami(c);
3562
- const base = await discoverSitesBase(c);
4345
+ const base = await sitesBase();
3563
4346
  return {
3564
4347
  summary: `Signed in as ${me.email} (sites are served at ${sitesPattern(base)})`,
3565
4348
  data: me
@@ -3575,24 +4358,24 @@ async function writeInlineFiles(dir, files) {
3575
4358
  const secretFiles = [];
3576
4359
  let total = 0;
3577
4360
  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);
4361
+ const path3 = f.path.replace(/^(\.\/)+/, "").replace(/^\/+/, "");
4362
+ const segments = path3.split("/");
4363
+ if (path3 && !path3.includes("\\") && segments.every((s) => s !== "" && s !== "." && s !== "..") && segments.some((s) => s.startsWith("."))) {
4364
+ skippedDotfiles.push(path3);
3582
4365
  continue;
3583
4366
  }
3584
- if (!isValidSitePath(path)) throw new CliError(`Invalid file path: "${f.path}"`);
3585
- if (isSecretPath(path)) {
3586
- secretFiles.push(path);
4367
+ if (!isValidSitePath(path3)) throw new CliError(`Invalid file path: "${f.path}"`);
4368
+ if (isSecretPath(path3)) {
4369
+ secretFiles.push(path3);
3587
4370
  continue;
3588
4371
  }
3589
- if (seen.has(path)) throw new CliError(`"${path}" is listed twice`);
3590
- seen.add(path);
4372
+ if (seen.has(path3)) throw new CliError(`"${path3}" is listed twice`);
4373
+ seen.add(path3);
3591
4374
  const bytes = Buffer.from(f.content, f.encoding === "base64" ? "base64" : "utf8");
3592
4375
  total += bytes.length;
3593
4376
  if (total > MAX_DEPLOY_BYTES)
3594
4377
  throw new CliError(`The site is larger than ${formatBytes(MAX_DEPLOY_BYTES)}`);
3595
- const target = join6(dir, ...path.split("/"));
4378
+ const target = join6(dir, ...path3.split("/"));
3596
4379
  await mkdir3(dirname2(target), { recursive: true });
3597
4380
  await writeFile4(target, bytes);
3598
4381
  }
@@ -3600,6 +4383,7 @@ async function writeInlineFiles(dir, files) {
3600
4383
  return { skippedDotfiles };
3601
4384
  }
3602
4385
  function describeError(error) {
4386
+ if (error instanceof NoServerError) return NO_SERVER_HINT;
3603
4387
  if (error instanceof CliError && error.exitCode === EXIT_NOT_LOGGED_IN) return LOGIN_HINT;
3604
4388
  if (error instanceof CliApiError && error.status === 401) return LOGIN_HINT;
3605
4389
  if (error instanceof CliError)
@@ -3608,7 +4392,9 @@ ${error.hint}` : error.message;
3608
4392
  return error instanceof Error ? error.message : String(error);
3609
4393
  }
3610
4394
  async function runMcpServer(deps = {}) {
3611
- const server = createMcpServer(deps);
4395
+ const store = deps.store ?? createConfigStore();
4396
+ const instance = deps.instance ?? await store.resolve({ serverUrl: deps.server }).then((config) => loadInstance(store, config, deps.fetch ?? ((i, init) => fetch(i, init)))).catch(() => null);
4397
+ const server = createMcpServer({ ...deps, store, instance });
3612
4398
  await server.connect(new StdioServerTransport());
3613
4399
  }
3614
4400
 
@@ -3616,7 +4402,7 @@ async function runMcpServer(deps = {}) {
3616
4402
  import { stat as stat6 } from "node:fs/promises";
3617
4403
  import { resolve as resolve4 } from "node:path";
3618
4404
  var PASS_THROUGH = /* @__PURE__ */ new Set(["-h", "--help", "-V", "--version"]);
3619
- var pathExists = (path) => stat6(resolve4(path)).then(
4405
+ var pathExists = (path3) => stat6(resolve4(path3)).then(
3620
4406
  () => true,
3621
4407
  () => false
3622
4408
  );
@@ -3664,30 +4450,32 @@ function routeOptionsFor(program2) {
3664
4450
  var program = new Command();
3665
4451
  program.name(BIN_NAME).description("Fling a folder of static files at Fling and get a link back").version(VERSION).option(
3666
4452
  "--server <url>",
3667
- `server URL (default: $${ENV.url} or config, else ${DEFAULT_SERVER_URL})`
4453
+ `server for this command: a URL, or the host of one you signed in to (default: $${ENV.url}, else the current one)`
3668
4454
  ).option("--json", "print JSON instead of human output", false).showHelpAfterError().configureHelp({
3669
4455
  sortSubcommands: false,
3670
- styleTitle: (title) => chalk9.bold(brand.primary(title)),
3671
- styleCommandText: (text2) => chalk9.bold(text2),
4456
+ styleTitle: (title) => chalk10.bold(brand.primary(title)),
4457
+ styleCommandText: (text2) => chalk10.bold(text2),
3672
4458
  styleSubcommandText: (text2) => brand.accent(text2),
3673
4459
  styleOptionText: (text2) => brand.accent(text2),
3674
- styleArgumentText: (text2) => chalk9.dim(text2)
4460
+ styleArgumentText: (text2) => chalk10.dim(text2)
3675
4461
  }).addHelpText(
3676
4462
  "before",
3677
4463
  () => process.stdout.isTTY ? logo({ columns: process.stdout.columns }) : ""
3678
4464
  ).addHelpText(
3679
4465
  "after",
3680
4466
  () => `
3681
- ${chalk9.bold(brand.primary("Examples:"))}
4467
+ ${chalk10.bold(brand.primary("Examples:"))}
4468
+ $ ${BIN_NAME} login https://fling.example.com sign in to your Fling (once; there is no default)
3682
4469
  $ ${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
4470
+ $ ${BIN_NAME} use list the Flings you signed in to; \`use <url>\` switches
4471
+ $ ${BIN_NAME} deploy ./dist --name my-app \u2192 https://my-app.<your sites domain>
3684
4472
  $ ${BIN_NAME} ./dist the same: an existing path deploys
3685
4473
  $ ${BIN_NAME} ./dist --yes no prompts (CI): use the generated name, fail if it is taken
3686
4474
  $ ${BIN_NAME} "Pitch Deck.html" a single page
3687
4475
  $ ${BIN_NAME} site.zip a zip of a site
3688
- $ ${BIN_NAME} share my-app public link for people outside Nearform
4476
+ $ ${BIN_NAME} share my-app public link for people who can't sign in
3689
4477
 
3690
- ${chalk9.dim(`Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
4478
+ ${chalk10.dim(`Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
3691
4479
  Env: ${ENV.apiKey} \xB7 ${ENV.url} \xB7 ${ENV.configDir} \xB7 ${ENV.debug}`)}`
3692
4480
  );
3693
4481
  function action(handler) {
@@ -3731,25 +4519,36 @@ program.command("deploy [path]").helpGroup("Deploy:").description(
3731
4519
  ).option(
3732
4520
  "-n, --name <name>",
3733
4521
  "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(
4522
+ ).option("--spa", "single-page app: serve index.html for unknown paths").option("--public", "make the site public (default: sign-in required)").option(
3735
4523
  "-y, --yes",
3736
4524
  "never prompt: accept the generated name; without index.html, list the HTML pages (or fling anyway if there are none)"
3737
4525
  ).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
4526
  action(
3739
- (ctx, path, opts) => runDeploy(ctx, path, opts)
4527
+ (ctx, path3, opts) => runDeploy(ctx, path3, opts)
3740
4528
  )
3741
4529
  );
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);
4530
+ program.command("setup [url]").helpGroup("Setup:").description(
4531
+ "sign in (to the Fling at url, which becomes current), then add the Fling plugin to Claude Code and the extension to Claude Desktop"
4532
+ ).option("-y, --yes", "do every step without asking").option("-s, --scope <scope>", "Claude Code plugin scope: user, project or local", "user").action(
4533
+ action(
4534
+ (ctx, url, opts) => runSetup(ctx, { ...opts, url })
4535
+ )
4536
+ );
4537
+ program.command("login [url]").helpGroup("Setup:").description(
4538
+ "sign in through the browser to the Fling at url and store an API key; it becomes the current server (default: the current one)"
4539
+ ).action(
4540
+ action(async (ctx, url) => {
4541
+ await runLogin(ctx, url);
3748
4542
  ctx.log.hint(`Next: \`${BIN_NAME} setup\` adds Fling to Claude Code and Claude Desktop.`);
3749
4543
  })
3750
4544
  );
3751
- program.command("logout").helpGroup("Setup:").description("remove the stored API key").action(action((ctx) => runLogout(ctx)));
3752
- program.command("whoami").helpGroup("Setup:").description("show the signed-in user").action(action((ctx) => runWhoami(ctx)));
4545
+ program.command("use [url]").helpGroup("Setup:").description(
4546
+ "switch to another Fling you signed in to (a URL or its host); without url, list them"
4547
+ ).action(action((ctx, url) => runUse(ctx, url)));
4548
+ program.command("logout [url]").helpGroup("Setup:").description(
4549
+ "remove a server's API key (default: the current server's, which stays current); any other server is forgotten"
4550
+ ).action(action((ctx, url) => runLogout(ctx, url)));
4551
+ program.command("whoami").helpGroup("Setup:").description("show the signed-in user and the current server").action(action((ctx) => runWhoami(ctx)));
3753
4552
  program.commandsGroup("Setup:").helpCommand("help [command]", "show help for a command");
3754
4553
  program.command("ls").helpGroup("Sites:").alias("list").description("list your sites").option("--archived", "list archived sites").action(action((ctx, opts) => runList(ctx, opts)));
3755
4554
  program.command("info <name>").helpGroup("Sites:").description("show a site").action(action((ctx, name) => runInfo(ctx, name)));
@@ -3763,10 +4562,30 @@ program.command("share <name>").helpGroup("Sites:").description("print (or creat
3763
4562
  (ctx, name, opts) => runShare(ctx, name, opts)
3764
4563
  )
3765
4564
  );
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)));
4565
+ 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
4566
  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
4567
  program.command("unarchive <name>").helpGroup("Sites:").description("bring an archived site back").action(action((ctx, name) => runArchive(ctx, name, true)));
3769
4568
  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)));
4569
+ 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(
4570
+ action(
4571
+ (ctx, name, opts) => runComments(ctx, name, opts)
4572
+ )
4573
+ );
4574
+ 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(
4575
+ action(
4576
+ (ctx, name, text2, opts) => runComment(ctx, name, text2, opts)
4577
+ )
4578
+ );
4579
+ program.command("resolve <name> <id>").helpGroup("Comments:").description("mark a comment thread resolved").option("--reopen", "reopen it instead").action(
4580
+ action(
4581
+ (ctx, name, id, opts) => runResolve(ctx, name, id, opts)
4582
+ )
4583
+ );
4584
+ 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(
4585
+ action(
4586
+ (ctx, name, id, opts) => runUncomment(ctx, name, id, opts)
4587
+ )
4588
+ );
3770
4589
  program.command("mcp").helpGroup("Agents:").description("run the MCP server on stdio (used by the Claude Code plugin)").action(async (_opts, command) => {
3771
4590
  const globals = command.optsWithGlobals();
3772
4591
  await runMcpServer({ server: globals.server });