fbi-proxy 1.9.0 → 1.10.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.
@@ -0,0 +1,213 @@
1
+ import {
2
+ mkdir,
3
+ writeFile,
4
+ rm,
5
+ chmod,
6
+ rename,
7
+ copyFile,
8
+ } from "node:fs/promises";
9
+ import { existsSync } from "node:fs";
10
+ import { createHash } from "node:crypto";
11
+ import { join } from "node:path";
12
+ import { homedir, tmpdir } from "node:os";
13
+
14
+ export type CaddyPlatform = {
15
+ os: "linux" | "darwin" | "windows";
16
+ arch: "amd64" | "arm64";
17
+ ext: "tar.gz" | "zip";
18
+ };
19
+
20
+ export function detectPlatform(
21
+ platform: NodeJS.Platform = process.platform,
22
+ arch: string = process.arch,
23
+ ): CaddyPlatform {
24
+ const osMap: Partial<Record<NodeJS.Platform, CaddyPlatform["os"]>> = {
25
+ linux: "linux",
26
+ darwin: "darwin",
27
+ win32: "windows",
28
+ };
29
+ const archMap: Record<string, CaddyPlatform["arch"]> = {
30
+ x64: "amd64",
31
+ arm64: "arm64",
32
+ };
33
+ const os = osMap[platform];
34
+ if (!os) throw new Error(`Unsupported OS: ${platform}`);
35
+ const mappedArch = archMap[arch];
36
+ if (!mappedArch) throw new Error(`Unsupported arch: ${arch}`);
37
+ return { os, arch: mappedArch, ext: os === "windows" ? "zip" : "tar.gz" };
38
+ }
39
+
40
+ export function buildAssetName(version: string, p: CaddyPlatform): string {
41
+ const v = version.replace(/^v/, "");
42
+ return `caddy_${v}_${p.os}_${p.arch}.${p.ext}`;
43
+ }
44
+
45
+ export function buildAssetUrl(version: string, name: string): string {
46
+ const tag = version.startsWith("v") ? version : `v${version}`;
47
+ return `https://github.com/caddyserver/caddy/releases/download/${tag}/${name}`;
48
+ }
49
+
50
+ export function buildChecksumsUrl(version: string): string {
51
+ const v = version.replace(/^v/, "");
52
+ const tag = version.startsWith("v") ? version : `v${version}`;
53
+ return `https://github.com/caddyserver/caddy/releases/download/${tag}/caddy_${v}_checksums.txt`;
54
+ }
55
+
56
+ export async function fetchLatestVersion(
57
+ signal?: AbortSignal,
58
+ ): Promise<string> {
59
+ const res = await fetch(
60
+ "https://api.github.com/repos/caddyserver/caddy/releases/latest",
61
+ { signal, headers: { "User-Agent": "fbi-proxy" } },
62
+ );
63
+ if (!res.ok) {
64
+ throw new Error(
65
+ `GitHub API ${res.status} ${res.statusText} for latest release`,
66
+ );
67
+ }
68
+ const data = (await res.json()) as { tag_name?: string };
69
+ if (!data.tag_name)
70
+ throw new Error("GitHub API: response is missing tag_name");
71
+ return data.tag_name;
72
+ }
73
+
74
+ /** Parse a checksums.txt file. Format: `<hex> <filename>` per line. */
75
+ export function parseChecksums(text: string): Map<string, string> {
76
+ const out = new Map<string, string>();
77
+ for (const line of text.split(/\r?\n/)) {
78
+ const trimmed = line.trim();
79
+ if (!trimmed || trimmed.startsWith("#")) continue;
80
+ const m = trimmed.match(/^([a-fA-F0-9]+)\s+\*?(.+)$/);
81
+ if (m) out.set(m[2]!.trim(), m[1]!.toLowerCase());
82
+ }
83
+ return out;
84
+ }
85
+
86
+ async function sha512OfPath(path: string): Promise<string> {
87
+ const hash = createHash("sha512");
88
+ const f = Bun.file(path);
89
+ const stream = f.stream();
90
+ for await (const chunk of stream) hash.update(chunk);
91
+ return hash.digest("hex");
92
+ }
93
+
94
+ export type DownloadOpts = {
95
+ version?: string;
96
+ destDir?: string;
97
+ signal?: AbortSignal;
98
+ log?: (msg: string) => void;
99
+ platform?: CaddyPlatform;
100
+ };
101
+
102
+ /**
103
+ * Download, verify (SHA-512 against the release's checksums.txt), extract,
104
+ * and install a Caddy binary into `destDir` (default `~/.fbi-proxy/bin`).
105
+ * Returns the absolute path to the extracted binary.
106
+ *
107
+ * Skips network work if the destination binary already exists.
108
+ */
109
+ export async function downloadCaddy(opts: DownloadOpts = {}): Promise<string> {
110
+ const log = opts.log ?? ((m) => console.log(`[caddy-download] ${m}`));
111
+ const platform = opts.platform ?? detectPlatform();
112
+ const destDir = opts.destDir ?? join(homedir(), ".fbi-proxy", "bin");
113
+ const binaryName = platform.os === "windows" ? "caddy.exe" : "caddy";
114
+ const destPath = join(destDir, binaryName);
115
+
116
+ if (existsSync(destPath)) {
117
+ log(`already installed: ${destPath}`);
118
+ return destPath;
119
+ }
120
+
121
+ const version = opts.version ?? (await fetchLatestVersion(opts.signal));
122
+ log(`platform: ${platform.os}/${platform.arch}, version: ${version}`);
123
+
124
+ const assetName = buildAssetName(version, platform);
125
+ const assetUrl = buildAssetUrl(version, assetName);
126
+ const checksumsUrl = buildChecksumsUrl(version);
127
+
128
+ log(`fetching checksums: ${checksumsUrl}`);
129
+ const cksRes = await fetch(checksumsUrl, {
130
+ signal: opts.signal,
131
+ headers: { "User-Agent": "fbi-proxy" },
132
+ });
133
+ if (!cksRes.ok)
134
+ throw new Error(
135
+ `checksums fetch failed: ${cksRes.status} ${cksRes.statusText}`,
136
+ );
137
+ const checksums = parseChecksums(await cksRes.text());
138
+ const expectedSum = checksums.get(assetName);
139
+ if (!expectedSum) {
140
+ throw new Error(
141
+ `no checksum entry for '${assetName}' — release may not include this platform`,
142
+ );
143
+ }
144
+
145
+ await mkdir(destDir, { recursive: true });
146
+ const tmpArchive = join(tmpdir(), `fbi-proxy.${process.pid}.${assetName}`);
147
+
148
+ log(`downloading: ${assetUrl}`);
149
+ const dlRes = await fetch(assetUrl, {
150
+ signal: opts.signal,
151
+ headers: { "User-Agent": "fbi-proxy" },
152
+ });
153
+ if (!dlRes.ok)
154
+ throw new Error(`download failed: ${dlRes.status} ${dlRes.statusText}`);
155
+ const bytes = await dlRes.arrayBuffer();
156
+ await writeFile(tmpArchive, Buffer.from(bytes));
157
+ log(`downloaded ${bytes.byteLength} bytes`);
158
+
159
+ const actualSum = await sha512OfPath(tmpArchive);
160
+ if (actualSum !== expectedSum) {
161
+ await rm(tmpArchive, { force: true });
162
+ throw new Error(
163
+ `SHA-512 mismatch for ${assetName}\n expected: ${expectedSum}\n got: ${actualSum}`,
164
+ );
165
+ }
166
+ log("checksum OK");
167
+
168
+ const tmpExtract = join(tmpdir(), `fbi-proxy-extract.${process.pid}`);
169
+ await rm(tmpExtract, { recursive: true, force: true });
170
+ await mkdir(tmpExtract, { recursive: true });
171
+
172
+ const tarCmd =
173
+ platform.ext === "tar.gz"
174
+ ? ["tar", "-xzf", tmpArchive, "-C", tmpExtract]
175
+ : ["tar", "-xf", tmpArchive, "-C", tmpExtract];
176
+ log(`extracting: ${tarCmd.join(" ")}`);
177
+ const proc = Bun.spawn(tarCmd, { stdout: "inherit", stderr: "inherit" });
178
+ const code = await proc.exited;
179
+ if (code !== 0) {
180
+ await rm(tmpArchive, { force: true });
181
+ await rm(tmpExtract, { recursive: true, force: true });
182
+ throw new Error(`tar extraction exited with code ${code}`);
183
+ }
184
+
185
+ const extractedBinary = join(tmpExtract, binaryName);
186
+ if (!existsSync(extractedBinary)) {
187
+ await rm(tmpArchive, { force: true });
188
+ await rm(tmpExtract, { recursive: true, force: true });
189
+ throw new Error(
190
+ `archive did not contain expected '${binaryName}' at top level`,
191
+ );
192
+ }
193
+
194
+ // Use copy+unlink instead of rename to handle cross-filesystem moves
195
+ // (e.g. /tmp on tmpfs vs ~/.fbi-proxy on a different mount). rename(2)
196
+ // fails with EXDEV in that case.
197
+ try {
198
+ await rename(extractedBinary, destPath);
199
+ } catch (err) {
200
+ if ((err as NodeJS.ErrnoException).code === "EXDEV") {
201
+ await copyFile(extractedBinary, destPath);
202
+ } else {
203
+ throw err;
204
+ }
205
+ }
206
+ if (platform.os !== "windows") await chmod(destPath, 0o755);
207
+
208
+ await rm(tmpArchive, { force: true });
209
+ await rm(tmpExtract, { recursive: true, force: true });
210
+
211
+ log(`installed: ${destPath}`);
212
+ return destPath;
213
+ }
@@ -0,0 +1,183 @@
1
+ import * as readline from "node:readline/promises";
2
+ import { stdin, stdout } from "node:process";
3
+ import type { AuthConfigShape } from "./authConfig";
4
+ import { randomBytes } from "node:crypto";
5
+
6
+ export type WizardPrompter = {
7
+ ask: (question: string, defaultValue?: string) => Promise<string>;
8
+ askChoice: (question: string, choices: readonly string[]) => Promise<number>;
9
+ print: (line: string) => void;
10
+ };
11
+
12
+ export type WizardOptions = {
13
+ domain: string;
14
+ existing?: AuthConfigShape | null;
15
+ };
16
+
17
+ export async function runWizard(
18
+ prompter: WizardPrompter,
19
+ opts: WizardOptions,
20
+ ): Promise<AuthConfigShape> {
21
+ prompter.print("");
22
+ prompter.print("fbi-auth setup wizard");
23
+ prompter.print("─────────────────────");
24
+ prompter.print("");
25
+
26
+ const domain = await prompter.ask("Domain to gate", opts.domain);
27
+ const cleanDomain = domain.replace(/^\.+/, "").trim();
28
+
29
+ const providerIdx = await prompter.askChoice("Identity provider", [
30
+ "Google OAuth (BYO client ID + secret)",
31
+ "Firebase Auth (BYO project ID)",
32
+ "Snolab default (zero-config; supported domains only)",
33
+ ]);
34
+ const provider: AuthConfigShape["provider"] =
35
+ providerIdx === 0 ? "google" : providerIdx === 1 ? "firebase" : "snolab";
36
+
37
+ let clientId: string | undefined;
38
+ let clientSecret: string | undefined;
39
+ let firebase: AuthConfigShape["firebase"];
40
+
41
+ if (provider === "google") {
42
+ clientId = await prompter.ask(
43
+ "Google OAuth Client ID",
44
+ opts.existing?.provider === "google" ? opts.existing.clientId : undefined,
45
+ );
46
+ clientSecret = await prompter.ask(
47
+ "Google OAuth Client Secret",
48
+ opts.existing?.provider === "google"
49
+ ? opts.existing.clientSecret
50
+ : undefined,
51
+ );
52
+ prompter.print("");
53
+ prompter.print(
54
+ ` → Add this redirect URI in Google Cloud Console: https://sso.${cleanDomain}/callback`,
55
+ );
56
+ prompter.print("");
57
+ } else if (provider === "firebase") {
58
+ const projectId = await prompter.ask(
59
+ "Firebase Project ID",
60
+ opts.existing?.firebase?.projectId,
61
+ );
62
+ const apiKey = await prompter.ask(
63
+ "Firebase Web API Key (optional)",
64
+ opts.existing?.firebase?.apiKey ?? "",
65
+ );
66
+ const authDomain = await prompter.ask(
67
+ "Firebase Auth Domain",
68
+ opts.existing?.firebase?.authDomain ?? `${projectId}.firebaseapp.com`,
69
+ );
70
+ firebase = {
71
+ projectId: projectId.trim(),
72
+ apiKey: apiKey.trim() || undefined,
73
+ authDomain: authDomain.trim() || undefined,
74
+ };
75
+ } else {
76
+ // provider === "snolab" — no credentials to collect. The IdP values
77
+ // are baked into lib/fbi-auth/src/snolabDefaults.ts. Server startup
78
+ // will surface a clear error if the snolab project hasn't published
79
+ // values yet, or if the chosen domain isn't on the supported list.
80
+ prompter.print("");
81
+ prompter.print(
82
+ ` → Snolab default IdP — no credentials needed. Domain '${cleanDomain}'`,
83
+ );
84
+ prompter.print(
85
+ ` will be checked against SNOLAB_SUPPORTED_DOMAINS at startup.`,
86
+ );
87
+ prompter.print("");
88
+ }
89
+
90
+ const allowIdx = await prompter.askChoice("Allowlist policy", [
91
+ "Anyone who completes sign-in",
92
+ "Specific email addresses",
93
+ "Specific email domain(s)",
94
+ ]);
95
+
96
+ let allowlist: AuthConfigShape["allowlist"] = { anySignedIn: true };
97
+ if (allowIdx === 1) {
98
+ const raw = await prompter.ask("Allowed emails (comma-separated)");
99
+ allowlist = {
100
+ emails: raw
101
+ .split(",")
102
+ .map((s) => s.trim())
103
+ .filter(Boolean),
104
+ anySignedIn: false,
105
+ };
106
+ } else if (allowIdx === 2) {
107
+ const raw = await prompter.ask("Allowed email domains (comma-separated)");
108
+ allowlist = {
109
+ domains: raw
110
+ .split(",")
111
+ .map((s) => s.trim())
112
+ .filter(Boolean),
113
+ anySignedIn: false,
114
+ };
115
+ }
116
+
117
+ const cfg: AuthConfigShape = {
118
+ version: 1,
119
+ domain: cleanDomain,
120
+ cookieDomain: `.${cleanDomain}`,
121
+ ssoHost: `sso.${cleanDomain}`,
122
+ provider,
123
+ clientId,
124
+ clientSecret,
125
+ firebase,
126
+ sessionSecret:
127
+ opts.existing?.sessionSecret ?? randomBytes(32).toString("base64url"),
128
+ allowlist,
129
+ };
130
+
131
+ prompter.print("");
132
+ prompter.print("Config preview:");
133
+ prompter.print(JSON.stringify(redact(cfg), null, 2));
134
+ prompter.print("");
135
+
136
+ return cfg;
137
+ }
138
+
139
+ function redact(c: AuthConfigShape): AuthConfigShape {
140
+ return {
141
+ ...c,
142
+ clientSecret: c.clientSecret ? "***" : undefined,
143
+ sessionSecret: "***",
144
+ };
145
+ }
146
+
147
+ export function readlinePrompter(): WizardPrompter {
148
+ const rl = readline.createInterface({ input: stdin, output: stdout });
149
+ return {
150
+ async ask(question, defaultValue) {
151
+ const hint =
152
+ defaultValue !== undefined && defaultValue !== ""
153
+ ? ` [${defaultValue}]`
154
+ : "";
155
+ const answer = (await rl.question(`? ${question}${hint}: `)).trim();
156
+ return answer || defaultValue || "";
157
+ },
158
+ async askChoice(question, choices) {
159
+ this.print(`? ${question}:`);
160
+ choices.forEach((c, i) => this.print(` ${i + 1}) ${c}`));
161
+ while (true) {
162
+ const raw = (await rl.question("> ")).trim();
163
+ const idx = Number(raw) - 1;
164
+ if (Number.isInteger(idx) && idx >= 0 && idx < choices.length)
165
+ return idx;
166
+ this.print(` (enter 1-${choices.length})`);
167
+ }
168
+ },
169
+ print(line) {
170
+ stdout.write(line + "\n");
171
+ },
172
+ };
173
+ }
174
+
175
+ export function isTty(): boolean {
176
+ return Boolean(stdin.isTTY && stdout.isTTY);
177
+ }
178
+
179
+ export function closeReadlinePrompter(p: WizardPrompter): void {
180
+ void p;
181
+ // readline.createInterface keeps stdin in raw-ish mode; calling rl.close() requires the rl ref
182
+ // — kept simple here since the wizard runs once at startup and we exit/proceed right after.
183
+ }
@@ -0,0 +1,125 @@
1
+ import { existsSync } from "node:fs";
2
+ import { access } from "node:fs/promises";
3
+ import { constants as fsConstants } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { $ } from "../dSpawn";
7
+ import { downloadCaddy } from "./downloadCaddy";
8
+
9
+ export type CaddyHandle = {
10
+ pid: number | undefined;
11
+ caddyfilePath: string;
12
+ binary: string;
13
+ kill: () => void;
14
+ };
15
+
16
+ /**
17
+ * Resolve the Caddy binary, preferring (in order):
18
+ * 1. `CADDY_BIN` env var
19
+ * 2. `caddy` on $PATH (homebrew, apt, scoop, xcaddy, …)
20
+ * 3. `~/.fbi-proxy/bin/caddy` (previously auto-downloaded)
21
+ * 4. Auto-download the latest release from GitHub, verify SHA-512,
22
+ * install to `~/.fbi-proxy/bin/caddy`, and use it.
23
+ *
24
+ * Set `FBI_CADDY_AUTO_DOWNLOAD=false` to disable step 4 (e.g. for
25
+ * air-gapped environments). When disabled and steps 1-3 all miss,
26
+ * returns `null` so the CLI can print a helpful error.
27
+ */
28
+ export async function resolveCaddyBinary(): Promise<string | null> {
29
+ const fromEnv = process.env.CADDY_BIN;
30
+ if (fromEnv && (await isExecutable(fromEnv))) return fromEnv;
31
+
32
+ const fromPath = await whichCaddy();
33
+ if (fromPath) return fromPath;
34
+
35
+ const downloaded = join(homedir(), ".fbi-proxy", "bin", "caddy");
36
+ if (existsSync(downloaded) && (await isExecutable(downloaded))) {
37
+ return downloaded;
38
+ }
39
+
40
+ if (process.env.FBI_CADDY_AUTO_DOWNLOAD === "false") {
41
+ return null;
42
+ }
43
+
44
+ try {
45
+ console.log(
46
+ "[caddy] no binary found — downloading the latest release from GitHub (~30 MB).",
47
+ );
48
+ console.log("[caddy] (set FBI_CADDY_AUTO_DOWNLOAD=false to opt out)");
49
+ const path = await downloadCaddy({
50
+ log: (m) => console.log(`[caddy-download] ${m}`),
51
+ });
52
+ return path;
53
+ } catch (err) {
54
+ console.error(`[caddy] auto-download failed: ${(err as Error).message}`);
55
+ return null;
56
+ }
57
+ }
58
+
59
+ export function caddyNotFoundMessage(): string {
60
+ return [
61
+ "",
62
+ "[fbi-proxy] --with-caddy was passed but Caddy could not be found or downloaded.",
63
+ "",
64
+ "Auto-download from GitHub Releases is the default — if you saw a",
65
+ "download error above, check your network or set FBI_CADDY_AUTO_DOWNLOAD=false",
66
+ "and install Caddy manually:",
67
+ "",
68
+ " - macOS: brew install caddy",
69
+ " - Debian: sudo apt install caddy (or see https://caddyserver.com/docs/install)",
70
+ " - Windows: scoop install caddy (or: winget install CaddyServer.Caddy)",
71
+ " - Manual: https://caddyserver.com/download",
72
+ "",
73
+ "Or point fbi-proxy at an existing binary:",
74
+ " CADDY_BIN=/path/to/caddy bunx fbi-proxy --with-caddy --domain <your-domain>",
75
+ "",
76
+ ].join("\n");
77
+ }
78
+
79
+ /**
80
+ * Spawn `caddy run --config <caddyfilePath>` as a tracked child process.
81
+ *
82
+ * The caller is expected to have already verified the binary is reachable via
83
+ * `resolveCaddyBinary()`. If you pass a custom binary path via `opts.binary`,
84
+ * we use it directly.
85
+ */
86
+ export async function spawnCaddy(opts: {
87
+ caddyfilePath: string;
88
+ binary?: string;
89
+ }): Promise<CaddyHandle | null> {
90
+ const binary = opts.binary ?? (await resolveCaddyBinary());
91
+ if (!binary) return null;
92
+
93
+ console.log(`[caddy] using binary: ${binary}`);
94
+ console.log(`[caddy] config: ${opts.caddyfilePath}`);
95
+
96
+ const proc =
97
+ $`${binary} run --config ${opts.caddyfilePath} --adapter caddyfile`.process;
98
+
99
+ proc.on("exit", (code) => {
100
+ console.log(`[caddy] exited with code ${code}`);
101
+ });
102
+
103
+ return {
104
+ pid: proc.pid,
105
+ caddyfilePath: opts.caddyfilePath,
106
+ binary,
107
+ kill: () => proc.kill?.(),
108
+ };
109
+ }
110
+
111
+ async function isExecutable(path: string): Promise<boolean> {
112
+ try {
113
+ await access(path, fsConstants.X_OK);
114
+ return true;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ async function whichCaddy(): Promise<string | null> {
121
+ const result = await $`which caddy`.catch(() => null);
122
+ if (!result || result.code !== 0) return null;
123
+ const out = result.out.trim();
124
+ return out.length > 0 ? out.split("\n")[0]!.trim() : null;
125
+ }
@@ -0,0 +1,43 @@
1
+ import path from "node:path";
2
+ import getPort from "get-port";
3
+ import { $ } from "../dSpawn";
4
+
5
+ export type FbiAuthHandle = {
6
+ port: number;
7
+ pid: number | undefined;
8
+ kill: () => void;
9
+ };
10
+
11
+ export async function spawnFbiAuth(opts: {
12
+ configPath: string;
13
+ preferredPort?: number;
14
+ }): Promise<FbiAuthHandle> {
15
+ const port = await getPort({ port: opts.preferredPort ?? 2433 });
16
+ const entry = path.resolve(
17
+ import.meta.dir,
18
+ "..",
19
+ "..",
20
+ "lib",
21
+ "fbi-auth",
22
+ "src",
23
+ "server.ts",
24
+ );
25
+
26
+ const proc = $.opt({
27
+ env: {
28
+ ...process.env,
29
+ FBI_AUTH_PORT: String(port),
30
+ FBI_AUTH_CONFIG_PATH: opts.configPath,
31
+ },
32
+ })`bun ${entry}`.process;
33
+
34
+ proc.on("exit", (code) => {
35
+ console.log(`[fbi-auth] exited with code ${code}`);
36
+ });
37
+
38
+ return {
39
+ port,
40
+ pid: proc.pid,
41
+ kill: () => proc.kill?.(),
42
+ };
43
+ }
@@ -8,10 +8,7 @@ if (import.meta.main) {
8
8
  await getFbiProxyBinary();
9
9
  }
10
10
 
11
- export async function getFbiProxyBinary({
12
- rebuild = false,
13
- originalCwd = "",
14
- } = {}) {
11
+ export async function getFbiProxyBinary({ rebuild = false, originalCwd = "" } = {}) {
15
12
  const isWin = process.platform === "win32";
16
13
  const binaryName = getFbiProxyFilename();
17
14
  const binarySuffix = isWin ? ".exe" : "";
@@ -19,10 +16,7 @@ export async function getFbiProxyBinary({
19
16
  // Check for local build in original working directory first
20
17
  // This allows users to run `bunx fbi-proxy` from their local repo and use their own build
21
18
  if (!rebuild && originalCwd) {
22
- const localBuilt = path.join(
23
- originalCwd,
24
- `target/release/fbi-proxy${binarySuffix}`,
25
- );
19
+ const localBuilt = path.join(originalCwd, `target/release/fbi-proxy${binarySuffix}`);
26
20
  if (existsSync(localBuilt)) {
27
21
  console.log(`Using local build: ${localBuilt}`);
28
22
  await chmod(localBuilt, 0o755).catch(() => {});
@@ -61,7 +55,5 @@ export async function getFbiProxyBinary({
61
55
  return built;
62
56
  }
63
57
 
64
- throw new Error(
65
- "Oops, failed to build fbi-proxy binary. Please check your Rust setup.",
66
- );
58
+ throw new Error("Oops, failed to build fbi-proxy binary. Please check your Rust setup.");
67
59
  }