@nearform/fling 0.1.1 → 0.2.0

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