@nearform/fling 0.1.1 → 0.2.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/README.md +52 -24
- package/dist/cli.js +1253 -461
- package/dist/cli.js.map +4 -4
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
+
import chalk9 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";
|
|
@@ -23,14 +24,198 @@ var MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|
|
23
24
|
var MAX_FILES = 5e3;
|
|
24
25
|
var MAX_PATH_LENGTH = 1024;
|
|
25
26
|
|
|
27
|
+
// ../../packages/shared/src/secrets.ts
|
|
28
|
+
var SECRET_SCAN_MAX_FILE_BYTES = MAX_FILE_BYTES;
|
|
29
|
+
var BINARY_SNIFF_BYTES = 8192;
|
|
30
|
+
var SECRET_EXTENSIONS = [
|
|
31
|
+
".pem",
|
|
32
|
+
".key",
|
|
33
|
+
".p12",
|
|
34
|
+
".pfx",
|
|
35
|
+
".jks",
|
|
36
|
+
".keystore",
|
|
37
|
+
".tfstate",
|
|
38
|
+
".tfstate.backup",
|
|
39
|
+
".tfvars",
|
|
40
|
+
".tfvars.json",
|
|
41
|
+
".kdbx"
|
|
42
|
+
];
|
|
43
|
+
var SECRET_NAMES = /* @__PURE__ */ new Set([
|
|
44
|
+
"credentials",
|
|
45
|
+
"credentials.json",
|
|
46
|
+
"secrets.json",
|
|
47
|
+
"secrets.yml",
|
|
48
|
+
"secrets.yaml",
|
|
49
|
+
"wp-config.php",
|
|
50
|
+
".htpasswd"
|
|
51
|
+
]);
|
|
52
|
+
var SSH_KEY_RE = /^id_(?:rsa|dsa|ecdsa|ed25519)(?:[._-]|$)/;
|
|
53
|
+
function isSecretPath(path) {
|
|
54
|
+
const name = (path.split(/[\\/]/).at(-1) ?? "").toLowerCase();
|
|
55
|
+
if (!name) return false;
|
|
56
|
+
if (SECRET_NAMES.has(name)) return true;
|
|
57
|
+
if (SECRET_EXTENSIONS.some((ext) => name.endsWith(ext) && name.length > ext.length)) return true;
|
|
58
|
+
if (/service[-_]account.*\.json$/.test(name)) return true;
|
|
59
|
+
if (SSH_KEY_RE.test(name) && !name.endsWith(".pub")) return true;
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
var PLACEHOLDER_RE = /example|xxxx/i;
|
|
63
|
+
var isPlaceholder = (m) => PLACEHOLDER_RE.test(m[0]);
|
|
64
|
+
var DB_PASSWORD_PLACEHOLDER = /^(?:password|passw(?:or)?d|pass|pwd|secret|changeme|example|test|admin|root|postgres|mysql|redis|guest|user|\*+|x+|\.+)$/i;
|
|
65
|
+
var LOCAL_HOST = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|db|host|hostname)(?::\d+)?$/i;
|
|
66
|
+
var RULES = [
|
|
67
|
+
{
|
|
68
|
+
id: "private-key",
|
|
69
|
+
label: "private key",
|
|
70
|
+
// The header, optional RFC 1421 headers (Proc-Type, DEK-Info, Version), then key material.
|
|
71
|
+
re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?-----[ \t]*\r?\n(?:[A-Za-z-]+:[^\n]*\r?\n)*(?:[ \t]*\r?\n)?[ \t]*[A-Za-z0-9+/=]{40,}/g
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "aws-access-key-id",
|
|
75
|
+
label: "AWS access key ID",
|
|
76
|
+
re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
|
|
77
|
+
allow: isPlaceholder
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "aws-secret-access-key",
|
|
81
|
+
label: "AWS secret access key",
|
|
82
|
+
re: /\b(?:aws_?)?secret_?access_?key["']?\s*[:=]\s*["']?[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/gi,
|
|
83
|
+
allow: isPlaceholder
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "github-token",
|
|
87
|
+
label: "GitHub token",
|
|
88
|
+
re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{80,})/g,
|
|
89
|
+
allow: isPlaceholder
|
|
90
|
+
},
|
|
91
|
+
{ id: "npm-token", label: "npm token", re: /\bnpm_[A-Za-z0-9]{36}\b/g, allow: isPlaceholder },
|
|
92
|
+
{
|
|
93
|
+
id: "slack-token",
|
|
94
|
+
label: "Slack token",
|
|
95
|
+
re: /\bxox[abposr]-[0-9]{6,}-[A-Za-z0-9-]{10,}/g,
|
|
96
|
+
allow: isPlaceholder
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "slack-webhook",
|
|
100
|
+
label: "Slack webhook URL",
|
|
101
|
+
re: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]{6,}\/B[A-Z0-9]{6,}\/[A-Za-z0-9]{20,}/g,
|
|
102
|
+
allow: isPlaceholder
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "stripe-live-key",
|
|
106
|
+
label: "Stripe live key",
|
|
107
|
+
re: /\b[sr]k_live_[0-9a-zA-Z]{24,}\b/g,
|
|
108
|
+
allow: isPlaceholder
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: "google-oauth-client-secret",
|
|
112
|
+
label: "Google OAuth client secret",
|
|
113
|
+
re: /\bGOCSPX-[A-Za-z0-9_-]{28}(?![A-Za-z0-9_-])/g,
|
|
114
|
+
allow: isPlaceholder
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: "openai-api-key",
|
|
118
|
+
label: "OpenAI API key",
|
|
119
|
+
re: /\bsk-(?:(?:proj|svcacct|admin)-[A-Za-z0-9_-]{40,}|[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20})/g,
|
|
120
|
+
allow: isPlaceholder
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "anthropic-api-key",
|
|
124
|
+
label: "Anthropic API key",
|
|
125
|
+
re: /\bsk-ant-[A-Za-z0-9_-]{80,}/g,
|
|
126
|
+
allow: isPlaceholder
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: "sendgrid-api-key",
|
|
130
|
+
label: "SendGrid API key",
|
|
131
|
+
re: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,
|
|
132
|
+
allow: isPlaceholder
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: "azure-storage-key",
|
|
136
|
+
label: "Azure storage account key",
|
|
137
|
+
re: /\bAccountKey=[A-Za-z0-9+/]{80,}={0,2}/g,
|
|
138
|
+
allow: isPlaceholder
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: "database-url-password",
|
|
142
|
+
label: "database URL with a password",
|
|
143
|
+
re: /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|rediss?|amqps?):\/\/[^:\s/@"'`<>]+:([^@\s/"'`<>]{3,})@([^\s/?#"'`<>]+)/g,
|
|
144
|
+
allow: (m) => {
|
|
145
|
+
const password = m[1] ?? "";
|
|
146
|
+
const host = m[2] ?? "";
|
|
147
|
+
return DB_PASSWORD_PLACEHOLDER.test(password) || /[${}%]/.test(password) || PLACEHOLDER_RE.test(password) || LOCAL_HOST.test(host) || /(?:^|\.)example(?:\.[a-z]+)?(?::\d+)?$/i.test(host);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
];
|
|
151
|
+
var SECRET_RULE_LABELS = Object.fromEntries(
|
|
152
|
+
RULES.map((r) => [r.id, r.label])
|
|
153
|
+
);
|
|
154
|
+
function lineAt(starts, index) {
|
|
155
|
+
let lo = 0;
|
|
156
|
+
let hi = starts.length - 1;
|
|
157
|
+
while (lo < hi) {
|
|
158
|
+
const mid = lo + hi + 1 >> 1;
|
|
159
|
+
if (starts[mid] <= index) lo = mid;
|
|
160
|
+
else hi = mid - 1;
|
|
161
|
+
}
|
|
162
|
+
return lo + 1;
|
|
163
|
+
}
|
|
164
|
+
function scanForSecrets(text2, path) {
|
|
165
|
+
let starts;
|
|
166
|
+
const findings = [];
|
|
167
|
+
const seen = /* @__PURE__ */ new Set();
|
|
168
|
+
for (const rule of RULES) {
|
|
169
|
+
rule.re.lastIndex = 0;
|
|
170
|
+
for (let m = rule.re.exec(text2); m !== null; m = rule.re.exec(text2)) {
|
|
171
|
+
if (m[0].length === 0) rule.re.lastIndex++;
|
|
172
|
+
if (rule.allow?.(m)) continue;
|
|
173
|
+
if (!starts) {
|
|
174
|
+
starts = [0];
|
|
175
|
+
for (let i = text2.indexOf("\n"); i !== -1; i = text2.indexOf("\n", i + 1)) starts.push(i + 1);
|
|
176
|
+
}
|
|
177
|
+
const line = lineAt(starts, m.index);
|
|
178
|
+
const key = `${rule.id}:${line}`;
|
|
179
|
+
if (seen.has(key)) continue;
|
|
180
|
+
seen.add(key);
|
|
181
|
+
findings.push({ path, line, rule: rule.id });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return findings.sort((a, b) => a.line - b.line || a.rule.localeCompare(b.rule));
|
|
185
|
+
}
|
|
186
|
+
var BINARY_EXTENSIONS = new Set(
|
|
187
|
+
"png jpg jpeg gif webp avif ico bmp tif tiff heic psd woff woff2 ttf otf eot mp3 mp4 m4a m4v mov webm ogg oga ogv wav flac aac avi mkv zip gz tgz br zst bz2 xz 7z rar wasm pdf glb gltf-binary bin dat".split(/\s+/).filter(Boolean)
|
|
188
|
+
);
|
|
189
|
+
function isScannablePath(path) {
|
|
190
|
+
const name = path.split("/").at(-1) ?? "";
|
|
191
|
+
const dot = name.lastIndexOf(".");
|
|
192
|
+
return dot <= 0 || !BINARY_EXTENSIONS.has(name.slice(dot + 1).toLowerCase());
|
|
193
|
+
}
|
|
194
|
+
function looksBinary(bytes) {
|
|
195
|
+
const end = Math.min(bytes.length, BINARY_SNIFF_BYTES);
|
|
196
|
+
for (let i = 0; i < end; i++) if (bytes[i] === 0) return true;
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
function scanBytesForSecrets(bytes, path) {
|
|
200
|
+
if (!isScannablePath(path) || bytes.length > SECRET_SCAN_MAX_FILE_BYTES || looksBinary(bytes))
|
|
201
|
+
return [];
|
|
202
|
+
return scanForSecrets(new TextDecoder("utf-8").decode(bytes), path);
|
|
203
|
+
}
|
|
204
|
+
function describeSecretFindings(findings, max = 20) {
|
|
205
|
+
const lines = findings.slice(0, max).map((f) => `${f.path} line ${f.line}: ${SECRET_RULE_LABELS[f.rule] ?? f.rule}`);
|
|
206
|
+
if (findings.length > max) lines.push(`\u2026 and ${findings.length - max} more`);
|
|
207
|
+
return lines;
|
|
208
|
+
}
|
|
209
|
+
var NEVER_PUBLISHED_NOTE = "Fling never publishes dotfiles, gitignored files, secret files (keys, certificates, credentials) or files that contain secrets.";
|
|
210
|
+
|
|
26
211
|
// ../../packages/shared/src/manifest.ts
|
|
27
212
|
function isValidSitePath(path) {
|
|
28
213
|
if (!path || path.length > MAX_PATH_LENGTH) return false;
|
|
29
214
|
if (path.startsWith("/") || path.includes("\\") || path.includes("\0")) return false;
|
|
30
|
-
return path.split("/").every((seg) => seg !== "" && seg
|
|
215
|
+
return path.split("/").every((seg) => seg !== "" && !seg.startsWith("."));
|
|
31
216
|
}
|
|
32
217
|
var ManifestFileSchema = z.object({
|
|
33
|
-
path: z.string().refine(isValidSitePath, "invalid path"),
|
|
218
|
+
path: z.string().refine(isValidSitePath, "invalid path (dotfiles are never published)").refine((p) => !isSecretPath(p), "secret file (keys, certificates, credentials) never published"),
|
|
34
219
|
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
35
220
|
size: z.number().int().min(0).max(MAX_FILE_BYTES),
|
|
36
221
|
contentType: z.string().min(1).max(255)
|
|
@@ -279,6 +464,7 @@ var NAME_RULES = `Site names are 1-${SITE_NAME_MAX} chars of lowercase letters,
|
|
|
279
464
|
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.';
|
|
280
465
|
var VISIBILITY = "Sites are private by default (only signed-in Nearformers can open them); pass `public: true` only if the user asks for a public site. Share links (fling_share) are public to anyone who has them.";
|
|
281
466
|
var INLINE_ONLY = "If you created the content in this conversation, pass it directly \u2014 never pass file paths from your sandbox (this server cannot read them) and never try to curl/upload from your sandbox.";
|
|
467
|
+
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.";
|
|
282
468
|
var ENCODING = '`encoding` is "utf8" (default, for text: HTML, CSS, JS, SVG, JSON, Markdown\u2026) or "base64" (binary: images, fonts, PDFs).';
|
|
283
469
|
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.";
|
|
284
470
|
var listHtml = z4.boolean().optional().describe(
|
|
@@ -286,7 +472,7 @@ var listHtml = z4.boolean().optional().describe(
|
|
|
286
472
|
);
|
|
287
473
|
var siteName = z4.string().min(1).max(200).describe('Site name, e.g. "q3-report"');
|
|
288
474
|
var sitePath = z4.string().min(1).max(1024).describe(
|
|
289
|
-
'Site-relative path with forward slashes, e.g. "index.html", "css/app.css", "img/logo.png". The root page must be "index.html".'
|
|
475
|
+
'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")'
|
|
290
476
|
);
|
|
291
477
|
var encoding = z4.enum(["utf8", "base64"]).optional().describe('How `content` is encoded: "utf8" (default) for text, "base64" for binary files');
|
|
292
478
|
var spa = z4.boolean().optional().describe(
|
|
@@ -339,7 +525,7 @@ var mcpTools = {
|
|
|
339
525
|
publish: tool({
|
|
340
526
|
name: "fling_publish",
|
|
341
527
|
title: "Publish a site",
|
|
342
|
-
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} The result includes \`warnings\`. Share the returned URL with the user; fling_files shows exactly which files the site has.`,
|
|
528
|
+
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.`,
|
|
343
529
|
inputSchema: {
|
|
344
530
|
name: siteName,
|
|
345
531
|
files: z4.array(
|
|
@@ -365,7 +551,7 @@ var mcpTools = {
|
|
|
365
551
|
uploadFile: tool({
|
|
366
552
|
name: "fling_upload_file",
|
|
367
553
|
title: "Upload a file (chunked)",
|
|
368
|
-
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}`,
|
|
554
|
+
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}`,
|
|
369
555
|
inputSchema: {
|
|
370
556
|
uploadId: z4.string().min(1).max(100).describe("From fling_upload_begin"),
|
|
371
557
|
path: sitePath,
|
|
@@ -440,6 +626,7 @@ var MCP_REMOTE_INSTRUCTIONS = [
|
|
|
440
626
|
"Every site needs a name. If the user did not give one, propose a short descriptive name and confirm it before publishing (fling_check_name shows whether it is free). Names are global and first come, first served; when a name is taken, offer the returned suggestions.",
|
|
441
627
|
"Sites are private (Nearformers only) by default. Only make one public if asked; to show a private site to someone outside Nearform, create a share link with fling_share.",
|
|
442
628
|
"If a site has HTML pages but no root index.html, fling_publish / fling_upload_finish stop and ask: check with the user whether to generate an index.html listing the pages (`listHtml: true`) or publish without one (`listHtml: false`, the root URL will 404), then retry.",
|
|
629
|
+
NEVER_PUBLISHED,
|
|
443
630
|
`For sites over ${MCP_PUBLISH_MAX_BYTES / 1024 / 1024} MB use fling_upload_begin, fling_upload_file (repeat, \`append\` for big files) and fling_upload_finish.`,
|
|
444
631
|
"Always give the user the URL that comes back. Use fling_list / fling_info to find existing sites, fling_rollback to restore a version, fling_archive rather than fling_delete unless the user insists. Use fling_files to see exactly which files a site has (e.g. when the user reports a 404)."
|
|
445
632
|
].join("\n\n");
|
|
@@ -469,7 +656,7 @@ function describeVersionFiles(name, files, opts) {
|
|
|
469
656
|
var READY_TIMEOUT_MS = 15e3;
|
|
470
657
|
var NOT_READY_NOTE = "it can take a few more seconds to appear everywhere; reload if you see a 404";
|
|
471
658
|
var isReachableStatus = (status) => status >= 200 && status < 400 || status === 401 || status === 403;
|
|
472
|
-
var defaultSleep = (ms) => new Promise((
|
|
659
|
+
var defaultSleep = (ms) => new Promise((resolve5) => setTimeout(resolve5, ms));
|
|
473
660
|
async function waitUntilReachable(url, opts = {}) {
|
|
474
661
|
const timeoutMs = opts.timeoutMs ?? READY_TIMEOUT_MS;
|
|
475
662
|
const doFetch = opts.fetch ?? ((u, init) => fetch(u, init));
|
|
@@ -543,7 +730,7 @@ function exitCodeFor(error) {
|
|
|
543
730
|
// package.json
|
|
544
731
|
var package_default = {
|
|
545
732
|
name: "@nearform/fling",
|
|
546
|
-
version: "0.
|
|
733
|
+
version: "0.2.0",
|
|
547
734
|
license: "MIT",
|
|
548
735
|
description: "Fling a folder of static files at fling.labs.nearform.com and get a link back. CLI + MCP server.",
|
|
549
736
|
type: "module",
|
|
@@ -574,6 +761,7 @@ var package_default = {
|
|
|
574
761
|
test: "vitest run"
|
|
575
762
|
},
|
|
576
763
|
dependencies: {
|
|
764
|
+
"@clack/prompts": "^1.8.1",
|
|
577
765
|
"@modelcontextprotocol/sdk": "^1.20.0",
|
|
578
766
|
chalk: "^5.6.2",
|
|
579
767
|
commander: "^15.0.0",
|
|
@@ -707,26 +895,37 @@ async function putUpload(fetchImpl, url, headers, body, timeoutMs = 5 * 6e4) {
|
|
|
707
895
|
}
|
|
708
896
|
async function readBody(response) {
|
|
709
897
|
if (response.status === 204) return void 0;
|
|
710
|
-
const
|
|
711
|
-
if (
|
|
898
|
+
const text2 = await response.text();
|
|
899
|
+
if (text2 === "") return void 0;
|
|
712
900
|
try {
|
|
713
|
-
return JSON.parse(
|
|
901
|
+
return JSON.parse(text2);
|
|
714
902
|
} catch {
|
|
715
|
-
return
|
|
903
|
+
return text2;
|
|
716
904
|
}
|
|
717
905
|
}
|
|
718
906
|
function errorFromResponse(status, body, method, path) {
|
|
719
907
|
const envelope = ErrorEnvelopeSchema.safeParse(body);
|
|
720
908
|
if (envelope.success) {
|
|
721
|
-
|
|
909
|
+
const { code } = envelope.data.error;
|
|
910
|
+
const error = new CliApiError({
|
|
722
911
|
status,
|
|
723
|
-
code
|
|
912
|
+
code,
|
|
724
913
|
body,
|
|
725
|
-
message: envelope.data.error.message
|
|
914
|
+
message: envelope.data.error.message,
|
|
915
|
+
hint: code ? NEVER_PUBLISHED_HINTS[code] : void 0
|
|
726
916
|
});
|
|
917
|
+
const extra = body.error;
|
|
918
|
+
if (Array.isArray(extra?.findings)) Object.assign(error, { findings: extra.findings });
|
|
919
|
+
if (Array.isArray(extra?.paths)) Object.assign(error, { paths: extra.paths });
|
|
920
|
+
return error;
|
|
727
921
|
}
|
|
728
922
|
return new CliApiError({ status, body, message: `${method} ${path} failed with HTTP ${status}` });
|
|
729
923
|
}
|
|
924
|
+
var NEVER_PUBLISHED_HINTS = {
|
|
925
|
+
secret_detected: "Remove the secret from the file (and rotate it if it was ever shared), or add the file to .flingignore if the site does not need it.",
|
|
926
|
+
secret_file: "Move the secret files out of the folder, or add them to .flingignore if the site does not need them.",
|
|
927
|
+
dotfile: `Dotfiles are never flung. Update the CLI (\`npm i -g @nearform/fling@latest\`), which skips them.`
|
|
928
|
+
};
|
|
730
929
|
function describeCause(cause) {
|
|
731
930
|
if (cause instanceof Error) {
|
|
732
931
|
const inner = cause.cause;
|
|
@@ -904,8 +1103,8 @@ async function startCallbackServer(options = {}) {
|
|
|
904
1103
|
const timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS;
|
|
905
1104
|
let settle;
|
|
906
1105
|
let timer;
|
|
907
|
-
const result = new Promise((
|
|
908
|
-
settle = { resolve:
|
|
1106
|
+
const result = new Promise((resolve5, reject) => {
|
|
1107
|
+
settle = { resolve: resolve5, reject };
|
|
909
1108
|
}).finally(() => clearTimeout(timer));
|
|
910
1109
|
result.catch(() => {
|
|
911
1110
|
});
|
|
@@ -966,7 +1165,7 @@ ${close ? "<script>setTimeout(function(){window.close()},800)</script>" : ""}
|
|
|
966
1165
|
res.end(html);
|
|
967
1166
|
}
|
|
968
1167
|
function listenOnFreePort(server) {
|
|
969
|
-
return new Promise((
|
|
1168
|
+
return new Promise((resolve5, reject) => {
|
|
970
1169
|
let port = CALLBACK_PORT_START;
|
|
971
1170
|
const tryNext = () => {
|
|
972
1171
|
if (port > CALLBACK_PORT_END) {
|
|
@@ -988,18 +1187,18 @@ function listenOnFreePort(server) {
|
|
|
988
1187
|
});
|
|
989
1188
|
server.listen(port, "127.0.0.1", () => {
|
|
990
1189
|
server.removeAllListeners("error");
|
|
991
|
-
|
|
1190
|
+
resolve5(port);
|
|
992
1191
|
});
|
|
993
1192
|
};
|
|
994
1193
|
tryNext();
|
|
995
1194
|
});
|
|
996
1195
|
}
|
|
997
|
-
function escapeHtml2(
|
|
998
|
-
return
|
|
1196
|
+
function escapeHtml2(text2) {
|
|
1197
|
+
return text2.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
|
|
999
1198
|
}
|
|
1000
1199
|
|
|
1001
1200
|
// src/context.ts
|
|
1002
|
-
import {
|
|
1201
|
+
import { confirm, isCancel, text } from "@clack/prompts";
|
|
1003
1202
|
|
|
1004
1203
|
// src/ready.ts
|
|
1005
1204
|
import chalk from "chalk";
|
|
@@ -1035,20 +1234,83 @@ async function waitWithSpinner(waiter, url, paths, io) {
|
|
|
1035
1234
|
}
|
|
1036
1235
|
|
|
1037
1236
|
// src/utils/logger.ts
|
|
1237
|
+
import chalk3 from "chalk";
|
|
1238
|
+
|
|
1239
|
+
// src/ui/theme.ts
|
|
1038
1240
|
import chalk2 from "chalk";
|
|
1039
|
-
var
|
|
1241
|
+
var STOPS = [
|
|
1242
|
+
[0, 229, 255],
|
|
1243
|
+
[124, 77, 255],
|
|
1244
|
+
[255, 78, 205]
|
|
1245
|
+
];
|
|
1246
|
+
var WORDMARK = [
|
|
1247
|
+
"\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 ",
|
|
1248
|
+
"\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 ",
|
|
1249
|
+
"\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",
|
|
1250
|
+
"\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",
|
|
1251
|
+
"\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",
|
|
1252
|
+
"\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 "
|
|
1253
|
+
];
|
|
1254
|
+
var TRAILS = [" ", " \u2501\u2501\u2501 ", " \u2501\u2501\u2501\u2501 ", " \u2501\u2501 ", " ", " "];
|
|
1255
|
+
var LOGO_WIDTH = TRAILS[0].length + WORDMARK[0].length;
|
|
1256
|
+
var brand = {
|
|
1257
|
+
primary: chalk2.hex("#7C4DFF"),
|
|
1258
|
+
accent: chalk2.hex("#00E5FF"),
|
|
1259
|
+
url: (url) => chalk2.bold.hex("#00E5FF")(url)
|
|
1260
|
+
};
|
|
1261
|
+
var symbols = {
|
|
1262
|
+
info: chalk2.hex("#7C4DFF")("\u25C6"),
|
|
1263
|
+
success: chalk2.green("\u2714"),
|
|
1264
|
+
warn: chalk2.yellow("\u25B2"),
|
|
1265
|
+
error: chalk2.red("\u2716"),
|
|
1266
|
+
step: chalk2.dim("\u2502")
|
|
1267
|
+
};
|
|
1268
|
+
function colourAt(t) {
|
|
1269
|
+
const scaled = Math.min(Math.max(t, 0), 1) * (STOPS.length - 1);
|
|
1270
|
+
const i = Math.min(Math.floor(scaled), STOPS.length - 2);
|
|
1271
|
+
const f = scaled - i;
|
|
1272
|
+
const a = STOPS[i];
|
|
1273
|
+
const b = STOPS[i + 1];
|
|
1274
|
+
return [0, 1, 2].map((k) => Math.round(a[k] + (b[k] - a[k]) * f));
|
|
1275
|
+
}
|
|
1276
|
+
function gradient(text2, width = text2.length, offset = 0) {
|
|
1277
|
+
if (chalk2.level === 0) return text2;
|
|
1278
|
+
return [...text2].map((ch, i) => ch === " " ? ch : chalk2.rgb(...colourAt((offset + i) / (width - 1)))(ch)).join("");
|
|
1279
|
+
}
|
|
1280
|
+
function logo(options = {}) {
|
|
1281
|
+
const columns = options.columns || process.stderr.columns || 80;
|
|
1282
|
+
const tagline = options.tagline ?? "throw static files at the web, get a link back";
|
|
1283
|
+
if (columns < LOGO_WIDTH + 2) return `${gradient("\u25B6\u25B6 fling")} ${chalk2.dim(tagline)}`;
|
|
1284
|
+
const lines = WORDMARK.map(
|
|
1285
|
+
(row, i) => chalk2.dim(TRAILS[i] ?? "") + gradient(row, WORDMARK[0].length)
|
|
1286
|
+
);
|
|
1287
|
+
return ["", ...lines, `${" ".repeat(TRAILS[0].length)}${chalk2.dim(tagline)}`, ""].join("\n");
|
|
1288
|
+
}
|
|
1289
|
+
var ANSI = /\x1b\[[0-9;]*m/g;
|
|
1290
|
+
var visibleLength = (s) => s.replace(ANSI, "").length;
|
|
1291
|
+
function box(lines, title) {
|
|
1292
|
+
const width = Math.max(...lines.map(visibleLength), title ? visibleLength(title) + 2 : 0);
|
|
1293
|
+
const border = chalk2.dim;
|
|
1294
|
+
const top = title ? `${border("\u256D\u2500")} ${title} ${border(`${"\u2500".repeat(width - visibleLength(title) - 1)}\u256E`)}` : border(`\u256D${"\u2500".repeat(width + 2)}\u256E`);
|
|
1295
|
+
const body = lines.map(
|
|
1296
|
+
(line) => `${border("\u2502")} ${line}${" ".repeat(width - visibleLength(line))} ${border("\u2502")}`
|
|
1297
|
+
);
|
|
1298
|
+
return [top, ...body, border(`\u2570${"\u2500".repeat(width + 2)}\u256F`)].join("\n");
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// src/utils/logger.ts
|
|
1040
1302
|
function createLogger(options = {}) {
|
|
1041
1303
|
const write = options.write ?? ((line) => process.stderr.write(`${line}
|
|
1042
1304
|
`));
|
|
1043
1305
|
const debugEnabled = options.debug ?? false;
|
|
1044
1306
|
return {
|
|
1045
|
-
info: (message) => write(`${
|
|
1046
|
-
success: (message) => write(`${
|
|
1047
|
-
warn: (message) => write(`${
|
|
1048
|
-
error: (message) => write(`${
|
|
1049
|
-
hint: (message) => write(
|
|
1307
|
+
info: (message) => write(`${symbols.info} ${message}`),
|
|
1308
|
+
success: (message) => write(`${symbols.success} ${message}`),
|
|
1309
|
+
warn: (message) => write(`${symbols.warn} ${chalk3.yellow(message)}`),
|
|
1310
|
+
error: (message) => write(`${symbols.error} ${chalk3.red(message)}`),
|
|
1311
|
+
hint: (message) => write(chalk3.dim(` ${message}`)),
|
|
1050
1312
|
debug: (message) => {
|
|
1051
|
-
if (debugEnabled) write(
|
|
1313
|
+
if (debugEnabled) write(chalk3.dim(`[debug] ${message}`));
|
|
1052
1314
|
}
|
|
1053
1315
|
};
|
|
1054
1316
|
}
|
|
@@ -1068,12 +1330,13 @@ var silentLogger = {
|
|
|
1068
1330
|
};
|
|
1069
1331
|
|
|
1070
1332
|
// src/utils/output.ts
|
|
1071
|
-
import
|
|
1333
|
+
import chalk4 from "chalk";
|
|
1072
1334
|
function createOutput(options = {}) {
|
|
1073
1335
|
const json = options.json ?? false;
|
|
1074
1336
|
const write = options.write ?? ((chunk) => process.stdout.write(chunk));
|
|
1075
1337
|
return {
|
|
1076
1338
|
json,
|
|
1339
|
+
tty: options.tty ?? (options.write ? false : Boolean(process.stdout.isTTY)),
|
|
1077
1340
|
data: (raw, human) => write(`${json ? formatJson(raw) : human()}
|
|
1078
1341
|
`),
|
|
1079
1342
|
text: (line) => {
|
|
@@ -1086,15 +1349,15 @@ function formatJson(value) {
|
|
|
1086
1349
|
return JSON.stringify(value, null, 2);
|
|
1087
1350
|
}
|
|
1088
1351
|
function renderTable(rows, columns) {
|
|
1089
|
-
if (rows.length === 0) return
|
|
1352
|
+
if (rows.length === 0) return chalk4.dim("(no results)");
|
|
1090
1353
|
const cells = rows.map((row) => columns.map((column) => formatCell(column.value(row))));
|
|
1091
1354
|
const widths = columns.map(
|
|
1092
1355
|
(column, i) => Math.max(column.header.length, ...cells.map((line) => line[i]?.length ?? 0))
|
|
1093
1356
|
);
|
|
1094
|
-
const pad = (
|
|
1357
|
+
const pad = (text2, i) => i === columns.length - 1 ? text2 : text2.padEnd(widths[i] ?? 0);
|
|
1095
1358
|
const header = columns.map((column, i) => pad(column.header, i)).join(" ");
|
|
1096
1359
|
const body = cells.map((line) => line.map(pad).join(" "));
|
|
1097
|
-
return [
|
|
1360
|
+
return [chalk4.bold(header), ...body].join("\n");
|
|
1098
1361
|
}
|
|
1099
1362
|
function formatCell(value) {
|
|
1100
1363
|
if (value === null || value === void 0 || value === "") return "-";
|
|
@@ -1144,16 +1407,14 @@ async function createContext(options = {}) {
|
|
|
1144
1407
|
};
|
|
1145
1408
|
}
|
|
1146
1409
|
async function ttyConfirm(question) {
|
|
1147
|
-
const answer = await
|
|
1148
|
-
|
|
1410
|
+
const answer = await confirm({ message: question, initialValue: false, output: process.stderr });
|
|
1411
|
+
if (isCancel(answer)) throw new CliError("Cancelled");
|
|
1412
|
+
return answer;
|
|
1149
1413
|
}
|
|
1150
1414
|
async function ttyPrompt(question) {
|
|
1151
|
-
const
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
} finally {
|
|
1155
|
-
rl.close();
|
|
1156
|
-
}
|
|
1415
|
+
const answer = await text({ message: question.trim(), output: process.stderr });
|
|
1416
|
+
if (isCancel(answer)) throw new CliError("Cancelled");
|
|
1417
|
+
return answer ?? "";
|
|
1157
1418
|
}
|
|
1158
1419
|
function ttyProgress(line) {
|
|
1159
1420
|
process.stderr.write(`\r\x1B[2K${line ?? ""}`);
|
|
@@ -1308,30 +1569,135 @@ async function runWhoami(ctx) {
|
|
|
1308
1569
|
apiKeySource: ctx.config.apiKeySource
|
|
1309
1570
|
},
|
|
1310
1571
|
() => [
|
|
1311
|
-
`${
|
|
1312
|
-
`${
|
|
1313
|
-
`${
|
|
1314
|
-
`${
|
|
1572
|
+
`${chalk5.bold("User:")} ${me.name} <${me.email}>`,
|
|
1573
|
+
`${chalk5.bold("Sites:")} ${sitesPattern(base)} ${chalk5.dim("(names are first come, first served)")}`,
|
|
1574
|
+
`${chalk5.bold("Server:")} ${ctx.config.serverUrl}`,
|
|
1575
|
+
`${chalk5.bold("API key:")} ${redactKey(ctx.config.apiKey)} ${chalk5.dim(`(from ${ctx.config.apiKeySource})`)}`
|
|
1315
1576
|
].join("\n")
|
|
1316
1577
|
);
|
|
1317
1578
|
}
|
|
1318
1579
|
|
|
1319
1580
|
// src/commands/deploy.ts
|
|
1320
|
-
import
|
|
1581
|
+
import { stat as stat4 } from "node:fs/promises";
|
|
1582
|
+
import { resolve as resolve3 } from "node:path";
|
|
1583
|
+
import chalk6 from "chalk";
|
|
1321
1584
|
|
|
1322
1585
|
// src/deploy.ts
|
|
1323
|
-
import { readFile as
|
|
1586
|
+
import { readFile as readFile4 } from "node:fs/promises";
|
|
1324
1587
|
|
|
1325
1588
|
// src/manifest.ts
|
|
1326
1589
|
import { createHash } from "node:crypto";
|
|
1327
1590
|
import { createReadStream } from "node:fs";
|
|
1328
|
-
import { readdir, readFile as
|
|
1329
|
-
import { join as
|
|
1330
|
-
import
|
|
1591
|
+
import { readdir, readFile as readFile3, realpath as realpath2, stat as stat2 } from "node:fs/promises";
|
|
1592
|
+
import { join as join3, relative as relative2, sep as sep2 } from "node:path";
|
|
1593
|
+
import ignore2 from "ignore";
|
|
1331
1594
|
import mime from "mime";
|
|
1595
|
+
|
|
1596
|
+
// src/gitignore.ts
|
|
1597
|
+
import { execFile } from "node:child_process";
|
|
1598
|
+
import { readFile as readFile2, realpath } from "node:fs/promises";
|
|
1599
|
+
import { join as join2, relative, sep } from "node:path";
|
|
1600
|
+
import ignore from "ignore";
|
|
1601
|
+
var GIT_TIMEOUT_MS = 2e4;
|
|
1602
|
+
function git(cwd, args, input) {
|
|
1603
|
+
return new Promise((resolve5) => {
|
|
1604
|
+
try {
|
|
1605
|
+
const child = execFile(
|
|
1606
|
+
"git",
|
|
1607
|
+
["-C", cwd, ...args],
|
|
1608
|
+
{ timeout: GIT_TIMEOUT_MS, maxBuffer: 256 * 1024 * 1024, encoding: "utf8" },
|
|
1609
|
+
(error, stdout) => {
|
|
1610
|
+
const code = error?.code;
|
|
1611
|
+
if (error && !(args[0] === "check-ignore" && code === 1)) resolve5(null);
|
|
1612
|
+
else resolve5(stdout);
|
|
1613
|
+
}
|
|
1614
|
+
);
|
|
1615
|
+
child.on("error", () => resolve5(null));
|
|
1616
|
+
if (input !== void 0) child.stdin?.end(input);
|
|
1617
|
+
} catch {
|
|
1618
|
+
resolve5(null);
|
|
1619
|
+
}
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
async function rootIsIgnored(top, root) {
|
|
1623
|
+
const rel = relative(top, root);
|
|
1624
|
+
if (!rel || rel.startsWith("..")) return false;
|
|
1625
|
+
const segments = rel.split(sep);
|
|
1626
|
+
const candidates = segments.map((_, i) => `${segments.slice(0, i + 1).join("/")}/`);
|
|
1627
|
+
const out = await git(top, ["check-ignore", "--stdin", "-z"], `${candidates.join("\0")}\0`);
|
|
1628
|
+
return out !== null && out.split("\0").some(Boolean);
|
|
1629
|
+
}
|
|
1630
|
+
function gitMode(entries) {
|
|
1631
|
+
const files = /* @__PURE__ */ new Set();
|
|
1632
|
+
const dirs = [];
|
|
1633
|
+
for (const e of entries) {
|
|
1634
|
+
if (!e) continue;
|
|
1635
|
+
if (e.endsWith("/")) dirs.push(e);
|
|
1636
|
+
else files.add(e);
|
|
1637
|
+
}
|
|
1638
|
+
const dirSet = new Set(dirs);
|
|
1639
|
+
return {
|
|
1640
|
+
mode: "git",
|
|
1641
|
+
ignores(rel, isDir) {
|
|
1642
|
+
if (isDir ? dirSet.has(`${rel}/`) : files.has(rel)) return true;
|
|
1643
|
+
let i = rel.indexOf("/");
|
|
1644
|
+
while (i !== -1) {
|
|
1645
|
+
if (dirSet.has(rel.slice(0, i + 1))) return true;
|
|
1646
|
+
i = rel.indexOf("/", i + 1);
|
|
1647
|
+
}
|
|
1648
|
+
return false;
|
|
1649
|
+
},
|
|
1650
|
+
async enterDir() {
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
function nestedMode() {
|
|
1655
|
+
const levels = [];
|
|
1656
|
+
return {
|
|
1657
|
+
mode: "nested",
|
|
1658
|
+
ignores(rel, isDir) {
|
|
1659
|
+
for (const { base, ig } of levels) {
|
|
1660
|
+
if (base && !rel.startsWith(`${base}/`)) continue;
|
|
1661
|
+
const sub = base ? rel.slice(base.length + 1) : rel;
|
|
1662
|
+
if (sub && ig.ignores(isDir ? `${sub}/` : sub)) return true;
|
|
1663
|
+
}
|
|
1664
|
+
return false;
|
|
1665
|
+
},
|
|
1666
|
+
async enterDir(absDir, relDir) {
|
|
1667
|
+
let text2;
|
|
1668
|
+
try {
|
|
1669
|
+
text2 = await readFile2(join2(absDir, ".gitignore"), "utf8");
|
|
1670
|
+
} catch {
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
levels.push({ base: relDir, ig: ignore().add(text2) });
|
|
1674
|
+
}
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
async function loadGitIgnore(root) {
|
|
1678
|
+
const topOut = await git(root, ["rev-parse", "--show-toplevel"]);
|
|
1679
|
+
const topRaw = topOut?.trim();
|
|
1680
|
+
if (!topRaw) return nestedMode();
|
|
1681
|
+
const top = await realpath(topRaw).catch(() => topRaw);
|
|
1682
|
+
if (await rootIsIgnored(top, root)) return nestedMode();
|
|
1683
|
+
const listed = await git(root, [
|
|
1684
|
+
"ls-files",
|
|
1685
|
+
"--others",
|
|
1686
|
+
"--ignored",
|
|
1687
|
+
"--exclude-standard",
|
|
1688
|
+
"--directory",
|
|
1689
|
+
"-z"
|
|
1690
|
+
]);
|
|
1691
|
+
if (listed === null) return nestedMode();
|
|
1692
|
+
return gitMode(listed.split("\0"));
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// src/manifest.ts
|
|
1332
1696
|
var IGNORE_FILE = ".flingignore";
|
|
1333
1697
|
var NO_INDEX_WARNING = "No index.html at the top of the folder \u2014 the site root will 404.";
|
|
1334
1698
|
var ALWAYS_SKIP = /* @__PURE__ */ new Set(["node_modules"]);
|
|
1699
|
+
var QUIET_DOT_ENTRIES = /* @__PURE__ */ new Set([".git", ".DS_Store"]);
|
|
1700
|
+
var SKIPPED_SHOWN = 5;
|
|
1335
1701
|
var CHARSET_TYPES = /* @__PURE__ */ new Set([
|
|
1336
1702
|
"application/javascript",
|
|
1337
1703
|
"application/json",
|
|
@@ -1339,6 +1705,45 @@ var CHARSET_TYPES = /* @__PURE__ */ new Set([
|
|
|
1339
1705
|
"application/xml",
|
|
1340
1706
|
"image/svg+xml"
|
|
1341
1707
|
]);
|
|
1708
|
+
var SecretFilesError = class extends CliError {
|
|
1709
|
+
constructor(paths) {
|
|
1710
|
+
const shown = paths.slice(0, 20);
|
|
1711
|
+
super(
|
|
1712
|
+
`Not flung: ${paths.length === 1 ? "this is a secret file" : `these are ${paths.length} secret files`} (keys, certificates, credentials): ${shown.join(", ")}${paths.length > shown.length ? ` (+${paths.length - shown.length} more)` : ""}. ${NEVER_PUBLISHED_NOTE}`,
|
|
1713
|
+
{
|
|
1714
|
+
hint: `Move ${paths.length === 1 ? "it" : "them"} out of the folder, or add ${paths.length === 1 ? "it" : "them"} to ${IGNORE_FILE} if the site does not need ${paths.length === 1 ? "it" : "them"}.`
|
|
1715
|
+
}
|
|
1716
|
+
);
|
|
1717
|
+
this.paths = paths;
|
|
1718
|
+
this.name = "SecretFilesError";
|
|
1719
|
+
}
|
|
1720
|
+
code = "secret_file";
|
|
1721
|
+
};
|
|
1722
|
+
var SecretsFoundError = class extends CliError {
|
|
1723
|
+
constructor(findings) {
|
|
1724
|
+
const files = new Set(findings.map((f) => f.path)).size;
|
|
1725
|
+
super(
|
|
1726
|
+
`Not flung: found secrets in ${files} file${files === 1 ? "" : "s"}:
|
|
1727
|
+
${describeSecretFindings(findings).join("\n ")}
|
|
1728
|
+
${NEVER_PUBLISHED_NOTE}`,
|
|
1729
|
+
{
|
|
1730
|
+
hint: `Remove the secret (and rotate it if it was ever shared), or add the file to ${IGNORE_FILE} if the site does not need it.`
|
|
1731
|
+
}
|
|
1732
|
+
);
|
|
1733
|
+
this.findings = findings;
|
|
1734
|
+
this.name = "SecretsFoundError";
|
|
1735
|
+
}
|
|
1736
|
+
code = "secret_detected";
|
|
1737
|
+
};
|
|
1738
|
+
function describeSkipped(skipped) {
|
|
1739
|
+
const line = (n, what, names) => `Skipped ${n} ${what}${n === 1 ? "" : "s"} (${names.slice(0, SKIPPED_SHOWN).join(", ")}${n > SKIPPED_SHOWN ? ", \u2026" : ""}) \u2014 ${what}s are never flung`;
|
|
1740
|
+
const lines = [];
|
|
1741
|
+
if (skipped.dotfiles.length)
|
|
1742
|
+
lines.push(line(skipped.dotfiles.length, "dotfile", skipped.dotfiles));
|
|
1743
|
+
if (skipped.gitignored.length)
|
|
1744
|
+
lines.push(line(skipped.gitignored.length, "gitignored file", skipped.gitignored));
|
|
1745
|
+
return lines;
|
|
1746
|
+
}
|
|
1342
1747
|
function contentTypeFor(path) {
|
|
1343
1748
|
const type = mime.getType(path) ?? "application/octet-stream";
|
|
1344
1749
|
return type.startsWith("text/") || CHARSET_TYPES.has(type) ? `${type}; charset=utf-8` : type;
|
|
@@ -1361,29 +1766,38 @@ function toManifest(local) {
|
|
|
1361
1766
|
async function buildManifest(dir) {
|
|
1362
1767
|
let root;
|
|
1363
1768
|
try {
|
|
1364
|
-
root = await
|
|
1769
|
+
root = await realpath2(dir);
|
|
1365
1770
|
} catch {
|
|
1366
1771
|
throw new CliError(`Folder not found: ${dir}`);
|
|
1367
1772
|
}
|
|
1368
1773
|
if (!(await stat2(root)).isDirectory()) throw new CliError(`Not a folder: ${dir}`);
|
|
1369
1774
|
const ig = await loadIgnore(root);
|
|
1775
|
+
const gitIgnore = await loadGitIgnore(root);
|
|
1370
1776
|
const warnings = [];
|
|
1777
|
+
const skipped = { dotfiles: [], gitignored: [] };
|
|
1778
|
+
const secretFiles = [];
|
|
1371
1779
|
const found = [];
|
|
1372
1780
|
const visitedDirs = /* @__PURE__ */ new Set([root]);
|
|
1373
|
-
const inside = (real) => real === root || real.startsWith(root +
|
|
1781
|
+
const inside = (real) => real === root || real.startsWith(root + sep2);
|
|
1374
1782
|
async function walk2(realDir, relDir) {
|
|
1783
|
+
await gitIgnore.enterDir(realDir, relDir);
|
|
1375
1784
|
const entries = await readdir(realDir, { withFileTypes: true });
|
|
1376
1785
|
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
1377
1786
|
for (const entry of entries) {
|
|
1378
|
-
if (entry.name.startsWith(".") || ALWAYS_SKIP.has(entry.name)) continue;
|
|
1379
1787
|
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
1380
|
-
|
|
1788
|
+
if (entry.name.startsWith(".")) {
|
|
1789
|
+
if (!QUIET_DOT_ENTRIES.has(entry.name))
|
|
1790
|
+
skipped.dotfiles.push(entry.isDirectory() ? `${rel}/` : rel);
|
|
1791
|
+
continue;
|
|
1792
|
+
}
|
|
1793
|
+
if (ALWAYS_SKIP.has(entry.name)) continue;
|
|
1794
|
+
let abs = join3(realDir, entry.name);
|
|
1381
1795
|
let isDir = entry.isDirectory();
|
|
1382
1796
|
let isFile = entry.isFile();
|
|
1383
1797
|
if (entry.isSymbolicLink()) {
|
|
1384
1798
|
let target;
|
|
1385
1799
|
try {
|
|
1386
|
-
target = await
|
|
1800
|
+
target = await realpath2(abs);
|
|
1387
1801
|
} catch {
|
|
1388
1802
|
warnings.push(`Skipped broken symlink: ${rel}`);
|
|
1389
1803
|
continue;
|
|
@@ -1393,17 +1807,38 @@ async function buildManifest(dir) {
|
|
|
1393
1807
|
continue;
|
|
1394
1808
|
}
|
|
1395
1809
|
const info = await stat2(target);
|
|
1810
|
+
const targetRel = relative2(root, target).split(sep2).join("/");
|
|
1811
|
+
if (targetRel.split("/").some((seg) => seg.startsWith("."))) {
|
|
1812
|
+
skipped.dotfiles.push(rel);
|
|
1813
|
+
continue;
|
|
1814
|
+
}
|
|
1815
|
+
if (targetRel && gitIgnore.ignores(targetRel, info.isDirectory())) {
|
|
1816
|
+
skipped.gitignored.push(rel);
|
|
1817
|
+
continue;
|
|
1818
|
+
}
|
|
1396
1819
|
abs = target;
|
|
1397
1820
|
isDir = info.isDirectory();
|
|
1398
1821
|
isFile = info.isFile();
|
|
1399
1822
|
}
|
|
1400
1823
|
if (isDir) {
|
|
1824
|
+
if (gitIgnore.ignores(rel, true)) {
|
|
1825
|
+
skipped.gitignored.push(`${rel}/`);
|
|
1826
|
+
continue;
|
|
1827
|
+
}
|
|
1401
1828
|
if (ig.ignores(`${rel}/`)) continue;
|
|
1402
1829
|
if (visitedDirs.has(abs)) continue;
|
|
1403
1830
|
visitedDirs.add(abs);
|
|
1404
1831
|
await walk2(abs, rel);
|
|
1405
1832
|
} else if (isFile) {
|
|
1833
|
+
if (gitIgnore.ignores(rel, false)) {
|
|
1834
|
+
skipped.gitignored.push(rel);
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1406
1837
|
if (ig.ignores(rel)) continue;
|
|
1838
|
+
if (isSecretPath(rel)) {
|
|
1839
|
+
secretFiles.push(rel);
|
|
1840
|
+
continue;
|
|
1841
|
+
}
|
|
1407
1842
|
const { size } = await stat2(abs);
|
|
1408
1843
|
found.push({ path: rel, absPath: abs, size });
|
|
1409
1844
|
if (found.length > MAX_FILES) {
|
|
@@ -1415,21 +1850,34 @@ async function buildManifest(dir) {
|
|
|
1415
1850
|
}
|
|
1416
1851
|
}
|
|
1417
1852
|
await walk2(root, "");
|
|
1853
|
+
if (secretFiles.length > 0) throw new SecretFilesError(secretFiles);
|
|
1418
1854
|
if (found.length === 0) {
|
|
1419
1855
|
throw new CliError(
|
|
1420
|
-
`Nothing to deploy: ${dir} has no files (dotfiles and node_modules are skipped)`
|
|
1856
|
+
`Nothing to deploy: ${dir} has no files (dotfiles, gitignored files and node_modules are skipped)`
|
|
1421
1857
|
);
|
|
1422
1858
|
}
|
|
1423
1859
|
checkLimits(found, dir);
|
|
1424
1860
|
if (!found.some((f) => f.path === "index.html")) {
|
|
1425
1861
|
warnings.push(NO_INDEX_WARNING);
|
|
1426
1862
|
}
|
|
1427
|
-
const
|
|
1428
|
-
|
|
1429
|
-
sha256
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1863
|
+
const findings = [];
|
|
1864
|
+
const files = await mapLimit(found, 16, async (f) => {
|
|
1865
|
+
let sha256;
|
|
1866
|
+
if (f.size <= SECRET_SCAN_MAX_FILE_BYTES && isScannablePath(f.path)) {
|
|
1867
|
+
const bytes = await readFile3(f.absPath);
|
|
1868
|
+
sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
1869
|
+
findings.push(...scanBytesForSecrets(bytes, f.path));
|
|
1870
|
+
} else sha256 = await sha256File(f.absPath);
|
|
1871
|
+
return { ...f, sha256, contentType: contentTypeFor(f.path) };
|
|
1872
|
+
});
|
|
1873
|
+
if (findings.length > 0) {
|
|
1874
|
+
throw new SecretsFoundError(
|
|
1875
|
+
findings.sort(
|
|
1876
|
+
(a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : a.line - b.line || a.rule.localeCompare(b.rule)
|
|
1877
|
+
)
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
return { root, files, totalBytes: files.reduce((n, f) => n + f.size, 0), warnings, skipped };
|
|
1433
1881
|
}
|
|
1434
1882
|
function checkLimits(files, dir) {
|
|
1435
1883
|
for (const f of files) {
|
|
@@ -1493,9 +1941,9 @@ async function withHtmlListing(local, siteName2) {
|
|
|
1493
1941
|
};
|
|
1494
1942
|
}
|
|
1495
1943
|
async function loadIgnore(root) {
|
|
1496
|
-
const ig =
|
|
1944
|
+
const ig = ignore2();
|
|
1497
1945
|
try {
|
|
1498
|
-
ig.add(await
|
|
1946
|
+
ig.add(await readFile3(join3(root, IGNORE_FILE), "utf8"));
|
|
1499
1947
|
} catch (error) {
|
|
1500
1948
|
if (error.code !== "ENOENT") throw error;
|
|
1501
1949
|
}
|
|
@@ -1517,7 +1965,7 @@ async function mapLimit(items, limit, fn) {
|
|
|
1517
1965
|
// src/naming.ts
|
|
1518
1966
|
import { existsSync } from "node:fs";
|
|
1519
1967
|
import { homedir as homedir2 } from "node:os";
|
|
1520
|
-
import { basename, dirname, isAbsolute, relative, resolve, sep as
|
|
1968
|
+
import { basename, dirname, isAbsolute, relative as relative3, resolve, sep as sep3 } from "node:path";
|
|
1521
1969
|
var PROJECT_MARKERS = ["package.json", "pyproject.toml", "Cargo.toml", "go.mod"];
|
|
1522
1970
|
var BUILD_OUTPUT_DIRS = /* @__PURE__ */ new Set([
|
|
1523
1971
|
"dist",
|
|
@@ -1549,23 +1997,23 @@ function findProjectRoot(dir, env = {}) {
|
|
|
1549
1997
|
const home = resolve(env.home ?? homedir2());
|
|
1550
1998
|
const candidates = [];
|
|
1551
1999
|
for (let d = resolve(dir); d !== home && dirname(d) !== d; d = dirname(d)) candidates.push(d);
|
|
1552
|
-
return candidates.find((d) => exists(`${d}${
|
|
2000
|
+
return candidates.find((d) => exists(`${d}${sep3}.git`)) ?? candidates.find((d) => PROJECT_MARKERS.some((m) => exists(`${d}${sep3}${m}`))) ?? null;
|
|
1553
2001
|
}
|
|
1554
2002
|
var isGeneric = (segment) => segment.startsWith(".") || BUILD_OUTPUT_DIRS.has(segment.toLowerCase());
|
|
1555
2003
|
function nameParts(dir, env = {}) {
|
|
1556
2004
|
const abs = resolve(dir);
|
|
1557
2005
|
const root = findProjectRoot(abs, env);
|
|
1558
2006
|
if (!root) {
|
|
1559
|
-
const fromHome =
|
|
2007
|
+
const fromHome = relative3(resolve(env.home ?? homedir2()), abs);
|
|
1560
2008
|
if (fromHome === "") return [];
|
|
1561
2009
|
const underHome = fromHome && !fromHome.startsWith("..") && !isAbsolute(fromHome);
|
|
1562
|
-
const segments = (underHome ? fromHome : abs).split(
|
|
2010
|
+
const segments = (underHome ? fromHome : abs).split(sep3).filter(Boolean);
|
|
1563
2011
|
const pick = [...segments].reverse().find((s) => !isGeneric(s)) ?? segments.at(-1);
|
|
1564
2012
|
return pick ? [pick] : [];
|
|
1565
2013
|
}
|
|
1566
2014
|
const parts = [basename(root)];
|
|
1567
|
-
const rel =
|
|
1568
|
-
for (const raw of rel ? rel.split(
|
|
2015
|
+
const rel = relative3(root, abs);
|
|
2016
|
+
for (const raw of rel ? rel.split(sep3) : []) {
|
|
1569
2017
|
if (isGeneric(raw) || CONTAINER_DIRS.has(raw.toLowerCase())) continue;
|
|
1570
2018
|
const segment = raw.replace(BUILD_SUFFIX_RE, "");
|
|
1571
2019
|
const slug = slugify(segment, 200);
|
|
@@ -1696,7 +2144,7 @@ async function deploy(deps, opts) {
|
|
|
1696
2144
|
}
|
|
1697
2145
|
async function uploadWithRetry(deps, file, url, headers) {
|
|
1698
2146
|
const sleep = deps.sleep ?? defaultSleep2;
|
|
1699
|
-
const body = file.content ?? await
|
|
2147
|
+
const body = file.content ?? await readFile4(file.absPath);
|
|
1700
2148
|
for (let attempt = 0; ; attempt++) {
|
|
1701
2149
|
try {
|
|
1702
2150
|
await putUpload(deps.fetch, url, headers, body);
|
|
@@ -1714,9 +2162,9 @@ async function uploadWithRetry(deps, file, url, headers) {
|
|
|
1714
2162
|
}
|
|
1715
2163
|
|
|
1716
2164
|
// src/inputs.ts
|
|
1717
|
-
import { copyFile, mkdir as mkdir2, mkdtemp, readFile as
|
|
2165
|
+
import { copyFile, mkdir as mkdir2, mkdtemp, readFile as readFile5, rm, stat as stat3, writeFile as writeFile2 } from "node:fs/promises";
|
|
1718
2166
|
import { tmpdir } from "node:os";
|
|
1719
|
-
import { basename as basename2, extname, join as
|
|
2167
|
+
import { basename as basename2, extname, join as join4, resolve as resolve2, sep as sep4 } from "node:path";
|
|
1720
2168
|
import { unzipSync } from "fflate";
|
|
1721
2169
|
function inputKind(path, isDirectory) {
|
|
1722
2170
|
if (isDirectory) return "folder";
|
|
@@ -1734,7 +2182,7 @@ function defaultNameFor(path, isDirectory) {
|
|
|
1734
2182
|
return isDirectory ? defaultSiteName(path) : fileSiteName(path);
|
|
1735
2183
|
}
|
|
1736
2184
|
async function tempFolder() {
|
|
1737
|
-
const dir = await mkdtemp(
|
|
2185
|
+
const dir = await mkdtemp(join4(tmpdir(), "fling-"));
|
|
1738
2186
|
return { dir, cleanup: () => rm(dir, { recursive: true, force: true }) };
|
|
1739
2187
|
}
|
|
1740
2188
|
async function prepareInput(path) {
|
|
@@ -1747,32 +2195,44 @@ async function prepareInput(path) {
|
|
|
1747
2195
|
const kind = inputKind(path, info.isDirectory());
|
|
1748
2196
|
const defaultName = defaultNameFor(path, info.isDirectory());
|
|
1749
2197
|
if (kind === "folder")
|
|
1750
|
-
return {
|
|
1751
|
-
|
|
2198
|
+
return {
|
|
2199
|
+
kind,
|
|
2200
|
+
source: path,
|
|
2201
|
+
dir: path,
|
|
2202
|
+
defaultName,
|
|
2203
|
+
skippedDotfiles: [],
|
|
2204
|
+
cleanup: async () => {
|
|
2205
|
+
}
|
|
2206
|
+
};
|
|
2207
|
+
const file = basename2(path);
|
|
2208
|
+
if (file.startsWith("."))
|
|
2209
|
+
throw new CliError(`Not flung: ${file} is a dotfile. ${NEVER_PUBLISHED_NOTE}`);
|
|
2210
|
+
if (kind === "file" && isSecretPath(file)) throw new SecretFilesError([file]);
|
|
1752
2211
|
if (info.size > MAX_DEPLOY_BYTES)
|
|
1753
2212
|
throw new CliError(
|
|
1754
2213
|
`${basename2(path)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_DEPLOY_BYTES)}`
|
|
1755
2214
|
);
|
|
1756
2215
|
const tmp = await tempFolder();
|
|
2216
|
+
const skippedDotfiles = [];
|
|
1757
2217
|
try {
|
|
1758
2218
|
if (kind === "html") {
|
|
1759
2219
|
if (info.size > MAX_FILE_BYTES)
|
|
1760
2220
|
throw new CliError(
|
|
1761
2221
|
`${basename2(path)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`
|
|
1762
2222
|
);
|
|
1763
|
-
await copyFile(path,
|
|
2223
|
+
await copyFile(path, join4(tmp.dir, "index.html"));
|
|
1764
2224
|
} else if (kind === "zip") {
|
|
1765
|
-
await extractZip(await
|
|
2225
|
+
await extractZip(await readFile5(path), tmp.dir, skippedDotfiles);
|
|
1766
2226
|
} else {
|
|
1767
2227
|
const name = basename2(path);
|
|
1768
|
-
await copyFile(path,
|
|
1769
|
-
await writeFile2(
|
|
2228
|
+
await copyFile(path, join4(tmp.dir, name));
|
|
2229
|
+
await writeFile2(join4(tmp.dir, "index.html"), wrapperPage(name));
|
|
1770
2230
|
}
|
|
1771
2231
|
} catch (error) {
|
|
1772
2232
|
await tmp.cleanup();
|
|
1773
2233
|
throw error;
|
|
1774
2234
|
}
|
|
1775
|
-
return { kind, source: path, dir: tmp.dir, defaultName, cleanup: tmp.cleanup };
|
|
2235
|
+
return { kind, source: path, dir: tmp.dir, defaultName, skippedDotfiles, cleanup: tmp.cleanup };
|
|
1776
2236
|
}
|
|
1777
2237
|
var JUNK_FILES = /* @__PURE__ */ new Set(["thumbs.db", "desktop.ini"]);
|
|
1778
2238
|
function zipEntryPath(name) {
|
|
@@ -1795,7 +2255,16 @@ function unwrapSingleFolder(paths) {
|
|
|
1795
2255
|
current = current.map((p) => p.slice(first.length + 1));
|
|
1796
2256
|
}
|
|
1797
2257
|
}
|
|
1798
|
-
|
|
2258
|
+
function zipDotEntry(name) {
|
|
2259
|
+
const segments = name.replace(/\\/g, "/").split("/").filter((s) => s !== "" && s !== ".");
|
|
2260
|
+
if (segments[0] === "__MACOSX") return null;
|
|
2261
|
+
const i = segments.findIndex((s) => s.startsWith("."));
|
|
2262
|
+
if (i === -1) return null;
|
|
2263
|
+
const seg = segments[i];
|
|
2264
|
+
if (seg === ".DS_Store" || seg === ".git" || seg.startsWith("._")) return null;
|
|
2265
|
+
return i < segments.length - 1 ? `${segments.slice(0, i + 1).join("/")}/` : segments.join("/");
|
|
2266
|
+
}
|
|
2267
|
+
async function extractZip(data, dest, skippedDotfiles = []) {
|
|
1799
2268
|
const unsafe = [];
|
|
1800
2269
|
let declared = 0;
|
|
1801
2270
|
let count = 0;
|
|
@@ -1808,7 +2277,11 @@ async function extractZip(data, dest) {
|
|
|
1808
2277
|
unsafe.push(file.name);
|
|
1809
2278
|
return false;
|
|
1810
2279
|
}
|
|
1811
|
-
if (path === null)
|
|
2280
|
+
if (path === null) {
|
|
2281
|
+
const dot = zipDotEntry(file.name);
|
|
2282
|
+
if (dot && !skippedDotfiles.includes(dot)) skippedDotfiles.push(dot);
|
|
2283
|
+
return false;
|
|
2284
|
+
}
|
|
1812
2285
|
count++;
|
|
1813
2286
|
declared += file.originalSize;
|
|
1814
2287
|
if (file.originalSize > MAX_FILE_BYTES)
|
|
@@ -1849,8 +2322,8 @@ async function extractZip(data, dest) {
|
|
|
1849
2322
|
throw new CliError(`The zip expands to more than the limits allow`);
|
|
1850
2323
|
const rel = paths.get(name);
|
|
1851
2324
|
const target = resolve2(root, ...rel.split("/"));
|
|
1852
|
-
if (!target.startsWith(root +
|
|
1853
|
-
await mkdir2(
|
|
2325
|
+
if (!target.startsWith(root + sep4)) throw new CliError(`Unsafe path in the zip: ${name}`);
|
|
2326
|
+
await mkdir2(join4(target, ".."), { recursive: true });
|
|
1854
2327
|
await writeFile2(target, content);
|
|
1855
2328
|
written.push(rel);
|
|
1856
2329
|
}
|
|
@@ -1890,63 +2363,548 @@ function wrapperPage(file) {
|
|
|
1890
2363
|
`;
|
|
1891
2364
|
}
|
|
1892
2365
|
|
|
1893
|
-
// src/
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
2366
|
+
// src/commands/deploy.ts
|
|
2367
|
+
async function runDeploy(ctx, input, opts) {
|
|
2368
|
+
const client = requireClient(ctx);
|
|
2369
|
+
const ask = ctx.interactive && !opts.yes;
|
|
2370
|
+
const base = ask ? await discoverSitesBase(client) : sitesBaseFor(client.serverUrl);
|
|
2371
|
+
const path = resolve3(input ?? ".");
|
|
2372
|
+
if (!await stat4(path).catch(() => null))
|
|
2373
|
+
throw new CliError(`No such file or folder: ${input ?? "."}`, {
|
|
2374
|
+
hint: `Give the path to a folder, .html, .zip or other file, e.g. \`${ctx.binName} deploy ./dist\`.`
|
|
2375
|
+
});
|
|
2376
|
+
const prepared = await prepareInput(path);
|
|
2377
|
+
try {
|
|
2378
|
+
await deployPrepared(ctx, client, base, prepared, opts, ask);
|
|
2379
|
+
} finally {
|
|
2380
|
+
await prepared.cleanup();
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
async function deployPrepared(ctx, client, base, prepared, opts, ask) {
|
|
2384
|
+
let local = await buildManifest(prepared.dir);
|
|
2385
|
+
local.skipped.dotfiles.unshift(...prepared.skippedDotfiles);
|
|
2386
|
+
for (const line of describeSkipped(local.skipped)) ctx.log.info(line);
|
|
2387
|
+
let name = opts.name !== void 0 ? resolveSiteName(prepared.dir, opts.name) : prepared.defaultName;
|
|
2388
|
+
if (opts.name === void 0 && ask) name = await confirmSiteName(ctx, client, name, base);
|
|
2389
|
+
if (!hasRootIndex(local)) {
|
|
2390
|
+
const pages = htmlPages(local).length;
|
|
2391
|
+
const root = base ? siteUrlFor(name, base) : "the site root";
|
|
2392
|
+
const warning = `No index.html at the root \u2014 ${root} will show a 404.`;
|
|
2393
|
+
const choice = opts.listHtml === true ? "list" : opts.listHtml === false ? "fling" : ask ? await askNoIndex(ctx, pages) : pages > 0 ? "list" : "fling";
|
|
2394
|
+
if (choice === "list") {
|
|
2395
|
+
local = await withHtmlListing(local, name);
|
|
2396
|
+
ctx.log.info(`Generated index.html listing ${pages} HTML page${pages === 1 ? "" : "s"}`);
|
|
2397
|
+
} else ctx.log.warn(warning);
|
|
2398
|
+
}
|
|
2399
|
+
let progressShown = false;
|
|
2400
|
+
const result = await deploy(
|
|
2401
|
+
{ client, fetch: ctx.fetch },
|
|
2402
|
+
{
|
|
2403
|
+
dir: prepared.dir,
|
|
2404
|
+
name,
|
|
2405
|
+
manifest: local,
|
|
2406
|
+
spa: opts.spa ? true : void 0,
|
|
2407
|
+
public: opts.public,
|
|
2408
|
+
onProgress: (p) => {
|
|
2409
|
+
if (p.total === 0) return;
|
|
2410
|
+
progressShown = true;
|
|
2411
|
+
ctx.progress(
|
|
2412
|
+
`${progressBar(p.totalBytes ? p.bytes / p.totalBytes : p.uploaded / p.total)} uploading ${p.uploaded}/${p.total} files \xB7 ${formatBytes(p.bytes)}/${formatBytes(p.totalBytes)}`
|
|
2413
|
+
);
|
|
1922
2414
|
}
|
|
1923
|
-
return out;
|
|
1924
|
-
} catch {
|
|
1925
|
-
return [];
|
|
1926
2415
|
}
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
2416
|
+
).finally(() => {
|
|
2417
|
+
if (progressShown) ctx.progress(null);
|
|
2418
|
+
});
|
|
2419
|
+
for (const warning of result.warnings) if (warning !== NO_INDEX_WARNING) ctx.log.warn(warning);
|
|
2420
|
+
const summary = `${result.fileCount} files, ${formatBytes(result.totalBytes)}`;
|
|
2421
|
+
if (result.status === "unchanged") ctx.log.info(`Unchanged ${chalk6.dim(`(${summary})`)}`);
|
|
2422
|
+
else if (result.status === "repointed")
|
|
2423
|
+
ctx.log.success(`Restored version ${result.versionId} ${chalk6.dim(`(${summary})`)}`);
|
|
2424
|
+
else
|
|
2425
|
+
ctx.log.success(
|
|
2426
|
+
`Deployed ${result.name} ${chalk6.dim(`(${summary}; uploaded ${result.uploaded}, version ${result.versionId})`)}`
|
|
2427
|
+
);
|
|
2428
|
+
const ready = result.status === "unchanged" ? true : (await waitWithSpinner(
|
|
2429
|
+
ctx.waitReady,
|
|
2430
|
+
result.url,
|
|
2431
|
+
local.files.map((f) => f.path),
|
|
2432
|
+
{ progress: ctx.progress, warn: (m) => ctx.log.warn(m) }
|
|
2433
|
+
)).ready;
|
|
2434
|
+
const privateNote = `Private: Nearform sign-in required. Share publicly with \`${ctx.binName} share ${result.name}\`.`;
|
|
2435
|
+
ctx.out.data({ status: result.status, site: result.site, ready, skipped: local.skipped }, () => {
|
|
2436
|
+
const isPrivate = result.site.visibility === "private";
|
|
2437
|
+
if (ctx.out.tty)
|
|
2438
|
+
return box(
|
|
2439
|
+
[brand.url(result.url), ...isPrivate ? [chalk6.dim(privateNote)] : []],
|
|
2440
|
+
isPrivate ? chalk6.bold("flung \xB7 private") : chalk6.bold("flung \xB7 public")
|
|
2441
|
+
);
|
|
2442
|
+
const lines = [chalk6.bold(result.url)];
|
|
2443
|
+
if (isPrivate) lines.push(chalk6.dim(privateNote));
|
|
2444
|
+
return lines.join("\n");
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
var BAR_WIDTH = 20;
|
|
2448
|
+
function progressBar(fraction) {
|
|
2449
|
+
const done = Math.round(Math.min(Math.max(fraction, 0), 1) * BAR_WIDTH);
|
|
2450
|
+
return brand.primary("\u2501".repeat(done)) + chalk6.dim("\u2500".repeat(BAR_WIDTH - done));
|
|
2451
|
+
}
|
|
2452
|
+
async function askNoIndex(ctx, pages) {
|
|
2453
|
+
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 ";
|
|
2454
|
+
for (; ; ) {
|
|
2455
|
+
const answer = (await ctx.prompt(question)).trim().toLowerCase();
|
|
2456
|
+
if (pages > 0 && (answer === "" || answer === "l" || answer === "list")) return "list";
|
|
2457
|
+
if (answer === "f" || answer === "fling") return "fling";
|
|
2458
|
+
if (answer === "a" || answer === "abort" || answer === "n" || answer === "no" || answer === "")
|
|
2459
|
+
throw new CliError("Not flung: no index.html at the root", {
|
|
2460
|
+
hint: "Fling the folder that contains index.html (your build output, e.g. ./dist), or pass --list-html / --no-list-html."
|
|
2461
|
+
});
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
async function confirmSiteName(ctx, client, generated, base) {
|
|
2465
|
+
const where = (n) => base ? ` \u2192 ${siteUrlFor(n, base)}` : "";
|
|
2466
|
+
const describe = (n, note) => ctx.log.info(`Site name: ${chalk6.bold(n)}${where(n)} ${chalk6.dim(`(${note})`)}`);
|
|
2467
|
+
let candidate = generated;
|
|
2468
|
+
for (; ; ) {
|
|
2469
|
+
const check2 = await checkName(client, candidate);
|
|
2470
|
+
let proposal;
|
|
2471
|
+
if (check2.status === "available" || check2.status === "yours") {
|
|
2472
|
+
proposal = candidate;
|
|
2473
|
+
describe(candidate, check2.status === "yours" ? "updates your existing site" : "new");
|
|
2474
|
+
} else {
|
|
2475
|
+
const why = check2.status === "taken" ? "is taken" : "is not a valid site name";
|
|
2476
|
+
const alternatives = check2.suggestions.length ? ` Suggestions: ${check2.suggestions.join(", ")}` : "";
|
|
2477
|
+
ctx.log.warn(`${candidate} ${why}.${alternatives}`);
|
|
2478
|
+
proposal = check2.suggestions[0];
|
|
2479
|
+
if (proposal) describe(proposal, "new");
|
|
2480
|
+
}
|
|
2481
|
+
for (; ; ) {
|
|
2482
|
+
const question = proposal ? `Use "${proposal}"? [Y/n or type another name] ` : "Type a site name (or n to abort): ";
|
|
2483
|
+
const answer = (await ctx.prompt(question)).trim();
|
|
2484
|
+
if (proposal && (answer === "" || /^y(es)?$/i.test(answer))) return proposal;
|
|
2485
|
+
if (/^no?$/i.test(answer))
|
|
2486
|
+
throw new CliError("Aborted \u2014 nothing was flung", {
|
|
2487
|
+
hint: "Pass --name <name> to choose the site name up front."
|
|
2488
|
+
});
|
|
2489
|
+
if (answer === "") continue;
|
|
2490
|
+
if (!isValidSiteName(answer)) {
|
|
2491
|
+
ctx.log.warn(
|
|
2492
|
+
`"${answer}" is not a valid site name: use lowercase letters, digits and single hyphens (max ${SITE_NAME_MAX}); some names are reserved.`
|
|
2493
|
+
);
|
|
2494
|
+
continue;
|
|
2495
|
+
}
|
|
2496
|
+
candidate = answer;
|
|
2497
|
+
break;
|
|
1937
2498
|
}
|
|
1938
2499
|
}
|
|
1939
|
-
}
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
// src/commands/setup.ts
|
|
2503
|
+
import { intro, outro, spinner } from "@clack/prompts";
|
|
2504
|
+
import chalk7 from "chalk";
|
|
2505
|
+
|
|
2506
|
+
// src/claude.ts
|
|
2507
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
2508
|
+
import { access, mkdtemp as mkdtemp2, writeFile as writeFile3 } from "node:fs/promises";
|
|
2509
|
+
import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
|
|
2510
|
+
import { join as join5 } from "node:path";
|
|
2511
|
+
var MARKETPLACE_SOURCE = "nearform/fling";
|
|
2512
|
+
var MARKETPLACE_NAME = "fling";
|
|
2513
|
+
var PLUGIN_NAME = "fling";
|
|
2514
|
+
var PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
|
|
2515
|
+
var MCPB_PATH = "/downloads/fling.mcpb";
|
|
2516
|
+
var runClaude = (args) => new Promise((resolve5) => {
|
|
2517
|
+
execFile2(
|
|
2518
|
+
"claude",
|
|
2519
|
+
[...args],
|
|
2520
|
+
{ timeout: 12e4, maxBuffer: 16 * 1024 * 1024, shell: process.platform === "win32" },
|
|
2521
|
+
(error, stdout, stderr) => {
|
|
2522
|
+
const missing = error?.code === "ENOENT";
|
|
2523
|
+
const code = !error ? 0 : missing ? -1 : typeof error.code === "number" ? error.code : 1;
|
|
2524
|
+
resolve5({ code, stdout: String(stdout), stderr: String(stderr) });
|
|
2525
|
+
}
|
|
2526
|
+
);
|
|
2527
|
+
});
|
|
2528
|
+
async function claudeCodeStatus(run) {
|
|
2529
|
+
const version = await run(["--version"]);
|
|
2530
|
+
if (version.code !== 0) return { claude: "missing" };
|
|
2531
|
+
const claude = version.stdout.trim().split(/\s+/)[0] ?? "unknown";
|
|
2532
|
+
const list = await run(["plugin", "list", "--json"]);
|
|
2533
|
+
let plugins = [];
|
|
2534
|
+
try {
|
|
2535
|
+
plugins = JSON.parse(list.stdout);
|
|
2536
|
+
} catch {
|
|
2537
|
+
}
|
|
2538
|
+
const fling = Array.isArray(plugins) ? plugins.find((p) => p.id === PLUGIN_ID) : void 0;
|
|
2539
|
+
if (!fling) return { claude, installed: false };
|
|
2540
|
+
return {
|
|
2541
|
+
claude,
|
|
2542
|
+
installed: true,
|
|
2543
|
+
version: fling.version,
|
|
2544
|
+
scope: fling.scope,
|
|
2545
|
+
enabled: fling.enabled
|
|
2546
|
+
};
|
|
2547
|
+
}
|
|
2548
|
+
async function installPlugin(run, scope) {
|
|
2549
|
+
check(
|
|
2550
|
+
await run(["plugin", "install", PLUGIN_NAME, "--marketplace", MARKETPLACE_SOURCE, "-s", scope]),
|
|
2551
|
+
"install the Fling plugin"
|
|
2552
|
+
);
|
|
2553
|
+
}
|
|
2554
|
+
async function updatePlugin(run, scope) {
|
|
2555
|
+
check(await run(["plugin", "marketplace", "update", MARKETPLACE_NAME]), "update the marketplace");
|
|
2556
|
+
check(
|
|
2557
|
+
await run(["plugin", "update", PLUGIN_ID, ...scope ? ["-s", scope] : []]),
|
|
2558
|
+
"update the Fling plugin"
|
|
2559
|
+
);
|
|
2560
|
+
}
|
|
2561
|
+
function check(result, what) {
|
|
2562
|
+
if (result.code === 0) return;
|
|
2563
|
+
const detail = (result.stderr || result.stdout).trim().split("\n").at(-1);
|
|
2564
|
+
throw new Error(`Could not ${what}${detail ? `: ${detail}` : ""}`);
|
|
2565
|
+
}
|
|
2566
|
+
async function findClaudeDesktop(platform = process.platform, env = process.env, exists = (path) => access(path).then(
|
|
2567
|
+
() => true,
|
|
2568
|
+
() => false
|
|
2569
|
+
)) {
|
|
2570
|
+
const candidates = platform === "darwin" ? ["/Applications/Claude.app", join5(homedir3(), "Applications", "Claude.app")] : platform === "win32" && env.LOCALAPPDATA ? [join5(env.LOCALAPPDATA, "AnthropicClaude"), join5(env.LOCALAPPDATA, "Programs", "Claude")] : [];
|
|
2571
|
+
for (const path of candidates) if (await exists(path)) return path;
|
|
2572
|
+
return null;
|
|
2573
|
+
}
|
|
2574
|
+
async function downloadExtension(serverUrl, fetch2) {
|
|
2575
|
+
const url = new URL(MCPB_PATH, serverUrl).toString();
|
|
2576
|
+
const response = await fetch2(url);
|
|
2577
|
+
if (!response.ok) throw new Error(`Could not download ${url} (HTTP ${response.status})`);
|
|
2578
|
+
const dir = await mkdtemp2(join5(tmpdir2(), "fling-mcpb-"));
|
|
2579
|
+
const file = join5(dir, "fling.mcpb");
|
|
2580
|
+
await writeFile3(file, Buffer.from(await response.arrayBuffer()));
|
|
2581
|
+
return file;
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
// src/commands/setup.ts
|
|
2585
|
+
var SCOPES = ["user", "project", "local"];
|
|
2586
|
+
async function runSetup(baseCtx, opts, deps = {}) {
|
|
2587
|
+
const scope = opts.scope ?? "user";
|
|
2588
|
+
if (!SCOPES.includes(scope))
|
|
2589
|
+
throw new CliError(`--scope must be one of ${SCOPES.join(", ")}, not "${opts.scope}"`);
|
|
2590
|
+
const run = deps.runClaude ?? runClaude;
|
|
2591
|
+
const ask = baseCtx.interactive && !opts.yes;
|
|
2592
|
+
const fancy = baseCtx.interactive && !baseCtx.json;
|
|
2593
|
+
const ctx = fancy ? {
|
|
2594
|
+
...baseCtx,
|
|
2595
|
+
log: createLogger({
|
|
2596
|
+
debug: baseCtx.config.debug,
|
|
2597
|
+
write: (line) => process.stderr.write(`${chalk7.gray("\u2502")} ${line}
|
|
2598
|
+
`)
|
|
2599
|
+
})
|
|
2600
|
+
} : baseCtx;
|
|
2601
|
+
if (fancy) {
|
|
2602
|
+
process.stderr.write(`${logo()}
|
|
2603
|
+
`);
|
|
2604
|
+
intro(chalk7.bold(" fling setup "), { output: process.stderr });
|
|
2605
|
+
}
|
|
2606
|
+
const results = {
|
|
2607
|
+
login: await stepLogin(ctx),
|
|
2608
|
+
claudeCode: "skipped",
|
|
2609
|
+
claudeDesktop: "skipped"
|
|
2610
|
+
};
|
|
2611
|
+
results.claudeCode = await stepClaudeCode(ctx, run, scope, ask, fancy);
|
|
2612
|
+
results.claudeDesktop = await stepClaudeDesktop(ctx, deps, ask, fancy);
|
|
2613
|
+
const failed = Object.values(results).includes("failed");
|
|
2614
|
+
if (fancy)
|
|
2615
|
+
outro(
|
|
2616
|
+
failed ? chalk7.yellow("Setup finished with problems \u2014 see above, then run `fling setup` again.") : `You're set. Try ${chalk7.bold(`${ctx.binName} deploy ./dist`)}, or ask Claude to \u201Cfling this\u201D.`,
|
|
2617
|
+
{ output: process.stderr }
|
|
2618
|
+
);
|
|
2619
|
+
ctx.out.data(results, () => "");
|
|
2620
|
+
if (failed) process.exitCode = 1;
|
|
2621
|
+
}
|
|
2622
|
+
async function stepLogin(ctx) {
|
|
2623
|
+
if (ctx.config.apiKey) {
|
|
2624
|
+
try {
|
|
2625
|
+
const me = await whoami(requireClient(ctx));
|
|
2626
|
+
ctx.log.success(`Signed in as ${chalk7.bold(me.name)} ${chalk7.dim(`<${me.email}>`)}`);
|
|
2627
|
+
return "already";
|
|
2628
|
+
} catch (error) {
|
|
2629
|
+
if (exitCodeFor(error) !== 2) {
|
|
2630
|
+
ctx.log.error(error instanceof Error ? error.message : String(error));
|
|
2631
|
+
return "failed";
|
|
2632
|
+
}
|
|
2633
|
+
ctx.log.warn("The stored API key no longer works \u2014 signing in again");
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
try {
|
|
2637
|
+
await runLogin({ ...ctx, json: false });
|
|
2638
|
+
return "done";
|
|
2639
|
+
} catch (error) {
|
|
2640
|
+
ctx.log.error(error instanceof Error ? error.message : String(error));
|
|
2641
|
+
return "failed";
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
async function stepClaudeCode(ctx, run, scope, ask, fancy) {
|
|
2645
|
+
const status = await claudeCodeStatus(run);
|
|
2646
|
+
if (status.claude === "missing") {
|
|
2647
|
+
ctx.log.info(`Claude Code not found ${chalk7.dim("(no `claude` on your PATH) \u2014 skipped")}`);
|
|
2648
|
+
ctx.log.hint(
|
|
2649
|
+
`Once it is installed, run \`${ctx.binName} setup\` again, or in Claude Code: /plugin marketplace add ${MARKETPLACE_SOURCE} then /plugin install ${PLUGIN_ID}`
|
|
2650
|
+
);
|
|
2651
|
+
return "missing";
|
|
2652
|
+
}
|
|
2653
|
+
const installed = "installed" in status && status.installed;
|
|
2654
|
+
const question = installed ? `The Fling plugin is installed in Claude Code${status.version ? ` (${status.version})` : ""}. Update it to the latest?` : "Install the Fling plugin (skill + MCP server) into Claude Code?";
|
|
2655
|
+
if (ask && !await ctx.confirm(question)) {
|
|
2656
|
+
ctx.log.info(chalk7.dim("Claude Code plugin: skipped"));
|
|
2657
|
+
return installed ? "already" : "skipped";
|
|
2658
|
+
}
|
|
2659
|
+
const s = fancy ? spinner({ output: process.stderr }) : null;
|
|
2660
|
+
s?.start(installed ? "Updating the Claude Code plugin" : "Installing the Claude Code plugin");
|
|
2661
|
+
try {
|
|
2662
|
+
if (installed) await updatePlugin(run, status.scope);
|
|
2663
|
+
else await installPlugin(run, scope);
|
|
2664
|
+
} catch (error) {
|
|
2665
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2666
|
+
if (s) s.error(message);
|
|
2667
|
+
else ctx.log.error(message);
|
|
2668
|
+
return "failed";
|
|
2669
|
+
}
|
|
2670
|
+
const done = installed ? `Claude Code plugin ${PLUGIN_NAME} is up to date` : `Claude Code plugin ${PLUGIN_NAME} installed ${chalk7.dim(`(${scope} scope)`)}`;
|
|
2671
|
+
if (s) s.stop(done);
|
|
2672
|
+
else ctx.log.success(done);
|
|
2673
|
+
ctx.log.hint("Restart Claude Code (or run /reload-plugins) to pick it up.");
|
|
2674
|
+
return installed ? "updated" : "done";
|
|
2675
|
+
}
|
|
2676
|
+
async function stepClaudeDesktop(ctx, deps, ask, fancy) {
|
|
2677
|
+
const app = await (deps.findClaudeDesktop ?? findClaudeDesktop)();
|
|
2678
|
+
if (!app) return "missing";
|
|
2679
|
+
if (ask && !await ctx.confirm("Add the Fling extension to Claude Desktop too?")) {
|
|
2680
|
+
ctx.log.info(chalk7.dim("Claude Desktop extension: skipped"));
|
|
2681
|
+
return "skipped";
|
|
2682
|
+
}
|
|
2683
|
+
const s = fancy ? spinner({ output: process.stderr }) : null;
|
|
2684
|
+
s?.start("Downloading the Claude Desktop extension");
|
|
2685
|
+
try {
|
|
2686
|
+
const file = await (deps.downloadExtension ?? ((serverUrl) => downloadExtension(serverUrl, ctx.fetch)))(ctx.config.serverUrl);
|
|
2687
|
+
await ctx.open(file);
|
|
2688
|
+
const done = "Opened fling.mcpb in Claude Desktop \u2014 click Install there to finish";
|
|
2689
|
+
if (s) s.stop(done);
|
|
2690
|
+
else ctx.log.success(done);
|
|
2691
|
+
return "done";
|
|
2692
|
+
} catch (error) {
|
|
2693
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2694
|
+
if (s) s.error(message);
|
|
2695
|
+
else ctx.log.error(message);
|
|
2696
|
+
return "failed";
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
// src/commands/sites.ts
|
|
2701
|
+
import chalk8 from "chalk";
|
|
2702
|
+
async function runList(ctx, opts) {
|
|
2703
|
+
const sites = await listSites(requireClient(ctx), opts);
|
|
2704
|
+
ctx.out.data(
|
|
2705
|
+
{ sites },
|
|
2706
|
+
() => renderTable(sites, [
|
|
2707
|
+
{ header: "NAME", value: (s) => s.name },
|
|
2708
|
+
{ header: "URL", value: (s) => s.url },
|
|
2709
|
+
{
|
|
2710
|
+
header: "VISIBILITY",
|
|
2711
|
+
value: (s) => s.archived ? `${s.visibility} (archived)` : s.visibility
|
|
2712
|
+
},
|
|
2713
|
+
{ header: "SHARE", value: (s) => s.shareUrl },
|
|
2714
|
+
{ header: "UPDATED", value: (s) => formatDate(s.updatedAt) }
|
|
2715
|
+
])
|
|
2716
|
+
);
|
|
2717
|
+
}
|
|
2718
|
+
function describeSite(site) {
|
|
2719
|
+
const row = (label, value) => `${chalk8.bold(`${label}:`.padEnd(12))}${value}`;
|
|
2720
|
+
return [
|
|
2721
|
+
row("Name", site.name),
|
|
2722
|
+
row("URL", site.url),
|
|
2723
|
+
row("Visibility", site.visibility + (site.archived ? chalk8.yellow(" (archived)") : "")),
|
|
2724
|
+
row("SPA", site.spa ? "yes" : "no"),
|
|
2725
|
+
row("Share link", site.shareUrl ?? "-"),
|
|
2726
|
+
row("Version", site.currentVersionId ?? "-"),
|
|
2727
|
+
...filesRow(site, row),
|
|
2728
|
+
row("Created", formatDate(site.createdAt)),
|
|
2729
|
+
row("Updated", formatDate(site.updatedAt))
|
|
2730
|
+
];
|
|
2731
|
+
}
|
|
2732
|
+
function filesRow(site, row) {
|
|
2733
|
+
const current = site.versions?.find((v) => v.id === site.currentVersionId);
|
|
2734
|
+
if (!current) return [];
|
|
2735
|
+
const n = current.fileCount;
|
|
2736
|
+
return [row("Files", `${n} file${n === 1 ? "" : "s"}, ${formatBytes(current.totalBytes)}`)];
|
|
2737
|
+
}
|
|
2738
|
+
async function runInfo(ctx, name) {
|
|
2739
|
+
const site = await getSite(requireClient(ctx), name);
|
|
2740
|
+
ctx.out.data(
|
|
2741
|
+
site,
|
|
2742
|
+
() => [
|
|
2743
|
+
...describeSite(site),
|
|
2744
|
+
"",
|
|
2745
|
+
chalk8.dim(
|
|
2746
|
+
`${site.versions.length} version(s) \u2014 \`fling versions ${name}\`; files \u2014 \`fling files ${name}\``
|
|
2747
|
+
)
|
|
2748
|
+
].join("\n")
|
|
2749
|
+
);
|
|
2750
|
+
}
|
|
2751
|
+
async function runVersions(ctx, name) {
|
|
2752
|
+
const site = await getSite(requireClient(ctx), name);
|
|
2753
|
+
ctx.out.data(
|
|
2754
|
+
{ name: site.name, currentVersionId: site.currentVersionId, versions: site.versions },
|
|
2755
|
+
() => renderTable(site.versions, [
|
|
2756
|
+
{ header: "", value: (v) => v.id === site.currentVersionId ? "*" : " " },
|
|
2757
|
+
{ header: "VERSION", value: (v) => v.id },
|
|
2758
|
+
{ header: "FILES", value: (v) => v.fileCount },
|
|
2759
|
+
{ header: "SIZE", value: (v) => formatBytes(v.totalBytes) },
|
|
2760
|
+
{ header: "CREATED", value: (v) => formatDate(v.createdAt) }
|
|
2761
|
+
])
|
|
2762
|
+
);
|
|
2763
|
+
}
|
|
2764
|
+
async function runFiles(ctx, name, versionId) {
|
|
2765
|
+
const result = await versionFiles(requireClient(ctx), name, versionId);
|
|
2766
|
+
if (!result.files.some((f) => f.path === "index.html")) {
|
|
2767
|
+
ctx.log.warn("No index.html at the root \u2014 the site root URL will 404");
|
|
2768
|
+
}
|
|
2769
|
+
ctx.out.data(result, () => {
|
|
2770
|
+
const n = result.files.length;
|
|
2771
|
+
const header = chalk8.dim(
|
|
2772
|
+
`${result.versionId}${result.current ? " (current)" : " (not served)"} \u2014 ${n} file${n === 1 ? "" : "s"}, ${formatBytes(result.totalBytes)}${result.generatedIndex ? ", generated index.html" : ""}`
|
|
2773
|
+
);
|
|
2774
|
+
const table = renderTable(result.files, [
|
|
2775
|
+
{ header: "PATH", value: (f) => f.path },
|
|
2776
|
+
{ header: "SIZE", value: (f) => formatBytes(f.size) },
|
|
2777
|
+
{ header: "TYPE", value: (f) => f.contentType }
|
|
2778
|
+
]);
|
|
2779
|
+
return `${header}
|
|
2780
|
+
${table}`;
|
|
2781
|
+
});
|
|
2782
|
+
}
|
|
2783
|
+
async function runRollback(ctx, name, versionId) {
|
|
2784
|
+
const site = await rollback(requireClient(ctx), name, versionId);
|
|
2785
|
+
ctx.log.success(`${name} now serves version ${site.currentVersionId ?? versionId}`);
|
|
2786
|
+
ctx.out.data(site, () => site.url);
|
|
2787
|
+
}
|
|
2788
|
+
async function runShare(ctx, name, opts) {
|
|
2789
|
+
if (opts.refresh && opts.revoke) throw new CliError("Use either --refresh or --revoke, not both");
|
|
2790
|
+
const client = requireClient(ctx);
|
|
2791
|
+
const result = await share(client, name, opts);
|
|
2792
|
+
let ready;
|
|
2793
|
+
if (result.action === "created" || result.action === "refreshed") {
|
|
2794
|
+
const files = ctx.waitReady ? await versionFiles(client, name).catch(() => null) : null;
|
|
2795
|
+
ready = (await waitWithSpinner(ctx.waitReady, result.shareUrl, files?.files.map((f) => f.path) ?? [], {
|
|
2796
|
+
progress: ctx.progress,
|
|
2797
|
+
warn: (m) => ctx.log.warn(m)
|
|
2798
|
+
})).ready;
|
|
2799
|
+
}
|
|
2800
|
+
if (result.action === "revoked") ctx.log.success(`Share link for ${name} revoked`);
|
|
2801
|
+
else if (result.action === "refreshed")
|
|
2802
|
+
ctx.log.success("New share link \u2014 the old one no longer works");
|
|
2803
|
+
else if (result.action === "created")
|
|
2804
|
+
ctx.log.success("Share link created \u2014 anyone with it can view the site");
|
|
2805
|
+
ctx.out.data(
|
|
2806
|
+
ready === void 0 ? result : { ...result, ready },
|
|
2807
|
+
() => result.shareUrl ? result.shareUrl : chalk8.dim("(no share link)")
|
|
2808
|
+
);
|
|
2809
|
+
}
|
|
2810
|
+
async function runVisibility(ctx, name, visibility) {
|
|
2811
|
+
if (visibility !== "private" && visibility !== "public") {
|
|
2812
|
+
throw new CliError(`Visibility must be "private" or "public", not "${visibility}"`);
|
|
2813
|
+
}
|
|
2814
|
+
const site = await setVisibility(requireClient(ctx), name, visibility);
|
|
2815
|
+
ctx.log.success(
|
|
2816
|
+
site.visibility === "public" ? `${name} is public \u2014 no sign-in needed` : `${name} is private \u2014 Nearform sign-in required`
|
|
2817
|
+
);
|
|
2818
|
+
ctx.out.data(site, () => site.url);
|
|
2819
|
+
}
|
|
2820
|
+
async function runArchive(ctx, name, unarchive = false) {
|
|
2821
|
+
const site = await archiveSite(requireClient(ctx), name, { unarchive });
|
|
2822
|
+
ctx.log.success(
|
|
2823
|
+
unarchive ? `${name} restored` : `${name} archived \u2014 it 404s until \`fling unarchive ${name}\``
|
|
2824
|
+
);
|
|
2825
|
+
if (ctx.json) ctx.out.data(site, () => "");
|
|
2826
|
+
else if (unarchive) ctx.out.text(site.url);
|
|
2827
|
+
}
|
|
2828
|
+
async function runRemove(ctx, name, opts) {
|
|
2829
|
+
if (!opts.yes) {
|
|
2830
|
+
if (!ctx.interactive) {
|
|
2831
|
+
throw new CliError(`Refusing to delete ${name} without confirmation`, {
|
|
2832
|
+
hint: "Pass --yes to delete."
|
|
2833
|
+
});
|
|
2834
|
+
}
|
|
2835
|
+
const ok = await ctx.confirm(`Delete ${name} and every version of it? This cannot be undone.`);
|
|
2836
|
+
if (!ok) throw new CliError("Cancelled");
|
|
2837
|
+
}
|
|
2838
|
+
const result = await deleteSite(requireClient(ctx), name);
|
|
2839
|
+
ctx.log.success(`${name} deleted`);
|
|
2840
|
+
if (ctx.json) ctx.out.data(result, () => "");
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
// src/mcp.ts
|
|
2844
|
+
import { mkdir as mkdir3, mkdtemp as mkdtemp3, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
|
|
2845
|
+
import { tmpdir as tmpdir3 } from "node:os";
|
|
2846
|
+
import { basename as basename3, dirname as dirname2, join as join6 } from "node:path";
|
|
2847
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2848
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2849
|
+
import { z as z7 } from "zod";
|
|
2850
|
+
|
|
2851
|
+
// src/locate.ts
|
|
2852
|
+
import { lstat, readdir as readdir2, readFile as readFile6, realpath as realpath3, stat as stat5 } from "node:fs/promises";
|
|
2853
|
+
import { posix, win32 } from "node:path";
|
|
2854
|
+
import { fileURLToPath } from "node:url";
|
|
2855
|
+
var DOWNLOADS_DIR_ENV = "FLING_DOWNLOADS_DIR";
|
|
2856
|
+
var FOUND_AS_GIVEN = "path";
|
|
2857
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "__MACOSX", "Library", "AppData", "$RECYCLE.BIN"]);
|
|
2858
|
+
var SESSION_SKIP_DIRS = /* @__PURE__ */ new Set(["skills-plugin", "rpm", "uploads", "memory"]);
|
|
2859
|
+
var SESSIONS_DEPTH = 4;
|
|
2860
|
+
var OUTPUTS_DEPTH = 4;
|
|
2861
|
+
var SANDBOX_PATH_RE = /^\/(mnt\/user-data|mnt\/data|sessions|home\/claude|tmp\/outputs)(\/|$)/;
|
|
2862
|
+
var nodeFs = {
|
|
2863
|
+
async stat(path) {
|
|
2864
|
+
try {
|
|
2865
|
+
const s = await stat5(path);
|
|
2866
|
+
return { isFile: s.isFile(), isDirectory: s.isDirectory(), mtimeMs: s.mtimeMs, size: s.size };
|
|
2867
|
+
} catch {
|
|
2868
|
+
return null;
|
|
2869
|
+
}
|
|
2870
|
+
},
|
|
2871
|
+
async readdir(path) {
|
|
2872
|
+
try {
|
|
2873
|
+
const entries = await readdir2(path, { withFileTypes: true });
|
|
2874
|
+
const out = [];
|
|
2875
|
+
for (const e of entries) {
|
|
2876
|
+
if (e.isSymbolicLink()) {
|
|
2877
|
+
const s = await stat5(`${path}/${e.name}`).catch(() => null);
|
|
2878
|
+
out.push({ name: e.name, isFile: Boolean(s?.isFile()), isDirectory: false });
|
|
2879
|
+
} else out.push({ name: e.name, isFile: e.isFile(), isDirectory: e.isDirectory() });
|
|
2880
|
+
}
|
|
2881
|
+
return out;
|
|
2882
|
+
} catch {
|
|
2883
|
+
return [];
|
|
2884
|
+
}
|
|
2885
|
+
},
|
|
2886
|
+
async realpath(path) {
|
|
2887
|
+
return realpath3(path).catch(() => null);
|
|
2888
|
+
},
|
|
2889
|
+
async readText(path) {
|
|
2890
|
+
try {
|
|
2891
|
+
if (!(await lstat(path)).isFile()) return null;
|
|
2892
|
+
return await readFile6(path, "utf8");
|
|
2893
|
+
} catch {
|
|
2894
|
+
return null;
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
};
|
|
2898
|
+
var pathFor = (platform) => platform === "win32" ? win32 : posix;
|
|
2899
|
+
var usable = (value) => Boolean(value?.trim()) && !value?.trim().startsWith("${");
|
|
2900
|
+
async function xdgUserDir(key, opts, fs) {
|
|
2901
|
+
const fromEnv2 = opts.env[`XDG_${key}_DIR`];
|
|
2902
|
+
if (usable(fromEnv2)) return fromEnv2;
|
|
2903
|
+
const configHome = opts.env.XDG_CONFIG_HOME || posix.join(opts.home, ".config");
|
|
2904
|
+
const text2 = await fs.readText(posix.join(configHome, "user-dirs.dirs"));
|
|
2905
|
+
const match = text2?.match(new RegExp(`^XDG_${key}_DIR="([^"]*)"`, "m"));
|
|
2906
|
+
if (!match?.[1]) return void 0;
|
|
2907
|
+
return match[1].replace(/^\$HOME(?=\/|$)/, opts.home);
|
|
1950
2908
|
}
|
|
1951
2909
|
async function searchRoots(opts) {
|
|
1952
2910
|
const fs = opts.fs ?? nodeFs;
|
|
@@ -2178,286 +3136,7 @@ async function findFiles(opts, find = {}) {
|
|
|
2178
3136
|
return { files, searched: roots, complete };
|
|
2179
3137
|
}
|
|
2180
3138
|
|
|
2181
|
-
// src/commands/deploy.ts
|
|
2182
|
-
async function runDeploy(ctx, input, opts) {
|
|
2183
|
-
const client = requireClient(ctx);
|
|
2184
|
-
const ask = ctx.interactive && !opts.yes;
|
|
2185
|
-
const base = ask ? await discoverSitesBase(client) : sitesBaseFor(client.serverUrl);
|
|
2186
|
-
const located = await resolveInput(input ?? ".", ctx.locate ?? systemLocateOptions());
|
|
2187
|
-
if (located.foundIn !== FOUND_AS_GIVEN) {
|
|
2188
|
-
ctx.log.info(`Found in ${located.foundIn}: ${located.path}`);
|
|
2189
|
-
if (located.alternatives.length)
|
|
2190
|
-
ctx.log.hint(
|
|
2191
|
-
`Also found (older): ${located.alternatives.map((a) => a.path).join(", ")} \u2014 pass the full path to fling another.`
|
|
2192
|
-
);
|
|
2193
|
-
}
|
|
2194
|
-
const prepared = await prepareInput(located.path);
|
|
2195
|
-
try {
|
|
2196
|
-
await deployPrepared(ctx, client, base, prepared, opts, ask);
|
|
2197
|
-
} finally {
|
|
2198
|
-
await prepared.cleanup();
|
|
2199
|
-
}
|
|
2200
|
-
}
|
|
2201
|
-
async function deployPrepared(ctx, client, base, prepared, opts, ask) {
|
|
2202
|
-
let local = await buildManifest(prepared.dir);
|
|
2203
|
-
let name = opts.name !== void 0 ? resolveSiteName(prepared.dir, opts.name) : prepared.defaultName;
|
|
2204
|
-
if (opts.name === void 0 && ask) name = await confirmSiteName(ctx, client, name, base);
|
|
2205
|
-
if (!hasRootIndex(local)) {
|
|
2206
|
-
const pages = htmlPages(local).length;
|
|
2207
|
-
const root = base ? siteUrlFor(name, base) : "the site root";
|
|
2208
|
-
const warning = `No index.html at the root \u2014 ${root} will show a 404.`;
|
|
2209
|
-
const choice = opts.listHtml === true ? "list" : opts.listHtml === false ? "fling" : ask ? await askNoIndex(ctx, pages) : pages > 0 ? "list" : "fling";
|
|
2210
|
-
if (choice === "list") {
|
|
2211
|
-
local = await withHtmlListing(local, name);
|
|
2212
|
-
ctx.log.info(`Generated index.html listing ${pages} HTML page${pages === 1 ? "" : "s"}`);
|
|
2213
|
-
} else ctx.log.warn(warning);
|
|
2214
|
-
}
|
|
2215
|
-
let progressShown = false;
|
|
2216
|
-
const result = await deploy(
|
|
2217
|
-
{ client, fetch: ctx.fetch },
|
|
2218
|
-
{
|
|
2219
|
-
dir: prepared.dir,
|
|
2220
|
-
name,
|
|
2221
|
-
manifest: local,
|
|
2222
|
-
spa: opts.spa ? true : void 0,
|
|
2223
|
-
public: opts.public,
|
|
2224
|
-
onProgress: (p) => {
|
|
2225
|
-
if (p.total === 0) return;
|
|
2226
|
-
progressShown = true;
|
|
2227
|
-
ctx.progress(
|
|
2228
|
-
`${chalk5.cyan("fling")} uploading ${p.uploaded}/${p.total} files \xB7 ${formatBytes(p.bytes)}/${formatBytes(p.totalBytes)}`
|
|
2229
|
-
);
|
|
2230
|
-
}
|
|
2231
|
-
}
|
|
2232
|
-
).finally(() => {
|
|
2233
|
-
if (progressShown) ctx.progress(null);
|
|
2234
|
-
});
|
|
2235
|
-
for (const warning of result.warnings) if (warning !== NO_INDEX_WARNING) ctx.log.warn(warning);
|
|
2236
|
-
const summary = `${result.fileCount} files, ${formatBytes(result.totalBytes)}`;
|
|
2237
|
-
if (result.status === "unchanged") ctx.log.info(`Unchanged ${chalk5.dim(`(${summary})`)}`);
|
|
2238
|
-
else if (result.status === "repointed")
|
|
2239
|
-
ctx.log.success(`Restored version ${result.versionId} ${chalk5.dim(`(${summary})`)}`);
|
|
2240
|
-
else
|
|
2241
|
-
ctx.log.success(
|
|
2242
|
-
`Deployed ${result.name} ${chalk5.dim(`(${summary}; uploaded ${result.uploaded}, version ${result.versionId})`)}`
|
|
2243
|
-
);
|
|
2244
|
-
const ready = result.status === "unchanged" ? true : (await waitWithSpinner(
|
|
2245
|
-
ctx.waitReady,
|
|
2246
|
-
result.url,
|
|
2247
|
-
local.files.map((f) => f.path),
|
|
2248
|
-
{ progress: ctx.progress, warn: (m) => ctx.log.warn(m) }
|
|
2249
|
-
)).ready;
|
|
2250
|
-
ctx.out.data({ status: result.status, site: result.site, ready }, () => {
|
|
2251
|
-
const lines = [chalk5.bold(result.url)];
|
|
2252
|
-
if (result.site.visibility === "private")
|
|
2253
|
-
lines.push(
|
|
2254
|
-
chalk5.dim(
|
|
2255
|
-
`Private: Nearform sign-in required. Share publicly with \`${ctx.binName} share ${result.name}\`.`
|
|
2256
|
-
)
|
|
2257
|
-
);
|
|
2258
|
-
return lines.join("\n");
|
|
2259
|
-
});
|
|
2260
|
-
}
|
|
2261
|
-
async function askNoIndex(ctx, pages) {
|
|
2262
|
-
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 ";
|
|
2263
|
-
for (; ; ) {
|
|
2264
|
-
const answer = (await ctx.prompt(question)).trim().toLowerCase();
|
|
2265
|
-
if (pages > 0 && (answer === "" || answer === "l" || answer === "list")) return "list";
|
|
2266
|
-
if (answer === "f" || answer === "fling") return "fling";
|
|
2267
|
-
if (answer === "a" || answer === "abort" || answer === "n" || answer === "no" || answer === "")
|
|
2268
|
-
throw new CliError("Not flung: no index.html at the root", {
|
|
2269
|
-
hint: "Fling the folder that contains index.html (your build output, e.g. ./dist), or pass --list-html / --no-list-html."
|
|
2270
|
-
});
|
|
2271
|
-
}
|
|
2272
|
-
}
|
|
2273
|
-
async function confirmSiteName(ctx, client, generated, base) {
|
|
2274
|
-
const where = (n) => base ? ` \u2192 ${siteUrlFor(n, base)}` : "";
|
|
2275
|
-
const describe = (n, note) => ctx.log.info(`Site name: ${chalk5.bold(n)}${where(n)} ${chalk5.dim(`(${note})`)}`);
|
|
2276
|
-
let candidate = generated;
|
|
2277
|
-
for (; ; ) {
|
|
2278
|
-
const check = await checkName(client, candidate);
|
|
2279
|
-
let proposal;
|
|
2280
|
-
if (check.status === "available" || check.status === "yours") {
|
|
2281
|
-
proposal = candidate;
|
|
2282
|
-
describe(candidate, check.status === "yours" ? "updates your existing site" : "new");
|
|
2283
|
-
} else {
|
|
2284
|
-
const why = check.status === "taken" ? "is taken" : "is not a valid site name";
|
|
2285
|
-
const alternatives = check.suggestions.length ? ` Suggestions: ${check.suggestions.join(", ")}` : "";
|
|
2286
|
-
ctx.log.warn(`${candidate} ${why}.${alternatives}`);
|
|
2287
|
-
proposal = check.suggestions[0];
|
|
2288
|
-
if (proposal) describe(proposal, "new");
|
|
2289
|
-
}
|
|
2290
|
-
for (; ; ) {
|
|
2291
|
-
const question = proposal ? `Use "${proposal}"? [Y/n or type another name] ` : "Type a site name (or n to abort): ";
|
|
2292
|
-
const answer = (await ctx.prompt(question)).trim();
|
|
2293
|
-
if (proposal && (answer === "" || /^y(es)?$/i.test(answer))) return proposal;
|
|
2294
|
-
if (/^no?$/i.test(answer))
|
|
2295
|
-
throw new CliError("Aborted \u2014 nothing was flung", {
|
|
2296
|
-
hint: "Pass --name <name> to choose the site name up front."
|
|
2297
|
-
});
|
|
2298
|
-
if (answer === "") continue;
|
|
2299
|
-
if (!isValidSiteName(answer)) {
|
|
2300
|
-
ctx.log.warn(
|
|
2301
|
-
`"${answer}" is not a valid site name: use lowercase letters, digits and single hyphens (max ${SITE_NAME_MAX}); some names are reserved.`
|
|
2302
|
-
);
|
|
2303
|
-
continue;
|
|
2304
|
-
}
|
|
2305
|
-
candidate = answer;
|
|
2306
|
-
break;
|
|
2307
|
-
}
|
|
2308
|
-
}
|
|
2309
|
-
}
|
|
2310
|
-
|
|
2311
|
-
// src/commands/sites.ts
|
|
2312
|
-
import chalk6 from "chalk";
|
|
2313
|
-
async function runList(ctx, opts) {
|
|
2314
|
-
const sites = await listSites(requireClient(ctx), opts);
|
|
2315
|
-
ctx.out.data(
|
|
2316
|
-
{ sites },
|
|
2317
|
-
() => renderTable(sites, [
|
|
2318
|
-
{ header: "NAME", value: (s) => s.name },
|
|
2319
|
-
{ header: "URL", value: (s) => s.url },
|
|
2320
|
-
{
|
|
2321
|
-
header: "VISIBILITY",
|
|
2322
|
-
value: (s) => s.archived ? `${s.visibility} (archived)` : s.visibility
|
|
2323
|
-
},
|
|
2324
|
-
{ header: "SHARE", value: (s) => s.shareUrl },
|
|
2325
|
-
{ header: "UPDATED", value: (s) => formatDate(s.updatedAt) }
|
|
2326
|
-
])
|
|
2327
|
-
);
|
|
2328
|
-
}
|
|
2329
|
-
function describeSite(site) {
|
|
2330
|
-
const row = (label, value) => `${chalk6.bold(`${label}:`.padEnd(12))}${value}`;
|
|
2331
|
-
return [
|
|
2332
|
-
row("Name", site.name),
|
|
2333
|
-
row("URL", site.url),
|
|
2334
|
-
row("Visibility", site.visibility + (site.archived ? chalk6.yellow(" (archived)") : "")),
|
|
2335
|
-
row("SPA", site.spa ? "yes" : "no"),
|
|
2336
|
-
row("Share link", site.shareUrl ?? "-"),
|
|
2337
|
-
row("Version", site.currentVersionId ?? "-"),
|
|
2338
|
-
...filesRow(site, row),
|
|
2339
|
-
row("Created", formatDate(site.createdAt)),
|
|
2340
|
-
row("Updated", formatDate(site.updatedAt))
|
|
2341
|
-
];
|
|
2342
|
-
}
|
|
2343
|
-
function filesRow(site, row) {
|
|
2344
|
-
const current = site.versions?.find((v) => v.id === site.currentVersionId);
|
|
2345
|
-
if (!current) return [];
|
|
2346
|
-
const n = current.fileCount;
|
|
2347
|
-
return [row("Files", `${n} file${n === 1 ? "" : "s"}, ${formatBytes(current.totalBytes)}`)];
|
|
2348
|
-
}
|
|
2349
|
-
async function runInfo(ctx, name) {
|
|
2350
|
-
const site = await getSite(requireClient(ctx), name);
|
|
2351
|
-
ctx.out.data(
|
|
2352
|
-
site,
|
|
2353
|
-
() => [
|
|
2354
|
-
...describeSite(site),
|
|
2355
|
-
"",
|
|
2356
|
-
chalk6.dim(
|
|
2357
|
-
`${site.versions.length} version(s) \u2014 \`fling versions ${name}\`; files \u2014 \`fling files ${name}\``
|
|
2358
|
-
)
|
|
2359
|
-
].join("\n")
|
|
2360
|
-
);
|
|
2361
|
-
}
|
|
2362
|
-
async function runVersions(ctx, name) {
|
|
2363
|
-
const site = await getSite(requireClient(ctx), name);
|
|
2364
|
-
ctx.out.data(
|
|
2365
|
-
{ name: site.name, currentVersionId: site.currentVersionId, versions: site.versions },
|
|
2366
|
-
() => renderTable(site.versions, [
|
|
2367
|
-
{ header: "", value: (v) => v.id === site.currentVersionId ? "*" : " " },
|
|
2368
|
-
{ header: "VERSION", value: (v) => v.id },
|
|
2369
|
-
{ header: "FILES", value: (v) => v.fileCount },
|
|
2370
|
-
{ header: "SIZE", value: (v) => formatBytes(v.totalBytes) },
|
|
2371
|
-
{ header: "CREATED", value: (v) => formatDate(v.createdAt) }
|
|
2372
|
-
])
|
|
2373
|
-
);
|
|
2374
|
-
}
|
|
2375
|
-
async function runFiles(ctx, name, versionId) {
|
|
2376
|
-
const result = await versionFiles(requireClient(ctx), name, versionId);
|
|
2377
|
-
if (!result.files.some((f) => f.path === "index.html")) {
|
|
2378
|
-
ctx.log.warn("No index.html at the root \u2014 the site root URL will 404");
|
|
2379
|
-
}
|
|
2380
|
-
ctx.out.data(result, () => {
|
|
2381
|
-
const n = result.files.length;
|
|
2382
|
-
const header = chalk6.dim(
|
|
2383
|
-
`${result.versionId}${result.current ? " (current)" : " (not served)"} \u2014 ${n} file${n === 1 ? "" : "s"}, ${formatBytes(result.totalBytes)}${result.generatedIndex ? ", generated index.html" : ""}`
|
|
2384
|
-
);
|
|
2385
|
-
const table = renderTable(result.files, [
|
|
2386
|
-
{ header: "PATH", value: (f) => f.path },
|
|
2387
|
-
{ header: "SIZE", value: (f) => formatBytes(f.size) },
|
|
2388
|
-
{ header: "TYPE", value: (f) => f.contentType }
|
|
2389
|
-
]);
|
|
2390
|
-
return `${header}
|
|
2391
|
-
${table}`;
|
|
2392
|
-
});
|
|
2393
|
-
}
|
|
2394
|
-
async function runRollback(ctx, name, versionId) {
|
|
2395
|
-
const site = await rollback(requireClient(ctx), name, versionId);
|
|
2396
|
-
ctx.log.success(`${name} now serves version ${site.currentVersionId ?? versionId}`);
|
|
2397
|
-
ctx.out.data(site, () => site.url);
|
|
2398
|
-
}
|
|
2399
|
-
async function runShare(ctx, name, opts) {
|
|
2400
|
-
if (opts.refresh && opts.revoke) throw new CliError("Use either --refresh or --revoke, not both");
|
|
2401
|
-
const client = requireClient(ctx);
|
|
2402
|
-
const result = await share(client, name, opts);
|
|
2403
|
-
let ready;
|
|
2404
|
-
if (result.action === "created" || result.action === "refreshed") {
|
|
2405
|
-
const files = ctx.waitReady ? await versionFiles(client, name).catch(() => null) : null;
|
|
2406
|
-
ready = (await waitWithSpinner(ctx.waitReady, result.shareUrl, files?.files.map((f) => f.path) ?? [], {
|
|
2407
|
-
progress: ctx.progress,
|
|
2408
|
-
warn: (m) => ctx.log.warn(m)
|
|
2409
|
-
})).ready;
|
|
2410
|
-
}
|
|
2411
|
-
if (result.action === "revoked") ctx.log.success(`Share link for ${name} revoked`);
|
|
2412
|
-
else if (result.action === "refreshed")
|
|
2413
|
-
ctx.log.success("New share link \u2014 the old one no longer works");
|
|
2414
|
-
else if (result.action === "created")
|
|
2415
|
-
ctx.log.success("Share link created \u2014 anyone with it can view the site");
|
|
2416
|
-
ctx.out.data(
|
|
2417
|
-
ready === void 0 ? result : { ...result, ready },
|
|
2418
|
-
() => result.shareUrl ? result.shareUrl : chalk6.dim("(no share link)")
|
|
2419
|
-
);
|
|
2420
|
-
}
|
|
2421
|
-
async function runVisibility(ctx, name, visibility) {
|
|
2422
|
-
if (visibility !== "private" && visibility !== "public") {
|
|
2423
|
-
throw new CliError(`Visibility must be "private" or "public", not "${visibility}"`);
|
|
2424
|
-
}
|
|
2425
|
-
const site = await setVisibility(requireClient(ctx), name, visibility);
|
|
2426
|
-
ctx.log.success(
|
|
2427
|
-
site.visibility === "public" ? `${name} is public \u2014 no sign-in needed` : `${name} is private \u2014 Nearform sign-in required`
|
|
2428
|
-
);
|
|
2429
|
-
ctx.out.data(site, () => site.url);
|
|
2430
|
-
}
|
|
2431
|
-
async function runArchive(ctx, name, unarchive = false) {
|
|
2432
|
-
const site = await archiveSite(requireClient(ctx), name, { unarchive });
|
|
2433
|
-
ctx.log.success(
|
|
2434
|
-
unarchive ? `${name} restored` : `${name} archived \u2014 it 404s until \`fling unarchive ${name}\``
|
|
2435
|
-
);
|
|
2436
|
-
if (ctx.json) ctx.out.data(site, () => "");
|
|
2437
|
-
else if (unarchive) ctx.out.text(site.url);
|
|
2438
|
-
}
|
|
2439
|
-
async function runRemove(ctx, name, opts) {
|
|
2440
|
-
if (!opts.yes) {
|
|
2441
|
-
if (!ctx.interactive) {
|
|
2442
|
-
throw new CliError(`Refusing to delete ${name} without confirmation`, {
|
|
2443
|
-
hint: "Pass --yes to delete."
|
|
2444
|
-
});
|
|
2445
|
-
}
|
|
2446
|
-
const ok = await ctx.confirm(`Delete ${name} and every version of it? This cannot be undone.`);
|
|
2447
|
-
if (!ok) throw new CliError("Cancelled");
|
|
2448
|
-
}
|
|
2449
|
-
const result = await deleteSite(requireClient(ctx), name);
|
|
2450
|
-
ctx.log.success(`${name} deleted`);
|
|
2451
|
-
if (ctx.json) ctx.out.data(result, () => "");
|
|
2452
|
-
}
|
|
2453
|
-
|
|
2454
3139
|
// src/mcp.ts
|
|
2455
|
-
import { mkdir as mkdir3, mkdtemp as mkdtemp2, rm as rm2, writeFile as writeFile3 } from "node:fs/promises";
|
|
2456
|
-
import { tmpdir as tmpdir2 } from "node:os";
|
|
2457
|
-
import { basename as basename3, dirname as dirname2, join as join4 } from "node:path";
|
|
2458
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2459
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2460
|
-
import { z as z7 } from "zod";
|
|
2461
3140
|
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.`;
|
|
2462
3141
|
var MCP_LOCAL_INSTRUCTIONS = [
|
|
2463
3142
|
"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.",
|
|
@@ -2465,6 +3144,7 @@ var MCP_LOCAL_INSTRUCTIONS = [
|
|
|
2465
3144
|
'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.',
|
|
2466
3145
|
"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.",
|
|
2467
3146
|
"When there is no index.html but there are HTML pages, fling_deploy and fling_publish do not publish: ask the user whether to generate an index page listing the pages (`listHtml: true`) or publish without one (`listHtml: false`, the root URL 404s), then retry.",
|
|
3147
|
+
NEVER_PUBLISHED,
|
|
2468
3148
|
"Not logged in? Call fling_login, let the user finish signing in in the browser, then retry.",
|
|
2469
3149
|
"Sites are private (Nearformers only) by default; only make one public if asked. Use fling_share for a link people outside Nearform can open. Always give the user the URL that comes back."
|
|
2470
3150
|
].join("\n\n");
|
|
@@ -2505,6 +3185,9 @@ ${JSON.stringify(data, null, 2)}` }] };
|
|
|
2505
3185
|
}
|
|
2506
3186
|
async function deployFolder(c, dir, opts) {
|
|
2507
3187
|
let local = await buildManifest(dir);
|
|
3188
|
+
local.skipped.dotfiles.unshift(...opts.skippedDotfiles ?? []);
|
|
3189
|
+
const skippedNote = describeSkipped(local.skipped).map((l) => `
|
|
3190
|
+
${l}.`).join("");
|
|
2508
3191
|
let generatedIndex = false;
|
|
2509
3192
|
if (!hasRootIndex(local)) {
|
|
2510
3193
|
const pages = htmlPages(local).map((f) => f.path);
|
|
@@ -2547,7 +3230,7 @@ Warnings:
|
|
|
2547
3230
|
);
|
|
2548
3231
|
return {
|
|
2549
3232
|
kind: "deployed",
|
|
2550
|
-
summary: `${what}: ${result.url} (${result.site.visibility})${generatedIndex ? " \u2014 with a generated index.html listing the HTML pages" : ""}${warnings}${edge.note}
|
|
3233
|
+
summary: `${what}: ${result.url} (${result.site.visibility})${generatedIndex ? " \u2014 with a generated index.html listing the HTML pages" : ""}${warnings}${skippedNote}${edge.note}
|
|
2551
3234
|
To check exactly which files the site has, call fling_files with name "${result.name}".`,
|
|
2552
3235
|
data: {
|
|
2553
3236
|
status: result.status,
|
|
@@ -2557,7 +3240,8 @@ To check exactly which files the site has, call fling_files with name "${result.
|
|
|
2557
3240
|
versionId: result.versionId,
|
|
2558
3241
|
site: result.site,
|
|
2559
3242
|
generatedIndex,
|
|
2560
|
-
warnings: result.warnings
|
|
3243
|
+
warnings: result.warnings,
|
|
3244
|
+
skipped: local.skipped
|
|
2561
3245
|
}
|
|
2562
3246
|
};
|
|
2563
3247
|
}
|
|
@@ -2570,7 +3254,7 @@ To check exactly which files the site has, call fling_files with name "${result.
|
|
|
2570
3254
|
"fling_deploy",
|
|
2571
3255
|
{
|
|
2572
3256
|
title: "Deploy a file or folder from this computer",
|
|
2573
|
-
description: 'Fling something that is on the user\'s computer and return its stable URL (https://<name>.fling.labs.nearform.com): 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 to signed-in Nearformers unless `public` is set.',
|
|
3257
|
+
description: 'Fling something that is on the user\'s computer and return its stable URL (https://<name>.fling.labs.nearform.com): 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 to signed-in Nearformers unless `public` is set. ' + NEVER_PUBLISHED,
|
|
2574
3258
|
inputSchema: {
|
|
2575
3259
|
path: z7.string().min(1).describe(
|
|
2576
3260
|
"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)"
|
|
@@ -2596,7 +3280,8 @@ To check exactly which files the site has, call fling_files with name "${result.
|
|
|
2596
3280
|
spa: args.spa,
|
|
2597
3281
|
public: args.public,
|
|
2598
3282
|
listHtml: args.listHtml,
|
|
2599
|
-
retryWith: "fling_deploy"
|
|
3283
|
+
retryWith: "fling_deploy",
|
|
3284
|
+
skippedDotfiles: prepared.skippedDotfiles
|
|
2600
3285
|
});
|
|
2601
3286
|
const where = located.foundIn === FOUND_AS_GIVEN ? "" : `Found "${basename3(located.path)}" in ${located.foundIn}: ${located.path}
|
|
2602
3287
|
`;
|
|
@@ -2665,10 +3350,11 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
2665
3350
|
},
|
|
2666
3351
|
(args) => run(async (c) => {
|
|
2667
3352
|
const name = resolveSiteName(".", args.name);
|
|
2668
|
-
const tmp = await
|
|
3353
|
+
const tmp = await mkdtemp3(join6(tmpdir3(), "fling-publish-"));
|
|
2669
3354
|
try {
|
|
2670
|
-
await writeInlineFiles(tmp, args.files);
|
|
3355
|
+
const { skippedDotfiles } = await writeInlineFiles(tmp, args.files);
|
|
2671
3356
|
const outcome = await deployFolder(c, tmp, {
|
|
3357
|
+
skippedDotfiles,
|
|
2672
3358
|
name,
|
|
2673
3359
|
nameGiven: true,
|
|
2674
3360
|
spa: args.spa,
|
|
@@ -2701,20 +3387,20 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
2701
3387
|
source = await resolveInput(args.path, locate);
|
|
2702
3388
|
name = defaultNameFor(source.path, source.isDirectory);
|
|
2703
3389
|
}
|
|
2704
|
-
const
|
|
2705
|
-
const base =
|
|
3390
|
+
const check2 = await checkName(c, name);
|
|
3391
|
+
const base = check2.status === "invalid" ? null : await discoverSitesBase(c);
|
|
2706
3392
|
const url = base ? siteUrlFor(name, base) : null;
|
|
2707
|
-
const suggestions =
|
|
3393
|
+
const suggestions = check2.suggestions.length ? ` Suggestions: ${check2.suggestions.join(", ")}.` : "";
|
|
2708
3394
|
const summary = {
|
|
2709
3395
|
available: `"${name}" is available${url ? ` \u2192 ${url}` : ""}.`,
|
|
2710
3396
|
yours: `"${name}" is the user's existing site${url ? ` (${url})` : ""} \u2014 deploying updates it.`,
|
|
2711
3397
|
taken: `"${name}" is taken by someone else.${suggestions}`,
|
|
2712
3398
|
invalid: `"${name}" is not a valid site name.${suggestions}`
|
|
2713
|
-
}[
|
|
3399
|
+
}[check2.status];
|
|
2714
3400
|
return {
|
|
2715
3401
|
summary,
|
|
2716
3402
|
data: {
|
|
2717
|
-
...
|
|
3403
|
+
...check2,
|
|
2718
3404
|
url,
|
|
2719
3405
|
...source ? { generated: true, source: { path: source.path, foundIn: source.foundIn } } : {}
|
|
2720
3406
|
}
|
|
@@ -2882,23 +3568,36 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
2882
3568
|
);
|
|
2883
3569
|
return server;
|
|
2884
3570
|
}
|
|
2885
|
-
var textResult = (
|
|
3571
|
+
var textResult = (text2) => ({ content: [{ type: "text", text: text2 }] });
|
|
2886
3572
|
async function writeInlineFiles(dir, files) {
|
|
2887
3573
|
const seen = /* @__PURE__ */ new Set();
|
|
3574
|
+
const skippedDotfiles = [];
|
|
3575
|
+
const secretFiles = [];
|
|
2888
3576
|
let total = 0;
|
|
2889
3577
|
for (const f of files) {
|
|
2890
3578
|
const path = f.path.replace(/^(\.\/)+/, "").replace(/^\/+/, "");
|
|
3579
|
+
const segments = path.split("/");
|
|
3580
|
+
if (path && !path.includes("\\") && segments.every((s) => s !== "" && s !== "." && s !== "..") && segments.some((s) => s.startsWith("."))) {
|
|
3581
|
+
skippedDotfiles.push(path);
|
|
3582
|
+
continue;
|
|
3583
|
+
}
|
|
2891
3584
|
if (!isValidSitePath(path)) throw new CliError(`Invalid file path: "${f.path}"`);
|
|
3585
|
+
if (isSecretPath(path)) {
|
|
3586
|
+
secretFiles.push(path);
|
|
3587
|
+
continue;
|
|
3588
|
+
}
|
|
2892
3589
|
if (seen.has(path)) throw new CliError(`"${path}" is listed twice`);
|
|
2893
3590
|
seen.add(path);
|
|
2894
3591
|
const bytes = Buffer.from(f.content, f.encoding === "base64" ? "base64" : "utf8");
|
|
2895
3592
|
total += bytes.length;
|
|
2896
3593
|
if (total > MAX_DEPLOY_BYTES)
|
|
2897
3594
|
throw new CliError(`The site is larger than ${formatBytes(MAX_DEPLOY_BYTES)}`);
|
|
2898
|
-
const target =
|
|
3595
|
+
const target = join6(dir, ...path.split("/"));
|
|
2899
3596
|
await mkdir3(dirname2(target), { recursive: true });
|
|
2900
|
-
await
|
|
3597
|
+
await writeFile4(target, bytes);
|
|
2901
3598
|
}
|
|
3599
|
+
if (secretFiles.length > 0) throw new SecretFilesError(secretFiles);
|
|
3600
|
+
return { skippedDotfiles };
|
|
2902
3601
|
}
|
|
2903
3602
|
function describeError(error) {
|
|
2904
3603
|
if (error instanceof CliError && error.exitCode === EXIT_NOT_LOGGED_IN) return LOGIN_HINT;
|
|
@@ -2913,24 +3612,83 @@ async function runMcpServer(deps = {}) {
|
|
|
2913
3612
|
await server.connect(new StdioServerTransport());
|
|
2914
3613
|
}
|
|
2915
3614
|
|
|
3615
|
+
// src/route.ts
|
|
3616
|
+
import { stat as stat6 } from "node:fs/promises";
|
|
3617
|
+
import { resolve as resolve4 } from "node:path";
|
|
3618
|
+
var PASS_THROUGH = /* @__PURE__ */ new Set(["-h", "--help", "-V", "--version"]);
|
|
3619
|
+
var pathExists = (path) => stat6(resolve4(path)).then(
|
|
3620
|
+
() => true,
|
|
3621
|
+
() => false
|
|
3622
|
+
);
|
|
3623
|
+
async function routeArgs(args, opts) {
|
|
3624
|
+
const exists = opts.exists ?? pathExists;
|
|
3625
|
+
let first;
|
|
3626
|
+
for (let i = 0; i < args.length; i++) {
|
|
3627
|
+
const arg = args[i];
|
|
3628
|
+
if (arg === "--") {
|
|
3629
|
+
first = args[i + 1];
|
|
3630
|
+
break;
|
|
3631
|
+
}
|
|
3632
|
+
if (arg.startsWith("-")) {
|
|
3633
|
+
if (PASS_THROUGH.has(arg)) return { kind: "run", args: [...args] };
|
|
3634
|
+
if (opts.valueFlags.has(arg)) i++;
|
|
3635
|
+
continue;
|
|
3636
|
+
}
|
|
3637
|
+
first = arg;
|
|
3638
|
+
break;
|
|
3639
|
+
}
|
|
3640
|
+
if (first === void 0) return { kind: "help" };
|
|
3641
|
+
if (await exists(first)) return { kind: "run", args: ["deploy", ...args] };
|
|
3642
|
+
if (opts.commands.has(first)) return { kind: "run", args: [...args] };
|
|
3643
|
+
return { kind: "unknown", arg: first };
|
|
3644
|
+
}
|
|
3645
|
+
function routeOptionsFor(program2) {
|
|
3646
|
+
const commands = /* @__PURE__ */ new Set(["help"]);
|
|
3647
|
+
const valueFlags = /* @__PURE__ */ new Set();
|
|
3648
|
+
const addFlags = (command) => {
|
|
3649
|
+
for (const option of command.options)
|
|
3650
|
+
if (option.required || option.optional) {
|
|
3651
|
+
for (const flag of [option.short, option.long]) if (flag) valueFlags.add(flag);
|
|
3652
|
+
}
|
|
3653
|
+
};
|
|
3654
|
+
addFlags(program2);
|
|
3655
|
+
for (const command of program2.commands) {
|
|
3656
|
+
commands.add(command.name());
|
|
3657
|
+
for (const alias of command.aliases()) commands.add(alias);
|
|
3658
|
+
addFlags(command);
|
|
3659
|
+
}
|
|
3660
|
+
return { commands, valueFlags };
|
|
3661
|
+
}
|
|
3662
|
+
|
|
2916
3663
|
// src/cli.ts
|
|
2917
3664
|
var program = new Command();
|
|
2918
3665
|
program.name(BIN_NAME).description("Fling a folder of static files at Fling and get a link back").version(VERSION).option(
|
|
2919
3666
|
"--server <url>",
|
|
2920
3667
|
`server URL (default: $${ENV.url} or config, else ${DEFAULT_SERVER_URL})`
|
|
2921
|
-
).option("--json", "print JSON instead of human output", false).showHelpAfterError().
|
|
3668
|
+
).option("--json", "print JSON instead of human output", false).showHelpAfterError().configureHelp({
|
|
3669
|
+
sortSubcommands: false,
|
|
3670
|
+
styleTitle: (title) => chalk9.bold(brand.primary(title)),
|
|
3671
|
+
styleCommandText: (text2) => chalk9.bold(text2),
|
|
3672
|
+
styleSubcommandText: (text2) => brand.accent(text2),
|
|
3673
|
+
styleOptionText: (text2) => brand.accent(text2),
|
|
3674
|
+
styleArgumentText: (text2) => chalk9.dim(text2)
|
|
3675
|
+
}).addHelpText(
|
|
3676
|
+
"before",
|
|
3677
|
+
() => process.stdout.isTTY ? logo({ columns: process.stdout.columns }) : ""
|
|
3678
|
+
).addHelpText(
|
|
2922
3679
|
"after",
|
|
2923
|
-
`
|
|
2924
|
-
Examples:
|
|
2925
|
-
$ ${BIN_NAME}
|
|
2926
|
-
$ ${BIN_NAME} ./dist --name my-app
|
|
2927
|
-
$ ${BIN_NAME} ./dist
|
|
2928
|
-
$ ${BIN_NAME}
|
|
2929
|
-
$ ${BIN_NAME}
|
|
2930
|
-
$ ${BIN_NAME}
|
|
3680
|
+
() => `
|
|
3681
|
+
${chalk9.bold(brand.primary("Examples:"))}
|
|
3682
|
+
$ ${BIN_NAME} setup sign in and add Fling to Claude Code / Claude Desktop
|
|
3683
|
+
$ ${BIN_NAME} deploy ./dist --name my-app \u2192 https://my-app.fling.labs.nearform.com
|
|
3684
|
+
$ ${BIN_NAME} ./dist the same: an existing path deploys
|
|
3685
|
+
$ ${BIN_NAME} ./dist --yes no prompts (CI): use the generated name, fail if it is taken
|
|
3686
|
+
$ ${BIN_NAME} "Pitch Deck.html" a single page
|
|
3687
|
+
$ ${BIN_NAME} site.zip a zip of a site
|
|
3688
|
+
$ ${BIN_NAME} share my-app public link for people outside Nearform
|
|
2931
3689
|
|
|
2932
|
-
Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
|
|
2933
|
-
Env: ${ENV.apiKey} \xB7 ${ENV.url} \xB7 ${ENV.configDir} \xB7 ${ENV.debug}`
|
|
3690
|
+
${chalk9.dim(`Exit codes: 0 ok \xB7 1 error \xB7 2 not logged in \xB7 3 forbidden
|
|
3691
|
+
Env: ${ENV.apiKey} \xB7 ${ENV.url} \xB7 ${ENV.configDir} \xB7 ${ENV.debug}`)}`
|
|
2934
3692
|
);
|
|
2935
3693
|
function action(handler) {
|
|
2936
3694
|
return async (...args) => {
|
|
@@ -2953,15 +3711,23 @@ function report(error, ctx) {
|
|
|
2953
3711
|
if (error instanceof CliError && error.hint) log.hint(error.hint);
|
|
2954
3712
|
if (ctx?.config.debug && error instanceof Error && error.stack) log.hint(error.stack);
|
|
2955
3713
|
if (ctx?.json) {
|
|
2956
|
-
const code = error
|
|
3714
|
+
const { code, findings, paths } = error;
|
|
2957
3715
|
process.stdout.write(
|
|
2958
|
-
`${formatJson({
|
|
3716
|
+
`${formatJson({
|
|
3717
|
+
error: {
|
|
3718
|
+
message,
|
|
3719
|
+
exitCode: exitCodeFor(error),
|
|
3720
|
+
...typeof code === "string" ? { code } : {},
|
|
3721
|
+
...Array.isArray(findings) ? { findings } : {},
|
|
3722
|
+
...Array.isArray(paths) ? { paths } : {}
|
|
3723
|
+
}
|
|
3724
|
+
})}
|
|
2959
3725
|
`
|
|
2960
3726
|
);
|
|
2961
3727
|
}
|
|
2962
3728
|
}
|
|
2963
|
-
program.command("deploy [path]"
|
|
2964
|
-
"deploy a folder (default: .), a single .html file, a .zip or any file
|
|
3729
|
+
program.command("deploy [path]").helpGroup("Deploy:").description(
|
|
3730
|
+
"deploy a folder (default: .), a single .html file, a .zip or any file. `fling <path>` is short for it"
|
|
2965
3731
|
).option(
|
|
2966
3732
|
"-n, --name <name>",
|
|
2967
3733
|
"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)"
|
|
@@ -2973,28 +3739,54 @@ program.command("deploy [path]", { isDefault: true }).description(
|
|
|
2973
3739
|
(ctx, path, opts) => runDeploy(ctx, path, opts)
|
|
2974
3740
|
)
|
|
2975
3741
|
);
|
|
2976
|
-
program.command("
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
program.command("
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
3742
|
+
program.command("setup").helpGroup("Setup:").description(
|
|
3743
|
+
"sign in, then add the Fling plugin to Claude Code and the extension to Claude Desktop"
|
|
3744
|
+
).option("-y, --yes", "do every step without asking").option("-s, --scope <scope>", "Claude Code plugin scope: user, project or local", "user").action(action((ctx, opts) => runSetup(ctx, opts)));
|
|
3745
|
+
program.command("login").helpGroup("Setup:").description("sign in through the browser and store an API key").action(
|
|
3746
|
+
action(async (ctx) => {
|
|
3747
|
+
await runLogin(ctx);
|
|
3748
|
+
ctx.log.hint(`Next: \`${BIN_NAME} setup\` adds Fling to Claude Code and Claude Desktop.`);
|
|
3749
|
+
})
|
|
3750
|
+
);
|
|
3751
|
+
program.command("logout").helpGroup("Setup:").description("remove the stored API key").action(action((ctx) => runLogout(ctx)));
|
|
3752
|
+
program.command("whoami").helpGroup("Setup:").description("show the signed-in user").action(action((ctx) => runWhoami(ctx)));
|
|
3753
|
+
program.commandsGroup("Setup:").helpCommand("help [command]", "show help for a command");
|
|
3754
|
+
program.command("ls").helpGroup("Sites:").alias("list").description("list your sites").option("--archived", "list archived sites").action(action((ctx, opts) => runList(ctx, opts)));
|
|
3755
|
+
program.command("info <name>").helpGroup("Sites:").description("show a site").action(action((ctx, name) => runInfo(ctx, name)));
|
|
3756
|
+
program.command("versions <name>").helpGroup("Sites:").description("list a site's versions (* = live)").action(action((ctx, name) => runVersions(ctx, name)));
|
|
3757
|
+
program.command("files <name> [versionId]").helpGroup("Sites:").description("list the files in a site's live version (or in versionId)").action(
|
|
2983
3758
|
action((ctx, name, versionId) => runFiles(ctx, name, versionId))
|
|
2984
3759
|
);
|
|
2985
|
-
program.command("rollback <name> <versionId>").description("serve an earlier version again").action(action((ctx, name, versionId) => runRollback(ctx, name, versionId)));
|
|
2986
|
-
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(
|
|
3760
|
+
program.command("rollback <name> <versionId>").helpGroup("Sites:").description("serve an earlier version again").action(action((ctx, name, versionId) => runRollback(ctx, name, versionId)));
|
|
3761
|
+
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(
|
|
2987
3762
|
action(
|
|
2988
3763
|
(ctx, name, opts) => runShare(ctx, name, opts)
|
|
2989
3764
|
)
|
|
2990
3765
|
);
|
|
2991
|
-
program.command("visibility <name> <visibility>").description("private (Nearform sign-in) or public (anyone)").action(action((ctx, name, visibility) => runVisibility(ctx, name, visibility)));
|
|
2992
|
-
program.command("archive <name>").description("take a site offline (404) but keep its files; undo with unarchive").action(action((ctx, name) => runArchive(ctx, name)));
|
|
2993
|
-
program.command("unarchive <name>").description("bring an archived site back").action(action((ctx, name) => runArchive(ctx, name, true)));
|
|
2994
|
-
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)));
|
|
2995
|
-
program.command("mcp").description("run the MCP server on stdio (used by the Claude Code plugin)").action(async (_opts, command) => {
|
|
3766
|
+
program.command("visibility <name> <visibility>").helpGroup("Sites:").description("private (Nearform sign-in) or public (anyone)").action(action((ctx, name, visibility) => runVisibility(ctx, name, visibility)));
|
|
3767
|
+
program.command("archive <name>").helpGroup("Sites:").description("take a site offline (404) but keep its files; undo with unarchive").action(action((ctx, name) => runArchive(ctx, name)));
|
|
3768
|
+
program.command("unarchive <name>").helpGroup("Sites:").description("bring an archived site back").action(action((ctx, name) => runArchive(ctx, name, true)));
|
|
3769
|
+
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)));
|
|
3770
|
+
program.command("mcp").helpGroup("Agents:").description("run the MCP server on stdio (used by the Claude Code plugin)").action(async (_opts, command) => {
|
|
2996
3771
|
const globals = command.optsWithGlobals();
|
|
2997
3772
|
await runMcpServer({ server: globals.server });
|
|
2998
3773
|
});
|
|
2999
|
-
|
|
3774
|
+
async function main() {
|
|
3775
|
+
const route = await routeArgs(process.argv.slice(2), routeOptionsFor(program));
|
|
3776
|
+
if (route.kind === "help") {
|
|
3777
|
+
program.outputHelp();
|
|
3778
|
+
return;
|
|
3779
|
+
}
|
|
3780
|
+
if (route.kind === "unknown") {
|
|
3781
|
+
const log = createLogger();
|
|
3782
|
+
log.error(`"${route.arg}" is not a command, file or folder`);
|
|
3783
|
+
log.hint(
|
|
3784
|
+
`Run \`${BIN_NAME} --help\` for the commands, or \`${BIN_NAME} deploy <path>\` to deploy.`
|
|
3785
|
+
);
|
|
3786
|
+
process.exitCode = 1;
|
|
3787
|
+
return;
|
|
3788
|
+
}
|
|
3789
|
+
await program.parseAsync(route.args, { from: "user" });
|
|
3790
|
+
}
|
|
3791
|
+
await main();
|
|
3000
3792
|
//# sourceMappingURL=cli.js.map
|