@nearform/fling 0.8.0 → 0.10.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.
Files changed (3) hide show
  1. package/README.md +134 -25
  2. package/dist/cli.js +2204 -388
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1289,6 +1289,31 @@ import { z as z4 } from "zod";
1289
1289
 
1290
1290
  // ../../packages/shared/src/comments.ts
1291
1291
  import { z as z3 } from "zod";
1292
+
1293
+ // ../../packages/shared/src/markdown-path.ts
1294
+ var MARKDOWN_EXTENSION = /\.(md|markdown)$/i;
1295
+ var isMarkdownPath = (path3) => MARKDOWN_EXTENSION.test(path3);
1296
+ var HOME_PAGE_NAMES = [/^index\.(md|markdown)$/i, /^readme\.(md|markdown)$/i];
1297
+ function markdownHomePages(files) {
1298
+ const folders = /* @__PURE__ */ new Map();
1299
+ for (const path3 of files) {
1300
+ const slash = path3.lastIndexOf("/");
1301
+ const folder = slash === -1 ? "" : path3.slice(0, slash);
1302
+ const names = folders.get(folder) ?? [];
1303
+ names.push(path3.slice(slash + 1));
1304
+ folders.set(folder, names);
1305
+ }
1306
+ const pages = /* @__PURE__ */ new Map();
1307
+ for (const [folder, names] of folders) {
1308
+ if (names.includes("index.html")) continue;
1309
+ const markdown = names.filter(isMarkdownPath).sort();
1310
+ const chosen = HOME_PAGE_NAMES.map((pattern) => markdown.find((name) => pattern.test(name))).find(Boolean) ?? (markdown.length === 1 ? markdown[0] : void 0);
1311
+ if (chosen) pages.set(folder, folder ? `${folder}/${chosen}` : chosen);
1312
+ }
1313
+ return pages;
1314
+ }
1315
+
1316
+ // ../../packages/shared/src/comments.ts
1292
1317
  var COMMENT_MAX_CHARS = 4e3;
1293
1318
  var COMMENT_PATH_MAX = 1024;
1294
1319
  var FORM_ID_MAX = 40;
@@ -1382,18 +1407,22 @@ function normalizeCommentPath(input) {
1382
1407
  if (path3 === "") return "/";
1383
1408
  if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path3)) {
1384
1409
  try {
1385
- path3 = new URL(path3).pathname;
1410
+ const url = new URL(path3);
1411
+ path3 = url.pathname + url.search;
1386
1412
  } catch {
1387
1413
  return null;
1388
1414
  }
1389
1415
  }
1416
+ const query = /^[^?#]*\?([^#]*)/.exec(path3)?.[1] ?? "";
1390
1417
  path3 = path3.replace(/[?#].*$/, "");
1418
+ if (isMarkdownPath(path3) && query.split("&").includes("raw=1")) path3 += RAW_QUERY;
1391
1419
  if (!path3.startsWith("/")) path3 = `/${path3}`;
1392
1420
  path3 = path3.replace(/\/{2,}/g, "/").replace(/(^|\/)index\.html$/, "$1");
1393
1421
  if (path3.length > COMMENT_PATH_MAX || /\s/.test(path3)) return null;
1394
1422
  if (path3.split("/").some((s) => s === "." || s === "..")) return null;
1395
1423
  return path3;
1396
1424
  }
1425
+ var RAW_QUERY = "?raw=1";
1397
1426
  function commentThreads(comments) {
1398
1427
  const threads = /* @__PURE__ */ new Map();
1399
1428
  for (const c of comments) {
@@ -1729,16 +1758,16 @@ function formAppliesTo(form, path3) {
1729
1758
  if (include.length > 0 && !include.some((g) => globMatches(g, path3))) return false;
1730
1759
  return !exclude.some((g) => globMatches(g, path3));
1731
1760
  }
1732
- function reviewPages(paths) {
1761
+ function reviewPages(paths2) {
1733
1762
  const pages = /* @__PURE__ */ new Set();
1734
- for (const p of paths) {
1763
+ for (const p of paths2) {
1735
1764
  if (!/\.html?$/i.test(p) && !p.endsWith("/")) continue;
1736
- const page = normalizeCommentPath(p);
1737
- if (page !== null) pages.add(page);
1765
+ const page2 = normalizeCommentPath(p);
1766
+ if (page2 !== null) pages.add(page2);
1738
1767
  }
1739
1768
  return [...pages].sort();
1740
1769
  }
1741
- function checkFormsConfig(config, paths) {
1770
+ function checkFormsConfig(config, paths2) {
1742
1771
  const parsed = FormsConfigSchema.safeParse(config);
1743
1772
  if (!parsed.success) {
1744
1773
  const errors = parsed.error.issues.map(
@@ -1746,7 +1775,7 @@ function checkFormsConfig(config, paths) {
1746
1775
  );
1747
1776
  return { ok: false, errors, summary: null };
1748
1777
  }
1749
- const pages = paths ? reviewPages(paths) : null;
1778
+ const pages = paths2 ? reviewPages(paths2) : null;
1750
1779
  const warnings = [];
1751
1780
  const forms2 = parsed.data.forms.map((form) => {
1752
1781
  const matched = pages ? pages.filter((p) => formAppliesTo(form, p)) : null;
@@ -1863,7 +1892,7 @@ function responsesPageUrl(appOrigin, name) {
1863
1892
  function progressLines(exported) {
1864
1893
  return exported.progress.map((p) => {
1865
1894
  const form = exported.forms.find((f) => f.id === p.form);
1866
- const done = p.pages.filter((page) => (p.done[page]?.length ?? 0) > 0).length;
1895
+ const done = p.pages.filter((page2) => (p.done[page2]?.length ?? 0) > 0).length;
1867
1896
  const count2 = exported.responses.filter((r) => r.formId === p.form).length;
1868
1897
  const where = form?.kind === "comment" ? `${count2} response${count2 === 1 ? "" : "s"} on ${Object.keys(p.done).length} of ${p.pages.length} pages` : `${done} / ${p.pages.length} page${p.pages.length === 1 ? "" : "s"} answered`;
1869
1898
  return `${form?.title ?? p.form} (${p.form}): ${where}, ${p.reviewers.length} reviewer${p.reviewers.length === 1 ? "" : "s"}`;
@@ -1875,6 +1904,7 @@ var MAX_DEPLOY_BYTES = 100 * 1024 * 1024;
1875
1904
  var MAX_FILE_BYTES = 25 * 1024 * 1024;
1876
1905
  var MAX_FILES = 5e3;
1877
1906
  var MAX_PATH_LENGTH = 1024;
1907
+ var MAX_PATH_FOLDER_LENGTH = 100;
1878
1908
 
1879
1909
  // ../../packages/shared/src/manifest.ts
1880
1910
  import { z as z5 } from "zod";
@@ -2085,12 +2115,17 @@ function normalizePublishPath(raw) {
2085
2115
  const path3 = raw.trim().replace(/^\/+/, "").replace(/\/+$/, "");
2086
2116
  return isValidSitePath(path3) ? path3 : null;
2087
2117
  }
2088
- var PublishPathSchema = z6.string().transform((raw, ctx) => {
2118
+ function normalizePathFolder(raw) {
2089
2119
  const path3 = normalizePublishPath(raw);
2120
+ if (path3 === null || path3.includes("/") || path3.length > MAX_PATH_FOLDER_LENGTH) return null;
2121
+ return path3;
2122
+ }
2123
+ var PublishPathSchema = z6.string().transform((raw, ctx) => {
2124
+ const path3 = normalizePathFolder(raw);
2090
2125
  if (path3 === null) {
2091
2126
  ctx.addIssue({
2092
2127
  code: "custom",
2093
- message: "path must be a folder of the site, like 30-10-26 or reports/2026 (no dotfiles)"
2128
+ message: `path must be one top-level folder of the site, like PAY-412 (at most ${MAX_PATH_FOLDER_LENGTH} characters, no dotfiles): publish nested folders as part of it`
2094
2129
  });
2095
2130
  return z6.NEVER;
2096
2131
  }
@@ -2192,9 +2227,8 @@ var VersionSchema = z9.object({
2192
2227
  /** who published it (email); absent on versions published before it was recorded */
2193
2228
  publishedBy: z9.string().optional(),
2194
2229
  /**
2195
- * A path publish (`pathPublish` capability): the folder it replaced (`30-10-26`, shown as
2196
- * `/30-10-26/`), and the version it carried the rest of the site forward from (absent when the
2197
- * site had none)
2230
+ * A Path's version: the Path's folder (`PAY-412`, shown as `/PAY-412/`). A publish on a base:
2231
+ * the version it built on, carrying its other files forward
2198
2232
  */
2199
2233
  path: z9.string().optional(),
2200
2234
  base: z9.string().optional(),
@@ -2205,6 +2239,29 @@ var SiteDetailSchema = SiteWithAudienceSchema.extend({
2205
2239
  versions: z9.array(VersionSchema)
2206
2240
  });
2207
2241
  var SiteListSchema = z9.object({ sites: z9.array(SiteSchema) });
2242
+ var SitePathSchema = z9.object({
2243
+ folder: z9.string(),
2244
+ /** the `<title>` of its index.html, '' without one */
2245
+ title: z9.string(),
2246
+ url: z9.string(),
2247
+ currentVersionId: z9.string().nullable(),
2248
+ archived: z9.boolean(),
2249
+ owners: SiteRoleMembersSchema.optional(),
2250
+ publishers: SiteRoleMembersSchema.optional(),
2251
+ createdBy: z9.string().optional(),
2252
+ canPublish: z9.boolean(),
2253
+ /** the caller may change its owners and publishers, archive and delete it */
2254
+ canManage: z9.boolean().optional(),
2255
+ createdAt: z9.string(),
2256
+ updatedAt: z9.string()
2257
+ });
2258
+ var PathListSchema = z9.object({ paths: z9.array(SitePathSchema) });
2259
+ var PathDetailSchema = SitePathSchema.extend({ versions: z9.array(VersionSchema) });
2260
+ var UpdatePathRequestSchema = z9.object({
2261
+ owners: OwnersInputSchema.optional(),
2262
+ publishers: PublishersInputSchema.optional(),
2263
+ archived: z9.boolean().optional()
2264
+ });
2208
2265
  var SharedSiteSchema = z9.object({
2209
2266
  name: z9.string(),
2210
2267
  url: z9.string(),
@@ -2270,7 +2327,6 @@ var DeployRequestSchema = z9.object({
2270
2327
  }
2271
2328
  if (req.partial && req.base === void 0) issue("`partial` needs `base`");
2272
2329
  if (req.remove !== void 0 && !req.partial) issue("`remove` needs `partial: true`");
2273
- if (req.base !== void 0 && req.path !== void 0) issue("Pass `base` or `path`, not both");
2274
2330
  });
2275
2331
  var UploadSchema = z9.object({
2276
2332
  path: z9.string(),
@@ -2278,11 +2334,26 @@ var UploadSchema = z9.object({
2278
2334
  /** headers the client must send with the PUT (they are part of the signature) */
2279
2335
  headers: z9.record(z9.string(), z9.string())
2280
2336
  });
2337
+ var PublishHintSchema = z9.object({
2338
+ code: z9.enum(["paths_index_missing", "paths_block_hidden"]),
2339
+ message: z9.string(),
2340
+ folders: z9.array(z9.string()).optional()
2341
+ });
2281
2342
  var DeployResponseSchema = z9.discriminatedUnion("status", [
2282
2343
  /** content identical to the current version — nothing happened */
2283
- z9.object({ status: z9.literal("unchanged"), versionId: z9.string(), site: SiteSchema }),
2344
+ z9.object({
2345
+ status: z9.literal("unchanged"),
2346
+ versionId: z9.string(),
2347
+ site: SiteSchema,
2348
+ hints: z9.array(PublishHintSchema).optional()
2349
+ }),
2284
2350
  /** this content was deployed before — site now points at that version again */
2285
- z9.object({ status: z9.literal("repointed"), versionId: z9.string(), site: SiteSchema }),
2351
+ z9.object({
2352
+ status: z9.literal("repointed"),
2353
+ versionId: z9.string(),
2354
+ site: SiteSchema,
2355
+ hints: z9.array(PublishHintSchema).optional()
2356
+ }),
2286
2357
  /** new content — PUT every upload, then POST /api/sites/:name/deploys/:versionId/finalize */
2287
2358
  z9.object({
2288
2359
  status: z9.literal("upload"),
@@ -2295,7 +2366,8 @@ var FinalizeResponseSchema = z9.object({
2295
2366
  versionId: z9.string(),
2296
2367
  site: SiteSchema,
2297
2368
  /** this deploy made the site live for the first time (a new site, its access just set) */
2298
- first: z9.boolean().optional()
2369
+ first: z9.boolean().optional(),
2370
+ hints: z9.array(PublishHintSchema).optional()
2299
2371
  });
2300
2372
  var RollbackRequestSchema = z9.object({ versionId: z9.string() });
2301
2373
  var UpdateSiteRequestSchema = z9.object({
@@ -2743,6 +2815,7 @@ function siteUrl(label, sitesDomain, protocol = "https:") {
2743
2815
  // ../../packages/shared/src/instance.ts
2744
2816
  import { z as z11 } from "zod";
2745
2817
  var INSTANCE_METADATA_PATH = "/.well-known/fling";
2818
+ var RELEASE_HEADER = "Fling-Release";
2746
2819
  var HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
2747
2820
  var color = z11.string().regex(HEX_COLOR_RE);
2748
2821
  var SignInProviderSchema = z11.looseObject({
@@ -2839,7 +2912,22 @@ var KvsSiteValueSchema = z12.object({
2839
2912
  /** serve /index.html for unknown extensionless paths */
2840
2913
  spa: z12.boolean(),
2841
2914
  /** present when the site is restricted (private with an audience): the edge wants a site pass */
2842
- r: z12.literal(1).optional()
2915
+ r: z12.literal(1).optional(),
2916
+ /** present when the site has live Paths: a request's first folder may be one (`path:` keys) */
2917
+ p: z12.literal(1).optional(),
2918
+ /**
2919
+ * the rendered home page and Paths listing (`sites/<id>/_rendered/<i>.html|json`), served for
2920
+ * `/`, `/index.html` and PATHS_JSON_PATH instead of the version's own index.html
2921
+ */
2922
+ i: z12.string().optional(),
2923
+ /** present when the current version's Markdown files have rendered pages (markdown.ts) */
2924
+ m: z12.literal(1).optional()
2925
+ });
2926
+ var KvsPathValueSchema = z12.object({
2927
+ id: z12.string(),
2928
+ v: z12.string(),
2929
+ /** as on a site's value: its current version's Markdown has rendered pages */
2930
+ m: z12.literal(1).optional()
2843
2931
  });
2844
2932
 
2845
2933
  // ../../packages/shared/src/listing.ts
@@ -2860,10 +2948,10 @@ function extractHtmlTitle(html) {
2860
2948
  function renderHtmlListing(siteName2, entries) {
2861
2949
  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);
2862
2950
  const groups2 = /* @__PURE__ */ new Map();
2863
- for (const page of pages) {
2864
- const slash = page.path.lastIndexOf("/");
2865
- const folder = slash === -1 ? "" : page.path.slice(0, slash);
2866
- groups2.set(folder, [...groups2.get(folder) ?? [], page]);
2951
+ for (const page2 of pages) {
2952
+ const slash = page2.path.lastIndexOf("/");
2953
+ const folder = slash === -1 ? "" : page2.path.slice(0, slash);
2954
+ groups2.set(folder, [...groups2.get(folder) ?? [], page2]);
2867
2955
  }
2868
2956
  const sections = [...groups2.entries()].sort(([a], [b]) => a === "" ? -1 : b === "" ? 1 : a < b ? -1 : 1).map(([folder, items]) => {
2869
2957
  const heading = folder ? `<h2>${escapeHtml(folder)}/</h2>` : "";
@@ -3124,6 +3212,385 @@ function describeNotificationSettings(settings) {
3124
3212
  return lines.join("\n");
3125
3213
  }
3126
3214
 
3215
+ // ../../packages/shared/src/paths-index.ts
3216
+ var PATHS_SORTS = ["updated", "oldest", "folder", "folder-desc"];
3217
+ var PATHS_OPTIONS = ["nocontrols", "nostyle", ...PATHS_SORTS.map((s) => `sort=${s}`)];
3218
+ var PATHS_PLACEHOLDERS = ["href", "folder", "title", "updated", "date", "version"];
3219
+ function matchesQuery(row2, query) {
3220
+ const marks = /[̀-ͯ]/g;
3221
+ const hay = `${row2.folder} ${row2.title}`.normalize("NFD").replace(marks, "").toLowerCase();
3222
+ const terms = String(query).normalize("NFD").replace(marks, "").toLowerCase().split(/\s+/);
3223
+ for (let i = 0; i < terms.length; i++) {
3224
+ if (terms[i] && hay.indexOf(terms[i]) < 0) return false;
3225
+ }
3226
+ return true;
3227
+ }
3228
+ function compareRows(a, b, sort) {
3229
+ const x = String(a.folder);
3230
+ const y = String(b.folder);
3231
+ const xs = x.match(/\d+|\D+/g) || [];
3232
+ const ys = y.match(/\d+|\D+/g) || [];
3233
+ let byFolder = 0;
3234
+ for (let i = 0; i < xs.length && i < ys.length && byFolder === 0; i++) {
3235
+ const p = xs[i];
3236
+ const q = ys[i];
3237
+ if (/^\d/.test(p) && /^\d/.test(q)) {
3238
+ const pn = p.replace(/^0+/, "");
3239
+ const qn = q.replace(/^0+/, "");
3240
+ byFolder = pn.length - qn.length || (pn < qn ? -1 : pn > qn ? 1 : 0);
3241
+ } else {
3242
+ const pl = p.toLowerCase();
3243
+ const ql = q.toLowerCase();
3244
+ byFolder = pl < ql ? -1 : pl > ql ? 1 : 0;
3245
+ }
3246
+ }
3247
+ if (byFolder === 0) byFolder = xs.length - ys.length;
3248
+ if (byFolder === 0) byFolder = x < y ? -1 : x > y ? 1 : 0;
3249
+ const byTime = (Date.parse(a.updated) || 0) - (Date.parse(b.updated) || 0);
3250
+ if (sort === "folder") return byFolder;
3251
+ if (sort === "folder-desc") return -byFolder;
3252
+ if (sort === "oldest") return byTime || byFolder;
3253
+ return -byTime || byFolder;
3254
+ }
3255
+ var OPENER = /<!--\s*fling:paths(?=\s|-->)([\s\S]*?)-->/g;
3256
+ var CLOSER = /<!--\s*\/fling:paths\s*-->/g;
3257
+ var EMPTY = /<!--\s*fling:empty\s*-->/;
3258
+ var CONTROLS = /<!--\s*fling:paths-controls\s*-->/;
3259
+ var PLACEHOLDER = /\{\{\s*([A-Za-z_]\w*)\s*\}\}/g;
3260
+ function findBlock(html) {
3261
+ OPENER.lastIndex = 0;
3262
+ const open = OPENER.exec(html);
3263
+ if (!open) return null;
3264
+ const afterOpen = open.index + open[0].length;
3265
+ const options = open[1].trim().split(/\s+/).filter(Boolean);
3266
+ OPENER.lastIndex = afterOpen;
3267
+ const next = OPENER.exec(html);
3268
+ CLOSER.lastIndex = afterOpen;
3269
+ const close = CLOSER.exec(html);
3270
+ const paired = close !== null && (next === null || close.index < next.index);
3271
+ OPENER.lastIndex = paired && close ? close.index + close[0].length : afterOpen;
3272
+ const more = OPENER.exec(html) !== null;
3273
+ OPENER.lastIndex = 0;
3274
+ if (!paired || !close) {
3275
+ return { kind: "bare", start: open.index, end: afterOpen, options, item: "", empty: null, more };
3276
+ }
3277
+ const inner = html.slice(afterOpen, close.index);
3278
+ const e = EMPTY.exec(inner);
3279
+ return {
3280
+ kind: "template",
3281
+ start: open.index,
3282
+ end: close.index + close[0].length,
3283
+ options,
3284
+ item: e ? inner.slice(0, e.index) : inner,
3285
+ empty: e ? inner.slice(e.index + e[0].length) : null,
3286
+ more
3287
+ };
3288
+ }
3289
+ function relativeTime(iso, now) {
3290
+ const t = Date.parse(iso);
3291
+ if (!Number.isFinite(t)) return "";
3292
+ const minutes = Math.floor((now - t) / 6e4);
3293
+ if (minutes < 1) return "just now";
3294
+ if (minutes < 60) return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
3295
+ const hours = Math.floor(minutes / 60);
3296
+ if (hours < 24) return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
3297
+ const days = Math.floor(hours / 24);
3298
+ if (days < 7) return days === 1 ? "yesterday" : `${days} days ago`;
3299
+ return "";
3300
+ }
3301
+ var LOOK = [
3302
+ ".fling-paths-controls{display:flex;flex-wrap:wrap;align-items:stretch;gap:var(--fling-paths-gap,.75rem);margin:0 0 var(--fling-paths-gap,.75rem);font:inherit;color:inherit}",
3303
+ ".fling-paths-controls label{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}",
3304
+ ".fling-paths-controls input,.fling-paths-controls select{font:inherit;line-height:1.25;color:inherit;background-color:transparent;color-scheme:inherit;accent-color:var(--fling-paths-accent,LinkText);border:1px solid color-mix(in srgb,currentColor 25%,transparent);border-radius:var(--fling-paths-radius,.5rem);padding:.45rem .65rem;margin:0}",
3305
+ // The platform's own select keeps its height and arrow whatever the padding: draw both, the
3306
+ // arrow in the text's colour so it follows the page.
3307
+ ".fling-paths-controls select{appearance:none;cursor:pointer;padding-inline-end:2rem;background-image:linear-gradient(45deg,transparent 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,transparent 50%);background-size:.3rem .3rem;background-position:right 1rem center,right .7rem center;background-repeat:no-repeat}",
3308
+ ".fling-paths-controls select option{color:CanvasText;background:Canvas}",
3309
+ ".fling-paths-controls input{flex:1 1 14rem;min-inline-size:0}",
3310
+ ".fling-paths-controls :focus-visible{outline:2px solid var(--fling-paths-accent,LinkText);outline-offset:1px}",
3311
+ ".fling-paths-count{align-self:center;opacity:.7;font-size:.9em;font-variant-numeric:tabular-nums}",
3312
+ ".fling-paths{list-style:none;margin:0;padding:0;display:grid;gap:calc(var(--fling-paths-gap,.75rem)/2)}",
3313
+ ".fling-paths li{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:.25rem var(--fling-paths-gap,.75rem);padding:.6rem .8rem;border:1px solid color-mix(in srgb,currentColor 15%,transparent);border-radius:var(--fling-paths-radius,.5rem)}",
3314
+ ".fling-paths a{color:var(--fling-paths-accent,LinkText);text-decoration:none;font-weight:600}",
3315
+ ".fling-paths a:hover{text-decoration:underline}",
3316
+ ".fling-paths-folder{font-weight:400;opacity:.7;font-family:ui-monospace,monospace;font-size:.85em;margin-inline-start:.5rem}",
3317
+ ".fling-paths time{opacity:.7;font-size:.9em;font-variant-numeric:tabular-nums}"
3318
+ ].join("\n");
3319
+ var PATHS_SCRIPT = `(function () {
3320
+ var matchesQuery = (${matchesQuery.toString()});
3321
+ var compareRows = (${compareRows.toString()});
3322
+ var relativeTime = (${relativeTime.toString()});
3323
+ // Dates within the last week read as how long ago they were; the rendered date stays as the
3324
+ // tooltip, and as the text from a week on.
3325
+ function ages() {
3326
+ var now = Date.now();
3327
+ document.querySelectorAll('[data-fling-path] time[datetime]').forEach(function (el) {
3328
+ if (!el.hasAttribute('data-fling-date')) el.setAttribute('data-fling-date', el.textContent);
3329
+ var fixed = el.getAttribute('data-fling-date');
3330
+ var ago = relativeTime(el.getAttribute('datetime'), now);
3331
+ el.textContent = ago || fixed;
3332
+ if (ago && fixed) el.title = fixed; else el.removeAttribute('title');
3333
+ });
3334
+ }
3335
+ function start() {
3336
+ if (window.__flingPaths) return;
3337
+ var box = document.querySelector('[data-fling-paths-controls]');
3338
+ if (!box) return;
3339
+ window.__flingPaths = true;
3340
+ ages();
3341
+ setInterval(ages, 60000);
3342
+ var input = box.querySelector('input');
3343
+ var select = box.querySelector('select');
3344
+ var count = box.querySelector('[data-fling-paths-count]');
3345
+ var fallback = box.getAttribute('data-sort') || 'updated';
3346
+ var els = Array.prototype.slice.call(document.querySelectorAll('[data-fling-path]'));
3347
+ var rows = els.map(function (el) {
3348
+ return { el: el, folder: el.getAttribute('data-folder') || '', title: el.getAttribute('data-title') || '', updated: el.getAttribute('data-updated') || '' };
3349
+ });
3350
+ function rowOf(el) { return rows[els.indexOf(el)]; }
3351
+ function known(sort) {
3352
+ for (var i = 0; i < select.options.length; i++) if (select.options[i].value === sort) return true;
3353
+ return false;
3354
+ }
3355
+ function apply(write) {
3356
+ var q = input.value;
3357
+ var sort = select.value;
3358
+ var shown = 0;
3359
+ rows.forEach(function (r) {
3360
+ var ok = matchesQuery(r, q);
3361
+ r.el.hidden = !ok;
3362
+ if (ok) shown++;
3363
+ });
3364
+ var parents = [];
3365
+ rows.forEach(function (r) { if (r.el.parentNode && parents.indexOf(r.el.parentNode) < 0) parents.push(r.el.parentNode); });
3366
+ parents.forEach(function (p) {
3367
+ var group = Array.prototype.filter.call(p.children, function (el) { return els.indexOf(el) >= 0; });
3368
+ if (group.length < 2) return;
3369
+ var anchor = group[group.length - 1].nextSibling;
3370
+ group.slice().sort(function (a, b) { return compareRows(rowOf(a), rowOf(b), sort); })
3371
+ .forEach(function (el) { p.insertBefore(el, anchor); });
3372
+ });
3373
+ if (count) count.textContent = shown + ' of ' + rows.length;
3374
+ if (!write) return;
3375
+ try {
3376
+ var url = new URL(location.href);
3377
+ if (q) url.searchParams.set('q', q); else url.searchParams.delete('q');
3378
+ if (sort !== fallback) url.searchParams.set('sort', sort); else url.searchParams.delete('sort');
3379
+ history.replaceState(history.state, '', url.toString());
3380
+ } catch (e) {}
3381
+ }
3382
+ var params = new URLSearchParams(location.search);
3383
+ input.value = params.get('q') || '';
3384
+ var wanted = params.get('sort');
3385
+ if (wanted && known(wanted)) select.value = wanted;
3386
+ input.addEventListener('input', function () { apply(true); });
3387
+ select.addEventListener('change', function () { apply(true); });
3388
+ input.addEventListener('keydown', function (e) {
3389
+ if (e.key === 'Escape' && input.value) { e.preventDefault(); input.value = ''; apply(true); }
3390
+ });
3391
+ document.addEventListener('keydown', function (e) {
3392
+ if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey) return;
3393
+ var t = e.target;
3394
+ if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
3395
+ e.preventDefault();
3396
+ input.focus();
3397
+ });
3398
+ apply(false);
3399
+ }
3400
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
3401
+ else start();
3402
+ })();`;
3403
+ function checkPathsIndex(html) {
3404
+ const page2 = typeof html === "string" ? html : "";
3405
+ const warnings = [];
3406
+ const errors = [];
3407
+ const block = findBlock(page2);
3408
+ if (!block) {
3409
+ errors.push("No Paths block: add <!-- fling:paths --> where the listing should go.");
3410
+ return { ok: false, block: null, options: [], warnings, errors };
3411
+ }
3412
+ for (const o of block.options) {
3413
+ if (!PATHS_OPTIONS.includes(o)) {
3414
+ warnings.push(`Unknown option "${o}"; the options are ${PATHS_OPTIONS.join(", ")}.`);
3415
+ }
3416
+ }
3417
+ if (block.more) warnings.push("More than one Paths block: only the first is filled.");
3418
+ CLOSER.lastIndex = 0;
3419
+ const firstClose = CLOSER.exec(page2);
3420
+ CLOSER.lastIndex = 0;
3421
+ if (firstClose && firstClose.index < block.start) {
3422
+ warnings.push("A <!-- /fling:paths --> comes before the opening marker and is ignored.");
3423
+ }
3424
+ if (block.kind === "bare") {
3425
+ if (EMPTY.test(page2.slice(block.end)) || firstClose && firstClose.index > block.start) {
3426
+ warnings.push(
3427
+ "The template is not closed: add <!-- /fling:paths --> after it (before any other block). Until then the marker is bare and Fling uses its default list."
3428
+ );
3429
+ }
3430
+ } else {
3431
+ if (!/<[A-Za-z]/.test(block.item) && !/\{\{/.test(block.item)) {
3432
+ warnings.push("The template item is empty, so each Path renders as nothing.");
3433
+ }
3434
+ const unknown = /* @__PURE__ */ new Set();
3435
+ for (const m of block.item.matchAll(PLACEHOLDER)) {
3436
+ if (!PATHS_PLACEHOLDERS.includes(m[1])) unknown.add(m[1]);
3437
+ }
3438
+ for (const name of unknown) {
3439
+ warnings.push(
3440
+ `Unknown placeholder {{${name}}} is left as it is; the placeholders are ${PATHS_PLACEHOLDERS.map((p) => `{{${p}}}`).join(" ")}.`
3441
+ );
3442
+ }
3443
+ if (!/href\s*=\s*["']?\{\{\s*href\s*\}\}/.test(block.item)) {
3444
+ warnings.push('The template item has no link to its Path: add <a href="{{href}}">.');
3445
+ }
3446
+ }
3447
+ const panels = page2.match(new RegExp(CONTROLS.source, "g"))?.length ?? 0;
3448
+ if (panels > 0 && block.options.includes("nocontrols")) {
3449
+ warnings.push("<!-- fling:paths-controls --> is removed because the block says nocontrols.");
3450
+ }
3451
+ if (panels > 1)
3452
+ warnings.push("More than one <!-- fling:paths-controls -->: only the first is used.");
3453
+ return { ok: errors.length === 0, block: block.kind, options: block.options, warnings, errors };
3454
+ }
3455
+
3456
+ // ../../packages/shared/src/paths-templates.ts
3457
+ var PATHS_TEMPLATE_NAMES = ["plans", "docs", "reports", "minimal"];
3458
+ var PATHS_TEMPLATE_DESCRIPTIONS = {
3459
+ plans: "a board of plans, one card per ticket folder (e.g. PAY-412), newest first",
3460
+ docs: "a hub of documentation sections, A\u2013Z",
3461
+ reports: "an archive of reports by date, newest first, the date shown first",
3462
+ minimal: "a plain page with Fling's default list"
3463
+ };
3464
+ var DEFAULT_TITLES = {
3465
+ plans: "Plans",
3466
+ docs: "Docs",
3467
+ reports: "Reports",
3468
+ minimal: "Paths"
3469
+ };
3470
+ var ESCAPES = {
3471
+ "&": "&amp;",
3472
+ "<": "&lt;",
3473
+ ">": "&gt;",
3474
+ '"': "&quot;",
3475
+ "'": "&#39;"
3476
+ };
3477
+ var esc = (value) => value.replace(/[&<>"']/g, (c) => ESCAPES[c] ?? c);
3478
+ var BASE_CSS = `:root{color-scheme:light dark;--bg:#f7f7f8;--fg:#1c1c21;--muted:#5d5d68;--card:#fff;--line:#e2e2e7;--fling-paths-accent:#2f5fc4}
3479
+ @media (prefers-color-scheme:dark){:root{--bg:#141417;--fg:#ececf0;--muted:#a0a0ab;--card:#1d1d22;--line:#2e2e35;--fling-paths-accent:#8ab0ff}}
3480
+ *{box-sizing:border-box}
3481
+ body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}
3482
+ main{max-width:var(--width,56rem);margin:0 auto;padding:clamp(1.5rem,5vw,3.5rem) 1.25rem}
3483
+ header{margin-bottom:1.75rem}
3484
+ h1{font-size:clamp(1.6rem,4vw,2.2rem);letter-spacing:-.015em;margin:0 0 .35rem}
3485
+ header p{margin:0;color:var(--muted)}
3486
+ a{color:var(--fling-paths-accent)}
3487
+ .empty{color:var(--muted);padding:2rem;text-align:center;border:1px dashed var(--line);border-radius:.75rem}`;
3488
+ function page(title, intro2, css, body) {
3489
+ const t = esc(title);
3490
+ return `<!doctype html>
3491
+ <html lang="en">
3492
+ <head>
3493
+ <meta charset="utf-8">
3494
+ <meta name="viewport" content="width=device-width, initial-scale=1">
3495
+ <meta name="color-scheme" content="light dark">
3496
+ <title>${t}</title>
3497
+ <style>
3498
+ ${BASE_CSS}
3499
+ ${css}
3500
+ </style>
3501
+ </head>
3502
+ <body>
3503
+ <main>
3504
+ <header>
3505
+ <h1>${t}</h1>
3506
+ <p>${intro2}</p>
3507
+ </header>
3508
+ <!-- fling:paths-controls -->
3509
+ ${body}
3510
+ </main>
3511
+ </body>
3512
+ </html>
3513
+ `;
3514
+ }
3515
+ var TEMPLATES2 = {
3516
+ plans: (title) => page(
3517
+ title,
3518
+ "One plan per ticket, newest first.",
3519
+ `.board{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,16rem),1fr));gap:1rem}
3520
+ .card{background:var(--card);border:1px solid var(--line);border-radius:.75rem;transition:border-color .15s}
3521
+ .card:hover{border-color:var(--fling-paths-accent)}
3522
+ .card a{display:flex;flex-direction:column;gap:.4rem;height:100%;padding:1rem 1.1rem;color:inherit;text-decoration:none}
3523
+ .ticket{align-self:start;font:600 .8rem/1 ui-monospace,monospace;color:var(--fling-paths-accent);background:color-mix(in srgb,var(--fling-paths-accent) 12%,transparent);padding:.3rem .5rem;border-radius:.4rem}
3524
+ .card h2{font-size:1.05rem;line-height:1.35;margin:0}
3525
+ .card time{margin-top:auto;font-size:.85rem;color:var(--muted)}`,
3526
+ `<div class="board">
3527
+ <!-- fling:paths sort=updated -->
3528
+ <article class="card"><a href="{{href}}"><span class="ticket">{{folder}}</span><h2>{{title}}</h2><time datetime="{{updated}}">{{date}}</time></a></article>
3529
+ <!-- fling:empty -->
3530
+ <p class="empty">No plans yet. Publish each plan to a folder named after its ticket, such as PAY-412.</p>
3531
+ <!-- /fling:paths -->
3532
+ </div>`
3533
+ ),
3534
+ docs: (title) => page(
3535
+ title,
3536
+ "Every section, A\u2013Z.",
3537
+ `.sections{list-style:none;margin:0;padding:0;border-top:1px solid var(--line)}
3538
+ .sections li{border-bottom:1px solid var(--line)}
3539
+ .sections a{display:flex;flex-wrap:wrap;align-items:baseline;gap:.25rem 1rem;padding:.9rem .25rem;color:inherit;text-decoration:none}
3540
+ .sections a:hover .name{color:var(--fling-paths-accent);text-decoration:underline}
3541
+ .name{font-weight:600;flex:1 1 16rem}
3542
+ .meta{font-size:.85rem;color:var(--muted)}
3543
+ .meta code{font-family:ui-monospace,monospace}`,
3544
+ `<ul class="sections">
3545
+ <!-- fling:paths sort=folder -->
3546
+ <li><a href="{{href}}"><span class="name">{{title}}</span><span class="meta"><code>{{folder}}</code> \xB7 updated <time datetime="{{updated}}">{{date}}</time></span></a></li>
3547
+ <!-- fling:empty -->
3548
+ <li class="empty">No sections yet. Publish each section to its own folder.</li>
3549
+ <!-- /fling:paths -->
3550
+ </ul>`
3551
+ ),
3552
+ reports: (title) => page(
3553
+ title,
3554
+ "Every report, newest first.",
3555
+ `.archive{list-style:none;margin:0;padding:0;display:grid;gap:.75rem}
3556
+ .report{display:grid;grid-template-columns:8.5rem 1fr;align-items:baseline;gap:.25rem 1.25rem;background:var(--card);border:1px solid var(--line);border-radius:.75rem;padding:1rem 1.1rem}
3557
+ @media (width < 34rem){.report{grid-template-columns:1fr}}
3558
+ .report time{font-size:1.15rem;font-weight:700;font-variant-numeric:tabular-nums}
3559
+ .report a{font-weight:600;text-decoration:none}
3560
+ .report a:hover{text-decoration:underline}
3561
+ .report small{grid-column:-2;color:var(--muted);font-family:ui-monospace,monospace}`,
3562
+ `<ol class="archive">
3563
+ <!-- fling:paths sort=updated -->
3564
+ <li class="report"><time datetime="{{updated}}">{{date}}</time><a href="{{href}}">{{title}}</a><small>{{folder}}</small></li>
3565
+ <!-- fling:empty -->
3566
+ <li class="empty">No reports yet. Publish each report to its own folder, such as 2026-11-05.</li>
3567
+ <!-- /fling:paths -->
3568
+ </ol>`
3569
+ ),
3570
+ minimal: (title) => `<!doctype html>
3571
+ <html lang="en">
3572
+ <head>
3573
+ <meta charset="utf-8">
3574
+ <meta name="viewport" content="width=device-width, initial-scale=1">
3575
+ <meta name="color-scheme" content="light dark">
3576
+ <title>${esc(title)}</title>
3577
+ <style>
3578
+ body{max-width:44rem;margin:0 auto;padding:3rem 1.25rem;font:16px/1.5 system-ui,sans-serif}
3579
+ </style>
3580
+ </head>
3581
+ <body>
3582
+ <h1>${esc(title)}</h1>
3583
+ <!-- fling:paths -->
3584
+ </body>
3585
+ </html>
3586
+ `
3587
+ };
3588
+ function pathsTemplate(name, opts = {}) {
3589
+ const title = opts.title?.trim() || DEFAULT_TITLES[name];
3590
+ return TEMPLATES2[name](title);
3591
+ }
3592
+ var isPathsTemplateName = (name) => PATHS_TEMPLATE_NAMES.includes(name);
3593
+
3127
3594
  // ../../packages/shared/src/read-files.ts
3128
3595
  var MCP_READ_MAX_BYTES = 1.5 * 1024 * 1024;
3129
3596
  function pageOfFiles(files, opts) {
@@ -3132,7 +3599,7 @@ function pageOfFiles(files, opts) {
3132
3599
  const found = new Set(selected.map((f) => f.path));
3133
3600
  const missing = opts.paths ? opts.paths.filter((p) => !found.has(p)) : [];
3134
3601
  const start = Math.max(0, Number.parseInt(opts.cursor ?? "0", 10) || 0);
3135
- const page = [];
3602
+ const page2 = [];
3136
3603
  const tooLarge = [];
3137
3604
  let used = 0;
3138
3605
  for (let i = start; i < selected.length; i++) {
@@ -3141,11 +3608,11 @@ function pageOfFiles(files, opts) {
3141
3608
  tooLarge.push(file);
3142
3609
  continue;
3143
3610
  }
3144
- if (used + file.size > opts.budget) return { page, tooLarge, missing, nextCursor: String(i) };
3145
- page.push(file);
3611
+ if (used + file.size > opts.budget) return { page: page2, tooLarge, missing, nextCursor: String(i) };
3612
+ page2.push(file);
3146
3613
  used += file.size;
3147
3614
  }
3148
- return { page, tooLarge, missing };
3615
+ return { page: page2, tooLarge, missing };
3149
3616
  }
3150
3617
  function encodeForAgent(path3, bytes) {
3151
3618
  if (isScannablePath(path3) && !looksBinary(bytes)) {
@@ -3163,8 +3630,9 @@ function encodeForAgent(path3, bytes) {
3163
3630
  function describeReadFiles(name, result) {
3164
3631
  const read = result.files.filter((f) => !f.tooLarge);
3165
3632
  const big = result.files.filter((f) => f.tooLarge);
3633
+ const of = result.path === void 0 ? name : `the Path /${result.path}/ of ${name}`;
3166
3634
  const lines = [
3167
- `${read.length} file(s) of ${name}, version ${result.versionId}: ${read.map((f) => f.path).join(", ") || "(none)"}.`
3635
+ `${read.length} file(s) of ${of}, version ${result.versionId}${result.path === void 0 ? "" : " (paths within the Path)"}: ${read.map((f) => f.path).join(", ") || "(none)"}.`
3168
3636
  ];
3169
3637
  if (big.length) {
3170
3638
  lines.push(
@@ -3178,7 +3646,7 @@ function describeReadFiles(name, result) {
3178
3646
  );
3179
3647
  }
3180
3648
  lines.push(
3181
- `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.`
3649
+ result.path === void 0 ? `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.` : `To change the Path, call fling_publish with path "${result.path}", base "${result.versionId}" and only the files you changed or added (and \`remove\` for files to delete), named within the Path: every other file of it is kept.`
3182
3650
  );
3183
3651
  return lines.join("\n");
3184
3652
  }
@@ -3231,14 +3699,20 @@ var listHtml = z14.boolean().optional().describe(
3231
3699
  "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"
3232
3700
  );
3233
3701
  var siteName = z14.string().min(1).max(200).describe('Site name, e.g. "q3-report"');
3234
- 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.';
3702
+ function describePublishHints(hints) {
3703
+ if (!hints?.length) return "";
3704
+ return `
3705
+ Worth doing:
3706
+ - ${hints.map((h) => h.message).join("\n- ")}`;
3707
+ }
3708
+ var PATH_PUBLISH_NOTE = `With \`path\` (one top-level folder, e.g. "PAY-412"), the files are published as that folder's Path: they go to <site>/PAY-412/\u2026 (give their paths relative to the folder, its page being "index.html"), and the rest of the site is untouched. A Path has its own versions, rollback, owners and publishers, and is made on first use (you own it). Use it for one plan, spec, report or week's page on a shared team site; teammates publishing other Paths never collide. fling_paths lists a site's Paths. The site's home page lists them by default when it has no index.html, or as a custom page with a Paths block (fling_paths_template); an index.html without a block lists none: settle which with the user (see fling_paths).`;
3235
3709
  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.";
3236
3710
  var base = z14.string().min(1).max(100).optional().describe(
3237
3711
  "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`"
3238
3712
  );
3239
3713
  var remove = z14.array(z14.string().min(1).max(1024)).max(MAX_FILES).optional().describe("With `base`: the paths of the files to delete from the site");
3240
3714
  var publishPath = z14.string().min(1).max(1024).optional().describe(
3241
- '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'
3715
+ 'Publish as the Path at this top-level folder of the site, e.g. "PAY-412" (served at <site>/PAY-412/); the rest of the site is untouched. File paths are relative to it. Omit to publish the site itself'
3242
3716
  );
3243
3717
  var sitePath = z14.string().min(1).max(1024).describe(
3244
3718
  '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")'
@@ -3284,6 +3758,53 @@ function describeResponses(exported, opts = {}) {
3284
3758
  ...opts.responsesPage ? [`Progress and downloads (CSV, JSONL): ${opts.responsesPage}`] : []
3285
3759
  ].join("\n");
3286
3760
  }
3761
+ var PATHS_TEMPLATES_LIST = PATHS_TEMPLATE_NAMES.map(
3762
+ (n) => `\`${n}\` (${PATHS_TEMPLATE_DESCRIPTIONS[n]})`
3763
+ ).join(", ");
3764
+ var PATHS_BLOCK_FORMAT = `A Paths block is either the bare marker \`<!-- fling:paths -->\` (Fling's default list, styled by the page) or a template pair \`<!-- fling:paths sort=updated --> item <!-- fling:empty --> shown when there are none <!-- /fling:paths -->\`, whose item repeats once per Path with the placeholders ${PATHS_PLACEHOLDERS.map((p) => `{{${p}}}`).join(" ")} (HTML-escaped; the \`<!-- fling:empty -->\` part is optional). Put dates in \`<time datetime="{{updated}}">{{date}}</time>\`: within a week they read as how long ago ("2 hours ago") once the page loads. Options go in the opening marker: ${PATHS_OPTIONS.join(", ")}. \`<!-- fling:paths-controls -->\` places the search and sort controls. Only the first block is filled.`;
3765
+ var PATHS_HOME_CHOICE = `A site with Paths has a home page (its root index.html) that can list them. Make the choice explicit with the user: (a) the default: with no root index.html, Fling serves a listing of the live Paths, and a site whose first publish is a Path gets one (the \`minimal\` template); nothing to do. (b) a custom page: the site's own index.html with a Paths block, which Fling fills with the live Paths whenever one changes. Start from fling_paths_template (${PATHS_TEMPLATE_NAMES.join(", ")}), adapt it freely while keeping the block, check it with fling_paths_check, then publish it as the site root (without \`path\`). (c) an index.html without a Paths block is served as it is and lists no Paths; publishing then says so (the paths_index_missing hint).`;
3766
+ function describePathsHome(home) {
3767
+ if (home === "block") {
3768
+ return "Home page: the site's index.html has a Paths block, which lists the live Paths. To change how it looks, edit that page keeping the block (fling_read_files), check it with fling_paths_check and publish it as the site root (without `path`).";
3769
+ }
3770
+ if (home === "default") {
3771
+ return "Home page: Fling's default listing of the live Paths (the site has no index.html of its own). For a custom page, start from fling_paths_template, check it with fling_paths_check and publish it as the site's index.html (without `path`).";
3772
+ }
3773
+ if (home === "own") {
3774
+ return 'Home page: the site\'s own index.html, which has no Paths block, so it lists none of them. Offer the user a custom page with a block (fling_paths_template) or the default listing (remove index.html: fling_publish with `base` and `remove: ["index.html"]`).';
3775
+ }
3776
+ return 'Home page: if the site\'s home page has no Paths block, offer a custom one (fling_paths_template) or the default (remove index.html: fling_publish with `base` and `remove: ["index.html"]`).';
3777
+ }
3778
+ function describePathLine(p) {
3779
+ const state = p.archived ? "archived" : p.currentVersionId ? `version ${p.currentVersionId}` : "nothing published";
3780
+ return `- /${p.folder}/${p.title ? ` "${p.title}"` : ""}: ${p.url} (${state}, updated ${p.updatedAt}${p.canPublish ? ", you can publish it" : ""})`;
3781
+ }
3782
+ function describePaths(name, paths2, opts = {}) {
3783
+ const head = paths2.length ? `${paths2.length} Path${paths2.length === 1 ? "" : "s"} on ${name}:` : `${name} has no Paths yet. Publish a folder as one with \`path\` (e.g. "PAY-412").`;
3784
+ return [head, ...paths2.map(describePathLine), describePathsHome(opts.home)].join("\n");
3785
+ }
3786
+ function describePathDetail(name, path3) {
3787
+ const roles = path3.owners ? [`Owners: ${whoHolds(path3.owners)}. Publishers: ${whoHolds(path3.publishers)}.`] : [];
3788
+ const versions = path3.versions.slice(0, 10).map(
3789
+ (v) => `- ${v.id}${v.id === path3.currentVersionId ? " (current)" : ""}: ${v.fileCount} files, ${v.createdAt}${v.publishedBy ? `, by ${v.publishedBy}` : ""}`
3790
+ );
3791
+ return [
3792
+ `/${path3.folder}/ of ${name}${path3.title ? ` "${path3.title}"` : ""}: ${path3.url}${path3.archived ? " (archived)" : ""}${path3.canPublish ? " \u2014 you can publish it" : ""}`,
3793
+ ...roles,
3794
+ "Versions (newest first; roll back with fling_rollback and this `path`):",
3795
+ ...versions.length ? versions : ["(none)"]
3796
+ ].join("\n");
3797
+ }
3798
+ function describePathsCheck(check2) {
3799
+ const head = check2.ok ? `The page has a ${check2.block === "template" ? "template" : "bare"} Paths block${check2.options.length ? ` (options: ${check2.options.join(", ")})` : ""}: Fling fills it with the live Paths.` : "The page has no Paths block Fling can fill:";
3800
+ return [
3801
+ head,
3802
+ ...check2.errors.map((e) => `- Error: ${e}`),
3803
+ ...check2.warnings.map((w) => `- Warning: ${w}`),
3804
+ ...check2.ok ? ["Publish it as the site's root index.html (without `path`)."] : []
3805
+ ].join("\n");
3806
+ }
3807
+ var pathFolder = z14.string().min(1).max(MAX_PATH_FOLDER_LENGTH + 2);
3287
3808
  var encoding = z14.enum(["utf8", "base64"]).optional().describe('How `content` is encoded: "utf8" (default) for text, "base64" for binary files');
3288
3809
  var spa = z14.boolean().optional().describe(
3289
3810
  "Single-page app: serve index.html for unknown paths (client-side routing). Omit to keep the current setting"
@@ -3294,7 +3815,7 @@ function mcpToolsFor(w) {
3294
3815
  const isPublic = z14.boolean().optional().describe(
3295
3816
  "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"
3296
3817
  );
3297
- const access2 = z14.enum(ACCESS_LEVELS).optional().describe(
3818
+ const access3 = z14.enum(ACCESS_LEVELS).optional().describe(
3298
3819
  `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`
3299
3820
  );
3300
3821
  const accessEmails = emails.optional().describe('With access "people" (or "groups"): the addresses that may open it');
@@ -3323,8 +3844,13 @@ function mcpToolsFor(w) {
3323
3844
  info: tool({
3324
3845
  name: "fling_info",
3325
3846
  title: "Site details",
3326
- description: "Show one site: URL, visibility, share link, current version and version history (ids for fling_rollback).",
3327
- inputSchema: { name: siteName },
3847
+ description: "Show one site: URL, visibility, share link, current version and version history (ids for fling_rollback). With `path` (one of the site's Paths, from fling_paths), show that Path instead: its URL, owners, publishers and its own versions.",
3848
+ inputSchema: {
3849
+ name: siteName,
3850
+ path: pathFolder.optional().describe(
3851
+ `One of the site's Paths, its folder (e.g. "PAY-412"): show it instead of the site`
3852
+ )
3853
+ },
3328
3854
  annotations: { readOnlyHint: true, openWorldHint: false }
3329
3855
  }),
3330
3856
  files: tool({
@@ -3333,7 +3859,10 @@ function mcpToolsFor(w) {
3333
3859
  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).",
3334
3860
  inputSchema: {
3335
3861
  name: siteName,
3336
- versionId: z14.string().min(1).max(100).optional().describe("Version id (from fling_info); omit for the version being served")
3862
+ versionId: z14.string().min(1).max(100).optional().describe("Version id (from fling_info); omit for the version being served"),
3863
+ path: pathFolder.optional().describe(
3864
+ "One of the site's Paths, its folder (e.g. \"PAY-412\"): list that Path's version (ids from fling_info with this `path`) instead; its files are under the folder"
3865
+ )
3337
3866
  },
3338
3867
  annotations: { readOnlyHint: true, openWorldHint: false }
3339
3868
  }),
@@ -3344,7 +3873,7 @@ function mcpToolsFor(w) {
3344
3873
  inputSchema: {
3345
3874
  name: siteName,
3346
3875
  paths: z14.array(z14.string().min(1).max(1024)).max(MAX_FILES).optional().describe('Only these files (site paths, e.g. "index.html"); omit for all of them'),
3347
- path: z14.string().min(1).max(1024).optional().describe('Only the files under this folder of the site, e.g. "30-10-26"'),
3876
+ path: z14.string().min(1).max(1024).optional().describe('The files of this Path of the site, e.g. "PAY-412" (paths relative to it)'),
3348
3877
  cursor: z14.string().max(20).optional().describe("From the previous call, for the next files"),
3349
3878
  versionId: z14.string().min(1).max(100).optional().describe("Version id (from fling_info); omit for the version being served")
3350
3879
  },
@@ -3376,13 +3905,13 @@ function mcpToolsFor(w) {
3376
3905
  base,
3377
3906
  remove,
3378
3907
  spa,
3379
- access: access2,
3908
+ access: access3,
3380
3909
  emails: accessEmails,
3381
3910
  groups: accessGroups,
3382
3911
  public: isPublic,
3383
3912
  listHtml,
3384
3913
  forms: formsConfig.nullable().optional().describe(
3385
- `The site's forms config (as ${FORMS_FILE}; fling_forms_template, fling_forms_check), for structured feedback on the review page. null removes the forms. Omitted: a publish with \`path\` or \`base\` keeps the site's forms, any other publish has none`
3914
+ `The site's forms config (as ${FORMS_FILE}; fling_forms_template, fling_forms_check), for structured feedback on the review page. null removes the forms. Omitted: a publish on a \`base\` keeps the site's forms, a publish with \`path\` leaves them as they are (forms are the site's, never a Path's: \`forms\` with \`path\` is refused), any other publish has none`
3386
3915
  )
3387
3916
  },
3388
3917
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true }
@@ -3397,7 +3926,7 @@ function mcpToolsFor(w) {
3397
3926
  base,
3398
3927
  remove,
3399
3928
  spa,
3400
- access: access2,
3929
+ access: access3,
3401
3930
  emails: accessEmails,
3402
3931
  groups: accessGroups,
3403
3932
  public: isPublic
@@ -3420,7 +3949,7 @@ function mcpToolsFor(w) {
3420
3949
  uploadFinish: tool({
3421
3950
  name: "fling_upload_finish",
3422
3951
  title: "Finish a chunked upload",
3423
- 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\`.`,
3952
+ 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\`, that Path does; with \`base\`, plus the rest of that version). ${LIST_HTML} The upload stays open, so just call fling_upload_finish again with \`listHtml\`. The result includes \`warnings\`.`,
3424
3953
  inputSchema: {
3425
3954
  uploadId: z14.string().min(1).max(100).describe("From fling_upload_begin"),
3426
3955
  listHtml
@@ -3699,13 +4228,42 @@ function mcpToolsFor(w) {
3699
4228
  },
3700
4229
  annotations: { idempotentHint: true, openWorldHint: false }
3701
4230
  }),
4231
+ paths: tool({
4232
+ name: "fling_paths",
4233
+ title: "List a site's Paths",
4234
+ description: `List a site's Paths: the top-level folders (e.g. one per ticket, PAY-412) published on their own with \`path\`, each with its title, URL, current version, when it was updated, whether it is archived and whether the user can publish to it. Use it before publishing to a shared team site, to find a Path to change or roll back (fling_info, fling_files and fling_rollback take \`path\`), or when the user asks what is on it. ${PATHS_HOME_CHOICE} ${PATHS_BLOCK_FORMAT}`,
4235
+ inputSchema: { name: siteName },
4236
+ annotations: { readOnlyHint: true, openWorldHint: false }
4237
+ }),
4238
+ pathsTemplate: tool({
4239
+ name: "fling_paths_template",
4240
+ title: "Start a Paths home page",
4241
+ description: `A starter home page (index.html) for a site with Paths, with its Paths block in place: Fling fills it with the site's live Paths. Templates: ${PATHS_TEMPLATES_LIST}. Use it when the user wants a custom home page listing their Paths rather than the default one. Adapt it freely (wording, layout, styling) while keeping the block, check it with fling_paths_check, then publish it as the site root (without \`path\`). Needs no sign-in. ${PATHS_HOME_CHOICE} ${PATHS_BLOCK_FORMAT}`,
4242
+ inputSchema: {
4243
+ template: z14.enum(PATHS_TEMPLATE_NAMES).describe("Which starter"),
4244
+ title: z14.string().min(1).max(200).optional().describe(`The page's title and heading (e.g. "Payments plans")`)
4245
+ },
4246
+ annotations: { readOnlyHint: true, openWorldHint: false }
4247
+ }),
4248
+ pathsCheck: tool({
4249
+ name: "fling_paths_check",
4250
+ title: "Check a Paths home page",
4251
+ description: `Check a site's home page (index.html) before publishing it, for the Paths block Fling fills with the site's Paths: whether there is one, bare or template, its options, and warnings (unknown options or placeholders, an unclosed template, an item without a link). Returns \`ok\`, \`errors\` and \`warnings\`. Use it on every custom home page for a site with Paths. Needs no sign-in. ${PATHS_HOME_CHOICE} ${PATHS_BLOCK_FORMAT}`,
4252
+ inputSchema: {
4253
+ html: z14.string().min(1).max(MCP_PUBLISH_MAX_BYTES).describe("The page, as HTML")
4254
+ },
4255
+ annotations: { readOnlyHint: true, openWorldHint: false }
4256
+ }),
3702
4257
  rollback: tool({
3703
4258
  name: "fling_rollback",
3704
4259
  title: "Roll back",
3705
- description: "Point a site at an earlier version (ids from fling_info). Instant; nothing is uploaded.",
4260
+ description: "Point a site at an earlier version (ids from fling_info). Instant; nothing is uploaded. With `path`, roll back only that Path (ids from fling_info with the same `path`); the rest of the site and its other Paths stay as they are.",
3706
4261
  inputSchema: {
3707
4262
  name: siteName,
3708
- versionId: z14.string().min(1).max(100).describe("Version id to serve")
4263
+ versionId: z14.string().min(1).max(100).describe("Version id to serve"),
4264
+ path: pathFolder.optional().describe(
4265
+ `One of the site's Paths, its folder (e.g. "PAY-412"): roll back only that Path`
4266
+ )
3709
4267
  },
3710
4268
  annotations: { idempotentHint: true, openWorldHint: false }
3711
4269
  })
@@ -3771,7 +4329,7 @@ function describeComments(list, opts = {}) {
3771
4329
  var READY_TIMEOUT_MS = 15e3;
3772
4330
  var NOT_READY_NOTE = "it can take a few more seconds to appear everywhere; reload if you see a 404";
3773
4331
  var isReachableStatus = (status) => status >= 200 && status < 400 || status === 401 || status === 403;
3774
- var defaultSleep = (ms) => new Promise((resolve7) => setTimeout(resolve7, ms));
4332
+ var defaultSleep = (ms) => new Promise((resolve8) => setTimeout(resolve8, ms));
3775
4333
  async function waitUntilReachable(url, opts = {}) {
3776
4334
  const timeoutMs = opts.timeoutMs ?? READY_TIMEOUT_MS;
3777
4335
  const doFetch = opts.fetch ?? ((u, init) => fetch(u, init));
@@ -3804,10 +4362,10 @@ async function waitUntilReachable(url, opts = {}) {
3804
4362
  interval = Math.min(interval * 1.25, 2e3);
3805
4363
  }
3806
4364
  }
3807
- function probeUrl(siteUrl2, paths) {
4365
+ function probeUrl(siteUrl2, paths2) {
3808
4366
  const base2 = siteUrl2.endsWith("/") ? siteUrl2 : `${siteUrl2}/`;
3809
- if (paths.length === 0 || paths.includes("index.html")) return base2;
3810
- const pick = paths.find((p) => /\.html?$/i.test(p)) ?? paths[0];
4367
+ if (paths2.length === 0 || paths2.includes("index.html")) return base2;
4368
+ const pick = paths2.find((p) => /\.html?$/i.test(p)) ?? paths2[0];
3811
4369
  return base2 + pick.split("/").map(encodeURIComponent).join("/");
3812
4370
  }
3813
4371
  function readinessWaitDisabled(env) {
@@ -3921,7 +4479,7 @@ var LiveServerMessageSchema = z16.discriminatedUnion("type", [
3921
4479
  ]);
3922
4480
 
3923
4481
  // src/cli.ts
3924
- import chalk14 from "chalk";
4482
+ import chalk16 from "chalk";
3925
4483
  import { Command, Option } from "commander";
3926
4484
 
3927
4485
  // src/commands/auth.ts
@@ -3971,7 +4529,7 @@ function exitCodeFor(error) {
3971
4529
  // package.json
3972
4530
  var package_default = {
3973
4531
  name: "@nearform/fling",
3974
- version: "0.8.0",
4532
+ version: "0.10.0",
3975
4533
  license: "MIT",
3976
4534
  description: "Fling a folder of static files at a Fling server and get a link back. CLI + MCP server.",
3977
4535
  type: "module",
@@ -4025,6 +4583,7 @@ var package_default = {
4025
4583
  // src/package-info.ts
4026
4584
  var PACKAGE_NAME = package_default.name;
4027
4585
  var VERSION = package_default.version;
4586
+ var NODE_ENGINE = package_default.engines.node;
4028
4587
  var BIN_NAME = Object.keys(package_default.bin)[0] ?? "fling";
4029
4588
  var NPX_COMMAND = `npx ${package_default.name}`;
4030
4589
 
@@ -4066,6 +4625,14 @@ function createApiClient(options) {
4066
4625
  const serverUrl = options.serverUrl.replace(/\/+$/, "");
4067
4626
  const fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
4068
4627
  const timeoutMs = options.timeoutMs ?? 6e4;
4628
+ let knownRelease = options.release;
4629
+ async function noteRelease(response) {
4630
+ const release = response.headers?.get?.(RELEASE_HEADER)?.trim();
4631
+ if (!release || release.length > 64 || release === knownRelease) return;
4632
+ knownRelease = release;
4633
+ await options.onRelease?.(release).catch(() => {
4634
+ });
4635
+ }
4069
4636
  async function request(method, path3, reqOptions = {}) {
4070
4637
  const url = buildUrl(serverUrl, path3, reqOptions.query);
4071
4638
  const headers = {
@@ -4091,6 +4658,7 @@ function createApiClient(options) {
4091
4658
  cause
4092
4659
  });
4093
4660
  }
4661
+ await noteRelease(response);
4094
4662
  const raw = await readBody(response);
4095
4663
  if (!response.ok) throw errorFromResponse(response.status, raw, method, path3);
4096
4664
  if (!reqOptions.schema) return raw;
@@ -4184,7 +4752,7 @@ function describeCause(cause) {
4184
4752
  // src/config.ts
4185
4753
  import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
4186
4754
  import { homedir } from "node:os";
4187
- import { join } from "node:path";
4755
+ import { delimiter, join } from "node:path";
4188
4756
  import { z as z17 } from "zod";
4189
4757
  var CONFIG_DIR_NAME = ".fling";
4190
4758
  var CONFIG_FILE_NAME = "config.json";
@@ -4196,7 +4764,11 @@ var ENV = {
4196
4764
  /** Where to look for files given by name (the .mcpb extension sets it from its settings). */
4197
4765
  downloadsDir: "FLING_DOWNLOADS_DIR",
4198
4766
  /** A server the local MCP server adds to the remembered ones on start (the .mcpb bakes it in). */
4199
- addServer: "FLING_ADD_SERVER"
4767
+ addServer: "FLING_ADD_SERVER",
4768
+ /** Set (to anything) to turn off the line saying a newer Fling release is out (update-check.ts). */
4769
+ noUpdateCheck: "FLING_NO_UPDATE_CHECK",
4770
+ /** Set by CI systems; the update check stays quiet there too. */
4771
+ ci: "CI"
4200
4772
  };
4201
4773
  var serverEntrySchema = z17.object({
4202
4774
  /** What the server said about itself at login. A copy this CLI can't read is just refetched. */
@@ -4218,6 +4790,19 @@ function fromEnv(value) {
4218
4790
  function serverToAdd(env = process.env) {
4219
4791
  return fromEnv(env[ENV.addServer]);
4220
4792
  }
4793
+ function updateCheckSilenced(env = process.env) {
4794
+ return Boolean(fromEnv(env[ENV.noUpdateCheck]) || fromEnv(env[ENV.ci]));
4795
+ }
4796
+ function envOverrides(env = process.env) {
4797
+ return {
4798
+ configDir: Boolean(env[ENV.configDir]),
4799
+ url: Boolean(fromEnv(env[ENV.url])),
4800
+ apiKey: Boolean(fromEnv(env[ENV.apiKey]))
4801
+ };
4802
+ }
4803
+ function pathDirs(env = process.env) {
4804
+ return (env.PATH ?? env.Path ?? "").split(delimiter).filter(Boolean);
4805
+ }
4221
4806
  function resolveConfigDir(env = process.env) {
4222
4807
  return env[ENV.configDir] ?? join(env.HOME ?? homedir(), CONFIG_DIR_NAME);
4223
4808
  }
@@ -4435,6 +5020,19 @@ async function loadInstanceWith(store, config, fetch2, capability) {
4435
5020
  return stored;
4436
5021
  }
4437
5022
  }
5023
+ async function recordRelease(store, serverUrl, release) {
5024
+ const entry = (await store.load()).servers[serverUrl];
5025
+ if (!entry?.instance || entry.instance.release === release) return;
5026
+ await store.putServer(
5027
+ serverUrl,
5028
+ { instance: { ...entry.instance, release } },
5029
+ { makeCurrent: false }
5030
+ );
5031
+ }
5032
+ var releaseTracking = (store, config) => config.remembered && config.serverUrl ? {
5033
+ release: config.instance?.release,
5034
+ onRelease: (release) => recordRelease(store, config.serverUrl, release)
5035
+ } : {};
4438
5036
 
4439
5037
  // src/auth.ts
4440
5038
  var CALLBACK_PORT_START = 8765;
@@ -4504,8 +5102,8 @@ async function startCallbackServer(options = {}) {
4504
5102
  const timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS;
4505
5103
  let settle;
4506
5104
  let timer;
4507
- const result = new Promise((resolve7, reject) => {
4508
- settle = { resolve: resolve7, reject };
5105
+ const result = new Promise((resolve8, reject) => {
5106
+ settle = { resolve: resolve8, reject };
4509
5107
  }).finally(() => clearTimeout(timer));
4510
5108
  result.catch(() => {
4511
5109
  });
@@ -4566,7 +5164,7 @@ ${close ? "<script>setTimeout(function(){window.close()},800)</script>" : ""}
4566
5164
  res.end(html);
4567
5165
  }
4568
5166
  function listenOnFreePort(server) {
4569
- return new Promise((resolve7, reject) => {
5167
+ return new Promise((resolve8, reject) => {
4570
5168
  let port = CALLBACK_PORT_START;
4571
5169
  const tryNext = () => {
4572
5170
  if (port > CALLBACK_PORT_END) {
@@ -4588,7 +5186,7 @@ function listenOnFreePort(server) {
4588
5186
  });
4589
5187
  server.listen(port, "127.0.0.1", () => {
4590
5188
  server.removeAllListeners("error");
4591
- resolve7(port);
5189
+ resolve8(port);
4592
5190
  });
4593
5191
  };
4594
5192
  tryNext();
@@ -4606,20 +5204,20 @@ import chalk from "chalk";
4606
5204
  var skipWait = async () => ({ ready: true, waitedMs: 0, skipped: true });
4607
5205
  function createReadyWaiter(options = {}) {
4608
5206
  if (readinessWaitDisabled(options.env ?? process.env)) return skipWait;
4609
- return (url, paths, opts) => waitUntilReachable(probeUrl(url, paths), {
5207
+ return (url, paths2, opts) => waitUntilReachable(probeUrl(url, paths2), {
4610
5208
  timeoutMs: options.timeoutMs,
4611
5209
  fetch: options.fetch,
4612
5210
  onWaiting: opts?.onWaiting
4613
5211
  });
4614
5212
  }
4615
5213
  var SPINNER = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
4616
- async function waitWithSpinner(waiter, url, paths, io) {
5214
+ async function waitWithSpinner(waiter, url, paths2, io) {
4617
5215
  if (!waiter) return { ready: true, waitedMs: 0, skipped: true };
4618
5216
  let frame = 0;
4619
5217
  let timer;
4620
5218
  const draw = () => io.progress(`${chalk.cyan(SPINNER[frame++ % SPINNER.length])} Waiting for the edge\u2026`);
4621
5219
  try {
4622
- const result = await waiter(url, paths, {
5220
+ const result = await waiter(url, paths2, {
4623
5221
  onWaiting: () => {
4624
5222
  draw();
4625
5223
  timer = setInterval(draw, 100);
@@ -4830,7 +5428,9 @@ function requireClient(ctx) {
4830
5428
  return createApiClient({
4831
5429
  serverUrl,
4832
5430
  apiKey: ctx.config.apiKey,
4833
- fetch: ctx.fetch
5431
+ fetch: ctx.fetch,
5432
+ // A response saying the server runs another release updates the stored one.
5433
+ ...ctx.store ? releaseTracking(ctx.store, ctx.config) : {}
4834
5434
  });
4835
5435
  }
4836
5436
 
@@ -4895,10 +5495,10 @@ async function setVisibility(client, name, visibility) {
4895
5495
  const raw = await client.request("PATCH", sitePath2(name), { body: { visibility } });
4896
5496
  return siteAfter(client, name, raw);
4897
5497
  }
4898
- async function setAccess(client, name, access2, audience) {
5498
+ async function setAccess(client, name, access3, audience) {
4899
5499
  return client.request("PATCH", sitePath2(name), {
4900
5500
  schema: SiteWithAudienceSchema,
4901
- body: { access: access2, ...audience ? { audience } : {} }
5501
+ body: { access: access3, ...audience ? { audience } : {} }
4902
5502
  });
4903
5503
  }
4904
5504
  function accessLevelsEnabled(instance) {
@@ -4969,6 +5569,73 @@ async function setRoles(client, name, which, change) {
4969
5569
  });
4970
5570
  return withRoles(site);
4971
5571
  }
5572
+ function pathsEnabled(instance) {
5573
+ return instance?.capabilities.paths === true;
5574
+ }
5575
+ var pathOf = (name, folder) => `${sitePath2(name)}/paths/${encodeURIComponent(folder)}`;
5576
+ async function listPaths(client, name) {
5577
+ const res = await client.request("GET", `${sitePath2(name)}/paths`, { schema: PathListSchema });
5578
+ return res.paths;
5579
+ }
5580
+ function getPath(client, name, folder) {
5581
+ return client.request("GET", pathOf(name, folder), { schema: PathDetailSchema });
5582
+ }
5583
+ function rollbackPath(client, name, folder, versionId) {
5584
+ return client.request("POST", `${pathOf(name, folder)}/current`, {
5585
+ schema: SitePathSchema,
5586
+ body: { versionId }
5587
+ });
5588
+ }
5589
+ function archivePath(client, name, folder, archived) {
5590
+ return client.request("PATCH", pathOf(name, folder), {
5591
+ schema: SitePathSchema,
5592
+ body: { archived }
5593
+ });
5594
+ }
5595
+ async function deletePath(client, name, folder) {
5596
+ await client.request("DELETE", pathOf(name, folder));
5597
+ return { name, path: folder, deleted: true };
5598
+ }
5599
+ var pathWithRoles = (path3) => {
5600
+ if (!path3.owners) {
5601
+ throw new CliError(`You can't see who owns /${path3.folder}/`, {
5602
+ hint: "Only those on the site see its Paths' owners and publishers."
5603
+ });
5604
+ }
5605
+ return {
5606
+ ...path3,
5607
+ owners: path3.owners,
5608
+ publishers: path3.publishers ?? { emails: [], groups: [] }
5609
+ };
5610
+ };
5611
+ async function getPathRoles(client, name, folder) {
5612
+ return pathWithRoles(await getPath(client, name, folder));
5613
+ }
5614
+ async function setPathRoles(client, name, folder, which, change) {
5615
+ const current = await getPathRoles(client, name, folder);
5616
+ const path3 = await client.request("PATCH", pathOf(name, folder), {
5617
+ schema: SitePathSchema,
5618
+ body: { [which]: editRoleInput(current[which], change) }
5619
+ });
5620
+ return pathWithRoles(path3);
5621
+ }
5622
+ async function pathVersionFiles(client, name, folder, versionId) {
5623
+ const path3 = await getPath(client, name, folder);
5624
+ const id = versionId ?? path3.currentVersionId;
5625
+ if (!id) throw new CliError(`/${path3.folder}/ of ${name} has no published version yet`);
5626
+ const files = await client.request(
5627
+ "GET",
5628
+ `${sitePath2(name)}/versions/${encodeURIComponent(id)}/files`,
5629
+ { schema: VersionFilesSchema, query: { path: path3.folder } }
5630
+ );
5631
+ return {
5632
+ name,
5633
+ path: path3.folder,
5634
+ url: path3.url,
5635
+ current: id === path3.currentVersionId,
5636
+ ...files
5637
+ };
5638
+ }
4972
5639
  var groupPath = (slug) => `/api/groups/${encodeURIComponent(slug)}`;
4973
5640
  async function listGroups(client, opts = {}) {
4974
5641
  const res = await client.request("GET", "/api/groups", {
@@ -5326,7 +5993,7 @@ import { join as join2, relative, sep } from "node:path";
5326
5993
  import ignore from "ignore";
5327
5994
  var GIT_TIMEOUT_MS = 2e4;
5328
5995
  function git(cwd, args, input) {
5329
- return new Promise((resolve7) => {
5996
+ return new Promise((resolve8) => {
5330
5997
  try {
5331
5998
  const child = execFile(
5332
5999
  "git",
@@ -5334,14 +6001,14 @@ function git(cwd, args, input) {
5334
6001
  { timeout: GIT_TIMEOUT_MS, maxBuffer: 256 * 1024 * 1024, encoding: "utf8" },
5335
6002
  (error, stdout) => {
5336
6003
  const code = error?.code;
5337
- if (error && !(args[0] === "check-ignore" && code === 1)) resolve7(null);
5338
- else resolve7(stdout);
6004
+ if (error && !(args[0] === "check-ignore" && code === 1)) resolve8(null);
6005
+ else resolve8(stdout);
5339
6006
  }
5340
6007
  );
5341
- child.on("error", () => resolve7(null));
6008
+ child.on("error", () => resolve8(null));
5342
6009
  if (input !== void 0) child.stdin?.end(input);
5343
6010
  } catch {
5344
- resolve7(null);
6011
+ resolve8(null);
5345
6012
  }
5346
6013
  });
5347
6014
  }
@@ -5432,15 +6099,15 @@ var CHARSET_TYPES = /* @__PURE__ */ new Set([
5432
6099
  "image/svg+xml"
5433
6100
  ]);
5434
6101
  var SecretFilesError = class extends CliError {
5435
- constructor(paths) {
5436
- const shown2 = paths.slice(0, 20);
6102
+ constructor(paths2) {
6103
+ const shown3 = paths2.slice(0, 20);
5437
6104
  super(
5438
- `Not flung: ${paths.length === 1 ? "this is a secret file" : `these are ${paths.length} secret files`} (keys, certificates, credentials): ${shown2.join(", ")}${paths.length > shown2.length ? ` (+${paths.length - shown2.length} more)` : ""}. ${NEVER_PUBLISHED_NOTE}`,
6105
+ `Not flung: ${paths2.length === 1 ? "this is a secret file" : `these are ${paths2.length} secret files`} (keys, certificates, credentials): ${shown3.join(", ")}${paths2.length > shown3.length ? ` (+${paths2.length - shown3.length} more)` : ""}. ${NEVER_PUBLISHED_NOTE}`,
5439
6106
  {
5440
- hint: `Move ${paths.length === 1 ? "it" : "them"} out of the folder, or add ${paths.length === 1 ? "it" : "them"} to ${IGNORE_FILE} if the site does not need ${paths.length === 1 ? "it" : "them"}.`
6107
+ hint: `Move ${paths2.length === 1 ? "it" : "them"} out of the folder, or add ${paths2.length === 1 ? "it" : "them"} to ${IGNORE_FILE} if the site does not need ${paths2.length === 1 ? "it" : "them"}.`
5441
6108
  }
5442
6109
  );
5443
- this.paths = paths;
6110
+ this.paths = paths2;
5444
6111
  this.name = "SecretFilesError";
5445
6112
  }
5446
6113
  code = "secret_file";
@@ -5629,7 +6296,7 @@ function checkLimits(files, dir) {
5629
6296
  );
5630
6297
  }
5631
6298
  }
5632
- var hasRootIndex = (local) => local.files.some((f) => f.path === "index.html");
6299
+ var hasRootIndex = (local, markdown = false) => local.files.some((f) => f.path === "index.html") || markdown && markdownHomePages(local.files.map((f) => f.path)).has("");
5633
6300
  var htmlPages = (local) => local.files.filter((f) => isHtmlPath(f.path) && f.path !== "index.html");
5634
6301
  var TITLE_BYTES = 65536;
5635
6302
  async function readHead(file) {
@@ -5828,14 +6495,14 @@ async function deploy(deps, opts) {
5828
6495
  try {
5829
6496
  return await deployOnce(deps, opts, name, local);
5830
6497
  } catch (error) {
5831
- if (opts.path === void 0 || !baseChanged(error)) throw error;
6498
+ if (opts.path === void 0 || opts.base !== void 0 || !baseChanged(error)) throw error;
5832
6499
  return deployOnce(deps, opts, name, local);
5833
6500
  }
5834
6501
  }
5835
6502
  async function deployOnce(deps, opts, name, local) {
5836
6503
  const onBaseChanged = (error) => opts.base !== void 0 && baseChanged(error) ? new BaseChangedError(name, opts.base) : error;
5837
6504
  const folder = opts.path;
5838
- const prefix = folder ?? opts.within;
6505
+ const prefix = folder;
5839
6506
  const sitePath4 = (file) => prefix === void 0 ? file : `${prefix}/${file}`;
5840
6507
  const base2 = {
5841
6508
  name,
@@ -5853,7 +6520,7 @@ async function deployOnce(deps, opts, name, local) {
5853
6520
  ...folder === void 0 ? {} : { path: folder },
5854
6521
  ...opts.base === void 0 ? {} : {
5855
6522
  base: opts.base,
5856
- ...opts.partial ? { partial: true, remove: opts.remove ?? [] } : {}
6523
+ ...opts.partial ? { partial: true, remove: (opts.remove ?? []).map(sitePath4) } : {}
5857
6524
  },
5858
6525
  ...opts.spa !== void 0 ? { spa: opts.spa } : {},
5859
6526
  ...opts.public ? { visibility: "public" } : {},
@@ -5874,7 +6541,8 @@ async function deployOnce(deps, opts, name, local) {
5874
6541
  versionId: res.versionId,
5875
6542
  site: res.site,
5876
6543
  uploaded: 0,
5877
- first: false
6544
+ first: false,
6545
+ hints: res.hints ?? []
5878
6546
  };
5879
6547
  }
5880
6548
  const byPath = new Map(local.files.map((f) => [sitePath4(f.path), f]));
@@ -5905,7 +6573,8 @@ async function deployOnce(deps, opts, name, local) {
5905
6573
  versionId: fin.versionId,
5906
6574
  site: fin.site,
5907
6575
  uploaded: uploads.length,
5908
- first: fin.first === true
6576
+ first: fin.first === true,
6577
+ hints: fin.hints ?? []
5909
6578
  };
5910
6579
  }
5911
6580
  async function uploadWithRetry(deps, file, url, headers) {
@@ -5953,8 +6622,8 @@ async function readFormsFile(dir) {
5953
6622
  });
5954
6623
  }
5955
6624
  }
5956
- function requireValidForms(config, paths, where, bin) {
5957
- const check2 = checkFormsConfig(config, paths);
6625
+ function requireValidForms(config, paths2, where, bin) {
6626
+ const check2 = checkFormsConfig(config, paths2);
5958
6627
  if (!check2.ok) {
5959
6628
  throw new CliError(
5960
6629
  `${where} is not a forms config Fling can use:
@@ -5967,6 +6636,12 @@ function requireValidForms(config, paths, where, bin) {
5967
6636
  async function planForms(opts) {
5968
6637
  const file = opts.inline === void 0 ? await readFormsFile(opts.dir) : null;
5969
6638
  const raw = opts.inline !== void 0 ? opts.inline : file?.config;
6639
+ if (opts.path !== void 0) {
6640
+ if (raw === void 0 || raw === null) return { send: void 0, check: null };
6641
+ throw new CliError(`Forms belong to the site, not to the Path /${opts.path}/`, {
6642
+ hint: `Deploy ${file ? FORMS_FILE : "the forms"} with the site's root (without a path), with \`paths\` patterns that reach /${opts.path}/ if its pages need them.`
6643
+ });
6644
+ }
5970
6645
  if (raw === void 0) {
5971
6646
  if (!formsEnabled(opts.instance)) return { send: void 0, check: null };
5972
6647
  const send = !opts.layered || opts.pulledWithForms ? null : void 0;
@@ -6034,7 +6709,7 @@ async function tempFolder() {
6034
6709
  const dir = await mkdtemp(join5(tmpdir(), "fling-"));
6035
6710
  return { dir, cleanup: () => rm(dir, { recursive: true, force: true }) };
6036
6711
  }
6037
- async function prepareInput(path3) {
6712
+ async function prepareInput(path3, opts = {}) {
6038
6713
  let info;
6039
6714
  try {
6040
6715
  info = await stat3(path3);
@@ -6075,7 +6750,9 @@ async function prepareInput(path3) {
6075
6750
  } else {
6076
6751
  const name = basename2(path3);
6077
6752
  await copyFile(path3, join5(tmp.dir, name));
6078
- await writeFile2(join5(tmp.dir, "index.html"), wrapperPage(name));
6753
+ if (!(opts.markdown && isMarkdownPath(name))) {
6754
+ await writeFile2(join5(tmp.dir, "index.html"), wrapperPage(name));
6755
+ }
6079
6756
  }
6080
6757
  } catch (error) {
6081
6758
  await tmp.cleanup();
@@ -6096,8 +6773,8 @@ function zipEntryPath(name) {
6096
6773
  if (JUNK_FILES.has((segments.at(-1) ?? "").toLowerCase())) return null;
6097
6774
  return segments.join("/");
6098
6775
  }
6099
- function unwrapSingleFolder(paths) {
6100
- let current = paths;
6776
+ function unwrapSingleFolder(paths2) {
6777
+ let current = paths2;
6101
6778
  for (; ; ) {
6102
6779
  const first = current[0]?.split("/")[0];
6103
6780
  if (!first || !current.every((p) => p.includes("/") && p.split("/")[0] === first)) return current;
@@ -6154,11 +6831,11 @@ async function extractZip(data, dest, skippedDotfiles = []) {
6154
6831
  `Refusing to extract the zip: it has paths that escape its folder (${unsafe.slice(0, 3).join(", ")})`
6155
6832
  );
6156
6833
  const names = Object.keys(entries);
6157
- const paths = /* @__PURE__ */ new Map();
6834
+ const paths2 = /* @__PURE__ */ new Map();
6158
6835
  const kept = names.map((n) => zipEntryPath(n));
6159
6836
  const unwrapped = unwrapSingleFolder(kept);
6160
6837
  names.forEach((n, i) => {
6161
- paths.set(n, unwrapped[i]);
6838
+ paths2.set(n, unwrapped[i]);
6162
6839
  });
6163
6840
  if (names.length === 0) throw new CliError("The zip has no files to fling");
6164
6841
  const root = resolve2(dest);
@@ -6169,7 +6846,7 @@ async function extractZip(data, dest, skippedDotfiles = []) {
6169
6846
  total += content.length;
6170
6847
  if (content.length > MAX_FILE_BYTES || total > MAX_DEPLOY_BYTES)
6171
6848
  throw new CliError(`The zip expands to more than the limits allow`);
6172
- const rel = paths.get(name);
6849
+ const rel = paths2.get(name);
6173
6850
  const target = resolve2(root, ...rel.split("/"));
6174
6851
  if (!target.startsWith(root + sep4)) throw new CliError(`Unsafe path in the zip: ${name}`);
6175
6852
  await mkdir2(join5(target, ".."), { recursive: true });
@@ -6226,7 +6903,7 @@ var SiteLinkSchema = z19.object({
6226
6903
  name: z19.string(),
6227
6904
  /** the version pulled, or the one the last deploy made */
6228
6905
  versionId: z19.string(),
6229
- /** a pull of one folder of the site: the folder is this folder */
6906
+ /** a pull of one of the site's Paths (its folder): the folder is that Path */
6230
6907
  path: z19.string().optional(),
6231
6908
  /** every file pulled (or deployed), by site path: its SHA-256 */
6232
6909
  files: z19.record(z19.string(), z19.string()),
@@ -6266,7 +6943,7 @@ function onLink(link, local) {
6266
6943
  for (const path3 of Object.keys(link.files)) if (!(path3 in now)) changes.removed.push(path3);
6267
6944
  return {
6268
6945
  base: link.versionId,
6269
- ...link.path === void 0 ? {} : { partial: true, within: link.path, remove: changes.removed },
6946
+ ...link.path === void 0 ? {} : { path: link.path },
6270
6947
  changes
6271
6948
  };
6272
6949
  }
@@ -6451,7 +7128,9 @@ async function runDeploy(ctx, input, opts) {
6451
7128
  hint: `Leave it out, then use \`${ctx.binName} access\` or \`${ctx.binName} visibility\`.`
6452
7129
  });
6453
7130
  }
6454
- const prepared = await prepareInput(path3);
7131
+ const prepared = await prepareInput(path3, {
7132
+ markdown: instance?.capabilities.markdown === true
7133
+ });
6455
7134
  try {
6456
7135
  const { access: _asked, path: _path, ...rest } = opts;
6457
7136
  await deployPrepared(
@@ -6492,10 +7171,10 @@ async function linkedTo(ctx, link, opts) {
6492
7171
  }
6493
7172
  function publishFolder(ctx, opts) {
6494
7173
  if (opts.path === void 0) return void 0;
6495
- const folder = normalizePublishPath(opts.path);
7174
+ const folder = normalizePathFolder(opts.path);
6496
7175
  if (folder === null) {
6497
7176
  throw new CliError(`Invalid --path "${opts.path}"`, {
6498
- hint: "Use a folder of the site, like 30-10-26 or reports/2026 (no dotfiles)."
7177
+ hint: `Use one top-level folder of the site, like PAY-412 (no dotfiles, at most ${MAX_PATH_FOLDER_LENGTH} characters): it becomes that folder's Path. Nested folders go inside it.`
6499
7178
  });
6500
7179
  }
6501
7180
  if (opts.name === void 0) {
@@ -6516,7 +7195,7 @@ async function deployPrepared(ctx, client, instance, base2, prepared, opts, ask)
6516
7195
  if (!link && opts.name === void 0 && ask) {
6517
7196
  name = await confirmSiteName(ctx, client, name, base2);
6518
7197
  }
6519
- if (!link && !hasRootIndex(local)) {
7198
+ if (!link && !hasRootIndex(local, instance?.capabilities.markdown === true)) {
6520
7199
  const pages = htmlPages(local).length;
6521
7200
  const root = base2 ? siteUrlFor(name, base2) + (folder ? `${folder}/` : "") : "the site root";
6522
7201
  const warning = `No index.html ${folder ? `in ${folder}/` : "at the root"} \u2014 ${root} will show a 404.`;
@@ -6531,10 +7210,11 @@ async function deployPrepared(ctx, client, instance, base2, prepared, opts, ask)
6531
7210
  const forms2 = await planForms({
6532
7211
  dir: prepared.dir,
6533
7212
  pages: local.files.map((f) => inFolder(f.path)),
6534
- layered: opts.folder !== void 0 || onBase !== null,
7213
+ layered: onBase !== null,
6535
7214
  pulledWithForms: onBase !== null && link?.forms !== void 0,
6536
7215
  instance,
6537
- bin: ctx.binName
7216
+ bin: ctx.binName,
7217
+ ...folder === void 0 ? {} : { path: folder }
6538
7218
  });
6539
7219
  let progressShown = false;
6540
7220
  const result = await deploy(
@@ -6543,10 +7223,9 @@ async function deployPrepared(ctx, client, instance, base2, prepared, opts, ask)
6543
7223
  dir: prepared.dir,
6544
7224
  name,
6545
7225
  manifest: local,
6546
- path: opts.folder,
7226
+ path: opts.folder ?? onBase?.path,
6547
7227
  ...onBase ? {
6548
- base: onBase.base,
6549
- ...onBase.partial ? { partial: true, remove: onBase.remove, within: onBase.within } : {}
7228
+ base: onBase.base
6550
7229
  } : {},
6551
7230
  spa: opts.spa ? true : void 0,
6552
7231
  public: opts.public,
@@ -6579,6 +7258,7 @@ async function deployPrepared(ctx, client, instance, base2, prepared, opts, ask)
6579
7258
  );
6580
7259
  }
6581
7260
  for (const warning of result.warnings) if (warning !== NO_INDEX_WARNING) ctx.log.warn(warning);
7261
+ for (const hint2 of result.hints) ctx.log.info(hint2.message);
6582
7262
  const summary = changes ? `${describeChanges(changes)} since ${link?.versionId}` : `${result.fileCount} files, ${formatBytes(result.totalBytes)}`;
6583
7263
  if (result.status === "unchanged") ctx.log.info(`Unchanged ${chalk7.dim(`(${summary})`)}`);
6584
7264
  else if (result.status === "repointed")
@@ -6588,12 +7268,15 @@ async function deployPrepared(ctx, client, instance, base2, prepared, opts, ask)
6588
7268
  `Deployed ${result.name}${folder ? ` to /${folder}/` : ""} ${chalk7.dim(`(${summary}; uploaded ${result.uploaded}, version ${result.versionId})`)}`
6589
7269
  );
6590
7270
  const url = folder ? new URL(`${folder}/`, result.url).href : result.url;
6591
- const ready = result.status === "unchanged" ? true : (await waitWithSpinner(
6592
- ctx.waitReady,
6593
- url,
6594
- local.files.map((f) => inFolder(f.path)),
6595
- { progress: ctx.progress, warn: (m) => ctx.log.warn(m) }
6596
- )).ready;
7271
+ const ready = result.status === "unchanged" ? true : (
7272
+ // From the site's root, as the files are named there: a Path's are under its folder.
7273
+ (await waitWithSpinner(
7274
+ ctx.waitReady,
7275
+ result.url,
7276
+ local.files.map((f) => inFolder(f.path)),
7277
+ { progress: ctx.progress, warn: (m) => ctx.log.warn(m) }
7278
+ )).ready
7279
+ );
6597
7280
  const members2 = instance ? privateAudience(instance) : "anyone signed in to this Fling";
6598
7281
  const level = siteAccessLevel(result.site);
6599
7282
  const who = describeSiteAccess(result.site, members2);
@@ -6612,16 +7295,18 @@ async function deployPrepared(ctx, client, instance, base2, prepared, opts, ask)
6612
7295
  const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
6613
7296
  const reviewNote = instance ? `Share for feedback (reviewers sign in to ${instance.name}):` : "Share for feedback:";
6614
7297
  let formsSummary = forms2.check?.summary ?? null;
6615
- if (!formsSummary && forms2.send === void 0 && formsEnabled(instance)) {
7298
+ if (!formsSummary && folder === void 0 && forms2.send === void 0 && formsEnabled(instance)) {
6616
7299
  const live = await getForms(client, result.name).catch(() => null);
6617
7300
  if (live?.config) formsSummary = checkFormsConfig(live.config).summary;
6618
7301
  }
6619
7302
  const responsesUrl = formsSummary && instance ? responsesPageUrl(instance.appOrigin, result.name) : void 0;
6620
- const hint = formsEnabled(instance) && !formsSummary ? formsHint(ctx.binName) : void 0;
7303
+ const hint = formsEnabled(instance) && !formsSummary && folder === void 0 ? formsHint(ctx.binName) : void 0;
6621
7304
  const data = {
6622
7305
  status: result.status,
6623
7306
  site: result.site,
6624
7307
  ...folder ? { path: folder, url } : {},
7308
+ versionId: result.versionId,
7309
+ ...result.hints.length ? { hints: result.hints } : {},
6625
7310
  ...changes && link ? { base: opts.force ? null : link.versionId, changes } : {},
6626
7311
  access: level,
6627
7312
  ...result.first ? { created: true } : {},
@@ -6714,47 +7399,841 @@ async function confirmSiteName(ctx, client, generated, base2) {
6714
7399
  }
6715
7400
  }
6716
7401
 
6717
- // src/commands/forms.ts
6718
- import { readFile as readFile8, stat as stat6, writeFile as writeFile4 } from "node:fs/promises";
6719
- import { dirname as dirname3, join as join8, relative as relative5, resolve as resolve4 } from "node:path";
7402
+ // src/commands/doctor.ts
6720
7403
  import chalk8 from "chalk";
6721
- async function formsClient(ctx) {
6722
- const client = requireClient(ctx);
6723
- const instance = await loadInstanceWith(ctx.store, ctx.config, ctx.fetch, "forms");
6724
- if (instance && !formsEnabled(instance)) {
6725
- throw new CliError(`${instance.name} has no forms`, {
6726
- hint: "Ask its operator to update it (forms come with comments)."
6727
- });
7404
+
7405
+ // src/claude.ts
7406
+ import { execFile as execFile2 } from "node:child_process";
7407
+ import { access, mkdtemp as mkdtemp2, readFile as readFile8, writeFile as writeFile4 } from "node:fs/promises";
7408
+ import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
7409
+ import { join as join8 } from "node:path";
7410
+
7411
+ // mcpb/manifest.json
7412
+ var manifest_default = {
7413
+ manifest_version: "0.3",
7414
+ name: "fling",
7415
+ display_name: "Fling",
7416
+ version: "0.10.0",
7417
+ description: "Publish HTML pages, zips and folders from your computer to Fling and get a link back.",
7418
+ long_description: "Fling hosts static sites at https://<name>.<your sites domain> (private to people signed in to your Fling unless you make them public or share a link).\n\nThis extension runs on your computer, so Claude can publish:\n\n- content it created in the conversation (sent inline \u2014 no sandbox uploads),\n- a file you downloaded or Claude Desktop saved, by name: it looks in Downloads, Desktop, Documents/Claude and Claude Desktop's output folders,\n- a single .html file, a .zip of a site, or a folder with index.html.\n\nIt works with every Fling you sign in to: the one you downloaded it from is added on start, Claude can sign in to another with the fling_login tool, and the logins are shared with the fling CLI (`fling login <address>`). When several Flings are set up, Claude settles with you which one a conversation uses and names it on every call.",
7419
+ author: {
7420
+ name: "Fling contributors",
7421
+ url: "https://github.com/nearform/fling"
7422
+ },
7423
+ repository: {
7424
+ type: "git",
7425
+ url: "https://github.com/nearform/fling.git"
7426
+ },
7427
+ homepage: "https://github.com/nearform/fling",
7428
+ support: "https://github.com/nearform/fling/issues",
7429
+ icon: "icon.png",
7430
+ license: "MIT",
7431
+ keywords: ["publish", "html", "static site", "hosting"],
7432
+ server: {
7433
+ type: "node",
7434
+ entry_point: "server/index.mjs",
7435
+ mcp_config: {
7436
+ command: "node",
7437
+ args: ["${__dirname}/server/index.mjs"],
7438
+ env: {
7439
+ FLING_DOWNLOADS_DIR: "${user_config.downloads_dir}"
7440
+ }
7441
+ }
7442
+ },
7443
+ tools: [
7444
+ {
7445
+ name: "fling_publish",
7446
+ description: "Publish a site from files passed inline (content Claude created)"
7447
+ },
7448
+ {
7449
+ name: "fling_deploy",
7450
+ description: "Publish a file, zip or folder from this computer, found by name in Downloads and other folders"
7451
+ },
7452
+ {
7453
+ name: "fling_pull",
7454
+ description: "Download a site's files into a folder on this computer, to change them and deploy again"
7455
+ },
7456
+ {
7457
+ name: "fling_read_files",
7458
+ description: "Read a site's files inline, to change some and publish them with fling_publish"
7459
+ },
7460
+ {
7461
+ name: "fling_find_files",
7462
+ description: "List recent HTML files, zips and site folders on this computer"
7463
+ },
7464
+ { name: "fling_check_name", description: "Check whether a site name is free" },
7465
+ { name: "fling_login", description: "Sign in to a Fling in the browser" },
7466
+ {
7467
+ name: "fling_servers",
7468
+ description: "List the Flings set up on this computer, or switch the current one"
7469
+ },
7470
+ {
7471
+ name: "fling_doctor",
7472
+ description: "Check the Fling setup on this computer and say how to fix what is wrong"
7473
+ },
7474
+ { name: "fling_whoami", description: "Show the signed-in user" },
7475
+ { name: "fling_list", description: "List your sites, or the sites shared with you" },
7476
+ { name: "fling_info", description: "Show a site and its versions" },
7477
+ { name: "fling_share", description: "Get, rotate or revoke a public share link" },
7478
+ { name: "fling_set_visibility", description: "Make a site private or public" },
7479
+ {
7480
+ name: "fling_set_audience",
7481
+ description: "Limit a private site to listed people and groups, or open it to everyone signed in"
7482
+ },
7483
+ { name: "fling_groups", description: "List the groups of people on a Fling" },
7484
+ { name: "fling_group_show", description: "Show a group and its members" },
7485
+ { name: "fling_group_create", description: "Create a group of people" },
7486
+ {
7487
+ name: "fling_group_members",
7488
+ description: "Add people to a group you own, or take them out"
7489
+ },
7490
+ { name: "fling_group_rename", description: "Rename a group you own" },
7491
+ { name: "fling_group_delete", description: "Delete a group you own" },
7492
+ {
7493
+ name: "fling_archive",
7494
+ description: "Take a site offline (or restore it), keeping its files"
7495
+ },
7496
+ { name: "fling_rollback", description: "Serve an earlier version of a site" },
7497
+ { name: "fling_delete", description: "Permanently delete a site" },
7498
+ {
7499
+ name: "fling_notifications",
7500
+ description: "Show what a Fling emails you about"
7501
+ },
7502
+ {
7503
+ name: "fling_notifications_set",
7504
+ description: "Turn notifications on or off, per event or all at once"
7505
+ }
7506
+ ],
7507
+ user_config: {
7508
+ downloads_dir: {
7509
+ type: "directory",
7510
+ title: "Downloads folder",
7511
+ description: "Where to look first for files you ask Claude to fling by name (Desktop, Documents/Claude and Claude Desktop output folders are searched too).",
7512
+ default: "${HOME}/Downloads",
7513
+ required: false
7514
+ }
7515
+ },
7516
+ compatibility: {
7517
+ platforms: ["darwin", "win32"],
7518
+ runtimes: {
7519
+ node: ">=20.0.0"
7520
+ }
6728
7521
  }
6729
- return { client, instance };
6730
- }
6731
- var shown = (path3) => {
6732
- const rel = relative5(process.cwd(), path3);
6733
- return rel === "" ? "." : rel.startsWith("..") ? path3 : rel;
6734
7522
  };
6735
- async function runForms(ctx, name, opts) {
6736
- const { client, instance } = await formsClient(ctx);
6737
- const forms2 = await getForms(client, name, opts.path);
6738
- if (!forms2.config) {
6739
- ctx.log.info(`${name} has no forms on its live version`);
6740
- ctx.log.hint(
6741
- `\`${ctx.binName} forms init\` writes a ${FORMS_FILE} to deploy with the site's folder.`
6742
- );
6743
- if (ctx.json) ctx.out.data(forms2, () => "");
6744
- return;
6745
- }
6746
- const summary = checkFormsConfig(forms2.config).summary;
6747
- if (summary) {
6748
- const lines = formsSummaryLines(summary);
6749
- const onPath = opts.path ? ` (on ${forms2.path ?? opts.path}: ${forms2.forms.map((f) => f.id).join(", ") || "none"})` : "";
6750
- ctx.log.info(`${summary.title} on ${name}${onPath}`);
6751
- for (const line of lines) ctx.log.hint(line);
6752
- }
6753
- if (instance)
6754
- ctx.log.hint(`Responses and progress: ${responsesPageUrl(instance.appOrigin, name)}`);
6755
- ctx.out.data(forms2, () => formatJson(forms2.config));
6756
- }
6757
- async function runFormsInit(ctx, dir, opts) {
7523
+
7524
+ // src/claude.ts
7525
+ var MARKETPLACE_SOURCE = REPO_SLUG;
7526
+ var MARKETPLACE_NAME = "fling";
7527
+ var PLUGIN_NAME = "fling";
7528
+ var PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
7529
+ var MCPB_PATH = "/downloads/fling.mcpb";
7530
+ var runClaude = (args) => new Promise((resolve8) => {
7531
+ execFile2(
7532
+ "claude",
7533
+ [...args],
7534
+ { timeout: 12e4, maxBuffer: 16 * 1024 * 1024, shell: process.platform === "win32" },
7535
+ (error, stdout, stderr) => {
7536
+ const missing = error?.code === "ENOENT";
7537
+ const code = !error ? 0 : missing ? -1 : typeof error.code === "number" ? error.code : 1;
7538
+ resolve8({ code, stdout: String(stdout), stderr: String(stderr) });
7539
+ }
7540
+ );
7541
+ });
7542
+ async function claudeCodeStatus(run) {
7543
+ const version = await run(["--version"]);
7544
+ if (version.code !== 0) return { claude: "missing" };
7545
+ const claude = version.stdout.trim().split(/\s+/)[0] ?? "unknown";
7546
+ const list = await run(["plugin", "list", "--json"]);
7547
+ let plugins = [];
7548
+ try {
7549
+ plugins = JSON.parse(list.stdout);
7550
+ } catch {
7551
+ }
7552
+ const fling = Array.isArray(plugins) ? plugins.find((p) => p.id === PLUGIN_ID) : void 0;
7553
+ if (!fling) return { claude, installed: false };
7554
+ return {
7555
+ claude,
7556
+ installed: true,
7557
+ version: fling.version,
7558
+ scope: fling.scope,
7559
+ enabled: fling.enabled
7560
+ };
7561
+ }
7562
+ async function installPlugin(run, scope) {
7563
+ check(
7564
+ await run(["plugin", "install", PLUGIN_NAME, "--marketplace", MARKETPLACE_SOURCE, "-s", scope]),
7565
+ "install the Fling plugin"
7566
+ );
7567
+ }
7568
+ async function updatePlugin(run, scope) {
7569
+ check(await run(["plugin", "marketplace", "update", MARKETPLACE_NAME]), "update the marketplace");
7570
+ check(
7571
+ await run(["plugin", "update", PLUGIN_ID, ...scope ? ["-s", scope] : []]),
7572
+ "update the Fling plugin"
7573
+ );
7574
+ }
7575
+ async function marketplaceConfigured(run) {
7576
+ const list = await run(["plugin", "marketplace", "list", "--json"]);
7577
+ if (list.code !== 0) return null;
7578
+ try {
7579
+ const marketplaces = JSON.parse(list.stdout);
7580
+ return Array.isArray(marketplaces) && marketplaces.some((m) => m.name === MARKETPLACE_NAME);
7581
+ } catch {
7582
+ return null;
7583
+ }
7584
+ }
7585
+ function check(result, what) {
7586
+ if (result.code === 0) return;
7587
+ const detail = (result.stderr || result.stdout).trim().split("\n").at(-1);
7588
+ throw new Error(`Could not ${what}${detail ? `: ${detail}` : ""}`);
7589
+ }
7590
+ async function findClaudeDesktop(platform = process.platform, env = process.env, exists = (path3) => access(path3).then(
7591
+ () => true,
7592
+ () => false
7593
+ )) {
7594
+ const candidates = platform === "darwin" ? ["/Applications/Claude.app", join8(homedir3(), "Applications", "Claude.app")] : platform === "win32" && env.LOCALAPPDATA ? [join8(env.LOCALAPPDATA, "AnthropicClaude"), join8(env.LOCALAPPDATA, "Programs", "Claude")] : [];
7595
+ for (const path3 of candidates) if (await exists(path3)) return path3;
7596
+ return null;
7597
+ }
7598
+ var DESKTOP_EXTENSION_ID = `local.mcpb.${manifest_default.author.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.${manifest_default.name}`;
7599
+ function claudeDesktopDataDir(platform = process.platform, env = process.env, home = homedir3()) {
7600
+ if (platform === "darwin") return join8(home, "Library", "Application Support", "Claude");
7601
+ if (platform === "win32" && env.APPDATA) return join8(env.APPDATA, "Claude");
7602
+ return null;
7603
+ }
7604
+ async function desktopExtensionStatus(dataDir) {
7605
+ const readJson2 = async (path3) => {
7606
+ try {
7607
+ return JSON.parse(await readFile8(path3, "utf8"));
7608
+ } catch {
7609
+ return null;
7610
+ }
7611
+ };
7612
+ const manifest = await readJson2(
7613
+ join8(dataDir, "Claude Extensions", DESKTOP_EXTENSION_ID, "manifest.json")
7614
+ );
7615
+ if (!manifest) return { installed: false };
7616
+ const settings = await readJson2(
7617
+ join8(dataDir, "Claude Extensions Settings", `${DESKTOP_EXTENSION_ID}.json`)
7618
+ );
7619
+ return {
7620
+ installed: true,
7621
+ ...typeof manifest.version === "string" ? { version: manifest.version } : {},
7622
+ ...typeof settings?.isEnabled === "boolean" ? { enabled: settings.isEnabled } : {}
7623
+ };
7624
+ }
7625
+ var releaseExtensionUrl = (version) => `${REPO_URL}/releases/download/v${version}/fling.mcpb`;
7626
+ function extensionSources(serverUrl, serverRelease, version = VERSION) {
7627
+ const server = {
7628
+ url: new URL(MCPB_PATH, serverUrl).toString(),
7629
+ ...serverRelease ? { version: serverRelease } : {}
7630
+ };
7631
+ if (serverRelease === version) return [server];
7632
+ return [{ url: releaseExtensionUrl(version), version }, server];
7633
+ }
7634
+ async function downloadExtension(url, fetch2) {
7635
+ const response = await fetch2(url);
7636
+ if (!response.ok) throw new Error(`Could not download ${url} (HTTP ${response.status})`);
7637
+ const dir = await mkdtemp2(join8(tmpdir2(), "fling-mcpb-"));
7638
+ const file = join8(dir, "fling.mcpb");
7639
+ await writeFile4(file, Buffer.from(await response.arrayBuffer()));
7640
+ return file;
7641
+ }
7642
+
7643
+ // src/doctor.ts
7644
+ import { execFile as execFile3 } from "node:child_process";
7645
+ import { access as access2, constants, realpath as realpath3, stat as stat6 } from "node:fs/promises";
7646
+ import { join as join10 } from "node:path";
7647
+
7648
+ // src/update-check.ts
7649
+ import { mkdir as mkdir4, readFile as readFile9, writeFile as writeFile5 } from "node:fs/promises";
7650
+ import { join as join9 } from "node:path";
7651
+ var UPDATE_COMMAND = `npm i -g ${PACKAGE_NAME}@latest && ${BIN_NAME} setup`;
7652
+ var UPDATE_STATE_FILE = "update-check.json";
7653
+ var NOTIFY_EVERY_MS = 24 * 60 * 60 * 1e3;
7654
+ var SEMVER = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/;
7655
+ function parseSemver(input) {
7656
+ if (typeof input !== "string") return null;
7657
+ const m = SEMVER.exec(input.trim());
7658
+ if (!m) return null;
7659
+ return { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ? m[4].split(".") : [] };
7660
+ }
7661
+ function compareSemver(a, b) {
7662
+ for (let i = 0; i < 3; i++) {
7663
+ const d = (a.core[i] ?? 0) - (b.core[i] ?? 0);
7664
+ if (d !== 0) return d;
7665
+ }
7666
+ if (!a.pre.length || !b.pre.length) return b.pre.length - a.pre.length;
7667
+ for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) {
7668
+ const x = a.pre[i];
7669
+ const y = b.pre[i];
7670
+ if (x === void 0) return -1;
7671
+ if (y === void 0) return 1;
7672
+ if (x === y) continue;
7673
+ const xn = /^\d+$/.test(x);
7674
+ const yn = /^\d+$/.test(y);
7675
+ if (xn && yn) return Number(x) - Number(y);
7676
+ if (xn !== yn) return xn ? -1 : 1;
7677
+ return x < y ? -1 : 1;
7678
+ }
7679
+ return 0;
7680
+ }
7681
+ function newerRelease(release, version = VERSION) {
7682
+ const theirs = parseSemver(release);
7683
+ const ours = parseSemver(version);
7684
+ if (!theirs || !ours || compareSemver(theirs, ours) <= 0) return null;
7685
+ return String(release).trim();
7686
+ }
7687
+ var updateLine = (release, version = VERSION) => `Fling ${release} is out (this CLI is ${version}). Update: ${UPDATE_COMMAND}`;
7688
+ var mcpUpdateNote = (release, version = VERSION) => `Fling ${release} is out and this Fling MCP server is ${version}. Tell the user once in this conversation, and suggest they update by running \`${UPDATE_COMMAND}\` in a terminal, then restarting Claude.`;
7689
+ async function updateNotice(input) {
7690
+ try {
7691
+ if (input.json || !input.stderrTTY || input.silenced || !input.serverUrl) return null;
7692
+ const version = input.version ?? VERSION;
7693
+ const release = newerRelease(input.release, version);
7694
+ if (!release) return null;
7695
+ const now = input.now ?? /* @__PURE__ */ new Date();
7696
+ const file = join9(input.store.dir, UPDATE_STATE_FILE);
7697
+ const state = await readState(file);
7698
+ const last = Date.parse(state.notified[input.serverUrl] ?? "");
7699
+ if (Number.isFinite(last) && now.getTime() - last < NOTIFY_EVERY_MS) return null;
7700
+ state.notified[input.serverUrl] = now.toISOString();
7701
+ await mkdir4(input.store.dir, { recursive: true, mode: 448 });
7702
+ await writeFile5(file, `${JSON.stringify(state, null, 2)}
7703
+ `, { mode: 384 });
7704
+ return updateLine(release, version);
7705
+ } catch {
7706
+ return null;
7707
+ }
7708
+ }
7709
+ async function noticeAfterCommand(ctx, server, options = {}) {
7710
+ try {
7711
+ const config = await ctx.store.resolve({ serverUrl: server }).catch(() => ctx.config);
7712
+ const line = await updateNotice({
7713
+ store: ctx.store,
7714
+ serverUrl: config.serverUrl,
7715
+ release: config.instance?.release,
7716
+ json: ctx.json,
7717
+ stderrTTY: options.stderrTTY ?? Boolean(process.stderr.isTTY),
7718
+ silenced: options.silenced ?? updateCheckSilenced(),
7719
+ now: options.now,
7720
+ version: options.version
7721
+ });
7722
+ if (line) ctx.log.warn(line);
7723
+ } catch {
7724
+ }
7725
+ }
7726
+ async function readState(file) {
7727
+ try {
7728
+ const json = JSON.parse(await readFile9(file, "utf8"));
7729
+ const notified = json?.notified;
7730
+ if (notified && typeof notified === "object" && !Array.isArray(notified)) {
7731
+ const entries = Object.entries(notified).filter(
7732
+ (e) => typeof e[1] === "string"
7733
+ );
7734
+ return { notified: Object.fromEntries(entries) };
7735
+ }
7736
+ } catch {
7737
+ }
7738
+ return { notified: {} };
7739
+ }
7740
+
7741
+ // src/doctor.ts
7742
+ var NPM_DIST_TAGS_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME.replace("/", "%2f")}/dist-tags`;
7743
+ var DEFAULT_TIMEOUT_MS = 5e3;
7744
+ var PLUGIN_UPDATE = `claude plugin marketplace update ${MARKETPLACE_NAME} && claude plugin update ${PLUGIN_ID}`;
7745
+ async function runChecks(deps) {
7746
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
7747
+ const fetchWithTimeout = (input, init) => deps.fetch(input, { ...init, signal: AbortSignal.timeout(timeoutMs) });
7748
+ const d = { ...deps, fetch: fetchWithTimeout, timeoutMs, version: deps.version ?? VERSION };
7749
+ const groups2 = await Promise.all([
7750
+ cliChecks(d),
7751
+ configChecks(d),
7752
+ serverChecks(d),
7753
+ claudeCodeChecks(d),
7754
+ claudeDesktopChecks(d)
7755
+ ]);
7756
+ return groups2.flat();
7757
+ }
7758
+ var doctorExitCode = (checks) => checks.some((c) => c.status === "problem") ? 1 : 0;
7759
+ function systemDoctorDeps(store, fetch2, runClaude2, findClaudeDesktop2) {
7760
+ const { env, home, platform } = systemLocateOptions();
7761
+ return {
7762
+ store,
7763
+ fetch: fetch2,
7764
+ env,
7765
+ home,
7766
+ platform,
7767
+ runClaude: runClaude2,
7768
+ findClaudeDesktop: findClaudeDesktop2,
7769
+ runProgram: (file, args) => new Promise((resolve8) => {
7770
+ execFile3(
7771
+ file,
7772
+ [...args],
7773
+ { timeout: DEFAULT_TIMEOUT_MS, shell: platform === "win32" },
7774
+ (error, stdout) => resolve8({ code: error ? 1 : 0, stdout: String(stdout) })
7775
+ );
7776
+ }),
7777
+ selfPath: process.argv[1],
7778
+ nodeVersion: process.versions.node
7779
+ };
7780
+ }
7781
+ var SYMBOL = { ok: "\u2714", warning: "\u25B2", problem: "\u2716" };
7782
+ function describeChecks(checks) {
7783
+ return checks.map((c) => `${SYMBOL[c.status]} ${c.message}${c.fix ? ` \u2014 fix: ${c.fix}` : ""}`).join("\n");
7784
+ }
7785
+ async function cliChecks(d) {
7786
+ const checks = [
7787
+ { area: "cli", id: "cli.version", status: "ok", message: `${BIN_NAME} CLI ${d.version}` }
7788
+ ];
7789
+ const min = minimumNode(NODE_ENGINE);
7790
+ const node = parseSemver(d.nodeVersion);
7791
+ if (min && node) {
7792
+ checks.push(
7793
+ compareSemver(node, min.semver) >= 0 ? { area: "cli", id: "cli.node", status: "ok", message: `Node.js ${d.nodeVersion}` } : {
7794
+ area: "cli",
7795
+ id: "cli.node",
7796
+ status: "problem",
7797
+ message: `Node.js ${d.nodeVersion} is too old: the CLI needs ${NODE_ENGINE}`,
7798
+ fix: `Install Node.js ${min.text} or later (https://nodejs.org)`
7799
+ }
7800
+ );
7801
+ }
7802
+ const latest = await latestOnNpm(d);
7803
+ if (latest) {
7804
+ const newer = newerRelease(latest, d.version);
7805
+ checks.push(
7806
+ newer ? {
7807
+ area: "cli",
7808
+ id: "cli.latest",
7809
+ status: "warning",
7810
+ message: `${PACKAGE_NAME} ${newer} is on npm (this CLI is ${d.version})`,
7811
+ fix: UPDATE_COMMAND
7812
+ } : {
7813
+ area: "cli",
7814
+ id: "cli.latest",
7815
+ status: "ok",
7816
+ message: `Up to date with npm (latest: ${latest})`
7817
+ }
7818
+ );
7819
+ }
7820
+ checks.push(await onPath(d));
7821
+ return checks;
7822
+ }
7823
+ function minimumNode(range) {
7824
+ const m = /^>=\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(range.trim());
7825
+ if (!m) return null;
7826
+ const text2 = [m[1], m[2], m[3]].filter((p) => p !== void 0).join(".");
7827
+ const semver = parseSemver(`${m[1]}.${m[2] ?? 0}.${m[3] ?? 0}`);
7828
+ return semver ? { text: text2, semver } : null;
7829
+ }
7830
+ async function latestOnNpm(d) {
7831
+ try {
7832
+ const response = await d.fetch(NPM_DIST_TAGS_URL, { headers: { Accept: "application/json" } });
7833
+ if (!response.ok) return null;
7834
+ const tags = await response.json();
7835
+ return typeof tags.latest === "string" && parseSemver(tags.latest) ? tags.latest : null;
7836
+ } catch {
7837
+ return null;
7838
+ }
7839
+ }
7840
+ async function onPath(d) {
7841
+ const names = d.platform === "win32" ? [`${BIN_NAME}.cmd`, `${BIN_NAME}.exe`, BIN_NAME] : [BIN_NAME];
7842
+ let found;
7843
+ for (const dir of pathDirs(d.env)) {
7844
+ for (const name of names) {
7845
+ const path3 = join10(dir, name);
7846
+ if (await isExecutable(path3, d.platform)) {
7847
+ found = path3;
7848
+ break;
7849
+ }
7850
+ }
7851
+ if (found) break;
7852
+ }
7853
+ const install = `npm i -g ${PACKAGE_NAME}@latest`;
7854
+ if (!found)
7855
+ return {
7856
+ area: "cli",
7857
+ id: "cli.path",
7858
+ status: "warning",
7859
+ message: `\`${BIN_NAME}\` is not on your PATH (this CLI runs through npx or another path)`,
7860
+ fix: install
7861
+ };
7862
+ const real = await realpath3(found).catch(() => found);
7863
+ const self2 = d.selfPath ? await realpath3(d.selfPath).catch(() => d.selfPath) : void 0;
7864
+ if (self2 && real === self2)
7865
+ return {
7866
+ area: "cli",
7867
+ id: "cli.path",
7868
+ status: "ok",
7869
+ message: `\`${BIN_NAME}\` on PATH is this CLI (${found})`
7870
+ };
7871
+ const other = await d.runProgram(found, ["--version"]);
7872
+ const version = other.code === 0 ? other.stdout.trim().split(/\s+/).at(-1) : void 0;
7873
+ if (version === d.version)
7874
+ return {
7875
+ area: "cli",
7876
+ id: "cli.path",
7877
+ status: "ok",
7878
+ message: `\`${BIN_NAME}\` on PATH (${found}) is ${version}, as this CLI`
7879
+ };
7880
+ return {
7881
+ area: "cli",
7882
+ id: "cli.path",
7883
+ status: "warning",
7884
+ message: `\`${BIN_NAME}\` on PATH (${found}) is ${version ? `version ${version}` : "another copy"}, not this CLI (${d.version})`,
7885
+ fix: `${install} && ${BIN_NAME} setup`
7886
+ };
7887
+ }
7888
+ async function isExecutable(path3, platform) {
7889
+ try {
7890
+ const s = await stat6(path3);
7891
+ if (!s.isFile()) return false;
7892
+ if (platform !== "win32") await access2(path3, constants.X_OK);
7893
+ return true;
7894
+ } catch {
7895
+ return false;
7896
+ }
7897
+ }
7898
+ async function configChecks(d) {
7899
+ const overrides = envOverrides(d.env);
7900
+ const checks = [];
7901
+ const where = `${d.store.dir}${overrides.configDir ? ` (from ${ENV.configDir})` : ""}`;
7902
+ const config = await d.store.load().catch(() => null);
7903
+ const fileMode = await stat6(d.store.file).then(
7904
+ (s) => s.mode & 511,
7905
+ () => null
7906
+ );
7907
+ if (fileMode === null || !config || Object.keys(config.servers).length === 0) {
7908
+ checks.push({
7909
+ area: "config",
7910
+ id: "config.dir",
7911
+ status: "warning",
7912
+ message: `No Fling is set up yet (config: ${where})`,
7913
+ fix: `${BIN_NAME} setup <your Fling's address>`
7914
+ });
7915
+ } else {
7916
+ checks.push({ area: "config", id: "config.dir", status: "ok", message: `Config: ${where}` });
7917
+ }
7918
+ if (fileMode !== null && d.platform !== "win32") {
7919
+ const mode = fileMode.toString(8).padStart(3, "0");
7920
+ checks.push(
7921
+ fileMode & 63 ? {
7922
+ area: "config",
7923
+ id: "config.permissions",
7924
+ status: "problem",
7925
+ message: `${d.store.file} holds API keys and is readable by others (mode ${mode})`,
7926
+ fix: `chmod 600 "${d.store.file}"`
7927
+ } : {
7928
+ area: "config",
7929
+ id: "config.permissions",
7930
+ status: "ok",
7931
+ message: `The config file is private (mode ${mode})`
7932
+ }
7933
+ );
7934
+ }
7935
+ if (overrides.url)
7936
+ checks.push({
7937
+ area: "config",
7938
+ id: "config.env.url",
7939
+ status: "warning",
7940
+ message: `${ENV.url} is set: commands use the server it names, not the current one`,
7941
+ fix: `unset ${ENV.url} (if you did not mean to)`
7942
+ });
7943
+ if (overrides.apiKey)
7944
+ checks.push({
7945
+ area: "config",
7946
+ id: "config.env.apiKey",
7947
+ status: "warning",
7948
+ message: `${ENV.apiKey} is set: commands use that key, not the stored one`,
7949
+ fix: `unset ${ENV.apiKey} (if you did not mean to)`
7950
+ });
7951
+ return checks;
7952
+ }
7953
+ async function serverChecks(d) {
7954
+ const config = await d.store.load().catch(() => null);
7955
+ if (!config) return [];
7956
+ const resolved = await d.store.resolve().catch(() => null);
7957
+ const urls = Object.keys(config.servers);
7958
+ if (resolved?.serverUrl && !urls.includes(resolved.serverUrl)) urls.push(resolved.serverUrl);
7959
+ const perServer = await Promise.all(
7960
+ urls.map(async (url) => {
7961
+ const entry = config.servers[url] ?? {};
7962
+ const label = `${url}${url === config.current ? " (current)" : ""}`;
7963
+ const apiKey = resolved?.serverUrl === url && resolved.apiKeySource === "env" ? resolved.apiKey : entry.apiKey;
7964
+ return oneServer(d, url, label, apiKey);
7965
+ })
7966
+ );
7967
+ return perServer.flat();
7968
+ }
7969
+ async function oneServer(d, url, label, apiKey) {
7970
+ const at = (id, check2) => ({
7971
+ area: "server",
7972
+ id,
7973
+ server: url,
7974
+ ...check2
7975
+ });
7976
+ let instance;
7977
+ try {
7978
+ instance = await fetchInstance(url, d.fetch);
7979
+ } catch (error) {
7980
+ return [
7981
+ at("server.metadata", {
7982
+ status: "problem",
7983
+ message: `${label}: ${error.message}`,
7984
+ fix: `Check the address and your connection; if the Fling moved: ${BIN_NAME} login <its new address>`
7985
+ })
7986
+ ];
7987
+ }
7988
+ const checks = [
7989
+ at("server.metadata", { status: "ok", message: `${label}: ${instance.name} answers` })
7990
+ ];
7991
+ const newer = newerRelease(instance.release, d.version);
7992
+ checks.push(
7993
+ newer ? at("server.release", {
7994
+ status: "warning",
7995
+ message: `${label} runs Fling ${newer}, newer than this CLI (${d.version})`,
7996
+ fix: UPDATE_COMMAND
7997
+ }) : at("server.release", {
7998
+ status: "ok",
7999
+ message: instance.release ? `${label} runs Fling ${instance.release}` : `${label} does not say which release it runs`
8000
+ })
8001
+ );
8002
+ const login = `${BIN_NAME} login ${url}`;
8003
+ if (!apiKey) {
8004
+ checks.push(
8005
+ at("server.signedIn", { status: "problem", message: `${label}: not signed in`, fix: login })
8006
+ );
8007
+ } else {
8008
+ try {
8009
+ const client = createApiClient({
8010
+ serverUrl: url,
8011
+ apiKey,
8012
+ fetch: d.fetch,
8013
+ timeoutMs: d.timeoutMs
8014
+ });
8015
+ const me = await client.request("GET", "/api/me");
8016
+ checks.push(
8017
+ at("server.signedIn", {
8018
+ status: "ok",
8019
+ message: `${label}: signed in${me?.email ? ` as ${me.email}` : ""}`
8020
+ })
8021
+ );
8022
+ } catch (error) {
8023
+ checks.push(
8024
+ exitCodeFor(error) === 2 ? at("server.signedIn", {
8025
+ status: "problem",
8026
+ message: `${label}: the API key is not accepted`,
8027
+ fix: login
8028
+ }) : at("server.signedIn", {
8029
+ status: "warning",
8030
+ message: `${label}: could not check the sign-in (${error.message})`,
8031
+ fix: `${BIN_NAME} whoami --server ${url}`
8032
+ })
8033
+ );
8034
+ }
8035
+ }
8036
+ const on = Object.entries(instance.capabilities).filter(([, v]) => v === true).map(([k]) => k).sort();
8037
+ checks.push(
8038
+ at("server.capabilities", {
8039
+ status: "ok",
8040
+ message: `${label}: ${on.length ? on.join(", ") : "no optional features"}`
8041
+ })
8042
+ );
8043
+ return checks;
8044
+ }
8045
+ async function claudeCodeChecks(d) {
8046
+ const status = await claudeCodeStatus(d.runClaude).catch(() => ({ claude: "missing" }));
8047
+ if (status.claude === "missing")
8048
+ return [
8049
+ {
8050
+ area: "claudeCode",
8051
+ id: "claudeCode.cli",
8052
+ status: "warning",
8053
+ message: "Claude Code not found (no `claude` on your PATH)",
8054
+ fix: `Install Claude Code (https://claude.com/claude-code), then run \`${BIN_NAME} setup\``
8055
+ }
8056
+ ];
8057
+ const checks = [
8058
+ {
8059
+ area: "claudeCode",
8060
+ id: "claudeCode.cli",
8061
+ status: "ok",
8062
+ message: `Claude Code ${status.claude}`
8063
+ }
8064
+ ];
8065
+ const marketplace = await marketplaceConfigured(d.runClaude).catch(() => null);
8066
+ if (marketplace === false)
8067
+ checks.push({
8068
+ area: "claudeCode",
8069
+ id: "claudeCode.marketplace",
8070
+ status: "warning",
8071
+ message: `The ${MARKETPLACE_NAME} marketplace is not configured in Claude Code`,
8072
+ fix: `${BIN_NAME} setup, or: claude plugin marketplace add ${MARKETPLACE_SOURCE}`
8073
+ });
8074
+ else if (marketplace === true)
8075
+ checks.push({
8076
+ area: "claudeCode",
8077
+ id: "claudeCode.marketplace",
8078
+ status: "ok",
8079
+ message: `The ${MARKETPLACE_NAME} marketplace is configured`
8080
+ });
8081
+ if (!("installed" in status) || !status.installed) {
8082
+ checks.push({
8083
+ area: "claudeCode",
8084
+ id: "claudeCode.plugin",
8085
+ status: "warning",
8086
+ message: "The Fling plugin is not installed in Claude Code",
8087
+ fix: `${BIN_NAME} setup`
8088
+ });
8089
+ return checks;
8090
+ }
8091
+ if (status.enabled === false)
8092
+ checks.push({
8093
+ area: "claudeCode",
8094
+ id: "claudeCode.enabled",
8095
+ status: "problem",
8096
+ message: "The Fling plugin is installed but disabled",
8097
+ fix: `claude plugin enable ${PLUGIN_ID}`
8098
+ });
8099
+ const behind = status.version ? newerRelease(d.version, status.version) : null;
8100
+ checks.push(
8101
+ behind ? {
8102
+ area: "claudeCode",
8103
+ id: "claudeCode.plugin",
8104
+ status: "warning",
8105
+ message: `The Fling plugin is ${status.version}, older than this CLI (${d.version})`,
8106
+ fix: `${BIN_NAME} setup, or: ${PLUGIN_UPDATE}`
8107
+ } : {
8108
+ area: "claudeCode",
8109
+ id: "claudeCode.plugin",
8110
+ status: "ok",
8111
+ message: `The Fling plugin is installed${status.version ? ` (${status.version})` : ""}${status.enabled === false ? "" : " and enabled"}`
8112
+ }
8113
+ );
8114
+ return checks;
8115
+ }
8116
+ async function claudeDesktopChecks(d) {
8117
+ const app = await d.findClaudeDesktop().catch(() => null);
8118
+ if (!app) return [];
8119
+ const dataDir = claudeDesktopDataDir(d.platform, d.env, d.home);
8120
+ const ext = dataDir ? await desktopExtensionStatus(dataDir) : null;
8121
+ const at = (check2) => ({
8122
+ area: "claudeDesktop",
8123
+ id: "claudeDesktop.extension",
8124
+ ...check2
8125
+ });
8126
+ if (!ext)
8127
+ return [
8128
+ at({
8129
+ status: "ok",
8130
+ message: `Claude Desktop is installed (${app}); the extension could not be looked up`
8131
+ })
8132
+ ];
8133
+ if (!ext.installed)
8134
+ return [
8135
+ at({
8136
+ status: "warning",
8137
+ message: "The Fling extension is not installed in Claude Desktop",
8138
+ fix: `${BIN_NAME} setup (it opens fling.mcpb in Claude Desktop)`
8139
+ })
8140
+ ];
8141
+ const checks = [];
8142
+ if (ext.enabled === false)
8143
+ checks.push({
8144
+ area: "claudeDesktop",
8145
+ id: "claudeDesktop.enabled",
8146
+ status: "problem",
8147
+ message: "The Fling extension is installed in Claude Desktop but disabled",
8148
+ fix: "Turn it on in Claude Desktop: Settings \u2192 Extensions \u2192 Fling"
8149
+ });
8150
+ const behind = ext.version ? newerRelease(d.version, ext.version) : null;
8151
+ checks.push(
8152
+ behind ? at({
8153
+ status: "warning",
8154
+ message: `The Claude Desktop extension is ${ext.version}, older than this CLI (${d.version})`,
8155
+ fix: `${BIN_NAME} setup`
8156
+ }) : at({
8157
+ status: "ok",
8158
+ message: `The Claude Desktop extension is installed${ext.version ? ` (${ext.version})` : ""} (${DESKTOP_EXTENSION_ID})`
8159
+ })
8160
+ );
8161
+ return checks;
8162
+ }
8163
+
8164
+ // src/commands/doctor.ts
8165
+ var AREAS = {
8166
+ cli: "CLI",
8167
+ config: "Config",
8168
+ server: "Flings",
8169
+ claudeCode: "Claude Code",
8170
+ claudeDesktop: "Claude Desktop"
8171
+ };
8172
+ var SYMBOLS = { ok: symbols.success, warning: symbols.warn, problem: symbols.error };
8173
+ async function runDoctor(ctx, deps) {
8174
+ const checks = await runChecks({
8175
+ ...systemDoctorDeps(ctx.store, ctx.fetch, runClaude, () => findClaudeDesktop()),
8176
+ ...deps
8177
+ });
8178
+ const ok = doctorExitCode(checks) === 0;
8179
+ ctx.out.data({ ok, checks }, () => render(checks));
8180
+ if (!ok) process.exitCode = 1;
8181
+ }
8182
+ function render(checks) {
8183
+ const lines = [];
8184
+ let area;
8185
+ for (const c of checks) {
8186
+ if (c.area !== area) {
8187
+ area = c.area;
8188
+ lines.push(`${lines.length ? "\n" : ""}${chalk8.bold(AREAS[c.area])}`);
8189
+ }
8190
+ const message = c.status === "problem" ? chalk8.red(c.message) : c.message;
8191
+ lines.push(`${SYMBOLS[c.status]} ${message}${c.fix ? chalk8.dim(` \u2014 fix: ${c.fix}`) : ""}`);
8192
+ }
8193
+ return lines.join("\n");
8194
+ }
8195
+
8196
+ // src/commands/forms.ts
8197
+ import { readFile as readFile10, stat as stat7, writeFile as writeFile6 } from "node:fs/promises";
8198
+ import { dirname as dirname3, join as join11, relative as relative5, resolve as resolve4 } from "node:path";
8199
+ import chalk9 from "chalk";
8200
+ async function formsClient(ctx) {
8201
+ const client = requireClient(ctx);
8202
+ const instance = await loadInstanceWith(ctx.store, ctx.config, ctx.fetch, "forms");
8203
+ if (instance && !formsEnabled(instance)) {
8204
+ throw new CliError(`${instance.name} has no forms`, {
8205
+ hint: "Ask its operator to update it (forms come with comments)."
8206
+ });
8207
+ }
8208
+ return { client, instance };
8209
+ }
8210
+ var shown = (path3) => {
8211
+ const rel = relative5(process.cwd(), path3);
8212
+ return rel === "" ? "." : rel.startsWith("..") ? path3 : rel;
8213
+ };
8214
+ async function runForms(ctx, name, opts) {
8215
+ const { client, instance } = await formsClient(ctx);
8216
+ const forms2 = await getForms(client, name, opts.path);
8217
+ if (!forms2.config) {
8218
+ ctx.log.info(`${name} has no forms on its live version`);
8219
+ ctx.log.hint(
8220
+ `\`${ctx.binName} forms init\` writes a ${FORMS_FILE} to deploy with the site's folder.`
8221
+ );
8222
+ if (ctx.json) ctx.out.data(forms2, () => "");
8223
+ return;
8224
+ }
8225
+ const summary = checkFormsConfig(forms2.config).summary;
8226
+ if (summary) {
8227
+ const lines = formsSummaryLines(summary);
8228
+ const onPath2 = opts.path ? ` (on ${forms2.path ?? opts.path}: ${forms2.forms.map((f) => f.id).join(", ") || "none"})` : "";
8229
+ ctx.log.info(`${summary.title} on ${name}${onPath2}`);
8230
+ for (const line of lines) ctx.log.hint(line);
8231
+ }
8232
+ if (instance)
8233
+ ctx.log.hint(`Responses and progress: ${responsesPageUrl(instance.appOrigin, name)}`);
8234
+ ctx.out.data(forms2, () => formatJson(forms2.config));
8235
+ }
8236
+ async function runFormsInit(ctx, dir, opts) {
6758
8237
  const template = opts.template ?? "labels";
6759
8238
  if (!isFormTemplateName(template)) {
6760
8239
  throw new CliError(`Unknown template "${template}"`, {
@@ -6762,32 +8241,32 @@ async function runFormsInit(ctx, dir, opts) {
6762
8241
  });
6763
8242
  }
6764
8243
  const folder = resolve4(dir ?? ".");
6765
- const info = await stat6(folder).catch(() => null);
8244
+ const info = await stat7(folder).catch(() => null);
6766
8245
  if (!info?.isDirectory()) throw new CliError(`No such folder: ${dir ?? "."}`);
6767
- const file = join8(folder, FORMS_FILE);
6768
- if (!opts.force && await stat6(file).catch(() => null)) {
8246
+ const file = join11(folder, FORMS_FILE);
8247
+ if (!opts.force && await stat7(file).catch(() => null)) {
6769
8248
  throw new CliError(`${shown(file)} already exists`, {
6770
8249
  hint: "Pass --force to replace it with the template."
6771
8250
  });
6772
8251
  }
6773
8252
  const config = formsTemplate(template, opts.title);
6774
8253
  requireValidForms(config, void 0, "The template", ctx.binName);
6775
- await writeFile4(file, `${formatJson(config)}
8254
+ await writeFile6(file, `${formatJson(config)}
6776
8255
  `);
6777
8256
  ctx.log.success(`Wrote ${shown(file)} (${template}: ${FORM_TEMPLATE_DESCRIPTIONS[template]})`);
6778
8257
  ctx.out.data(
6779
8258
  { path: file, template, forms: config },
6780
- () => chalk8.dim(
8259
+ () => chalk9.dim(
6781
8260
  `Change its options, wording and paths to fit, check it with \`${ctx.binName} forms check ${shown(dirname3(file))}\`, then deploy the folder.`
6782
8261
  )
6783
8262
  );
6784
8263
  }
6785
8264
  async function runFormsCheck(ctx, path3) {
6786
8265
  const target = resolve4(path3 ?? ".");
6787
- const info = await stat6(target).catch(() => null);
8266
+ const info = await stat7(target).catch(() => null);
6788
8267
  if (!info) throw new CliError(`No such file or folder: ${path3 ?? "."}`);
6789
8268
  const folder = info.isDirectory() ? target : dirname3(target);
6790
- const file = info.isDirectory() ? join8(target, FORMS_FILE) : target;
8269
+ const file = info.isDirectory() ? join11(target, FORMS_FILE) : target;
6791
8270
  const config = info.isDirectory() ? (await readFormsFile(folder))?.config : await readJson(file);
6792
8271
  if (config === void 0) {
6793
8272
  throw new CliError(`No ${FORMS_FILE} in ${shown(folder)}`, {
@@ -6808,7 +8287,7 @@ async function runFormsCheck(ctx, path3) {
6808
8287
  }
6809
8288
  async function readJson(file) {
6810
8289
  try {
6811
- return JSON.parse(await readFile8(file, "utf8"));
8290
+ return JSON.parse(await readFile10(file, "utf8"));
6812
8291
  } catch (error) {
6813
8292
  throw new CliError(`${shown(file)} is not valid JSON: ${error.message}`);
6814
8293
  }
@@ -6822,21 +8301,21 @@ async function runResponses(ctx, name, opts) {
6822
8301
  }
6823
8302
  const { client, instance } = await formsClient(ctx);
6824
8303
  const exported = await getResponses(client, name, { form: opts.form, path: opts.path });
6825
- const page = instance ? responsesPageUrl(instance.appOrigin, name) : void 0;
8304
+ const page2 = instance ? responsesPageUrl(instance.appOrigin, name) : void 0;
6826
8305
  if (format2 !== void 0 || opts.output !== void 0) {
6827
8306
  const as = format2 ?? "json";
6828
8307
  const body = as === "csv" ? responsesCsv(exported.responses, exported.forms) : as === "jsonl" ? responsesJsonl(exported.responses) : `${formatJson(exported)}
6829
8308
  `;
6830
8309
  if (opts.output !== void 0) {
6831
- await writeFile4(resolve4(opts.output), body);
8310
+ await writeFile6(resolve4(opts.output), body);
6832
8311
  ctx.log.success(
6833
8312
  `Wrote ${exported.responses.length} response${exported.responses.length === 1 ? "" : "s"} to ${shown(resolve4(opts.output))}`
6834
8313
  );
6835
8314
  } else ctx.out.data(as === "json" ? exported : body, () => body.replace(/\n$/, ""));
6836
8315
  return;
6837
8316
  }
6838
- ctx.out.data({ ...exported, ...page ? { responsesUrl: page } : {} }, () => {
6839
- if (!exported.title) return chalk8.dim(`${name} has no forms on its live version.`);
8317
+ ctx.out.data({ ...exported, ...page2 ? { responsesUrl: page2 } : {} }, () => {
8318
+ if (!exported.title) return chalk9.dim(`${name} has no forms on its live version.`);
6840
8319
  const byId = new Map(exported.forms.map((f) => [f.id, f]));
6841
8320
  const table = renderTable(exported.responses, [
6842
8321
  { header: "PAGE", value: (r) => r.path },
@@ -6854,19 +8333,19 @@ async function runResponses(ctx, name, opts) {
6854
8333
  }
6855
8334
  ]);
6856
8335
  return [
6857
- chalk8.bold(`${exported.title} on ${name}`),
8336
+ chalk9.bold(`${exported.title} on ${name}`),
6858
8337
  "",
6859
8338
  table,
6860
8339
  "",
6861
- ...progressLines(exported).map((l) => chalk8.dim(l)),
6862
- ...page ? [chalk8.dim(`Progress and downloads: ${page}`)] : [],
6863
- chalk8.dim(`Export: \`${ctx.binName} responses ${name} --format csv -o responses.csv\``)
8340
+ ...progressLines(exported).map((l) => chalk9.dim(l)),
8341
+ ...page2 ? [chalk9.dim(`Progress and downloads: ${page2}`)] : [],
8342
+ chalk9.dim(`Export: \`${ctx.binName} responses ${name} --format csv -o responses.csv\``)
6864
8343
  ].join("\n");
6865
8344
  });
6866
8345
  }
6867
8346
 
6868
8347
  // src/commands/groups.ts
6869
- import chalk9 from "chalk";
8348
+ import chalk10 from "chalk";
6870
8349
  function requireGroups(ctx) {
6871
8350
  const instance = ctx.config.instance;
6872
8351
  if (instance && !groupsEnabled(instance)) throw new CliError(`${instance.name} has no groups`);
@@ -6887,7 +8366,7 @@ async function runGroups(ctx, opts) {
6887
8366
  { header: "NAME", value: (g) => g.name },
6888
8367
  { header: "PEOPLE", value: (g) => g.size },
6889
8368
  { header: "OWNER", value: (g) => g.owner === me.email ? "you" : g.owner }
6890
- ]) : chalk9.dim(members2 ? "No group has exactly those people." : describeGroups([]))
8369
+ ]) : chalk10.dim(members2 ? "No group has exactly those people." : describeGroups([]))
6891
8370
  );
6892
8371
  }
6893
8372
  async function runGroupShow(ctx, slug) {
@@ -6933,7 +8412,7 @@ async function runGroupDelete(ctx, slug, opts) {
6933
8412
  }
6934
8413
 
6935
8414
  // src/commands/notifications.ts
6936
- import chalk10 from "chalk";
8415
+ import chalk11 from "chalk";
6937
8416
  var VALUES = ["on", "off", "default"];
6938
8417
  function notificationsClient(ctx) {
6939
8418
  const instance = ctx.config.instance;
@@ -6956,10 +8435,10 @@ function renderSettings(settings) {
6956
8435
  })),
6957
8436
  { header: "WHEN", value: (e) => e.title }
6958
8437
  ]);
6959
- const lines = [table, "", chalk10.dim("* your choice; the rest are the defaults")];
8438
+ const lines = [table, "", chalk11.dim("* your choice; the rest are the defaults")];
6960
8439
  if (settings.muted) {
6961
8440
  lines.unshift(
6962
- chalk10.yellow(
8441
+ chalk11.yellow(
6963
8442
  "Muted: nothing is sent, whatever the table says (`fling notifications unmute` to undo)."
6964
8443
  ),
6965
8444
  ""
@@ -6977,54 +8456,190 @@ async function runNotificationsSet(ctx, event, channel, value) {
6977
8456
  if (!VALUES.includes(value)) {
6978
8457
  throw new CliError(`"${value}" is not on, off or default`);
6979
8458
  }
6980
- const current = await getNotifications(client, name);
6981
- if (!current.events.some((e) => e.key === event)) {
6982
- throw new CliError(`${name} has no event "${event}"`, {
6983
- hint: `Events: ${current.events.map((e) => e.key).join(", ")}.`
8459
+ const current = await getNotifications(client, name);
8460
+ if (!current.events.some((e) => e.key === event)) {
8461
+ throw new CliError(`${name} has no event "${event}"`, {
8462
+ hint: `Events: ${current.events.map((e) => e.key).join(", ")}.`
8463
+ });
8464
+ }
8465
+ if (!current.channels.includes(channel)) {
8466
+ throw new CliError(`${name} has no channel "${channel}"`, {
8467
+ hint: `Channels: ${current.channels.join(", ")}.`
8468
+ });
8469
+ }
8470
+ const settings = await setNotifications(
8471
+ client,
8472
+ { cells: [{ event, channel, value }] },
8473
+ name
8474
+ );
8475
+ const now = settings.cells[event]?.[channel] ?? "off";
8476
+ ctx.log.success(
8477
+ value === "default" ? `${event} on ${channel} is back to the default (${now})` : `${event} on ${channel} is ${now}`
8478
+ );
8479
+ if (settings.muted) ctx.log.warn("Everything is muted, so nothing is sent until you unmute.");
8480
+ ctx.out.data(settings, () => renderSettings(settings));
8481
+ }
8482
+ async function runNotificationsMute(ctx, muted) {
8483
+ const { client, name } = notificationsClient(ctx);
8484
+ const settings = await setNotifications(client, { muted }, name);
8485
+ ctx.log.success(
8486
+ muted ? `Muted: ${name} sends you nothing` : `Unmuted: ${name} sends what the table says`
8487
+ );
8488
+ if (ctx.json) ctx.out.data(settings, () => "");
8489
+ }
8490
+ async function runNotificationsSent(ctx) {
8491
+ const { client, name } = notificationsClient(ctx);
8492
+ const sent = await listSentNotifications(client, name);
8493
+ ctx.out.data(
8494
+ { sent },
8495
+ () => sent.length ? renderTable(sent, [
8496
+ { header: "SENT", value: (s) => formatDate(s.sentAt) },
8497
+ { header: "CHANNEL", value: (s) => s.channel },
8498
+ { header: "SUBJECT", value: (s) => s.subject },
8499
+ { header: "EVENTS", value: (s) => s.eventKeys.join(", ") }
8500
+ ]) : chalk11.dim("Nothing was sent to you in the last 30 days.")
8501
+ );
8502
+ }
8503
+
8504
+ // src/commands/paths.ts
8505
+ import { readFile as readFile11, stat as stat8, writeFile as writeFile7 } from "node:fs/promises";
8506
+ import { join as join12, relative as relative6, resolve as resolve5 } from "node:path";
8507
+ import chalk12 from "chalk";
8508
+ var pathsHomeChoice = (bin) => [
8509
+ "A site's home page (its root index.html) can list its Paths:",
8510
+ ` default no index.html of its own: Fling serves a listing of the live Paths (a site whose first publish is a Path gets one). Nothing to do.`,
8511
+ ` custom your own index.html with a Paths block, filled with the live Paths: \`${bin} paths init\` writes a starter (${PATHS_TEMPLATE_NAMES.join(", ")}); adapt it, keeping the block, \`${bin} paths check\` it, then deploy it as the site root (without --path).`,
8512
+ " none an index.html without a Paths block lists no Paths; a deploy then says so."
8513
+ ];
8514
+ async function pathsClient(ctx) {
8515
+ const client = requireClient(ctx);
8516
+ const instance = await loadInstanceWith(ctx.store, ctx.config, ctx.fetch, "paths");
8517
+ if (instance && !pathsEnabled(instance)) {
8518
+ throw new CliError(`${instance.name} has no Paths`, { hint: "Ask its operator to update it." });
8519
+ }
8520
+ return client;
8521
+ }
8522
+ function pathFolder2(raw) {
8523
+ const folder = normalizePathFolder(raw);
8524
+ if (folder === null) {
8525
+ throw new CliError(`Invalid Path "${raw}"`, {
8526
+ hint: `A Path is one top-level folder of the site, like PAY-412 (no dotfiles, at most ${MAX_PATH_FOLDER_LENGTH} characters).`
8527
+ });
8528
+ }
8529
+ return folder;
8530
+ }
8531
+ var stateOf = (p) => p.archived ? chalk12.yellow("archived") : p.currentVersionId ? "live" : chalk12.dim("unpublished");
8532
+ async function runPaths(ctx, name) {
8533
+ const paths2 = await listPaths(await pathsClient(ctx), name);
8534
+ if (!ctx.json) {
8535
+ if (paths2.length === 0) ctx.log.info(`${name} has no Paths yet`);
8536
+ for (const line of pathsHomeChoice(ctx.binName)) ctx.log.hint(line);
8537
+ }
8538
+ ctx.out.data(
8539
+ { name, paths: paths2 },
8540
+ () => paths2.length === 0 ? chalk12.dim(
8541
+ `Publish a folder as one: \`${ctx.binName} deploy ./plan --name ${name} --path PAY-412\`.`
8542
+ ) : renderTable(paths2, [
8543
+ { header: "PATH", value: (p) => `/${p.folder}/` },
8544
+ { header: "TITLE", value: (p) => p.title || "-" },
8545
+ { header: "VERSION", value: (p) => p.currentVersionId ?? "-" },
8546
+ { header: "STATE", value: stateOf },
8547
+ { header: "PUBLISH", value: (p) => p.canPublish ? "yes" : "no" },
8548
+ { header: "UPDATED", value: (p) => formatDate(p.updatedAt) }
8549
+ ])
8550
+ );
8551
+ }
8552
+ async function runPathArchive(ctx, name, raw, unarchive = false) {
8553
+ const folder = pathFolder2(raw);
8554
+ const path3 = await archivePath(await pathsClient(ctx), name, folder, !unarchive);
8555
+ ctx.log.success(
8556
+ unarchive ? `/${folder}/ of ${name} restored` : `/${folder}/ of ${name} archived: it 404s until \`${ctx.binName} paths unarchive ${name} ${folder}\``
8557
+ );
8558
+ if (ctx.json) ctx.out.data(path3, () => "");
8559
+ else if (unarchive) ctx.out.text(path3.url);
8560
+ }
8561
+ async function runPathDelete(ctx, name, raw, opts) {
8562
+ const folder = pathFolder2(raw);
8563
+ const client = await pathsClient(ctx);
8564
+ if (!opts.yes) {
8565
+ if (!ctx.interactive) {
8566
+ throw new CliError(`Refusing to delete /${folder}/ of ${name} without confirmation`, {
8567
+ hint: "Pass --yes to delete."
8568
+ });
8569
+ }
8570
+ const ok = await ctx.confirm(
8571
+ `Delete /${folder}/ of ${name} and every version of it? This cannot be undone.`
8572
+ );
8573
+ if (!ok) throw new CliError("Cancelled");
8574
+ }
8575
+ const result = await deletePath(client, name, folder);
8576
+ ctx.log.success(`/${folder}/ of ${name} deleted`);
8577
+ if (ctx.json) ctx.out.data(result, () => "");
8578
+ }
8579
+ var shown2 = (path3) => {
8580
+ const rel = relative6(process.cwd(), path3);
8581
+ return rel === "" ? "." : rel.startsWith("..") ? path3 : rel;
8582
+ };
8583
+ async function runPathsInit(ctx, dir, opts) {
8584
+ const template = opts.template ?? "minimal";
8585
+ if (!isPathsTemplateName(template)) {
8586
+ throw new CliError(`Unknown template "${template}"`, {
8587
+ hint: `Use one of: ${PATHS_TEMPLATE_NAMES.join(", ")}.`
6984
8588
  });
6985
8589
  }
6986
- if (!current.channels.includes(channel)) {
6987
- throw new CliError(`${name} has no channel "${channel}"`, {
6988
- hint: `Channels: ${current.channels.join(", ")}.`
8590
+ const folder = resolve5(dir ?? ".");
8591
+ const info = await stat8(folder).catch(() => null);
8592
+ if (!info?.isDirectory()) throw new CliError(`No such folder: ${dir ?? "."}`);
8593
+ const file = join12(folder, "index.html");
8594
+ if (!opts.force && await stat8(file).catch(() => null)) {
8595
+ throw new CliError(`${shown2(file)} already exists`, {
8596
+ hint: `Pass --force to replace it with the template, or add a Paths block to it (\`${ctx.binName} paths check ${shown2(file)}\` says how).`
6989
8597
  });
6990
8598
  }
6991
- const settings = await setNotifications(
6992
- client,
6993
- { cells: [{ event, channel, value }] },
6994
- name
6995
- );
6996
- const now = settings.cells[event]?.[channel] ?? "off";
6997
- ctx.log.success(
6998
- value === "default" ? `${event} on ${channel} is back to the default (${now})` : `${event} on ${channel} is ${now}`
8599
+ const html = pathsTemplate(template, { title: opts.title });
8600
+ await writeFile7(file, html);
8601
+ ctx.log.success(`Wrote ${shown2(file)} (${template}: ${PATHS_TEMPLATE_DESCRIPTIONS[template]})`);
8602
+ ctx.out.data(
8603
+ { path: file, template },
8604
+ () => chalk12.dim(
8605
+ `Adapt its wording and look, keeping the <!-- fling:paths --> block; check it with \`${ctx.binName} paths check ${shown2(folder)}\`, then deploy the folder as the site root (without --path).`
8606
+ )
6999
8607
  );
7000
- if (settings.muted) ctx.log.warn("Everything is muted, so nothing is sent until you unmute.");
7001
- ctx.out.data(settings, () => renderSettings(settings));
7002
8608
  }
7003
- async function runNotificationsMute(ctx, muted) {
7004
- const { client, name } = notificationsClient(ctx);
7005
- const settings = await setNotifications(client, { muted }, name);
8609
+ async function runPathsCheck(ctx, path3) {
8610
+ const target = resolve5(path3 ?? ".");
8611
+ const info = await stat8(target).catch(() => null);
8612
+ if (!info) throw new CliError(`No such file or folder: ${path3 ?? "."}`);
8613
+ const file = info.isDirectory() ? join12(target, "index.html") : target;
8614
+ const html = await readFile11(file, "utf8").catch(() => null);
8615
+ if (html === null) {
8616
+ throw new CliError(`No ${shown2(file)}`, {
8617
+ hint: `\`${ctx.binName} paths init ${shown2(info.isDirectory() ? target : resolve5(target, ".."))}\` writes a starter.`
8618
+ });
8619
+ }
8620
+ const check2 = checkPathsIndex(html);
8621
+ for (const w of check2.warnings) ctx.log.warn(w);
8622
+ if (!check2.ok) {
8623
+ if (ctx.json) ctx.out.data({ path: file, ...check2 }, () => "");
8624
+ throw new CliError(`${shown2(file)} has no Paths block:
8625
+ ${check2.errors.join("\n ")}`, {
8626
+ hint: `Placeholders in a template: ${PATHS_PLACEHOLDERS.map((p) => `{{${p}}}`).join(" ")}. Options: ${PATHS_OPTIONS.join(", ")}.`
8627
+ });
8628
+ }
7006
8629
  ctx.log.success(
7007
- muted ? `Muted: ${name} sends you nothing` : `Unmuted: ${name} sends what the table says`
8630
+ `${shown2(file)} has a ${check2.block} Paths block${check2.options.length ? ` (${check2.options.join(", ")})` : ""}`
7008
8631
  );
7009
- if (ctx.json) ctx.out.data(settings, () => "");
7010
- }
7011
- async function runNotificationsSent(ctx) {
7012
- const { client, name } = notificationsClient(ctx);
7013
- const sent = await listSentNotifications(client, name);
7014
8632
  ctx.out.data(
7015
- { sent },
7016
- () => sent.length ? renderTable(sent, [
7017
- { header: "SENT", value: (s) => formatDate(s.sentAt) },
7018
- { header: "CHANNEL", value: (s) => s.channel },
7019
- { header: "SUBJECT", value: (s) => s.subject },
7020
- { header: "EVENTS", value: (s) => s.eventKeys.join(", ") }
7021
- ]) : chalk10.dim("Nothing was sent to you in the last 30 days.")
8633
+ { path: file, ...check2 },
8634
+ () => chalk12.dim(
8635
+ "Deploy it as the site root (without --path): the listing fills in with the live Paths."
8636
+ )
7022
8637
  );
7023
8638
  }
7024
8639
 
7025
8640
  // src/commands/pull.ts
7026
- import { relative as relative6, resolve as resolve5 } from "node:path";
7027
- import chalk11 from "chalk";
8641
+ import { relative as relative7, resolve as resolve6 } from "node:path";
8642
+ import chalk13 from "chalk";
7028
8643
  async function runPull(ctx, name, dir, opts) {
7029
8644
  const client = requireClient(ctx);
7030
8645
  const instance = await loadInstanceWith(ctx.store, ctx.config, ctx.fetch, "pull");
@@ -7035,15 +8650,15 @@ async function runPull(ctx, name, dir, opts) {
7035
8650
  }
7036
8651
  let path3;
7037
8652
  if (opts.path !== void 0) {
7038
- const folder = normalizePublishPath(opts.path);
8653
+ const folder = normalizePathFolder(opts.path);
7039
8654
  if (folder === null) {
7040
8655
  throw new CliError(`Invalid --path "${opts.path}"`, {
7041
- hint: "Use a folder of the site, like 30-10-26 or reports/2026 (no dotfiles)."
8656
+ hint: "Use one of the site\u2019s Paths, like PAY-412: `fling paths <site>` lists them."
7042
8657
  });
7043
8658
  }
7044
8659
  path3 = folder;
7045
8660
  }
7046
- const target = resolve5(dir ?? name);
8661
+ const target = resolve6(dir ?? name);
7047
8662
  let progressShown = false;
7048
8663
  const pulled = await pullSite(
7049
8664
  { client, fetch: ctx.fetch },
@@ -7064,101 +8679,21 @@ async function runPull(ctx, name, dir, opts) {
7064
8679
  ).finally(() => {
7065
8680
  if (progressShown) ctx.progress(null);
7066
8681
  });
7067
- const shown2 = relative6(process.cwd(), pulled.dir) || ".";
8682
+ const shown3 = relative7(process.cwd(), pulled.dir) || ".";
7068
8683
  ctx.log.success(
7069
- `Pulled ${name}${path3 ? ` /${path3}/` : ""} into ${shown2} ${chalk11.dim(`(${pulled.fileCount} files, ${formatBytes(pulled.totalBytes)}, version ${pulled.versionId})`)}`
8684
+ `Pulled ${name}${path3 ? ` /${path3}/` : ""} into ${shown3} ${chalk13.dim(`(${pulled.fileCount} files, ${formatBytes(pulled.totalBytes)}, version ${pulled.versionId})`)}`
7070
8685
  );
7071
8686
  ctx.out.data(
7072
8687
  pulled,
7073
- () => chalk11.dim(
7074
- `Change what you need, then \`${ctx.binName} deploy ${shown2}\`: it publishes on version ${pulled.versionId}, and stops if someone publishes ${name} before you do.`
8688
+ () => chalk13.dim(
8689
+ `Change what you need, then \`${ctx.binName} deploy ${shown3}\`: it publishes on version ${pulled.versionId}, and stops if someone publishes ${name} before you do.`
7075
8690
  )
7076
8691
  );
7077
8692
  }
7078
8693
 
7079
8694
  // src/commands/setup.ts
7080
8695
  import { intro, outro, spinner } from "@clack/prompts";
7081
- import chalk12 from "chalk";
7082
-
7083
- // src/claude.ts
7084
- import { execFile as execFile2 } from "node:child_process";
7085
- import { access, mkdtemp as mkdtemp2, writeFile as writeFile5 } from "node:fs/promises";
7086
- import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
7087
- import { join as join9 } from "node:path";
7088
- var MARKETPLACE_SOURCE = REPO_SLUG;
7089
- var MARKETPLACE_NAME = "fling";
7090
- var PLUGIN_NAME = "fling";
7091
- var PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
7092
- var MCPB_PATH = "/downloads/fling.mcpb";
7093
- var runClaude = (args) => new Promise((resolve7) => {
7094
- execFile2(
7095
- "claude",
7096
- [...args],
7097
- { timeout: 12e4, maxBuffer: 16 * 1024 * 1024, shell: process.platform === "win32" },
7098
- (error, stdout, stderr) => {
7099
- const missing = error?.code === "ENOENT";
7100
- const code = !error ? 0 : missing ? -1 : typeof error.code === "number" ? error.code : 1;
7101
- resolve7({ code, stdout: String(stdout), stderr: String(stderr) });
7102
- }
7103
- );
7104
- });
7105
- async function claudeCodeStatus(run) {
7106
- const version = await run(["--version"]);
7107
- if (version.code !== 0) return { claude: "missing" };
7108
- const claude = version.stdout.trim().split(/\s+/)[0] ?? "unknown";
7109
- const list = await run(["plugin", "list", "--json"]);
7110
- let plugins = [];
7111
- try {
7112
- plugins = JSON.parse(list.stdout);
7113
- } catch {
7114
- }
7115
- const fling = Array.isArray(plugins) ? plugins.find((p) => p.id === PLUGIN_ID) : void 0;
7116
- if (!fling) return { claude, installed: false };
7117
- return {
7118
- claude,
7119
- installed: true,
7120
- version: fling.version,
7121
- scope: fling.scope,
7122
- enabled: fling.enabled
7123
- };
7124
- }
7125
- async function installPlugin(run, scope) {
7126
- check(
7127
- await run(["plugin", "install", PLUGIN_NAME, "--marketplace", MARKETPLACE_SOURCE, "-s", scope]),
7128
- "install the Fling plugin"
7129
- );
7130
- }
7131
- async function updatePlugin(run, scope) {
7132
- check(await run(["plugin", "marketplace", "update", MARKETPLACE_NAME]), "update the marketplace");
7133
- check(
7134
- await run(["plugin", "update", PLUGIN_ID, ...scope ? ["-s", scope] : []]),
7135
- "update the Fling plugin"
7136
- );
7137
- }
7138
- function check(result, what) {
7139
- if (result.code === 0) return;
7140
- const detail = (result.stderr || result.stdout).trim().split("\n").at(-1);
7141
- throw new Error(`Could not ${what}${detail ? `: ${detail}` : ""}`);
7142
- }
7143
- async function findClaudeDesktop(platform = process.platform, env = process.env, exists = (path3) => access(path3).then(
7144
- () => true,
7145
- () => false
7146
- )) {
7147
- const candidates = platform === "darwin" ? ["/Applications/Claude.app", join9(homedir3(), "Applications", "Claude.app")] : platform === "win32" && env.LOCALAPPDATA ? [join9(env.LOCALAPPDATA, "AnthropicClaude"), join9(env.LOCALAPPDATA, "Programs", "Claude")] : [];
7148
- for (const path3 of candidates) if (await exists(path3)) return path3;
7149
- return null;
7150
- }
7151
- async function downloadExtension(serverUrl, fetch2) {
7152
- const url = new URL(MCPB_PATH, serverUrl).toString();
7153
- const response = await fetch2(url);
7154
- if (!response.ok) throw new Error(`Could not download ${url} (HTTP ${response.status})`);
7155
- const dir = await mkdtemp2(join9(tmpdir2(), "fling-mcpb-"));
7156
- const file = join9(dir, "fling.mcpb");
7157
- await writeFile5(file, Buffer.from(await response.arrayBuffer()));
7158
- return file;
7159
- }
7160
-
7161
- // src/commands/setup.ts
8696
+ import chalk14 from "chalk";
7162
8697
  var SCOPES = ["user", "project", "local"];
7163
8698
  async function runSetup(baseCtx, opts, deps = {}) {
7164
8699
  const scope = opts.scope ?? "user";
@@ -7171,14 +8706,14 @@ async function runSetup(baseCtx, opts, deps = {}) {
7171
8706
  ...baseCtx,
7172
8707
  log: createLogger({
7173
8708
  debug: baseCtx.config.debug,
7174
- write: (line) => process.stderr.write(`${chalk12.gray("\u2502")} ${line}
8709
+ write: (line) => process.stderr.write(`${chalk14.gray("\u2502")} ${line}
7175
8710
  `)
7176
8711
  })
7177
8712
  } : baseCtx;
7178
8713
  if (fancy) {
7179
8714
  process.stderr.write(`${logo()}
7180
8715
  `);
7181
- intro(chalk12.bold(" fling setup "), { output: process.stderr });
8716
+ intro(chalk14.bold(" fling setup "), { output: process.stderr });
7182
8717
  }
7183
8718
  const login = await stepLogin(ctx, opts.url);
7184
8719
  const results = {
@@ -7192,7 +8727,7 @@ async function runSetup(baseCtx, opts, deps = {}) {
7192
8727
  const failed = Object.values(results).includes("failed");
7193
8728
  if (fancy)
7194
8729
  outro(
7195
- failed ? chalk12.yellow("Setup finished with problems \u2014 see above, then run `fling setup` again.") : `You're set. Try ${chalk12.bold(`${ctx.binName} deploy ./dist`)}, or ask Claude to \u201Cfling this\u201D.`,
8730
+ failed ? chalk14.yellow("Setup finished with problems \u2014 see above, then run `fling setup` again.") : `You're set. Try ${chalk14.bold(`${ctx.binName} deploy ./dist`)}, or ask Claude to \u201Cfling this\u201D.`,
7196
8731
  { output: process.stderr }
7197
8732
  );
7198
8733
  ctx.out.data(results, () => "");
@@ -7215,7 +8750,10 @@ async function signIn(ctx, serverUrl) {
7215
8750
  try {
7216
8751
  const me = await whoami(requireClient(ctx));
7217
8752
  if (ctx.config.remembered) await ctx.store.setCurrent(serverUrl);
7218
- ctx.log.success(`Signed in as ${chalk12.bold(me.name)} ${chalk12.dim(`<${me.email}>`)}`);
8753
+ ctx.log.success(`Signed in as ${chalk14.bold(me.name)} ${chalk14.dim(`<${me.email}>`)}`);
8754
+ if (ctx.config.remembered)
8755
+ await fetchInstance(serverUrl, ctx.fetch).then((instance) => ctx.store.putServer(serverUrl, { instance }, { makeCurrent: false })).catch(() => {
8756
+ });
7219
8757
  return "already";
7220
8758
  } catch (error) {
7221
8759
  if (exitCodeFor(error) !== 2) {
@@ -7236,48 +8774,80 @@ async function signIn(ctx, serverUrl) {
7236
8774
  async function stepClaudeCode(ctx, run, scope, ask, fancy) {
7237
8775
  const status = await claudeCodeStatus(run);
7238
8776
  if (status.claude === "missing") {
7239
- ctx.log.info(`Claude Code not found ${chalk12.dim("(no `claude` on your PATH) \u2014 skipped")}`);
8777
+ ctx.log.info(`Claude Code not found ${chalk14.dim("(no `claude` on your PATH) \u2014 skipped")}`);
7240
8778
  ctx.log.hint(
7241
8779
  `Once it is installed, run \`${ctx.binName} setup\` again, or in Claude Code: /plugin marketplace add ${MARKETPLACE_SOURCE} then /plugin install ${PLUGIN_ID}`
7242
8780
  );
7243
8781
  return "missing";
7244
8782
  }
7245
- const installed = "installed" in status && status.installed;
7246
- 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?";
7247
- if (ask && !await ctx.confirm(question)) {
7248
- ctx.log.info(chalk12.dim("Claude Code plugin: skipped"));
7249
- return installed ? "already" : "skipped";
8783
+ if ("installed" in status && status.installed) return refreshPlugin(ctx, run, status, fancy);
8784
+ if (ask && !await ctx.confirm("Install the Fling plugin (skill + MCP server) into Claude Code?")) {
8785
+ ctx.log.info(chalk14.dim("Claude Code plugin: skipped"));
8786
+ return "skipped";
7250
8787
  }
7251
8788
  const s = fancy ? spinner({ output: process.stderr }) : null;
7252
- s?.start(installed ? "Updating the Claude Code plugin" : "Installing the Claude Code plugin");
8789
+ s?.start("Installing the Claude Code plugin");
7253
8790
  try {
7254
- if (installed) await updatePlugin(run, status.scope);
7255
- else await installPlugin(run, scope);
8791
+ await installPlugin(run, scope);
7256
8792
  } catch (error) {
7257
8793
  const message = error instanceof Error ? error.message : String(error);
7258
8794
  if (s) s.error(message);
7259
8795
  else ctx.log.error(message);
7260
8796
  return "failed";
7261
8797
  }
7262
- const done = installed ? `Claude Code plugin ${PLUGIN_NAME} is up to date` : `Claude Code plugin ${PLUGIN_NAME} installed ${chalk12.dim(`(${scope} scope)`)}`;
8798
+ const done = `Claude Code plugin ${PLUGIN_NAME} installed ${chalk14.dim(`(${scope} scope)`)}`;
7263
8799
  if (s) s.stop(done);
7264
8800
  else ctx.log.success(done);
7265
8801
  ctx.log.hint("Restart Claude Code (or run /reload-plugins) to pick it up.");
7266
- return installed ? "updated" : "done";
8802
+ return "done";
8803
+ }
8804
+ async function refreshPlugin(ctx, run, before, fancy) {
8805
+ const s = fancy ? spinner({ output: process.stderr }) : null;
8806
+ s?.start("Updating the Claude Code plugin");
8807
+ try {
8808
+ await updatePlugin(run, before.scope);
8809
+ } catch (error) {
8810
+ const message = `${error instanceof Error ? error.message : String(error)} \u2014 the installed plugin${before.version ? ` (${before.version})` : ""} is unchanged`;
8811
+ if (s) s.stop(chalk14.yellow(message));
8812
+ else ctx.log.warn(message);
8813
+ return "already";
8814
+ }
8815
+ const after = await claudeCodeStatus(run).catch(() => null);
8816
+ const version = after && "installed" in after && after.installed ? after.version : void 0;
8817
+ const unchanged = before.version !== void 0 && version === before.version;
8818
+ const done = unchanged ? `Claude Code plugin ${PLUGIN_NAME} is up to date ${chalk14.dim(`(${version})`)}` : before.version && version ? `Claude Code plugin ${PLUGIN_NAME} updated from ${before.version} to ${version}` : `Claude Code plugin ${PLUGIN_NAME} updated${version ? ` to ${version}` : ""}`;
8819
+ if (s) s.stop(done);
8820
+ else ctx.log.success(done);
8821
+ if (unchanged) return "already";
8822
+ ctx.log.hint("Restart Claude Code (or run /reload-plugins) to pick up the new version.");
8823
+ return "updated";
7267
8824
  }
7268
8825
  async function stepClaudeDesktop(ctx, deps, ask, fancy) {
7269
8826
  const app = await (deps.findClaudeDesktop ?? findClaudeDesktop)();
7270
8827
  if (!app) return "missing";
7271
8828
  if (ask && !await ctx.confirm("Add the Fling extension to Claude Desktop too?")) {
7272
- ctx.log.info(chalk12.dim("Claude Desktop extension: skipped"));
8829
+ ctx.log.info(chalk14.dim("Claude Desktop extension: skipped"));
7273
8830
  return "skipped";
7274
8831
  }
7275
8832
  const s = fancy ? spinner({ output: process.stderr }) : null;
7276
8833
  s?.start("Downloading the Claude Desktop extension");
8834
+ const download2 = deps.downloadExtension ?? ((url) => downloadExtension(url, ctx.fetch));
7277
8835
  try {
7278
- const file = await (deps.downloadExtension ?? ((serverUrl) => downloadExtension(serverUrl, ctx.fetch)))(requireServer(ctx));
7279
- await ctx.open(file);
7280
- const done = "Opened fling.mcpb in Claude Desktop \u2014 click Install there to finish";
8836
+ const sources = extensionSources(requireServer(ctx), ctx.config.instance?.release);
8837
+ let got;
8838
+ let lastError;
8839
+ for (const source of sources) {
8840
+ try {
8841
+ got = { file: await download2(source.url), version: source.version };
8842
+ break;
8843
+ } catch (error) {
8844
+ lastError = error;
8845
+ ctx.log.debug(`Could not download ${source.url}: ${String(error)}`);
8846
+ }
8847
+ }
8848
+ if (!got) throw lastError;
8849
+ await ctx.open(got.file);
8850
+ const done = `Opened fling.mcpb${got.version ? ` ${got.version}` : ""} in Claude Desktop \u2014 click Install there to finish (it replaces an older version)`;
7281
8851
  if (s) s.stop(done);
7282
8852
  else ctx.log.success(done);
7283
8853
  return "done";
@@ -7290,7 +8860,7 @@ async function stepClaudeDesktop(ctx, deps, ask, fancy) {
7290
8860
  }
7291
8861
 
7292
8862
  // src/commands/sites.ts
7293
- import chalk13 from "chalk";
8863
+ import chalk15 from "chalk";
7294
8864
  async function runList(ctx, opts) {
7295
8865
  const sites = await listSites(requireClient(ctx), opts);
7296
8866
  ctx.out.data(
@@ -7326,12 +8896,12 @@ async function runListShared(ctx, opts) {
7326
8896
  );
7327
8897
  }
7328
8898
  var accessLabel = (site) => ACCESS_LABELS[siteAccessLevel(site)];
7329
- var row = (label, value) => `${chalk13.bold(`${label}:`.padEnd(14))}${value}`;
8899
+ var row = (label, value) => `${chalk15.bold(`${label}:`.padEnd(14))}${value}`;
7330
8900
  function describeSite(site) {
7331
8901
  return [
7332
8902
  row("Name", site.name),
7333
8903
  row("URL", site.url),
7334
- row("Access", accessLabel(site) + (site.archived ? chalk13.yellow(" (archived)") : "")),
8904
+ row("Access", accessLabel(site) + (site.archived ? chalk15.yellow(" (archived)") : "")),
7335
8905
  row("SPA", site.spa ? "yes" : "no"),
7336
8906
  row("Share link", site.shareUrl ?? "-"),
7337
8907
  row("Version", site.currentVersionId ?? "-"),
@@ -7360,21 +8930,22 @@ function filesRow(site) {
7360
8930
  var membersOf = (ctx) => ctx.config.instance ? privateAudience(ctx.config.instance) : "anyone signed in to this Fling";
7361
8931
  async function runInfo(ctx, name) {
7362
8932
  const site = await getSite(requireClient(ctx), name);
7363
- const access2 = site.audience && audiencesEnabled(ctx.config.instance) ? [`${chalk13.bold("Who:".padEnd(14))}${describeAudience(site, membersOf(ctx))}`] : [];
8933
+ const access3 = site.audience && audiencesEnabled(ctx.config.instance) ? [`${chalk15.bold("Who:".padEnd(14))}${describeAudience(site, membersOf(ctx))}`] : [];
7364
8934
  ctx.out.data(
7365
8935
  site,
7366
8936
  () => [
7367
8937
  ...describeSite(site),
7368
- ...access2,
8938
+ ...access3,
7369
8939
  ...rolesRows(site),
7370
8940
  "",
7371
- chalk13.dim(
8941
+ chalk15.dim(
7372
8942
  `${site.versions.length} version(s) \u2014 \`fling versions ${name}\`; files \u2014 \`fling files ${name}\``
7373
8943
  )
7374
8944
  ].join("\n")
7375
8945
  );
7376
8946
  }
7377
- async function runVersions(ctx, name) {
8947
+ async function runVersions(ctx, name, opts = {}) {
8948
+ if (opts.path !== void 0) return runPathVersions(ctx, name, pathFolder2(opts.path));
7378
8949
  const site = await getSite(requireClient(ctx), name);
7379
8950
  ctx.out.data(
7380
8951
  { name: site.name, currentVersionId: site.currentVersionId, versions: site.versions },
@@ -7389,14 +8960,36 @@ async function runVersions(ctx, name) {
7389
8960
  ])
7390
8961
  );
7391
8962
  }
7392
- async function runFiles(ctx, name, versionId) {
7393
- const result = await versionFiles(requireClient(ctx), name, versionId);
8963
+ async function runPathVersions(ctx, name, folder) {
8964
+ const path3 = await getPath(await pathsClient(ctx), name, folder);
8965
+ ctx.out.data(
8966
+ {
8967
+ name,
8968
+ path: path3.folder,
8969
+ currentVersionId: path3.currentVersionId,
8970
+ versions: path3.versions
8971
+ },
8972
+ () => renderTable(path3.versions, [
8973
+ { header: "", value: (v) => v.id === path3.currentVersionId ? "*" : " " },
8974
+ { header: "VERSION", value: (v) => v.id },
8975
+ { header: "FILES", value: (v) => v.fileCount },
8976
+ { header: "SIZE", value: (v) => formatBytes(v.totalBytes) },
8977
+ { header: "BY", value: (v) => v.publishedBy ?? "-" },
8978
+ { header: "CREATED", value: (v) => formatDate(v.createdAt) }
8979
+ ])
8980
+ );
8981
+ }
8982
+ async function runFiles(ctx, name, versionId, opts = {}) {
8983
+ const folder = opts.path === void 0 ? void 0 : pathFolder2(opts.path);
8984
+ const result = folder === void 0 ? await versionFiles(requireClient(ctx), name, versionId) : await pathVersionFiles(await pathsClient(ctx), name, folder, versionId);
7394
8985
  if (!result.files.some((f) => f.path === "index.html")) {
7395
- ctx.log.warn("No index.html at the root \u2014 the site root URL will 404");
8986
+ ctx.log.warn(
8987
+ folder === void 0 ? "No index.html at the root \u2014 the site root URL will 404" : `No index.html in /${folder}/ \u2014 it will 404`
8988
+ );
7396
8989
  }
7397
8990
  ctx.out.data(result, () => {
7398
8991
  const n = result.files.length;
7399
- const header = chalk13.dim(
8992
+ const header = chalk15.dim(
7400
8993
  `${result.versionId}${result.current ? " (current)" : " (not served)"} \u2014 ${n} file${n === 1 ? "" : "s"}, ${formatBytes(result.totalBytes)}${result.generatedIndex ? ", generated index.html" : ""}`
7401
8994
  );
7402
8995
  const table = renderTable(result.files, [
@@ -7408,7 +9001,16 @@ async function runFiles(ctx, name, versionId) {
7408
9001
  ${table}`;
7409
9002
  });
7410
9003
  }
7411
- async function runRollback(ctx, name, versionId) {
9004
+ async function runRollback(ctx, name, versionId, opts = {}) {
9005
+ if (opts.path !== void 0) {
9006
+ const folder = pathFolder2(opts.path);
9007
+ const path3 = await rollbackPath(await pathsClient(ctx), name, folder, versionId);
9008
+ ctx.log.success(
9009
+ `/${folder}/ of ${name} now serves version ${path3.currentVersionId ?? versionId} (the rest of the site untouched)`
9010
+ );
9011
+ ctx.out.data(path3, () => path3.url);
9012
+ return;
9013
+ }
7412
9014
  const site = await rollback(requireClient(ctx), name, versionId);
7413
9015
  ctx.log.success(`${name} now serves version ${site.currentVersionId ?? versionId}`);
7414
9016
  ctx.out.data(site, () => site.url);
@@ -7432,7 +9034,7 @@ async function runShare(ctx, name, opts) {
7432
9034
  ctx.log.success("Share link created \u2014 anyone with it can view the site");
7433
9035
  ctx.out.data(
7434
9036
  ready === void 0 ? result : { ...result, ready },
7435
- () => result.shareUrl ? result.shareUrl : chalk13.dim("(no share link)")
9037
+ () => result.shareUrl ? result.shareUrl : chalk15.dim("(no share link)")
7436
9038
  );
7437
9039
  }
7438
9040
  async function runVisibility(ctx, name, visibility) {
@@ -7495,6 +9097,7 @@ async function runSetLevel(ctx, name, level, opts) {
7495
9097
  ctx.out.data({ ...site, warnings }, () => describeAudience(site, membersOf(ctx)));
7496
9098
  }
7497
9099
  async function runRoles(ctx, which, name, opts) {
9100
+ if (opts.path !== void 0) return runPathRoles(ctx, which, name, pathFolder2(opts.path), opts);
7498
9101
  const instance = await loadInstanceWith(ctx.store, ctx.config, ctx.fetch, "siteRoles");
7499
9102
  if (instance && !siteRolesEnabled(instance)) {
7500
9103
  throw new CliError(`${instance.name} has no site owners and publishers yet`, {
@@ -7520,6 +9123,23 @@ async function runRoles(ctx, which, name, opts) {
7520
9123
  () => describeRoles(site)
7521
9124
  );
7522
9125
  }
9126
+ async function runPathRoles(ctx, which, name, folder, opts) {
9127
+ const client = await pathsClient(ctx);
9128
+ const change = {
9129
+ ...opts.set ? { set: splitEmails(opts.set) } : {},
9130
+ add: splitEmails(opts.add ?? []),
9131
+ remove: splitEmails(opts.remove ?? []),
9132
+ addGroups: splitGroups(opts.group ?? []),
9133
+ removeGroups: splitGroups(opts.removeGroup ?? [])
9134
+ };
9135
+ const changed = changesAudience(change);
9136
+ const path3 = changed ? await setPathRoles(client, name, folder, which, change) : await getPathRoles(client, name, folder);
9137
+ if (changed) ctx.log.success(`The ${which} of /${folder}/ of ${name} changed`);
9138
+ ctx.out.data(
9139
+ { name, path: path3.folder, owners: path3.owners, publishers: path3.publishers },
9140
+ () => `/${path3.folder}/: ${describeRoles(path3)}`
9141
+ );
9142
+ }
7523
9143
  async function runArchive(ctx, name, unarchive = false) {
7524
9144
  const site = await archiveSite(requireClient(ctx), name, { unarchive });
7525
9145
  ctx.log.success(
@@ -7544,15 +9164,15 @@ async function runRemove(ctx, name, opts) {
7544
9164
  }
7545
9165
 
7546
9166
  // src/mcp.ts
7547
- import { mkdir as mkdir4, mkdtemp as mkdtemp3, rm as rm3, writeFile as writeFile6 } from "node:fs/promises";
9167
+ import { mkdir as mkdir5, mkdtemp as mkdtemp3, rm as rm3, writeFile as writeFile8 } from "node:fs/promises";
7548
9168
  import { tmpdir as tmpdir3 } from "node:os";
7549
- import { basename as basename3, dirname as dirname4, join as join10, resolve as resolvePath } from "node:path";
9169
+ import { basename as basename3, dirname as dirname4, join as join13, resolve as resolvePath } from "node:path";
7550
9170
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7551
9171
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7552
9172
  import { z as z20 } from "zod";
7553
9173
 
7554
9174
  // src/locate.ts
7555
- import { lstat, readdir as readdir3, readFile as readFile9, realpath as realpath3, stat as stat7 } from "node:fs/promises";
9175
+ import { lstat, readdir as readdir3, readFile as readFile12, realpath as realpath4, stat as stat9 } from "node:fs/promises";
7556
9176
  import { posix, win32 } from "node:path";
7557
9177
  import { fileURLToPath } from "node:url";
7558
9178
  var DOWNLOADS_DIR_ENV = "FLING_DOWNLOADS_DIR";
@@ -7565,7 +9185,7 @@ var SANDBOX_PATH_RE = /^\/(mnt\/user-data|mnt\/data|sessions|home\/claude|tmp\/o
7565
9185
  var nodeFs = {
7566
9186
  async stat(path3) {
7567
9187
  try {
7568
- const s = await stat7(path3);
9188
+ const s = await stat9(path3);
7569
9189
  return { isFile: s.isFile(), isDirectory: s.isDirectory(), mtimeMs: s.mtimeMs, size: s.size };
7570
9190
  } catch {
7571
9191
  return null;
@@ -7577,7 +9197,7 @@ var nodeFs = {
7577
9197
  const out = [];
7578
9198
  for (const e of entries) {
7579
9199
  if (e.isSymbolicLink()) {
7580
- const s = await stat7(`${path3}/${e.name}`).catch(() => null);
9200
+ const s = await stat9(`${path3}/${e.name}`).catch(() => null);
7581
9201
  out.push({ name: e.name, isFile: Boolean(s?.isFile()), isDirectory: false });
7582
9202
  } else out.push({ name: e.name, isFile: e.isFile(), isDirectory: e.isDirectory() });
7583
9203
  }
@@ -7587,12 +9207,12 @@ var nodeFs = {
7587
9207
  }
7588
9208
  },
7589
9209
  async realpath(path3) {
7590
- return realpath3(path3).catch(() => null);
9210
+ return realpath4(path3).catch(() => null);
7591
9211
  },
7592
9212
  async readText(path3) {
7593
9213
  try {
7594
9214
  if (!(await lstat(path3)).isFile()) return null;
7595
- return await readFile9(path3, "utf8");
9215
+ return await readFile12(path3, "utf8");
7596
9216
  } catch {
7597
9217
  return null;
7598
9218
  }
@@ -7964,6 +9584,7 @@ var LOGIN_HINT = `Not logged in to Fling. Call fling_login (it opens the browser
7964
9584
  var NO_SERVER_HINT = `No Fling server is set up on this computer. Ask the user for their Fling's address, then call fling_login with it as \`server\` (or ask them to run \`${NPX_COMMAND} login <url>\` in a terminal; in Claude Code: \`! ${NPX_COMMAND} login <url>\`), then try again.`;
7965
9585
  var mcpLocalInstructions = (w, opts = {}) => [
7966
9586
  `${w.name} publishes static sites (HTML/CSS/JS, images, documents) at ${w.sitesPattern}. This server runs on the user's computer.`,
9587
+ ...opts.newerRelease ? [mcpUpdateNote(opts.newerRelease)] : [],
7967
9588
  ...[serversInstructions(opts.servers ?? [])].filter((p) => p !== void 0),
7968
9589
  "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.",
7969
9590
  '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.',
@@ -7979,6 +9600,14 @@ var mcpLocalInstructions = (w, opts = {}) => [
7979
9600
  ] : [],
7980
9601
  ...opts.notifications ? [NOTIFICATIONS_NOTE(w)] : []
7981
9602
  ].join("\n\n");
9603
+ var PUBLISHING_TOOLS = /* @__PURE__ */ new Set(["fling_publish", "fling_deploy"]);
9604
+ function newestRelease(servers) {
9605
+ const newer = servers.map((s) => newerRelease(s.instance?.release)).filter((r) => r !== null);
9606
+ return newer.reduce(
9607
+ (best, r) => best && newerRelease(r, best) === null ? best : r,
9608
+ null
9609
+ );
9610
+ }
7982
9611
  var onFling = (url, instance) => `
7983
9612
  Fling: ${serverLabel(url)}${instance ? ` (${instance.name})` : ""}`;
7984
9613
  function createMcpServer(deps = {}) {
@@ -7999,8 +9628,8 @@ function createMcpServer(deps = {}) {
7999
9628
  inputSchema: { ...def.inputSchema, server: serverArg },
8000
9629
  annotations: def.annotations
8001
9630
  });
8002
- async function edgeReady(url, paths) {
8003
- const r = await waitReady(url, paths).catch(() => ({ ready: false, waitedMs: 0 }));
9631
+ async function edgeReady(url, paths2) {
9632
+ const r = await waitReady(url, paths2).catch(() => ({ ready: false, waitedMs: 0 }));
8004
9633
  return {
8005
9634
  ready: r.ready,
8006
9635
  note: r.ready ? "" : `
@@ -8025,7 +9654,12 @@ Not reachable yet at ${url}: ${NOT_READY_NOTE}.`
8025
9654
  const url = config.serverUrl;
8026
9655
  let instance;
8027
9656
  return {
8028
- client: createApiClient({ serverUrl: url, apiKey: config.apiKey, fetch: fetchImpl }),
9657
+ client: createApiClient({
9658
+ serverUrl: url,
9659
+ apiKey: config.apiKey,
9660
+ fetch: fetchImpl,
9661
+ ...releaseTracking(store, config)
9662
+ }),
8029
9663
  url,
8030
9664
  config,
8031
9665
  instance: () => {
@@ -8056,10 +9690,10 @@ Not reachable yet at ${url}: ${NOT_READY_NOTE}.`
8056
9690
  return instance;
8057
9691
  }
8058
9692
  async function publishFolder2(target2, raw) {
8059
- const folder = normalizePublishPath(raw);
9693
+ const folder = normalizePathFolder(raw);
8060
9694
  if (folder === null) {
8061
9695
  throw new CliError(
8062
- `Invalid path "${raw}": use a folder of the site, like "30-10-26" or "reports/2026" (no dotfiles)`
9696
+ `Invalid path "${raw}": use one top-level folder of the site, like "PAY-412" (no dotfiles, at most ${MAX_PATH_FOLDER_LENGTH} characters)`
8063
9697
  );
8064
9698
  }
8065
9699
  const instance = await loadInstanceWith(store, target2.config, fetchImpl, "pathPublish");
@@ -8078,6 +9712,23 @@ Not reachable yet at ${url}: ${NOT_READY_NOTE}.`
8078
9712
  );
8079
9713
  }
8080
9714
  }
9715
+ async function pathOn(target2, raw) {
9716
+ const folder = normalizePathFolder(raw);
9717
+ if (folder === null) {
9718
+ throw new CliError(
9719
+ `"${raw}" is not a Path: give one top-level folder of the site, like "PAY-412"`
9720
+ );
9721
+ }
9722
+ await requirePaths(target2);
9723
+ return folder;
9724
+ }
9725
+ async function requirePaths(target2) {
9726
+ const instance = await loadInstanceWith(store, target2.config, fetchImpl, "paths");
9727
+ if (instance && !pathsEnabled(instance)) {
9728
+ throw new CliError(`${instance.name} (${serverLabel(target2.url)}) has no Paths`);
9729
+ }
9730
+ return instance;
9731
+ }
8081
9732
  async function linkedServer(path3) {
8082
9733
  try {
8083
9734
  const located = await resolveInput(path3, locate);
@@ -8086,10 +9737,24 @@ Not reachable yet at ${url}: ${NOT_READY_NOTE}.`
8086
9737
  return void 0;
8087
9738
  }
8088
9739
  }
9740
+ let updateNoted = false;
9741
+ async function updateNoteFor(t2) {
9742
+ if (updateNoted) return "";
9743
+ const stored = await store.resolve({ serverUrl: t2.url }).catch(() => null);
9744
+ const release = newerRelease(
9745
+ (stored?.instance ?? await t2.instance().catch(() => null))?.release
9746
+ );
9747
+ if (!release) return "";
9748
+ updateNoted = true;
9749
+ return `
9750
+ ${mcpUpdateNote(release)}`;
9751
+ }
8089
9752
  async function run(tool2, server2, op) {
8090
9753
  try {
8091
- const { summary, data } = await op(await target(server2, tool2));
8092
- return textResult(`${summary}
9754
+ const t2 = await target(server2, tool2);
9755
+ const { summary, data } = await op(t2);
9756
+ const update = PUBLISHING_TOOLS.has(tool2) ? await updateNoteFor(t2) : "";
9757
+ return textResult(`${summary}${update}
8093
9758
 
8094
9759
  ${JSON.stringify(data, null, 2)}`);
8095
9760
  } catch (error) {
@@ -8100,9 +9765,6 @@ ${JSON.stringify(data, null, 2)}`);
8100
9765
  if (opts.access) await requireAccessLevels(t2);
8101
9766
  const link = opts.link ?? null;
8102
9767
  if (link && !opts.force || opts.base !== void 0) await requirePull(t2);
8103
- if (opts.base !== void 0 && opts.folder !== void 0) {
8104
- throw new CliError("Pass `base` or `path`, not both");
8105
- }
8106
9768
  const folder = opts.folder === void 0 ? void 0 : await publishFolder2(t2, opts.folder);
8107
9769
  const shownFolder = folder ?? link?.path;
8108
9770
  const inFolder = (file) => shownFolder === void 0 ? file : `${shownFolder}/${file}`;
@@ -8113,17 +9775,17 @@ ${JSON.stringify(data, null, 2)}`);
8113
9775
  ${l}.`).join("");
8114
9776
  let generatedIndex = false;
8115
9777
  const asItIs = link !== null || opts.base !== void 0;
8116
- if (!asItIs && !hasRootIndex(local)) {
9778
+ if (!asItIs && !hasRootIndex(local, (await t2.instance())?.capabilities.markdown === true)) {
8117
9779
  const pages = htmlPages(local).map((f) => f.path);
8118
9780
  if (pages.length > 0 && opts.listHtml === void 0) {
8119
- const shown2 = pages.slice(0, 20);
9781
+ const shown3 = pages.slice(0, 20);
8120
9782
  return {
8121
9783
  kind: "needs_decision",
8122
9784
  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\`.`,
8123
9785
  data: {
8124
9786
  status: "needs_decision",
8125
- htmlPages: shown2,
8126
- ...pages.length > shown2.length ? { moreHtmlPages: pages.length - shown2.length } : {},
9787
+ htmlPages: shown3,
9788
+ ...pages.length > shown3.length ? { moreHtmlPages: pages.length - shown3.length } : {},
8127
9789
  fileCount: local.files.length
8128
9790
  }
8129
9791
  };
@@ -8135,7 +9797,7 @@ ${l}.`).join("");
8135
9797
  }
8136
9798
  const onBase = link && !opts.force ? onLink(link, local) : null;
8137
9799
  const changes = link ? onLink(link, local).changes : null;
8138
- const layered = folder !== void 0 || onBase !== null || opts.base !== void 0;
9800
+ const layered = onBase !== null || opts.base !== void 0;
8139
9801
  let instance = await t2.instance();
8140
9802
  const forms2 = await planForms({
8141
9803
  dir,
@@ -8145,7 +9807,8 @@ ${l}.`).join("");
8145
9807
  // A Fling whose stored metadata predates forms is asked again before a forms file is refused.
8146
9808
  instance: formsEnabled(instance) ? instance : await loadInstanceWith(store, t2.config, fetchImpl, "forms"),
8147
9809
  bin: "fling",
8148
- ...opts.forms === void 0 ? {} : { inline: opts.forms }
9810
+ ...opts.forms === void 0 ? {} : { inline: opts.forms },
9811
+ ...shownFolder === void 0 ? {} : { path: shownFolder }
8149
9812
  });
8150
9813
  const result = await deploy(
8151
9814
  { client: t2.client, fetch: fetchImpl },
@@ -8153,10 +9816,9 @@ ${l}.`).join("");
8153
9816
  dir,
8154
9817
  name: opts.name,
8155
9818
  manifest: local,
8156
- path: folder,
9819
+ path: folder ?? onBase?.path,
8157
9820
  ...onBase ? {
8158
- base: onBase.base,
8159
- ...onBase.partial ? { partial: true, remove: onBase.remove, within: onBase.within } : {}
9821
+ base: onBase.base
8160
9822
  } : opts.base !== void 0 ? { base: opts.base, partial: true, remove: opts.remove ?? [] } : {},
8161
9823
  spa: opts.spa,
8162
9824
  ...opts.access ? {
@@ -8179,7 +9841,7 @@ ${l}.`).join("");
8179
9841
  );
8180
9842
  });
8181
9843
  if (link) await writeSiteLink(dir, linkAfter(link, result.versionId, local, forms2.sha256));
8182
- 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})`;
9844
+ 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 kept as it was)` : `Deployed ${result.fileCount} files${folder === void 0 ? "" : ` to the Path /${folder}/ (the rest of the site untouched)`} (uploaded ${result.uploaded})`;
8183
9845
  const warnings = result.warnings.length ? `
8184
9846
  Warnings:
8185
9847
  - ${result.warnings.join("\n- ")}` : "";
@@ -8190,13 +9852,14 @@ Warnings:
8190
9852
  const url = shownFolder === void 0 ? result.url : new URL(`${shownFolder}/`, result.url).href;
8191
9853
  instance = await t2.instance();
8192
9854
  const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
9855
+ const onPath2 = shownFolder !== void 0;
8193
9856
  let formsSummary = forms2.check?.summary ?? null;
8194
- if (!formsSummary && forms2.send === void 0 && formsEnabled(instance)) {
9857
+ if (!formsSummary && !onPath2 && forms2.send === void 0 && formsEnabled(instance)) {
8195
9858
  const live = await getForms(t2.client, result.name).catch(() => null);
8196
9859
  if (live?.config) formsSummary = checkFormsConfig(live.config).summary;
8197
9860
  }
8198
9861
  const responsesPage = formsSummary && instance ? responsesPageUrl(instance.appOrigin, result.name) : void 0;
8199
- const formsHint2 = formsEnabled(instance) && !formsSummary ? FORMS_HINT : void 0;
9862
+ const formsHint2 = formsEnabled(instance) && !formsSummary && !onPath2 ? FORMS_HINT : void 0;
8200
9863
  const formsText = formsSummary && responsesPage ? `
8201
9864
  ${describeFormsSummary(formsSummary, responsesPage)}` : "";
8202
9865
  const listing2 = generatedIndex ? " \u2014 with a generated index.html listing the HTML pages" : "";
@@ -8205,12 +9868,12 @@ ${describeFormsSummary(formsSummary, responsesPage)}` : "";
8205
9868
  ${shareForFeedback(review, instance?.name ?? "Fling", formsSummary !== null)}
8206
9869
  Site: ${url} (${level})${formsText}` : `${what}: ${url} (${level})${listing2}${formsText}`;
8207
9870
  const who = describeSiteAccess(result.site, await membersOn(t2));
8208
- const access3 = result.first ? `
9871
+ const access4 = result.first ? `
8209
9872
  Who can open it: ${who} ${accessChangeHint("mcp", result.name, { invites: instance?.capabilities.invites === true })}` : opts.access ? `
8210
9873
  Its access is unchanged (deploying never changes it for an existing site): ${who} ${accessChangeHint("mcp", result.name, { invites: instance?.capabilities.invites === true })}` : "";
8211
9874
  return {
8212
9875
  kind: "deployed",
8213
- summary: `${head}${access3}${warnings}${skippedNote}${edge.note}${onFling(t2.url, instance)}
9876
+ summary: `${head}${access4}${warnings}${describePublishHints(result.hints)}${skippedNote}${edge.note}${onFling(t2.url, instance)}
8214
9877
  To check exactly which files the site has, call fling_files with name "${result.name}".` + (formsHint2 ? `
8215
9878
  ${formsHint2}` : ""),
8216
9879
  data: {
@@ -8247,7 +9910,8 @@ ${formsHint2}` : ""),
8247
9910
  comments: servers.some((s) => commentsEnabled(s.instance)),
8248
9911
  forms: servers.some((s) => formsEnabled(s.instance)),
8249
9912
  notifications: servers.some((s) => notificationsEnabled(s.instance)),
8250
- servers
9913
+ servers,
9914
+ newerRelease: newestRelease(servers)
8251
9915
  })
8252
9916
  }
8253
9917
  );
@@ -8255,7 +9919,7 @@ ${formsHint2}` : ""),
8255
9919
  listHtml: listHtml2,
8256
9920
  spa: spa2,
8257
9921
  public: isPublic,
8258
- access: access2,
9922
+ access: access3,
8259
9923
  emails: emails2,
8260
9924
  groups: groups2,
8261
9925
  forms: formsArg
@@ -8273,10 +9937,10 @@ ${formsHint2}` : ""),
8273
9937
  "Site name (lowercase letters, digits, single hyphens; max 50). Defaults to a name generated from the file or folder"
8274
9938
  ),
8275
9939
  sitePath: z20.string().min(1).max(1024).optional().describe(
8276
- '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'
9940
+ 'Publish as the Path at this top-level folder of the site, e.g. "PAY-412" (served at <site>/PAY-412/); the rest of the site is untouched. Needs `name`. Omit to publish the site itself'
8277
9941
  ),
8278
9942
  spa: spa2,
8279
- access: access2,
9943
+ access: access3,
8280
9944
  emails: emails2,
8281
9945
  groups: groups2,
8282
9946
  public: isPublic,
@@ -8298,7 +9962,9 @@ ${formsHint2}` : ""),
8298
9962
  "`sitePath` needs `name`: the site to publish into. Ask the user which site, then call fling_deploy again"
8299
9963
  );
8300
9964
  }
8301
- const prepared = await prepareInput(located.path);
9965
+ const prepared = await prepareInput(located.path, {
9966
+ markdown: (await t2.instance())?.capabilities.markdown === true
9967
+ });
8302
9968
  try {
8303
9969
  const found = prepared.kind === "folder" ? await readSiteLink(prepared.dir) : null;
8304
9970
  const link = found && (args.name === void 0 || args.name === found.name) && args.sitePath === void 0 && found.server === t2.url ? found : null;
@@ -8352,7 +10018,7 @@ ${formsHint2}` : ""),
8352
10018
  versionId: z20.string().min(1).max(100).optional().describe(
8353
10019
  "Another version than the live one (owners and publishers; ids from fling_info)"
8354
10020
  ),
8355
- path: z20.string().min(1).max(1024).optional().describe('Only this folder of the site, e.g. "30-10-26"'),
10021
+ path: z20.string().min(1).max(1024).optional().describe('Only this Path of the site, e.g. "PAY-412"'),
8356
10022
  force: z20.boolean().optional().describe("Write into the folder even if it is not empty"),
8357
10023
  server: serverArg
8358
10024
  },
@@ -8360,13 +10026,13 @@ ${formsHint2}` : ""),
8360
10026
  },
8361
10027
  (args) => run("fling_pull", args.server, async (t2) => {
8362
10028
  await requirePull(t2);
8363
- const path3 = args.path === void 0 ? void 0 : normalizePublishPath(args.path);
10029
+ const path3 = args.path === void 0 ? void 0 : normalizePathFolder(args.path);
8364
10030
  if (path3 === null) {
8365
10031
  throw new CliError(
8366
- `Invalid path "${args.path}": use a folder of the site, like "30-10-26" (no dotfiles)`
10032
+ `Invalid path "${args.path}": use one of the site's Paths, like "PAY-412"`
8367
10033
  );
8368
10034
  }
8369
- const dir = args.dir === void 0 ? join10(locate.home, "Documents", "Claude", "fling", args.name) : resolvePath(cwd, args.dir);
10035
+ const dir = args.dir === void 0 ? join13(locate.home, "Documents", "Claude", "fling", args.name) : resolvePath(cwd, args.dir);
8370
10036
  const pulled = await pullSite(
8371
10037
  { client: t2.client, fetch: fetchImpl },
8372
10038
  {
@@ -8388,20 +10054,23 @@ ${formsHint2}` : ""),
8388
10054
  serverTool(mcpTools2.readFiles),
8389
10055
  (args) => run(mcpTools2.readFiles.name, args.server, async (t2) => {
8390
10056
  await requirePull(t2);
8391
- const path3 = args.path === void 0 ? void 0 : normalizePublishPath(args.path);
8392
- if (path3 === null) throw new CliError(`Invalid path "${args.path}"`);
10057
+ const path3 = args.path === void 0 ? void 0 : normalizePathFolder(args.path);
10058
+ if (path3 === null)
10059
+ throw new CliError(`Invalid path "${args.path}": use one of the site's Paths`);
8393
10060
  const pulled = await t2.client.request(
8394
10061
  "GET",
8395
10062
  `/api/sites/${encodeURIComponent(args.name)}/pull`,
8396
10063
  { schema: PullResponseSchema, query: { version: args.versionId, path: path3 } }
8397
10064
  );
8398
- const { page, tooLarge, missing, nextCursor } = pageOfFiles(pulled.files, {
10065
+ const prefix = path3 === void 0 ? "" : `${path3}/`;
10066
+ const within2 = pulled.files.map((f) => ({ ...f, path: f.path.slice(prefix.length) }));
10067
+ const { page: page2, tooLarge, missing, nextCursor } = pageOfFiles(within2, {
8399
10068
  paths: args.paths,
8400
10069
  cursor: args.cursor,
8401
10070
  budget: MCP_READ_MAX_BYTES
8402
10071
  });
8403
10072
  const read = await Promise.all(
8404
- page.map(async ({ url, ...f }) => {
10073
+ page2.map(async ({ url, ...f }) => {
8405
10074
  const res = await fetchImpl(url);
8406
10075
  if (!res.ok) throw new CliError(`Could not read ${f.path}: HTTP ${res.status}`);
8407
10076
  return { ...f, ...encodeForAgent(f.path, new Uint8Array(await res.arrayBuffer())) };
@@ -8414,6 +10083,7 @@ ${formsHint2}` : ""),
8414
10083
  const result = {
8415
10084
  name: args.name,
8416
10085
  versionId: pulled.versionId,
10086
+ ...path3 === void 0 ? {} : { path: path3 },
8417
10087
  files,
8418
10088
  missing,
8419
10089
  ...nextCursor ? { nextCursor } : {}
@@ -8472,7 +10142,7 @@ ${JSON.stringify(data, null, 2)}`);
8472
10142
  }
8473
10143
  if (args.files.length === 0 && !args.remove?.length)
8474
10144
  throw new CliError("No files to publish");
8475
- const tmp = await mkdtemp3(join10(tmpdir3(), "fling-publish-"));
10145
+ const tmp = await mkdtemp3(join13(tmpdir3(), "fling-publish-"));
8476
10146
  try {
8477
10147
  const { skippedDotfiles } = await writeInlineFiles(tmp, args.files);
8478
10148
  return await deployFolder(t2, tmp, {
@@ -8621,6 +10291,29 @@ ${known.join("\n")}` : ""
8621
10291
  }
8622
10292
  }
8623
10293
  );
10294
+ server.registerTool(
10295
+ "fling_doctor",
10296
+ {
10297
+ title: "Check this computer's Fling setup",
10298
+ description: "Diagnose Fling on the user's computer, read-only: the CLI's version (and the latest on npm), Node.js, the config, each Fling set up here (reachable, its release, whether the sign-in still works, its features), the Claude Code plugin and the Claude Desktop extension. One line per check (\u2714 ok, \u25B2 warning, \u2716 problem), each warning and problem with its fix. Call it when something about Fling does not work (sign-in, tools missing, versions), then tell the user the fixes; never run them for the user without asking.",
10299
+ inputSchema: {},
10300
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }
10301
+ },
10302
+ async () => {
10303
+ try {
10304
+ const checks = await runChecks({
10305
+ ...systemDoctorDeps(store, fetchImpl, runClaude, () => findClaudeDesktop()),
10306
+ ...deps.doctor
10307
+ });
10308
+ const ok = doctorExitCode(checks) === 0;
10309
+ return textResult(`${describeChecks(checks)}
10310
+
10311
+ ${JSON.stringify({ ok, checks }, null, 2)}`);
10312
+ } catch (error) {
10313
+ return failure(error);
10314
+ }
10315
+ }
10316
+ );
8624
10317
  server.registerTool(
8625
10318
  "fling_servers",
8626
10319
  {
@@ -8696,8 +10389,12 @@ ${lines}` : ""}`,
8696
10389
  t.info.name,
8697
10390
  serverTool(t.info),
8698
10391
  (args) => run(t.info.name, args.server, async (target2) => {
10392
+ if (args.path !== void 0) {
10393
+ const path3 = await getPath(target2.client, args.name, await pathOn(target2, args.path));
10394
+ return { summary: describePathDetail(args.name, path3), data: { name: args.name, ...path3 } };
10395
+ }
8699
10396
  const site = await getSite(target2.client, args.name);
8700
- const access3 = site.audience ? `
10397
+ const access4 = site.audience ? `
8701
10398
  ${describeAudience(site, await membersOn(target2))}` : "";
8702
10399
  const current = site.versions.find((v) => v.id === site.currentVersionId);
8703
10400
  const by = current?.publishedBy ? ` by ${current.publishedBy}` : "";
@@ -8706,14 +10403,25 @@ ${describeAudience(site, await membersOn(target2))}` : "";
8706
10403
  Current version published${by}${to}` : "";
8707
10404
  const roles = site.owners ? `
8708
10405
  ${describeRoles(site)}` : "";
8709
- return { summary: `${site.name}: ${site.url}${access3}${roles}${published}`, data: site };
10406
+ return { summary: `${site.name}: ${site.url}${access4}${roles}${published}`, data: site };
8710
10407
  })
8711
10408
  );
8712
10409
  server.registerTool(
8713
10410
  t.files.name,
8714
10411
  serverTool(t.files),
8715
- (args) => run(t.files.name, args.server, async ({ client }) => {
8716
- const result = await versionFiles(client, args.name, args.versionId);
10412
+ (args) => run(t.files.name, args.server, async (target2) => {
10413
+ if (args.path !== void 0) {
10414
+ const folder = await pathOn(target2, args.path);
10415
+ const result2 = await pathVersionFiles(target2.client, args.name, folder, args.versionId);
10416
+ return {
10417
+ summary: describeVersionFiles(`${args.name} /${folder}/`, result2, {
10418
+ current: result2.current,
10419
+ url: result2.url
10420
+ }),
10421
+ data: result2
10422
+ };
10423
+ }
10424
+ const result = await versionFiles(target2.client, args.name, args.versionId);
8717
10425
  return {
8718
10426
  summary: describeVersionFiles(result.name, result, {
8719
10427
  current: result.current,
@@ -8937,14 +10645,76 @@ ${describeGroups(groups3, me.email)}`,
8937
10645
  server.registerTool(
8938
10646
  t.rollback.name,
8939
10647
  serverTool(t.rollback),
8940
- (args) => run(t.rollback.name, args.server, async ({ client }) => {
8941
- const site = await rollback(client, args.name, args.versionId);
10648
+ (args) => run(t.rollback.name, args.server, async (target2) => {
10649
+ if (args.path !== void 0) {
10650
+ const folder = await pathOn(target2, args.path);
10651
+ const path3 = await rollbackPath(target2.client, args.name, folder, args.versionId);
10652
+ return {
10653
+ summary: `/${folder}/ of ${args.name} now serves version ${path3.currentVersionId}: ${path3.url} (the rest of the site untouched)`,
10654
+ data: { name: args.name, ...path3 }
10655
+ };
10656
+ }
10657
+ const site = await rollback(target2.client, args.name, args.versionId);
8942
10658
  return {
8943
10659
  summary: `${site.name} now serves version ${site.currentVersionId}: ${site.url}`,
8944
10660
  data: site
8945
10661
  };
8946
10662
  })
8947
10663
  );
10664
+ if (servers.some((s) => pathsEnabled(s.instance))) {
10665
+ server.registerTool(
10666
+ t.paths.name,
10667
+ serverTool(t.paths),
10668
+ (args) => run(t.paths.name, args.server, async (target2) => {
10669
+ const instance = await requirePaths(target2);
10670
+ const paths2 = await listPaths(target2.client, args.name);
10671
+ return {
10672
+ summary: `${describePaths(args.name, paths2)}${onFling(target2.url, instance)}`,
10673
+ data: { name: args.name, paths: paths2 }
10674
+ };
10675
+ })
10676
+ );
10677
+ }
10678
+ server.registerTool(
10679
+ t.pathsTemplate.name,
10680
+ {
10681
+ title: t.pathsTemplate.title,
10682
+ description: `${t.pathsTemplate.description} To deploy a folder with it, save it as the folder's index.html.`,
10683
+ inputSchema: t.pathsTemplate.inputSchema,
10684
+ annotations: t.pathsTemplate.annotations
10685
+ },
10686
+ async (args) => {
10687
+ try {
10688
+ const html = pathsTemplate(args.template, { title: args.title });
10689
+ return textResult(
10690
+ `A starter "${args.template}" home page with its Paths block in place. Adapt it to what the user wants while keeping the block, check it with fling_paths_check, then publish it as the site's root index.html (without \`path\`), or save it as index.html at the top of a folder to deploy.
10691
+
10692
+ ` + html
10693
+ );
10694
+ } catch (error) {
10695
+ return failure(error);
10696
+ }
10697
+ }
10698
+ );
10699
+ server.registerTool(
10700
+ t.pathsCheck.name,
10701
+ {
10702
+ title: t.pathsCheck.title,
10703
+ description: t.pathsCheck.description,
10704
+ inputSchema: t.pathsCheck.inputSchema,
10705
+ annotations: t.pathsCheck.annotations
10706
+ },
10707
+ async (args) => {
10708
+ try {
10709
+ const check2 = checkPathsIndex(args.html);
10710
+ return textResult(`${describePathsCheck(check2)}
10711
+
10712
+ ${JSON.stringify(check2, null, 2)}`);
10713
+ } catch (error) {
10714
+ return failure(error);
10715
+ }
10716
+ }
10717
+ );
8948
10718
  if (servers.some((s) => commentsEnabled(s.instance))) {
8949
10719
  async function reviewOn(target2) {
8950
10720
  const instance = await target2.instance();
@@ -9204,9 +10974,9 @@ async function writeInlineFiles(dir, files) {
9204
10974
  total += bytes.length;
9205
10975
  if (total > MAX_DEPLOY_BYTES)
9206
10976
  throw new CliError(`The site is larger than ${formatBytes(MAX_DEPLOY_BYTES)}`);
9207
- const target = join10(dir, ...path3.split("/"));
9208
- await mkdir4(dirname4(target), { recursive: true });
9209
- await writeFile6(target, bytes);
10977
+ const target = join13(dir, ...path3.split("/"));
10978
+ await mkdir5(dirname4(target), { recursive: true });
10979
+ await writeFile8(target, bytes);
9210
10980
  }
9211
10981
  if (secretFiles.length > 0) throw new SecretFilesError(secretFiles);
9212
10982
  return { skippedDotfiles };
@@ -9250,10 +11020,10 @@ async function runMcpServer(deps = {}) {
9250
11020
  }
9251
11021
 
9252
11022
  // src/route.ts
9253
- import { stat as stat8 } from "node:fs/promises";
9254
- import { resolve as resolve6 } from "node:path";
11023
+ import { stat as stat10 } from "node:fs/promises";
11024
+ import { resolve as resolve7 } from "node:path";
9255
11025
  var PASS_THROUGH = /* @__PURE__ */ new Set(["-h", "--help", "-V", "--version"]);
9256
- var pathExists = (path3) => stat8(resolve6(path3)).then(
11026
+ var pathExists = (path3) => stat10(resolve7(path3)).then(
9257
11027
  () => true,
9258
11028
  () => false
9259
11029
  );
@@ -9304,18 +11074,18 @@ program.name(BIN_NAME).description("Fling a folder of static files at Fling and
9304
11074
  `server for this command: a URL, or the host of one you signed in to (default: $${ENV.url}, else the current one)`
9305
11075
  ).option("--json", "print JSON instead of human output", false).showHelpAfterError().configureHelp({
9306
11076
  sortSubcommands: false,
9307
- styleTitle: (title) => chalk14.bold(brand.primary(title)),
9308
- styleCommandText: (text2) => chalk14.bold(text2),
11077
+ styleTitle: (title) => chalk16.bold(brand.primary(title)),
11078
+ styleCommandText: (text2) => chalk16.bold(text2),
9309
11079
  styleSubcommandText: (text2) => brand.accent(text2),
9310
11080
  styleOptionText: (text2) => brand.accent(text2),
9311
- styleArgumentText: (text2) => chalk14.dim(text2)
11081
+ styleArgumentText: (text2) => chalk16.dim(text2)
9312
11082
  }).addHelpText(
9313
11083
  "before",
9314
11084
  () => process.stdout.isTTY ? logo({ columns: process.stdout.columns }) : ""
9315
11085
  ).addHelpText(
9316
11086
  "after",
9317
11087
  () => `
9318
- ${chalk14.bold(brand.primary("Examples:"))}
11088
+ ${chalk16.bold(brand.primary("Examples:"))}
9319
11089
  $ ${BIN_NAME} login https://fling.example.com sign in to your Fling (once; there is no default)
9320
11090
  $ ${BIN_NAME} setup sign in and add Fling to Claude Code / Claude Desktop
9321
11091
  $ ${BIN_NAME} use list the Flings you signed in to; \`use <url>\` switches
@@ -9326,7 +11096,7 @@ ${chalk14.bold(brand.primary("Examples:"))}
9326
11096
  $ ${BIN_NAME} site.zip a zip of a site
9327
11097
  $ ${BIN_NAME} share my-app public link for people who can't sign in
9328
11098
 
9329
- ${chalk14.dim(`Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
11099
+ ${chalk16.dim(`Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
9330
11100
  Env: ${ENV.apiKey} \xB7 ${ENV.url} \xB7 ${ENV.configDir} \xB7 ${ENV.debug}`)}`
9331
11101
  );
9332
11102
  function action(handler) {
@@ -9341,6 +11111,7 @@ function action(handler) {
9341
11111
  report(error, ctx);
9342
11112
  process.exitCode = exitCodeFor(error);
9343
11113
  }
11114
+ if (ctx) await noticeAfterCommand(ctx, globals.server);
9344
11115
  };
9345
11116
  }
9346
11117
  function report(error, ctx) {
@@ -9350,7 +11121,7 @@ function report(error, ctx) {
9350
11121
  if (error instanceof CliError && error.hint) log.hint(error.hint);
9351
11122
  if (ctx?.config.debug && error instanceof Error && error.stack) log.hint(error.stack);
9352
11123
  if (ctx?.json) {
9353
- const { code, findings, paths } = error;
11124
+ const { code, findings, paths: paths2 } = error;
9354
11125
  process.stdout.write(
9355
11126
  `${formatJson({
9356
11127
  error: {
@@ -9358,7 +11129,7 @@ function report(error, ctx) {
9358
11129
  exitCode: exitCodeFor(error),
9359
11130
  ...typeof code === "string" ? { code } : {},
9360
11131
  ...Array.isArray(findings) ? { findings } : {},
9361
- ...Array.isArray(paths) ? { paths } : {}
11132
+ ...Array.isArray(paths2) ? { paths: paths2 } : {}
9362
11133
  }
9363
11134
  })}
9364
11135
  `
@@ -9372,7 +11143,7 @@ program.command("deploy [path]").helpGroup("Deploy:").description(
9372
11143
  "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)"
9373
11144
  ).option(
9374
11145
  "--path <folder>",
9375
- "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"
11146
+ "publish the folder as the Path at this top-level folder of the site (e.g. PAY-412, served at <site>/PAY-412/), with its own versions, owners and publishers; the rest of the site is untouched; one folder, not nested; needs --name (see `paths`)"
9376
11147
  ).option("--spa", "single-page app: serve index.html for unknown paths").option(
9377
11148
  "--access <level>",
9378
11149
  "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`"
@@ -9395,7 +11166,7 @@ program.command("pull <name> [dir]").helpGroup("Deploy:").description(
9395
11166
  ).option(
9396
11167
  "--version-id <id>",
9397
11168
  "another version than the live one (owners and publishers; ids from `versions`)"
9398
- ).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(
11169
+ ).option("--path <folder>", "only this Path of the site (e.g. PAY-412), at the top of dir").option("--force", "write into dir even if it is not empty").action(
9399
11170
  action(
9400
11171
  (ctx, name, dir, opts) => runPull(ctx, name, dir, opts)
9401
11172
  )
@@ -9407,6 +11178,9 @@ program.command("setup [url]").helpGroup("Setup:").description(
9407
11178
  (ctx, url, opts) => runSetup(ctx, { ...opts, url })
9408
11179
  )
9409
11180
  );
11181
+ program.command("doctor").helpGroup("Setup:").description(
11182
+ "check this machine: the CLI, its config, each Fling, Claude Code and Claude Desktop (changes nothing)"
11183
+ ).action(action((ctx) => runDoctor(ctx)));
9410
11184
  program.command("login [url]").helpGroup("Setup:").description(
9411
11185
  "sign in through the browser to the Fling at url and store an API key; it becomes the current server (default: the current one)"
9412
11186
  ).action(
@@ -9436,11 +11210,20 @@ program.command("ls").helpGroup("Sites:").alias("list").description("list your s
9436
11210
  )
9437
11211
  );
9438
11212
  program.command("info <name>").helpGroup("Sites:").description("show a site").action(action((ctx, name) => runInfo(ctx, name)));
9439
- program.command("versions <name>").helpGroup("Sites:").description("list a site's versions (* = live)").action(action((ctx, name) => runVersions(ctx, name)));
9440
- program.command("files <name> [versionId]").helpGroup("Sites:").description("list the files in a site's live version (or in versionId)").action(
9441
- action((ctx, name, versionId) => runFiles(ctx, name, versionId))
11213
+ program.command("versions <name>").helpGroup("Sites:").description("list a site's versions (* = live)").option("--path <folder>", "one of the site's Paths (e.g. PAY-412): list its own versions").action(action((ctx, name, opts) => runVersions(ctx, name, opts)));
11214
+ program.command("files <name> [versionId]").helpGroup("Sites:").description("list the files in a site's live version (or in versionId)").option("--path <folder>", "one of the site's Paths (e.g. PAY-412): the files of its version").action(
11215
+ action(
11216
+ (ctx, name, versionId, opts) => runFiles(ctx, name, versionId, opts)
11217
+ )
11218
+ );
11219
+ program.command("rollback <name> <versionId>").helpGroup("Sites:").description("serve an earlier version again").option(
11220
+ "--path <folder>",
11221
+ "one of the site's Paths (e.g. PAY-412): roll back only it (ids from `versions --path`)"
11222
+ ).action(
11223
+ action(
11224
+ (ctx, name, versionId, opts) => runRollback(ctx, name, versionId, opts)
11225
+ )
9442
11226
  );
9443
- program.command("rollback <name> <versionId>").helpGroup("Sites:").description("serve an earlier version again").action(action((ctx, name, versionId) => runRollback(ctx, name, versionId)));
9444
11227
  program.command("share <name>").helpGroup("Sites:").description("print (or create) a public share link \u2014 anyone with it can view the site").option("--refresh", "replace the link; the old one stops working").option("--revoke", "remove the link").action(
9445
11228
  action(
9446
11229
  (ctx, name, opts) => runShare(ctx, name, opts)
@@ -9480,7 +11263,10 @@ for (const [which, does] of [
9480
11263
  ).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(
9481
11264
  "--group <slugs...>",
9482
11265
  "add these groups (see `fling groups`); whoever manages a group decides who is in it"
9483
- ).option("--remove-group <slugs...>", "take these groups out").action(
11266
+ ).option("--remove-group <slugs...>", "take these groups out").option(
11267
+ "--path <folder>",
11268
+ `one of the site's Paths (e.g. PAY-412): its own ${which}, besides the site's owners`
11269
+ ).action(
9484
11270
  action(
9485
11271
  (ctx, name, opts) => runRoles(ctx, which, name, opts)
9486
11272
  )
@@ -9489,6 +11275,36 @@ for (const [which, does] of [
9489
11275
  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)));
9490
11276
  program.command("unarchive <name>").helpGroup("Sites:").description("bring an archived site back").action(action((ctx, name) => runArchive(ctx, name, true)));
9491
11277
  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)));
11278
+ var paths = program.command("paths").helpGroup("Paths:").description(
11279
+ "a site's top-level folders published on their own (`deploy --path`), each with its own versions and owners (default: list)"
11280
+ );
11281
+ paths.command("list <name>", { isDefault: true }).alias("ls").description(
11282
+ "list a site's Paths (`paths <name>`): folder, title, version, state, whether you can publish, updated"
11283
+ ).action(action((ctx, name) => runPaths(ctx, name)));
11284
+ paths.command("archive <name> <folder>").description("take a Path off the site (404) but keep its versions; undo with `paths unarchive`").action(action((ctx, name, folder) => runPathArchive(ctx, name, folder)));
11285
+ paths.command("unarchive <name> <folder>").description("bring an archived Path back").action(action((ctx, name, folder) => runPathArchive(ctx, name, folder, true)));
11286
+ paths.command("delete <name> <folder>").alias("rm").description("delete a Path and all its versions (asks first unless --yes)").option("-y, --yes", "do not ask for confirmation").action(
11287
+ action(
11288
+ (ctx, name, folder, opts) => runPathDelete(ctx, name, folder, opts)
11289
+ )
11290
+ );
11291
+ paths.command("init [dir]").description(
11292
+ "write a starter home page (index.html) listing the Paths into dir (default: .), to deploy as the site root"
11293
+ ).addOption(
11294
+ new Option("-t, --template <name>", "how it lists them").choices(PATHS_TEMPLATE_NAMES).default("minimal")
11295
+ ).option("--title <title>", "the page's title and heading").option("--force", "replace an existing index.html").addHelpText(
11296
+ "after",
11297
+ () => `
11298
+ Templates:
11299
+ ${PATHS_TEMPLATE_NAMES.map((n) => ` ${n.padEnd(8)} ${PATHS_TEMPLATE_DESCRIPTIONS[n]}`).join("\n")}
11300
+
11301
+ ${pathsHomeChoice(BIN_NAME).join("\n")}`
11302
+ ).action(
11303
+ action(
11304
+ (ctx, dir, opts) => runPathsInit(ctx, dir, opts)
11305
+ )
11306
+ );
11307
+ paths.command("check [path]").description("check a home page's Paths block: a folder's index.html (default: .) or a file").action(action((ctx, path3) => runPathsCheck(ctx, path3)));
9492
11308
  var groups = program.command("groups").helpGroup("Groups:").description(
9493
11309
  "named lists of people anyone can use in an audience; only the owner changes one (default: list)"
9494
11310
  );