@nearform/fling 0.5.0 → 0.6.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,22 +1,99 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/cli.ts
4
- import chalk10 from "chalk";
5
- import { Command } from "commander";
6
-
7
- // src/commands/auth.ts
8
- import chalk5 from "chalk";
9
-
10
- // src/auth.ts
11
- import { randomBytes } from "node:crypto";
12
- import { createServer } from "node:http";
13
- import { hostname as osHostname } from "node:os";
14
-
15
3
  // ../../packages/shared/src/api.ts
16
- import { z as z2 } from "zod";
4
+ import { z as z3 } from "zod";
17
5
 
18
- // ../../packages/shared/src/manifest.ts
6
+ // ../../packages/shared/src/audience.ts
19
7
  import { z } from "zod";
8
+ var AUDIENCE_EMAILS_MAX = 100;
9
+ var AUDIENCE_GROUPS_MAX = 10;
10
+ var AudienceEmailSchema = z.object({ email: z.string(), known: z.boolean() });
11
+ var AudienceGroupSchema = z.object({
12
+ slug: z.string(),
13
+ name: z.string(),
14
+ size: z.number(),
15
+ deleted: z.boolean().optional()
16
+ });
17
+ var AudienceSchema = z.object({
18
+ emails: z.array(AudienceEmailSchema),
19
+ groups: z.array(AudienceGroupSchema).default([])
20
+ });
21
+ var AudienceInputSchema = z.object({
22
+ emails: z.array(z.string().trim().toLowerCase().min(3).max(254)).max(AUDIENCE_EMAILS_MAX, `An audience holds at most ${AUDIENCE_EMAILS_MAX} addresses`),
23
+ groups: z.array(z.string().trim().toLowerCase().min(1).max(100)).max(AUDIENCE_GROUPS_MAX, `An audience names at most ${AUDIENCE_GROUPS_MAX} groups`).default([])
24
+ });
25
+ var normaliseEmail = (email) => email.trim().toLowerCase();
26
+ function splitEmails(values) {
27
+ const all = typeof values === "string" ? [values] : values;
28
+ return all.flatMap((v) => v.split(/[\s,;]+/)).map(normaliseEmail).filter(Boolean);
29
+ }
30
+ function editAudience(current, change) {
31
+ let emails2 = change.clear ? [] : change.set ? [...change.set] : [...current];
32
+ emails2 = [...emails2, ...change.add ?? []].map(normaliseEmail);
33
+ const removed = new Set((change.remove ?? []).map(normaliseEmail));
34
+ return [...new Set(emails2)].filter((e) => e && !removed.has(e));
35
+ }
36
+ function editAudienceGroups(current, change) {
37
+ const norm = (s) => s.trim().toLowerCase();
38
+ let groups2 = change.clear ? [] : change.setGroups ? [...change.setGroups] : [...current];
39
+ groups2 = [...groups2, ...change.addGroups ?? []].map(norm);
40
+ const removed = new Set((change.removeGroups ?? []).map(norm));
41
+ return [...new Set(groups2)].filter((g) => g && !removed.has(g));
42
+ }
43
+ function editAudienceInput(current, change) {
44
+ return {
45
+ emails: editAudience(
46
+ current.emails.map((e) => e.email),
47
+ change
48
+ ),
49
+ groups: editAudienceGroups(
50
+ current.groups.map((g) => g.slug),
51
+ change
52
+ )
53
+ };
54
+ }
55
+ var changesAudience = (change) => Boolean(
56
+ change.clear || change.set || change.add?.length || change.remove?.length || change.setGroups || change.addGroups?.length || change.removeGroups?.length
57
+ );
58
+ var people = (n) => `${n} ${n === 1 ? "person" : "people"}`;
59
+ var groupLabel = (g) => g.deleted ? `a deleted group (${g.slug})` : `${g.slug} (${g.name !== g.slug ? `${g.name}, ` : ""}${people(g.size)})`;
60
+ function whoIn(emails2, groups2) {
61
+ const live = groups2.filter((g) => !g.deleted);
62
+ return [
63
+ emails2.length ? people(emails2.length) : "",
64
+ live.length ? `${live.length === 1 ? "the group" : "the groups"} ${live.map(groupLabel).join(", ")}` : "",
65
+ ...groups2.filter((g) => g.deleted).map(groupLabel)
66
+ ].filter(Boolean);
67
+ }
68
+ var and = (parts) => parts.length > 1 ? `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}` : parts[0] ?? "";
69
+ var audienceList = (emails2) => emails2.map((e) => e.known ? e.email : `${e.email} (hasn't signed in yet)`).join(", ");
70
+ function describeAudience(site, privateAudience2) {
71
+ const emails2 = site.audience?.emails ?? [];
72
+ const groups2 = site.audience?.groups ?? [];
73
+ const empty = emails2.length + groups2.length === 0;
74
+ const list = emails2.length ? `: ${audienceList(emails2)}` : "";
75
+ if (site.visibility === "public") {
76
+ return empty ? "Public: anyone can open the site; reviewing it still needs sign-in." : `Public: anyone can open the site; reviewing it still needs sign-in. Its audience (${and(whoIn(emails2, groups2))}${list}) applies when it is private again.`;
77
+ }
78
+ if (empty) return `Private: ${privateAudience2} can open it.`;
79
+ const share2 = site.shareUrl ? ` A share link is active: anyone with it can still open the site (${site.shareUrl}).` : "";
80
+ if (emails2.length === 0 && groups2.every((g) => g.deleted)) {
81
+ const refs = groups2.map((g) => g.slug).join(", ");
82
+ return `Restricted: only you can open it. Its audience names only ${groups2.length === 1 ? "a deleted group" : "deleted groups"} (${refs}), which let nobody in: add people or groups, or clear it.${share2}`;
83
+ }
84
+ return `Restricted: only ${and(["you", ...whoIn(emails2, groups2)])} can open it${list}.${share2}`;
85
+ }
86
+ function sharedVia(site) {
87
+ const { direct, groups: groups2 } = site.via;
88
+ return and([
89
+ ...direct ? ["your address"] : [],
90
+ ...groups2.length ? [`${groups2.length === 1 ? "group" : "groups"} ${groups2.join(", ")}`] : []
91
+ ]);
92
+ }
93
+ var describeSharedSite = (site) => `- ${site.name}: ${site.url} (shared by ${site.owner}, through ${sharedVia(site)}${site.reviewUrl ? `; review: ${site.reviewUrl}` : ""})`;
94
+
95
+ // ../../packages/shared/src/manifest.ts
96
+ import { z as z2 } from "zod";
20
97
 
21
98
  // ../../packages/shared/src/limits.ts
22
99
  var MAX_DEPLOY_BYTES = 100 * 1024 * 1024;
@@ -214,220 +291,246 @@ function isValidSitePath(path3) {
214
291
  if (path3.startsWith("/") || path3.includes("\\") || path3.includes("\0")) return false;
215
292
  return path3.split("/").every((seg) => seg !== "" && !seg.startsWith("."));
216
293
  }
217
- var ManifestFileSchema = z.object({
218
- path: z.string().refine(isValidSitePath, "invalid path (dotfiles are never published)").refine((p) => !isSecretPath(p), "secret file (keys, certificates, credentials) never published"),
219
- sha256: z.string().regex(/^[a-f0-9]{64}$/),
220
- size: z.number().int().min(0).max(MAX_FILE_BYTES),
221
- contentType: z.string().min(1).max(255)
294
+ var ManifestFileSchema = z2.object({
295
+ path: z2.string().refine(isValidSitePath, "invalid path (dotfiles are never published)").refine((p) => !isSecretPath(p), "secret file (keys, certificates, credentials) never published"),
296
+ sha256: z2.string().regex(/^[a-f0-9]{64}$/),
297
+ size: z2.number().int().min(0).max(MAX_FILE_BYTES),
298
+ contentType: z2.string().min(1).max(255)
222
299
  });
223
- var ManifestSchema = z.object({ files: z.array(ManifestFileSchema).min(1).max(MAX_FILES) }).refine((m) => new Set(m.files.map((f) => f.path)).size === m.files.length, "duplicate paths").refine((m) => m.files.reduce((n, f) => n + f.size, 0) <= MAX_DEPLOY_BYTES, "deploy too large");
300
+ var ManifestSchema = z2.object({ files: z2.array(ManifestFileSchema).min(1).max(MAX_FILES) }).refine((m) => new Set(m.files.map((f) => f.path)).size === m.files.length, "duplicate paths").refine((m) => m.files.reduce((n, f) => n + f.size, 0) <= MAX_DEPLOY_BYTES, "deploy too large");
224
301
 
225
302
  // ../../packages/shared/src/api.ts
226
- var VisibilitySchema = z2.enum(["private", "public"]);
227
- var MeSchema = z2.object({
228
- email: z2.string(),
229
- name: z2.string(),
230
- picture: z2.string().nullable(),
231
- userSlug: z2.string(),
303
+ var VisibilitySchema = z3.enum(["private", "public"]);
304
+ var MeSchema = z3.object({
305
+ email: z3.string(),
306
+ name: z3.string(),
307
+ picture: z3.string().nullable(),
308
+ userSlug: z3.string(),
232
309
  /** one of the instance's admins (servers from before admins don't say: false) */
233
- admin: z2.boolean().default(false),
310
+ admin: z3.boolean().default(false),
234
311
  /** while an admin impersonates this user: the admin (browser sessions only) */
235
- impersonator: z2.string().optional(),
312
+ impersonator: z3.string().optional(),
236
313
  /** …and when the impersonation lapses */
237
- impersonationEndsAt: z2.string().optional()
314
+ impersonationEndsAt: z3.string().optional()
238
315
  });
239
- var SiteSchema = z2.object({
240
- name: z2.string(),
241
- url: z2.string(),
316
+ var SiteSchema = z3.object({
317
+ name: z3.string(),
318
+ url: z3.string(),
242
319
  visibility: VisibilitySchema,
243
- spa: z2.boolean(),
244
- archived: z2.boolean(),
245
- currentVersionId: z2.string().nullable(),
246
- shareUrl: z2.string().nullable(),
247
- createdAt: z2.string(),
248
- updatedAt: z2.string()
320
+ spa: z3.boolean(),
321
+ archived: z3.boolean(),
322
+ currentVersionId: z3.string().nullable(),
323
+ shareUrl: z3.string().nullable(),
324
+ /** private with an audience: only the owner and the people in it can open it */
325
+ restricted: z3.boolean().default(false),
326
+ /** how many addresses and groups the audience names (kept, and ignored, while public) */
327
+ audienceSize: z3.number().default(0),
328
+ /** how many of those are groups */
329
+ audienceGroups: z3.number().default(0),
330
+ createdAt: z3.string(),
331
+ updatedAt: z3.string()
332
+ });
333
+ var SiteWithAudienceSchema = SiteSchema.extend({ audience: AudienceSchema.optional() });
334
+ var VersionSchema = z3.object({
335
+ id: z3.string(),
336
+ fileCount: z3.number(),
337
+ totalBytes: z3.number(),
338
+ createdAt: z3.string()
249
339
  });
250
- var VersionSchema = z2.object({
251
- id: z2.string(),
252
- fileCount: z2.number(),
253
- totalBytes: z2.number(),
254
- createdAt: z2.string()
340
+ var SiteDetailSchema = SiteWithAudienceSchema.extend({ versions: z3.array(VersionSchema) });
341
+ var SiteListSchema = z3.object({ sites: z3.array(SiteSchema) });
342
+ var SharedSiteSchema = z3.object({
343
+ name: z3.string(),
344
+ url: z3.string(),
345
+ /** the site's review page (`comments` capability) */
346
+ reviewUrl: z3.string().optional(),
347
+ owner: z3.string(),
348
+ /** how the caller is in the audience: by address, and through which of their groups (slugs) */
349
+ via: z3.object({ direct: z3.boolean(), groups: z3.array(z3.string()) }),
350
+ createdAt: z3.string(),
351
+ updatedAt: z3.string()
255
352
  });
256
- var SiteDetailSchema = SiteSchema.extend({ versions: z2.array(VersionSchema) });
257
- var SiteListSchema = z2.object({ sites: z2.array(SiteSchema) });
258
- var SiteFileSchema = z2.object({
259
- path: z2.string(),
260
- size: z2.number(),
261
- contentType: z2.string()
353
+ var SharedSiteListSchema = z3.object({ sites: z3.array(SharedSiteSchema) });
354
+ var SiteFileSchema = z3.object({
355
+ path: z3.string(),
356
+ size: z3.number(),
357
+ contentType: z3.string()
262
358
  });
263
- var VersionFilesSchema = z2.object({
264
- versionId: z2.string(),
265
- files: z2.array(SiteFileSchema),
266
- totalBytes: z2.number(),
267
- generatedIndex: z2.boolean()
359
+ var VersionFilesSchema = z3.object({
360
+ versionId: z3.string(),
361
+ files: z3.array(SiteFileSchema),
362
+ totalBytes: z3.number(),
363
+ generatedIndex: z3.boolean()
268
364
  });
269
- var NameCheckSchema = z2.object({
270
- name: z2.string(),
271
- status: z2.enum(["available", "yours", "taken", "invalid"]),
272
- suggestions: z2.array(z2.string())
365
+ var NameCheckSchema = z3.object({
366
+ name: z3.string(),
367
+ status: z3.enum(["available", "yours", "taken", "invalid"]),
368
+ suggestions: z3.array(z3.string())
273
369
  });
274
- var DeployRequestSchema = z2.object({
370
+ var DeployRequestSchema = z3.object({
275
371
  manifest: ManifestSchema,
276
- spa: z2.boolean().optional(),
372
+ spa: z3.boolean().optional(),
277
373
  visibility: VisibilitySchema.optional()
278
374
  });
279
- var UploadSchema = z2.object({
280
- path: z2.string(),
281
- url: z2.string(),
375
+ var UploadSchema = z3.object({
376
+ path: z3.string(),
377
+ url: z3.string(),
282
378
  /** headers the client must send with the PUT (they are part of the signature) */
283
- headers: z2.record(z2.string(), z2.string())
379
+ headers: z3.record(z3.string(), z3.string())
284
380
  });
285
- var DeployResponseSchema = z2.discriminatedUnion("status", [
381
+ var DeployResponseSchema = z3.discriminatedUnion("status", [
286
382
  /** content identical to the current version — nothing happened */
287
- z2.object({ status: z2.literal("unchanged"), versionId: z2.string(), site: SiteSchema }),
383
+ z3.object({ status: z3.literal("unchanged"), versionId: z3.string(), site: SiteSchema }),
288
384
  /** this content was deployed before — site now points at that version again */
289
- z2.object({ status: z2.literal("repointed"), versionId: z2.string(), site: SiteSchema }),
385
+ z3.object({ status: z3.literal("repointed"), versionId: z3.string(), site: SiteSchema }),
290
386
  /** new content — PUT every upload, then POST /api/sites/:name/deploys/:versionId/finalize */
291
- z2.object({
292
- status: z2.literal("upload"),
293
- versionId: z2.string(),
294
- uploads: z2.array(UploadSchema)
387
+ z3.object({
388
+ status: z3.literal("upload"),
389
+ versionId: z3.string(),
390
+ uploads: z3.array(UploadSchema)
295
391
  })
296
392
  ]);
297
- var FinalizeResponseSchema = z2.object({
298
- status: z2.literal("live"),
299
- versionId: z2.string(),
393
+ var FinalizeResponseSchema = z3.object({
394
+ status: z3.literal("live"),
395
+ versionId: z3.string(),
300
396
  site: SiteSchema
301
397
  });
302
- var RollbackRequestSchema = z2.object({ versionId: z2.string() });
303
- var UpdateSiteRequestSchema = z2.object({
398
+ var RollbackRequestSchema = z3.object({ versionId: z3.string() });
399
+ var UpdateSiteRequestSchema = z3.object({
304
400
  visibility: VisibilitySchema.optional(),
305
- spa: z2.boolean().optional()
401
+ spa: z3.boolean().optional(),
402
+ audience: AudienceInputSchema.optional()
306
403
  });
307
- var ApiKeySchema = z2.object({
308
- id: z2.string(),
309
- name: z2.string(),
310
- createdAt: z2.string(),
311
- lastUsedAt: z2.string().nullable()
404
+ var ApiKeySchema = z3.object({
405
+ id: z3.string(),
406
+ name: z3.string(),
407
+ createdAt: z3.string(),
408
+ lastUsedAt: z3.string().nullable()
312
409
  });
313
- var ApiKeyListSchema = z2.object({ keys: z2.array(ApiKeySchema) });
314
- var ErrorEnvelopeSchema = z2.object({
315
- error: z2.object({ code: z2.string(), message: z2.string() })
410
+ var ApiKeyListSchema = z3.object({ keys: z3.array(ApiKeySchema) });
411
+ var ErrorEnvelopeSchema = z3.object({
412
+ error: z3.object({ code: z3.string(), message: z3.string() })
316
413
  });
317
- var ShareResponseSchema = z2.object({ shareUrl: z2.string() });
318
- var ConnectionSchema = z2.object({
319
- id: z2.string(),
320
- clientName: z2.string(),
414
+ var ShareResponseSchema = z3.object({ shareUrl: z3.string() });
415
+ var ConnectionSchema = z3.object({
416
+ id: z3.string(),
417
+ clientName: z3.string(),
321
418
  /** host the client identifies as (CIMD client_id host or redirect URI host) */
322
- clientHost: z2.string().nullable(),
323
- createdAt: z2.string(),
324
- lastUsedAt: z2.string().nullable()
419
+ clientHost: z3.string().nullable(),
420
+ createdAt: z3.string(),
421
+ lastUsedAt: z3.string().nullable()
325
422
  });
326
- var ConnectionListSchema = z2.object({ connections: z2.array(ConnectionSchema) });
327
- var OAuthRequestInfoSchema = z2.object({
328
- clientName: z2.string(),
329
- clientUri: z2.string().nullable(),
423
+ var ConnectionListSchema = z3.object({ connections: z3.array(ConnectionSchema) });
424
+ var OAuthRequestInfoSchema = z3.object({
425
+ clientName: z3.string(),
426
+ clientUri: z3.string().nullable(),
330
427
  /** host of the CIMD client_id URL, when the client identified itself that way */
331
- clientIdHost: z2.string().nullable(),
332
- redirectHost: z2.string(),
428
+ clientIdHost: z3.string().nullable(),
429
+ redirectHost: z3.string(),
333
430
  /** true when the code goes to a loopback address (any local program could be asking) */
334
- loopback: z2.boolean(),
335
- scopes: z2.array(z2.string()),
336
- email: z2.string()
431
+ loopback: z3.boolean(),
432
+ scopes: z3.array(z3.string()),
433
+ email: z3.string()
337
434
  });
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(),
435
+ var OAuthDecisionSchema = z3.object({ redirectUrl: z3.string() });
436
+ var AdminUserSchema = z3.object({
437
+ email: z3.string(),
438
+ name: z3.string(),
439
+ picture: z3.string().nullable(),
440
+ userSlug: z3.string(),
441
+ createdAt: z3.string().nullable(),
442
+ lastSignInAt: z3.string().nullable(),
346
443
  /** sites the user holds, archived ones included */
347
- siteCount: z2.number(),
348
- admin: z2.boolean()
444
+ siteCount: z3.number(),
445
+ admin: z3.boolean()
349
446
  });
350
- var AdminUserListSchema = z2.object({
351
- users: z2.array(AdminUserSchema),
447
+ var AdminUserListSchema = z3.object({
448
+ users: z3.array(AdminUserSchema),
352
449
  /** pass back as `cursor` for the next page; null: no more */
353
- cursor: z2.string().nullable()
450
+ cursor: z3.string().nullable()
354
451
  });
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()
452
+ var AuditEntrySchema = z3.object({
453
+ id: z3.string(),
454
+ at: z3.string(),
455
+ action: z3.enum(["impersonation.start", "impersonation.stop"]),
456
+ actor: z3.string(),
457
+ target: z3.string(),
458
+ until: z3.string().optional(),
459
+ lapsed: z3.boolean().optional()
363
460
  });
364
- var AuditListSchema = z2.object({ entries: z2.array(AuditEntrySchema) });
365
- var ImpersonateRequestSchema = z2.object({ email: z2.string().trim().toLowerCase().min(3) });
461
+ var AuditListSchema = z3.object({ entries: z3.array(AuditEntrySchema) });
462
+ var ImpersonateRequestSchema = z3.object({ email: z3.string().trim().toLowerCase().min(3) });
366
463
 
367
464
  // ../../packages/shared/src/comments.ts
368
- import { z as z3 } from "zod";
465
+ import { z as z4 } from "zod";
369
466
  var COMMENT_MAX_CHARS = 4e3;
370
467
  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() })
468
+ var CommentAuthorSchema = z4.discriminatedUnion("kind", [
469
+ z4.object({ kind: z4.literal("user"), email: z4.string() }),
470
+ z4.object({ kind: z4.literal("guest"), name: z4.string() })
374
471
  ]);
375
472
  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({
473
+ var ANCHOR_QUOTE_MAX = 500;
474
+ var px = z4.number().finite().min(-1e7).max(1e7);
475
+ var percent = z4.number().min(0).max(100);
476
+ var CommentAnchorSchema = z4.object({
379
477
  x: percent,
380
478
  y: percent,
381
- selector: z3.string().min(1).max(ANCHOR_SELECTOR_MAX).optional(),
479
+ selector: z4.string().min(1).max(ANCHOR_SELECTOR_MAX).optional(),
382
480
  ex: percent.optional(),
383
481
  ey: percent.optional(),
384
482
  docX: px.optional(),
385
483
  docY: px.optional(),
386
484
  scrollX: px.optional(),
387
485
  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()
486
+ vw: z4.number().finite().min(0).max(1e5).optional(),
487
+ vh: z4.number().finite().min(0).max(1e5).optional(),
488
+ hash: z4.string().max(COMMENT_PATH_MAX).optional(),
489
+ ew: percent.optional(),
490
+ eh: percent.optional(),
491
+ docW: z4.number().finite().min(0).max(1e7).optional(),
492
+ docH: z4.number().finite().min(0).max(1e7).optional(),
493
+ quote: z4.string().min(1).max(ANCHOR_QUOTE_MAX).optional()
391
494
  });
392
- var CommentSchema = z3.object({
393
- id: z3.string(),
495
+ var CommentSchema = z4.object({
496
+ id: z4.string(),
394
497
  /** the thread this replies to; null for the comment that starts a thread */
395
- parentId: z3.string().nullable(),
498
+ parentId: z4.string().nullable(),
396
499
  /** the page, as normalizeCommentPath() writes it ('/', '/docs/', '/about.html') */
397
- path: z3.string(),
500
+ path: z4.string(),
398
501
  /** the version the site was serving when the comment was made (null: none was) */
399
- versionId: z3.string().nullable(),
502
+ versionId: z4.string().nullable(),
400
503
  anchor: CommentAnchorSchema.nullable(),
401
- body: z3.string(),
504
+ body: z4.string(),
402
505
  author: CommentAuthorSchema,
403
- createdAt: z3.string(),
506
+ createdAt: z4.string(),
404
507
  /** threads only: when and by whom it was resolved (null: open) */
405
- resolvedAt: z3.string().nullable(),
508
+ resolvedAt: z4.string().nullable(),
406
509
  resolvedBy: CommentAuthorSchema.nullable(),
407
510
  /** whether the caller may delete it (policy's answer, so clients don't guess) */
408
- canDelete: z3.boolean()
511
+ canDelete: z4.boolean()
409
512
  });
410
- var CommentListSchema = z3.object({
411
- site: z3.object({
412
- name: z3.string(),
413
- url: z3.string(),
414
- currentVersionId: z3.string().nullable()
513
+ var CommentListSchema = z4.object({
514
+ site: z4.object({
515
+ name: z4.string(),
516
+ url: z4.string(),
517
+ currentVersionId: z4.string().nullable()
415
518
  }),
416
- comments: z3.array(CommentSchema),
417
- can: z3.object({
418
- create: z3.boolean(),
419
- resolve: z3.boolean(),
519
+ comments: z4.array(CommentSchema),
520
+ can: z4.object({
521
+ create: z4.boolean(),
522
+ resolve: z4.boolean(),
420
523
  /** delete anyone's comment (everyone may delete their own) */
421
- deleteAny: z3.boolean()
524
+ deleteAny: z4.boolean()
422
525
  })
423
526
  });
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(),
527
+ var CreateCommentRequestSchema = z4.object({
528
+ body: z4.string().trim().min(1).max(COMMENT_MAX_CHARS),
529
+ path: z4.string().max(COMMENT_PATH_MAX).optional(),
530
+ parentId: z4.string().min(1).max(100).optional(),
428
531
  anchor: CommentAnchorSchema.optional()
429
532
  });
430
- var UpdateCommentRequestSchema = z3.object({ resolved: z3.boolean() });
533
+ var UpdateCommentRequestSchema = z4.object({ resolved: z4.boolean() });
431
534
  function commentThreads(comments) {
432
535
  const threads = /* @__PURE__ */ new Map();
433
536
  for (const c of comments) {
@@ -451,6 +554,60 @@ var CLI_PACKAGE = "@nearform/fling";
451
554
  var REPO_SLUG = "nearform/fling";
452
555
  var DEFAULT_INSTALL_COMMAND = `npm install -g ${CLI_PACKAGE}`;
453
556
 
557
+ // ../../packages/shared/src/groups.ts
558
+ import { z as z5 } from "zod";
559
+ var GROUP_MEMBERS_MAX = 500;
560
+ var GROUP_SLUG_MAX = 50;
561
+ var GROUP_NAME_MAX = 100;
562
+ var GROUP_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
563
+ function isValidGroupSlug(slug) {
564
+ return slug.length <= GROUP_SLUG_MAX && GROUP_SLUG_RE.test(slug) && !slug.includes("--");
565
+ }
566
+ var GROUP_SLUG_RULES = `1-${GROUP_SLUG_MAX} lowercase letters, digits and single hyphens`;
567
+ var GroupSlugSchema = z5.string().trim().toLowerCase().refine(isValidGroupSlug, `A group's slug is ${GROUP_SLUG_RULES}`);
568
+ var GroupNameSchema = z5.string().trim().min(1).max(GROUP_NAME_MAX);
569
+ var memberEmails = z5.array(z5.string().trim().toLowerCase().min(3).max(254)).max(GROUP_MEMBERS_MAX, `A group holds at most ${GROUP_MEMBERS_MAX} people`);
570
+ var GroupSummarySchema = z5.object({
571
+ slug: z5.string(),
572
+ name: z5.string(),
573
+ /** the email of the user who made it, the only one who may change it */
574
+ owner: z5.string(),
575
+ size: z5.number(),
576
+ createdAt: z5.string(),
577
+ updatedAt: z5.string()
578
+ });
579
+ var GroupListSchema = z5.object({ groups: z5.array(GroupSummarySchema) });
580
+ var GroupDetailSchema = GroupSummarySchema.extend({
581
+ members: z5.array(AudienceEmailSchema),
582
+ canManage: z5.boolean()
583
+ });
584
+ var CreateGroupRequestSchema = z5.object({
585
+ slug: GroupSlugSchema,
586
+ name: GroupNameSchema,
587
+ members: memberEmails.optional()
588
+ });
589
+ var UpdateGroupRequestSchema = z5.object({ name: GroupNameSchema });
590
+ var GroupMembersRequestSchema = z5.object({
591
+ add: memberEmails.optional(),
592
+ remove: memberEmails.optional()
593
+ });
594
+ function splitGroups(values) {
595
+ const all = typeof values === "string" ? [values] : values;
596
+ return all.flatMap((v) => v.split(/[\s,;]+/)).map((s) => s.trim().toLowerCase()).filter(Boolean);
597
+ }
598
+ function describeGroups(groups2, me) {
599
+ if (groups2.length === 0) return "No groups yet.";
600
+ return groups2.map((g) => `- ${groupLabel(g)}, owned by ${g.owner === me ? "you" : g.owner}`).join("\n");
601
+ }
602
+ function describeGroup(group) {
603
+ const members2 = group.members;
604
+ const who = group.canManage ? "you (you can change it)" : `${group.owner} (only they can change it)`;
605
+ return [
606
+ `${groupLabel(group)}, owned by ${who}.`,
607
+ members2.length ? `Members: ${audienceList(members2)}.` : "No members yet."
608
+ ].join("\n");
609
+ }
610
+
454
611
  // ../../packages/shared/src/site-labels.ts
455
612
  var SITE_NAME_MAX = 50;
456
613
  var SHARE_TOKEN_LENGTH = 20;
@@ -485,47 +642,47 @@ function siteUrl(label, sitesDomain, protocol = "https:") {
485
642
  }
486
643
 
487
644
  // ../../packages/shared/src/instance.ts
488
- import { z as z4 } from "zod";
645
+ import { z as z6 } from "zod";
489
646
  var INSTANCE_METADATA_PATH = "/.well-known/fling";
490
647
  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()
648
+ var color = z6.string().regex(HEX_COLOR_RE);
649
+ var InstanceAuthSchema = z6.looseObject({
650
+ kind: z6.string().min(1),
651
+ displayName: z6.string().min(1),
652
+ issuer: z6.string().optional(),
653
+ emailDomains: z6.array(z6.string()).default([]),
654
+ invitedEmails: z6.boolean().optional()
498
655
  });
499
- var BrandColorsSchema = z4.looseObject({
656
+ var BrandColorsSchema = z6.looseObject({
500
657
  primary: color.optional(),
501
658
  primaryContent: color.optional(),
502
659
  accent: color.optional(),
503
660
  ink: color.optional(),
504
661
  highlight: color.optional()
505
662
  });
506
- var InstanceBrandSchema = z4.looseObject({
663
+ var InstanceBrandSchema = z6.looseObject({
507
664
  /** https: or data: URL of the logo mark shown before the name (light theme). */
508
- logoUrl: z4.string().optional(),
665
+ logoUrl: z6.string().optional(),
509
666
  /** The same for the dark theme; defaults to `logoUrl`. */
510
- logoDarkUrl: z4.string().optional(),
511
- faviconUrl: z4.string().optional(),
667
+ logoDarkUrl: z6.string().optional(),
668
+ faviconUrl: z6.string().optional(),
512
669
  colors: BrandColorsSchema.default({})
513
670
  });
514
- var InstanceMetadataSchema = z4.looseObject({
515
- version: z4.number().int().min(1),
671
+ var InstanceMetadataSchema = z6.looseObject({
672
+ version: z6.number().int().min(1),
516
673
  /** Display name, e.g. "Fling". */
517
- name: z4.string().min(1),
674
+ name: z6.string().min(1),
518
675
  /** e.g. `https://fling.example.test` (no trailing slash) */
519
- appOrigin: z4.string().url(),
676
+ appOrigin: z6.string().url(),
520
677
  /** host[:port] sites hang off: a site is `<name>.<sitesDomain>` */
521
- sitesDomain: z4.string().min(1),
522
- siteScheme: z4.enum(["http", "https"]),
678
+ sitesDomain: z6.string().min(1),
679
+ siteScheme: z6.enum(["http", "https"]),
523
680
  auth: InstanceAuthSchema,
524
681
  brand: InstanceBrandSchema.default({ colors: {} }),
525
682
  /** Feature flags: a client hides what is missing or false. */
526
- capabilities: z4.record(z4.string(), z4.boolean()).default({}),
683
+ capabilities: z6.record(z6.string(), z6.boolean()).default({}),
527
684
  /** How to install the CLI, e.g. `npm install -g <package>`. */
528
- installCommand: z4.string().min(1)
685
+ installCommand: z6.string().min(1)
529
686
  });
530
687
  function instanceSitesPattern(instance) {
531
688
  return `${instance.siteScheme}://<name>.${instance.sitesDomain}`;
@@ -549,15 +706,22 @@ function instanceMetadataUrl(serverUrl) {
549
706
  }
550
707
 
551
708
  // ../../packages/shared/src/kvs.ts
552
- import { z as z5 } from "zod";
553
- var KvsSiteValueSchema = z5.object({
709
+ import { z as z7 } from "zod";
710
+
711
+ // ../../packages/shared/src/route-keys.ts
712
+ var SITE_PASS_TTL_SECONDS = 5 * 60;
713
+
714
+ // ../../packages/shared/src/kvs.ts
715
+ var KvsSiteValueSchema = z7.object({
554
716
  /** siteId — S3 prefix is `sites/<id>/<v>/` */
555
- id: z5.string(),
717
+ id: z7.string(),
556
718
  /** current versionId */
557
- v: z5.string(),
558
- vis: z5.enum(["private", "public"]),
719
+ v: z7.string(),
720
+ vis: z7.enum(["private", "public"]),
559
721
  /** serve /index.html for unknown extensionless paths */
560
- spa: z5.boolean()
722
+ spa: z7.boolean(),
723
+ /** present when the site is restricted (private with an audience): the edge wants a site pass */
724
+ r: z7.literal(1).optional()
561
725
  });
562
726
 
563
727
  // ../../packages/shared/src/listing.ts
@@ -577,13 +741,13 @@ function extractHtmlTitle(html) {
577
741
  }
578
742
  function renderHtmlListing(siteName2, entries) {
579
743
  const pages = entries.filter((e) => isHtmlPath(e.path) && e.path.toLowerCase() !== "index.html").sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
580
- const groups = /* @__PURE__ */ new Map();
744
+ const groups2 = /* @__PURE__ */ new Map();
581
745
  for (const page of pages) {
582
746
  const slash = page.path.lastIndexOf("/");
583
747
  const folder = slash === -1 ? "" : page.path.slice(0, slash);
584
- groups.set(folder, [...groups.get(folder) ?? [], page]);
748
+ groups2.set(folder, [...groups2.get(folder) ?? [], page]);
585
749
  }
586
- const sections = [...groups.entries()].sort(([a], [b]) => a === "" ? -1 : b === "" ? 1 : a < b ? -1 : 1).map(([folder, items]) => {
750
+ const sections = [...groups2.entries()].sort(([a], [b]) => a === "" ? -1 : b === "" ? 1 : a < b ? -1 : 1).map(([folder, items]) => {
587
751
  const heading = folder ? `<h2>${escapeHtml(folder)}/</h2>` : "";
588
752
  const rows = items.map((item) => {
589
753
  const file = item.path.slice(item.path.lastIndexOf("/") + 1);
@@ -641,7 +805,19 @@ ${body}
641
805
  }
642
806
 
643
807
  // ../../packages/shared/src/mcp-tools.ts
644
- import { z as z6 } from "zod";
808
+ import { z as z8 } from "zod";
809
+
810
+ // ../../packages/shared/src/site-sort.ts
811
+ var SITE_SORTS = ["updated", "created", "name"];
812
+ var DEFAULT_SITE_SORT = "updated";
813
+ var byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
814
+ function sortSites(sites, sort = DEFAULT_SITE_SORT) {
815
+ const newest = (key) => (a, b) => Date.parse(b[key]) - Date.parse(a[key]) || byName(a, b);
816
+ const compare = sort === "name" ? byName : newest(sort === "created" ? "createdAt" : "updatedAt");
817
+ return [...sites].sort(compare);
818
+ }
819
+
820
+ // ../../packages/shared/src/mcp-tools.ts
645
821
  var MCP_PUBLISH_MAX_BYTES = 4 * 1024 * 1024;
646
822
  var MCP_UPLOAD_CHUNK_MAX_CHARS = 4 * 1024 * 1024;
647
823
  var MCP_UPLOAD_TTL_SECONDS = 60 * 60;
@@ -670,23 +846,28 @@ var INLINE_ONLY = "If you created the content in this conversation, pass it dire
670
846
  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.";
671
847
  var ENCODING = '`encoding` is "utf8" (default, for text: HTML, CSS, JS, SVG, JSON, Markdown\u2026) or "base64" (binary: images, fonts, PDFs).';
672
848
  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.";
673
- var listHtml = z6.boolean().optional().describe(
849
+ var listHtml = z8.boolean().optional().describe(
674
850
  "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"
675
851
  );
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(
852
+ var siteName = z8.string().min(1).max(200).describe('Site name, e.g. "q3-report"');
853
+ var sitePath = z8.string().min(1).max(1024).describe(
678
854
  '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")'
679
855
  );
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(
856
+ var emails = z8.array(z8.string().min(3).max(254)).max(AUDIENCE_EMAILS_MAX);
857
+ var groupSlugs = z8.array(z8.string().min(1).max(100)).max(AUDIENCE_GROUPS_MAX);
858
+ var groupSlug = z8.string().min(1).max(100).describe(`The group's slug, e.g. "design"`);
859
+ var members = z8.array(z8.string().min(3).max(254)).max(GROUP_MEMBERS_MAX);
860
+ var groupName = z8.string().min(1).max(GROUP_NAME_MAX);
861
+ var commentPath = z8.string().max(1024);
862
+ var commentId = z8.string().min(1).max(100);
863
+ var encoding = z8.enum(["utf8", "base64"]).optional().describe('How `content` is encoded: "utf8" (default) for text, "base64" for binary files');
864
+ var spa = z8.boolean().optional().describe(
684
865
  "Single-page app: serve index.html for unknown paths (client-side routing). Omit to keep the current setting"
685
866
  );
686
867
  function mcpToolsFor(w) {
687
868
  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
869
  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(
870
+ const isPublic = z8.boolean().optional().describe(
690
871
  `true \u2192 anyone can view; false \u2192 only ${w.privateAudience}. Omit to keep the current setting (new sites are private)`
691
872
  );
692
873
  return {
@@ -700,9 +881,13 @@ function mcpToolsFor(w) {
700
881
  list: tool({
701
882
  name: "fling_list",
702
883
  title: "List sites",
703
- description: "List the user's Fling sites with URL, visibility and share link.",
884
+ description: "List the user's Fling sites with URL, visibility and share link. With `shared: true`, list instead the restricted sites other people have shared with the user (by address or through a group they're in), with URL, review link and owner.",
704
885
  inputSchema: {
705
- archived: z6.boolean().optional().describe("List archived sites instead of live ones")
886
+ archived: z8.boolean().optional().describe("List archived sites instead of live ones"),
887
+ shared: z8.boolean().optional().describe("List the sites others have shared with the user instead of the user's own"),
888
+ sort: z8.enum(SITE_SORTS).optional().describe(
889
+ "Order: updated (last updated, newest first; the default), created (newest first) or name (A\u2013Z)"
890
+ )
706
891
  },
707
892
  annotations: { readOnlyHint: true, openWorldHint: false }
708
893
  }),
@@ -719,7 +904,7 @@ function mcpToolsFor(w) {
719
904
  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
905
  inputSchema: {
721
906
  name: siteName,
722
- versionId: z6.string().min(1).max(100).optional().describe("Version id (from fling_info); omit for the version being served")
907
+ versionId: z8.string().min(1).max(100).optional().describe("Version id (from fling_info); omit for the version being served")
723
908
  },
724
909
  annotations: { readOnlyHint: true, openWorldHint: false }
725
910
  }),
@@ -727,7 +912,7 @@ function mcpToolsFor(w) {
727
912
  name: "fling_check_name",
728
913
  title: "Check a site name",
729
914
  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") },
915
+ inputSchema: { name: z8.string().min(1).max(200).describe("Site name to check") },
731
916
  annotations: { readOnlyHint: true, openWorldHint: false }
732
917
  }),
733
918
  publish: tool({
@@ -736,10 +921,10 @@ function mcpToolsFor(w) {
736
921
  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
922
  inputSchema: {
738
923
  name: siteName,
739
- files: z6.array(
740
- z6.object({
924
+ files: z8.array(
925
+ z8.object({
741
926
  path: sitePath,
742
- content: z6.string().describe("The file content, encoded as `encoding` says"),
927
+ content: z8.string().describe("The file content, encoded as `encoding` says"),
743
928
  encoding
744
929
  })
745
930
  ).min(1).max(MAX_FILES).describe("Every file of the site"),
@@ -761,11 +946,11 @@ function mcpToolsFor(w) {
761
946
  title: "Upload a file (chunked)",
762
947
  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
948
  inputSchema: {
764
- uploadId: z6.string().min(1).max(100).describe("From fling_upload_begin"),
949
+ uploadId: z8.string().min(1).max(100).describe("From fling_upload_begin"),
765
950
  path: sitePath,
766
- content: z6.string().max(MCP_UPLOAD_CHUNK_MAX_CHARS).describe("The file content (or the next part of it, with `append`)"),
951
+ content: z8.string().max(MCP_UPLOAD_CHUNK_MAX_CHARS).describe("The file content (or the next part of it, with `append`)"),
767
952
  encoding,
768
- append: z6.boolean().optional().describe("Append this content to the file sent earlier with the same path")
953
+ append: z8.boolean().optional().describe("Append this content to the file sent earlier with the same path")
769
954
  },
770
955
  annotations: { destructiveHint: false, openWorldHint: false }
771
956
  }),
@@ -774,7 +959,7 @@ function mcpToolsFor(w) {
774
959
  title: "Finish a chunked upload",
775
960
  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
961
  inputSchema: {
777
- uploadId: z6.string().min(1).max(100).describe("From fling_upload_begin"),
962
+ uploadId: z8.string().min(1).max(100).describe("From fling_upload_begin"),
778
963
  listHtml
779
964
  },
780
965
  annotations: { destructiveHint: false, openWorldHint: true }
@@ -785,25 +970,97 @@ function mcpToolsFor(w) {
785
970
  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
971
  inputSchema: {
787
972
  name: siteName,
788
- refresh: z6.boolean().optional().describe("Rotate the link"),
789
- revoke: z6.boolean().optional().describe("Remove the link")
973
+ refresh: z8.boolean().optional().describe("Rotate the link"),
974
+ revoke: z8.boolean().optional().describe("Remove the link")
790
975
  },
791
976
  annotations: { destructiveHint: false, openWorldHint: true }
792
977
  }),
793
978
  setVisibility: tool({
794
979
  name: "fling_set_visibility",
795
980
  title: "Set visibility",
796
- description: `Make a site private (only ${w.privateAudience}) or public (anyone).`,
797
- inputSchema: { name: siteName, visibility: z6.enum(["private", "public"]) },
981
+ description: `Make a site private (open to ${w.privateAudience}) or public (anyone can open the site; its review page and comments still need sign-in).`,
982
+ inputSchema: { name: siteName, visibility: z8.enum(["private", "public"]) },
798
983
  annotations: { idempotentHint: true, openWorldHint: true }
799
984
  }),
985
+ setAudience: tool({
986
+ name: "fling_set_audience",
987
+ title: "Set who can see a private site",
988
+ description: `Limit a private site to listed people and groups (its audience), or open it again to ${w.privateAudience}. \`add\` and \`remove\` change the addresses and \`set\` replaces them; \`addGroups\`, \`removeGroups\` and \`setGroups\` do the same for groups, by slug (fling_groups lists them); \`clear\` empties both (then every signed-in member can see it again). The owner always sees the site. Each address must be one that can sign in to ${w.name}. The audience only applies while the site is private (fling_set_visibility); share links still open it to anyone. Removing someone takes up to 5 minutes to shut them out. fling_info shows the audience.`,
989
+ inputSchema: {
990
+ name: siteName,
991
+ add: emails.optional().describe("Addresses to let in"),
992
+ remove: emails.optional().describe("Addresses to take out"),
993
+ set: emails.optional().describe("Replace the addresses with exactly these"),
994
+ addGroups: groupSlugs.optional().describe("Groups (slugs) to let in"),
995
+ removeGroups: groupSlugs.optional().describe("Groups (slugs) to take out"),
996
+ setGroups: groupSlugs.optional().describe("Replace the groups with exactly these slugs"),
997
+ clear: z8.boolean().optional().describe("Empty the audience (addresses and groups): every signed-in member sees it")
998
+ },
999
+ annotations: { idempotentHint: true, openWorldHint: false }
1000
+ }),
1001
+ groups: tool({
1002
+ name: "fling_groups",
1003
+ title: "List groups",
1004
+ description: `List the groups on ${w.name}: named lists of people (a team) that any member can use in a site's audience (fling_set_audience \`addGroups\`). Each has a slug, a name, a size and an owner; \`mine\` lists only the user's own. \`members\` lists only the groups of exactly those people: use it before making a group, or to swap a list of addresses in an audience for a group that already holds them.`,
1005
+ inputSchema: {
1006
+ mine: z8.boolean().optional().describe("Only the groups the user owns"),
1007
+ members: members.optional().describe("Only the groups whose members are exactly these addresses")
1008
+ },
1009
+ annotations: { readOnlyHint: true, openWorldHint: false }
1010
+ }),
1011
+ groupShow: tool({
1012
+ name: "fling_group_show",
1013
+ title: "Show a group",
1014
+ description: "Show a group and its members (each marked when they have not signed in yet), and whether the user owns it (only the owner can change it).",
1015
+ inputSchema: { slug: groupSlug },
1016
+ annotations: { readOnlyHint: true, openWorldHint: false }
1017
+ }),
1018
+ groupCreate: tool({
1019
+ name: "fling_group_create",
1020
+ title: "Create a group",
1021
+ description: `Create a group the user owns: a named list of people that anyone signed in to ${w.name} can see and use in a site's audience, but only the user can change. The slug is ${GROUP_SLUG_RULES}, unique on ${w.name}; each member must be an address that can sign in (at most ${GROUP_MEMBERS_MAX}).`,
1022
+ inputSchema: {
1023
+ slug: groupSlug,
1024
+ name: groupName.describe('Its display name, e.g. "Design team"'),
1025
+ members: members.optional().describe("Addresses to put in it")
1026
+ },
1027
+ annotations: { destructiveHint: false, openWorldHint: false }
1028
+ }),
1029
+ groupMembers: tool({
1030
+ name: "fling_group_members",
1031
+ title: "Change who is in a group",
1032
+ description: "Add people to a group the user owns, or take them out. Every site whose audience names the group follows: someone taken out is shut out within 5 minutes.",
1033
+ inputSchema: {
1034
+ slug: groupSlug,
1035
+ add: members.optional().describe("Addresses to add"),
1036
+ remove: members.optional().describe("Addresses to take out")
1037
+ },
1038
+ annotations: { idempotentHint: true, openWorldHint: false }
1039
+ }),
1040
+ groupRename: tool({
1041
+ name: "fling_group_rename",
1042
+ title: "Rename a group",
1043
+ description: "Change the display name of a group the user owns (the slug stays).",
1044
+ inputSchema: { slug: groupSlug, name: groupName.describe("The new display name") },
1045
+ annotations: { idempotentHint: true, openWorldHint: false }
1046
+ }),
1047
+ groupDelete: tool({
1048
+ name: "fling_group_delete",
1049
+ title: "Delete a group",
1050
+ description: "Permanently delete a group the user owns. Sites whose audience names it keep their restriction, and anyone it let in loses access (a site limited to it alone is left to its owner). Cannot be undone \u2014 confirm with the user first and pass `confirm: true`.",
1051
+ inputSchema: {
1052
+ slug: groupSlug,
1053
+ confirm: z8.literal(true).describe("Must be true: the user has confirmed the deletion")
1054
+ },
1055
+ annotations: { destructiveHint: true, openWorldHint: false }
1056
+ }),
800
1057
  archive: tool({
801
1058
  name: "fling_archive",
802
1059
  title: "Archive or restore a site",
803
1060
  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
1061
  inputSchema: {
805
1062
  name: siteName,
806
- unarchive: z6.boolean().optional().describe("Restore an archived site")
1063
+ unarchive: z8.boolean().optional().describe("Restore an archived site")
807
1064
  },
808
1065
  annotations: { idempotentHint: true, openWorldHint: false }
809
1066
  }),
@@ -813,7 +1070,7 @@ function mcpToolsFor(w) {
813
1070
  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
1071
  inputSchema: {
815
1072
  name: siteName,
816
- confirm: z6.literal(true).describe("Must be true: the user has confirmed the deletion")
1073
+ confirm: z8.literal(true).describe("Must be true: the user has confirmed the deletion")
817
1074
  },
818
1075
  annotations: { destructiveHint: true, openWorldHint: false }
819
1076
  }),
@@ -824,7 +1081,7 @@ function mcpToolsFor(w) {
824
1081
  inputSchema: {
825
1082
  name: siteName,
826
1083
  path: commentPath.optional().describe('Only this page, e.g. "/" or "/docs/"'),
827
- includeResolved: z6.boolean().optional().describe("Include resolved threads")
1084
+ includeResolved: z8.boolean().optional().describe("Include resolved threads")
828
1085
  },
829
1086
  annotations: { readOnlyHint: true, openWorldHint: false }
830
1087
  }),
@@ -834,7 +1091,7 @@ function mcpToolsFor(w) {
834
1091
  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
1092
  inputSchema: {
836
1093
  name: siteName,
837
- body: z6.string().min(1).max(COMMENT_MAX_CHARS).describe("The comment (plain text)"),
1094
+ body: z8.string().min(1).max(COMMENT_MAX_CHARS).describe("The comment (plain text)"),
838
1095
  path: commentPath.optional().describe('The page it is about (default "/"); ignored for a reply'),
839
1096
  replyTo: commentId.optional().describe("Reply to this thread instead of starting one")
840
1097
  },
@@ -847,7 +1104,7 @@ function mcpToolsFor(w) {
847
1104
  inputSchema: {
848
1105
  name: siteName,
849
1106
  id: commentId.describe("The thread id"),
850
- reopen: z6.boolean().optional().describe("Reopen a resolved thread")
1107
+ reopen: z8.boolean().optional().describe("Reopen a resolved thread")
851
1108
  },
852
1109
  annotations: { idempotentHint: true, openWorldHint: false }
853
1110
  }),
@@ -864,7 +1121,7 @@ function mcpToolsFor(w) {
864
1121
  description: "Point a site at an earlier version (ids from fling_info). Instant; nothing is uploaded.",
865
1122
  inputSchema: {
866
1123
  name: siteName,
867
- versionId: z6.string().min(1).max(100).describe("Version id to serve")
1124
+ versionId: z8.string().min(1).max(100).describe("Version id to serve")
868
1125
  },
869
1126
  annotations: { idempotentHint: true, openWorldHint: false }
870
1127
  })
@@ -961,94 +1218,109 @@ function readinessWaitDisabled(env) {
961
1218
  }
962
1219
 
963
1220
  // ../../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),
1221
+ import { z as z9 } from "zod";
1222
+ var tag = { fling: z9.literal("review") };
1223
+ var path = z9.string().max(COMMENT_PATH_MAX);
1224
+ var hash = z9.string().max(COMMENT_PATH_MAX);
1225
+ var BridgePinSchema = z9.object({
1226
+ id: z9.string(),
1227
+ n: z9.number().int().min(1),
971
1228
  anchor: CommentAnchorSchema,
972
- selected: z7.boolean(),
973
- resolved: z7.boolean()
1229
+ selected: z9.boolean(),
1230
+ resolved: z9.boolean()
974
1231
  });
975
- var BridgeCursorSchema = z7.object({
976
- id: z7.string(),
977
- name: z7.string().max(100),
978
- colour: z7.string().max(32),
1232
+ var BridgeCursorSchema = z9.object({
1233
+ id: z9.string(),
1234
+ name: z9.string().max(100),
1235
+ colour: z9.string().max(32),
979
1236
  anchor: CommentAnchorSchema
980
1237
  });
981
- var ShellMessageSchema = z7.discriminatedUnion("type", [
982
- z7.object({ ...tag, type: z7.literal("ready") }),
1238
+ var ShellMessageSchema = z9.discriminatedUnion("type", [
1239
+ z9.object({ ...tag, type: z9.literal("ready") }),
983
1240
  /** 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() }),
1241
+ z9.object({ ...tag, type: z9.literal("location"), path, hash, title: z9.string().max(300) }),
1242
+ /** a pin placed while placing: a click (a spot) or a drag (a box) */
1243
+ z9.object({ ...tag, type: z9.literal("pinPlaced"), anchor: CommentAnchorSchema }),
1244
+ /** the reviewer selected text in the page: an anchor on it, with its quote */
1245
+ z9.object({ ...tag, type: z9.literal("textSelected"), anchor: CommentAnchorSchema }),
1246
+ z9.object({ ...tag, type: z9.literal("pinCancelled") }),
1247
+ z9.object({ ...tag, type: z9.literal("pinClicked"), id: z9.string() }),
988
1248
  /** this reviewer's pointer on the page; null when it left the frame */
989
- z7.object({ ...tag, type: z7.literal("cursor"), anchor: CommentAnchorSchema.nullable() }),
1249
+ z9.object({ ...tag, type: z9.literal("cursor"), anchor: CommentAnchorSchema.nullable() }),
990
1250
  /** 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") })
1251
+ z9.object({ ...tag, type: z9.literal("blocked"), href: z9.string().max(2048) }),
1252
+ z9.object({ ...tag, type: z9.literal("escape") })
993
1253
  ]);
994
- var PageMessageSchema = z7.discriminatedUnion("type", [
1254
+ var PageMessageSchema = z9.discriminatedUnion("type", [
995
1255
  /** 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() }),
1256
+ z9.object({ ...tag, type: z9.literal("navigate"), path: z9.string().max(COMMENT_PATH_MAX * 2) }),
1257
+ z9.object({ ...tag, type: z9.literal("reload") }),
1258
+ z9.object({ ...tag, type: z9.literal("placing"), on: z9.boolean() }),
999
1259
  /** the pins on the frame's current page */
1000
- z7.object({ ...tag, type: z7.literal("pins"), pins: z7.array(BridgePinSchema) }),
1260
+ z9.object({ ...tag, type: z9.literal("pins"), pins: z9.array(BridgePinSchema) }),
1001
1261
  /** 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) })
1262
+ z9.object({ ...tag, type: z9.literal("focusPin"), id: z9.string() }),
1263
+ z9.object({ ...tag, type: z9.literal("cursors"), cursors: z9.array(BridgeCursorSchema) })
1004
1264
  ]);
1005
1265
 
1006
1266
  // ../../packages/shared/src/review-live.ts
1007
- import { z as z8 } from "zod";
1267
+ import { z as z10 } from "zod";
1008
1268
  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),
1269
+ var LivePeerSchema = z10.object({
1270
+ id: z10.string(),
1271
+ name: z10.string().max(100),
1272
+ colour: z10.string().max(32),
1013
1273
  /** their profile picture (https), when sign-in gave one; else clients show initials */
1014
- picture: z8.string().max(LIVE_PICTURE_MAX).optional()
1274
+ picture: z10.string().max(LIVE_PICTURE_MAX).optional()
1015
1275
  });
1016
- var LiveTicketSchema = z8.object({
1017
- url: z8.string(),
1018
- ticket: z8.string(),
1276
+ var LiveTicketSchema = z10.object({
1277
+ url: z10.string(),
1278
+ ticket: z10.string(),
1019
1279
  /** seconds the ticket is good for: connect before then */
1020
- expiresIn: z8.number().int()
1280
+ expiresIn: z10.number().int()
1021
1281
  });
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") }),
1282
+ var path2 = z10.string().max(COMMENT_PATH_MAX);
1283
+ var hash2 = z10.string().max(COMMENT_PATH_MAX);
1284
+ var LiveClientMessageSchema = z10.discriminatedUnion("type", [
1285
+ z10.object({ type: z10.literal("hello") }),
1026
1286
  /** where this peer's pointer is; `anchor` null when it left the page */
1027
- z8.object({
1028
- type: z8.literal("cursor"),
1287
+ z10.object({
1288
+ type: z10.literal("cursor"),
1029
1289
  path: path2,
1030
1290
  hash: hash2,
1031
1291
  anchor: CommentAnchorSchema.nullable()
1032
1292
  }),
1033
- z8.object({ type: z8.literal("ping") })
1293
+ z10.object({ type: z10.literal("ping") })
1034
1294
  ]);
1035
- var LiveServerMessageSchema = z8.discriminatedUnion("type", [
1295
+ var LiveServerMessageSchema = z10.discriminatedUnion("type", [
1036
1296
  /** 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(),
1297
+ z10.object({ type: z10.literal("welcome"), you: LivePeerSchema, peers: z10.array(LivePeerSchema) }),
1298
+ z10.object({ type: z10.literal("join"), peer: LivePeerSchema }),
1299
+ z10.object({ type: z10.literal("leave"), peerId: z10.string() }),
1300
+ z10.object({
1301
+ type: z10.literal("cursor"),
1302
+ peerId: z10.string(),
1043
1303
  path: path2,
1044
1304
  hash: hash2,
1045
1305
  anchor: CommentAnchorSchema.nullable()
1046
1306
  }),
1047
1307
  /** the site's comments changed: refetch them */
1048
- z8.object({ type: z8.literal("comments") }),
1049
- z8.object({ type: z8.literal("pong") })
1308
+ z10.object({ type: z10.literal("comments") }),
1309
+ z10.object({ type: z10.literal("pong") })
1050
1310
  ]);
1051
1311
 
1312
+ // src/cli.ts
1313
+ import chalk11 from "chalk";
1314
+ import { Command, Option } from "commander";
1315
+
1316
+ // src/commands/auth.ts
1317
+ import chalk5 from "chalk";
1318
+
1319
+ // src/auth.ts
1320
+ import { randomBytes } from "node:crypto";
1321
+ import { createServer } from "node:http";
1322
+ import { hostname as osHostname } from "node:os";
1323
+
1052
1324
  // src/errors.ts
1053
1325
  var EXIT_ERROR = 1;
1054
1326
  var EXIT_NOT_LOGGED_IN = 2;
@@ -1088,7 +1360,7 @@ function exitCodeFor(error) {
1088
1360
  // package.json
1089
1361
  var package_default = {
1090
1362
  name: "@nearform/fling",
1091
- version: "0.5.0",
1363
+ version: "0.6.0",
1092
1364
  license: "MIT",
1093
1365
  description: "Fling a folder of static files at a Fling server and get a link back. CLI + MCP server.",
1094
1366
  type: "module",
@@ -1299,7 +1571,7 @@ function describeCause(cause) {
1299
1571
  import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
1300
1572
  import { homedir } from "node:os";
1301
1573
  import { join } from "node:path";
1302
- import { z as z9 } from "zod";
1574
+ import { z as z11 } from "zod";
1303
1575
  var CONFIG_DIR_NAME = ".fling";
1304
1576
  var CONFIG_FILE_NAME = "config.json";
1305
1577
  var ENV = {
@@ -1312,14 +1584,14 @@ var ENV = {
1312
1584
  /** A server the local MCP server adds to the remembered ones on start (the .mcpb bakes it in). */
1313
1585
  addServer: "FLING_ADD_SERVER"
1314
1586
  };
1315
- var serverEntrySchema = z9.object({
1587
+ var serverEntrySchema = z11.object({
1316
1588
  /** What the server said about itself at login. A copy this CLI can't read is just refetched. */
1317
1589
  instance: InstanceMetadataSchema.optional().catch(void 0),
1318
- apiKey: z9.string().min(1).optional().catch(void 0),
1319
- user: z9.object({
1320
- email: z9.string().optional(),
1321
- name: z9.string().optional(),
1322
- userSlug: z9.string().optional()
1590
+ apiKey: z11.string().min(1).optional().catch(void 0),
1591
+ user: z11.object({
1592
+ email: z11.string().optional(),
1593
+ name: z11.string().optional(),
1594
+ userSlug: z11.string().optional()
1323
1595
  }).optional().catch(void 0)
1324
1596
  });
1325
1597
  function systemLocateOptions(env = process.env) {
@@ -1937,8 +2209,8 @@ function requireClient(ctx) {
1937
2209
  }
1938
2210
 
1939
2211
  // src/sites.ts
1940
- import { z as z10 } from "zod";
1941
- var ShareResponseSchema2 = z10.looseObject({ shareUrl: z10.string() });
2212
+ import { z as z12 } from "zod";
2213
+ var ShareResponseSchema2 = z12.looseObject({ shareUrl: z12.string() });
1942
2214
  var sitePath2 = (name) => `/api/sites/${encodeURIComponent(name)}`;
1943
2215
  function whoami(client) {
1944
2216
  return client.request("GET", "/api/me", { schema: MeSchema });
@@ -1962,7 +2234,11 @@ async function listSites(client, opts = {}) {
1962
2234
  schema: SiteListSchema,
1963
2235
  query: { archived: opts.archived ? 1 : void 0 }
1964
2236
  });
1965
- return res.sites;
2237
+ return sortSites(res.sites, opts.sort);
2238
+ }
2239
+ async function listShared(client, opts = {}) {
2240
+ const res = await client.request("GET", "/api/shared", { schema: SharedSiteListSchema });
2241
+ return sortSites(res.sites, opts.sort);
1966
2242
  }
1967
2243
  function getSite(client, name) {
1968
2244
  return client.request("GET", sitePath2(name), { schema: SiteDetailSchema });
@@ -1993,6 +2269,74 @@ async function setVisibility(client, name, visibility) {
1993
2269
  const raw = await client.request("PATCH", sitePath2(name), { body: { visibility } });
1994
2270
  return siteAfter(client, name, raw);
1995
2271
  }
2272
+ async function getAudience(client, name) {
2273
+ const site = await getSite(client, name);
2274
+ if (!site.audience) throw noAudiences();
2275
+ return site;
2276
+ }
2277
+ var noAudiences = () => new CliError("This Fling does not support limiting a site to listed people", {
2278
+ hint: "Ask its operator to update it."
2279
+ });
2280
+ async function setAudience(client, name, change) {
2281
+ const current = await getAudience(client, name);
2282
+ const audience = current.audience;
2283
+ const site = await client.request("PATCH", sitePath2(name), {
2284
+ schema: SiteWithAudienceSchema,
2285
+ body: { audience: editAudienceInput(audience, change) }
2286
+ });
2287
+ if (!site.audience) throw noAudiences();
2288
+ return site;
2289
+ }
2290
+ function audienceWarnings(site) {
2291
+ const n = site.audience ? site.audience.emails.length + site.audience.groups.length : site.audienceSize;
2292
+ if (site.visibility === "public" && n > 0) {
2293
+ return [
2294
+ `${site.name} is public, so its audience doesn't apply yet; it does once the site is private: fling visibility ${site.name} private`
2295
+ ];
2296
+ }
2297
+ if (site.restricted && site.shareUrl) {
2298
+ return [`${site.name} has a share link: anyone with it can still open the site`];
2299
+ }
2300
+ return [];
2301
+ }
2302
+ function audiencesEnabled(instance) {
2303
+ if (!instance) return false;
2304
+ return instance.capabilities.audiences ?? true;
2305
+ }
2306
+ var groupPath = (slug) => `/api/groups/${encodeURIComponent(slug)}`;
2307
+ async function listGroups(client, opts = {}) {
2308
+ const res = await client.request("GET", "/api/groups", {
2309
+ schema: GroupListSchema,
2310
+ query: {
2311
+ mine: opts.mine ? 1 : void 0,
2312
+ members: opts.members ? splitEmails(opts.members).join(",") : void 0
2313
+ }
2314
+ });
2315
+ return res.groups;
2316
+ }
2317
+ function getGroup(client, slug) {
2318
+ return client.request("GET", groupPath(slug), { schema: GroupDetailSchema });
2319
+ }
2320
+ function createGroup(client, input) {
2321
+ return client.request("POST", "/api/groups", { schema: GroupDetailSchema, body: input });
2322
+ }
2323
+ function changeGroupMembers(client, slug, change) {
2324
+ return client.request("POST", `${groupPath(slug)}/members`, {
2325
+ schema: GroupDetailSchema,
2326
+ body: change
2327
+ });
2328
+ }
2329
+ function renameGroup(client, slug, name) {
2330
+ return client.request("PATCH", groupPath(slug), { schema: GroupDetailSchema, body: { name } });
2331
+ }
2332
+ async function deleteGroup(client, slug) {
2333
+ await client.request("DELETE", groupPath(slug));
2334
+ return { slug, deleted: true };
2335
+ }
2336
+ function groupsEnabled(instance) {
2337
+ if (!instance) return false;
2338
+ return instance.capabilities.groups ?? true;
2339
+ }
1996
2340
  async function archiveSite(client, name, opts = {}) {
1997
2341
  const raw = await client.request(
1998
2342
  "POST",
@@ -2212,6 +2556,7 @@ async function runComments(ctx, name, opts) {
2212
2556
  }
2213
2557
  const state = c.resolvedAt ? chalk6.green(" \u2713 resolved") : "";
2214
2558
  lines.push(`${chalk6.dim(c.id)}${state} ${describe(c, list.site.currentVersionId)}`);
2559
+ if (c.anchor?.quote) lines.push(chalk6.dim(` > ${c.anchor.quote}`));
2215
2560
  lines.push(...c.body.split("\n").map((l) => ` ${l}`));
2216
2561
  for (const r of replies) {
2217
2562
  lines.push(` ${chalk6.dim("\u21B3")} ${describe(r, list.site.currentVersionId)}`);
@@ -3111,7 +3456,7 @@ async function deployPrepared(ctx, client, instance, base, prepared, opts, ask)
3111
3456
  local.files.map((f) => f.path),
3112
3457
  { progress: ctx.progress, warn: (m) => ctx.log.warn(m) }
3113
3458
  )).ready;
3114
- const who = instance ? `only ${privateAudience(instance)} can open it` : "sign-in required";
3459
+ const who = instance ? `${privateAudience(instance)} can open it` : "sign-in required";
3115
3460
  const privateNote = `Private: ${who}. Share publicly with \`${ctx.binName} share ${result.name}\`.`;
3116
3461
  const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
3117
3462
  const reviewNote = instance ? `Share for feedback (reviewers sign in to ${instance.name}):` : "Share for feedback:";
@@ -3194,9 +3539,76 @@ async function confirmSiteName(ctx, client, generated, base) {
3194
3539
  }
3195
3540
  }
3196
3541
 
3542
+ // src/commands/groups.ts
3543
+ import chalk8 from "chalk";
3544
+ function requireGroups(ctx) {
3545
+ const instance = ctx.config.instance;
3546
+ if (instance && !groupsEnabled(instance)) throw new CliError(`${instance.name} has no groups`);
3547
+ return requireClient(ctx);
3548
+ }
3549
+ async function runGroups(ctx, opts) {
3550
+ const client = requireGroups(ctx);
3551
+ const members2 = opts.members ? splitEmails(opts.members) : void 0;
3552
+ if (members2 && members2.length === 0) throw new CliError("Name at least one address");
3553
+ const [groups2, me] = await Promise.all([
3554
+ listGroups(client, { mine: opts.mine, members: members2 }),
3555
+ whoami(client)
3556
+ ]);
3557
+ ctx.out.data(
3558
+ { groups: groups2 },
3559
+ () => groups2.length ? renderTable(groups2, [
3560
+ { header: "SLUG", value: (g) => g.slug },
3561
+ { header: "NAME", value: (g) => g.name },
3562
+ { header: "PEOPLE", value: (g) => g.size },
3563
+ { header: "OWNER", value: (g) => g.owner === me.email ? "you" : g.owner }
3564
+ ]) : chalk8.dim(members2 ? "No group has exactly those people." : describeGroups([]))
3565
+ );
3566
+ }
3567
+ async function runGroupShow(ctx, slug) {
3568
+ const group = await getGroup(requireGroups(ctx), slug);
3569
+ ctx.out.data(group, () => describeGroup(group));
3570
+ }
3571
+ async function runGroupCreate(ctx, slug, opts) {
3572
+ const group = await createGroup(requireGroups(ctx), {
3573
+ slug,
3574
+ name: opts.name ?? slug,
3575
+ members: splitEmails(opts.add ?? [])
3576
+ });
3577
+ ctx.log.success(`Group ${group.slug} created`);
3578
+ ctx.out.data(group, () => describeGroup(group));
3579
+ }
3580
+ async function runGroupMembers(ctx, slug, emails2, how) {
3581
+ const list = splitEmails(emails2);
3582
+ if (list.length === 0) throw new CliError("Name at least one address");
3583
+ const group = await changeGroupMembers(requireGroups(ctx), slug, { [how]: list });
3584
+ ctx.out.data(group, () => describeGroup(group));
3585
+ }
3586
+ async function runGroupRename(ctx, slug, name) {
3587
+ const group = await renameGroup(requireGroups(ctx), slug, name);
3588
+ ctx.log.success(`Group ${group.slug} is now called ${group.name}`);
3589
+ ctx.out.data(group, () => describeGroup(group));
3590
+ }
3591
+ async function runGroupDelete(ctx, slug, opts) {
3592
+ const client = requireGroups(ctx);
3593
+ if (!opts.yes) {
3594
+ if (!ctx.interactive) {
3595
+ throw new CliError(`Refusing to delete group ${slug} without confirmation`, {
3596
+ hint: "Pass --yes to delete."
3597
+ });
3598
+ }
3599
+ const ok = await ctx.confirm(
3600
+ `Delete group ${slug}? Sites whose audience names it keep their restriction, and anyone it let in loses access. This cannot be undone.`
3601
+ );
3602
+ if (!ok) throw new CliError("Cancelled");
3603
+ }
3604
+ const result = await deleteGroup(client, slug);
3605
+ ctx.log.success(`Group ${slug} deleted`);
3606
+ if (ctx.json) ctx.out.data(result, () => "");
3607
+ }
3608
+
3197
3609
  // src/commands/setup.ts
3198
3610
  import { intro, outro, spinner } from "@clack/prompts";
3199
- import chalk8 from "chalk";
3611
+ import chalk9 from "chalk";
3200
3612
 
3201
3613
  // src/claude.ts
3202
3614
  import { execFile as execFile2 } from "node:child_process";
@@ -3289,14 +3701,14 @@ async function runSetup(baseCtx, opts, deps = {}) {
3289
3701
  ...baseCtx,
3290
3702
  log: createLogger({
3291
3703
  debug: baseCtx.config.debug,
3292
- write: (line) => process.stderr.write(`${chalk8.gray("\u2502")} ${line}
3704
+ write: (line) => process.stderr.write(`${chalk9.gray("\u2502")} ${line}
3293
3705
  `)
3294
3706
  })
3295
3707
  } : baseCtx;
3296
3708
  if (fancy) {
3297
3709
  process.stderr.write(`${logo()}
3298
3710
  `);
3299
- intro(chalk8.bold(" fling setup "), { output: process.stderr });
3711
+ intro(chalk9.bold(" fling setup "), { output: process.stderr });
3300
3712
  }
3301
3713
  const login = await stepLogin(ctx, opts.url);
3302
3714
  const results = {
@@ -3310,7 +3722,7 @@ async function runSetup(baseCtx, opts, deps = {}) {
3310
3722
  const failed = Object.values(results).includes("failed");
3311
3723
  if (fancy)
3312
3724
  outro(
3313
- 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.`,
3725
+ failed ? chalk9.yellow("Setup finished with problems \u2014 see above, then run `fling setup` again.") : `You're set. Try ${chalk9.bold(`${ctx.binName} deploy ./dist`)}, or ask Claude to \u201Cfling this\u201D.`,
3314
3726
  { output: process.stderr }
3315
3727
  );
3316
3728
  ctx.out.data(results, () => "");
@@ -3333,7 +3745,7 @@ async function signIn(ctx, serverUrl) {
3333
3745
  try {
3334
3746
  const me = await whoami(requireClient(ctx));
3335
3747
  if (ctx.config.remembered) await ctx.store.setCurrent(serverUrl);
3336
- ctx.log.success(`Signed in as ${chalk8.bold(me.name)} ${chalk8.dim(`<${me.email}>`)}`);
3748
+ ctx.log.success(`Signed in as ${chalk9.bold(me.name)} ${chalk9.dim(`<${me.email}>`)}`);
3337
3749
  return "already";
3338
3750
  } catch (error) {
3339
3751
  if (exitCodeFor(error) !== 2) {
@@ -3354,7 +3766,7 @@ async function signIn(ctx, serverUrl) {
3354
3766
  async function stepClaudeCode(ctx, run, scope, ask, fancy) {
3355
3767
  const status = await claudeCodeStatus(run);
3356
3768
  if (status.claude === "missing") {
3357
- ctx.log.info(`Claude Code not found ${chalk8.dim("(no `claude` on your PATH) \u2014 skipped")}`);
3769
+ ctx.log.info(`Claude Code not found ${chalk9.dim("(no `claude` on your PATH) \u2014 skipped")}`);
3358
3770
  ctx.log.hint(
3359
3771
  `Once it is installed, run \`${ctx.binName} setup\` again, or in Claude Code: /plugin marketplace add ${MARKETPLACE_SOURCE} then /plugin install ${PLUGIN_ID}`
3360
3772
  );
@@ -3363,7 +3775,7 @@ async function stepClaudeCode(ctx, run, scope, ask, fancy) {
3363
3775
  const installed = "installed" in status && status.installed;
3364
3776
  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?";
3365
3777
  if (ask && !await ctx.confirm(question)) {
3366
- ctx.log.info(chalk8.dim("Claude Code plugin: skipped"));
3778
+ ctx.log.info(chalk9.dim("Claude Code plugin: skipped"));
3367
3779
  return installed ? "already" : "skipped";
3368
3780
  }
3369
3781
  const s = fancy ? spinner({ output: process.stderr }) : null;
@@ -3377,7 +3789,7 @@ async function stepClaudeCode(ctx, run, scope, ask, fancy) {
3377
3789
  else ctx.log.error(message);
3378
3790
  return "failed";
3379
3791
  }
3380
- const done = installed ? `Claude Code plugin ${PLUGIN_NAME} is up to date` : `Claude Code plugin ${PLUGIN_NAME} installed ${chalk8.dim(`(${scope} scope)`)}`;
3792
+ const done = installed ? `Claude Code plugin ${PLUGIN_NAME} is up to date` : `Claude Code plugin ${PLUGIN_NAME} installed ${chalk9.dim(`(${scope} scope)`)}`;
3381
3793
  if (s) s.stop(done);
3382
3794
  else ctx.log.success(done);
3383
3795
  ctx.log.hint("Restart Claude Code (or run /reload-plugins) to pick it up.");
@@ -3387,7 +3799,7 @@ async function stepClaudeDesktop(ctx, deps, ask, fancy) {
3387
3799
  const app = await (deps.findClaudeDesktop ?? findClaudeDesktop)();
3388
3800
  if (!app) return "missing";
3389
3801
  if (ask && !await ctx.confirm("Add the Fling extension to Claude Desktop too?")) {
3390
- ctx.log.info(chalk8.dim("Claude Desktop extension: skipped"));
3802
+ ctx.log.info(chalk9.dim("Claude Desktop extension: skipped"));
3391
3803
  return "skipped";
3392
3804
  }
3393
3805
  const s = fancy ? spinner({ output: process.stderr }) : null;
@@ -3408,7 +3820,7 @@ async function stepClaudeDesktop(ctx, deps, ask, fancy) {
3408
3820
  }
3409
3821
 
3410
3822
  // src/commands/sites.ts
3411
- import chalk9 from "chalk";
3823
+ import chalk10 from "chalk";
3412
3824
  async function runList(ctx, opts) {
3413
3825
  const sites = await listSites(requireClient(ctx), opts);
3414
3826
  ctx.out.data(
@@ -3425,12 +3837,29 @@ async function runList(ctx, opts) {
3425
3837
  ])
3426
3838
  );
3427
3839
  }
3840
+ async function runListShared(ctx, opts) {
3841
+ const instance = ctx.config.instance;
3842
+ if (instance && !audiencesEnabled(instance)) {
3843
+ throw new CliError(`${instance.name} can't limit a site to listed people, so nothing is shared`);
3844
+ }
3845
+ const sites = await listShared(requireClient(ctx), opts);
3846
+ ctx.out.data(
3847
+ { sites },
3848
+ () => renderTable(sites, [
3849
+ { header: "NAME", value: (s) => s.name },
3850
+ { header: "URL", value: (s) => s.url },
3851
+ { header: "OWNER", value: (s) => s.owner },
3852
+ { header: "VIA", value: (s) => sharedVia(s) },
3853
+ { header: "UPDATED", value: (s) => formatDate(s.updatedAt) }
3854
+ ])
3855
+ );
3856
+ }
3428
3857
  function describeSite(site) {
3429
- const row = (label, value) => `${chalk9.bold(`${label}:`.padEnd(12))}${value}`;
3858
+ const row = (label, value) => `${chalk10.bold(`${label}:`.padEnd(12))}${value}`;
3430
3859
  return [
3431
3860
  row("Name", site.name),
3432
3861
  row("URL", site.url),
3433
- row("Visibility", site.visibility + (site.archived ? chalk9.yellow(" (archived)") : "")),
3862
+ row("Visibility", site.visibility + (site.archived ? chalk10.yellow(" (archived)") : "")),
3434
3863
  row("SPA", site.spa ? "yes" : "no"),
3435
3864
  row("Share link", site.shareUrl ?? "-"),
3436
3865
  row("Version", site.currentVersionId ?? "-"),
@@ -3445,14 +3874,17 @@ function filesRow(site, row) {
3445
3874
  const n = current.fileCount;
3446
3875
  return [row("Files", `${n} file${n === 1 ? "" : "s"}, ${formatBytes(current.totalBytes)}`)];
3447
3876
  }
3877
+ var membersOf = (ctx) => ctx.config.instance ? privateAudience(ctx.config.instance) : "anyone signed in to this Fling";
3448
3878
  async function runInfo(ctx, name) {
3449
3879
  const site = await getSite(requireClient(ctx), name);
3880
+ const access2 = site.audience && audiencesEnabled(ctx.config.instance) ? [`${chalk10.bold("Access:".padEnd(12))}${describeAudience(site, membersOf(ctx))}`] : [];
3450
3881
  ctx.out.data(
3451
3882
  site,
3452
3883
  () => [
3453
3884
  ...describeSite(site),
3885
+ ...access2,
3454
3886
  "",
3455
- chalk9.dim(
3887
+ chalk10.dim(
3456
3888
  `${site.versions.length} version(s) \u2014 \`fling versions ${name}\`; files \u2014 \`fling files ${name}\``
3457
3889
  )
3458
3890
  ].join("\n")
@@ -3478,7 +3910,7 @@ async function runFiles(ctx, name, versionId) {
3478
3910
  }
3479
3911
  ctx.out.data(result, () => {
3480
3912
  const n = result.files.length;
3481
- const header = chalk9.dim(
3913
+ const header = chalk10.dim(
3482
3914
  `${result.versionId}${result.current ? " (current)" : " (not served)"} \u2014 ${n} file${n === 1 ? "" : "s"}, ${formatBytes(result.totalBytes)}${result.generatedIndex ? ", generated index.html" : ""}`
3483
3915
  );
3484
3916
  const table = renderTable(result.files, [
@@ -3514,7 +3946,7 @@ async function runShare(ctx, name, opts) {
3514
3946
  ctx.log.success("Share link created \u2014 anyone with it can view the site");
3515
3947
  ctx.out.data(
3516
3948
  ready === void 0 ? result : { ...result, ready },
3517
- () => result.shareUrl ? result.shareUrl : chalk9.dim("(no share link)")
3949
+ () => result.shareUrl ? result.shareUrl : chalk10.dim("(no share link)")
3518
3950
  );
3519
3951
  }
3520
3952
  async function runVisibility(ctx, name, visibility) {
@@ -3527,6 +3959,27 @@ async function runVisibility(ctx, name, visibility) {
3527
3959
  );
3528
3960
  ctx.out.data(site, () => site.url);
3529
3961
  }
3962
+ async function runAccess(ctx, name, opts) {
3963
+ const instance = ctx.config.instance;
3964
+ if (instance && !audiencesEnabled(instance)) {
3965
+ throw new CliError(`${instance.name} can't limit a site to listed people`);
3966
+ }
3967
+ const change = {
3968
+ add: splitEmails(opts.add ?? []),
3969
+ remove: splitEmails(opts.remove ?? []),
3970
+ addGroups: splitGroups(opts.group ?? []),
3971
+ removeGroups: splitGroups(opts.removeGroup ?? []),
3972
+ clear: opts.clear
3973
+ };
3974
+ if (instance && (change.addGroups.length || change.removeGroups.length)) {
3975
+ if (!groupsEnabled(instance)) throw new CliError(`${instance.name} has no groups`);
3976
+ }
3977
+ const client = requireClient(ctx);
3978
+ const site = changesAudience(change) ? await setAudience(client, name, change) : await getAudience(client, name);
3979
+ const warnings = audienceWarnings(site);
3980
+ for (const w of warnings) ctx.log.warn(w);
3981
+ ctx.out.data({ ...site, warnings }, () => describeAudience(site, membersOf(ctx)));
3982
+ }
3530
3983
  async function runArchive(ctx, name, unarchive = false) {
3531
3984
  const site = await archiveSite(requireClient(ctx), name, { unarchive });
3532
3985
  ctx.log.success(
@@ -3556,7 +4009,7 @@ import { tmpdir as tmpdir3 } from "node:os";
3556
4009
  import { basename as basename3, dirname as dirname2, join as join6 } from "node:path";
3557
4010
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3558
4011
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3559
- import { z as z11 } from "zod";
4012
+ import { z as z13 } from "zod";
3560
4013
 
3561
4014
  // src/locate.ts
3562
4015
  import { lstat, readdir as readdir2, readFile as readFile6, realpath as realpath3, stat as stat5 } from "node:fs/promises";
@@ -3978,7 +4431,7 @@ var mcpLocalInstructions = (w, opts = {}) => [
3978
4431
  "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.",
3979
4432
  NEVER_PUBLISHED,
3980
4433
  "Not logged in? Call fling_login, let the user finish signing in in the browser, then retry.",
3981
- `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."}`,
4434
+ `Sites are private (open to ${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."}`,
3982
4435
  ...opts.comments ? [COMMENTS_NOTE(w)] : []
3983
4436
  ].join("\n\n");
3984
4437
  var onFling = (url, instance) => `
@@ -3993,7 +4446,7 @@ function createMcpServer(deps = {}) {
3993
4446
  const several = servers.length > 1;
3994
4447
  const wording = mcpWording(several ? null : servers[0]?.instance);
3995
4448
  const mcpTools2 = mcpToolsFor(wording);
3996
- const serverArg = z11.string().min(1).max(500).optional().describe(serverArgDescription(servers));
4449
+ const serverArg = z13.string().min(1).max(500).optional().describe(serverArgDescription(servers));
3997
4450
  const note = serverToolNote(servers);
3998
4451
  const serverTool = (def, description = def.description) => ({
3999
4452
  title: def.title,
@@ -4130,12 +4583,12 @@ To check exactly which files the site has, call fling_files with name "${result.
4130
4583
  "fling_deploy",
4131
4584
  {
4132
4585
  title: "Deploy a file or folder from this computer",
4133
- 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}${note}`,
4586
+ 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 (open to ${wording.privateAudience}) unless \`public\` is set. ${NEVER_PUBLISHED}${note}`,
4134
4587
  inputSchema: {
4135
- path: z11.string().min(1).describe(
4588
+ path: z13.string().min(1).describe(
4136
4589
  "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)"
4137
4590
  ),
4138
- name: z11.string().optional().describe(
4591
+ name: z13.string().optional().describe(
4139
4592
  "Site name (lowercase letters, digits, single hyphens; max 50). Defaults to a name generated from the file or folder"
4140
4593
  ),
4141
4594
  spa: spa2,
@@ -4185,9 +4638,9 @@ To check exactly which files the site has, call fling_files with name "${result.
4185
4638
  title: "Find files to fling",
4186
4639
  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.",
4187
4640
  inputSchema: {
4188
- query: z11.string().optional().describe('Only names containing this text (case-insensitive), e.g. "deck"'),
4189
- limit: z11.number().int().min(1).max(100).optional().describe("Max results (default 20)"),
4190
- days: z11.number().int().min(1).max(3650).optional().describe("Only things modified in the last N days (default 14)")
4641
+ query: z13.string().optional().describe('Only names containing this text (case-insensitive), e.g. "deck"'),
4642
+ limit: z13.number().int().min(1).max(100).optional().describe("Max results (default 20)"),
4643
+ days: z13.number().int().min(1).max(3650).optional().describe("Only things modified in the last N days (default 14)")
4191
4644
  },
4192
4645
  annotations: { readOnlyHint: true, openWorldHint: false }
4193
4646
  },
@@ -4248,8 +4701,8 @@ ${JSON.stringify(data, null, 2)}`);
4248
4701
  ...serverTool(mcpTools2.checkName),
4249
4702
  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.${note}`,
4250
4703
  inputSchema: {
4251
- name: z11.string().min(1).max(200).optional().describe("Site name to check"),
4252
- path: z11.string().optional().describe("File or folder whose generated default name to check (path or file name)"),
4704
+ name: z13.string().min(1).max(200).optional().describe("Site name to check"),
4705
+ path: z13.string().optional().describe("File or folder whose generated default name to check (path or file name)"),
4253
4706
  server: serverArg
4254
4707
  }
4255
4708
  },
@@ -4290,7 +4743,7 @@ ${JSON.stringify(data, null, 2)}`);
4290
4743
  title: "Sign in to Fling",
4291
4744
  description: `Sign the user in to ${several ? "a Fling" : 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. Pass \`server\` (its address) to sign in to another Fling, including one not set up on this computer yet: it is remembered, and becomes the current one only when none is.` + note,
4292
4745
  inputSchema: {
4293
- server: z11.string().min(1).max(500).optional().describe(
4746
+ server: z13.string().min(1).max(500).optional().describe(
4294
4747
  `The Fling to sign in to: its address (e.g. "https://fling.example.com"), or the host of one set up on this computer. ${several ? `Required while several Flings are set up (${servers.map((s) => serverLabel(s.url)).join(", ")})` : `Omit it to use ${servers[0] ? serverLabel(servers[0].url) : "the one this computer knows"}`}`
4295
4748
  )
4296
4749
  },
@@ -4374,7 +4827,7 @@ ${known.join("\n")}` : ""
4374
4827
  title: "List the Flings on this computer",
4375
4828
  description: `List the Flings set up on this computer, read now: each one's address, name, who is signed in (or "signed out") and which is current, the user's usual one. Call it when unsure which Fling the user means or which exist; the list may have changed since this conversation started. Pass \`use\` to make one the current Fling, the same as running \`${NPX_COMMAND} use <server>\`: it changes the default of the fling CLI too, so only do it when the user asks. It does not replace passing \`server\` to the other tools while several Flings are set up.`,
4376
4829
  inputSchema: {
4377
- use: z11.string().min(1).max(500).optional().describe(
4830
+ use: z13.string().min(1).max(500).optional().describe(
4378
4831
  "Make this Fling current (address, host or unique part of it), like `fling use`"
4379
4832
  )
4380
4833
  },
@@ -4418,17 +4871,39 @@ ${JSON.stringify(data, null, 2)}`);
4418
4871
  server.registerTool(
4419
4872
  t.list.name,
4420
4873
  serverTool(t.list),
4421
- (args) => run(t.list.name, args.server, async ({ client, url }) => {
4422
- const sites = await listSites(client, { archived: args.archived });
4874
+ (args) => run(t.list.name, args.server, async (target2) => {
4875
+ const { client, url } = target2;
4876
+ if (args.shared) {
4877
+ const instance = await target2.instance();
4878
+ if (instance && !audiencesEnabled(instance)) {
4879
+ throw new CliError(
4880
+ `${instance.name} (${serverLabel(url)}) can't limit a site to listed people, so nothing is shared`
4881
+ );
4882
+ }
4883
+ const sites2 = await listShared(client, { sort: args.sort });
4884
+ const lines = sites2.map(describeSharedSite).join("\n");
4885
+ return {
4886
+ summary: `${sites2.length} site(s) shared with you on ${serverLabel(url)}${lines ? `:
4887
+ ${lines}` : ""}`,
4888
+ data: { sites: sites2 }
4889
+ };
4890
+ }
4891
+ const sites = await listSites(client, { archived: args.archived, sort: args.sort });
4423
4892
  return { summary: `${sites.length} site(s) on ${serverLabel(url)}`, data: { sites } };
4424
4893
  })
4425
4894
  );
4895
+ const membersOn = async (target2) => {
4896
+ const instance = await target2.instance();
4897
+ return instance ? privateAudience(instance) : "anyone signed in to this Fling";
4898
+ };
4426
4899
  server.registerTool(
4427
4900
  t.info.name,
4428
4901
  serverTool(t.info),
4429
- (args) => run(t.info.name, args.server, async ({ client }) => {
4430
- const site = await getSite(client, args.name);
4431
- return { summary: `${site.name}: ${site.url}`, data: site };
4902
+ (args) => run(t.info.name, args.server, async (target2) => {
4903
+ const site = await getSite(target2.client, args.name);
4904
+ const access2 = site.audience ? `
4905
+ ${describeAudience(site, await membersOn(target2))}` : "";
4906
+ return { summary: `${site.name}: ${site.url}${access2}`, data: site };
4432
4907
  })
4433
4908
  );
4434
4909
  server.registerTool(
@@ -4472,6 +4947,126 @@ ${JSON.stringify(data, null, 2)}`);
4472
4947
  return { summary: `${site.name} is now ${site.visibility}: ${site.url}`, data: site };
4473
4948
  })
4474
4949
  );
4950
+ if (servers.some((s) => audiencesEnabled(s.instance))) {
4951
+ server.registerTool(
4952
+ t.setAudience.name,
4953
+ serverTool(t.setAudience),
4954
+ (args) => run(t.setAudience.name, args.server, async (target2) => {
4955
+ const instance = await target2.instance();
4956
+ if (instance && !audiencesEnabled(instance)) {
4957
+ throw new CliError(
4958
+ `${instance.name} (${serverLabel(target2.url)}) can't limit a site to listed people`
4959
+ );
4960
+ }
4961
+ const change = {
4962
+ add: args.add,
4963
+ remove: args.remove,
4964
+ set: args.set,
4965
+ addGroups: args.addGroups,
4966
+ removeGroups: args.removeGroups,
4967
+ setGroups: args.setGroups,
4968
+ clear: args.clear
4969
+ };
4970
+ if (!changesAudience(change)) {
4971
+ throw new CliError(
4972
+ "Pass `add`, `remove`, `set`, `addGroups`, `removeGroups`, `setGroups` or `clear`"
4973
+ );
4974
+ }
4975
+ const site = await setAudience(target2.client, args.name, change);
4976
+ return {
4977
+ summary: `${site.name}: ${describeAudience(site, await membersOn(target2))}${onFling(target2.url, instance)}`,
4978
+ data: site
4979
+ };
4980
+ })
4981
+ );
4982
+ }
4983
+ if (servers.some((s) => groupsEnabled(s.instance))) {
4984
+ const withGroups = async (target2) => {
4985
+ const instance = await target2.instance();
4986
+ if (instance && !groupsEnabled(instance)) {
4987
+ throw new CliError(`${instance.name} (${serverLabel(target2.url)}) has no groups`);
4988
+ }
4989
+ return instance;
4990
+ };
4991
+ server.registerTool(
4992
+ t.groups.name,
4993
+ serverTool(t.groups),
4994
+ (args) => run(t.groups.name, args.server, async (target2) => {
4995
+ const instance = await withGroups(target2);
4996
+ const [groups2, me] = await Promise.all([
4997
+ listGroups(target2.client, { mine: args.mine, members: args.members }),
4998
+ whoami(target2.client)
4999
+ ]);
5000
+ const which = args.members ? " of exactly those people" : "";
5001
+ return {
5002
+ summary: args.members && groups2.length === 0 ? `No group has exactly those people${onFling(target2.url, instance)}` : `${groups2.length} ${args.mine ? "of your " : ""}group(s)${which}${onFling(target2.url, instance)}
5003
+ ${describeGroups(groups2, me.email)}`,
5004
+ data: { groups: groups2 }
5005
+ };
5006
+ })
5007
+ );
5008
+ server.registerTool(
5009
+ t.groupShow.name,
5010
+ serverTool(t.groupShow),
5011
+ (args) => run(t.groupShow.name, args.server, async (target2) => {
5012
+ await withGroups(target2);
5013
+ const group = await getGroup(target2.client, args.slug);
5014
+ return { summary: describeGroup(group), data: group };
5015
+ })
5016
+ );
5017
+ server.registerTool(
5018
+ t.groupCreate.name,
5019
+ serverTool(t.groupCreate),
5020
+ (args) => run(t.groupCreate.name, args.server, async (target2) => {
5021
+ const instance = await withGroups(target2);
5022
+ const group = await createGroup(target2.client, {
5023
+ slug: args.slug,
5024
+ name: args.name,
5025
+ members: args.members
5026
+ });
5027
+ return {
5028
+ summary: `Created ${describeGroup(group)}${onFling(target2.url, instance)}`,
5029
+ data: group
5030
+ };
5031
+ })
5032
+ );
5033
+ server.registerTool(
5034
+ t.groupMembers.name,
5035
+ serverTool(t.groupMembers),
5036
+ (args) => run(t.groupMembers.name, args.server, async (target2) => {
5037
+ await withGroups(target2);
5038
+ if (!args.add?.length && !args.remove?.length) {
5039
+ throw new CliError("Pass `add` or `remove`");
5040
+ }
5041
+ const group = await changeGroupMembers(target2.client, args.slug, {
5042
+ add: args.add,
5043
+ remove: args.remove
5044
+ });
5045
+ return { summary: describeGroup(group), data: group };
5046
+ })
5047
+ );
5048
+ server.registerTool(
5049
+ t.groupRename.name,
5050
+ serverTool(t.groupRename),
5051
+ (args) => run(t.groupRename.name, args.server, async (target2) => {
5052
+ await withGroups(target2);
5053
+ const group = await renameGroup(target2.client, args.slug, args.name);
5054
+ return { summary: describeGroup(group), data: group };
5055
+ })
5056
+ );
5057
+ server.registerTool(
5058
+ t.groupDelete.name,
5059
+ serverTool(t.groupDelete),
5060
+ (args) => run(t.groupDelete.name, args.server, async (target2) => {
5061
+ await withGroups(target2);
5062
+ const result = await deleteGroup(target2.client, args.slug);
5063
+ return {
5064
+ summary: `Group ${args.slug} deleted. Sites whose audience named it keep their restriction: anyone it let in has lost access`,
5065
+ data: result
5066
+ };
5067
+ })
5068
+ );
5069
+ }
4475
5070
  server.registerTool(
4476
5071
  t.archive.name,
4477
5072
  serverTool(t.archive),
@@ -4701,18 +5296,18 @@ program.name(BIN_NAME).description("Fling a folder of static files at Fling and
4701
5296
  `server for this command: a URL, or the host of one you signed in to (default: $${ENV.url}, else the current one)`
4702
5297
  ).option("--json", "print JSON instead of human output", false).showHelpAfterError().configureHelp({
4703
5298
  sortSubcommands: false,
4704
- styleTitle: (title) => chalk10.bold(brand.primary(title)),
4705
- styleCommandText: (text2) => chalk10.bold(text2),
5299
+ styleTitle: (title) => chalk11.bold(brand.primary(title)),
5300
+ styleCommandText: (text2) => chalk11.bold(text2),
4706
5301
  styleSubcommandText: (text2) => brand.accent(text2),
4707
5302
  styleOptionText: (text2) => brand.accent(text2),
4708
- styleArgumentText: (text2) => chalk10.dim(text2)
5303
+ styleArgumentText: (text2) => chalk11.dim(text2)
4709
5304
  }).addHelpText(
4710
5305
  "before",
4711
5306
  () => process.stdout.isTTY ? logo({ columns: process.stdout.columns }) : ""
4712
5307
  ).addHelpText(
4713
5308
  "after",
4714
5309
  () => `
4715
- ${chalk10.bold(brand.primary("Examples:"))}
5310
+ ${chalk11.bold(brand.primary("Examples:"))}
4716
5311
  $ ${BIN_NAME} login https://fling.example.com sign in to your Fling (once; there is no default)
4717
5312
  $ ${BIN_NAME} setup sign in and add Fling to Claude Code / Claude Desktop
4718
5313
  $ ${BIN_NAME} use list the Flings you signed in to; \`use <url>\` switches
@@ -4723,7 +5318,7 @@ ${chalk10.bold(brand.primary("Examples:"))}
4723
5318
  $ ${BIN_NAME} site.zip a zip of a site
4724
5319
  $ ${BIN_NAME} share my-app public link for people who can't sign in
4725
5320
 
4726
- ${chalk10.dim(`Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
5321
+ ${chalk11.dim(`Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
4727
5322
  Env: ${ENV.apiKey} \xB7 ${ENV.url} \xB7 ${ENV.configDir} \xB7 ${ENV.debug}`)}`
4728
5323
  );
4729
5324
  function action(handler) {
@@ -4767,7 +5362,10 @@ program.command("deploy [path]").helpGroup("Deploy:").description(
4767
5362
  ).option(
4768
5363
  "-n, --name <name>",
4769
5364
  "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)"
4770
- ).option("--spa", "single-page app: serve index.html for unknown paths").option("--public", "make the site public (default: sign-in required)").option(
5365
+ ).option("--spa", "single-page app: serve index.html for unknown paths").option(
5366
+ "--public",
5367
+ "make the site public: anyone can open it, reviewing still needs sign-in (default: sign-in required)"
5368
+ ).option(
4771
5369
  "-y, --yes",
4772
5370
  "never prompt: accept the generated name; without index.html, list the HTML pages (or fling anyway if there are none)"
4773
5371
  ).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(
@@ -4798,7 +5396,18 @@ program.command("logout [url]").helpGroup("Setup:").description(
4798
5396
  ).action(action((ctx, url) => runLogout(ctx, url)));
4799
5397
  program.command("whoami").helpGroup("Setup:").description("show the signed-in user and the current server").action(action((ctx) => runWhoami(ctx)));
4800
5398
  program.commandsGroup("Setup:").helpCommand("help [command]", "show help for a command");
4801
- program.command("ls").helpGroup("Sites:").alias("list").description("list your sites").option("--archived", "list archived sites").action(action((ctx, opts) => runList(ctx, opts)));
5399
+ program.command("ls").helpGroup("Sites:").alias("list").description("list your sites").option("--archived", "list archived sites").addOption(
5400
+ new Option(
5401
+ "--shared",
5402
+ "list the sites others have shared with you instead of your own"
5403
+ ).conflicts("archived")
5404
+ ).addOption(
5405
+ new Option("--sort <order>", "updated (newest first), created (newest first) or name (A\u2013Z)").choices(SITE_SORTS).default(DEFAULT_SITE_SORT)
5406
+ ).action(
5407
+ action(
5408
+ (ctx, opts) => opts.shared ? runListShared(ctx, opts) : runList(ctx, opts)
5409
+ )
5410
+ );
4802
5411
  program.command("info <name>").helpGroup("Sites:").description("show a site").action(action((ctx, name) => runInfo(ctx, name)));
4803
5412
  program.command("versions <name>").helpGroup("Sites:").description("list a site's versions (* = live)").action(action((ctx, name) => runVersions(ctx, name)));
4804
5413
  program.command("files <name> [versionId]").helpGroup("Sites:").description("list the files in a site's live version (or in versionId)").action(
@@ -4810,10 +5419,37 @@ program.command("share <name>").helpGroup("Sites:").description("print (or creat
4810
5419
  (ctx, name, opts) => runShare(ctx, name, opts)
4811
5420
  )
4812
5421
  );
4813
- program.command("visibility <name> <visibility>").helpGroup("Sites:").description("private (sign-in required) or public (anyone)").action(action((ctx, name, visibility) => runVisibility(ctx, name, visibility)));
5422
+ program.command("visibility <name> <visibility>").helpGroup("Sites:").description(
5423
+ "private (sign-in required) or public (anyone can open the site; reviewing it still needs sign-in)"
5424
+ ).action(action((ctx, name, visibility) => runVisibility(ctx, name, visibility)));
5425
+ program.command("access <name>").helpGroup("Sites:").description("show who can open a private site, or limit it to listed people and groups").option("--add <emails...>", "let these addresses in (comma-separated or repeated)").option("--remove <emails...>", "take these addresses out").option("--group <slugs...>", "let these groups in (see `fling groups`)").option("--remove-group <slugs...>", "take these groups out").option("--clear", "empty the audience: everyone signed in can open it again").action(
5426
+ action(
5427
+ (ctx, name, opts) => runAccess(ctx, name, opts)
5428
+ )
5429
+ );
4814
5430
  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)));
4815
5431
  program.command("unarchive <name>").helpGroup("Sites:").description("bring an archived site back").action(action((ctx, name) => runArchive(ctx, name, true)));
4816
5432
  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)));
5433
+ var groups = program.command("groups").helpGroup("Groups:").description(
5434
+ "named lists of people anyone can use in an audience; only the owner changes one (default: list)"
5435
+ );
5436
+ groups.command("list", { isDefault: true }).alias("ls").description("list the groups on this Fling").option("--mine", "only the groups you own").option("--members <emails...>", "only the groups of exactly these people (a,b or a b)").action(action((ctx, opts) => runGroups(ctx, opts)));
5437
+ groups.command("create <slug>").description("create a group you own (slug: lowercase letters, digits and single hyphens)").option("--name <name>", "its display name (default: the slug)").option("--add <emails...>", "put these addresses in it").action(
5438
+ action(
5439
+ (ctx, slug, opts) => runGroupCreate(ctx, slug, opts)
5440
+ )
5441
+ );
5442
+ groups.command("show <slug>").description("show a group and its members").action(action((ctx, slug) => runGroupShow(ctx, slug)));
5443
+ groups.command("add <slug> <emails...>").description("add people to a group you own").action(
5444
+ action((ctx, slug, emails2) => runGroupMembers(ctx, slug, emails2, "add"))
5445
+ );
5446
+ groups.command("remove <slug> <emails...>").description("take people out of a group you own").action(
5447
+ action((ctx, slug, emails2) => runGroupMembers(ctx, slug, emails2, "remove"))
5448
+ );
5449
+ groups.command("rename <slug> <name>").description("change the display name of a group you own").action(action((ctx, slug, name) => runGroupRename(ctx, slug, name)));
5450
+ groups.command("delete <slug>").alias("rm").description(
5451
+ "delete a group you own; anyone it let into a site loses access (asks first unless --yes)"
5452
+ ).option("-y, --yes", "do not ask for confirmation").action(action((ctx, slug, opts) => runGroupDelete(ctx, slug, opts)));
4817
5453
  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(
4818
5454
  action(
4819
5455
  (ctx, name, opts) => runComments(ctx, name, opts)