@nearform/fling 0.1.0 → 0.1.2
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 +17 -3
- package/dist/cli.js +499 -62
- package/dist/cli.js.map +4 -4
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -23,14 +23,198 @@ var MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|
|
23
23
|
var MAX_FILES = 5e3;
|
|
24
24
|
var MAX_PATH_LENGTH = 1024;
|
|
25
25
|
|
|
26
|
+
// ../../packages/shared/src/secrets.ts
|
|
27
|
+
var SECRET_SCAN_MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
28
|
+
var BINARY_SNIFF_BYTES = 8192;
|
|
29
|
+
var SECRET_EXTENSIONS = [
|
|
30
|
+
".pem",
|
|
31
|
+
".key",
|
|
32
|
+
".p12",
|
|
33
|
+
".pfx",
|
|
34
|
+
".jks",
|
|
35
|
+
".keystore",
|
|
36
|
+
".tfstate",
|
|
37
|
+
".tfstate.backup",
|
|
38
|
+
".tfvars",
|
|
39
|
+
".tfvars.json",
|
|
40
|
+
".kdbx"
|
|
41
|
+
];
|
|
42
|
+
var SECRET_NAMES = /* @__PURE__ */ new Set([
|
|
43
|
+
"credentials",
|
|
44
|
+
"credentials.json",
|
|
45
|
+
"secrets.json",
|
|
46
|
+
"secrets.yml",
|
|
47
|
+
"secrets.yaml",
|
|
48
|
+
"wp-config.php",
|
|
49
|
+
".htpasswd"
|
|
50
|
+
]);
|
|
51
|
+
var SSH_KEY_RE = /^id_(?:rsa|dsa|ecdsa|ed25519)(?:[._-]|$)/;
|
|
52
|
+
function isSecretPath(path) {
|
|
53
|
+
const name = (path.split(/[\\/]/).at(-1) ?? "").toLowerCase();
|
|
54
|
+
if (!name) return false;
|
|
55
|
+
if (SECRET_NAMES.has(name)) return true;
|
|
56
|
+
if (SECRET_EXTENSIONS.some((ext) => name.endsWith(ext) && name.length > ext.length)) return true;
|
|
57
|
+
if (/service[-_]account.*\.json$/.test(name)) return true;
|
|
58
|
+
if (SSH_KEY_RE.test(name) && !name.endsWith(".pub")) return true;
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
var PLACEHOLDER_RE = /example|xxxx/i;
|
|
62
|
+
var isPlaceholder = (m) => PLACEHOLDER_RE.test(m[0]);
|
|
63
|
+
var DB_PASSWORD_PLACEHOLDER = /^(?:password|passw(?:or)?d|pass|pwd|secret|changeme|example|test|admin|root|postgres|mysql|redis|guest|user|\*+|x+|\.+)$/i;
|
|
64
|
+
var LOCAL_HOST = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|db|host|hostname)(?::\d+)?$/i;
|
|
65
|
+
var RULES = [
|
|
66
|
+
{
|
|
67
|
+
id: "private-key",
|
|
68
|
+
label: "private key",
|
|
69
|
+
// The header, optional RFC 1421 headers (Proc-Type, DEK-Info, Version), then key material.
|
|
70
|
+
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
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "aws-access-key-id",
|
|
74
|
+
label: "AWS access key ID",
|
|
75
|
+
re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
|
|
76
|
+
allow: isPlaceholder
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "aws-secret-access-key",
|
|
80
|
+
label: "AWS secret access key",
|
|
81
|
+
re: /\b(?:aws_?)?secret_?access_?key["']?\s*[:=]\s*["']?[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/gi,
|
|
82
|
+
allow: isPlaceholder
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "github-token",
|
|
86
|
+
label: "GitHub token",
|
|
87
|
+
re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{80,})/g,
|
|
88
|
+
allow: isPlaceholder
|
|
89
|
+
},
|
|
90
|
+
{ id: "npm-token", label: "npm token", re: /\bnpm_[A-Za-z0-9]{36}\b/g, allow: isPlaceholder },
|
|
91
|
+
{
|
|
92
|
+
id: "slack-token",
|
|
93
|
+
label: "Slack token",
|
|
94
|
+
re: /\bxox[abposr]-[0-9]{6,}-[A-Za-z0-9-]{10,}/g,
|
|
95
|
+
allow: isPlaceholder
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: "slack-webhook",
|
|
99
|
+
label: "Slack webhook URL",
|
|
100
|
+
re: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]{6,}\/B[A-Z0-9]{6,}\/[A-Za-z0-9]{20,}/g,
|
|
101
|
+
allow: isPlaceholder
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: "stripe-live-key",
|
|
105
|
+
label: "Stripe live key",
|
|
106
|
+
re: /\b[sr]k_live_[0-9a-zA-Z]{24,}\b/g,
|
|
107
|
+
allow: isPlaceholder
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "google-oauth-client-secret",
|
|
111
|
+
label: "Google OAuth client secret",
|
|
112
|
+
re: /\bGOCSPX-[A-Za-z0-9_-]{28}(?![A-Za-z0-9_-])/g,
|
|
113
|
+
allow: isPlaceholder
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: "openai-api-key",
|
|
117
|
+
label: "OpenAI API key",
|
|
118
|
+
re: /\bsk-(?:(?:proj|svcacct|admin)-[A-Za-z0-9_-]{40,}|[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20})/g,
|
|
119
|
+
allow: isPlaceholder
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: "anthropic-api-key",
|
|
123
|
+
label: "Anthropic API key",
|
|
124
|
+
re: /\bsk-ant-[A-Za-z0-9_-]{80,}/g,
|
|
125
|
+
allow: isPlaceholder
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: "sendgrid-api-key",
|
|
129
|
+
label: "SendGrid API key",
|
|
130
|
+
re: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,
|
|
131
|
+
allow: isPlaceholder
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: "azure-storage-key",
|
|
135
|
+
label: "Azure storage account key",
|
|
136
|
+
re: /\bAccountKey=[A-Za-z0-9+/]{80,}={0,2}/g,
|
|
137
|
+
allow: isPlaceholder
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
id: "database-url-password",
|
|
141
|
+
label: "database URL with a password",
|
|
142
|
+
re: /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|rediss?|amqps?):\/\/[^:\s/@"'`<>]+:([^@\s/"'`<>]{3,})@([^\s/?#"'`<>]+)/g,
|
|
143
|
+
allow: (m) => {
|
|
144
|
+
const password = m[1] ?? "";
|
|
145
|
+
const host = m[2] ?? "";
|
|
146
|
+
return DB_PASSWORD_PLACEHOLDER.test(password) || /[${}%]/.test(password) || PLACEHOLDER_RE.test(password) || LOCAL_HOST.test(host) || /(?:^|\.)example(?:\.[a-z]+)?(?::\d+)?$/i.test(host);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
];
|
|
150
|
+
var SECRET_RULE_LABELS = Object.fromEntries(
|
|
151
|
+
RULES.map((r) => [r.id, r.label])
|
|
152
|
+
);
|
|
153
|
+
function lineAt(starts, index) {
|
|
154
|
+
let lo = 0;
|
|
155
|
+
let hi = starts.length - 1;
|
|
156
|
+
while (lo < hi) {
|
|
157
|
+
const mid = lo + hi + 1 >> 1;
|
|
158
|
+
if (starts[mid] <= index) lo = mid;
|
|
159
|
+
else hi = mid - 1;
|
|
160
|
+
}
|
|
161
|
+
return lo + 1;
|
|
162
|
+
}
|
|
163
|
+
function scanForSecrets(text, path) {
|
|
164
|
+
let starts;
|
|
165
|
+
const findings = [];
|
|
166
|
+
const seen = /* @__PURE__ */ new Set();
|
|
167
|
+
for (const rule of RULES) {
|
|
168
|
+
rule.re.lastIndex = 0;
|
|
169
|
+
for (let m = rule.re.exec(text); m !== null; m = rule.re.exec(text)) {
|
|
170
|
+
if (m[0].length === 0) rule.re.lastIndex++;
|
|
171
|
+
if (rule.allow?.(m)) continue;
|
|
172
|
+
if (!starts) {
|
|
173
|
+
starts = [0];
|
|
174
|
+
for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1)) starts.push(i + 1);
|
|
175
|
+
}
|
|
176
|
+
const line = lineAt(starts, m.index);
|
|
177
|
+
const key = `${rule.id}:${line}`;
|
|
178
|
+
if (seen.has(key)) continue;
|
|
179
|
+
seen.add(key);
|
|
180
|
+
findings.push({ path, line, rule: rule.id });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return findings.sort((a, b) => a.line - b.line || a.rule.localeCompare(b.rule));
|
|
184
|
+
}
|
|
185
|
+
var BINARY_EXTENSIONS = new Set(
|
|
186
|
+
"png jpg jpeg gif webp avif ico bmp tif tiff heic psd woff woff2 ttf otf eot mp3 mp4 m4a m4v mov webm ogg oga ogv wav flac aac avi mkv zip gz tgz br zst bz2 xz 7z rar wasm pdf glb gltf-binary bin dat".split(/\s+/).filter(Boolean)
|
|
187
|
+
);
|
|
188
|
+
function isScannablePath(path) {
|
|
189
|
+
const name = path.split("/").at(-1) ?? "";
|
|
190
|
+
const dot = name.lastIndexOf(".");
|
|
191
|
+
return dot <= 0 || !BINARY_EXTENSIONS.has(name.slice(dot + 1).toLowerCase());
|
|
192
|
+
}
|
|
193
|
+
function looksBinary(bytes) {
|
|
194
|
+
const end = Math.min(bytes.length, BINARY_SNIFF_BYTES);
|
|
195
|
+
for (let i = 0; i < end; i++) if (bytes[i] === 0) return true;
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
function scanBytesForSecrets(bytes, path) {
|
|
199
|
+
if (!isScannablePath(path) || bytes.length > SECRET_SCAN_MAX_FILE_BYTES || looksBinary(bytes))
|
|
200
|
+
return [];
|
|
201
|
+
return scanForSecrets(new TextDecoder("utf-8").decode(bytes), path);
|
|
202
|
+
}
|
|
203
|
+
function describeSecretFindings(findings, max = 20) {
|
|
204
|
+
const lines = findings.slice(0, max).map((f) => `${f.path} line ${f.line}: ${SECRET_RULE_LABELS[f.rule] ?? f.rule}`);
|
|
205
|
+
if (findings.length > max) lines.push(`\u2026 and ${findings.length - max} more`);
|
|
206
|
+
return lines;
|
|
207
|
+
}
|
|
208
|
+
var NEVER_PUBLISHED_NOTE = "Fling never publishes dotfiles, gitignored files, secret files (keys, certificates, credentials) or files that contain secrets.";
|
|
209
|
+
|
|
26
210
|
// ../../packages/shared/src/manifest.ts
|
|
27
211
|
function isValidSitePath(path) {
|
|
28
212
|
if (!path || path.length > MAX_PATH_LENGTH) return false;
|
|
29
213
|
if (path.startsWith("/") || path.includes("\\") || path.includes("\0")) return false;
|
|
30
|
-
return path.split("/").every((seg) => seg !== "" && seg
|
|
214
|
+
return path.split("/").every((seg) => seg !== "" && !seg.startsWith("."));
|
|
31
215
|
}
|
|
32
216
|
var ManifestFileSchema = z.object({
|
|
33
|
-
path: z.string().refine(isValidSitePath, "invalid path"),
|
|
217
|
+
path: z.string().refine(isValidSitePath, "invalid path (dotfiles are never published)").refine((p) => !isSecretPath(p), "secret file (keys, certificates, credentials) never published"),
|
|
34
218
|
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
35
219
|
size: z.number().int().min(0).max(MAX_FILE_BYTES),
|
|
36
220
|
contentType: z.string().min(1).max(255)
|
|
@@ -279,6 +463,7 @@ var NAME_RULES = `Site names are 1-${SITE_NAME_MAX} chars of lowercase letters,
|
|
|
279
463
|
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
464
|
var VISIBILITY = "Sites are private by default (only signed-in Nearformers can open them); pass `public: true` only if the user asks for a public site. Share links (fling_share) are public to anyone who has them.";
|
|
281
465
|
var INLINE_ONLY = "If you created the content in this conversation, pass it directly \u2014 never pass file paths from your sandbox (this server cannot read them) and never try to curl/upload from your sandbox.";
|
|
466
|
+
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
467
|
var ENCODING = '`encoding` is "utf8" (default, for text: HTML, CSS, JS, SVG, JSON, Markdown\u2026) or "base64" (binary: images, fonts, PDFs).';
|
|
283
468
|
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
469
|
var listHtml = z4.boolean().optional().describe(
|
|
@@ -286,7 +471,7 @@ var listHtml = z4.boolean().optional().describe(
|
|
|
286
471
|
);
|
|
287
472
|
var siteName = z4.string().min(1).max(200).describe('Site name, e.g. "q3-report"');
|
|
288
473
|
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".'
|
|
474
|
+
'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
475
|
);
|
|
291
476
|
var encoding = z4.enum(["utf8", "base64"]).optional().describe('How `content` is encoded: "utf8" (default) for text, "base64" for binary files');
|
|
292
477
|
var spa = z4.boolean().optional().describe(
|
|
@@ -339,7 +524,7 @@ var mcpTools = {
|
|
|
339
524
|
publish: tool({
|
|
340
525
|
name: "fling_publish",
|
|
341
526
|
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.`,
|
|
527
|
+
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
528
|
inputSchema: {
|
|
344
529
|
name: siteName,
|
|
345
530
|
files: z4.array(
|
|
@@ -365,7 +550,7 @@ var mcpTools = {
|
|
|
365
550
|
uploadFile: tool({
|
|
366
551
|
name: "fling_upload_file",
|
|
367
552
|
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}`,
|
|
553
|
+
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
554
|
inputSchema: {
|
|
370
555
|
uploadId: z4.string().min(1).max(100).describe("From fling_upload_begin"),
|
|
371
556
|
path: sitePath,
|
|
@@ -440,6 +625,7 @@ var MCP_REMOTE_INSTRUCTIONS = [
|
|
|
440
625
|
"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
626
|
"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
627
|
"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.",
|
|
628
|
+
NEVER_PUBLISHED,
|
|
443
629
|
`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
630
|
"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
631
|
].join("\n\n");
|
|
@@ -543,7 +729,7 @@ function exitCodeFor(error) {
|
|
|
543
729
|
// package.json
|
|
544
730
|
var package_default = {
|
|
545
731
|
name: "@nearform/fling",
|
|
546
|
-
version: "0.1.
|
|
732
|
+
version: "0.1.2",
|
|
547
733
|
license: "MIT",
|
|
548
734
|
description: "Fling a folder of static files at fling.labs.nearform.com and get a link back. CLI + MCP server.",
|
|
549
735
|
type: "module",
|
|
@@ -718,15 +904,26 @@ async function readBody(response) {
|
|
|
718
904
|
function errorFromResponse(status, body, method, path) {
|
|
719
905
|
const envelope = ErrorEnvelopeSchema.safeParse(body);
|
|
720
906
|
if (envelope.success) {
|
|
721
|
-
|
|
907
|
+
const { code } = envelope.data.error;
|
|
908
|
+
const error = new CliApiError({
|
|
722
909
|
status,
|
|
723
|
-
code
|
|
910
|
+
code,
|
|
724
911
|
body,
|
|
725
|
-
message: envelope.data.error.message
|
|
912
|
+
message: envelope.data.error.message,
|
|
913
|
+
hint: code ? NEVER_PUBLISHED_HINTS[code] : void 0
|
|
726
914
|
});
|
|
915
|
+
const extra = body.error;
|
|
916
|
+
if (Array.isArray(extra?.findings)) Object.assign(error, { findings: extra.findings });
|
|
917
|
+
if (Array.isArray(extra?.paths)) Object.assign(error, { paths: extra.paths });
|
|
918
|
+
return error;
|
|
727
919
|
}
|
|
728
920
|
return new CliApiError({ status, body, message: `${method} ${path} failed with HTTP ${status}` });
|
|
729
921
|
}
|
|
922
|
+
var NEVER_PUBLISHED_HINTS = {
|
|
923
|
+
secret_detected: "Remove the secret from the file (and rotate it if it was ever shared), or add the file to .flingignore if the site does not need it.",
|
|
924
|
+
secret_file: "Move the secret files out of the folder, or add them to .flingignore if the site does not need them.",
|
|
925
|
+
dotfile: `Dotfiles are never flung. Update the CLI (\`npm i -g @nearform/fling@latest\`), which skips them.`
|
|
926
|
+
};
|
|
730
927
|
function describeCause(cause) {
|
|
731
928
|
if (cause instanceof Error) {
|
|
732
929
|
const inner = cause.cause;
|
|
@@ -1320,18 +1517,121 @@ async function runWhoami(ctx) {
|
|
|
1320
1517
|
import chalk5 from "chalk";
|
|
1321
1518
|
|
|
1322
1519
|
// src/deploy.ts
|
|
1323
|
-
import { readFile as
|
|
1520
|
+
import { readFile as readFile4 } from "node:fs/promises";
|
|
1324
1521
|
|
|
1325
1522
|
// src/manifest.ts
|
|
1326
1523
|
import { createHash } from "node:crypto";
|
|
1327
1524
|
import { createReadStream } from "node:fs";
|
|
1328
|
-
import { readdir, readFile as
|
|
1329
|
-
import { join as
|
|
1330
|
-
import
|
|
1525
|
+
import { readdir, readFile as readFile3, realpath as realpath2, stat as stat2 } from "node:fs/promises";
|
|
1526
|
+
import { join as join3, relative as relative2, sep as sep2 } from "node:path";
|
|
1527
|
+
import ignore2 from "ignore";
|
|
1331
1528
|
import mime from "mime";
|
|
1529
|
+
|
|
1530
|
+
// src/gitignore.ts
|
|
1531
|
+
import { execFile } from "node:child_process";
|
|
1532
|
+
import { readFile as readFile2, realpath } from "node:fs/promises";
|
|
1533
|
+
import { join as join2, relative, sep } from "node:path";
|
|
1534
|
+
import ignore from "ignore";
|
|
1535
|
+
var GIT_TIMEOUT_MS = 2e4;
|
|
1536
|
+
function git(cwd, args, input) {
|
|
1537
|
+
return new Promise((resolve3) => {
|
|
1538
|
+
try {
|
|
1539
|
+
const child = execFile(
|
|
1540
|
+
"git",
|
|
1541
|
+
["-C", cwd, ...args],
|
|
1542
|
+
{ timeout: GIT_TIMEOUT_MS, maxBuffer: 256 * 1024 * 1024, encoding: "utf8" },
|
|
1543
|
+
(error, stdout) => {
|
|
1544
|
+
const code = error?.code;
|
|
1545
|
+
if (error && !(args[0] === "check-ignore" && code === 1)) resolve3(null);
|
|
1546
|
+
else resolve3(stdout);
|
|
1547
|
+
}
|
|
1548
|
+
);
|
|
1549
|
+
child.on("error", () => resolve3(null));
|
|
1550
|
+
if (input !== void 0) child.stdin?.end(input);
|
|
1551
|
+
} catch {
|
|
1552
|
+
resolve3(null);
|
|
1553
|
+
}
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
async function rootIsIgnored(top, root) {
|
|
1557
|
+
const rel = relative(top, root);
|
|
1558
|
+
if (!rel || rel.startsWith("..")) return false;
|
|
1559
|
+
const segments = rel.split(sep);
|
|
1560
|
+
const candidates = segments.map((_, i) => `${segments.slice(0, i + 1).join("/")}/`);
|
|
1561
|
+
const out = await git(top, ["check-ignore", "--stdin", "-z"], `${candidates.join("\0")}\0`);
|
|
1562
|
+
return out !== null && out.split("\0").some(Boolean);
|
|
1563
|
+
}
|
|
1564
|
+
function gitMode(entries) {
|
|
1565
|
+
const files = /* @__PURE__ */ new Set();
|
|
1566
|
+
const dirs = [];
|
|
1567
|
+
for (const e of entries) {
|
|
1568
|
+
if (!e) continue;
|
|
1569
|
+
if (e.endsWith("/")) dirs.push(e);
|
|
1570
|
+
else files.add(e);
|
|
1571
|
+
}
|
|
1572
|
+
const dirSet = new Set(dirs);
|
|
1573
|
+
return {
|
|
1574
|
+
mode: "git",
|
|
1575
|
+
ignores(rel, isDir) {
|
|
1576
|
+
if (isDir ? dirSet.has(`${rel}/`) : files.has(rel)) return true;
|
|
1577
|
+
let i = rel.indexOf("/");
|
|
1578
|
+
while (i !== -1) {
|
|
1579
|
+
if (dirSet.has(rel.slice(0, i + 1))) return true;
|
|
1580
|
+
i = rel.indexOf("/", i + 1);
|
|
1581
|
+
}
|
|
1582
|
+
return false;
|
|
1583
|
+
},
|
|
1584
|
+
async enterDir() {
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
function nestedMode() {
|
|
1589
|
+
const levels = [];
|
|
1590
|
+
return {
|
|
1591
|
+
mode: "nested",
|
|
1592
|
+
ignores(rel, isDir) {
|
|
1593
|
+
for (const { base, ig } of levels) {
|
|
1594
|
+
if (base && !rel.startsWith(`${base}/`)) continue;
|
|
1595
|
+
const sub = base ? rel.slice(base.length + 1) : rel;
|
|
1596
|
+
if (sub && ig.ignores(isDir ? `${sub}/` : sub)) return true;
|
|
1597
|
+
}
|
|
1598
|
+
return false;
|
|
1599
|
+
},
|
|
1600
|
+
async enterDir(absDir, relDir) {
|
|
1601
|
+
let text;
|
|
1602
|
+
try {
|
|
1603
|
+
text = await readFile2(join2(absDir, ".gitignore"), "utf8");
|
|
1604
|
+
} catch {
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
levels.push({ base: relDir, ig: ignore().add(text) });
|
|
1608
|
+
}
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
async function loadGitIgnore(root) {
|
|
1612
|
+
const topOut = await git(root, ["rev-parse", "--show-toplevel"]);
|
|
1613
|
+
const topRaw = topOut?.trim();
|
|
1614
|
+
if (!topRaw) return nestedMode();
|
|
1615
|
+
const top = await realpath(topRaw).catch(() => topRaw);
|
|
1616
|
+
if (await rootIsIgnored(top, root)) return nestedMode();
|
|
1617
|
+
const listed = await git(root, [
|
|
1618
|
+
"ls-files",
|
|
1619
|
+
"--others",
|
|
1620
|
+
"--ignored",
|
|
1621
|
+
"--exclude-standard",
|
|
1622
|
+
"--directory",
|
|
1623
|
+
"-z"
|
|
1624
|
+
]);
|
|
1625
|
+
if (listed === null) return nestedMode();
|
|
1626
|
+
return gitMode(listed.split("\0"));
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// src/manifest.ts
|
|
1332
1630
|
var IGNORE_FILE = ".flingignore";
|
|
1333
1631
|
var NO_INDEX_WARNING = "No index.html at the top of the folder \u2014 the site root will 404.";
|
|
1334
1632
|
var ALWAYS_SKIP = /* @__PURE__ */ new Set(["node_modules"]);
|
|
1633
|
+
var QUIET_DOT_ENTRIES = /* @__PURE__ */ new Set([".git", ".DS_Store"]);
|
|
1634
|
+
var SKIPPED_SHOWN = 5;
|
|
1335
1635
|
var CHARSET_TYPES = /* @__PURE__ */ new Set([
|
|
1336
1636
|
"application/javascript",
|
|
1337
1637
|
"application/json",
|
|
@@ -1339,6 +1639,45 @@ var CHARSET_TYPES = /* @__PURE__ */ new Set([
|
|
|
1339
1639
|
"application/xml",
|
|
1340
1640
|
"image/svg+xml"
|
|
1341
1641
|
]);
|
|
1642
|
+
var SecretFilesError = class extends CliError {
|
|
1643
|
+
constructor(paths) {
|
|
1644
|
+
const shown = paths.slice(0, 20);
|
|
1645
|
+
super(
|
|
1646
|
+
`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}`,
|
|
1647
|
+
{
|
|
1648
|
+
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"}.`
|
|
1649
|
+
}
|
|
1650
|
+
);
|
|
1651
|
+
this.paths = paths;
|
|
1652
|
+
this.name = "SecretFilesError";
|
|
1653
|
+
}
|
|
1654
|
+
code = "secret_file";
|
|
1655
|
+
};
|
|
1656
|
+
var SecretsFoundError = class extends CliError {
|
|
1657
|
+
constructor(findings) {
|
|
1658
|
+
const files = new Set(findings.map((f) => f.path)).size;
|
|
1659
|
+
super(
|
|
1660
|
+
`Not flung: found secrets in ${files} file${files === 1 ? "" : "s"}:
|
|
1661
|
+
${describeSecretFindings(findings).join("\n ")}
|
|
1662
|
+
${NEVER_PUBLISHED_NOTE}`,
|
|
1663
|
+
{
|
|
1664
|
+
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.`
|
|
1665
|
+
}
|
|
1666
|
+
);
|
|
1667
|
+
this.findings = findings;
|
|
1668
|
+
this.name = "SecretsFoundError";
|
|
1669
|
+
}
|
|
1670
|
+
code = "secret_detected";
|
|
1671
|
+
};
|
|
1672
|
+
function describeSkipped(skipped) {
|
|
1673
|
+
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`;
|
|
1674
|
+
const lines = [];
|
|
1675
|
+
if (skipped.dotfiles.length)
|
|
1676
|
+
lines.push(line(skipped.dotfiles.length, "dotfile", skipped.dotfiles));
|
|
1677
|
+
if (skipped.gitignored.length)
|
|
1678
|
+
lines.push(line(skipped.gitignored.length, "gitignored file", skipped.gitignored));
|
|
1679
|
+
return lines;
|
|
1680
|
+
}
|
|
1342
1681
|
function contentTypeFor(path) {
|
|
1343
1682
|
const type = mime.getType(path) ?? "application/octet-stream";
|
|
1344
1683
|
return type.startsWith("text/") || CHARSET_TYPES.has(type) ? `${type}; charset=utf-8` : type;
|
|
@@ -1361,29 +1700,38 @@ function toManifest(local) {
|
|
|
1361
1700
|
async function buildManifest(dir) {
|
|
1362
1701
|
let root;
|
|
1363
1702
|
try {
|
|
1364
|
-
root = await
|
|
1703
|
+
root = await realpath2(dir);
|
|
1365
1704
|
} catch {
|
|
1366
1705
|
throw new CliError(`Folder not found: ${dir}`);
|
|
1367
1706
|
}
|
|
1368
1707
|
if (!(await stat2(root)).isDirectory()) throw new CliError(`Not a folder: ${dir}`);
|
|
1369
1708
|
const ig = await loadIgnore(root);
|
|
1709
|
+
const gitIgnore = await loadGitIgnore(root);
|
|
1370
1710
|
const warnings = [];
|
|
1711
|
+
const skipped = { dotfiles: [], gitignored: [] };
|
|
1712
|
+
const secretFiles = [];
|
|
1371
1713
|
const found = [];
|
|
1372
1714
|
const visitedDirs = /* @__PURE__ */ new Set([root]);
|
|
1373
|
-
const inside = (real) => real === root || real.startsWith(root +
|
|
1715
|
+
const inside = (real) => real === root || real.startsWith(root + sep2);
|
|
1374
1716
|
async function walk2(realDir, relDir) {
|
|
1717
|
+
await gitIgnore.enterDir(realDir, relDir);
|
|
1375
1718
|
const entries = await readdir(realDir, { withFileTypes: true });
|
|
1376
1719
|
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
1377
1720
|
for (const entry of entries) {
|
|
1378
|
-
if (entry.name.startsWith(".") || ALWAYS_SKIP.has(entry.name)) continue;
|
|
1379
1721
|
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
1380
|
-
|
|
1722
|
+
if (entry.name.startsWith(".")) {
|
|
1723
|
+
if (!QUIET_DOT_ENTRIES.has(entry.name))
|
|
1724
|
+
skipped.dotfiles.push(entry.isDirectory() ? `${rel}/` : rel);
|
|
1725
|
+
continue;
|
|
1726
|
+
}
|
|
1727
|
+
if (ALWAYS_SKIP.has(entry.name)) continue;
|
|
1728
|
+
let abs = join3(realDir, entry.name);
|
|
1381
1729
|
let isDir = entry.isDirectory();
|
|
1382
1730
|
let isFile = entry.isFile();
|
|
1383
1731
|
if (entry.isSymbolicLink()) {
|
|
1384
1732
|
let target;
|
|
1385
1733
|
try {
|
|
1386
|
-
target = await
|
|
1734
|
+
target = await realpath2(abs);
|
|
1387
1735
|
} catch {
|
|
1388
1736
|
warnings.push(`Skipped broken symlink: ${rel}`);
|
|
1389
1737
|
continue;
|
|
@@ -1393,17 +1741,38 @@ async function buildManifest(dir) {
|
|
|
1393
1741
|
continue;
|
|
1394
1742
|
}
|
|
1395
1743
|
const info = await stat2(target);
|
|
1744
|
+
const targetRel = relative2(root, target).split(sep2).join("/");
|
|
1745
|
+
if (targetRel.split("/").some((seg) => seg.startsWith("."))) {
|
|
1746
|
+
skipped.dotfiles.push(rel);
|
|
1747
|
+
continue;
|
|
1748
|
+
}
|
|
1749
|
+
if (targetRel && gitIgnore.ignores(targetRel, info.isDirectory())) {
|
|
1750
|
+
skipped.gitignored.push(rel);
|
|
1751
|
+
continue;
|
|
1752
|
+
}
|
|
1396
1753
|
abs = target;
|
|
1397
1754
|
isDir = info.isDirectory();
|
|
1398
1755
|
isFile = info.isFile();
|
|
1399
1756
|
}
|
|
1400
1757
|
if (isDir) {
|
|
1758
|
+
if (gitIgnore.ignores(rel, true)) {
|
|
1759
|
+
skipped.gitignored.push(`${rel}/`);
|
|
1760
|
+
continue;
|
|
1761
|
+
}
|
|
1401
1762
|
if (ig.ignores(`${rel}/`)) continue;
|
|
1402
1763
|
if (visitedDirs.has(abs)) continue;
|
|
1403
1764
|
visitedDirs.add(abs);
|
|
1404
1765
|
await walk2(abs, rel);
|
|
1405
1766
|
} else if (isFile) {
|
|
1767
|
+
if (gitIgnore.ignores(rel, false)) {
|
|
1768
|
+
skipped.gitignored.push(rel);
|
|
1769
|
+
continue;
|
|
1770
|
+
}
|
|
1406
1771
|
if (ig.ignores(rel)) continue;
|
|
1772
|
+
if (isSecretPath(rel)) {
|
|
1773
|
+
secretFiles.push(rel);
|
|
1774
|
+
continue;
|
|
1775
|
+
}
|
|
1407
1776
|
const { size } = await stat2(abs);
|
|
1408
1777
|
found.push({ path: rel, absPath: abs, size });
|
|
1409
1778
|
if (found.length > MAX_FILES) {
|
|
@@ -1415,21 +1784,34 @@ async function buildManifest(dir) {
|
|
|
1415
1784
|
}
|
|
1416
1785
|
}
|
|
1417
1786
|
await walk2(root, "");
|
|
1787
|
+
if (secretFiles.length > 0) throw new SecretFilesError(secretFiles);
|
|
1418
1788
|
if (found.length === 0) {
|
|
1419
1789
|
throw new CliError(
|
|
1420
|
-
`Nothing to deploy: ${dir} has no files (dotfiles and node_modules are skipped)`
|
|
1790
|
+
`Nothing to deploy: ${dir} has no files (dotfiles, gitignored files and node_modules are skipped)`
|
|
1421
1791
|
);
|
|
1422
1792
|
}
|
|
1423
1793
|
checkLimits(found, dir);
|
|
1424
1794
|
if (!found.some((f) => f.path === "index.html")) {
|
|
1425
1795
|
warnings.push(NO_INDEX_WARNING);
|
|
1426
1796
|
}
|
|
1427
|
-
const
|
|
1428
|
-
|
|
1429
|
-
sha256
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1797
|
+
const findings = [];
|
|
1798
|
+
const files = await mapLimit(found, 16, async (f) => {
|
|
1799
|
+
let sha256;
|
|
1800
|
+
if (f.size <= SECRET_SCAN_MAX_FILE_BYTES && isScannablePath(f.path)) {
|
|
1801
|
+
const bytes = await readFile3(f.absPath);
|
|
1802
|
+
sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
1803
|
+
findings.push(...scanBytesForSecrets(bytes, f.path));
|
|
1804
|
+
} else sha256 = await sha256File(f.absPath);
|
|
1805
|
+
return { ...f, sha256, contentType: contentTypeFor(f.path) };
|
|
1806
|
+
});
|
|
1807
|
+
if (findings.length > 0) {
|
|
1808
|
+
throw new SecretsFoundError(
|
|
1809
|
+
findings.sort(
|
|
1810
|
+
(a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : a.line - b.line || a.rule.localeCompare(b.rule)
|
|
1811
|
+
)
|
|
1812
|
+
);
|
|
1813
|
+
}
|
|
1814
|
+
return { root, files, totalBytes: files.reduce((n, f) => n + f.size, 0), warnings, skipped };
|
|
1433
1815
|
}
|
|
1434
1816
|
function checkLimits(files, dir) {
|
|
1435
1817
|
for (const f of files) {
|
|
@@ -1493,9 +1875,9 @@ async function withHtmlListing(local, siteName2) {
|
|
|
1493
1875
|
};
|
|
1494
1876
|
}
|
|
1495
1877
|
async function loadIgnore(root) {
|
|
1496
|
-
const ig =
|
|
1878
|
+
const ig = ignore2();
|
|
1497
1879
|
try {
|
|
1498
|
-
ig.add(await
|
|
1880
|
+
ig.add(await readFile3(join3(root, IGNORE_FILE), "utf8"));
|
|
1499
1881
|
} catch (error) {
|
|
1500
1882
|
if (error.code !== "ENOENT") throw error;
|
|
1501
1883
|
}
|
|
@@ -1517,7 +1899,7 @@ async function mapLimit(items, limit, fn) {
|
|
|
1517
1899
|
// src/naming.ts
|
|
1518
1900
|
import { existsSync } from "node:fs";
|
|
1519
1901
|
import { homedir as homedir2 } from "node:os";
|
|
1520
|
-
import { basename, dirname, isAbsolute, relative, resolve, sep as
|
|
1902
|
+
import { basename, dirname, isAbsolute, relative as relative3, resolve, sep as sep3 } from "node:path";
|
|
1521
1903
|
var PROJECT_MARKERS = ["package.json", "pyproject.toml", "Cargo.toml", "go.mod"];
|
|
1522
1904
|
var BUILD_OUTPUT_DIRS = /* @__PURE__ */ new Set([
|
|
1523
1905
|
"dist",
|
|
@@ -1549,23 +1931,23 @@ function findProjectRoot(dir, env = {}) {
|
|
|
1549
1931
|
const home = resolve(env.home ?? homedir2());
|
|
1550
1932
|
const candidates = [];
|
|
1551
1933
|
for (let d = resolve(dir); d !== home && dirname(d) !== d; d = dirname(d)) candidates.push(d);
|
|
1552
|
-
return candidates.find((d) => exists(`${d}${
|
|
1934
|
+
return candidates.find((d) => exists(`${d}${sep3}.git`)) ?? candidates.find((d) => PROJECT_MARKERS.some((m) => exists(`${d}${sep3}${m}`))) ?? null;
|
|
1553
1935
|
}
|
|
1554
1936
|
var isGeneric = (segment) => segment.startsWith(".") || BUILD_OUTPUT_DIRS.has(segment.toLowerCase());
|
|
1555
1937
|
function nameParts(dir, env = {}) {
|
|
1556
1938
|
const abs = resolve(dir);
|
|
1557
1939
|
const root = findProjectRoot(abs, env);
|
|
1558
1940
|
if (!root) {
|
|
1559
|
-
const fromHome =
|
|
1941
|
+
const fromHome = relative3(resolve(env.home ?? homedir2()), abs);
|
|
1560
1942
|
if (fromHome === "") return [];
|
|
1561
1943
|
const underHome = fromHome && !fromHome.startsWith("..") && !isAbsolute(fromHome);
|
|
1562
|
-
const segments = (underHome ? fromHome : abs).split(
|
|
1944
|
+
const segments = (underHome ? fromHome : abs).split(sep3).filter(Boolean);
|
|
1563
1945
|
const pick = [...segments].reverse().find((s) => !isGeneric(s)) ?? segments.at(-1);
|
|
1564
1946
|
return pick ? [pick] : [];
|
|
1565
1947
|
}
|
|
1566
1948
|
const parts = [basename(root)];
|
|
1567
|
-
const rel =
|
|
1568
|
-
for (const raw of rel ? rel.split(
|
|
1949
|
+
const rel = relative3(root, abs);
|
|
1950
|
+
for (const raw of rel ? rel.split(sep3) : []) {
|
|
1569
1951
|
if (isGeneric(raw) || CONTAINER_DIRS.has(raw.toLowerCase())) continue;
|
|
1570
1952
|
const segment = raw.replace(BUILD_SUFFIX_RE, "");
|
|
1571
1953
|
const slug = slugify(segment, 200);
|
|
@@ -1696,7 +2078,7 @@ async function deploy(deps, opts) {
|
|
|
1696
2078
|
}
|
|
1697
2079
|
async function uploadWithRetry(deps, file, url, headers) {
|
|
1698
2080
|
const sleep = deps.sleep ?? defaultSleep2;
|
|
1699
|
-
const body = file.content ?? await
|
|
2081
|
+
const body = file.content ?? await readFile4(file.absPath);
|
|
1700
2082
|
for (let attempt = 0; ; attempt++) {
|
|
1701
2083
|
try {
|
|
1702
2084
|
await putUpload(deps.fetch, url, headers, body);
|
|
@@ -1714,9 +2096,9 @@ async function uploadWithRetry(deps, file, url, headers) {
|
|
|
1714
2096
|
}
|
|
1715
2097
|
|
|
1716
2098
|
// src/inputs.ts
|
|
1717
|
-
import { copyFile, mkdir as mkdir2, mkdtemp, readFile as
|
|
2099
|
+
import { copyFile, mkdir as mkdir2, mkdtemp, readFile as readFile5, rm, stat as stat3, writeFile as writeFile2 } from "node:fs/promises";
|
|
1718
2100
|
import { tmpdir } from "node:os";
|
|
1719
|
-
import { basename as basename2, extname, join as
|
|
2101
|
+
import { basename as basename2, extname, join as join4, resolve as resolve2, sep as sep4 } from "node:path";
|
|
1720
2102
|
import { unzipSync } from "fflate";
|
|
1721
2103
|
function inputKind(path, isDirectory) {
|
|
1722
2104
|
if (isDirectory) return "folder";
|
|
@@ -1734,7 +2116,7 @@ function defaultNameFor(path, isDirectory) {
|
|
|
1734
2116
|
return isDirectory ? defaultSiteName(path) : fileSiteName(path);
|
|
1735
2117
|
}
|
|
1736
2118
|
async function tempFolder() {
|
|
1737
|
-
const dir = await mkdtemp(
|
|
2119
|
+
const dir = await mkdtemp(join4(tmpdir(), "fling-"));
|
|
1738
2120
|
return { dir, cleanup: () => rm(dir, { recursive: true, force: true }) };
|
|
1739
2121
|
}
|
|
1740
2122
|
async function prepareInput(path) {
|
|
@@ -1747,32 +2129,44 @@ async function prepareInput(path) {
|
|
|
1747
2129
|
const kind = inputKind(path, info.isDirectory());
|
|
1748
2130
|
const defaultName = defaultNameFor(path, info.isDirectory());
|
|
1749
2131
|
if (kind === "folder")
|
|
1750
|
-
return {
|
|
1751
|
-
|
|
2132
|
+
return {
|
|
2133
|
+
kind,
|
|
2134
|
+
source: path,
|
|
2135
|
+
dir: path,
|
|
2136
|
+
defaultName,
|
|
2137
|
+
skippedDotfiles: [],
|
|
2138
|
+
cleanup: async () => {
|
|
2139
|
+
}
|
|
2140
|
+
};
|
|
2141
|
+
const file = basename2(path);
|
|
2142
|
+
if (file.startsWith("."))
|
|
2143
|
+
throw new CliError(`Not flung: ${file} is a dotfile. ${NEVER_PUBLISHED_NOTE}`);
|
|
2144
|
+
if (kind === "file" && isSecretPath(file)) throw new SecretFilesError([file]);
|
|
1752
2145
|
if (info.size > MAX_DEPLOY_BYTES)
|
|
1753
2146
|
throw new CliError(
|
|
1754
2147
|
`${basename2(path)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_DEPLOY_BYTES)}`
|
|
1755
2148
|
);
|
|
1756
2149
|
const tmp = await tempFolder();
|
|
2150
|
+
const skippedDotfiles = [];
|
|
1757
2151
|
try {
|
|
1758
2152
|
if (kind === "html") {
|
|
1759
2153
|
if (info.size > MAX_FILE_BYTES)
|
|
1760
2154
|
throw new CliError(
|
|
1761
2155
|
`${basename2(path)} is ${formatBytes(info.size)}; the limit is ${formatBytes(MAX_FILE_BYTES)} per file`
|
|
1762
2156
|
);
|
|
1763
|
-
await copyFile(path,
|
|
2157
|
+
await copyFile(path, join4(tmp.dir, "index.html"));
|
|
1764
2158
|
} else if (kind === "zip") {
|
|
1765
|
-
await extractZip(await
|
|
2159
|
+
await extractZip(await readFile5(path), tmp.dir, skippedDotfiles);
|
|
1766
2160
|
} else {
|
|
1767
2161
|
const name = basename2(path);
|
|
1768
|
-
await copyFile(path,
|
|
1769
|
-
await writeFile2(
|
|
2162
|
+
await copyFile(path, join4(tmp.dir, name));
|
|
2163
|
+
await writeFile2(join4(tmp.dir, "index.html"), wrapperPage(name));
|
|
1770
2164
|
}
|
|
1771
2165
|
} catch (error) {
|
|
1772
2166
|
await tmp.cleanup();
|
|
1773
2167
|
throw error;
|
|
1774
2168
|
}
|
|
1775
|
-
return { kind, source: path, dir: tmp.dir, defaultName, cleanup: tmp.cleanup };
|
|
2169
|
+
return { kind, source: path, dir: tmp.dir, defaultName, skippedDotfiles, cleanup: tmp.cleanup };
|
|
1776
2170
|
}
|
|
1777
2171
|
var JUNK_FILES = /* @__PURE__ */ new Set(["thumbs.db", "desktop.ini"]);
|
|
1778
2172
|
function zipEntryPath(name) {
|
|
@@ -1795,7 +2189,16 @@ function unwrapSingleFolder(paths) {
|
|
|
1795
2189
|
current = current.map((p) => p.slice(first.length + 1));
|
|
1796
2190
|
}
|
|
1797
2191
|
}
|
|
1798
|
-
|
|
2192
|
+
function zipDotEntry(name) {
|
|
2193
|
+
const segments = name.replace(/\\/g, "/").split("/").filter((s) => s !== "" && s !== ".");
|
|
2194
|
+
if (segments[0] === "__MACOSX") return null;
|
|
2195
|
+
const i = segments.findIndex((s) => s.startsWith("."));
|
|
2196
|
+
if (i === -1) return null;
|
|
2197
|
+
const seg = segments[i];
|
|
2198
|
+
if (seg === ".DS_Store" || seg === ".git" || seg.startsWith("._")) return null;
|
|
2199
|
+
return i < segments.length - 1 ? `${segments.slice(0, i + 1).join("/")}/` : segments.join("/");
|
|
2200
|
+
}
|
|
2201
|
+
async function extractZip(data, dest, skippedDotfiles = []) {
|
|
1799
2202
|
const unsafe = [];
|
|
1800
2203
|
let declared = 0;
|
|
1801
2204
|
let count = 0;
|
|
@@ -1808,7 +2211,11 @@ async function extractZip(data, dest) {
|
|
|
1808
2211
|
unsafe.push(file.name);
|
|
1809
2212
|
return false;
|
|
1810
2213
|
}
|
|
1811
|
-
if (path === null)
|
|
2214
|
+
if (path === null) {
|
|
2215
|
+
const dot = zipDotEntry(file.name);
|
|
2216
|
+
if (dot && !skippedDotfiles.includes(dot)) skippedDotfiles.push(dot);
|
|
2217
|
+
return false;
|
|
2218
|
+
}
|
|
1812
2219
|
count++;
|
|
1813
2220
|
declared += file.originalSize;
|
|
1814
2221
|
if (file.originalSize > MAX_FILE_BYTES)
|
|
@@ -1849,8 +2256,8 @@ async function extractZip(data, dest) {
|
|
|
1849
2256
|
throw new CliError(`The zip expands to more than the limits allow`);
|
|
1850
2257
|
const rel = paths.get(name);
|
|
1851
2258
|
const target = resolve2(root, ...rel.split("/"));
|
|
1852
|
-
if (!target.startsWith(root +
|
|
1853
|
-
await mkdir2(
|
|
2259
|
+
if (!target.startsWith(root + sep4)) throw new CliError(`Unsafe path in the zip: ${name}`);
|
|
2260
|
+
await mkdir2(join4(target, ".."), { recursive: true });
|
|
1854
2261
|
await writeFile2(target, content);
|
|
1855
2262
|
written.push(rel);
|
|
1856
2263
|
}
|
|
@@ -1891,7 +2298,7 @@ function wrapperPage(file) {
|
|
|
1891
2298
|
}
|
|
1892
2299
|
|
|
1893
2300
|
// src/locate.ts
|
|
1894
|
-
import { lstat, readdir as readdir2, readFile as
|
|
2301
|
+
import { lstat, readdir as readdir2, readFile as readFile6, realpath as realpath3, stat as stat4 } from "node:fs/promises";
|
|
1895
2302
|
import { posix, win32 } from "node:path";
|
|
1896
2303
|
import { fileURLToPath } from "node:url";
|
|
1897
2304
|
var DOWNLOADS_DIR_ENV = "FLING_DOWNLOADS_DIR";
|
|
@@ -1926,12 +2333,12 @@ var nodeFs = {
|
|
|
1926
2333
|
}
|
|
1927
2334
|
},
|
|
1928
2335
|
async realpath(path) {
|
|
1929
|
-
return
|
|
2336
|
+
return realpath3(path).catch(() => null);
|
|
1930
2337
|
},
|
|
1931
2338
|
async readText(path) {
|
|
1932
2339
|
try {
|
|
1933
2340
|
if (!(await lstat(path)).isFile()) return null;
|
|
1934
|
-
return await
|
|
2341
|
+
return await readFile6(path, "utf8");
|
|
1935
2342
|
} catch {
|
|
1936
2343
|
return null;
|
|
1937
2344
|
}
|
|
@@ -2200,6 +2607,8 @@ async function runDeploy(ctx, input, opts) {
|
|
|
2200
2607
|
}
|
|
2201
2608
|
async function deployPrepared(ctx, client, base, prepared, opts, ask) {
|
|
2202
2609
|
let local = await buildManifest(prepared.dir);
|
|
2610
|
+
local.skipped.dotfiles.unshift(...prepared.skippedDotfiles);
|
|
2611
|
+
for (const line of describeSkipped(local.skipped)) ctx.log.info(line);
|
|
2203
2612
|
let name = opts.name !== void 0 ? resolveSiteName(prepared.dir, opts.name) : prepared.defaultName;
|
|
2204
2613
|
if (opts.name === void 0 && ask) name = await confirmSiteName(ctx, client, name, base);
|
|
2205
2614
|
if (!hasRootIndex(local)) {
|
|
@@ -2247,7 +2656,7 @@ async function deployPrepared(ctx, client, base, prepared, opts, ask) {
|
|
|
2247
2656
|
local.files.map((f) => f.path),
|
|
2248
2657
|
{ progress: ctx.progress, warn: (m) => ctx.log.warn(m) }
|
|
2249
2658
|
)).ready;
|
|
2250
|
-
ctx.out.data({ status: result.status, site: result.site, ready }, () => {
|
|
2659
|
+
ctx.out.data({ status: result.status, site: result.site, ready, skipped: local.skipped }, () => {
|
|
2251
2660
|
const lines = [chalk5.bold(result.url)];
|
|
2252
2661
|
if (result.site.visibility === "private")
|
|
2253
2662
|
lines.push(
|
|
@@ -2454,7 +2863,7 @@ async function runRemove(ctx, name, opts) {
|
|
|
2454
2863
|
// src/mcp.ts
|
|
2455
2864
|
import { mkdir as mkdir3, mkdtemp as mkdtemp2, rm as rm2, writeFile as writeFile3 } from "node:fs/promises";
|
|
2456
2865
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
2457
|
-
import { basename as basename3, dirname as dirname2, join as
|
|
2866
|
+
import { basename as basename3, dirname as dirname2, join as join5 } from "node:path";
|
|
2458
2867
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2459
2868
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2460
2869
|
import { z as z7 } from "zod";
|
|
@@ -2465,6 +2874,7 @@ var MCP_LOCAL_INSTRUCTIONS = [
|
|
|
2465
2874
|
'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
2875
|
"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
2876
|
"When there is no index.html but there are HTML pages, fling_deploy and fling_publish do not publish: ask the user whether to generate an index page listing the pages (`listHtml: true`) or publish without one (`listHtml: false`, the root URL 404s), then retry.",
|
|
2877
|
+
NEVER_PUBLISHED,
|
|
2468
2878
|
"Not logged in? Call fling_login, let the user finish signing in in the browser, then retry.",
|
|
2469
2879
|
"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
2880
|
].join("\n\n");
|
|
@@ -2505,6 +2915,9 @@ ${JSON.stringify(data, null, 2)}` }] };
|
|
|
2505
2915
|
}
|
|
2506
2916
|
async function deployFolder(c, dir, opts) {
|
|
2507
2917
|
let local = await buildManifest(dir);
|
|
2918
|
+
local.skipped.dotfiles.unshift(...opts.skippedDotfiles ?? []);
|
|
2919
|
+
const skippedNote = describeSkipped(local.skipped).map((l) => `
|
|
2920
|
+
${l}.`).join("");
|
|
2508
2921
|
let generatedIndex = false;
|
|
2509
2922
|
if (!hasRootIndex(local)) {
|
|
2510
2923
|
const pages = htmlPages(local).map((f) => f.path);
|
|
@@ -2547,7 +2960,7 @@ Warnings:
|
|
|
2547
2960
|
);
|
|
2548
2961
|
return {
|
|
2549
2962
|
kind: "deployed",
|
|
2550
|
-
summary: `${what}: ${result.url} (${result.site.visibility})${generatedIndex ? " \u2014 with a generated index.html listing the HTML pages" : ""}${warnings}${edge.note}
|
|
2963
|
+
summary: `${what}: ${result.url} (${result.site.visibility})${generatedIndex ? " \u2014 with a generated index.html listing the HTML pages" : ""}${warnings}${skippedNote}${edge.note}
|
|
2551
2964
|
To check exactly which files the site has, call fling_files with name "${result.name}".`,
|
|
2552
2965
|
data: {
|
|
2553
2966
|
status: result.status,
|
|
@@ -2557,7 +2970,8 @@ To check exactly which files the site has, call fling_files with name "${result.
|
|
|
2557
2970
|
versionId: result.versionId,
|
|
2558
2971
|
site: result.site,
|
|
2559
2972
|
generatedIndex,
|
|
2560
|
-
warnings: result.warnings
|
|
2973
|
+
warnings: result.warnings,
|
|
2974
|
+
skipped: local.skipped
|
|
2561
2975
|
}
|
|
2562
2976
|
};
|
|
2563
2977
|
}
|
|
@@ -2570,7 +2984,7 @@ To check exactly which files the site has, call fling_files with name "${result.
|
|
|
2570
2984
|
"fling_deploy",
|
|
2571
2985
|
{
|
|
2572
2986
|
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.',
|
|
2987
|
+
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
2988
|
inputSchema: {
|
|
2575
2989
|
path: z7.string().min(1).describe(
|
|
2576
2990
|
"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 +3010,8 @@ To check exactly which files the site has, call fling_files with name "${result.
|
|
|
2596
3010
|
spa: args.spa,
|
|
2597
3011
|
public: args.public,
|
|
2598
3012
|
listHtml: args.listHtml,
|
|
2599
|
-
retryWith: "fling_deploy"
|
|
3013
|
+
retryWith: "fling_deploy",
|
|
3014
|
+
skippedDotfiles: prepared.skippedDotfiles
|
|
2600
3015
|
});
|
|
2601
3016
|
const where = located.foundIn === FOUND_AS_GIVEN ? "" : `Found "${basename3(located.path)}" in ${located.foundIn}: ${located.path}
|
|
2602
3017
|
`;
|
|
@@ -2665,10 +3080,11 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
2665
3080
|
},
|
|
2666
3081
|
(args) => run(async (c) => {
|
|
2667
3082
|
const name = resolveSiteName(".", args.name);
|
|
2668
|
-
const tmp = await mkdtemp2(
|
|
3083
|
+
const tmp = await mkdtemp2(join5(tmpdir2(), "fling-publish-"));
|
|
2669
3084
|
try {
|
|
2670
|
-
await writeInlineFiles(tmp, args.files);
|
|
3085
|
+
const { skippedDotfiles } = await writeInlineFiles(tmp, args.files);
|
|
2671
3086
|
const outcome = await deployFolder(c, tmp, {
|
|
3087
|
+
skippedDotfiles,
|
|
2672
3088
|
name,
|
|
2673
3089
|
nameGiven: true,
|
|
2674
3090
|
spa: args.spa,
|
|
@@ -2885,20 +3301,33 @@ ${JSON.stringify(data, null, 2)}` }]
|
|
|
2885
3301
|
var textResult = (text) => ({ content: [{ type: "text", text }] });
|
|
2886
3302
|
async function writeInlineFiles(dir, files) {
|
|
2887
3303
|
const seen = /* @__PURE__ */ new Set();
|
|
3304
|
+
const skippedDotfiles = [];
|
|
3305
|
+
const secretFiles = [];
|
|
2888
3306
|
let total = 0;
|
|
2889
3307
|
for (const f of files) {
|
|
2890
3308
|
const path = f.path.replace(/^(\.\/)+/, "").replace(/^\/+/, "");
|
|
3309
|
+
const segments = path.split("/");
|
|
3310
|
+
if (path && !path.includes("\\") && segments.every((s) => s !== "" && s !== "." && s !== "..") && segments.some((s) => s.startsWith("."))) {
|
|
3311
|
+
skippedDotfiles.push(path);
|
|
3312
|
+
continue;
|
|
3313
|
+
}
|
|
2891
3314
|
if (!isValidSitePath(path)) throw new CliError(`Invalid file path: "${f.path}"`);
|
|
3315
|
+
if (isSecretPath(path)) {
|
|
3316
|
+
secretFiles.push(path);
|
|
3317
|
+
continue;
|
|
3318
|
+
}
|
|
2892
3319
|
if (seen.has(path)) throw new CliError(`"${path}" is listed twice`);
|
|
2893
3320
|
seen.add(path);
|
|
2894
3321
|
const bytes = Buffer.from(f.content, f.encoding === "base64" ? "base64" : "utf8");
|
|
2895
3322
|
total += bytes.length;
|
|
2896
3323
|
if (total > MAX_DEPLOY_BYTES)
|
|
2897
3324
|
throw new CliError(`The site is larger than ${formatBytes(MAX_DEPLOY_BYTES)}`);
|
|
2898
|
-
const target =
|
|
3325
|
+
const target = join5(dir, ...path.split("/"));
|
|
2899
3326
|
await mkdir3(dirname2(target), { recursive: true });
|
|
2900
3327
|
await writeFile3(target, bytes);
|
|
2901
3328
|
}
|
|
3329
|
+
if (secretFiles.length > 0) throw new SecretFilesError(secretFiles);
|
|
3330
|
+
return { skippedDotfiles };
|
|
2902
3331
|
}
|
|
2903
3332
|
function describeError(error) {
|
|
2904
3333
|
if (error instanceof CliError && error.exitCode === EXIT_NOT_LOGGED_IN) return LOGIN_HINT;
|
|
@@ -2953,9 +3382,17 @@ function report(error, ctx) {
|
|
|
2953
3382
|
if (error instanceof CliError && error.hint) log.hint(error.hint);
|
|
2954
3383
|
if (ctx?.config.debug && error instanceof Error && error.stack) log.hint(error.stack);
|
|
2955
3384
|
if (ctx?.json) {
|
|
2956
|
-
const code = error
|
|
3385
|
+
const { code, findings, paths } = error;
|
|
2957
3386
|
process.stdout.write(
|
|
2958
|
-
`${formatJson({
|
|
3387
|
+
`${formatJson({
|
|
3388
|
+
error: {
|
|
3389
|
+
message,
|
|
3390
|
+
exitCode: exitCodeFor(error),
|
|
3391
|
+
...typeof code === "string" ? { code } : {},
|
|
3392
|
+
...Array.isArray(findings) ? { findings } : {},
|
|
3393
|
+
...Array.isArray(paths) ? { paths } : {}
|
|
3394
|
+
}
|
|
3395
|
+
})}
|
|
2959
3396
|
`
|
|
2960
3397
|
);
|
|
2961
3398
|
}
|