@nearform/fling 0.5.1 → 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,22 +1,184 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/cli.ts
4
- import chalk10 from "chalk";
5
- import { Command } from "commander";
6
-
7
- // src/commands/auth.ts
8
- import chalk5 from "chalk";
9
-
10
- // src/auth.ts
11
- import { randomBytes } from "node:crypto";
12
- import { createServer } from "node:http";
13
- import { hostname as osHostname } from "node:os";
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
+ }
14
80
 
15
81
  // ../../packages/shared/src/api.ts
16
- import { z as z2 } from "zod";
82
+ import { z as z7 } from "zod";
17
83
 
18
- // ../../packages/shared/src/manifest.ts
19
- import { z } from "zod";
84
+ // ../../packages/shared/src/audience.ts
85
+ import { z as z2 } from "zod";
86
+ var AUDIENCE_EMAILS_MAX = 100;
87
+ var AUDIENCE_GROUPS_MAX = 10;
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()
98
+ });
99
+ var AudienceSchema = z2.object({
100
+ emails: z2.array(AudienceEmailSchema),
101
+ groups: z2.array(AudienceGroupSchema).default([])
102
+ });
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([])
106
+ });
107
+ var normaliseEmail = (email) => email.trim().toLowerCase();
108
+ function splitEmails(values) {
109
+ const all = typeof values === "string" ? [values] : values;
110
+ return all.flatMap((v) => v.split(/[\s,;]+/)).map(normaliseEmail).filter(Boolean);
111
+ }
112
+ function editAudience(current, change) {
113
+ let emails2 = change.clear ? [] : change.set ? [...change.set] : [...current];
114
+ emails2 = [...emails2, ...change.add ?? []].map(normaliseEmail);
115
+ const removed = new Set((change.remove ?? []).map(normaliseEmail));
116
+ return [...new Set(emails2)].filter((e) => e && !removed.has(e));
117
+ }
118
+ function editAudienceGroups(current, change) {
119
+ const norm = (s) => s.trim().toLowerCase();
120
+ let groups2 = change.clear ? [] : change.setGroups ? [...change.setGroups] : [...current];
121
+ groups2 = [...groups2, ...change.addGroups ?? []].map(norm);
122
+ const removed = new Set((change.removeGroups ?? []).map(norm));
123
+ return [...new Set(groups2)].filter((g) => g && !removed.has(g));
124
+ }
125
+ function editAudienceInput(current, change) {
126
+ return {
127
+ emails: editAudience(
128
+ current.emails.map((e) => e.email),
129
+ change
130
+ ),
131
+ groups: editAudienceGroups(
132
+ current.groups.map((g) => g.slug),
133
+ change
134
+ )
135
+ };
136
+ }
137
+ var changesAudience = (change) => Boolean(
138
+ change.clear || change.set || change.add?.length || change.remove?.length || change.setGroups || change.addGroups?.length || change.removeGroups?.length
139
+ );
140
+ var people = (n) => `${n} ${n === 1 ? "person" : "people"}`;
141
+ var groupLabel = (g) => g.deleted ? `a deleted group (${g.slug})` : `${g.slug} (${g.name !== g.slug ? `${g.name}, ` : ""}${people(g.size)})`;
142
+ function whoIn(emails2, groups2) {
143
+ const live = groups2.filter((g) => !g.deleted);
144
+ return [
145
+ emails2.length ? people(emails2.length) : "",
146
+ live.length ? `${live.length === 1 ? "the group" : "the groups"} ${live.map(groupLabel).join(", ")}` : "",
147
+ ...groups2.filter((g) => g.deleted).map(groupLabel)
148
+ ].filter(Boolean);
149
+ }
150
+ var and = (parts) => parts.length > 1 ? `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}` : parts[0] ?? "";
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(", ");
155
+ function describeAudience(site, privateAudience2) {
156
+ const emails2 = site.audience?.emails ?? [];
157
+ const groups2 = site.audience?.groups ?? [];
158
+ const empty = emails2.length + groups2.length === 0;
159
+ const list = emails2.length ? `: ${audienceList(emails2)}` : "";
160
+ if (site.visibility === "public") {
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}`;
163
+ }
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
+ }
168
+ if (emails2.length === 0 && groups2.every((g) => g.deleted)) {
169
+ const refs = groups2.map((g) => g.slug).join(", ");
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}`;
171
+ }
172
+ return `Restricted: only ${and(["you", ...whoIn(emails2, groups2)])} can open it${list}.${share2}`;
173
+ }
174
+ function sharedVia(site) {
175
+ const { direct, groups: groups2 } = site.via;
176
+ return and([
177
+ ...direct ? ["your address"] : [],
178
+ ...groups2.length ? [`${groups2.length === 1 ? "group" : "groups"} ${groups2.join(", ")}`] : []
179
+ ]);
180
+ }
181
+ var describeSharedSite = (site) => `- ${site.name}: ${site.url} (shared by ${site.owner}, through ${sharedVia(site)}${site.reviewUrl ? `; review: ${site.reviewUrl}` : ""})`;
20
182
 
21
183
  // ../../packages/shared/src/limits.ts
22
184
  var MAX_DEPLOY_BYTES = 100 * 1024 * 1024;
@@ -24,6 +186,9 @@ var MAX_FILE_BYTES = 25 * 1024 * 1024;
24
186
  var MAX_FILES = 5e3;
25
187
  var MAX_PATH_LENGTH = 1024;
26
188
 
189
+ // ../../packages/shared/src/manifest.ts
190
+ import { z as z3 } from "zod";
191
+
27
192
  // ../../packages/shared/src/secrets.ts
28
193
  var SECRET_SCAN_MAX_FILE_BYTES = MAX_FILE_BYTES;
29
194
  var BINARY_SNIFF_BYTES = 8192;
@@ -214,226 +379,464 @@ function isValidSitePath(path3) {
214
379
  if (path3.startsWith("/") || path3.includes("\\") || path3.includes("\0")) return false;
215
380
  return path3.split("/").every((seg) => seg !== "" && !seg.startsWith("."));
216
381
  }
217
- var ManifestFileSchema = z.object({
218
- path: z.string().refine(isValidSitePath, "invalid path (dotfiles are never published)").refine((p) => !isSecretPath(p), "secret file (keys, certificates, credentials) never published"),
219
- sha256: z.string().regex(/^[a-f0-9]{64}$/),
220
- size: z.number().int().min(0).max(MAX_FILE_BYTES),
221
- contentType: z.string().min(1).max(255)
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)
222
387
  });
223
- var ManifestSchema = z.object({ files: z.array(ManifestFileSchema).min(1).max(MAX_FILES) }).refine((m) => new Set(m.files.map((f) => f.path)).size === m.files.length, "duplicate paths").refine((m) => m.files.reduce((n, f) => n + f.size, 0) <= MAX_DEPLOY_BYTES, "deploy too large");
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([])
424
+ });
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() });
224
451
 
225
452
  // ../../packages/shared/src/api.ts
226
- var VisibilitySchema = z2.enum(["private", "public"]);
227
- var MeSchema = z2.object({
228
- email: z2.string(),
229
- name: z2.string(),
230
- picture: z2.string().nullable(),
231
- userSlug: z2.string(),
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(),
232
459
  /** one of the instance's admins (servers from before admins don't say: false) */
233
- admin: z2.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),
234
463
  /** while an admin impersonates this user: the admin (browser sessions only) */
235
- impersonator: z2.string().optional(),
464
+ impersonator: z7.string().optional(),
236
465
  /** …and when the impersonation lapses */
237
- impersonationEndsAt: z2.string().optional()
466
+ impersonationEndsAt: z7.string().optional()
238
467
  });
239
- var SiteSchema = z2.object({
240
- name: z2.string(),
241
- url: z2.string(),
468
+ var SiteSchema = z7.object({
469
+ name: z7.string(),
470
+ url: z7.string(),
242
471
  visibility: VisibilitySchema,
243
- spa: z2.boolean(),
244
- archived: z2.boolean(),
245
- currentVersionId: z2.string().nullable(),
246
- shareUrl: z2.string().nullable(),
247
- createdAt: z2.string(),
248
- updatedAt: z2.string()
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),
480
+ /** how many addresses and groups the audience names (kept, and ignored, while public) */
481
+ audienceSize: z7.number().default(0),
482
+ /** how many of those are groups */
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()
249
492
  });
250
- var VersionSchema = z2.object({
251
- id: z2.string(),
252
- fileCount: z2.number(),
253
- totalBytes: z2.number(),
254
- createdAt: z2.string()
493
+ var SiteWithAudienceSchema = SiteSchema.extend({
494
+ audience: AudienceSchema.optional(),
495
+ owners: SiteRoleMembersSchema.optional(),
496
+ publishers: SiteRoleMembersSchema.optional()
255
497
  });
256
- var SiteDetailSchema = SiteSchema.extend({ versions: z2.array(VersionSchema) });
257
- var SiteListSchema = z2.object({ sites: z2.array(SiteSchema) });
258
- var SiteFileSchema = z2.object({
259
- path: z2.string(),
260
- size: z2.number(),
261
- contentType: z2.string()
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()
262
512
  });
263
- var VersionFilesSchema = z2.object({
264
- versionId: z2.string(),
265
- files: z2.array(SiteFileSchema),
266
- totalBytes: z2.number(),
267
- generatedIndex: z2.boolean()
513
+ var SiteDetailSchema = SiteWithAudienceSchema.extend({
514
+ owner: z7.string().optional(),
515
+ versions: z7.array(VersionSchema)
268
516
  });
269
- var NameCheckSchema = z2.object({
270
- name: z2.string(),
271
- status: z2.enum(["available", "yours", "taken", "invalid"]),
272
- suggestions: z2.array(z2.string())
517
+ var SiteListSchema = z7.object({ sites: z7.array(SiteSchema) });
518
+ var SharedSiteSchema = z7.object({
519
+ name: z7.string(),
520
+ url: z7.string(),
521
+ /** the site's review page (`comments` capability) */
522
+ reviewUrl: z7.string().optional(),
523
+ owner: z7.string(),
524
+ /** how the caller is in the audience: by address, and through which of their groups (slugs) */
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()
530
+ });
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()
536
+ });
537
+ var VersionFilesSchema = z7.object({
538
+ versionId: z7.string(),
539
+ files: z7.array(SiteFileSchema),
540
+ totalBytes: z7.number(),
541
+ generatedIndex: z7.boolean()
273
542
  });
274
- var DeployRequestSchema = z2.object({
275
- manifest: ManifestSchema,
276
- spa: z2.boolean().optional(),
277
- visibility: VisibilitySchema.optional()
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()
278
549
  });
279
- var UploadSchema = z2.object({
280
- path: z2.string(),
281
- url: z2.string(),
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()
556
+ });
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(),
282
585
  /** headers the client must send with the PUT (they are part of the signature) */
283
- headers: z2.record(z2.string(), z2.string())
586
+ headers: z7.record(z7.string(), z7.string())
284
587
  });
285
- var DeployResponseSchema = z2.discriminatedUnion("status", [
588
+ var DeployResponseSchema = z7.discriminatedUnion("status", [
286
589
  /** content identical to the current version — nothing happened */
287
- z2.object({ status: z2.literal("unchanged"), versionId: z2.string(), site: SiteSchema }),
590
+ z7.object({ status: z7.literal("unchanged"), versionId: z7.string(), site: SiteSchema }),
288
591
  /** this content was deployed before — site now points at that version again */
289
- z2.object({ status: z2.literal("repointed"), versionId: z2.string(), site: SiteSchema }),
592
+ z7.object({ status: z7.literal("repointed"), versionId: z7.string(), site: SiteSchema }),
290
593
  /** new content — PUT every upload, then POST /api/sites/:name/deploys/:versionId/finalize */
291
- z2.object({
292
- status: z2.literal("upload"),
293
- versionId: z2.string(),
294
- uploads: z2.array(UploadSchema)
594
+ z7.object({
595
+ status: z7.literal("upload"),
596
+ versionId: z7.string(),
597
+ uploads: z7.array(UploadSchema)
295
598
  })
296
599
  ]);
297
- var FinalizeResponseSchema = z2.object({
298
- status: z2.literal("live"),
299
- versionId: z2.string(),
300
- 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()
301
606
  });
302
- var RollbackRequestSchema = z2.object({ versionId: z2.string() });
303
- var UpdateSiteRequestSchema = z2.object({
607
+ var RollbackRequestSchema = z7.object({ versionId: z7.string() });
608
+ var UpdateSiteRequestSchema = z7.object({
609
+ access: AccessLevelSchema.optional(),
304
610
  visibility: VisibilitySchema.optional(),
305
- spa: z2.boolean().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()
306
617
  });
307
- var ApiKeySchema = z2.object({
308
- id: z2.string(),
309
- name: z2.string(),
310
- createdAt: z2.string(),
311
- lastUsedAt: z2.string().nullable()
618
+ var ApiKeySchema = z7.object({
619
+ id: z7.string(),
620
+ name: z7.string(),
621
+ createdAt: z7.string(),
622
+ lastUsedAt: z7.string().nullable()
312
623
  });
313
- var ApiKeyListSchema = z2.object({ keys: z2.array(ApiKeySchema) });
314
- var ErrorEnvelopeSchema = z2.object({
315
- error: z2.object({ code: z2.string(), message: z2.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() })
316
627
  });
317
- var ShareResponseSchema = z2.object({ shareUrl: z2.string() });
318
- var ConnectionSchema = z2.object({
319
- id: z2.string(),
320
- clientName: z2.string(),
628
+ var ShareResponseSchema = z7.object({ shareUrl: z7.string() });
629
+ var ConnectionSchema = z7.object({
630
+ id: z7.string(),
631
+ clientName: z7.string(),
321
632
  /** host the client identifies as (CIMD client_id host or redirect URI host) */
322
- clientHost: z2.string().nullable(),
323
- createdAt: z2.string(),
324
- lastUsedAt: z2.string().nullable()
633
+ clientHost: z7.string().nullable(),
634
+ createdAt: z7.string(),
635
+ lastUsedAt: z7.string().nullable()
325
636
  });
326
- var ConnectionListSchema = z2.object({ connections: z2.array(ConnectionSchema) });
327
- var OAuthRequestInfoSchema = z2.object({
328
- clientName: z2.string(),
329
- clientUri: z2.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(),
330
641
  /** host of the CIMD client_id URL, when the client identified itself that way */
331
- clientIdHost: z2.string().nullable(),
332
- redirectHost: z2.string(),
642
+ clientIdHost: z7.string().nullable(),
643
+ redirectHost: z7.string(),
333
644
  /** true when the code goes to a loopback address (any local program could be asking) */
334
- loopback: z2.boolean(),
335
- scopes: z2.array(z2.string()),
336
- email: z2.string()
645
+ loopback: z7.boolean(),
646
+ scopes: z7.array(z7.string()),
647
+ email: z7.string()
337
648
  });
338
- var OAuthDecisionSchema = z2.object({ redirectUrl: z2.string() });
339
- var AdminUserSchema = z2.object({
340
- email: z2.string(),
341
- name: z2.string(),
342
- picture: z2.string().nullable(),
343
- userSlug: z2.string(),
344
- createdAt: z2.string().nullable(),
345
- lastSignInAt: z2.string().nullable(),
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(),
346
657
  /** sites the user holds, archived ones included */
347
- siteCount: z2.number(),
348
- admin: z2.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()
349
662
  });
350
- var AdminUserListSchema = z2.object({
351
- users: z2.array(AdminUserSchema),
663
+ var AdminUserListSchema = z7.object({
664
+ users: z7.array(AdminUserSchema),
352
665
  /** pass back as `cursor` for the next page; null: no more */
353
- cursor: z2.string().nullable()
666
+ cursor: z7.string().nullable()
354
667
  });
355
- var AuditEntrySchema = z2.object({
356
- id: z2.string(),
357
- at: z2.string(),
358
- action: z2.enum(["impersonation.start", "impersonation.stop"]),
359
- actor: z2.string(),
360
- target: z2.string(),
361
- until: z2.string().optional(),
362
- lapsed: z2.boolean().optional()
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()
363
768
  });
364
- var AuditListSchema = z2.object({ entries: z2.array(AuditEntrySchema) });
365
- var ImpersonateRequestSchema = z2.object({ email: z2.string().trim().toLowerCase().min(3) });
366
769
 
367
770
  // ../../packages/shared/src/comments.ts
368
- import { z as z3 } from "zod";
771
+ import { z as z8 } from "zod";
369
772
  var COMMENT_MAX_CHARS = 4e3;
370
773
  var COMMENT_PATH_MAX = 1024;
371
- var CommentAuthorSchema = z3.discriminatedUnion("kind", [
372
- z3.object({ kind: z3.literal("user"), email: z3.string() }),
373
- z3.object({ kind: z3.literal("guest"), name: z3.string() })
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() })
374
777
  ]);
375
778
  var ANCHOR_SELECTOR_MAX = 512;
376
779
  var ANCHOR_QUOTE_MAX = 500;
377
- var px = z3.number().finite().min(-1e7).max(1e7);
378
- var percent = z3.number().min(0).max(100);
379
- var CommentAnchorSchema = z3.object({
780
+ var px = z8.number().finite().min(-1e7).max(1e7);
781
+ var percent = z8.number().min(0).max(100);
782
+ var CommentAnchorSchema = z8.object({
380
783
  x: percent,
381
784
  y: percent,
382
- selector: z3.string().min(1).max(ANCHOR_SELECTOR_MAX).optional(),
785
+ selector: z8.string().min(1).max(ANCHOR_SELECTOR_MAX).optional(),
383
786
  ex: percent.optional(),
384
787
  ey: percent.optional(),
385
788
  docX: px.optional(),
386
789
  docY: px.optional(),
387
790
  scrollX: px.optional(),
388
791
  scrollY: px.optional(),
389
- vw: z3.number().finite().min(0).max(1e5).optional(),
390
- vh: z3.number().finite().min(0).max(1e5).optional(),
391
- hash: z3.string().max(COMMENT_PATH_MAX).optional(),
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(),
392
795
  ew: percent.optional(),
393
796
  eh: percent.optional(),
394
- docW: z3.number().finite().min(0).max(1e7).optional(),
395
- docH: z3.number().finite().min(0).max(1e7).optional(),
396
- quote: z3.string().min(1).max(ANCHOR_QUOTE_MAX).optional()
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()
397
800
  });
398
- var CommentSchema = z3.object({
399
- id: z3.string(),
801
+ var CommentSchema = z8.object({
802
+ id: z8.string(),
400
803
  /** the thread this replies to; null for the comment that starts a thread */
401
- parentId: z3.string().nullable(),
804
+ parentId: z8.string().nullable(),
402
805
  /** the page, as normalizeCommentPath() writes it ('/', '/docs/', '/about.html') */
403
- path: z3.string(),
806
+ path: z8.string(),
404
807
  /** the version the site was serving when the comment was made (null: none was) */
405
- versionId: z3.string().nullable(),
808
+ versionId: z8.string().nullable(),
406
809
  anchor: CommentAnchorSchema.nullable(),
407
- body: z3.string(),
810
+ body: z8.string(),
408
811
  author: CommentAuthorSchema,
409
- createdAt: z3.string(),
812
+ createdAt: z8.string(),
410
813
  /** threads only: when and by whom it was resolved (null: open) */
411
- resolvedAt: z3.string().nullable(),
814
+ resolvedAt: z8.string().nullable(),
412
815
  resolvedBy: CommentAuthorSchema.nullable(),
413
816
  /** whether the caller may delete it (policy's answer, so clients don't guess) */
414
- canDelete: z3.boolean()
817
+ canDelete: z8.boolean()
415
818
  });
416
- var CommentListSchema = z3.object({
417
- site: z3.object({
418
- name: z3.string(),
419
- url: z3.string(),
420
- currentVersionId: z3.string().nullable()
819
+ var CommentListSchema = z8.object({
820
+ site: z8.object({
821
+ name: z8.string(),
822
+ url: z8.string(),
823
+ currentVersionId: z8.string().nullable()
421
824
  }),
422
- comments: z3.array(CommentSchema),
423
- can: z3.object({
424
- create: z3.boolean(),
425
- resolve: z3.boolean(),
825
+ comments: z8.array(CommentSchema),
826
+ can: z8.object({
827
+ create: z8.boolean(),
828
+ resolve: z8.boolean(),
426
829
  /** delete anyone's comment (everyone may delete their own) */
427
- deleteAny: z3.boolean()
830
+ deleteAny: z8.boolean()
428
831
  })
429
832
  });
430
- var CreateCommentRequestSchema = z3.object({
431
- body: z3.string().trim().min(1).max(COMMENT_MAX_CHARS),
432
- path: z3.string().max(COMMENT_PATH_MAX).optional(),
433
- parentId: z3.string().min(1).max(100).optional(),
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(),
434
837
  anchor: CommentAnchorSchema.optional()
435
838
  });
436
- var UpdateCommentRequestSchema = z3.object({ resolved: z3.boolean() });
839
+ var UpdateCommentRequestSchema = z8.object({ resolved: z8.boolean() });
437
840
  function commentThreads(comments) {
438
841
  const threads = /* @__PURE__ */ new Map();
439
842
  for (const c of comments) {
@@ -448,14 +851,69 @@ function authorLabel(author) {
448
851
  return author.kind === "user" ? author.email : `${author.name} (guest)`;
449
852
  }
450
853
  function reviewUrl(appOrigin, name, path3) {
451
- const base = `${appOrigin.replace(/\/+$/, "")}/review/${name === "<name>" ? name : encodeURIComponent(name)}`;
452
- 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;
453
856
  }
454
857
 
455
858
  // ../../packages/shared/src/distribution.ts
456
859
  var CLI_PACKAGE = "@nearform/fling";
457
860
  var REPO_SLUG = "nearform/fling";
458
861
  var DEFAULT_INSTALL_COMMAND = `npm install -g ${CLI_PACKAGE}`;
862
+ var REPO_URL = `https://github.com/${REPO_SLUG}`;
863
+
864
+ // ../../packages/shared/src/groups.ts
865
+ import { z as z9 } from "zod";
866
+ var GROUP_MEMBERS_MAX = 500;
867
+ var GROUP_SLUG_MAX = 50;
868
+ var GROUP_NAME_MAX = 100;
869
+ var GROUP_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
870
+ function isValidGroupSlug(slug) {
871
+ return slug.length <= GROUP_SLUG_MAX && GROUP_SLUG_RE.test(slug) && !slug.includes("--");
872
+ }
873
+ var GROUP_SLUG_RULES = `1-${GROUP_SLUG_MAX} lowercase letters, digits and single hyphens`;
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(),
880
+ /** the email of the user who made it, the only one who may change it */
881
+ owner: z9.string(),
882
+ size: z9.number(),
883
+ createdAt: z9.string(),
884
+ updatedAt: z9.string()
885
+ });
886
+ var GroupListSchema = z9.object({ groups: z9.array(GroupSummarySchema) });
887
+ var GroupDetailSchema = GroupSummarySchema.extend({
888
+ members: z9.array(AudienceEmailSchema),
889
+ canManage: z9.boolean()
890
+ });
891
+ var CreateGroupRequestSchema = z9.object({
892
+ slug: GroupSlugSchema,
893
+ name: GroupNameSchema,
894
+ members: memberEmails.optional()
895
+ });
896
+ var UpdateGroupRequestSchema = z9.object({ name: GroupNameSchema });
897
+ var GroupMembersRequestSchema = z9.object({
898
+ add: memberEmails.optional(),
899
+ remove: memberEmails.optional()
900
+ });
901
+ function splitGroups(values) {
902
+ const all = typeof values === "string" ? [values] : values;
903
+ return all.flatMap((v) => v.split(/[\s,;]+/)).map((s) => s.trim().toLowerCase()).filter(Boolean);
904
+ }
905
+ function describeGroups(groups2, me) {
906
+ if (groups2.length === 0) return "No groups yet.";
907
+ return groups2.map((g) => `- ${groupLabel(g)}, owned by ${g.owner === me ? "you" : g.owner}`).join("\n");
908
+ }
909
+ function describeGroup(group) {
910
+ const members2 = group.members;
911
+ const who = group.canManage ? "you (you can change it)" : `${group.owner} (only they can change it)`;
912
+ return [
913
+ `${groupLabel(group)}, owned by ${who}.`,
914
+ members2.length ? `Members: ${audienceList(members2)}.` : "No members yet."
915
+ ].join("\n");
916
+ }
459
917
 
460
918
  // ../../packages/shared/src/site-labels.ts
461
919
  var SITE_NAME_MAX = 50;
@@ -491,48 +949,67 @@ function siteUrl(label, sitesDomain, protocol = "https:") {
491
949
  }
492
950
 
493
951
  // ../../packages/shared/src/instance.ts
494
- import { z as z4 } from "zod";
952
+ import { z as z10 } from "zod";
495
953
  var INSTANCE_METADATA_PATH = "/.well-known/fling";
496
954
  var HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
497
- var color = z4.string().regex(HEX_COLOR_RE);
498
- var InstanceAuthSchema = z4.looseObject({
499
- kind: z4.string().min(1),
500
- displayName: z4.string().min(1),
501
- issuer: z4.string().optional(),
502
- emailDomains: z4.array(z4.string()).default([]),
503
- invitedEmails: z4.boolean().optional()
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()
504
962
  });
505
- var BrandColorsSchema = z4.looseObject({
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()
970
+ });
971
+ var BrandColorsSchema = z10.looseObject({
506
972
  primary: color.optional(),
507
973
  primaryContent: color.optional(),
508
974
  accent: color.optional(),
509
975
  ink: color.optional(),
510
976
  highlight: color.optional()
511
977
  });
512
- var InstanceBrandSchema = z4.looseObject({
978
+ var InstanceBrandSchema = z10.looseObject({
513
979
  /** https: or data: URL of the logo mark shown before the name (light theme). */
514
- logoUrl: z4.string().optional(),
980
+ logoUrl: z10.string().optional(),
515
981
  /** The same for the dark theme; defaults to `logoUrl`. */
516
- logoDarkUrl: z4.string().optional(),
517
- faviconUrl: z4.string().optional(),
982
+ logoDarkUrl: z10.string().optional(),
983
+ faviconUrl: z10.string().optional(),
518
984
  colors: BrandColorsSchema.default({})
519
985
  });
520
- var InstanceMetadataSchema = z4.looseObject({
521
- version: z4.number().int().min(1),
986
+ var InstanceMetadataSchema = z10.looseObject({
987
+ version: z10.number().int().min(1),
522
988
  /** Display name, e.g. "Fling". */
523
- name: z4.string().min(1),
989
+ name: z10.string().min(1),
524
990
  /** e.g. `https://fling.example.test` (no trailing slash) */
525
- appOrigin: z4.string().url(),
991
+ appOrigin: z10.string().url(),
526
992
  /** host[:port] sites hang off: a site is `<name>.<sitesDomain>` */
527
- sitesDomain: z4.string().min(1),
528
- siteScheme: z4.enum(["http", "https"]),
993
+ sitesDomain: z10.string().min(1),
994
+ siteScheme: z10.enum(["http", "https"]),
529
995
  auth: InstanceAuthSchema,
530
996
  brand: InstanceBrandSchema.default({ colors: {} }),
531
997
  /** Feature flags: a client hides what is missing or false. */
532
- capabilities: z4.record(z4.string(), z4.boolean()).default({}),
998
+ capabilities: z10.record(z10.string(), z10.boolean()).default({}),
533
999
  /** How to install the CLI, e.g. `npm install -g <package>`. */
534
- installCommand: z4.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)
535
1011
  });
1012
+ var defaultAccessOf = (instance) => instance?.defaultAccess ?? LEGACY_DEFAULT_ACCESS;
536
1013
  function instanceSitesPattern(instance) {
537
1014
  return `${instance.siteScheme}://<name>.${instance.sitesDomain}`;
538
1015
  }
@@ -555,15 +1032,22 @@ function instanceMetadataUrl(serverUrl) {
555
1032
  }
556
1033
 
557
1034
  // ../../packages/shared/src/kvs.ts
558
- import { z as z5 } from "zod";
559
- var KvsSiteValueSchema = z5.object({
1035
+ import { z as z11 } from "zod";
1036
+
1037
+ // ../../packages/shared/src/route-keys.ts
1038
+ var SITE_PASS_TTL_SECONDS = 5 * 60;
1039
+
1040
+ // ../../packages/shared/src/kvs.ts
1041
+ var KvsSiteValueSchema = z11.object({
560
1042
  /** siteId — S3 prefix is `sites/<id>/<v>/` */
561
- id: z5.string(),
1043
+ id: z11.string(),
562
1044
  /** current versionId */
563
- v: z5.string(),
564
- vis: z5.enum(["private", "public"]),
1045
+ v: z11.string(),
1046
+ vis: z11.enum(["private", "public"]),
565
1047
  /** serve /index.html for unknown extensionless paths */
566
- spa: z5.boolean()
1048
+ spa: z11.boolean(),
1049
+ /** present when the site is restricted (private with an audience): the edge wants a site pass */
1050
+ r: z11.literal(1).optional()
567
1051
  });
568
1052
 
569
1053
  // ../../packages/shared/src/listing.ts
@@ -583,13 +1067,13 @@ function extractHtmlTitle(html) {
583
1067
  }
584
1068
  function renderHtmlListing(siteName2, entries) {
585
1069
  const pages = entries.filter((e) => isHtmlPath(e.path) && e.path.toLowerCase() !== "index.html").sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
586
- const groups = /* @__PURE__ */ new Map();
1070
+ const groups2 = /* @__PURE__ */ new Map();
587
1071
  for (const page of pages) {
588
1072
  const slash = page.path.lastIndexOf("/");
589
1073
  const folder = slash === -1 ? "" : page.path.slice(0, slash);
590
- groups.set(folder, [...groups.get(folder) ?? [], page]);
1074
+ groups2.set(folder, [...groups2.get(folder) ?? [], page]);
591
1075
  }
592
- const sections = [...groups.entries()].sort(([a], [b]) => a === "" ? -1 : b === "" ? 1 : a < b ? -1 : 1).map(([folder, items]) => {
1076
+ const sections = [...groups2.entries()].sort(([a], [b]) => a === "" ? -1 : b === "" ? 1 : a < b ? -1 : 1).map(([folder, items]) => {
593
1077
  const heading = folder ? `<h2>${escapeHtml(folder)}/</h2>` : "";
594
1078
  const rows = items.map((item) => {
595
1079
  const file = item.path.slice(item.path.lastIndexOf("/") + 1);
@@ -602,7 +1086,7 @@ ${rows}
602
1086
  </ul>`;
603
1087
  }).join("\n");
604
1088
  const body = pages.length ? sections : '<p class="empty">No HTML pages in this fling.</p>';
605
- const count = `${pages.length} page${pages.length === 1 ? "" : "s"}`;
1089
+ const count2 = `${pages.length} page${pages.length === 1 ? "" : "s"}`;
606
1090
  return `<!doctype html>
607
1091
  <html lang="en">
608
1092
  <head>
@@ -637,7 +1121,7 @@ ${rows}
637
1121
  </head>
638
1122
  <body>
639
1123
  <main>
640
- <header><h1>${escapeHtml(siteName2)}</h1><p>${count}</p></header>
1124
+ <header><h1>${escapeHtml(siteName2)}</h1><p>${count2}</p></header>
641
1125
  ${body}
642
1126
  <footer>Flung with Fling</footer>
643
1127
  </main>
@@ -647,7 +1131,277 @@ ${body}
647
1131
  }
648
1132
 
649
1133
  // ../../packages/shared/src/mcp-tools.ts
650
- import { z as z6 } 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
+ }
1393
+
1394
+ // ../../packages/shared/src/site-sort.ts
1395
+ var SITE_SORTS = ["updated", "created", "name"];
1396
+ var DEFAULT_SITE_SORT = "updated";
1397
+ var byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
1398
+ function sortSites(sites, sort = DEFAULT_SITE_SORT) {
1399
+ const newest = (key) => (a, b) => Date.parse(b[key]) - Date.parse(a[key]) || byName(a, b);
1400
+ const compare = sort === "name" ? byName : newest(sort === "created" ? "createdAt" : "updatedAt");
1401
+ return [...sites].sort(compare);
1402
+ }
1403
+
1404
+ // ../../packages/shared/src/mcp-tools.ts
651
1405
  var MCP_PUBLISH_MAX_BYTES = 4 * 1024 * 1024;
652
1406
  var MCP_UPLOAD_CHUNK_MAX_CHARS = 4 * 1024 * 1024;
653
1407
  var MCP_UPLOAD_TTL_SECONDS = 60 * 60;
@@ -668,33 +1422,65 @@ function mcpWording(instance) {
668
1422
  sitesPattern: instanceSitesPattern(instance),
669
1423
  privateAudience: privateAudience(instance),
670
1424
  signIn: `their ${instance.auth.displayName} account`,
671
- 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
672
1428
  };
673
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.`;
674
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.';
675
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.";
676
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.";
677
1436
  var ENCODING = '`encoding` is "utf8" (default, for text: HTML, CSS, JS, SVG, JSON, Markdown\u2026) or "base64" (binary: images, fonts, PDFs).';
678
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.";
679
- var listHtml = z6.boolean().optional().describe(
1438
+ var listHtml = z13.boolean().optional().describe(
680
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"
681
1440
  );
682
- var siteName = z6.string().min(1).max(200).describe('Site name, e.g. "q3-report"');
683
- var sitePath = z6.string().min(1).max(1024).describe(
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(
684
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")'
685
1453
  );
686
- var commentPath = z6.string().max(1024);
687
- var commentId = z6.string().min(1).max(100);
688
- var encoding = z6.enum(["utf8", "base64"]).optional().describe('How `content` is encoded: "utf8" (default) for text, "base64" for binary files');
689
- var spa = z6.boolean().optional().describe(
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(
690
1471
  "Single-page app: serve index.html for unknown paths (client-side routing). Omit to keep the current setting"
691
1472
  );
692
1473
  function mcpToolsFor(w) {
693
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}.`;
694
- const VISIBILITY = `Sites are private by default (only ${w.privateAudience} can open them); pass \`public: true\` only if the user asks for a public site. Share links (fling_share) are public to anyone who has them.`;
695
- const isPublic = z6.boolean().optional().describe(
696
- `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`
697
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');
698
1484
  return {
699
1485
  whoami: tool({
700
1486
  name: "fling_whoami",
@@ -706,9 +1492,13 @@ function mcpToolsFor(w) {
706
1492
  list: tool({
707
1493
  name: "fling_list",
708
1494
  title: "List sites",
709
- description: "List the user's Fling sites with URL, visibility and share link.",
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.",
710
1496
  inputSchema: {
711
- archived: z6.boolean().optional().describe("List archived sites instead of live ones")
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(
1500
+ "Order: updated (last updated, newest first; the default), created (newest first) or name (A\u2013Z)"
1501
+ )
712
1502
  },
713
1503
  annotations: { readOnlyHint: true, openWorldHint: false }
714
1504
  }),
@@ -725,7 +1515,20 @@ function mcpToolsFor(w) {
725
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).",
726
1516
  inputSchema: {
727
1517
  name: siteName,
728
- versionId: z6.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")
729
1532
  },
730
1533
  annotations: { readOnlyHint: true, openWorldHint: false }
731
1534
  }),
@@ -733,23 +1536,31 @@ function mcpToolsFor(w) {
733
1536
  name: "fling_check_name",
734
1537
  title: "Check a site name",
735
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,
736
- inputSchema: { name: z6.string().min(1).max(200).describe("Site name to check") },
1539
+ inputSchema: { name: z13.string().min(1).max(200).describe("Site name to check") },
737
1540
  annotations: { readOnlyHint: true, openWorldHint: false }
738
1541
  }),
739
1542
  publish: tool({
740
1543
  name: "fling_publish",
741
1544
  title: "Publish a site",
742
- 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.`,
743
1546
  inputSchema: {
744
1547
  name: siteName,
745
- files: z6.array(
746
- z6.object({
1548
+ files: z13.array(
1549
+ z13.object({
747
1550
  path: sitePath,
748
- content: z6.string().describe("The file content, encoded as `encoding` says"),
1551
+ content: z13.string().describe("The file content, encoded as `encoding` says"),
749
1552
  encoding
750
1553
  })
751
- ).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,
752
1560
  spa,
1561
+ access: access2,
1562
+ emails: accessEmails,
1563
+ groups: accessGroups,
753
1564
  public: isPublic,
754
1565
  listHtml
755
1566
  },
@@ -758,8 +1569,18 @@ function mcpToolsFor(w) {
758
1569
  uploadBegin: tool({
759
1570
  name: "fling_upload_begin",
760
1571
  title: "Start a chunked upload",
761
- 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}`,
762
- 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
+ },
763
1584
  annotations: { destructiveHint: false, openWorldHint: false }
764
1585
  }),
765
1586
  uploadFile: tool({
@@ -767,20 +1588,20 @@ function mcpToolsFor(w) {
767
1588
  title: "Upload a file (chunked)",
768
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}`,
769
1590
  inputSchema: {
770
- uploadId: z6.string().min(1).max(100).describe("From fling_upload_begin"),
1591
+ uploadId: z13.string().min(1).max(100).describe("From fling_upload_begin"),
771
1592
  path: sitePath,
772
- content: z6.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`)"),
773
1594
  encoding,
774
- append: z6.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")
775
1596
  },
776
1597
  annotations: { destructiveHint: false, openWorldHint: false }
777
1598
  }),
778
1599
  uploadFinish: tool({
779
1600
  name: "fling_upload_finish",
780
1601
  title: "Finish a chunked upload",
781
- 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\`.`,
782
1603
  inputSchema: {
783
- uploadId: z6.string().min(1).max(100).describe("From fling_upload_begin"),
1604
+ uploadId: z13.string().min(1).max(100).describe("From fling_upload_begin"),
784
1605
  listHtml
785
1606
  },
786
1607
  annotations: { destructiveHint: false, openWorldHint: true }
@@ -791,25 +1612,143 @@ function mcpToolsFor(w) {
791
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.",
792
1613
  inputSchema: {
793
1614
  name: siteName,
794
- refresh: z6.boolean().optional().describe("Rotate the link"),
795
- revoke: z6.boolean().optional().describe("Remove the link")
1615
+ refresh: z13.boolean().optional().describe("Rotate the link"),
1616
+ revoke: z13.boolean().optional().describe("Remove the link")
796
1617
  },
797
1618
  annotations: { destructiveHint: false, openWorldHint: true }
798
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
+ }),
799
1632
  setVisibility: tool({
800
1633
  name: "fling_set_visibility",
801
1634
  title: "Set visibility",
802
- description: `Make a site private (only ${w.privateAudience}) or public (anyone).`,
803
- inputSchema: { name: siteName, visibility: z6.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"]) },
804
1637
  annotations: { idempotentHint: true, openWorldHint: true }
805
1638
  }),
1639
+ setAudience: tool({
1640
+ name: "fling_set_audience",
1641
+ title: "Set who can see a private site",
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.`,
1643
+ inputSchema: {
1644
+ name: siteName,
1645
+ add: emails.optional().describe("Addresses to let in"),
1646
+ remove: emails.optional().describe("Addresses to take out"),
1647
+ set: emails.optional().describe("Replace the addresses with exactly these"),
1648
+ addGroups: groupSlugs.optional().describe("Groups (slugs) to let in"),
1649
+ removeGroups: groupSlugs.optional().describe("Groups (slugs) to take out"),
1650
+ setGroups: groupSlugs.optional().describe("Replace the groups with exactly these slugs"),
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
+ )
1686
+ },
1687
+ annotations: { idempotentHint: true, openWorldHint: false }
1688
+ }),
1689
+ groups: tool({
1690
+ name: "fling_groups",
1691
+ title: "List groups",
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.`,
1693
+ inputSchema: {
1694
+ mine: z13.boolean().optional().describe("Only the groups the user owns"),
1695
+ members: members.optional().describe("Only the groups whose members are exactly these addresses")
1696
+ },
1697
+ annotations: { readOnlyHint: true, openWorldHint: false }
1698
+ }),
1699
+ groupShow: tool({
1700
+ name: "fling_group_show",
1701
+ title: "Show a group",
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).",
1703
+ inputSchema: { slug: groupSlug },
1704
+ annotations: { readOnlyHint: true, openWorldHint: false }
1705
+ }),
1706
+ groupCreate: tool({
1707
+ name: "fling_group_create",
1708
+ title: "Create a group",
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)}`,
1710
+ inputSchema: {
1711
+ slug: groupSlug,
1712
+ name: groupName.describe('Its display name, e.g. "Design team"'),
1713
+ members: members.optional().describe("Addresses to put in it")
1714
+ },
1715
+ annotations: { destructiveHint: false, openWorldHint: false }
1716
+ }),
1717
+ groupMembers: tool({
1718
+ name: "fling_group_members",
1719
+ title: "Change who is in a group",
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)}`,
1721
+ inputSchema: {
1722
+ slug: groupSlug,
1723
+ add: members.optional().describe("Addresses to add"),
1724
+ remove: members.optional().describe("Addresses to take out")
1725
+ },
1726
+ annotations: { idempotentHint: true, openWorldHint: false }
1727
+ }),
1728
+ groupRename: tool({
1729
+ name: "fling_group_rename",
1730
+ title: "Rename a group",
1731
+ description: "Change the display name of a group the user owns (the slug stays).",
1732
+ inputSchema: { slug: groupSlug, name: groupName.describe("The new display name") },
1733
+ annotations: { idempotentHint: true, openWorldHint: false }
1734
+ }),
1735
+ groupDelete: tool({
1736
+ name: "fling_group_delete",
1737
+ title: "Delete a group",
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`.",
1739
+ inputSchema: {
1740
+ slug: groupSlug,
1741
+ confirm: z13.literal(true).describe("Must be true: the user has confirmed the deletion")
1742
+ },
1743
+ annotations: { destructiveHint: true, openWorldHint: false }
1744
+ }),
806
1745
  archive: tool({
807
1746
  name: "fling_archive",
808
1747
  title: "Archive or restore a site",
809
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.",
810
1749
  inputSchema: {
811
1750
  name: siteName,
812
- unarchive: z6.boolean().optional().describe("Restore an archived site")
1751
+ unarchive: z13.boolean().optional().describe("Restore an archived site")
813
1752
  },
814
1753
  annotations: { idempotentHint: true, openWorldHint: false }
815
1754
  }),
@@ -819,7 +1758,7 @@ function mcpToolsFor(w) {
819
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.",
820
1759
  inputSchema: {
821
1760
  name: siteName,
822
- confirm: z6.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")
823
1762
  },
824
1763
  annotations: { destructiveHint: true, openWorldHint: false }
825
1764
  }),
@@ -830,7 +1769,7 @@ function mcpToolsFor(w) {
830
1769
  inputSchema: {
831
1770
  name: siteName,
832
1771
  path: commentPath.optional().describe('Only this page, e.g. "/" or "/docs/"'),
833
- includeResolved: z6.boolean().optional().describe("Include resolved threads")
1772
+ includeResolved: z13.boolean().optional().describe("Include resolved threads")
834
1773
  },
835
1774
  annotations: { readOnlyHint: true, openWorldHint: false }
836
1775
  }),
@@ -840,7 +1779,7 @@ function mcpToolsFor(w) {
840
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.`,
841
1780
  inputSchema: {
842
1781
  name: siteName,
843
- body: z6.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)"),
844
1783
  path: commentPath.optional().describe('The page it is about (default "/"); ignored for a reply'),
845
1784
  replyTo: commentId.optional().describe("Reply to this thread instead of starting one")
846
1785
  },
@@ -853,7 +1792,7 @@ function mcpToolsFor(w) {
853
1792
  inputSchema: {
854
1793
  name: siteName,
855
1794
  id: commentId.describe("The thread id"),
856
- reopen: z6.boolean().optional().describe("Reopen a resolved thread")
1795
+ reopen: z13.boolean().optional().describe("Reopen a resolved thread")
857
1796
  },
858
1797
  annotations: { idempotentHint: true, openWorldHint: false }
859
1798
  }),
@@ -864,19 +1803,39 @@ function mcpToolsFor(w) {
864
1803
  inputSchema: { name: siteName, id: commentId.describe("The comment id") },
865
1804
  annotations: { destructiveHint: true, openWorldHint: false }
866
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
+ }),
867
1825
  rollback: tool({
868
1826
  name: "fling_rollback",
869
1827
  title: "Roll back",
870
1828
  description: "Point a site at an earlier version (ids from fling_info). Instant; nothing is uploaded.",
871
1829
  inputSchema: {
872
1830
  name: siteName,
873
- versionId: z6.string().min(1).max(100).describe("Version id to serve")
1831
+ versionId: z13.string().min(1).max(100).describe("Version id to serve")
874
1832
  },
875
1833
  annotations: { idempotentHint: true, openWorldHint: false }
876
1834
  })
877
1835
  };
878
1836
  }
879
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.`;
880
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.`;
881
1840
  var shareForFeedback = (reviewPage2, instanceName) => `Share for feedback: ${reviewPage2} (reviewers sign in to ${instanceName} to see the site and comment).`;
882
1841
  var MCP_FILES_LISTED = 200;
@@ -923,7 +1882,7 @@ function describeComments(list, opts = {}) {
923
1882
  var READY_TIMEOUT_MS = 15e3;
924
1883
  var NOT_READY_NOTE = "it can take a few more seconds to appear everywhere; reload if you see a 404";
925
1884
  var isReachableStatus = (status) => status >= 200 && status < 400 || status === 401 || status === 403;
926
- var defaultSleep = (ms) => new Promise((resolve5) => setTimeout(resolve5, ms));
1885
+ var defaultSleep = (ms) => new Promise((resolve6) => setTimeout(resolve6, ms));
927
1886
  async function waitUntilReachable(url, opts = {}) {
928
1887
  const timeoutMs = opts.timeoutMs ?? READY_TIMEOUT_MS;
929
1888
  const doFetch = opts.fetch ?? ((u, init) => fetch(u, init));
@@ -957,107 +1916,133 @@ async function waitUntilReachable(url, opts = {}) {
957
1916
  }
958
1917
  }
959
1918
  function probeUrl(siteUrl2, paths) {
960
- const base = siteUrl2.endsWith("/") ? siteUrl2 : `${siteUrl2}/`;
961
- 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;
962
1921
  const pick = paths.find((p) => /\.html?$/i.test(p)) ?? paths[0];
963
- return base + pick.split("/").map(encodeURIComponent).join("/");
1922
+ return base2 + pick.split("/").map(encodeURIComponent).join("/");
964
1923
  }
965
1924
  function readinessWaitDisabled(env) {
966
1925
  return env.FLING_NO_WAIT === "1" || env.VITEST === "true" || env.NODE_ENV === "test";
967
1926
  }
968
1927
 
969
1928
  // ../../packages/shared/src/review-bridge.ts
970
- import { z as z7 } from "zod";
971
- var tag = { fling: z7.literal("review") };
972
- var path = z7.string().max(COMMENT_PATH_MAX);
973
- var hash = z7.string().max(COMMENT_PATH_MAX);
974
- var BridgePinSchema = z7.object({
975
- id: z7.string(),
976
- n: z7.number().int().min(1),
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),
977
1936
  anchor: CommentAnchorSchema,
978
- selected: z7.boolean(),
979
- resolved: z7.boolean()
1937
+ selected: z14.boolean(),
1938
+ resolved: z14.boolean()
980
1939
  });
981
- var BridgeCursorSchema = z7.object({
982
- id: z7.string(),
983
- name: z7.string().max(100),
984
- colour: z7.string().max(32),
1940
+ var BridgeCursorSchema = z14.object({
1941
+ id: z14.string(),
1942
+ name: z14.string().max(100),
1943
+ colour: z14.string().max(32),
985
1944
  anchor: CommentAnchorSchema
986
1945
  });
987
- var ShellMessageSchema = z7.discriminatedUnion("type", [
988
- z7.object({ ...tag, type: z7.literal("ready") }),
1946
+ var ShellMessageSchema = z14.discriminatedUnion("type", [
1947
+ z14.object({ ...tag, type: z14.literal("ready") }),
989
1948
  /** the frame is on `path` (normalised, see normalizeCommentPath) with fragment `hash` ('' or '#…') */
990
- z7.object({ ...tag, type: z7.literal("location"), path, hash, title: z7.string().max(300) }),
1949
+ z14.object({ ...tag, type: z14.literal("location"), path, hash, title: z14.string().max(300) }),
991
1950
  /** a pin placed while placing: a click (a spot) or a drag (a box) */
992
- z7.object({ ...tag, type: z7.literal("pinPlaced"), anchor: CommentAnchorSchema }),
1951
+ z14.object({ ...tag, type: z14.literal("pinPlaced"), anchor: CommentAnchorSchema }),
993
1952
  /** the reviewer selected text in the page: an anchor on it, with its quote */
994
- z7.object({ ...tag, type: z7.literal("textSelected"), anchor: CommentAnchorSchema }),
995
- z7.object({ ...tag, type: z7.literal("pinCancelled") }),
996
- z7.object({ ...tag, type: z7.literal("pinClicked"), id: z7.string() }),
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() }),
997
1956
  /** this reviewer's pointer on the page; null when it left the frame */
998
- z7.object({ ...tag, type: z7.literal("cursor"), anchor: CommentAnchorSchema.nullable() }),
1957
+ z14.object({ ...tag, type: z14.literal("cursor"), anchor: CommentAnchorSchema.nullable() }),
999
1958
  /** a link or form to another origin, stopped: the frame stays on the site */
1000
- z7.object({ ...tag, type: z7.literal("blocked"), href: z7.string().max(2048) }),
1001
- z7.object({ ...tag, type: z7.literal("escape") })
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) })
1002
1970
  ]);
1003
- var PageMessageSchema = z7.discriminatedUnion("type", [
1971
+ var PageMessageSchema = z14.discriminatedUnion("type", [
1004
1972
  /** load `path` (site-relative, may carry `?query` and `#hash`) in the frame */
1005
- z7.object({ ...tag, type: z7.literal("navigate"), path: z7.string().max(COMMENT_PATH_MAX * 2) }),
1006
- z7.object({ ...tag, type: z7.literal("reload") }),
1007
- z7.object({ ...tag, type: z7.literal("placing"), on: z7.boolean() }),
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() }),
1008
1976
  /** the pins on the frame's current page */
1009
- z7.object({ ...tag, type: z7.literal("pins"), pins: z7.array(BridgePinSchema) }),
1977
+ z14.object({ ...tag, type: z14.literal("pins"), pins: z14.array(BridgePinSchema) }),
1010
1978
  /** scroll the pin into view and draw attention to it */
1011
- z7.object({ ...tag, type: z7.literal("focusPin"), id: z7.string() }),
1012
- z7.object({ ...tag, type: z7.literal("cursors"), cursors: z7.array(BridgeCursorSchema) })
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) })
1013
1986
  ]);
1014
1987
 
1015
1988
  // ../../packages/shared/src/review-live.ts
1016
- import { z as z8 } from "zod";
1989
+ import { z as z15 } from "zod";
1017
1990
  var LIVE_PICTURE_MAX = 1024;
1018
- var LivePeerSchema = z8.object({
1019
- id: z8.string(),
1020
- name: z8.string().max(100),
1021
- colour: z8.string().max(32),
1991
+ var LivePeerSchema = z15.object({
1992
+ id: z15.string(),
1993
+ name: z15.string().max(100),
1994
+ colour: z15.string().max(32),
1022
1995
  /** their profile picture (https), when sign-in gave one; else clients show initials */
1023
- picture: z8.string().max(LIVE_PICTURE_MAX).optional()
1996
+ picture: z15.string().max(LIVE_PICTURE_MAX).optional()
1024
1997
  });
1025
- var LiveTicketSchema = z8.object({
1026
- url: z8.string(),
1027
- ticket: z8.string(),
1998
+ var LiveTicketSchema = z15.object({
1999
+ url: z15.string(),
2000
+ ticket: z15.string(),
1028
2001
  /** seconds the ticket is good for: connect before then */
1029
- expiresIn: z8.number().int()
2002
+ expiresIn: z15.number().int()
1030
2003
  });
1031
- var path2 = z8.string().max(COMMENT_PATH_MAX);
1032
- var hash2 = z8.string().max(COMMENT_PATH_MAX);
1033
- var LiveClientMessageSchema = z8.discriminatedUnion("type", [
1034
- z8.object({ type: z8.literal("hello") }),
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") }),
1035
2008
  /** where this peer's pointer is; `anchor` null when it left the page */
1036
- z8.object({
1037
- type: z8.literal("cursor"),
2009
+ z15.object({
2010
+ type: z15.literal("cursor"),
1038
2011
  path: path2,
1039
2012
  hash: hash2,
1040
2013
  anchor: CommentAnchorSchema.nullable()
1041
2014
  }),
1042
- z8.object({ type: z8.literal("ping") })
2015
+ z15.object({ type: z15.literal("ping") })
1043
2016
  ]);
1044
- var LiveServerMessageSchema = z8.discriminatedUnion("type", [
2017
+ var LiveServerMessageSchema = z15.discriminatedUnion("type", [
1045
2018
  /** the answer to `hello`: who this connection is, and who else is in the room */
1046
- z8.object({ type: z8.literal("welcome"), you: LivePeerSchema, peers: z8.array(LivePeerSchema) }),
1047
- z8.object({ type: z8.literal("join"), peer: LivePeerSchema }),
1048
- z8.object({ type: z8.literal("leave"), peerId: z8.string() }),
1049
- z8.object({
1050
- type: z8.literal("cursor"),
1051
- peerId: z8.string(),
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(),
1052
2025
  path: path2,
1053
2026
  hash: hash2,
1054
2027
  anchor: CommentAnchorSchema.nullable()
1055
2028
  }),
1056
2029
  /** the site's comments changed: refetch them */
1057
- z8.object({ type: z8.literal("comments") }),
1058
- z8.object({ type: z8.literal("pong") })
2030
+ z15.object({ type: z15.literal("comments") }),
2031
+ z15.object({ type: z15.literal("pong") })
1059
2032
  ]);
1060
2033
 
2034
+ // src/cli.ts
2035
+ import chalk13 from "chalk";
2036
+ import { Command, Option } from "commander";
2037
+
2038
+ // src/commands/auth.ts
2039
+ import chalk5 from "chalk";
2040
+
2041
+ // src/auth.ts
2042
+ import { randomBytes } from "node:crypto";
2043
+ import { createServer } from "node:http";
2044
+ import { hostname as osHostname } from "node:os";
2045
+
1061
2046
  // src/errors.ts
1062
2047
  var EXIT_ERROR = 1;
1063
2048
  var EXIT_NOT_LOGGED_IN = 2;
@@ -1097,7 +2082,7 @@ function exitCodeFor(error) {
1097
2082
  // package.json
1098
2083
  var package_default = {
1099
2084
  name: "@nearform/fling",
1100
- version: "0.5.1",
2085
+ version: "0.7.0",
1101
2086
  license: "MIT",
1102
2087
  description: "Fling a folder of static files at a Fling server and get a link back. CLI + MCP server.",
1103
2088
  type: "module",
@@ -1106,9 +2091,7 @@ var package_default = {
1106
2091
  },
1107
2092
  files: [
1108
2093
  "dist/cli.js",
1109
- "dist/cli.js.map",
1110
- "README.md",
1111
- "CHANGELOG.md"
2094
+ "README.md"
1112
2095
  ],
1113
2096
  engines: {
1114
2097
  node: ">=22"
@@ -1140,6 +2123,7 @@ var package_default = {
1140
2123
  zod: "^4.6.7"
1141
2124
  },
1142
2125
  devDependencies: {
2126
+ "@anthropic-ai/mcpb": "^2.1.2",
1143
2127
  "@fling/shared": "workspace:*",
1144
2128
  "@types/node": "^24.0.0",
1145
2129
  esbuild: "^0.25.12",
@@ -1280,7 +2264,7 @@ function errorFromResponse(status, body, method, path3) {
1280
2264
  code,
1281
2265
  body,
1282
2266
  message: envelope.data.error.message,
1283
- hint: code ? NEVER_PUBLISHED_HINTS[code] : void 0
2267
+ hint: code ? ERROR_HINTS[code] : void 0
1284
2268
  });
1285
2269
  const extra = body.error;
1286
2270
  if (Array.isArray(extra?.findings)) Object.assign(error, { findings: extra.findings });
@@ -1294,6 +2278,10 @@ var NEVER_PUBLISHED_HINTS = {
1294
2278
  secret_file: "Move the secret files out of the folder, or add them to .flingignore if the site does not need them.",
1295
2279
  dotfile: `Dotfiles are never flung. Update the CLI (\`npm i -g ${PACKAGE_NAME}@latest\`), which skips them.`
1296
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
+ };
1297
2285
  function describeCause(cause) {
1298
2286
  if (cause instanceof Error) {
1299
2287
  const inner = cause.cause;
@@ -1308,7 +2296,7 @@ function describeCause(cause) {
1308
2296
  import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
1309
2297
  import { homedir } from "node:os";
1310
2298
  import { join } from "node:path";
1311
- import { z as z9 } from "zod";
2299
+ import { z as z16 } from "zod";
1312
2300
  var CONFIG_DIR_NAME = ".fling";
1313
2301
  var CONFIG_FILE_NAME = "config.json";
1314
2302
  var ENV = {
@@ -1321,14 +2309,14 @@ var ENV = {
1321
2309
  /** A server the local MCP server adds to the remembered ones on start (the .mcpb bakes it in). */
1322
2310
  addServer: "FLING_ADD_SERVER"
1323
2311
  };
1324
- var serverEntrySchema = z9.object({
2312
+ var serverEntrySchema = z16.object({
1325
2313
  /** What the server said about itself at login. A copy this CLI can't read is just refetched. */
1326
2314
  instance: InstanceMetadataSchema.optional().catch(void 0),
1327
- apiKey: z9.string().min(1).optional().catch(void 0),
1328
- user: z9.object({
1329
- email: z9.string().optional(),
1330
- name: z9.string().optional(),
1331
- userSlug: z9.string().optional()
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()
1332
2320
  }).optional().catch(void 0)
1333
2321
  });
1334
2322
  function systemLocateOptions(env = process.env) {
@@ -1546,6 +2534,18 @@ async function loadInstance(store, config, fetch2) {
1546
2534
  return null;
1547
2535
  }
1548
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
+ }
1549
2549
 
1550
2550
  // src/auth.ts
1551
2551
  var CALLBACK_PORT_START = 8765;
@@ -1615,8 +2615,8 @@ async function startCallbackServer(options = {}) {
1615
2615
  const timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS;
1616
2616
  let settle;
1617
2617
  let timer;
1618
- const result = new Promise((resolve5, reject) => {
1619
- settle = { resolve: resolve5, reject };
2618
+ const result = new Promise((resolve6, reject) => {
2619
+ settle = { resolve: resolve6, reject };
1620
2620
  }).finally(() => clearTimeout(timer));
1621
2621
  result.catch(() => {
1622
2622
  });
@@ -1677,7 +2677,7 @@ ${close ? "<script>setTimeout(function(){window.close()},800)</script>" : ""}
1677
2677
  res.end(html);
1678
2678
  }
1679
2679
  function listenOnFreePort(server) {
1680
- return new Promise((resolve5, reject) => {
2680
+ return new Promise((resolve6, reject) => {
1681
2681
  let port = CALLBACK_PORT_START;
1682
2682
  const tryNext = () => {
1683
2683
  if (port > CALLBACK_PORT_END) {
@@ -1699,7 +2699,7 @@ function listenOnFreePort(server) {
1699
2699
  });
1700
2700
  server.listen(port, "127.0.0.1", () => {
1701
2701
  server.removeAllListeners("error");
1702
- resolve5(port);
2702
+ resolve6(port);
1703
2703
  });
1704
2704
  };
1705
2705
  tryNext();
@@ -1794,7 +2794,7 @@ function logo(options = {}) {
1794
2794
  const tagline = options.tagline ?? "throw static files at the web, get a link back";
1795
2795
  if (columns < LOGO_WIDTH + 2) return `${gradient("\u25B6\u25B6 fling")} ${chalk2.dim(tagline)}`;
1796
2796
  const lines = WORDMARK.map(
1797
- (row, i) => chalk2.dim(TRAILS[i] ?? "") + gradient(row, WORDMARK[0].length)
2797
+ (row2, i) => chalk2.dim(TRAILS[i] ?? "") + gradient(row2, WORDMARK[0].length)
1798
2798
  );
1799
2799
  return ["", ...lines, `${" ".repeat(TRAILS[0].length)}${chalk2.dim(tagline)}`, ""].join("\n");
1800
2800
  }
@@ -1862,7 +2862,7 @@ function formatJson(value) {
1862
2862
  }
1863
2863
  function renderTable(rows, columns) {
1864
2864
  if (rows.length === 0) return chalk4.dim("(no results)");
1865
- 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))));
1866
2866
  const widths = columns.map(
1867
2867
  (column, i) => Math.max(column.header.length, ...cells.map((line) => line[i]?.length ?? 0))
1868
2868
  );
@@ -1946,8 +2946,8 @@ function requireClient(ctx) {
1946
2946
  }
1947
2947
 
1948
2948
  // src/sites.ts
1949
- import { z as z10 } from "zod";
1950
- var ShareResponseSchema2 = z10.looseObject({ shareUrl: z10.string() });
2949
+ import { z as z17 } from "zod";
2950
+ var ShareResponseSchema2 = z17.looseObject({ shareUrl: z17.string() });
1951
2951
  var sitePath2 = (name) => `/api/sites/${encodeURIComponent(name)}`;
1952
2952
  function whoami(client) {
1953
2953
  return client.request("GET", "/api/me", { schema: MeSchema });
@@ -1960,18 +2960,22 @@ function checkName(client, name) {
1960
2960
  function sitesBaseOf(instance) {
1961
2961
  return instance ? { protocol: `${instance.siteScheme}:`, domain: instance.sitesDomain } : null;
1962
2962
  }
1963
- function siteUrlFor(name, base) {
1964
- return siteUrl(name, base.domain, base.protocol);
2963
+ function siteUrlFor(name, base2) {
2964
+ return siteUrl(name, base2.domain, base2.protocol);
1965
2965
  }
1966
- function sitesPattern(base) {
1967
- return base ? siteUrlFor("<name>", base) : "<name>.<sites domain>";
2966
+ function sitesPattern(base2) {
2967
+ return base2 ? siteUrlFor("<name>", base2) : "<name>.<sites domain>";
1968
2968
  }
1969
2969
  async function listSites(client, opts = {}) {
1970
2970
  const res = await client.request("GET", "/api/sites", {
1971
2971
  schema: SiteListSchema,
1972
2972
  query: { archived: opts.archived ? 1 : void 0 }
1973
2973
  });
1974
- return res.sites;
2974
+ return sortSites(res.sites, opts.sort);
2975
+ }
2976
+ async function listShared(client, opts = {}) {
2977
+ const res = await client.request("GET", "/api/shared", { schema: SharedSiteListSchema });
2978
+ return sortSites(res.sites, opts.sort);
1975
2979
  }
1976
2980
  function getSite(client, name) {
1977
2981
  return client.request("GET", sitePath2(name), { schema: SiteDetailSchema });
@@ -2002,6 +3006,114 @@ async function setVisibility(client, name, visibility) {
2002
3006
  const raw = await client.request("PATCH", sitePath2(name), { body: { visibility } });
2003
3007
  return siteAfter(client, name, raw);
2004
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
+ }
3018
+ async function getAudience(client, name) {
3019
+ const site = await getSite(client, name);
3020
+ if (!site.audience) throw noAudiences();
3021
+ return site;
3022
+ }
3023
+ var noAudiences = () => new CliError("This Fling does not support limiting a site to listed people", {
3024
+ hint: "Ask its operator to update it."
3025
+ });
3026
+ async function setAudience(client, name, change) {
3027
+ const current = await getAudience(client, name);
3028
+ const audience = current.audience;
3029
+ const site = await client.request("PATCH", sitePath2(name), {
3030
+ schema: SiteWithAudienceSchema,
3031
+ body: { audience: editAudienceInput(audience, change) }
3032
+ });
3033
+ if (!site.audience) throw noAudiences();
3034
+ return site;
3035
+ }
3036
+ function audienceWarnings(site) {
3037
+ const n = site.audience ? site.audience.emails.length + site.audience.groups.length : site.audienceSize;
3038
+ if (site.visibility === "public" && n > 0) {
3039
+ return [
3040
+ `${site.name} is public, so its audience doesn't apply yet; it does once the site is private: fling visibility ${site.name} private`
3041
+ ];
3042
+ }
3043
+ if (site.restricted && site.shareUrl) {
3044
+ return [`${site.name} has a share link: anyone with it can still open the site`];
3045
+ }
3046
+ return [];
3047
+ }
3048
+ function audiencesEnabled(instance) {
3049
+ if (!instance) return false;
3050
+ return instance.capabilities.audiences ?? true;
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
+ }
3083
+ var groupPath = (slug) => `/api/groups/${encodeURIComponent(slug)}`;
3084
+ async function listGroups(client, opts = {}) {
3085
+ const res = await client.request("GET", "/api/groups", {
3086
+ schema: GroupListSchema,
3087
+ query: {
3088
+ mine: opts.mine ? 1 : void 0,
3089
+ members: opts.members ? splitEmails(opts.members).join(",") : void 0
3090
+ }
3091
+ });
3092
+ return res.groups;
3093
+ }
3094
+ function getGroup(client, slug) {
3095
+ return client.request("GET", groupPath(slug), { schema: GroupDetailSchema });
3096
+ }
3097
+ function createGroup(client, input) {
3098
+ return client.request("POST", "/api/groups", { schema: GroupDetailSchema, body: input });
3099
+ }
3100
+ function changeGroupMembers(client, slug, change) {
3101
+ return client.request("POST", `${groupPath(slug)}/members`, {
3102
+ schema: GroupDetailSchema,
3103
+ body: change
3104
+ });
3105
+ }
3106
+ function renameGroup(client, slug, name) {
3107
+ return client.request("PATCH", groupPath(slug), { schema: GroupDetailSchema, body: { name } });
3108
+ }
3109
+ async function deleteGroup(client, slug) {
3110
+ await client.request("DELETE", groupPath(slug));
3111
+ return { slug, deleted: true };
3112
+ }
3113
+ function groupsEnabled(instance) {
3114
+ if (!instance) return false;
3115
+ return instance.capabilities.groups ?? true;
3116
+ }
2005
3117
  async function archiveSite(client, name, opts = {}) {
2006
3118
  const raw = await client.request(
2007
3119
  "POST",
@@ -2054,6 +3166,42 @@ function commentsEnabled(instance) {
2054
3166
  if (!instance) return false;
2055
3167
  return instance.capabilities.comments ?? true;
2056
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
+ }
2057
3205
 
2058
3206
  // src/commands/auth.ts
2059
3207
  async function loginServer(ctx, url) {
@@ -2267,8 +3415,8 @@ async function runUncomment(ctx, name, id, opts) {
2267
3415
  }
2268
3416
 
2269
3417
  // src/commands/deploy.ts
2270
- import { stat as stat4 } from "node:fs/promises";
2271
- 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";
2272
3420
  import chalk7 from "chalk";
2273
3421
 
2274
3422
  // src/deploy.ts
@@ -2289,7 +3437,7 @@ import { join as join2, relative, sep } from "node:path";
2289
3437
  import ignore from "ignore";
2290
3438
  var GIT_TIMEOUT_MS = 2e4;
2291
3439
  function git(cwd, args, input) {
2292
- return new Promise((resolve5) => {
3440
+ return new Promise((resolve6) => {
2293
3441
  try {
2294
3442
  const child = execFile(
2295
3443
  "git",
@@ -2297,14 +3445,14 @@ function git(cwd, args, input) {
2297
3445
  { timeout: GIT_TIMEOUT_MS, maxBuffer: 256 * 1024 * 1024, encoding: "utf8" },
2298
3446
  (error, stdout) => {
2299
3447
  const code = error?.code;
2300
- if (error && !(args[0] === "check-ignore" && code === 1)) resolve5(null);
2301
- else resolve5(stdout);
3448
+ if (error && !(args[0] === "check-ignore" && code === 1)) resolve6(null);
3449
+ else resolve6(stdout);
2302
3450
  }
2303
3451
  );
2304
- child.on("error", () => resolve5(null));
3452
+ child.on("error", () => resolve6(null));
2305
3453
  if (input !== void 0) child.stdin?.end(input);
2306
3454
  } catch {
2307
- resolve5(null);
3455
+ resolve6(null);
2308
3456
  }
2309
3457
  });
2310
3458
  }
@@ -2345,9 +3493,9 @@ function nestedMode() {
2345
3493
  return {
2346
3494
  mode: "nested",
2347
3495
  ignores(rel, isDir) {
2348
- for (const { base, ig } of levels) {
2349
- if (base && !rel.startsWith(`${base}/`)) continue;
2350
- 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;
2351
3499
  if (sub && ig.ignores(isDir ? `${sub}/` : sub)) return true;
2352
3500
  }
2353
3501
  return false;
@@ -2749,6 +3897,20 @@ async function nameTakenError(client, name, body) {
2749
3897
  }
2750
3898
  return new NameTakenError(name, suggestions);
2751
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
+ };
2752
3914
  var UPLOAD_CONCURRENCY = 8;
2753
3915
  var UPLOAD_RETRIES = 3;
2754
3916
  function resolveSiteName(dir, name) {
@@ -2769,39 +3931,62 @@ function resolveSiteName(dir, name) {
2769
3931
  return derived;
2770
3932
  }
2771
3933
  var defaultSleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
3934
+ var baseChanged = (error) => error instanceof CliApiError && error.status === 409 && error.code === "base_changed";
2772
3935
  async function deploy(deps, opts) {
2773
3936
  const name = resolveSiteName(opts.dir, opts.name);
2774
3937
  const local = opts.manifest ?? await buildManifest(opts.dir);
2775
- 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 = {
2776
3951
  name,
2777
3952
  fileCount: local.files.length,
2778
3953
  totalBytes: local.totalBytes,
2779
- warnings: local.warnings
3954
+ warnings: local.warnings,
3955
+ ...folder === void 0 ? {} : { path: folder }
2780
3956
  };
2781
3957
  const path3 = `/api/sites/${encodeURIComponent(name)}/deploys`;
3958
+ const manifest = toManifest(local);
2782
3959
  const res = await deps.client.request("POST", path3, {
2783
3960
  schema: DeployResponseSchema,
2784
3961
  body: {
2785
- 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
+ },
2786
3968
  ...opts.spa !== void 0 ? { spa: opts.spa } : {},
2787
- ...opts.public ? { visibility: "public" } : {}
3969
+ ...opts.public ? { visibility: "public" } : {},
3970
+ ...opts.access ? { access: opts.access } : {},
3971
+ ...opts.audience ? { audience: opts.audience } : {}
2788
3972
  }
2789
3973
  }).catch(async (error) => {
2790
3974
  if (error instanceof CliApiError && error.status === 409 && error.code === "name_taken")
2791
3975
  throw await nameTakenError(deps.client, name, error.body);
2792
- throw error;
3976
+ throw onBaseChanged(error);
2793
3977
  });
2794
3978
  if (res.status !== "upload") {
2795
3979
  return {
2796
- ...base,
3980
+ ...base2,
2797
3981
  status: res.status,
2798
3982
  url: res.site.url,
2799
3983
  versionId: res.versionId,
2800
3984
  site: res.site,
2801
- uploaded: 0
3985
+ uploaded: 0,
3986
+ first: false
2802
3987
  };
2803
3988
  }
2804
- const byPath = new Map(local.files.map((f) => [f.path, f]));
3989
+ const byPath = new Map(local.files.map((f) => [sitePath3(f.path), f]));
2805
3990
  const uploads = res.uploads.map((u) => {
2806
3991
  const file = byPath.get(u.path);
2807
3992
  if (!file) throw new CliError(`Server asked for a file that is not in the manifest: ${u.path}`);
@@ -2817,18 +4002,19 @@ async function deploy(deps, opts) {
2817
4002
  bytes += u.file.size;
2818
4003
  opts.onProgress?.({ uploaded: done, total: uploads.length, bytes, totalBytes });
2819
4004
  });
2820
- const fin = await deps.client.request(
2821
- "POST",
2822
- `${path3}/${encodeURIComponent(res.versionId)}/finalize`,
2823
- { schema: FinalizeResponseSchema }
2824
- );
4005
+ const fin = await deps.client.request("POST", `${path3}/${encodeURIComponent(res.versionId)}/finalize`, {
4006
+ schema: FinalizeResponseSchema
4007
+ }).catch((error) => {
4008
+ throw onBaseChanged(error);
4009
+ });
2825
4010
  return {
2826
- ...base,
4011
+ ...base2,
2827
4012
  status: "live",
2828
4013
  url: fin.site.url,
2829
4014
  versionId: fin.versionId,
2830
4015
  site: fin.site,
2831
- uploaded: uploads.length
4016
+ uploaded: uploads.length,
4017
+ first: fin.first === true
2832
4018
  };
2833
4019
  }
2834
4020
  async function uploadWithRetry(deps, file, url, headers) {
@@ -2956,7 +4142,7 @@ function zipDotEntry(name) {
2956
4142
  async function extractZip(data, dest, skippedDotfiles = []) {
2957
4143
  const unsafe = [];
2958
4144
  let declared = 0;
2959
- let count = 0;
4145
+ let count2 = 0;
2960
4146
  let entries;
2961
4147
  try {
2962
4148
  entries = unzipSync(data, {
@@ -2971,7 +4157,7 @@ async function extractZip(data, dest, skippedDotfiles = []) {
2971
4157
  if (dot && !skippedDotfiles.includes(dot)) skippedDotfiles.push(dot);
2972
4158
  return false;
2973
4159
  }
2974
- count++;
4160
+ count2++;
2975
4161
  declared += file.originalSize;
2976
4162
  if (file.originalSize > MAX_FILE_BYTES)
2977
4163
  throw new CliError(
@@ -2981,7 +4167,7 @@ async function extractZip(data, dest, skippedDotfiles = []) {
2981
4167
  throw new CliError(
2982
4168
  `The zip expands to more than ${formatBytes(MAX_DEPLOY_BYTES)}, the limit per deploy`
2983
4169
  );
2984
- 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`);
2985
4171
  return true;
2986
4172
  }
2987
4173
  });
@@ -3052,40 +4238,307 @@ function wrapperPage(file) {
3052
4238
  `;
3053
4239
  }
3054
4240
 
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;
4262
+ try {
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
+
3055
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
+ }
3056
4433
  async function runDeploy(ctx, input, opts) {
3057
- const client = requireClient(ctx);
3058
- const ask = ctx.interactive && !opts.yes;
3059
- const instance = await loadInstance(ctx.store, ctx.config, ctx.fetch);
3060
- const base = sitesBaseOf(instance);
3061
4434
  const path3 = resolve3(input ?? ".");
3062
- if (!await stat4(path3).catch(() => null))
4435
+ const found = await stat5(path3).catch(() => null);
4436
+ if (!found)
3063
4437
  throw new CliError(`No such file or folder: ${input ?? "."}`, {
3064
4438
  hint: `Give the path to a folder, .html, .zip or other file, e.g. \`${ctx.binName} deploy ./dist\`.`
3065
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
+ }
3066
4465
  const prepared = await prepareInput(path3);
3067
4466
  try {
3068
- await deployPrepared(ctx, client, instance, base, prepared, opts, ask);
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
+ );
3069
4477
  } finally {
3070
4478
  await prepared.cleanup();
3071
4479
  }
3072
4480
  }
3073
- 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}`;
3074
4523
  let local = await buildManifest(prepared.dir);
3075
4524
  local.skipped.dotfiles.unshift(...prepared.skippedDotfiles);
3076
4525
  for (const line of describeSkipped(local.skipped)) ctx.log.info(line);
3077
- let name = opts.name !== void 0 ? resolveSiteName(prepared.dir, opts.name) : prepared.defaultName;
3078
- if (opts.name === void 0 && ask) name = await confirmSiteName(ctx, client, name, base);
3079
- 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)) {
3080
4531
  const pages = htmlPages(local).length;
3081
- const root = base ? siteUrlFor(name, base) : "the site root";
3082
- 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.`;
3083
4534
  const choice = opts.listHtml === true ? "list" : opts.listHtml === false ? "fling" : ask ? await askNoIndex(ctx, pages) : pages > 0 ? "list" : "fling";
3084
4535
  if (choice === "list") {
3085
4536
  local = await withHtmlListing(local, name);
3086
4537
  ctx.log.info(`Generated index.html listing ${pages} HTML page${pages === 1 ? "" : "s"}`);
3087
4538
  } else ctx.log.warn(warning);
3088
4539
  }
4540
+ const onBase = link && !opts.force ? onLink(link, local) : null;
4541
+ const changes = link ? onLink(link, local).changes : null;
3089
4542
  let progressShown = false;
3090
4543
  const result = await deploy(
3091
4544
  { client, fetch: ctx.fetch },
@@ -3093,8 +4546,15 @@ async function deployPrepared(ctx, client, instance, base, prepared, opts, ask)
3093
4546
  dir: prepared.dir,
3094
4547
  name,
3095
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
+ } : {},
3096
4554
  spa: opts.spa ? true : void 0,
3097
4555
  public: opts.public,
4556
+ access: opts.access,
4557
+ audience: opts.audience,
3098
4558
  onProgress: (p) => {
3099
4559
  if (p.total === 0) return;
3100
4560
  progressShown = true;
@@ -3103,49 +4563,80 @@ async function deployPrepared(ctx, client, instance, base, prepared, opts, ask)
3103
4563
  );
3104
4564
  }
3105
4565
  }
3106
- ).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(() => {
3107
4575
  if (progressShown) ctx.progress(null);
3108
4576
  });
4577
+ if (link) {
4578
+ await writeSiteLink(
4579
+ prepared.dir,
4580
+ linkAfter({ ...link, server: client.serverUrl }, result.versionId, local)
4581
+ );
4582
+ }
3109
4583
  for (const warning of result.warnings) if (warning !== NO_INDEX_WARNING) ctx.log.warn(warning);
3110
- const summary = `${result.fileCount} files, ${formatBytes(result.totalBytes)}`;
4584
+ const summary = changes ? `${describeChanges(changes)} since ${link?.versionId}` : `${result.fileCount} files, ${formatBytes(result.totalBytes)}`;
3111
4585
  if (result.status === "unchanged") ctx.log.info(`Unchanged ${chalk7.dim(`(${summary})`)}`);
3112
4586
  else if (result.status === "repointed")
3113
4587
  ctx.log.success(`Restored version ${result.versionId} ${chalk7.dim(`(${summary})`)}`);
3114
4588
  else
3115
4589
  ctx.log.success(
3116
- `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})`)}`
3117
4591
  );
4592
+ const url = folder ? new URL(`${folder}/`, result.url).href : result.url;
3118
4593
  const ready = result.status === "unchanged" ? true : (await waitWithSpinner(
3119
4594
  ctx.waitReady,
3120
- result.url,
3121
- local.files.map((f) => f.path),
4595
+ url,
4596
+ local.files.map((f) => inFolder(f.path)),
3122
4597
  { progress: ctx.progress, warn: (m) => ctx.log.warn(m) }
3123
4598
  )).ready;
3124
- const who = instance ? `only ${privateAudience(instance)} can open it` : "sign-in required";
3125
- 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}\`.`];
3126
4614
  const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
3127
4615
  const reviewNote = instance ? `Share for feedback (reviewers sign in to ${instance.name}):` : "Share for feedback:";
3128
4616
  const data = {
3129
4617
  status: result.status,
3130
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 } : {},
3131
4623
  ...review ? { reviewUrl: review } : {},
3132
4624
  ready,
3133
4625
  skipped: local.skipped
3134
4626
  };
3135
4627
  ctx.out.data(data, () => {
3136
- const isPrivate = result.site.visibility === "private";
3137
4628
  if (ctx.out.tty)
3138
4629
  return box(
3139
4630
  [
3140
4631
  ...review ? [chalk7.dim(reviewNote), brand.url(review), "", chalk7.dim("Site:")] : [],
3141
- brand.url(result.url),
3142
- ...isPrivate ? [chalk7.dim(privateNote)] : []
4632
+ brand.url(url),
4633
+ ...accessNotes.map((n) => chalk7.dim(n))
3143
4634
  ],
3144
- isPrivate ? chalk7.bold("flung \xB7 private") : chalk7.bold("flung \xB7 public")
4635
+ chalk7.bold(`flung \xB7 ${ACCESS_LABELS[level].toLowerCase()}`)
3145
4636
  );
3146
- const lines = [chalk7.bold(result.url)];
4637
+ const lines = [chalk7.bold(url)];
3147
4638
  if (review) lines.push(chalk7.dim(`${reviewNote} ${review}`));
3148
- if (isPrivate) lines.push(chalk7.dim(privateNote));
4639
+ for (const n of accessNotes) lines.push(chalk7.dim(n));
3149
4640
  return lines.join("\n");
3150
4641
  });
3151
4642
  }
@@ -3166,8 +4657,8 @@ async function askNoIndex(ctx, pages) {
3166
4657
  });
3167
4658
  }
3168
4659
  }
3169
- async function confirmSiteName(ctx, client, generated, base) {
3170
- 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)}` : "";
3171
4662
  const describe2 = (n, note) => ctx.log.info(`Site name: ${chalk7.bold(n)}${where(n)} ${chalk7.dim(`(${note})`)}`);
3172
4663
  let candidate = generated;
3173
4664
  for (; ; ) {
@@ -3183,42 +4674,253 @@ async function confirmSiteName(ctx, client, generated, base) {
3183
4674
  proposal = check2.suggestions[0];
3184
4675
  if (proposal) describe2(proposal, "new");
3185
4676
  }
3186
- for (; ; ) {
3187
- const question = proposal ? `Use "${proposal}"? [Y/n or type another name] ` : "Type a site name (or n to abort): ";
3188
- const answer = (await ctx.prompt(question)).trim();
3189
- if (proposal && (answer === "" || /^y(es)?$/i.test(answer))) return proposal;
3190
- if (/^no?$/i.test(answer))
3191
- throw new CliError("Aborted \u2014 nothing was flung", {
3192
- hint: "Pass --name <name> to choose the site name up front."
3193
- });
3194
- if (answer === "") continue;
3195
- if (!isValidSiteName(answer)) {
3196
- ctx.log.warn(
3197
- `"${answer}" is not a valid site name: use lowercase letters, digits and single hyphens (max ${SITE_NAME_MAX}); some names are reserved.`
4677
+ for (; ; ) {
4678
+ const question = proposal ? `Use "${proposal}"? [Y/n or type another name] ` : "Type a site name (or n to abort): ";
4679
+ const answer = (await ctx.prompt(question)).trim();
4680
+ if (proposal && (answer === "" || /^y(es)?$/i.test(answer))) return proposal;
4681
+ if (/^no?$/i.test(answer))
4682
+ throw new CliError("Aborted \u2014 nothing was flung", {
4683
+ hint: "Pass --name <name> to choose the site name up front."
4684
+ });
4685
+ if (answer === "") continue;
4686
+ if (!isValidSiteName(answer)) {
4687
+ ctx.log.warn(
4688
+ `"${answer}" is not a valid site name: use lowercase letters, digits and single hyphens (max ${SITE_NAME_MAX}); some names are reserved.`
4689
+ );
4690
+ continue;
4691
+ }
4692
+ candidate = answer;
4693
+ break;
4694
+ }
4695
+ }
4696
+ }
4697
+
4698
+ // src/commands/groups.ts
4699
+ import chalk8 from "chalk";
4700
+ function requireGroups(ctx) {
4701
+ const instance = ctx.config.instance;
4702
+ if (instance && !groupsEnabled(instance)) throw new CliError(`${instance.name} has no groups`);
4703
+ return requireClient(ctx);
4704
+ }
4705
+ async function runGroups(ctx, opts) {
4706
+ const client = requireGroups(ctx);
4707
+ const members2 = opts.members ? splitEmails(opts.members) : void 0;
4708
+ if (members2 && members2.length === 0) throw new CliError("Name at least one address");
4709
+ const [groups2, me] = await Promise.all([
4710
+ listGroups(client, { mine: opts.mine, members: members2 }),
4711
+ whoami(client)
4712
+ ]);
4713
+ ctx.out.data(
4714
+ { groups: groups2 },
4715
+ () => groups2.length ? renderTable(groups2, [
4716
+ { header: "SLUG", value: (g) => g.slug },
4717
+ { header: "NAME", value: (g) => g.name },
4718
+ { header: "PEOPLE", value: (g) => g.size },
4719
+ { header: "OWNER", value: (g) => g.owner === me.email ? "you" : g.owner }
4720
+ ]) : chalk8.dim(members2 ? "No group has exactly those people." : describeGroups([]))
4721
+ );
4722
+ }
4723
+ async function runGroupShow(ctx, slug) {
4724
+ const group = await getGroup(requireGroups(ctx), slug);
4725
+ ctx.out.data(group, () => describeGroup(group));
4726
+ }
4727
+ async function runGroupCreate(ctx, slug, opts) {
4728
+ const group = await createGroup(requireGroups(ctx), {
4729
+ slug,
4730
+ name: opts.name ?? slug,
4731
+ members: splitEmails(opts.add ?? [])
4732
+ });
4733
+ ctx.log.success(`Group ${group.slug} created`);
4734
+ ctx.out.data(group, () => describeGroup(group));
4735
+ }
4736
+ async function runGroupMembers(ctx, slug, emails2, how) {
4737
+ const list = splitEmails(emails2);
4738
+ if (list.length === 0) throw new CliError("Name at least one address");
4739
+ const group = await changeGroupMembers(requireGroups(ctx), slug, { [how]: list });
4740
+ ctx.out.data(group, () => describeGroup(group));
4741
+ }
4742
+ async function runGroupRename(ctx, slug, name) {
4743
+ const group = await renameGroup(requireGroups(ctx), slug, name);
4744
+ ctx.log.success(`Group ${group.slug} is now called ${group.name}`);
4745
+ ctx.out.data(group, () => describeGroup(group));
4746
+ }
4747
+ async function runGroupDelete(ctx, slug, opts) {
4748
+ const client = requireGroups(ctx);
4749
+ if (!opts.yes) {
4750
+ if (!ctx.interactive) {
4751
+ throw new CliError(`Refusing to delete group ${slug} without confirmation`, {
4752
+ hint: "Pass --yes to delete."
4753
+ });
4754
+ }
4755
+ const ok = await ctx.confirm(
4756
+ `Delete group ${slug}? Sites whose audience names it keep their restriction, and anyone it let in loses access. This cannot be undone.`
4757
+ );
4758
+ if (!ok) throw new CliError("Cancelled");
4759
+ }
4760
+ const result = await deleteGroup(client, slug);
4761
+ ctx.log.success(`Group ${slug} deleted`);
4762
+ if (ctx.json) ctx.out.data(result, () => "");
4763
+ }
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)}`
3198
4891
  );
3199
- continue;
3200
4892
  }
3201
- candidate = answer;
3202
- break;
3203
4893
  }
3204
- }
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
+ );
3205
4907
  }
3206
4908
 
3207
4909
  // src/commands/setup.ts
3208
4910
  import { intro, outro, spinner } from "@clack/prompts";
3209
- import chalk8 from "chalk";
4911
+ import chalk11 from "chalk";
3210
4912
 
3211
4913
  // src/claude.ts
3212
4914
  import { execFile as execFile2 } from "node:child_process";
3213
- 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";
3214
4916
  import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
3215
- import { join as join5 } from "node:path";
4917
+ import { join as join6 } from "node:path";
3216
4918
  var MARKETPLACE_SOURCE = REPO_SLUG;
3217
4919
  var MARKETPLACE_NAME = "fling";
3218
4920
  var PLUGIN_NAME = "fling";
3219
4921
  var PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
3220
4922
  var MCPB_PATH = "/downloads/fling.mcpb";
3221
- var runClaude = (args) => new Promise((resolve5) => {
4923
+ var runClaude = (args) => new Promise((resolve6) => {
3222
4924
  execFile2(
3223
4925
  "claude",
3224
4926
  [...args],
@@ -3226,7 +4928,7 @@ var runClaude = (args) => new Promise((resolve5) => {
3226
4928
  (error, stdout, stderr) => {
3227
4929
  const missing = error?.code === "ENOENT";
3228
4930
  const code = !error ? 0 : missing ? -1 : typeof error.code === "number" ? error.code : 1;
3229
- resolve5({ code, stdout: String(stdout), stderr: String(stderr) });
4931
+ resolve6({ code, stdout: String(stdout), stderr: String(stderr) });
3230
4932
  }
3231
4933
  );
3232
4934
  });
@@ -3272,7 +4974,7 @@ async function findClaudeDesktop(platform = process.platform, env = process.env,
3272
4974
  () => true,
3273
4975
  () => false
3274
4976
  )) {
3275
- 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")] : [];
3276
4978
  for (const path3 of candidates) if (await exists(path3)) return path3;
3277
4979
  return null;
3278
4980
  }
@@ -3280,9 +4982,9 @@ async function downloadExtension(serverUrl, fetch2) {
3280
4982
  const url = new URL(MCPB_PATH, serverUrl).toString();
3281
4983
  const response = await fetch2(url);
3282
4984
  if (!response.ok) throw new Error(`Could not download ${url} (HTTP ${response.status})`);
3283
- const dir = await mkdtemp2(join5(tmpdir2(), "fling-mcpb-"));
3284
- const file = join5(dir, "fling.mcpb");
3285
- 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()));
3286
4988
  return file;
3287
4989
  }
3288
4990
 
@@ -3299,14 +5001,14 @@ async function runSetup(baseCtx, opts, deps = {}) {
3299
5001
  ...baseCtx,
3300
5002
  log: createLogger({
3301
5003
  debug: baseCtx.config.debug,
3302
- write: (line) => process.stderr.write(`${chalk8.gray("\u2502")} ${line}
5004
+ write: (line) => process.stderr.write(`${chalk11.gray("\u2502")} ${line}
3303
5005
  `)
3304
5006
  })
3305
5007
  } : baseCtx;
3306
5008
  if (fancy) {
3307
5009
  process.stderr.write(`${logo()}
3308
5010
  `);
3309
- intro(chalk8.bold(" fling setup "), { output: process.stderr });
5011
+ intro(chalk11.bold(" fling setup "), { output: process.stderr });
3310
5012
  }
3311
5013
  const login = await stepLogin(ctx, opts.url);
3312
5014
  const results = {
@@ -3320,7 +5022,7 @@ async function runSetup(baseCtx, opts, deps = {}) {
3320
5022
  const failed = Object.values(results).includes("failed");
3321
5023
  if (fancy)
3322
5024
  outro(
3323
- failed ? chalk8.yellow("Setup finished with problems \u2014 see above, then run `fling setup` again.") : `You're set. Try ${chalk8.bold(`${ctx.binName} deploy ./dist`)}, or ask Claude to \u201Cfling this\u201D.`,
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.`,
3324
5026
  { output: process.stderr }
3325
5027
  );
3326
5028
  ctx.out.data(results, () => "");
@@ -3343,7 +5045,7 @@ async function signIn(ctx, serverUrl) {
3343
5045
  try {
3344
5046
  const me = await whoami(requireClient(ctx));
3345
5047
  if (ctx.config.remembered) await ctx.store.setCurrent(serverUrl);
3346
- ctx.log.success(`Signed in as ${chalk8.bold(me.name)} ${chalk8.dim(`<${me.email}>`)}`);
5048
+ ctx.log.success(`Signed in as ${chalk11.bold(me.name)} ${chalk11.dim(`<${me.email}>`)}`);
3347
5049
  return "already";
3348
5050
  } catch (error) {
3349
5051
  if (exitCodeFor(error) !== 2) {
@@ -3364,7 +5066,7 @@ async function signIn(ctx, serverUrl) {
3364
5066
  async function stepClaudeCode(ctx, run, scope, ask, fancy) {
3365
5067
  const status = await claudeCodeStatus(run);
3366
5068
  if (status.claude === "missing") {
3367
- ctx.log.info(`Claude Code not found ${chalk8.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")}`);
3368
5070
  ctx.log.hint(
3369
5071
  `Once it is installed, run \`${ctx.binName} setup\` again, or in Claude Code: /plugin marketplace add ${MARKETPLACE_SOURCE} then /plugin install ${PLUGIN_ID}`
3370
5072
  );
@@ -3373,7 +5075,7 @@ async function stepClaudeCode(ctx, run, scope, ask, fancy) {
3373
5075
  const installed = "installed" in status && status.installed;
3374
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?";
3375
5077
  if (ask && !await ctx.confirm(question)) {
3376
- ctx.log.info(chalk8.dim("Claude Code plugin: skipped"));
5078
+ ctx.log.info(chalk11.dim("Claude Code plugin: skipped"));
3377
5079
  return installed ? "already" : "skipped";
3378
5080
  }
3379
5081
  const s = fancy ? spinner({ output: process.stderr }) : null;
@@ -3387,7 +5089,7 @@ async function stepClaudeCode(ctx, run, scope, ask, fancy) {
3387
5089
  else ctx.log.error(message);
3388
5090
  return "failed";
3389
5091
  }
3390
- const done = installed ? `Claude Code plugin ${PLUGIN_NAME} is up to date` : `Claude Code plugin ${PLUGIN_NAME} installed ${chalk8.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)`)}`;
3391
5093
  if (s) s.stop(done);
3392
5094
  else ctx.log.success(done);
3393
5095
  ctx.log.hint("Restart Claude Code (or run /reload-plugins) to pick it up.");
@@ -3397,7 +5099,7 @@ async function stepClaudeDesktop(ctx, deps, ask, fancy) {
3397
5099
  const app = await (deps.findClaudeDesktop ?? findClaudeDesktop)();
3398
5100
  if (!app) return "missing";
3399
5101
  if (ask && !await ctx.confirm("Add the Fling extension to Claude Desktop too?")) {
3400
- ctx.log.info(chalk8.dim("Claude Desktop extension: skipped"));
5102
+ ctx.log.info(chalk11.dim("Claude Desktop extension: skipped"));
3401
5103
  return "skipped";
3402
5104
  }
3403
5105
  const s = fancy ? spinner({ output: process.stderr }) : null;
@@ -3418,7 +5120,7 @@ async function stepClaudeDesktop(ctx, deps, ask, fancy) {
3418
5120
  }
3419
5121
 
3420
5122
  // src/commands/sites.ts
3421
- import chalk9 from "chalk";
5123
+ import chalk12 from "chalk";
3422
5124
  async function runList(ctx, opts) {
3423
5125
  const sites = await listSites(requireClient(ctx), opts);
3424
5126
  ctx.out.data(
@@ -3427,42 +5129,76 @@ async function runList(ctx, opts) {
3427
5129
  { header: "NAME", value: (s) => s.name },
3428
5130
  { header: "URL", value: (s) => s.url },
3429
5131
  {
3430
- header: "VISIBILITY",
3431
- value: (s) => s.archived ? `${s.visibility} (archived)` : s.visibility
5132
+ header: "ACCESS",
5133
+ value: (s) => accessLabel(s) + (s.archived ? " (archived)" : "")
3432
5134
  },
5135
+ ...sites.some((s) => s.role) ? [{ header: "ROLE", value: (s) => s.role ?? "-" }] : [],
3433
5136
  { header: "SHARE", value: (s) => s.shareUrl },
3434
5137
  { header: "UPDATED", value: (s) => formatDate(s.updatedAt) }
3435
5138
  ])
3436
5139
  );
3437
5140
  }
5141
+ async function runListShared(ctx, opts) {
5142
+ const instance = ctx.config.instance;
5143
+ if (instance && !audiencesEnabled(instance)) {
5144
+ throw new CliError(`${instance.name} can't limit a site to listed people, so nothing is shared`);
5145
+ }
5146
+ const sites = await listShared(requireClient(ctx), opts);
5147
+ ctx.out.data(
5148
+ { sites },
5149
+ () => renderTable(sites, [
5150
+ { header: "NAME", value: (s) => s.name },
5151
+ { header: "URL", value: (s) => s.url },
5152
+ { header: "OWNER", value: (s) => s.owner },
5153
+ { header: "VIA", value: (s) => sharedVia(s) },
5154
+ { header: "UPDATED", value: (s) => formatDate(s.updatedAt) }
5155
+ ])
5156
+ );
5157
+ }
5158
+ var accessLabel = (site) => ACCESS_LABELS[siteAccessLevel(site)];
5159
+ var row = (label, value) => `${chalk12.bold(`${label}:`.padEnd(14))}${value}`;
3438
5160
  function describeSite(site) {
3439
- const row = (label, value) => `${chalk9.bold(`${label}:`.padEnd(12))}${value}`;
3440
5161
  return [
3441
5162
  row("Name", site.name),
3442
5163
  row("URL", site.url),
3443
- row("Visibility", site.visibility + (site.archived ? chalk9.yellow(" (archived)") : "")),
5164
+ row("Access", accessLabel(site) + (site.archived ? chalk12.yellow(" (archived)") : "")),
3444
5165
  row("SPA", site.spa ? "yes" : "no"),
3445
5166
  row("Share link", site.shareUrl ?? "-"),
3446
5167
  row("Version", site.currentVersionId ?? "-"),
3447
- ...filesRow(site, row),
5168
+ ...filesRow(site),
3448
5169
  row("Created", formatDate(site.createdAt)),
3449
5170
  row("Updated", formatDate(site.updatedAt))
3450
5171
  ];
3451
5172
  }
3452
- 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) {
3453
5181
  const current = site.versions?.find((v) => v.id === site.currentVersionId);
3454
5182
  if (!current) return [];
3455
5183
  const n = current.fileCount;
3456
- 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
+ ];
3457
5189
  }
5190
+ var membersOf = (ctx) => ctx.config.instance ? privateAudience(ctx.config.instance) : "anyone signed in to this Fling";
3458
5191
  async function runInfo(ctx, name) {
3459
5192
  const site = await getSite(requireClient(ctx), name);
5193
+ const access2 = site.audience && audiencesEnabled(ctx.config.instance) ? [`${chalk12.bold("Who:".padEnd(14))}${describeAudience(site, membersOf(ctx))}`] : [];
3460
5194
  ctx.out.data(
3461
5195
  site,
3462
5196
  () => [
3463
5197
  ...describeSite(site),
5198
+ ...access2,
5199
+ ...rolesRows(site),
3464
5200
  "",
3465
- chalk9.dim(
5201
+ chalk12.dim(
3466
5202
  `${site.versions.length} version(s) \u2014 \`fling versions ${name}\`; files \u2014 \`fling files ${name}\``
3467
5203
  )
3468
5204
  ].join("\n")
@@ -3477,6 +5213,8 @@ async function runVersions(ctx, name) {
3477
5213
  { header: "VERSION", value: (v) => v.id },
3478
5214
  { header: "FILES", value: (v) => v.fileCount },
3479
5215
  { header: "SIZE", value: (v) => formatBytes(v.totalBytes) },
5216
+ { header: "BY", value: (v) => v.publishedBy ?? "-" },
5217
+ { header: "PATH", value: (v) => v.path ? `/${v.path}/` : "-" },
3480
5218
  { header: "CREATED", value: (v) => formatDate(v.createdAt) }
3481
5219
  ])
3482
5220
  );
@@ -3488,7 +5226,7 @@ async function runFiles(ctx, name, versionId) {
3488
5226
  }
3489
5227
  ctx.out.data(result, () => {
3490
5228
  const n = result.files.length;
3491
- const header = chalk9.dim(
5229
+ const header = chalk12.dim(
3492
5230
  `${result.versionId}${result.current ? " (current)" : " (not served)"} \u2014 ${n} file${n === 1 ? "" : "s"}, ${formatBytes(result.totalBytes)}${result.generatedIndex ? ", generated index.html" : ""}`
3493
5231
  );
3494
5232
  const table = renderTable(result.files, [
@@ -3524,7 +5262,7 @@ async function runShare(ctx, name, opts) {
3524
5262
  ctx.log.success("Share link created \u2014 anyone with it can view the site");
3525
5263
  ctx.out.data(
3526
5264
  ready === void 0 ? result : { ...result, ready },
3527
- () => result.shareUrl ? result.shareUrl : chalk9.dim("(no share link)")
5265
+ () => result.shareUrl ? result.shareUrl : chalk12.dim("(no share link)")
3528
5266
  );
3529
5267
  }
3530
5268
  async function runVisibility(ctx, name, visibility) {
@@ -3537,6 +5275,81 @@ async function runVisibility(ctx, name, visibility) {
3537
5275
  );
3538
5276
  ctx.out.data(site, () => site.url);
3539
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
+ }
5287
+ async function runAccess(ctx, name, opts) {
5288
+ const level = levelOf(opts);
5289
+ if (level) return runSetLevel(ctx, name, level, opts);
5290
+ const instance = ctx.config.instance;
5291
+ if (instance && !audiencesEnabled(instance)) {
5292
+ throw new CliError(`${instance.name} can't limit a site to listed people`);
5293
+ }
5294
+ const change = {
5295
+ add: splitEmails(opts.add ?? []),
5296
+ remove: splitEmails(opts.remove ?? []),
5297
+ addGroups: splitGroups(opts.group ?? []),
5298
+ removeGroups: splitGroups(opts.removeGroup ?? []),
5299
+ clear: opts.clear
5300
+ };
5301
+ if (instance && (change.addGroups.length || change.removeGroups.length)) {
5302
+ if (!groupsEnabled(instance)) throw new CliError(`${instance.name} has no groups`);
5303
+ }
5304
+ const client = requireClient(ctx);
5305
+ const site = changesAudience(change) ? await setAudience(client, name, change) : await getAudience(client, name);
5306
+ const warnings = audienceWarnings(site);
5307
+ for (const w of warnings) ctx.log.warn(w);
5308
+ ctx.out.data({ ...site, warnings }, () => describeAudience(site, membersOf(ctx)));
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
+ }
3540
5353
  async function runArchive(ctx, name, unarchive = false) {
3541
5354
  const site = await archiveSite(requireClient(ctx), name, { unarchive });
3542
5355
  ctx.log.success(
@@ -3561,15 +5374,15 @@ async function runRemove(ctx, name, opts) {
3561
5374
  }
3562
5375
 
3563
5376
  // src/mcp.ts
3564
- 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";
3565
5378
  import { tmpdir as tmpdir3 } from "node:os";
3566
- 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";
3567
5380
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3568
5381
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3569
- import { z as z11 } from "zod";
5382
+ import { z as z19 } from "zod";
3570
5383
 
3571
5384
  // src/locate.ts
3572
- 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";
3573
5386
  import { posix, win32 } from "node:path";
3574
5387
  import { fileURLToPath } from "node:url";
3575
5388
  var DOWNLOADS_DIR_ENV = "FLING_DOWNLOADS_DIR";
@@ -3582,7 +5395,7 @@ var SANDBOX_PATH_RE = /^\/(mnt\/user-data|mnt\/data|sessions|home\/claude|tmp\/o
3582
5395
  var nodeFs = {
3583
5396
  async stat(path3) {
3584
5397
  try {
3585
- const s = await stat5(path3);
5398
+ const s = await stat6(path3);
3586
5399
  return { isFile: s.isFile(), isDirectory: s.isDirectory(), mtimeMs: s.mtimeMs, size: s.size };
3587
5400
  } catch {
3588
5401
  return null;
@@ -3590,11 +5403,11 @@ var nodeFs = {
3590
5403
  },
3591
5404
  async readdir(path3) {
3592
5405
  try {
3593
- const entries = await readdir2(path3, { withFileTypes: true });
5406
+ const entries = await readdir3(path3, { withFileTypes: true });
3594
5407
  const out = [];
3595
5408
  for (const e of entries) {
3596
5409
  if (e.isSymbolicLink()) {
3597
- const s = await stat5(`${path3}/${e.name}`).catch(() => null);
5410
+ const s = await stat6(`${path3}/${e.name}`).catch(() => null);
3598
5411
  out.push({ name: e.name, isFile: Boolean(s?.isFile()), isDirectory: false });
3599
5412
  } else out.push({ name: e.name, isFile: e.isFile(), isDirectory: e.isDirectory() });
3600
5413
  }
@@ -3609,7 +5422,7 @@ var nodeFs = {
3609
5422
  async readText(path3) {
3610
5423
  try {
3611
5424
  if (!(await lstat(path3)).isFile()) return null;
3612
- return await readFile6(path3, "utf8");
5425
+ return await readFile7(path3, "utf8");
3613
5426
  } catch {
3614
5427
  return null;
3615
5428
  }
@@ -3984,12 +5797,14 @@ var mcpLocalInstructions = (w, opts = {}) => [
3984
5797
  ...[serversInstructions(opts.servers ?? [])].filter((p) => p !== void 0),
3985
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.",
3986
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.)",
3987
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.",
3988
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.",
3989
5803
  NEVER_PUBLISHED,
3990
5804
  "Not logged in? Call fling_login, let the user finish signing in in the browser, then retry.",
3991
- `Sites are private (only ${w.privateAudience}) by default; only make one public if asked. Use fling_share for a link people who cannot sign in can open. ${opts.comments ? "Always give the user the links that come back: the review link first, as the one to share for feedback, then the site URL." : "Always give the user the URL that comes back."}`,
3992
- ...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)] : []
3993
5808
  ].join("\n\n");
3994
5809
  var onFling = (url, instance) => `
3995
5810
  Fling: ${serverLabel(url)}${instance ? ` (${instance.name})` : ""}`;
@@ -4003,7 +5818,7 @@ function createMcpServer(deps = {}) {
4003
5818
  const several = servers.length > 1;
4004
5819
  const wording = mcpWording(several ? null : servers[0]?.instance);
4005
5820
  const mcpTools2 = mcpToolsFor(wording);
4006
- const serverArg = z11.string().min(1).max(500).optional().describe(serverArgDescription(servers));
5821
+ const serverArg = z19.string().min(1).max(500).optional().describe(serverArgDescription(servers));
4007
5822
  const note = serverToolNote(servers);
4008
5823
  const serverTool = (def, description = def.description) => ({
4009
5824
  title: def.title,
@@ -4046,6 +5861,58 @@ Not reachable yet at ${url}: ${NOT_READY_NOTE}.`
4046
5861
  }
4047
5862
  };
4048
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
+ }
4049
5916
  async function run(tool2, server2, op) {
4050
5917
  try {
4051
5918
  const { summary, data } = await op(await target(server2, tool2));
@@ -4057,18 +5924,29 @@ ${JSON.stringify(data, null, 2)}`);
4057
5924
  }
4058
5925
  }
4059
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}/`;
4060
5937
  let local = await buildManifest(dir);
4061
5938
  local.skipped.dotfiles.unshift(...opts.skippedDotfiles ?? []);
4062
5939
  const skippedNote = describeSkipped(local.skipped).map((l) => `
4063
5940
  ${l}.`).join("");
4064
5941
  let generatedIndex = false;
4065
- if (!hasRootIndex(local)) {
5942
+ const asItIs = link !== null || opts.base !== void 0;
5943
+ if (!asItIs && !hasRootIndex(local)) {
4066
5944
  const pages = htmlPages(local).map((f) => f.path);
4067
5945
  if (pages.length > 0 && opts.listHtml === void 0) {
4068
5946
  const shown = pages.slice(0, 20);
4069
5947
  return {
4070
5948
  kind: "needs_decision",
4071
- 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\`.`,
4072
5950
  data: {
4073
5951
  status: "needs_decision",
4074
5952
  htmlPages: shown,
@@ -4082,10 +5960,31 @@ ${l}.`).join("");
4082
5960
  generatedIndex = true;
4083
5961
  }
4084
5962
  }
5963
+ const onBase = link && !opts.force ? onLink(link, local) : null;
5964
+ const changes = link ? onLink(link, local).changes : null;
4085
5965
  const result = await deploy(
4086
5966
  { client: t2.client, fetch: fetchImpl },
4087
- { 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
+ }
4088
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
+ }
4089
5988
  if (!(error instanceof NameTakenError)) throw error;
4090
5989
  const how = opts.nameGiven ? "" : ` (generated from ${opts.nameFrom ?? "the folder"})`;
4091
5990
  const pick = error.suggestions.length ? ` Free alternatives: ${error.suggestions.join(", ")}.` : "";
@@ -4093,23 +5992,30 @@ ${l}.`).join("");
4093
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\`.`
4094
5993
  );
4095
5994
  });
4096
- 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})`;
4097
5997
  const warnings = result.warnings.length ? `
4098
5998
  Warnings:
4099
5999
  - ${result.warnings.join("\n- ")}` : "";
4100
6000
  const edge = result.status === "unchanged" ? { ready: true, note: "" } : await edgeReady(
4101
6001
  result.url,
4102
- local.files.map((f) => f.path)
6002
+ local.files.map((f) => inFolder(f.path))
4103
6003
  );
6004
+ const url = shownFolder === void 0 ? result.url : new URL(`${shownFolder}/`, result.url).href;
4104
6005
  const instance = await t2.instance();
4105
6006
  const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
4106
6007
  const listing2 = generatedIndex ? " \u2014 with a generated index.html listing the HTML pages" : "";
6008
+ const level = result.site.access ?? result.site.visibility;
4107
6009
  const head = review ? `${what}${listing2}.
4108
6010
  ${shareForFeedback(review, instance?.name ?? "Fling")}
4109
- 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 })}` : "";
4110
6016
  return {
4111
6017
  kind: "deployed",
4112
- summary: `${head}${warnings}${skippedNote}${edge.note}${onFling(t2.url, instance)}
6018
+ summary: `${head}${access3}${warnings}${skippedNote}${edge.note}${onFling(t2.url, instance)}
4113
6019
  To check exactly which files the site has, call fling_files with name "${result.name}".`,
4114
6020
  data: {
4115
6021
  status: result.status,
@@ -4119,6 +6025,9 @@ To check exactly which files the site has, call fling_files with name "${result.
4119
6025
  nameGenerated: !opts.nameGiven,
4120
6026
  versionId: result.versionId,
4121
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 } : {},
4122
6031
  ...review ? { reviewUrl: review } : {},
4123
6032
  generatedIndex,
4124
6033
  warnings: result.warnings,
@@ -4131,42 +6040,65 @@ To check exactly which files the site has, call fling_files with name "${result.
4131
6040
  {
4132
6041
  instructions: mcpLocalInstructions(wording, {
4133
6042
  comments: servers.some((s) => commentsEnabled(s.instance)),
6043
+ notifications: servers.some((s) => notificationsEnabled(s.instance)),
4134
6044
  servers
4135
6045
  })
4136
6046
  }
4137
6047
  );
4138
- 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;
4139
6049
  server.registerTool(
4140
6050
  "fling_deploy",
4141
6051
  {
4142
6052
  title: "Deploy a file or folder from this computer",
4143
- description: `Fling something that is on the user's computer and return its stable URL (${wording.sitesPattern}): a folder of static files (e.g. a build output like dist/), a single .html file (served as the site's index.html), a .zip of a site (extracted; a single top-level folder is unwrapped), or any other single file (.pdf, .svg, .png\u2026, served next to a generated page that shows it). \`path\` can be absolute, relative to the working directory, or just a file name ("Pitch Deck.html"): names are looked up, newest first, in Downloads, Desktop, Documents/Claude and Claude Desktop output folders, and the result says where the file was found. Never pass paths from your own sandbox (/mnt/user-data/\u2026, /sessions/\u2026): for content you created, use fling_publish. Without \`name\`, a name is generated: from the file name for files ("Pitch Deck (1).html" \u2192 pitch-deck), from the project and folder for folders (~/work/rocketpod/docs \u2192 rocketpod-docs). Site names are one global namespace, first come first served \u2014 confirm the name with the user first (fling_check_name with \`path\` shows the generated one). If the name is taken it fails with free suggestions \u2014 ask the user to pick one, then retry with \`name\`. If there is no index.html but there are HTML pages, it does not deploy: ask the user whether to generate an index page listing them (\`listHtml: true\`) or deploy without one (\`listHtml: false\`), then retry. Re-deploying identical content is a no-op. Sites are private (only ${wording.privateAudience}) unless \`public\` is set. ${NEVER_PUBLISHED}${note}`,
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}`,
4144
6054
  inputSchema: {
4145
- path: z11.string().min(1).describe(
6055
+ path: z19.string().min(1).describe(
4146
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)"
4147
6057
  ),
4148
- name: z11.string().optional().describe(
6058
+ name: z19.string().optional().describe(
4149
6059
  "Site name (lowercase letters, digits, single hyphens; max 50). Defaults to a name generated from the file or folder"
4150
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
+ ),
4151
6064
  spa: spa2,
6065
+ access: access2,
6066
+ emails: emails2,
6067
+ groups: groups2,
4152
6068
  public: isPublic,
4153
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
+ ),
4154
6073
  server: serverArg
4155
6074
  },
4156
6075
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true }
4157
6076
  },
4158
- (args) => run("fling_deploy", args.server, async (t2) => {
6077
+ async (args) => run("fling_deploy", args.server ?? await linkedServer(args.path), async (t2) => {
4159
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
+ }
4160
6084
  const prepared = await prepareInput(located.path);
4161
6085
  try {
4162
- 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;
4163
6089
  const outcome = await deployFolder(t2, prepared.dir, {
6090
+ link,
6091
+ force: args.force,
4164
6092
  name,
4165
- nameGiven: args.name !== void 0,
6093
+ nameGiven: args.name !== void 0 || link !== null,
4166
6094
  nameFrom: prepared.kind === "folder" ? "the folder" : "the file name",
4167
6095
  spa: args.spa,
4168
6096
  public: args.public,
6097
+ access: args.access,
6098
+ emails: args.emails,
6099
+ groups: args.groups,
4169
6100
  listHtml: args.listHtml,
6101
+ folder: args.sitePath,
4170
6102
  retryWith: "fling_deploy",
4171
6103
  skippedDotfiles: prepared.skippedDotfiles
4172
6104
  });
@@ -4189,15 +6121,97 @@ To check exactly which files the site has, call fling_files with name "${result.
4189
6121
  }
4190
6122
  })
4191
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
+ );
4192
6206
  server.registerTool(
4193
6207
  "fling_find_files",
4194
6208
  {
4195
6209
  title: "Find files to fling",
4196
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.",
4197
6211
  inputSchema: {
4198
- query: z11.string().optional().describe('Only names containing this text (case-insensitive), e.g. "deck"'),
4199
- limit: z11.number().int().min(1).max(100).optional().describe("Max results (default 20)"),
4200
- days: z11.number().int().min(1).max(3650).optional().describe("Only things modified in the last N days (default 14)")
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)")
4201
6215
  },
4202
6216
  annotations: { readOnlyHint: true, openWorldHint: false }
4203
6217
  },
@@ -4235,7 +6249,12 @@ ${JSON.stringify(data, null, 2)}`);
4235
6249
  ),
4236
6250
  (args) => run(publishDef.name, args.server, async (t2) => {
4237
6251
  const name = resolveSiteName(".", args.name);
4238
- 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-"));
4239
6258
  try {
4240
6259
  const { skippedDotfiles } = await writeInlineFiles(tmp, args.files);
4241
6260
  return await deployFolder(t2, tmp, {
@@ -4244,11 +6263,16 @@ ${JSON.stringify(data, null, 2)}`);
4244
6263
  nameGiven: true,
4245
6264
  spa: args.spa,
4246
6265
  public: args.public,
6266
+ access: args.access,
6267
+ emails: args.emails,
6268
+ groups: args.groups,
4247
6269
  listHtml: args.listHtml,
6270
+ folder: args.path,
6271
+ ...args.base === void 0 ? {} : { base: args.base, remove: args.remove },
4248
6272
  retryWith: "fling_publish"
4249
6273
  });
4250
6274
  } finally {
4251
- await rm2(tmp, { recursive: true, force: true });
6275
+ await rm3(tmp, { recursive: true, force: true });
4252
6276
  }
4253
6277
  })
4254
6278
  );
@@ -4258,8 +6282,8 @@ ${JSON.stringify(data, null, 2)}`);
4258
6282
  ...serverTool(mcpTools2.checkName),
4259
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}`,
4260
6284
  inputSchema: {
4261
- name: z11.string().min(1).max(200).optional().describe("Site name to check"),
4262
- path: z11.string().optional().describe("File or folder whose generated default name to check (path or file name)"),
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)"),
4263
6287
  server: serverArg
4264
6288
  }
4265
6289
  },
@@ -4273,8 +6297,8 @@ ${JSON.stringify(data, null, 2)}`);
4273
6297
  name = defaultNameFor(source.path, source.isDirectory);
4274
6298
  }
4275
6299
  const check2 = await checkName(t2.client, name);
4276
- const base = check2.status === "invalid" ? null : sitesBaseOf(await t2.instance());
4277
- 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;
4278
6302
  const suggestions = check2.suggestions.length ? ` Suggestions: ${check2.suggestions.join(", ")}.` : "";
4279
6303
  const summary = {
4280
6304
  available: `"${name}" is available${url ? ` \u2192 ${url}` : ""}.`,
@@ -4300,7 +6324,7 @@ ${JSON.stringify(data, null, 2)}`);
4300
6324
  title: "Sign in to Fling",
4301
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,
4302
6326
  inputSchema: {
4303
- server: z11.string().min(1).max(500).optional().describe(
6327
+ server: z19.string().min(1).max(500).optional().describe(
4304
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"}`}`
4305
6329
  )
4306
6330
  },
@@ -4384,7 +6408,7 @@ ${known.join("\n")}` : ""
4384
6408
  title: "List the Flings on this computer",
4385
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.`,
4386
6410
  inputSchema: {
4387
- use: z11.string().min(1).max(500).optional().describe(
6411
+ use: z19.string().min(1).max(500).optional().describe(
4388
6412
  "Make this Fling current (address, host or unique part of it), like `fling use`"
4389
6413
  )
4390
6414
  },
@@ -4428,17 +6452,42 @@ ${JSON.stringify(data, null, 2)}`);
4428
6452
  server.registerTool(
4429
6453
  t.list.name,
4430
6454
  serverTool(t.list),
4431
- (args) => run(t.list.name, args.server, async ({ client, url }) => {
4432
- const sites = await listSites(client, { archived: args.archived });
6455
+ (args) => run(t.list.name, args.server, async (target2) => {
6456
+ const { client, url } = target2;
6457
+ if (args.shared) {
6458
+ const instance = await target2.instance();
6459
+ if (instance && !audiencesEnabled(instance)) {
6460
+ throw new CliError(
6461
+ `${instance.name} (${serverLabel(url)}) can't limit a site to listed people, so nothing is shared`
6462
+ );
6463
+ }
6464
+ const sites2 = await listShared(client, { sort: args.sort });
6465
+ const lines = sites2.map(describeSharedSite).join("\n");
6466
+ return {
6467
+ summary: `${sites2.length} site(s) shared with you on ${serverLabel(url)}${lines ? `:
6468
+ ${lines}` : ""}`,
6469
+ data: { sites: sites2 }
6470
+ };
6471
+ }
6472
+ const sites = await listSites(client, { archived: args.archived, sort: args.sort });
4433
6473
  return { summary: `${sites.length} site(s) on ${serverLabel(url)}`, data: { sites } };
4434
6474
  })
4435
6475
  );
4436
6476
  server.registerTool(
4437
6477
  t.info.name,
4438
6478
  serverTool(t.info),
4439
- (args) => run(t.info.name, args.server, async ({ client }) => {
4440
- const site = await getSite(client, args.name);
4441
- return { summary: `${site.name}: ${site.url}`, data: site };
6479
+ (args) => run(t.info.name, args.server, async (target2) => {
6480
+ const site = await getSite(target2.client, args.name);
6481
+ const access3 = site.audience ? `
6482
+ ${describeAudience(site, await membersOn(target2))}` : "";
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 };
4442
6491
  })
4443
6492
  );
4444
6493
  server.registerTool(
@@ -4474,6 +6523,24 @@ ${JSON.stringify(data, null, 2)}`);
4474
6523
  };
4475
6524
  })
4476
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
+ );
4477
6544
  server.registerTool(
4478
6545
  t.setVisibility.name,
4479
6546
  serverTool(t.setVisibility),
@@ -4482,6 +6549,156 @@ ${JSON.stringify(data, null, 2)}`);
4482
6549
  return { summary: `${site.name} is now ${site.visibility}: ${site.url}`, data: site };
4483
6550
  })
4484
6551
  );
6552
+ if (servers.some((s) => audiencesEnabled(s.instance))) {
6553
+ server.registerTool(
6554
+ t.setAudience.name,
6555
+ serverTool(t.setAudience),
6556
+ (args) => run(t.setAudience.name, args.server, async (target2) => {
6557
+ const instance = await target2.instance();
6558
+ if (instance && !audiencesEnabled(instance)) {
6559
+ throw new CliError(
6560
+ `${instance.name} (${serverLabel(target2.url)}) can't limit a site to listed people`
6561
+ );
6562
+ }
6563
+ const change = {
6564
+ add: args.add,
6565
+ remove: args.remove,
6566
+ set: args.set,
6567
+ addGroups: args.addGroups,
6568
+ removeGroups: args.removeGroups,
6569
+ setGroups: args.setGroups,
6570
+ clear: args.clear
6571
+ };
6572
+ if (!changesAudience(change)) {
6573
+ throw new CliError(
6574
+ "Pass `add`, `remove`, `set`, `addGroups`, `removeGroups`, `setGroups` or `clear`"
6575
+ );
6576
+ }
6577
+ const site = await setAudience(target2.client, args.name, change);
6578
+ return {
6579
+ summary: `${site.name}: ${describeAudience(site, await membersOn(target2))}${onFling(target2.url, instance)}`,
6580
+ data: site
6581
+ };
6582
+ })
6583
+ );
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
+ }
6615
+ if (servers.some((s) => groupsEnabled(s.instance))) {
6616
+ const withGroups = async (target2) => {
6617
+ const instance = await target2.instance();
6618
+ if (instance && !groupsEnabled(instance)) {
6619
+ throw new CliError(`${instance.name} (${serverLabel(target2.url)}) has no groups`);
6620
+ }
6621
+ return instance;
6622
+ };
6623
+ server.registerTool(
6624
+ t.groups.name,
6625
+ serverTool(t.groups),
6626
+ (args) => run(t.groups.name, args.server, async (target2) => {
6627
+ const instance = await withGroups(target2);
6628
+ const [groups3, me] = await Promise.all([
6629
+ listGroups(target2.client, { mine: args.mine, members: args.members }),
6630
+ whoami(target2.client)
6631
+ ]);
6632
+ const which = args.members ? " of exactly those people" : "";
6633
+ return {
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 }
6637
+ };
6638
+ })
6639
+ );
6640
+ server.registerTool(
6641
+ t.groupShow.name,
6642
+ serverTool(t.groupShow),
6643
+ (args) => run(t.groupShow.name, args.server, async (target2) => {
6644
+ await withGroups(target2);
6645
+ const group = await getGroup(target2.client, args.slug);
6646
+ return { summary: describeGroup(group), data: group };
6647
+ })
6648
+ );
6649
+ server.registerTool(
6650
+ t.groupCreate.name,
6651
+ serverTool(t.groupCreate),
6652
+ (args) => run(t.groupCreate.name, args.server, async (target2) => {
6653
+ const instance = await withGroups(target2);
6654
+ const group = await createGroup(target2.client, {
6655
+ slug: args.slug,
6656
+ name: args.name,
6657
+ members: args.members
6658
+ });
6659
+ return {
6660
+ summary: `Created ${describeGroup(group)}${onFling(target2.url, instance)}`,
6661
+ data: group
6662
+ };
6663
+ })
6664
+ );
6665
+ server.registerTool(
6666
+ t.groupMembers.name,
6667
+ serverTool(t.groupMembers),
6668
+ (args) => run(t.groupMembers.name, args.server, async (target2) => {
6669
+ await withGroups(target2);
6670
+ if (!args.add?.length && !args.remove?.length) {
6671
+ throw new CliError("Pass `add` or `remove`");
6672
+ }
6673
+ const group = await changeGroupMembers(target2.client, args.slug, {
6674
+ add: args.add,
6675
+ remove: args.remove
6676
+ });
6677
+ return { summary: describeGroup(group), data: group };
6678
+ })
6679
+ );
6680
+ server.registerTool(
6681
+ t.groupRename.name,
6682
+ serverTool(t.groupRename),
6683
+ (args) => run(t.groupRename.name, args.server, async (target2) => {
6684
+ await withGroups(target2);
6685
+ const group = await renameGroup(target2.client, args.slug, args.name);
6686
+ return { summary: describeGroup(group), data: group };
6687
+ })
6688
+ );
6689
+ server.registerTool(
6690
+ t.groupDelete.name,
6691
+ serverTool(t.groupDelete),
6692
+ (args) => run(t.groupDelete.name, args.server, async (target2) => {
6693
+ await withGroups(target2);
6694
+ const result = await deleteGroup(target2.client, args.slug);
6695
+ return {
6696
+ summary: `Group ${args.slug} deleted. Sites whose audience named it keep their restriction: anyone it let in has lost access`,
6697
+ data: result
6698
+ };
6699
+ })
6700
+ );
6701
+ }
4485
6702
  server.registerTool(
4486
6703
  t.archive.name,
4487
6704
  serverTool(t.archive),
@@ -4572,15 +6789,54 @@ ${JSON.stringify(data, null, 2)}`);
4572
6789
  })
4573
6790
  );
4574
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
+ }
4575
6831
  server.registerTool(
4576
6832
  t.whoami.name,
4577
6833
  serverTool(t.whoami),
4578
6834
  (args) => run(t.whoami.name, args.server, async (target2) => {
4579
6835
  const me = await whoami(target2.client);
4580
6836
  const instance = await target2.instance();
4581
- const base = sitesBaseOf(instance);
6837
+ const base2 = sitesBaseOf(instance);
4582
6838
  return {
4583
- 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)})`,
4584
6840
  data: { ...me, server: target2.url }
4585
6841
  };
4586
6842
  })
@@ -4611,9 +6867,9 @@ async function writeInlineFiles(dir, files) {
4611
6867
  total += bytes.length;
4612
6868
  if (total > MAX_DEPLOY_BYTES)
4613
6869
  throw new CliError(`The site is larger than ${formatBytes(MAX_DEPLOY_BYTES)}`);
4614
- const target = join6(dir, ...path3.split("/"));
4615
- await mkdir3(dirname2(target), { recursive: true });
4616
- await writeFile4(target, bytes);
6870
+ const target = join7(dir, ...path3.split("/"));
6871
+ await mkdir4(dirname3(target), { recursive: true });
6872
+ await writeFile5(target, bytes);
4617
6873
  }
4618
6874
  if (secretFiles.length > 0) throw new SecretFilesError(secretFiles);
4619
6875
  return { skippedDotfiles };
@@ -4657,10 +6913,10 @@ async function runMcpServer(deps = {}) {
4657
6913
  }
4658
6914
 
4659
6915
  // src/route.ts
4660
- import { stat as stat6 } from "node:fs/promises";
4661
- import { resolve as resolve4 } from "node:path";
6916
+ import { stat as stat7 } from "node:fs/promises";
6917
+ import { resolve as resolve5 } from "node:path";
4662
6918
  var PASS_THROUGH = /* @__PURE__ */ new Set(["-h", "--help", "-V", "--version"]);
4663
- var pathExists = (path3) => stat6(resolve4(path3)).then(
6919
+ var pathExists = (path3) => stat7(resolve5(path3)).then(
4664
6920
  () => true,
4665
6921
  () => false
4666
6922
  );
@@ -4711,18 +6967,18 @@ program.name(BIN_NAME).description("Fling a folder of static files at Fling and
4711
6967
  `server for this command: a URL, or the host of one you signed in to (default: $${ENV.url}, else the current one)`
4712
6968
  ).option("--json", "print JSON instead of human output", false).showHelpAfterError().configureHelp({
4713
6969
  sortSubcommands: false,
4714
- styleTitle: (title) => chalk10.bold(brand.primary(title)),
4715
- styleCommandText: (text2) => chalk10.bold(text2),
6970
+ styleTitle: (title) => chalk13.bold(brand.primary(title)),
6971
+ styleCommandText: (text2) => chalk13.bold(text2),
4716
6972
  styleSubcommandText: (text2) => brand.accent(text2),
4717
6973
  styleOptionText: (text2) => brand.accent(text2),
4718
- styleArgumentText: (text2) => chalk10.dim(text2)
6974
+ styleArgumentText: (text2) => chalk13.dim(text2)
4719
6975
  }).addHelpText(
4720
6976
  "before",
4721
6977
  () => process.stdout.isTTY ? logo({ columns: process.stdout.columns }) : ""
4722
6978
  ).addHelpText(
4723
6979
  "after",
4724
6980
  () => `
4725
- ${chalk10.bold(brand.primary("Examples:"))}
6981
+ ${chalk13.bold(brand.primary("Examples:"))}
4726
6982
  $ ${BIN_NAME} login https://fling.example.com sign in to your Fling (once; there is no default)
4727
6983
  $ ${BIN_NAME} setup sign in and add Fling to Claude Code / Claude Desktop
4728
6984
  $ ${BIN_NAME} use list the Flings you signed in to; \`use <url>\` switches
@@ -4733,7 +6989,7 @@ ${chalk10.bold(brand.primary("Examples:"))}
4733
6989
  $ ${BIN_NAME} site.zip a zip of a site
4734
6990
  $ ${BIN_NAME} share my-app public link for people who can't sign in
4735
6991
 
4736
- ${chalk10.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
4737
6993
  Env: ${ENV.apiKey} \xB7 ${ENV.url} \xB7 ${ENV.configDir} \xB7 ${ENV.debug}`)}`
4738
6994
  );
4739
6995
  function action(handler) {
@@ -4777,14 +7033,36 @@ program.command("deploy [path]").helpGroup("Deploy:").description(
4777
7033
  ).option(
4778
7034
  "-n, --name <name>",
4779
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)"
4780
- ).option("--spa", "single-page app: serve index.html for unknown paths").option("--public", "make the site public (default: sign-in required)").option(
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"
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(
7043
+ "--public",
7044
+ "make the site public: anyone can open it, reviewing still needs sign-in (on every deploy; for a new site prefer --access public)"
7045
+ ).option(
4781
7046
  "-y, --yes",
4782
7047
  "never prompt: accept the generated name; without index.html, list the HTML pages (or fling anyway if there are none)"
4783
- ).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(
4784
7052
  action(
4785
7053
  (ctx, path3, opts) => runDeploy(ctx, path3, opts)
4786
7054
  )
4787
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
+ );
4788
7066
  program.command("setup [url]").helpGroup("Setup:").description(
4789
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"
4790
7068
  ).option("-y, --yes", "do every step without asking").option("-s, --scope <scope>", "Claude Code plugin scope: user, project or local", "user").action(
@@ -4808,7 +7086,18 @@ program.command("logout [url]").helpGroup("Setup:").description(
4808
7086
  ).action(action((ctx, url) => runLogout(ctx, url)));
4809
7087
  program.command("whoami").helpGroup("Setup:").description("show the signed-in user and the current server").action(action((ctx) => runWhoami(ctx)));
4810
7088
  program.commandsGroup("Setup:").helpCommand("help [command]", "show help for a command");
4811
- program.command("ls").helpGroup("Sites:").alias("list").description("list your sites").option("--archived", "list archived sites").action(action((ctx, opts) => runList(ctx, opts)));
7089
+ program.command("ls").helpGroup("Sites:").alias("list").description("list your sites").option("--archived", "list archived sites").addOption(
7090
+ new Option(
7091
+ "--shared",
7092
+ "list the sites others have shared with you instead of your own"
7093
+ ).conflicts("archived")
7094
+ ).addOption(
7095
+ new Option("--sort <order>", "updated (newest first), created (newest first) or name (A\u2013Z)").choices(SITE_SORTS).default(DEFAULT_SITE_SORT)
7096
+ ).action(
7097
+ action(
7098
+ (ctx, opts) => opts.shared ? runListShared(ctx, opts) : runList(ctx, opts)
7099
+ )
7100
+ );
4812
7101
  program.command("info <name>").helpGroup("Sites:").description("show a site").action(action((ctx, name) => runInfo(ctx, name)));
4813
7102
  program.command("versions <name>").helpGroup("Sites:").description("list a site's versions (* = live)").action(action((ctx, name) => runVersions(ctx, name)));
4814
7103
  program.command("files <name> [versionId]").helpGroup("Sites:").description("list the files in a site's live version (or in versionId)").action(
@@ -4820,10 +7109,69 @@ program.command("share <name>").helpGroup("Sites:").description("print (or creat
4820
7109
  (ctx, name, opts) => runShare(ctx, name, opts)
4821
7110
  )
4822
7111
  );
4823
- program.command("visibility <name> <visibility>").helpGroup("Sites:").description("private (sign-in required) or public (anyone)").action(action((ctx, name, visibility) => runVisibility(ctx, name, visibility)));
7112
+ program.command("visibility <name> <visibility>").helpGroup("Sites:").description(
7113
+ "private (sign-in required) or public (anyone can open the site; reviewing it still needs sign-in)"
7114
+ ).action(action((ctx, name, visibility) => runVisibility(ctx, name, visibility)));
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(
7127
+ action(
7128
+ (ctx, name, opts) => runAccess(ctx, name, opts)
7129
+ )
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
+ }
4824
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)));
4825
7153
  program.command("unarchive <name>").helpGroup("Sites:").description("bring an archived site back").action(action((ctx, name) => runArchive(ctx, name, true)));
4826
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)));
7155
+ var groups = program.command("groups").helpGroup("Groups:").description(
7156
+ "named lists of people anyone can use in an audience; only the owner changes one (default: list)"
7157
+ );
7158
+ groups.command("list", { isDefault: true }).alias("ls").description("list the groups on this Fling").option("--mine", "only the groups you own").option("--members <emails...>", "only the groups of exactly these people (a,b or a b)").action(action((ctx, opts) => runGroups(ctx, opts)));
7159
+ groups.command("create <slug>").description("create a group you own (slug: lowercase letters, digits and single hyphens)").option("--name <name>", "its display name (default: the slug)").option("--add <emails...>", "put these addresses in it").action(
7160
+ action(
7161
+ (ctx, slug, opts) => runGroupCreate(ctx, slug, opts)
7162
+ )
7163
+ );
7164
+ groups.command("show <slug>").description("show a group and its members").action(action((ctx, slug) => runGroupShow(ctx, slug)));
7165
+ groups.command("add <slug> <emails...>").description("add people to a group you own").action(
7166
+ action((ctx, slug, emails2) => runGroupMembers(ctx, slug, emails2, "add"))
7167
+ );
7168
+ groups.command("remove <slug> <emails...>").description("take people out of a group you own").action(
7169
+ action((ctx, slug, emails2) => runGroupMembers(ctx, slug, emails2, "remove"))
7170
+ );
7171
+ groups.command("rename <slug> <name>").description("change the display name of a group you own").action(action((ctx, slug, name) => runGroupRename(ctx, slug, name)));
7172
+ groups.command("delete <slug>").alias("rm").description(
7173
+ "delete a group you own; anyone it let into a site loses access (asks first unless --yes)"
7174
+ ).option("-y, --yes", "do not ask for confirmation").action(action((ctx, slug, opts) => runGroupDelete(ctx, slug, opts)));
4827
7175
  program.command("comments <name>").helpGroup("Comments:").description("list a site's open comment threads (people leave them on its review page)").option("-p, --path <path>", "only this page, e.g. / or /docs/").option("-a, --all", "include resolved threads").action(
4828
7176
  action(
4829
7177
  (ctx, name, opts) => runComments(ctx, name, opts)
@@ -4844,6 +7192,16 @@ program.command("uncomment <name> <id>").helpGroup("Comments:").alias("delete-co
4844
7192
  (ctx, name, id, opts) => runUncomment(ctx, name, id, opts)
4845
7193
  )
4846
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)));
4847
7205
  program.command("mcp").helpGroup("Agents:").description("run the MCP server on stdio (used by the Claude Code plugin)").action(async (_opts, command) => {
4848
7206
  const globals = command.optsWithGlobals();
4849
7207
  await runMcpServer({ server: globals.server });
@@ -4866,4 +7224,3 @@ async function main() {
4866
7224
  await program.parseAsync(route.args, { from: "user" });
4867
7225
  }
4868
7226
  await main();
4869
- //# sourceMappingURL=cli.js.map