@nearform/fling 0.6.0 → 0.7.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,26 +1,108 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // ../../packages/shared/src/access.ts
4
+ import { z } from "zod";
5
+ var ACCESS_LEVELS = ["only_me", "members", "groups", "people", "public"];
6
+ var AccessLevelSchema = z.enum(ACCESS_LEVELS);
7
+ var DEFAULT_ACCESS_LEVELS = ["only_me", "members"];
8
+ var DefaultAccessLevelSchema = z.enum(DEFAULT_ACCESS_LEVELS);
9
+ var LEGACY_DEFAULT_ACCESS = "members";
10
+ var needsAudience = (level) => level === "groups" || level === "people";
11
+ var ACCESS_LABELS = {
12
+ only_me: "Only you",
13
+ members: "Everyone signed in",
14
+ groups: "Teams",
15
+ people: "Specific people",
16
+ public: "Public"
17
+ };
18
+ function accessDescription(level, members2) {
19
+ switch (level) {
20
+ case "only_me":
21
+ return "Only you can open it.";
22
+ case "members":
23
+ return `${capitalise(members2)} can open it.`;
24
+ case "groups":
25
+ return "Only you and the members of the groups you name can open it.";
26
+ case "people":
27
+ return "Only you and the people you name can open it.";
28
+ case "public":
29
+ return "Anyone with the address can open it, without signing in; its review page and comments still need sign-in.";
30
+ }
31
+ }
32
+ var capitalise = (s) => s.charAt(0).toUpperCase() + s.slice(1);
33
+ function accessLevelOf(site) {
34
+ if (site.visibility === "public") return "public";
35
+ if (!site.restricted) return "members";
36
+ if (site.emails > 0) return "people";
37
+ return site.groups > 0 ? "groups" : "only_me";
38
+ }
39
+ var siteAccessLevel = (site) => site.access ?? accessLevelOf({
40
+ visibility: site.visibility,
41
+ restricted: site.restricted ?? false,
42
+ emails: (site.audienceSize ?? 0) - (site.audienceGroups ?? 0),
43
+ groups: site.audienceGroups ?? 0
44
+ });
45
+ function parseAccessLevel(value) {
46
+ const v = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
47
+ const aliases = {
48
+ me: "only_me",
49
+ only_you: "only_me",
50
+ owner: "only_me",
51
+ private: "members",
52
+ member: "members",
53
+ everyone: "members",
54
+ group: "groups",
55
+ teams: "groups",
56
+ team: "groups",
57
+ person: "people",
58
+ emails: "people"
59
+ };
60
+ return ACCESS_LEVELS.includes(v) ? v : aliases[v] ?? null;
61
+ }
62
+ function accessChangeHint(client, name, opts = {}) {
63
+ const bin = opts.bin ?? "fling";
64
+ const how = client === "cli" ? `Change who can open it with \`${bin} access ${name} --only-me | --members | --add <email> | --group <slug> | --public\`.` : `To change who can open it, call fling_set_access (only_me, members, groups, people or public).`;
65
+ return opts.invites ? `${how} An address from outside is invited as a reviewer, who sees only what is shared with them.` : how;
66
+ }
67
+ var count = (n, one, many) => `${n} ${n === 1 ? one : many}`;
68
+ function describeSiteAccess(site, members2) {
69
+ const level = siteAccessLevel(site);
70
+ const share2 = site.shareUrl && level !== "public" && level !== "members" ? " A share link is active: anyone with it can still open the site." : "";
71
+ if (!needsAudience(level)) return `${accessDescription(level, members2)}${share2}`;
72
+ const groups2 = site.audienceGroups ?? 0;
73
+ const people2 = (site.audienceSize ?? 0) - groups2;
74
+ const who = [
75
+ people2 ? count(people2, "person", "people") : "",
76
+ groups2 ? count(groups2, "group", "groups") : ""
77
+ ].filter(Boolean);
78
+ return `Only you and ${who.join(" and ")} can open it.${share2}`;
79
+ }
80
+
3
81
  // ../../packages/shared/src/api.ts
4
- import { z as z3 } from "zod";
82
+ import { z as z7 } from "zod";
5
83
 
6
84
  // ../../packages/shared/src/audience.ts
7
- import { z } from "zod";
85
+ import { z as z2 } from "zod";
8
86
  var AUDIENCE_EMAILS_MAX = 100;
9
87
  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()
88
+ var AudienceEmailSchema = z2.object({
89
+ email: z2.string(),
90
+ known: z2.boolean(),
91
+ guest: z2.boolean().optional()
92
+ });
93
+ var AudienceGroupSchema = z2.object({
94
+ slug: z2.string(),
95
+ name: z2.string(),
96
+ size: z2.number(),
97
+ deleted: z2.boolean().optional()
16
98
  });
17
- var AudienceSchema = z.object({
18
- emails: z.array(AudienceEmailSchema),
19
- groups: z.array(AudienceGroupSchema).default([])
99
+ var AudienceSchema = z2.object({
100
+ emails: z2.array(AudienceEmailSchema),
101
+ groups: z2.array(AudienceGroupSchema).default([])
20
102
  });
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([])
103
+ var AudienceInputSchema = z2.object({
104
+ emails: z2.array(z2.string().trim().toLowerCase().min(3).max(254)).max(AUDIENCE_EMAILS_MAX, `An audience holds at most ${AUDIENCE_EMAILS_MAX} addresses`),
105
+ groups: z2.array(z2.string().trim().toLowerCase().min(1).max(100)).max(AUDIENCE_GROUPS_MAX, `An audience names at most ${AUDIENCE_GROUPS_MAX} groups`).default([])
24
106
  });
25
107
  var normaliseEmail = (email) => email.trim().toLowerCase();
26
108
  function splitEmails(values) {
@@ -66,17 +148,23 @@ function whoIn(emails2, groups2) {
66
148
  ].filter(Boolean);
67
149
  }
68
150
  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(", ");
151
+ var audienceList = (emails2) => emails2.map((e) => {
152
+ const notes = [e.guest ? "guest" : "", e.known ? "" : "hasn't signed in yet"].filter(Boolean);
153
+ return notes.length ? `${e.email} (${notes.join(", ")})` : e.email;
154
+ }).join(", ");
70
155
  function describeAudience(site, privateAudience2) {
71
156
  const emails2 = site.audience?.emails ?? [];
72
157
  const groups2 = site.audience?.groups ?? [];
73
158
  const empty = emails2.length + groups2.length === 0;
74
159
  const list = emails2.length ? `: ${audienceList(emails2)}` : "";
75
160
  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.`;
161
+ const kept = empty ? "" : ` Its audience (${and(whoIn(emails2, groups2))}${list}) applies when it is private again.`;
162
+ return `Public: anyone can open the site; reviewing it still needs sign-in.${kept}`;
77
163
  }
78
- if (empty) return `Private: ${privateAudience2} can open it.`;
79
164
  const share2 = site.shareUrl ? ` A share link is active: anyone with it can still open the site (${site.shareUrl}).` : "";
165
+ if (empty) {
166
+ return site.restricted ? `Only you: nobody else can open it.${share2}` : `Private: ${privateAudience2} can open it.`;
167
+ }
80
168
  if (emails2.length === 0 && groups2.every((g) => g.deleted)) {
81
169
  const refs = groups2.map((g) => g.slug).join(", ");
82
170
  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}`;
@@ -92,15 +180,15 @@ function sharedVia(site) {
92
180
  }
93
181
  var describeSharedSite = (site) => `- ${site.name}: ${site.url} (shared by ${site.owner}, through ${sharedVia(site)}${site.reviewUrl ? `; review: ${site.reviewUrl}` : ""})`;
94
182
 
95
- // ../../packages/shared/src/manifest.ts
96
- import { z as z2 } from "zod";
97
-
98
183
  // ../../packages/shared/src/limits.ts
99
184
  var MAX_DEPLOY_BYTES = 100 * 1024 * 1024;
100
185
  var MAX_FILE_BYTES = 25 * 1024 * 1024;
101
186
  var MAX_FILES = 5e3;
102
187
  var MAX_PATH_LENGTH = 1024;
103
188
 
189
+ // ../../packages/shared/src/manifest.ts
190
+ import { z as z3 } from "zod";
191
+
104
192
  // ../../packages/shared/src/secrets.ts
105
193
  var SECRET_SCAN_MAX_FILE_BYTES = MAX_FILE_BYTES;
106
194
  var BINARY_SNIFF_BYTES = 8192;
@@ -291,246 +379,464 @@ function isValidSitePath(path3) {
291
379
  if (path3.startsWith("/") || path3.includes("\\") || path3.includes("\0")) return false;
292
380
  return path3.split("/").every((seg) => seg !== "" && !seg.startsWith("."));
293
381
  }
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)
382
+ var ManifestFileSchema = z3.object({
383
+ path: z3.string().refine(isValidSitePath, "invalid path (dotfiles are never published)").refine((p) => !isSecretPath(p), "secret file (keys, certificates, credentials) never published"),
384
+ sha256: z3.string().regex(/^[a-f0-9]{64}$/),
385
+ size: z3.number().int().min(0).max(MAX_FILE_BYTES),
386
+ contentType: z3.string().min(1).max(255)
387
+ });
388
+ var manifestOf = (files) => z3.object({ 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");
389
+ var ManifestSchema = manifestOf(z3.array(ManifestFileSchema).min(1).max(MAX_FILES));
390
+ var DeployManifestSchema = manifestOf(z3.array(ManifestFileSchema).max(MAX_FILES));
391
+
392
+ // ../../packages/shared/src/publish-path.ts
393
+ import { z as z4 } from "zod";
394
+ function normalizePublishPath(raw) {
395
+ const path3 = raw.trim().replace(/^\/+/, "").replace(/\/+$/, "");
396
+ return isValidSitePath(path3) ? path3 : null;
397
+ }
398
+ var PublishPathSchema = z4.string().transform((raw, ctx) => {
399
+ const path3 = normalizePublishPath(raw);
400
+ if (path3 === null) {
401
+ ctx.addIssue({
402
+ code: "custom",
403
+ message: "path must be a folder of the site, like 30-10-26 or reports/2026 (no dotfiles)"
404
+ });
405
+ return z4.NEVER;
406
+ }
407
+ return path3;
408
+ });
409
+
410
+ // ../../packages/shared/src/site-roles.ts
411
+ import { z as z5 } from "zod";
412
+ var OWNER_EMAILS_MAX = 20;
413
+ var OWNER_GROUPS_MAX = 5;
414
+ var PUBLISHER_EMAILS_MAX = 50;
415
+ var PUBLISHER_GROUPS_MAX = 10;
416
+ var SiteRoleSchema = z5.enum(["owner", "publisher", "admin"]);
417
+ var SiteRoleMembersSchema = z5.object({
418
+ emails: z5.array(AudienceEmailSchema),
419
+ groups: z5.array(AudienceGroupSchema).default([])
420
+ });
421
+ var roleInput = (what, emailsMax, groupsMax) => z5.object({
422
+ emails: z5.array(z5.string().trim().toLowerCase().min(3).max(254)).max(emailsMax, `A site has at most ${emailsMax} ${what} addresses`),
423
+ groups: z5.array(z5.string().trim().toLowerCase().min(1).max(100)).max(groupsMax, `A site names at most ${groupsMax} ${what} groups`).default([])
299
424
  });
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");
425
+ var OwnersInputSchema = roleInput("owner", OWNER_EMAILS_MAX, OWNER_GROUPS_MAX);
426
+ var PublishersInputSchema = roleInput(
427
+ "publisher",
428
+ PUBLISHER_EMAILS_MAX,
429
+ PUBLISHER_GROUPS_MAX
430
+ );
431
+ var editRoleInput = (current, change) => editAudienceInput(current, change);
432
+ function whoHolds(members2) {
433
+ const parts = [
434
+ ...(members2?.emails ?? []).map((e) => e.guest ? `${e.email} (guest)` : e.email),
435
+ ...(members2?.groups ?? []).map((g) => `the group ${groupLabel(g)}`)
436
+ ];
437
+ return parts.length ? parts.join(", ") : "nobody";
438
+ }
439
+ var roleLabel = (role) => role === "publisher" ? "a publisher" : `an ${role}`;
440
+ function describeRoles(site) {
441
+ if (!site.owners) return "";
442
+ const you = site.role ? ` You are ${roleLabel(site.role)}.` : "";
443
+ return `Owners: ${whoHolds(site.owners)}. Publishers: ${whoHolds(site.publishers)}.${you}`;
444
+ }
445
+
446
+ // ../../packages/shared/src/thumbnails.ts
447
+ import { z as z6 } from "zod";
448
+ var THUMBNAIL_MAX_BYTES = 200 * 1024;
449
+ var ThumbnailSchema = z6.object({ versionId: z6.string(), updatedAt: z6.string() });
450
+ var ThumbnailStampSchema = z6.object({ updatedAt: z6.string() });
301
451
 
302
452
  // ../../packages/shared/src/api.ts
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(),
453
+ var VisibilitySchema = z7.enum(["private", "public"]);
454
+ var MeSchema = z7.object({
455
+ email: z7.string(),
456
+ name: z7.string(),
457
+ picture: z7.string().nullable(),
458
+ userSlug: z7.string(),
309
459
  /** one of the instance's admins (servers from before admins don't say: false) */
310
- admin: z3.boolean().default(false),
460
+ admin: z7.boolean().default(false),
461
+ /** a guest: someone invited from outside who reviews what is shared with them, nothing else */
462
+ guest: z7.boolean().default(false),
311
463
  /** while an admin impersonates this user: the admin (browser sessions only) */
312
- impersonator: z3.string().optional(),
464
+ impersonator: z7.string().optional(),
313
465
  /** …and when the impersonation lapses */
314
- impersonationEndsAt: z3.string().optional()
466
+ impersonationEndsAt: z7.string().optional()
315
467
  });
316
- var SiteSchema = z3.object({
317
- name: z3.string(),
318
- url: z3.string(),
468
+ var SiteSchema = z7.object({
469
+ name: z7.string(),
470
+ url: z7.string(),
319
471
  visibility: VisibilitySchema,
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),
472
+ spa: z7.boolean(),
473
+ archived: z7.boolean(),
474
+ currentVersionId: z7.string().nullable(),
475
+ shareUrl: z7.string().nullable(),
476
+ /** who can open it (access.ts); servers before access levels omit it (`siteAccessLevel`) */
477
+ access: AccessLevelSchema.optional(),
478
+ /** private and limited to the owner and the audience (an empty one: the owner alone) */
479
+ restricted: z7.boolean().default(false),
326
480
  /** how many addresses and groups the audience names (kept, and ignored, while public) */
327
- audienceSize: z3.number().default(0),
481
+ audienceSize: z7.number().default(0),
328
482
  /** how many of those are groups */
329
- audienceGroups: z3.number().default(0),
330
- createdAt: z3.string(),
331
- updatedAt: z3.string()
483
+ audienceGroups: z7.number().default(0),
484
+ /** the caller's role on it (site-roles.ts); servers before site roles omit it */
485
+ role: SiteRoleSchema.optional(),
486
+ /** the caller may pull its current version (`pull` capability; servers without it omit it) */
487
+ canPull: z7.boolean().optional(),
488
+ /** its home page's thumbnail, when it has one (`thumbnails` capability; thumbnails.ts) */
489
+ thumbnail: ThumbnailSchema.optional(),
490
+ createdAt: z7.string(),
491
+ updatedAt: z7.string()
492
+ });
493
+ var SiteWithAudienceSchema = SiteSchema.extend({
494
+ audience: AudienceSchema.optional(),
495
+ owners: SiteRoleMembersSchema.optional(),
496
+ publishers: SiteRoleMembersSchema.optional()
497
+ });
498
+ var VersionSchema = z7.object({
499
+ id: z7.string(),
500
+ fileCount: z7.number(),
501
+ totalBytes: z7.number(),
502
+ /** who published it (email); absent on versions published before it was recorded */
503
+ publishedBy: z7.string().optional(),
504
+ /**
505
+ * A path publish (`pathPublish` capability): the folder it replaced (`30-10-26`, shown as
506
+ * `/30-10-26/`), and the version it carried the rest of the site forward from (absent when the
507
+ * site had none)
508
+ */
509
+ path: z7.string().optional(),
510
+ base: z7.string().optional(),
511
+ createdAt: z7.string()
332
512
  });
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()
513
+ var SiteDetailSchema = SiteWithAudienceSchema.extend({
514
+ owner: z7.string().optional(),
515
+ versions: z7.array(VersionSchema)
339
516
  });
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(),
517
+ var SiteListSchema = z7.object({ sites: z7.array(SiteSchema) });
518
+ var SharedSiteSchema = z7.object({
519
+ name: z7.string(),
520
+ url: z7.string(),
345
521
  /** the site's review page (`comments` capability) */
346
- reviewUrl: z3.string().optional(),
347
- owner: z3.string(),
522
+ reviewUrl: z7.string().optional(),
523
+ owner: z7.string(),
348
524
  /** 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()
525
+ via: z7.object({ direct: z7.boolean(), groups: z7.array(z7.string()) }),
526
+ /** when its thumbnail was taken, when it has one (`thumbnails` capability) */
527
+ thumbnail: ThumbnailStampSchema.optional(),
528
+ createdAt: z7.string(),
529
+ updatedAt: z7.string()
352
530
  });
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()
531
+ var SharedSiteListSchema = z7.object({ sites: z7.array(SharedSiteSchema) });
532
+ var SiteFileSchema = z7.object({
533
+ path: z7.string(),
534
+ size: z7.number(),
535
+ contentType: z7.string()
358
536
  });
359
- var VersionFilesSchema = z3.object({
360
- versionId: z3.string(),
361
- files: z3.array(SiteFileSchema),
362
- totalBytes: z3.number(),
363
- generatedIndex: z3.boolean()
537
+ var VersionFilesSchema = z7.object({
538
+ versionId: z7.string(),
539
+ files: z7.array(SiteFileSchema),
540
+ totalBytes: z7.number(),
541
+ generatedIndex: z7.boolean()
364
542
  });
365
- var NameCheckSchema = z3.object({
366
- name: z3.string(),
367
- status: z3.enum(["available", "yours", "taken", "invalid"]),
368
- suggestions: z3.array(z3.string())
543
+ var PULL_URL_TTL_SECONDS = 15 * 60;
544
+ var PullFileSchema = SiteFileSchema.extend({
545
+ /** hex SHA-256 of the bytes */
546
+ sha256: z7.string(),
547
+ /** a signed GET for the bytes, good for PULL_URL_TTL_SECONDS */
548
+ url: z7.string()
369
549
  });
370
- var DeployRequestSchema = z3.object({
371
- manifest: ManifestSchema,
372
- spa: z3.boolean().optional(),
373
- visibility: VisibilitySchema.optional()
550
+ var PullResponseSchema = z7.object({
551
+ versionId: z7.string(),
552
+ path: z7.string().optional(),
553
+ files: z7.array(PullFileSchema),
554
+ /** when the URLs stop working */
555
+ expiresAt: z7.string()
374
556
  });
375
- var UploadSchema = z3.object({
376
- path: z3.string(),
377
- url: z3.string(),
557
+ var NameCheckSchema = z7.object({
558
+ name: z7.string(),
559
+ status: z7.enum(["available", "yours", "taken", "invalid"]),
560
+ suggestions: z7.array(z7.string())
561
+ });
562
+ var WhySiteSchema = z7.object({ url: z7.string().nullable() });
563
+ var DeployRequestSchema = z7.object({
564
+ manifest: DeployManifestSchema,
565
+ path: PublishPathSchema.optional(),
566
+ base: z7.string().min(1).max(100).optional(),
567
+ partial: z7.boolean().optional(),
568
+ remove: z7.array(z7.string().min(1).max(MAX_PATH_LENGTH)).max(MAX_FILES).optional(),
569
+ spa: z7.boolean().optional(),
570
+ visibility: VisibilitySchema.optional(),
571
+ access: AccessLevelSchema.optional(),
572
+ audience: AudienceInputSchema.optional()
573
+ }).superRefine((req, ctx) => {
574
+ const issue = (message) => ctx.addIssue({ code: "custom", message });
575
+ if (req.manifest.files.length === 0 && !(req.partial && req.remove?.length)) {
576
+ issue("manifest.files: at least one file");
577
+ }
578
+ if (req.partial && req.base === void 0) issue("`partial` needs `base`");
579
+ if (req.remove !== void 0 && !req.partial) issue("`remove` needs `partial: true`");
580
+ if (req.base !== void 0 && req.path !== void 0) issue("Pass `base` or `path`, not both");
581
+ });
582
+ var UploadSchema = z7.object({
583
+ path: z7.string(),
584
+ url: z7.string(),
378
585
  /** headers the client must send with the PUT (they are part of the signature) */
379
- headers: z3.record(z3.string(), z3.string())
586
+ headers: z7.record(z7.string(), z7.string())
380
587
  });
381
- var DeployResponseSchema = z3.discriminatedUnion("status", [
588
+ var DeployResponseSchema = z7.discriminatedUnion("status", [
382
589
  /** content identical to the current version — nothing happened */
383
- z3.object({ status: z3.literal("unchanged"), versionId: z3.string(), site: SiteSchema }),
590
+ z7.object({ status: z7.literal("unchanged"), versionId: z7.string(), site: SiteSchema }),
384
591
  /** this content was deployed before — site now points at that version again */
385
- z3.object({ status: z3.literal("repointed"), versionId: z3.string(), site: SiteSchema }),
592
+ z7.object({ status: z7.literal("repointed"), versionId: z7.string(), site: SiteSchema }),
386
593
  /** new content — PUT every upload, then POST /api/sites/:name/deploys/:versionId/finalize */
387
- z3.object({
388
- status: z3.literal("upload"),
389
- versionId: z3.string(),
390
- uploads: z3.array(UploadSchema)
594
+ z7.object({
595
+ status: z7.literal("upload"),
596
+ versionId: z7.string(),
597
+ uploads: z7.array(UploadSchema)
391
598
  })
392
599
  ]);
393
- var FinalizeResponseSchema = z3.object({
394
- status: z3.literal("live"),
395
- versionId: z3.string(),
396
- site: SiteSchema
600
+ var FinalizeResponseSchema = z7.object({
601
+ status: z7.literal("live"),
602
+ versionId: z7.string(),
603
+ site: SiteSchema,
604
+ /** this deploy made the site live for the first time (a new site, its access just set) */
605
+ first: z7.boolean().optional()
397
606
  });
398
- var RollbackRequestSchema = z3.object({ versionId: z3.string() });
399
- var UpdateSiteRequestSchema = z3.object({
607
+ var RollbackRequestSchema = z7.object({ versionId: z7.string() });
608
+ var UpdateSiteRequestSchema = z7.object({
609
+ access: AccessLevelSchema.optional(),
400
610
  visibility: VisibilitySchema.optional(),
401
- spa: z3.boolean().optional(),
402
- audience: AudienceInputSchema.optional()
611
+ spa: z7.boolean().optional(),
612
+ audience: AudienceInputSchema.optional(),
613
+ /** the whole new list of owners (site-roles.ts): owners only */
614
+ owners: OwnersInputSchema.optional(),
615
+ /** the whole new list of publishers (site-roles.ts): owners only */
616
+ publishers: PublishersInputSchema.optional()
403
617
  });
404
- var ApiKeySchema = z3.object({
405
- id: z3.string(),
406
- name: z3.string(),
407
- createdAt: z3.string(),
408
- lastUsedAt: z3.string().nullable()
618
+ var ApiKeySchema = z7.object({
619
+ id: z7.string(),
620
+ name: z7.string(),
621
+ createdAt: z7.string(),
622
+ lastUsedAt: z7.string().nullable()
409
623
  });
410
- var ApiKeyListSchema = z3.object({ keys: z3.array(ApiKeySchema) });
411
- var ErrorEnvelopeSchema = z3.object({
412
- error: z3.object({ code: z3.string(), message: z3.string() })
624
+ var ApiKeyListSchema = z7.object({ keys: z7.array(ApiKeySchema) });
625
+ var ErrorEnvelopeSchema = z7.object({
626
+ error: z7.object({ code: z7.string(), message: z7.string() })
413
627
  });
414
- var ShareResponseSchema = z3.object({ shareUrl: z3.string() });
415
- var ConnectionSchema = z3.object({
416
- id: z3.string(),
417
- clientName: z3.string(),
628
+ var ShareResponseSchema = z7.object({ shareUrl: z7.string() });
629
+ var ConnectionSchema = z7.object({
630
+ id: z7.string(),
631
+ clientName: z7.string(),
418
632
  /** host the client identifies as (CIMD client_id host or redirect URI host) */
419
- clientHost: z3.string().nullable(),
420
- createdAt: z3.string(),
421
- lastUsedAt: z3.string().nullable()
633
+ clientHost: z7.string().nullable(),
634
+ createdAt: z7.string(),
635
+ lastUsedAt: z7.string().nullable()
422
636
  });
423
- var ConnectionListSchema = z3.object({ connections: z3.array(ConnectionSchema) });
424
- var OAuthRequestInfoSchema = z3.object({
425
- clientName: z3.string(),
426
- clientUri: z3.string().nullable(),
637
+ var ConnectionListSchema = z7.object({ connections: z7.array(ConnectionSchema) });
638
+ var OAuthRequestInfoSchema = z7.object({
639
+ clientName: z7.string(),
640
+ clientUri: z7.string().nullable(),
427
641
  /** host of the CIMD client_id URL, when the client identified itself that way */
428
- clientIdHost: z3.string().nullable(),
429
- redirectHost: z3.string(),
642
+ clientIdHost: z7.string().nullable(),
643
+ redirectHost: z7.string(),
430
644
  /** true when the code goes to a loopback address (any local program could be asking) */
431
- loopback: z3.boolean(),
432
- scopes: z3.array(z3.string()),
433
- email: z3.string()
645
+ loopback: z7.boolean(),
646
+ scopes: z7.array(z7.string()),
647
+ email: z7.string()
434
648
  });
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(),
649
+ var OAuthDecisionSchema = z7.object({ redirectUrl: z7.string() });
650
+ var AdminUserSchema = z7.object({
651
+ email: z7.string(),
652
+ name: z7.string(),
653
+ picture: z7.string().nullable(),
654
+ userSlug: z7.string(),
655
+ createdAt: z7.string().nullable(),
656
+ lastSignInAt: z7.string().nullable(),
443
657
  /** sites the user holds, archived ones included */
444
- siteCount: z3.number(),
445
- admin: z3.boolean()
658
+ siteCount: z7.number(),
659
+ admin: z7.boolean(),
660
+ /** outside the email policy, on a live guest invitation (see GET /api/admin/invitations) */
661
+ guest: z7.boolean()
446
662
  });
447
- var AdminUserListSchema = z3.object({
448
- users: z3.array(AdminUserSchema),
663
+ var AdminUserListSchema = z7.object({
664
+ users: z7.array(AdminUserSchema),
449
665
  /** pass back as `cursor` for the next page; null: no more */
450
- cursor: z3.string().nullable()
666
+ cursor: z7.string().nullable()
451
667
  });
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()
668
+ var AdminSiteSchema = z7.object({
669
+ name: z7.string(),
670
+ url: z7.string(),
671
+ owner: z7.string(),
672
+ access: AccessLevelSchema,
673
+ archived: z7.boolean(),
674
+ /** it has a current version */
675
+ live: z7.boolean(),
676
+ /** when its thumbnail was taken, when it has one (`thumbnails` capability) */
677
+ thumbnail: ThumbnailStampSchema.optional(),
678
+ createdAt: z7.string(),
679
+ updatedAt: z7.string()
680
+ });
681
+ var AdminSiteListSchema = z7.object({
682
+ sites: z7.array(AdminSiteSchema),
683
+ /** the list stops at the server's ceiling: there are more sites than it holds */
684
+ truncated: z7.boolean()
685
+ });
686
+ var SETTING_GROUPS = [
687
+ "sign_in",
688
+ "sites",
689
+ "branding",
690
+ "features",
691
+ "mail",
692
+ "platform"
693
+ ];
694
+ var SettingValueSchema = z7.union([z7.string(), z7.number(), z7.boolean(), z7.array(z7.string())]).nullable();
695
+ var AdminSettingsSchema = z7.object({
696
+ settings: z7.array(
697
+ z7.object({
698
+ key: z7.string(),
699
+ group: z7.enum(SETTING_GROUPS),
700
+ value: SettingValueSchema,
701
+ set: z7.boolean()
702
+ })
703
+ ),
704
+ secrets: z7.array(
705
+ z7.object({ name: z7.string(), group: z7.enum(SETTING_GROUPS), set: z7.boolean() }).strict()
706
+ )
707
+ });
708
+ var InvitationRoleSchema = z7.enum(["member", "guest"]);
709
+ var InvitationViaSchema = z7.enum(["admin", "share", "group"]);
710
+ var AdminInvitationSchema = z7.object({
711
+ email: z7.string(),
712
+ role: InvitationRoleSchema,
713
+ /** who invited them: the admin, or whoever shared */
714
+ invitedBy: z7.string(),
715
+ via: InvitationViaSchema,
716
+ createdAt: z7.string(),
717
+ /** set once revoked; the invitation is kept as the record */
718
+ revokedAt: z7.string().nullable(),
719
+ /** their first sign-in has happened */
720
+ signedIn: z7.boolean(),
721
+ lastSignInAt: z7.string().nullable()
722
+ });
723
+ var AdminInvitationListSchema = z7.object({
724
+ invitations: z7.array(AdminInvitationSchema),
725
+ /** pass back as `cursor` for the next page; null: no more */
726
+ cursor: z7.string().nullable()
727
+ });
728
+ var InviteRequestSchema = z7.object({
729
+ email: z7.string().trim().toLowerCase().min(3).max(254)
730
+ });
731
+ var AdminInviteResultSchema = z7.object({
732
+ invitation: AdminInvitationSchema,
733
+ /** the provider took the email */
734
+ sent: z7.boolean(),
735
+ /** why it didn't, in words (`rejected: sender not verified`) */
736
+ error: z7.string().nullable()
737
+ });
738
+ var AuditEntrySchema = z7.object({
739
+ id: z7.string(),
740
+ at: z7.string(),
741
+ action: z7.enum([
742
+ "impersonation.start",
743
+ "impersonation.stop",
744
+ "invitation.create",
745
+ "invitation.revoke",
746
+ "site.change",
747
+ "site.open"
748
+ ]),
749
+ actor: z7.string(),
750
+ /** the person, or for site.change and site.open the site's name */
751
+ target: z7.string(),
752
+ /** site.change, site.open: the site's owner */
753
+ owner: z7.string().optional(),
754
+ /** site.change: what the admin did (a policy action, e.g. `site.archive`) */
755
+ change: z7.string().optional(),
756
+ /** invitation.create: how it came about (an admin's invite, a share, a group) */
757
+ via: InvitationViaSchema.optional(),
758
+ /** invitation.create: what it lets them be */
759
+ role: InvitationRoleSchema.optional(),
760
+ until: z7.string().optional(),
761
+ lapsed: z7.boolean().optional()
762
+ });
763
+ var AuditListSchema = z7.object({ entries: z7.array(AuditEntrySchema) });
764
+ var ImpersonateRequestSchema = z7.object({ email: z7.string().trim().toLowerCase().min(3) });
765
+ var SignInLinkRequestSchema = z7.object({
766
+ email: z7.string().trim().toLowerCase().min(3).max(254),
767
+ return: z7.string().max(2048).optional()
460
768
  });
461
- var AuditListSchema = z3.object({ entries: z3.array(AuditEntrySchema) });
462
- var ImpersonateRequestSchema = z3.object({ email: z3.string().trim().toLowerCase().min(3) });
463
769
 
464
770
  // ../../packages/shared/src/comments.ts
465
- import { z as z4 } from "zod";
771
+ import { z as z8 } from "zod";
466
772
  var COMMENT_MAX_CHARS = 4e3;
467
773
  var COMMENT_PATH_MAX = 1024;
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() })
774
+ var CommentAuthorSchema = z8.discriminatedUnion("kind", [
775
+ z8.object({ kind: z8.literal("user"), email: z8.string() }),
776
+ z8.object({ kind: z8.literal("guest"), name: z8.string() })
471
777
  ]);
472
778
  var ANCHOR_SELECTOR_MAX = 512;
473
779
  var ANCHOR_QUOTE_MAX = 500;
474
- var px = z4.number().finite().min(-1e7).max(1e7);
475
- var percent = z4.number().min(0).max(100);
476
- var CommentAnchorSchema = z4.object({
780
+ var px = z8.number().finite().min(-1e7).max(1e7);
781
+ var percent = z8.number().min(0).max(100);
782
+ var CommentAnchorSchema = z8.object({
477
783
  x: percent,
478
784
  y: percent,
479
- selector: z4.string().min(1).max(ANCHOR_SELECTOR_MAX).optional(),
785
+ selector: z8.string().min(1).max(ANCHOR_SELECTOR_MAX).optional(),
480
786
  ex: percent.optional(),
481
787
  ey: percent.optional(),
482
788
  docX: px.optional(),
483
789
  docY: px.optional(),
484
790
  scrollX: px.optional(),
485
791
  scrollY: px.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(),
792
+ vw: z8.number().finite().min(0).max(1e5).optional(),
793
+ vh: z8.number().finite().min(0).max(1e5).optional(),
794
+ hash: z8.string().max(COMMENT_PATH_MAX).optional(),
489
795
  ew: percent.optional(),
490
796
  eh: percent.optional(),
491
- docW: z4.number().finite().min(0).max(1e7).optional(),
492
- docH: z4.number().finite().min(0).max(1e7).optional(),
493
- quote: z4.string().min(1).max(ANCHOR_QUOTE_MAX).optional()
797
+ docW: z8.number().finite().min(0).max(1e7).optional(),
798
+ docH: z8.number().finite().min(0).max(1e7).optional(),
799
+ quote: z8.string().min(1).max(ANCHOR_QUOTE_MAX).optional()
494
800
  });
495
- var CommentSchema = z4.object({
496
- id: z4.string(),
801
+ var CommentSchema = z8.object({
802
+ id: z8.string(),
497
803
  /** the thread this replies to; null for the comment that starts a thread */
498
- parentId: z4.string().nullable(),
804
+ parentId: z8.string().nullable(),
499
805
  /** the page, as normalizeCommentPath() writes it ('/', '/docs/', '/about.html') */
500
- path: z4.string(),
806
+ path: z8.string(),
501
807
  /** the version the site was serving when the comment was made (null: none was) */
502
- versionId: z4.string().nullable(),
808
+ versionId: z8.string().nullable(),
503
809
  anchor: CommentAnchorSchema.nullable(),
504
- body: z4.string(),
810
+ body: z8.string(),
505
811
  author: CommentAuthorSchema,
506
- createdAt: z4.string(),
812
+ createdAt: z8.string(),
507
813
  /** threads only: when and by whom it was resolved (null: open) */
508
- resolvedAt: z4.string().nullable(),
814
+ resolvedAt: z8.string().nullable(),
509
815
  resolvedBy: CommentAuthorSchema.nullable(),
510
816
  /** whether the caller may delete it (policy's answer, so clients don't guess) */
511
- canDelete: z4.boolean()
817
+ canDelete: z8.boolean()
512
818
  });
513
- var CommentListSchema = z4.object({
514
- site: z4.object({
515
- name: z4.string(),
516
- url: z4.string(),
517
- currentVersionId: z4.string().nullable()
819
+ var CommentListSchema = z8.object({
820
+ site: z8.object({
821
+ name: z8.string(),
822
+ url: z8.string(),
823
+ currentVersionId: z8.string().nullable()
518
824
  }),
519
- comments: z4.array(CommentSchema),
520
- can: z4.object({
521
- create: z4.boolean(),
522
- resolve: z4.boolean(),
825
+ comments: z8.array(CommentSchema),
826
+ can: z8.object({
827
+ create: z8.boolean(),
828
+ resolve: z8.boolean(),
523
829
  /** delete anyone's comment (everyone may delete their own) */
524
- deleteAny: z4.boolean()
830
+ deleteAny: z8.boolean()
525
831
  })
526
832
  });
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(),
833
+ var CreateCommentRequestSchema = z8.object({
834
+ body: z8.string().trim().min(1).max(COMMENT_MAX_CHARS),
835
+ path: z8.string().max(COMMENT_PATH_MAX).optional(),
836
+ parentId: z8.string().min(1).max(100).optional(),
531
837
  anchor: CommentAnchorSchema.optional()
532
838
  });
533
- var UpdateCommentRequestSchema = z4.object({ resolved: z4.boolean() });
839
+ var UpdateCommentRequestSchema = z8.object({ resolved: z8.boolean() });
534
840
  function commentThreads(comments) {
535
841
  const threads = /* @__PURE__ */ new Map();
536
842
  for (const c of comments) {
@@ -545,17 +851,18 @@ function authorLabel(author) {
545
851
  return author.kind === "user" ? author.email : `${author.name} (guest)`;
546
852
  }
547
853
  function reviewUrl(appOrigin, name, path3) {
548
- const base = `${appOrigin.replace(/\/+$/, "")}/review/${name === "<name>" ? name : encodeURIComponent(name)}`;
549
- return path3 && path3 !== "/" ? `${base}?path=${encodeURIComponent(path3)}` : base;
854
+ const base2 = `${appOrigin.replace(/\/+$/, "")}/review/${name === "<name>" ? name : encodeURIComponent(name)}`;
855
+ return path3 && path3 !== "/" ? `${base2}?path=${encodeURIComponent(path3)}` : base2;
550
856
  }
551
857
 
552
858
  // ../../packages/shared/src/distribution.ts
553
859
  var CLI_PACKAGE = "@nearform/fling";
554
860
  var REPO_SLUG = "nearform/fling";
555
861
  var DEFAULT_INSTALL_COMMAND = `npm install -g ${CLI_PACKAGE}`;
862
+ var REPO_URL = `https://github.com/${REPO_SLUG}`;
556
863
 
557
864
  // ../../packages/shared/src/groups.ts
558
- import { z as z5 } from "zod";
865
+ import { z as z9 } from "zod";
559
866
  var GROUP_MEMBERS_MAX = 500;
560
867
  var GROUP_SLUG_MAX = 50;
561
868
  var GROUP_NAME_MAX = 100;
@@ -564,30 +871,30 @@ function isValidGroupSlug(slug) {
564
871
  return slug.length <= GROUP_SLUG_MAX && GROUP_SLUG_RE.test(slug) && !slug.includes("--");
565
872
  }
566
873
  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(),
874
+ var GroupSlugSchema = z9.string().trim().toLowerCase().refine(isValidGroupSlug, `A group's slug is ${GROUP_SLUG_RULES}`);
875
+ var GroupNameSchema = z9.string().trim().min(1).max(GROUP_NAME_MAX);
876
+ var memberEmails = z9.array(z9.string().trim().toLowerCase().min(3).max(254)).max(GROUP_MEMBERS_MAX, `A group holds at most ${GROUP_MEMBERS_MAX} people`);
877
+ var GroupSummarySchema = z9.object({
878
+ slug: z9.string(),
879
+ name: z9.string(),
573
880
  /** 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()
881
+ owner: z9.string(),
882
+ size: z9.number(),
883
+ createdAt: z9.string(),
884
+ updatedAt: z9.string()
578
885
  });
579
- var GroupListSchema = z5.object({ groups: z5.array(GroupSummarySchema) });
886
+ var GroupListSchema = z9.object({ groups: z9.array(GroupSummarySchema) });
580
887
  var GroupDetailSchema = GroupSummarySchema.extend({
581
- members: z5.array(AudienceEmailSchema),
582
- canManage: z5.boolean()
888
+ members: z9.array(AudienceEmailSchema),
889
+ canManage: z9.boolean()
583
890
  });
584
- var CreateGroupRequestSchema = z5.object({
891
+ var CreateGroupRequestSchema = z9.object({
585
892
  slug: GroupSlugSchema,
586
893
  name: GroupNameSchema,
587
894
  members: memberEmails.optional()
588
895
  });
589
- var UpdateGroupRequestSchema = z5.object({ name: GroupNameSchema });
590
- var GroupMembersRequestSchema = z5.object({
896
+ var UpdateGroupRequestSchema = z9.object({ name: GroupNameSchema });
897
+ var GroupMembersRequestSchema = z9.object({
591
898
  add: memberEmails.optional(),
592
899
  remove: memberEmails.optional()
593
900
  });
@@ -642,48 +949,67 @@ function siteUrl(label, sitesDomain, protocol = "https:") {
642
949
  }
643
950
 
644
951
  // ../../packages/shared/src/instance.ts
645
- import { z as z6 } from "zod";
952
+ import { z as z10 } from "zod";
646
953
  var INSTANCE_METADATA_PATH = "/.well-known/fling";
647
954
  var HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
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()
955
+ var color = z10.string().regex(HEX_COLOR_RE);
956
+ var SignInProviderSchema = z10.looseObject({
957
+ /** `google` or `oidc` today; clients pass it back as is. */
958
+ id: z10.string().min(1),
959
+ /** "Sign in with <displayName>". */
960
+ displayName: z10.string().min(1),
961
+ issuer: z10.string().optional()
962
+ });
963
+ var InstanceAuthSchema = z10.looseObject({
964
+ kind: z10.string().min(1),
965
+ displayName: z10.string().min(1),
966
+ issuer: z10.string().optional(),
967
+ providers: z10.array(SignInProviderSchema).optional(),
968
+ emailDomains: z10.array(z10.string()).default([]),
969
+ invitedEmails: z10.boolean().optional()
655
970
  });
656
- var BrandColorsSchema = z6.looseObject({
971
+ var BrandColorsSchema = z10.looseObject({
657
972
  primary: color.optional(),
658
973
  primaryContent: color.optional(),
659
974
  accent: color.optional(),
660
975
  ink: color.optional(),
661
976
  highlight: color.optional()
662
977
  });
663
- var InstanceBrandSchema = z6.looseObject({
978
+ var InstanceBrandSchema = z10.looseObject({
664
979
  /** https: or data: URL of the logo mark shown before the name (light theme). */
665
- logoUrl: z6.string().optional(),
980
+ logoUrl: z10.string().optional(),
666
981
  /** The same for the dark theme; defaults to `logoUrl`. */
667
- logoDarkUrl: z6.string().optional(),
668
- faviconUrl: z6.string().optional(),
982
+ logoDarkUrl: z10.string().optional(),
983
+ faviconUrl: z10.string().optional(),
669
984
  colors: BrandColorsSchema.default({})
670
985
  });
671
- var InstanceMetadataSchema = z6.looseObject({
672
- version: z6.number().int().min(1),
986
+ var InstanceMetadataSchema = z10.looseObject({
987
+ version: z10.number().int().min(1),
673
988
  /** Display name, e.g. "Fling". */
674
- name: z6.string().min(1),
989
+ name: z10.string().min(1),
675
990
  /** e.g. `https://fling.example.test` (no trailing slash) */
676
- appOrigin: z6.string().url(),
991
+ appOrigin: z10.string().url(),
677
992
  /** host[:port] sites hang off: a site is `<name>.<sitesDomain>` */
678
- sitesDomain: z6.string().min(1),
679
- siteScheme: z6.enum(["http", "https"]),
993
+ sitesDomain: z10.string().min(1),
994
+ siteScheme: z10.enum(["http", "https"]),
680
995
  auth: InstanceAuthSchema,
681
996
  brand: InstanceBrandSchema.default({ colors: {} }),
682
997
  /** Feature flags: a client hides what is missing or false. */
683
- capabilities: z6.record(z6.string(), z6.boolean()).default({}),
998
+ capabilities: z10.record(z10.string(), z10.boolean()).default({}),
684
999
  /** How to install the CLI, e.g. `npm install -g <package>`. */
685
- installCommand: z6.string().min(1)
1000
+ installCommand: z10.string().min(1),
1001
+ /**
1002
+ * The access level a new site gets when its first deploy names none (`SITE_DEFAULT_ACCESS`).
1003
+ * Missing (older servers) or unknown: `members` (`defaultAccessOf`).
1004
+ */
1005
+ defaultAccess: DefaultAccessLevelSchema.optional().catch(void 0),
1006
+ /**
1007
+ * The Fling release the server runs, e.g. `0.7.0` (the root package.json's version, built in).
1008
+ * Missing on servers built before releases were stamped.
1009
+ */
1010
+ release: z10.string().min(1).optional().catch(void 0)
686
1011
  });
1012
+ var defaultAccessOf = (instance) => instance?.defaultAccess ?? LEGACY_DEFAULT_ACCESS;
687
1013
  function instanceSitesPattern(instance) {
688
1014
  return `${instance.siteScheme}://<name>.${instance.sitesDomain}`;
689
1015
  }
@@ -706,22 +1032,22 @@ function instanceMetadataUrl(serverUrl) {
706
1032
  }
707
1033
 
708
1034
  // ../../packages/shared/src/kvs.ts
709
- import { z as z7 } from "zod";
1035
+ import { z as z11 } from "zod";
710
1036
 
711
1037
  // ../../packages/shared/src/route-keys.ts
712
1038
  var SITE_PASS_TTL_SECONDS = 5 * 60;
713
1039
 
714
1040
  // ../../packages/shared/src/kvs.ts
715
- var KvsSiteValueSchema = z7.object({
1041
+ var KvsSiteValueSchema = z11.object({
716
1042
  /** siteId — S3 prefix is `sites/<id>/<v>/` */
717
- id: z7.string(),
1043
+ id: z11.string(),
718
1044
  /** current versionId */
719
- v: z7.string(),
720
- vis: z7.enum(["private", "public"]),
1045
+ v: z11.string(),
1046
+ vis: z11.enum(["private", "public"]),
721
1047
  /** serve /index.html for unknown extensionless paths */
722
- spa: z7.boolean(),
1048
+ spa: z11.boolean(),
723
1049
  /** present when the site is restricted (private with an audience): the edge wants a site pass */
724
- r: z7.literal(1).optional()
1050
+ r: z11.literal(1).optional()
725
1051
  });
726
1052
 
727
1053
  // ../../packages/shared/src/listing.ts
@@ -760,7 +1086,7 @@ ${rows}
760
1086
  </ul>`;
761
1087
  }).join("\n");
762
1088
  const body = pages.length ? sections : '<p class="empty">No HTML pages in this fling.</p>';
763
- const count = `${pages.length} page${pages.length === 1 ? "" : "s"}`;
1089
+ const count2 = `${pages.length} page${pages.length === 1 ? "" : "s"}`;
764
1090
  return `<!doctype html>
765
1091
  <html lang="en">
766
1092
  <head>
@@ -795,7 +1121,7 @@ ${rows}
795
1121
  </head>
796
1122
  <body>
797
1123
  <main>
798
- <header><h1>${escapeHtml(siteName2)}</h1><p>${count}</p></header>
1124
+ <header><h1>${escapeHtml(siteName2)}</h1><p>${count2}</p></header>
799
1125
  ${body}
800
1126
  <footer>Flung with Fling</footer>
801
1127
  </main>
@@ -805,7 +1131,265 @@ ${body}
805
1131
  }
806
1132
 
807
1133
  // ../../packages/shared/src/mcp-tools.ts
808
- import { z as z8 } from "zod";
1134
+ import { z as z13 } from "zod";
1135
+
1136
+ // ../../packages/shared/src/notifications.ts
1137
+ import { z as z12 } from "zod";
1138
+ var CHANNEL_IDS = ["email"];
1139
+ var ChannelIdSchema = z12.enum(CHANNEL_IDS);
1140
+ var DeliverySchema = z12.enum(["on", "off"]);
1141
+ var EVENT_KEYS = [
1142
+ "site.shared",
1143
+ "site.shared_via_group",
1144
+ "site.owner_added",
1145
+ "site.publisher_added",
1146
+ "group.member_added",
1147
+ "comment.created",
1148
+ "comment.reply"
1149
+ ];
1150
+ var EventKeySchema = z12.enum(EVENT_KEYS);
1151
+ var EVENT_CATALOGUE = [
1152
+ {
1153
+ key: "site.shared",
1154
+ title: "Someone shares a site with you",
1155
+ description: "A site owner adds you to who may see their site.",
1156
+ section: "sharing",
1157
+ defaults: { email: "on" },
1158
+ coolDown: false
1159
+ },
1160
+ {
1161
+ key: "site.shared_via_group",
1162
+ title: "Someone shares a site with a group you are in",
1163
+ description: "A site owner adds one of your groups to who may see their site.",
1164
+ section: "sharing",
1165
+ defaults: { email: "on" },
1166
+ coolDown: false
1167
+ },
1168
+ {
1169
+ key: "site.owner_added",
1170
+ title: "Someone makes you an owner of a site",
1171
+ description: "A site's owner adds you, or a group you are in, to its owners.",
1172
+ section: "sharing",
1173
+ defaults: { email: "on" },
1174
+ coolDown: false,
1175
+ capability: "siteRoles"
1176
+ },
1177
+ {
1178
+ key: "site.publisher_added",
1179
+ title: "Someone makes you a publisher of a site",
1180
+ description: "A site's owner adds you, or a group you are in, to who may publish it.",
1181
+ section: "sharing",
1182
+ defaults: { email: "on" },
1183
+ coolDown: false,
1184
+ capability: "siteRoles"
1185
+ },
1186
+ {
1187
+ key: "group.member_added",
1188
+ title: "Someone adds you to a group",
1189
+ description: "A group's owner adds you to it, opening the sites shared with it to you.",
1190
+ section: "groups",
1191
+ defaults: { email: "on" },
1192
+ coolDown: false
1193
+ },
1194
+ {
1195
+ key: "comment.created",
1196
+ title: "Someone comments on your site",
1197
+ description: "A new thread is started on a site you own.",
1198
+ section: "comments",
1199
+ defaults: { email: "on" },
1200
+ coolDown: true,
1201
+ capability: "comments"
1202
+ },
1203
+ {
1204
+ key: "comment.reply",
1205
+ title: "Someone replies in your thread",
1206
+ description: "A reply is added to a thread you started or replied in.",
1207
+ section: "comments",
1208
+ defaults: { email: "on" },
1209
+ coolDown: true,
1210
+ capability: "comments"
1211
+ }
1212
+ ];
1213
+ var BY_KEY = new Map(EVENT_CATALOGUE.map((e) => [e.key, e]));
1214
+ var NotificationPreferencesUpdateSchema = z12.object({
1215
+ muted: z12.boolean().optional(),
1216
+ cells: z12.partialRecord(EventKeySchema, z12.partialRecord(ChannelIdSchema, DeliverySchema.nullable())).optional()
1217
+ });
1218
+ var EventDefinitionSchema = z12.looseObject({
1219
+ key: z12.string(),
1220
+ title: z12.string(),
1221
+ description: z12.string(),
1222
+ section: z12.string(),
1223
+ defaults: z12.record(z12.string(), DeliverySchema),
1224
+ coolDown: z12.boolean(),
1225
+ capability: z12.string().optional()
1226
+ });
1227
+ var NotificationSettingsSchema = z12.looseObject({
1228
+ channels: z12.array(z12.string()),
1229
+ events: z12.array(EventDefinitionSchema),
1230
+ muted: z12.boolean(),
1231
+ cells: z12.record(z12.string(), z12.record(z12.string(), DeliverySchema)),
1232
+ overrides: z12.record(z12.string(), z12.record(z12.string(), DeliverySchema)).default({})
1233
+ });
1234
+ var SentNotificationSchema = z12.looseObject({
1235
+ id: z12.string(),
1236
+ channel: z12.string(),
1237
+ /** the events it told them about (several when folded) */
1238
+ eventKeys: z12.array(z12.string()),
1239
+ subject: z12.string(),
1240
+ /** the provider's message id, when it gave one */
1241
+ providerId: z12.string().nullable(),
1242
+ sentAt: z12.string()
1243
+ });
1244
+ var SentNotificationListSchema = z12.object({ sent: z12.array(SentNotificationSchema) });
1245
+ var MailCountsSchema = z12.object({
1246
+ sent: z12.number(),
1247
+ retrying: z12.number(),
1248
+ givenUp: z12.number(),
1249
+ suppressed: z12.number(),
1250
+ dropped: z12.number()
1251
+ });
1252
+ var MailAdminSchema = z12.object({
1253
+ config: z12.object({
1254
+ driver: z12.string(),
1255
+ from: z12.string(),
1256
+ fromName: z12.string(),
1257
+ replyTo: z12.string().nullable(),
1258
+ configurationSet: z12.string().nullable(),
1259
+ /** outside invitations are on (`INVITES`) */
1260
+ invites: z12.boolean()
1261
+ }),
1262
+ health: z12.object({
1263
+ /** the first hour counted (UTC, `2026-10-28T14`) */
1264
+ since: z12.string(),
1265
+ /** the hours with any activity, oldest first */
1266
+ hours: z12.array(MailCountsSchema.extend({ hour: z12.string() })),
1267
+ totals: MailCountsSchema,
1268
+ /** the latest failed sends, newest first */
1269
+ failures: z12.array(
1270
+ z12.object({
1271
+ at: z12.string(),
1272
+ batchId: z12.string(),
1273
+ eventKeys: z12.array(z12.string()),
1274
+ reason: z12.string(),
1275
+ detail: z12.string().nullable()
1276
+ })
1277
+ )
1278
+ }),
1279
+ /** test emails this admin may still send this hour, of `limit` */
1280
+ tests: z12.object({ remaining: z12.number(), limit: z12.number() })
1281
+ });
1282
+ var MailTestRequestSchema = z12.object({
1283
+ to: z12.string().trim().toLowerCase().min(3).max(254)
1284
+ });
1285
+ var MailTestResultSchema = z12.discriminatedUnion("ok", [
1286
+ z12.object({ ok: z12.literal(true), providerId: z12.string().nullable(), subject: z12.string() }),
1287
+ z12.object({
1288
+ ok: z12.literal(false),
1289
+ reason: z12.string(),
1290
+ detail: z12.string().nullable(),
1291
+ error: z12.string()
1292
+ })
1293
+ ]);
1294
+ var CellChangeSchema = z12.object({
1295
+ event: EventKeySchema,
1296
+ channel: ChannelIdSchema,
1297
+ value: z12.enum(["on", "off", "default"])
1298
+ });
1299
+ function preferencesUpdateOf(change) {
1300
+ const cells = {};
1301
+ for (const c of change.cells ?? []) {
1302
+ cells[c.event] = { ...cells[c.event], [c.channel]: c.value === "default" ? null : c.value };
1303
+ }
1304
+ return {
1305
+ ...change.muted === void 0 ? {} : { muted: change.muted },
1306
+ ...Object.keys(cells).length ? { cells } : {}
1307
+ };
1308
+ }
1309
+ var SECTION_TITLES = {
1310
+ sharing: "Sharing",
1311
+ groups: "Groups",
1312
+ comments: "Comments"
1313
+ };
1314
+ var sectionTitle = (section) => SECTION_TITLES[section] ?? section;
1315
+ function describeNotificationSettings(settings) {
1316
+ const lines = [
1317
+ settings.muted ? "All notifications are muted: nothing is sent, whatever the settings below say." : `Notifications are on. Channels: ${settings.channels.join(", ")}.`
1318
+ ];
1319
+ let section = "";
1320
+ for (const e of settings.events) {
1321
+ if (e.section !== section) {
1322
+ section = e.section;
1323
+ lines.push("", `${sectionTitle(section)}:`);
1324
+ }
1325
+ const cells = settings.channels.map((ch) => {
1326
+ const value = settings.cells[e.key]?.[ch] ?? "off";
1327
+ const own = settings.overrides[e.key]?.[ch] !== void 0;
1328
+ return `${ch} ${value}${own ? " (yours)" : ""}`;
1329
+ });
1330
+ lines.push(`- ${e.key}: ${e.title} \u2014 ${cells.join(", ")}`);
1331
+ }
1332
+ return lines.join("\n");
1333
+ }
1334
+
1335
+ // ../../packages/shared/src/read-files.ts
1336
+ var MCP_READ_MAX_BYTES = 1.5 * 1024 * 1024;
1337
+ function pageOfFiles(files, opts) {
1338
+ const wanted = opts.paths ? new Set(opts.paths) : null;
1339
+ const selected = wanted ? files.filter((f) => wanted.has(f.path)) : [...files];
1340
+ const found = new Set(selected.map((f) => f.path));
1341
+ const missing = opts.paths ? opts.paths.filter((p) => !found.has(p)) : [];
1342
+ const start = Math.max(0, Number.parseInt(opts.cursor ?? "0", 10) || 0);
1343
+ const page = [];
1344
+ const tooLarge = [];
1345
+ let used = 0;
1346
+ for (let i = start; i < selected.length; i++) {
1347
+ const file = selected[i];
1348
+ if (file.size > opts.budget) {
1349
+ tooLarge.push(file);
1350
+ continue;
1351
+ }
1352
+ if (used + file.size > opts.budget) return { page, tooLarge, missing, nextCursor: String(i) };
1353
+ page.push(file);
1354
+ used += file.size;
1355
+ }
1356
+ return { page, tooLarge, missing };
1357
+ }
1358
+ function encodeForAgent(path3, bytes) {
1359
+ if (isScannablePath(path3) && !looksBinary(bytes)) {
1360
+ try {
1361
+ return { encoding: "utf8", content: new TextDecoder("utf-8", { fatal: true }).decode(bytes) };
1362
+ } catch {
1363
+ }
1364
+ }
1365
+ let binary = "";
1366
+ for (let i = 0; i < bytes.length; i += 32768) {
1367
+ binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
1368
+ }
1369
+ return { encoding: "base64", content: btoa(binary) };
1370
+ }
1371
+ function describeReadFiles(name, result) {
1372
+ const read = result.files.filter((f) => !f.tooLarge);
1373
+ const big = result.files.filter((f) => f.tooLarge);
1374
+ const lines = [
1375
+ `${read.length} file(s) of ${name}, version ${result.versionId}: ${read.map((f) => f.path).join(", ") || "(none)"}.`
1376
+ ];
1377
+ if (big.length) {
1378
+ lines.push(
1379
+ `Too large to return here (left out; they stay as they are when you publish on this base): ${big.map((f) => f.path).join(", ")}.`
1380
+ );
1381
+ }
1382
+ if (result.missing.length) lines.push(`Not in this version: ${result.missing.join(", ")}.`);
1383
+ if (result.nextCursor) {
1384
+ lines.push(
1385
+ `More files: call fling_read_files again with cursor "${result.nextCursor}" (or name the files you need in \`paths\`).`
1386
+ );
1387
+ }
1388
+ lines.push(
1389
+ `To change the site, call fling_publish with base "${result.versionId}" and only the files you changed or added (and \`remove\` for files to delete): every other file is kept.`
1390
+ );
1391
+ return lines.join("\n");
1392
+ }
809
1393
 
810
1394
  // ../../packages/shared/src/site-sort.ts
811
1395
  var SITE_SORTS = ["updated", "created", "name"];
@@ -838,38 +1422,65 @@ function mcpWording(instance) {
838
1422
  sitesPattern: instanceSitesPattern(instance),
839
1423
  privateAudience: privateAudience(instance),
840
1424
  signIn: `their ${instance.auth.displayName} account`,
841
- reviewPattern: instance.appOrigin ? reviewUrl(instance.appOrigin, "<name>") : GENERIC_REVIEW
1425
+ reviewPattern: instance.appOrigin ? reviewUrl(instance.appOrigin, "<name>") : GENERIC_REVIEW,
1426
+ defaultAccess: defaultAccessOf(instance),
1427
+ invites: instance.capabilities?.invites === true
842
1428
  };
843
1429
  }
1430
+ var addressRule = (w) => w.invites ? `Each address must be one that can sign in to ${w.name}, or one from outside it, which is invited as a reviewer (a guest who signs in with an emailed link and sees only what is shared with them; a few a day per person, else \`too_many_invites\`).` : `Each address must be one that can sign in to ${w.name}.`;
1431
+ var defaultAccessPhrase = (w) => w.defaultAccess === "members" ? `open to ${w.privateAudience}` : w.defaultAccess === "only_me" ? "visible only to the user" : "given the Fling's default (only the user, unless the Fling is set up otherwise)";
1432
+ var ACCESS_NOTE = (w) => "Before publishing a new site, ask the user who should be able to open it, unless they already said, and pass `access`: `only_me` (only the user), `members` (" + w.privateAudience + `), \`groups\` or \`people\` (only the groups in \`groups\`, by slug, and the addresses in \`emails\`; one site may name both), or \`public\` (anyone, no sign-in; only if the user asks). Without \`access\` a new site is ${defaultAccessPhrase(w)}. Re-publishing never changes who can open an existing site: use fling_set_access for that. Share links (fling_share) open a site to anyone who has them.`;
844
1433
  var NAME_REQUIRED = 'Publishing needs a name: if the user did not give one, propose a short descriptive one (e.g. "q3-report") and confirm it with them first (fling_check_name tells you whether it is free). If the name is taken by someone else the call fails with free suggestions \u2014 offer those to the user and retry with the one they pick.';
845
1434
  var INLINE_ONLY = "If you created the content in this conversation, pass it directly \u2014 never pass file paths from your sandbox (this server cannot read them) and never try to curl/upload from your sandbox.";
846
1435
  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.";
847
1436
  var ENCODING = '`encoding` is "utf8" (default, for text: HTML, CSS, JS, SVG, JSON, Markdown\u2026) or "base64" (binary: images, fonts, PDFs).';
848
1437
  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.";
849
- var listHtml = z8.boolean().optional().describe(
1438
+ var listHtml = z13.boolean().optional().describe(
850
1439
  "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"
851
1440
  );
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(
1441
+ var siteName = z13.string().min(1).max(200).describe('Site name, e.g. "q3-report"');
1442
+ var PATH_PUBLISH_NOTE = 'With `path` (e.g. "30-10-26"), only that folder of the site is replaced: the files go to <site>/30-10-26/\u2026 (give their paths relative to the folder, its page being "index.html") and every other file of the site stays as it is. Use it to add one report, release note or week\'s page to a shared site without republishing the rest; rolling back removes it again.';
1443
+ var PULL_NOTE = "To change a site that is already published (fix a typo, update a page, add one), start from what is live, not from scratch: read its files with fling_read_files, then call fling_publish with `base` (the `versionId` it returned) and only the files you changed or added, plus `remove` for files to delete; every other file stays as it is. If someone published the site in between, it fails with base_changed: read the files again and redo the changes, rather than publishing without `base` over theirs.";
1444
+ var base = z13.string().min(1).max(100).optional().describe(
1445
+ "The versionId fling_read_files returned: publish only what changed on top of it. `files` then holds only the files you changed or added, and every other file of the site is kept. Fails with base_changed if the site has changed since. Not with `path`"
1446
+ );
1447
+ var remove = z13.array(z13.string().min(1).max(1024)).max(MAX_FILES).optional().describe("With `base`: the paths of the files to delete from the site");
1448
+ var publishPath = z13.string().min(1).max(1024).optional().describe(
1449
+ 'Publish into this folder of the site only, e.g. "30-10-26" or "reports/2026" (served at <site>/30-10-26/), keeping every other file. File paths are relative to it. Omit to replace the whole site'
1450
+ );
1451
+ var sitePath = z13.string().min(1).max(1024).describe(
854
1452
  '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")'
855
1453
  );
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(
1454
+ var emails = z13.array(z13.string().min(3).max(254)).max(AUDIENCE_EMAILS_MAX);
1455
+ var groupSlugs = z13.array(z13.string().min(1).max(100)).max(AUDIENCE_GROUPS_MAX);
1456
+ var roleChange = {
1457
+ add: z13.array(z13.string().min(3).max(254)).max(PUBLISHER_EMAILS_MAX).optional(),
1458
+ remove: z13.array(z13.string().min(3).max(254)).max(PUBLISHER_EMAILS_MAX).optional(),
1459
+ set: z13.array(z13.string().min(3).max(254)).max(PUBLISHER_EMAILS_MAX).optional(),
1460
+ addGroups: z13.array(z13.string().min(1).max(100)).max(PUBLISHER_GROUPS_MAX).optional(),
1461
+ removeGroups: z13.array(z13.string().min(1).max(100)).max(PUBLISHER_GROUPS_MAX).optional(),
1462
+ setGroups: z13.array(z13.string().min(1).max(100)).max(PUBLISHER_GROUPS_MAX).optional()
1463
+ };
1464
+ var groupSlug = z13.string().min(1).max(100).describe(`The group's slug, e.g. "design"`);
1465
+ var members = z13.array(z13.string().min(3).max(254)).max(GROUP_MEMBERS_MAX);
1466
+ var groupName = z13.string().min(1).max(GROUP_NAME_MAX);
1467
+ var commentPath = z13.string().max(1024);
1468
+ var commentId = z13.string().min(1).max(100);
1469
+ var encoding = z13.enum(["utf8", "base64"]).optional().describe('How `content` is encoded: "utf8" (default) for text, "base64" for binary files');
1470
+ var spa = z13.boolean().optional().describe(
865
1471
  "Single-page app: serve index.html for unknown paths (client-side routing). Omit to keep the current setting"
866
1472
  );
867
1473
  function mcpToolsFor(w) {
868
1474
  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}.`;
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.`;
870
- const isPublic = z8.boolean().optional().describe(
871
- `true \u2192 anyone can view; false \u2192 only ${w.privateAudience}. Omit to keep the current setting (new sites are private)`
1475
+ const VISIBILITY = ACCESS_NOTE(w);
1476
+ const isPublic = z13.boolean().optional().describe(
1477
+ "Prefer `access` (a new site) or fling_set_access (an existing one). true \u2192 anyone can view; false \u2192 private. Omit to keep the current setting"
1478
+ );
1479
+ const access2 = z13.enum(ACCESS_LEVELS).optional().describe(
1480
+ `Who can open a new site: only_me, members (${w.privateAudience}), groups, people or public. Ask the user before the first publish; omit for the default (${defaultAccessPhrase(w)}). Ignored when the site already exists`
872
1481
  );
1482
+ const accessEmails = emails.optional().describe('With access "people" (or "groups"): the addresses that may open it');
1483
+ const accessGroups = groupSlugs.optional().describe('With access "groups" (or "people"): the groups, by slug, whose members may open it');
873
1484
  return {
874
1485
  whoami: tool({
875
1486
  name: "fling_whoami",
@@ -883,9 +1494,9 @@ function mcpToolsFor(w) {
883
1494
  title: "List sites",
884
1495
  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.",
885
1496
  inputSchema: {
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(
1497
+ archived: z13.boolean().optional().describe("List archived sites instead of live ones"),
1498
+ shared: z13.boolean().optional().describe("List the sites others have shared with the user instead of the user's own"),
1499
+ sort: z13.enum(SITE_SORTS).optional().describe(
889
1500
  "Order: updated (last updated, newest first; the default), created (newest first) or name (A\u2013Z)"
890
1501
  )
891
1502
  },
@@ -904,7 +1515,20 @@ function mcpToolsFor(w) {
904
1515
  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).",
905
1516
  inputSchema: {
906
1517
  name: siteName,
907
- versionId: z8.string().min(1).max(100).optional().describe("Version id (from fling_info); omit for the version being served")
1518
+ versionId: z13.string().min(1).max(100).optional().describe("Version id (from fling_info); omit for the version being served")
1519
+ },
1520
+ annotations: { readOnlyHint: true, openWorldHint: false }
1521
+ }),
1522
+ readFiles: tool({
1523
+ name: "fling_read_files",
1524
+ title: "Read a site's files",
1525
+ description: `Read the files of a site's current version (or \`versionId\`), with their content inline (text as utf8, anything else base64), to change some of them and publish again. Returns up to ${MCP_READ_MAX_BYTES / 1024 / 1024} MB a call, in path order: when there is more, pass the returned \`cursor\` for the next files, or name the files you need in \`paths\` (fling_files lists them). It returns the \`versionId\` to pass to fling_publish as \`base\`. Anyone who can open the site may read it; publishing needs an owner or publisher. ${PULL_NOTE}`,
1526
+ inputSchema: {
1527
+ name: siteName,
1528
+ paths: z13.array(z13.string().min(1).max(1024)).max(MAX_FILES).optional().describe('Only these files (site paths, e.g. "index.html"); omit for all of them'),
1529
+ path: z13.string().min(1).max(1024).optional().describe('Only the files under this folder of the site, e.g. "30-10-26"'),
1530
+ cursor: z13.string().max(20).optional().describe("From the previous call, for the next files"),
1531
+ versionId: z13.string().min(1).max(100).optional().describe("Version id (from fling_info); omit for the version being served")
908
1532
  },
909
1533
  annotations: { readOnlyHint: true, openWorldHint: false }
910
1534
  }),
@@ -912,23 +1536,31 @@ function mcpToolsFor(w) {
912
1536
  name: "fling_check_name",
913
1537
  title: "Check a site name",
914
1538
  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,
915
- inputSchema: { name: z8.string().min(1).max(200).describe("Site name to check") },
1539
+ inputSchema: { name: z13.string().min(1).max(200).describe("Site name to check") },
916
1540
  annotations: { readOnlyHint: true, openWorldHint: false }
917
1541
  }),
918
1542
  publish: tool({
919
1543
  name: "fling_publish",
920
1544
  title: "Publish a site",
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.`,
1545
+ 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), unless \`path\` or \`base\` is given; publishing identical content again is a no-op. ${PATH_PUBLISH_NOTE} ${PULL_NOTE} ${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.`,
922
1546
  inputSchema: {
923
1547
  name: siteName,
924
- files: z8.array(
925
- z8.object({
1548
+ files: z13.array(
1549
+ z13.object({
926
1550
  path: sitePath,
927
- content: z8.string().describe("The file content, encoded as `encoding` says"),
1551
+ content: z13.string().describe("The file content, encoded as `encoding` says"),
928
1552
  encoding
929
1553
  })
930
- ).min(1).max(MAX_FILES).describe("Every file of the site"),
1554
+ ).max(MAX_FILES).describe(
1555
+ "Every file of the site (with `path`: of that folder; with `base`: only those changed or added)"
1556
+ ),
1557
+ path: publishPath,
1558
+ base,
1559
+ remove,
931
1560
  spa,
1561
+ access: access2,
1562
+ emails: accessEmails,
1563
+ groups: accessGroups,
932
1564
  public: isPublic,
933
1565
  listHtml
934
1566
  },
@@ -937,8 +1569,18 @@ function mcpToolsFor(w) {
937
1569
  uploadBegin: tool({
938
1570
  name: "fling_upload_begin",
939
1571
  title: "Start a chunked upload",
940
- description: `Start publishing a site too big for one fling_publish call (over ${MCP_PUBLISH_MAX_BYTES / 1024 / 1024} MB, or large binary files). Returns an \`uploadId\`; then send each file with fling_upload_file and finish with fling_upload_finish within ${MCP_UPLOAD_TTL_SECONDS / 60} minutes. ${INLINE_ONLY} ${NAME_REQUIRED} ${VISIBILITY}`,
941
- inputSchema: { name: siteName, spa, public: isPublic },
1572
+ description: `Start publishing a site too big for one fling_publish call (over ${MCP_PUBLISH_MAX_BYTES / 1024 / 1024} MB, or large binary files). Returns an \`uploadId\`; then send each file with fling_upload_file and finish with fling_upload_finish within ${MCP_UPLOAD_TTL_SECONDS / 60} minutes. ${INLINE_ONLY} ${NAME_REQUIRED} ${VISIBILITY} ${PATH_PUBLISH_NOTE} With \`base\` (from fling_read_files), send only the files you changed or added, and \`remove\` the ones to delete: every other file of the site is kept.`,
1573
+ inputSchema: {
1574
+ name: siteName,
1575
+ path: publishPath,
1576
+ base,
1577
+ remove,
1578
+ spa,
1579
+ access: access2,
1580
+ emails: accessEmails,
1581
+ groups: accessGroups,
1582
+ public: isPublic
1583
+ },
942
1584
  annotations: { destructiveHint: false, openWorldHint: false }
943
1585
  }),
944
1586
  uploadFile: tool({
@@ -946,20 +1588,20 @@ function mcpToolsFor(w) {
946
1588
  title: "Upload a file (chunked)",
947
1589
  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}`,
948
1590
  inputSchema: {
949
- uploadId: z8.string().min(1).max(100).describe("From fling_upload_begin"),
1591
+ uploadId: z13.string().min(1).max(100).describe("From fling_upload_begin"),
950
1592
  path: sitePath,
951
- content: z8.string().max(MCP_UPLOAD_CHUNK_MAX_CHARS).describe("The file content (or the next part of it, with `append`)"),
1593
+ content: z13.string().max(MCP_UPLOAD_CHUNK_MAX_CHARS).describe("The file content (or the next part of it, with `append`)"),
952
1594
  encoding,
953
- append: z8.boolean().optional().describe("Append this content to the file sent earlier with the same path")
1595
+ append: z13.boolean().optional().describe("Append this content to the file sent earlier with the same path")
954
1596
  },
955
1597
  annotations: { destructiveHint: false, openWorldHint: false }
956
1598
  }),
957
1599
  uploadFinish: tool({
958
1600
  name: "fling_upload_finish",
959
1601
  title: "Finish a chunked upload",
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\`.`,
1602
+ 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 (with \`path\` or \`base\`, plus the rest of the site). ${LIST_HTML} The upload stays open, so just call fling_upload_finish again with \`listHtml\`. The result includes \`warnings\`.`,
961
1603
  inputSchema: {
962
- uploadId: z8.string().min(1).max(100).describe("From fling_upload_begin"),
1604
+ uploadId: z13.string().min(1).max(100).describe("From fling_upload_begin"),
963
1605
  listHtml
964
1606
  },
965
1607
  annotations: { destructiveHint: false, openWorldHint: true }
@@ -970,22 +1612,34 @@ function mcpToolsFor(w) {
970
1612
  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.",
971
1613
  inputSchema: {
972
1614
  name: siteName,
973
- refresh: z8.boolean().optional().describe("Rotate the link"),
974
- revoke: z8.boolean().optional().describe("Remove the link")
1615
+ refresh: z13.boolean().optional().describe("Rotate the link"),
1616
+ revoke: z13.boolean().optional().describe("Remove the link")
975
1617
  },
976
1618
  annotations: { destructiveHint: false, openWorldHint: true }
977
1619
  }),
1620
+ setAccess: tool({
1621
+ name: "fling_set_access",
1622
+ title: "Set who can open a site",
1623
+ description: "Set a site's access level: `only_me` (only the user), `members` (" + w.privateAudience + `), \`groups\` and \`people\` (only the user, the groups in \`groups\` by slug and the addresses in \`emails\`; both levels take both, and replace the whole audience), or \`public\` (anyone can open the site; its review page and comments still need sign-in). Only change it when the user asks. ${addressRule(w)} Share links still open the site to anyone who has them. Someone who loses access is shut out within 5 minutes; people and groups added are told by email when the instance sends it.`,
1624
+ inputSchema: {
1625
+ name: siteName,
1626
+ access: z13.enum(ACCESS_LEVELS).describe("only_me, members, groups, people or public"),
1627
+ emails: emails.optional().describe("For groups or people: the addresses that may open it"),
1628
+ groups: groupSlugs.optional().describe("For groups or people: the groups (slugs, fling_groups) whose members may")
1629
+ },
1630
+ annotations: { idempotentHint: true, openWorldHint: true }
1631
+ }),
978
1632
  setVisibility: tool({
979
1633
  name: "fling_set_visibility",
980
1634
  title: "Set visibility",
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"]) },
1635
+ description: `Make a site private or public (anyone can open the site; its review page and comments still need sign-in). Making it private gives it back the access it had before (only the user, ${w.privateAudience}, or its audience). fling_set_access sets any level.`,
1636
+ inputSchema: { name: siteName, visibility: z13.enum(["private", "public"]) },
983
1637
  annotations: { idempotentHint: true, openWorldHint: true }
984
1638
  }),
985
1639
  setAudience: tool({
986
1640
  name: "fling_set_audience",
987
1641
  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.`,
1642
+ description: `Limit a private site to listed people and groups (its audience), or open it again to ${w.privateAudience}; fling_set_access does the same for whole levels, including only the user. \`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. ${addressRule(w)} 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
1643
  inputSchema: {
990
1644
  name: siteName,
991
1645
  add: emails.optional().describe("Addresses to let in"),
@@ -994,7 +1648,41 @@ function mcpToolsFor(w) {
994
1648
  addGroups: groupSlugs.optional().describe("Groups (slugs) to let in"),
995
1649
  removeGroups: groupSlugs.optional().describe("Groups (slugs) to take out"),
996
1650
  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")
1651
+ clear: z13.boolean().optional().describe("Empty the audience (addresses and groups): every signed-in member sees it")
1652
+ },
1653
+ annotations: { idempotentHint: true, openWorldHint: false }
1654
+ }),
1655
+ setOwners: tool({
1656
+ name: "fling_set_owners",
1657
+ title: "Set a site's owners",
1658
+ description: `Change who owns a site: people and groups who may do everything the user can with it (publish, roll back, change its access and settings, its owners and publishers, archive and delete it). Only owners change it, and only when the user asks. \`add\` and \`remove\` change the addresses and \`set\` replaces them; \`addGroups\`, \`removeGroups\` and \`setGroups\` do the same for groups, by slug (fling_groups). Only members of ${w.name} may be owners, never guests; a site keeps at least one person (address) as an owner, and has at most ${OWNER_EMAILS_MAX} addresses. Naming a group trusts whoever manages it. fling_info shows the owners and publishers.`,
1659
+ inputSchema: {
1660
+ name: siteName,
1661
+ add: roleChange.add.describe("Addresses to make owners"),
1662
+ remove: roleChange.remove.describe("Addresses to take out of the owners"),
1663
+ set: roleChange.set.describe("Replace the owner addresses with exactly these"),
1664
+ addGroups: roleChange.addGroups.describe("Groups (slugs) to make owners"),
1665
+ removeGroups: roleChange.removeGroups.describe("Groups (slugs) to take out"),
1666
+ setGroups: roleChange.setGroups.describe(
1667
+ "Replace the owner groups with exactly these slugs"
1668
+ )
1669
+ },
1670
+ annotations: { idempotentHint: true, openWorldHint: false }
1671
+ }),
1672
+ setPublishers: tool({
1673
+ name: "fling_set_publishers",
1674
+ title: "Set a site's publishers",
1675
+ description: `Change who publishes a site without owning it: people and groups who may deploy new versions and roll back, but not change its access or settings, add owners or delete it. Only the site's owners change it, and only when the user asks. \`add\` and \`remove\` change the addresses and \`set\` replaces them; \`addGroups\`, \`removeGroups\` and \`setGroups\` do the same for groups, by slug (fling_groups). Only members of ${w.name} may publish, never guests. Someone who is also an owner stays an owner. fling_info shows the owners and publishers.`,
1676
+ inputSchema: {
1677
+ name: siteName,
1678
+ add: roleChange.add.describe("Addresses to make publishers"),
1679
+ remove: roleChange.remove.describe("Addresses to take out of the publishers"),
1680
+ set: roleChange.set.describe("Replace the publisher addresses with exactly these"),
1681
+ addGroups: roleChange.addGroups.describe("Groups (slugs) to make publishers"),
1682
+ removeGroups: roleChange.removeGroups.describe("Groups (slugs) to take out"),
1683
+ setGroups: roleChange.setGroups.describe(
1684
+ "Replace the publisher groups with exactly these slugs"
1685
+ )
998
1686
  },
999
1687
  annotations: { idempotentHint: true, openWorldHint: false }
1000
1688
  }),
@@ -1003,7 +1691,7 @@ function mcpToolsFor(w) {
1003
1691
  title: "List groups",
1004
1692
  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
1693
  inputSchema: {
1006
- mine: z8.boolean().optional().describe("Only the groups the user owns"),
1694
+ mine: z13.boolean().optional().describe("Only the groups the user owns"),
1007
1695
  members: members.optional().describe("Only the groups whose members are exactly these addresses")
1008
1696
  },
1009
1697
  annotations: { readOnlyHint: true, openWorldHint: false }
@@ -1011,14 +1699,14 @@ function mcpToolsFor(w) {
1011
1699
  groupShow: tool({
1012
1700
  name: "fling_group_show",
1013
1701
  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).",
1702
+ description: "Show a group and its members (each marked when they have not signed in yet, and when they are a guest invited from outside), and whether the user owns it (only the owner can change it).",
1015
1703
  inputSchema: { slug: groupSlug },
1016
1704
  annotations: { readOnlyHint: true, openWorldHint: false }
1017
1705
  }),
1018
1706
  groupCreate: tool({
1019
1707
  name: "fling_group_create",
1020
1708
  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}).`,
1709
+ 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}; at most ${GROUP_MEMBERS_MAX} members. ${addressRule(w)}`,
1022
1710
  inputSchema: {
1023
1711
  slug: groupSlug,
1024
1712
  name: groupName.describe('Its display name, e.g. "Design team"'),
@@ -1029,7 +1717,7 @@ function mcpToolsFor(w) {
1029
1717
  groupMembers: tool({
1030
1718
  name: "fling_group_members",
1031
1719
  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.",
1720
+ 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. ${addressRule(w)}`,
1033
1721
  inputSchema: {
1034
1722
  slug: groupSlug,
1035
1723
  add: members.optional().describe("Addresses to add"),
@@ -1050,7 +1738,7 @@ function mcpToolsFor(w) {
1050
1738
  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
1739
  inputSchema: {
1052
1740
  slug: groupSlug,
1053
- confirm: z8.literal(true).describe("Must be true: the user has confirmed the deletion")
1741
+ confirm: z13.literal(true).describe("Must be true: the user has confirmed the deletion")
1054
1742
  },
1055
1743
  annotations: { destructiveHint: true, openWorldHint: false }
1056
1744
  }),
@@ -1060,7 +1748,7 @@ function mcpToolsFor(w) {
1060
1748
  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.",
1061
1749
  inputSchema: {
1062
1750
  name: siteName,
1063
- unarchive: z8.boolean().optional().describe("Restore an archived site")
1751
+ unarchive: z13.boolean().optional().describe("Restore an archived site")
1064
1752
  },
1065
1753
  annotations: { idempotentHint: true, openWorldHint: false }
1066
1754
  }),
@@ -1070,7 +1758,7 @@ function mcpToolsFor(w) {
1070
1758
  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.",
1071
1759
  inputSchema: {
1072
1760
  name: siteName,
1073
- confirm: z8.literal(true).describe("Must be true: the user has confirmed the deletion")
1761
+ confirm: z13.literal(true).describe("Must be true: the user has confirmed the deletion")
1074
1762
  },
1075
1763
  annotations: { destructiveHint: true, openWorldHint: false }
1076
1764
  }),
@@ -1081,7 +1769,7 @@ function mcpToolsFor(w) {
1081
1769
  inputSchema: {
1082
1770
  name: siteName,
1083
1771
  path: commentPath.optional().describe('Only this page, e.g. "/" or "/docs/"'),
1084
- includeResolved: z8.boolean().optional().describe("Include resolved threads")
1772
+ includeResolved: z13.boolean().optional().describe("Include resolved threads")
1085
1773
  },
1086
1774
  annotations: { readOnlyHint: true, openWorldHint: false }
1087
1775
  }),
@@ -1091,7 +1779,7 @@ function mcpToolsFor(w) {
1091
1779
  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.`,
1092
1780
  inputSchema: {
1093
1781
  name: siteName,
1094
- body: z8.string().min(1).max(COMMENT_MAX_CHARS).describe("The comment (plain text)"),
1782
+ body: z13.string().min(1).max(COMMENT_MAX_CHARS).describe("The comment (plain text)"),
1095
1783
  path: commentPath.optional().describe('The page it is about (default "/"); ignored for a reply'),
1096
1784
  replyTo: commentId.optional().describe("Reply to this thread instead of starting one")
1097
1785
  },
@@ -1104,7 +1792,7 @@ function mcpToolsFor(w) {
1104
1792
  inputSchema: {
1105
1793
  name: siteName,
1106
1794
  id: commentId.describe("The thread id"),
1107
- reopen: z8.boolean().optional().describe("Reopen a resolved thread")
1795
+ reopen: z13.boolean().optional().describe("Reopen a resolved thread")
1108
1796
  },
1109
1797
  annotations: { idempotentHint: true, openWorldHint: false }
1110
1798
  }),
@@ -1115,19 +1803,39 @@ function mcpToolsFor(w) {
1115
1803
  inputSchema: { name: siteName, id: commentId.describe("The comment id") },
1116
1804
  annotations: { destructiveHint: true, openWorldHint: false }
1117
1805
  }),
1806
+ notifications: tool({
1807
+ name: "fling_notifications",
1808
+ title: "Notification settings",
1809
+ description: `Show what ${w.name} tells the user about and how: each event (a site shared with them, added to a group, a comment on their site, a reply in their thread\u2026) with its key and, per channel (email), whether it is on or off, marking the ones the user chose themselves; and whether everything is muted. Use it before fling_notifications_set, or when the user asks why they get (or do not get) emails.`,
1810
+ inputSchema: {},
1811
+ annotations: { readOnlyHint: true, openWorldHint: false }
1812
+ }),
1813
+ notificationsSet: tool({
1814
+ name: "fling_notifications_set",
1815
+ title: "Change notification settings",
1816
+ description: `Change what ${w.name} tells the user about: \`muted: true\` stops everything (false turns it back on, with their choices kept), and \`cells\` turns single events on or off per channel, or back to the default (\`value: "default"\`). Event keys come from fling_notifications. Only change what the user asked for. Returns the settings after the change.`,
1817
+ inputSchema: {
1818
+ muted: z13.boolean().optional().describe("true: send nothing at all; false: unmute"),
1819
+ cells: z13.array(CellChangeSchema).max(50).optional().describe(
1820
+ 'Events to change: `{ event, channel, value }`, value "on", "off" or "default"'
1821
+ )
1822
+ },
1823
+ annotations: { idempotentHint: true, openWorldHint: false }
1824
+ }),
1118
1825
  rollback: tool({
1119
1826
  name: "fling_rollback",
1120
1827
  title: "Roll back",
1121
1828
  description: "Point a site at an earlier version (ids from fling_info). Instant; nothing is uploaded.",
1122
1829
  inputSchema: {
1123
1830
  name: siteName,
1124
- versionId: z8.string().min(1).max(100).describe("Version id to serve")
1831
+ versionId: z13.string().min(1).max(100).describe("Version id to serve")
1125
1832
  },
1126
1833
  annotations: { idempotentHint: true, openWorldHint: false }
1127
1834
  })
1128
1835
  };
1129
1836
  }
1130
1837
  var mcpTools = mcpToolsFor(mcpWording());
1838
+ var NOTIFICATIONS_NOTE = (w) => `${w.name} emails people when a site is shared with them, they are made an owner or publisher of one, they are added to a group, or someone comments on their site or replies in their thread. fling_notifications shows what the user hears about; change it with fling_notifications_set only when they ask.`;
1131
1839
  var COMMENTS_NOTE = (w) => `People review sites at ${w.reviewPattern} and leave comments on its pages. Publishing returns this review link (\`reviewUrl\`): it is the one to share when the user wants feedback; reviewers sign in to use it. fling_comments_list shows the comments; after acting on feedback, resolve the threads with fling_comment_resolve. Only post (fling_comment_add) or delete (fling_comment_delete) comments when the user asks.`;
1132
1840
  var shareForFeedback = (reviewPage2, instanceName) => `Share for feedback: ${reviewPage2} (reviewers sign in to ${instanceName} to see the site and comment).`;
1133
1841
  var MCP_FILES_LISTED = 200;
@@ -1174,7 +1882,7 @@ function describeComments(list, opts = {}) {
1174
1882
  var READY_TIMEOUT_MS = 15e3;
1175
1883
  var NOT_READY_NOTE = "it can take a few more seconds to appear everywhere; reload if you see a 404";
1176
1884
  var isReachableStatus = (status) => status >= 200 && status < 400 || status === 401 || status === 403;
1177
- var defaultSleep = (ms) => new Promise((resolve5) => setTimeout(resolve5, ms));
1885
+ var defaultSleep = (ms) => new Promise((resolve6) => setTimeout(resolve6, ms));
1178
1886
  async function waitUntilReachable(url, opts = {}) {
1179
1887
  const timeoutMs = opts.timeoutMs ?? READY_TIMEOUT_MS;
1180
1888
  const doFetch = opts.fetch ?? ((u, init) => fetch(u, init));
@@ -1208,109 +1916,123 @@ async function waitUntilReachable(url, opts = {}) {
1208
1916
  }
1209
1917
  }
1210
1918
  function probeUrl(siteUrl2, paths) {
1211
- const base = siteUrl2.endsWith("/") ? siteUrl2 : `${siteUrl2}/`;
1212
- if (paths.length === 0 || paths.includes("index.html")) return base;
1919
+ const base2 = siteUrl2.endsWith("/") ? siteUrl2 : `${siteUrl2}/`;
1920
+ if (paths.length === 0 || paths.includes("index.html")) return base2;
1213
1921
  const pick = paths.find((p) => /\.html?$/i.test(p)) ?? paths[0];
1214
- return base + pick.split("/").map(encodeURIComponent).join("/");
1922
+ return base2 + pick.split("/").map(encodeURIComponent).join("/");
1215
1923
  }
1216
1924
  function readinessWaitDisabled(env) {
1217
1925
  return env.FLING_NO_WAIT === "1" || env.VITEST === "true" || env.NODE_ENV === "test";
1218
1926
  }
1219
1927
 
1220
1928
  // ../../packages/shared/src/review-bridge.ts
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),
1929
+ import { z as z14 } from "zod";
1930
+ var tag = { fling: z14.literal("review") };
1931
+ var path = z14.string().max(COMMENT_PATH_MAX);
1932
+ var hash = z14.string().max(COMMENT_PATH_MAX);
1933
+ var BridgePinSchema = z14.object({
1934
+ id: z14.string(),
1935
+ n: z14.number().int().min(1),
1228
1936
  anchor: CommentAnchorSchema,
1229
- selected: z9.boolean(),
1230
- resolved: z9.boolean()
1937
+ selected: z14.boolean(),
1938
+ resolved: z14.boolean()
1231
1939
  });
1232
- var BridgeCursorSchema = z9.object({
1233
- id: z9.string(),
1234
- name: z9.string().max(100),
1235
- colour: z9.string().max(32),
1940
+ var BridgeCursorSchema = z14.object({
1941
+ id: z14.string(),
1942
+ name: z14.string().max(100),
1943
+ colour: z14.string().max(32),
1236
1944
  anchor: CommentAnchorSchema
1237
1945
  });
1238
- var ShellMessageSchema = z9.discriminatedUnion("type", [
1239
- z9.object({ ...tag, type: z9.literal("ready") }),
1946
+ var ShellMessageSchema = z14.discriminatedUnion("type", [
1947
+ z14.object({ ...tag, type: z14.literal("ready") }),
1240
1948
  /** the frame is on `path` (normalised, see normalizeCommentPath) with fragment `hash` ('' or '#…') */
1241
- z9.object({ ...tag, type: z9.literal("location"), path, hash, title: z9.string().max(300) }),
1949
+ z14.object({ ...tag, type: z14.literal("location"), path, hash, title: z14.string().max(300) }),
1242
1950
  /** a pin placed while placing: a click (a spot) or a drag (a box) */
1243
- z9.object({ ...tag, type: z9.literal("pinPlaced"), anchor: CommentAnchorSchema }),
1951
+ z14.object({ ...tag, type: z14.literal("pinPlaced"), anchor: CommentAnchorSchema }),
1244
1952
  /** the reviewer selected text in the page: an anchor on it, with its quote */
1245
- z9.object({ ...tag, type: z9.literal("textSelected"), anchor: CommentAnchorSchema }),
1246
- z9.object({ ...tag, type: z9.literal("pinCancelled") }),
1247
- z9.object({ ...tag, type: z9.literal("pinClicked"), id: z9.string() }),
1953
+ z14.object({ ...tag, type: z14.literal("textSelected"), anchor: CommentAnchorSchema }),
1954
+ z14.object({ ...tag, type: z14.literal("pinCancelled") }),
1955
+ z14.object({ ...tag, type: z14.literal("pinClicked"), id: z14.string() }),
1248
1956
  /** this reviewer's pointer on the page; null when it left the frame */
1249
- z9.object({ ...tag, type: z9.literal("cursor"), anchor: CommentAnchorSchema.nullable() }),
1957
+ z14.object({ ...tag, type: z14.literal("cursor"), anchor: CommentAnchorSchema.nullable() }),
1250
1958
  /** a link or form to another origin, stopped: the frame stays on the site */
1251
- z9.object({ ...tag, type: z9.literal("blocked"), href: z9.string().max(2048) }),
1252
- z9.object({ ...tag, type: z9.literal("escape") })
1959
+ z14.object({ ...tag, type: z14.literal("blocked"), href: z14.string().max(2048) }),
1960
+ z14.object({ ...tag, type: z14.literal("escape") }),
1961
+ /** preview mode: the thumbnail the page asked for, a PNG of the frame's viewport of `versionId` */
1962
+ z14.object({
1963
+ ...tag,
1964
+ type: z14.literal("captured"),
1965
+ png: z14.instanceof(Blob),
1966
+ versionId: z14.string().max(100)
1967
+ }),
1968
+ /** preview mode: no thumbnail could be drawn (CAPTURE_STALE: the host serves another version) */
1969
+ z14.object({ ...tag, type: z14.literal("captureFailed"), reason: z14.string().max(300) })
1253
1970
  ]);
1254
- var PageMessageSchema = z9.discriminatedUnion("type", [
1971
+ var PageMessageSchema = z14.discriminatedUnion("type", [
1255
1972
  /** load `path` (site-relative, may carry `?query` and `#hash`) in the frame */
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() }),
1973
+ z14.object({ ...tag, type: z14.literal("navigate"), path: z14.string().max(COMMENT_PATH_MAX * 2) }),
1974
+ z14.object({ ...tag, type: z14.literal("reload") }),
1975
+ z14.object({ ...tag, type: z14.literal("placing"), on: z14.boolean() }),
1259
1976
  /** the pins on the frame's current page */
1260
- z9.object({ ...tag, type: z9.literal("pins"), pins: z9.array(BridgePinSchema) }),
1977
+ z14.object({ ...tag, type: z14.literal("pins"), pins: z14.array(BridgePinSchema) }),
1261
1978
  /** scroll the pin into view and draw attention to it */
1262
- z9.object({ ...tag, type: z9.literal("focusPin"), id: z9.string() }),
1263
- z9.object({ ...tag, type: z9.literal("cursors"), cursors: z9.array(BridgeCursorSchema) })
1979
+ z14.object({ ...tag, type: z14.literal("focusPin"), id: z14.string() }),
1980
+ z14.object({ ...tag, type: z14.literal("cursors"), cursors: z14.array(BridgeCursorSchema) }),
1981
+ /**
1982
+ * preview mode: once the page has loaded and settled, draw it as a thumbnail (`captured`), only
1983
+ * if the sites host serves `versionId` (the site's current version, as the API says)
1984
+ */
1985
+ z14.object({ ...tag, type: z14.literal("capture"), versionId: z14.string().max(100) })
1264
1986
  ]);
1265
1987
 
1266
1988
  // ../../packages/shared/src/review-live.ts
1267
- import { z as z10 } from "zod";
1989
+ import { z as z15 } from "zod";
1268
1990
  var LIVE_PICTURE_MAX = 1024;
1269
- var LivePeerSchema = z10.object({
1270
- id: z10.string(),
1271
- name: z10.string().max(100),
1272
- colour: z10.string().max(32),
1991
+ var LivePeerSchema = z15.object({
1992
+ id: z15.string(),
1993
+ name: z15.string().max(100),
1994
+ colour: z15.string().max(32),
1273
1995
  /** their profile picture (https), when sign-in gave one; else clients show initials */
1274
- picture: z10.string().max(LIVE_PICTURE_MAX).optional()
1996
+ picture: z15.string().max(LIVE_PICTURE_MAX).optional()
1275
1997
  });
1276
- var LiveTicketSchema = z10.object({
1277
- url: z10.string(),
1278
- ticket: z10.string(),
1998
+ var LiveTicketSchema = z15.object({
1999
+ url: z15.string(),
2000
+ ticket: z15.string(),
1279
2001
  /** seconds the ticket is good for: connect before then */
1280
- expiresIn: z10.number().int()
2002
+ expiresIn: z15.number().int()
1281
2003
  });
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") }),
2004
+ var path2 = z15.string().max(COMMENT_PATH_MAX);
2005
+ var hash2 = z15.string().max(COMMENT_PATH_MAX);
2006
+ var LiveClientMessageSchema = z15.discriminatedUnion("type", [
2007
+ z15.object({ type: z15.literal("hello") }),
1286
2008
  /** where this peer's pointer is; `anchor` null when it left the page */
1287
- z10.object({
1288
- type: z10.literal("cursor"),
2009
+ z15.object({
2010
+ type: z15.literal("cursor"),
1289
2011
  path: path2,
1290
2012
  hash: hash2,
1291
2013
  anchor: CommentAnchorSchema.nullable()
1292
2014
  }),
1293
- z10.object({ type: z10.literal("ping") })
2015
+ z15.object({ type: z15.literal("ping") })
1294
2016
  ]);
1295
- var LiveServerMessageSchema = z10.discriminatedUnion("type", [
2017
+ var LiveServerMessageSchema = z15.discriminatedUnion("type", [
1296
2018
  /** the answer to `hello`: who this connection is, and who else is in the room */
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(),
2019
+ z15.object({ type: z15.literal("welcome"), you: LivePeerSchema, peers: z15.array(LivePeerSchema) }),
2020
+ z15.object({ type: z15.literal("join"), peer: LivePeerSchema }),
2021
+ z15.object({ type: z15.literal("leave"), peerId: z15.string() }),
2022
+ z15.object({
2023
+ type: z15.literal("cursor"),
2024
+ peerId: z15.string(),
1303
2025
  path: path2,
1304
2026
  hash: hash2,
1305
2027
  anchor: CommentAnchorSchema.nullable()
1306
2028
  }),
1307
2029
  /** the site's comments changed: refetch them */
1308
- z10.object({ type: z10.literal("comments") }),
1309
- z10.object({ type: z10.literal("pong") })
2030
+ z15.object({ type: z15.literal("comments") }),
2031
+ z15.object({ type: z15.literal("pong") })
1310
2032
  ]);
1311
2033
 
1312
2034
  // src/cli.ts
1313
- import chalk11 from "chalk";
2035
+ import chalk13 from "chalk";
1314
2036
  import { Command, Option } from "commander";
1315
2037
 
1316
2038
  // src/commands/auth.ts
@@ -1360,7 +2082,7 @@ function exitCodeFor(error) {
1360
2082
  // package.json
1361
2083
  var package_default = {
1362
2084
  name: "@nearform/fling",
1363
- version: "0.6.0",
2085
+ version: "0.7.0",
1364
2086
  license: "MIT",
1365
2087
  description: "Fling a folder of static files at a Fling server and get a link back. CLI + MCP server.",
1366
2088
  type: "module",
@@ -1369,9 +2091,7 @@ var package_default = {
1369
2091
  },
1370
2092
  files: [
1371
2093
  "dist/cli.js",
1372
- "dist/cli.js.map",
1373
- "README.md",
1374
- "CHANGELOG.md"
2094
+ "README.md"
1375
2095
  ],
1376
2096
  engines: {
1377
2097
  node: ">=22"
@@ -1403,6 +2123,7 @@ var package_default = {
1403
2123
  zod: "^4.6.7"
1404
2124
  },
1405
2125
  devDependencies: {
2126
+ "@anthropic-ai/mcpb": "^2.1.2",
1406
2127
  "@fling/shared": "workspace:*",
1407
2128
  "@types/node": "^24.0.0",
1408
2129
  esbuild: "^0.25.12",
@@ -1543,7 +2264,7 @@ function errorFromResponse(status, body, method, path3) {
1543
2264
  code,
1544
2265
  body,
1545
2266
  message: envelope.data.error.message,
1546
- hint: code ? NEVER_PUBLISHED_HINTS[code] : void 0
2267
+ hint: code ? ERROR_HINTS[code] : void 0
1547
2268
  });
1548
2269
  const extra = body.error;
1549
2270
  if (Array.isArray(extra?.findings)) Object.assign(error, { findings: extra.findings });
@@ -1557,6 +2278,10 @@ var NEVER_PUBLISHED_HINTS = {
1557
2278
  secret_file: "Move the secret files out of the folder, or add them to .flingignore if the site does not need them.",
1558
2279
  dotfile: `Dotfiles are never flung. Update the CLI (\`npm i -g ${PACKAGE_NAME}@latest\`), which skips them.`
1559
2280
  };
2281
+ var ERROR_HINTS = {
2282
+ ...NEVER_PUBLISHED_HINTS,
2283
+ too_many_invites: "Nothing changed. Addresses from outside are invited as reviewers, a few a day per person: add them tomorrow, or ask an admin to invite them."
2284
+ };
1560
2285
  function describeCause(cause) {
1561
2286
  if (cause instanceof Error) {
1562
2287
  const inner = cause.cause;
@@ -1571,7 +2296,7 @@ function describeCause(cause) {
1571
2296
  import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
1572
2297
  import { homedir } from "node:os";
1573
2298
  import { join } from "node:path";
1574
- import { z as z11 } from "zod";
2299
+ import { z as z16 } from "zod";
1575
2300
  var CONFIG_DIR_NAME = ".fling";
1576
2301
  var CONFIG_FILE_NAME = "config.json";
1577
2302
  var ENV = {
@@ -1584,14 +2309,14 @@ var ENV = {
1584
2309
  /** A server the local MCP server adds to the remembered ones on start (the .mcpb bakes it in). */
1585
2310
  addServer: "FLING_ADD_SERVER"
1586
2311
  };
1587
- var serverEntrySchema = z11.object({
2312
+ var serverEntrySchema = z16.object({
1588
2313
  /** What the server said about itself at login. A copy this CLI can't read is just refetched. */
1589
2314
  instance: InstanceMetadataSchema.optional().catch(void 0),
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()
2315
+ apiKey: z16.string().min(1).optional().catch(void 0),
2316
+ user: z16.object({
2317
+ email: z16.string().optional(),
2318
+ name: z16.string().optional(),
2319
+ userSlug: z16.string().optional()
1595
2320
  }).optional().catch(void 0)
1596
2321
  });
1597
2322
  function systemLocateOptions(env = process.env) {
@@ -1809,6 +2534,18 @@ async function loadInstance(store, config, fetch2) {
1809
2534
  return null;
1810
2535
  }
1811
2536
  }
2537
+ async function loadInstanceWith(store, config, fetch2, capability) {
2538
+ const stored = await loadInstance(store, config, fetch2);
2539
+ if (stored?.capabilities[capability] === true || !config.serverUrl) return stored;
2540
+ try {
2541
+ const instance = await fetchInstance(config.serverUrl, fetch2);
2542
+ if (config.remembered)
2543
+ await store.putServer(config.serverUrl, { instance }, { makeCurrent: false });
2544
+ return instance;
2545
+ } catch {
2546
+ return stored;
2547
+ }
2548
+ }
1812
2549
 
1813
2550
  // src/auth.ts
1814
2551
  var CALLBACK_PORT_START = 8765;
@@ -1878,8 +2615,8 @@ async function startCallbackServer(options = {}) {
1878
2615
  const timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS;
1879
2616
  let settle;
1880
2617
  let timer;
1881
- const result = new Promise((resolve5, reject) => {
1882
- settle = { resolve: resolve5, reject };
2618
+ const result = new Promise((resolve6, reject) => {
2619
+ settle = { resolve: resolve6, reject };
1883
2620
  }).finally(() => clearTimeout(timer));
1884
2621
  result.catch(() => {
1885
2622
  });
@@ -1940,7 +2677,7 @@ ${close ? "<script>setTimeout(function(){window.close()},800)</script>" : ""}
1940
2677
  res.end(html);
1941
2678
  }
1942
2679
  function listenOnFreePort(server) {
1943
- return new Promise((resolve5, reject) => {
2680
+ return new Promise((resolve6, reject) => {
1944
2681
  let port = CALLBACK_PORT_START;
1945
2682
  const tryNext = () => {
1946
2683
  if (port > CALLBACK_PORT_END) {
@@ -1962,7 +2699,7 @@ function listenOnFreePort(server) {
1962
2699
  });
1963
2700
  server.listen(port, "127.0.0.1", () => {
1964
2701
  server.removeAllListeners("error");
1965
- resolve5(port);
2702
+ resolve6(port);
1966
2703
  });
1967
2704
  };
1968
2705
  tryNext();
@@ -2057,7 +2794,7 @@ function logo(options = {}) {
2057
2794
  const tagline = options.tagline ?? "throw static files at the web, get a link back";
2058
2795
  if (columns < LOGO_WIDTH + 2) return `${gradient("\u25B6\u25B6 fling")} ${chalk2.dim(tagline)}`;
2059
2796
  const lines = WORDMARK.map(
2060
- (row, i) => chalk2.dim(TRAILS[i] ?? "") + gradient(row, WORDMARK[0].length)
2797
+ (row2, i) => chalk2.dim(TRAILS[i] ?? "") + gradient(row2, WORDMARK[0].length)
2061
2798
  );
2062
2799
  return ["", ...lines, `${" ".repeat(TRAILS[0].length)}${chalk2.dim(tagline)}`, ""].join("\n");
2063
2800
  }
@@ -2125,7 +2862,7 @@ function formatJson(value) {
2125
2862
  }
2126
2863
  function renderTable(rows, columns) {
2127
2864
  if (rows.length === 0) return chalk4.dim("(no results)");
2128
- const cells = rows.map((row) => columns.map((column) => formatCell(column.value(row))));
2865
+ const cells = rows.map((row2) => columns.map((column) => formatCell(column.value(row2))));
2129
2866
  const widths = columns.map(
2130
2867
  (column, i) => Math.max(column.header.length, ...cells.map((line) => line[i]?.length ?? 0))
2131
2868
  );
@@ -2209,8 +2946,8 @@ function requireClient(ctx) {
2209
2946
  }
2210
2947
 
2211
2948
  // src/sites.ts
2212
- import { z as z12 } from "zod";
2213
- var ShareResponseSchema2 = z12.looseObject({ shareUrl: z12.string() });
2949
+ import { z as z17 } from "zod";
2950
+ var ShareResponseSchema2 = z17.looseObject({ shareUrl: z17.string() });
2214
2951
  var sitePath2 = (name) => `/api/sites/${encodeURIComponent(name)}`;
2215
2952
  function whoami(client) {
2216
2953
  return client.request("GET", "/api/me", { schema: MeSchema });
@@ -2223,11 +2960,11 @@ function checkName(client, name) {
2223
2960
  function sitesBaseOf(instance) {
2224
2961
  return instance ? { protocol: `${instance.siteScheme}:`, domain: instance.sitesDomain } : null;
2225
2962
  }
2226
- function siteUrlFor(name, base) {
2227
- return siteUrl(name, base.domain, base.protocol);
2963
+ function siteUrlFor(name, base2) {
2964
+ return siteUrl(name, base2.domain, base2.protocol);
2228
2965
  }
2229
- function sitesPattern(base) {
2230
- return base ? siteUrlFor("<name>", base) : "<name>.<sites domain>";
2966
+ function sitesPattern(base2) {
2967
+ return base2 ? siteUrlFor("<name>", base2) : "<name>.<sites domain>";
2231
2968
  }
2232
2969
  async function listSites(client, opts = {}) {
2233
2970
  const res = await client.request("GET", "/api/sites", {
@@ -2269,6 +3006,15 @@ async function setVisibility(client, name, visibility) {
2269
3006
  const raw = await client.request("PATCH", sitePath2(name), { body: { visibility } });
2270
3007
  return siteAfter(client, name, raw);
2271
3008
  }
3009
+ async function setAccess(client, name, access2, audience) {
3010
+ return client.request("PATCH", sitePath2(name), {
3011
+ schema: SiteWithAudienceSchema,
3012
+ body: { access: access2, ...audience ? { audience } : {} }
3013
+ });
3014
+ }
3015
+ function accessLevelsEnabled(instance) {
3016
+ return instance?.capabilities.accessLevels === true;
3017
+ }
2272
3018
  async function getAudience(client, name) {
2273
3019
  const site = await getSite(client, name);
2274
3020
  if (!site.audience) throw noAudiences();
@@ -2303,6 +3049,37 @@ function audiencesEnabled(instance) {
2303
3049
  if (!instance) return false;
2304
3050
  return instance.capabilities.audiences ?? true;
2305
3051
  }
3052
+ function siteRolesEnabled(instance) {
3053
+ return instance?.capabilities.siteRoles === true;
3054
+ }
3055
+ function pathPublishEnabled(instance) {
3056
+ return instance?.capabilities.pathPublish === true;
3057
+ }
3058
+ function pullEnabled(instance) {
3059
+ return instance?.capabilities.pull === true;
3060
+ }
3061
+ var noRoles = () => new CliError("This Fling does not support site owners and publishers", {
3062
+ hint: "Ask its operator to update it."
3063
+ });
3064
+ var withRoles = (site) => {
3065
+ if (!site.owners) throw noRoles();
3066
+ return {
3067
+ ...site,
3068
+ owners: site.owners,
3069
+ publishers: site.publishers ?? { emails: [], groups: [] }
3070
+ };
3071
+ };
3072
+ async function getRoles(client, name) {
3073
+ return withRoles(await getSite(client, name));
3074
+ }
3075
+ async function setRoles(client, name, which, change) {
3076
+ const current = await getRoles(client, name);
3077
+ const site = await client.request("PATCH", sitePath2(name), {
3078
+ schema: SiteWithAudienceSchema,
3079
+ body: { [which]: editRoleInput(current[which], change) }
3080
+ });
3081
+ return withRoles(site);
3082
+ }
2306
3083
  var groupPath = (slug) => `/api/groups/${encodeURIComponent(slug)}`;
2307
3084
  async function listGroups(client, opts = {}) {
2308
3085
  const res = await client.request("GET", "/api/groups", {
@@ -2389,6 +3166,42 @@ function commentsEnabled(instance) {
2389
3166
  if (!instance) return false;
2390
3167
  return instance.capabilities.comments ?? true;
2391
3168
  }
3169
+ function notificationsEnabled(instance) {
3170
+ if (!instance) return false;
3171
+ return instance.capabilities.notifications ?? true;
3172
+ }
3173
+ async function notificationsCall(name, call) {
3174
+ try {
3175
+ return await call();
3176
+ } catch (err) {
3177
+ if (err instanceof CliApiError && err.status === 404) {
3178
+ throw new CliError(`${name} has no notifications`);
3179
+ }
3180
+ throw err;
3181
+ }
3182
+ }
3183
+ function getNotifications(client, name = "This Fling") {
3184
+ return notificationsCall(
3185
+ name,
3186
+ () => client.request("GET", "/api/me/notifications", { schema: NotificationSettingsSchema })
3187
+ );
3188
+ }
3189
+ function setNotifications(client, change, name = "This Fling") {
3190
+ return notificationsCall(
3191
+ name,
3192
+ () => client.request("PUT", "/api/me/notifications", {
3193
+ schema: NotificationSettingsSchema,
3194
+ body: preferencesUpdateOf(change)
3195
+ })
3196
+ );
3197
+ }
3198
+ async function listSentNotifications(client, name = "This Fling") {
3199
+ const res = await notificationsCall(
3200
+ name,
3201
+ () => client.request("GET", "/api/me/notifications/sent", { schema: SentNotificationListSchema })
3202
+ );
3203
+ return res.sent;
3204
+ }
2392
3205
 
2393
3206
  // src/commands/auth.ts
2394
3207
  async function loginServer(ctx, url) {
@@ -2602,8 +3415,8 @@ async function runUncomment(ctx, name, id, opts) {
2602
3415
  }
2603
3416
 
2604
3417
  // src/commands/deploy.ts
2605
- import { stat as stat4 } from "node:fs/promises";
2606
- import { resolve as resolve3 } from "node:path";
3418
+ import { stat as stat5 } from "node:fs/promises";
3419
+ import { relative as relative4, resolve as resolve3 } from "node:path";
2607
3420
  import chalk7 from "chalk";
2608
3421
 
2609
3422
  // src/deploy.ts
@@ -2624,7 +3437,7 @@ import { join as join2, relative, sep } from "node:path";
2624
3437
  import ignore from "ignore";
2625
3438
  var GIT_TIMEOUT_MS = 2e4;
2626
3439
  function git(cwd, args, input) {
2627
- return new Promise((resolve5) => {
3440
+ return new Promise((resolve6) => {
2628
3441
  try {
2629
3442
  const child = execFile(
2630
3443
  "git",
@@ -2632,14 +3445,14 @@ function git(cwd, args, input) {
2632
3445
  { timeout: GIT_TIMEOUT_MS, maxBuffer: 256 * 1024 * 1024, encoding: "utf8" },
2633
3446
  (error, stdout) => {
2634
3447
  const code = error?.code;
2635
- if (error && !(args[0] === "check-ignore" && code === 1)) resolve5(null);
2636
- else resolve5(stdout);
3448
+ if (error && !(args[0] === "check-ignore" && code === 1)) resolve6(null);
3449
+ else resolve6(stdout);
2637
3450
  }
2638
3451
  );
2639
- child.on("error", () => resolve5(null));
3452
+ child.on("error", () => resolve6(null));
2640
3453
  if (input !== void 0) child.stdin?.end(input);
2641
3454
  } catch {
2642
- resolve5(null);
3455
+ resolve6(null);
2643
3456
  }
2644
3457
  });
2645
3458
  }
@@ -2680,9 +3493,9 @@ function nestedMode() {
2680
3493
  return {
2681
3494
  mode: "nested",
2682
3495
  ignores(rel, isDir) {
2683
- for (const { base, ig } of levels) {
2684
- if (base && !rel.startsWith(`${base}/`)) continue;
2685
- const sub = base ? rel.slice(base.length + 1) : rel;
3496
+ for (const { base: base2, ig } of levels) {
3497
+ if (base2 && !rel.startsWith(`${base2}/`)) continue;
3498
+ const sub = base2 ? rel.slice(base2.length + 1) : rel;
2686
3499
  if (sub && ig.ignores(isDir ? `${sub}/` : sub)) return true;
2687
3500
  }
2688
3501
  return false;
@@ -3084,6 +3897,20 @@ async function nameTakenError(client, name, body) {
3084
3897
  }
3085
3898
  return new NameTakenError(name, suggestions);
3086
3899
  }
3900
+ var BaseChangedError = class extends CliError {
3901
+ constructor(siteName2, base2, hint) {
3902
+ super(
3903
+ `${siteName2} has changed since you pulled it (version ${base2}): someone published it since`,
3904
+ {
3905
+ hint: hint ?? "Pull it again into another folder, redo your changes there and deploy that, or deploy with --force to replace what they published."
3906
+ }
3907
+ );
3908
+ this.siteName = siteName2;
3909
+ this.base = base2;
3910
+ this.name = "BaseChangedError";
3911
+ }
3912
+ code = "base_changed";
3913
+ };
3087
3914
  var UPLOAD_CONCURRENCY = 8;
3088
3915
  var UPLOAD_RETRIES = 3;
3089
3916
  function resolveSiteName(dir, name) {
@@ -3104,39 +3931,62 @@ function resolveSiteName(dir, name) {
3104
3931
  return derived;
3105
3932
  }
3106
3933
  var defaultSleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
3934
+ var baseChanged = (error) => error instanceof CliApiError && error.status === 409 && error.code === "base_changed";
3107
3935
  async function deploy(deps, opts) {
3108
3936
  const name = resolveSiteName(opts.dir, opts.name);
3109
3937
  const local = opts.manifest ?? await buildManifest(opts.dir);
3110
- const base = {
3938
+ try {
3939
+ return await deployOnce(deps, opts, name, local);
3940
+ } catch (error) {
3941
+ if (opts.path === void 0 || !baseChanged(error)) throw error;
3942
+ return deployOnce(deps, opts, name, local);
3943
+ }
3944
+ }
3945
+ async function deployOnce(deps, opts, name, local) {
3946
+ const onBaseChanged = (error) => opts.base !== void 0 && baseChanged(error) ? new BaseChangedError(name, opts.base) : error;
3947
+ const folder = opts.path;
3948
+ const prefix = folder ?? opts.within;
3949
+ const sitePath3 = (file) => prefix === void 0 ? file : `${prefix}/${file}`;
3950
+ const base2 = {
3111
3951
  name,
3112
3952
  fileCount: local.files.length,
3113
3953
  totalBytes: local.totalBytes,
3114
- warnings: local.warnings
3954
+ warnings: local.warnings,
3955
+ ...folder === void 0 ? {} : { path: folder }
3115
3956
  };
3116
3957
  const path3 = `/api/sites/${encodeURIComponent(name)}/deploys`;
3958
+ const manifest = toManifest(local);
3117
3959
  const res = await deps.client.request("POST", path3, {
3118
3960
  schema: DeployResponseSchema,
3119
3961
  body: {
3120
- manifest: toManifest(local),
3962
+ manifest: { files: manifest.files.map((f) => ({ ...f, path: sitePath3(f.path) })) },
3963
+ ...folder === void 0 ? {} : { path: folder },
3964
+ ...opts.base === void 0 ? {} : {
3965
+ base: opts.base,
3966
+ ...opts.partial ? { partial: true, remove: opts.remove ?? [] } : {}
3967
+ },
3121
3968
  ...opts.spa !== void 0 ? { spa: opts.spa } : {},
3122
- ...opts.public ? { visibility: "public" } : {}
3969
+ ...opts.public ? { visibility: "public" } : {},
3970
+ ...opts.access ? { access: opts.access } : {},
3971
+ ...opts.audience ? { audience: opts.audience } : {}
3123
3972
  }
3124
3973
  }).catch(async (error) => {
3125
3974
  if (error instanceof CliApiError && error.status === 409 && error.code === "name_taken")
3126
3975
  throw await nameTakenError(deps.client, name, error.body);
3127
- throw error;
3976
+ throw onBaseChanged(error);
3128
3977
  });
3129
3978
  if (res.status !== "upload") {
3130
3979
  return {
3131
- ...base,
3980
+ ...base2,
3132
3981
  status: res.status,
3133
3982
  url: res.site.url,
3134
3983
  versionId: res.versionId,
3135
3984
  site: res.site,
3136
- uploaded: 0
3985
+ uploaded: 0,
3986
+ first: false
3137
3987
  };
3138
3988
  }
3139
- const byPath = new Map(local.files.map((f) => [f.path, f]));
3989
+ const byPath = new Map(local.files.map((f) => [sitePath3(f.path), f]));
3140
3990
  const uploads = res.uploads.map((u) => {
3141
3991
  const file = byPath.get(u.path);
3142
3992
  if (!file) throw new CliError(`Server asked for a file that is not in the manifest: ${u.path}`);
@@ -3152,18 +4002,19 @@ async function deploy(deps, opts) {
3152
4002
  bytes += u.file.size;
3153
4003
  opts.onProgress?.({ uploaded: done, total: uploads.length, bytes, totalBytes });
3154
4004
  });
3155
- const fin = await deps.client.request(
3156
- "POST",
3157
- `${path3}/${encodeURIComponent(res.versionId)}/finalize`,
3158
- { schema: FinalizeResponseSchema }
3159
- );
4005
+ const fin = await deps.client.request("POST", `${path3}/${encodeURIComponent(res.versionId)}/finalize`, {
4006
+ schema: FinalizeResponseSchema
4007
+ }).catch((error) => {
4008
+ throw onBaseChanged(error);
4009
+ });
3160
4010
  return {
3161
- ...base,
4011
+ ...base2,
3162
4012
  status: "live",
3163
4013
  url: fin.site.url,
3164
4014
  versionId: fin.versionId,
3165
4015
  site: fin.site,
3166
- uploaded: uploads.length
4016
+ uploaded: uploads.length,
4017
+ first: fin.first === true
3167
4018
  };
3168
4019
  }
3169
4020
  async function uploadWithRetry(deps, file, url, headers) {
@@ -3291,7 +4142,7 @@ function zipDotEntry(name) {
3291
4142
  async function extractZip(data, dest, skippedDotfiles = []) {
3292
4143
  const unsafe = [];
3293
4144
  let declared = 0;
3294
- let count = 0;
4145
+ let count2 = 0;
3295
4146
  let entries;
3296
4147
  try {
3297
4148
  entries = unzipSync(data, {
@@ -3306,7 +4157,7 @@ async function extractZip(data, dest, skippedDotfiles = []) {
3306
4157
  if (dot && !skippedDotfiles.includes(dot)) skippedDotfiles.push(dot);
3307
4158
  return false;
3308
4159
  }
3309
- count++;
4160
+ count2++;
3310
4161
  declared += file.originalSize;
3311
4162
  if (file.originalSize > MAX_FILE_BYTES)
3312
4163
  throw new CliError(
@@ -3316,7 +4167,7 @@ async function extractZip(data, dest, skippedDotfiles = []) {
3316
4167
  throw new CliError(
3317
4168
  `The zip expands to more than ${formatBytes(MAX_DEPLOY_BYTES)}, the limit per deploy`
3318
4169
  );
3319
- if (count > MAX_FILES) throw new CliError(`The zip has more than ${MAX_FILES} files`);
4170
+ if (count2 > MAX_FILES) throw new CliError(`The zip has more than ${MAX_FILES} files`);
3320
4171
  return true;
3321
4172
  }
3322
4173
  });
@@ -3387,40 +4238,307 @@ function wrapperPage(file) {
3387
4238
  `;
3388
4239
  }
3389
4240
 
3390
- // src/commands/deploy.ts
3391
- async function runDeploy(ctx, input, opts) {
3392
- const client = requireClient(ctx);
3393
- const ask = ctx.interactive && !opts.yes;
3394
- const instance = await loadInstance(ctx.store, ctx.config, ctx.fetch);
3395
- const base = sitesBaseOf(instance);
3396
- const path3 = resolve3(input ?? ".");
3397
- if (!await stat4(path3).catch(() => null))
3398
- throw new CliError(`No such file or folder: ${input ?? "."}`, {
3399
- hint: `Give the path to a folder, .html, .zip or other file, e.g. \`${ctx.binName} deploy ./dist\`.`
3400
- });
3401
- const prepared = await prepareInput(path3);
4241
+ // src/pull.ts
4242
+ import { createHash as createHash2 } from "node:crypto";
4243
+ import { mkdir as mkdir3, readdir as readdir2, readFile as readFile6, rm as rm2, stat as stat4, writeFile as writeFile3 } from "node:fs/promises";
4244
+ import { dirname as dirname2, join as join5 } from "node:path";
4245
+ import { z as z18 } from "zod";
4246
+ var SITE_LINK_FILE = ".fling-site.json";
4247
+ var DOWNLOAD_CONCURRENCY = 8;
4248
+ var DOWNLOAD_RETRIES = 3;
4249
+ var SiteLinkSchema = z18.object({
4250
+ /** the Fling it was pulled from */
4251
+ server: z18.string(),
4252
+ name: z18.string(),
4253
+ /** the version pulled, or the one the last deploy made */
4254
+ versionId: z18.string(),
4255
+ /** a pull of one folder of the site: the folder is this folder */
4256
+ path: z18.string().optional(),
4257
+ /** every file pulled (or deployed), by site path: its SHA-256 */
4258
+ files: z18.record(z18.string(), z18.string())
4259
+ });
4260
+ async function readSiteLink(dir) {
4261
+ let raw;
3402
4262
  try {
3403
- await deployPrepared(ctx, client, instance, base, prepared, opts, ask);
4263
+ raw = await readFile6(join5(dir, SITE_LINK_FILE), "utf8");
4264
+ } catch {
4265
+ return null;
4266
+ }
4267
+ try {
4268
+ return SiteLinkSchema.parse(JSON.parse(raw));
4269
+ } catch {
4270
+ throw new CliError(`${join5(dir, SITE_LINK_FILE)} is not a site link Fling can read`, {
4271
+ hint: "Delete it to deploy the folder as a plain folder, or pull the site again."
4272
+ });
4273
+ }
4274
+ }
4275
+ async function writeSiteLink(dir, link) {
4276
+ await writeFile3(join5(dir, SITE_LINK_FILE), `${JSON.stringify(link, null, 2)}
4277
+ `);
4278
+ }
4279
+ var siteFiles = (link, local) => Object.fromEntries(
4280
+ local.files.map((f) => [link.path === void 0 ? f.path : `${link.path}/${f.path}`, f.sha256])
4281
+ );
4282
+ function onLink(link, local) {
4283
+ const now = siteFiles(link, local);
4284
+ const changes = { changed: [], added: [], removed: [] };
4285
+ for (const [path3, sha] of Object.entries(now)) {
4286
+ const was = link.files[path3];
4287
+ if (was === void 0) changes.added.push(path3);
4288
+ else if (was !== sha) changes.changed.push(path3);
4289
+ }
4290
+ for (const path3 of Object.keys(link.files)) if (!(path3 in now)) changes.removed.push(path3);
4291
+ return {
4292
+ base: link.versionId,
4293
+ ...link.path === void 0 ? {} : { partial: true, within: link.path, remove: changes.removed },
4294
+ changes
4295
+ };
4296
+ }
4297
+ var linkAfter = (link, versionId, local) => ({
4298
+ ...link,
4299
+ versionId,
4300
+ files: siteFiles(link, local)
4301
+ });
4302
+ function describeChanges(c) {
4303
+ const parts = [
4304
+ c.changed.length ? `${c.changed.length} changed` : "",
4305
+ c.added.length ? `${c.added.length} added` : "",
4306
+ c.removed.length ? `${c.removed.length} removed` : ""
4307
+ ].filter(Boolean);
4308
+ return parts.length ? parts.join(", ") : "no changes";
4309
+ }
4310
+ var defaultSleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
4311
+ async function pullSite(deps, opts) {
4312
+ const existing = await stat4(opts.dir).catch(() => null);
4313
+ if (existing && !existing.isDirectory()) throw new CliError(`${opts.dir} is not a folder`);
4314
+ const entries = existing ? await readdir2(opts.dir) : [];
4315
+ if (entries.length > 0 && !opts.force) {
4316
+ throw new CliError(`${opts.dir} is not empty`, {
4317
+ hint: "Pull into a new folder, or pass --force to write the files over what is there."
4318
+ });
4319
+ }
4320
+ const before = entries.includes(SITE_LINK_FILE) ? await readSiteLink(opts.dir) : null;
4321
+ const pulled = await deps.client.request(
4322
+ "GET",
4323
+ `/api/sites/${encodeURIComponent(opts.name)}/pull`,
4324
+ {
4325
+ schema: PullResponseSchema,
4326
+ query: { version: opts.versionId, path: opts.path }
4327
+ }
4328
+ );
4329
+ const prefix = opts.path === void 0 ? "" : `${opts.path}/`;
4330
+ const local = (f) => {
4331
+ const rel = f.path.slice(prefix.length);
4332
+ if (!f.path.startsWith(prefix) || !isValidSitePath(rel)) {
4333
+ throw new CliError(`The server sent a file outside the site: ${f.path}`);
4334
+ }
4335
+ return rel;
4336
+ };
4337
+ const totalBytes = pulled.files.reduce((n, f) => n + f.size, 0);
4338
+ let done = 0;
4339
+ let bytes = 0;
4340
+ await mkdir3(opts.dir, { recursive: true });
4341
+ opts.onProgress?.({ done, total: pulled.files.length, bytes, totalBytes });
4342
+ await mapLimit(pulled.files, DOWNLOAD_CONCURRENCY, async (f) => {
4343
+ const target = join5(opts.dir, local(f));
4344
+ const body = await download(deps, f);
4345
+ await mkdir3(dirname2(target), { recursive: true });
4346
+ await writeFile3(target, body);
4347
+ done++;
4348
+ bytes += f.size;
4349
+ opts.onProgress?.({ done, total: pulled.files.length, bytes, totalBytes });
4350
+ });
4351
+ if (before && before.server === deps.client.serverUrl && before.name === opts.name) {
4352
+ const now = new Set(pulled.files.map((f) => f.path));
4353
+ const beforePrefix = before.path === void 0 ? "" : `${before.path}/`;
4354
+ for (const path3 of Object.keys(before.files)) {
4355
+ if (now.has(path3) || !path3.startsWith(beforePrefix)) continue;
4356
+ const rel = path3.slice(beforePrefix.length);
4357
+ if (isValidSitePath(rel)) await rm2(join5(opts.dir, rel), { force: true });
4358
+ }
4359
+ }
4360
+ await writeSiteLink(opts.dir, {
4361
+ server: deps.client.serverUrl,
4362
+ name: opts.name,
4363
+ versionId: pulled.versionId,
4364
+ ...pulled.path === void 0 ? {} : { path: pulled.path },
4365
+ files: Object.fromEntries(pulled.files.map((f) => [f.path, f.sha256]))
4366
+ });
4367
+ return {
4368
+ server: deps.client.serverUrl,
4369
+ name: opts.name,
4370
+ dir: opts.dir,
4371
+ versionId: pulled.versionId,
4372
+ ...pulled.path === void 0 ? {} : { path: pulled.path },
4373
+ fileCount: pulled.files.length,
4374
+ totalBytes
4375
+ };
4376
+ }
4377
+ async function download(deps, file) {
4378
+ const sleep = deps.sleep ?? defaultSleep3;
4379
+ for (let attempt = 0; ; attempt++) {
4380
+ let retryable = true;
4381
+ let why;
4382
+ try {
4383
+ const res = await deps.fetch(file.url, { signal: AbortSignal.timeout(5 * 6e4) });
4384
+ if (res.ok) {
4385
+ const body = new Uint8Array(await res.arrayBuffer());
4386
+ if (createHash2("sha256").update(body).digest("hex") === file.sha256) return body;
4387
+ why = "the bytes do not match their hash";
4388
+ } else {
4389
+ await res.arrayBuffer().catch(() => {
4390
+ });
4391
+ retryable = res.status >= 500 || res.status === 408 || res.status === 429;
4392
+ why = `HTTP ${res.status}`;
4393
+ }
4394
+ } catch (error) {
4395
+ why = `network error (${error.message})`;
4396
+ }
4397
+ if (!retryable || attempt >= DOWNLOAD_RETRIES) {
4398
+ throw new CliError(`Download of ${file.path} failed: ${why}`, {
4399
+ hint: retryable ? "Check your connection and pull again." : `Download links last ${PULL_URL_TTL_SECONDS / 60} minutes: pull again.`
4400
+ });
4401
+ }
4402
+ await sleep(500 * 2 ** attempt);
4403
+ }
4404
+ }
4405
+
4406
+ // src/commands/deploy.ts
4407
+ function accessChoice(opts, bin = "fling") {
4408
+ const emails2 = splitEmails(opts.add ?? []);
4409
+ const groups2 = splitGroups(opts.group ?? []);
4410
+ const named = emails2.length + groups2.length > 0;
4411
+ let level;
4412
+ if (opts.access !== void 0) {
4413
+ const parsed = parseAccessLevel(opts.access);
4414
+ if (!parsed) {
4415
+ throw new CliError(`Unknown access "${opts.access}"`, {
4416
+ hint: "Use only_me, members, groups, people or public."
4417
+ });
4418
+ }
4419
+ level = parsed;
4420
+ } else if (named) level = emails2.length ? "people" : "groups";
4421
+ if (!level) return {};
4422
+ if (opts.public) throw new CliError("Use --access or --public, not both");
4423
+ if (needsAudience(level) && !named) {
4424
+ throw new CliError(`--access ${level} needs someone to let in`, {
4425
+ hint: `Add --add <emails> or --group <slugs> (\`${bin} groups\` lists them).`
4426
+ });
4427
+ }
4428
+ if (!needsAudience(level) && named) {
4429
+ throw new CliError(`--access ${level} lets no one else in: leave out --add and --group`);
4430
+ }
4431
+ return { access: level, ...needsAudience(level) ? { audience: { emails: emails2, groups: groups2 } } : {} };
4432
+ }
4433
+ async function runDeploy(ctx, input, opts) {
4434
+ const path3 = resolve3(input ?? ".");
4435
+ const found = await stat5(path3).catch(() => null);
4436
+ if (!found)
4437
+ throw new CliError(`No such file or folder: ${input ?? "."}`, {
4438
+ hint: `Give the path to a folder, .html, .zip or other file, e.g. \`${ctx.binName} deploy ./dist\`.`
4439
+ });
4440
+ const linked = found.isDirectory() ? await linkedTo(ctx, await readSiteLink(path3), opts) : null;
4441
+ if (linked) ctx = linked.ctx;
4442
+ const link = linked?.link ?? null;
4443
+ const client = requireClient(ctx);
4444
+ const ask = ctx.interactive && !opts.yes;
4445
+ const choice = accessChoice(opts, ctx.binName);
4446
+ const folder = publishFolder(ctx, opts);
4447
+ const needs = folder !== void 0 ? "pathPublish" : link && !opts.force ? "pull" : choice.access ? "accessLevels" : null;
4448
+ const instance = needs ? await loadInstanceWith(ctx.store, ctx.config, ctx.fetch, needs) : await loadInstance(ctx.store, ctx.config, ctx.fetch);
4449
+ const base2 = sitesBaseOf(instance);
4450
+ if (folder !== void 0 && !pathPublishEnabled(instance)) {
4451
+ throw new CliError(`${instance?.name ?? "This Fling"} can't publish to a path yet`, {
4452
+ hint: "Leave out --path to publish the whole site, or ask its operator to update it."
4453
+ });
4454
+ }
4455
+ if (link && !opts.force && !pullEnabled(instance)) {
4456
+ throw new CliError(`${instance?.name ?? "This Fling"} can't publish on a pulled version yet`, {
4457
+ hint: "Deploy with --force to replace the whole site with the folder, or ask its operator to update it."
4458
+ });
4459
+ }
4460
+ if (choice.access && instance && !accessLevelsEnabled(instance)) {
4461
+ throw new CliError(`${instance.name} can't take --access yet`, {
4462
+ hint: `Leave it out, then use \`${ctx.binName} access\` or \`${ctx.binName} visibility\`.`
4463
+ });
4464
+ }
4465
+ const prepared = await prepareInput(path3);
4466
+ try {
4467
+ const { access: _asked, path: _path, ...rest } = opts;
4468
+ await deployPrepared(
4469
+ ctx,
4470
+ client,
4471
+ instance,
4472
+ base2,
4473
+ prepared,
4474
+ { ...rest, ...choice, folder, link },
4475
+ ask
4476
+ );
3404
4477
  } finally {
3405
4478
  await prepared.cleanup();
3406
4479
  }
3407
4480
  }
3408
- async function deployPrepared(ctx, client, instance, base, prepared, opts, ask) {
4481
+ async function linkedTo(ctx, link, opts) {
4482
+ if (!link) return null;
4483
+ const plain = (why) => {
4484
+ ctx.log.info(`The folder was pulled from ${link.name} on ${link.server}; ${why}`);
4485
+ return null;
4486
+ };
4487
+ if (opts.name !== void 0 && opts.name !== link.name) {
4488
+ return plain(`deploying it to ${opts.name} as a plain folder.`);
4489
+ }
4490
+ if (opts.path !== void 0) return plain("--path deploys it as a plain folder.");
4491
+ if (ctx.config.serverUrl === link.server) return { link, ctx };
4492
+ if (ctx.config.serverUrlSource === "flag" || ctx.config.serverUrlSource === "env") {
4493
+ return plain(`deploying it to ${ctx.config.serverUrl} as a plain folder.`);
4494
+ }
4495
+ const config = await ctx.store.resolve({ serverUrl: link.server });
4496
+ if (!config.apiKey) {
4497
+ throw new CliError(`Not signed in to ${link.server}, where the folder was pulled from`, {
4498
+ exitCode: EXIT_NOT_LOGGED_IN,
4499
+ hint: `Run \`${ctx.binName} login ${link.server}\` first.`
4500
+ });
4501
+ }
4502
+ return { link, ctx: { ...ctx, config } };
4503
+ }
4504
+ function publishFolder(ctx, opts) {
4505
+ if (opts.path === void 0) return void 0;
4506
+ const folder = normalizePublishPath(opts.path);
4507
+ if (folder === null) {
4508
+ throw new CliError(`Invalid --path "${opts.path}"`, {
4509
+ hint: "Use a folder of the site, like 30-10-26 or reports/2026 (no dotfiles)."
4510
+ });
4511
+ }
4512
+ if (opts.name === void 0) {
4513
+ throw new CliError("--path needs --name: the site to publish into", {
4514
+ hint: `e.g. \`${ctx.binName} deploy ./report --name changelog --path ${folder}\`.`
4515
+ });
4516
+ }
4517
+ return folder;
4518
+ }
4519
+ async function deployPrepared(ctx, client, instance, base2, prepared, opts, ask) {
4520
+ const { link } = opts;
4521
+ const folder = opts.folder ?? link?.path;
4522
+ const inFolder = (file) => folder === void 0 ? file : `${folder}/${file}`;
3409
4523
  let local = await buildManifest(prepared.dir);
3410
4524
  local.skipped.dotfiles.unshift(...prepared.skippedDotfiles);
3411
4525
  for (const line of describeSkipped(local.skipped)) ctx.log.info(line);
3412
- let name = opts.name !== void 0 ? resolveSiteName(prepared.dir, opts.name) : prepared.defaultName;
3413
- if (opts.name === void 0 && ask) name = await confirmSiteName(ctx, client, name, base);
3414
- if (!hasRootIndex(local)) {
4526
+ let name = link ? link.name : opts.name !== void 0 ? resolveSiteName(prepared.dir, opts.name) : prepared.defaultName;
4527
+ if (!link && opts.name === void 0 && ask) {
4528
+ name = await confirmSiteName(ctx, client, name, base2);
4529
+ }
4530
+ if (!link && !hasRootIndex(local)) {
3415
4531
  const pages = htmlPages(local).length;
3416
- const root = base ? siteUrlFor(name, base) : "the site root";
3417
- const warning = `No index.html at the root \u2014 ${root} will show a 404.`;
4532
+ const root = base2 ? siteUrlFor(name, base2) + (folder ? `${folder}/` : "") : "the site root";
4533
+ const warning = `No index.html ${folder ? `in ${folder}/` : "at the root"} \u2014 ${root} will show a 404.`;
3418
4534
  const choice = opts.listHtml === true ? "list" : opts.listHtml === false ? "fling" : ask ? await askNoIndex(ctx, pages) : pages > 0 ? "list" : "fling";
3419
4535
  if (choice === "list") {
3420
4536
  local = await withHtmlListing(local, name);
3421
4537
  ctx.log.info(`Generated index.html listing ${pages} HTML page${pages === 1 ? "" : "s"}`);
3422
4538
  } else ctx.log.warn(warning);
3423
4539
  }
4540
+ const onBase = link && !opts.force ? onLink(link, local) : null;
4541
+ const changes = link ? onLink(link, local).changes : null;
3424
4542
  let progressShown = false;
3425
4543
  const result = await deploy(
3426
4544
  { client, fetch: ctx.fetch },
@@ -3428,8 +4546,15 @@ async function deployPrepared(ctx, client, instance, base, prepared, opts, ask)
3428
4546
  dir: prepared.dir,
3429
4547
  name,
3430
4548
  manifest: local,
4549
+ path: opts.folder,
4550
+ ...onBase ? {
4551
+ base: onBase.base,
4552
+ ...onBase.partial ? { partial: true, remove: onBase.remove, within: onBase.within } : {}
4553
+ } : {},
3431
4554
  spa: opts.spa ? true : void 0,
3432
4555
  public: opts.public,
4556
+ access: opts.access,
4557
+ audience: opts.audience,
3433
4558
  onProgress: (p) => {
3434
4559
  if (p.total === 0) return;
3435
4560
  progressShown = true;
@@ -3438,49 +4563,80 @@ async function deployPrepared(ctx, client, instance, base, prepared, opts, ask)
3438
4563
  );
3439
4564
  }
3440
4565
  }
3441
- ).finally(() => {
4566
+ ).catch((error) => {
4567
+ if (!(error instanceof BaseChangedError)) throw error;
4568
+ const dir = relative4(process.cwd(), prepared.dir) || ".";
4569
+ throw new BaseChangedError(
4570
+ error.siteName,
4571
+ error.base,
4572
+ `Pull it again into another folder (\`${ctx.binName} pull ${name} ${dir}-latest\`), redo your changes there and deploy that; or \`${ctx.binName} deploy ${dir} --force\` to replace what they published with this folder.`
4573
+ );
4574
+ }).finally(() => {
3442
4575
  if (progressShown) ctx.progress(null);
3443
4576
  });
4577
+ if (link) {
4578
+ await writeSiteLink(
4579
+ prepared.dir,
4580
+ linkAfter({ ...link, server: client.serverUrl }, result.versionId, local)
4581
+ );
4582
+ }
3444
4583
  for (const warning of result.warnings) if (warning !== NO_INDEX_WARNING) ctx.log.warn(warning);
3445
- const summary = `${result.fileCount} files, ${formatBytes(result.totalBytes)}`;
4584
+ const summary = changes ? `${describeChanges(changes)} since ${link?.versionId}` : `${result.fileCount} files, ${formatBytes(result.totalBytes)}`;
3446
4585
  if (result.status === "unchanged") ctx.log.info(`Unchanged ${chalk7.dim(`(${summary})`)}`);
3447
4586
  else if (result.status === "repointed")
3448
4587
  ctx.log.success(`Restored version ${result.versionId} ${chalk7.dim(`(${summary})`)}`);
3449
4588
  else
3450
4589
  ctx.log.success(
3451
- `Deployed ${result.name} ${chalk7.dim(`(${summary}; uploaded ${result.uploaded}, version ${result.versionId})`)}`
4590
+ `Deployed ${result.name}${folder ? ` to /${folder}/` : ""} ${chalk7.dim(`(${summary}; uploaded ${result.uploaded}, version ${result.versionId})`)}`
3452
4591
  );
4592
+ const url = folder ? new URL(`${folder}/`, result.url).href : result.url;
3453
4593
  const ready = result.status === "unchanged" ? true : (await waitWithSpinner(
3454
4594
  ctx.waitReady,
3455
- result.url,
3456
- local.files.map((f) => f.path),
4595
+ url,
4596
+ local.files.map((f) => inFolder(f.path)),
3457
4597
  { progress: ctx.progress, warn: (m) => ctx.log.warn(m) }
3458
4598
  )).ready;
3459
- const who = instance ? `${privateAudience(instance)} can open it` : "sign-in required";
3460
- const privateNote = `Private: ${who}. Share publicly with \`${ctx.binName} share ${result.name}\`.`;
4599
+ const members2 = instance ? privateAudience(instance) : "anyone signed in to this Fling";
4600
+ const level = siteAccessLevel(result.site);
4601
+ const who = describeSiteAccess(result.site, members2);
4602
+ if (!result.first && opts.access) {
4603
+ ctx.log.warn(
4604
+ `--access only applies to a new site: ${result.name} keeps its access. Change it with \`${ctx.binName} access ${result.name}\`.`
4605
+ );
4606
+ }
4607
+ const accessNotes = result.first ? [
4608
+ `Who can open it: ${who}`,
4609
+ accessChangeHint("cli", result.name, {
4610
+ bin: ctx.binName,
4611
+ invites: instance?.capabilities.invites === true
4612
+ })
4613
+ ] : level === "public" ? [] : [`${who} Share it with a link: \`${ctx.binName} share ${result.name}\`.`];
3461
4614
  const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
3462
4615
  const reviewNote = instance ? `Share for feedback (reviewers sign in to ${instance.name}):` : "Share for feedback:";
3463
4616
  const data = {
3464
4617
  status: result.status,
3465
4618
  site: result.site,
4619
+ ...folder ? { path: folder, url } : {},
4620
+ ...changes && link ? { base: opts.force ? null : link.versionId, changes } : {},
4621
+ access: level,
4622
+ ...result.first ? { created: true } : {},
3466
4623
  ...review ? { reviewUrl: review } : {},
3467
4624
  ready,
3468
4625
  skipped: local.skipped
3469
4626
  };
3470
4627
  ctx.out.data(data, () => {
3471
- const isPrivate = result.site.visibility === "private";
3472
4628
  if (ctx.out.tty)
3473
4629
  return box(
3474
4630
  [
3475
4631
  ...review ? [chalk7.dim(reviewNote), brand.url(review), "", chalk7.dim("Site:")] : [],
3476
- brand.url(result.url),
3477
- ...isPrivate ? [chalk7.dim(privateNote)] : []
4632
+ brand.url(url),
4633
+ ...accessNotes.map((n) => chalk7.dim(n))
3478
4634
  ],
3479
- isPrivate ? chalk7.bold("flung \xB7 private") : chalk7.bold("flung \xB7 public")
4635
+ chalk7.bold(`flung \xB7 ${ACCESS_LABELS[level].toLowerCase()}`)
3480
4636
  );
3481
- const lines = [chalk7.bold(result.url)];
4637
+ const lines = [chalk7.bold(url)];
3482
4638
  if (review) lines.push(chalk7.dim(`${reviewNote} ${review}`));
3483
- if (isPrivate) lines.push(chalk7.dim(privateNote));
4639
+ for (const n of accessNotes) lines.push(chalk7.dim(n));
3484
4640
  return lines.join("\n");
3485
4641
  });
3486
4642
  }
@@ -3501,8 +4657,8 @@ async function askNoIndex(ctx, pages) {
3501
4657
  });
3502
4658
  }
3503
4659
  }
3504
- async function confirmSiteName(ctx, client, generated, base) {
3505
- const where = (n) => base ? ` \u2192 ${siteUrlFor(n, base)}` : "";
4660
+ async function confirmSiteName(ctx, client, generated, base2) {
4661
+ const where = (n) => base2 ? ` \u2192 ${siteUrlFor(n, base2)}` : "";
3506
4662
  const describe2 = (n, note) => ctx.log.info(`Site name: ${chalk7.bold(n)}${where(n)} ${chalk7.dim(`(${note})`)}`);
3507
4663
  let candidate = generated;
3508
4664
  for (; ; ) {
@@ -3606,21 +4762,165 @@ async function runGroupDelete(ctx, slug, opts) {
3606
4762
  if (ctx.json) ctx.out.data(result, () => "");
3607
4763
  }
3608
4764
 
4765
+ // src/commands/notifications.ts
4766
+ import chalk9 from "chalk";
4767
+ var VALUES = ["on", "off", "default"];
4768
+ function notificationsClient(ctx) {
4769
+ const instance = ctx.config.instance;
4770
+ if (instance && !notificationsEnabled(instance)) {
4771
+ throw new CliError(`${instance.name} has no notifications`);
4772
+ }
4773
+ return { client: requireClient(ctx), name: instance?.name ?? "This Fling" };
4774
+ }
4775
+ function renderSettings(settings) {
4776
+ const marked = (key, channel) => {
4777
+ const value = settings.cells[key]?.[channel] ?? "off";
4778
+ return settings.overrides[key]?.[channel] !== void 0 ? `${value}*` : value;
4779
+ };
4780
+ const table = renderTable(settings.events, [
4781
+ { header: "SECTION", value: (e) => sectionTitle(e.section) },
4782
+ { header: "EVENT", value: (e) => e.key },
4783
+ ...settings.channels.map((channel) => ({
4784
+ header: channel.toUpperCase(),
4785
+ value: (e) => marked(e.key, channel)
4786
+ })),
4787
+ { header: "WHEN", value: (e) => e.title }
4788
+ ]);
4789
+ const lines = [table, "", chalk9.dim("* your choice; the rest are the defaults")];
4790
+ if (settings.muted) {
4791
+ lines.unshift(
4792
+ chalk9.yellow(
4793
+ "Muted: nothing is sent, whatever the table says (`fling notifications unmute` to undo)."
4794
+ ),
4795
+ ""
4796
+ );
4797
+ }
4798
+ return lines.join("\n");
4799
+ }
4800
+ async function runNotifications(ctx) {
4801
+ const { client, name } = notificationsClient(ctx);
4802
+ const settings = await getNotifications(client, name);
4803
+ ctx.out.data(settings, () => renderSettings(settings));
4804
+ }
4805
+ async function runNotificationsSet(ctx, event, channel, value) {
4806
+ const { client, name } = notificationsClient(ctx);
4807
+ if (!VALUES.includes(value)) {
4808
+ throw new CliError(`"${value}" is not on, off or default`);
4809
+ }
4810
+ const current = await getNotifications(client, name);
4811
+ if (!current.events.some((e) => e.key === event)) {
4812
+ throw new CliError(`${name} has no event "${event}"`, {
4813
+ hint: `Events: ${current.events.map((e) => e.key).join(", ")}.`
4814
+ });
4815
+ }
4816
+ if (!current.channels.includes(channel)) {
4817
+ throw new CliError(`${name} has no channel "${channel}"`, {
4818
+ hint: `Channels: ${current.channels.join(", ")}.`
4819
+ });
4820
+ }
4821
+ const settings = await setNotifications(
4822
+ client,
4823
+ { cells: [{ event, channel, value }] },
4824
+ name
4825
+ );
4826
+ const now = settings.cells[event]?.[channel] ?? "off";
4827
+ ctx.log.success(
4828
+ value === "default" ? `${event} on ${channel} is back to the default (${now})` : `${event} on ${channel} is ${now}`
4829
+ );
4830
+ if (settings.muted) ctx.log.warn("Everything is muted, so nothing is sent until you unmute.");
4831
+ ctx.out.data(settings, () => renderSettings(settings));
4832
+ }
4833
+ async function runNotificationsMute(ctx, muted) {
4834
+ const { client, name } = notificationsClient(ctx);
4835
+ const settings = await setNotifications(client, { muted }, name);
4836
+ ctx.log.success(
4837
+ muted ? `Muted: ${name} sends you nothing` : `Unmuted: ${name} sends what the table says`
4838
+ );
4839
+ if (ctx.json) ctx.out.data(settings, () => "");
4840
+ }
4841
+ async function runNotificationsSent(ctx) {
4842
+ const { client, name } = notificationsClient(ctx);
4843
+ const sent = await listSentNotifications(client, name);
4844
+ ctx.out.data(
4845
+ { sent },
4846
+ () => sent.length ? renderTable(sent, [
4847
+ { header: "SENT", value: (s) => formatDate(s.sentAt) },
4848
+ { header: "CHANNEL", value: (s) => s.channel },
4849
+ { header: "SUBJECT", value: (s) => s.subject },
4850
+ { header: "EVENTS", value: (s) => s.eventKeys.join(", ") }
4851
+ ]) : chalk9.dim("Nothing was sent to you in the last 30 days.")
4852
+ );
4853
+ }
4854
+
4855
+ // src/commands/pull.ts
4856
+ import { relative as relative5, resolve as resolve4 } from "node:path";
4857
+ import chalk10 from "chalk";
4858
+ async function runPull(ctx, name, dir, opts) {
4859
+ const client = requireClient(ctx);
4860
+ const instance = await loadInstanceWith(ctx.store, ctx.config, ctx.fetch, "pull");
4861
+ if (instance && !pullEnabled(instance)) {
4862
+ throw new CliError(`${instance.name} can't pull sites yet`, {
4863
+ hint: "Ask its operator to update it."
4864
+ });
4865
+ }
4866
+ let path3;
4867
+ if (opts.path !== void 0) {
4868
+ const folder = normalizePublishPath(opts.path);
4869
+ if (folder === null) {
4870
+ throw new CliError(`Invalid --path "${opts.path}"`, {
4871
+ hint: "Use a folder of the site, like 30-10-26 or reports/2026 (no dotfiles)."
4872
+ });
4873
+ }
4874
+ path3 = folder;
4875
+ }
4876
+ const target = resolve4(dir ?? name);
4877
+ let progressShown = false;
4878
+ const pulled = await pullSite(
4879
+ { client, fetch: ctx.fetch },
4880
+ {
4881
+ name,
4882
+ dir: target,
4883
+ ...opts.versionId ? { versionId: opts.versionId } : {},
4884
+ ...path3 === void 0 ? {} : { path: path3 },
4885
+ force: opts.force,
4886
+ onProgress: (p) => {
4887
+ if (p.total === 0) return;
4888
+ progressShown = true;
4889
+ ctx.progress(
4890
+ `downloading ${p.done}/${p.total} files \xB7 ${formatBytes(p.bytes)}/${formatBytes(p.totalBytes)}`
4891
+ );
4892
+ }
4893
+ }
4894
+ ).finally(() => {
4895
+ if (progressShown) ctx.progress(null);
4896
+ });
4897
+ const shown = relative5(process.cwd(), pulled.dir) || ".";
4898
+ ctx.log.success(
4899
+ `Pulled ${name}${path3 ? ` /${path3}/` : ""} into ${shown} ${chalk10.dim(`(${pulled.fileCount} files, ${formatBytes(pulled.totalBytes)}, version ${pulled.versionId})`)}`
4900
+ );
4901
+ ctx.out.data(
4902
+ pulled,
4903
+ () => chalk10.dim(
4904
+ `Change what you need, then \`${ctx.binName} deploy ${shown}\`: it publishes on version ${pulled.versionId}, and stops if someone publishes ${name} before you do.`
4905
+ )
4906
+ );
4907
+ }
4908
+
3609
4909
  // src/commands/setup.ts
3610
4910
  import { intro, outro, spinner } from "@clack/prompts";
3611
- import chalk9 from "chalk";
4911
+ import chalk11 from "chalk";
3612
4912
 
3613
4913
  // src/claude.ts
3614
4914
  import { execFile as execFile2 } from "node:child_process";
3615
- import { access, mkdtemp as mkdtemp2, writeFile as writeFile3 } from "node:fs/promises";
4915
+ import { access, mkdtemp as mkdtemp2, writeFile as writeFile4 } from "node:fs/promises";
3616
4916
  import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
3617
- import { join as join5 } from "node:path";
4917
+ import { join as join6 } from "node:path";
3618
4918
  var MARKETPLACE_SOURCE = REPO_SLUG;
3619
4919
  var MARKETPLACE_NAME = "fling";
3620
4920
  var PLUGIN_NAME = "fling";
3621
4921
  var PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
3622
4922
  var MCPB_PATH = "/downloads/fling.mcpb";
3623
- var runClaude = (args) => new Promise((resolve5) => {
4923
+ var runClaude = (args) => new Promise((resolve6) => {
3624
4924
  execFile2(
3625
4925
  "claude",
3626
4926
  [...args],
@@ -3628,7 +4928,7 @@ var runClaude = (args) => new Promise((resolve5) => {
3628
4928
  (error, stdout, stderr) => {
3629
4929
  const missing = error?.code === "ENOENT";
3630
4930
  const code = !error ? 0 : missing ? -1 : typeof error.code === "number" ? error.code : 1;
3631
- resolve5({ code, stdout: String(stdout), stderr: String(stderr) });
4931
+ resolve6({ code, stdout: String(stdout), stderr: String(stderr) });
3632
4932
  }
3633
4933
  );
3634
4934
  });
@@ -3674,7 +4974,7 @@ async function findClaudeDesktop(platform = process.platform, env = process.env,
3674
4974
  () => true,
3675
4975
  () => false
3676
4976
  )) {
3677
- const candidates = platform === "darwin" ? ["/Applications/Claude.app", join5(homedir3(), "Applications", "Claude.app")] : platform === "win32" && env.LOCALAPPDATA ? [join5(env.LOCALAPPDATA, "AnthropicClaude"), join5(env.LOCALAPPDATA, "Programs", "Claude")] : [];
4977
+ const candidates = platform === "darwin" ? ["/Applications/Claude.app", join6(homedir3(), "Applications", "Claude.app")] : platform === "win32" && env.LOCALAPPDATA ? [join6(env.LOCALAPPDATA, "AnthropicClaude"), join6(env.LOCALAPPDATA, "Programs", "Claude")] : [];
3678
4978
  for (const path3 of candidates) if (await exists(path3)) return path3;
3679
4979
  return null;
3680
4980
  }
@@ -3682,9 +4982,9 @@ async function downloadExtension(serverUrl, fetch2) {
3682
4982
  const url = new URL(MCPB_PATH, serverUrl).toString();
3683
4983
  const response = await fetch2(url);
3684
4984
  if (!response.ok) throw new Error(`Could not download ${url} (HTTP ${response.status})`);
3685
- const dir = await mkdtemp2(join5(tmpdir2(), "fling-mcpb-"));
3686
- const file = join5(dir, "fling.mcpb");
3687
- await writeFile3(file, Buffer.from(await response.arrayBuffer()));
4985
+ const dir = await mkdtemp2(join6(tmpdir2(), "fling-mcpb-"));
4986
+ const file = join6(dir, "fling.mcpb");
4987
+ await writeFile4(file, Buffer.from(await response.arrayBuffer()));
3688
4988
  return file;
3689
4989
  }
3690
4990
 
@@ -3701,14 +5001,14 @@ async function runSetup(baseCtx, opts, deps = {}) {
3701
5001
  ...baseCtx,
3702
5002
  log: createLogger({
3703
5003
  debug: baseCtx.config.debug,
3704
- write: (line) => process.stderr.write(`${chalk9.gray("\u2502")} ${line}
5004
+ write: (line) => process.stderr.write(`${chalk11.gray("\u2502")} ${line}
3705
5005
  `)
3706
5006
  })
3707
5007
  } : baseCtx;
3708
5008
  if (fancy) {
3709
5009
  process.stderr.write(`${logo()}
3710
5010
  `);
3711
- intro(chalk9.bold(" fling setup "), { output: process.stderr });
5011
+ intro(chalk11.bold(" fling setup "), { output: process.stderr });
3712
5012
  }
3713
5013
  const login = await stepLogin(ctx, opts.url);
3714
5014
  const results = {
@@ -3722,7 +5022,7 @@ async function runSetup(baseCtx, opts, deps = {}) {
3722
5022
  const failed = Object.values(results).includes("failed");
3723
5023
  if (fancy)
3724
5024
  outro(
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.`,
5025
+ failed ? chalk11.yellow("Setup finished with problems \u2014 see above, then run `fling setup` again.") : `You're set. Try ${chalk11.bold(`${ctx.binName} deploy ./dist`)}, or ask Claude to \u201Cfling this\u201D.`,
3726
5026
  { output: process.stderr }
3727
5027
  );
3728
5028
  ctx.out.data(results, () => "");
@@ -3745,7 +5045,7 @@ async function signIn(ctx, serverUrl) {
3745
5045
  try {
3746
5046
  const me = await whoami(requireClient(ctx));
3747
5047
  if (ctx.config.remembered) await ctx.store.setCurrent(serverUrl);
3748
- ctx.log.success(`Signed in as ${chalk9.bold(me.name)} ${chalk9.dim(`<${me.email}>`)}`);
5048
+ ctx.log.success(`Signed in as ${chalk11.bold(me.name)} ${chalk11.dim(`<${me.email}>`)}`);
3749
5049
  return "already";
3750
5050
  } catch (error) {
3751
5051
  if (exitCodeFor(error) !== 2) {
@@ -3766,7 +5066,7 @@ async function signIn(ctx, serverUrl) {
3766
5066
  async function stepClaudeCode(ctx, run, scope, ask, fancy) {
3767
5067
  const status = await claudeCodeStatus(run);
3768
5068
  if (status.claude === "missing") {
3769
- ctx.log.info(`Claude Code not found ${chalk9.dim("(no `claude` on your PATH) \u2014 skipped")}`);
5069
+ ctx.log.info(`Claude Code not found ${chalk11.dim("(no `claude` on your PATH) \u2014 skipped")}`);
3770
5070
  ctx.log.hint(
3771
5071
  `Once it is installed, run \`${ctx.binName} setup\` again, or in Claude Code: /plugin marketplace add ${MARKETPLACE_SOURCE} then /plugin install ${PLUGIN_ID}`
3772
5072
  );
@@ -3775,7 +5075,7 @@ async function stepClaudeCode(ctx, run, scope, ask, fancy) {
3775
5075
  const installed = "installed" in status && status.installed;
3776
5076
  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?";
3777
5077
  if (ask && !await ctx.confirm(question)) {
3778
- ctx.log.info(chalk9.dim("Claude Code plugin: skipped"));
5078
+ ctx.log.info(chalk11.dim("Claude Code plugin: skipped"));
3779
5079
  return installed ? "already" : "skipped";
3780
5080
  }
3781
5081
  const s = fancy ? spinner({ output: process.stderr }) : null;
@@ -3789,7 +5089,7 @@ async function stepClaudeCode(ctx, run, scope, ask, fancy) {
3789
5089
  else ctx.log.error(message);
3790
5090
  return "failed";
3791
5091
  }
3792
- const done = installed ? `Claude Code plugin ${PLUGIN_NAME} is up to date` : `Claude Code plugin ${PLUGIN_NAME} installed ${chalk9.dim(`(${scope} scope)`)}`;
5092
+ const done = installed ? `Claude Code plugin ${PLUGIN_NAME} is up to date` : `Claude Code plugin ${PLUGIN_NAME} installed ${chalk11.dim(`(${scope} scope)`)}`;
3793
5093
  if (s) s.stop(done);
3794
5094
  else ctx.log.success(done);
3795
5095
  ctx.log.hint("Restart Claude Code (or run /reload-plugins) to pick it up.");
@@ -3799,7 +5099,7 @@ async function stepClaudeDesktop(ctx, deps, ask, fancy) {
3799
5099
  const app = await (deps.findClaudeDesktop ?? findClaudeDesktop)();
3800
5100
  if (!app) return "missing";
3801
5101
  if (ask && !await ctx.confirm("Add the Fling extension to Claude Desktop too?")) {
3802
- ctx.log.info(chalk9.dim("Claude Desktop extension: skipped"));
5102
+ ctx.log.info(chalk11.dim("Claude Desktop extension: skipped"));
3803
5103
  return "skipped";
3804
5104
  }
3805
5105
  const s = fancy ? spinner({ output: process.stderr }) : null;
@@ -3820,7 +5120,7 @@ async function stepClaudeDesktop(ctx, deps, ask, fancy) {
3820
5120
  }
3821
5121
 
3822
5122
  // src/commands/sites.ts
3823
- import chalk10 from "chalk";
5123
+ import chalk12 from "chalk";
3824
5124
  async function runList(ctx, opts) {
3825
5125
  const sites = await listSites(requireClient(ctx), opts);
3826
5126
  ctx.out.data(
@@ -3829,9 +5129,10 @@ async function runList(ctx, opts) {
3829
5129
  { header: "NAME", value: (s) => s.name },
3830
5130
  { header: "URL", value: (s) => s.url },
3831
5131
  {
3832
- header: "VISIBILITY",
3833
- value: (s) => s.archived ? `${s.visibility} (archived)` : s.visibility
5132
+ header: "ACCESS",
5133
+ value: (s) => accessLabel(s) + (s.archived ? " (archived)" : "")
3834
5134
  },
5135
+ ...sites.some((s) => s.role) ? [{ header: "ROLE", value: (s) => s.role ?? "-" }] : [],
3835
5136
  { header: "SHARE", value: (s) => s.shareUrl },
3836
5137
  { header: "UPDATED", value: (s) => formatDate(s.updatedAt) }
3837
5138
  ])
@@ -3854,37 +5155,50 @@ async function runListShared(ctx, opts) {
3854
5155
  ])
3855
5156
  );
3856
5157
  }
5158
+ var accessLabel = (site) => ACCESS_LABELS[siteAccessLevel(site)];
5159
+ var row = (label, value) => `${chalk12.bold(`${label}:`.padEnd(14))}${value}`;
3857
5160
  function describeSite(site) {
3858
- const row = (label, value) => `${chalk10.bold(`${label}:`.padEnd(12))}${value}`;
3859
5161
  return [
3860
5162
  row("Name", site.name),
3861
5163
  row("URL", site.url),
3862
- row("Visibility", site.visibility + (site.archived ? chalk10.yellow(" (archived)") : "")),
5164
+ row("Access", accessLabel(site) + (site.archived ? chalk12.yellow(" (archived)") : "")),
3863
5165
  row("SPA", site.spa ? "yes" : "no"),
3864
5166
  row("Share link", site.shareUrl ?? "-"),
3865
5167
  row("Version", site.currentVersionId ?? "-"),
3866
- ...filesRow(site, row),
5168
+ ...filesRow(site),
3867
5169
  row("Created", formatDate(site.createdAt)),
3868
5170
  row("Updated", formatDate(site.updatedAt))
3869
5171
  ];
3870
5172
  }
3871
- function filesRow(site, row) {
5173
+ function rolesRows(site) {
5174
+ return [
5175
+ ...site.owners ? [row("Owners", whoHolds(site.owners))] : [],
5176
+ ...site.owners ? [row("Publishers", whoHolds(site.publishers))] : [],
5177
+ ...site.role ? [row("You", site.role)] : []
5178
+ ];
5179
+ }
5180
+ function filesRow(site) {
3872
5181
  const current = site.versions?.find((v) => v.id === site.currentVersionId);
3873
5182
  if (!current) return [];
3874
5183
  const n = current.fileCount;
3875
- return [row("Files", `${n} file${n === 1 ? "" : "s"}, ${formatBytes(current.totalBytes)}`)];
5184
+ return [
5185
+ row("Files", `${n} file${n === 1 ? "" : "s"}, ${formatBytes(current.totalBytes)}`),
5186
+ ...current.publishedBy ? [row("Published by", current.publishedBy)] : [],
5187
+ ...current.path ? [row("Published to", `/${current.path}/`)] : []
5188
+ ];
3876
5189
  }
3877
5190
  var membersOf = (ctx) => ctx.config.instance ? privateAudience(ctx.config.instance) : "anyone signed in to this Fling";
3878
5191
  async function runInfo(ctx, name) {
3879
5192
  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))}`] : [];
5193
+ const access2 = site.audience && audiencesEnabled(ctx.config.instance) ? [`${chalk12.bold("Who:".padEnd(14))}${describeAudience(site, membersOf(ctx))}`] : [];
3881
5194
  ctx.out.data(
3882
5195
  site,
3883
5196
  () => [
3884
5197
  ...describeSite(site),
3885
5198
  ...access2,
5199
+ ...rolesRows(site),
3886
5200
  "",
3887
- chalk10.dim(
5201
+ chalk12.dim(
3888
5202
  `${site.versions.length} version(s) \u2014 \`fling versions ${name}\`; files \u2014 \`fling files ${name}\``
3889
5203
  )
3890
5204
  ].join("\n")
@@ -3899,6 +5213,8 @@ async function runVersions(ctx, name) {
3899
5213
  { header: "VERSION", value: (v) => v.id },
3900
5214
  { header: "FILES", value: (v) => v.fileCount },
3901
5215
  { header: "SIZE", value: (v) => formatBytes(v.totalBytes) },
5216
+ { header: "BY", value: (v) => v.publishedBy ?? "-" },
5217
+ { header: "PATH", value: (v) => v.path ? `/${v.path}/` : "-" },
3902
5218
  { header: "CREATED", value: (v) => formatDate(v.createdAt) }
3903
5219
  ])
3904
5220
  );
@@ -3910,7 +5226,7 @@ async function runFiles(ctx, name, versionId) {
3910
5226
  }
3911
5227
  ctx.out.data(result, () => {
3912
5228
  const n = result.files.length;
3913
- const header = chalk10.dim(
5229
+ const header = chalk12.dim(
3914
5230
  `${result.versionId}${result.current ? " (current)" : " (not served)"} \u2014 ${n} file${n === 1 ? "" : "s"}, ${formatBytes(result.totalBytes)}${result.generatedIndex ? ", generated index.html" : ""}`
3915
5231
  );
3916
5232
  const table = renderTable(result.files, [
@@ -3946,7 +5262,7 @@ async function runShare(ctx, name, opts) {
3946
5262
  ctx.log.success("Share link created \u2014 anyone with it can view the site");
3947
5263
  ctx.out.data(
3948
5264
  ready === void 0 ? result : { ...result, ready },
3949
- () => result.shareUrl ? result.shareUrl : chalk10.dim("(no share link)")
5265
+ () => result.shareUrl ? result.shareUrl : chalk12.dim("(no share link)")
3950
5266
  );
3951
5267
  }
3952
5268
  async function runVisibility(ctx, name, visibility) {
@@ -3959,7 +5275,18 @@ async function runVisibility(ctx, name, visibility) {
3959
5275
  );
3960
5276
  ctx.out.data(site, () => site.url);
3961
5277
  }
5278
+ function levelOf(opts) {
5279
+ const levels = [
5280
+ ...opts.onlyMe ? ["only_me"] : [],
5281
+ ...opts.members ? ["members"] : [],
5282
+ ...opts.public ? ["public"] : []
5283
+ ];
5284
+ if (levels.length > 1) throw new CliError("Use one of --only-me, --members and --public");
5285
+ return levels[0];
5286
+ }
3962
5287
  async function runAccess(ctx, name, opts) {
5288
+ const level = levelOf(opts);
5289
+ if (level) return runSetLevel(ctx, name, level, opts);
3963
5290
  const instance = ctx.config.instance;
3964
5291
  if (instance && !audiencesEnabled(instance)) {
3965
5292
  throw new CliError(`${instance.name} can't limit a site to listed people`);
@@ -3980,6 +5307,49 @@ async function runAccess(ctx, name, opts) {
3980
5307
  for (const w of warnings) ctx.log.warn(w);
3981
5308
  ctx.out.data({ ...site, warnings }, () => describeAudience(site, membersOf(ctx)));
3982
5309
  }
5310
+ async function runSetLevel(ctx, name, level, opts) {
5311
+ if (opts.add?.length || opts.remove?.length || opts.group?.length || opts.removeGroup?.length || opts.clear) {
5312
+ throw new CliError(`--${level === "only_me" ? "only-me" : level} lets no one else in`, {
5313
+ hint: "Leave out --add, --remove, --group, --remove-group and --clear."
5314
+ });
5315
+ }
5316
+ const instance = await loadInstanceWith(ctx.store, ctx.config, ctx.fetch, "accessLevels");
5317
+ if (instance && !accessLevelsEnabled(instance)) {
5318
+ throw new CliError(`${instance.name} has no access levels yet`, {
5319
+ hint: `Use \`${ctx.binName} visibility ${name} public|private\` and \`${ctx.binName} access ${name} --clear\`.`
5320
+ });
5321
+ }
5322
+ const site = await setAccess(requireClient(ctx), name, level);
5323
+ const warnings = audienceWarnings(site);
5324
+ for (const w of warnings) ctx.log.warn(w);
5325
+ ctx.out.data({ ...site, warnings }, () => describeAudience(site, membersOf(ctx)));
5326
+ }
5327
+ async function runRoles(ctx, which, name, opts) {
5328
+ const instance = await loadInstanceWith(ctx.store, ctx.config, ctx.fetch, "siteRoles");
5329
+ if (instance && !siteRolesEnabled(instance)) {
5330
+ throw new CliError(`${instance.name} has no site owners and publishers yet`, {
5331
+ hint: "Ask its operator to update it."
5332
+ });
5333
+ }
5334
+ const change = {
5335
+ ...opts.set ? { set: splitEmails(opts.set) } : {},
5336
+ add: splitEmails(opts.add ?? []),
5337
+ remove: splitEmails(opts.remove ?? []),
5338
+ addGroups: splitGroups(opts.group ?? []),
5339
+ removeGroups: splitGroups(opts.removeGroup ?? [])
5340
+ };
5341
+ if (instance && (change.addGroups.length || change.removeGroups.length)) {
5342
+ if (!groupsEnabled(instance)) throw new CliError(`${instance.name} has no groups`);
5343
+ }
5344
+ const client = requireClient(ctx);
5345
+ const changed = changesAudience(change);
5346
+ const site = changed ? await setRoles(client, name, which, change) : await getRoles(client, name);
5347
+ if (changed) ctx.log.success(`${name}'s ${which} changed`);
5348
+ ctx.out.data(
5349
+ { name: site.name, role: site.role, owners: site.owners, publishers: site.publishers },
5350
+ () => describeRoles(site)
5351
+ );
5352
+ }
3983
5353
  async function runArchive(ctx, name, unarchive = false) {
3984
5354
  const site = await archiveSite(requireClient(ctx), name, { unarchive });
3985
5355
  ctx.log.success(
@@ -4004,15 +5374,15 @@ async function runRemove(ctx, name, opts) {
4004
5374
  }
4005
5375
 
4006
5376
  // src/mcp.ts
4007
- import { mkdir as mkdir3, mkdtemp as mkdtemp3, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
5377
+ import { mkdir as mkdir4, mkdtemp as mkdtemp3, rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
4008
5378
  import { tmpdir as tmpdir3 } from "node:os";
4009
- import { basename as basename3, dirname as dirname2, join as join6 } from "node:path";
5379
+ import { basename as basename3, dirname as dirname3, join as join7, resolve as resolvePath } from "node:path";
4010
5380
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4011
5381
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4012
- import { z as z13 } from "zod";
5382
+ import { z as z19 } from "zod";
4013
5383
 
4014
5384
  // src/locate.ts
4015
- import { lstat, readdir as readdir2, readFile as readFile6, realpath as realpath3, stat as stat5 } from "node:fs/promises";
5385
+ import { lstat, readdir as readdir3, readFile as readFile7, realpath as realpath3, stat as stat6 } from "node:fs/promises";
4016
5386
  import { posix, win32 } from "node:path";
4017
5387
  import { fileURLToPath } from "node:url";
4018
5388
  var DOWNLOADS_DIR_ENV = "FLING_DOWNLOADS_DIR";
@@ -4025,7 +5395,7 @@ var SANDBOX_PATH_RE = /^\/(mnt\/user-data|mnt\/data|sessions|home\/claude|tmp\/o
4025
5395
  var nodeFs = {
4026
5396
  async stat(path3) {
4027
5397
  try {
4028
- const s = await stat5(path3);
5398
+ const s = await stat6(path3);
4029
5399
  return { isFile: s.isFile(), isDirectory: s.isDirectory(), mtimeMs: s.mtimeMs, size: s.size };
4030
5400
  } catch {
4031
5401
  return null;
@@ -4033,11 +5403,11 @@ var nodeFs = {
4033
5403
  },
4034
5404
  async readdir(path3) {
4035
5405
  try {
4036
- const entries = await readdir2(path3, { withFileTypes: true });
5406
+ const entries = await readdir3(path3, { withFileTypes: true });
4037
5407
  const out = [];
4038
5408
  for (const e of entries) {
4039
5409
  if (e.isSymbolicLink()) {
4040
- const s = await stat5(`${path3}/${e.name}`).catch(() => null);
5410
+ const s = await stat6(`${path3}/${e.name}`).catch(() => null);
4041
5411
  out.push({ name: e.name, isFile: Boolean(s?.isFile()), isDirectory: false });
4042
5412
  } else out.push({ name: e.name, isFile: e.isFile(), isDirectory: e.isDirectory() });
4043
5413
  }
@@ -4052,7 +5422,7 @@ var nodeFs = {
4052
5422
  async readText(path3) {
4053
5423
  try {
4054
5424
  if (!(await lstat(path3)).isFile()) return null;
4055
- return await readFile6(path3, "utf8");
5425
+ return await readFile7(path3, "utf8");
4056
5426
  } catch {
4057
5427
  return null;
4058
5428
  }
@@ -4427,12 +5797,14 @@ var mcpLocalInstructions = (w, opts = {}) => [
4427
5797
  ...[serversInstructions(opts.servers ?? [])].filter((p) => p !== void 0),
4428
5798
  "Content you created in this conversation (an HTML page, a report, a small app): call fling_publish with every file inline (text as utf8, binary as base64). Never pass file paths from your sandbox (e.g. /mnt/user-data/outputs/\u2026, /sessions/\u2026) \u2014 this computer cannot see them \u2014 and never curl or upload from the sandbox.",
4429
5799
  'A file or folder the user has on this computer (a downloaded .html, a .zip, a build folder): call fling_deploy with its path, or just its file name (e.g. "Pitch Deck.html") \u2014 it is looked up in Downloads, Desktop, Documents/Claude and Claude Desktop output folders. Use fling_find_files to see recent candidates when unsure which file the user means.',
5800
+ "To change a site that is already published, start from what is live: fling_pull downloads its files into a folder on this computer; edit them there, then fling_deploy that folder publishes on the version pulled, sending only what changed. If it says base_changed, someone published in between: pull again into another folder and redo the changes, rather than force over their work. (fling_read_files and fling_publish with `base` do the same inline, for content you edit in this conversation.)",
4430
5801
  "Confirm the site name with the user before publishing (fling_check_name shows whether it is free and, for a path, the generated name). Names are global, first come first served; when one is taken, offer the suggestions.",
4431
5802
  "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.",
4432
5803
  NEVER_PUBLISHED,
4433
5804
  "Not logged in? Call fling_login, let the user finish signing in in the browser, then retry.",
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."}`,
4435
- ...opts.comments ? [COMMENTS_NOTE(w)] : []
5805
+ `${ACCESS_NOTE(w)} 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."}`,
5806
+ ...opts.comments ? [COMMENTS_NOTE(w)] : [],
5807
+ ...opts.notifications ? [NOTIFICATIONS_NOTE(w)] : []
4436
5808
  ].join("\n\n");
4437
5809
  var onFling = (url, instance) => `
4438
5810
  Fling: ${serverLabel(url)}${instance ? ` (${instance.name})` : ""}`;
@@ -4446,7 +5818,7 @@ function createMcpServer(deps = {}) {
4446
5818
  const several = servers.length > 1;
4447
5819
  const wording = mcpWording(several ? null : servers[0]?.instance);
4448
5820
  const mcpTools2 = mcpToolsFor(wording);
4449
- const serverArg = z13.string().min(1).max(500).optional().describe(serverArgDescription(servers));
5821
+ const serverArg = z19.string().min(1).max(500).optional().describe(serverArgDescription(servers));
4450
5822
  const note = serverToolNote(servers);
4451
5823
  const serverTool = (def, description = def.description) => ({
4452
5824
  title: def.title,
@@ -4489,6 +5861,58 @@ Not reachable yet at ${url}: ${NOT_READY_NOTE}.`
4489
5861
  }
4490
5862
  };
4491
5863
  }
5864
+ async function membersOn(target2) {
5865
+ const instance = await target2.instance();
5866
+ return instance ? privateAudience(instance) : "anyone signed in to this Fling";
5867
+ }
5868
+ async function requireAccessLevels(target2) {
5869
+ const instance = await loadInstanceWith(store, target2.config, fetchImpl, "accessLevels");
5870
+ if (instance && !accessLevelsEnabled(instance)) {
5871
+ throw new CliError(
5872
+ `${instance.name} (${serverLabel(target2.url)}) has no access levels yet: leave out \`access\`, then use fling_set_visibility or fling_set_audience`
5873
+ );
5874
+ }
5875
+ }
5876
+ async function requireSiteRoles(target2) {
5877
+ const instance = await loadInstanceWith(store, target2.config, fetchImpl, "siteRoles");
5878
+ if (instance && !siteRolesEnabled(instance)) {
5879
+ throw new CliError(
5880
+ `${instance.name} (${serverLabel(target2.url)}) has no site owners and publishers yet`
5881
+ );
5882
+ }
5883
+ return instance;
5884
+ }
5885
+ async function publishFolder2(target2, raw) {
5886
+ const folder = normalizePublishPath(raw);
5887
+ if (folder === null) {
5888
+ throw new CliError(
5889
+ `Invalid path "${raw}": use a folder of the site, like "30-10-26" or "reports/2026" (no dotfiles)`
5890
+ );
5891
+ }
5892
+ const instance = await loadInstanceWith(store, target2.config, fetchImpl, "pathPublish");
5893
+ if (!pathPublishEnabled(instance)) {
5894
+ throw new CliError(
5895
+ `${instance?.name ?? "This Fling"} (${serverLabel(target2.url)}) can't publish to a path yet: publish the whole site instead`
5896
+ );
5897
+ }
5898
+ return folder;
5899
+ }
5900
+ async function requirePull(target2) {
5901
+ const instance = await loadInstanceWith(store, target2.config, fetchImpl, "pull");
5902
+ if (!pullEnabled(instance)) {
5903
+ throw new CliError(
5904
+ `${instance?.name ?? "This Fling"} (${serverLabel(target2.url)}) can't pull sites or publish on a pulled version yet`
5905
+ );
5906
+ }
5907
+ }
5908
+ async function linkedServer(path3) {
5909
+ try {
5910
+ const located = await resolveInput(path3, locate);
5911
+ return located.isDirectory ? (await readSiteLink(located.path))?.server : void 0;
5912
+ } catch {
5913
+ return void 0;
5914
+ }
5915
+ }
4492
5916
  async function run(tool2, server2, op) {
4493
5917
  try {
4494
5918
  const { summary, data } = await op(await target(server2, tool2));
@@ -4500,18 +5924,29 @@ ${JSON.stringify(data, null, 2)}`);
4500
5924
  }
4501
5925
  }
4502
5926
  async function deployFolder(t2, dir, opts) {
5927
+ if (opts.access) await requireAccessLevels(t2);
5928
+ const link = opts.link ?? null;
5929
+ if (link && !opts.force || opts.base !== void 0) await requirePull(t2);
5930
+ if (opts.base !== void 0 && opts.folder !== void 0) {
5931
+ throw new CliError("Pass `base` or `path`, not both");
5932
+ }
5933
+ const folder = opts.folder === void 0 ? void 0 : await publishFolder2(t2, opts.folder);
5934
+ const shownFolder = folder ?? link?.path;
5935
+ const inFolder = (file) => shownFolder === void 0 ? file : `${shownFolder}/${file}`;
5936
+ const top = folder === void 0 ? "at the root" : `in /${folder}/`;
4503
5937
  let local = await buildManifest(dir);
4504
5938
  local.skipped.dotfiles.unshift(...opts.skippedDotfiles ?? []);
4505
5939
  const skippedNote = describeSkipped(local.skipped).map((l) => `
4506
5940
  ${l}.`).join("");
4507
5941
  let generatedIndex = false;
4508
- if (!hasRootIndex(local)) {
5942
+ const asItIs = link !== null || opts.base !== void 0;
5943
+ if (!asItIs && !hasRootIndex(local)) {
4509
5944
  const pages = htmlPages(local).map((f) => f.path);
4510
5945
  if (pages.length > 0 && opts.listHtml === void 0) {
4511
5946
  const shown = pages.slice(0, 20);
4512
5947
  return {
4513
5948
  kind: "needs_decision",
4514
- summary: `Not published yet: there is no index.html at the root, but there ${pages.length === 1 ? "is 1 HTML page" : `are ${pages.length} HTML pages`}. Ask the user whether to generate an index.html listing them (call again with \`listHtml: true\`) or to publish without one (\`listHtml: false\` \u2014 the root URL will 404, the pages still work at their own paths). Then call ${opts.retryWith} again with the same arguments plus \`listHtml\`.`,
5949
+ summary: `Not published yet: there is no index.html ${top}, but there ${pages.length === 1 ? "is 1 HTML page" : `are ${pages.length} HTML pages`}. Ask the user whether to generate an index.html listing them (call again with \`listHtml: true\`) or to publish without one (\`listHtml: false\` \u2014 the root URL will 404, the pages still work at their own paths). Then call ${opts.retryWith} again with the same arguments plus \`listHtml\`.`,
4515
5950
  data: {
4516
5951
  status: "needs_decision",
4517
5952
  htmlPages: shown,
@@ -4525,10 +5960,31 @@ ${l}.`).join("");
4525
5960
  generatedIndex = true;
4526
5961
  }
4527
5962
  }
5963
+ const onBase = link && !opts.force ? onLink(link, local) : null;
5964
+ const changes = link ? onLink(link, local).changes : null;
4528
5965
  const result = await deploy(
4529
5966
  { client: t2.client, fetch: fetchImpl },
4530
- { dir, name: opts.name, manifest: local, spa: opts.spa, public: opts.public }
5967
+ {
5968
+ dir,
5969
+ name: opts.name,
5970
+ manifest: local,
5971
+ path: folder,
5972
+ ...onBase ? {
5973
+ base: onBase.base,
5974
+ ...onBase.partial ? { partial: true, remove: onBase.remove, within: onBase.within } : {}
5975
+ } : opts.base !== void 0 ? { base: opts.base, partial: true, remove: opts.remove ?? [] } : {},
5976
+ spa: opts.spa,
5977
+ ...opts.access ? {
5978
+ access: opts.access,
5979
+ ...needsAudience(opts.access) ? { audience: { emails: opts.emails ?? [], groups: opts.groups ?? [] } } : {}
5980
+ } : { public: opts.public }
5981
+ }
4531
5982
  ).catch((error) => {
5983
+ if (error instanceof BaseChangedError) {
5984
+ throw new CliError(
5985
+ `Not published: ${error.message}. ` + (link ? `Pull it again with fling_pull into another folder, redo the changes there and deploy that; or call ${opts.retryWith} again with \`force: true\` to replace what they published, only if the user says so.` : "Read the files again with fling_read_files and redo the changes on the new version.")
5986
+ );
5987
+ }
4532
5988
  if (!(error instanceof NameTakenError)) throw error;
4533
5989
  const how = opts.nameGiven ? "" : ` (generated from ${opts.nameFrom ?? "the folder"})`;
4534
5990
  const pick = error.suggestions.length ? ` Free alternatives: ${error.suggestions.join(", ")}.` : "";
@@ -4536,23 +5992,30 @@ ${l}.`).join("");
4536
5992
  `Not deployed: the site name "${error.siteName}"${how} is taken by someone else on ${serverLabel(t2.url)}.${pick} Ask the user which name to use (one of these or their own), then call ${opts.retryWith} again with \`name\`.`
4537
5993
  );
4538
5994
  });
4539
- const what = result.status === "unchanged" ? "Unchanged \u2014 already live" : result.status === "repointed" ? `Restored version ${result.versionId}` : `Deployed ${result.fileCount} files (uploaded ${result.uploaded})`;
5995
+ if (link) await writeSiteLink(dir, linkAfter(link, result.versionId, local));
5996
+ const what = result.status === "unchanged" ? "Unchanged \u2014 already live" : result.status === "repointed" ? `Restored version ${result.versionId}` : changes && link ? `Deployed ${describeChanges(changes)} since version ${link.versionId}${link.path === void 0 ? "" : ` in /${link.path}/`} (uploaded ${result.uploaded})` : opts.base !== void 0 ? `Published ${result.fileCount} changed or added file${result.fileCount === 1 ? "" : "s"} on version ${opts.base} (the rest of the site kept as it was)` : `Deployed ${result.fileCount} files${folder === void 0 ? "" : ` to /${folder}/ (the rest of the site kept as it was)`} (uploaded ${result.uploaded})`;
4540
5997
  const warnings = result.warnings.length ? `
4541
5998
  Warnings:
4542
5999
  - ${result.warnings.join("\n- ")}` : "";
4543
6000
  const edge = result.status === "unchanged" ? { ready: true, note: "" } : await edgeReady(
4544
6001
  result.url,
4545
- local.files.map((f) => f.path)
6002
+ local.files.map((f) => inFolder(f.path))
4546
6003
  );
6004
+ const url = shownFolder === void 0 ? result.url : new URL(`${shownFolder}/`, result.url).href;
4547
6005
  const instance = await t2.instance();
4548
6006
  const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
4549
6007
  const listing2 = generatedIndex ? " \u2014 with a generated index.html listing the HTML pages" : "";
6008
+ const level = result.site.access ?? result.site.visibility;
4550
6009
  const head = review ? `${what}${listing2}.
4551
6010
  ${shareForFeedback(review, instance?.name ?? "Fling")}
4552
- Site: ${result.url} (${result.site.visibility})` : `${what}: ${result.url} (${result.site.visibility})${listing2}`;
6011
+ Site: ${url} (${level})` : `${what}: ${url} (${level})${listing2}`;
6012
+ const who = describeSiteAccess(result.site, await membersOn(t2));
6013
+ const access3 = result.first ? `
6014
+ Who can open it: ${who} ${accessChangeHint("mcp", result.name, { invites: instance?.capabilities.invites === true })}` : opts.access ? `
6015
+ Its access is unchanged (deploying never changes it for an existing site): ${who} ${accessChangeHint("mcp", result.name, { invites: instance?.capabilities.invites === true })}` : "";
4553
6016
  return {
4554
6017
  kind: "deployed",
4555
- summary: `${head}${warnings}${skippedNote}${edge.note}${onFling(t2.url, instance)}
6018
+ summary: `${head}${access3}${warnings}${skippedNote}${edge.note}${onFling(t2.url, instance)}
4556
6019
  To check exactly which files the site has, call fling_files with name "${result.name}".`,
4557
6020
  data: {
4558
6021
  status: result.status,
@@ -4562,6 +6025,9 @@ To check exactly which files the site has, call fling_files with name "${result.
4562
6025
  nameGenerated: !opts.nameGiven,
4563
6026
  versionId: result.versionId,
4564
6027
  site: result.site,
6028
+ ...shownFolder === void 0 ? {} : { path: shownFolder, url },
6029
+ ...changes && link ? { base: opts.force ? null : link.versionId, changes } : {},
6030
+ ...result.first ? { created: true } : {},
4565
6031
  ...review ? { reviewUrl: review } : {},
4566
6032
  generatedIndex,
4567
6033
  warnings: result.warnings,
@@ -4574,42 +6040,65 @@ To check exactly which files the site has, call fling_files with name "${result.
4574
6040
  {
4575
6041
  instructions: mcpLocalInstructions(wording, {
4576
6042
  comments: servers.some((s) => commentsEnabled(s.instance)),
6043
+ notifications: servers.some((s) => notificationsEnabled(s.instance)),
4577
6044
  servers
4578
6045
  })
4579
6046
  }
4580
6047
  );
4581
- const { listHtml: listHtml2, spa: spa2, public: isPublic } = mcpTools2.publish.inputSchema;
6048
+ const { listHtml: listHtml2, spa: spa2, public: isPublic, access: access2, emails: emails2, groups: groups2 } = mcpTools2.publish.inputSchema;
4582
6049
  server.registerTool(
4583
6050
  "fling_deploy",
4584
6051
  {
4585
6052
  title: "Deploy a file or folder from this computer",
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}`,
6053
+ 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. ${ACCESS_NOTE(wording)} ${PATH_PUBLISH_NOTE.replace("With `path`", "With `sitePath`")} A folder made by fling_pull publishes to the site and Fling it was pulled from, on the version pulled: only what changed is sent, and if someone published the site since, it stops (base_changed) rather than replace their work; \`force: true\` replaces it anyway (only when the user says so). ${NEVER_PUBLISHED}${note}`,
4587
6054
  inputSchema: {
4588
- path: z13.string().min(1).describe(
6055
+ path: z19.string().min(1).describe(
4589
6056
  "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)"
4590
6057
  ),
4591
- name: z13.string().optional().describe(
6058
+ name: z19.string().optional().describe(
4592
6059
  "Site name (lowercase letters, digits, single hyphens; max 50). Defaults to a name generated from the file or folder"
4593
6060
  ),
6061
+ sitePath: z19.string().min(1).max(1024).optional().describe(
6062
+ 'Publish into this folder of the site only, e.g. "30-10-26" (served at <site>/30-10-26/), keeping every other file of the site. Needs `name`. Omit to replace the whole site'
6063
+ ),
4594
6064
  spa: spa2,
6065
+ access: access2,
6066
+ emails: emails2,
6067
+ groups: groups2,
4595
6068
  public: isPublic,
4596
6069
  listHtml: listHtml2,
6070
+ force: z19.boolean().optional().describe(
6071
+ "A folder made by fling_pull: publish it without a base, replacing whatever is live even if someone published since. Only when the user says so"
6072
+ ),
4597
6073
  server: serverArg
4598
6074
  },
4599
6075
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true }
4600
6076
  },
4601
- (args) => run("fling_deploy", args.server, async (t2) => {
6077
+ async (args) => run("fling_deploy", args.server ?? await linkedServer(args.path), async (t2) => {
4602
6078
  const located = await resolveInput(args.path, locate);
6079
+ if (args.sitePath !== void 0 && args.name === void 0) {
6080
+ throw new CliError(
6081
+ "`sitePath` needs `name`: the site to publish into. Ask the user which site, then call fling_deploy again"
6082
+ );
6083
+ }
4603
6084
  const prepared = await prepareInput(located.path);
4604
6085
  try {
4605
- const name = args.name !== void 0 ? resolveSiteName(prepared.dir, args.name) : prepared.defaultName;
6086
+ const found = prepared.kind === "folder" ? await readSiteLink(prepared.dir) : null;
6087
+ const link = found && (args.name === void 0 || args.name === found.name) && args.sitePath === void 0 && found.server === t2.url ? found : null;
6088
+ const name = link ? link.name : args.name !== void 0 ? resolveSiteName(prepared.dir, args.name) : prepared.defaultName;
4606
6089
  const outcome = await deployFolder(t2, prepared.dir, {
6090
+ link,
6091
+ force: args.force,
4607
6092
  name,
4608
- nameGiven: args.name !== void 0,
6093
+ nameGiven: args.name !== void 0 || link !== null,
4609
6094
  nameFrom: prepared.kind === "folder" ? "the folder" : "the file name",
4610
6095
  spa: args.spa,
4611
6096
  public: args.public,
6097
+ access: args.access,
6098
+ emails: args.emails,
6099
+ groups: args.groups,
4612
6100
  listHtml: args.listHtml,
6101
+ folder: args.sitePath,
4613
6102
  retryWith: "fling_deploy",
4614
6103
  skippedDotfiles: prepared.skippedDotfiles
4615
6104
  });
@@ -4632,15 +6121,97 @@ To check exactly which files the site has, call fling_files with name "${result.
4632
6121
  }
4633
6122
  })
4634
6123
  );
6124
+ server.registerTool(
6125
+ "fling_pull",
6126
+ {
6127
+ title: "Download a site's files to change them",
6128
+ description: `Download a site's files into a folder on this computer, to change some or all of them and publish again: by default ~/Documents/Claude/fling/<name>. Then edit the files there and call fling_deploy with the folder: it publishes on the version pulled, sending only what changed. Anyone who can open the site may pull it; publishing needs an owner or publisher. \`path\` pulls one folder of the site (its files at the top of the folder); deploying it keeps the rest of the site. If the folder is not empty it stops, unless \`force\` (it then writes over what is there). When fling_deploy says base_changed, someone published in between: pull again into another folder and redo the changes there.` + note,
6129
+ inputSchema: {
6130
+ name: z19.string().min(1).max(200).describe("Site name"),
6131
+ dir: z19.string().min(1).optional().describe(
6132
+ "Folder to write the files to (absolute, or relative to the working directory). Default ~/Documents/Claude/fling/<name>"
6133
+ ),
6134
+ versionId: z19.string().min(1).max(100).optional().describe(
6135
+ "Another version than the live one (owners and publishers; ids from fling_info)"
6136
+ ),
6137
+ path: z19.string().min(1).max(1024).optional().describe('Only this folder of the site, e.g. "30-10-26"'),
6138
+ force: z19.boolean().optional().describe("Write into the folder even if it is not empty"),
6139
+ server: serverArg
6140
+ },
6141
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
6142
+ },
6143
+ (args) => run("fling_pull", args.server, async (t2) => {
6144
+ await requirePull(t2);
6145
+ const path3 = args.path === void 0 ? void 0 : normalizePublishPath(args.path);
6146
+ if (path3 === null) {
6147
+ throw new CliError(
6148
+ `Invalid path "${args.path}": use a folder of the site, like "30-10-26" (no dotfiles)`
6149
+ );
6150
+ }
6151
+ const dir = args.dir === void 0 ? join7(locate.home, "Documents", "Claude", "fling", args.name) : resolvePath(cwd, args.dir);
6152
+ const pulled = await pullSite(
6153
+ { client: t2.client, fetch: fetchImpl },
6154
+ {
6155
+ name: args.name,
6156
+ dir,
6157
+ ...args.versionId ? { versionId: args.versionId } : {},
6158
+ ...path3 === void 0 ? {} : { path: path3 },
6159
+ force: args.force
6160
+ }
6161
+ );
6162
+ return {
6163
+ summary: `Pulled ${pulled.fileCount} file(s) of ${pulled.name}${path3 ? ` /${path3}/` : ""} (version ${pulled.versionId}, ${formatBytes(pulled.totalBytes)}) into ${pulled.dir}. Edit the files there, then call fling_deploy with path "${pulled.dir}": it publishes on version ${pulled.versionId}.`,
6164
+ data: pulled
6165
+ };
6166
+ })
6167
+ );
6168
+ server.registerTool(
6169
+ mcpTools2.readFiles.name,
6170
+ serverTool(mcpTools2.readFiles),
6171
+ (args) => run(mcpTools2.readFiles.name, args.server, async (t2) => {
6172
+ await requirePull(t2);
6173
+ const path3 = args.path === void 0 ? void 0 : normalizePublishPath(args.path);
6174
+ if (path3 === null) throw new CliError(`Invalid path "${args.path}"`);
6175
+ const pulled = await t2.client.request(
6176
+ "GET",
6177
+ `/api/sites/${encodeURIComponent(args.name)}/pull`,
6178
+ { schema: PullResponseSchema, query: { version: args.versionId, path: path3 } }
6179
+ );
6180
+ const { page, tooLarge, missing, nextCursor } = pageOfFiles(pulled.files, {
6181
+ paths: args.paths,
6182
+ cursor: args.cursor,
6183
+ budget: MCP_READ_MAX_BYTES
6184
+ });
6185
+ const read = await Promise.all(
6186
+ page.map(async ({ url, ...f }) => {
6187
+ const res = await fetchImpl(url);
6188
+ if (!res.ok) throw new CliError(`Could not read ${f.path}: HTTP ${res.status}`);
6189
+ return { ...f, ...encodeForAgent(f.path, new Uint8Array(await res.arrayBuffer())) };
6190
+ })
6191
+ );
6192
+ const files = [
6193
+ ...read,
6194
+ ...tooLarge.map(({ url: _url, ...f }) => ({ ...f, tooLarge: true }))
6195
+ ].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
6196
+ const result = {
6197
+ name: args.name,
6198
+ versionId: pulled.versionId,
6199
+ files,
6200
+ missing,
6201
+ ...nextCursor ? { nextCursor } : {}
6202
+ };
6203
+ return { summary: describeReadFiles(args.name, result), data: result };
6204
+ })
6205
+ );
4635
6206
  server.registerTool(
4636
6207
  "fling_find_files",
4637
6208
  {
4638
6209
  title: "Find files to fling",
4639
6210
  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.",
4640
6211
  inputSchema: {
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)")
6212
+ query: z19.string().optional().describe('Only names containing this text (case-insensitive), e.g. "deck"'),
6213
+ limit: z19.number().int().min(1).max(100).optional().describe("Max results (default 20)"),
6214
+ days: z19.number().int().min(1).max(3650).optional().describe("Only things modified in the last N days (default 14)")
4644
6215
  },
4645
6216
  annotations: { readOnlyHint: true, openWorldHint: false }
4646
6217
  },
@@ -4678,7 +6249,12 @@ ${JSON.stringify(data, null, 2)}`);
4678
6249
  ),
4679
6250
  (args) => run(publishDef.name, args.server, async (t2) => {
4680
6251
  const name = resolveSiteName(".", args.name);
4681
- const tmp = await mkdtemp3(join6(tmpdir3(), "fling-publish-"));
6252
+ if (args.remove?.length && args.base === void 0) {
6253
+ throw new CliError("`remove` needs `base`: the versionId fling_read_files returned");
6254
+ }
6255
+ if (args.files.length === 0 && !args.remove?.length)
6256
+ throw new CliError("No files to publish");
6257
+ const tmp = await mkdtemp3(join7(tmpdir3(), "fling-publish-"));
4682
6258
  try {
4683
6259
  const { skippedDotfiles } = await writeInlineFiles(tmp, args.files);
4684
6260
  return await deployFolder(t2, tmp, {
@@ -4687,11 +6263,16 @@ ${JSON.stringify(data, null, 2)}`);
4687
6263
  nameGiven: true,
4688
6264
  spa: args.spa,
4689
6265
  public: args.public,
6266
+ access: args.access,
6267
+ emails: args.emails,
6268
+ groups: args.groups,
4690
6269
  listHtml: args.listHtml,
6270
+ folder: args.path,
6271
+ ...args.base === void 0 ? {} : { base: args.base, remove: args.remove },
4691
6272
  retryWith: "fling_publish"
4692
6273
  });
4693
6274
  } finally {
4694
- await rm2(tmp, { recursive: true, force: true });
6275
+ await rm3(tmp, { recursive: true, force: true });
4695
6276
  }
4696
6277
  })
4697
6278
  );
@@ -4701,8 +6282,8 @@ ${JSON.stringify(data, null, 2)}`);
4701
6282
  ...serverTool(mcpTools2.checkName),
4702
6283
  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}`,
4703
6284
  inputSchema: {
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)"),
6285
+ name: z19.string().min(1).max(200).optional().describe("Site name to check"),
6286
+ path: z19.string().optional().describe("File or folder whose generated default name to check (path or file name)"),
4706
6287
  server: serverArg
4707
6288
  }
4708
6289
  },
@@ -4716,8 +6297,8 @@ ${JSON.stringify(data, null, 2)}`);
4716
6297
  name = defaultNameFor(source.path, source.isDirectory);
4717
6298
  }
4718
6299
  const check2 = await checkName(t2.client, name);
4719
- const base = check2.status === "invalid" ? null : sitesBaseOf(await t2.instance());
4720
- const url = base ? siteUrlFor(name, base) : null;
6300
+ const base2 = check2.status === "invalid" ? null : sitesBaseOf(await t2.instance());
6301
+ const url = base2 ? siteUrlFor(name, base2) : null;
4721
6302
  const suggestions = check2.suggestions.length ? ` Suggestions: ${check2.suggestions.join(", ")}.` : "";
4722
6303
  const summary = {
4723
6304
  available: `"${name}" is available${url ? ` \u2192 ${url}` : ""}.`,
@@ -4743,7 +6324,7 @@ ${JSON.stringify(data, null, 2)}`);
4743
6324
  title: "Sign in to Fling",
4744
6325
  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,
4745
6326
  inputSchema: {
4746
- server: z13.string().min(1).max(500).optional().describe(
6327
+ server: z19.string().min(1).max(500).optional().describe(
4747
6328
  `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"}`}`
4748
6329
  )
4749
6330
  },
@@ -4827,7 +6408,7 @@ ${known.join("\n")}` : ""
4827
6408
  title: "List the Flings on this computer",
4828
6409
  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.`,
4829
6410
  inputSchema: {
4830
- use: z13.string().min(1).max(500).optional().describe(
6411
+ use: z19.string().min(1).max(500).optional().describe(
4831
6412
  "Make this Fling current (address, host or unique part of it), like `fling use`"
4832
6413
  )
4833
6414
  },
@@ -4892,18 +6473,21 @@ ${lines}` : ""}`,
4892
6473
  return { summary: `${sites.length} site(s) on ${serverLabel(url)}`, data: { sites } };
4893
6474
  })
4894
6475
  );
4895
- const membersOn = async (target2) => {
4896
- const instance = await target2.instance();
4897
- return instance ? privateAudience(instance) : "anyone signed in to this Fling";
4898
- };
4899
6476
  server.registerTool(
4900
6477
  t.info.name,
4901
6478
  serverTool(t.info),
4902
6479
  (args) => run(t.info.name, args.server, async (target2) => {
4903
6480
  const site = await getSite(target2.client, args.name);
4904
- const access2 = site.audience ? `
6481
+ const access3 = site.audience ? `
4905
6482
  ${describeAudience(site, await membersOn(target2))}` : "";
4906
- return { summary: `${site.name}: ${site.url}${access2}`, data: site };
6483
+ const current = site.versions.find((v) => v.id === site.currentVersionId);
6484
+ const by = current?.publishedBy ? ` by ${current.publishedBy}` : "";
6485
+ const to = current?.path ? ` to /${current.path}/` : "";
6486
+ const published = by || to ? `
6487
+ Current version published${by}${to}` : "";
6488
+ const roles = site.owners ? `
6489
+ ${describeRoles(site)}` : "";
6490
+ return { summary: `${site.name}: ${site.url}${access3}${roles}${published}`, data: site };
4907
6491
  })
4908
6492
  );
4909
6493
  server.registerTool(
@@ -4939,6 +6523,24 @@ ${describeAudience(site, await membersOn(target2))}` : "";
4939
6523
  };
4940
6524
  })
4941
6525
  );
6526
+ server.registerTool(
6527
+ t.setAccess.name,
6528
+ serverTool(t.setAccess),
6529
+ (args) => run(t.setAccess.name, args.server, async (target2) => {
6530
+ await requireAccessLevels(target2);
6531
+ const site = await setAccess(
6532
+ target2.client,
6533
+ args.name,
6534
+ args.access,
6535
+ needsAudience(args.access) ? { emails: args.emails ?? [], groups: args.groups ?? [] } : void 0
6536
+ );
6537
+ const instance = await target2.instance();
6538
+ return {
6539
+ summary: `${site.name}: ${describeAudience(site, await membersOn(target2))}${onFling(target2.url, instance)}`,
6540
+ data: site
6541
+ };
6542
+ })
6543
+ );
4942
6544
  server.registerTool(
4943
6545
  t.setVisibility.name,
4944
6546
  serverTool(t.setVisibility),
@@ -4980,6 +6582,36 @@ ${describeAudience(site, await membersOn(target2))}` : "";
4980
6582
  })
4981
6583
  );
4982
6584
  }
6585
+ for (const [def, which] of [
6586
+ [t.setOwners, "owners"],
6587
+ [t.setPublishers, "publishers"]
6588
+ ]) {
6589
+ server.registerTool(
6590
+ def.name,
6591
+ serverTool(def),
6592
+ (args) => run(def.name, args.server, async (target2) => {
6593
+ const instance = await requireSiteRoles(target2);
6594
+ const change = {
6595
+ add: args.add,
6596
+ remove: args.remove,
6597
+ set: args.set,
6598
+ addGroups: args.addGroups,
6599
+ removeGroups: args.removeGroups,
6600
+ setGroups: args.setGroups
6601
+ };
6602
+ if (!changesAudience(change)) {
6603
+ throw new CliError(
6604
+ "Pass `add`, `remove`, `set`, `addGroups`, `removeGroups` or `setGroups`"
6605
+ );
6606
+ }
6607
+ const site = await setRoles(target2.client, args.name, which, change);
6608
+ return {
6609
+ summary: `${site.name}: ${describeRoles(site)}${onFling(target2.url, instance)}`,
6610
+ data: site
6611
+ };
6612
+ })
6613
+ );
6614
+ }
4983
6615
  if (servers.some((s) => groupsEnabled(s.instance))) {
4984
6616
  const withGroups = async (target2) => {
4985
6617
  const instance = await target2.instance();
@@ -4993,15 +6625,15 @@ ${describeAudience(site, await membersOn(target2))}` : "";
4993
6625
  serverTool(t.groups),
4994
6626
  (args) => run(t.groups.name, args.server, async (target2) => {
4995
6627
  const instance = await withGroups(target2);
4996
- const [groups2, me] = await Promise.all([
6628
+ const [groups3, me] = await Promise.all([
4997
6629
  listGroups(target2.client, { mine: args.mine, members: args.members }),
4998
6630
  whoami(target2.client)
4999
6631
  ]);
5000
6632
  const which = args.members ? " of exactly those people" : "";
5001
6633
  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 }
6634
+ summary: args.members && groups3.length === 0 ? `No group has exactly those people${onFling(target2.url, instance)}` : `${groups3.length} ${args.mine ? "of your " : ""}group(s)${which}${onFling(target2.url, instance)}
6635
+ ${describeGroups(groups3, me.email)}`,
6636
+ data: { groups: groups3 }
5005
6637
  };
5006
6638
  })
5007
6639
  );
@@ -5157,15 +6789,54 @@ ${describeGroups(groups2, me.email)}`,
5157
6789
  })
5158
6790
  );
5159
6791
  }
6792
+ if (servers.some((s) => notificationsEnabled(s.instance))) {
6793
+ const withNotifications = async (target2) => {
6794
+ const instance = await target2.instance();
6795
+ const name = `${instance?.name ?? "This Fling"} (${serverLabel(target2.url)})`;
6796
+ if (instance && !notificationsEnabled(instance)) {
6797
+ throw new CliError(`${name} has no notifications`);
6798
+ }
6799
+ return { name, fling: onFling(target2.url, instance) };
6800
+ };
6801
+ server.registerTool(
6802
+ t.notifications.name,
6803
+ serverTool(t.notifications),
6804
+ (args) => run(t.notifications.name, args.server, async (target2) => {
6805
+ const { name, fling } = await withNotifications(target2);
6806
+ const settings = await getNotifications(target2.client, name);
6807
+ return { summary: `${describeNotificationSettings(settings)}${fling}`, data: settings };
6808
+ })
6809
+ );
6810
+ server.registerTool(
6811
+ t.notificationsSet.name,
6812
+ serverTool(t.notificationsSet),
6813
+ (args) => run(t.notificationsSet.name, args.server, async (target2) => {
6814
+ const { name, fling } = await withNotifications(target2);
6815
+ if (args.muted === void 0 && !args.cells?.length) {
6816
+ throw new CliError("Pass `muted` or `cells`");
6817
+ }
6818
+ const settings = await setNotifications(
6819
+ target2.client,
6820
+ { muted: args.muted, cells: args.cells },
6821
+ name
6822
+ );
6823
+ return {
6824
+ summary: `Saved.
6825
+ ${describeNotificationSettings(settings)}${fling}`,
6826
+ data: settings
6827
+ };
6828
+ })
6829
+ );
6830
+ }
5160
6831
  server.registerTool(
5161
6832
  t.whoami.name,
5162
6833
  serverTool(t.whoami),
5163
6834
  (args) => run(t.whoami.name, args.server, async (target2) => {
5164
6835
  const me = await whoami(target2.client);
5165
6836
  const instance = await target2.instance();
5166
- const base = sitesBaseOf(instance);
6837
+ const base2 = sitesBaseOf(instance);
5167
6838
  return {
5168
- summary: `Signed in to ${serverLabel(target2.url)}${instance ? ` (${instance.name})` : ""} as ${me.email} (sites are served at ${sitesPattern(base)})`,
6839
+ summary: `Signed in to ${serverLabel(target2.url)}${instance ? ` (${instance.name})` : ""} as ${me.email} (sites are served at ${sitesPattern(base2)})`,
5169
6840
  data: { ...me, server: target2.url }
5170
6841
  };
5171
6842
  })
@@ -5196,9 +6867,9 @@ async function writeInlineFiles(dir, files) {
5196
6867
  total += bytes.length;
5197
6868
  if (total > MAX_DEPLOY_BYTES)
5198
6869
  throw new CliError(`The site is larger than ${formatBytes(MAX_DEPLOY_BYTES)}`);
5199
- const target = join6(dir, ...path3.split("/"));
5200
- await mkdir3(dirname2(target), { recursive: true });
5201
- await writeFile4(target, bytes);
6870
+ const target = join7(dir, ...path3.split("/"));
6871
+ await mkdir4(dirname3(target), { recursive: true });
6872
+ await writeFile5(target, bytes);
5202
6873
  }
5203
6874
  if (secretFiles.length > 0) throw new SecretFilesError(secretFiles);
5204
6875
  return { skippedDotfiles };
@@ -5242,10 +6913,10 @@ async function runMcpServer(deps = {}) {
5242
6913
  }
5243
6914
 
5244
6915
  // src/route.ts
5245
- import { stat as stat6 } from "node:fs/promises";
5246
- import { resolve as resolve4 } from "node:path";
6916
+ import { stat as stat7 } from "node:fs/promises";
6917
+ import { resolve as resolve5 } from "node:path";
5247
6918
  var PASS_THROUGH = /* @__PURE__ */ new Set(["-h", "--help", "-V", "--version"]);
5248
- var pathExists = (path3) => stat6(resolve4(path3)).then(
6919
+ var pathExists = (path3) => stat7(resolve5(path3)).then(
5249
6920
  () => true,
5250
6921
  () => false
5251
6922
  );
@@ -5296,18 +6967,18 @@ program.name(BIN_NAME).description("Fling a folder of static files at Fling and
5296
6967
  `server for this command: a URL, or the host of one you signed in to (default: $${ENV.url}, else the current one)`
5297
6968
  ).option("--json", "print JSON instead of human output", false).showHelpAfterError().configureHelp({
5298
6969
  sortSubcommands: false,
5299
- styleTitle: (title) => chalk11.bold(brand.primary(title)),
5300
- styleCommandText: (text2) => chalk11.bold(text2),
6970
+ styleTitle: (title) => chalk13.bold(brand.primary(title)),
6971
+ styleCommandText: (text2) => chalk13.bold(text2),
5301
6972
  styleSubcommandText: (text2) => brand.accent(text2),
5302
6973
  styleOptionText: (text2) => brand.accent(text2),
5303
- styleArgumentText: (text2) => chalk11.dim(text2)
6974
+ styleArgumentText: (text2) => chalk13.dim(text2)
5304
6975
  }).addHelpText(
5305
6976
  "before",
5306
6977
  () => process.stdout.isTTY ? logo({ columns: process.stdout.columns }) : ""
5307
6978
  ).addHelpText(
5308
6979
  "after",
5309
6980
  () => `
5310
- ${chalk11.bold(brand.primary("Examples:"))}
6981
+ ${chalk13.bold(brand.primary("Examples:"))}
5311
6982
  $ ${BIN_NAME} login https://fling.example.com sign in to your Fling (once; there is no default)
5312
6983
  $ ${BIN_NAME} setup sign in and add Fling to Claude Code / Claude Desktop
5313
6984
  $ ${BIN_NAME} use list the Flings you signed in to; \`use <url>\` switches
@@ -5318,7 +6989,7 @@ ${chalk11.bold(brand.primary("Examples:"))}
5318
6989
  $ ${BIN_NAME} site.zip a zip of a site
5319
6990
  $ ${BIN_NAME} share my-app public link for people who can't sign in
5320
6991
 
5321
- ${chalk11.dim(`Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
6992
+ ${chalk13.dim(`Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
5322
6993
  Env: ${ENV.apiKey} \xB7 ${ENV.url} \xB7 ${ENV.configDir} \xB7 ${ENV.debug}`)}`
5323
6994
  );
5324
6995
  function action(handler) {
@@ -5362,17 +7033,36 @@ program.command("deploy [path]").helpGroup("Deploy:").description(
5362
7033
  ).option(
5363
7034
  "-n, --name <name>",
5364
7035
  "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)"
7036
+ ).option(
7037
+ "--path <folder>",
7038
+ "publish into this folder of the site (e.g. 30-10-26, served at <site>/30-10-26/) and keep the rest of the site; needs --name"
5365
7039
  ).option("--spa", "single-page app: serve index.html for unknown paths").option(
7040
+ "--access <level>",
7041
+ "who can open a new site: only_me, members (everyone signed in), groups, people or public (default: the Fling's, usually only_me). A deployed site keeps its access: see `access`"
7042
+ ).option("--add <emails...>", "with --access people: the addresses that may open it").option("--group <slugs...>", "with --access groups: the groups whose members may open it").option(
5366
7043
  "--public",
5367
- "make the site public: anyone can open it, reviewing still needs sign-in (default: sign-in required)"
7044
+ "make the site public: anyone can open it, reviewing still needs sign-in (on every deploy; for a new site prefer --access public)"
5368
7045
  ).option(
5369
7046
  "-y, --yes",
5370
7047
  "never prompt: accept the generated name; without index.html, list the HTML pages (or fling anyway if there are none)"
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(
7048
+ ).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)").option(
7049
+ "--force",
7050
+ "a pulled folder: publish it without a base, replacing whatever is live (even if someone published since you pulled)"
7051
+ ).action(
5372
7052
  action(
5373
7053
  (ctx, path3, opts) => runDeploy(ctx, path3, opts)
5374
7054
  )
5375
7055
  );
7056
+ program.command("pull <name> [dir]").helpGroup("Deploy:").description(
7057
+ "download a site's files into dir (default: ./<name>) to change them; `deploy` in that folder publishes on the version pulled"
7058
+ ).option(
7059
+ "--version-id <id>",
7060
+ "another version than the live one (owners and publishers; ids from `versions`)"
7061
+ ).option("--path <folder>", "only this folder of the site (e.g. 30-10-26), at the top of dir").option("--force", "write into dir even if it is not empty").action(
7062
+ action(
7063
+ (ctx, name, dir, opts) => runPull(ctx, name, dir, opts)
7064
+ )
7065
+ );
5376
7066
  program.command("setup [url]").helpGroup("Setup:").description(
5377
7067
  "sign in (to the Fling at url, which becomes current), then add the Fling plugin to Claude Code and the extension to Claude Desktop"
5378
7068
  ).option("-y, --yes", "do every step without asking").option("-s, --scope <scope>", "Claude Code plugin scope: user, project or local", "user").action(
@@ -5422,11 +7112,43 @@ program.command("share <name>").helpGroup("Sites:").description("print (or creat
5422
7112
  program.command("visibility <name> <visibility>").helpGroup("Sites:").description(
5423
7113
  "private (sign-in required) or public (anyone can open the site; reviewing it still needs sign-in)"
5424
7114
  ).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(
7115
+ program.command("access <name>").helpGroup("Sites:").description(
7116
+ "show who can open a site, or change it: only you, everyone signed in, listed people and groups, or anyone"
7117
+ ).addOption(new Option("--only-me", "only you can open it").conflicts(["members", "public"])).addOption(new Option("--members", "everyone signed in can open it").conflicts("public")).addOption(
7118
+ new Option("--public", "anyone can open it; reviewing still needs sign-in").conflicts([
7119
+ "add",
7120
+ "group",
7121
+ "clear"
7122
+ ])
7123
+ ).option(
7124
+ "--add <emails...>",
7125
+ "let these addresses in (comma-separated or repeated); where the Fling invites by sharing, an address from outside is invited as a reviewer"
7126
+ ).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
7127
  action(
5427
7128
  (ctx, name, opts) => runAccess(ctx, name, opts)
5428
7129
  )
5429
7130
  );
7131
+ for (const [which, does] of [
7132
+ [
7133
+ "owners",
7134
+ "own it: publish, change its access and settings, its owners and publishers, delete it"
7135
+ ],
7136
+ [
7137
+ "publishers",
7138
+ "publish it and roll it back, but can't change its access or settings or delete it"
7139
+ ]
7140
+ ]) {
7141
+ program.command(`${which} <name>`).helpGroup("Sites:").description(
7142
+ `show who ${which === "owners" ? "owns" : "publishes"} a site, or change it (${which} ${does})`
7143
+ ).option("--add <emails...>", `add these addresses (members of this Fling only)`).option("--remove <emails...>", "take these addresses out").option("--set <emails...>", `make these addresses the whole list (groups stay)`).option(
7144
+ "--group <slugs...>",
7145
+ "add these groups (see `fling groups`); whoever manages a group decides who is in it"
7146
+ ).option("--remove-group <slugs...>", "take these groups out").action(
7147
+ action(
7148
+ (ctx, name, opts) => runRoles(ctx, which, name, opts)
7149
+ )
7150
+ );
7151
+ }
5430
7152
  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)));
5431
7153
  program.command("unarchive <name>").helpGroup("Sites:").description("bring an archived site back").action(action((ctx, name) => runArchive(ctx, name, true)));
5432
7154
  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)));
@@ -5470,6 +7192,16 @@ program.command("uncomment <name> <id>").helpGroup("Comments:").alias("delete-co
5470
7192
  (ctx, name, id, opts) => runUncomment(ctx, name, id, opts)
5471
7193
  )
5472
7194
  );
7195
+ var notifications = program.command("notifications").helpGroup("Notifications:").description("what this Fling tells you about, and how (default: show the table)");
7196
+ notifications.command("show", { isDefault: true }).description("show each event and channel: on or off, * where you chose it").action(action((ctx) => runNotifications(ctx)));
7197
+ notifications.command("set <event> <channel> <value>").description("turn one event on or off on a channel, or back to its default (on|off|default)").action(
7198
+ action(
7199
+ (ctx, event, channel, value) => runNotificationsSet(ctx, event, channel, value)
7200
+ )
7201
+ );
7202
+ notifications.command("mute").description("stop all notifications (your choices are kept)").action(action((ctx) => runNotificationsMute(ctx, true)));
7203
+ notifications.command("unmute").description("turn notifications back on").action(action((ctx) => runNotificationsMute(ctx, false)));
7204
+ notifications.command("sent").description("list what was sent to you in the last 30 days").action(action((ctx) => runNotificationsSent(ctx)));
5473
7205
  program.command("mcp").helpGroup("Agents:").description("run the MCP server on stdio (used by the Claude Code plugin)").action(async (_opts, command) => {
5474
7206
  const globals = command.optsWithGlobals();
5475
7207
  await runMcpServer({ server: globals.server });
@@ -5492,4 +7224,3 @@ async function main() {
5492
7224
  await program.parseAsync(route.args, { from: "user" });
5493
7225
  }
5494
7226
  await main();
5495
- //# sourceMappingURL=cli.js.map