@nearform/fling 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/LICENSE +1 -1
- package/README.md +81 -41
- package/dist/cli.js +1812 -829
- package/dist/cli.js.map +4 -4
- package/package.json +5 -3
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
+
import chalk10 from "chalk";
|
|
4
5
|
import { Command } from "commander";
|
|
5
6
|
|
|
6
7
|
// src/commands/auth.ts
|
|
7
|
-
import
|
|
8
|
+
import chalk5 from "chalk";
|
|
8
9
|
|
|
9
10
|
// src/auth.ts
|
|
10
11
|
import { randomBytes } from "node:crypto";
|
|
@@ -24,7 +25,7 @@ var MAX_FILES = 5e3;
|
|
|
24
25
|
var MAX_PATH_LENGTH = 1024;
|
|
25
26
|
|
|
26
27
|
// ../../packages/shared/src/secrets.ts
|
|
27
|
-
var SECRET_SCAN_MAX_FILE_BYTES =
|
|
28
|
+
var SECRET_SCAN_MAX_FILE_BYTES = MAX_FILE_BYTES;
|
|
28
29
|
var BINARY_SNIFF_BYTES = 8192;
|
|
29
30
|
var SECRET_EXTENSIONS = [
|
|
30
31
|
".pem",
|
|
@@ -49,8 +50,8 @@ var SECRET_NAMES = /* @__PURE__ */ new Set([
|
|
|
49
50
|
".htpasswd"
|
|
50
51
|
]);
|
|
51
52
|
var SSH_KEY_RE = /^id_(?:rsa|dsa|ecdsa|ed25519)(?:[._-]|$)/;
|
|
52
|
-
function isSecretPath(
|
|
53
|
-
const name = (
|
|
53
|
+
function isSecretPath(path3) {
|
|
54
|
+
const name = (path3.split(/[\\/]/).at(-1) ?? "").toLowerCase();
|
|
54
55
|
if (!name) return false;
|
|
55
56
|
if (SECRET_NAMES.has(name)) return true;
|
|
56
57
|
if (SECRET_EXTENSIONS.some((ext) => name.endsWith(ext) && name.length > ext.length)) return true;
|
|
@@ -160,24 +161,24 @@ function lineAt(starts, index) {
|
|
|
160
161
|
}
|
|
161
162
|
return lo + 1;
|
|
162
163
|
}
|
|
163
|
-
function scanForSecrets(
|
|
164
|
+
function scanForSecrets(text2, path3) {
|
|
164
165
|
let starts;
|
|
165
166
|
const findings = [];
|
|
166
167
|
const seen = /* @__PURE__ */ new Set();
|
|
167
168
|
for (const rule of RULES) {
|
|
168
169
|
rule.re.lastIndex = 0;
|
|
169
|
-
for (let m = rule.re.exec(
|
|
170
|
+
for (let m = rule.re.exec(text2); m !== null; m = rule.re.exec(text2)) {
|
|
170
171
|
if (m[0].length === 0) rule.re.lastIndex++;
|
|
171
172
|
if (rule.allow?.(m)) continue;
|
|
172
173
|
if (!starts) {
|
|
173
174
|
starts = [0];
|
|
174
|
-
for (let i =
|
|
175
|
+
for (let i = text2.indexOf("\n"); i !== -1; i = text2.indexOf("\n", i + 1)) starts.push(i + 1);
|
|
175
176
|
}
|
|
176
177
|
const line = lineAt(starts, m.index);
|
|
177
178
|
const key = `${rule.id}:${line}`;
|
|
178
179
|
if (seen.has(key)) continue;
|
|
179
180
|
seen.add(key);
|
|
180
|
-
findings.push({ path, line, rule: rule.id });
|
|
181
|
+
findings.push({ path: path3, line, rule: rule.id });
|
|
181
182
|
}
|
|
182
183
|
}
|
|
183
184
|
return findings.sort((a, b) => a.line - b.line || a.rule.localeCompare(b.rule));
|
|
@@ -185,8 +186,8 @@ function scanForSecrets(text, path) {
|
|
|
185
186
|
var BINARY_EXTENSIONS = new Set(
|
|
186
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)
|
|
187
188
|
);
|
|
188
|
-
function isScannablePath(
|
|
189
|
-
const name =
|
|
189
|
+
function isScannablePath(path3) {
|
|
190
|
+
const name = path3.split("/").at(-1) ?? "";
|
|
190
191
|
const dot = name.lastIndexOf(".");
|
|
191
192
|
return dot <= 0 || !BINARY_EXTENSIONS.has(name.slice(dot + 1).toLowerCase());
|
|
192
193
|
}
|
|
@@ -195,10 +196,10 @@ function looksBinary(bytes) {
|
|
|
195
196
|
for (let i = 0; i < end; i++) if (bytes[i] === 0) return true;
|
|
196
197
|
return false;
|
|
197
198
|
}
|
|
198
|
-
function scanBytesForSecrets(bytes,
|
|
199
|
-
if (!isScannablePath(
|
|
199
|
+
function scanBytesForSecrets(bytes, path3) {
|
|
200
|
+
if (!isScannablePath(path3) || bytes.length > SECRET_SCAN_MAX_FILE_BYTES || looksBinary(bytes))
|
|
200
201
|
return [];
|
|
201
|
-
return scanForSecrets(new TextDecoder("utf-8").decode(bytes),
|
|
202
|
+
return scanForSecrets(new TextDecoder("utf-8").decode(bytes), path3);
|
|
202
203
|
}
|
|
203
204
|
function describeSecretFindings(findings, max = 20) {
|
|
204
205
|
const lines = findings.slice(0, max).map((f) => `${f.path} line ${f.line}: ${SECRET_RULE_LABELS[f.rule] ?? f.rule}`);
|
|
@@ -208,10 +209,10 @@ function describeSecretFindings(findings, max = 20) {
|
|
|
208
209
|
var NEVER_PUBLISHED_NOTE = "Fling never publishes dotfiles, gitignored files, secret files (keys, certificates, credentials) or files that contain secrets.";
|
|
209
210
|
|
|
210
211
|
// ../../packages/shared/src/manifest.ts
|
|
211
|
-
function isValidSitePath(
|
|
212
|
-
if (!
|
|
213
|
-
if (
|
|
214
|
-
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("."));
|
|
215
216
|
}
|
|
216
217
|
var ManifestFileSchema = z.object({
|
|
217
218
|
path: z.string().refine(isValidSitePath, "invalid path (dotfiles are never published)").refine((p) => !isSecretPath(p), "secret file (keys, certificates, credentials) never published"),
|
|
@@ -227,7 +228,13 @@ var MeSchema = z2.object({
|
|
|
227
228
|
email: z2.string(),
|
|
228
229
|
name: z2.string(),
|
|
229
230
|
picture: z2.string().nullable(),
|
|
230
|
-
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()
|
|
231
238
|
});
|
|
232
239
|
var SiteSchema = z2.object({
|
|
233
240
|
name: z2.string(),
|
|
@@ -329,8 +336,122 @@ var OAuthRequestInfoSchema = z2.object({
|
|
|
329
336
|
email: z2.string()
|
|
330
337
|
});
|
|
331
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) });
|
|
332
366
|
|
|
333
|
-
// ../../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
|
|
334
455
|
var SITE_NAME_MAX = 50;
|
|
335
456
|
var SHARE_TOKEN_LENGTH = 20;
|
|
336
457
|
var RESERVED_SITE_NAMES = [
|
|
@@ -351,26 +472,92 @@ var RESERVED_SITE_NAMES = [
|
|
|
351
472
|
];
|
|
352
473
|
var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
353
474
|
var SHARE_TOKEN_RE = new RegExp(`^[a-z0-9]{${SHARE_TOKEN_LENGTH}}$`);
|
|
354
|
-
function slugify(input, max = SITE_NAME_MAX) {
|
|
355
|
-
return input.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/g, "");
|
|
356
|
-
}
|
|
357
475
|
function isValidSiteName(name) {
|
|
358
476
|
return name.length <= SITE_NAME_MAX && SLUG_RE.test(name) && !name.includes("--") && !RESERVED_SITE_NAMES.includes(name);
|
|
359
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
|
+
}
|
|
360
483
|
function siteUrl(label, sitesDomain, protocol = "https:") {
|
|
361
484
|
return `${protocol}//${label}.${sitesDomain}/`;
|
|
362
485
|
}
|
|
363
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
|
+
|
|
364
551
|
// ../../packages/shared/src/kvs.ts
|
|
365
|
-
import { z as
|
|
366
|
-
var KvsSiteValueSchema =
|
|
552
|
+
import { z as z5 } from "zod";
|
|
553
|
+
var KvsSiteValueSchema = z5.object({
|
|
367
554
|
/** siteId — S3 prefix is `sites/<id>/<v>/` */
|
|
368
|
-
id:
|
|
555
|
+
id: z5.string(),
|
|
369
556
|
/** current versionId */
|
|
370
|
-
v:
|
|
371
|
-
vis:
|
|
557
|
+
v: z5.string(),
|
|
558
|
+
vis: z5.enum(["private", "public"]),
|
|
372
559
|
/** serve /index.html for unknown extensionless paths */
|
|
373
|
-
spa:
|
|
560
|
+
spa: z5.boolean()
|
|
374
561
|
});
|
|
375
562
|
|
|
376
563
|
// ../../packages/shared/src/listing.ts
|
|
@@ -379,9 +566,9 @@ var escapeHtml = (s) => s.replace(
|
|
|
379
566
|
/[&<>"']/g,
|
|
380
567
|
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]
|
|
381
568
|
);
|
|
382
|
-
var hrefFor = (
|
|
383
|
-
function isHtmlPath(
|
|
384
|
-
return /\.html?$/i.test(
|
|
569
|
+
var hrefFor = (path3) => path3.split("/").map(encodeURIComponent).join("/");
|
|
570
|
+
function isHtmlPath(path3) {
|
|
571
|
+
return /\.html?$/i.test(path3);
|
|
385
572
|
}
|
|
386
573
|
function extractHtmlTitle(html) {
|
|
387
574
|
const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html.slice(0, 65536));
|
|
@@ -454,181 +641,238 @@ ${body}
|
|
|
454
641
|
}
|
|
455
642
|
|
|
456
643
|
// ../../packages/shared/src/mcp-tools.ts
|
|
457
|
-
import { z as
|
|
644
|
+
import { z as z6 } from "zod";
|
|
458
645
|
var MCP_PUBLISH_MAX_BYTES = 4 * 1024 * 1024;
|
|
459
646
|
var MCP_UPLOAD_CHUNK_MAX_CHARS = 4 * 1024 * 1024;
|
|
460
647
|
var MCP_UPLOAD_TTL_SECONDS = 60 * 60;
|
|
461
648
|
var tool = (def) => def;
|
|
462
|
-
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
|
+
}
|
|
463
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.';
|
|
464
|
-
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.";
|
|
465
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.";
|
|
466
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.";
|
|
467
671
|
var ENCODING = '`encoding` is "utf8" (default, for text: HTML, CSS, JS, SVG, JSON, Markdown\u2026) or "base64" (binary: images, fonts, PDFs).';
|
|
468
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.";
|
|
469
|
-
var listHtml =
|
|
673
|
+
var listHtml = z6.boolean().optional().describe(
|
|
470
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"
|
|
471
675
|
);
|
|
472
|
-
var siteName =
|
|
473
|
-
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(
|
|
474
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")'
|
|
475
679
|
);
|
|
476
|
-
var
|
|
477
|
-
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(
|
|
478
684
|
"Single-page app: serve index.html for unknown paths (client-side routing). Omit to keep the current setting"
|
|
479
685
|
);
|
|
480
|
-
|
|
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
|
-
files:
|
|
531
|
-
|
|
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
|
-
path:
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
listHtml
|
|
570
|
-
|
|
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
|
-
|
|
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).`;
|
|
632
876
|
var MCP_FILES_LISTED = 200;
|
|
633
877
|
function describeVersionFiles(name, files, opts) {
|
|
634
878
|
const kb = (n2) => n2 < 1024 ? `${n2} B` : n2 < 1024 * 1024 ? `${(n2 / 1024).toFixed(1)} KB` : `${(n2 / 1024 / 1024).toFixed(1)} MB`;
|
|
@@ -650,12 +894,30 @@ function describeVersionFiles(name, files, opts) {
|
|
|
650
894
|
if (n > MCP_FILES_LISTED) lines.push(`\u2026 and ${n - MCP_FILES_LISTED} more`);
|
|
651
895
|
return lines.join("\n");
|
|
652
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
|
+
}
|
|
653
915
|
|
|
654
916
|
// ../../packages/shared/src/ready.ts
|
|
655
917
|
var READY_TIMEOUT_MS = 15e3;
|
|
656
918
|
var NOT_READY_NOTE = "it can take a few more seconds to appear everywhere; reload if you see a 404";
|
|
657
919
|
var isReachableStatus = (status) => status >= 200 && status < 400 || status === 401 || status === 403;
|
|
658
|
-
var defaultSleep = (ms) => new Promise((
|
|
920
|
+
var defaultSleep = (ms) => new Promise((resolve5) => setTimeout(resolve5, ms));
|
|
659
921
|
async function waitUntilReachable(url, opts = {}) {
|
|
660
922
|
const timeoutMs = opts.timeoutMs ?? READY_TIMEOUT_MS;
|
|
661
923
|
const doFetch = opts.fetch ?? ((u, init) => fetch(u, init));
|
|
@@ -698,6 +960,95 @@ function readinessWaitDisabled(env) {
|
|
|
698
960
|
return env.FLING_NO_WAIT === "1" || env.VITEST === "true" || env.NODE_ENV === "test";
|
|
699
961
|
}
|
|
700
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
|
+
|
|
701
1052
|
// src/errors.ts
|
|
702
1053
|
var EXIT_ERROR = 1;
|
|
703
1054
|
var EXIT_NOT_LOGGED_IN = 2;
|
|
@@ -722,6 +1073,14 @@ var NotLoggedInError = class extends CliError {
|
|
|
722
1073
|
this.name = "NotLoggedInError";
|
|
723
1074
|
}
|
|
724
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
|
+
};
|
|
725
1084
|
function exitCodeFor(error) {
|
|
726
1085
|
return error instanceof CliError ? error.exitCode : EXIT_ERROR;
|
|
727
1086
|
}
|
|
@@ -729,9 +1088,9 @@ function exitCodeFor(error) {
|
|
|
729
1088
|
// package.json
|
|
730
1089
|
var package_default = {
|
|
731
1090
|
name: "@nearform/fling",
|
|
732
|
-
version: "0.
|
|
1091
|
+
version: "0.3.0",
|
|
733
1092
|
license: "MIT",
|
|
734
|
-
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.",
|
|
735
1094
|
type: "module",
|
|
736
1095
|
bin: {
|
|
737
1096
|
fling: "./dist/cli.js"
|
|
@@ -739,7 +1098,8 @@ var package_default = {
|
|
|
739
1098
|
files: [
|
|
740
1099
|
"dist/cli.js",
|
|
741
1100
|
"dist/cli.js.map",
|
|
742
|
-
"README.md"
|
|
1101
|
+
"README.md",
|
|
1102
|
+
"CHANGELOG.md"
|
|
743
1103
|
],
|
|
744
1104
|
engines: {
|
|
745
1105
|
node: ">=22"
|
|
@@ -760,6 +1120,7 @@ var package_default = {
|
|
|
760
1120
|
test: "vitest run"
|
|
761
1121
|
},
|
|
762
1122
|
dependencies: {
|
|
1123
|
+
"@clack/prompts": "^1.8.1",
|
|
763
1124
|
"@modelcontextprotocol/sdk": "^1.20.0",
|
|
764
1125
|
chalk: "^5.6.2",
|
|
765
1126
|
commander: "^15.0.0",
|
|
@@ -812,8 +1173,8 @@ function defaultHint(status) {
|
|
|
812
1173
|
if (status === 403) return "You are not allowed to do that.";
|
|
813
1174
|
return void 0;
|
|
814
1175
|
}
|
|
815
|
-
function buildUrl(serverUrl,
|
|
816
|
-
const url = new URL(`${serverUrl.replace(/\/+$/, "")}${
|
|
1176
|
+
function buildUrl(serverUrl, path3, query) {
|
|
1177
|
+
const url = new URL(`${serverUrl.replace(/\/+$/, "")}${path3.startsWith("/") ? path3 : `/${path3}`}`);
|
|
817
1178
|
for (const [key, value] of Object.entries(query ?? {})) {
|
|
818
1179
|
if (value !== void 0 && value !== null) url.searchParams.set(key, String(value));
|
|
819
1180
|
}
|
|
@@ -823,8 +1184,8 @@ function createApiClient(options) {
|
|
|
823
1184
|
const serverUrl = options.serverUrl.replace(/\/+$/, "");
|
|
824
1185
|
const fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
|
|
825
1186
|
const timeoutMs = options.timeoutMs ?? 6e4;
|
|
826
|
-
async function request(method,
|
|
827
|
-
const url = buildUrl(serverUrl,
|
|
1187
|
+
async function request(method, path3, reqOptions = {}) {
|
|
1188
|
+
const url = buildUrl(serverUrl, path3, reqOptions.query);
|
|
828
1189
|
const headers = {
|
|
829
1190
|
Accept: "application/json",
|
|
830
1191
|
"User-Agent": `${BIN_NAME}-cli/${VERSION}`
|
|
@@ -849,7 +1210,7 @@ function createApiClient(options) {
|
|
|
849
1210
|
});
|
|
850
1211
|
}
|
|
851
1212
|
const raw = await readBody(response);
|
|
852
|
-
if (!response.ok) throw errorFromResponse(response.status, raw, method,
|
|
1213
|
+
if (!response.ok) throw errorFromResponse(response.status, raw, method, path3);
|
|
853
1214
|
if (!reqOptions.schema) return raw;
|
|
854
1215
|
const parsed = reqOptions.schema.safeParse(raw);
|
|
855
1216
|
if (!parsed.success) {
|
|
@@ -859,8 +1220,8 @@ function createApiClient(options) {
|
|
|
859
1220
|
status: response.status,
|
|
860
1221
|
code: "invalid_response",
|
|
861
1222
|
body: raw,
|
|
862
|
-
message: `Unexpected response from ${method} ${
|
|
863
|
-
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\`.`
|
|
864
1225
|
});
|
|
865
1226
|
}
|
|
866
1227
|
return parsed.data;
|
|
@@ -893,15 +1254,15 @@ async function putUpload(fetchImpl, url, headers, body, timeoutMs = 5 * 6e4) {
|
|
|
893
1254
|
}
|
|
894
1255
|
async function readBody(response) {
|
|
895
1256
|
if (response.status === 204) return void 0;
|
|
896
|
-
const
|
|
897
|
-
if (
|
|
1257
|
+
const text2 = await response.text();
|
|
1258
|
+
if (text2 === "") return void 0;
|
|
898
1259
|
try {
|
|
899
|
-
return JSON.parse(
|
|
1260
|
+
return JSON.parse(text2);
|
|
900
1261
|
} catch {
|
|
901
|
-
return
|
|
1262
|
+
return text2;
|
|
902
1263
|
}
|
|
903
1264
|
}
|
|
904
|
-
function errorFromResponse(status, body, method,
|
|
1265
|
+
function errorFromResponse(status, body, method, path3) {
|
|
905
1266
|
const envelope = ErrorEnvelopeSchema.safeParse(body);
|
|
906
1267
|
if (envelope.success) {
|
|
907
1268
|
const { code } = envelope.data.error;
|
|
@@ -917,12 +1278,12 @@ function errorFromResponse(status, body, method, path) {
|
|
|
917
1278
|
if (Array.isArray(extra?.paths)) Object.assign(error, { paths: extra.paths });
|
|
918
1279
|
return error;
|
|
919
1280
|
}
|
|
920
|
-
return new CliApiError({ status, body, message: `${method} ${
|
|
1281
|
+
return new CliApiError({ status, body, message: `${method} ${path3} failed with HTTP ${status}` });
|
|
921
1282
|
}
|
|
922
1283
|
var NEVER_PUBLISHED_HINTS = {
|
|
923
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.",
|
|
924
1285
|
secret_file: "Move the secret files out of the folder, or add them to .flingignore if the site does not need them.",
|
|
925
|
-
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.`
|
|
926
1287
|
};
|
|
927
1288
|
function describeCause(cause) {
|
|
928
1289
|
if (cause instanceof Error) {
|
|
@@ -938,10 +1299,9 @@ function describeCause(cause) {
|
|
|
938
1299
|
import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
939
1300
|
import { homedir } from "node:os";
|
|
940
1301
|
import { join } from "node:path";
|
|
941
|
-
import { z as
|
|
1302
|
+
import { z as z9 } from "zod";
|
|
942
1303
|
var CONFIG_DIR_NAME = ".fling";
|
|
943
1304
|
var CONFIG_FILE_NAME = "config.json";
|
|
944
|
-
var DEFAULT_SERVER_URL = "https://fling.labs.nearform.com";
|
|
945
1305
|
var ENV = {
|
|
946
1306
|
apiKey: "FLING_API_KEY",
|
|
947
1307
|
url: "FLING_URL",
|
|
@@ -950,13 +1310,15 @@ var ENV = {
|
|
|
950
1310
|
/** Where to look for files given by name (the .mcpb extension sets it from its settings). */
|
|
951
1311
|
downloadsDir: "FLING_DOWNLOADS_DIR"
|
|
952
1312
|
};
|
|
953
|
-
var cliConfigSchema =
|
|
954
|
-
serverUrl:
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1313
|
+
var cliConfigSchema = z9.object({
|
|
1314
|
+
serverUrl: z9.string().url().optional(),
|
|
1315
|
+
/** What `serverUrl` said about itself at login. A copy a newer CLI can't read is just refetched. */
|
|
1316
|
+
instance: InstanceMetadataSchema.optional().catch(void 0),
|
|
1317
|
+
apiKey: z9.string().min(1).optional(),
|
|
1318
|
+
user: z9.object({
|
|
1319
|
+
email: z9.string().optional(),
|
|
1320
|
+
name: z9.string().optional(),
|
|
1321
|
+
userSlug: z9.string().optional()
|
|
960
1322
|
}).optional()
|
|
961
1323
|
});
|
|
962
1324
|
function systemLocateOptions(env = process.env) {
|
|
@@ -1012,18 +1374,21 @@ function createConfigStore(options = {}) {
|
|
|
1012
1374
|
return next;
|
|
1013
1375
|
},
|
|
1014
1376
|
async clearCredentials() {
|
|
1015
|
-
const { serverUrl } = await load();
|
|
1016
|
-
await save(
|
|
1377
|
+
const { serverUrl, instance } = await load();
|
|
1378
|
+
await save({ serverUrl, instance });
|
|
1017
1379
|
},
|
|
1018
1380
|
async resolve(overrides = {}) {
|
|
1019
1381
|
const config = await load();
|
|
1020
1382
|
const envUrl = fromEnv(env[ENV.url]);
|
|
1021
1383
|
const envKey = fromEnv(env[ENV.apiKey]);
|
|
1022
|
-
const [
|
|
1384
|
+
const [rawUrl, serverUrlSource] = overrides.serverUrl ? [overrides.serverUrl, "flag"] : envUrl ? [envUrl, "env"] : config.serverUrl ? [config.serverUrl, "config"] : [void 0, "none"];
|
|
1385
|
+
const serverUrl = rawUrl === void 0 ? void 0 : normalizeServerUrl(rawUrl);
|
|
1023
1386
|
const [apiKey, apiKeySource] = envKey ? [envKey, "env"] : config.apiKey ? [config.apiKey, "config"] : [void 0, "none"];
|
|
1387
|
+
const stored = config.serverUrl && normalizeServerUrl(config.serverUrl);
|
|
1024
1388
|
return {
|
|
1025
|
-
serverUrl
|
|
1389
|
+
serverUrl,
|
|
1026
1390
|
serverUrlSource,
|
|
1391
|
+
instance: serverUrl && stored === serverUrl ? config.instance : void 0,
|
|
1027
1392
|
apiKey,
|
|
1028
1393
|
apiKeySource,
|
|
1029
1394
|
user: config.user,
|
|
@@ -1032,12 +1397,53 @@ function createConfigStore(options = {}) {
|
|
|
1032
1397
|
}
|
|
1033
1398
|
};
|
|
1034
1399
|
}
|
|
1400
|
+
function normalizeServerUrl(input) {
|
|
1401
|
+
const trimmed = input.trim();
|
|
1402
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
1403
|
+
const url = new URL(withScheme);
|
|
1404
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
1405
|
+
throw new Error(`Not an http(s) URL: ${input}`);
|
|
1406
|
+
}
|
|
1407
|
+
return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
|
|
1408
|
+
}
|
|
1035
1409
|
var REDACTED_KEY_CHARS = 10;
|
|
1036
1410
|
function redactKey(key) {
|
|
1037
1411
|
if (!key) return "-";
|
|
1038
1412
|
return key.length <= REDACTED_KEY_CHARS + 8 ? "****" : `${key.slice(0, REDACTED_KEY_CHARS)}\u2026`;
|
|
1039
1413
|
}
|
|
1040
1414
|
|
|
1415
|
+
// src/instance.ts
|
|
1416
|
+
async function fetchInstance(serverUrl, fetch2) {
|
|
1417
|
+
const url = instanceMetadataUrl(serverUrl);
|
|
1418
|
+
let response;
|
|
1419
|
+
try {
|
|
1420
|
+
response = await fetch2(url, { headers: { Accept: "application/json" } });
|
|
1421
|
+
} catch (cause) {
|
|
1422
|
+
throw new CliError(`Could not reach ${serverUrl} (${cause.message ?? cause})`, {
|
|
1423
|
+
hint: "Check the address and your connection.",
|
|
1424
|
+
cause
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
const notFling = () => new CliError(`${serverUrl} does not look like a Fling server (no valid ${url})`, {
|
|
1428
|
+
hint: "Use the address of your Fling, e.g. https://fling.example.com."
|
|
1429
|
+
});
|
|
1430
|
+
if (!response.ok) throw notFling();
|
|
1431
|
+
const parsed = InstanceMetadataSchema.safeParse(await response.json().catch(() => null));
|
|
1432
|
+
if (!parsed.success) throw notFling();
|
|
1433
|
+
return parsed.data;
|
|
1434
|
+
}
|
|
1435
|
+
async function loadInstance(store, config, fetch2) {
|
|
1436
|
+
if (config.instance) return config.instance;
|
|
1437
|
+
if (!config.serverUrl) return null;
|
|
1438
|
+
try {
|
|
1439
|
+
const instance = await fetchInstance(config.serverUrl, fetch2);
|
|
1440
|
+
if (config.serverUrlSource === "config") await store.update({ instance });
|
|
1441
|
+
return instance;
|
|
1442
|
+
} catch {
|
|
1443
|
+
return null;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1041
1447
|
// src/auth.ts
|
|
1042
1448
|
var CALLBACK_PORT_START = 8765;
|
|
1043
1449
|
var CALLBACK_PORT_END = 8770;
|
|
@@ -1046,12 +1452,13 @@ var CALLBACK_PATH = "/callback";
|
|
|
1046
1452
|
var AUTH_CLI_PATH = "/auth/cli";
|
|
1047
1453
|
async function loginFlow(options) {
|
|
1048
1454
|
const { serverUrl, store, log } = options;
|
|
1455
|
+
const instance = await fetchInstance(serverUrl, options.fetch ?? fetch);
|
|
1049
1456
|
const state = randomBytes(16).toString("hex");
|
|
1050
1457
|
const callback = await startCallbackServer({ state, timeoutMs: options.timeoutMs });
|
|
1051
1458
|
try {
|
|
1052
1459
|
const authUrl = buildAuthUrl(serverUrl, callback.url, options.hostname ?? osHostname(), state);
|
|
1053
1460
|
options.onAuthUrl?.(authUrl);
|
|
1054
|
-
log.info(`Opening your browser to sign in at ${serverUrl}`);
|
|
1461
|
+
log.info(`Opening your browser to sign in to ${instance.name} at ${serverUrl}`);
|
|
1055
1462
|
log.hint(`If it does not open, visit: ${authUrl}`);
|
|
1056
1463
|
try {
|
|
1057
1464
|
await options.open(authUrl);
|
|
@@ -1064,6 +1471,7 @@ async function loginFlow(options) {
|
|
|
1064
1471
|
const user = await client.request("GET", "/api/me", { schema: MeSchema });
|
|
1065
1472
|
await store.update({
|
|
1066
1473
|
serverUrl,
|
|
1474
|
+
instance,
|
|
1067
1475
|
apiKey: key,
|
|
1068
1476
|
user: { email: user.email, name: user.name, userSlug: user.userSlug }
|
|
1069
1477
|
});
|
|
@@ -1071,7 +1479,7 @@ async function loginFlow(options) {
|
|
|
1071
1479
|
log.success(`Signed in as ${user.email}`);
|
|
1072
1480
|
log.hint(`Server: ${serverUrl}`);
|
|
1073
1481
|
log.hint(`API key: ${keyPrefix} (stored in ${store.file})`);
|
|
1074
|
-
return { serverUrl, keyPrefix, user };
|
|
1482
|
+
return { serverUrl, keyPrefix, user, instance };
|
|
1075
1483
|
} finally {
|
|
1076
1484
|
callback.close();
|
|
1077
1485
|
}
|
|
@@ -1101,8 +1509,8 @@ async function startCallbackServer(options = {}) {
|
|
|
1101
1509
|
const timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS;
|
|
1102
1510
|
let settle;
|
|
1103
1511
|
let timer;
|
|
1104
|
-
const result = new Promise((
|
|
1105
|
-
settle = { resolve:
|
|
1512
|
+
const result = new Promise((resolve5, reject) => {
|
|
1513
|
+
settle = { resolve: resolve5, reject };
|
|
1106
1514
|
}).finally(() => clearTimeout(timer));
|
|
1107
1515
|
result.catch(() => {
|
|
1108
1516
|
});
|
|
@@ -1163,7 +1571,7 @@ ${close ? "<script>setTimeout(function(){window.close()},800)</script>" : ""}
|
|
|
1163
1571
|
res.end(html);
|
|
1164
1572
|
}
|
|
1165
1573
|
function listenOnFreePort(server) {
|
|
1166
|
-
return new Promise((
|
|
1574
|
+
return new Promise((resolve5, reject) => {
|
|
1167
1575
|
let port = CALLBACK_PORT_START;
|
|
1168
1576
|
const tryNext = () => {
|
|
1169
1577
|
if (port > CALLBACK_PORT_END) {
|
|
@@ -1185,18 +1593,18 @@ function listenOnFreePort(server) {
|
|
|
1185
1593
|
});
|
|
1186
1594
|
server.listen(port, "127.0.0.1", () => {
|
|
1187
1595
|
server.removeAllListeners("error");
|
|
1188
|
-
|
|
1596
|
+
resolve5(port);
|
|
1189
1597
|
});
|
|
1190
1598
|
};
|
|
1191
1599
|
tryNext();
|
|
1192
1600
|
});
|
|
1193
1601
|
}
|
|
1194
|
-
function escapeHtml2(
|
|
1195
|
-
return
|
|
1602
|
+
function escapeHtml2(text2) {
|
|
1603
|
+
return text2.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
|
|
1196
1604
|
}
|
|
1197
1605
|
|
|
1198
1606
|
// src/context.ts
|
|
1199
|
-
import {
|
|
1607
|
+
import { confirm, isCancel, text } from "@clack/prompts";
|
|
1200
1608
|
|
|
1201
1609
|
// src/ready.ts
|
|
1202
1610
|
import chalk from "chalk";
|
|
@@ -1232,20 +1640,83 @@ async function waitWithSpinner(waiter, url, paths, io) {
|
|
|
1232
1640
|
}
|
|
1233
1641
|
|
|
1234
1642
|
// src/utils/logger.ts
|
|
1643
|
+
import chalk3 from "chalk";
|
|
1644
|
+
|
|
1645
|
+
// src/ui/theme.ts
|
|
1235
1646
|
import chalk2 from "chalk";
|
|
1236
|
-
var
|
|
1647
|
+
var STOPS = [
|
|
1648
|
+
[0, 229, 255],
|
|
1649
|
+
[124, 77, 255],
|
|
1650
|
+
[255, 78, 205]
|
|
1651
|
+
];
|
|
1652
|
+
var WORDMARK = [
|
|
1653
|
+
"\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 ",
|
|
1654
|
+
"\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D ",
|
|
1655
|
+
"\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2588\u2557",
|
|
1656
|
+
"\u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551",
|
|
1657
|
+
"\u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D",
|
|
1658
|
+
"\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D "
|
|
1659
|
+
];
|
|
1660
|
+
var TRAILS = [" ", " \u2501\u2501\u2501 ", " \u2501\u2501\u2501\u2501 ", " \u2501\u2501 ", " ", " "];
|
|
1661
|
+
var LOGO_WIDTH = TRAILS[0].length + WORDMARK[0].length;
|
|
1662
|
+
var brand = {
|
|
1663
|
+
primary: chalk2.hex("#7C4DFF"),
|
|
1664
|
+
accent: chalk2.hex("#00E5FF"),
|
|
1665
|
+
url: (url) => chalk2.bold.hex("#00E5FF")(url)
|
|
1666
|
+
};
|
|
1667
|
+
var symbols = {
|
|
1668
|
+
info: chalk2.hex("#7C4DFF")("\u25C6"),
|
|
1669
|
+
success: chalk2.green("\u2714"),
|
|
1670
|
+
warn: chalk2.yellow("\u25B2"),
|
|
1671
|
+
error: chalk2.red("\u2716"),
|
|
1672
|
+
step: chalk2.dim("\u2502")
|
|
1673
|
+
};
|
|
1674
|
+
function colourAt(t) {
|
|
1675
|
+
const scaled = Math.min(Math.max(t, 0), 1) * (STOPS.length - 1);
|
|
1676
|
+
const i = Math.min(Math.floor(scaled), STOPS.length - 2);
|
|
1677
|
+
const f = scaled - i;
|
|
1678
|
+
const a = STOPS[i];
|
|
1679
|
+
const b = STOPS[i + 1];
|
|
1680
|
+
return [0, 1, 2].map((k) => Math.round(a[k] + (b[k] - a[k]) * f));
|
|
1681
|
+
}
|
|
1682
|
+
function gradient(text2, width = text2.length, offset = 0) {
|
|
1683
|
+
if (chalk2.level === 0) return text2;
|
|
1684
|
+
return [...text2].map((ch, i) => ch === " " ? ch : chalk2.rgb(...colourAt((offset + i) / (width - 1)))(ch)).join("");
|
|
1685
|
+
}
|
|
1686
|
+
function logo(options = {}) {
|
|
1687
|
+
const columns = options.columns || process.stderr.columns || 80;
|
|
1688
|
+
const tagline = options.tagline ?? "throw static files at the web, get a link back";
|
|
1689
|
+
if (columns < LOGO_WIDTH + 2) return `${gradient("\u25B6\u25B6 fling")} ${chalk2.dim(tagline)}`;
|
|
1690
|
+
const lines = WORDMARK.map(
|
|
1691
|
+
(row, i) => chalk2.dim(TRAILS[i] ?? "") + gradient(row, WORDMARK[0].length)
|
|
1692
|
+
);
|
|
1693
|
+
return ["", ...lines, `${" ".repeat(TRAILS[0].length)}${chalk2.dim(tagline)}`, ""].join("\n");
|
|
1694
|
+
}
|
|
1695
|
+
var ANSI = /\x1b\[[0-9;]*m/g;
|
|
1696
|
+
var visibleLength = (s) => s.replace(ANSI, "").length;
|
|
1697
|
+
function box(lines, title) {
|
|
1698
|
+
const width = Math.max(...lines.map(visibleLength), title ? visibleLength(title) + 2 : 0);
|
|
1699
|
+
const border = chalk2.dim;
|
|
1700
|
+
const top = title ? `${border("\u256D\u2500")} ${title} ${border(`${"\u2500".repeat(width - visibleLength(title) - 1)}\u256E`)}` : border(`\u256D${"\u2500".repeat(width + 2)}\u256E`);
|
|
1701
|
+
const body = lines.map(
|
|
1702
|
+
(line) => `${border("\u2502")} ${line}${" ".repeat(width - visibleLength(line))} ${border("\u2502")}`
|
|
1703
|
+
);
|
|
1704
|
+
return [top, ...body, border(`\u2570${"\u2500".repeat(width + 2)}\u256F`)].join("\n");
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// src/utils/logger.ts
|
|
1237
1708
|
function createLogger(options = {}) {
|
|
1238
1709
|
const write = options.write ?? ((line) => process.stderr.write(`${line}
|
|
1239
1710
|
`));
|
|
1240
1711
|
const debugEnabled = options.debug ?? false;
|
|
1241
1712
|
return {
|
|
1242
|
-
info: (message) => write(`${
|
|
1243
|
-
success: (message) => write(`${
|
|
1244
|
-
warn: (message) => write(`${
|
|
1245
|
-
error: (message) => write(`${
|
|
1246
|
-
hint: (message) => write(
|
|
1713
|
+
info: (message) => write(`${symbols.info} ${message}`),
|
|
1714
|
+
success: (message) => write(`${symbols.success} ${message}`),
|
|
1715
|
+
warn: (message) => write(`${symbols.warn} ${chalk3.yellow(message)}`),
|
|
1716
|
+
error: (message) => write(`${symbols.error} ${chalk3.red(message)}`),
|
|
1717
|
+
hint: (message) => write(chalk3.dim(` ${message}`)),
|
|
1247
1718
|
debug: (message) => {
|
|
1248
|
-
if (debugEnabled) write(
|
|
1719
|
+
if (debugEnabled) write(chalk3.dim(`[debug] ${message}`));
|
|
1249
1720
|
}
|
|
1250
1721
|
};
|
|
1251
1722
|
}
|
|
@@ -1265,12 +1736,13 @@ var silentLogger = {
|
|
|
1265
1736
|
};
|
|
1266
1737
|
|
|
1267
1738
|
// src/utils/output.ts
|
|
1268
|
-
import
|
|
1739
|
+
import chalk4 from "chalk";
|
|
1269
1740
|
function createOutput(options = {}) {
|
|
1270
1741
|
const json = options.json ?? false;
|
|
1271
1742
|
const write = options.write ?? ((chunk) => process.stdout.write(chunk));
|
|
1272
1743
|
return {
|
|
1273
1744
|
json,
|
|
1745
|
+
tty: options.tty ?? (options.write ? false : Boolean(process.stdout.isTTY)),
|
|
1274
1746
|
data: (raw, human) => write(`${json ? formatJson(raw) : human()}
|
|
1275
1747
|
`),
|
|
1276
1748
|
text: (line) => {
|
|
@@ -1283,15 +1755,15 @@ function formatJson(value) {
|
|
|
1283
1755
|
return JSON.stringify(value, null, 2);
|
|
1284
1756
|
}
|
|
1285
1757
|
function renderTable(rows, columns) {
|
|
1286
|
-
if (rows.length === 0) return
|
|
1758
|
+
if (rows.length === 0) return chalk4.dim("(no results)");
|
|
1287
1759
|
const cells = rows.map((row) => columns.map((column) => formatCell(column.value(row))));
|
|
1288
1760
|
const widths = columns.map(
|
|
1289
1761
|
(column, i) => Math.max(column.header.length, ...cells.map((line) => line[i]?.length ?? 0))
|
|
1290
1762
|
);
|
|
1291
|
-
const pad = (
|
|
1763
|
+
const pad = (text2, i) => i === columns.length - 1 ? text2 : text2.padEnd(widths[i] ?? 0);
|
|
1292
1764
|
const header = columns.map((column, i) => pad(column.header, i)).join(" ");
|
|
1293
1765
|
const body = cells.map((line) => line.map(pad).join(" "));
|
|
1294
|
-
return [
|
|
1766
|
+
return [chalk4.bold(header), ...body].join("\n");
|
|
1295
1767
|
}
|
|
1296
1768
|
function formatCell(value) {
|
|
1297
1769
|
if (value === null || value === void 0 || value === "") return "-";
|
|
@@ -1341,32 +1813,35 @@ async function createContext(options = {}) {
|
|
|
1341
1813
|
};
|
|
1342
1814
|
}
|
|
1343
1815
|
async function ttyConfirm(question) {
|
|
1344
|
-
const answer = await
|
|
1345
|
-
|
|
1816
|
+
const answer = await confirm({ message: question, initialValue: false, output: process.stderr });
|
|
1817
|
+
if (isCancel(answer)) throw new CliError("Cancelled");
|
|
1818
|
+
return answer;
|
|
1346
1819
|
}
|
|
1347
1820
|
async function ttyPrompt(question) {
|
|
1348
|
-
const
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
} finally {
|
|
1352
|
-
rl.close();
|
|
1353
|
-
}
|
|
1821
|
+
const answer = await text({ message: question.trim(), output: process.stderr });
|
|
1822
|
+
if (isCancel(answer)) throw new CliError("Cancelled");
|
|
1823
|
+
return answer ?? "";
|
|
1354
1824
|
}
|
|
1355
1825
|
function ttyProgress(line) {
|
|
1356
1826
|
process.stderr.write(`\r\x1B[2K${line ?? ""}`);
|
|
1357
1827
|
}
|
|
1828
|
+
function requireServer(ctx) {
|
|
1829
|
+
if (!ctx.config.serverUrl) throw new NoServerError(ctx.binName);
|
|
1830
|
+
return ctx.config.serverUrl;
|
|
1831
|
+
}
|
|
1358
1832
|
function requireClient(ctx) {
|
|
1833
|
+
const serverUrl = requireServer(ctx);
|
|
1359
1834
|
if (!ctx.config.apiKey) throw new NotLoggedInError(ctx.binName);
|
|
1360
1835
|
return createApiClient({
|
|
1361
|
-
serverUrl
|
|
1836
|
+
serverUrl,
|
|
1362
1837
|
apiKey: ctx.config.apiKey,
|
|
1363
1838
|
fetch: ctx.fetch
|
|
1364
1839
|
});
|
|
1365
1840
|
}
|
|
1366
1841
|
|
|
1367
1842
|
// src/sites.ts
|
|
1368
|
-
import { z as
|
|
1369
|
-
var ShareResponseSchema2 =
|
|
1843
|
+
import { z as z10 } from "zod";
|
|
1844
|
+
var ShareResponseSchema2 = z10.looseObject({ shareUrl: z10.string() });
|
|
1370
1845
|
var sitePath2 = (name) => `/api/sites/${encodeURIComponent(name)}`;
|
|
1371
1846
|
function whoami(client) {
|
|
1372
1847
|
return client.request("GET", "/api/me", { schema: MeSchema });
|
|
@@ -1376,32 +1851,8 @@ function checkName(client, name) {
|
|
|
1376
1851
|
schema: NameCheckSchema
|
|
1377
1852
|
});
|
|
1378
1853
|
}
|
|
1379
|
-
function
|
|
1380
|
-
|
|
1381
|
-
const url = new URL(serverUrl);
|
|
1382
|
-
const { hostname } = url;
|
|
1383
|
-
if (url.port || !hostname.includes(".") || /^[\d.]+$/.test(hostname) || hostname.startsWith("["))
|
|
1384
|
-
return null;
|
|
1385
|
-
return { protocol: url.protocol, domain: hostname };
|
|
1386
|
-
} catch {
|
|
1387
|
-
return null;
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
function sitesBaseFromSiteUrl(siteUrl2) {
|
|
1391
|
-
try {
|
|
1392
|
-
const url = new URL(siteUrl2);
|
|
1393
|
-
const dot = url.host.indexOf(".");
|
|
1394
|
-
return dot > 0 ? { protocol: url.protocol, domain: url.host.slice(dot + 1) } : null;
|
|
1395
|
-
} catch {
|
|
1396
|
-
return null;
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
|
-
async function discoverSitesBase(client) {
|
|
1400
|
-
const inferred = sitesBaseFor(client.serverUrl);
|
|
1401
|
-
if (inferred) return inferred;
|
|
1402
|
-
const sites = await listSites(client).catch(() => []);
|
|
1403
|
-
const first = sites[0];
|
|
1404
|
-
return first ? sitesBaseFromSiteUrl(first.url) : null;
|
|
1854
|
+
function sitesBaseOf(instance) {
|
|
1855
|
+
return instance ? { protocol: `${instance.siteScheme}:`, domain: instance.sitesDomain } : null;
|
|
1405
1856
|
}
|
|
1406
1857
|
function siteUrlFor(name, base) {
|
|
1407
1858
|
return siteUrl(name, base.domain, base.protocol);
|
|
@@ -1471,11 +1922,47 @@ async function share(client, name, opts = {}) {
|
|
|
1471
1922
|
});
|
|
1472
1923
|
return { name, action: hadLink ? "refreshed" : "created", shareUrl: res.shareUrl };
|
|
1473
1924
|
}
|
|
1925
|
+
function listComments(client, name, opts = {}) {
|
|
1926
|
+
return client.request("GET", `${sitePath2(name)}/comments`, {
|
|
1927
|
+
schema: CommentListSchema,
|
|
1928
|
+
query: { path: opts.path }
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
function addComment(client, name, input) {
|
|
1932
|
+
return client.request("POST", `${sitePath2(name)}/comments`, {
|
|
1933
|
+
schema: CommentSchema,
|
|
1934
|
+
body: input
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
function resolveComment(client, name, id, resolved) {
|
|
1938
|
+
return client.request("PATCH", `${sitePath2(name)}/comments/${encodeURIComponent(id)}`, {
|
|
1939
|
+
schema: CommentSchema,
|
|
1940
|
+
body: { resolved }
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
async function deleteComment(client, name, id) {
|
|
1944
|
+
await client.request("DELETE", `${sitePath2(name)}/comments/${encodeURIComponent(id)}`);
|
|
1945
|
+
return { name, id, deleted: true };
|
|
1946
|
+
}
|
|
1947
|
+
function commentsEnabled(instance) {
|
|
1948
|
+
if (!instance) return false;
|
|
1949
|
+
return instance.capabilities.comments ?? true;
|
|
1950
|
+
}
|
|
1474
1951
|
|
|
1475
1952
|
// src/commands/auth.ts
|
|
1476
|
-
|
|
1953
|
+
function loginServer(ctx, url) {
|
|
1954
|
+
if (url === void 0) return requireServer(ctx);
|
|
1955
|
+
try {
|
|
1956
|
+
return normalizeServerUrl(url);
|
|
1957
|
+
} catch {
|
|
1958
|
+
throw new CliError(`"${url}" is not a server address`, {
|
|
1959
|
+
hint: `Run \`${ctx.binName} login <url>\` with your Fling's address, e.g. https://fling.example.com.`
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
async function runLogin(ctx, url) {
|
|
1477
1964
|
const result = await loginFlow({
|
|
1478
|
-
serverUrl: ctx
|
|
1965
|
+
serverUrl: loginServer(ctx, url),
|
|
1479
1966
|
store: ctx.store,
|
|
1480
1967
|
log: ctx.log,
|
|
1481
1968
|
open: ctx.open,
|
|
@@ -1491,30 +1978,109 @@ async function runLogout(ctx) {
|
|
|
1491
1978
|
}
|
|
1492
1979
|
await ctx.store.clearCredentials();
|
|
1493
1980
|
ctx.log.success("Logged out; API key removed from this machine");
|
|
1494
|
-
|
|
1981
|
+
if (ctx.config.serverUrl)
|
|
1982
|
+
ctx.log.hint(`Revoke it on the server under Connections at ${ctx.config.serverUrl}`);
|
|
1495
1983
|
}
|
|
1496
1984
|
async function runWhoami(ctx) {
|
|
1497
1985
|
const client = requireClient(ctx);
|
|
1498
1986
|
const me = await whoami(client);
|
|
1499
|
-
const
|
|
1987
|
+
const instance = await loadInstance(ctx.store, ctx.config, ctx.fetch);
|
|
1500
1988
|
ctx.out.data(
|
|
1501
1989
|
{
|
|
1502
1990
|
...me,
|
|
1503
1991
|
serverUrl: ctx.config.serverUrl,
|
|
1992
|
+
instance: instance?.name ?? null,
|
|
1504
1993
|
apiKey: redactKey(ctx.config.apiKey),
|
|
1505
1994
|
apiKeySource: ctx.config.apiKeySource
|
|
1506
1995
|
},
|
|
1507
1996
|
() => [
|
|
1508
|
-
`${
|
|
1509
|
-
`${
|
|
1510
|
-
`${
|
|
1511
|
-
`${
|
|
1997
|
+
`${chalk5.bold("User:")} ${me.name} <${me.email}>`,
|
|
1998
|
+
`${chalk5.bold("Sites:")} ${sitesPattern(sitesBaseOf(instance))} ${chalk5.dim("(names are first come, first served)")}`,
|
|
1999
|
+
`${chalk5.bold("Server:")} ${ctx.config.serverUrl}${instance ? chalk5.dim(` (${instance.name})`) : ""}`,
|
|
2000
|
+
`${chalk5.bold("API key:")} ${redactKey(ctx.config.apiKey)} ${chalk5.dim(`(from ${ctx.config.apiKeySource})`)}`
|
|
1512
2001
|
].join("\n")
|
|
1513
2002
|
);
|
|
1514
2003
|
}
|
|
1515
2004
|
|
|
2005
|
+
// src/commands/comments.ts
|
|
2006
|
+
import chalk6 from "chalk";
|
|
2007
|
+
function commentsClient(ctx) {
|
|
2008
|
+
const instance = ctx.config.instance;
|
|
2009
|
+
if (instance && !commentsEnabled(instance)) {
|
|
2010
|
+
throw new CliError(`${instance.name} has comments turned off`);
|
|
2011
|
+
}
|
|
2012
|
+
return requireClient(ctx);
|
|
2013
|
+
}
|
|
2014
|
+
var reviewPage = (ctx, name, path3) => reviewUrl(ctx.config.instance?.appOrigin ?? ctx.config.serverUrl ?? "", name, path3);
|
|
2015
|
+
function describe(c, currentVersionId) {
|
|
2016
|
+
const version = c.versionId === null ? "" : c.versionId === currentVersionId ? "" : chalk6.dim(` (on version ${c.versionId})`);
|
|
2017
|
+
return `${chalk6.bold(authorLabel(c.author))} ${chalk6.dim(formatDate(c.createdAt))}${version}`;
|
|
2018
|
+
}
|
|
2019
|
+
async function runComments(ctx, name, opts) {
|
|
2020
|
+
const list = await listComments(commentsClient(ctx), name, { path: opts.path });
|
|
2021
|
+
const all = commentThreads(list.comments);
|
|
2022
|
+
const threads = opts.all ? all : all.filter((t) => t.comment.resolvedAt === null);
|
|
2023
|
+
const hidden = all.length - threads.length;
|
|
2024
|
+
ctx.out.data({ ...list, reviewUrl: reviewPage(ctx, name) }, () => {
|
|
2025
|
+
const lines = [];
|
|
2026
|
+
let lastPath = null;
|
|
2027
|
+
for (const { comment: c, replies } of threads.sort(
|
|
2028
|
+
(a, b) => a.comment.path.localeCompare(b.comment.path)
|
|
2029
|
+
)) {
|
|
2030
|
+
if (c.path !== lastPath) {
|
|
2031
|
+
lines.push("", chalk6.bold.underline(c.path));
|
|
2032
|
+
lastPath = c.path;
|
|
2033
|
+
}
|
|
2034
|
+
const state = c.resolvedAt ? chalk6.green(" \u2713 resolved") : "";
|
|
2035
|
+
lines.push(`${chalk6.dim(c.id)}${state} ${describe(c, list.site.currentVersionId)}`);
|
|
2036
|
+
lines.push(...c.body.split("\n").map((l) => ` ${l}`));
|
|
2037
|
+
for (const r of replies) {
|
|
2038
|
+
lines.push(` ${chalk6.dim("\u21B3")} ${describe(r, list.site.currentVersionId)}`);
|
|
2039
|
+
lines.push(...r.body.split("\n").map((l) => ` ${l}`));
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
const summary = chalk6.dim(
|
|
2043
|
+
`${threads.length} ${opts.all ? "" : "open "}thread${threads.length === 1 ? "" : "s"}` + (hidden ? ` (${hidden} resolved \u2014 \`--all\` shows them)` : "") + ` \xB7 review: ${reviewPage(ctx, name)}`
|
|
2044
|
+
);
|
|
2045
|
+
return [...lines, "", summary].join("\n").replace(/^\n/, "");
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
async function runComment(ctx, name, text2, opts) {
|
|
2049
|
+
if (!text2.trim()) throw new CliError("The comment is empty");
|
|
2050
|
+
const comment = await addComment(commentsClient(ctx), name, {
|
|
2051
|
+
body: text2,
|
|
2052
|
+
path: opts.path,
|
|
2053
|
+
parentId: opts.reply
|
|
2054
|
+
});
|
|
2055
|
+
ctx.log.success(
|
|
2056
|
+
comment.parentId ? `Replied to ${comment.parentId} on ${name}` : `Comment added on ${name} ${comment.path}`
|
|
2057
|
+
);
|
|
2058
|
+
ctx.out.data(comment, () => reviewPage(ctx, name, comment.path));
|
|
2059
|
+
}
|
|
2060
|
+
async function runResolve(ctx, name, id, opts) {
|
|
2061
|
+
const comment = await resolveComment(commentsClient(ctx), name, id, !opts.reopen);
|
|
2062
|
+
ctx.log.success(opts.reopen ? `Thread ${id} reopened` : `Thread ${id} resolved`);
|
|
2063
|
+
if (ctx.json) ctx.out.data(comment, () => "");
|
|
2064
|
+
}
|
|
2065
|
+
async function runUncomment(ctx, name, id, opts) {
|
|
2066
|
+
if (!opts.yes) {
|
|
2067
|
+
if (!ctx.interactive) {
|
|
2068
|
+
throw new CliError(`Refusing to delete comment ${id} without confirmation`, {
|
|
2069
|
+
hint: "Pass --yes to delete."
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
const ok = await ctx.confirm(`Delete comment ${id} (and its replies)? This cannot be undone.`);
|
|
2073
|
+
if (!ok) throw new CliError("Cancelled");
|
|
2074
|
+
}
|
|
2075
|
+
const result = await deleteComment(commentsClient(ctx), name, id);
|
|
2076
|
+
ctx.log.success(`Comment ${id} deleted`);
|
|
2077
|
+
if (ctx.json) ctx.out.data(result, () => "");
|
|
2078
|
+
}
|
|
2079
|
+
|
|
1516
2080
|
// src/commands/deploy.ts
|
|
1517
|
-
import
|
|
2081
|
+
import { stat as stat4 } from "node:fs/promises";
|
|
2082
|
+
import { resolve as resolve3 } from "node:path";
|
|
2083
|
+
import chalk7 from "chalk";
|
|
1518
2084
|
|
|
1519
2085
|
// src/deploy.ts
|
|
1520
2086
|
import { readFile as readFile4 } from "node:fs/promises";
|
|
@@ -1534,7 +2100,7 @@ import { join as join2, relative, sep } from "node:path";
|
|
|
1534
2100
|
import ignore from "ignore";
|
|
1535
2101
|
var GIT_TIMEOUT_MS = 2e4;
|
|
1536
2102
|
function git(cwd, args, input) {
|
|
1537
|
-
return new Promise((
|
|
2103
|
+
return new Promise((resolve5) => {
|
|
1538
2104
|
try {
|
|
1539
2105
|
const child = execFile(
|
|
1540
2106
|
"git",
|
|
@@ -1542,14 +2108,14 @@ function git(cwd, args, input) {
|
|
|
1542
2108
|
{ timeout: GIT_TIMEOUT_MS, maxBuffer: 256 * 1024 * 1024, encoding: "utf8" },
|
|
1543
2109
|
(error, stdout) => {
|
|
1544
2110
|
const code = error?.code;
|
|
1545
|
-
if (error && !(args[0] === "check-ignore" && code === 1))
|
|
1546
|
-
else
|
|
2111
|
+
if (error && !(args[0] === "check-ignore" && code === 1)) resolve5(null);
|
|
2112
|
+
else resolve5(stdout);
|
|
1547
2113
|
}
|
|
1548
2114
|
);
|
|
1549
|
-
child.on("error", () =>
|
|
2115
|
+
child.on("error", () => resolve5(null));
|
|
1550
2116
|
if (input !== void 0) child.stdin?.end(input);
|
|
1551
2117
|
} catch {
|
|
1552
|
-
|
|
2118
|
+
resolve5(null);
|
|
1553
2119
|
}
|
|
1554
2120
|
});
|
|
1555
2121
|
}
|
|
@@ -1598,13 +2164,13 @@ function nestedMode() {
|
|
|
1598
2164
|
return false;
|
|
1599
2165
|
},
|
|
1600
2166
|
async enterDir(absDir, relDir) {
|
|
1601
|
-
let
|
|
2167
|
+
let text2;
|
|
1602
2168
|
try {
|
|
1603
|
-
|
|
2169
|
+
text2 = await readFile2(join2(absDir, ".gitignore"), "utf8");
|
|
1604
2170
|
} catch {
|
|
1605
2171
|
return;
|
|
1606
2172
|
}
|
|
1607
|
-
levels.push({ base: relDir, ig: ignore().add(
|
|
2173
|
+
levels.push({ base: relDir, ig: ignore().add(text2) });
|
|
1608
2174
|
}
|
|
1609
2175
|
};
|
|
1610
2176
|
}
|
|
@@ -1678,19 +2244,19 @@ function describeSkipped(skipped) {
|
|
|
1678
2244
|
lines.push(line(skipped.gitignored.length, "gitignored file", skipped.gitignored));
|
|
1679
2245
|
return lines;
|
|
1680
2246
|
}
|
|
1681
|
-
function contentTypeFor(
|
|
1682
|
-
const type = mime.getType(
|
|
2247
|
+
function contentTypeFor(path3) {
|
|
2248
|
+
const type = mime.getType(path3) ?? "application/octet-stream";
|
|
1683
2249
|
return type.startsWith("text/") || CHARSET_TYPES.has(type) ? `${type}; charset=utf-8` : type;
|
|
1684
2250
|
}
|
|
1685
|
-
async function sha256File(
|
|
1686
|
-
const
|
|
1687
|
-
for await (const chunk of createReadStream(
|
|
1688
|
-
return
|
|
2251
|
+
async function sha256File(path3) {
|
|
2252
|
+
const hash3 = createHash("sha256");
|
|
2253
|
+
for await (const chunk of createReadStream(path3)) hash3.update(chunk);
|
|
2254
|
+
return hash3.digest("hex");
|
|
1689
2255
|
}
|
|
1690
2256
|
function toManifest(local) {
|
|
1691
2257
|
return {
|
|
1692
|
-
files: local.files.map(({ path, sha256, size, contentType }) => ({
|
|
1693
|
-
path,
|
|
2258
|
+
files: local.files.map(({ path: path3, sha256, size, contentType }) => ({
|
|
2259
|
+
path: path3,
|
|
1694
2260
|
sha256,
|
|
1695
2261
|
size,
|
|
1696
2262
|
contentType
|
|
@@ -2023,8 +2589,8 @@ async function deploy(deps, opts) {
|
|
|
2023
2589
|
totalBytes: local.totalBytes,
|
|
2024
2590
|
warnings: local.warnings
|
|
2025
2591
|
};
|
|
2026
|
-
const
|
|
2027
|
-
const res = await deps.client.request("POST",
|
|
2592
|
+
const path3 = `/api/sites/${encodeURIComponent(name)}/deploys`;
|
|
2593
|
+
const res = await deps.client.request("POST", path3, {
|
|
2028
2594
|
schema: DeployResponseSchema,
|
|
2029
2595
|
body: {
|
|
2030
2596
|
manifest: toManifest(local),
|
|
@@ -2064,7 +2630,7 @@ async function deploy(deps, opts) {
|
|
|
2064
2630
|
});
|
|
2065
2631
|
const fin = await deps.client.request(
|
|
2066
2632
|
"POST",
|
|
2067
|
-
`${
|
|
2633
|
+
`${path3}/${encodeURIComponent(res.versionId)}/finalize`,
|
|
2068
2634
|
{ schema: FinalizeResponseSchema }
|
|
2069
2635
|
);
|
|
2070
2636
|
return {
|
|
@@ -2100,51 +2666,51 @@ import { copyFile, mkdir as mkdir2, mkdtemp, readFile as readFile5, rm, stat as
|
|
|
2100
2666
|
import { tmpdir } from "node:os";
|
|
2101
2667
|
import { basename as basename2, extname, join as join4, resolve as resolve2, sep as sep4 } from "node:path";
|
|
2102
2668
|
import { unzipSync } from "fflate";
|
|
2103
|
-
function inputKind(
|
|
2669
|
+
function inputKind(path3, isDirectory) {
|
|
2104
2670
|
if (isDirectory) return "folder";
|
|
2105
|
-
const ext = extname(
|
|
2671
|
+
const ext = extname(path3).toLowerCase();
|
|
2106
2672
|
if (ext === ".html" || ext === ".htm") return "html";
|
|
2107
2673
|
if (ext === ".zip") return "zip";
|
|
2108
2674
|
return "file";
|
|
2109
2675
|
}
|
|
2110
|
-
function fileSiteName(
|
|
2111
|
-
const file = basename2(
|
|
2676
|
+
function fileSiteName(path3) {
|
|
2677
|
+
const file = basename2(path3.replace(/\\/g, "/"));
|
|
2112
2678
|
const stem = file.slice(0, file.length - extname(file).length) || file;
|
|
2113
2679
|
return fitName([stem.replace(/\s*\(\d+\)$/, "")]);
|
|
2114
2680
|
}
|
|
2115
|
-
function defaultNameFor(
|
|
2116
|
-
return isDirectory ? defaultSiteName(
|
|
2681
|
+
function defaultNameFor(path3, isDirectory) {
|
|
2682
|
+
return isDirectory ? defaultSiteName(path3) : fileSiteName(path3);
|
|
2117
2683
|
}
|
|
2118
2684
|
async function tempFolder() {
|
|
2119
2685
|
const dir = await mkdtemp(join4(tmpdir(), "fling-"));
|
|
2120
2686
|
return { dir, cleanup: () => rm(dir, { recursive: true, force: true }) };
|
|
2121
2687
|
}
|
|
2122
|
-
async function prepareInput(
|
|
2688
|
+
async function prepareInput(path3) {
|
|
2123
2689
|
let info;
|
|
2124
2690
|
try {
|
|
2125
|
-
info = await stat3(
|
|
2691
|
+
info = await stat3(path3);
|
|
2126
2692
|
} catch {
|
|
2127
|
-
throw new CliError(`Not found: ${
|
|
2693
|
+
throw new CliError(`Not found: ${path3}`);
|
|
2128
2694
|
}
|
|
2129
|
-
const kind = inputKind(
|
|
2130
|
-
const defaultName = defaultNameFor(
|
|
2695
|
+
const kind = inputKind(path3, info.isDirectory());
|
|
2696
|
+
const defaultName = defaultNameFor(path3, info.isDirectory());
|
|
2131
2697
|
if (kind === "folder")
|
|
2132
2698
|
return {
|
|
2133
2699
|
kind,
|
|
2134
|
-
source:
|
|
2135
|
-
dir:
|
|
2700
|
+
source: path3,
|
|
2701
|
+
dir: path3,
|
|
2136
2702
|
defaultName,
|
|
2137
2703
|
skippedDotfiles: [],
|
|
2138
2704
|
cleanup: async () => {
|
|
2139
2705
|
}
|
|
2140
2706
|
};
|
|
2141
|
-
const file = basename2(
|
|
2707
|
+
const file = basename2(path3);
|
|
2142
2708
|
if (file.startsWith("."))
|
|
2143
2709
|
throw new CliError(`Not flung: ${file} is a dotfile. ${NEVER_PUBLISHED_NOTE}`);
|
|
2144
2710
|
if (kind === "file" && isSecretPath(file)) throw new SecretFilesError([file]);
|
|
2145
2711
|
if (info.size > MAX_DEPLOY_BYTES)
|
|
2146
2712
|
throw new CliError(
|
|
2147
|
-
`${basename2(
|
|
2713
|
+
`${basename2(path3)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_DEPLOY_BYTES)}`
|
|
2148
2714
|
);
|
|
2149
2715
|
const tmp = await tempFolder();
|
|
2150
2716
|
const skippedDotfiles = [];
|
|
@@ -2152,21 +2718,21 @@ async function prepareInput(path) {
|
|
|
2152
2718
|
if (kind === "html") {
|
|
2153
2719
|
if (info.size > MAX_FILE_BYTES)
|
|
2154
2720
|
throw new CliError(
|
|
2155
|
-
`${basename2(
|
|
2721
|
+
`${basename2(path3)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`
|
|
2156
2722
|
);
|
|
2157
|
-
await copyFile(
|
|
2723
|
+
await copyFile(path3, join4(tmp.dir, "index.html"));
|
|
2158
2724
|
} else if (kind === "zip") {
|
|
2159
|
-
await extractZip(await readFile5(
|
|
2725
|
+
await extractZip(await readFile5(path3), tmp.dir, skippedDotfiles);
|
|
2160
2726
|
} else {
|
|
2161
|
-
const name = basename2(
|
|
2162
|
-
await copyFile(
|
|
2727
|
+
const name = basename2(path3);
|
|
2728
|
+
await copyFile(path3, join4(tmp.dir, name));
|
|
2163
2729
|
await writeFile2(join4(tmp.dir, "index.html"), wrapperPage(name));
|
|
2164
2730
|
}
|
|
2165
2731
|
} catch (error) {
|
|
2166
2732
|
await tmp.cleanup();
|
|
2167
2733
|
throw error;
|
|
2168
2734
|
}
|
|
2169
|
-
return { kind, source:
|
|
2735
|
+
return { kind, source: path3, dir: tmp.dir, defaultName, skippedDotfiles, cleanup: tmp.cleanup };
|
|
2170
2736
|
}
|
|
2171
2737
|
var JUNK_FILES = /* @__PURE__ */ new Set(["thumbs.db", "desktop.ini"]);
|
|
2172
2738
|
function zipEntryPath(name) {
|
|
@@ -2206,12 +2772,12 @@ async function extractZip(data, dest, skippedDotfiles = []) {
|
|
|
2206
2772
|
try {
|
|
2207
2773
|
entries = unzipSync(data, {
|
|
2208
2774
|
filter: (file) => {
|
|
2209
|
-
const
|
|
2210
|
-
if (
|
|
2775
|
+
const path3 = zipEntryPath(file.name);
|
|
2776
|
+
if (path3 === "unsafe") {
|
|
2211
2777
|
unsafe.push(file.name);
|
|
2212
2778
|
return false;
|
|
2213
2779
|
}
|
|
2214
|
-
if (
|
|
2780
|
+
if (path3 === null) {
|
|
2215
2781
|
const dot = zipDotEntry(file.name);
|
|
2216
2782
|
if (dot && !skippedDotfiles.includes(dot)) skippedDotfiles.push(dot);
|
|
2217
2783
|
return false;
|
|
@@ -2220,7 +2786,7 @@ async function extractZip(data, dest, skippedDotfiles = []) {
|
|
|
2220
2786
|
declared += file.originalSize;
|
|
2221
2787
|
if (file.originalSize > MAX_FILE_BYTES)
|
|
2222
2788
|
throw new CliError(
|
|
2223
|
-
`${
|
|
2789
|
+
`${path3} in the zip is ${formatBytes(file.originalSize)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`
|
|
2224
2790
|
);
|
|
2225
2791
|
if (declared > MAX_DEPLOY_BYTES)
|
|
2226
2792
|
throw new CliError(
|
|
@@ -2297,315 +2863,25 @@ function wrapperPage(file) {
|
|
|
2297
2863
|
`;
|
|
2298
2864
|
}
|
|
2299
2865
|
|
|
2300
|
-
// src/locate.ts
|
|
2301
|
-
import { lstat, readdir as readdir2, readFile as readFile6, realpath as realpath3, stat as stat4 } from "node:fs/promises";
|
|
2302
|
-
import { posix, win32 } from "node:path";
|
|
2303
|
-
import { fileURLToPath } from "node:url";
|
|
2304
|
-
var DOWNLOADS_DIR_ENV = "FLING_DOWNLOADS_DIR";
|
|
2305
|
-
var FOUND_AS_GIVEN = "path";
|
|
2306
|
-
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "__MACOSX", "Library", "AppData", "$RECYCLE.BIN"]);
|
|
2307
|
-
var SESSION_SKIP_DIRS = /* @__PURE__ */ new Set(["skills-plugin", "rpm", "uploads", "memory"]);
|
|
2308
|
-
var SESSIONS_DEPTH = 4;
|
|
2309
|
-
var OUTPUTS_DEPTH = 4;
|
|
2310
|
-
var SANDBOX_PATH_RE = /^\/(mnt\/user-data|mnt\/data|sessions|home\/claude|tmp\/outputs)(\/|$)/;
|
|
2311
|
-
var nodeFs = {
|
|
2312
|
-
async stat(path) {
|
|
2313
|
-
try {
|
|
2314
|
-
const s = await stat4(path);
|
|
2315
|
-
return { isFile: s.isFile(), isDirectory: s.isDirectory(), mtimeMs: s.mtimeMs, size: s.size };
|
|
2316
|
-
} catch {
|
|
2317
|
-
return null;
|
|
2318
|
-
}
|
|
2319
|
-
},
|
|
2320
|
-
async readdir(path) {
|
|
2321
|
-
try {
|
|
2322
|
-
const entries = await readdir2(path, { withFileTypes: true });
|
|
2323
|
-
const out = [];
|
|
2324
|
-
for (const e of entries) {
|
|
2325
|
-
if (e.isSymbolicLink()) {
|
|
2326
|
-
const s = await stat4(`${path}/${e.name}`).catch(() => null);
|
|
2327
|
-
out.push({ name: e.name, isFile: Boolean(s?.isFile()), isDirectory: false });
|
|
2328
|
-
} else out.push({ name: e.name, isFile: e.isFile(), isDirectory: e.isDirectory() });
|
|
2329
|
-
}
|
|
2330
|
-
return out;
|
|
2331
|
-
} catch {
|
|
2332
|
-
return [];
|
|
2333
|
-
}
|
|
2334
|
-
},
|
|
2335
|
-
async realpath(path) {
|
|
2336
|
-
return realpath3(path).catch(() => null);
|
|
2337
|
-
},
|
|
2338
|
-
async readText(path) {
|
|
2339
|
-
try {
|
|
2340
|
-
if (!(await lstat(path)).isFile()) return null;
|
|
2341
|
-
return await readFile6(path, "utf8");
|
|
2342
|
-
} catch {
|
|
2343
|
-
return null;
|
|
2344
|
-
}
|
|
2345
|
-
}
|
|
2346
|
-
};
|
|
2347
|
-
var pathFor = (platform) => platform === "win32" ? win32 : posix;
|
|
2348
|
-
var usable = (value) => Boolean(value?.trim()) && !value?.trim().startsWith("${");
|
|
2349
|
-
async function xdgUserDir(key, opts, fs) {
|
|
2350
|
-
const fromEnv2 = opts.env[`XDG_${key}_DIR`];
|
|
2351
|
-
if (usable(fromEnv2)) return fromEnv2;
|
|
2352
|
-
const configHome = opts.env.XDG_CONFIG_HOME || posix.join(opts.home, ".config");
|
|
2353
|
-
const text = await fs.readText(posix.join(configHome, "user-dirs.dirs"));
|
|
2354
|
-
const match = text?.match(new RegExp(`^XDG_${key}_DIR="([^"]*)"`, "m"));
|
|
2355
|
-
if (!match?.[1]) return void 0;
|
|
2356
|
-
return match[1].replace(/^\$HOME(?=\/|$)/, opts.home);
|
|
2357
|
-
}
|
|
2358
|
-
async function searchRoots(opts) {
|
|
2359
|
-
const fs = opts.fs ?? nodeFs;
|
|
2360
|
-
const p = pathFor(opts.platform);
|
|
2361
|
-
const { home, env, platform } = opts;
|
|
2362
|
-
const userHome = platform === "win32" && usable(env.USERPROFILE) ? env.USERPROFILE : home;
|
|
2363
|
-
const roots = [];
|
|
2364
|
-
const add = (label, path, depth, sessions = false) => {
|
|
2365
|
-
if (!path) return;
|
|
2366
|
-
const norm = p.resolve(path);
|
|
2367
|
-
if (roots.some((r) => r.path === norm)) return;
|
|
2368
|
-
roots.push({ label, path: norm, depth, ...sessions ? { sessions } : {} });
|
|
2369
|
-
};
|
|
2370
|
-
const cwd = p.resolve(opts.cwd);
|
|
2371
|
-
if (cwd !== p.parse(cwd).root && cwd !== p.resolve(userHome)) add("current folder", cwd, 4);
|
|
2372
|
-
const configured = usable(opts.downloadsDir) ? opts.downloadsDir : usable(env[DOWNLOADS_DIR_ENV]) ? env[DOWNLOADS_DIR_ENV] : void 0;
|
|
2373
|
-
const defaultDownloads = platform === "linux" ? await xdgUserDir("DOWNLOAD", opts, fs) ?? p.join(home, "Downloads") : p.join(userHome, "Downloads");
|
|
2374
|
-
add("Downloads", configured ? expandHome(configured, home, platform) : defaultDownloads, 3);
|
|
2375
|
-
add("Downloads", defaultDownloads, 3);
|
|
2376
|
-
const desktop = platform === "linux" ? await xdgUserDir("DESKTOP", opts, fs) ?? p.join(home, "Desktop") : p.join(userHome, "Desktop");
|
|
2377
|
-
add("Desktop", desktop, 3);
|
|
2378
|
-
if (platform === "win32" && usable(env.OneDrive))
|
|
2379
|
-
add("Desktop", p.join(env.OneDrive, "Desktop"), 3);
|
|
2380
|
-
const documents = platform === "linux" ? await xdgUserDir("DOCUMENTS", opts, fs) ?? p.join(home, "Documents") : p.join(userHome, "Documents");
|
|
2381
|
-
add("Documents/Claude", p.join(documents, "Claude"), 4);
|
|
2382
|
-
add("~/Claude", p.join(userHome, "Claude"), 4);
|
|
2383
|
-
const claudeConfig = platform === "darwin" ? p.join(home, "Library", "Application Support", "Claude") : platform === "win32" ? p.join(
|
|
2384
|
-
usable(env.APPDATA) ? env.APPDATA : p.join(userHome, "AppData", "Roaming"),
|
|
2385
|
-
"Claude"
|
|
2386
|
-
) : p.join(env.XDG_CONFIG_HOME || p.join(home, ".config"), "Claude");
|
|
2387
|
-
add(
|
|
2388
|
-
"Claude Desktop outputs",
|
|
2389
|
-
p.join(claudeConfig, "local-agent-mode-sessions"),
|
|
2390
|
-
SESSIONS_DEPTH,
|
|
2391
|
-
true
|
|
2392
|
-
);
|
|
2393
|
-
if (!fs.realpath) return roots;
|
|
2394
|
-
const real = [];
|
|
2395
|
-
for (const root of roots) {
|
|
2396
|
-
const path = await fs.realpath(root.path) ?? root.path;
|
|
2397
|
-
if (!real.some((r) => r.path === path)) real.push({ ...root, path });
|
|
2398
|
-
}
|
|
2399
|
-
return real;
|
|
2400
|
-
}
|
|
2401
|
-
function expandHome(input, home, platform) {
|
|
2402
|
-
if (input === "~") return home;
|
|
2403
|
-
if (input.startsWith("~/") || platform === "win32" && input.startsWith("~\\"))
|
|
2404
|
-
return pathFor(platform).join(home, input.slice(2));
|
|
2405
|
-
return input;
|
|
2406
|
-
}
|
|
2407
|
-
async function walk(roots, opts, visit) {
|
|
2408
|
-
const fs = opts.fs ?? nodeFs;
|
|
2409
|
-
const p = pathFor(opts.platform);
|
|
2410
|
-
const now = opts.now ?? Date.now;
|
|
2411
|
-
const deadline = now() + (opts.timeBudgetMs ?? 4e3);
|
|
2412
|
-
let budget = opts.entryBudget ?? 6e4;
|
|
2413
|
-
const skip = (name) => name.startsWith(".") || SKIP_DIRS.has(name);
|
|
2414
|
-
const expanded = [];
|
|
2415
|
-
for (const root of roots) {
|
|
2416
|
-
if (!root.sessions) {
|
|
2417
|
-
expanded.push(root);
|
|
2418
|
-
continue;
|
|
2419
|
-
}
|
|
2420
|
-
let level = [root.path];
|
|
2421
|
-
for (let depth = 0; depth < root.depth && level.length; depth++) {
|
|
2422
|
-
const next = [];
|
|
2423
|
-
for (const dir of level) {
|
|
2424
|
-
for (const e of await fs.readdir(dir)) {
|
|
2425
|
-
if (!e.isDirectory || skip(e.name) || SESSION_SKIP_DIRS.has(e.name)) continue;
|
|
2426
|
-
const child = p.join(dir, e.name);
|
|
2427
|
-
if (e.name === "outputs")
|
|
2428
|
-
expanded.push({ label: root.label, path: child, depth: OUTPUTS_DEPTH });
|
|
2429
|
-
else next.push(child);
|
|
2430
|
-
}
|
|
2431
|
-
}
|
|
2432
|
-
level = next;
|
|
2433
|
-
}
|
|
2434
|
-
}
|
|
2435
|
-
for (const root of expanded) {
|
|
2436
|
-
let level = [root.path];
|
|
2437
|
-
for (let depth = 0; depth <= root.depth && level.length; depth++) {
|
|
2438
|
-
const next = [];
|
|
2439
|
-
for (const dir of level) {
|
|
2440
|
-
if (now() > deadline || budget <= 0) return { complete: false };
|
|
2441
|
-
const entries = await fs.readdir(dir);
|
|
2442
|
-
budget -= entries.length;
|
|
2443
|
-
for (const entry of entries) {
|
|
2444
|
-
if (entry.name.startsWith(".")) continue;
|
|
2445
|
-
const path = p.join(dir, entry.name);
|
|
2446
|
-
const result = await visit({ root, path, entry, siblings: entries });
|
|
2447
|
-
if (entry.isDirectory && !skip(entry.name) && result !== "skip" && depth < root.depth)
|
|
2448
|
-
next.push(path);
|
|
2449
|
-
}
|
|
2450
|
-
}
|
|
2451
|
-
level = next;
|
|
2452
|
-
}
|
|
2453
|
-
}
|
|
2454
|
-
return { complete: true };
|
|
2455
|
-
}
|
|
2456
|
-
function withoutDuplicateSuffix(name) {
|
|
2457
|
-
return name.replace(/ \(\d+\)(?=\.[^.]+$|$)/, "");
|
|
2458
|
-
}
|
|
2459
|
-
function namesMatch(candidate, wanted) {
|
|
2460
|
-
const a = candidate.toLowerCase();
|
|
2461
|
-
const b = wanted.toLowerCase();
|
|
2462
|
-
return a === b || withoutDuplicateSuffix(a) === withoutDuplicateSuffix(b);
|
|
2463
|
-
}
|
|
2464
|
-
var splitSegments = (path) => path.split(/[\\/]+/).filter((s) => s !== "" && s !== "." && s !== "~");
|
|
2465
|
-
function suffixScore(path, wanted) {
|
|
2466
|
-
const have = splitSegments(path);
|
|
2467
|
-
let score = 1;
|
|
2468
|
-
for (let i = 2; i <= Math.min(have.length, wanted.length); i++) {
|
|
2469
|
-
if (have[have.length - i]?.toLowerCase() !== wanted[wanted.length - i]?.toLowerCase()) break;
|
|
2470
|
-
score++;
|
|
2471
|
-
}
|
|
2472
|
-
return score;
|
|
2473
|
-
}
|
|
2474
|
-
var looksLikeSandboxPath = (input) => SANDBOX_PATH_RE.test(input.replace(/\\/g, "/"));
|
|
2475
|
-
function normaliseInput(raw, opts) {
|
|
2476
|
-
let input = raw.trim().replace(/^(["'])(.*)\1$/, "$2");
|
|
2477
|
-
if (input.startsWith("file://")) {
|
|
2478
|
-
try {
|
|
2479
|
-
input = fileURLToPath(input, { windows: opts.platform === "win32" });
|
|
2480
|
-
} catch {
|
|
2481
|
-
input = decodeURIComponent(input.slice("file://".length));
|
|
2482
|
-
}
|
|
2483
|
-
}
|
|
2484
|
-
return expandHome(input, opts.home, opts.platform);
|
|
2485
|
-
}
|
|
2486
|
-
async function resolveInput(input, opts) {
|
|
2487
|
-
const fs = opts.fs ?? nodeFs;
|
|
2488
|
-
const p = pathFor(opts.platform);
|
|
2489
|
-
const given = normaliseInput(input, opts);
|
|
2490
|
-
if (!given) throw new CliError("No path given");
|
|
2491
|
-
const direct = p.isAbsolute(given) ? p.normalize(given) : p.resolve(opts.cwd, given);
|
|
2492
|
-
const directStat = await fs.stat(direct);
|
|
2493
|
-
if (directStat && (directStat.isFile || directStat.isDirectory)) {
|
|
2494
|
-
return {
|
|
2495
|
-
path: direct,
|
|
2496
|
-
foundIn: FOUND_AS_GIVEN,
|
|
2497
|
-
isDirectory: directStat.isDirectory,
|
|
2498
|
-
alternatives: []
|
|
2499
|
-
};
|
|
2500
|
-
}
|
|
2501
|
-
const wanted = splitSegments(given);
|
|
2502
|
-
const name = wanted.at(-1);
|
|
2503
|
-
const roots = await searchRoots(opts);
|
|
2504
|
-
if (!name) throw notFound(input, roots);
|
|
2505
|
-
const found = [];
|
|
2506
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2507
|
-
await walk(roots, opts, async ({ root, path, entry }) => {
|
|
2508
|
-
if (!(entry.isFile || entry.isDirectory) || !namesMatch(entry.name, name)) return;
|
|
2509
|
-
if (seen.has(path)) return;
|
|
2510
|
-
seen.add(path);
|
|
2511
|
-
const s = await fs.stat(path);
|
|
2512
|
-
if (!s) return;
|
|
2513
|
-
found.push({
|
|
2514
|
-
path,
|
|
2515
|
-
foundIn: root.label,
|
|
2516
|
-
kind: s.isDirectory ? "folder" : "file",
|
|
2517
|
-
modified: new Date(s.mtimeMs).toISOString(),
|
|
2518
|
-
size: s.size,
|
|
2519
|
-
score: suffixScore(path, wanted),
|
|
2520
|
-
mtimeMs: s.mtimeMs,
|
|
2521
|
-
isDirectory: s.isDirectory
|
|
2522
|
-
});
|
|
2523
|
-
return void 0;
|
|
2524
|
-
});
|
|
2525
|
-
if (found.length === 0) throw notFound(input, roots);
|
|
2526
|
-
found.sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs);
|
|
2527
|
-
const strip = ({ score: _s, mtimeMs: _m, isDirectory: _d, ...c }) => c;
|
|
2528
|
-
const [best, ...rest] = found;
|
|
2529
|
-
return {
|
|
2530
|
-
path: best.path,
|
|
2531
|
-
foundIn: best.foundIn,
|
|
2532
|
-
isDirectory: best.isDirectory,
|
|
2533
|
-
alternatives: rest.slice(0, 5).map(strip)
|
|
2534
|
-
};
|
|
2535
|
-
}
|
|
2536
|
-
function notFound(input, roots) {
|
|
2537
|
-
const where = roots.map((r) => `${r.label} (${r.path})`).join(", ");
|
|
2538
|
-
const sandbox = looksLikeSandboxPath(input) ? " That looks like a path inside Claude\u2019s sandbox, which this computer cannot see: publish the content inline with fling_publish, or save/download the file to this computer first." : "";
|
|
2539
|
-
return new CliError(`Could not find "${input}". Looked for it by name in: ${where}.${sandbox}`, {
|
|
2540
|
-
hint: "Pass the full path, or save the file to your Downloads folder and use just its name."
|
|
2541
|
-
});
|
|
2542
|
-
}
|
|
2543
|
-
var LISTED_EXT = /\.(html?|zip)$/i;
|
|
2544
|
-
async function findFiles(opts, find = {}) {
|
|
2545
|
-
const fs = opts.fs ?? nodeFs;
|
|
2546
|
-
const p = pathFor(opts.platform);
|
|
2547
|
-
const now = (opts.now ?? Date.now)();
|
|
2548
|
-
const since = now - (find.days ?? 14) * 24 * 60 * 60 * 1e3;
|
|
2549
|
-
const query = find.query?.trim().toLowerCase();
|
|
2550
|
-
const roots = await searchRoots(opts);
|
|
2551
|
-
const found = /* @__PURE__ */ new Map();
|
|
2552
|
-
const rootPaths = new Set(roots.map((r) => r.path));
|
|
2553
|
-
const { complete } = await walk(roots, opts, async ({ root, path, entry }) => {
|
|
2554
|
-
if (entry.isDirectory) {
|
|
2555
|
-
if (rootPaths.has(path)) return;
|
|
2556
|
-
const index = (await fs.readdir(path)).find((e) => e.isFile && /^index\.html?$/i.test(e.name));
|
|
2557
|
-
if (!index) return;
|
|
2558
|
-
const s2 = await fs.stat(p.join(path, index.name));
|
|
2559
|
-
if (s2 && s2.mtimeMs >= since && (!query || entry.name.toLowerCase().includes(query)))
|
|
2560
|
-
found.set(path, {
|
|
2561
|
-
path,
|
|
2562
|
-
foundIn: root.label,
|
|
2563
|
-
kind: "folder",
|
|
2564
|
-
modified: new Date(s2.mtimeMs).toISOString(),
|
|
2565
|
-
size: s2.size,
|
|
2566
|
-
mtimeMs: s2.mtimeMs
|
|
2567
|
-
});
|
|
2568
|
-
return "skip";
|
|
2569
|
-
}
|
|
2570
|
-
if (!entry.isFile || !LISTED_EXT.test(entry.name)) return;
|
|
2571
|
-
if (query && !entry.name.toLowerCase().includes(query)) return;
|
|
2572
|
-
const s = await fs.stat(path);
|
|
2573
|
-
if (!s || s.mtimeMs < since || found.has(path)) return;
|
|
2574
|
-
found.set(path, {
|
|
2575
|
-
path,
|
|
2576
|
-
foundIn: root.label,
|
|
2577
|
-
kind: "file",
|
|
2578
|
-
modified: new Date(s.mtimeMs).toISOString(),
|
|
2579
|
-
size: s.size,
|
|
2580
|
-
mtimeMs: s.mtimeMs
|
|
2581
|
-
});
|
|
2582
|
-
return void 0;
|
|
2583
|
-
});
|
|
2584
|
-
const files = [...found.values()].sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, find.limit ?? 20).map(({ mtimeMs: _m, ...c }) => c);
|
|
2585
|
-
return { files, searched: roots, complete };
|
|
2586
|
-
}
|
|
2587
|
-
|
|
2588
2866
|
// src/commands/deploy.ts
|
|
2589
2867
|
async function runDeploy(ctx, input, opts) {
|
|
2590
2868
|
const client = requireClient(ctx);
|
|
2591
2869
|
const ask = ctx.interactive && !opts.yes;
|
|
2592
|
-
const
|
|
2593
|
-
const
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
ctx.
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
}
|
|
2601
|
-
const prepared = await prepareInput(located.path);
|
|
2870
|
+
const instance = await loadInstance(ctx.store, ctx.config, ctx.fetch);
|
|
2871
|
+
const base = sitesBaseOf(instance);
|
|
2872
|
+
const path3 = resolve3(input ?? ".");
|
|
2873
|
+
if (!await stat4(path3).catch(() => null))
|
|
2874
|
+
throw new CliError(`No such file or folder: ${input ?? "."}`, {
|
|
2875
|
+
hint: `Give the path to a folder, .html, .zip or other file, e.g. \`${ctx.binName} deploy ./dist\`.`
|
|
2876
|
+
});
|
|
2877
|
+
const prepared = await prepareInput(path3);
|
|
2602
2878
|
try {
|
|
2603
|
-
await deployPrepared(ctx, client, base, prepared, opts, ask);
|
|
2879
|
+
await deployPrepared(ctx, client, instance, base, prepared, opts, ask);
|
|
2604
2880
|
} finally {
|
|
2605
2881
|
await prepared.cleanup();
|
|
2606
2882
|
}
|
|
2607
2883
|
}
|
|
2608
|
-
async function deployPrepared(ctx, client, base, prepared, opts, ask) {
|
|
2884
|
+
async function deployPrepared(ctx, client, instance, base, prepared, opts, ask) {
|
|
2609
2885
|
let local = await buildManifest(prepared.dir);
|
|
2610
2886
|
local.skipped.dotfiles.unshift(...prepared.skippedDotfiles);
|
|
2611
2887
|
for (const line of describeSkipped(local.skipped)) ctx.log.info(line);
|
|
@@ -2634,7 +2910,7 @@ async function deployPrepared(ctx, client, base, prepared, opts, ask) {
|
|
|
2634
2910
|
if (p.total === 0) return;
|
|
2635
2911
|
progressShown = true;
|
|
2636
2912
|
ctx.progress(
|
|
2637
|
-
`${
|
|
2913
|
+
`${progressBar(p.totalBytes ? p.bytes / p.totalBytes : p.uploaded / p.total)} uploading ${p.uploaded}/${p.total} files \xB7 ${formatBytes(p.bytes)}/${formatBytes(p.totalBytes)}`
|
|
2638
2914
|
);
|
|
2639
2915
|
}
|
|
2640
2916
|
}
|
|
@@ -2643,12 +2919,12 @@ async function deployPrepared(ctx, client, base, prepared, opts, ask) {
|
|
|
2643
2919
|
});
|
|
2644
2920
|
for (const warning of result.warnings) if (warning !== NO_INDEX_WARNING) ctx.log.warn(warning);
|
|
2645
2921
|
const summary = `${result.fileCount} files, ${formatBytes(result.totalBytes)}`;
|
|
2646
|
-
if (result.status === "unchanged") ctx.log.info(`Unchanged ${
|
|
2922
|
+
if (result.status === "unchanged") ctx.log.info(`Unchanged ${chalk7.dim(`(${summary})`)}`);
|
|
2647
2923
|
else if (result.status === "repointed")
|
|
2648
|
-
ctx.log.success(`Restored version ${result.versionId} ${
|
|
2924
|
+
ctx.log.success(`Restored version ${result.versionId} ${chalk7.dim(`(${summary})`)}`);
|
|
2649
2925
|
else
|
|
2650
2926
|
ctx.log.success(
|
|
2651
|
-
`Deployed ${result.name} ${
|
|
2927
|
+
`Deployed ${result.name} ${chalk7.dim(`(${summary}; uploaded ${result.uploaded}, version ${result.versionId})`)}`
|
|
2652
2928
|
);
|
|
2653
2929
|
const ready = result.status === "unchanged" ? true : (await waitWithSpinner(
|
|
2654
2930
|
ctx.waitReady,
|
|
@@ -2656,17 +2932,39 @@ async function deployPrepared(ctx, client, base, prepared, opts, ask) {
|
|
|
2656
2932
|
local.files.map((f) => f.path),
|
|
2657
2933
|
{ progress: ctx.progress, warn: (m) => ctx.log.warn(m) }
|
|
2658
2934
|
)).ready;
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2935
|
+
const who = instance ? `only ${privateAudience(instance)} can open it` : "sign-in required";
|
|
2936
|
+
const privateNote = `Private: ${who}. Share publicly with \`${ctx.binName} share ${result.name}\`.`;
|
|
2937
|
+
const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
|
|
2938
|
+
const reviewNote = instance ? `Share for feedback (reviewers sign in to ${instance.name}):` : "Share for feedback:";
|
|
2939
|
+
const data = {
|
|
2940
|
+
status: result.status,
|
|
2941
|
+
site: result.site,
|
|
2942
|
+
...review ? { reviewUrl: review } : {},
|
|
2943
|
+
ready,
|
|
2944
|
+
skipped: local.skipped
|
|
2945
|
+
};
|
|
2946
|
+
ctx.out.data(data, () => {
|
|
2947
|
+
const isPrivate = result.site.visibility === "private";
|
|
2948
|
+
if (ctx.out.tty)
|
|
2949
|
+
return box(
|
|
2950
|
+
[
|
|
2951
|
+
...review ? [chalk7.dim(reviewNote), brand.url(review), "", chalk7.dim("Site:")] : [],
|
|
2952
|
+
brand.url(result.url),
|
|
2953
|
+
...isPrivate ? [chalk7.dim(privateNote)] : []
|
|
2954
|
+
],
|
|
2955
|
+
isPrivate ? chalk7.bold("flung \xB7 private") : chalk7.bold("flung \xB7 public")
|
|
2666
2956
|
);
|
|
2957
|
+
const lines = [chalk7.bold(result.url)];
|
|
2958
|
+
if (review) lines.push(chalk7.dim(`${reviewNote} ${review}`));
|
|
2959
|
+
if (isPrivate) lines.push(chalk7.dim(privateNote));
|
|
2667
2960
|
return lines.join("\n");
|
|
2668
2961
|
});
|
|
2669
2962
|
}
|
|
2963
|
+
var BAR_WIDTH = 20;
|
|
2964
|
+
function progressBar(fraction) {
|
|
2965
|
+
const done = Math.round(Math.min(Math.max(fraction, 0), 1) * BAR_WIDTH);
|
|
2966
|
+
return brand.primary("\u2501".repeat(done)) + chalk7.dim("\u2500".repeat(BAR_WIDTH - done));
|
|
2967
|
+
}
|
|
2670
2968
|
async function askNoIndex(ctx, pages) {
|
|
2671
2969
|
const question = pages > 0 ? "No index.html at the root. [L]ist the HTML pages on a generated index page / [f]ling anyway (the root will 404) / [a]bort " : "No index.html at the root (and no HTML pages to list). [f]ling anyway (the root will 404) / [A]bort ";
|
|
2672
2970
|
for (; ; ) {
|
|
@@ -2681,20 +2979,20 @@ async function askNoIndex(ctx, pages) {
|
|
|
2681
2979
|
}
|
|
2682
2980
|
async function confirmSiteName(ctx, client, generated, base) {
|
|
2683
2981
|
const where = (n) => base ? ` \u2192 ${siteUrlFor(n, base)}` : "";
|
|
2684
|
-
const
|
|
2982
|
+
const describe2 = (n, note) => ctx.log.info(`Site name: ${chalk7.bold(n)}${where(n)} ${chalk7.dim(`(${note})`)}`);
|
|
2685
2983
|
let candidate = generated;
|
|
2686
2984
|
for (; ; ) {
|
|
2687
|
-
const
|
|
2985
|
+
const check2 = await checkName(client, candidate);
|
|
2688
2986
|
let proposal;
|
|
2689
|
-
if (
|
|
2987
|
+
if (check2.status === "available" || check2.status === "yours") {
|
|
2690
2988
|
proposal = candidate;
|
|
2691
|
-
|
|
2989
|
+
describe2(candidate, check2.status === "yours" ? "updates your existing site" : "new");
|
|
2692
2990
|
} else {
|
|
2693
|
-
const why =
|
|
2694
|
-
const alternatives =
|
|
2991
|
+
const why = check2.status === "taken" ? "is taken" : "is not a valid site name";
|
|
2992
|
+
const alternatives = check2.suggestions.length ? ` Suggestions: ${check2.suggestions.join(", ")}` : "";
|
|
2695
2993
|
ctx.log.warn(`${candidate} ${why}.${alternatives}`);
|
|
2696
|
-
proposal =
|
|
2697
|
-
if (proposal)
|
|
2994
|
+
proposal = check2.suggestions[0];
|
|
2995
|
+
if (proposal) describe2(proposal, "new");
|
|
2698
2996
|
}
|
|
2699
2997
|
for (; ; ) {
|
|
2700
2998
|
const question = proposal ? `Use "${proposal}"? [Y/n or type another name] ` : "Type a site name (or n to abort): ";
|
|
@@ -2717,8 +3015,215 @@ async function confirmSiteName(ctx, client, generated, base) {
|
|
|
2717
3015
|
}
|
|
2718
3016
|
}
|
|
2719
3017
|
|
|
3018
|
+
// src/commands/setup.ts
|
|
3019
|
+
import { intro, outro, spinner } from "@clack/prompts";
|
|
3020
|
+
import chalk8 from "chalk";
|
|
3021
|
+
|
|
3022
|
+
// src/claude.ts
|
|
3023
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
3024
|
+
import { access, mkdtemp as mkdtemp2, writeFile as writeFile3 } from "node:fs/promises";
|
|
3025
|
+
import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
|
|
3026
|
+
import { join as join5 } from "node:path";
|
|
3027
|
+
var MARKETPLACE_SOURCE = REPO_SLUG;
|
|
3028
|
+
var MARKETPLACE_NAME = "fling";
|
|
3029
|
+
var PLUGIN_NAME = "fling";
|
|
3030
|
+
var PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
|
|
3031
|
+
var MCPB_PATH = "/downloads/fling.mcpb";
|
|
3032
|
+
var runClaude = (args) => new Promise((resolve5) => {
|
|
3033
|
+
execFile2(
|
|
3034
|
+
"claude",
|
|
3035
|
+
[...args],
|
|
3036
|
+
{ timeout: 12e4, maxBuffer: 16 * 1024 * 1024, shell: process.platform === "win32" },
|
|
3037
|
+
(error, stdout, stderr) => {
|
|
3038
|
+
const missing = error?.code === "ENOENT";
|
|
3039
|
+
const code = !error ? 0 : missing ? -1 : typeof error.code === "number" ? error.code : 1;
|
|
3040
|
+
resolve5({ code, stdout: String(stdout), stderr: String(stderr) });
|
|
3041
|
+
}
|
|
3042
|
+
);
|
|
3043
|
+
});
|
|
3044
|
+
async function claudeCodeStatus(run) {
|
|
3045
|
+
const version = await run(["--version"]);
|
|
3046
|
+
if (version.code !== 0) return { claude: "missing" };
|
|
3047
|
+
const claude = version.stdout.trim().split(/\s+/)[0] ?? "unknown";
|
|
3048
|
+
const list = await run(["plugin", "list", "--json"]);
|
|
3049
|
+
let plugins = [];
|
|
3050
|
+
try {
|
|
3051
|
+
plugins = JSON.parse(list.stdout);
|
|
3052
|
+
} catch {
|
|
3053
|
+
}
|
|
3054
|
+
const fling = Array.isArray(plugins) ? plugins.find((p) => p.id === PLUGIN_ID) : void 0;
|
|
3055
|
+
if (!fling) return { claude, installed: false };
|
|
3056
|
+
return {
|
|
3057
|
+
claude,
|
|
3058
|
+
installed: true,
|
|
3059
|
+
version: fling.version,
|
|
3060
|
+
scope: fling.scope,
|
|
3061
|
+
enabled: fling.enabled
|
|
3062
|
+
};
|
|
3063
|
+
}
|
|
3064
|
+
async function installPlugin(run, scope) {
|
|
3065
|
+
check(
|
|
3066
|
+
await run(["plugin", "install", PLUGIN_NAME, "--marketplace", MARKETPLACE_SOURCE, "-s", scope]),
|
|
3067
|
+
"install the Fling plugin"
|
|
3068
|
+
);
|
|
3069
|
+
}
|
|
3070
|
+
async function updatePlugin(run, scope) {
|
|
3071
|
+
check(await run(["plugin", "marketplace", "update", MARKETPLACE_NAME]), "update the marketplace");
|
|
3072
|
+
check(
|
|
3073
|
+
await run(["plugin", "update", PLUGIN_ID, ...scope ? ["-s", scope] : []]),
|
|
3074
|
+
"update the Fling plugin"
|
|
3075
|
+
);
|
|
3076
|
+
}
|
|
3077
|
+
function check(result, what) {
|
|
3078
|
+
if (result.code === 0) return;
|
|
3079
|
+
const detail = (result.stderr || result.stdout).trim().split("\n").at(-1);
|
|
3080
|
+
throw new Error(`Could not ${what}${detail ? `: ${detail}` : ""}`);
|
|
3081
|
+
}
|
|
3082
|
+
async function findClaudeDesktop(platform = process.platform, env = process.env, exists = (path3) => access(path3).then(
|
|
3083
|
+
() => true,
|
|
3084
|
+
() => false
|
|
3085
|
+
)) {
|
|
3086
|
+
const candidates = platform === "darwin" ? ["/Applications/Claude.app", join5(homedir3(), "Applications", "Claude.app")] : platform === "win32" && env.LOCALAPPDATA ? [join5(env.LOCALAPPDATA, "AnthropicClaude"), join5(env.LOCALAPPDATA, "Programs", "Claude")] : [];
|
|
3087
|
+
for (const path3 of candidates) if (await exists(path3)) return path3;
|
|
3088
|
+
return null;
|
|
3089
|
+
}
|
|
3090
|
+
async function downloadExtension(serverUrl, fetch2) {
|
|
3091
|
+
const url = new URL(MCPB_PATH, serverUrl).toString();
|
|
3092
|
+
const response = await fetch2(url);
|
|
3093
|
+
if (!response.ok) throw new Error(`Could not download ${url} (HTTP ${response.status})`);
|
|
3094
|
+
const dir = await mkdtemp2(join5(tmpdir2(), "fling-mcpb-"));
|
|
3095
|
+
const file = join5(dir, "fling.mcpb");
|
|
3096
|
+
await writeFile3(file, Buffer.from(await response.arrayBuffer()));
|
|
3097
|
+
return file;
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
// src/commands/setup.ts
|
|
3101
|
+
var SCOPES = ["user", "project", "local"];
|
|
3102
|
+
async function runSetup(baseCtx, opts, deps = {}) {
|
|
3103
|
+
const scope = opts.scope ?? "user";
|
|
3104
|
+
if (!SCOPES.includes(scope))
|
|
3105
|
+
throw new CliError(`--scope must be one of ${SCOPES.join(", ")}, not "${opts.scope}"`);
|
|
3106
|
+
const run = deps.runClaude ?? runClaude;
|
|
3107
|
+
const ask = baseCtx.interactive && !opts.yes;
|
|
3108
|
+
const fancy = baseCtx.interactive && !baseCtx.json;
|
|
3109
|
+
const ctx = fancy ? {
|
|
3110
|
+
...baseCtx,
|
|
3111
|
+
log: createLogger({
|
|
3112
|
+
debug: baseCtx.config.debug,
|
|
3113
|
+
write: (line) => process.stderr.write(`${chalk8.gray("\u2502")} ${line}
|
|
3114
|
+
`)
|
|
3115
|
+
})
|
|
3116
|
+
} : baseCtx;
|
|
3117
|
+
if (fancy) {
|
|
3118
|
+
process.stderr.write(`${logo()}
|
|
3119
|
+
`);
|
|
3120
|
+
intro(chalk8.bold(" fling setup "), { output: process.stderr });
|
|
3121
|
+
}
|
|
3122
|
+
const results = {
|
|
3123
|
+
login: await stepLogin(ctx, opts.url),
|
|
3124
|
+
claudeCode: "skipped",
|
|
3125
|
+
claudeDesktop: "skipped"
|
|
3126
|
+
};
|
|
3127
|
+
const after = results.login === "done" ? { ...ctx, config: await ctx.store.resolve() } : ctx;
|
|
3128
|
+
results.claudeCode = await stepClaudeCode(after, run, scope, ask, fancy);
|
|
3129
|
+
results.claudeDesktop = await stepClaudeDesktop(after, deps, ask, fancy);
|
|
3130
|
+
const failed = Object.values(results).includes("failed");
|
|
3131
|
+
if (fancy)
|
|
3132
|
+
outro(
|
|
3133
|
+
failed ? chalk8.yellow("Setup finished with problems \u2014 see above, then run `fling setup` again.") : `You're set. Try ${chalk8.bold(`${ctx.binName} deploy ./dist`)}, or ask Claude to \u201Cfling this\u201D.`,
|
|
3134
|
+
{ output: process.stderr }
|
|
3135
|
+
);
|
|
3136
|
+
ctx.out.data(results, () => "");
|
|
3137
|
+
if (failed) process.exitCode = 1;
|
|
3138
|
+
}
|
|
3139
|
+
async function stepLogin(ctx, url) {
|
|
3140
|
+
const sameServer = url === void 0 || safeNormalize(url) === ctx.config.serverUrl;
|
|
3141
|
+
if (ctx.config.apiKey && sameServer) {
|
|
3142
|
+
try {
|
|
3143
|
+
const me = await whoami(requireClient(ctx));
|
|
3144
|
+
ctx.log.success(`Signed in as ${chalk8.bold(me.name)} ${chalk8.dim(`<${me.email}>`)}`);
|
|
3145
|
+
return "already";
|
|
3146
|
+
} catch (error) {
|
|
3147
|
+
if (exitCodeFor(error) !== 2) {
|
|
3148
|
+
ctx.log.error(error instanceof Error ? error.message : String(error));
|
|
3149
|
+
return "failed";
|
|
3150
|
+
}
|
|
3151
|
+
ctx.log.warn("The stored API key no longer works \u2014 signing in again");
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
try {
|
|
3155
|
+
await runLogin({ ...ctx, json: false }, url);
|
|
3156
|
+
return "done";
|
|
3157
|
+
} catch (error) {
|
|
3158
|
+
ctx.log.error(error instanceof Error ? error.message : String(error));
|
|
3159
|
+
return "failed";
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
function safeNormalize(url) {
|
|
3163
|
+
try {
|
|
3164
|
+
return normalizeServerUrl(url);
|
|
3165
|
+
} catch {
|
|
3166
|
+
return void 0;
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
async function stepClaudeCode(ctx, run, scope, ask, fancy) {
|
|
3170
|
+
const status = await claudeCodeStatus(run);
|
|
3171
|
+
if (status.claude === "missing") {
|
|
3172
|
+
ctx.log.info(`Claude Code not found ${chalk8.dim("(no `claude` on your PATH) \u2014 skipped")}`);
|
|
3173
|
+
ctx.log.hint(
|
|
3174
|
+
`Once it is installed, run \`${ctx.binName} setup\` again, or in Claude Code: /plugin marketplace add ${MARKETPLACE_SOURCE} then /plugin install ${PLUGIN_ID}`
|
|
3175
|
+
);
|
|
3176
|
+
return "missing";
|
|
3177
|
+
}
|
|
3178
|
+
const installed = "installed" in status && status.installed;
|
|
3179
|
+
const question = installed ? `The Fling plugin is installed in Claude Code${status.version ? ` (${status.version})` : ""}. Update it to the latest?` : "Install the Fling plugin (skill + MCP server) into Claude Code?";
|
|
3180
|
+
if (ask && !await ctx.confirm(question)) {
|
|
3181
|
+
ctx.log.info(chalk8.dim("Claude Code plugin: skipped"));
|
|
3182
|
+
return installed ? "already" : "skipped";
|
|
3183
|
+
}
|
|
3184
|
+
const s = fancy ? spinner({ output: process.stderr }) : null;
|
|
3185
|
+
s?.start(installed ? "Updating the Claude Code plugin" : "Installing the Claude Code plugin");
|
|
3186
|
+
try {
|
|
3187
|
+
if (installed) await updatePlugin(run, status.scope);
|
|
3188
|
+
else await installPlugin(run, scope);
|
|
3189
|
+
} catch (error) {
|
|
3190
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3191
|
+
if (s) s.error(message);
|
|
3192
|
+
else ctx.log.error(message);
|
|
3193
|
+
return "failed";
|
|
3194
|
+
}
|
|
3195
|
+
const done = installed ? `Claude Code plugin ${PLUGIN_NAME} is up to date` : `Claude Code plugin ${PLUGIN_NAME} installed ${chalk8.dim(`(${scope} scope)`)}`;
|
|
3196
|
+
if (s) s.stop(done);
|
|
3197
|
+
else ctx.log.success(done);
|
|
3198
|
+
ctx.log.hint("Restart Claude Code (or run /reload-plugins) to pick it up.");
|
|
3199
|
+
return installed ? "updated" : "done";
|
|
3200
|
+
}
|
|
3201
|
+
async function stepClaudeDesktop(ctx, deps, ask, fancy) {
|
|
3202
|
+
const app = await (deps.findClaudeDesktop ?? findClaudeDesktop)();
|
|
3203
|
+
if (!app) return "missing";
|
|
3204
|
+
if (ask && !await ctx.confirm("Add the Fling extension to Claude Desktop too?")) {
|
|
3205
|
+
ctx.log.info(chalk8.dim("Claude Desktop extension: skipped"));
|
|
3206
|
+
return "skipped";
|
|
3207
|
+
}
|
|
3208
|
+
const s = fancy ? spinner({ output: process.stderr }) : null;
|
|
3209
|
+
s?.start("Downloading the Claude Desktop extension");
|
|
3210
|
+
try {
|
|
3211
|
+
const file = await (deps.downloadExtension ?? ((serverUrl) => downloadExtension(serverUrl, ctx.fetch)))(requireServer(ctx));
|
|
3212
|
+
await ctx.open(file);
|
|
3213
|
+
const done = "Opened fling.mcpb in Claude Desktop \u2014 click Install there to finish";
|
|
3214
|
+
if (s) s.stop(done);
|
|
3215
|
+
else ctx.log.success(done);
|
|
3216
|
+
return "done";
|
|
3217
|
+
} catch (error) {
|
|
3218
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3219
|
+
if (s) s.error(message);
|
|
3220
|
+
else ctx.log.error(message);
|
|
3221
|
+
return "failed";
|
|
3222
|
+
}
|
|
3223
|
+
}
|
|
3224
|
+
|
|
2720
3225
|
// src/commands/sites.ts
|
|
2721
|
-
import
|
|
3226
|
+
import chalk9 from "chalk";
|
|
2722
3227
|
async function runList(ctx, opts) {
|
|
2723
3228
|
const sites = await listSites(requireClient(ctx), opts);
|
|
2724
3229
|
ctx.out.data(
|
|
@@ -2736,11 +3241,11 @@ async function runList(ctx, opts) {
|
|
|
2736
3241
|
);
|
|
2737
3242
|
}
|
|
2738
3243
|
function describeSite(site) {
|
|
2739
|
-
const row = (label, value) => `${
|
|
3244
|
+
const row = (label, value) => `${chalk9.bold(`${label}:`.padEnd(12))}${value}`;
|
|
2740
3245
|
return [
|
|
2741
3246
|
row("Name", site.name),
|
|
2742
3247
|
row("URL", site.url),
|
|
2743
|
-
row("Visibility", site.visibility + (site.archived ?
|
|
3248
|
+
row("Visibility", site.visibility + (site.archived ? chalk9.yellow(" (archived)") : "")),
|
|
2744
3249
|
row("SPA", site.spa ? "yes" : "no"),
|
|
2745
3250
|
row("Share link", site.shareUrl ?? "-"),
|
|
2746
3251
|
row("Version", site.currentVersionId ?? "-"),
|
|
@@ -2755,128 +3260,420 @@ function filesRow(site, row) {
|
|
|
2755
3260
|
const n = current.fileCount;
|
|
2756
3261
|
return [row("Files", `${n} file${n === 1 ? "" : "s"}, ${formatBytes(current.totalBytes)}`)];
|
|
2757
3262
|
}
|
|
2758
|
-
async function runInfo(ctx, name) {
|
|
2759
|
-
const site = await getSite(requireClient(ctx), name);
|
|
2760
|
-
ctx.out.data(
|
|
2761
|
-
site,
|
|
2762
|
-
() => [
|
|
2763
|
-
...describeSite(site),
|
|
2764
|
-
"",
|
|
2765
|
-
|
|
2766
|
-
`${site.versions.length} version(s) \u2014 \`fling versions ${name}\`; files \u2014 \`fling files ${name}\``
|
|
2767
|
-
)
|
|
2768
|
-
].join("\n")
|
|
2769
|
-
);
|
|
3263
|
+
async function runInfo(ctx, name) {
|
|
3264
|
+
const site = await getSite(requireClient(ctx), name);
|
|
3265
|
+
ctx.out.data(
|
|
3266
|
+
site,
|
|
3267
|
+
() => [
|
|
3268
|
+
...describeSite(site),
|
|
3269
|
+
"",
|
|
3270
|
+
chalk9.dim(
|
|
3271
|
+
`${site.versions.length} version(s) \u2014 \`fling versions ${name}\`; files \u2014 \`fling files ${name}\``
|
|
3272
|
+
)
|
|
3273
|
+
].join("\n")
|
|
3274
|
+
);
|
|
3275
|
+
}
|
|
3276
|
+
async function runVersions(ctx, name) {
|
|
3277
|
+
const site = await getSite(requireClient(ctx), name);
|
|
3278
|
+
ctx.out.data(
|
|
3279
|
+
{ name: site.name, currentVersionId: site.currentVersionId, versions: site.versions },
|
|
3280
|
+
() => renderTable(site.versions, [
|
|
3281
|
+
{ header: "", value: (v) => v.id === site.currentVersionId ? "*" : " " },
|
|
3282
|
+
{ header: "VERSION", value: (v) => v.id },
|
|
3283
|
+
{ header: "FILES", value: (v) => v.fileCount },
|
|
3284
|
+
{ header: "SIZE", value: (v) => formatBytes(v.totalBytes) },
|
|
3285
|
+
{ header: "CREATED", value: (v) => formatDate(v.createdAt) }
|
|
3286
|
+
])
|
|
3287
|
+
);
|
|
3288
|
+
}
|
|
3289
|
+
async function runFiles(ctx, name, versionId) {
|
|
3290
|
+
const result = await versionFiles(requireClient(ctx), name, versionId);
|
|
3291
|
+
if (!result.files.some((f) => f.path === "index.html")) {
|
|
3292
|
+
ctx.log.warn("No index.html at the root \u2014 the site root URL will 404");
|
|
3293
|
+
}
|
|
3294
|
+
ctx.out.data(result, () => {
|
|
3295
|
+
const n = result.files.length;
|
|
3296
|
+
const header = chalk9.dim(
|
|
3297
|
+
`${result.versionId}${result.current ? " (current)" : " (not served)"} \u2014 ${n} file${n === 1 ? "" : "s"}, ${formatBytes(result.totalBytes)}${result.generatedIndex ? ", generated index.html" : ""}`
|
|
3298
|
+
);
|
|
3299
|
+
const table = renderTable(result.files, [
|
|
3300
|
+
{ header: "PATH", value: (f) => f.path },
|
|
3301
|
+
{ header: "SIZE", value: (f) => formatBytes(f.size) },
|
|
3302
|
+
{ header: "TYPE", value: (f) => f.contentType }
|
|
3303
|
+
]);
|
|
3304
|
+
return `${header}
|
|
3305
|
+
${table}`;
|
|
3306
|
+
});
|
|
3307
|
+
}
|
|
3308
|
+
async function runRollback(ctx, name, versionId) {
|
|
3309
|
+
const site = await rollback(requireClient(ctx), name, versionId);
|
|
3310
|
+
ctx.log.success(`${name} now serves version ${site.currentVersionId ?? versionId}`);
|
|
3311
|
+
ctx.out.data(site, () => site.url);
|
|
3312
|
+
}
|
|
3313
|
+
async function runShare(ctx, name, opts) {
|
|
3314
|
+
if (opts.refresh && opts.revoke) throw new CliError("Use either --refresh or --revoke, not both");
|
|
3315
|
+
const client = requireClient(ctx);
|
|
3316
|
+
const result = await share(client, name, opts);
|
|
3317
|
+
let ready;
|
|
3318
|
+
if (result.action === "created" || result.action === "refreshed") {
|
|
3319
|
+
const files = ctx.waitReady ? await versionFiles(client, name).catch(() => null) : null;
|
|
3320
|
+
ready = (await waitWithSpinner(ctx.waitReady, result.shareUrl, files?.files.map((f) => f.path) ?? [], {
|
|
3321
|
+
progress: ctx.progress,
|
|
3322
|
+
warn: (m) => ctx.log.warn(m)
|
|
3323
|
+
})).ready;
|
|
3324
|
+
}
|
|
3325
|
+
if (result.action === "revoked") ctx.log.success(`Share link for ${name} revoked`);
|
|
3326
|
+
else if (result.action === "refreshed")
|
|
3327
|
+
ctx.log.success("New share link \u2014 the old one no longer works");
|
|
3328
|
+
else if (result.action === "created")
|
|
3329
|
+
ctx.log.success("Share link created \u2014 anyone with it can view the site");
|
|
3330
|
+
ctx.out.data(
|
|
3331
|
+
ready === void 0 ? result : { ...result, ready },
|
|
3332
|
+
() => result.shareUrl ? result.shareUrl : chalk9.dim("(no share link)")
|
|
3333
|
+
);
|
|
3334
|
+
}
|
|
3335
|
+
async function runVisibility(ctx, name, visibility) {
|
|
3336
|
+
if (visibility !== "private" && visibility !== "public") {
|
|
3337
|
+
throw new CliError(`Visibility must be "private" or "public", not "${visibility}"`);
|
|
3338
|
+
}
|
|
3339
|
+
const site = await setVisibility(requireClient(ctx), name, visibility);
|
|
3340
|
+
ctx.log.success(
|
|
3341
|
+
site.visibility === "public" ? `${name} is public \u2014 no sign-in needed` : `${name} is private \u2014 sign-in required`
|
|
3342
|
+
);
|
|
3343
|
+
ctx.out.data(site, () => site.url);
|
|
3344
|
+
}
|
|
3345
|
+
async function runArchive(ctx, name, unarchive = false) {
|
|
3346
|
+
const site = await archiveSite(requireClient(ctx), name, { unarchive });
|
|
3347
|
+
ctx.log.success(
|
|
3348
|
+
unarchive ? `${name} restored` : `${name} archived \u2014 it 404s until \`fling unarchive ${name}\``
|
|
3349
|
+
);
|
|
3350
|
+
if (ctx.json) ctx.out.data(site, () => "");
|
|
3351
|
+
else if (unarchive) ctx.out.text(site.url);
|
|
3352
|
+
}
|
|
3353
|
+
async function runRemove(ctx, name, opts) {
|
|
3354
|
+
if (!opts.yes) {
|
|
3355
|
+
if (!ctx.interactive) {
|
|
3356
|
+
throw new CliError(`Refusing to delete ${name} without confirmation`, {
|
|
3357
|
+
hint: "Pass --yes to delete."
|
|
3358
|
+
});
|
|
3359
|
+
}
|
|
3360
|
+
const ok = await ctx.confirm(`Delete ${name} and every version of it? This cannot be undone.`);
|
|
3361
|
+
if (!ok) throw new CliError("Cancelled");
|
|
3362
|
+
}
|
|
3363
|
+
const result = await deleteSite(requireClient(ctx), name);
|
|
3364
|
+
ctx.log.success(`${name} deleted`);
|
|
3365
|
+
if (ctx.json) ctx.out.data(result, () => "");
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3368
|
+
// src/mcp.ts
|
|
3369
|
+
import { mkdir as mkdir3, mkdtemp as mkdtemp3, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
|
|
3370
|
+
import { tmpdir as tmpdir3 } from "node:os";
|
|
3371
|
+
import { basename as basename3, dirname as dirname2, join as join6 } from "node:path";
|
|
3372
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3373
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3374
|
+
import { z as z11 } from "zod";
|
|
3375
|
+
|
|
3376
|
+
// src/locate.ts
|
|
3377
|
+
import { lstat, readdir as readdir2, readFile as readFile6, realpath as realpath3, stat as stat5 } from "node:fs/promises";
|
|
3378
|
+
import { posix, win32 } from "node:path";
|
|
3379
|
+
import { fileURLToPath } from "node:url";
|
|
3380
|
+
var DOWNLOADS_DIR_ENV = "FLING_DOWNLOADS_DIR";
|
|
3381
|
+
var FOUND_AS_GIVEN = "path";
|
|
3382
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "__MACOSX", "Library", "AppData", "$RECYCLE.BIN"]);
|
|
3383
|
+
var SESSION_SKIP_DIRS = /* @__PURE__ */ new Set(["skills-plugin", "rpm", "uploads", "memory"]);
|
|
3384
|
+
var SESSIONS_DEPTH = 4;
|
|
3385
|
+
var OUTPUTS_DEPTH = 4;
|
|
3386
|
+
var SANDBOX_PATH_RE = /^\/(mnt\/user-data|mnt\/data|sessions|home\/claude|tmp\/outputs)(\/|$)/;
|
|
3387
|
+
var nodeFs = {
|
|
3388
|
+
async stat(path3) {
|
|
3389
|
+
try {
|
|
3390
|
+
const s = await stat5(path3);
|
|
3391
|
+
return { isFile: s.isFile(), isDirectory: s.isDirectory(), mtimeMs: s.mtimeMs, size: s.size };
|
|
3392
|
+
} catch {
|
|
3393
|
+
return null;
|
|
3394
|
+
}
|
|
3395
|
+
},
|
|
3396
|
+
async readdir(path3) {
|
|
3397
|
+
try {
|
|
3398
|
+
const entries = await readdir2(path3, { withFileTypes: true });
|
|
3399
|
+
const out = [];
|
|
3400
|
+
for (const e of entries) {
|
|
3401
|
+
if (e.isSymbolicLink()) {
|
|
3402
|
+
const s = await stat5(`${path3}/${e.name}`).catch(() => null);
|
|
3403
|
+
out.push({ name: e.name, isFile: Boolean(s?.isFile()), isDirectory: false });
|
|
3404
|
+
} else out.push({ name: e.name, isFile: e.isFile(), isDirectory: e.isDirectory() });
|
|
3405
|
+
}
|
|
3406
|
+
return out;
|
|
3407
|
+
} catch {
|
|
3408
|
+
return [];
|
|
3409
|
+
}
|
|
3410
|
+
},
|
|
3411
|
+
async realpath(path3) {
|
|
3412
|
+
return realpath3(path3).catch(() => null);
|
|
3413
|
+
},
|
|
3414
|
+
async readText(path3) {
|
|
3415
|
+
try {
|
|
3416
|
+
if (!(await lstat(path3)).isFile()) return null;
|
|
3417
|
+
return await readFile6(path3, "utf8");
|
|
3418
|
+
} catch {
|
|
3419
|
+
return null;
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
};
|
|
3423
|
+
var pathFor = (platform) => platform === "win32" ? win32 : posix;
|
|
3424
|
+
var usable = (value) => Boolean(value?.trim()) && !value?.trim().startsWith("${");
|
|
3425
|
+
async function xdgUserDir(key, opts, fs) {
|
|
3426
|
+
const fromEnv2 = opts.env[`XDG_${key}_DIR`];
|
|
3427
|
+
if (usable(fromEnv2)) return fromEnv2;
|
|
3428
|
+
const configHome = opts.env.XDG_CONFIG_HOME || posix.join(opts.home, ".config");
|
|
3429
|
+
const text2 = await fs.readText(posix.join(configHome, "user-dirs.dirs"));
|
|
3430
|
+
const match = text2?.match(new RegExp(`^XDG_${key}_DIR="([^"]*)"`, "m"));
|
|
3431
|
+
if (!match?.[1]) return void 0;
|
|
3432
|
+
return match[1].replace(/^\$HOME(?=\/|$)/, opts.home);
|
|
3433
|
+
}
|
|
3434
|
+
async function searchRoots(opts) {
|
|
3435
|
+
const fs = opts.fs ?? nodeFs;
|
|
3436
|
+
const p = pathFor(opts.platform);
|
|
3437
|
+
const { home, env, platform } = opts;
|
|
3438
|
+
const userHome = platform === "win32" && usable(env.USERPROFILE) ? env.USERPROFILE : home;
|
|
3439
|
+
const roots = [];
|
|
3440
|
+
const add = (label, path3, depth, sessions = false) => {
|
|
3441
|
+
if (!path3) return;
|
|
3442
|
+
const norm = p.resolve(path3);
|
|
3443
|
+
if (roots.some((r) => r.path === norm)) return;
|
|
3444
|
+
roots.push({ label, path: norm, depth, ...sessions ? { sessions } : {} });
|
|
3445
|
+
};
|
|
3446
|
+
const cwd = p.resolve(opts.cwd);
|
|
3447
|
+
if (cwd !== p.parse(cwd).root && cwd !== p.resolve(userHome)) add("current folder", cwd, 4);
|
|
3448
|
+
const configured = usable(opts.downloadsDir) ? opts.downloadsDir : usable(env[DOWNLOADS_DIR_ENV]) ? env[DOWNLOADS_DIR_ENV] : void 0;
|
|
3449
|
+
const defaultDownloads = platform === "linux" ? await xdgUserDir("DOWNLOAD", opts, fs) ?? p.join(home, "Downloads") : p.join(userHome, "Downloads");
|
|
3450
|
+
add("Downloads", configured ? expandHome(configured, home, platform) : defaultDownloads, 3);
|
|
3451
|
+
add("Downloads", defaultDownloads, 3);
|
|
3452
|
+
const desktop = platform === "linux" ? await xdgUserDir("DESKTOP", opts, fs) ?? p.join(home, "Desktop") : p.join(userHome, "Desktop");
|
|
3453
|
+
add("Desktop", desktop, 3);
|
|
3454
|
+
if (platform === "win32" && usable(env.OneDrive))
|
|
3455
|
+
add("Desktop", p.join(env.OneDrive, "Desktop"), 3);
|
|
3456
|
+
const documents = platform === "linux" ? await xdgUserDir("DOCUMENTS", opts, fs) ?? p.join(home, "Documents") : p.join(userHome, "Documents");
|
|
3457
|
+
add("Documents/Claude", p.join(documents, "Claude"), 4);
|
|
3458
|
+
add("~/Claude", p.join(userHome, "Claude"), 4);
|
|
3459
|
+
const claudeConfig = platform === "darwin" ? p.join(home, "Library", "Application Support", "Claude") : platform === "win32" ? p.join(
|
|
3460
|
+
usable(env.APPDATA) ? env.APPDATA : p.join(userHome, "AppData", "Roaming"),
|
|
3461
|
+
"Claude"
|
|
3462
|
+
) : p.join(env.XDG_CONFIG_HOME || p.join(home, ".config"), "Claude");
|
|
3463
|
+
add(
|
|
3464
|
+
"Claude Desktop outputs",
|
|
3465
|
+
p.join(claudeConfig, "local-agent-mode-sessions"),
|
|
3466
|
+
SESSIONS_DEPTH,
|
|
3467
|
+
true
|
|
3468
|
+
);
|
|
3469
|
+
if (!fs.realpath) return roots;
|
|
3470
|
+
const real = [];
|
|
3471
|
+
for (const root of roots) {
|
|
3472
|
+
const path3 = await fs.realpath(root.path) ?? root.path;
|
|
3473
|
+
if (!real.some((r) => r.path === path3)) real.push({ ...root, path: path3 });
|
|
3474
|
+
}
|
|
3475
|
+
return real;
|
|
3476
|
+
}
|
|
3477
|
+
function expandHome(input, home, platform) {
|
|
3478
|
+
if (input === "~") return home;
|
|
3479
|
+
if (input.startsWith("~/") || platform === "win32" && input.startsWith("~\\"))
|
|
3480
|
+
return pathFor(platform).join(home, input.slice(2));
|
|
3481
|
+
return input;
|
|
3482
|
+
}
|
|
3483
|
+
async function walk(roots, opts, visit) {
|
|
3484
|
+
const fs = opts.fs ?? nodeFs;
|
|
3485
|
+
const p = pathFor(opts.platform);
|
|
3486
|
+
const now = opts.now ?? Date.now;
|
|
3487
|
+
const deadline = now() + (opts.timeBudgetMs ?? 4e3);
|
|
3488
|
+
let budget = opts.entryBudget ?? 6e4;
|
|
3489
|
+
const skip = (name) => name.startsWith(".") || SKIP_DIRS.has(name);
|
|
3490
|
+
const expanded = [];
|
|
3491
|
+
for (const root of roots) {
|
|
3492
|
+
if (!root.sessions) {
|
|
3493
|
+
expanded.push(root);
|
|
3494
|
+
continue;
|
|
3495
|
+
}
|
|
3496
|
+
let level = [root.path];
|
|
3497
|
+
for (let depth = 0; depth < root.depth && level.length; depth++) {
|
|
3498
|
+
const next = [];
|
|
3499
|
+
for (const dir of level) {
|
|
3500
|
+
for (const e of await fs.readdir(dir)) {
|
|
3501
|
+
if (!e.isDirectory || skip(e.name) || SESSION_SKIP_DIRS.has(e.name)) continue;
|
|
3502
|
+
const child = p.join(dir, e.name);
|
|
3503
|
+
if (e.name === "outputs")
|
|
3504
|
+
expanded.push({ label: root.label, path: child, depth: OUTPUTS_DEPTH });
|
|
3505
|
+
else next.push(child);
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
level = next;
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
3511
|
+
for (const root of expanded) {
|
|
3512
|
+
let level = [root.path];
|
|
3513
|
+
for (let depth = 0; depth <= root.depth && level.length; depth++) {
|
|
3514
|
+
const next = [];
|
|
3515
|
+
for (const dir of level) {
|
|
3516
|
+
if (now() > deadline || budget <= 0) return { complete: false };
|
|
3517
|
+
const entries = await fs.readdir(dir);
|
|
3518
|
+
budget -= entries.length;
|
|
3519
|
+
for (const entry of entries) {
|
|
3520
|
+
if (entry.name.startsWith(".")) continue;
|
|
3521
|
+
const path3 = p.join(dir, entry.name);
|
|
3522
|
+
const result = await visit({ root, path: path3, entry, siblings: entries });
|
|
3523
|
+
if (entry.isDirectory && !skip(entry.name) && result !== "skip" && depth < root.depth)
|
|
3524
|
+
next.push(path3);
|
|
3525
|
+
}
|
|
3526
|
+
}
|
|
3527
|
+
level = next;
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
return { complete: true };
|
|
3531
|
+
}
|
|
3532
|
+
function withoutDuplicateSuffix(name) {
|
|
3533
|
+
return name.replace(/ \(\d+\)(?=\.[^.]+$|$)/, "");
|
|
2770
3534
|
}
|
|
2771
|
-
|
|
2772
|
-
const
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
() => renderTable(site.versions, [
|
|
2776
|
-
{ header: "", value: (v) => v.id === site.currentVersionId ? "*" : " " },
|
|
2777
|
-
{ header: "VERSION", value: (v) => v.id },
|
|
2778
|
-
{ header: "FILES", value: (v) => v.fileCount },
|
|
2779
|
-
{ header: "SIZE", value: (v) => formatBytes(v.totalBytes) },
|
|
2780
|
-
{ header: "CREATED", value: (v) => formatDate(v.createdAt) }
|
|
2781
|
-
])
|
|
2782
|
-
);
|
|
3535
|
+
function namesMatch(candidate, wanted) {
|
|
3536
|
+
const a = candidate.toLowerCase();
|
|
3537
|
+
const b = wanted.toLowerCase();
|
|
3538
|
+
return a === b || withoutDuplicateSuffix(a) === withoutDuplicateSuffix(b);
|
|
2783
3539
|
}
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
3540
|
+
var splitSegments = (path3) => path3.split(/[\\/]+/).filter((s) => s !== "" && s !== "." && s !== "~");
|
|
3541
|
+
function suffixScore(path3, wanted) {
|
|
3542
|
+
const have = splitSegments(path3);
|
|
3543
|
+
let score = 1;
|
|
3544
|
+
for (let i = 2; i <= Math.min(have.length, wanted.length); i++) {
|
|
3545
|
+
if (have[have.length - i]?.toLowerCase() !== wanted[wanted.length - i]?.toLowerCase()) break;
|
|
3546
|
+
score++;
|
|
2788
3547
|
}
|
|
2789
|
-
|
|
2790
|
-
const n = result.files.length;
|
|
2791
|
-
const header = chalk6.dim(
|
|
2792
|
-
`${result.versionId}${result.current ? " (current)" : " (not served)"} \u2014 ${n} file${n === 1 ? "" : "s"}, ${formatBytes(result.totalBytes)}${result.generatedIndex ? ", generated index.html" : ""}`
|
|
2793
|
-
);
|
|
2794
|
-
const table = renderTable(result.files, [
|
|
2795
|
-
{ header: "PATH", value: (f) => f.path },
|
|
2796
|
-
{ header: "SIZE", value: (f) => formatBytes(f.size) },
|
|
2797
|
-
{ header: "TYPE", value: (f) => f.contentType }
|
|
2798
|
-
]);
|
|
2799
|
-
return `${header}
|
|
2800
|
-
${table}`;
|
|
2801
|
-
});
|
|
2802
|
-
}
|
|
2803
|
-
async function runRollback(ctx, name, versionId) {
|
|
2804
|
-
const site = await rollback(requireClient(ctx), name, versionId);
|
|
2805
|
-
ctx.log.success(`${name} now serves version ${site.currentVersionId ?? versionId}`);
|
|
2806
|
-
ctx.out.data(site, () => site.url);
|
|
3548
|
+
return score;
|
|
2807
3549
|
}
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
warn: (m) => ctx.log.warn(m)
|
|
2818
|
-
})).ready;
|
|
3550
|
+
var looksLikeSandboxPath = (input) => SANDBOX_PATH_RE.test(input.replace(/\\/g, "/"));
|
|
3551
|
+
function normaliseInput(raw, opts) {
|
|
3552
|
+
let input = raw.trim().replace(/^(["'])(.*)\1$/, "$2");
|
|
3553
|
+
if (input.startsWith("file://")) {
|
|
3554
|
+
try {
|
|
3555
|
+
input = fileURLToPath(input, { windows: opts.platform === "win32" });
|
|
3556
|
+
} catch {
|
|
3557
|
+
input = decodeURIComponent(input.slice("file://".length));
|
|
3558
|
+
}
|
|
2819
3559
|
}
|
|
2820
|
-
|
|
2821
|
-
else if (result.action === "refreshed")
|
|
2822
|
-
ctx.log.success("New share link \u2014 the old one no longer works");
|
|
2823
|
-
else if (result.action === "created")
|
|
2824
|
-
ctx.log.success("Share link created \u2014 anyone with it can view the site");
|
|
2825
|
-
ctx.out.data(
|
|
2826
|
-
ready === void 0 ? result : { ...result, ready },
|
|
2827
|
-
() => result.shareUrl ? result.shareUrl : chalk6.dim("(no share link)")
|
|
2828
|
-
);
|
|
3560
|
+
return expandHome(input, opts.home, opts.platform);
|
|
2829
3561
|
}
|
|
2830
|
-
async function
|
|
2831
|
-
|
|
2832
|
-
|
|
3562
|
+
async function resolveInput(input, opts) {
|
|
3563
|
+
const fs = opts.fs ?? nodeFs;
|
|
3564
|
+
const p = pathFor(opts.platform);
|
|
3565
|
+
const given = normaliseInput(input, opts);
|
|
3566
|
+
if (!given) throw new CliError("No path given");
|
|
3567
|
+
const direct = p.isAbsolute(given) ? p.normalize(given) : p.resolve(opts.cwd, given);
|
|
3568
|
+
const directStat = await fs.stat(direct);
|
|
3569
|
+
if (directStat && (directStat.isFile || directStat.isDirectory)) {
|
|
3570
|
+
return {
|
|
3571
|
+
path: direct,
|
|
3572
|
+
foundIn: FOUND_AS_GIVEN,
|
|
3573
|
+
isDirectory: directStat.isDirectory,
|
|
3574
|
+
alternatives: []
|
|
3575
|
+
};
|
|
2833
3576
|
}
|
|
2834
|
-
const
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
);
|
|
2838
|
-
|
|
3577
|
+
const wanted = splitSegments(given);
|
|
3578
|
+
const name = wanted.at(-1);
|
|
3579
|
+
const roots = await searchRoots(opts);
|
|
3580
|
+
if (!name) throw notFound(input, roots);
|
|
3581
|
+
const found = [];
|
|
3582
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3583
|
+
await walk(roots, opts, async ({ root, path: path3, entry }) => {
|
|
3584
|
+
if (!(entry.isFile || entry.isDirectory) || !namesMatch(entry.name, name)) return;
|
|
3585
|
+
if (seen.has(path3)) return;
|
|
3586
|
+
seen.add(path3);
|
|
3587
|
+
const s = await fs.stat(path3);
|
|
3588
|
+
if (!s) return;
|
|
3589
|
+
found.push({
|
|
3590
|
+
path: path3,
|
|
3591
|
+
foundIn: root.label,
|
|
3592
|
+
kind: s.isDirectory ? "folder" : "file",
|
|
3593
|
+
modified: new Date(s.mtimeMs).toISOString(),
|
|
3594
|
+
size: s.size,
|
|
3595
|
+
score: suffixScore(path3, wanted),
|
|
3596
|
+
mtimeMs: s.mtimeMs,
|
|
3597
|
+
isDirectory: s.isDirectory
|
|
3598
|
+
});
|
|
3599
|
+
return void 0;
|
|
3600
|
+
});
|
|
3601
|
+
if (found.length === 0) throw notFound(input, roots);
|
|
3602
|
+
found.sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs);
|
|
3603
|
+
const strip = ({ score: _s, mtimeMs: _m, isDirectory: _d, ...c }) => c;
|
|
3604
|
+
const [best, ...rest] = found;
|
|
3605
|
+
return {
|
|
3606
|
+
path: best.path,
|
|
3607
|
+
foundIn: best.foundIn,
|
|
3608
|
+
isDirectory: best.isDirectory,
|
|
3609
|
+
alternatives: rest.slice(0, 5).map(strip)
|
|
3610
|
+
};
|
|
2839
3611
|
}
|
|
2840
|
-
|
|
2841
|
-
const
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
else if (unarchive) ctx.out.text(site.url);
|
|
3612
|
+
function notFound(input, roots) {
|
|
3613
|
+
const where = roots.map((r) => `${r.label} (${r.path})`).join(", ");
|
|
3614
|
+
const sandbox = looksLikeSandboxPath(input) ? " That looks like a path inside Claude\u2019s sandbox, which this computer cannot see: publish the content inline with fling_publish, or save/download the file to this computer first." : "";
|
|
3615
|
+
return new CliError(`Could not find "${input}". Looked for it by name in: ${where}.${sandbox}`, {
|
|
3616
|
+
hint: "Pass the full path, or save the file to your Downloads folder and use just its name."
|
|
3617
|
+
});
|
|
2847
3618
|
}
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
3619
|
+
var LISTED_EXT = /\.(html?|zip)$/i;
|
|
3620
|
+
async function findFiles(opts, find = {}) {
|
|
3621
|
+
const fs = opts.fs ?? nodeFs;
|
|
3622
|
+
const p = pathFor(opts.platform);
|
|
3623
|
+
const now = (opts.now ?? Date.now)();
|
|
3624
|
+
const since = now - (find.days ?? 14) * 24 * 60 * 60 * 1e3;
|
|
3625
|
+
const query = find.query?.trim().toLowerCase();
|
|
3626
|
+
const roots = await searchRoots(opts);
|
|
3627
|
+
const found = /* @__PURE__ */ new Map();
|
|
3628
|
+
const rootPaths = new Set(roots.map((r) => r.path));
|
|
3629
|
+
const { complete } = await walk(roots, opts, async ({ root, path: path3, entry }) => {
|
|
3630
|
+
if (entry.isDirectory) {
|
|
3631
|
+
if (rootPaths.has(path3)) return;
|
|
3632
|
+
const index = (await fs.readdir(path3)).find((e) => e.isFile && /^index\.html?$/i.test(e.name));
|
|
3633
|
+
if (!index) return;
|
|
3634
|
+
const s2 = await fs.stat(p.join(path3, index.name));
|
|
3635
|
+
if (s2 && s2.mtimeMs >= since && (!query || entry.name.toLowerCase().includes(query)))
|
|
3636
|
+
found.set(path3, {
|
|
3637
|
+
path: path3,
|
|
3638
|
+
foundIn: root.label,
|
|
3639
|
+
kind: "folder",
|
|
3640
|
+
modified: new Date(s2.mtimeMs).toISOString(),
|
|
3641
|
+
size: s2.size,
|
|
3642
|
+
mtimeMs: s2.mtimeMs
|
|
3643
|
+
});
|
|
3644
|
+
return "skip";
|
|
2854
3645
|
}
|
|
2855
|
-
|
|
2856
|
-
if (
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
3646
|
+
if (!entry.isFile || !LISTED_EXT.test(entry.name)) return;
|
|
3647
|
+
if (query && !entry.name.toLowerCase().includes(query)) return;
|
|
3648
|
+
const s = await fs.stat(path3);
|
|
3649
|
+
if (!s || s.mtimeMs < since || found.has(path3)) return;
|
|
3650
|
+
found.set(path3, {
|
|
3651
|
+
path: path3,
|
|
3652
|
+
foundIn: root.label,
|
|
3653
|
+
kind: "file",
|
|
3654
|
+
modified: new Date(s.mtimeMs).toISOString(),
|
|
3655
|
+
size: s.size,
|
|
3656
|
+
mtimeMs: s.mtimeMs
|
|
3657
|
+
});
|
|
3658
|
+
return void 0;
|
|
3659
|
+
});
|
|
3660
|
+
const files = [...found.values()].sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, find.limit ?? 20).map(({ mtimeMs: _m, ...c }) => c);
|
|
3661
|
+
return { files, searched: roots, complete };
|
|
2861
3662
|
}
|
|
2862
3663
|
|
|
2863
3664
|
// src/mcp.ts
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2869
|
-
import { z as z7 } from "zod";
|
|
2870
|
-
var LOGIN_HINT = `Not logged in to Fling. Call fling_login (it opens the browser to sign in with Nearform Google), or ask the user to run \`${NPX_COMMAND} login\` in a terminal (in Claude Code: \`! ${NPX_COMMAND} login\`), then try again.`;
|
|
2871
|
-
var MCP_LOCAL_INSTRUCTIONS = [
|
|
2872
|
-
"Fling publishes static sites (HTML/CSS/JS, images, documents) for Nearform staff at https://<name>.fling.labs.nearform.com. This server runs on the user's computer.",
|
|
3665
|
+
var LOGIN_HINT = `Not logged in to Fling. Call fling_login (it opens the browser to sign in), or ask the user to run \`${NPX_COMMAND} login\` in a terminal (in Claude Code: \`! ${NPX_COMMAND} login\`), then try again.`;
|
|
3666
|
+
var NO_SERVER_HINT = `No Fling server is set up on this computer. Ask the user for their Fling's address and to run \`${NPX_COMMAND} login <url>\` in a terminal (in Claude Code: \`! ${NPX_COMMAND} login <url>\`), then try again.`;
|
|
3667
|
+
var mcpLocalInstructions = (w, opts = {}) => [
|
|
3668
|
+
`${w.name} publishes static sites (HTML/CSS/JS, images, documents) at ${w.sitesPattern}. This server runs on the user's computer.`,
|
|
2873
3669
|
"Content you created in this conversation (an HTML page, a report, a small app): call fling_publish with every file inline (text as utf8, binary as base64). Never pass file paths from your sandbox (e.g. /mnt/user-data/outputs/\u2026, /sessions/\u2026) \u2014 this computer cannot see them \u2014 and never curl or upload from the sandbox.",
|
|
2874
3670
|
'A file or folder the user has on this computer (a downloaded .html, a .zip, a build folder): call fling_deploy with its path, or just its file name (e.g. "Pitch Deck.html") \u2014 it is looked up in Downloads, Desktop, Documents/Claude and Claude Desktop output folders. Use fling_find_files to see recent candidates when unsure which file the user means.',
|
|
2875
3671
|
"Confirm the site name with the user before publishing (fling_check_name shows whether it is free and, for a path, the generated name). Names are global, first come first served; when one is taken, offer the suggestions.",
|
|
2876
3672
|
"When there is no index.html but there are HTML pages, fling_deploy and fling_publish do not publish: ask the user whether to generate an index page listing the pages (`listHtml: true`) or publish without one (`listHtml: false`, the root URL 404s), then retry.",
|
|
2877
3673
|
NEVER_PUBLISHED,
|
|
2878
3674
|
"Not logged in? Call fling_login, let the user finish signing in in the browser, then retry.",
|
|
2879
|
-
|
|
3675
|
+
`Sites are private (only ${w.privateAudience}) by default; only make one public if asked. Use fling_share for a link people who cannot sign in can open. ${opts.comments ? "Always give the user the links that come back: the review link first, as the one to share for feedback, then the site URL." : "Always give the user the URL that comes back."}`,
|
|
3676
|
+
...opts.comments ? [COMMENTS_NOTE(w)] : []
|
|
2880
3677
|
].join("\n\n");
|
|
2881
3678
|
var toolDef = (def) => ({
|
|
2882
3679
|
title: def.title,
|
|
@@ -2890,6 +3687,8 @@ function createMcpServer(deps = {}) {
|
|
|
2890
3687
|
const cwd = deps.cwd ?? process.cwd();
|
|
2891
3688
|
const locate = deps.locate ?? { ...systemLocateOptions(), cwd };
|
|
2892
3689
|
const waitReady = deps.waitReady ?? createReadyWaiter();
|
|
3690
|
+
const wording = mcpWording(deps.instance);
|
|
3691
|
+
const mcpTools2 = mcpToolsFor(wording);
|
|
2893
3692
|
async function edgeReady(url, paths) {
|
|
2894
3693
|
const r = await waitReady(url, paths).catch(() => ({ ready: false, waitedMs: 0 }));
|
|
2895
3694
|
return {
|
|
@@ -2900,9 +3699,14 @@ Not reachable yet at ${url}: ${NOT_READY_NOTE}.`
|
|
|
2900
3699
|
}
|
|
2901
3700
|
async function client() {
|
|
2902
3701
|
const config = await store.resolve({ serverUrl: deps.server });
|
|
3702
|
+
if (!config.serverUrl) throw new NoServerError("fling");
|
|
2903
3703
|
if (!config.apiKey) throw new CliError("Not logged in", { exitCode: EXIT_NOT_LOGGED_IN });
|
|
2904
3704
|
return createApiClient({ serverUrl: config.serverUrl, apiKey: config.apiKey, fetch: fetchImpl });
|
|
2905
3705
|
}
|
|
3706
|
+
async function sitesBase() {
|
|
3707
|
+
const config = await store.resolve({ serverUrl: deps.server });
|
|
3708
|
+
return sitesBaseOf(await loadInstance(store, config, fetchImpl));
|
|
3709
|
+
}
|
|
2906
3710
|
async function run(op) {
|
|
2907
3711
|
try {
|
|
2908
3712
|
const { summary, data } = await op(await client());
|
|
@@ -2958,9 +3762,15 @@ Warnings:
|
|
|
2958
3762
|
result.url,
|
|
2959
3763
|
local.files.map((f) => f.path)
|
|
2960
3764
|
);
|
|
3765
|
+
const instance = deps.instance;
|
|
3766
|
+
const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
|
|
3767
|
+
const listing = generatedIndex ? " \u2014 with a generated index.html listing the HTML pages" : "";
|
|
3768
|
+
const head = review ? `${what}${listing}.
|
|
3769
|
+
${shareForFeedback(review, instance?.name ?? "Fling")}
|
|
3770
|
+
Site: ${result.url} (${result.site.visibility})` : `${what}: ${result.url} (${result.site.visibility})${listing}`;
|
|
2961
3771
|
return {
|
|
2962
3772
|
kind: "deployed",
|
|
2963
|
-
summary: `${
|
|
3773
|
+
summary: `${head}${warnings}${skippedNote}${edge.note}
|
|
2964
3774
|
To check exactly which files the site has, call fling_files with name "${result.name}".`,
|
|
2965
3775
|
data: {
|
|
2966
3776
|
status: result.status,
|
|
@@ -2969,6 +3779,7 @@ To check exactly which files the site has, call fling_files with name "${result.
|
|
|
2969
3779
|
nameGenerated: !opts.nameGiven,
|
|
2970
3780
|
versionId: result.versionId,
|
|
2971
3781
|
site: result.site,
|
|
3782
|
+
...review ? { reviewUrl: review } : {},
|
|
2972
3783
|
generatedIndex,
|
|
2973
3784
|
warnings: result.warnings,
|
|
2974
3785
|
skipped: local.skipped
|
|
@@ -2977,23 +3788,27 @@ To check exactly which files the site has, call fling_files with name "${result.
|
|
|
2977
3788
|
}
|
|
2978
3789
|
const server = new McpServer(
|
|
2979
3790
|
{ name: "fling", version: VERSION },
|
|
2980
|
-
{
|
|
3791
|
+
{
|
|
3792
|
+
instructions: mcpLocalInstructions(wording, {
|
|
3793
|
+
comments: commentsEnabled(deps.instance)
|
|
3794
|
+
})
|
|
3795
|
+
}
|
|
2981
3796
|
);
|
|
2982
|
-
const { listHtml: listHtml2, spa: spa2, public:
|
|
3797
|
+
const { listHtml: listHtml2, spa: spa2, public: isPublic } = mcpTools2.publish.inputSchema;
|
|
2983
3798
|
server.registerTool(
|
|
2984
3799
|
"fling_deploy",
|
|
2985
3800
|
{
|
|
2986
3801
|
title: "Deploy a file or folder from this computer",
|
|
2987
|
-
description:
|
|
3802
|
+
description: `Fling something that is on the user's computer and return its stable URL (${wording.sitesPattern}): a folder of static files (e.g. a build output like dist/), a single .html file (served as the site's index.html), a .zip of a site (extracted; a single top-level folder is unwrapped), or any other single file (.pdf, .svg, .png\u2026, served next to a generated page that shows it). \`path\` can be absolute, relative to the working directory, or just a file name ("Pitch Deck.html"): names are looked up, newest first, in Downloads, Desktop, Documents/Claude and Claude Desktop output folders, and the result says where the file was found. Never pass paths from your own sandbox (/mnt/user-data/\u2026, /sessions/\u2026): for content you created, use fling_publish. Without \`name\`, a name is generated: from the file name for files ("Pitch Deck (1).html" \u2192 pitch-deck), from the project and folder for folders (~/work/rocketpod/docs \u2192 rocketpod-docs). Site names are one global namespace, first come first served \u2014 confirm the name with the user first (fling_check_name with \`path\` shows the generated one). If the name is taken it fails with free suggestions \u2014 ask the user to pick one, then retry with \`name\`. If there is no index.html but there are HTML pages, it does not deploy: ask the user whether to generate an index page listing them (\`listHtml: true\`) or deploy without one (\`listHtml: false\`), then retry. Re-deploying identical content is a no-op. Sites are private (only ${wording.privateAudience}) unless \`public\` is set. ` + NEVER_PUBLISHED,
|
|
2988
3803
|
inputSchema: {
|
|
2989
|
-
path:
|
|
3804
|
+
path: z11.string().min(1).describe(
|
|
2990
3805
|
"File or folder on this computer: absolute, relative to the working directory, or just its name (looked up in Downloads, Desktop and Claude output folders)"
|
|
2991
3806
|
),
|
|
2992
|
-
name:
|
|
3807
|
+
name: z11.string().optional().describe(
|
|
2993
3808
|
"Site name (lowercase letters, digits, single hyphens; max 50). Defaults to a name generated from the file or folder"
|
|
2994
3809
|
),
|
|
2995
3810
|
spa: spa2,
|
|
2996
|
-
public:
|
|
3811
|
+
public: isPublic,
|
|
2997
3812
|
listHtml: listHtml2
|
|
2998
3813
|
},
|
|
2999
3814
|
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
@@ -3038,9 +3853,9 @@ To check exactly which files the site has, call fling_files with name "${result.
|
|
|
3038
3853
|
title: "Find files to fling",
|
|
3039
3854
|
description: "List recent things on this computer that could be flung, newest first: .html/.htm and .zip files, and folders containing an index.html, from the working directory, Downloads, Desktop, Documents/Claude, ~/Claude and Claude Desktop output folders. Each has its path, where it was found, size and modified time. Use it when the user refers to a file they downloaded or that Claude Desktop saved, then pass the chosen path (or name) to fling_deploy.",
|
|
3040
3855
|
inputSchema: {
|
|
3041
|
-
query:
|
|
3042
|
-
limit:
|
|
3043
|
-
days:
|
|
3856
|
+
query: z11.string().optional().describe('Only names containing this text (case-insensitive), e.g. "deck"'),
|
|
3857
|
+
limit: z11.number().int().min(1).max(100).optional().describe("Max results (default 20)"),
|
|
3858
|
+
days: z11.number().int().min(1).max(3650).optional().describe("Only things modified in the last N days (default 14)")
|
|
3044
3859
|
},
|
|
3045
3860
|
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
3046
3861
|
},
|
|
@@ -3067,7 +3882,7 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3067
3882
|
}
|
|
3068
3883
|
}
|
|
3069
3884
|
);
|
|
3070
|
-
const publishDef =
|
|
3885
|
+
const publishDef = mcpTools2.publish;
|
|
3071
3886
|
server.registerTool(
|
|
3072
3887
|
publishDef.name,
|
|
3073
3888
|
{
|
|
@@ -3080,7 +3895,7 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3080
3895
|
},
|
|
3081
3896
|
(args) => run(async (c) => {
|
|
3082
3897
|
const name = resolveSiteName(".", args.name);
|
|
3083
|
-
const tmp = await
|
|
3898
|
+
const tmp = await mkdtemp3(join6(tmpdir3(), "fling-publish-"));
|
|
3084
3899
|
try {
|
|
3085
3900
|
const { skippedDotfiles } = await writeInlineFiles(tmp, args.files);
|
|
3086
3901
|
const outcome = await deployFolder(c, tmp, {
|
|
@@ -3101,11 +3916,11 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3101
3916
|
server.registerTool(
|
|
3102
3917
|
"fling_check_name",
|
|
3103
3918
|
{
|
|
3104
|
-
...toolDef(
|
|
3105
|
-
description: `${
|
|
3919
|
+
...toolDef(mcpTools2.checkName),
|
|
3920
|
+
description: `${mcpTools2.checkName.description} Pass \`path\` instead of \`name\` to see the name fling_deploy would generate for a file or folder on this computer (same lookup as fling_deploy), so you can confirm it with the user.`,
|
|
3106
3921
|
inputSchema: {
|
|
3107
|
-
name:
|
|
3108
|
-
path:
|
|
3922
|
+
name: z11.string().min(1).max(200).optional().describe("Site name to check"),
|
|
3923
|
+
path: z11.string().optional().describe("File or folder whose generated default name to check (path or file name)")
|
|
3109
3924
|
}
|
|
3110
3925
|
},
|
|
3111
3926
|
(args) => run(async (c) => {
|
|
@@ -3117,20 +3932,20 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3117
3932
|
source = await resolveInput(args.path, locate);
|
|
3118
3933
|
name = defaultNameFor(source.path, source.isDirectory);
|
|
3119
3934
|
}
|
|
3120
|
-
const
|
|
3121
|
-
const base =
|
|
3935
|
+
const check2 = await checkName(c, name);
|
|
3936
|
+
const base = check2.status === "invalid" ? null : await sitesBase();
|
|
3122
3937
|
const url = base ? siteUrlFor(name, base) : null;
|
|
3123
|
-
const suggestions =
|
|
3938
|
+
const suggestions = check2.suggestions.length ? ` Suggestions: ${check2.suggestions.join(", ")}.` : "";
|
|
3124
3939
|
const summary = {
|
|
3125
3940
|
available: `"${name}" is available${url ? ` \u2192 ${url}` : ""}.`,
|
|
3126
3941
|
yours: `"${name}" is the user's existing site${url ? ` (${url})` : ""} \u2014 deploying updates it.`,
|
|
3127
3942
|
taken: `"${name}" is taken by someone else.${suggestions}`,
|
|
3128
3943
|
invalid: `"${name}" is not a valid site name.${suggestions}`
|
|
3129
|
-
}[
|
|
3944
|
+
}[check2.status];
|
|
3130
3945
|
return {
|
|
3131
3946
|
summary,
|
|
3132
3947
|
data: {
|
|
3133
|
-
...
|
|
3948
|
+
...check2,
|
|
3134
3949
|
url,
|
|
3135
3950
|
...source ? { generated: true, source: { path: source.path, foundIn: source.foundIn } } : {}
|
|
3136
3951
|
}
|
|
@@ -3142,17 +3957,19 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3142
3957
|
"fling_login",
|
|
3143
3958
|
{
|
|
3144
3959
|
title: "Sign in to Fling",
|
|
3145
|
-
description:
|
|
3960
|
+
description: `Sign the user in to ${wording.name}: opens their browser to sign in with ${wording.signIn} and stores the key on this computer (shared with the fling CLI). Waits up to ~30 seconds; if sign-in is not finished by then, it keeps waiting in the background for up to 5 minutes \u2014 tell the user to finish signing in in the browser, then retry what you were doing. Call it when another tool says the user is not logged in.`,
|
|
3146
3961
|
inputSchema: {},
|
|
3147
3962
|
annotations: { readOnlyHint: false, openWorldHint: true }
|
|
3148
3963
|
},
|
|
3149
3964
|
async () => {
|
|
3150
3965
|
try {
|
|
3151
3966
|
const config = await store.resolve({ serverUrl: deps.server });
|
|
3967
|
+
const serverUrl = config.serverUrl;
|
|
3968
|
+
if (!serverUrl) throw new NoServerError("fling");
|
|
3152
3969
|
if (config.apiKey && !pendingLogin) {
|
|
3153
3970
|
const me = await whoami(
|
|
3154
3971
|
createApiClient({
|
|
3155
|
-
serverUrl
|
|
3972
|
+
serverUrl,
|
|
3156
3973
|
apiKey: config.apiKey,
|
|
3157
3974
|
fetch: fetchImpl
|
|
3158
3975
|
})
|
|
@@ -3164,7 +3981,7 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3164
3981
|
done: Promise.resolve()
|
|
3165
3982
|
};
|
|
3166
3983
|
login.done = loginFlow({
|
|
3167
|
-
serverUrl
|
|
3984
|
+
serverUrl,
|
|
3168
3985
|
store,
|
|
3169
3986
|
log: silentLogger,
|
|
3170
3987
|
open: deps.open ?? (async (url) => (await import("open")).default(url)),
|
|
@@ -3199,7 +4016,7 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3199
4016
|
}
|
|
3200
4017
|
}
|
|
3201
4018
|
);
|
|
3202
|
-
const t =
|
|
4019
|
+
const t = mcpTools2;
|
|
3203
4020
|
server.registerTool(
|
|
3204
4021
|
t.list.name,
|
|
3205
4022
|
toolDef(t.list),
|
|
@@ -3284,12 +4101,66 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3284
4101
|
};
|
|
3285
4102
|
})
|
|
3286
4103
|
);
|
|
4104
|
+
if (deps.instance && commentsEnabled(deps.instance)) {
|
|
4105
|
+
const appOrigin = deps.instance.appOrigin;
|
|
4106
|
+
const review = (name) => reviewUrl(appOrigin, name);
|
|
4107
|
+
server.registerTool(
|
|
4108
|
+
t.commentsList.name,
|
|
4109
|
+
toolDef(t.commentsList),
|
|
4110
|
+
(args) => run(async (c) => {
|
|
4111
|
+
const list = await listComments(c, args.name, { path: args.path });
|
|
4112
|
+
const reviewPage2 = review(list.site.name);
|
|
4113
|
+
return {
|
|
4114
|
+
summary: describeComments(list, {
|
|
4115
|
+
includeResolved: args.includeResolved,
|
|
4116
|
+
reviewUrl: reviewPage2
|
|
4117
|
+
}),
|
|
4118
|
+
data: { ...list, reviewUrl: reviewPage2 }
|
|
4119
|
+
};
|
|
4120
|
+
})
|
|
4121
|
+
);
|
|
4122
|
+
server.registerTool(
|
|
4123
|
+
t.commentAdd.name,
|
|
4124
|
+
toolDef(t.commentAdd),
|
|
4125
|
+
(args) => run(async (c) => {
|
|
4126
|
+
const comment = await addComment(c, args.name, {
|
|
4127
|
+
body: args.body,
|
|
4128
|
+
path: args.path,
|
|
4129
|
+
parentId: args.replyTo
|
|
4130
|
+
});
|
|
4131
|
+
const what = comment.parentId ? `Replied to thread ${comment.parentId}` : "Comment added";
|
|
4132
|
+
return {
|
|
4133
|
+
summary: `${what} on ${args.name} (${comment.path}, id ${comment.id}). Review page: ${review(args.name)}`,
|
|
4134
|
+
data: comment
|
|
4135
|
+
};
|
|
4136
|
+
})
|
|
4137
|
+
);
|
|
4138
|
+
server.registerTool(
|
|
4139
|
+
t.commentResolve.name,
|
|
4140
|
+
toolDef(t.commentResolve),
|
|
4141
|
+
(args) => run(async (c) => {
|
|
4142
|
+
const comment = await resolveComment(c, args.name, args.id, !args.reopen);
|
|
4143
|
+
return {
|
|
4144
|
+
summary: `Thread ${comment.id} on ${args.name} ${args.reopen ? "reopened" : "resolved"}`,
|
|
4145
|
+
data: comment
|
|
4146
|
+
};
|
|
4147
|
+
})
|
|
4148
|
+
);
|
|
4149
|
+
server.registerTool(
|
|
4150
|
+
t.commentDelete.name,
|
|
4151
|
+
toolDef(t.commentDelete),
|
|
4152
|
+
(args) => run(async (c) => {
|
|
4153
|
+
const result = await deleteComment(c, args.name, args.id);
|
|
4154
|
+
return { summary: `Comment ${args.id} on ${args.name} deleted`, data: result };
|
|
4155
|
+
})
|
|
4156
|
+
);
|
|
4157
|
+
}
|
|
3287
4158
|
server.registerTool(
|
|
3288
4159
|
t.whoami.name,
|
|
3289
4160
|
toolDef(t.whoami),
|
|
3290
4161
|
() => run(async (c) => {
|
|
3291
4162
|
const me = await whoami(c);
|
|
3292
|
-
const base = await
|
|
4163
|
+
const base = await sitesBase();
|
|
3293
4164
|
return {
|
|
3294
4165
|
summary: `Signed in as ${me.email} (sites are served at ${sitesPattern(base)})`,
|
|
3295
4166
|
data: me
|
|
@@ -3298,38 +4169,39 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
3298
4169
|
);
|
|
3299
4170
|
return server;
|
|
3300
4171
|
}
|
|
3301
|
-
var textResult = (
|
|
4172
|
+
var textResult = (text2) => ({ content: [{ type: "text", text: text2 }] });
|
|
3302
4173
|
async function writeInlineFiles(dir, files) {
|
|
3303
4174
|
const seen = /* @__PURE__ */ new Set();
|
|
3304
4175
|
const skippedDotfiles = [];
|
|
3305
4176
|
const secretFiles = [];
|
|
3306
4177
|
let total = 0;
|
|
3307
4178
|
for (const f of files) {
|
|
3308
|
-
const
|
|
3309
|
-
const segments =
|
|
3310
|
-
if (
|
|
3311
|
-
skippedDotfiles.push(
|
|
4179
|
+
const path3 = f.path.replace(/^(\.\/)+/, "").replace(/^\/+/, "");
|
|
4180
|
+
const segments = path3.split("/");
|
|
4181
|
+
if (path3 && !path3.includes("\\") && segments.every((s) => s !== "" && s !== "." && s !== "..") && segments.some((s) => s.startsWith("."))) {
|
|
4182
|
+
skippedDotfiles.push(path3);
|
|
3312
4183
|
continue;
|
|
3313
4184
|
}
|
|
3314
|
-
if (!isValidSitePath(
|
|
3315
|
-
if (isSecretPath(
|
|
3316
|
-
secretFiles.push(
|
|
4185
|
+
if (!isValidSitePath(path3)) throw new CliError(`Invalid file path: "${f.path}"`);
|
|
4186
|
+
if (isSecretPath(path3)) {
|
|
4187
|
+
secretFiles.push(path3);
|
|
3317
4188
|
continue;
|
|
3318
4189
|
}
|
|
3319
|
-
if (seen.has(
|
|
3320
|
-
seen.add(
|
|
4190
|
+
if (seen.has(path3)) throw new CliError(`"${path3}" is listed twice`);
|
|
4191
|
+
seen.add(path3);
|
|
3321
4192
|
const bytes = Buffer.from(f.content, f.encoding === "base64" ? "base64" : "utf8");
|
|
3322
4193
|
total += bytes.length;
|
|
3323
4194
|
if (total > MAX_DEPLOY_BYTES)
|
|
3324
4195
|
throw new CliError(`The site is larger than ${formatBytes(MAX_DEPLOY_BYTES)}`);
|
|
3325
|
-
const target =
|
|
4196
|
+
const target = join6(dir, ...path3.split("/"));
|
|
3326
4197
|
await mkdir3(dirname2(target), { recursive: true });
|
|
3327
|
-
await
|
|
4198
|
+
await writeFile4(target, bytes);
|
|
3328
4199
|
}
|
|
3329
4200
|
if (secretFiles.length > 0) throw new SecretFilesError(secretFiles);
|
|
3330
4201
|
return { skippedDotfiles };
|
|
3331
4202
|
}
|
|
3332
4203
|
function describeError(error) {
|
|
4204
|
+
if (error instanceof NoServerError) return NO_SERVER_HINT;
|
|
3333
4205
|
if (error instanceof CliError && error.exitCode === EXIT_NOT_LOGGED_IN) return LOGIN_HINT;
|
|
3334
4206
|
if (error instanceof CliApiError && error.status === 401) return LOGIN_HINT;
|
|
3335
4207
|
if (error instanceof CliError)
|
|
@@ -3338,28 +4210,87 @@ ${error.hint}` : error.message;
|
|
|
3338
4210
|
return error instanceof Error ? error.message : String(error);
|
|
3339
4211
|
}
|
|
3340
4212
|
async function runMcpServer(deps = {}) {
|
|
3341
|
-
const
|
|
4213
|
+
const store = deps.store ?? createConfigStore();
|
|
4214
|
+
const instance = deps.instance ?? await store.resolve({ serverUrl: deps.server }).then((config) => loadInstance(store, config, deps.fetch ?? ((i, init) => fetch(i, init)))).catch(() => null);
|
|
4215
|
+
const server = createMcpServer({ ...deps, store, instance });
|
|
3342
4216
|
await server.connect(new StdioServerTransport());
|
|
3343
4217
|
}
|
|
3344
4218
|
|
|
4219
|
+
// src/route.ts
|
|
4220
|
+
import { stat as stat6 } from "node:fs/promises";
|
|
4221
|
+
import { resolve as resolve4 } from "node:path";
|
|
4222
|
+
var PASS_THROUGH = /* @__PURE__ */ new Set(["-h", "--help", "-V", "--version"]);
|
|
4223
|
+
var pathExists = (path3) => stat6(resolve4(path3)).then(
|
|
4224
|
+
() => true,
|
|
4225
|
+
() => false
|
|
4226
|
+
);
|
|
4227
|
+
async function routeArgs(args, opts) {
|
|
4228
|
+
const exists = opts.exists ?? pathExists;
|
|
4229
|
+
let first;
|
|
4230
|
+
for (let i = 0; i < args.length; i++) {
|
|
4231
|
+
const arg = args[i];
|
|
4232
|
+
if (arg === "--") {
|
|
4233
|
+
first = args[i + 1];
|
|
4234
|
+
break;
|
|
4235
|
+
}
|
|
4236
|
+
if (arg.startsWith("-")) {
|
|
4237
|
+
if (PASS_THROUGH.has(arg)) return { kind: "run", args: [...args] };
|
|
4238
|
+
if (opts.valueFlags.has(arg)) i++;
|
|
4239
|
+
continue;
|
|
4240
|
+
}
|
|
4241
|
+
first = arg;
|
|
4242
|
+
break;
|
|
4243
|
+
}
|
|
4244
|
+
if (first === void 0) return { kind: "help" };
|
|
4245
|
+
if (await exists(first)) return { kind: "run", args: ["deploy", ...args] };
|
|
4246
|
+
if (opts.commands.has(first)) return { kind: "run", args: [...args] };
|
|
4247
|
+
return { kind: "unknown", arg: first };
|
|
4248
|
+
}
|
|
4249
|
+
function routeOptionsFor(program2) {
|
|
4250
|
+
const commands = /* @__PURE__ */ new Set(["help"]);
|
|
4251
|
+
const valueFlags = /* @__PURE__ */ new Set();
|
|
4252
|
+
const addFlags = (command) => {
|
|
4253
|
+
for (const option of command.options)
|
|
4254
|
+
if (option.required || option.optional) {
|
|
4255
|
+
for (const flag of [option.short, option.long]) if (flag) valueFlags.add(flag);
|
|
4256
|
+
}
|
|
4257
|
+
};
|
|
4258
|
+
addFlags(program2);
|
|
4259
|
+
for (const command of program2.commands) {
|
|
4260
|
+
commands.add(command.name());
|
|
4261
|
+
for (const alias of command.aliases()) commands.add(alias);
|
|
4262
|
+
addFlags(command);
|
|
4263
|
+
}
|
|
4264
|
+
return { commands, valueFlags };
|
|
4265
|
+
}
|
|
4266
|
+
|
|
3345
4267
|
// src/cli.ts
|
|
3346
4268
|
var program = new Command();
|
|
3347
|
-
program.name(BIN_NAME).description("Fling a folder of static files at Fling and get a link back").version(VERSION).option(
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
)
|
|
4269
|
+
program.name(BIN_NAME).description("Fling a folder of static files at Fling and get a link back").version(VERSION).option("--server <url>", `server URL (default: $${ENV.url}, else the one you logged in to)`).option("--json", "print JSON instead of human output", false).showHelpAfterError().configureHelp({
|
|
4270
|
+
sortSubcommands: false,
|
|
4271
|
+
styleTitle: (title) => chalk10.bold(brand.primary(title)),
|
|
4272
|
+
styleCommandText: (text2) => chalk10.bold(text2),
|
|
4273
|
+
styleSubcommandText: (text2) => brand.accent(text2),
|
|
4274
|
+
styleOptionText: (text2) => brand.accent(text2),
|
|
4275
|
+
styleArgumentText: (text2) => chalk10.dim(text2)
|
|
4276
|
+
}).addHelpText(
|
|
4277
|
+
"before",
|
|
4278
|
+
() => process.stdout.isTTY ? logo({ columns: process.stdout.columns }) : ""
|
|
4279
|
+
).addHelpText(
|
|
3351
4280
|
"after",
|
|
3352
|
-
`
|
|
3353
|
-
Examples:
|
|
3354
|
-
$ ${BIN_NAME} login
|
|
3355
|
-
$ ${BIN_NAME}
|
|
3356
|
-
$ ${BIN_NAME} ./dist --
|
|
3357
|
-
$ ${BIN_NAME}
|
|
3358
|
-
$ ${BIN_NAME}
|
|
3359
|
-
$ ${BIN_NAME}
|
|
4281
|
+
() => `
|
|
4282
|
+
${chalk10.bold(brand.primary("Examples:"))}
|
|
4283
|
+
$ ${BIN_NAME} login https://fling.example.com sign in to your Fling (once; there is no default)
|
|
4284
|
+
$ ${BIN_NAME} setup sign in and add Fling to Claude Code / Claude Desktop
|
|
4285
|
+
$ ${BIN_NAME} deploy ./dist --name my-app \u2192 https://my-app.<your sites domain>
|
|
4286
|
+
$ ${BIN_NAME} ./dist the same: an existing path deploys
|
|
4287
|
+
$ ${BIN_NAME} ./dist --yes no prompts (CI): use the generated name, fail if it is taken
|
|
4288
|
+
$ ${BIN_NAME} "Pitch Deck.html" a single page
|
|
4289
|
+
$ ${BIN_NAME} site.zip a zip of a site
|
|
4290
|
+
$ ${BIN_NAME} share my-app public link for people who can't sign in
|
|
3360
4291
|
|
|
3361
|
-
Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
|
|
3362
|
-
Env: ${ENV.apiKey} \xB7 ${ENV.url} \xB7 ${ENV.configDir} \xB7 ${ENV.debug}`
|
|
4292
|
+
${chalk10.dim(`Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
|
|
4293
|
+
Env: ${ENV.apiKey} \xB7 ${ENV.url} \xB7 ${ENV.configDir} \xB7 ${ENV.debug}`)}`
|
|
3363
4294
|
);
|
|
3364
4295
|
function action(handler) {
|
|
3365
4296
|
return async (...args) => {
|
|
@@ -3397,41 +4328,93 @@ function report(error, ctx) {
|
|
|
3397
4328
|
);
|
|
3398
4329
|
}
|
|
3399
4330
|
}
|
|
3400
|
-
program.command("deploy [path]"
|
|
3401
|
-
"deploy a folder (default: .), a single .html file, a .zip or any file
|
|
4331
|
+
program.command("deploy [path]").helpGroup("Deploy:").description(
|
|
4332
|
+
"deploy a folder (default: .), a single .html file, a .zip or any file. `fling <path>` is short for it"
|
|
3402
4333
|
).option(
|
|
3403
4334
|
"-n, --name <name>",
|
|
3404
4335
|
"site name \u2014 names are global, first come first served (default: generated from the project and folder, e.g. rocketpod-docs; you confirm it on a terminal)"
|
|
3405
|
-
).option("--spa", "single-page app: serve index.html for unknown paths").option("--public", "make the site public (default:
|
|
4336
|
+
).option("--spa", "single-page app: serve index.html for unknown paths").option("--public", "make the site public (default: sign-in required)").option(
|
|
3406
4337
|
"-y, --yes",
|
|
3407
4338
|
"never prompt: accept the generated name; without index.html, list the HTML pages (or fling anyway if there are none)"
|
|
3408
4339
|
).option("--list-html", "no index.html at the root: generate one listing the HTML pages").option("--no-list-html", "no index.html at the root: fling anyway (the root will 404)").action(
|
|
3409
4340
|
action(
|
|
3410
|
-
(ctx,
|
|
4341
|
+
(ctx, path3, opts) => runDeploy(ctx, path3, opts)
|
|
4342
|
+
)
|
|
4343
|
+
);
|
|
4344
|
+
program.command("setup [url]").helpGroup("Setup:").description(
|
|
4345
|
+
"sign in (to the Fling at url, the first time), then add the Fling plugin to Claude Code and the extension to Claude Desktop"
|
|
4346
|
+
).option("-y, --yes", "do every step without asking").option("-s, --scope <scope>", "Claude Code plugin scope: user, project or local", "user").action(
|
|
4347
|
+
action(
|
|
4348
|
+
(ctx, url, opts) => runSetup(ctx, { ...opts, url })
|
|
3411
4349
|
)
|
|
3412
4350
|
);
|
|
3413
|
-
program.command("login").
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
4351
|
+
program.command("login [url]").helpGroup("Setup:").description(
|
|
4352
|
+
"sign in through the browser to the Fling at url (remembered; later logins can omit it) and store an API key"
|
|
4353
|
+
).action(
|
|
4354
|
+
action(async (ctx, url) => {
|
|
4355
|
+
await runLogin(ctx, url);
|
|
4356
|
+
ctx.log.hint(`Next: \`${BIN_NAME} setup\` adds Fling to Claude Code and Claude Desktop.`);
|
|
4357
|
+
})
|
|
4358
|
+
);
|
|
4359
|
+
program.command("logout").helpGroup("Setup:").description("remove the stored API key").action(action((ctx) => runLogout(ctx)));
|
|
4360
|
+
program.command("whoami").helpGroup("Setup:").description("show the signed-in user").action(action((ctx) => runWhoami(ctx)));
|
|
4361
|
+
program.commandsGroup("Setup:").helpCommand("help [command]", "show help for a command");
|
|
4362
|
+
program.command("ls").helpGroup("Sites:").alias("list").description("list your sites").option("--archived", "list archived sites").action(action((ctx, opts) => runList(ctx, opts)));
|
|
4363
|
+
program.command("info <name>").helpGroup("Sites:").description("show a site").action(action((ctx, name) => runInfo(ctx, name)));
|
|
4364
|
+
program.command("versions <name>").helpGroup("Sites:").description("list a site's versions (* = live)").action(action((ctx, name) => runVersions(ctx, name)));
|
|
4365
|
+
program.command("files <name> [versionId]").helpGroup("Sites:").description("list the files in a site's live version (or in versionId)").action(
|
|
3420
4366
|
action((ctx, name, versionId) => runFiles(ctx, name, versionId))
|
|
3421
4367
|
);
|
|
3422
|
-
program.command("rollback <name> <versionId>").description("serve an earlier version again").action(action((ctx, name, versionId) => runRollback(ctx, name, versionId)));
|
|
3423
|
-
program.command("share <name>").description("print (or create) a public share link \u2014 anyone with it can view the site").option("--refresh", "replace the link; the old one stops working").option("--revoke", "remove the link").action(
|
|
4368
|
+
program.command("rollback <name> <versionId>").helpGroup("Sites:").description("serve an earlier version again").action(action((ctx, name, versionId) => runRollback(ctx, name, versionId)));
|
|
4369
|
+
program.command("share <name>").helpGroup("Sites:").description("print (or create) a public share link \u2014 anyone with it can view the site").option("--refresh", "replace the link; the old one stops working").option("--revoke", "remove the link").action(
|
|
3424
4370
|
action(
|
|
3425
4371
|
(ctx, name, opts) => runShare(ctx, name, opts)
|
|
3426
4372
|
)
|
|
3427
4373
|
);
|
|
3428
|
-
program.command("visibility <name> <visibility>").description("private (
|
|
3429
|
-
program.command("archive <name>").description("take a site offline (404) but keep its files; undo with unarchive").action(action((ctx, name) => runArchive(ctx, name)));
|
|
3430
|
-
program.command("unarchive <name>").description("bring an archived site back").action(action((ctx, name) => runArchive(ctx, name, true)));
|
|
3431
|
-
program.command("rm <name>").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)));
|
|
3432
|
-
program.command("
|
|
4374
|
+
program.command("visibility <name> <visibility>").helpGroup("Sites:").description("private (sign-in required) or public (anyone)").action(action((ctx, name, visibility) => runVisibility(ctx, name, visibility)));
|
|
4375
|
+
program.command("archive <name>").helpGroup("Sites:").description("take a site offline (404) but keep its files; undo with unarchive").action(action((ctx, name) => runArchive(ctx, name)));
|
|
4376
|
+
program.command("unarchive <name>").helpGroup("Sites:").description("bring an archived site back").action(action((ctx, name) => runArchive(ctx, name, true)));
|
|
4377
|
+
program.command("rm <name>").helpGroup("Sites:").alias("delete").description("delete a site and all its versions (asks first unless --yes)").option("-y, --yes", "do not ask for confirmation").action(action((ctx, name, opts) => runRemove(ctx, name, opts)));
|
|
4378
|
+
program.command("comments <name>").helpGroup("Comments:").description("list a site's open comment threads (people leave them on its review page)").option("-p, --path <path>", "only this page, e.g. / or /docs/").option("-a, --all", "include resolved threads").action(
|
|
4379
|
+
action(
|
|
4380
|
+
(ctx, name, opts) => runComments(ctx, name, opts)
|
|
4381
|
+
)
|
|
4382
|
+
);
|
|
4383
|
+
program.command("comment <name> <text>").helpGroup("Comments:").description("comment on a page of a site (default: its home page), or reply to a thread").option("-p, --path <path>", "the page, e.g. /docs/ or about.html").option("-r, --reply <id>", "reply to this thread").action(
|
|
4384
|
+
action(
|
|
4385
|
+
(ctx, name, text2, opts) => runComment(ctx, name, text2, opts)
|
|
4386
|
+
)
|
|
4387
|
+
);
|
|
4388
|
+
program.command("resolve <name> <id>").helpGroup("Comments:").description("mark a comment thread resolved").option("--reopen", "reopen it instead").action(
|
|
4389
|
+
action(
|
|
4390
|
+
(ctx, name, id, opts) => runResolve(ctx, name, id, opts)
|
|
4391
|
+
)
|
|
4392
|
+
);
|
|
4393
|
+
program.command("uncomment <name> <id>").helpGroup("Comments:").alias("delete-comment").description("delete a comment (a thread goes with its replies; asks first unless --yes)").option("-y, --yes", "do not ask for confirmation").action(
|
|
4394
|
+
action(
|
|
4395
|
+
(ctx, name, id, opts) => runUncomment(ctx, name, id, opts)
|
|
4396
|
+
)
|
|
4397
|
+
);
|
|
4398
|
+
program.command("mcp").helpGroup("Agents:").description("run the MCP server on stdio (used by the Claude Code plugin)").action(async (_opts, command) => {
|
|
3433
4399
|
const globals = command.optsWithGlobals();
|
|
3434
4400
|
await runMcpServer({ server: globals.server });
|
|
3435
4401
|
});
|
|
3436
|
-
|
|
4402
|
+
async function main() {
|
|
4403
|
+
const route = await routeArgs(process.argv.slice(2), routeOptionsFor(program));
|
|
4404
|
+
if (route.kind === "help") {
|
|
4405
|
+
program.outputHelp();
|
|
4406
|
+
return;
|
|
4407
|
+
}
|
|
4408
|
+
if (route.kind === "unknown") {
|
|
4409
|
+
const log = createLogger();
|
|
4410
|
+
log.error(`"${route.arg}" is not a command, file or folder`);
|
|
4411
|
+
log.hint(
|
|
4412
|
+
`Run \`${BIN_NAME} --help\` for the commands, or \`${BIN_NAME} deploy <path>\` to deploy.`
|
|
4413
|
+
);
|
|
4414
|
+
process.exitCode = 1;
|
|
4415
|
+
return;
|
|
4416
|
+
}
|
|
4417
|
+
await program.parseAsync(route.args, { from: "user" });
|
|
4418
|
+
}
|
|
4419
|
+
await main();
|
|
3437
4420
|
//# sourceMappingURL=cli.js.map
|