@nearform/fling 0.2.0 → 0.4.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 +49 -0
- package/LICENSE +1 -1
- package/README.md +73 -26
- package/dist/cli.js +1271 -452
- 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.4.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,14 +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
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1313
|
+
var serverEntrySchema = z9.object({
|
|
1314
|
+
/** What the server said about itself at login. A copy this CLI can't read is just refetched. */
|
|
1315
|
+
instance: InstanceMetadataSchema.optional().catch(void 0),
|
|
1316
|
+
apiKey: z9.string().min(1).optional().catch(void 0),
|
|
1317
|
+
user: z9.object({
|
|
1318
|
+
email: z9.string().optional(),
|
|
1319
|
+
name: z9.string().optional(),
|
|
1320
|
+
userSlug: z9.string().optional()
|
|
1321
|
+
}).optional().catch(void 0)
|
|
963
1322
|
});
|
|
964
1323
|
function systemLocateOptions(env = process.env) {
|
|
965
1324
|
return { cwd: process.cwd(), home: homedir(), platform: process.platform, env };
|
|
@@ -971,6 +1330,37 @@ function fromEnv(value) {
|
|
|
971
1330
|
function resolveConfigDir(env = process.env) {
|
|
972
1331
|
return env[ENV.configDir] ?? join(env.HOME ?? homedir(), CONFIG_DIR_NAME);
|
|
973
1332
|
}
|
|
1333
|
+
function safeNormalize(input) {
|
|
1334
|
+
if (typeof input !== "string") return void 0;
|
|
1335
|
+
try {
|
|
1336
|
+
return normalizeServerUrl(input);
|
|
1337
|
+
} catch {
|
|
1338
|
+
return void 0;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
function isObject(value) {
|
|
1342
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1343
|
+
}
|
|
1344
|
+
function parseConfig(json) {
|
|
1345
|
+
if (!isObject(json)) throw new Error("expected a JSON object");
|
|
1346
|
+
const servers = {};
|
|
1347
|
+
let current;
|
|
1348
|
+
if ("servers" in json) {
|
|
1349
|
+
if (isObject(json.servers)) {
|
|
1350
|
+
for (const [key, value] of Object.entries(json.servers)) {
|
|
1351
|
+
const url = safeNormalize(key);
|
|
1352
|
+
const entry = isObject(value) ? serverEntrySchema.safeParse(value) : void 0;
|
|
1353
|
+
if (url && entry?.success) servers[url] = { ...servers[url], ...entry.data };
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
current = safeNormalize(json.current);
|
|
1357
|
+
} else {
|
|
1358
|
+
current = safeNormalize(json.serverUrl);
|
|
1359
|
+
if (current) servers[current] = serverEntrySchema.parse(json);
|
|
1360
|
+
}
|
|
1361
|
+
if (current && !servers[current]) servers[current] = {};
|
|
1362
|
+
return current ? { current, servers } : { servers };
|
|
1363
|
+
}
|
|
974
1364
|
function createConfigStore(options = {}) {
|
|
975
1365
|
const env = options.env ?? process.env;
|
|
976
1366
|
const dir = options.dir ?? resolveConfigDir(env);
|
|
@@ -980,7 +1370,7 @@ function createConfigStore(options = {}) {
|
|
|
980
1370
|
try {
|
|
981
1371
|
raw = await readFile(file, "utf8");
|
|
982
1372
|
} catch (error) {
|
|
983
|
-
if (error.code === "ENOENT") return {};
|
|
1373
|
+
if (error.code === "ENOENT") return { servers: {} };
|
|
984
1374
|
throw error;
|
|
985
1375
|
}
|
|
986
1376
|
let json;
|
|
@@ -989,57 +1379,160 @@ function createConfigStore(options = {}) {
|
|
|
989
1379
|
} catch {
|
|
990
1380
|
throw new Error(`Config file ${file} is not valid JSON`);
|
|
991
1381
|
}
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1382
|
+
try {
|
|
1383
|
+
return parseConfig(json);
|
|
1384
|
+
} catch (error) {
|
|
1385
|
+
throw new Error(`Config file ${file} is invalid: ${error.message}`);
|
|
995
1386
|
}
|
|
996
|
-
return parsed.data;
|
|
997
1387
|
}
|
|
998
1388
|
async function save(config) {
|
|
999
1389
|
await mkdir(dir, { recursive: true, mode: 448 });
|
|
1000
1390
|
await chmod(dir, 448).catch(() => {
|
|
1001
1391
|
});
|
|
1002
|
-
|
|
1392
|
+
const body = { current: config.current, servers: config.servers };
|
|
1393
|
+
await writeFile(file, `${JSON.stringify(body, null, 2)}
|
|
1003
1394
|
`, { mode: 384 });
|
|
1004
1395
|
await chmod(file, 384);
|
|
1005
1396
|
}
|
|
1397
|
+
async function edit(change) {
|
|
1398
|
+
const config = await load();
|
|
1399
|
+
change(config);
|
|
1400
|
+
await save(config);
|
|
1401
|
+
return config;
|
|
1402
|
+
}
|
|
1006
1403
|
return {
|
|
1007
1404
|
dir,
|
|
1008
1405
|
file,
|
|
1009
1406
|
load,
|
|
1010
1407
|
save,
|
|
1011
|
-
|
|
1012
|
-
const
|
|
1013
|
-
|
|
1014
|
-
|
|
1408
|
+
putServer(serverUrl, patch, opts = {}) {
|
|
1409
|
+
const url = normalizeServerUrl(serverUrl);
|
|
1410
|
+
return edit((config) => {
|
|
1411
|
+
config.servers[url] = { ...config.servers[url], ...patch };
|
|
1412
|
+
if (opts.makeCurrent ?? !config.current) config.current = url;
|
|
1413
|
+
});
|
|
1015
1414
|
},
|
|
1016
|
-
async
|
|
1017
|
-
const
|
|
1018
|
-
await
|
|
1415
|
+
async setCurrent(serverUrl) {
|
|
1416
|
+
const url = normalizeServerUrl(serverUrl);
|
|
1417
|
+
await edit((config) => {
|
|
1418
|
+
config.servers[url] ??= {};
|
|
1419
|
+
config.current = url;
|
|
1420
|
+
});
|
|
1421
|
+
},
|
|
1422
|
+
async clearCredentials(serverUrl) {
|
|
1423
|
+
const url = normalizeServerUrl(serverUrl);
|
|
1424
|
+
await edit((config) => {
|
|
1425
|
+
const entry = config.servers[url];
|
|
1426
|
+
if (entry) config.servers[url] = entry.instance ? { instance: entry.instance } : {};
|
|
1427
|
+
});
|
|
1428
|
+
},
|
|
1429
|
+
async removeServer(serverUrl) {
|
|
1430
|
+
const url = normalizeServerUrl(serverUrl);
|
|
1431
|
+
await edit((config) => {
|
|
1432
|
+
delete config.servers[url];
|
|
1433
|
+
if (config.current === url) config.current = void 0;
|
|
1434
|
+
});
|
|
1019
1435
|
},
|
|
1020
1436
|
async resolve(overrides = {}) {
|
|
1021
1437
|
const config = await load();
|
|
1022
1438
|
const envUrl = fromEnv(env[ENV.url]);
|
|
1023
1439
|
const envKey = fromEnv(env[ENV.apiKey]);
|
|
1024
|
-
const [
|
|
1025
|
-
const
|
|
1440
|
+
const [rawUrl, serverUrlSource] = overrides.serverUrl ? [overrides.serverUrl, "flag"] : envUrl ? [envUrl, "env"] : config.current ? [config.current, "config"] : [void 0, "none"];
|
|
1441
|
+
const serverUrl = rawUrl === void 0 ? void 0 : matchServer(Object.keys(config.servers), rawUrl, { loose: true }).url;
|
|
1442
|
+
const entry = serverUrl === void 0 ? void 0 : config.servers[serverUrl];
|
|
1443
|
+
const [apiKey, apiKeySource] = envKey ? [envKey, "env"] : entry?.apiKey ? [entry.apiKey, "config"] : [void 0, "none"];
|
|
1026
1444
|
return {
|
|
1027
|
-
serverUrl
|
|
1445
|
+
serverUrl,
|
|
1028
1446
|
serverUrlSource,
|
|
1447
|
+
current: config.current,
|
|
1448
|
+
remembered: entry !== void 0,
|
|
1449
|
+
instance: entry?.instance,
|
|
1029
1450
|
apiKey,
|
|
1030
1451
|
apiKeySource,
|
|
1031
|
-
user:
|
|
1452
|
+
user: entry?.user,
|
|
1032
1453
|
debug: Boolean(env[ENV.debug])
|
|
1033
1454
|
};
|
|
1034
1455
|
}
|
|
1035
1456
|
};
|
|
1036
1457
|
}
|
|
1458
|
+
var HAS_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
1459
|
+
function matchServer(remembered, input, options = {}) {
|
|
1460
|
+
const trimmed = input.trim();
|
|
1461
|
+
const normalized = safeNormalize(trimmed);
|
|
1462
|
+
if (normalized && remembered.includes(normalized)) return { url: normalized, remembered: true };
|
|
1463
|
+
if (!HAS_SCHEME.test(trimmed)) {
|
|
1464
|
+
const bare = trimmed.replace(/\/+$/, "").toLowerCase();
|
|
1465
|
+
const parts = remembered.map((url) => {
|
|
1466
|
+
const u = new URL(url);
|
|
1467
|
+
return { url, host: u.host, hostname: u.hostname, address: `${u.host}${u.pathname}` };
|
|
1468
|
+
});
|
|
1469
|
+
const tiers = [
|
|
1470
|
+
parts.filter((p) => p.host === bare || p.address === bare),
|
|
1471
|
+
options.loose && bare ? parts.filter((p) => p.address.startsWith(bare) || p.hostname.endsWith(`.${bare}`)) : []
|
|
1472
|
+
];
|
|
1473
|
+
for (const found of tiers) {
|
|
1474
|
+
const [only] = found;
|
|
1475
|
+
if (only && found.length === 1) return { url: only.url, remembered: true };
|
|
1476
|
+
if (found.length > 1)
|
|
1477
|
+
throw new CliError(`"${input}" matches more than one server you have signed in to`, {
|
|
1478
|
+
hint: `Use the full address: ${found.map((p) => p.url).join(", ")}`
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
if (!normalized)
|
|
1483
|
+
throw new CliError(`"${input}" is not a server address`, {
|
|
1484
|
+
hint: "Use your Fling's address, e.g. https://fling.example.com."
|
|
1485
|
+
});
|
|
1486
|
+
return { url: normalized, remembered: false };
|
|
1487
|
+
}
|
|
1488
|
+
function normalizeServerUrl(input) {
|
|
1489
|
+
const trimmed = input.trim();
|
|
1490
|
+
const withScheme = HAS_SCHEME.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
1491
|
+
const url = new URL(withScheme);
|
|
1492
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
1493
|
+
throw new Error(`Not an http(s) URL: ${input}`);
|
|
1494
|
+
}
|
|
1495
|
+
return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
|
|
1496
|
+
}
|
|
1037
1497
|
var REDACTED_KEY_CHARS = 10;
|
|
1038
1498
|
function redactKey(key) {
|
|
1039
1499
|
if (!key) return "-";
|
|
1040
1500
|
return key.length <= REDACTED_KEY_CHARS + 8 ? "****" : `${key.slice(0, REDACTED_KEY_CHARS)}\u2026`;
|
|
1041
1501
|
}
|
|
1042
1502
|
|
|
1503
|
+
// src/instance.ts
|
|
1504
|
+
async function fetchInstance(serverUrl, fetch2) {
|
|
1505
|
+
const url = instanceMetadataUrl(serverUrl);
|
|
1506
|
+
let response;
|
|
1507
|
+
try {
|
|
1508
|
+
response = await fetch2(url, { headers: { Accept: "application/json" } });
|
|
1509
|
+
} catch (cause) {
|
|
1510
|
+
throw new CliError(`Could not reach ${serverUrl} (${cause.message ?? cause})`, {
|
|
1511
|
+
hint: "Check the address and your connection.",
|
|
1512
|
+
cause
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
const notFling = () => new CliError(`${serverUrl} does not look like a Fling server (no valid ${url})`, {
|
|
1516
|
+
hint: "Use the address of your Fling, e.g. https://fling.example.com."
|
|
1517
|
+
});
|
|
1518
|
+
if (!response.ok) throw notFling();
|
|
1519
|
+
const parsed = InstanceMetadataSchema.safeParse(await response.json().catch(() => null));
|
|
1520
|
+
if (!parsed.success) throw notFling();
|
|
1521
|
+
return parsed.data;
|
|
1522
|
+
}
|
|
1523
|
+
async function loadInstance(store, config, fetch2) {
|
|
1524
|
+
if (config.instance) return config.instance;
|
|
1525
|
+
if (!config.serverUrl) return null;
|
|
1526
|
+
try {
|
|
1527
|
+
const instance = await fetchInstance(config.serverUrl, fetch2);
|
|
1528
|
+
if (config.remembered)
|
|
1529
|
+
await store.putServer(config.serverUrl, { instance }, { makeCurrent: false });
|
|
1530
|
+
return instance;
|
|
1531
|
+
} catch {
|
|
1532
|
+
return null;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1043
1536
|
// src/auth.ts
|
|
1044
1537
|
var CALLBACK_PORT_START = 8765;
|
|
1045
1538
|
var CALLBACK_PORT_END = 8770;
|
|
@@ -1048,12 +1541,13 @@ var CALLBACK_PATH = "/callback";
|
|
|
1048
1541
|
var AUTH_CLI_PATH = "/auth/cli";
|
|
1049
1542
|
async function loginFlow(options) {
|
|
1050
1543
|
const { serverUrl, store, log } = options;
|
|
1544
|
+
const instance = await fetchInstance(serverUrl, options.fetch ?? fetch);
|
|
1051
1545
|
const state = randomBytes(16).toString("hex");
|
|
1052
1546
|
const callback = await startCallbackServer({ state, timeoutMs: options.timeoutMs });
|
|
1053
1547
|
try {
|
|
1054
1548
|
const authUrl = buildAuthUrl(serverUrl, callback.url, options.hostname ?? osHostname(), state);
|
|
1055
1549
|
options.onAuthUrl?.(authUrl);
|
|
1056
|
-
log.info(`Opening your browser to sign in at ${serverUrl}`);
|
|
1550
|
+
log.info(`Opening your browser to sign in to ${instance.name} at ${serverUrl}`);
|
|
1057
1551
|
log.hint(`If it does not open, visit: ${authUrl}`);
|
|
1058
1552
|
try {
|
|
1059
1553
|
await options.open(authUrl);
|
|
@@ -1064,16 +1558,20 @@ async function loginFlow(options) {
|
|
|
1064
1558
|
const { key } = await callback.result;
|
|
1065
1559
|
const client = createApiClient({ serverUrl, apiKey: key, fetch: options.fetch });
|
|
1066
1560
|
const user = await client.request("GET", "/api/me", { schema: MeSchema });
|
|
1067
|
-
await store.
|
|
1561
|
+
await store.putServer(
|
|
1068
1562
|
serverUrl,
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1563
|
+
{
|
|
1564
|
+
instance,
|
|
1565
|
+
apiKey: key,
|
|
1566
|
+
user: { email: user.email, name: user.name, userSlug: user.userSlug }
|
|
1567
|
+
},
|
|
1568
|
+
{ makeCurrent: options.makeCurrent ?? true }
|
|
1569
|
+
);
|
|
1072
1570
|
const keyPrefix = redactKey(key);
|
|
1073
1571
|
log.success(`Signed in as ${user.email}`);
|
|
1074
1572
|
log.hint(`Server: ${serverUrl}`);
|
|
1075
1573
|
log.hint(`API key: ${keyPrefix} (stored in ${store.file})`);
|
|
1076
|
-
return { serverUrl, keyPrefix, user };
|
|
1574
|
+
return { serverUrl, keyPrefix, user, instance };
|
|
1077
1575
|
} finally {
|
|
1078
1576
|
callback.close();
|
|
1079
1577
|
}
|
|
@@ -1419,18 +1917,23 @@ async function ttyPrompt(question) {
|
|
|
1419
1917
|
function ttyProgress(line) {
|
|
1420
1918
|
process.stderr.write(`\r\x1B[2K${line ?? ""}`);
|
|
1421
1919
|
}
|
|
1920
|
+
function requireServer(ctx) {
|
|
1921
|
+
if (!ctx.config.serverUrl) throw new NoServerError(ctx.binName);
|
|
1922
|
+
return ctx.config.serverUrl;
|
|
1923
|
+
}
|
|
1422
1924
|
function requireClient(ctx) {
|
|
1925
|
+
const serverUrl = requireServer(ctx);
|
|
1423
1926
|
if (!ctx.config.apiKey) throw new NotLoggedInError(ctx.binName);
|
|
1424
1927
|
return createApiClient({
|
|
1425
|
-
serverUrl
|
|
1928
|
+
serverUrl,
|
|
1426
1929
|
apiKey: ctx.config.apiKey,
|
|
1427
1930
|
fetch: ctx.fetch
|
|
1428
1931
|
});
|
|
1429
1932
|
}
|
|
1430
1933
|
|
|
1431
1934
|
// src/sites.ts
|
|
1432
|
-
import { z as
|
|
1433
|
-
var ShareResponseSchema2 =
|
|
1935
|
+
import { z as z10 } from "zod";
|
|
1936
|
+
var ShareResponseSchema2 = z10.looseObject({ shareUrl: z10.string() });
|
|
1434
1937
|
var sitePath2 = (name) => `/api/sites/${encodeURIComponent(name)}`;
|
|
1435
1938
|
function whoami(client) {
|
|
1436
1939
|
return client.request("GET", "/api/me", { schema: MeSchema });
|
|
@@ -1440,32 +1943,8 @@ function checkName(client, name) {
|
|
|
1440
1943
|
schema: NameCheckSchema
|
|
1441
1944
|
});
|
|
1442
1945
|
}
|
|
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;
|
|
1946
|
+
function sitesBaseOf(instance) {
|
|
1947
|
+
return instance ? { protocol: `${instance.siteScheme}:`, domain: instance.sitesDomain } : null;
|
|
1469
1948
|
}
|
|
1470
1949
|
function siteUrlFor(name, base) {
|
|
1471
1950
|
return siteUrl(name, base.domain, base.protocol);
|
|
@@ -1535,52 +2014,247 @@ async function share(client, name, opts = {}) {
|
|
|
1535
2014
|
});
|
|
1536
2015
|
return { name, action: hadLink ? "refreshed" : "created", shareUrl: res.shareUrl };
|
|
1537
2016
|
}
|
|
2017
|
+
function listComments(client, name, opts = {}) {
|
|
2018
|
+
return client.request("GET", `${sitePath2(name)}/comments`, {
|
|
2019
|
+
schema: CommentListSchema,
|
|
2020
|
+
query: { path: opts.path }
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
function addComment(client, name, input) {
|
|
2024
|
+
return client.request("POST", `${sitePath2(name)}/comments`, {
|
|
2025
|
+
schema: CommentSchema,
|
|
2026
|
+
body: input
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
function resolveComment(client, name, id, resolved) {
|
|
2030
|
+
return client.request("PATCH", `${sitePath2(name)}/comments/${encodeURIComponent(id)}`, {
|
|
2031
|
+
schema: CommentSchema,
|
|
2032
|
+
body: { resolved }
|
|
2033
|
+
});
|
|
2034
|
+
}
|
|
2035
|
+
async function deleteComment(client, name, id) {
|
|
2036
|
+
await client.request("DELETE", `${sitePath2(name)}/comments/${encodeURIComponent(id)}`);
|
|
2037
|
+
return { name, id, deleted: true };
|
|
2038
|
+
}
|
|
2039
|
+
function commentsEnabled(instance) {
|
|
2040
|
+
if (!instance) return false;
|
|
2041
|
+
return instance.capabilities.comments ?? true;
|
|
2042
|
+
}
|
|
1538
2043
|
|
|
1539
2044
|
// src/commands/auth.ts
|
|
1540
|
-
async function
|
|
2045
|
+
async function loginServer(ctx, url) {
|
|
2046
|
+
if (url === void 0) return requireServer(ctx);
|
|
2047
|
+
const { servers } = await ctx.store.load();
|
|
2048
|
+
return matchServer(Object.keys(servers), url).url;
|
|
2049
|
+
}
|
|
2050
|
+
async function runLogin(ctx, url) {
|
|
1541
2051
|
const result = await loginFlow({
|
|
1542
|
-
serverUrl: ctx
|
|
2052
|
+
serverUrl: await loginServer(ctx, url),
|
|
1543
2053
|
store: ctx.store,
|
|
1544
2054
|
log: ctx.log,
|
|
1545
2055
|
open: ctx.open,
|
|
1546
|
-
fetch: ctx.fetch
|
|
2056
|
+
fetch: ctx.fetch,
|
|
2057
|
+
makeCurrent: true
|
|
1547
2058
|
});
|
|
1548
2059
|
if (ctx.json) ctx.out.data(result, () => "");
|
|
1549
2060
|
}
|
|
1550
|
-
async function
|
|
2061
|
+
async function runUse(ctx, url) {
|
|
1551
2062
|
const config = await ctx.store.load();
|
|
1552
|
-
|
|
1553
|
-
|
|
2063
|
+
const urls = Object.keys(config.servers);
|
|
2064
|
+
const overridden = ctx.config.serverUrlSource === "env" || ctx.config.serverUrlSource === "flag" ? ctx.config.serverUrl : void 0;
|
|
2065
|
+
const overrideHint = () => {
|
|
2066
|
+
if (overridden)
|
|
2067
|
+
ctx.log.hint(
|
|
2068
|
+
`${ctx.config.serverUrlSource === "env" ? "FLING_URL" : "--server"} is set: commands run with it use ${overridden}.`
|
|
2069
|
+
);
|
|
2070
|
+
};
|
|
2071
|
+
if (url === void 0) {
|
|
2072
|
+
const servers = urls.map((server) => {
|
|
2073
|
+
const entry2 = config.servers[server] ?? {};
|
|
2074
|
+
return {
|
|
2075
|
+
url: server,
|
|
2076
|
+
current: server === config.current,
|
|
2077
|
+
instance: entry2.instance?.name ?? null,
|
|
2078
|
+
email: entry2.apiKey ? entry2.user?.email ?? null : null,
|
|
2079
|
+
signedIn: Boolean(entry2.apiKey)
|
|
2080
|
+
};
|
|
2081
|
+
});
|
|
2082
|
+
if (servers.length === 0 && !ctx.json) {
|
|
2083
|
+
ctx.log.info("No Fling servers yet");
|
|
2084
|
+
ctx.log.hint(`Run \`${ctx.binName} login <url>\` with your Fling's address.`);
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
const width = Math.max(...servers.map((s) => s.url.length));
|
|
2088
|
+
ctx.out.data(
|
|
2089
|
+
{ current: config.current ?? null, servers },
|
|
2090
|
+
() => servers.map(
|
|
2091
|
+
(s) => [
|
|
2092
|
+
s.current ? chalk5.bold("*") : " ",
|
|
2093
|
+
s.current ? chalk5.bold(s.url.padEnd(width)) : s.url.padEnd(width),
|
|
2094
|
+
s.instance ?? chalk5.dim("(unknown)"),
|
|
2095
|
+
s.signedIn ? s.email ?? chalk5.dim("(signed in)") : chalk5.dim("(signed out)")
|
|
2096
|
+
].join(" ")
|
|
2097
|
+
).join("\n")
|
|
2098
|
+
);
|
|
2099
|
+
if (!ctx.json) {
|
|
2100
|
+
ctx.log.hint(`* = current. Switch with \`${ctx.binName} use <url>\`.`);
|
|
2101
|
+
overrideHint();
|
|
2102
|
+
}
|
|
1554
2103
|
return;
|
|
1555
2104
|
}
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
2105
|
+
const match = matchServer(urls, url, { loose: true });
|
|
2106
|
+
if (!match.remembered)
|
|
2107
|
+
throw new CliError(`Not signed in to ${match.url} on this computer`, {
|
|
2108
|
+
hint: `Run \`${ctx.binName} login ${match.url}\` to add it, or \`${ctx.binName} use\` to list your servers.`
|
|
2109
|
+
});
|
|
2110
|
+
await ctx.store.setCurrent(match.url);
|
|
2111
|
+
const entry = config.servers[match.url] ?? {};
|
|
2112
|
+
const name = entry.instance?.name;
|
|
2113
|
+
const email = entry.apiKey ? entry.user?.email : void 0;
|
|
2114
|
+
ctx.out.data(
|
|
2115
|
+
{ current: match.url, instance: name ?? null, email: email ?? null },
|
|
2116
|
+
() => `Now using ${chalk5.bold(match.url)}${name ? chalk5.dim(` (${name})`) : ""}`
|
|
2117
|
+
);
|
|
2118
|
+
if (!ctx.json) {
|
|
2119
|
+
if (entry.apiKey) ctx.log.hint(`Signed in as ${email ?? "an unknown user"}.`);
|
|
2120
|
+
else ctx.log.hint(`Signed out there: run \`${ctx.binName} login\` to sign in.`);
|
|
2121
|
+
overrideHint();
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
async function runLogout(ctx, url) {
|
|
2125
|
+
const config = await ctx.store.load();
|
|
2126
|
+
const target = url === void 0 ? ctx.config.serverUrl : matchServer(Object.keys(config.servers), url, { loose: true }).url;
|
|
2127
|
+
const entry = target === void 0 ? void 0 : config.servers[target];
|
|
2128
|
+
if (target === void 0 || entry === void 0) {
|
|
2129
|
+
ctx.log.info(`Not logged in${target ? ` to ${target}` : ""} \u2014 nothing to do`);
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
if (target === config.current) {
|
|
2133
|
+
if (!entry.apiKey) {
|
|
2134
|
+
ctx.log.info("Not logged in \u2014 nothing to do");
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
await ctx.store.clearCredentials(target);
|
|
2138
|
+
ctx.log.success("Logged out; API key removed from this machine");
|
|
2139
|
+
} else {
|
|
2140
|
+
await ctx.store.removeServer(target);
|
|
2141
|
+
ctx.log.success(
|
|
2142
|
+
entry.apiKey ? `Logged out of ${target}; API key and server removed from this machine` : `Removed ${target} from this machine`
|
|
2143
|
+
);
|
|
2144
|
+
}
|
|
2145
|
+
if (entry.apiKey) ctx.log.hint(`Revoke it on the server under Connections at ${target}`);
|
|
1559
2146
|
}
|
|
1560
2147
|
async function runWhoami(ctx) {
|
|
1561
2148
|
const client = requireClient(ctx);
|
|
1562
2149
|
const me = await whoami(client);
|
|
1563
|
-
const
|
|
2150
|
+
const instance = await loadInstance(ctx.store, ctx.config, ctx.fetch);
|
|
2151
|
+
const { serverUrl, current } = ctx.config;
|
|
2152
|
+
const others = Object.keys((await ctx.store.load()).servers).filter((s) => s !== serverUrl);
|
|
1564
2153
|
ctx.out.data(
|
|
1565
2154
|
{
|
|
1566
2155
|
...me,
|
|
1567
|
-
serverUrl
|
|
2156
|
+
serverUrl,
|
|
2157
|
+
serverUrlSource: ctx.config.serverUrlSource,
|
|
2158
|
+
current: current ?? null,
|
|
2159
|
+
instance: instance?.name ?? null,
|
|
1568
2160
|
apiKey: redactKey(ctx.config.apiKey),
|
|
1569
2161
|
apiKeySource: ctx.config.apiKeySource
|
|
1570
2162
|
},
|
|
1571
2163
|
() => [
|
|
1572
2164
|
`${chalk5.bold("User:")} ${me.name} <${me.email}>`,
|
|
1573
|
-
`${chalk5.bold("Sites:")} ${sitesPattern(
|
|
1574
|
-
`${chalk5.bold("Server:")} ${
|
|
1575
|
-
|
|
2165
|
+
`${chalk5.bold("Sites:")} ${sitesPattern(sitesBaseOf(instance))} ${chalk5.dim("(names are first come, first served)")}`,
|
|
2166
|
+
`${chalk5.bold("Server:")} ${serverUrl}${instance ? chalk5.dim(` (${instance.name})`) : ""} ${chalk5.dim(
|
|
2167
|
+
serverUrl === current ? "(current)" : `(from ${ctx.config.serverUrlSource === "env" ? "FLING_URL" : "--server"}; current: ${current ?? "none"})`
|
|
2168
|
+
)}`,
|
|
2169
|
+
`${chalk5.bold("API key:")} ${redactKey(ctx.config.apiKey)} ${chalk5.dim(`(from ${ctx.config.apiKeySource})`)}`,
|
|
2170
|
+
...others.length > 0 ? [
|
|
2171
|
+
chalk5.dim(
|
|
2172
|
+
`${others.length} other server${others.length === 1 ? "" : "s"} on this computer: \`${ctx.binName} use\` lists them`
|
|
2173
|
+
)
|
|
2174
|
+
] : []
|
|
1576
2175
|
].join("\n")
|
|
1577
2176
|
);
|
|
1578
2177
|
}
|
|
1579
2178
|
|
|
2179
|
+
// src/commands/comments.ts
|
|
2180
|
+
import chalk6 from "chalk";
|
|
2181
|
+
function commentsClient(ctx) {
|
|
2182
|
+
const instance = ctx.config.instance;
|
|
2183
|
+
if (instance && !commentsEnabled(instance)) {
|
|
2184
|
+
throw new CliError(`${instance.name} has comments turned off`);
|
|
2185
|
+
}
|
|
2186
|
+
return requireClient(ctx);
|
|
2187
|
+
}
|
|
2188
|
+
var reviewPage = (ctx, name, path3) => reviewUrl(ctx.config.instance?.appOrigin ?? ctx.config.serverUrl ?? "", name, path3);
|
|
2189
|
+
function describe(c, currentVersionId) {
|
|
2190
|
+
const version = c.versionId === null ? "" : c.versionId === currentVersionId ? "" : chalk6.dim(` (on version ${c.versionId})`);
|
|
2191
|
+
return `${chalk6.bold(authorLabel(c.author))} ${chalk6.dim(formatDate(c.createdAt))}${version}`;
|
|
2192
|
+
}
|
|
2193
|
+
async function runComments(ctx, name, opts) {
|
|
2194
|
+
const list = await listComments(commentsClient(ctx), name, { path: opts.path });
|
|
2195
|
+
const all = commentThreads(list.comments);
|
|
2196
|
+
const threads = opts.all ? all : all.filter((t) => t.comment.resolvedAt === null);
|
|
2197
|
+
const hidden = all.length - threads.length;
|
|
2198
|
+
ctx.out.data({ ...list, reviewUrl: reviewPage(ctx, name) }, () => {
|
|
2199
|
+
const lines = [];
|
|
2200
|
+
let lastPath = null;
|
|
2201
|
+
for (const { comment: c, replies } of threads.sort(
|
|
2202
|
+
(a, b) => a.comment.path.localeCompare(b.comment.path)
|
|
2203
|
+
)) {
|
|
2204
|
+
if (c.path !== lastPath) {
|
|
2205
|
+
lines.push("", chalk6.bold.underline(c.path));
|
|
2206
|
+
lastPath = c.path;
|
|
2207
|
+
}
|
|
2208
|
+
const state = c.resolvedAt ? chalk6.green(" \u2713 resolved") : "";
|
|
2209
|
+
lines.push(`${chalk6.dim(c.id)}${state} ${describe(c, list.site.currentVersionId)}`);
|
|
2210
|
+
lines.push(...c.body.split("\n").map((l) => ` ${l}`));
|
|
2211
|
+
for (const r of replies) {
|
|
2212
|
+
lines.push(` ${chalk6.dim("\u21B3")} ${describe(r, list.site.currentVersionId)}`);
|
|
2213
|
+
lines.push(...r.body.split("\n").map((l) => ` ${l}`));
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
const summary = chalk6.dim(
|
|
2217
|
+
`${threads.length} ${opts.all ? "" : "open "}thread${threads.length === 1 ? "" : "s"}` + (hidden ? ` (${hidden} resolved \u2014 \`--all\` shows them)` : "") + ` \xB7 review: ${reviewPage(ctx, name)}`
|
|
2218
|
+
);
|
|
2219
|
+
return [...lines, "", summary].join("\n").replace(/^\n/, "");
|
|
2220
|
+
});
|
|
2221
|
+
}
|
|
2222
|
+
async function runComment(ctx, name, text2, opts) {
|
|
2223
|
+
if (!text2.trim()) throw new CliError("The comment is empty");
|
|
2224
|
+
const comment = await addComment(commentsClient(ctx), name, {
|
|
2225
|
+
body: text2,
|
|
2226
|
+
path: opts.path,
|
|
2227
|
+
parentId: opts.reply
|
|
2228
|
+
});
|
|
2229
|
+
ctx.log.success(
|
|
2230
|
+
comment.parentId ? `Replied to ${comment.parentId} on ${name}` : `Comment added on ${name} ${comment.path}`
|
|
2231
|
+
);
|
|
2232
|
+
ctx.out.data(comment, () => reviewPage(ctx, name, comment.path));
|
|
2233
|
+
}
|
|
2234
|
+
async function runResolve(ctx, name, id, opts) {
|
|
2235
|
+
const comment = await resolveComment(commentsClient(ctx), name, id, !opts.reopen);
|
|
2236
|
+
ctx.log.success(opts.reopen ? `Thread ${id} reopened` : `Thread ${id} resolved`);
|
|
2237
|
+
if (ctx.json) ctx.out.data(comment, () => "");
|
|
2238
|
+
}
|
|
2239
|
+
async function runUncomment(ctx, name, id, opts) {
|
|
2240
|
+
if (!opts.yes) {
|
|
2241
|
+
if (!ctx.interactive) {
|
|
2242
|
+
throw new CliError(`Refusing to delete comment ${id} without confirmation`, {
|
|
2243
|
+
hint: "Pass --yes to delete."
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
const ok = await ctx.confirm(`Delete comment ${id} (and its replies)? This cannot be undone.`);
|
|
2247
|
+
if (!ok) throw new CliError("Cancelled");
|
|
2248
|
+
}
|
|
2249
|
+
const result = await deleteComment(commentsClient(ctx), name, id);
|
|
2250
|
+
ctx.log.success(`Comment ${id} deleted`);
|
|
2251
|
+
if (ctx.json) ctx.out.data(result, () => "");
|
|
2252
|
+
}
|
|
2253
|
+
|
|
1580
2254
|
// src/commands/deploy.ts
|
|
1581
2255
|
import { stat as stat4 } from "node:fs/promises";
|
|
1582
2256
|
import { resolve as resolve3 } from "node:path";
|
|
1583
|
-
import
|
|
2257
|
+
import chalk7 from "chalk";
|
|
1584
2258
|
|
|
1585
2259
|
// src/deploy.ts
|
|
1586
2260
|
import { readFile as readFile4 } from "node:fs/promises";
|
|
@@ -1744,19 +2418,19 @@ function describeSkipped(skipped) {
|
|
|
1744
2418
|
lines.push(line(skipped.gitignored.length, "gitignored file", skipped.gitignored));
|
|
1745
2419
|
return lines;
|
|
1746
2420
|
}
|
|
1747
|
-
function contentTypeFor(
|
|
1748
|
-
const type = mime.getType(
|
|
2421
|
+
function contentTypeFor(path3) {
|
|
2422
|
+
const type = mime.getType(path3) ?? "application/octet-stream";
|
|
1749
2423
|
return type.startsWith("text/") || CHARSET_TYPES.has(type) ? `${type}; charset=utf-8` : type;
|
|
1750
2424
|
}
|
|
1751
|
-
async function sha256File(
|
|
1752
|
-
const
|
|
1753
|
-
for await (const chunk of createReadStream(
|
|
1754
|
-
return
|
|
2425
|
+
async function sha256File(path3) {
|
|
2426
|
+
const hash3 = createHash("sha256");
|
|
2427
|
+
for await (const chunk of createReadStream(path3)) hash3.update(chunk);
|
|
2428
|
+
return hash3.digest("hex");
|
|
1755
2429
|
}
|
|
1756
2430
|
function toManifest(local) {
|
|
1757
2431
|
return {
|
|
1758
|
-
files: local.files.map(({ path, sha256, size, contentType }) => ({
|
|
1759
|
-
path,
|
|
2432
|
+
files: local.files.map(({ path: path3, sha256, size, contentType }) => ({
|
|
2433
|
+
path: path3,
|
|
1760
2434
|
sha256,
|
|
1761
2435
|
size,
|
|
1762
2436
|
contentType
|
|
@@ -2089,8 +2763,8 @@ async function deploy(deps, opts) {
|
|
|
2089
2763
|
totalBytes: local.totalBytes,
|
|
2090
2764
|
warnings: local.warnings
|
|
2091
2765
|
};
|
|
2092
|
-
const
|
|
2093
|
-
const res = await deps.client.request("POST",
|
|
2766
|
+
const path3 = `/api/sites/${encodeURIComponent(name)}/deploys`;
|
|
2767
|
+
const res = await deps.client.request("POST", path3, {
|
|
2094
2768
|
schema: DeployResponseSchema,
|
|
2095
2769
|
body: {
|
|
2096
2770
|
manifest: toManifest(local),
|
|
@@ -2130,7 +2804,7 @@ async function deploy(deps, opts) {
|
|
|
2130
2804
|
});
|
|
2131
2805
|
const fin = await deps.client.request(
|
|
2132
2806
|
"POST",
|
|
2133
|
-
`${
|
|
2807
|
+
`${path3}/${encodeURIComponent(res.versionId)}/finalize`,
|
|
2134
2808
|
{ schema: FinalizeResponseSchema }
|
|
2135
2809
|
);
|
|
2136
2810
|
return {
|
|
@@ -2166,51 +2840,51 @@ import { copyFile, mkdir as mkdir2, mkdtemp, readFile as readFile5, rm, stat as
|
|
|
2166
2840
|
import { tmpdir } from "node:os";
|
|
2167
2841
|
import { basename as basename2, extname, join as join4, resolve as resolve2, sep as sep4 } from "node:path";
|
|
2168
2842
|
import { unzipSync } from "fflate";
|
|
2169
|
-
function inputKind(
|
|
2843
|
+
function inputKind(path3, isDirectory) {
|
|
2170
2844
|
if (isDirectory) return "folder";
|
|
2171
|
-
const ext = extname(
|
|
2845
|
+
const ext = extname(path3).toLowerCase();
|
|
2172
2846
|
if (ext === ".html" || ext === ".htm") return "html";
|
|
2173
2847
|
if (ext === ".zip") return "zip";
|
|
2174
2848
|
return "file";
|
|
2175
2849
|
}
|
|
2176
|
-
function fileSiteName(
|
|
2177
|
-
const file = basename2(
|
|
2850
|
+
function fileSiteName(path3) {
|
|
2851
|
+
const file = basename2(path3.replace(/\\/g, "/"));
|
|
2178
2852
|
const stem = file.slice(0, file.length - extname(file).length) || file;
|
|
2179
2853
|
return fitName([stem.replace(/\s*\(\d+\)$/, "")]);
|
|
2180
2854
|
}
|
|
2181
|
-
function defaultNameFor(
|
|
2182
|
-
return isDirectory ? defaultSiteName(
|
|
2855
|
+
function defaultNameFor(path3, isDirectory) {
|
|
2856
|
+
return isDirectory ? defaultSiteName(path3) : fileSiteName(path3);
|
|
2183
2857
|
}
|
|
2184
2858
|
async function tempFolder() {
|
|
2185
2859
|
const dir = await mkdtemp(join4(tmpdir(), "fling-"));
|
|
2186
2860
|
return { dir, cleanup: () => rm(dir, { recursive: true, force: true }) };
|
|
2187
2861
|
}
|
|
2188
|
-
async function prepareInput(
|
|
2862
|
+
async function prepareInput(path3) {
|
|
2189
2863
|
let info;
|
|
2190
2864
|
try {
|
|
2191
|
-
info = await stat3(
|
|
2865
|
+
info = await stat3(path3);
|
|
2192
2866
|
} catch {
|
|
2193
|
-
throw new CliError(`Not found: ${
|
|
2867
|
+
throw new CliError(`Not found: ${path3}`);
|
|
2194
2868
|
}
|
|
2195
|
-
const kind = inputKind(
|
|
2196
|
-
const defaultName = defaultNameFor(
|
|
2869
|
+
const kind = inputKind(path3, info.isDirectory());
|
|
2870
|
+
const defaultName = defaultNameFor(path3, info.isDirectory());
|
|
2197
2871
|
if (kind === "folder")
|
|
2198
2872
|
return {
|
|
2199
2873
|
kind,
|
|
2200
|
-
source:
|
|
2201
|
-
dir:
|
|
2874
|
+
source: path3,
|
|
2875
|
+
dir: path3,
|
|
2202
2876
|
defaultName,
|
|
2203
2877
|
skippedDotfiles: [],
|
|
2204
2878
|
cleanup: async () => {
|
|
2205
2879
|
}
|
|
2206
2880
|
};
|
|
2207
|
-
const file = basename2(
|
|
2881
|
+
const file = basename2(path3);
|
|
2208
2882
|
if (file.startsWith("."))
|
|
2209
2883
|
throw new CliError(`Not flung: ${file} is a dotfile. ${NEVER_PUBLISHED_NOTE}`);
|
|
2210
2884
|
if (kind === "file" && isSecretPath(file)) throw new SecretFilesError([file]);
|
|
2211
2885
|
if (info.size > MAX_DEPLOY_BYTES)
|
|
2212
2886
|
throw new CliError(
|
|
2213
|
-
`${basename2(
|
|
2887
|
+
`${basename2(path3)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_DEPLOY_BYTES)}`
|
|
2214
2888
|
);
|
|
2215
2889
|
const tmp = await tempFolder();
|
|
2216
2890
|
const skippedDotfiles = [];
|
|
@@ -2218,21 +2892,21 @@ async function prepareInput(path) {
|
|
|
2218
2892
|
if (kind === "html") {
|
|
2219
2893
|
if (info.size > MAX_FILE_BYTES)
|
|
2220
2894
|
throw new CliError(
|
|
2221
|
-
`${basename2(
|
|
2895
|
+
`${basename2(path3)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`
|
|
2222
2896
|
);
|
|
2223
|
-
await copyFile(
|
|
2897
|
+
await copyFile(path3, join4(tmp.dir, "index.html"));
|
|
2224
2898
|
} else if (kind === "zip") {
|
|
2225
|
-
await extractZip(await readFile5(
|
|
2899
|
+
await extractZip(await readFile5(path3), tmp.dir, skippedDotfiles);
|
|
2226
2900
|
} else {
|
|
2227
|
-
const name = basename2(
|
|
2228
|
-
await copyFile(
|
|
2901
|
+
const name = basename2(path3);
|
|
2902
|
+
await copyFile(path3, join4(tmp.dir, name));
|
|
2229
2903
|
await writeFile2(join4(tmp.dir, "index.html"), wrapperPage(name));
|
|
2230
2904
|
}
|
|
2231
2905
|
} catch (error) {
|
|
2232
2906
|
await tmp.cleanup();
|
|
2233
2907
|
throw error;
|
|
2234
2908
|
}
|
|
2235
|
-
return { kind, source:
|
|
2909
|
+
return { kind, source: path3, dir: tmp.dir, defaultName, skippedDotfiles, cleanup: tmp.cleanup };
|
|
2236
2910
|
}
|
|
2237
2911
|
var JUNK_FILES = /* @__PURE__ */ new Set(["thumbs.db", "desktop.ini"]);
|
|
2238
2912
|
function zipEntryPath(name) {
|
|
@@ -2272,12 +2946,12 @@ async function extractZip(data, dest, skippedDotfiles = []) {
|
|
|
2272
2946
|
try {
|
|
2273
2947
|
entries = unzipSync(data, {
|
|
2274
2948
|
filter: (file) => {
|
|
2275
|
-
const
|
|
2276
|
-
if (
|
|
2949
|
+
const path3 = zipEntryPath(file.name);
|
|
2950
|
+
if (path3 === "unsafe") {
|
|
2277
2951
|
unsafe.push(file.name);
|
|
2278
2952
|
return false;
|
|
2279
2953
|
}
|
|
2280
|
-
if (
|
|
2954
|
+
if (path3 === null) {
|
|
2281
2955
|
const dot = zipDotEntry(file.name);
|
|
2282
2956
|
if (dot && !skippedDotfiles.includes(dot)) skippedDotfiles.push(dot);
|
|
2283
2957
|
return false;
|
|
@@ -2286,7 +2960,7 @@ async function extractZip(data, dest, skippedDotfiles = []) {
|
|
|
2286
2960
|
declared += file.originalSize;
|
|
2287
2961
|
if (file.originalSize > MAX_FILE_BYTES)
|
|
2288
2962
|
throw new CliError(
|
|
2289
|
-
`${
|
|
2963
|
+
`${path3} in the zip is ${formatBytes(file.originalSize)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`
|
|
2290
2964
|
);
|
|
2291
2965
|
if (declared > MAX_DEPLOY_BYTES)
|
|
2292
2966
|
throw new CliError(
|
|
@@ -2367,20 +3041,21 @@ function wrapperPage(file) {
|
|
|
2367
3041
|
async function runDeploy(ctx, input, opts) {
|
|
2368
3042
|
const client = requireClient(ctx);
|
|
2369
3043
|
const ask = ctx.interactive && !opts.yes;
|
|
2370
|
-
const
|
|
2371
|
-
const
|
|
2372
|
-
|
|
3044
|
+
const instance = await loadInstance(ctx.store, ctx.config, ctx.fetch);
|
|
3045
|
+
const base = sitesBaseOf(instance);
|
|
3046
|
+
const path3 = resolve3(input ?? ".");
|
|
3047
|
+
if (!await stat4(path3).catch(() => null))
|
|
2373
3048
|
throw new CliError(`No such file or folder: ${input ?? "."}`, {
|
|
2374
3049
|
hint: `Give the path to a folder, .html, .zip or other file, e.g. \`${ctx.binName} deploy ./dist\`.`
|
|
2375
3050
|
});
|
|
2376
|
-
const prepared = await prepareInput(
|
|
3051
|
+
const prepared = await prepareInput(path3);
|
|
2377
3052
|
try {
|
|
2378
|
-
await deployPrepared(ctx, client, base, prepared, opts, ask);
|
|
3053
|
+
await deployPrepared(ctx, client, instance, base, prepared, opts, ask);
|
|
2379
3054
|
} finally {
|
|
2380
3055
|
await prepared.cleanup();
|
|
2381
3056
|
}
|
|
2382
3057
|
}
|
|
2383
|
-
async function deployPrepared(ctx, client, base, prepared, opts, ask) {
|
|
3058
|
+
async function deployPrepared(ctx, client, instance, base, prepared, opts, ask) {
|
|
2384
3059
|
let local = await buildManifest(prepared.dir);
|
|
2385
3060
|
local.skipped.dotfiles.unshift(...prepared.skippedDotfiles);
|
|
2386
3061
|
for (const line of describeSkipped(local.skipped)) ctx.log.info(line);
|
|
@@ -2418,12 +3093,12 @@ async function deployPrepared(ctx, client, base, prepared, opts, ask) {
|
|
|
2418
3093
|
});
|
|
2419
3094
|
for (const warning of result.warnings) if (warning !== NO_INDEX_WARNING) ctx.log.warn(warning);
|
|
2420
3095
|
const summary = `${result.fileCount} files, ${formatBytes(result.totalBytes)}`;
|
|
2421
|
-
if (result.status === "unchanged") ctx.log.info(`Unchanged ${
|
|
3096
|
+
if (result.status === "unchanged") ctx.log.info(`Unchanged ${chalk7.dim(`(${summary})`)}`);
|
|
2422
3097
|
else if (result.status === "repointed")
|
|
2423
|
-
ctx.log.success(`Restored version ${result.versionId} ${
|
|
3098
|
+
ctx.log.success(`Restored version ${result.versionId} ${chalk7.dim(`(${summary})`)}`);
|
|
2424
3099
|
else
|
|
2425
3100
|
ctx.log.success(
|
|
2426
|
-
`Deployed ${result.name} ${
|
|
3101
|
+
`Deployed ${result.name} ${chalk7.dim(`(${summary}; uploaded ${result.uploaded}, version ${result.versionId})`)}`
|
|
2427
3102
|
);
|
|
2428
3103
|
const ready = result.status === "unchanged" ? true : (await waitWithSpinner(
|
|
2429
3104
|
ctx.waitReady,
|
|
@@ -2431,23 +3106,38 @@ async function deployPrepared(ctx, client, base, prepared, opts, ask) {
|
|
|
2431
3106
|
local.files.map((f) => f.path),
|
|
2432
3107
|
{ progress: ctx.progress, warn: (m) => ctx.log.warn(m) }
|
|
2433
3108
|
)).ready;
|
|
2434
|
-
const
|
|
2435
|
-
|
|
3109
|
+
const who = instance ? `only ${privateAudience(instance)} can open it` : "sign-in required";
|
|
3110
|
+
const privateNote = `Private: ${who}. Share publicly with \`${ctx.binName} share ${result.name}\`.`;
|
|
3111
|
+
const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
|
|
3112
|
+
const reviewNote = instance ? `Share for feedback (reviewers sign in to ${instance.name}):` : "Share for feedback:";
|
|
3113
|
+
const data = {
|
|
3114
|
+
status: result.status,
|
|
3115
|
+
site: result.site,
|
|
3116
|
+
...review ? { reviewUrl: review } : {},
|
|
3117
|
+
ready,
|
|
3118
|
+
skipped: local.skipped
|
|
3119
|
+
};
|
|
3120
|
+
ctx.out.data(data, () => {
|
|
2436
3121
|
const isPrivate = result.site.visibility === "private";
|
|
2437
3122
|
if (ctx.out.tty)
|
|
2438
3123
|
return box(
|
|
2439
|
-
[
|
|
2440
|
-
|
|
3124
|
+
[
|
|
3125
|
+
...review ? [chalk7.dim(reviewNote), brand.url(review), "", chalk7.dim("Site:")] : [],
|
|
3126
|
+
brand.url(result.url),
|
|
3127
|
+
...isPrivate ? [chalk7.dim(privateNote)] : []
|
|
3128
|
+
],
|
|
3129
|
+
isPrivate ? chalk7.bold("flung \xB7 private") : chalk7.bold("flung \xB7 public")
|
|
2441
3130
|
);
|
|
2442
|
-
const lines = [
|
|
2443
|
-
if (
|
|
3131
|
+
const lines = [chalk7.bold(result.url)];
|
|
3132
|
+
if (review) lines.push(chalk7.dim(`${reviewNote} ${review}`));
|
|
3133
|
+
if (isPrivate) lines.push(chalk7.dim(privateNote));
|
|
2444
3134
|
return lines.join("\n");
|
|
2445
3135
|
});
|
|
2446
3136
|
}
|
|
2447
3137
|
var BAR_WIDTH = 20;
|
|
2448
3138
|
function progressBar(fraction) {
|
|
2449
3139
|
const done = Math.round(Math.min(Math.max(fraction, 0), 1) * BAR_WIDTH);
|
|
2450
|
-
return brand.primary("\u2501".repeat(done)) +
|
|
3140
|
+
return brand.primary("\u2501".repeat(done)) + chalk7.dim("\u2500".repeat(BAR_WIDTH - done));
|
|
2451
3141
|
}
|
|
2452
3142
|
async function askNoIndex(ctx, pages) {
|
|
2453
3143
|
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 +3153,20 @@ async function askNoIndex(ctx, pages) {
|
|
|
2463
3153
|
}
|
|
2464
3154
|
async function confirmSiteName(ctx, client, generated, base) {
|
|
2465
3155
|
const where = (n) => base ? ` \u2192 ${siteUrlFor(n, base)}` : "";
|
|
2466
|
-
const
|
|
3156
|
+
const describe2 = (n, note) => ctx.log.info(`Site name: ${chalk7.bold(n)}${where(n)} ${chalk7.dim(`(${note})`)}`);
|
|
2467
3157
|
let candidate = generated;
|
|
2468
3158
|
for (; ; ) {
|
|
2469
3159
|
const check2 = await checkName(client, candidate);
|
|
2470
3160
|
let proposal;
|
|
2471
3161
|
if (check2.status === "available" || check2.status === "yours") {
|
|
2472
3162
|
proposal = candidate;
|
|
2473
|
-
|
|
3163
|
+
describe2(candidate, check2.status === "yours" ? "updates your existing site" : "new");
|
|
2474
3164
|
} else {
|
|
2475
3165
|
const why = check2.status === "taken" ? "is taken" : "is not a valid site name";
|
|
2476
3166
|
const alternatives = check2.suggestions.length ? ` Suggestions: ${check2.suggestions.join(", ")}` : "";
|
|
2477
3167
|
ctx.log.warn(`${candidate} ${why}.${alternatives}`);
|
|
2478
3168
|
proposal = check2.suggestions[0];
|
|
2479
|
-
if (proposal)
|
|
3169
|
+
if (proposal) describe2(proposal, "new");
|
|
2480
3170
|
}
|
|
2481
3171
|
for (; ; ) {
|
|
2482
3172
|
const question = proposal ? `Use "${proposal}"? [Y/n or type another name] ` : "Type a site name (or n to abort): ";
|
|
@@ -2501,14 +3191,14 @@ async function confirmSiteName(ctx, client, generated, base) {
|
|
|
2501
3191
|
|
|
2502
3192
|
// src/commands/setup.ts
|
|
2503
3193
|
import { intro, outro, spinner } from "@clack/prompts";
|
|
2504
|
-
import
|
|
3194
|
+
import chalk8 from "chalk";
|
|
2505
3195
|
|
|
2506
3196
|
// src/claude.ts
|
|
2507
3197
|
import { execFile as execFile2 } from "node:child_process";
|
|
2508
3198
|
import { access, mkdtemp as mkdtemp2, writeFile as writeFile3 } from "node:fs/promises";
|
|
2509
3199
|
import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
|
|
2510
3200
|
import { join as join5 } from "node:path";
|
|
2511
|
-
var MARKETPLACE_SOURCE =
|
|
3201
|
+
var MARKETPLACE_SOURCE = REPO_SLUG;
|
|
2512
3202
|
var MARKETPLACE_NAME = "fling";
|
|
2513
3203
|
var PLUGIN_NAME = "fling";
|
|
2514
3204
|
var PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
|
|
@@ -2563,12 +3253,12 @@ function check(result, what) {
|
|
|
2563
3253
|
const detail = (result.stderr || result.stdout).trim().split("\n").at(-1);
|
|
2564
3254
|
throw new Error(`Could not ${what}${detail ? `: ${detail}` : ""}`);
|
|
2565
3255
|
}
|
|
2566
|
-
async function findClaudeDesktop(platform = process.platform, env = process.env, exists = (
|
|
3256
|
+
async function findClaudeDesktop(platform = process.platform, env = process.env, exists = (path3) => access(path3).then(
|
|
2567
3257
|
() => true,
|
|
2568
3258
|
() => false
|
|
2569
3259
|
)) {
|
|
2570
3260
|
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
|
|
3261
|
+
for (const path3 of candidates) if (await exists(path3)) return path3;
|
|
2572
3262
|
return null;
|
|
2573
3263
|
}
|
|
2574
3264
|
async function downloadExtension(serverUrl, fetch2) {
|
|
@@ -2594,36 +3284,51 @@ async function runSetup(baseCtx, opts, deps = {}) {
|
|
|
2594
3284
|
...baseCtx,
|
|
2595
3285
|
log: createLogger({
|
|
2596
3286
|
debug: baseCtx.config.debug,
|
|
2597
|
-
write: (line) => process.stderr.write(`${
|
|
3287
|
+
write: (line) => process.stderr.write(`${chalk8.gray("\u2502")} ${line}
|
|
2598
3288
|
`)
|
|
2599
3289
|
})
|
|
2600
3290
|
} : baseCtx;
|
|
2601
3291
|
if (fancy) {
|
|
2602
3292
|
process.stderr.write(`${logo()}
|
|
2603
3293
|
`);
|
|
2604
|
-
intro(
|
|
3294
|
+
intro(chalk8.bold(" fling setup "), { output: process.stderr });
|
|
2605
3295
|
}
|
|
3296
|
+
const login = await stepLogin(ctx, opts.url);
|
|
2606
3297
|
const results = {
|
|
2607
|
-
login:
|
|
3298
|
+
login: login.result,
|
|
2608
3299
|
claudeCode: "skipped",
|
|
2609
3300
|
claudeDesktop: "skipped"
|
|
2610
3301
|
};
|
|
2611
|
-
|
|
2612
|
-
results.
|
|
3302
|
+
const after = login.serverUrl ? { ...ctx, config: await ctx.store.resolve({ serverUrl: login.serverUrl }) } : ctx;
|
|
3303
|
+
results.claudeCode = await stepClaudeCode(after, run, scope, ask, fancy);
|
|
3304
|
+
results.claudeDesktop = await stepClaudeDesktop(after, deps, ask, fancy);
|
|
2613
3305
|
const failed = Object.values(results).includes("failed");
|
|
2614
3306
|
if (fancy)
|
|
2615
3307
|
outro(
|
|
2616
|
-
failed ?
|
|
3308
|
+
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
3309
|
{ output: process.stderr }
|
|
2618
3310
|
);
|
|
2619
3311
|
ctx.out.data(results, () => "");
|
|
2620
3312
|
if (failed) process.exitCode = 1;
|
|
2621
3313
|
}
|
|
2622
|
-
async function stepLogin(ctx) {
|
|
3314
|
+
async function stepLogin(ctx, url) {
|
|
3315
|
+
let serverUrl;
|
|
3316
|
+
let target;
|
|
3317
|
+
try {
|
|
3318
|
+
serverUrl = await loginServer(ctx, url);
|
|
3319
|
+
target = { ...ctx, config: await ctx.store.resolve({ serverUrl }) };
|
|
3320
|
+
} catch (error) {
|
|
3321
|
+
ctx.log.error(error instanceof Error ? error.message : String(error));
|
|
3322
|
+
return { result: "failed" };
|
|
3323
|
+
}
|
|
3324
|
+
return { result: await signIn(target, serverUrl), serverUrl };
|
|
3325
|
+
}
|
|
3326
|
+
async function signIn(ctx, serverUrl) {
|
|
2623
3327
|
if (ctx.config.apiKey) {
|
|
2624
3328
|
try {
|
|
2625
3329
|
const me = await whoami(requireClient(ctx));
|
|
2626
|
-
ctx.
|
|
3330
|
+
if (ctx.config.remembered) await ctx.store.setCurrent(serverUrl);
|
|
3331
|
+
ctx.log.success(`Signed in as ${chalk8.bold(me.name)} ${chalk8.dim(`<${me.email}>`)}`);
|
|
2627
3332
|
return "already";
|
|
2628
3333
|
} catch (error) {
|
|
2629
3334
|
if (exitCodeFor(error) !== 2) {
|
|
@@ -2634,7 +3339,7 @@ async function stepLogin(ctx) {
|
|
|
2634
3339
|
}
|
|
2635
3340
|
}
|
|
2636
3341
|
try {
|
|
2637
|
-
await runLogin({ ...ctx, json: false });
|
|
3342
|
+
await runLogin({ ...ctx, json: false }, serverUrl);
|
|
2638
3343
|
return "done";
|
|
2639
3344
|
} catch (error) {
|
|
2640
3345
|
ctx.log.error(error instanceof Error ? error.message : String(error));
|
|
@@ -2644,7 +3349,7 @@ async function stepLogin(ctx) {
|
|
|
2644
3349
|
async function stepClaudeCode(ctx, run, scope, ask, fancy) {
|
|
2645
3350
|
const status = await claudeCodeStatus(run);
|
|
2646
3351
|
if (status.claude === "missing") {
|
|
2647
|
-
ctx.log.info(`Claude Code not found ${
|
|
3352
|
+
ctx.log.info(`Claude Code not found ${chalk8.dim("(no `claude` on your PATH) \u2014 skipped")}`);
|
|
2648
3353
|
ctx.log.hint(
|
|
2649
3354
|
`Once it is installed, run \`${ctx.binName} setup\` again, or in Claude Code: /plugin marketplace add ${MARKETPLACE_SOURCE} then /plugin install ${PLUGIN_ID}`
|
|
2650
3355
|
);
|
|
@@ -2653,7 +3358,7 @@ async function stepClaudeCode(ctx, run, scope, ask, fancy) {
|
|
|
2653
3358
|
const installed = "installed" in status && status.installed;
|
|
2654
3359
|
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
3360
|
if (ask && !await ctx.confirm(question)) {
|
|
2656
|
-
ctx.log.info(
|
|
3361
|
+
ctx.log.info(chalk8.dim("Claude Code plugin: skipped"));
|
|
2657
3362
|
return installed ? "already" : "skipped";
|
|
2658
3363
|
}
|
|
2659
3364
|
const s = fancy ? spinner({ output: process.stderr }) : null;
|
|
@@ -2667,7 +3372,7 @@ async function stepClaudeCode(ctx, run, scope, ask, fancy) {
|
|
|
2667
3372
|
else ctx.log.error(message);
|
|
2668
3373
|
return "failed";
|
|
2669
3374
|
}
|
|
2670
|
-
const done = installed ? `Claude Code plugin ${PLUGIN_NAME} is up to date` : `Claude Code plugin ${PLUGIN_NAME} installed ${
|
|
3375
|
+
const done = installed ? `Claude Code plugin ${PLUGIN_NAME} is up to date` : `Claude Code plugin ${PLUGIN_NAME} installed ${chalk8.dim(`(${scope} scope)`)}`;
|
|
2671
3376
|
if (s) s.stop(done);
|
|
2672
3377
|
else ctx.log.success(done);
|
|
2673
3378
|
ctx.log.hint("Restart Claude Code (or run /reload-plugins) to pick it up.");
|
|
@@ -2677,13 +3382,13 @@ async function stepClaudeDesktop(ctx, deps, ask, fancy) {
|
|
|
2677
3382
|
const app = await (deps.findClaudeDesktop ?? findClaudeDesktop)();
|
|
2678
3383
|
if (!app) return "missing";
|
|
2679
3384
|
if (ask && !await ctx.confirm("Add the Fling extension to Claude Desktop too?")) {
|
|
2680
|
-
ctx.log.info(
|
|
3385
|
+
ctx.log.info(chalk8.dim("Claude Desktop extension: skipped"));
|
|
2681
3386
|
return "skipped";
|
|
2682
3387
|
}
|
|
2683
3388
|
const s = fancy ? spinner({ output: process.stderr }) : null;
|
|
2684
3389
|
s?.start("Downloading the Claude Desktop extension");
|
|
2685
3390
|
try {
|
|
2686
|
-
const file = await (deps.downloadExtension ?? ((serverUrl) => downloadExtension(serverUrl, ctx.fetch)))(ctx
|
|
3391
|
+
const file = await (deps.downloadExtension ?? ((serverUrl) => downloadExtension(serverUrl, ctx.fetch)))(requireServer(ctx));
|
|
2687
3392
|
await ctx.open(file);
|
|
2688
3393
|
const done = "Opened fling.mcpb in Claude Desktop \u2014 click Install there to finish";
|
|
2689
3394
|
if (s) s.stop(done);
|
|
@@ -2698,7 +3403,7 @@ async function stepClaudeDesktop(ctx, deps, ask, fancy) {
|
|
|
2698
3403
|
}
|
|
2699
3404
|
|
|
2700
3405
|
// src/commands/sites.ts
|
|
2701
|
-
import
|
|
3406
|
+
import chalk9 from "chalk";
|
|
2702
3407
|
async function runList(ctx, opts) {
|
|
2703
3408
|
const sites = await listSites(requireClient(ctx), opts);
|
|
2704
3409
|
ctx.out.data(
|
|
@@ -2716,11 +3421,11 @@ async function runList(ctx, opts) {
|
|
|
2716
3421
|
);
|
|
2717
3422
|
}
|
|
2718
3423
|
function describeSite(site) {
|
|
2719
|
-
const row = (label, value) => `${
|
|
3424
|
+
const row = (label, value) => `${chalk9.bold(`${label}:`.padEnd(12))}${value}`;
|
|
2720
3425
|
return [
|
|
2721
3426
|
row("Name", site.name),
|
|
2722
3427
|
row("URL", site.url),
|
|
2723
|
-
row("Visibility", site.visibility + (site.archived ?
|
|
3428
|
+
row("Visibility", site.visibility + (site.archived ? chalk9.yellow(" (archived)") : "")),
|
|
2724
3429
|
row("SPA", site.spa ? "yes" : "no"),
|
|
2725
3430
|
row("Share link", site.shareUrl ?? "-"),
|
|
2726
3431
|
row("Version", site.currentVersionId ?? "-"),
|
|
@@ -2742,7 +3447,7 @@ async function runInfo(ctx, name) {
|
|
|
2742
3447
|
() => [
|
|
2743
3448
|
...describeSite(site),
|
|
2744
3449
|
"",
|
|
2745
|
-
|
|
3450
|
+
chalk9.dim(
|
|
2746
3451
|
`${site.versions.length} version(s) \u2014 \`fling versions ${name}\`; files \u2014 \`fling files ${name}\``
|
|
2747
3452
|
)
|
|
2748
3453
|
].join("\n")
|
|
@@ -2768,7 +3473,7 @@ async function runFiles(ctx, name, versionId) {
|
|
|
2768
3473
|
}
|
|
2769
3474
|
ctx.out.data(result, () => {
|
|
2770
3475
|
const n = result.files.length;
|
|
2771
|
-
const header =
|
|
3476
|
+
const header = chalk9.dim(
|
|
2772
3477
|
`${result.versionId}${result.current ? " (current)" : " (not served)"} \u2014 ${n} file${n === 1 ? "" : "s"}, ${formatBytes(result.totalBytes)}${result.generatedIndex ? ", generated index.html" : ""}`
|
|
2773
3478
|
);
|
|
2774
3479
|
const table = renderTable(result.files, [
|
|
@@ -2804,7 +3509,7 @@ async function runShare(ctx, name, opts) {
|
|
|
2804
3509
|
ctx.log.success("Share link created \u2014 anyone with it can view the site");
|
|
2805
3510
|
ctx.out.data(
|
|
2806
3511
|
ready === void 0 ? result : { ...result, ready },
|
|
2807
|
-
() => result.shareUrl ? result.shareUrl :
|
|
3512
|
+
() => result.shareUrl ? result.shareUrl : chalk9.dim("(no share link)")
|
|
2808
3513
|
);
|
|
2809
3514
|
}
|
|
2810
3515
|
async function runVisibility(ctx, name, visibility) {
|
|
@@ -2813,7 +3518,7 @@ async function runVisibility(ctx, name, visibility) {
|
|
|
2813
3518
|
}
|
|
2814
3519
|
const site = await setVisibility(requireClient(ctx), name, visibility);
|
|
2815
3520
|
ctx.log.success(
|
|
2816
|
-
site.visibility === "public" ? `${name} is public \u2014 no sign-in needed` : `${name} is private \u2014
|
|
3521
|
+
site.visibility === "public" ? `${name} is public \u2014 no sign-in needed` : `${name} is private \u2014 sign-in required`
|
|
2817
3522
|
);
|
|
2818
3523
|
ctx.out.data(site, () => site.url);
|
|
2819
3524
|
}
|
|
@@ -2846,7 +3551,7 @@ import { tmpdir as tmpdir3 } from "node:os";
|
|
|
2846
3551
|
import { basename as basename3, dirname as dirname2, join as join6 } from "node:path";
|
|
2847
3552
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2848
3553
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2849
|
-
import { z as
|
|
3554
|
+
import { z as z11 } from "zod";
|
|
2850
3555
|
|
|
2851
3556
|
// src/locate.ts
|
|
2852
3557
|
import { lstat, readdir as readdir2, readFile as readFile6, realpath as realpath3, stat as stat5 } from "node:fs/promises";
|
|
@@ -2860,21 +3565,21 @@ var SESSIONS_DEPTH = 4;
|
|
|
2860
3565
|
var OUTPUTS_DEPTH = 4;
|
|
2861
3566
|
var SANDBOX_PATH_RE = /^\/(mnt\/user-data|mnt\/data|sessions|home\/claude|tmp\/outputs)(\/|$)/;
|
|
2862
3567
|
var nodeFs = {
|
|
2863
|
-
async stat(
|
|
3568
|
+
async stat(path3) {
|
|
2864
3569
|
try {
|
|
2865
|
-
const s = await stat5(
|
|
3570
|
+
const s = await stat5(path3);
|
|
2866
3571
|
return { isFile: s.isFile(), isDirectory: s.isDirectory(), mtimeMs: s.mtimeMs, size: s.size };
|
|
2867
3572
|
} catch {
|
|
2868
3573
|
return null;
|
|
2869
3574
|
}
|
|
2870
3575
|
},
|
|
2871
|
-
async readdir(
|
|
3576
|
+
async readdir(path3) {
|
|
2872
3577
|
try {
|
|
2873
|
-
const entries = await readdir2(
|
|
3578
|
+
const entries = await readdir2(path3, { withFileTypes: true });
|
|
2874
3579
|
const out = [];
|
|
2875
3580
|
for (const e of entries) {
|
|
2876
3581
|
if (e.isSymbolicLink()) {
|
|
2877
|
-
const s = await stat5(`${
|
|
3582
|
+
const s = await stat5(`${path3}/${e.name}`).catch(() => null);
|
|
2878
3583
|
out.push({ name: e.name, isFile: Boolean(s?.isFile()), isDirectory: false });
|
|
2879
3584
|
} else out.push({ name: e.name, isFile: e.isFile(), isDirectory: e.isDirectory() });
|
|
2880
3585
|
}
|
|
@@ -2883,13 +3588,13 @@ var nodeFs = {
|
|
|
2883
3588
|
return [];
|
|
2884
3589
|
}
|
|
2885
3590
|
},
|
|
2886
|
-
async realpath(
|
|
2887
|
-
return realpath3(
|
|
3591
|
+
async realpath(path3) {
|
|
3592
|
+
return realpath3(path3).catch(() => null);
|
|
2888
3593
|
},
|
|
2889
|
-
async readText(
|
|
3594
|
+
async readText(path3) {
|
|
2890
3595
|
try {
|
|
2891
|
-
if (!(await lstat(
|
|
2892
|
-
return await readFile6(
|
|
3596
|
+
if (!(await lstat(path3)).isFile()) return null;
|
|
3597
|
+
return await readFile6(path3, "utf8");
|
|
2893
3598
|
} catch {
|
|
2894
3599
|
return null;
|
|
2895
3600
|
}
|
|
@@ -2912,9 +3617,9 @@ async function searchRoots(opts) {
|
|
|
2912
3617
|
const { home, env, platform } = opts;
|
|
2913
3618
|
const userHome = platform === "win32" && usable(env.USERPROFILE) ? env.USERPROFILE : home;
|
|
2914
3619
|
const roots = [];
|
|
2915
|
-
const add = (label,
|
|
2916
|
-
if (!
|
|
2917
|
-
const norm = p.resolve(
|
|
3620
|
+
const add = (label, path3, depth, sessions = false) => {
|
|
3621
|
+
if (!path3) return;
|
|
3622
|
+
const norm = p.resolve(path3);
|
|
2918
3623
|
if (roots.some((r) => r.path === norm)) return;
|
|
2919
3624
|
roots.push({ label, path: norm, depth, ...sessions ? { sessions } : {} });
|
|
2920
3625
|
};
|
|
@@ -2944,8 +3649,8 @@ async function searchRoots(opts) {
|
|
|
2944
3649
|
if (!fs.realpath) return roots;
|
|
2945
3650
|
const real = [];
|
|
2946
3651
|
for (const root of roots) {
|
|
2947
|
-
const
|
|
2948
|
-
if (!real.some((r) => r.path ===
|
|
3652
|
+
const path3 = await fs.realpath(root.path) ?? root.path;
|
|
3653
|
+
if (!real.some((r) => r.path === path3)) real.push({ ...root, path: path3 });
|
|
2949
3654
|
}
|
|
2950
3655
|
return real;
|
|
2951
3656
|
}
|
|
@@ -2993,10 +3698,10 @@ async function walk(roots, opts, visit) {
|
|
|
2993
3698
|
budget -= entries.length;
|
|
2994
3699
|
for (const entry of entries) {
|
|
2995
3700
|
if (entry.name.startsWith(".")) continue;
|
|
2996
|
-
const
|
|
2997
|
-
const result = await visit({ root, path, entry, siblings: entries });
|
|
3701
|
+
const path3 = p.join(dir, entry.name);
|
|
3702
|
+
const result = await visit({ root, path: path3, entry, siblings: entries });
|
|
2998
3703
|
if (entry.isDirectory && !skip(entry.name) && result !== "skip" && depth < root.depth)
|
|
2999
|
-
next.push(
|
|
3704
|
+
next.push(path3);
|
|
3000
3705
|
}
|
|
3001
3706
|
}
|
|
3002
3707
|
level = next;
|
|
@@ -3012,9 +3717,9 @@ function namesMatch(candidate, wanted) {
|
|
|
3012
3717
|
const b = wanted.toLowerCase();
|
|
3013
3718
|
return a === b || withoutDuplicateSuffix(a) === withoutDuplicateSuffix(b);
|
|
3014
3719
|
}
|
|
3015
|
-
var splitSegments = (
|
|
3016
|
-
function suffixScore(
|
|
3017
|
-
const have = splitSegments(
|
|
3720
|
+
var splitSegments = (path3) => path3.split(/[\\/]+/).filter((s) => s !== "" && s !== "." && s !== "~");
|
|
3721
|
+
function suffixScore(path3, wanted) {
|
|
3722
|
+
const have = splitSegments(path3);
|
|
3018
3723
|
let score = 1;
|
|
3019
3724
|
for (let i = 2; i <= Math.min(have.length, wanted.length); i++) {
|
|
3020
3725
|
if (have[have.length - i]?.toLowerCase() !== wanted[wanted.length - i]?.toLowerCase()) break;
|
|
@@ -3055,19 +3760,19 @@ async function resolveInput(input, opts) {
|
|
|
3055
3760
|
if (!name) throw notFound(input, roots);
|
|
3056
3761
|
const found = [];
|
|
3057
3762
|
const seen = /* @__PURE__ */ new Set();
|
|
3058
|
-
await walk(roots, opts, async ({ root, path, entry }) => {
|
|
3763
|
+
await walk(roots, opts, async ({ root, path: path3, entry }) => {
|
|
3059
3764
|
if (!(entry.isFile || entry.isDirectory) || !namesMatch(entry.name, name)) return;
|
|
3060
|
-
if (seen.has(
|
|
3061
|
-
seen.add(
|
|
3062
|
-
const s = await fs.stat(
|
|
3765
|
+
if (seen.has(path3)) return;
|
|
3766
|
+
seen.add(path3);
|
|
3767
|
+
const s = await fs.stat(path3);
|
|
3063
3768
|
if (!s) return;
|
|
3064
3769
|
found.push({
|
|
3065
|
-
path,
|
|
3770
|
+
path: path3,
|
|
3066
3771
|
foundIn: root.label,
|
|
3067
3772
|
kind: s.isDirectory ? "folder" : "file",
|
|
3068
3773
|
modified: new Date(s.mtimeMs).toISOString(),
|
|
3069
3774
|
size: s.size,
|
|
3070
|
-
score: suffixScore(
|
|
3775
|
+
score: suffixScore(path3, wanted),
|
|
3071
3776
|
mtimeMs: s.mtimeMs,
|
|
3072
3777
|
isDirectory: s.isDirectory
|
|
3073
3778
|
});
|
|
@@ -3101,15 +3806,15 @@ async function findFiles(opts, find = {}) {
|
|
|
3101
3806
|
const roots = await searchRoots(opts);
|
|
3102
3807
|
const found = /* @__PURE__ */ new Map();
|
|
3103
3808
|
const rootPaths = new Set(roots.map((r) => r.path));
|
|
3104
|
-
const { complete } = await walk(roots, opts, async ({ root, path, entry }) => {
|
|
3809
|
+
const { complete } = await walk(roots, opts, async ({ root, path: path3, entry }) => {
|
|
3105
3810
|
if (entry.isDirectory) {
|
|
3106
|
-
if (rootPaths.has(
|
|
3107
|
-
const index = (await fs.readdir(
|
|
3811
|
+
if (rootPaths.has(path3)) return;
|
|
3812
|
+
const index = (await fs.readdir(path3)).find((e) => e.isFile && /^index\.html?$/i.test(e.name));
|
|
3108
3813
|
if (!index) return;
|
|
3109
|
-
const s2 = await fs.stat(p.join(
|
|
3814
|
+
const s2 = await fs.stat(p.join(path3, index.name));
|
|
3110
3815
|
if (s2 && s2.mtimeMs >= since && (!query || entry.name.toLowerCase().includes(query)))
|
|
3111
|
-
found.set(
|
|
3112
|
-
path,
|
|
3816
|
+
found.set(path3, {
|
|
3817
|
+
path: path3,
|
|
3113
3818
|
foundIn: root.label,
|
|
3114
3819
|
kind: "folder",
|
|
3115
3820
|
modified: new Date(s2.mtimeMs).toISOString(),
|
|
@@ -3120,10 +3825,10 @@ async function findFiles(opts, find = {}) {
|
|
|
3120
3825
|
}
|
|
3121
3826
|
if (!entry.isFile || !LISTED_EXT.test(entry.name)) return;
|
|
3122
3827
|
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,
|
|
3828
|
+
const s = await fs.stat(path3);
|
|
3829
|
+
if (!s || s.mtimeMs < since || found.has(path3)) return;
|
|
3830
|
+
found.set(path3, {
|
|
3831
|
+
path: path3,
|
|
3127
3832
|
foundIn: root.label,
|
|
3128
3833
|
kind: "file",
|
|
3129
3834
|
modified: new Date(s.mtimeMs).toISOString(),
|
|
@@ -3137,16 +3842,18 @@ async function findFiles(opts, find = {}) {
|
|
|
3137
3842
|
}
|
|
3138
3843
|
|
|
3139
3844
|
// 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
|
-
|
|
3845
|
+
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.`;
|
|
3846
|
+
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.`;
|
|
3847
|
+
var mcpLocalInstructions = (w, opts = {}) => [
|
|
3848
|
+
`${w.name} publishes static sites (HTML/CSS/JS, images, documents) at ${w.sitesPattern}. This server runs on the user's computer.`,
|
|
3143
3849
|
"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
3850
|
'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
3851
|
"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
3852
|
"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
3853
|
NEVER_PUBLISHED,
|
|
3148
3854
|
"Not logged in? Call fling_login, let the user finish signing in in the browser, then retry.",
|
|
3149
|
-
|
|
3855
|
+
`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."}`,
|
|
3856
|
+
...opts.comments ? [COMMENTS_NOTE(w)] : []
|
|
3150
3857
|
].join("\n\n");
|
|
3151
3858
|
var toolDef = (def) => ({
|
|
3152
3859
|
title: def.title,
|
|
@@ -3160,6 +3867,8 @@ function createMcpServer(deps = {}) {
|
|
|
3160
3867
|
const cwd = deps.cwd ?? process.cwd();
|
|
3161
3868
|
const locate = deps.locate ?? { ...systemLocateOptions(), cwd };
|
|
3162
3869
|
const waitReady = deps.waitReady ?? createReadyWaiter();
|
|
3870
|
+
const wording = mcpWording(deps.instance);
|
|
3871
|
+
const mcpTools2 = mcpToolsFor(wording);
|
|
3163
3872
|
async function edgeReady(url, paths) {
|
|
3164
3873
|
const r = await waitReady(url, paths).catch(() => ({ ready: false, waitedMs: 0 }));
|
|
3165
3874
|
return {
|
|
@@ -3170,9 +3879,14 @@ Not reachable yet at ${url}: ${NOT_READY_NOTE}.`
|
|
|
3170
3879
|
}
|
|
3171
3880
|
async function client() {
|
|
3172
3881
|
const config = await store.resolve({ serverUrl: deps.server });
|
|
3882
|
+
if (!config.serverUrl) throw new NoServerError("fling");
|
|
3173
3883
|
if (!config.apiKey) throw new CliError("Not logged in", { exitCode: EXIT_NOT_LOGGED_IN });
|
|
3174
3884
|
return createApiClient({ serverUrl: config.serverUrl, apiKey: config.apiKey, fetch: fetchImpl });
|
|
3175
3885
|
}
|
|
3886
|
+
async function sitesBase() {
|
|
3887
|
+
const config = await store.resolve({ serverUrl: deps.server });
|
|
3888
|
+
return sitesBaseOf(await loadInstance(store, config, fetchImpl));
|
|
3889
|
+
}
|
|
3176
3890
|
async function run(op) {
|
|
3177
3891
|
try {
|
|
3178
3892
|
const { summary, data } = await op(await client());
|
|
@@ -3228,9 +3942,15 @@ Warnings:
|
|
|
3228
3942
|
result.url,
|
|
3229
3943
|
local.files.map((f) => f.path)
|
|
3230
3944
|
);
|
|
3945
|
+
const instance = deps.instance;
|
|
3946
|
+
const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
|
|
3947
|
+
const listing = generatedIndex ? " \u2014 with a generated index.html listing the HTML pages" : "";
|
|
3948
|
+
const head = review ? `${what}${listing}.
|
|
3949
|
+
${shareForFeedback(review, instance?.name ?? "Fling")}
|
|
3950
|
+
Site: ${result.url} (${result.site.visibility})` : `${what}: ${result.url} (${result.site.visibility})${listing}`;
|
|
3231
3951
|
return {
|
|
3232
3952
|
kind: "deployed",
|
|
3233
|
-
summary: `${
|
|
3953
|
+
summary: `${head}${warnings}${skippedNote}${edge.note}
|
|
3234
3954
|
To check exactly which files the site has, call fling_files with name "${result.name}".`,
|
|
3235
3955
|
data: {
|
|
3236
3956
|
status: result.status,
|
|
@@ -3239,6 +3959,7 @@ To check exactly which files the site has, call fling_files with name "${result.
|
|
|
3239
3959
|
nameGenerated: !opts.nameGiven,
|
|
3240
3960
|
versionId: result.versionId,
|
|
3241
3961
|
site: result.site,
|
|
3962
|
+
...review ? { reviewUrl: review } : {},
|
|
3242
3963
|
generatedIndex,
|
|
3243
3964
|
warnings: result.warnings,
|
|
3244
3965
|
skipped: local.skipped
|
|
@@ -3247,23 +3968,27 @@ To check exactly which files the site has, call fling_files with name "${result.
|
|
|
3247
3968
|
}
|
|
3248
3969
|
const server = new McpServer(
|
|
3249
3970
|
{ name: "fling", version: VERSION },
|
|
3250
|
-
{
|
|
3971
|
+
{
|
|
3972
|
+
instructions: mcpLocalInstructions(wording, {
|
|
3973
|
+
comments: commentsEnabled(deps.instance)
|
|
3974
|
+
})
|
|
3975
|
+
}
|
|
3251
3976
|
);
|
|
3252
|
-
const { listHtml: listHtml2, spa: spa2, public:
|
|
3977
|
+
const { listHtml: listHtml2, spa: spa2, public: isPublic } = mcpTools2.publish.inputSchema;
|
|
3253
3978
|
server.registerTool(
|
|
3254
3979
|
"fling_deploy",
|
|
3255
3980
|
{
|
|
3256
3981
|
title: "Deploy a file or folder from this computer",
|
|
3257
|
-
description:
|
|
3982
|
+
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
3983
|
inputSchema: {
|
|
3259
|
-
path:
|
|
3984
|
+
path: z11.string().min(1).describe(
|
|
3260
3985
|
"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
3986
|
),
|
|
3262
|
-
name:
|
|
3987
|
+
name: z11.string().optional().describe(
|
|
3263
3988
|
"Site name (lowercase letters, digits, single hyphens; max 50). Defaults to a name generated from the file or folder"
|
|
3264
3989
|
),
|
|
3265
3990
|
spa: spa2,
|
|
3266
|
-
public:
|
|
3991
|
+
public: isPublic,
|
|
3267
3992
|
listHtml: listHtml2
|
|
3268
3993
|
},
|
|
3269
3994
|
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
@@ -3308,9 +4033,9 @@ To check exactly which files the site has, call fling_files with name "${result.
|
|
|
3308
4033
|
title: "Find files to fling",
|
|
3309
4034
|
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
4035
|
inputSchema: {
|
|
3311
|
-
query:
|
|
3312
|
-
limit:
|
|
3313
|
-
days:
|
|
4036
|
+
query: z11.string().optional().describe('Only names containing this text (case-insensitive), e.g. "deck"'),
|
|
4037
|
+
limit: z11.number().int().min(1).max(100).optional().describe("Max results (default 20)"),
|
|
4038
|
+
days: z11.number().int().min(1).max(3650).optional().describe("Only things modified in the last N days (default 14)")
|
|
3314
4039
|
},
|
|
3315
4040
|
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
3316
4041
|
},
|
|
@@ -3337,7 +4062,7 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3337
4062
|
}
|
|
3338
4063
|
}
|
|
3339
4064
|
);
|
|
3340
|
-
const publishDef =
|
|
4065
|
+
const publishDef = mcpTools2.publish;
|
|
3341
4066
|
server.registerTool(
|
|
3342
4067
|
publishDef.name,
|
|
3343
4068
|
{
|
|
@@ -3371,11 +4096,11 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3371
4096
|
server.registerTool(
|
|
3372
4097
|
"fling_check_name",
|
|
3373
4098
|
{
|
|
3374
|
-
...toolDef(
|
|
3375
|
-
description: `${
|
|
4099
|
+
...toolDef(mcpTools2.checkName),
|
|
4100
|
+
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
4101
|
inputSchema: {
|
|
3377
|
-
name:
|
|
3378
|
-
path:
|
|
4102
|
+
name: z11.string().min(1).max(200).optional().describe("Site name to check"),
|
|
4103
|
+
path: z11.string().optional().describe("File or folder whose generated default name to check (path or file name)")
|
|
3379
4104
|
}
|
|
3380
4105
|
},
|
|
3381
4106
|
(args) => run(async (c) => {
|
|
@@ -3388,7 +4113,7 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3388
4113
|
name = defaultNameFor(source.path, source.isDirectory);
|
|
3389
4114
|
}
|
|
3390
4115
|
const check2 = await checkName(c, name);
|
|
3391
|
-
const base = check2.status === "invalid" ? null : await
|
|
4116
|
+
const base = check2.status === "invalid" ? null : await sitesBase();
|
|
3392
4117
|
const url = base ? siteUrlFor(name, base) : null;
|
|
3393
4118
|
const suggestions = check2.suggestions.length ? ` Suggestions: ${check2.suggestions.join(", ")}.` : "";
|
|
3394
4119
|
const summary = {
|
|
@@ -3412,17 +4137,19 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3412
4137
|
"fling_login",
|
|
3413
4138
|
{
|
|
3414
4139
|
title: "Sign in to Fling",
|
|
3415
|
-
description:
|
|
4140
|
+
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
4141
|
inputSchema: {},
|
|
3417
4142
|
annotations: { readOnlyHint: false, openWorldHint: true }
|
|
3418
4143
|
},
|
|
3419
4144
|
async () => {
|
|
3420
4145
|
try {
|
|
3421
4146
|
const config = await store.resolve({ serverUrl: deps.server });
|
|
4147
|
+
const serverUrl = config.serverUrl;
|
|
4148
|
+
if (!serverUrl) throw new NoServerError("fling");
|
|
3422
4149
|
if (config.apiKey && !pendingLogin) {
|
|
3423
4150
|
const me = await whoami(
|
|
3424
4151
|
createApiClient({
|
|
3425
|
-
serverUrl
|
|
4152
|
+
serverUrl,
|
|
3426
4153
|
apiKey: config.apiKey,
|
|
3427
4154
|
fetch: fetchImpl
|
|
3428
4155
|
})
|
|
@@ -3434,12 +4161,14 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3434
4161
|
done: Promise.resolve()
|
|
3435
4162
|
};
|
|
3436
4163
|
login.done = loginFlow({
|
|
3437
|
-
serverUrl
|
|
4164
|
+
serverUrl,
|
|
3438
4165
|
store,
|
|
3439
4166
|
log: silentLogger,
|
|
3440
4167
|
open: deps.open ?? (async (url) => (await import("open")).default(url)),
|
|
3441
4168
|
fetch: fetchImpl,
|
|
3442
4169
|
timeoutMs: deps.loginTimeoutMs,
|
|
4170
|
+
// Signing in to the extension's server leaves the CLI's current server as it is.
|
|
4171
|
+
makeCurrent: !config.current || config.current === serverUrl,
|
|
3443
4172
|
onAuthUrl: (url) => {
|
|
3444
4173
|
login.url = url;
|
|
3445
4174
|
}
|
|
@@ -3469,7 +4198,7 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3469
4198
|
}
|
|
3470
4199
|
}
|
|
3471
4200
|
);
|
|
3472
|
-
const t =
|
|
4201
|
+
const t = mcpTools2;
|
|
3473
4202
|
server.registerTool(
|
|
3474
4203
|
t.list.name,
|
|
3475
4204
|
toolDef(t.list),
|
|
@@ -3554,12 +4283,66 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3554
4283
|
};
|
|
3555
4284
|
})
|
|
3556
4285
|
);
|
|
4286
|
+
if (deps.instance && commentsEnabled(deps.instance)) {
|
|
4287
|
+
const appOrigin = deps.instance.appOrigin;
|
|
4288
|
+
const review = (name) => reviewUrl(appOrigin, name);
|
|
4289
|
+
server.registerTool(
|
|
4290
|
+
t.commentsList.name,
|
|
4291
|
+
toolDef(t.commentsList),
|
|
4292
|
+
(args) => run(async (c) => {
|
|
4293
|
+
const list = await listComments(c, args.name, { path: args.path });
|
|
4294
|
+
const reviewPage2 = review(list.site.name);
|
|
4295
|
+
return {
|
|
4296
|
+
summary: describeComments(list, {
|
|
4297
|
+
includeResolved: args.includeResolved,
|
|
4298
|
+
reviewUrl: reviewPage2
|
|
4299
|
+
}),
|
|
4300
|
+
data: { ...list, reviewUrl: reviewPage2 }
|
|
4301
|
+
};
|
|
4302
|
+
})
|
|
4303
|
+
);
|
|
4304
|
+
server.registerTool(
|
|
4305
|
+
t.commentAdd.name,
|
|
4306
|
+
toolDef(t.commentAdd),
|
|
4307
|
+
(args) => run(async (c) => {
|
|
4308
|
+
const comment = await addComment(c, args.name, {
|
|
4309
|
+
body: args.body,
|
|
4310
|
+
path: args.path,
|
|
4311
|
+
parentId: args.replyTo
|
|
4312
|
+
});
|
|
4313
|
+
const what = comment.parentId ? `Replied to thread ${comment.parentId}` : "Comment added";
|
|
4314
|
+
return {
|
|
4315
|
+
summary: `${what} on ${args.name} (${comment.path}, id ${comment.id}). Review page: ${review(args.name)}`,
|
|
4316
|
+
data: comment
|
|
4317
|
+
};
|
|
4318
|
+
})
|
|
4319
|
+
);
|
|
4320
|
+
server.registerTool(
|
|
4321
|
+
t.commentResolve.name,
|
|
4322
|
+
toolDef(t.commentResolve),
|
|
4323
|
+
(args) => run(async (c) => {
|
|
4324
|
+
const comment = await resolveComment(c, args.name, args.id, !args.reopen);
|
|
4325
|
+
return {
|
|
4326
|
+
summary: `Thread ${comment.id} on ${args.name} ${args.reopen ? "reopened" : "resolved"}`,
|
|
4327
|
+
data: comment
|
|
4328
|
+
};
|
|
4329
|
+
})
|
|
4330
|
+
);
|
|
4331
|
+
server.registerTool(
|
|
4332
|
+
t.commentDelete.name,
|
|
4333
|
+
toolDef(t.commentDelete),
|
|
4334
|
+
(args) => run(async (c) => {
|
|
4335
|
+
const result = await deleteComment(c, args.name, args.id);
|
|
4336
|
+
return { summary: `Comment ${args.id} on ${args.name} deleted`, data: result };
|
|
4337
|
+
})
|
|
4338
|
+
);
|
|
4339
|
+
}
|
|
3557
4340
|
server.registerTool(
|
|
3558
4341
|
t.whoami.name,
|
|
3559
4342
|
toolDef(t.whoami),
|
|
3560
4343
|
() => run(async (c) => {
|
|
3561
4344
|
const me = await whoami(c);
|
|
3562
|
-
const base = await
|
|
4345
|
+
const base = await sitesBase();
|
|
3563
4346
|
return {
|
|
3564
4347
|
summary: `Signed in as ${me.email} (sites are served at ${sitesPattern(base)})`,
|
|
3565
4348
|
data: me
|
|
@@ -3575,24 +4358,24 @@ async function writeInlineFiles(dir, files) {
|
|
|
3575
4358
|
const secretFiles = [];
|
|
3576
4359
|
let total = 0;
|
|
3577
4360
|
for (const f of files) {
|
|
3578
|
-
const
|
|
3579
|
-
const segments =
|
|
3580
|
-
if (
|
|
3581
|
-
skippedDotfiles.push(
|
|
4361
|
+
const path3 = f.path.replace(/^(\.\/)+/, "").replace(/^\/+/, "");
|
|
4362
|
+
const segments = path3.split("/");
|
|
4363
|
+
if (path3 && !path3.includes("\\") && segments.every((s) => s !== "" && s !== "." && s !== "..") && segments.some((s) => s.startsWith("."))) {
|
|
4364
|
+
skippedDotfiles.push(path3);
|
|
3582
4365
|
continue;
|
|
3583
4366
|
}
|
|
3584
|
-
if (!isValidSitePath(
|
|
3585
|
-
if (isSecretPath(
|
|
3586
|
-
secretFiles.push(
|
|
4367
|
+
if (!isValidSitePath(path3)) throw new CliError(`Invalid file path: "${f.path}"`);
|
|
4368
|
+
if (isSecretPath(path3)) {
|
|
4369
|
+
secretFiles.push(path3);
|
|
3587
4370
|
continue;
|
|
3588
4371
|
}
|
|
3589
|
-
if (seen.has(
|
|
3590
|
-
seen.add(
|
|
4372
|
+
if (seen.has(path3)) throw new CliError(`"${path3}" is listed twice`);
|
|
4373
|
+
seen.add(path3);
|
|
3591
4374
|
const bytes = Buffer.from(f.content, f.encoding === "base64" ? "base64" : "utf8");
|
|
3592
4375
|
total += bytes.length;
|
|
3593
4376
|
if (total > MAX_DEPLOY_BYTES)
|
|
3594
4377
|
throw new CliError(`The site is larger than ${formatBytes(MAX_DEPLOY_BYTES)}`);
|
|
3595
|
-
const target = join6(dir, ...
|
|
4378
|
+
const target = join6(dir, ...path3.split("/"));
|
|
3596
4379
|
await mkdir3(dirname2(target), { recursive: true });
|
|
3597
4380
|
await writeFile4(target, bytes);
|
|
3598
4381
|
}
|
|
@@ -3600,6 +4383,7 @@ async function writeInlineFiles(dir, files) {
|
|
|
3600
4383
|
return { skippedDotfiles };
|
|
3601
4384
|
}
|
|
3602
4385
|
function describeError(error) {
|
|
4386
|
+
if (error instanceof NoServerError) return NO_SERVER_HINT;
|
|
3603
4387
|
if (error instanceof CliError && error.exitCode === EXIT_NOT_LOGGED_IN) return LOGIN_HINT;
|
|
3604
4388
|
if (error instanceof CliApiError && error.status === 401) return LOGIN_HINT;
|
|
3605
4389
|
if (error instanceof CliError)
|
|
@@ -3608,7 +4392,9 @@ ${error.hint}` : error.message;
|
|
|
3608
4392
|
return error instanceof Error ? error.message : String(error);
|
|
3609
4393
|
}
|
|
3610
4394
|
async function runMcpServer(deps = {}) {
|
|
3611
|
-
const
|
|
4395
|
+
const store = deps.store ?? createConfigStore();
|
|
4396
|
+
const instance = deps.instance ?? await store.resolve({ serverUrl: deps.server }).then((config) => loadInstance(store, config, deps.fetch ?? ((i, init) => fetch(i, init)))).catch(() => null);
|
|
4397
|
+
const server = createMcpServer({ ...deps, store, instance });
|
|
3612
4398
|
await server.connect(new StdioServerTransport());
|
|
3613
4399
|
}
|
|
3614
4400
|
|
|
@@ -3616,7 +4402,7 @@ async function runMcpServer(deps = {}) {
|
|
|
3616
4402
|
import { stat as stat6 } from "node:fs/promises";
|
|
3617
4403
|
import { resolve as resolve4 } from "node:path";
|
|
3618
4404
|
var PASS_THROUGH = /* @__PURE__ */ new Set(["-h", "--help", "-V", "--version"]);
|
|
3619
|
-
var pathExists = (
|
|
4405
|
+
var pathExists = (path3) => stat6(resolve4(path3)).then(
|
|
3620
4406
|
() => true,
|
|
3621
4407
|
() => false
|
|
3622
4408
|
);
|
|
@@ -3664,30 +4450,32 @@ function routeOptionsFor(program2) {
|
|
|
3664
4450
|
var program = new Command();
|
|
3665
4451
|
program.name(BIN_NAME).description("Fling a folder of static files at Fling and get a link back").version(VERSION).option(
|
|
3666
4452
|
"--server <url>",
|
|
3667
|
-
`server URL (default: $${ENV.url}
|
|
4453
|
+
`server for this command: a URL, or the host of one you signed in to (default: $${ENV.url}, else the current one)`
|
|
3668
4454
|
).option("--json", "print JSON instead of human output", false).showHelpAfterError().configureHelp({
|
|
3669
4455
|
sortSubcommands: false,
|
|
3670
|
-
styleTitle: (title) =>
|
|
3671
|
-
styleCommandText: (text2) =>
|
|
4456
|
+
styleTitle: (title) => chalk10.bold(brand.primary(title)),
|
|
4457
|
+
styleCommandText: (text2) => chalk10.bold(text2),
|
|
3672
4458
|
styleSubcommandText: (text2) => brand.accent(text2),
|
|
3673
4459
|
styleOptionText: (text2) => brand.accent(text2),
|
|
3674
|
-
styleArgumentText: (text2) =>
|
|
4460
|
+
styleArgumentText: (text2) => chalk10.dim(text2)
|
|
3675
4461
|
}).addHelpText(
|
|
3676
4462
|
"before",
|
|
3677
4463
|
() => process.stdout.isTTY ? logo({ columns: process.stdout.columns }) : ""
|
|
3678
4464
|
).addHelpText(
|
|
3679
4465
|
"after",
|
|
3680
4466
|
() => `
|
|
3681
|
-
${
|
|
4467
|
+
${chalk10.bold(brand.primary("Examples:"))}
|
|
4468
|
+
$ ${BIN_NAME} login https://fling.example.com sign in to your Fling (once; there is no default)
|
|
3682
4469
|
$ ${BIN_NAME} setup sign in and add Fling to Claude Code / Claude Desktop
|
|
3683
|
-
$ ${BIN_NAME}
|
|
4470
|
+
$ ${BIN_NAME} use list the Flings you signed in to; \`use <url>\` switches
|
|
4471
|
+
$ ${BIN_NAME} deploy ./dist --name my-app \u2192 https://my-app.<your sites domain>
|
|
3684
4472
|
$ ${BIN_NAME} ./dist the same: an existing path deploys
|
|
3685
4473
|
$ ${BIN_NAME} ./dist --yes no prompts (CI): use the generated name, fail if it is taken
|
|
3686
4474
|
$ ${BIN_NAME} "Pitch Deck.html" a single page
|
|
3687
4475
|
$ ${BIN_NAME} site.zip a zip of a site
|
|
3688
|
-
$ ${BIN_NAME} share my-app public link for people
|
|
4476
|
+
$ ${BIN_NAME} share my-app public link for people who can't sign in
|
|
3689
4477
|
|
|
3690
|
-
${
|
|
4478
|
+
${chalk10.dim(`Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
|
|
3691
4479
|
Env: ${ENV.apiKey} \xB7 ${ENV.url} \xB7 ${ENV.configDir} \xB7 ${ENV.debug}`)}`
|
|
3692
4480
|
);
|
|
3693
4481
|
function action(handler) {
|
|
@@ -3731,25 +4519,36 @@ program.command("deploy [path]").helpGroup("Deploy:").description(
|
|
|
3731
4519
|
).option(
|
|
3732
4520
|
"-n, --name <name>",
|
|
3733
4521
|
"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:
|
|
4522
|
+
).option("--spa", "single-page app: serve index.html for unknown paths").option("--public", "make the site public (default: sign-in required)").option(
|
|
3735
4523
|
"-y, --yes",
|
|
3736
4524
|
"never prompt: accept the generated name; without index.html, list the HTML pages (or fling anyway if there are none)"
|
|
3737
4525
|
).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
4526
|
action(
|
|
3739
|
-
(ctx,
|
|
4527
|
+
(ctx, path3, opts) => runDeploy(ctx, path3, opts)
|
|
3740
4528
|
)
|
|
3741
4529
|
);
|
|
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
|
-
|
|
4530
|
+
program.command("setup [url]").helpGroup("Setup:").description(
|
|
4531
|
+
"sign in (to the Fling at url, which becomes current), then add the Fling plugin to Claude Code and the extension to Claude Desktop"
|
|
4532
|
+
).option("-y, --yes", "do every step without asking").option("-s, --scope <scope>", "Claude Code plugin scope: user, project or local", "user").action(
|
|
4533
|
+
action(
|
|
4534
|
+
(ctx, url, opts) => runSetup(ctx, { ...opts, url })
|
|
4535
|
+
)
|
|
4536
|
+
);
|
|
4537
|
+
program.command("login [url]").helpGroup("Setup:").description(
|
|
4538
|
+
"sign in through the browser to the Fling at url and store an API key; it becomes the current server (default: the current one)"
|
|
4539
|
+
).action(
|
|
4540
|
+
action(async (ctx, url) => {
|
|
4541
|
+
await runLogin(ctx, url);
|
|
3748
4542
|
ctx.log.hint(`Next: \`${BIN_NAME} setup\` adds Fling to Claude Code and Claude Desktop.`);
|
|
3749
4543
|
})
|
|
3750
4544
|
);
|
|
3751
|
-
program.command("
|
|
3752
|
-
|
|
4545
|
+
program.command("use [url]").helpGroup("Setup:").description(
|
|
4546
|
+
"switch to another Fling you signed in to (a URL or its host); without url, list them"
|
|
4547
|
+
).action(action((ctx, url) => runUse(ctx, url)));
|
|
4548
|
+
program.command("logout [url]").helpGroup("Setup:").description(
|
|
4549
|
+
"remove a server's API key (default: the current server's, which stays current); any other server is forgotten"
|
|
4550
|
+
).action(action((ctx, url) => runLogout(ctx, url)));
|
|
4551
|
+
program.command("whoami").helpGroup("Setup:").description("show the signed-in user and the current server").action(action((ctx) => runWhoami(ctx)));
|
|
3753
4552
|
program.commandsGroup("Setup:").helpCommand("help [command]", "show help for a command");
|
|
3754
4553
|
program.command("ls").helpGroup("Sites:").alias("list").description("list your sites").option("--archived", "list archived sites").action(action((ctx, opts) => runList(ctx, opts)));
|
|
3755
4554
|
program.command("info <name>").helpGroup("Sites:").description("show a site").action(action((ctx, name) => runInfo(ctx, name)));
|
|
@@ -3763,10 +4562,30 @@ program.command("share <name>").helpGroup("Sites:").description("print (or creat
|
|
|
3763
4562
|
(ctx, name, opts) => runShare(ctx, name, opts)
|
|
3764
4563
|
)
|
|
3765
4564
|
);
|
|
3766
|
-
program.command("visibility <name> <visibility>").helpGroup("Sites:").description("private (
|
|
4565
|
+
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
4566
|
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
4567
|
program.command("unarchive <name>").helpGroup("Sites:").description("bring an archived site back").action(action((ctx, name) => runArchive(ctx, name, true)));
|
|
3769
4568
|
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)));
|
|
4569
|
+
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(
|
|
4570
|
+
action(
|
|
4571
|
+
(ctx, name, opts) => runComments(ctx, name, opts)
|
|
4572
|
+
)
|
|
4573
|
+
);
|
|
4574
|
+
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(
|
|
4575
|
+
action(
|
|
4576
|
+
(ctx, name, text2, opts) => runComment(ctx, name, text2, opts)
|
|
4577
|
+
)
|
|
4578
|
+
);
|
|
4579
|
+
program.command("resolve <name> <id>").helpGroup("Comments:").description("mark a comment thread resolved").option("--reopen", "reopen it instead").action(
|
|
4580
|
+
action(
|
|
4581
|
+
(ctx, name, id, opts) => runResolve(ctx, name, id, opts)
|
|
4582
|
+
)
|
|
4583
|
+
);
|
|
4584
|
+
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(
|
|
4585
|
+
action(
|
|
4586
|
+
(ctx, name, id, opts) => runUncomment(ctx, name, id, opts)
|
|
4587
|
+
)
|
|
4588
|
+
);
|
|
3770
4589
|
program.command("mcp").helpGroup("Agents:").description("run the MCP server on stdio (used by the Claude Code plugin)").action(async (_opts, command) => {
|
|
3771
4590
|
const globals = command.optsWithGlobals();
|
|
3772
4591
|
await runMcpServer({ server: globals.server });
|