@nearform/fling 0.1.1 → 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/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 !== "." && 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.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
- return new CliApiError({
907
+ const { code } = envelope.data.error;
908
+ const error = new CliApiError({
722
909
  status,
723
- code: envelope.data.error.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 readFile3 } from "node:fs/promises";
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 readFile2, realpath, stat as stat2 } from "node:fs/promises";
1329
- import { join as join2, sep } from "node:path";
1330
- import ignore from "ignore";
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 realpath(dir);
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 + sep);
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
- let abs = join2(realDir, entry.name);
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 realpath(abs);
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 files = await mapLimit(found, 16, async (f) => ({
1428
- ...f,
1429
- sha256: await sha256File(f.absPath),
1430
- contentType: contentTypeFor(f.path)
1431
- }));
1432
- return { root, files, totalBytes: files.reduce((n, f) => n + f.size, 0), warnings };
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 = ignore();
1878
+ const ig = ignore2();
1497
1879
  try {
1498
- ig.add(await readFile2(join2(root, IGNORE_FILE), "utf8"));
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 sep2 } from "node:path";
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}${sep2}.git`)) ?? candidates.find((d) => PROJECT_MARKERS.some((m) => exists(`${d}${sep2}${m}`))) ?? null;
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 = relative(resolve(env.home ?? homedir2()), abs);
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(sep2).filter(Boolean);
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 = relative(root, abs);
1568
- for (const raw of rel ? rel.split(sep2) : []) {
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 readFile3(file.absPath);
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 readFile4, rm, stat as stat3, writeFile as writeFile2 } from "node:fs/promises";
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 join3, resolve as resolve2, sep as sep3 } from "node:path";
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(join3(tmpdir(), "fling-"));
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 { kind, source: path, dir: path, defaultName, cleanup: async () => {
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, join3(tmp.dir, "index.html"));
2157
+ await copyFile(path, join4(tmp.dir, "index.html"));
1764
2158
  } else if (kind === "zip") {
1765
- await extractZip(await readFile4(path), tmp.dir);
2159
+ await extractZip(await readFile5(path), tmp.dir, skippedDotfiles);
1766
2160
  } else {
1767
2161
  const name = basename2(path);
1768
- await copyFile(path, join3(tmp.dir, name));
1769
- await writeFile2(join3(tmp.dir, "index.html"), wrapperPage(name));
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
- async function extractZip(data, dest) {
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) return false;
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 + sep3)) throw new CliError(`Unsafe path in the zip: ${name}`);
1853
- await mkdir2(join3(target, ".."), { recursive: true });
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 readFile5, realpath as realpath2, stat as stat4 } from "node:fs/promises";
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 realpath2(path).catch(() => null);
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 readFile5(path, "utf8");
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 join4 } from "node:path";
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(join4(tmpdir2(), "fling-publish-"));
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 = join4(dir, ...path.split("/"));
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.code;
3385
+ const { code, findings, paths } = error;
2957
3386
  process.stdout.write(
2958
- `${formatJson({ error: { message, exitCode: exitCodeFor(error), ...typeof code === "string" ? { code } : {} } })}
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
  }