@nearform/fling 0.5.1 → 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,226 +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
473
  var ANCHOR_QUOTE_MAX = 500;
377
- var px = z3.number().finite().min(-1e7).max(1e7);
378
- var percent = z3.number().min(0).max(100);
379
- var CommentAnchorSchema = z3.object({
474
+ var px = z4.number().finite().min(-1e7).max(1e7);
475
+ var percent = z4.number().min(0).max(100);
476
+ var CommentAnchorSchema = z4.object({
380
477
  x: percent,
381
478
  y: percent,
382
- selector: z3.string().min(1).max(ANCHOR_SELECTOR_MAX).optional(),
479
+ selector: z4.string().min(1).max(ANCHOR_SELECTOR_MAX).optional(),
383
480
  ex: percent.optional(),
384
481
  ey: percent.optional(),
385
482
  docX: px.optional(),
386
483
  docY: px.optional(),
387
484
  scrollX: px.optional(),
388
485
  scrollY: px.optional(),
389
- vw: z3.number().finite().min(0).max(1e5).optional(),
390
- vh: z3.number().finite().min(0).max(1e5).optional(),
391
- 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(),
392
489
  ew: percent.optional(),
393
490
  eh: percent.optional(),
394
- docW: z3.number().finite().min(0).max(1e7).optional(),
395
- docH: z3.number().finite().min(0).max(1e7).optional(),
396
- quote: z3.string().min(1).max(ANCHOR_QUOTE_MAX).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()
397
494
  });
398
- var CommentSchema = z3.object({
399
- id: z3.string(),
495
+ var CommentSchema = z4.object({
496
+ id: z4.string(),
400
497
  /** the thread this replies to; null for the comment that starts a thread */
401
- parentId: z3.string().nullable(),
498
+ parentId: z4.string().nullable(),
402
499
  /** the page, as normalizeCommentPath() writes it ('/', '/docs/', '/about.html') */
403
- path: z3.string(),
500
+ path: z4.string(),
404
501
  /** the version the site was serving when the comment was made (null: none was) */
405
- versionId: z3.string().nullable(),
502
+ versionId: z4.string().nullable(),
406
503
  anchor: CommentAnchorSchema.nullable(),
407
- body: z3.string(),
504
+ body: z4.string(),
408
505
  author: CommentAuthorSchema,
409
- createdAt: z3.string(),
506
+ createdAt: z4.string(),
410
507
  /** threads only: when and by whom it was resolved (null: open) */
411
- resolvedAt: z3.string().nullable(),
508
+ resolvedAt: z4.string().nullable(),
412
509
  resolvedBy: CommentAuthorSchema.nullable(),
413
510
  /** whether the caller may delete it (policy's answer, so clients don't guess) */
414
- canDelete: z3.boolean()
511
+ canDelete: z4.boolean()
415
512
  });
416
- var CommentListSchema = z3.object({
417
- site: z3.object({
418
- name: z3.string(),
419
- url: z3.string(),
420
- 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()
421
518
  }),
422
- comments: z3.array(CommentSchema),
423
- can: z3.object({
424
- create: z3.boolean(),
425
- resolve: z3.boolean(),
519
+ comments: z4.array(CommentSchema),
520
+ can: z4.object({
521
+ create: z4.boolean(),
522
+ resolve: z4.boolean(),
426
523
  /** delete anyone's comment (everyone may delete their own) */
427
- deleteAny: z3.boolean()
524
+ deleteAny: z4.boolean()
428
525
  })
429
526
  });
430
- var CreateCommentRequestSchema = z3.object({
431
- body: z3.string().trim().min(1).max(COMMENT_MAX_CHARS),
432
- path: z3.string().max(COMMENT_PATH_MAX).optional(),
433
- 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(),
434
531
  anchor: CommentAnchorSchema.optional()
435
532
  });
436
- var UpdateCommentRequestSchema = z3.object({ resolved: z3.boolean() });
533
+ var UpdateCommentRequestSchema = z4.object({ resolved: z4.boolean() });
437
534
  function commentThreads(comments) {
438
535
  const threads = /* @__PURE__ */ new Map();
439
536
  for (const c of comments) {
@@ -457,6 +554,60 @@ var CLI_PACKAGE = "@nearform/fling";
457
554
  var REPO_SLUG = "nearform/fling";
458
555
  var DEFAULT_INSTALL_COMMAND = `npm install -g ${CLI_PACKAGE}`;
459
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
+
460
611
  // ../../packages/shared/src/site-labels.ts
461
612
  var SITE_NAME_MAX = 50;
462
613
  var SHARE_TOKEN_LENGTH = 20;
@@ -491,47 +642,47 @@ function siteUrl(label, sitesDomain, protocol = "https:") {
491
642
  }
492
643
 
493
644
  // ../../packages/shared/src/instance.ts
494
- import { z as z4 } from "zod";
645
+ import { z as z6 } from "zod";
495
646
  var INSTANCE_METADATA_PATH = "/.well-known/fling";
496
647
  var HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
497
- var color = z4.string().regex(HEX_COLOR_RE);
498
- var InstanceAuthSchema = z4.looseObject({
499
- kind: z4.string().min(1),
500
- displayName: z4.string().min(1),
501
- issuer: z4.string().optional(),
502
- emailDomains: z4.array(z4.string()).default([]),
503
- 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()
504
655
  });
505
- var BrandColorsSchema = z4.looseObject({
656
+ var BrandColorsSchema = z6.looseObject({
506
657
  primary: color.optional(),
507
658
  primaryContent: color.optional(),
508
659
  accent: color.optional(),
509
660
  ink: color.optional(),
510
661
  highlight: color.optional()
511
662
  });
512
- var InstanceBrandSchema = z4.looseObject({
663
+ var InstanceBrandSchema = z6.looseObject({
513
664
  /** https: or data: URL of the logo mark shown before the name (light theme). */
514
- logoUrl: z4.string().optional(),
665
+ logoUrl: z6.string().optional(),
515
666
  /** The same for the dark theme; defaults to `logoUrl`. */
516
- logoDarkUrl: z4.string().optional(),
517
- faviconUrl: z4.string().optional(),
667
+ logoDarkUrl: z6.string().optional(),
668
+ faviconUrl: z6.string().optional(),
518
669
  colors: BrandColorsSchema.default({})
519
670
  });
520
- var InstanceMetadataSchema = z4.looseObject({
521
- version: z4.number().int().min(1),
671
+ var InstanceMetadataSchema = z6.looseObject({
672
+ version: z6.number().int().min(1),
522
673
  /** Display name, e.g. "Fling". */
523
- name: z4.string().min(1),
674
+ name: z6.string().min(1),
524
675
  /** e.g. `https://fling.example.test` (no trailing slash) */
525
- appOrigin: z4.string().url(),
676
+ appOrigin: z6.string().url(),
526
677
  /** host[:port] sites hang off: a site is `<name>.<sitesDomain>` */
527
- sitesDomain: z4.string().min(1),
528
- siteScheme: z4.enum(["http", "https"]),
678
+ sitesDomain: z6.string().min(1),
679
+ siteScheme: z6.enum(["http", "https"]),
529
680
  auth: InstanceAuthSchema,
530
681
  brand: InstanceBrandSchema.default({ colors: {} }),
531
682
  /** Feature flags: a client hides what is missing or false. */
532
- capabilities: z4.record(z4.string(), z4.boolean()).default({}),
683
+ capabilities: z6.record(z6.string(), z6.boolean()).default({}),
533
684
  /** How to install the CLI, e.g. `npm install -g <package>`. */
534
- installCommand: z4.string().min(1)
685
+ installCommand: z6.string().min(1)
535
686
  });
536
687
  function instanceSitesPattern(instance) {
537
688
  return `${instance.siteScheme}://<name>.${instance.sitesDomain}`;
@@ -555,15 +706,22 @@ function instanceMetadataUrl(serverUrl) {
555
706
  }
556
707
 
557
708
  // ../../packages/shared/src/kvs.ts
558
- import { z as z5 } from "zod";
559
- 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({
560
716
  /** siteId — S3 prefix is `sites/<id>/<v>/` */
561
- id: z5.string(),
717
+ id: z7.string(),
562
718
  /** current versionId */
563
- v: z5.string(),
564
- vis: z5.enum(["private", "public"]),
719
+ v: z7.string(),
720
+ vis: z7.enum(["private", "public"]),
565
721
  /** serve /index.html for unknown extensionless paths */
566
- 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()
567
725
  });
568
726
 
569
727
  // ../../packages/shared/src/listing.ts
@@ -583,13 +741,13 @@ function extractHtmlTitle(html) {
583
741
  }
584
742
  function renderHtmlListing(siteName2, entries) {
585
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);
586
- const groups = /* @__PURE__ */ new Map();
744
+ const groups2 = /* @__PURE__ */ new Map();
587
745
  for (const page of pages) {
588
746
  const slash = page.path.lastIndexOf("/");
589
747
  const folder = slash === -1 ? "" : page.path.slice(0, slash);
590
- groups.set(folder, [...groups.get(folder) ?? [], page]);
748
+ groups2.set(folder, [...groups2.get(folder) ?? [], page]);
591
749
  }
592
- 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]) => {
593
751
  const heading = folder ? `<h2>${escapeHtml(folder)}/</h2>` : "";
594
752
  const rows = items.map((item) => {
595
753
  const file = item.path.slice(item.path.lastIndexOf("/") + 1);
@@ -647,7 +805,19 @@ ${body}
647
805
  }
648
806
 
649
807
  // ../../packages/shared/src/mcp-tools.ts
650
- 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
651
821
  var MCP_PUBLISH_MAX_BYTES = 4 * 1024 * 1024;
652
822
  var MCP_UPLOAD_CHUNK_MAX_CHARS = 4 * 1024 * 1024;
653
823
  var MCP_UPLOAD_TTL_SECONDS = 60 * 60;
@@ -676,23 +846,28 @@ var INLINE_ONLY = "If you created the content in this conversation, pass it dire
676
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.";
677
847
  var ENCODING = '`encoding` is "utf8" (default, for text: HTML, CSS, JS, SVG, JSON, Markdown\u2026) or "base64" (binary: images, fonts, PDFs).';
678
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.";
679
- var listHtml = z6.boolean().optional().describe(
849
+ var listHtml = z8.boolean().optional().describe(
680
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"
681
851
  );
682
- var siteName = z6.string().min(1).max(200).describe('Site name, e.g. "q3-report"');
683
- 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(
684
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")'
685
855
  );
686
- var commentPath = z6.string().max(1024);
687
- var commentId = z6.string().min(1).max(100);
688
- var encoding = z6.enum(["utf8", "base64"]).optional().describe('How `content` is encoded: "utf8" (default) for text, "base64" for binary files');
689
- 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(
690
865
  "Single-page app: serve index.html for unknown paths (client-side routing). Omit to keep the current setting"
691
866
  );
692
867
  function mcpToolsFor(w) {
693
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}.`;
694
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.`;
695
- const isPublic = z6.boolean().optional().describe(
870
+ const isPublic = z8.boolean().optional().describe(
696
871
  `true \u2192 anyone can view; false \u2192 only ${w.privateAudience}. Omit to keep the current setting (new sites are private)`
697
872
  );
698
873
  return {
@@ -706,9 +881,13 @@ function mcpToolsFor(w) {
706
881
  list: tool({
707
882
  name: "fling_list",
708
883
  title: "List sites",
709
- 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.",
710
885
  inputSchema: {
711
- 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
+ )
712
891
  },
713
892
  annotations: { readOnlyHint: true, openWorldHint: false }
714
893
  }),
@@ -725,7 +904,7 @@ function mcpToolsFor(w) {
725
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).",
726
905
  inputSchema: {
727
906
  name: siteName,
728
- 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")
729
908
  },
730
909
  annotations: { readOnlyHint: true, openWorldHint: false }
731
910
  }),
@@ -733,7 +912,7 @@ function mcpToolsFor(w) {
733
912
  name: "fling_check_name",
734
913
  title: "Check a site name",
735
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,
736
- 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") },
737
916
  annotations: { readOnlyHint: true, openWorldHint: false }
738
917
  }),
739
918
  publish: tool({
@@ -742,10 +921,10 @@ function mcpToolsFor(w) {
742
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.`,
743
922
  inputSchema: {
744
923
  name: siteName,
745
- files: z6.array(
746
- z6.object({
924
+ files: z8.array(
925
+ z8.object({
747
926
  path: sitePath,
748
- content: z6.string().describe("The file content, encoded as `encoding` says"),
927
+ content: z8.string().describe("The file content, encoded as `encoding` says"),
749
928
  encoding
750
929
  })
751
930
  ).min(1).max(MAX_FILES).describe("Every file of the site"),
@@ -767,11 +946,11 @@ function mcpToolsFor(w) {
767
946
  title: "Upload a file (chunked)",
768
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}`,
769
948
  inputSchema: {
770
- 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"),
771
950
  path: sitePath,
772
- 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`)"),
773
952
  encoding,
774
- 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")
775
954
  },
776
955
  annotations: { destructiveHint: false, openWorldHint: false }
777
956
  }),
@@ -780,7 +959,7 @@ function mcpToolsFor(w) {
780
959
  title: "Finish a chunked upload",
781
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\`.`,
782
961
  inputSchema: {
783
- 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"),
784
963
  listHtml
785
964
  },
786
965
  annotations: { destructiveHint: false, openWorldHint: true }
@@ -791,25 +970,97 @@ function mcpToolsFor(w) {
791
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.",
792
971
  inputSchema: {
793
972
  name: siteName,
794
- refresh: z6.boolean().optional().describe("Rotate the link"),
795
- 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")
796
975
  },
797
976
  annotations: { destructiveHint: false, openWorldHint: true }
798
977
  }),
799
978
  setVisibility: tool({
800
979
  name: "fling_set_visibility",
801
980
  title: "Set visibility",
802
- description: `Make a site private (only ${w.privateAudience}) or public (anyone).`,
803
- 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"]) },
804
983
  annotations: { idempotentHint: true, openWorldHint: true }
805
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
+ }),
806
1057
  archive: tool({
807
1058
  name: "fling_archive",
808
1059
  title: "Archive or restore a site",
809
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.",
810
1061
  inputSchema: {
811
1062
  name: siteName,
812
- unarchive: z6.boolean().optional().describe("Restore an archived site")
1063
+ unarchive: z8.boolean().optional().describe("Restore an archived site")
813
1064
  },
814
1065
  annotations: { idempotentHint: true, openWorldHint: false }
815
1066
  }),
@@ -819,7 +1070,7 @@ function mcpToolsFor(w) {
819
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.",
820
1071
  inputSchema: {
821
1072
  name: siteName,
822
- 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")
823
1074
  },
824
1075
  annotations: { destructiveHint: true, openWorldHint: false }
825
1076
  }),
@@ -830,7 +1081,7 @@ function mcpToolsFor(w) {
830
1081
  inputSchema: {
831
1082
  name: siteName,
832
1083
  path: commentPath.optional().describe('Only this page, e.g. "/" or "/docs/"'),
833
- includeResolved: z6.boolean().optional().describe("Include resolved threads")
1084
+ includeResolved: z8.boolean().optional().describe("Include resolved threads")
834
1085
  },
835
1086
  annotations: { readOnlyHint: true, openWorldHint: false }
836
1087
  }),
@@ -840,7 +1091,7 @@ function mcpToolsFor(w) {
840
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.`,
841
1092
  inputSchema: {
842
1093
  name: siteName,
843
- 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)"),
844
1095
  path: commentPath.optional().describe('The page it is about (default "/"); ignored for a reply'),
845
1096
  replyTo: commentId.optional().describe("Reply to this thread instead of starting one")
846
1097
  },
@@ -853,7 +1104,7 @@ function mcpToolsFor(w) {
853
1104
  inputSchema: {
854
1105
  name: siteName,
855
1106
  id: commentId.describe("The thread id"),
856
- reopen: z6.boolean().optional().describe("Reopen a resolved thread")
1107
+ reopen: z8.boolean().optional().describe("Reopen a resolved thread")
857
1108
  },
858
1109
  annotations: { idempotentHint: true, openWorldHint: false }
859
1110
  }),
@@ -870,7 +1121,7 @@ function mcpToolsFor(w) {
870
1121
  description: "Point a site at an earlier version (ids from fling_info). Instant; nothing is uploaded.",
871
1122
  inputSchema: {
872
1123
  name: siteName,
873
- 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")
874
1125
  },
875
1126
  annotations: { idempotentHint: true, openWorldHint: false }
876
1127
  })
@@ -967,97 +1218,109 @@ function readinessWaitDisabled(env) {
967
1218
  }
968
1219
 
969
1220
  // ../../packages/shared/src/review-bridge.ts
970
- import { z as z7 } from "zod";
971
- var tag = { fling: z7.literal("review") };
972
- var path = z7.string().max(COMMENT_PATH_MAX);
973
- var hash = z7.string().max(COMMENT_PATH_MAX);
974
- var BridgePinSchema = z7.object({
975
- id: z7.string(),
976
- 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),
977
1228
  anchor: CommentAnchorSchema,
978
- selected: z7.boolean(),
979
- resolved: z7.boolean()
1229
+ selected: z9.boolean(),
1230
+ resolved: z9.boolean()
980
1231
  });
981
- var BridgeCursorSchema = z7.object({
982
- id: z7.string(),
983
- name: z7.string().max(100),
984
- 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),
985
1236
  anchor: CommentAnchorSchema
986
1237
  });
987
- var ShellMessageSchema = z7.discriminatedUnion("type", [
988
- z7.object({ ...tag, type: z7.literal("ready") }),
1238
+ var ShellMessageSchema = z9.discriminatedUnion("type", [
1239
+ z9.object({ ...tag, type: z9.literal("ready") }),
989
1240
  /** the frame is on `path` (normalised, see normalizeCommentPath) with fragment `hash` ('' or '#…') */
990
- z7.object({ ...tag, type: z7.literal("location"), path, hash, title: z7.string().max(300) }),
1241
+ z9.object({ ...tag, type: z9.literal("location"), path, hash, title: z9.string().max(300) }),
991
1242
  /** a pin placed while placing: a click (a spot) or a drag (a box) */
992
- z7.object({ ...tag, type: z7.literal("pinPlaced"), anchor: CommentAnchorSchema }),
1243
+ z9.object({ ...tag, type: z9.literal("pinPlaced"), anchor: CommentAnchorSchema }),
993
1244
  /** the reviewer selected text in the page: an anchor on it, with its quote */
994
- z7.object({ ...tag, type: z7.literal("textSelected"), anchor: CommentAnchorSchema }),
995
- z7.object({ ...tag, type: z7.literal("pinCancelled") }),
996
- z7.object({ ...tag, type: z7.literal("pinClicked"), id: z7.string() }),
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() }),
997
1248
  /** this reviewer's pointer on the page; null when it left the frame */
998
- z7.object({ ...tag, type: z7.literal("cursor"), anchor: CommentAnchorSchema.nullable() }),
1249
+ z9.object({ ...tag, type: z9.literal("cursor"), anchor: CommentAnchorSchema.nullable() }),
999
1250
  /** a link or form to another origin, stopped: the frame stays on the site */
1000
- z7.object({ ...tag, type: z7.literal("blocked"), href: z7.string().max(2048) }),
1001
- 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") })
1002
1253
  ]);
1003
- var PageMessageSchema = z7.discriminatedUnion("type", [
1254
+ var PageMessageSchema = z9.discriminatedUnion("type", [
1004
1255
  /** load `path` (site-relative, may carry `?query` and `#hash`) in the frame */
1005
- z7.object({ ...tag, type: z7.literal("navigate"), path: z7.string().max(COMMENT_PATH_MAX * 2) }),
1006
- z7.object({ ...tag, type: z7.literal("reload") }),
1007
- 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() }),
1008
1259
  /** the pins on the frame's current page */
1009
- z7.object({ ...tag, type: z7.literal("pins"), pins: z7.array(BridgePinSchema) }),
1260
+ z9.object({ ...tag, type: z9.literal("pins"), pins: z9.array(BridgePinSchema) }),
1010
1261
  /** scroll the pin into view and draw attention to it */
1011
- z7.object({ ...tag, type: z7.literal("focusPin"), id: z7.string() }),
1012
- 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) })
1013
1264
  ]);
1014
1265
 
1015
1266
  // ../../packages/shared/src/review-live.ts
1016
- import { z as z8 } from "zod";
1267
+ import { z as z10 } from "zod";
1017
1268
  var LIVE_PICTURE_MAX = 1024;
1018
- var LivePeerSchema = z8.object({
1019
- id: z8.string(),
1020
- name: z8.string().max(100),
1021
- 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),
1022
1273
  /** their profile picture (https), when sign-in gave one; else clients show initials */
1023
- picture: z8.string().max(LIVE_PICTURE_MAX).optional()
1274
+ picture: z10.string().max(LIVE_PICTURE_MAX).optional()
1024
1275
  });
1025
- var LiveTicketSchema = z8.object({
1026
- url: z8.string(),
1027
- ticket: z8.string(),
1276
+ var LiveTicketSchema = z10.object({
1277
+ url: z10.string(),
1278
+ ticket: z10.string(),
1028
1279
  /** seconds the ticket is good for: connect before then */
1029
- expiresIn: z8.number().int()
1280
+ expiresIn: z10.number().int()
1030
1281
  });
1031
- var path2 = z8.string().max(COMMENT_PATH_MAX);
1032
- var hash2 = z8.string().max(COMMENT_PATH_MAX);
1033
- var LiveClientMessageSchema = z8.discriminatedUnion("type", [
1034
- 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") }),
1035
1286
  /** where this peer's pointer is; `anchor` null when it left the page */
1036
- z8.object({
1037
- type: z8.literal("cursor"),
1287
+ z10.object({
1288
+ type: z10.literal("cursor"),
1038
1289
  path: path2,
1039
1290
  hash: hash2,
1040
1291
  anchor: CommentAnchorSchema.nullable()
1041
1292
  }),
1042
- z8.object({ type: z8.literal("ping") })
1293
+ z10.object({ type: z10.literal("ping") })
1043
1294
  ]);
1044
- var LiveServerMessageSchema = z8.discriminatedUnion("type", [
1295
+ var LiveServerMessageSchema = z10.discriminatedUnion("type", [
1045
1296
  /** the answer to `hello`: who this connection is, and who else is in the room */
1046
- z8.object({ type: z8.literal("welcome"), you: LivePeerSchema, peers: z8.array(LivePeerSchema) }),
1047
- z8.object({ type: z8.literal("join"), peer: LivePeerSchema }),
1048
- z8.object({ type: z8.literal("leave"), peerId: z8.string() }),
1049
- z8.object({
1050
- type: z8.literal("cursor"),
1051
- 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(),
1052
1303
  path: path2,
1053
1304
  hash: hash2,
1054
1305
  anchor: CommentAnchorSchema.nullable()
1055
1306
  }),
1056
1307
  /** the site's comments changed: refetch them */
1057
- z8.object({ type: z8.literal("comments") }),
1058
- z8.object({ type: z8.literal("pong") })
1308
+ z10.object({ type: z10.literal("comments") }),
1309
+ z10.object({ type: z10.literal("pong") })
1059
1310
  ]);
1060
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
+
1061
1324
  // src/errors.ts
1062
1325
  var EXIT_ERROR = 1;
1063
1326
  var EXIT_NOT_LOGGED_IN = 2;
@@ -1097,7 +1360,7 @@ function exitCodeFor(error) {
1097
1360
  // package.json
1098
1361
  var package_default = {
1099
1362
  name: "@nearform/fling",
1100
- version: "0.5.1",
1363
+ version: "0.6.0",
1101
1364
  license: "MIT",
1102
1365
  description: "Fling a folder of static files at a Fling server and get a link back. CLI + MCP server.",
1103
1366
  type: "module",
@@ -1308,7 +1571,7 @@ function describeCause(cause) {
1308
1571
  import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
1309
1572
  import { homedir } from "node:os";
1310
1573
  import { join } from "node:path";
1311
- import { z as z9 } from "zod";
1574
+ import { z as z11 } from "zod";
1312
1575
  var CONFIG_DIR_NAME = ".fling";
1313
1576
  var CONFIG_FILE_NAME = "config.json";
1314
1577
  var ENV = {
@@ -1321,14 +1584,14 @@ var ENV = {
1321
1584
  /** A server the local MCP server adds to the remembered ones on start (the .mcpb bakes it in). */
1322
1585
  addServer: "FLING_ADD_SERVER"
1323
1586
  };
1324
- var serverEntrySchema = z9.object({
1587
+ var serverEntrySchema = z11.object({
1325
1588
  /** What the server said about itself at login. A copy this CLI can't read is just refetched. */
1326
1589
  instance: InstanceMetadataSchema.optional().catch(void 0),
1327
- apiKey: z9.string().min(1).optional().catch(void 0),
1328
- user: z9.object({
1329
- email: z9.string().optional(),
1330
- name: z9.string().optional(),
1331
- 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()
1332
1595
  }).optional().catch(void 0)
1333
1596
  });
1334
1597
  function systemLocateOptions(env = process.env) {
@@ -1946,8 +2209,8 @@ function requireClient(ctx) {
1946
2209
  }
1947
2210
 
1948
2211
  // src/sites.ts
1949
- import { z as z10 } from "zod";
1950
- var ShareResponseSchema2 = z10.looseObject({ shareUrl: z10.string() });
2212
+ import { z as z12 } from "zod";
2213
+ var ShareResponseSchema2 = z12.looseObject({ shareUrl: z12.string() });
1951
2214
  var sitePath2 = (name) => `/api/sites/${encodeURIComponent(name)}`;
1952
2215
  function whoami(client) {
1953
2216
  return client.request("GET", "/api/me", { schema: MeSchema });
@@ -1971,7 +2234,11 @@ async function listSites(client, opts = {}) {
1971
2234
  schema: SiteListSchema,
1972
2235
  query: { archived: opts.archived ? 1 : void 0 }
1973
2236
  });
1974
- 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);
1975
2242
  }
1976
2243
  function getSite(client, name) {
1977
2244
  return client.request("GET", sitePath2(name), { schema: SiteDetailSchema });
@@ -2002,6 +2269,74 @@ async function setVisibility(client, name, visibility) {
2002
2269
  const raw = await client.request("PATCH", sitePath2(name), { body: { visibility } });
2003
2270
  return siteAfter(client, name, raw);
2004
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
+ }
2005
2340
  async function archiveSite(client, name, opts = {}) {
2006
2341
  const raw = await client.request(
2007
2342
  "POST",
@@ -3121,7 +3456,7 @@ async function deployPrepared(ctx, client, instance, base, prepared, opts, ask)
3121
3456
  local.files.map((f) => f.path),
3122
3457
  { progress: ctx.progress, warn: (m) => ctx.log.warn(m) }
3123
3458
  )).ready;
3124
- const who = instance ? `only ${privateAudience(instance)} can open it` : "sign-in required";
3459
+ const who = instance ? `${privateAudience(instance)} can open it` : "sign-in required";
3125
3460
  const privateNote = `Private: ${who}. Share publicly with \`${ctx.binName} share ${result.name}\`.`;
3126
3461
  const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
3127
3462
  const reviewNote = instance ? `Share for feedback (reviewers sign in to ${instance.name}):` : "Share for feedback:";
@@ -3204,9 +3539,76 @@ async function confirmSiteName(ctx, client, generated, base) {
3204
3539
  }
3205
3540
  }
3206
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
+
3207
3609
  // src/commands/setup.ts
3208
3610
  import { intro, outro, spinner } from "@clack/prompts";
3209
- import chalk8 from "chalk";
3611
+ import chalk9 from "chalk";
3210
3612
 
3211
3613
  // src/claude.ts
3212
3614
  import { execFile as execFile2 } from "node:child_process";
@@ -3299,14 +3701,14 @@ async function runSetup(baseCtx, opts, deps = {}) {
3299
3701
  ...baseCtx,
3300
3702
  log: createLogger({
3301
3703
  debug: baseCtx.config.debug,
3302
- write: (line) => process.stderr.write(`${chalk8.gray("\u2502")} ${line}
3704
+ write: (line) => process.stderr.write(`${chalk9.gray("\u2502")} ${line}
3303
3705
  `)
3304
3706
  })
3305
3707
  } : baseCtx;
3306
3708
  if (fancy) {
3307
3709
  process.stderr.write(`${logo()}
3308
3710
  `);
3309
- intro(chalk8.bold(" fling setup "), { output: process.stderr });
3711
+ intro(chalk9.bold(" fling setup "), { output: process.stderr });
3310
3712
  }
3311
3713
  const login = await stepLogin(ctx, opts.url);
3312
3714
  const results = {
@@ -3320,7 +3722,7 @@ async function runSetup(baseCtx, opts, deps = {}) {
3320
3722
  const failed = Object.values(results).includes("failed");
3321
3723
  if (fancy)
3322
3724
  outro(
3323
- 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.`,
3324
3726
  { output: process.stderr }
3325
3727
  );
3326
3728
  ctx.out.data(results, () => "");
@@ -3343,7 +3745,7 @@ async function signIn(ctx, serverUrl) {
3343
3745
  try {
3344
3746
  const me = await whoami(requireClient(ctx));
3345
3747
  if (ctx.config.remembered) await ctx.store.setCurrent(serverUrl);
3346
- 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}>`)}`);
3347
3749
  return "already";
3348
3750
  } catch (error) {
3349
3751
  if (exitCodeFor(error) !== 2) {
@@ -3364,7 +3766,7 @@ async function signIn(ctx, serverUrl) {
3364
3766
  async function stepClaudeCode(ctx, run, scope, ask, fancy) {
3365
3767
  const status = await claudeCodeStatus(run);
3366
3768
  if (status.claude === "missing") {
3367
- 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")}`);
3368
3770
  ctx.log.hint(
3369
3771
  `Once it is installed, run \`${ctx.binName} setup\` again, or in Claude Code: /plugin marketplace add ${MARKETPLACE_SOURCE} then /plugin install ${PLUGIN_ID}`
3370
3772
  );
@@ -3373,7 +3775,7 @@ async function stepClaudeCode(ctx, run, scope, ask, fancy) {
3373
3775
  const installed = "installed" in status && status.installed;
3374
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?";
3375
3777
  if (ask && !await ctx.confirm(question)) {
3376
- ctx.log.info(chalk8.dim("Claude Code plugin: skipped"));
3778
+ ctx.log.info(chalk9.dim("Claude Code plugin: skipped"));
3377
3779
  return installed ? "already" : "skipped";
3378
3780
  }
3379
3781
  const s = fancy ? spinner({ output: process.stderr }) : null;
@@ -3387,7 +3789,7 @@ async function stepClaudeCode(ctx, run, scope, ask, fancy) {
3387
3789
  else ctx.log.error(message);
3388
3790
  return "failed";
3389
3791
  }
3390
- 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)`)}`;
3391
3793
  if (s) s.stop(done);
3392
3794
  else ctx.log.success(done);
3393
3795
  ctx.log.hint("Restart Claude Code (or run /reload-plugins) to pick it up.");
@@ -3397,7 +3799,7 @@ async function stepClaudeDesktop(ctx, deps, ask, fancy) {
3397
3799
  const app = await (deps.findClaudeDesktop ?? findClaudeDesktop)();
3398
3800
  if (!app) return "missing";
3399
3801
  if (ask && !await ctx.confirm("Add the Fling extension to Claude Desktop too?")) {
3400
- ctx.log.info(chalk8.dim("Claude Desktop extension: skipped"));
3802
+ ctx.log.info(chalk9.dim("Claude Desktop extension: skipped"));
3401
3803
  return "skipped";
3402
3804
  }
3403
3805
  const s = fancy ? spinner({ output: process.stderr }) : null;
@@ -3418,7 +3820,7 @@ async function stepClaudeDesktop(ctx, deps, ask, fancy) {
3418
3820
  }
3419
3821
 
3420
3822
  // src/commands/sites.ts
3421
- import chalk9 from "chalk";
3823
+ import chalk10 from "chalk";
3422
3824
  async function runList(ctx, opts) {
3423
3825
  const sites = await listSites(requireClient(ctx), opts);
3424
3826
  ctx.out.data(
@@ -3435,12 +3837,29 @@ async function runList(ctx, opts) {
3435
3837
  ])
3436
3838
  );
3437
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
+ }
3438
3857
  function describeSite(site) {
3439
- const row = (label, value) => `${chalk9.bold(`${label}:`.padEnd(12))}${value}`;
3858
+ const row = (label, value) => `${chalk10.bold(`${label}:`.padEnd(12))}${value}`;
3440
3859
  return [
3441
3860
  row("Name", site.name),
3442
3861
  row("URL", site.url),
3443
- row("Visibility", site.visibility + (site.archived ? chalk9.yellow(" (archived)") : "")),
3862
+ row("Visibility", site.visibility + (site.archived ? chalk10.yellow(" (archived)") : "")),
3444
3863
  row("SPA", site.spa ? "yes" : "no"),
3445
3864
  row("Share link", site.shareUrl ?? "-"),
3446
3865
  row("Version", site.currentVersionId ?? "-"),
@@ -3455,14 +3874,17 @@ function filesRow(site, row) {
3455
3874
  const n = current.fileCount;
3456
3875
  return [row("Files", `${n} file${n === 1 ? "" : "s"}, ${formatBytes(current.totalBytes)}`)];
3457
3876
  }
3877
+ var membersOf = (ctx) => ctx.config.instance ? privateAudience(ctx.config.instance) : "anyone signed in to this Fling";
3458
3878
  async function runInfo(ctx, name) {
3459
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))}`] : [];
3460
3881
  ctx.out.data(
3461
3882
  site,
3462
3883
  () => [
3463
3884
  ...describeSite(site),
3885
+ ...access2,
3464
3886
  "",
3465
- chalk9.dim(
3887
+ chalk10.dim(
3466
3888
  `${site.versions.length} version(s) \u2014 \`fling versions ${name}\`; files \u2014 \`fling files ${name}\``
3467
3889
  )
3468
3890
  ].join("\n")
@@ -3488,7 +3910,7 @@ async function runFiles(ctx, name, versionId) {
3488
3910
  }
3489
3911
  ctx.out.data(result, () => {
3490
3912
  const n = result.files.length;
3491
- const header = chalk9.dim(
3913
+ const header = chalk10.dim(
3492
3914
  `${result.versionId}${result.current ? " (current)" : " (not served)"} \u2014 ${n} file${n === 1 ? "" : "s"}, ${formatBytes(result.totalBytes)}${result.generatedIndex ? ", generated index.html" : ""}`
3493
3915
  );
3494
3916
  const table = renderTable(result.files, [
@@ -3524,7 +3946,7 @@ async function runShare(ctx, name, opts) {
3524
3946
  ctx.log.success("Share link created \u2014 anyone with it can view the site");
3525
3947
  ctx.out.data(
3526
3948
  ready === void 0 ? result : { ...result, ready },
3527
- () => result.shareUrl ? result.shareUrl : chalk9.dim("(no share link)")
3949
+ () => result.shareUrl ? result.shareUrl : chalk10.dim("(no share link)")
3528
3950
  );
3529
3951
  }
3530
3952
  async function runVisibility(ctx, name, visibility) {
@@ -3537,6 +3959,27 @@ async function runVisibility(ctx, name, visibility) {
3537
3959
  );
3538
3960
  ctx.out.data(site, () => site.url);
3539
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
+ }
3540
3983
  async function runArchive(ctx, name, unarchive = false) {
3541
3984
  const site = await archiveSite(requireClient(ctx), name, { unarchive });
3542
3985
  ctx.log.success(
@@ -3566,7 +4009,7 @@ import { tmpdir as tmpdir3 } from "node:os";
3566
4009
  import { basename as basename3, dirname as dirname2, join as join6 } from "node:path";
3567
4010
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3568
4011
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3569
- import { z as z11 } from "zod";
4012
+ import { z as z13 } from "zod";
3570
4013
 
3571
4014
  // src/locate.ts
3572
4015
  import { lstat, readdir as readdir2, readFile as readFile6, realpath as realpath3, stat as stat5 } from "node:fs/promises";
@@ -3988,7 +4431,7 @@ var mcpLocalInstructions = (w, opts = {}) => [
3988
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.",
3989
4432
  NEVER_PUBLISHED,
3990
4433
  "Not logged in? Call fling_login, let the user finish signing in in the browser, then retry.",
3991
- `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."}`,
3992
4435
  ...opts.comments ? [COMMENTS_NOTE(w)] : []
3993
4436
  ].join("\n\n");
3994
4437
  var onFling = (url, instance) => `
@@ -4003,7 +4446,7 @@ function createMcpServer(deps = {}) {
4003
4446
  const several = servers.length > 1;
4004
4447
  const wording = mcpWording(several ? null : servers[0]?.instance);
4005
4448
  const mcpTools2 = mcpToolsFor(wording);
4006
- 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));
4007
4450
  const note = serverToolNote(servers);
4008
4451
  const serverTool = (def, description = def.description) => ({
4009
4452
  title: def.title,
@@ -4140,12 +4583,12 @@ To check exactly which files the site has, call fling_files with name "${result.
4140
4583
  "fling_deploy",
4141
4584
  {
4142
4585
  title: "Deploy a file or folder from this computer",
4143
- 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}`,
4144
4587
  inputSchema: {
4145
- path: z11.string().min(1).describe(
4588
+ path: z13.string().min(1).describe(
4146
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)"
4147
4590
  ),
4148
- name: z11.string().optional().describe(
4591
+ name: z13.string().optional().describe(
4149
4592
  "Site name (lowercase letters, digits, single hyphens; max 50). Defaults to a name generated from the file or folder"
4150
4593
  ),
4151
4594
  spa: spa2,
@@ -4195,9 +4638,9 @@ To check exactly which files the site has, call fling_files with name "${result.
4195
4638
  title: "Find files to fling",
4196
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.",
4197
4640
  inputSchema: {
4198
- query: z11.string().optional().describe('Only names containing this text (case-insensitive), e.g. "deck"'),
4199
- limit: z11.number().int().min(1).max(100).optional().describe("Max results (default 20)"),
4200
- 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)")
4201
4644
  },
4202
4645
  annotations: { readOnlyHint: true, openWorldHint: false }
4203
4646
  },
@@ -4258,8 +4701,8 @@ ${JSON.stringify(data, null, 2)}`);
4258
4701
  ...serverTool(mcpTools2.checkName),
4259
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}`,
4260
4703
  inputSchema: {
4261
- name: z11.string().min(1).max(200).optional().describe("Site name to check"),
4262
- 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)"),
4263
4706
  server: serverArg
4264
4707
  }
4265
4708
  },
@@ -4300,7 +4743,7 @@ ${JSON.stringify(data, null, 2)}`);
4300
4743
  title: "Sign in to Fling",
4301
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,
4302
4745
  inputSchema: {
4303
- server: z11.string().min(1).max(500).optional().describe(
4746
+ server: z13.string().min(1).max(500).optional().describe(
4304
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"}`}`
4305
4748
  )
4306
4749
  },
@@ -4384,7 +4827,7 @@ ${known.join("\n")}` : ""
4384
4827
  title: "List the Flings on this computer",
4385
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.`,
4386
4829
  inputSchema: {
4387
- use: z11.string().min(1).max(500).optional().describe(
4830
+ use: z13.string().min(1).max(500).optional().describe(
4388
4831
  "Make this Fling current (address, host or unique part of it), like `fling use`"
4389
4832
  )
4390
4833
  },
@@ -4428,17 +4871,39 @@ ${JSON.stringify(data, null, 2)}`);
4428
4871
  server.registerTool(
4429
4872
  t.list.name,
4430
4873
  serverTool(t.list),
4431
- (args) => run(t.list.name, args.server, async ({ client, url }) => {
4432
- 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 });
4433
4892
  return { summary: `${sites.length} site(s) on ${serverLabel(url)}`, data: { sites } };
4434
4893
  })
4435
4894
  );
4895
+ const membersOn = async (target2) => {
4896
+ const instance = await target2.instance();
4897
+ return instance ? privateAudience(instance) : "anyone signed in to this Fling";
4898
+ };
4436
4899
  server.registerTool(
4437
4900
  t.info.name,
4438
4901
  serverTool(t.info),
4439
- (args) => run(t.info.name, args.server, async ({ client }) => {
4440
- const site = await getSite(client, args.name);
4441
- 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 };
4442
4907
  })
4443
4908
  );
4444
4909
  server.registerTool(
@@ -4482,6 +4947,126 @@ ${JSON.stringify(data, null, 2)}`);
4482
4947
  return { summary: `${site.name} is now ${site.visibility}: ${site.url}`, data: site };
4483
4948
  })
4484
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
+ }
4485
5070
  server.registerTool(
4486
5071
  t.archive.name,
4487
5072
  serverTool(t.archive),
@@ -4711,18 +5296,18 @@ program.name(BIN_NAME).description("Fling a folder of static files at Fling and
4711
5296
  `server for this command: a URL, or the host of one you signed in to (default: $${ENV.url}, else the current one)`
4712
5297
  ).option("--json", "print JSON instead of human output", false).showHelpAfterError().configureHelp({
4713
5298
  sortSubcommands: false,
4714
- styleTitle: (title) => chalk10.bold(brand.primary(title)),
4715
- styleCommandText: (text2) => chalk10.bold(text2),
5299
+ styleTitle: (title) => chalk11.bold(brand.primary(title)),
5300
+ styleCommandText: (text2) => chalk11.bold(text2),
4716
5301
  styleSubcommandText: (text2) => brand.accent(text2),
4717
5302
  styleOptionText: (text2) => brand.accent(text2),
4718
- styleArgumentText: (text2) => chalk10.dim(text2)
5303
+ styleArgumentText: (text2) => chalk11.dim(text2)
4719
5304
  }).addHelpText(
4720
5305
  "before",
4721
5306
  () => process.stdout.isTTY ? logo({ columns: process.stdout.columns }) : ""
4722
5307
  ).addHelpText(
4723
5308
  "after",
4724
5309
  () => `
4725
- ${chalk10.bold(brand.primary("Examples:"))}
5310
+ ${chalk11.bold(brand.primary("Examples:"))}
4726
5311
  $ ${BIN_NAME} login https://fling.example.com sign in to your Fling (once; there is no default)
4727
5312
  $ ${BIN_NAME} setup sign in and add Fling to Claude Code / Claude Desktop
4728
5313
  $ ${BIN_NAME} use list the Flings you signed in to; \`use <url>\` switches
@@ -4733,7 +5318,7 @@ ${chalk10.bold(brand.primary("Examples:"))}
4733
5318
  $ ${BIN_NAME} site.zip a zip of a site
4734
5319
  $ ${BIN_NAME} share my-app public link for people who can't sign in
4735
5320
 
4736
- ${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
4737
5322
  Env: ${ENV.apiKey} \xB7 ${ENV.url} \xB7 ${ENV.configDir} \xB7 ${ENV.debug}`)}`
4738
5323
  );
4739
5324
  function action(handler) {
@@ -4777,7 +5362,10 @@ program.command("deploy [path]").helpGroup("Deploy:").description(
4777
5362
  ).option(
4778
5363
  "-n, --name <name>",
4779
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)"
4780
- ).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(
4781
5369
  "-y, --yes",
4782
5370
  "never prompt: accept the generated name; without index.html, list the HTML pages (or fling anyway if there are none)"
4783
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(
@@ -4808,7 +5396,18 @@ program.command("logout [url]").helpGroup("Setup:").description(
4808
5396
  ).action(action((ctx, url) => runLogout(ctx, url)));
4809
5397
  program.command("whoami").helpGroup("Setup:").description("show the signed-in user and the current server").action(action((ctx) => runWhoami(ctx)));
4810
5398
  program.commandsGroup("Setup:").helpCommand("help [command]", "show help for a command");
4811
- 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
+ );
4812
5411
  program.command("info <name>").helpGroup("Sites:").description("show a site").action(action((ctx, name) => runInfo(ctx, name)));
4813
5412
  program.command("versions <name>").helpGroup("Sites:").description("list a site's versions (* = live)").action(action((ctx, name) => runVersions(ctx, name)));
4814
5413
  program.command("files <name> [versionId]").helpGroup("Sites:").description("list the files in a site's live version (or in versionId)").action(
@@ -4820,10 +5419,37 @@ program.command("share <name>").helpGroup("Sites:").description("print (or creat
4820
5419
  (ctx, name, opts) => runShare(ctx, name, opts)
4821
5420
  )
4822
5421
  );
4823
- 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
+ );
4824
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)));
4825
5431
  program.command("unarchive <name>").helpGroup("Sites:").description("bring an archived site back").action(action((ctx, name) => runArchive(ctx, name, true)));
4826
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)));
4827
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(
4828
5454
  action(
4829
5455
  (ctx, name, opts) => runComments(ctx, name, opts)