@nearform/fling 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/LICENSE +1 -1
- package/README.md +50 -24
- package/dist/cli.js +1056 -428
- package/dist/cli.js.map +4 -4
- package/package.json +4 -3
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import
|
|
4
|
+
import chalk10 from "chalk";
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
|
|
7
7
|
// src/commands/auth.ts
|
|
@@ -50,8 +50,8 @@ var SECRET_NAMES = /* @__PURE__ */ new Set([
|
|
|
50
50
|
".htpasswd"
|
|
51
51
|
]);
|
|
52
52
|
var SSH_KEY_RE = /^id_(?:rsa|dsa|ecdsa|ed25519)(?:[._-]|$)/;
|
|
53
|
-
function isSecretPath(
|
|
54
|
-
const name = (
|
|
53
|
+
function isSecretPath(path3) {
|
|
54
|
+
const name = (path3.split(/[\\/]/).at(-1) ?? "").toLowerCase();
|
|
55
55
|
if (!name) return false;
|
|
56
56
|
if (SECRET_NAMES.has(name)) return true;
|
|
57
57
|
if (SECRET_EXTENSIONS.some((ext) => name.endsWith(ext) && name.length > ext.length)) return true;
|
|
@@ -161,7 +161,7 @@ function lineAt(starts, index) {
|
|
|
161
161
|
}
|
|
162
162
|
return lo + 1;
|
|
163
163
|
}
|
|
164
|
-
function scanForSecrets(text2,
|
|
164
|
+
function scanForSecrets(text2, path3) {
|
|
165
165
|
let starts;
|
|
166
166
|
const findings = [];
|
|
167
167
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -178,7 +178,7 @@ function scanForSecrets(text2, path) {
|
|
|
178
178
|
const key = `${rule.id}:${line}`;
|
|
179
179
|
if (seen.has(key)) continue;
|
|
180
180
|
seen.add(key);
|
|
181
|
-
findings.push({ path, line, rule: rule.id });
|
|
181
|
+
findings.push({ path: path3, line, rule: rule.id });
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
184
|
return findings.sort((a, b) => a.line - b.line || a.rule.localeCompare(b.rule));
|
|
@@ -186,8 +186,8 @@ function scanForSecrets(text2, path) {
|
|
|
186
186
|
var BINARY_EXTENSIONS = new Set(
|
|
187
187
|
"png jpg jpeg gif webp avif ico bmp tif tiff heic psd woff woff2 ttf otf eot mp3 mp4 m4a m4v mov webm ogg oga ogv wav flac aac avi mkv zip gz tgz br zst bz2 xz 7z rar wasm pdf glb gltf-binary bin dat".split(/\s+/).filter(Boolean)
|
|
188
188
|
);
|
|
189
|
-
function isScannablePath(
|
|
190
|
-
const name =
|
|
189
|
+
function isScannablePath(path3) {
|
|
190
|
+
const name = path3.split("/").at(-1) ?? "";
|
|
191
191
|
const dot = name.lastIndexOf(".");
|
|
192
192
|
return dot <= 0 || !BINARY_EXTENSIONS.has(name.slice(dot + 1).toLowerCase());
|
|
193
193
|
}
|
|
@@ -196,10 +196,10 @@ function looksBinary(bytes) {
|
|
|
196
196
|
for (let i = 0; i < end; i++) if (bytes[i] === 0) return true;
|
|
197
197
|
return false;
|
|
198
198
|
}
|
|
199
|
-
function scanBytesForSecrets(bytes,
|
|
200
|
-
if (!isScannablePath(
|
|
199
|
+
function scanBytesForSecrets(bytes, path3) {
|
|
200
|
+
if (!isScannablePath(path3) || bytes.length > SECRET_SCAN_MAX_FILE_BYTES || looksBinary(bytes))
|
|
201
201
|
return [];
|
|
202
|
-
return scanForSecrets(new TextDecoder("utf-8").decode(bytes),
|
|
202
|
+
return scanForSecrets(new TextDecoder("utf-8").decode(bytes), path3);
|
|
203
203
|
}
|
|
204
204
|
function describeSecretFindings(findings, max = 20) {
|
|
205
205
|
const lines = findings.slice(0, max).map((f) => `${f.path} line ${f.line}: ${SECRET_RULE_LABELS[f.rule] ?? f.rule}`);
|
|
@@ -209,10 +209,10 @@ function describeSecretFindings(findings, max = 20) {
|
|
|
209
209
|
var NEVER_PUBLISHED_NOTE = "Fling never publishes dotfiles, gitignored files, secret files (keys, certificates, credentials) or files that contain secrets.";
|
|
210
210
|
|
|
211
211
|
// ../../packages/shared/src/manifest.ts
|
|
212
|
-
function isValidSitePath(
|
|
213
|
-
if (!
|
|
214
|
-
if (
|
|
215
|
-
return
|
|
212
|
+
function isValidSitePath(path3) {
|
|
213
|
+
if (!path3 || path3.length > MAX_PATH_LENGTH) return false;
|
|
214
|
+
if (path3.startsWith("/") || path3.includes("\\") || path3.includes("\0")) return false;
|
|
215
|
+
return path3.split("/").every((seg) => seg !== "" && !seg.startsWith("."));
|
|
216
216
|
}
|
|
217
217
|
var ManifestFileSchema = z.object({
|
|
218
218
|
path: z.string().refine(isValidSitePath, "invalid path (dotfiles are never published)").refine((p) => !isSecretPath(p), "secret file (keys, certificates, credentials) never published"),
|
|
@@ -228,7 +228,13 @@ var MeSchema = z2.object({
|
|
|
228
228
|
email: z2.string(),
|
|
229
229
|
name: z2.string(),
|
|
230
230
|
picture: z2.string().nullable(),
|
|
231
|
-
userSlug: z2.string()
|
|
231
|
+
userSlug: z2.string(),
|
|
232
|
+
/** one of the instance's admins (servers from before admins don't say: false) */
|
|
233
|
+
admin: z2.boolean().default(false),
|
|
234
|
+
/** while an admin impersonates this user: the admin (browser sessions only) */
|
|
235
|
+
impersonator: z2.string().optional(),
|
|
236
|
+
/** …and when the impersonation lapses */
|
|
237
|
+
impersonationEndsAt: z2.string().optional()
|
|
232
238
|
});
|
|
233
239
|
var SiteSchema = z2.object({
|
|
234
240
|
name: z2.string(),
|
|
@@ -330,8 +336,122 @@ var OAuthRequestInfoSchema = z2.object({
|
|
|
330
336
|
email: z2.string()
|
|
331
337
|
});
|
|
332
338
|
var OAuthDecisionSchema = z2.object({ redirectUrl: z2.string() });
|
|
339
|
+
var AdminUserSchema = z2.object({
|
|
340
|
+
email: z2.string(),
|
|
341
|
+
name: z2.string(),
|
|
342
|
+
picture: z2.string().nullable(),
|
|
343
|
+
userSlug: z2.string(),
|
|
344
|
+
createdAt: z2.string().nullable(),
|
|
345
|
+
lastSignInAt: z2.string().nullable(),
|
|
346
|
+
/** sites the user holds, archived ones included */
|
|
347
|
+
siteCount: z2.number(),
|
|
348
|
+
admin: z2.boolean()
|
|
349
|
+
});
|
|
350
|
+
var AdminUserListSchema = z2.object({
|
|
351
|
+
users: z2.array(AdminUserSchema),
|
|
352
|
+
/** pass back as `cursor` for the next page; null: no more */
|
|
353
|
+
cursor: z2.string().nullable()
|
|
354
|
+
});
|
|
355
|
+
var AuditEntrySchema = z2.object({
|
|
356
|
+
id: z2.string(),
|
|
357
|
+
at: z2.string(),
|
|
358
|
+
action: z2.enum(["impersonation.start", "impersonation.stop"]),
|
|
359
|
+
actor: z2.string(),
|
|
360
|
+
target: z2.string(),
|
|
361
|
+
until: z2.string().optional(),
|
|
362
|
+
lapsed: z2.boolean().optional()
|
|
363
|
+
});
|
|
364
|
+
var AuditListSchema = z2.object({ entries: z2.array(AuditEntrySchema) });
|
|
365
|
+
var ImpersonateRequestSchema = z2.object({ email: z2.string().trim().toLowerCase().min(3) });
|
|
333
366
|
|
|
334
|
-
// ../../packages/shared/src/
|
|
367
|
+
// ../../packages/shared/src/comments.ts
|
|
368
|
+
import { z as z3 } from "zod";
|
|
369
|
+
var COMMENT_MAX_CHARS = 4e3;
|
|
370
|
+
var COMMENT_PATH_MAX = 1024;
|
|
371
|
+
var CommentAuthorSchema = z3.discriminatedUnion("kind", [
|
|
372
|
+
z3.object({ kind: z3.literal("user"), email: z3.string() }),
|
|
373
|
+
z3.object({ kind: z3.literal("guest"), name: z3.string() })
|
|
374
|
+
]);
|
|
375
|
+
var ANCHOR_SELECTOR_MAX = 512;
|
|
376
|
+
var px = z3.number().finite().min(-1e7).max(1e7);
|
|
377
|
+
var percent = z3.number().min(0).max(100);
|
|
378
|
+
var CommentAnchorSchema = z3.object({
|
|
379
|
+
x: percent,
|
|
380
|
+
y: percent,
|
|
381
|
+
selector: z3.string().min(1).max(ANCHOR_SELECTOR_MAX).optional(),
|
|
382
|
+
ex: percent.optional(),
|
|
383
|
+
ey: percent.optional(),
|
|
384
|
+
docX: px.optional(),
|
|
385
|
+
docY: px.optional(),
|
|
386
|
+
scrollX: px.optional(),
|
|
387
|
+
scrollY: px.optional(),
|
|
388
|
+
vw: z3.number().finite().min(0).max(1e5).optional(),
|
|
389
|
+
vh: z3.number().finite().min(0).max(1e5).optional(),
|
|
390
|
+
hash: z3.string().max(COMMENT_PATH_MAX).optional()
|
|
391
|
+
});
|
|
392
|
+
var CommentSchema = z3.object({
|
|
393
|
+
id: z3.string(),
|
|
394
|
+
/** the thread this replies to; null for the comment that starts a thread */
|
|
395
|
+
parentId: z3.string().nullable(),
|
|
396
|
+
/** the page, as normalizeCommentPath() writes it ('/', '/docs/', '/about.html') */
|
|
397
|
+
path: z3.string(),
|
|
398
|
+
/** the version the site was serving when the comment was made (null: none was) */
|
|
399
|
+
versionId: z3.string().nullable(),
|
|
400
|
+
anchor: CommentAnchorSchema.nullable(),
|
|
401
|
+
body: z3.string(),
|
|
402
|
+
author: CommentAuthorSchema,
|
|
403
|
+
createdAt: z3.string(),
|
|
404
|
+
/** threads only: when and by whom it was resolved (null: open) */
|
|
405
|
+
resolvedAt: z3.string().nullable(),
|
|
406
|
+
resolvedBy: CommentAuthorSchema.nullable(),
|
|
407
|
+
/** whether the caller may delete it (policy's answer, so clients don't guess) */
|
|
408
|
+
canDelete: z3.boolean()
|
|
409
|
+
});
|
|
410
|
+
var CommentListSchema = z3.object({
|
|
411
|
+
site: z3.object({
|
|
412
|
+
name: z3.string(),
|
|
413
|
+
url: z3.string(),
|
|
414
|
+
currentVersionId: z3.string().nullable()
|
|
415
|
+
}),
|
|
416
|
+
comments: z3.array(CommentSchema),
|
|
417
|
+
can: z3.object({
|
|
418
|
+
create: z3.boolean(),
|
|
419
|
+
resolve: z3.boolean(),
|
|
420
|
+
/** delete anyone's comment (everyone may delete their own) */
|
|
421
|
+
deleteAny: z3.boolean()
|
|
422
|
+
})
|
|
423
|
+
});
|
|
424
|
+
var CreateCommentRequestSchema = z3.object({
|
|
425
|
+
body: z3.string().trim().min(1).max(COMMENT_MAX_CHARS),
|
|
426
|
+
path: z3.string().max(COMMENT_PATH_MAX).optional(),
|
|
427
|
+
parentId: z3.string().min(1).max(100).optional(),
|
|
428
|
+
anchor: CommentAnchorSchema.optional()
|
|
429
|
+
});
|
|
430
|
+
var UpdateCommentRequestSchema = z3.object({ resolved: z3.boolean() });
|
|
431
|
+
function commentThreads(comments) {
|
|
432
|
+
const threads = /* @__PURE__ */ new Map();
|
|
433
|
+
for (const c of comments) {
|
|
434
|
+
if (c.parentId === null) threads.set(c.id, { comment: c, replies: [] });
|
|
435
|
+
}
|
|
436
|
+
for (const c of comments) {
|
|
437
|
+
if (c.parentId !== null) threads.get(c.parentId)?.replies.push(c);
|
|
438
|
+
}
|
|
439
|
+
return [...threads.values()];
|
|
440
|
+
}
|
|
441
|
+
function authorLabel(author) {
|
|
442
|
+
return author.kind === "user" ? author.email : `${author.name} (guest)`;
|
|
443
|
+
}
|
|
444
|
+
function reviewUrl(appOrigin, name, path3) {
|
|
445
|
+
const base = `${appOrigin.replace(/\/+$/, "")}/review/${name === "<name>" ? name : encodeURIComponent(name)}`;
|
|
446
|
+
return path3 && path3 !== "/" ? `${base}?path=${encodeURIComponent(path3)}` : base;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ../../packages/shared/src/distribution.ts
|
|
450
|
+
var CLI_PACKAGE = "@nearform/fling";
|
|
451
|
+
var REPO_SLUG = "nearform/fling";
|
|
452
|
+
var DEFAULT_INSTALL_COMMAND = `npm install -g ${CLI_PACKAGE}`;
|
|
453
|
+
|
|
454
|
+
// ../../packages/shared/src/site-labels.ts
|
|
335
455
|
var SITE_NAME_MAX = 50;
|
|
336
456
|
var SHARE_TOKEN_LENGTH = 20;
|
|
337
457
|
var RESERVED_SITE_NAMES = [
|
|
@@ -352,26 +472,92 @@ var RESERVED_SITE_NAMES = [
|
|
|
352
472
|
];
|
|
353
473
|
var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
354
474
|
var SHARE_TOKEN_RE = new RegExp(`^[a-z0-9]{${SHARE_TOKEN_LENGTH}}$`);
|
|
355
|
-
function slugify(input, max = SITE_NAME_MAX) {
|
|
356
|
-
return input.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/g, "");
|
|
357
|
-
}
|
|
358
475
|
function isValidSiteName(name) {
|
|
359
476
|
return name.length <= SITE_NAME_MAX && SLUG_RE.test(name) && !name.includes("--") && !RESERVED_SITE_NAMES.includes(name);
|
|
360
477
|
}
|
|
478
|
+
|
|
479
|
+
// ../../packages/shared/src/hosts.ts
|
|
480
|
+
function slugify(input, max = SITE_NAME_MAX) {
|
|
481
|
+
return input.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/g, "");
|
|
482
|
+
}
|
|
361
483
|
function siteUrl(label, sitesDomain, protocol = "https:") {
|
|
362
484
|
return `${protocol}//${label}.${sitesDomain}/`;
|
|
363
485
|
}
|
|
364
486
|
|
|
487
|
+
// ../../packages/shared/src/instance.ts
|
|
488
|
+
import { z as z4 } from "zod";
|
|
489
|
+
var INSTANCE_METADATA_PATH = "/.well-known/fling";
|
|
490
|
+
var HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
491
|
+
var color = z4.string().regex(HEX_COLOR_RE);
|
|
492
|
+
var InstanceAuthSchema = z4.looseObject({
|
|
493
|
+
kind: z4.string().min(1),
|
|
494
|
+
displayName: z4.string().min(1),
|
|
495
|
+
issuer: z4.string().optional(),
|
|
496
|
+
emailDomains: z4.array(z4.string()).default([]),
|
|
497
|
+
invitedEmails: z4.boolean().optional()
|
|
498
|
+
});
|
|
499
|
+
var BrandColorsSchema = z4.looseObject({
|
|
500
|
+
primary: color.optional(),
|
|
501
|
+
primaryContent: color.optional(),
|
|
502
|
+
accent: color.optional(),
|
|
503
|
+
ink: color.optional(),
|
|
504
|
+
highlight: color.optional()
|
|
505
|
+
});
|
|
506
|
+
var InstanceBrandSchema = z4.looseObject({
|
|
507
|
+
/** https: or data: URL of the logo mark shown before the name (light theme). */
|
|
508
|
+
logoUrl: z4.string().optional(),
|
|
509
|
+
/** The same for the dark theme; defaults to `logoUrl`. */
|
|
510
|
+
logoDarkUrl: z4.string().optional(),
|
|
511
|
+
faviconUrl: z4.string().optional(),
|
|
512
|
+
colors: BrandColorsSchema.default({})
|
|
513
|
+
});
|
|
514
|
+
var InstanceMetadataSchema = z4.looseObject({
|
|
515
|
+
version: z4.number().int().min(1),
|
|
516
|
+
/** Display name, e.g. "Fling". */
|
|
517
|
+
name: z4.string().min(1),
|
|
518
|
+
/** e.g. `https://fling.example.test` (no trailing slash) */
|
|
519
|
+
appOrigin: z4.string().url(),
|
|
520
|
+
/** host[:port] sites hang off: a site is `<name>.<sitesDomain>` */
|
|
521
|
+
sitesDomain: z4.string().min(1),
|
|
522
|
+
siteScheme: z4.enum(["http", "https"]),
|
|
523
|
+
auth: InstanceAuthSchema,
|
|
524
|
+
brand: InstanceBrandSchema.default({ colors: {} }),
|
|
525
|
+
/** Feature flags: a client hides what is missing or false. */
|
|
526
|
+
capabilities: z4.record(z4.string(), z4.boolean()).default({}),
|
|
527
|
+
/** How to install the CLI, e.g. `npm install -g <package>`. */
|
|
528
|
+
installCommand: z4.string().min(1)
|
|
529
|
+
});
|
|
530
|
+
function instanceSitesPattern(instance) {
|
|
531
|
+
return `${instance.siteScheme}://<name>.${instance.sitesDomain}`;
|
|
532
|
+
}
|
|
533
|
+
function emailDomainsPhrase(auth) {
|
|
534
|
+
const domains = auth.emailDomains.map((d) => `@${d}`);
|
|
535
|
+
if (domains.length === 0) return null;
|
|
536
|
+
return domains.length === 1 ? domains[0] : `${domains.slice(0, -1).join(", ")} or ${domains.at(-1)}`;
|
|
537
|
+
}
|
|
538
|
+
function allowedAccountsPhrase(auth) {
|
|
539
|
+
const domains = emailDomainsPhrase(auth);
|
|
540
|
+
const parts = [domains && `${domains} accounts`, auth.invitedEmails && "invited accounts"];
|
|
541
|
+
return parts.filter(Boolean).join(" and ") || null;
|
|
542
|
+
}
|
|
543
|
+
function privateAudience(instance) {
|
|
544
|
+
const accounts = allowedAccountsPhrase(instance.auth);
|
|
545
|
+
return `anyone signed in to ${instance.name}${accounts ? ` (${accounts})` : ""}`;
|
|
546
|
+
}
|
|
547
|
+
function instanceMetadataUrl(serverUrl) {
|
|
548
|
+
return `${serverUrl.replace(/\/+$/, "")}${INSTANCE_METADATA_PATH}`;
|
|
549
|
+
}
|
|
550
|
+
|
|
365
551
|
// ../../packages/shared/src/kvs.ts
|
|
366
|
-
import { z as
|
|
367
|
-
var KvsSiteValueSchema =
|
|
552
|
+
import { z as z5 } from "zod";
|
|
553
|
+
var KvsSiteValueSchema = z5.object({
|
|
368
554
|
/** siteId — S3 prefix is `sites/<id>/<v>/` */
|
|
369
|
-
id:
|
|
555
|
+
id: z5.string(),
|
|
370
556
|
/** current versionId */
|
|
371
|
-
v:
|
|
372
|
-
vis:
|
|
557
|
+
v: z5.string(),
|
|
558
|
+
vis: z5.enum(["private", "public"]),
|
|
373
559
|
/** serve /index.html for unknown extensionless paths */
|
|
374
|
-
spa:
|
|
560
|
+
spa: z5.boolean()
|
|
375
561
|
});
|
|
376
562
|
|
|
377
563
|
// ../../packages/shared/src/listing.ts
|
|
@@ -380,9 +566,9 @@ var escapeHtml = (s) => s.replace(
|
|
|
380
566
|
/[&<>"']/g,
|
|
381
567
|
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]
|
|
382
568
|
);
|
|
383
|
-
var hrefFor = (
|
|
384
|
-
function isHtmlPath(
|
|
385
|
-
return /\.html?$/i.test(
|
|
569
|
+
var hrefFor = (path3) => path3.split("/").map(encodeURIComponent).join("/");
|
|
570
|
+
function isHtmlPath(path3) {
|
|
571
|
+
return /\.html?$/i.test(path3);
|
|
386
572
|
}
|
|
387
573
|
function extractHtmlTitle(html) {
|
|
388
574
|
const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html.slice(0, 65536));
|
|
@@ -455,181 +641,238 @@ ${body}
|
|
|
455
641
|
}
|
|
456
642
|
|
|
457
643
|
// ../../packages/shared/src/mcp-tools.ts
|
|
458
|
-
import { z as
|
|
644
|
+
import { z as z6 } from "zod";
|
|
459
645
|
var MCP_PUBLISH_MAX_BYTES = 4 * 1024 * 1024;
|
|
460
646
|
var MCP_UPLOAD_CHUNK_MAX_CHARS = 4 * 1024 * 1024;
|
|
461
647
|
var MCP_UPLOAD_TTL_SECONDS = 60 * 60;
|
|
462
648
|
var tool = (def) => def;
|
|
463
|
-
var
|
|
649
|
+
var GENERIC_REVIEW = "the site's review page in the Fling web app (/review/<name>)";
|
|
650
|
+
function mcpWording(instance) {
|
|
651
|
+
if (!instance) {
|
|
652
|
+
return {
|
|
653
|
+
name: "Fling",
|
|
654
|
+
sitesPattern: "https://<name>.<sites domain>",
|
|
655
|
+
privateAudience: "people signed in to this Fling",
|
|
656
|
+
signIn: "the Fling sign-in page",
|
|
657
|
+
reviewPattern: GENERIC_REVIEW
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
return {
|
|
661
|
+
name: instance.name,
|
|
662
|
+
sitesPattern: instanceSitesPattern(instance),
|
|
663
|
+
privateAudience: privateAudience(instance),
|
|
664
|
+
signIn: `their ${instance.auth.displayName} account`,
|
|
665
|
+
reviewPattern: instance.appOrigin ? reviewUrl(instance.appOrigin, "<name>") : GENERIC_REVIEW
|
|
666
|
+
};
|
|
667
|
+
}
|
|
464
668
|
var NAME_REQUIRED = 'Publishing needs a name: if the user did not give one, propose a short descriptive one (e.g. "q3-report") and confirm it with them first (fling_check_name tells you whether it is free). If the name is taken by someone else the call fails with free suggestions \u2014 offer those to the user and retry with the one they pick.';
|
|
465
|
-
var VISIBILITY = "Sites are private by default (only signed-in Nearformers can open them); pass `public: true` only if the user asks for a public site. Share links (fling_share) are public to anyone who has them.";
|
|
466
669
|
var INLINE_ONLY = "If you created the content in this conversation, pass it directly \u2014 never pass file paths from your sandbox (this server cannot read them) and never try to curl/upload from your sandbox.";
|
|
467
670
|
var NEVER_PUBLISHED = "Fling never publishes dotfiles, gitignored files, secret files (keys, certs, credentials) or files that contain secrets (API keys, tokens, private keys, passwords in database URLs); it stops and tells you which file and line. Remove the secret (or leave the file out) and retry \u2014 there is no way to override this.";
|
|
468
671
|
var ENCODING = '`encoding` is "utf8" (default, for text: HTML, CSS, JS, SVG, JSON, Markdown\u2026) or "base64" (binary: images, fonts, PDFs).';
|
|
469
672
|
var LIST_HTML = "If the files have no root index.html but do include HTML pages, the call does not publish: it reports how many HTML pages there are and asks you to check with the user. Ask them whether to generate an index.html listing those pages (retry with `listHtml: true`) or to publish without one (`listHtml: false` \u2014 the site root will 404, the pages still work at their own paths), then retry.";
|
|
470
|
-
var listHtml =
|
|
673
|
+
var listHtml = z6.boolean().optional().describe(
|
|
471
674
|
"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"
|
|
472
675
|
);
|
|
473
|
-
var siteName =
|
|
474
|
-
var sitePath =
|
|
676
|
+
var siteName = z6.string().min(1).max(200).describe('Site name, e.g. "q3-report"');
|
|
677
|
+
var sitePath = z6.string().min(1).max(1024).describe(
|
|
475
678
|
'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")'
|
|
476
679
|
);
|
|
477
|
-
var
|
|
478
|
-
var
|
|
680
|
+
var commentPath = z6.string().max(1024);
|
|
681
|
+
var commentId = z6.string().min(1).max(100);
|
|
682
|
+
var encoding = z6.enum(["utf8", "base64"]).optional().describe('How `content` is encoded: "utf8" (default) for text, "base64" for binary files');
|
|
683
|
+
var spa = z6.boolean().optional().describe(
|
|
479
684
|
"Single-page app: serve index.html for unknown paths (client-side routing). Omit to keep the current setting"
|
|
480
685
|
);
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
);
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
files:
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
path:
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
listHtml
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
686
|
+
function mcpToolsFor(w) {
|
|
687
|
+
const NAME_RULES = `Site names are 1-${SITE_NAME_MAX} chars of lowercase letters, digits and single hyphens, and are one global namespace (first come, first served): the site is served at ${w.sitesPattern}.`;
|
|
688
|
+
const VISIBILITY = `Sites are private by default (only ${w.privateAudience} can open them); pass \`public: true\` only if the user asks for a public site. Share links (fling_share) are public to anyone who has them.`;
|
|
689
|
+
const isPublic = z6.boolean().optional().describe(
|
|
690
|
+
`true \u2192 anyone can view; false \u2192 only ${w.privateAudience}. Omit to keep the current setting (new sites are private)`
|
|
691
|
+
);
|
|
692
|
+
return {
|
|
693
|
+
whoami: tool({
|
|
694
|
+
name: "fling_whoami",
|
|
695
|
+
title: "Who am I",
|
|
696
|
+
description: `Show the signed-in user that ${w.name} acts as.`,
|
|
697
|
+
inputSchema: {},
|
|
698
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
699
|
+
}),
|
|
700
|
+
list: tool({
|
|
701
|
+
name: "fling_list",
|
|
702
|
+
title: "List sites",
|
|
703
|
+
description: "List the user's Fling sites with URL, visibility and share link.",
|
|
704
|
+
inputSchema: {
|
|
705
|
+
archived: z6.boolean().optional().describe("List archived sites instead of live ones")
|
|
706
|
+
},
|
|
707
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
708
|
+
}),
|
|
709
|
+
info: tool({
|
|
710
|
+
name: "fling_info",
|
|
711
|
+
title: "Site details",
|
|
712
|
+
description: "Show one site: URL, visibility, share link, current version and version history (ids for fling_rollback).",
|
|
713
|
+
inputSchema: { name: siteName },
|
|
714
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
715
|
+
}),
|
|
716
|
+
files: tool({
|
|
717
|
+
name: "fling_files",
|
|
718
|
+
title: "List site files",
|
|
719
|
+
description: "List the files in a site's current version (or in `versionId`, from fling_info): path, size and content type, sorted by path. Use it to check what was actually published \u2014 e.g. when the user sees a 404, confirm the page exists at that path and that there is a root index.html (without one the site root 404s).",
|
|
720
|
+
inputSchema: {
|
|
721
|
+
name: siteName,
|
|
722
|
+
versionId: z6.string().min(1).max(100).optional().describe("Version id (from fling_info); omit for the version being served")
|
|
723
|
+
},
|
|
724
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
725
|
+
}),
|
|
726
|
+
checkName: tool({
|
|
727
|
+
name: "fling_check_name",
|
|
728
|
+
title: "Check a site name",
|
|
729
|
+
description: "Check whether a site name can be used before publishing: `available` (a new site), `yours` (publishing updates the user's existing site), `taken` (someone else's \u2014 free `suggestions` are returned) or `invalid`. " + NAME_RULES,
|
|
730
|
+
inputSchema: { name: z6.string().min(1).max(200).describe("Site name to check") },
|
|
731
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
732
|
+
}),
|
|
733
|
+
publish: tool({
|
|
734
|
+
name: "fling_publish",
|
|
735
|
+
title: "Publish a site",
|
|
736
|
+
description: `Publish a static site to Fling in one call and get back its URL. Pass every file of the site inline in \`files\` (whole site, up to ${MCP_PUBLISH_MAX_BYTES / 1024 / 1024} MB decoded in total; bigger sites: fling_upload_begin / fling_upload_file / fling_upload_finish). ${INLINE_ONLY} ${ENCODING} Publishing replaces the whole site with exactly these files (it is not a patch: include unchanged files too); publishing identical content again is a no-op. ${NAME_REQUIRED} ${NAME_RULES} ${VISIBILITY} ${LIST_HTML} ${NEVER_PUBLISHED} The result includes \`warnings\`. Share the returned URL with the user; fling_files shows exactly which files the site has.`,
|
|
737
|
+
inputSchema: {
|
|
738
|
+
name: siteName,
|
|
739
|
+
files: z6.array(
|
|
740
|
+
z6.object({
|
|
741
|
+
path: sitePath,
|
|
742
|
+
content: z6.string().describe("The file content, encoded as `encoding` says"),
|
|
743
|
+
encoding
|
|
744
|
+
})
|
|
745
|
+
).min(1).max(MAX_FILES).describe("Every file of the site"),
|
|
746
|
+
spa,
|
|
747
|
+
public: isPublic,
|
|
748
|
+
listHtml
|
|
749
|
+
},
|
|
750
|
+
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
751
|
+
}),
|
|
752
|
+
uploadBegin: tool({
|
|
753
|
+
name: "fling_upload_begin",
|
|
754
|
+
title: "Start a chunked upload",
|
|
755
|
+
description: `Start publishing a site too big for one fling_publish call (over ${MCP_PUBLISH_MAX_BYTES / 1024 / 1024} MB, or large binary files). Returns an \`uploadId\`; then send each file with fling_upload_file and finish with fling_upload_finish within ${MCP_UPLOAD_TTL_SECONDS / 60} minutes. ${INLINE_ONLY} ${NAME_REQUIRED} ${VISIBILITY}`,
|
|
756
|
+
inputSchema: { name: siteName, spa, public: isPublic },
|
|
757
|
+
annotations: { destructiveHint: false, openWorldHint: false }
|
|
758
|
+
}),
|
|
759
|
+
uploadFile: tool({
|
|
760
|
+
name: "fling_upload_file",
|
|
761
|
+
title: "Upload a file (chunked)",
|
|
762
|
+
description: `Add one file to a chunked upload started with fling_upload_begin. Sending the same path again replaces it. A file larger than ${MCP_UPLOAD_CHUNK_MAX_CHARS / 1024 / 1024} MB of content can span calls: send the first part normally, then the following parts in order with \`append: true\` (same path and encoding; send appends one at a time, not in parallel). ${ENCODING} ${NEVER_PUBLISHED}`,
|
|
763
|
+
inputSchema: {
|
|
764
|
+
uploadId: z6.string().min(1).max(100).describe("From fling_upload_begin"),
|
|
765
|
+
path: sitePath,
|
|
766
|
+
content: z6.string().max(MCP_UPLOAD_CHUNK_MAX_CHARS).describe("The file content (or the next part of it, with `append`)"),
|
|
767
|
+
encoding,
|
|
768
|
+
append: z6.boolean().optional().describe("Append this content to the file sent earlier with the same path")
|
|
769
|
+
},
|
|
770
|
+
annotations: { destructiveHint: false, openWorldHint: false }
|
|
771
|
+
}),
|
|
772
|
+
uploadFinish: tool({
|
|
773
|
+
name: "fling_upload_finish",
|
|
774
|
+
title: "Finish a chunked upload",
|
|
775
|
+
description: `Publish everything sent with fling_upload_file as the new version of the site and return its URL. The site becomes exactly the uploaded files. ${LIST_HTML} The upload stays open, so just call fling_upload_finish again with \`listHtml\`. The result includes \`warnings\`.`,
|
|
776
|
+
inputSchema: {
|
|
777
|
+
uploadId: z6.string().min(1).max(100).describe("From fling_upload_begin"),
|
|
778
|
+
listHtml
|
|
779
|
+
},
|
|
780
|
+
annotations: { destructiveHint: false, openWorldHint: true }
|
|
781
|
+
}),
|
|
782
|
+
share: tool({
|
|
783
|
+
name: "fling_share",
|
|
784
|
+
title: "Share link",
|
|
785
|
+
description: "Get or create a public share link for a site: anyone with the link can view it without signing in (use it to show a private site to someone who cannot sign in). `refresh` replaces the link (the old one stops working); `revoke` removes it.",
|
|
786
|
+
inputSchema: {
|
|
787
|
+
name: siteName,
|
|
788
|
+
refresh: z6.boolean().optional().describe("Rotate the link"),
|
|
789
|
+
revoke: z6.boolean().optional().describe("Remove the link")
|
|
790
|
+
},
|
|
791
|
+
annotations: { destructiveHint: false, openWorldHint: true }
|
|
792
|
+
}),
|
|
793
|
+
setVisibility: tool({
|
|
794
|
+
name: "fling_set_visibility",
|
|
795
|
+
title: "Set visibility",
|
|
796
|
+
description: `Make a site private (only ${w.privateAudience}) or public (anyone).`,
|
|
797
|
+
inputSchema: { name: siteName, visibility: z6.enum(["private", "public"]) },
|
|
798
|
+
annotations: { idempotentHint: true, openWorldHint: true }
|
|
799
|
+
}),
|
|
800
|
+
archive: tool({
|
|
801
|
+
name: "fling_archive",
|
|
802
|
+
title: "Archive or restore a site",
|
|
803
|
+
description: "Archive a site (it stops being served and is hidden from lists, but its name, files and versions are kept) or, with `unarchive`, bring it back. Prefer this over deleting.",
|
|
804
|
+
inputSchema: {
|
|
805
|
+
name: siteName,
|
|
806
|
+
unarchive: z6.boolean().optional().describe("Restore an archived site")
|
|
807
|
+
},
|
|
808
|
+
annotations: { idempotentHint: true, openWorldHint: false }
|
|
809
|
+
}),
|
|
810
|
+
delete: tool({
|
|
811
|
+
name: "fling_delete",
|
|
812
|
+
title: "Delete a site",
|
|
813
|
+
description: "Permanently delete a site and every version of it, freeing its name. Cannot be undone \u2014 confirm with the user first and pass `confirm: true`. Consider fling_archive instead.",
|
|
814
|
+
inputSchema: {
|
|
815
|
+
name: siteName,
|
|
816
|
+
confirm: z6.literal(true).describe("Must be true: the user has confirmed the deletion")
|
|
817
|
+
},
|
|
818
|
+
annotations: { destructiveHint: true, openWorldHint: false }
|
|
819
|
+
}),
|
|
820
|
+
commentsList: tool({
|
|
821
|
+
name: "fling_comments_list",
|
|
822
|
+
title: "List comments",
|
|
823
|
+
description: `List the comments people left on a site while reviewing it (${w.reviewPattern}), as threads: each has the page path it is about, the version the site was serving when it was written, its replies, and whether it is resolved. Anyone signed in to ${w.name} can comment on a site they can open. Resolved threads are left out unless \`includeResolved\`. Use it to act on feedback: fix the site, republish, then resolve the threads with fling_comment_resolve.`,
|
|
824
|
+
inputSchema: {
|
|
825
|
+
name: siteName,
|
|
826
|
+
path: commentPath.optional().describe('Only this page, e.g. "/" or "/docs/"'),
|
|
827
|
+
includeResolved: z6.boolean().optional().describe("Include resolved threads")
|
|
828
|
+
},
|
|
829
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
830
|
+
}),
|
|
831
|
+
commentAdd: tool({
|
|
832
|
+
name: "fling_comment_add",
|
|
833
|
+
title: "Add a comment",
|
|
834
|
+
description: `Comment on a page of a site (shown on its review page, ${w.reviewPattern}), or reply to a thread (\`replyTo\`: a thread id from fling_comments_list). Comments are signed with the user's name, so only write one the user asked for.`,
|
|
835
|
+
inputSchema: {
|
|
836
|
+
name: siteName,
|
|
837
|
+
body: z6.string().min(1).max(COMMENT_MAX_CHARS).describe("The comment (plain text)"),
|
|
838
|
+
path: commentPath.optional().describe('The page it is about (default "/"); ignored for a reply'),
|
|
839
|
+
replyTo: commentId.optional().describe("Reply to this thread instead of starting one")
|
|
840
|
+
},
|
|
841
|
+
annotations: { destructiveHint: false, openWorldHint: true }
|
|
842
|
+
}),
|
|
843
|
+
commentResolve: tool({
|
|
844
|
+
name: "fling_comment_resolve",
|
|
845
|
+
title: "Resolve a comment thread",
|
|
846
|
+
description: "Mark a comment thread resolved (e.g. after fixing what it asks for), or reopen it with `reopen: true`. Thread ids come from fling_comments_list.",
|
|
847
|
+
inputSchema: {
|
|
848
|
+
name: siteName,
|
|
849
|
+
id: commentId.describe("The thread id"),
|
|
850
|
+
reopen: z6.boolean().optional().describe("Reopen a resolved thread")
|
|
851
|
+
},
|
|
852
|
+
annotations: { idempotentHint: true, openWorldHint: false }
|
|
853
|
+
}),
|
|
854
|
+
commentDelete: tool({
|
|
855
|
+
name: "fling_comment_delete",
|
|
856
|
+
title: "Delete a comment",
|
|
857
|
+
description: "Delete a comment; deleting a thread deletes its replies. People can delete their own comments, and a site's owner anyone's. Cannot be undone: prefer fling_comment_resolve, and confirm with the user first.",
|
|
858
|
+
inputSchema: { name: siteName, id: commentId.describe("The comment id") },
|
|
859
|
+
annotations: { destructiveHint: true, openWorldHint: false }
|
|
860
|
+
}),
|
|
861
|
+
rollback: tool({
|
|
862
|
+
name: "fling_rollback",
|
|
863
|
+
title: "Roll back",
|
|
864
|
+
description: "Point a site at an earlier version (ids from fling_info). Instant; nothing is uploaded.",
|
|
865
|
+
inputSchema: {
|
|
866
|
+
name: siteName,
|
|
867
|
+
versionId: z6.string().min(1).max(100).describe("Version id to serve")
|
|
868
|
+
},
|
|
869
|
+
annotations: { idempotentHint: true, openWorldHint: false }
|
|
870
|
+
})
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
var mcpTools = mcpToolsFor(mcpWording());
|
|
874
|
+
var COMMENTS_NOTE = (w) => `People review sites at ${w.reviewPattern} and leave comments on its pages. Publishing returns this review link (\`reviewUrl\`): it is the one to share when the user wants feedback; reviewers sign in to use it. fling_comments_list shows the comments; after acting on feedback, resolve the threads with fling_comment_resolve. Only post (fling_comment_add) or delete (fling_comment_delete) comments when the user asks.`;
|
|
875
|
+
var shareForFeedback = (reviewPage2, instanceName) => `Share for feedback: ${reviewPage2} (reviewers sign in to ${instanceName} to see the site and comment).`;
|
|
633
876
|
var MCP_FILES_LISTED = 200;
|
|
634
877
|
function describeVersionFiles(name, files, opts) {
|
|
635
878
|
const kb = (n2) => n2 < 1024 ? `${n2} B` : n2 < 1024 * 1024 ? `${(n2 / 1024).toFixed(1)} KB` : `${(n2 / 1024 / 1024).toFixed(1)} MB`;
|
|
@@ -651,6 +894,24 @@ function describeVersionFiles(name, files, opts) {
|
|
|
651
894
|
if (n > MCP_FILES_LISTED) lines.push(`\u2026 and ${n - MCP_FILES_LISTED} more`);
|
|
652
895
|
return lines.join("\n");
|
|
653
896
|
}
|
|
897
|
+
function describeComments(list, opts = {}) {
|
|
898
|
+
const all = commentThreads(list.comments);
|
|
899
|
+
const threads = opts.includeResolved ? all : all.filter((t) => t.comment.resolvedAt === null);
|
|
900
|
+
const hidden = all.length - threads.length;
|
|
901
|
+
const name = list.site.name;
|
|
902
|
+
const version = (id) => id === null ? "" : id === list.site.currentVersionId ? ", current version" : `, version ${id}`;
|
|
903
|
+
const head = `${threads.length} ${opts.includeResolved ? "" : "open "}thread${threads.length === 1 ? "" : "s"} on ${name}` + (hidden ? ` (${hidden} resolved not shown)` : "") + (opts.reviewUrl ? `. Review page: ${opts.reviewUrl}` : "");
|
|
904
|
+
const blocks = threads.map(
|
|
905
|
+
({ comment: c, replies }) => [
|
|
906
|
+
`- [${c.id}] ${c.resolvedAt ? "RESOLVED " : ""}${c.path}${version(c.versionId)} \u2014 ${authorLabel(c.author)}, ${c.createdAt}:`,
|
|
907
|
+
` ${c.body.replace(/\n/g, "\n ")}`,
|
|
908
|
+
...replies.map(
|
|
909
|
+
(r) => ` - [${r.id}] ${authorLabel(r.author)}, ${r.createdAt}: ${r.body.replace(/\n/g, "\n ")}`
|
|
910
|
+
)
|
|
911
|
+
].join("\n")
|
|
912
|
+
);
|
|
913
|
+
return [head, ...blocks].join("\n");
|
|
914
|
+
}
|
|
654
915
|
|
|
655
916
|
// ../../packages/shared/src/ready.ts
|
|
656
917
|
var READY_TIMEOUT_MS = 15e3;
|
|
@@ -699,6 +960,95 @@ function readinessWaitDisabled(env) {
|
|
|
699
960
|
return env.FLING_NO_WAIT === "1" || env.VITEST === "true" || env.NODE_ENV === "test";
|
|
700
961
|
}
|
|
701
962
|
|
|
963
|
+
// ../../packages/shared/src/review-bridge.ts
|
|
964
|
+
import { z as z7 } from "zod";
|
|
965
|
+
var tag = { fling: z7.literal("review") };
|
|
966
|
+
var path = z7.string().max(COMMENT_PATH_MAX);
|
|
967
|
+
var hash = z7.string().max(COMMENT_PATH_MAX);
|
|
968
|
+
var BridgePinSchema = z7.object({
|
|
969
|
+
id: z7.string(),
|
|
970
|
+
n: z7.number().int().min(1),
|
|
971
|
+
anchor: CommentAnchorSchema,
|
|
972
|
+
selected: z7.boolean(),
|
|
973
|
+
resolved: z7.boolean()
|
|
974
|
+
});
|
|
975
|
+
var BridgeCursorSchema = z7.object({
|
|
976
|
+
id: z7.string(),
|
|
977
|
+
name: z7.string().max(100),
|
|
978
|
+
colour: z7.string().max(32),
|
|
979
|
+
anchor: CommentAnchorSchema
|
|
980
|
+
});
|
|
981
|
+
var ShellMessageSchema = z7.discriminatedUnion("type", [
|
|
982
|
+
z7.object({ ...tag, type: z7.literal("ready") }),
|
|
983
|
+
/** the frame is on `path` (normalised, see normalizeCommentPath) with fragment `hash` ('' or '#…') */
|
|
984
|
+
z7.object({ ...tag, type: z7.literal("location"), path, hash, title: z7.string().max(300) }),
|
|
985
|
+
z7.object({ ...tag, type: z7.literal("pinPlaced"), anchor: CommentAnchorSchema }),
|
|
986
|
+
z7.object({ ...tag, type: z7.literal("pinCancelled") }),
|
|
987
|
+
z7.object({ ...tag, type: z7.literal("pinClicked"), id: z7.string() }),
|
|
988
|
+
/** this reviewer's pointer on the page; null when it left the frame */
|
|
989
|
+
z7.object({ ...tag, type: z7.literal("cursor"), anchor: CommentAnchorSchema.nullable() }),
|
|
990
|
+
/** a link or form to another origin, stopped: the frame stays on the site */
|
|
991
|
+
z7.object({ ...tag, type: z7.literal("blocked"), href: z7.string().max(2048) }),
|
|
992
|
+
z7.object({ ...tag, type: z7.literal("escape") })
|
|
993
|
+
]);
|
|
994
|
+
var PageMessageSchema = z7.discriminatedUnion("type", [
|
|
995
|
+
/** load `path` (site-relative, may carry `?query` and `#hash`) in the frame */
|
|
996
|
+
z7.object({ ...tag, type: z7.literal("navigate"), path: z7.string().max(COMMENT_PATH_MAX * 2) }),
|
|
997
|
+
z7.object({ ...tag, type: z7.literal("reload") }),
|
|
998
|
+
z7.object({ ...tag, type: z7.literal("placing"), on: z7.boolean() }),
|
|
999
|
+
/** the pins on the frame's current page */
|
|
1000
|
+
z7.object({ ...tag, type: z7.literal("pins"), pins: z7.array(BridgePinSchema) }),
|
|
1001
|
+
/** scroll the pin into view and draw attention to it */
|
|
1002
|
+
z7.object({ ...tag, type: z7.literal("focusPin"), id: z7.string() }),
|
|
1003
|
+
z7.object({ ...tag, type: z7.literal("cursors"), cursors: z7.array(BridgeCursorSchema) })
|
|
1004
|
+
]);
|
|
1005
|
+
|
|
1006
|
+
// ../../packages/shared/src/review-live.ts
|
|
1007
|
+
import { z as z8 } from "zod";
|
|
1008
|
+
var LIVE_PICTURE_MAX = 1024;
|
|
1009
|
+
var LivePeerSchema = z8.object({
|
|
1010
|
+
id: z8.string(),
|
|
1011
|
+
name: z8.string().max(100),
|
|
1012
|
+
colour: z8.string().max(32),
|
|
1013
|
+
/** their profile picture (https), when sign-in gave one; else clients show initials */
|
|
1014
|
+
picture: z8.string().max(LIVE_PICTURE_MAX).optional()
|
|
1015
|
+
});
|
|
1016
|
+
var LiveTicketSchema = z8.object({
|
|
1017
|
+
url: z8.string(),
|
|
1018
|
+
ticket: z8.string(),
|
|
1019
|
+
/** seconds the ticket is good for: connect before then */
|
|
1020
|
+
expiresIn: z8.number().int()
|
|
1021
|
+
});
|
|
1022
|
+
var path2 = z8.string().max(COMMENT_PATH_MAX);
|
|
1023
|
+
var hash2 = z8.string().max(COMMENT_PATH_MAX);
|
|
1024
|
+
var LiveClientMessageSchema = z8.discriminatedUnion("type", [
|
|
1025
|
+
z8.object({ type: z8.literal("hello") }),
|
|
1026
|
+
/** where this peer's pointer is; `anchor` null when it left the page */
|
|
1027
|
+
z8.object({
|
|
1028
|
+
type: z8.literal("cursor"),
|
|
1029
|
+
path: path2,
|
|
1030
|
+
hash: hash2,
|
|
1031
|
+
anchor: CommentAnchorSchema.nullable()
|
|
1032
|
+
}),
|
|
1033
|
+
z8.object({ type: z8.literal("ping") })
|
|
1034
|
+
]);
|
|
1035
|
+
var LiveServerMessageSchema = z8.discriminatedUnion("type", [
|
|
1036
|
+
/** the answer to `hello`: who this connection is, and who else is in the room */
|
|
1037
|
+
z8.object({ type: z8.literal("welcome"), you: LivePeerSchema, peers: z8.array(LivePeerSchema) }),
|
|
1038
|
+
z8.object({ type: z8.literal("join"), peer: LivePeerSchema }),
|
|
1039
|
+
z8.object({ type: z8.literal("leave"), peerId: z8.string() }),
|
|
1040
|
+
z8.object({
|
|
1041
|
+
type: z8.literal("cursor"),
|
|
1042
|
+
peerId: z8.string(),
|
|
1043
|
+
path: path2,
|
|
1044
|
+
hash: hash2,
|
|
1045
|
+
anchor: CommentAnchorSchema.nullable()
|
|
1046
|
+
}),
|
|
1047
|
+
/** the site's comments changed: refetch them */
|
|
1048
|
+
z8.object({ type: z8.literal("comments") }),
|
|
1049
|
+
z8.object({ type: z8.literal("pong") })
|
|
1050
|
+
]);
|
|
1051
|
+
|
|
702
1052
|
// src/errors.ts
|
|
703
1053
|
var EXIT_ERROR = 1;
|
|
704
1054
|
var EXIT_NOT_LOGGED_IN = 2;
|
|
@@ -723,6 +1073,14 @@ var NotLoggedInError = class extends CliError {
|
|
|
723
1073
|
this.name = "NotLoggedInError";
|
|
724
1074
|
}
|
|
725
1075
|
};
|
|
1076
|
+
var NoServerError = class extends CliError {
|
|
1077
|
+
constructor(binName) {
|
|
1078
|
+
super(`No Fling server set \u2014 run \`${binName} login <url>\` with your Fling's address`, {
|
|
1079
|
+
exitCode: EXIT_NOT_LOGGED_IN
|
|
1080
|
+
});
|
|
1081
|
+
this.name = "NoServerError";
|
|
1082
|
+
}
|
|
1083
|
+
};
|
|
726
1084
|
function exitCodeFor(error) {
|
|
727
1085
|
return error instanceof CliError ? error.exitCode : EXIT_ERROR;
|
|
728
1086
|
}
|
|
@@ -730,9 +1088,9 @@ function exitCodeFor(error) {
|
|
|
730
1088
|
// package.json
|
|
731
1089
|
var package_default = {
|
|
732
1090
|
name: "@nearform/fling",
|
|
733
|
-
version: "0.
|
|
1091
|
+
version: "0.3.0",
|
|
734
1092
|
license: "MIT",
|
|
735
|
-
description: "Fling a folder of static files at
|
|
1093
|
+
description: "Fling a folder of static files at a Fling server and get a link back. CLI + MCP server.",
|
|
736
1094
|
type: "module",
|
|
737
1095
|
bin: {
|
|
738
1096
|
fling: "./dist/cli.js"
|
|
@@ -740,7 +1098,8 @@ var package_default = {
|
|
|
740
1098
|
files: [
|
|
741
1099
|
"dist/cli.js",
|
|
742
1100
|
"dist/cli.js.map",
|
|
743
|
-
"README.md"
|
|
1101
|
+
"README.md",
|
|
1102
|
+
"CHANGELOG.md"
|
|
744
1103
|
],
|
|
745
1104
|
engines: {
|
|
746
1105
|
node: ">=22"
|
|
@@ -814,8 +1173,8 @@ function defaultHint(status) {
|
|
|
814
1173
|
if (status === 403) return "You are not allowed to do that.";
|
|
815
1174
|
return void 0;
|
|
816
1175
|
}
|
|
817
|
-
function buildUrl(serverUrl,
|
|
818
|
-
const url = new URL(`${serverUrl.replace(/\/+$/, "")}${
|
|
1176
|
+
function buildUrl(serverUrl, path3, query) {
|
|
1177
|
+
const url = new URL(`${serverUrl.replace(/\/+$/, "")}${path3.startsWith("/") ? path3 : `/${path3}`}`);
|
|
819
1178
|
for (const [key, value] of Object.entries(query ?? {})) {
|
|
820
1179
|
if (value !== void 0 && value !== null) url.searchParams.set(key, String(value));
|
|
821
1180
|
}
|
|
@@ -825,8 +1184,8 @@ function createApiClient(options) {
|
|
|
825
1184
|
const serverUrl = options.serverUrl.replace(/\/+$/, "");
|
|
826
1185
|
const fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
|
|
827
1186
|
const timeoutMs = options.timeoutMs ?? 6e4;
|
|
828
|
-
async function request(method,
|
|
829
|
-
const url = buildUrl(serverUrl,
|
|
1187
|
+
async function request(method, path3, reqOptions = {}) {
|
|
1188
|
+
const url = buildUrl(serverUrl, path3, reqOptions.query);
|
|
830
1189
|
const headers = {
|
|
831
1190
|
Accept: "application/json",
|
|
832
1191
|
"User-Agent": `${BIN_NAME}-cli/${VERSION}`
|
|
@@ -851,7 +1210,7 @@ function createApiClient(options) {
|
|
|
851
1210
|
});
|
|
852
1211
|
}
|
|
853
1212
|
const raw = await readBody(response);
|
|
854
|
-
if (!response.ok) throw errorFromResponse(response.status, raw, method,
|
|
1213
|
+
if (!response.ok) throw errorFromResponse(response.status, raw, method, path3);
|
|
855
1214
|
if (!reqOptions.schema) return raw;
|
|
856
1215
|
const parsed = reqOptions.schema.safeParse(raw);
|
|
857
1216
|
if (!parsed.success) {
|
|
@@ -861,8 +1220,8 @@ function createApiClient(options) {
|
|
|
861
1220
|
status: response.status,
|
|
862
1221
|
code: "invalid_response",
|
|
863
1222
|
body: raw,
|
|
864
|
-
message: `Unexpected response from ${method} ${
|
|
865
|
-
hint: `Your ${BIN_NAME} CLI may be out of date \u2014 try \`npm i -g @
|
|
1223
|
+
message: `Unexpected response from ${method} ${path3}${where}: ${issue?.message ?? "invalid"}`,
|
|
1224
|
+
hint: `Your ${BIN_NAME} CLI may be out of date \u2014 try \`npm i -g ${PACKAGE_NAME}@latest\`.`
|
|
866
1225
|
});
|
|
867
1226
|
}
|
|
868
1227
|
return parsed.data;
|
|
@@ -903,7 +1262,7 @@ async function readBody(response) {
|
|
|
903
1262
|
return text2;
|
|
904
1263
|
}
|
|
905
1264
|
}
|
|
906
|
-
function errorFromResponse(status, body, method,
|
|
1265
|
+
function errorFromResponse(status, body, method, path3) {
|
|
907
1266
|
const envelope = ErrorEnvelopeSchema.safeParse(body);
|
|
908
1267
|
if (envelope.success) {
|
|
909
1268
|
const { code } = envelope.data.error;
|
|
@@ -919,12 +1278,12 @@ function errorFromResponse(status, body, method, path) {
|
|
|
919
1278
|
if (Array.isArray(extra?.paths)) Object.assign(error, { paths: extra.paths });
|
|
920
1279
|
return error;
|
|
921
1280
|
}
|
|
922
|
-
return new CliApiError({ status, body, message: `${method} ${
|
|
1281
|
+
return new CliApiError({ status, body, message: `${method} ${path3} failed with HTTP ${status}` });
|
|
923
1282
|
}
|
|
924
1283
|
var NEVER_PUBLISHED_HINTS = {
|
|
925
1284
|
secret_detected: "Remove the secret from the file (and rotate it if it was ever shared), or add the file to .flingignore if the site does not need it.",
|
|
926
1285
|
secret_file: "Move the secret files out of the folder, or add them to .flingignore if the site does not need them.",
|
|
927
|
-
dotfile: `Dotfiles are never flung. Update the CLI (\`npm i -g @
|
|
1286
|
+
dotfile: `Dotfiles are never flung. Update the CLI (\`npm i -g ${PACKAGE_NAME}@latest\`), which skips them.`
|
|
928
1287
|
};
|
|
929
1288
|
function describeCause(cause) {
|
|
930
1289
|
if (cause instanceof Error) {
|
|
@@ -940,10 +1299,9 @@ function describeCause(cause) {
|
|
|
940
1299
|
import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
941
1300
|
import { homedir } from "node:os";
|
|
942
1301
|
import { join } from "node:path";
|
|
943
|
-
import { z as
|
|
1302
|
+
import { z as z9 } from "zod";
|
|
944
1303
|
var CONFIG_DIR_NAME = ".fling";
|
|
945
1304
|
var CONFIG_FILE_NAME = "config.json";
|
|
946
|
-
var DEFAULT_SERVER_URL = "https://fling.labs.nearform.com";
|
|
947
1305
|
var ENV = {
|
|
948
1306
|
apiKey: "FLING_API_KEY",
|
|
949
1307
|
url: "FLING_URL",
|
|
@@ -952,13 +1310,15 @@ var ENV = {
|
|
|
952
1310
|
/** Where to look for files given by name (the .mcpb extension sets it from its settings). */
|
|
953
1311
|
downloadsDir: "FLING_DOWNLOADS_DIR"
|
|
954
1312
|
};
|
|
955
|
-
var cliConfigSchema =
|
|
956
|
-
serverUrl:
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1313
|
+
var cliConfigSchema = z9.object({
|
|
1314
|
+
serverUrl: z9.string().url().optional(),
|
|
1315
|
+
/** What `serverUrl` said about itself at login. A copy a newer CLI can't read is just refetched. */
|
|
1316
|
+
instance: InstanceMetadataSchema.optional().catch(void 0),
|
|
1317
|
+
apiKey: z9.string().min(1).optional(),
|
|
1318
|
+
user: z9.object({
|
|
1319
|
+
email: z9.string().optional(),
|
|
1320
|
+
name: z9.string().optional(),
|
|
1321
|
+
userSlug: z9.string().optional()
|
|
962
1322
|
}).optional()
|
|
963
1323
|
});
|
|
964
1324
|
function systemLocateOptions(env = process.env) {
|
|
@@ -1014,18 +1374,21 @@ function createConfigStore(options = {}) {
|
|
|
1014
1374
|
return next;
|
|
1015
1375
|
},
|
|
1016
1376
|
async clearCredentials() {
|
|
1017
|
-
const { serverUrl } = await load();
|
|
1018
|
-
await save(
|
|
1377
|
+
const { serverUrl, instance } = await load();
|
|
1378
|
+
await save({ serverUrl, instance });
|
|
1019
1379
|
},
|
|
1020
1380
|
async resolve(overrides = {}) {
|
|
1021
1381
|
const config = await load();
|
|
1022
1382
|
const envUrl = fromEnv(env[ENV.url]);
|
|
1023
1383
|
const envKey = fromEnv(env[ENV.apiKey]);
|
|
1024
|
-
const [
|
|
1384
|
+
const [rawUrl, serverUrlSource] = overrides.serverUrl ? [overrides.serverUrl, "flag"] : envUrl ? [envUrl, "env"] : config.serverUrl ? [config.serverUrl, "config"] : [void 0, "none"];
|
|
1385
|
+
const serverUrl = rawUrl === void 0 ? void 0 : normalizeServerUrl(rawUrl);
|
|
1025
1386
|
const [apiKey, apiKeySource] = envKey ? [envKey, "env"] : config.apiKey ? [config.apiKey, "config"] : [void 0, "none"];
|
|
1387
|
+
const stored = config.serverUrl && normalizeServerUrl(config.serverUrl);
|
|
1026
1388
|
return {
|
|
1027
|
-
serverUrl
|
|
1389
|
+
serverUrl,
|
|
1028
1390
|
serverUrlSource,
|
|
1391
|
+
instance: serverUrl && stored === serverUrl ? config.instance : void 0,
|
|
1029
1392
|
apiKey,
|
|
1030
1393
|
apiKeySource,
|
|
1031
1394
|
user: config.user,
|
|
@@ -1034,12 +1397,53 @@ function createConfigStore(options = {}) {
|
|
|
1034
1397
|
}
|
|
1035
1398
|
};
|
|
1036
1399
|
}
|
|
1400
|
+
function normalizeServerUrl(input) {
|
|
1401
|
+
const trimmed = input.trim();
|
|
1402
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
1403
|
+
const url = new URL(withScheme);
|
|
1404
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
1405
|
+
throw new Error(`Not an http(s) URL: ${input}`);
|
|
1406
|
+
}
|
|
1407
|
+
return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
|
|
1408
|
+
}
|
|
1037
1409
|
var REDACTED_KEY_CHARS = 10;
|
|
1038
1410
|
function redactKey(key) {
|
|
1039
1411
|
if (!key) return "-";
|
|
1040
1412
|
return key.length <= REDACTED_KEY_CHARS + 8 ? "****" : `${key.slice(0, REDACTED_KEY_CHARS)}\u2026`;
|
|
1041
1413
|
}
|
|
1042
1414
|
|
|
1415
|
+
// src/instance.ts
|
|
1416
|
+
async function fetchInstance(serverUrl, fetch2) {
|
|
1417
|
+
const url = instanceMetadataUrl(serverUrl);
|
|
1418
|
+
let response;
|
|
1419
|
+
try {
|
|
1420
|
+
response = await fetch2(url, { headers: { Accept: "application/json" } });
|
|
1421
|
+
} catch (cause) {
|
|
1422
|
+
throw new CliError(`Could not reach ${serverUrl} (${cause.message ?? cause})`, {
|
|
1423
|
+
hint: "Check the address and your connection.",
|
|
1424
|
+
cause
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
const notFling = () => new CliError(`${serverUrl} does not look like a Fling server (no valid ${url})`, {
|
|
1428
|
+
hint: "Use the address of your Fling, e.g. https://fling.example.com."
|
|
1429
|
+
});
|
|
1430
|
+
if (!response.ok) throw notFling();
|
|
1431
|
+
const parsed = InstanceMetadataSchema.safeParse(await response.json().catch(() => null));
|
|
1432
|
+
if (!parsed.success) throw notFling();
|
|
1433
|
+
return parsed.data;
|
|
1434
|
+
}
|
|
1435
|
+
async function loadInstance(store, config, fetch2) {
|
|
1436
|
+
if (config.instance) return config.instance;
|
|
1437
|
+
if (!config.serverUrl) return null;
|
|
1438
|
+
try {
|
|
1439
|
+
const instance = await fetchInstance(config.serverUrl, fetch2);
|
|
1440
|
+
if (config.serverUrlSource === "config") await store.update({ instance });
|
|
1441
|
+
return instance;
|
|
1442
|
+
} catch {
|
|
1443
|
+
return null;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1043
1447
|
// src/auth.ts
|
|
1044
1448
|
var CALLBACK_PORT_START = 8765;
|
|
1045
1449
|
var CALLBACK_PORT_END = 8770;
|
|
@@ -1048,12 +1452,13 @@ var CALLBACK_PATH = "/callback";
|
|
|
1048
1452
|
var AUTH_CLI_PATH = "/auth/cli";
|
|
1049
1453
|
async function loginFlow(options) {
|
|
1050
1454
|
const { serverUrl, store, log } = options;
|
|
1455
|
+
const instance = await fetchInstance(serverUrl, options.fetch ?? fetch);
|
|
1051
1456
|
const state = randomBytes(16).toString("hex");
|
|
1052
1457
|
const callback = await startCallbackServer({ state, timeoutMs: options.timeoutMs });
|
|
1053
1458
|
try {
|
|
1054
1459
|
const authUrl = buildAuthUrl(serverUrl, callback.url, options.hostname ?? osHostname(), state);
|
|
1055
1460
|
options.onAuthUrl?.(authUrl);
|
|
1056
|
-
log.info(`Opening your browser to sign in at ${serverUrl}`);
|
|
1461
|
+
log.info(`Opening your browser to sign in to ${instance.name} at ${serverUrl}`);
|
|
1057
1462
|
log.hint(`If it does not open, visit: ${authUrl}`);
|
|
1058
1463
|
try {
|
|
1059
1464
|
await options.open(authUrl);
|
|
@@ -1066,6 +1471,7 @@ async function loginFlow(options) {
|
|
|
1066
1471
|
const user = await client.request("GET", "/api/me", { schema: MeSchema });
|
|
1067
1472
|
await store.update({
|
|
1068
1473
|
serverUrl,
|
|
1474
|
+
instance,
|
|
1069
1475
|
apiKey: key,
|
|
1070
1476
|
user: { email: user.email, name: user.name, userSlug: user.userSlug }
|
|
1071
1477
|
});
|
|
@@ -1073,7 +1479,7 @@ async function loginFlow(options) {
|
|
|
1073
1479
|
log.success(`Signed in as ${user.email}`);
|
|
1074
1480
|
log.hint(`Server: ${serverUrl}`);
|
|
1075
1481
|
log.hint(`API key: ${keyPrefix} (stored in ${store.file})`);
|
|
1076
|
-
return { serverUrl, keyPrefix, user };
|
|
1482
|
+
return { serverUrl, keyPrefix, user, instance };
|
|
1077
1483
|
} finally {
|
|
1078
1484
|
callback.close();
|
|
1079
1485
|
}
|
|
@@ -1419,18 +1825,23 @@ async function ttyPrompt(question) {
|
|
|
1419
1825
|
function ttyProgress(line) {
|
|
1420
1826
|
process.stderr.write(`\r\x1B[2K${line ?? ""}`);
|
|
1421
1827
|
}
|
|
1828
|
+
function requireServer(ctx) {
|
|
1829
|
+
if (!ctx.config.serverUrl) throw new NoServerError(ctx.binName);
|
|
1830
|
+
return ctx.config.serverUrl;
|
|
1831
|
+
}
|
|
1422
1832
|
function requireClient(ctx) {
|
|
1833
|
+
const serverUrl = requireServer(ctx);
|
|
1423
1834
|
if (!ctx.config.apiKey) throw new NotLoggedInError(ctx.binName);
|
|
1424
1835
|
return createApiClient({
|
|
1425
|
-
serverUrl
|
|
1836
|
+
serverUrl,
|
|
1426
1837
|
apiKey: ctx.config.apiKey,
|
|
1427
1838
|
fetch: ctx.fetch
|
|
1428
1839
|
});
|
|
1429
1840
|
}
|
|
1430
1841
|
|
|
1431
1842
|
// src/sites.ts
|
|
1432
|
-
import { z as
|
|
1433
|
-
var ShareResponseSchema2 =
|
|
1843
|
+
import { z as z10 } from "zod";
|
|
1844
|
+
var ShareResponseSchema2 = z10.looseObject({ shareUrl: z10.string() });
|
|
1434
1845
|
var sitePath2 = (name) => `/api/sites/${encodeURIComponent(name)}`;
|
|
1435
1846
|
function whoami(client) {
|
|
1436
1847
|
return client.request("GET", "/api/me", { schema: MeSchema });
|
|
@@ -1440,32 +1851,8 @@ function checkName(client, name) {
|
|
|
1440
1851
|
schema: NameCheckSchema
|
|
1441
1852
|
});
|
|
1442
1853
|
}
|
|
1443
|
-
function
|
|
1444
|
-
|
|
1445
|
-
const url = new URL(serverUrl);
|
|
1446
|
-
const { hostname } = url;
|
|
1447
|
-
if (url.port || !hostname.includes(".") || /^[\d.]+$/.test(hostname) || hostname.startsWith("["))
|
|
1448
|
-
return null;
|
|
1449
|
-
return { protocol: url.protocol, domain: hostname };
|
|
1450
|
-
} catch {
|
|
1451
|
-
return null;
|
|
1452
|
-
}
|
|
1453
|
-
}
|
|
1454
|
-
function sitesBaseFromSiteUrl(siteUrl2) {
|
|
1455
|
-
try {
|
|
1456
|
-
const url = new URL(siteUrl2);
|
|
1457
|
-
const dot = url.host.indexOf(".");
|
|
1458
|
-
return dot > 0 ? { protocol: url.protocol, domain: url.host.slice(dot + 1) } : null;
|
|
1459
|
-
} catch {
|
|
1460
|
-
return null;
|
|
1461
|
-
}
|
|
1462
|
-
}
|
|
1463
|
-
async function discoverSitesBase(client) {
|
|
1464
|
-
const inferred = sitesBaseFor(client.serverUrl);
|
|
1465
|
-
if (inferred) return inferred;
|
|
1466
|
-
const sites = await listSites(client).catch(() => []);
|
|
1467
|
-
const first = sites[0];
|
|
1468
|
-
return first ? sitesBaseFromSiteUrl(first.url) : null;
|
|
1854
|
+
function sitesBaseOf(instance) {
|
|
1855
|
+
return instance ? { protocol: `${instance.siteScheme}:`, domain: instance.sitesDomain } : null;
|
|
1469
1856
|
}
|
|
1470
1857
|
function siteUrlFor(name, base) {
|
|
1471
1858
|
return siteUrl(name, base.domain, base.protocol);
|
|
@@ -1535,11 +1922,47 @@ async function share(client, name, opts = {}) {
|
|
|
1535
1922
|
});
|
|
1536
1923
|
return { name, action: hadLink ? "refreshed" : "created", shareUrl: res.shareUrl };
|
|
1537
1924
|
}
|
|
1925
|
+
function listComments(client, name, opts = {}) {
|
|
1926
|
+
return client.request("GET", `${sitePath2(name)}/comments`, {
|
|
1927
|
+
schema: CommentListSchema,
|
|
1928
|
+
query: { path: opts.path }
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
function addComment(client, name, input) {
|
|
1932
|
+
return client.request("POST", `${sitePath2(name)}/comments`, {
|
|
1933
|
+
schema: CommentSchema,
|
|
1934
|
+
body: input
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
function resolveComment(client, name, id, resolved) {
|
|
1938
|
+
return client.request("PATCH", `${sitePath2(name)}/comments/${encodeURIComponent(id)}`, {
|
|
1939
|
+
schema: CommentSchema,
|
|
1940
|
+
body: { resolved }
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
async function deleteComment(client, name, id) {
|
|
1944
|
+
await client.request("DELETE", `${sitePath2(name)}/comments/${encodeURIComponent(id)}`);
|
|
1945
|
+
return { name, id, deleted: true };
|
|
1946
|
+
}
|
|
1947
|
+
function commentsEnabled(instance) {
|
|
1948
|
+
if (!instance) return false;
|
|
1949
|
+
return instance.capabilities.comments ?? true;
|
|
1950
|
+
}
|
|
1538
1951
|
|
|
1539
1952
|
// src/commands/auth.ts
|
|
1540
|
-
|
|
1953
|
+
function loginServer(ctx, url) {
|
|
1954
|
+
if (url === void 0) return requireServer(ctx);
|
|
1955
|
+
try {
|
|
1956
|
+
return normalizeServerUrl(url);
|
|
1957
|
+
} catch {
|
|
1958
|
+
throw new CliError(`"${url}" is not a server address`, {
|
|
1959
|
+
hint: `Run \`${ctx.binName} login <url>\` with your Fling's address, e.g. https://fling.example.com.`
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
async function runLogin(ctx, url) {
|
|
1541
1964
|
const result = await loginFlow({
|
|
1542
|
-
serverUrl: ctx
|
|
1965
|
+
serverUrl: loginServer(ctx, url),
|
|
1543
1966
|
store: ctx.store,
|
|
1544
1967
|
log: ctx.log,
|
|
1545
1968
|
open: ctx.open,
|
|
@@ -1555,32 +1978,109 @@ async function runLogout(ctx) {
|
|
|
1555
1978
|
}
|
|
1556
1979
|
await ctx.store.clearCredentials();
|
|
1557
1980
|
ctx.log.success("Logged out; API key removed from this machine");
|
|
1558
|
-
|
|
1981
|
+
if (ctx.config.serverUrl)
|
|
1982
|
+
ctx.log.hint(`Revoke it on the server under Connections at ${ctx.config.serverUrl}`);
|
|
1559
1983
|
}
|
|
1560
1984
|
async function runWhoami(ctx) {
|
|
1561
1985
|
const client = requireClient(ctx);
|
|
1562
1986
|
const me = await whoami(client);
|
|
1563
|
-
const
|
|
1987
|
+
const instance = await loadInstance(ctx.store, ctx.config, ctx.fetch);
|
|
1564
1988
|
ctx.out.data(
|
|
1565
1989
|
{
|
|
1566
1990
|
...me,
|
|
1567
1991
|
serverUrl: ctx.config.serverUrl,
|
|
1992
|
+
instance: instance?.name ?? null,
|
|
1568
1993
|
apiKey: redactKey(ctx.config.apiKey),
|
|
1569
1994
|
apiKeySource: ctx.config.apiKeySource
|
|
1570
1995
|
},
|
|
1571
1996
|
() => [
|
|
1572
1997
|
`${chalk5.bold("User:")} ${me.name} <${me.email}>`,
|
|
1573
|
-
`${chalk5.bold("Sites:")} ${sitesPattern(
|
|
1574
|
-
`${chalk5.bold("Server:")} ${ctx.config.serverUrl}`,
|
|
1998
|
+
`${chalk5.bold("Sites:")} ${sitesPattern(sitesBaseOf(instance))} ${chalk5.dim("(names are first come, first served)")}`,
|
|
1999
|
+
`${chalk5.bold("Server:")} ${ctx.config.serverUrl}${instance ? chalk5.dim(` (${instance.name})`) : ""}`,
|
|
1575
2000
|
`${chalk5.bold("API key:")} ${redactKey(ctx.config.apiKey)} ${chalk5.dim(`(from ${ctx.config.apiKeySource})`)}`
|
|
1576
2001
|
].join("\n")
|
|
1577
2002
|
);
|
|
1578
2003
|
}
|
|
1579
2004
|
|
|
2005
|
+
// src/commands/comments.ts
|
|
2006
|
+
import chalk6 from "chalk";
|
|
2007
|
+
function commentsClient(ctx) {
|
|
2008
|
+
const instance = ctx.config.instance;
|
|
2009
|
+
if (instance && !commentsEnabled(instance)) {
|
|
2010
|
+
throw new CliError(`${instance.name} has comments turned off`);
|
|
2011
|
+
}
|
|
2012
|
+
return requireClient(ctx);
|
|
2013
|
+
}
|
|
2014
|
+
var reviewPage = (ctx, name, path3) => reviewUrl(ctx.config.instance?.appOrigin ?? ctx.config.serverUrl ?? "", name, path3);
|
|
2015
|
+
function describe(c, currentVersionId) {
|
|
2016
|
+
const version = c.versionId === null ? "" : c.versionId === currentVersionId ? "" : chalk6.dim(` (on version ${c.versionId})`);
|
|
2017
|
+
return `${chalk6.bold(authorLabel(c.author))} ${chalk6.dim(formatDate(c.createdAt))}${version}`;
|
|
2018
|
+
}
|
|
2019
|
+
async function runComments(ctx, name, opts) {
|
|
2020
|
+
const list = await listComments(commentsClient(ctx), name, { path: opts.path });
|
|
2021
|
+
const all = commentThreads(list.comments);
|
|
2022
|
+
const threads = opts.all ? all : all.filter((t) => t.comment.resolvedAt === null);
|
|
2023
|
+
const hidden = all.length - threads.length;
|
|
2024
|
+
ctx.out.data({ ...list, reviewUrl: reviewPage(ctx, name) }, () => {
|
|
2025
|
+
const lines = [];
|
|
2026
|
+
let lastPath = null;
|
|
2027
|
+
for (const { comment: c, replies } of threads.sort(
|
|
2028
|
+
(a, b) => a.comment.path.localeCompare(b.comment.path)
|
|
2029
|
+
)) {
|
|
2030
|
+
if (c.path !== lastPath) {
|
|
2031
|
+
lines.push("", chalk6.bold.underline(c.path));
|
|
2032
|
+
lastPath = c.path;
|
|
2033
|
+
}
|
|
2034
|
+
const state = c.resolvedAt ? chalk6.green(" \u2713 resolved") : "";
|
|
2035
|
+
lines.push(`${chalk6.dim(c.id)}${state} ${describe(c, list.site.currentVersionId)}`);
|
|
2036
|
+
lines.push(...c.body.split("\n").map((l) => ` ${l}`));
|
|
2037
|
+
for (const r of replies) {
|
|
2038
|
+
lines.push(` ${chalk6.dim("\u21B3")} ${describe(r, list.site.currentVersionId)}`);
|
|
2039
|
+
lines.push(...r.body.split("\n").map((l) => ` ${l}`));
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
const summary = chalk6.dim(
|
|
2043
|
+
`${threads.length} ${opts.all ? "" : "open "}thread${threads.length === 1 ? "" : "s"}` + (hidden ? ` (${hidden} resolved \u2014 \`--all\` shows them)` : "") + ` \xB7 review: ${reviewPage(ctx, name)}`
|
|
2044
|
+
);
|
|
2045
|
+
return [...lines, "", summary].join("\n").replace(/^\n/, "");
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
async function runComment(ctx, name, text2, opts) {
|
|
2049
|
+
if (!text2.trim()) throw new CliError("The comment is empty");
|
|
2050
|
+
const comment = await addComment(commentsClient(ctx), name, {
|
|
2051
|
+
body: text2,
|
|
2052
|
+
path: opts.path,
|
|
2053
|
+
parentId: opts.reply
|
|
2054
|
+
});
|
|
2055
|
+
ctx.log.success(
|
|
2056
|
+
comment.parentId ? `Replied to ${comment.parentId} on ${name}` : `Comment added on ${name} ${comment.path}`
|
|
2057
|
+
);
|
|
2058
|
+
ctx.out.data(comment, () => reviewPage(ctx, name, comment.path));
|
|
2059
|
+
}
|
|
2060
|
+
async function runResolve(ctx, name, id, opts) {
|
|
2061
|
+
const comment = await resolveComment(commentsClient(ctx), name, id, !opts.reopen);
|
|
2062
|
+
ctx.log.success(opts.reopen ? `Thread ${id} reopened` : `Thread ${id} resolved`);
|
|
2063
|
+
if (ctx.json) ctx.out.data(comment, () => "");
|
|
2064
|
+
}
|
|
2065
|
+
async function runUncomment(ctx, name, id, opts) {
|
|
2066
|
+
if (!opts.yes) {
|
|
2067
|
+
if (!ctx.interactive) {
|
|
2068
|
+
throw new CliError(`Refusing to delete comment ${id} without confirmation`, {
|
|
2069
|
+
hint: "Pass --yes to delete."
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
const ok = await ctx.confirm(`Delete comment ${id} (and its replies)? This cannot be undone.`);
|
|
2073
|
+
if (!ok) throw new CliError("Cancelled");
|
|
2074
|
+
}
|
|
2075
|
+
const result = await deleteComment(commentsClient(ctx), name, id);
|
|
2076
|
+
ctx.log.success(`Comment ${id} deleted`);
|
|
2077
|
+
if (ctx.json) ctx.out.data(result, () => "");
|
|
2078
|
+
}
|
|
2079
|
+
|
|
1580
2080
|
// src/commands/deploy.ts
|
|
1581
2081
|
import { stat as stat4 } from "node:fs/promises";
|
|
1582
2082
|
import { resolve as resolve3 } from "node:path";
|
|
1583
|
-
import
|
|
2083
|
+
import chalk7 from "chalk";
|
|
1584
2084
|
|
|
1585
2085
|
// src/deploy.ts
|
|
1586
2086
|
import { readFile as readFile4 } from "node:fs/promises";
|
|
@@ -1744,19 +2244,19 @@ function describeSkipped(skipped) {
|
|
|
1744
2244
|
lines.push(line(skipped.gitignored.length, "gitignored file", skipped.gitignored));
|
|
1745
2245
|
return lines;
|
|
1746
2246
|
}
|
|
1747
|
-
function contentTypeFor(
|
|
1748
|
-
const type = mime.getType(
|
|
2247
|
+
function contentTypeFor(path3) {
|
|
2248
|
+
const type = mime.getType(path3) ?? "application/octet-stream";
|
|
1749
2249
|
return type.startsWith("text/") || CHARSET_TYPES.has(type) ? `${type}; charset=utf-8` : type;
|
|
1750
2250
|
}
|
|
1751
|
-
async function sha256File(
|
|
1752
|
-
const
|
|
1753
|
-
for await (const chunk of createReadStream(
|
|
1754
|
-
return
|
|
2251
|
+
async function sha256File(path3) {
|
|
2252
|
+
const hash3 = createHash("sha256");
|
|
2253
|
+
for await (const chunk of createReadStream(path3)) hash3.update(chunk);
|
|
2254
|
+
return hash3.digest("hex");
|
|
1755
2255
|
}
|
|
1756
2256
|
function toManifest(local) {
|
|
1757
2257
|
return {
|
|
1758
|
-
files: local.files.map(({ path, sha256, size, contentType }) => ({
|
|
1759
|
-
path,
|
|
2258
|
+
files: local.files.map(({ path: path3, sha256, size, contentType }) => ({
|
|
2259
|
+
path: path3,
|
|
1760
2260
|
sha256,
|
|
1761
2261
|
size,
|
|
1762
2262
|
contentType
|
|
@@ -2089,8 +2589,8 @@ async function deploy(deps, opts) {
|
|
|
2089
2589
|
totalBytes: local.totalBytes,
|
|
2090
2590
|
warnings: local.warnings
|
|
2091
2591
|
};
|
|
2092
|
-
const
|
|
2093
|
-
const res = await deps.client.request("POST",
|
|
2592
|
+
const path3 = `/api/sites/${encodeURIComponent(name)}/deploys`;
|
|
2593
|
+
const res = await deps.client.request("POST", path3, {
|
|
2094
2594
|
schema: DeployResponseSchema,
|
|
2095
2595
|
body: {
|
|
2096
2596
|
manifest: toManifest(local),
|
|
@@ -2130,7 +2630,7 @@ async function deploy(deps, opts) {
|
|
|
2130
2630
|
});
|
|
2131
2631
|
const fin = await deps.client.request(
|
|
2132
2632
|
"POST",
|
|
2133
|
-
`${
|
|
2633
|
+
`${path3}/${encodeURIComponent(res.versionId)}/finalize`,
|
|
2134
2634
|
{ schema: FinalizeResponseSchema }
|
|
2135
2635
|
);
|
|
2136
2636
|
return {
|
|
@@ -2166,51 +2666,51 @@ import { copyFile, mkdir as mkdir2, mkdtemp, readFile as readFile5, rm, stat as
|
|
|
2166
2666
|
import { tmpdir } from "node:os";
|
|
2167
2667
|
import { basename as basename2, extname, join as join4, resolve as resolve2, sep as sep4 } from "node:path";
|
|
2168
2668
|
import { unzipSync } from "fflate";
|
|
2169
|
-
function inputKind(
|
|
2669
|
+
function inputKind(path3, isDirectory) {
|
|
2170
2670
|
if (isDirectory) return "folder";
|
|
2171
|
-
const ext = extname(
|
|
2671
|
+
const ext = extname(path3).toLowerCase();
|
|
2172
2672
|
if (ext === ".html" || ext === ".htm") return "html";
|
|
2173
2673
|
if (ext === ".zip") return "zip";
|
|
2174
2674
|
return "file";
|
|
2175
2675
|
}
|
|
2176
|
-
function fileSiteName(
|
|
2177
|
-
const file = basename2(
|
|
2676
|
+
function fileSiteName(path3) {
|
|
2677
|
+
const file = basename2(path3.replace(/\\/g, "/"));
|
|
2178
2678
|
const stem = file.slice(0, file.length - extname(file).length) || file;
|
|
2179
2679
|
return fitName([stem.replace(/\s*\(\d+\)$/, "")]);
|
|
2180
2680
|
}
|
|
2181
|
-
function defaultNameFor(
|
|
2182
|
-
return isDirectory ? defaultSiteName(
|
|
2681
|
+
function defaultNameFor(path3, isDirectory) {
|
|
2682
|
+
return isDirectory ? defaultSiteName(path3) : fileSiteName(path3);
|
|
2183
2683
|
}
|
|
2184
2684
|
async function tempFolder() {
|
|
2185
2685
|
const dir = await mkdtemp(join4(tmpdir(), "fling-"));
|
|
2186
2686
|
return { dir, cleanup: () => rm(dir, { recursive: true, force: true }) };
|
|
2187
2687
|
}
|
|
2188
|
-
async function prepareInput(
|
|
2688
|
+
async function prepareInput(path3) {
|
|
2189
2689
|
let info;
|
|
2190
2690
|
try {
|
|
2191
|
-
info = await stat3(
|
|
2691
|
+
info = await stat3(path3);
|
|
2192
2692
|
} catch {
|
|
2193
|
-
throw new CliError(`Not found: ${
|
|
2693
|
+
throw new CliError(`Not found: ${path3}`);
|
|
2194
2694
|
}
|
|
2195
|
-
const kind = inputKind(
|
|
2196
|
-
const defaultName = defaultNameFor(
|
|
2695
|
+
const kind = inputKind(path3, info.isDirectory());
|
|
2696
|
+
const defaultName = defaultNameFor(path3, info.isDirectory());
|
|
2197
2697
|
if (kind === "folder")
|
|
2198
2698
|
return {
|
|
2199
2699
|
kind,
|
|
2200
|
-
source:
|
|
2201
|
-
dir:
|
|
2700
|
+
source: path3,
|
|
2701
|
+
dir: path3,
|
|
2202
2702
|
defaultName,
|
|
2203
2703
|
skippedDotfiles: [],
|
|
2204
2704
|
cleanup: async () => {
|
|
2205
2705
|
}
|
|
2206
2706
|
};
|
|
2207
|
-
const file = basename2(
|
|
2707
|
+
const file = basename2(path3);
|
|
2208
2708
|
if (file.startsWith("."))
|
|
2209
2709
|
throw new CliError(`Not flung: ${file} is a dotfile. ${NEVER_PUBLISHED_NOTE}`);
|
|
2210
2710
|
if (kind === "file" && isSecretPath(file)) throw new SecretFilesError([file]);
|
|
2211
2711
|
if (info.size > MAX_DEPLOY_BYTES)
|
|
2212
2712
|
throw new CliError(
|
|
2213
|
-
`${basename2(
|
|
2713
|
+
`${basename2(path3)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_DEPLOY_BYTES)}`
|
|
2214
2714
|
);
|
|
2215
2715
|
const tmp = await tempFolder();
|
|
2216
2716
|
const skippedDotfiles = [];
|
|
@@ -2218,21 +2718,21 @@ async function prepareInput(path) {
|
|
|
2218
2718
|
if (kind === "html") {
|
|
2219
2719
|
if (info.size > MAX_FILE_BYTES)
|
|
2220
2720
|
throw new CliError(
|
|
2221
|
-
`${basename2(
|
|
2721
|
+
`${basename2(path3)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`
|
|
2222
2722
|
);
|
|
2223
|
-
await copyFile(
|
|
2723
|
+
await copyFile(path3, join4(tmp.dir, "index.html"));
|
|
2224
2724
|
} else if (kind === "zip") {
|
|
2225
|
-
await extractZip(await readFile5(
|
|
2725
|
+
await extractZip(await readFile5(path3), tmp.dir, skippedDotfiles);
|
|
2226
2726
|
} else {
|
|
2227
|
-
const name = basename2(
|
|
2228
|
-
await copyFile(
|
|
2727
|
+
const name = basename2(path3);
|
|
2728
|
+
await copyFile(path3, join4(tmp.dir, name));
|
|
2229
2729
|
await writeFile2(join4(tmp.dir, "index.html"), wrapperPage(name));
|
|
2230
2730
|
}
|
|
2231
2731
|
} catch (error) {
|
|
2232
2732
|
await tmp.cleanup();
|
|
2233
2733
|
throw error;
|
|
2234
2734
|
}
|
|
2235
|
-
return { kind, source:
|
|
2735
|
+
return { kind, source: path3, dir: tmp.dir, defaultName, skippedDotfiles, cleanup: tmp.cleanup };
|
|
2236
2736
|
}
|
|
2237
2737
|
var JUNK_FILES = /* @__PURE__ */ new Set(["thumbs.db", "desktop.ini"]);
|
|
2238
2738
|
function zipEntryPath(name) {
|
|
@@ -2272,12 +2772,12 @@ async function extractZip(data, dest, skippedDotfiles = []) {
|
|
|
2272
2772
|
try {
|
|
2273
2773
|
entries = unzipSync(data, {
|
|
2274
2774
|
filter: (file) => {
|
|
2275
|
-
const
|
|
2276
|
-
if (
|
|
2775
|
+
const path3 = zipEntryPath(file.name);
|
|
2776
|
+
if (path3 === "unsafe") {
|
|
2277
2777
|
unsafe.push(file.name);
|
|
2278
2778
|
return false;
|
|
2279
2779
|
}
|
|
2280
|
-
if (
|
|
2780
|
+
if (path3 === null) {
|
|
2281
2781
|
const dot = zipDotEntry(file.name);
|
|
2282
2782
|
if (dot && !skippedDotfiles.includes(dot)) skippedDotfiles.push(dot);
|
|
2283
2783
|
return false;
|
|
@@ -2286,7 +2786,7 @@ async function extractZip(data, dest, skippedDotfiles = []) {
|
|
|
2286
2786
|
declared += file.originalSize;
|
|
2287
2787
|
if (file.originalSize > MAX_FILE_BYTES)
|
|
2288
2788
|
throw new CliError(
|
|
2289
|
-
`${
|
|
2789
|
+
`${path3} in the zip is ${formatBytes(file.originalSize)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`
|
|
2290
2790
|
);
|
|
2291
2791
|
if (declared > MAX_DEPLOY_BYTES)
|
|
2292
2792
|
throw new CliError(
|
|
@@ -2367,20 +2867,21 @@ function wrapperPage(file) {
|
|
|
2367
2867
|
async function runDeploy(ctx, input, opts) {
|
|
2368
2868
|
const client = requireClient(ctx);
|
|
2369
2869
|
const ask = ctx.interactive && !opts.yes;
|
|
2370
|
-
const
|
|
2371
|
-
const
|
|
2372
|
-
|
|
2870
|
+
const instance = await loadInstance(ctx.store, ctx.config, ctx.fetch);
|
|
2871
|
+
const base = sitesBaseOf(instance);
|
|
2872
|
+
const path3 = resolve3(input ?? ".");
|
|
2873
|
+
if (!await stat4(path3).catch(() => null))
|
|
2373
2874
|
throw new CliError(`No such file or folder: ${input ?? "."}`, {
|
|
2374
2875
|
hint: `Give the path to a folder, .html, .zip or other file, e.g. \`${ctx.binName} deploy ./dist\`.`
|
|
2375
2876
|
});
|
|
2376
|
-
const prepared = await prepareInput(
|
|
2877
|
+
const prepared = await prepareInput(path3);
|
|
2377
2878
|
try {
|
|
2378
|
-
await deployPrepared(ctx, client, base, prepared, opts, ask);
|
|
2879
|
+
await deployPrepared(ctx, client, instance, base, prepared, opts, ask);
|
|
2379
2880
|
} finally {
|
|
2380
2881
|
await prepared.cleanup();
|
|
2381
2882
|
}
|
|
2382
2883
|
}
|
|
2383
|
-
async function deployPrepared(ctx, client, base, prepared, opts, ask) {
|
|
2884
|
+
async function deployPrepared(ctx, client, instance, base, prepared, opts, ask) {
|
|
2384
2885
|
let local = await buildManifest(prepared.dir);
|
|
2385
2886
|
local.skipped.dotfiles.unshift(...prepared.skippedDotfiles);
|
|
2386
2887
|
for (const line of describeSkipped(local.skipped)) ctx.log.info(line);
|
|
@@ -2418,12 +2919,12 @@ async function deployPrepared(ctx, client, base, prepared, opts, ask) {
|
|
|
2418
2919
|
});
|
|
2419
2920
|
for (const warning of result.warnings) if (warning !== NO_INDEX_WARNING) ctx.log.warn(warning);
|
|
2420
2921
|
const summary = `${result.fileCount} files, ${formatBytes(result.totalBytes)}`;
|
|
2421
|
-
if (result.status === "unchanged") ctx.log.info(`Unchanged ${
|
|
2922
|
+
if (result.status === "unchanged") ctx.log.info(`Unchanged ${chalk7.dim(`(${summary})`)}`);
|
|
2422
2923
|
else if (result.status === "repointed")
|
|
2423
|
-
ctx.log.success(`Restored version ${result.versionId} ${
|
|
2924
|
+
ctx.log.success(`Restored version ${result.versionId} ${chalk7.dim(`(${summary})`)}`);
|
|
2424
2925
|
else
|
|
2425
2926
|
ctx.log.success(
|
|
2426
|
-
`Deployed ${result.name} ${
|
|
2927
|
+
`Deployed ${result.name} ${chalk7.dim(`(${summary}; uploaded ${result.uploaded}, version ${result.versionId})`)}`
|
|
2427
2928
|
);
|
|
2428
2929
|
const ready = result.status === "unchanged" ? true : (await waitWithSpinner(
|
|
2429
2930
|
ctx.waitReady,
|
|
@@ -2431,23 +2932,38 @@ async function deployPrepared(ctx, client, base, prepared, opts, ask) {
|
|
|
2431
2932
|
local.files.map((f) => f.path),
|
|
2432
2933
|
{ progress: ctx.progress, warn: (m) => ctx.log.warn(m) }
|
|
2433
2934
|
)).ready;
|
|
2434
|
-
const
|
|
2435
|
-
|
|
2935
|
+
const who = instance ? `only ${privateAudience(instance)} can open it` : "sign-in required";
|
|
2936
|
+
const privateNote = `Private: ${who}. Share publicly with \`${ctx.binName} share ${result.name}\`.`;
|
|
2937
|
+
const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
|
|
2938
|
+
const reviewNote = instance ? `Share for feedback (reviewers sign in to ${instance.name}):` : "Share for feedback:";
|
|
2939
|
+
const data = {
|
|
2940
|
+
status: result.status,
|
|
2941
|
+
site: result.site,
|
|
2942
|
+
...review ? { reviewUrl: review } : {},
|
|
2943
|
+
ready,
|
|
2944
|
+
skipped: local.skipped
|
|
2945
|
+
};
|
|
2946
|
+
ctx.out.data(data, () => {
|
|
2436
2947
|
const isPrivate = result.site.visibility === "private";
|
|
2437
2948
|
if (ctx.out.tty)
|
|
2438
2949
|
return box(
|
|
2439
|
-
[
|
|
2440
|
-
|
|
2950
|
+
[
|
|
2951
|
+
...review ? [chalk7.dim(reviewNote), brand.url(review), "", chalk7.dim("Site:")] : [],
|
|
2952
|
+
brand.url(result.url),
|
|
2953
|
+
...isPrivate ? [chalk7.dim(privateNote)] : []
|
|
2954
|
+
],
|
|
2955
|
+
isPrivate ? chalk7.bold("flung \xB7 private") : chalk7.bold("flung \xB7 public")
|
|
2441
2956
|
);
|
|
2442
|
-
const lines = [
|
|
2443
|
-
if (
|
|
2957
|
+
const lines = [chalk7.bold(result.url)];
|
|
2958
|
+
if (review) lines.push(chalk7.dim(`${reviewNote} ${review}`));
|
|
2959
|
+
if (isPrivate) lines.push(chalk7.dim(privateNote));
|
|
2444
2960
|
return lines.join("\n");
|
|
2445
2961
|
});
|
|
2446
2962
|
}
|
|
2447
2963
|
var BAR_WIDTH = 20;
|
|
2448
2964
|
function progressBar(fraction) {
|
|
2449
2965
|
const done = Math.round(Math.min(Math.max(fraction, 0), 1) * BAR_WIDTH);
|
|
2450
|
-
return brand.primary("\u2501".repeat(done)) +
|
|
2966
|
+
return brand.primary("\u2501".repeat(done)) + chalk7.dim("\u2500".repeat(BAR_WIDTH - done));
|
|
2451
2967
|
}
|
|
2452
2968
|
async function askNoIndex(ctx, pages) {
|
|
2453
2969
|
const question = pages > 0 ? "No index.html at the root. [L]ist the HTML pages on a generated index page / [f]ling anyway (the root will 404) / [a]bort " : "No index.html at the root (and no HTML pages to list). [f]ling anyway (the root will 404) / [A]bort ";
|
|
@@ -2463,20 +2979,20 @@ async function askNoIndex(ctx, pages) {
|
|
|
2463
2979
|
}
|
|
2464
2980
|
async function confirmSiteName(ctx, client, generated, base) {
|
|
2465
2981
|
const where = (n) => base ? ` \u2192 ${siteUrlFor(n, base)}` : "";
|
|
2466
|
-
const
|
|
2982
|
+
const describe2 = (n, note) => ctx.log.info(`Site name: ${chalk7.bold(n)}${where(n)} ${chalk7.dim(`(${note})`)}`);
|
|
2467
2983
|
let candidate = generated;
|
|
2468
2984
|
for (; ; ) {
|
|
2469
2985
|
const check2 = await checkName(client, candidate);
|
|
2470
2986
|
let proposal;
|
|
2471
2987
|
if (check2.status === "available" || check2.status === "yours") {
|
|
2472
2988
|
proposal = candidate;
|
|
2473
|
-
|
|
2989
|
+
describe2(candidate, check2.status === "yours" ? "updates your existing site" : "new");
|
|
2474
2990
|
} else {
|
|
2475
2991
|
const why = check2.status === "taken" ? "is taken" : "is not a valid site name";
|
|
2476
2992
|
const alternatives = check2.suggestions.length ? ` Suggestions: ${check2.suggestions.join(", ")}` : "";
|
|
2477
2993
|
ctx.log.warn(`${candidate} ${why}.${alternatives}`);
|
|
2478
2994
|
proposal = check2.suggestions[0];
|
|
2479
|
-
if (proposal)
|
|
2995
|
+
if (proposal) describe2(proposal, "new");
|
|
2480
2996
|
}
|
|
2481
2997
|
for (; ; ) {
|
|
2482
2998
|
const question = proposal ? `Use "${proposal}"? [Y/n or type another name] ` : "Type a site name (or n to abort): ";
|
|
@@ -2501,14 +3017,14 @@ async function confirmSiteName(ctx, client, generated, base) {
|
|
|
2501
3017
|
|
|
2502
3018
|
// src/commands/setup.ts
|
|
2503
3019
|
import { intro, outro, spinner } from "@clack/prompts";
|
|
2504
|
-
import
|
|
3020
|
+
import chalk8 from "chalk";
|
|
2505
3021
|
|
|
2506
3022
|
// src/claude.ts
|
|
2507
3023
|
import { execFile as execFile2 } from "node:child_process";
|
|
2508
3024
|
import { access, mkdtemp as mkdtemp2, writeFile as writeFile3 } from "node:fs/promises";
|
|
2509
3025
|
import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
|
|
2510
3026
|
import { join as join5 } from "node:path";
|
|
2511
|
-
var MARKETPLACE_SOURCE =
|
|
3027
|
+
var MARKETPLACE_SOURCE = REPO_SLUG;
|
|
2512
3028
|
var MARKETPLACE_NAME = "fling";
|
|
2513
3029
|
var PLUGIN_NAME = "fling";
|
|
2514
3030
|
var PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
|
|
@@ -2563,12 +3079,12 @@ function check(result, what) {
|
|
|
2563
3079
|
const detail = (result.stderr || result.stdout).trim().split("\n").at(-1);
|
|
2564
3080
|
throw new Error(`Could not ${what}${detail ? `: ${detail}` : ""}`);
|
|
2565
3081
|
}
|
|
2566
|
-
async function findClaudeDesktop(platform = process.platform, env = process.env, exists = (
|
|
3082
|
+
async function findClaudeDesktop(platform = process.platform, env = process.env, exists = (path3) => access(path3).then(
|
|
2567
3083
|
() => true,
|
|
2568
3084
|
() => false
|
|
2569
3085
|
)) {
|
|
2570
3086
|
const candidates = platform === "darwin" ? ["/Applications/Claude.app", join5(homedir3(), "Applications", "Claude.app")] : platform === "win32" && env.LOCALAPPDATA ? [join5(env.LOCALAPPDATA, "AnthropicClaude"), join5(env.LOCALAPPDATA, "Programs", "Claude")] : [];
|
|
2571
|
-
for (const
|
|
3087
|
+
for (const path3 of candidates) if (await exists(path3)) return path3;
|
|
2572
3088
|
return null;
|
|
2573
3089
|
}
|
|
2574
3090
|
async function downloadExtension(serverUrl, fetch2) {
|
|
@@ -2594,36 +3110,38 @@ async function runSetup(baseCtx, opts, deps = {}) {
|
|
|
2594
3110
|
...baseCtx,
|
|
2595
3111
|
log: createLogger({
|
|
2596
3112
|
debug: baseCtx.config.debug,
|
|
2597
|
-
write: (line) => process.stderr.write(`${
|
|
3113
|
+
write: (line) => process.stderr.write(`${chalk8.gray("\u2502")} ${line}
|
|
2598
3114
|
`)
|
|
2599
3115
|
})
|
|
2600
3116
|
} : baseCtx;
|
|
2601
3117
|
if (fancy) {
|
|
2602
3118
|
process.stderr.write(`${logo()}
|
|
2603
3119
|
`);
|
|
2604
|
-
intro(
|
|
3120
|
+
intro(chalk8.bold(" fling setup "), { output: process.stderr });
|
|
2605
3121
|
}
|
|
2606
3122
|
const results = {
|
|
2607
|
-
login: await stepLogin(ctx),
|
|
3123
|
+
login: await stepLogin(ctx, opts.url),
|
|
2608
3124
|
claudeCode: "skipped",
|
|
2609
3125
|
claudeDesktop: "skipped"
|
|
2610
3126
|
};
|
|
2611
|
-
results.
|
|
2612
|
-
results.
|
|
3127
|
+
const after = results.login === "done" ? { ...ctx, config: await ctx.store.resolve() } : ctx;
|
|
3128
|
+
results.claudeCode = await stepClaudeCode(after, run, scope, ask, fancy);
|
|
3129
|
+
results.claudeDesktop = await stepClaudeDesktop(after, deps, ask, fancy);
|
|
2613
3130
|
const failed = Object.values(results).includes("failed");
|
|
2614
3131
|
if (fancy)
|
|
2615
3132
|
outro(
|
|
2616
|
-
failed ?
|
|
3133
|
+
failed ? chalk8.yellow("Setup finished with problems \u2014 see above, then run `fling setup` again.") : `You're set. Try ${chalk8.bold(`${ctx.binName} deploy ./dist`)}, or ask Claude to \u201Cfling this\u201D.`,
|
|
2617
3134
|
{ output: process.stderr }
|
|
2618
3135
|
);
|
|
2619
3136
|
ctx.out.data(results, () => "");
|
|
2620
3137
|
if (failed) process.exitCode = 1;
|
|
2621
3138
|
}
|
|
2622
|
-
async function stepLogin(ctx) {
|
|
2623
|
-
|
|
3139
|
+
async function stepLogin(ctx, url) {
|
|
3140
|
+
const sameServer = url === void 0 || safeNormalize(url) === ctx.config.serverUrl;
|
|
3141
|
+
if (ctx.config.apiKey && sameServer) {
|
|
2624
3142
|
try {
|
|
2625
3143
|
const me = await whoami(requireClient(ctx));
|
|
2626
|
-
ctx.log.success(`Signed in as ${
|
|
3144
|
+
ctx.log.success(`Signed in as ${chalk8.bold(me.name)} ${chalk8.dim(`<${me.email}>`)}`);
|
|
2627
3145
|
return "already";
|
|
2628
3146
|
} catch (error) {
|
|
2629
3147
|
if (exitCodeFor(error) !== 2) {
|
|
@@ -2634,17 +3152,24 @@ async function stepLogin(ctx) {
|
|
|
2634
3152
|
}
|
|
2635
3153
|
}
|
|
2636
3154
|
try {
|
|
2637
|
-
await runLogin({ ...ctx, json: false });
|
|
3155
|
+
await runLogin({ ...ctx, json: false }, url);
|
|
2638
3156
|
return "done";
|
|
2639
3157
|
} catch (error) {
|
|
2640
3158
|
ctx.log.error(error instanceof Error ? error.message : String(error));
|
|
2641
3159
|
return "failed";
|
|
2642
3160
|
}
|
|
2643
3161
|
}
|
|
3162
|
+
function safeNormalize(url) {
|
|
3163
|
+
try {
|
|
3164
|
+
return normalizeServerUrl(url);
|
|
3165
|
+
} catch {
|
|
3166
|
+
return void 0;
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
2644
3169
|
async function stepClaudeCode(ctx, run, scope, ask, fancy) {
|
|
2645
3170
|
const status = await claudeCodeStatus(run);
|
|
2646
3171
|
if (status.claude === "missing") {
|
|
2647
|
-
ctx.log.info(`Claude Code not found ${
|
|
3172
|
+
ctx.log.info(`Claude Code not found ${chalk8.dim("(no `claude` on your PATH) \u2014 skipped")}`);
|
|
2648
3173
|
ctx.log.hint(
|
|
2649
3174
|
`Once it is installed, run \`${ctx.binName} setup\` again, or in Claude Code: /plugin marketplace add ${MARKETPLACE_SOURCE} then /plugin install ${PLUGIN_ID}`
|
|
2650
3175
|
);
|
|
@@ -2653,7 +3178,7 @@ async function stepClaudeCode(ctx, run, scope, ask, fancy) {
|
|
|
2653
3178
|
const installed = "installed" in status && status.installed;
|
|
2654
3179
|
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?";
|
|
2655
3180
|
if (ask && !await ctx.confirm(question)) {
|
|
2656
|
-
ctx.log.info(
|
|
3181
|
+
ctx.log.info(chalk8.dim("Claude Code plugin: skipped"));
|
|
2657
3182
|
return installed ? "already" : "skipped";
|
|
2658
3183
|
}
|
|
2659
3184
|
const s = fancy ? spinner({ output: process.stderr }) : null;
|
|
@@ -2667,7 +3192,7 @@ async function stepClaudeCode(ctx, run, scope, ask, fancy) {
|
|
|
2667
3192
|
else ctx.log.error(message);
|
|
2668
3193
|
return "failed";
|
|
2669
3194
|
}
|
|
2670
|
-
const done = installed ? `Claude Code plugin ${PLUGIN_NAME} is up to date` : `Claude Code plugin ${PLUGIN_NAME} installed ${
|
|
3195
|
+
const done = installed ? `Claude Code plugin ${PLUGIN_NAME} is up to date` : `Claude Code plugin ${PLUGIN_NAME} installed ${chalk8.dim(`(${scope} scope)`)}`;
|
|
2671
3196
|
if (s) s.stop(done);
|
|
2672
3197
|
else ctx.log.success(done);
|
|
2673
3198
|
ctx.log.hint("Restart Claude Code (or run /reload-plugins) to pick it up.");
|
|
@@ -2677,13 +3202,13 @@ async function stepClaudeDesktop(ctx, deps, ask, fancy) {
|
|
|
2677
3202
|
const app = await (deps.findClaudeDesktop ?? findClaudeDesktop)();
|
|
2678
3203
|
if (!app) return "missing";
|
|
2679
3204
|
if (ask && !await ctx.confirm("Add the Fling extension to Claude Desktop too?")) {
|
|
2680
|
-
ctx.log.info(
|
|
3205
|
+
ctx.log.info(chalk8.dim("Claude Desktop extension: skipped"));
|
|
2681
3206
|
return "skipped";
|
|
2682
3207
|
}
|
|
2683
3208
|
const s = fancy ? spinner({ output: process.stderr }) : null;
|
|
2684
3209
|
s?.start("Downloading the Claude Desktop extension");
|
|
2685
3210
|
try {
|
|
2686
|
-
const file = await (deps.downloadExtension ?? ((serverUrl) => downloadExtension(serverUrl, ctx.fetch)))(ctx
|
|
3211
|
+
const file = await (deps.downloadExtension ?? ((serverUrl) => downloadExtension(serverUrl, ctx.fetch)))(requireServer(ctx));
|
|
2687
3212
|
await ctx.open(file);
|
|
2688
3213
|
const done = "Opened fling.mcpb in Claude Desktop \u2014 click Install there to finish";
|
|
2689
3214
|
if (s) s.stop(done);
|
|
@@ -2698,7 +3223,7 @@ async function stepClaudeDesktop(ctx, deps, ask, fancy) {
|
|
|
2698
3223
|
}
|
|
2699
3224
|
|
|
2700
3225
|
// src/commands/sites.ts
|
|
2701
|
-
import
|
|
3226
|
+
import chalk9 from "chalk";
|
|
2702
3227
|
async function runList(ctx, opts) {
|
|
2703
3228
|
const sites = await listSites(requireClient(ctx), opts);
|
|
2704
3229
|
ctx.out.data(
|
|
@@ -2716,11 +3241,11 @@ async function runList(ctx, opts) {
|
|
|
2716
3241
|
);
|
|
2717
3242
|
}
|
|
2718
3243
|
function describeSite(site) {
|
|
2719
|
-
const row = (label, value) => `${
|
|
3244
|
+
const row = (label, value) => `${chalk9.bold(`${label}:`.padEnd(12))}${value}`;
|
|
2720
3245
|
return [
|
|
2721
3246
|
row("Name", site.name),
|
|
2722
3247
|
row("URL", site.url),
|
|
2723
|
-
row("Visibility", site.visibility + (site.archived ?
|
|
3248
|
+
row("Visibility", site.visibility + (site.archived ? chalk9.yellow(" (archived)") : "")),
|
|
2724
3249
|
row("SPA", site.spa ? "yes" : "no"),
|
|
2725
3250
|
row("Share link", site.shareUrl ?? "-"),
|
|
2726
3251
|
row("Version", site.currentVersionId ?? "-"),
|
|
@@ -2742,7 +3267,7 @@ async function runInfo(ctx, name) {
|
|
|
2742
3267
|
() => [
|
|
2743
3268
|
...describeSite(site),
|
|
2744
3269
|
"",
|
|
2745
|
-
|
|
3270
|
+
chalk9.dim(
|
|
2746
3271
|
`${site.versions.length} version(s) \u2014 \`fling versions ${name}\`; files \u2014 \`fling files ${name}\``
|
|
2747
3272
|
)
|
|
2748
3273
|
].join("\n")
|
|
@@ -2768,7 +3293,7 @@ async function runFiles(ctx, name, versionId) {
|
|
|
2768
3293
|
}
|
|
2769
3294
|
ctx.out.data(result, () => {
|
|
2770
3295
|
const n = result.files.length;
|
|
2771
|
-
const header =
|
|
3296
|
+
const header = chalk9.dim(
|
|
2772
3297
|
`${result.versionId}${result.current ? " (current)" : " (not served)"} \u2014 ${n} file${n === 1 ? "" : "s"}, ${formatBytes(result.totalBytes)}${result.generatedIndex ? ", generated index.html" : ""}`
|
|
2773
3298
|
);
|
|
2774
3299
|
const table = renderTable(result.files, [
|
|
@@ -2804,7 +3329,7 @@ async function runShare(ctx, name, opts) {
|
|
|
2804
3329
|
ctx.log.success("Share link created \u2014 anyone with it can view the site");
|
|
2805
3330
|
ctx.out.data(
|
|
2806
3331
|
ready === void 0 ? result : { ...result, ready },
|
|
2807
|
-
() => result.shareUrl ? result.shareUrl :
|
|
3332
|
+
() => result.shareUrl ? result.shareUrl : chalk9.dim("(no share link)")
|
|
2808
3333
|
);
|
|
2809
3334
|
}
|
|
2810
3335
|
async function runVisibility(ctx, name, visibility) {
|
|
@@ -2813,7 +3338,7 @@ async function runVisibility(ctx, name, visibility) {
|
|
|
2813
3338
|
}
|
|
2814
3339
|
const site = await setVisibility(requireClient(ctx), name, visibility);
|
|
2815
3340
|
ctx.log.success(
|
|
2816
|
-
site.visibility === "public" ? `${name} is public \u2014 no sign-in needed` : `${name} is private \u2014
|
|
3341
|
+
site.visibility === "public" ? `${name} is public \u2014 no sign-in needed` : `${name} is private \u2014 sign-in required`
|
|
2817
3342
|
);
|
|
2818
3343
|
ctx.out.data(site, () => site.url);
|
|
2819
3344
|
}
|
|
@@ -2846,7 +3371,7 @@ import { tmpdir as tmpdir3 } from "node:os";
|
|
|
2846
3371
|
import { basename as basename3, dirname as dirname2, join as join6 } from "node:path";
|
|
2847
3372
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2848
3373
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2849
|
-
import { z as
|
|
3374
|
+
import { z as z11 } from "zod";
|
|
2850
3375
|
|
|
2851
3376
|
// src/locate.ts
|
|
2852
3377
|
import { lstat, readdir as readdir2, readFile as readFile6, realpath as realpath3, stat as stat5 } from "node:fs/promises";
|
|
@@ -2860,21 +3385,21 @@ var SESSIONS_DEPTH = 4;
|
|
|
2860
3385
|
var OUTPUTS_DEPTH = 4;
|
|
2861
3386
|
var SANDBOX_PATH_RE = /^\/(mnt\/user-data|mnt\/data|sessions|home\/claude|tmp\/outputs)(\/|$)/;
|
|
2862
3387
|
var nodeFs = {
|
|
2863
|
-
async stat(
|
|
3388
|
+
async stat(path3) {
|
|
2864
3389
|
try {
|
|
2865
|
-
const s = await stat5(
|
|
3390
|
+
const s = await stat5(path3);
|
|
2866
3391
|
return { isFile: s.isFile(), isDirectory: s.isDirectory(), mtimeMs: s.mtimeMs, size: s.size };
|
|
2867
3392
|
} catch {
|
|
2868
3393
|
return null;
|
|
2869
3394
|
}
|
|
2870
3395
|
},
|
|
2871
|
-
async readdir(
|
|
3396
|
+
async readdir(path3) {
|
|
2872
3397
|
try {
|
|
2873
|
-
const entries = await readdir2(
|
|
3398
|
+
const entries = await readdir2(path3, { withFileTypes: true });
|
|
2874
3399
|
const out = [];
|
|
2875
3400
|
for (const e of entries) {
|
|
2876
3401
|
if (e.isSymbolicLink()) {
|
|
2877
|
-
const s = await stat5(`${
|
|
3402
|
+
const s = await stat5(`${path3}/${e.name}`).catch(() => null);
|
|
2878
3403
|
out.push({ name: e.name, isFile: Boolean(s?.isFile()), isDirectory: false });
|
|
2879
3404
|
} else out.push({ name: e.name, isFile: e.isFile(), isDirectory: e.isDirectory() });
|
|
2880
3405
|
}
|
|
@@ -2883,13 +3408,13 @@ var nodeFs = {
|
|
|
2883
3408
|
return [];
|
|
2884
3409
|
}
|
|
2885
3410
|
},
|
|
2886
|
-
async realpath(
|
|
2887
|
-
return realpath3(
|
|
3411
|
+
async realpath(path3) {
|
|
3412
|
+
return realpath3(path3).catch(() => null);
|
|
2888
3413
|
},
|
|
2889
|
-
async readText(
|
|
3414
|
+
async readText(path3) {
|
|
2890
3415
|
try {
|
|
2891
|
-
if (!(await lstat(
|
|
2892
|
-
return await readFile6(
|
|
3416
|
+
if (!(await lstat(path3)).isFile()) return null;
|
|
3417
|
+
return await readFile6(path3, "utf8");
|
|
2893
3418
|
} catch {
|
|
2894
3419
|
return null;
|
|
2895
3420
|
}
|
|
@@ -2912,9 +3437,9 @@ async function searchRoots(opts) {
|
|
|
2912
3437
|
const { home, env, platform } = opts;
|
|
2913
3438
|
const userHome = platform === "win32" && usable(env.USERPROFILE) ? env.USERPROFILE : home;
|
|
2914
3439
|
const roots = [];
|
|
2915
|
-
const add = (label,
|
|
2916
|
-
if (!
|
|
2917
|
-
const norm = p.resolve(
|
|
3440
|
+
const add = (label, path3, depth, sessions = false) => {
|
|
3441
|
+
if (!path3) return;
|
|
3442
|
+
const norm = p.resolve(path3);
|
|
2918
3443
|
if (roots.some((r) => r.path === norm)) return;
|
|
2919
3444
|
roots.push({ label, path: norm, depth, ...sessions ? { sessions } : {} });
|
|
2920
3445
|
};
|
|
@@ -2944,8 +3469,8 @@ async function searchRoots(opts) {
|
|
|
2944
3469
|
if (!fs.realpath) return roots;
|
|
2945
3470
|
const real = [];
|
|
2946
3471
|
for (const root of roots) {
|
|
2947
|
-
const
|
|
2948
|
-
if (!real.some((r) => r.path ===
|
|
3472
|
+
const path3 = await fs.realpath(root.path) ?? root.path;
|
|
3473
|
+
if (!real.some((r) => r.path === path3)) real.push({ ...root, path: path3 });
|
|
2949
3474
|
}
|
|
2950
3475
|
return real;
|
|
2951
3476
|
}
|
|
@@ -2993,10 +3518,10 @@ async function walk(roots, opts, visit) {
|
|
|
2993
3518
|
budget -= entries.length;
|
|
2994
3519
|
for (const entry of entries) {
|
|
2995
3520
|
if (entry.name.startsWith(".")) continue;
|
|
2996
|
-
const
|
|
2997
|
-
const result = await visit({ root, path, entry, siblings: entries });
|
|
3521
|
+
const path3 = p.join(dir, entry.name);
|
|
3522
|
+
const result = await visit({ root, path: path3, entry, siblings: entries });
|
|
2998
3523
|
if (entry.isDirectory && !skip(entry.name) && result !== "skip" && depth < root.depth)
|
|
2999
|
-
next.push(
|
|
3524
|
+
next.push(path3);
|
|
3000
3525
|
}
|
|
3001
3526
|
}
|
|
3002
3527
|
level = next;
|
|
@@ -3012,9 +3537,9 @@ function namesMatch(candidate, wanted) {
|
|
|
3012
3537
|
const b = wanted.toLowerCase();
|
|
3013
3538
|
return a === b || withoutDuplicateSuffix(a) === withoutDuplicateSuffix(b);
|
|
3014
3539
|
}
|
|
3015
|
-
var splitSegments = (
|
|
3016
|
-
function suffixScore(
|
|
3017
|
-
const have = splitSegments(
|
|
3540
|
+
var splitSegments = (path3) => path3.split(/[\\/]+/).filter((s) => s !== "" && s !== "." && s !== "~");
|
|
3541
|
+
function suffixScore(path3, wanted) {
|
|
3542
|
+
const have = splitSegments(path3);
|
|
3018
3543
|
let score = 1;
|
|
3019
3544
|
for (let i = 2; i <= Math.min(have.length, wanted.length); i++) {
|
|
3020
3545
|
if (have[have.length - i]?.toLowerCase() !== wanted[wanted.length - i]?.toLowerCase()) break;
|
|
@@ -3055,19 +3580,19 @@ async function resolveInput(input, opts) {
|
|
|
3055
3580
|
if (!name) throw notFound(input, roots);
|
|
3056
3581
|
const found = [];
|
|
3057
3582
|
const seen = /* @__PURE__ */ new Set();
|
|
3058
|
-
await walk(roots, opts, async ({ root, path, entry }) => {
|
|
3583
|
+
await walk(roots, opts, async ({ root, path: path3, entry }) => {
|
|
3059
3584
|
if (!(entry.isFile || entry.isDirectory) || !namesMatch(entry.name, name)) return;
|
|
3060
|
-
if (seen.has(
|
|
3061
|
-
seen.add(
|
|
3062
|
-
const s = await fs.stat(
|
|
3585
|
+
if (seen.has(path3)) return;
|
|
3586
|
+
seen.add(path3);
|
|
3587
|
+
const s = await fs.stat(path3);
|
|
3063
3588
|
if (!s) return;
|
|
3064
3589
|
found.push({
|
|
3065
|
-
path,
|
|
3590
|
+
path: path3,
|
|
3066
3591
|
foundIn: root.label,
|
|
3067
3592
|
kind: s.isDirectory ? "folder" : "file",
|
|
3068
3593
|
modified: new Date(s.mtimeMs).toISOString(),
|
|
3069
3594
|
size: s.size,
|
|
3070
|
-
score: suffixScore(
|
|
3595
|
+
score: suffixScore(path3, wanted),
|
|
3071
3596
|
mtimeMs: s.mtimeMs,
|
|
3072
3597
|
isDirectory: s.isDirectory
|
|
3073
3598
|
});
|
|
@@ -3101,15 +3626,15 @@ async function findFiles(opts, find = {}) {
|
|
|
3101
3626
|
const roots = await searchRoots(opts);
|
|
3102
3627
|
const found = /* @__PURE__ */ new Map();
|
|
3103
3628
|
const rootPaths = new Set(roots.map((r) => r.path));
|
|
3104
|
-
const { complete } = await walk(roots, opts, async ({ root, path, entry }) => {
|
|
3629
|
+
const { complete } = await walk(roots, opts, async ({ root, path: path3, entry }) => {
|
|
3105
3630
|
if (entry.isDirectory) {
|
|
3106
|
-
if (rootPaths.has(
|
|
3107
|
-
const index = (await fs.readdir(
|
|
3631
|
+
if (rootPaths.has(path3)) return;
|
|
3632
|
+
const index = (await fs.readdir(path3)).find((e) => e.isFile && /^index\.html?$/i.test(e.name));
|
|
3108
3633
|
if (!index) return;
|
|
3109
|
-
const s2 = await fs.stat(p.join(
|
|
3634
|
+
const s2 = await fs.stat(p.join(path3, index.name));
|
|
3110
3635
|
if (s2 && s2.mtimeMs >= since && (!query || entry.name.toLowerCase().includes(query)))
|
|
3111
|
-
found.set(
|
|
3112
|
-
path,
|
|
3636
|
+
found.set(path3, {
|
|
3637
|
+
path: path3,
|
|
3113
3638
|
foundIn: root.label,
|
|
3114
3639
|
kind: "folder",
|
|
3115
3640
|
modified: new Date(s2.mtimeMs).toISOString(),
|
|
@@ -3120,10 +3645,10 @@ async function findFiles(opts, find = {}) {
|
|
|
3120
3645
|
}
|
|
3121
3646
|
if (!entry.isFile || !LISTED_EXT.test(entry.name)) return;
|
|
3122
3647
|
if (query && !entry.name.toLowerCase().includes(query)) return;
|
|
3123
|
-
const s = await fs.stat(
|
|
3124
|
-
if (!s || s.mtimeMs < since || found.has(
|
|
3125
|
-
found.set(
|
|
3126
|
-
path,
|
|
3648
|
+
const s = await fs.stat(path3);
|
|
3649
|
+
if (!s || s.mtimeMs < since || found.has(path3)) return;
|
|
3650
|
+
found.set(path3, {
|
|
3651
|
+
path: path3,
|
|
3127
3652
|
foundIn: root.label,
|
|
3128
3653
|
kind: "file",
|
|
3129
3654
|
modified: new Date(s.mtimeMs).toISOString(),
|
|
@@ -3137,16 +3662,18 @@ async function findFiles(opts, find = {}) {
|
|
|
3137
3662
|
}
|
|
3138
3663
|
|
|
3139
3664
|
// src/mcp.ts
|
|
3140
|
-
var LOGIN_HINT = `Not logged in to Fling. Call fling_login (it opens the browser to sign in
|
|
3141
|
-
var
|
|
3142
|
-
|
|
3665
|
+
var LOGIN_HINT = `Not logged in to Fling. Call fling_login (it opens the browser to sign in), or ask the user to run \`${NPX_COMMAND} login\` in a terminal (in Claude Code: \`! ${NPX_COMMAND} login\`), then try again.`;
|
|
3666
|
+
var NO_SERVER_HINT = `No Fling server is set up on this computer. Ask the user for their Fling's address and to run \`${NPX_COMMAND} login <url>\` in a terminal (in Claude Code: \`! ${NPX_COMMAND} login <url>\`), then try again.`;
|
|
3667
|
+
var mcpLocalInstructions = (w, opts = {}) => [
|
|
3668
|
+
`${w.name} publishes static sites (HTML/CSS/JS, images, documents) at ${w.sitesPattern}. This server runs on the user's computer.`,
|
|
3143
3669
|
"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.",
|
|
3144
3670
|
'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.',
|
|
3145
3671
|
"Confirm the site name with the user before publishing (fling_check_name shows whether it is free and, for a path, the generated name). Names are global, first come first served; when one is taken, offer the suggestions.",
|
|
3146
3672
|
"When there is no index.html but there are HTML pages, fling_deploy and fling_publish do not publish: ask the user whether to generate an index page listing the pages (`listHtml: true`) or publish without one (`listHtml: false`, the root URL 404s), then retry.",
|
|
3147
3673
|
NEVER_PUBLISHED,
|
|
3148
3674
|
"Not logged in? Call fling_login, let the user finish signing in in the browser, then retry.",
|
|
3149
|
-
|
|
3675
|
+
`Sites are private (only ${w.privateAudience}) by default; only make one public if asked. Use fling_share for a link people who cannot sign in can open. ${opts.comments ? "Always give the user the links that come back: the review link first, as the one to share for feedback, then the site URL." : "Always give the user the URL that comes back."}`,
|
|
3676
|
+
...opts.comments ? [COMMENTS_NOTE(w)] : []
|
|
3150
3677
|
].join("\n\n");
|
|
3151
3678
|
var toolDef = (def) => ({
|
|
3152
3679
|
title: def.title,
|
|
@@ -3160,6 +3687,8 @@ function createMcpServer(deps = {}) {
|
|
|
3160
3687
|
const cwd = deps.cwd ?? process.cwd();
|
|
3161
3688
|
const locate = deps.locate ?? { ...systemLocateOptions(), cwd };
|
|
3162
3689
|
const waitReady = deps.waitReady ?? createReadyWaiter();
|
|
3690
|
+
const wording = mcpWording(deps.instance);
|
|
3691
|
+
const mcpTools2 = mcpToolsFor(wording);
|
|
3163
3692
|
async function edgeReady(url, paths) {
|
|
3164
3693
|
const r = await waitReady(url, paths).catch(() => ({ ready: false, waitedMs: 0 }));
|
|
3165
3694
|
return {
|
|
@@ -3170,9 +3699,14 @@ Not reachable yet at ${url}: ${NOT_READY_NOTE}.`
|
|
|
3170
3699
|
}
|
|
3171
3700
|
async function client() {
|
|
3172
3701
|
const config = await store.resolve({ serverUrl: deps.server });
|
|
3702
|
+
if (!config.serverUrl) throw new NoServerError("fling");
|
|
3173
3703
|
if (!config.apiKey) throw new CliError("Not logged in", { exitCode: EXIT_NOT_LOGGED_IN });
|
|
3174
3704
|
return createApiClient({ serverUrl: config.serverUrl, apiKey: config.apiKey, fetch: fetchImpl });
|
|
3175
3705
|
}
|
|
3706
|
+
async function sitesBase() {
|
|
3707
|
+
const config = await store.resolve({ serverUrl: deps.server });
|
|
3708
|
+
return sitesBaseOf(await loadInstance(store, config, fetchImpl));
|
|
3709
|
+
}
|
|
3176
3710
|
async function run(op) {
|
|
3177
3711
|
try {
|
|
3178
3712
|
const { summary, data } = await op(await client());
|
|
@@ -3228,9 +3762,15 @@ Warnings:
|
|
|
3228
3762
|
result.url,
|
|
3229
3763
|
local.files.map((f) => f.path)
|
|
3230
3764
|
);
|
|
3765
|
+
const instance = deps.instance;
|
|
3766
|
+
const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
|
|
3767
|
+
const listing = generatedIndex ? " \u2014 with a generated index.html listing the HTML pages" : "";
|
|
3768
|
+
const head = review ? `${what}${listing}.
|
|
3769
|
+
${shareForFeedback(review, instance?.name ?? "Fling")}
|
|
3770
|
+
Site: ${result.url} (${result.site.visibility})` : `${what}: ${result.url} (${result.site.visibility})${listing}`;
|
|
3231
3771
|
return {
|
|
3232
3772
|
kind: "deployed",
|
|
3233
|
-
summary: `${
|
|
3773
|
+
summary: `${head}${warnings}${skippedNote}${edge.note}
|
|
3234
3774
|
To check exactly which files the site has, call fling_files with name "${result.name}".`,
|
|
3235
3775
|
data: {
|
|
3236
3776
|
status: result.status,
|
|
@@ -3239,6 +3779,7 @@ To check exactly which files the site has, call fling_files with name "${result.
|
|
|
3239
3779
|
nameGenerated: !opts.nameGiven,
|
|
3240
3780
|
versionId: result.versionId,
|
|
3241
3781
|
site: result.site,
|
|
3782
|
+
...review ? { reviewUrl: review } : {},
|
|
3242
3783
|
generatedIndex,
|
|
3243
3784
|
warnings: result.warnings,
|
|
3244
3785
|
skipped: local.skipped
|
|
@@ -3247,23 +3788,27 @@ To check exactly which files the site has, call fling_files with name "${result.
|
|
|
3247
3788
|
}
|
|
3248
3789
|
const server = new McpServer(
|
|
3249
3790
|
{ name: "fling", version: VERSION },
|
|
3250
|
-
{
|
|
3791
|
+
{
|
|
3792
|
+
instructions: mcpLocalInstructions(wording, {
|
|
3793
|
+
comments: commentsEnabled(deps.instance)
|
|
3794
|
+
})
|
|
3795
|
+
}
|
|
3251
3796
|
);
|
|
3252
|
-
const { listHtml: listHtml2, spa: spa2, public:
|
|
3797
|
+
const { listHtml: listHtml2, spa: spa2, public: isPublic } = mcpTools2.publish.inputSchema;
|
|
3253
3798
|
server.registerTool(
|
|
3254
3799
|
"fling_deploy",
|
|
3255
3800
|
{
|
|
3256
3801
|
title: "Deploy a file or folder from this computer",
|
|
3257
|
-
description:
|
|
3802
|
+
description: `Fling something that is on the user's computer and return its stable URL (${wording.sitesPattern}): a folder of static files (e.g. a build output like dist/), a single .html file (served as the site's index.html), a .zip of a site (extracted; a single top-level folder is unwrapped), or any other single file (.pdf, .svg, .png\u2026, served next to a generated page that shows it). \`path\` can be absolute, relative to the working directory, or just a file name ("Pitch Deck.html"): names are looked up, newest first, in Downloads, Desktop, Documents/Claude and Claude Desktop output folders, and the result says where the file was found. Never pass paths from your own sandbox (/mnt/user-data/\u2026, /sessions/\u2026): for content you created, use fling_publish. Without \`name\`, a name is generated: from the file name for files ("Pitch Deck (1).html" \u2192 pitch-deck), from the project and folder for folders (~/work/rocketpod/docs \u2192 rocketpod-docs). Site names are one global namespace, first come first served \u2014 confirm the name with the user first (fling_check_name with \`path\` shows the generated one). If the name is taken it fails with free suggestions \u2014 ask the user to pick one, then retry with \`name\`. If there is no index.html but there are HTML pages, it does not deploy: ask the user whether to generate an index page listing them (\`listHtml: true\`) or deploy without one (\`listHtml: false\`), then retry. Re-deploying identical content is a no-op. Sites are private (only ${wording.privateAudience}) unless \`public\` is set. ` + NEVER_PUBLISHED,
|
|
3258
3803
|
inputSchema: {
|
|
3259
|
-
path:
|
|
3804
|
+
path: z11.string().min(1).describe(
|
|
3260
3805
|
"File or folder on this computer: absolute, relative to the working directory, or just its name (looked up in Downloads, Desktop and Claude output folders)"
|
|
3261
3806
|
),
|
|
3262
|
-
name:
|
|
3807
|
+
name: z11.string().optional().describe(
|
|
3263
3808
|
"Site name (lowercase letters, digits, single hyphens; max 50). Defaults to a name generated from the file or folder"
|
|
3264
3809
|
),
|
|
3265
3810
|
spa: spa2,
|
|
3266
|
-
public:
|
|
3811
|
+
public: isPublic,
|
|
3267
3812
|
listHtml: listHtml2
|
|
3268
3813
|
},
|
|
3269
3814
|
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
@@ -3308,9 +3853,9 @@ To check exactly which files the site has, call fling_files with name "${result.
|
|
|
3308
3853
|
title: "Find files to fling",
|
|
3309
3854
|
description: "List recent things on this computer that could be flung, newest first: .html/.htm and .zip files, and folders containing an index.html, from the working directory, Downloads, Desktop, Documents/Claude, ~/Claude and Claude Desktop output folders. Each has its path, where it was found, size and modified time. Use it when the user refers to a file they downloaded or that Claude Desktop saved, then pass the chosen path (or name) to fling_deploy.",
|
|
3310
3855
|
inputSchema: {
|
|
3311
|
-
query:
|
|
3312
|
-
limit:
|
|
3313
|
-
days:
|
|
3856
|
+
query: z11.string().optional().describe('Only names containing this text (case-insensitive), e.g. "deck"'),
|
|
3857
|
+
limit: z11.number().int().min(1).max(100).optional().describe("Max results (default 20)"),
|
|
3858
|
+
days: z11.number().int().min(1).max(3650).optional().describe("Only things modified in the last N days (default 14)")
|
|
3314
3859
|
},
|
|
3315
3860
|
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
3316
3861
|
},
|
|
@@ -3337,7 +3882,7 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3337
3882
|
}
|
|
3338
3883
|
}
|
|
3339
3884
|
);
|
|
3340
|
-
const publishDef =
|
|
3885
|
+
const publishDef = mcpTools2.publish;
|
|
3341
3886
|
server.registerTool(
|
|
3342
3887
|
publishDef.name,
|
|
3343
3888
|
{
|
|
@@ -3371,11 +3916,11 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3371
3916
|
server.registerTool(
|
|
3372
3917
|
"fling_check_name",
|
|
3373
3918
|
{
|
|
3374
|
-
...toolDef(
|
|
3375
|
-
description: `${
|
|
3919
|
+
...toolDef(mcpTools2.checkName),
|
|
3920
|
+
description: `${mcpTools2.checkName.description} Pass \`path\` instead of \`name\` to see the name fling_deploy would generate for a file or folder on this computer (same lookup as fling_deploy), so you can confirm it with the user.`,
|
|
3376
3921
|
inputSchema: {
|
|
3377
|
-
name:
|
|
3378
|
-
path:
|
|
3922
|
+
name: z11.string().min(1).max(200).optional().describe("Site name to check"),
|
|
3923
|
+
path: z11.string().optional().describe("File or folder whose generated default name to check (path or file name)")
|
|
3379
3924
|
}
|
|
3380
3925
|
},
|
|
3381
3926
|
(args) => run(async (c) => {
|
|
@@ -3388,7 +3933,7 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3388
3933
|
name = defaultNameFor(source.path, source.isDirectory);
|
|
3389
3934
|
}
|
|
3390
3935
|
const check2 = await checkName(c, name);
|
|
3391
|
-
const base = check2.status === "invalid" ? null : await
|
|
3936
|
+
const base = check2.status === "invalid" ? null : await sitesBase();
|
|
3392
3937
|
const url = base ? siteUrlFor(name, base) : null;
|
|
3393
3938
|
const suggestions = check2.suggestions.length ? ` Suggestions: ${check2.suggestions.join(", ")}.` : "";
|
|
3394
3939
|
const summary = {
|
|
@@ -3412,17 +3957,19 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3412
3957
|
"fling_login",
|
|
3413
3958
|
{
|
|
3414
3959
|
title: "Sign in to Fling",
|
|
3415
|
-
description:
|
|
3960
|
+
description: `Sign the user in to ${wording.name}: opens their browser to sign in with ${wording.signIn} and stores the key on this computer (shared with the fling CLI). Waits up to ~30 seconds; if sign-in is not finished by then, it keeps waiting in the background for up to 5 minutes \u2014 tell the user to finish signing in in the browser, then retry what you were doing. Call it when another tool says the user is not logged in.`,
|
|
3416
3961
|
inputSchema: {},
|
|
3417
3962
|
annotations: { readOnlyHint: false, openWorldHint: true }
|
|
3418
3963
|
},
|
|
3419
3964
|
async () => {
|
|
3420
3965
|
try {
|
|
3421
3966
|
const config = await store.resolve({ serverUrl: deps.server });
|
|
3967
|
+
const serverUrl = config.serverUrl;
|
|
3968
|
+
if (!serverUrl) throw new NoServerError("fling");
|
|
3422
3969
|
if (config.apiKey && !pendingLogin) {
|
|
3423
3970
|
const me = await whoami(
|
|
3424
3971
|
createApiClient({
|
|
3425
|
-
serverUrl
|
|
3972
|
+
serverUrl,
|
|
3426
3973
|
apiKey: config.apiKey,
|
|
3427
3974
|
fetch: fetchImpl
|
|
3428
3975
|
})
|
|
@@ -3434,7 +3981,7 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3434
3981
|
done: Promise.resolve()
|
|
3435
3982
|
};
|
|
3436
3983
|
login.done = loginFlow({
|
|
3437
|
-
serverUrl
|
|
3984
|
+
serverUrl,
|
|
3438
3985
|
store,
|
|
3439
3986
|
log: silentLogger,
|
|
3440
3987
|
open: deps.open ?? (async (url) => (await import("open")).default(url)),
|
|
@@ -3469,7 +4016,7 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3469
4016
|
}
|
|
3470
4017
|
}
|
|
3471
4018
|
);
|
|
3472
|
-
const t =
|
|
4019
|
+
const t = mcpTools2;
|
|
3473
4020
|
server.registerTool(
|
|
3474
4021
|
t.list.name,
|
|
3475
4022
|
toolDef(t.list),
|
|
@@ -3554,12 +4101,66 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3554
4101
|
};
|
|
3555
4102
|
})
|
|
3556
4103
|
);
|
|
4104
|
+
if (deps.instance && commentsEnabled(deps.instance)) {
|
|
4105
|
+
const appOrigin = deps.instance.appOrigin;
|
|
4106
|
+
const review = (name) => reviewUrl(appOrigin, name);
|
|
4107
|
+
server.registerTool(
|
|
4108
|
+
t.commentsList.name,
|
|
4109
|
+
toolDef(t.commentsList),
|
|
4110
|
+
(args) => run(async (c) => {
|
|
4111
|
+
const list = await listComments(c, args.name, { path: args.path });
|
|
4112
|
+
const reviewPage2 = review(list.site.name);
|
|
4113
|
+
return {
|
|
4114
|
+
summary: describeComments(list, {
|
|
4115
|
+
includeResolved: args.includeResolved,
|
|
4116
|
+
reviewUrl: reviewPage2
|
|
4117
|
+
}),
|
|
4118
|
+
data: { ...list, reviewUrl: reviewPage2 }
|
|
4119
|
+
};
|
|
4120
|
+
})
|
|
4121
|
+
);
|
|
4122
|
+
server.registerTool(
|
|
4123
|
+
t.commentAdd.name,
|
|
4124
|
+
toolDef(t.commentAdd),
|
|
4125
|
+
(args) => run(async (c) => {
|
|
4126
|
+
const comment = await addComment(c, args.name, {
|
|
4127
|
+
body: args.body,
|
|
4128
|
+
path: args.path,
|
|
4129
|
+
parentId: args.replyTo
|
|
4130
|
+
});
|
|
4131
|
+
const what = comment.parentId ? `Replied to thread ${comment.parentId}` : "Comment added";
|
|
4132
|
+
return {
|
|
4133
|
+
summary: `${what} on ${args.name} (${comment.path}, id ${comment.id}). Review page: ${review(args.name)}`,
|
|
4134
|
+
data: comment
|
|
4135
|
+
};
|
|
4136
|
+
})
|
|
4137
|
+
);
|
|
4138
|
+
server.registerTool(
|
|
4139
|
+
t.commentResolve.name,
|
|
4140
|
+
toolDef(t.commentResolve),
|
|
4141
|
+
(args) => run(async (c) => {
|
|
4142
|
+
const comment = await resolveComment(c, args.name, args.id, !args.reopen);
|
|
4143
|
+
return {
|
|
4144
|
+
summary: `Thread ${comment.id} on ${args.name} ${args.reopen ? "reopened" : "resolved"}`,
|
|
4145
|
+
data: comment
|
|
4146
|
+
};
|
|
4147
|
+
})
|
|
4148
|
+
);
|
|
4149
|
+
server.registerTool(
|
|
4150
|
+
t.commentDelete.name,
|
|
4151
|
+
toolDef(t.commentDelete),
|
|
4152
|
+
(args) => run(async (c) => {
|
|
4153
|
+
const result = await deleteComment(c, args.name, args.id);
|
|
4154
|
+
return { summary: `Comment ${args.id} on ${args.name} deleted`, data: result };
|
|
4155
|
+
})
|
|
4156
|
+
);
|
|
4157
|
+
}
|
|
3557
4158
|
server.registerTool(
|
|
3558
4159
|
t.whoami.name,
|
|
3559
4160
|
toolDef(t.whoami),
|
|
3560
4161
|
() => run(async (c) => {
|
|
3561
4162
|
const me = await whoami(c);
|
|
3562
|
-
const base = await
|
|
4163
|
+
const base = await sitesBase();
|
|
3563
4164
|
return {
|
|
3564
4165
|
summary: `Signed in as ${me.email} (sites are served at ${sitesPattern(base)})`,
|
|
3565
4166
|
data: me
|
|
@@ -3575,24 +4176,24 @@ async function writeInlineFiles(dir, files) {
|
|
|
3575
4176
|
const secretFiles = [];
|
|
3576
4177
|
let total = 0;
|
|
3577
4178
|
for (const f of files) {
|
|
3578
|
-
const
|
|
3579
|
-
const segments =
|
|
3580
|
-
if (
|
|
3581
|
-
skippedDotfiles.push(
|
|
4179
|
+
const path3 = f.path.replace(/^(\.\/)+/, "").replace(/^\/+/, "");
|
|
4180
|
+
const segments = path3.split("/");
|
|
4181
|
+
if (path3 && !path3.includes("\\") && segments.every((s) => s !== "" && s !== "." && s !== "..") && segments.some((s) => s.startsWith("."))) {
|
|
4182
|
+
skippedDotfiles.push(path3);
|
|
3582
4183
|
continue;
|
|
3583
4184
|
}
|
|
3584
|
-
if (!isValidSitePath(
|
|
3585
|
-
if (isSecretPath(
|
|
3586
|
-
secretFiles.push(
|
|
4185
|
+
if (!isValidSitePath(path3)) throw new CliError(`Invalid file path: "${f.path}"`);
|
|
4186
|
+
if (isSecretPath(path3)) {
|
|
4187
|
+
secretFiles.push(path3);
|
|
3587
4188
|
continue;
|
|
3588
4189
|
}
|
|
3589
|
-
if (seen.has(
|
|
3590
|
-
seen.add(
|
|
4190
|
+
if (seen.has(path3)) throw new CliError(`"${path3}" is listed twice`);
|
|
4191
|
+
seen.add(path3);
|
|
3591
4192
|
const bytes = Buffer.from(f.content, f.encoding === "base64" ? "base64" : "utf8");
|
|
3592
4193
|
total += bytes.length;
|
|
3593
4194
|
if (total > MAX_DEPLOY_BYTES)
|
|
3594
4195
|
throw new CliError(`The site is larger than ${formatBytes(MAX_DEPLOY_BYTES)}`);
|
|
3595
|
-
const target = join6(dir, ...
|
|
4196
|
+
const target = join6(dir, ...path3.split("/"));
|
|
3596
4197
|
await mkdir3(dirname2(target), { recursive: true });
|
|
3597
4198
|
await writeFile4(target, bytes);
|
|
3598
4199
|
}
|
|
@@ -3600,6 +4201,7 @@ async function writeInlineFiles(dir, files) {
|
|
|
3600
4201
|
return { skippedDotfiles };
|
|
3601
4202
|
}
|
|
3602
4203
|
function describeError(error) {
|
|
4204
|
+
if (error instanceof NoServerError) return NO_SERVER_HINT;
|
|
3603
4205
|
if (error instanceof CliError && error.exitCode === EXIT_NOT_LOGGED_IN) return LOGIN_HINT;
|
|
3604
4206
|
if (error instanceof CliApiError && error.status === 401) return LOGIN_HINT;
|
|
3605
4207
|
if (error instanceof CliError)
|
|
@@ -3608,7 +4210,9 @@ ${error.hint}` : error.message;
|
|
|
3608
4210
|
return error instanceof Error ? error.message : String(error);
|
|
3609
4211
|
}
|
|
3610
4212
|
async function runMcpServer(deps = {}) {
|
|
3611
|
-
const
|
|
4213
|
+
const store = deps.store ?? createConfigStore();
|
|
4214
|
+
const instance = deps.instance ?? await store.resolve({ serverUrl: deps.server }).then((config) => loadInstance(store, config, deps.fetch ?? ((i, init) => fetch(i, init)))).catch(() => null);
|
|
4215
|
+
const server = createMcpServer({ ...deps, store, instance });
|
|
3612
4216
|
await server.connect(new StdioServerTransport());
|
|
3613
4217
|
}
|
|
3614
4218
|
|
|
@@ -3616,7 +4220,7 @@ async function runMcpServer(deps = {}) {
|
|
|
3616
4220
|
import { stat as stat6 } from "node:fs/promises";
|
|
3617
4221
|
import { resolve as resolve4 } from "node:path";
|
|
3618
4222
|
var PASS_THROUGH = /* @__PURE__ */ new Set(["-h", "--help", "-V", "--version"]);
|
|
3619
|
-
var pathExists = (
|
|
4223
|
+
var pathExists = (path3) => stat6(resolve4(path3)).then(
|
|
3620
4224
|
() => true,
|
|
3621
4225
|
() => false
|
|
3622
4226
|
);
|
|
@@ -3662,32 +4266,30 @@ function routeOptionsFor(program2) {
|
|
|
3662
4266
|
|
|
3663
4267
|
// src/cli.ts
|
|
3664
4268
|
var program = new Command();
|
|
3665
|
-
program.name(BIN_NAME).description("Fling a folder of static files at Fling and get a link back").version(VERSION).option(
|
|
3666
|
-
"--server <url>",
|
|
3667
|
-
`server URL (default: $${ENV.url} or config, else ${DEFAULT_SERVER_URL})`
|
|
3668
|
-
).option("--json", "print JSON instead of human output", false).showHelpAfterError().configureHelp({
|
|
4269
|
+
program.name(BIN_NAME).description("Fling a folder of static files at Fling and get a link back").version(VERSION).option("--server <url>", `server URL (default: $${ENV.url}, else the one you logged in to)`).option("--json", "print JSON instead of human output", false).showHelpAfterError().configureHelp({
|
|
3669
4270
|
sortSubcommands: false,
|
|
3670
|
-
styleTitle: (title) =>
|
|
3671
|
-
styleCommandText: (text2) =>
|
|
4271
|
+
styleTitle: (title) => chalk10.bold(brand.primary(title)),
|
|
4272
|
+
styleCommandText: (text2) => chalk10.bold(text2),
|
|
3672
4273
|
styleSubcommandText: (text2) => brand.accent(text2),
|
|
3673
4274
|
styleOptionText: (text2) => brand.accent(text2),
|
|
3674
|
-
styleArgumentText: (text2) =>
|
|
4275
|
+
styleArgumentText: (text2) => chalk10.dim(text2)
|
|
3675
4276
|
}).addHelpText(
|
|
3676
4277
|
"before",
|
|
3677
4278
|
() => process.stdout.isTTY ? logo({ columns: process.stdout.columns }) : ""
|
|
3678
4279
|
).addHelpText(
|
|
3679
4280
|
"after",
|
|
3680
4281
|
() => `
|
|
3681
|
-
${
|
|
4282
|
+
${chalk10.bold(brand.primary("Examples:"))}
|
|
4283
|
+
$ ${BIN_NAME} login https://fling.example.com sign in to your Fling (once; there is no default)
|
|
3682
4284
|
$ ${BIN_NAME} setup sign in and add Fling to Claude Code / Claude Desktop
|
|
3683
|
-
$ ${BIN_NAME} deploy ./dist --name my-app \u2192 https://my-app
|
|
4285
|
+
$ ${BIN_NAME} deploy ./dist --name my-app \u2192 https://my-app.<your sites domain>
|
|
3684
4286
|
$ ${BIN_NAME} ./dist the same: an existing path deploys
|
|
3685
4287
|
$ ${BIN_NAME} ./dist --yes no prompts (CI): use the generated name, fail if it is taken
|
|
3686
4288
|
$ ${BIN_NAME} "Pitch Deck.html" a single page
|
|
3687
4289
|
$ ${BIN_NAME} site.zip a zip of a site
|
|
3688
|
-
$ ${BIN_NAME} share my-app public link for people
|
|
4290
|
+
$ ${BIN_NAME} share my-app public link for people who can't sign in
|
|
3689
4291
|
|
|
3690
|
-
${
|
|
4292
|
+
${chalk10.dim(`Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
|
|
3691
4293
|
Env: ${ENV.apiKey} \xB7 ${ENV.url} \xB7 ${ENV.configDir} \xB7 ${ENV.debug}`)}`
|
|
3692
4294
|
);
|
|
3693
4295
|
function action(handler) {
|
|
@@ -3731,20 +4333,26 @@ program.command("deploy [path]").helpGroup("Deploy:").description(
|
|
|
3731
4333
|
).option(
|
|
3732
4334
|
"-n, --name <name>",
|
|
3733
4335
|
"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)"
|
|
3734
|
-
).option("--spa", "single-page app: serve index.html for unknown paths").option("--public", "make the site public (default:
|
|
4336
|
+
).option("--spa", "single-page app: serve index.html for unknown paths").option("--public", "make the site public (default: sign-in required)").option(
|
|
3735
4337
|
"-y, --yes",
|
|
3736
4338
|
"never prompt: accept the generated name; without index.html, list the HTML pages (or fling anyway if there are none)"
|
|
3737
4339
|
).option("--list-html", "no index.html at the root: generate one listing the HTML pages").option("--no-list-html", "no index.html at the root: fling anyway (the root will 404)").action(
|
|
3738
4340
|
action(
|
|
3739
|
-
(ctx,
|
|
4341
|
+
(ctx, path3, opts) => runDeploy(ctx, path3, opts)
|
|
3740
4342
|
)
|
|
3741
4343
|
);
|
|
3742
|
-
program.command("setup").helpGroup("Setup:").description(
|
|
3743
|
-
"sign in, then add the Fling plugin to Claude Code and the extension to Claude Desktop"
|
|
3744
|
-
).option("-y, --yes", "do every step without asking").option("-s, --scope <scope>", "Claude Code plugin scope: user, project or local", "user").action(
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
4344
|
+
program.command("setup [url]").helpGroup("Setup:").description(
|
|
4345
|
+
"sign in (to the Fling at url, the first time), then add the Fling plugin to Claude Code and the extension to Claude Desktop"
|
|
4346
|
+
).option("-y, --yes", "do every step without asking").option("-s, --scope <scope>", "Claude Code plugin scope: user, project or local", "user").action(
|
|
4347
|
+
action(
|
|
4348
|
+
(ctx, url, opts) => runSetup(ctx, { ...opts, url })
|
|
4349
|
+
)
|
|
4350
|
+
);
|
|
4351
|
+
program.command("login [url]").helpGroup("Setup:").description(
|
|
4352
|
+
"sign in through the browser to the Fling at url (remembered; later logins can omit it) and store an API key"
|
|
4353
|
+
).action(
|
|
4354
|
+
action(async (ctx, url) => {
|
|
4355
|
+
await runLogin(ctx, url);
|
|
3748
4356
|
ctx.log.hint(`Next: \`${BIN_NAME} setup\` adds Fling to Claude Code and Claude Desktop.`);
|
|
3749
4357
|
})
|
|
3750
4358
|
);
|
|
@@ -3763,10 +4371,30 @@ program.command("share <name>").helpGroup("Sites:").description("print (or creat
|
|
|
3763
4371
|
(ctx, name, opts) => runShare(ctx, name, opts)
|
|
3764
4372
|
)
|
|
3765
4373
|
);
|
|
3766
|
-
program.command("visibility <name> <visibility>").helpGroup("Sites:").description("private (
|
|
4374
|
+
program.command("visibility <name> <visibility>").helpGroup("Sites:").description("private (sign-in required) or public (anyone)").action(action((ctx, name, visibility) => runVisibility(ctx, name, visibility)));
|
|
3767
4375
|
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)));
|
|
3768
4376
|
program.command("unarchive <name>").helpGroup("Sites:").description("bring an archived site back").action(action((ctx, name) => runArchive(ctx, name, true)));
|
|
3769
4377
|
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)));
|
|
4378
|
+
program.command("comments <name>").helpGroup("Comments:").description("list a site's open comment threads (people leave them on its review page)").option("-p, --path <path>", "only this page, e.g. / or /docs/").option("-a, --all", "include resolved threads").action(
|
|
4379
|
+
action(
|
|
4380
|
+
(ctx, name, opts) => runComments(ctx, name, opts)
|
|
4381
|
+
)
|
|
4382
|
+
);
|
|
4383
|
+
program.command("comment <name> <text>").helpGroup("Comments:").description("comment on a page of a site (default: its home page), or reply to a thread").option("-p, --path <path>", "the page, e.g. /docs/ or about.html").option("-r, --reply <id>", "reply to this thread").action(
|
|
4384
|
+
action(
|
|
4385
|
+
(ctx, name, text2, opts) => runComment(ctx, name, text2, opts)
|
|
4386
|
+
)
|
|
4387
|
+
);
|
|
4388
|
+
program.command("resolve <name> <id>").helpGroup("Comments:").description("mark a comment thread resolved").option("--reopen", "reopen it instead").action(
|
|
4389
|
+
action(
|
|
4390
|
+
(ctx, name, id, opts) => runResolve(ctx, name, id, opts)
|
|
4391
|
+
)
|
|
4392
|
+
);
|
|
4393
|
+
program.command("uncomment <name> <id>").helpGroup("Comments:").alias("delete-comment").description("delete a comment (a thread goes with its replies; asks first unless --yes)").option("-y, --yes", "do not ask for confirmation").action(
|
|
4394
|
+
action(
|
|
4395
|
+
(ctx, name, id, opts) => runUncomment(ctx, name, id, opts)
|
|
4396
|
+
)
|
|
4397
|
+
);
|
|
3770
4398
|
program.command("mcp").helpGroup("Agents:").description("run the MCP server on stdio (used by the Claude Code plugin)").action(async (_opts, command) => {
|
|
3771
4399
|
const globals = command.optsWithGlobals();
|
|
3772
4400
|
await runMcpServer({ server: globals.server });
|