sproutboat 0.10.2 → 0.11.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/src/bearssl.ts CHANGED
@@ -16,9 +16,10 @@
16
16
  import { spawn } from "node:child_process";
17
17
  import { createHash } from "node:crypto";
18
18
  import { existsSync } from "node:fs";
19
- import { mkdir, readdir, writeFile } from "node:fs/promises";
19
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
20
20
  import { homedir } from "node:os";
21
21
  import { resolve } from "node:path";
22
+ import { resolveHostCompiler } from "./toolchain";
22
23
 
23
24
  export const BEARSSL_VERSION = "0.6";
24
25
  const BEARSSL_URL = `https://bearssl.org/bearssl-${BEARSSL_VERSION}.tar.gz`;
@@ -36,7 +37,41 @@ const BEARSSL_SHA256 = "6705bba1714961b41a728dfc5debbe348d2966c117649392f8c8139e
36
37
  const CACERT_URL = "https://curl.se/ca/cacert.pem";
37
38
  const CACERT_SHA256 = "f66dff1bdf8f96060b8177976f8b7d9254bc89bc4db933d769f7384d28480bc9";
38
39
 
39
- const cacheDir = (): string => resolve(homedir(), ".cache/sproutboat", `bearssl-${BEARSSL_VERSION}`);
40
+ const cacheDir = (): string =>
41
+ resolve(
42
+ process.env.SPROUTBOAT_TOOLCHAIN_CACHE ?? resolve(homedir(), ".cache/sproutboat"),
43
+ `bearssl-${BEARSSL_VERSION}`,
44
+ );
45
+
46
+ const digest = async (path: string): Promise<string> =>
47
+ createHash("sha256")
48
+ .update(await readFile(path))
49
+ .digest("hex");
50
+
51
+ async function withBearsslLock<T>(dir: string, name: string, action: () => Promise<T>): Promise<T> {
52
+ await mkdir(dir, { recursive: true });
53
+ const lock = resolve(dir, `.${name}.lock`);
54
+ const deadline = Date.now() + 30_000;
55
+ for (;;) {
56
+ try {
57
+ await mkdir(lock);
58
+ break;
59
+ } catch {
60
+ if (Date.now() > deadline) throw new Error(`timed out waiting for BearSSL cache lock ${lock}`);
61
+ try {
62
+ if (Date.now() - (await stat(lock)).mtimeMs > 5 * 60_000) await rm(lock, { recursive: true, force: true });
63
+ } catch {
64
+ /* publisher released the lock while it was being inspected */
65
+ }
66
+ await Bun.sleep(25);
67
+ }
68
+ }
69
+ try {
70
+ return await action();
71
+ } finally {
72
+ await rm(lock, { recursive: true, force: true });
73
+ }
74
+ }
40
75
 
41
76
  export type BearsslInput = {
42
77
  target: "linux-x86_64" | "host";
@@ -51,9 +86,18 @@ export type BearsslBuild = {
51
86
  objects: string[];
52
87
  };
53
88
 
54
- const run = (cmd: string, args: string[], cwd?: string): Promise<{ code: number; stderr: string }> =>
89
+ const run = (
90
+ cmd: string,
91
+ args: string[],
92
+ cwd?: string,
93
+ env?: Record<string, string | undefined>,
94
+ ): Promise<{ code: number; stderr: string }> =>
55
95
  new Promise((done) => {
56
- const child = spawn(cmd, args, { cwd, stdio: ["ignore", "ignore", "pipe"] });
96
+ const child = spawn(cmd, args, {
97
+ cwd,
98
+ stdio: ["ignore", "ignore", "pipe"],
99
+ env: env ? { ...process.env, ...env } : undefined,
100
+ });
57
101
  let stderr = "";
58
102
  child.stderr.on("data", (chunk) => (stderr += String(chunk)));
59
103
  child.on("close", (code) => done({ code: code ?? 1, stderr }));
@@ -73,13 +117,43 @@ async function download(url: string, expected: string, to: string): Promise<Buff
73
117
  async function sourceTree(): Promise<string> {
74
118
  const dir = cacheDir();
75
119
  const tree = resolve(dir, `bearssl-${BEARSSL_VERSION}`);
76
- if (existsSync(resolve(tree, "inc/bearssl.h"))) return tree;
77
- await mkdir(dir, { recursive: true });
78
- const tarball = resolve(dir, "bearssl.tar.gz");
79
- if (!existsSync(tarball)) await download(BEARSSL_URL, BEARSSL_SHA256, tarball);
80
- const untar = await run("tar", ["xzf", tarball], dir);
81
- if (untar.code !== 0) throw new Error(`could not unpack BearSSL: ${untar.stderr}`);
82
- return tree;
120
+ return withBearsslLock(dir, "source", async () => {
121
+ const tarball = resolve(dir, "bearssl.tar.gz");
122
+ const sourceManifest = resolve(tree, ".sproutboat-source");
123
+ try {
124
+ // SAFETY: this untrusted JSON is checked against pinned and computed hashes below.
125
+ const manifest = JSON.parse(await readFile(sourceManifest, "utf8")) as {
126
+ archive?: string;
127
+ header?: string;
128
+ };
129
+ if (manifest.archive === BEARSSL_SHA256 && manifest.header === (await digest(resolve(tree, "inc/bearssl.h"))))
130
+ return tree;
131
+ } catch {
132
+ /* rebuild an incomplete or corrupt source cache below */
133
+ }
134
+ await rm(tree, { recursive: true, force: true });
135
+ if (!existsSync(tarball) || (await digest(tarball)) !== BEARSSL_SHA256) {
136
+ await rm(tarball, { force: true });
137
+ await download(BEARSSL_URL, BEARSSL_SHA256, tarball);
138
+ }
139
+ const stage = resolve(dir, `.bearssl-stage-${process.pid}-${crypto.randomUUID()}`);
140
+ await mkdir(stage, { recursive: true });
141
+ const untar = await run("tar", ["xzf", tarball, "-C", stage]);
142
+ if (untar.code !== 0) throw new Error(`could not unpack BearSSL: ${untar.stderr}`);
143
+ const stagedTree = resolve(stage, `bearssl-${BEARSSL_VERSION}`);
144
+ if (!existsSync(resolve(stagedTree, "inc/bearssl.h"))) {
145
+ await rm(stage, { recursive: true, force: true });
146
+ throw new Error("BearSSL archive did not contain bearssl.h");
147
+ }
148
+ await writeFile(
149
+ resolve(stagedTree, ".sproutboat-source"),
150
+ JSON.stringify({ archive: BEARSSL_SHA256, header: await digest(resolve(stagedTree, "inc/bearssl.h")) }),
151
+ { mode: 0o444 },
152
+ );
153
+ await rename(stagedTree, tree);
154
+ await rm(stage, { recursive: true, force: true });
155
+ return tree;
156
+ });
83
157
  }
84
158
 
85
159
  /** Every .c under src/, which is what the library is. */
@@ -103,7 +177,8 @@ async function sources(tree: string): Promise<string[]> {
103
177
  async function trustAnchorTool(tree: string): Promise<string> {
104
178
  const tool = resolve(tree, "build/brssl");
105
179
  if (existsSync(tool)) return tool;
106
- const make = await run("make", ["-j8", "build/brssl"], tree);
180
+ const { cc } = await resolveHostCompiler();
181
+ const make = await run("make", ["-j8", "build/brssl"], tree, { CC: cc.join(" ") });
107
182
  if (!existsSync(tool)) throw new Error(`could not build brssl: ${make.stderr}`);
108
183
  return tool;
109
184
  }
@@ -117,27 +192,44 @@ async function trustAnchorTool(tree: string): Promise<string> {
117
192
  */
118
193
  async function trustAnchorSource(tree: string): Promise<string> {
119
194
  const out = resolve(cacheDir(), "trust-anchors.c");
120
- if (existsSync(out)) return out;
121
- const pem = resolve(cacheDir(), "cacert.pem");
122
- if (!existsSync(pem)) await download(CACERT_URL, CACERT_SHA256, pem);
123
- const tool = await trustAnchorTool(tree);
124
-
125
- const generated = await new Promise<string>((done, fail) => {
126
- const child = spawn(tool, ["ta", pem], { stdio: ["ignore", "pipe", "pipe"] });
127
- let text = "";
128
- let err = "";
129
- child.stdout.on("data", (chunk) => (text += String(chunk)));
130
- child.stderr.on("data", (chunk) => (err += String(chunk)));
131
- child.on("close", (code) => (code === 0 ? done(text) : fail(new Error(`brssl ta failed: ${err}`))));
132
- });
195
+ return withBearsslLock(cacheDir(), "anchors", async () => {
196
+ const manifestPath = resolve(cacheDir(), ".sproutboat-anchors");
197
+ const pem = resolve(cacheDir(), "cacert.pem");
198
+ try {
199
+ // SAFETY: this untrusted JSON is checked against pinned and computed hashes below.
200
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { pem?: string; source?: string };
201
+ if (manifest.pem === CACERT_SHA256 && manifest.source === (await digest(out))) return out;
202
+ } catch {
203
+ /* regenerate an incomplete or corrupt trust-anchor cache below */
204
+ }
205
+ if (!existsSync(pem) || (await digest(pem)) !== CACERT_SHA256) {
206
+ await rm(pem, { force: true });
207
+ await download(CACERT_URL, CACERT_SHA256, pem);
208
+ }
209
+ const tool = await trustAnchorTool(tree);
210
+
211
+ const generated = await new Promise<string>((done, fail) => {
212
+ const child = spawn(tool, ["ta", pem], { stdio: ["ignore", "pipe", "pipe"] });
213
+ let text = "";
214
+ let err = "";
215
+ child.stdout.on("data", (chunk) => (text += String(chunk)));
216
+ child.stderr.on("data", (chunk) => (err += String(chunk)));
217
+ child.on("close", (code) => (code === 0 ? done(text) : fail(new Error(`brssl ta failed: ${err}`))));
218
+ });
133
219
 
134
- const count = /#define TAs_NUM\s+(\d+)/.exec(generated)?.[1];
135
- if (!count) throw new Error("brssl ta produced no TAs_NUM — its output format changed");
136
- const linkable = generated
137
- .replace(/static const br_x509_trust_anchor TAs\[/, "const br_x509_trust_anchor sb_trust_anchors[")
138
- .replace(/#define TAs_NUM\s+\d+/, `const size_t sb_trust_anchor_count = ${count};`);
139
- await writeFile(out, `#include <stddef.h>\n#include "bearssl.h"\n${linkable}\n`);
140
- return out;
220
+ const count = /#define TAs_NUM\s+(\d+)/.exec(generated)?.[1];
221
+ if (!count) throw new Error("brssl ta produced no TAs_NUM — its output format changed");
222
+ const linkable = generated
223
+ .replace(/static const br_x509_trust_anchor TAs\[/, "const br_x509_trust_anchor sb_trust_anchors[")
224
+ .replace(/#define TAs_NUM\s+\d+/, `const size_t sb_trust_anchor_count = ${count};`);
225
+ const stage = `${out}.${process.pid}.${crypto.randomUUID()}`;
226
+ await writeFile(stage, `#include <stddef.h>\n#include "bearssl.h"\n${linkable}\n`);
227
+ await rename(stage, out);
228
+ const manifestStage = `${manifestPath}.${process.pid}.${crypto.randomUUID()}`;
229
+ await writeFile(manifestStage, JSON.stringify({ pem: CACERT_SHA256, source: await digest(out) }), { mode: 0o444 });
230
+ await rename(manifestStage, manifestPath);
231
+ return out;
232
+ });
141
233
  }
142
234
 
143
235
  /** Compile one C file, caching on the output path. */
@@ -152,29 +244,90 @@ export async function ensureBearssl(input: BearsslInput): Promise<BearsslBuild>
152
244
  const tree = await sourceTree();
153
245
  const includeDir = resolve(tree, "inc");
154
246
  const objDir = resolve(cacheDir(), `obj-${input.target}`);
155
- await mkdir(objDir, { recursive: true });
156
-
157
- const [cc, prefix] =
247
+ const [cc, ...prefix] =
158
248
  input.target === "host"
159
- ? (["cc", []] as const)
160
- : ([input.zigBin ?? "zig", ["cc", "-target", "x86_64-linux-musl"]] as const);
249
+ ? (await resolveHostCompiler()).cc
250
+ : [input.zigBin ?? "zig", "cc", "-target", "x86_64-linux-musl"];
161
251
  const includes = [includeDir, resolve(tree, "src")];
252
+ const sourceFiles = await sources(tree);
253
+ const anchors = await trustAnchorSource(tree);
254
+ const sourceHashes = Object.fromEntries(
255
+ await Promise.all(sourceFiles.map(async (source) => [source, await digest(source)] as const)),
256
+ );
257
+ const anchorSha256 = await digest(anchors);
258
+ const manifestPath = resolve(objDir, ".sproutboat-complete");
259
+ const objectNames = [
260
+ ...sourceFiles.map((source) => `${source.split("/").pop()!.replace(/\.c$/, "")}.o`),
261
+ "trust-anchors.o",
262
+ ];
263
+ const flags = ["-Os", ...includes.flatMap((include) => ["-I", include])];
264
+ const validCache = async (): Promise<boolean> => {
265
+ try {
266
+ // SAFETY: the manifest is private data written below. Every source, target,
267
+ // compiler invocation, and object digest must match before reuse.
268
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as {
269
+ target?: string;
270
+ command?: string[];
271
+ flags?: string[];
272
+ sources?: Record<string, string>;
273
+ anchors?: string;
274
+ objects?: Record<string, string>;
275
+ };
276
+ const validObjects = await Promise.all(
277
+ objectNames.map(async (name) => manifest.objects?.[name] === (await digest(resolve(objDir, name)))),
278
+ );
279
+ return (
280
+ manifest.target === input.target &&
281
+ JSON.stringify(manifest.command) === JSON.stringify([cc, ...prefix]) &&
282
+ JSON.stringify(manifest.flags) === JSON.stringify(flags) &&
283
+ JSON.stringify(manifest.sources) === JSON.stringify(sourceHashes) &&
284
+ manifest.anchors === anchorSha256 &&
285
+ validObjects.every(Boolean)
286
+ );
287
+ } catch {
288
+ return false;
289
+ }
290
+ };
291
+ if (await validCache()) return { includeDir, objects: objectNames.map((name) => resolve(objDir, name)) };
162
292
 
163
- const objects: string[] = [];
164
- // Sequential rather than parallel: this runs once per target and a burst of
165
- // ~180 compiler processes is a worse neighbour than a slow first build.
166
- for (const source of await sources(tree)) {
167
- const out = resolve(objDir, `${source.split("/").pop()!.replace(/\.c$/, "")}.o`);
168
- await compile(cc, [...prefix], source, out, includes);
169
- objects.push(out);
170
- }
293
+ return withBearsslLock(cacheDir(), `objects-${input.target}`, async () => {
294
+ if (await validCache()) return { includeDir, objects: objectNames.map((name) => resolve(objDir, name)) };
295
+ await rm(objDir, { recursive: true, force: true });
296
+ await mkdir(objDir, { recursive: true });
171
297
 
172
- const anchors = await trustAnchorSource(tree);
173
- const anchorObject = resolve(objDir, "trust-anchors.o");
174
- await compile(cc, [...prefix], anchors, anchorObject, includes);
175
- objects.push(anchorObject);
298
+ const objects: string[] = [];
299
+ // Sequential rather than parallel: this runs once per target and a burst of
300
+ // ~180 compiler processes is a worse neighbour than a slow first build.
301
+ for (const source of sourceFiles) {
302
+ const out = resolve(objDir, `${source.split("/").pop()!.replace(/\.c$/, "")}.o`);
303
+ await compile(cc, [...prefix], source, out, includes);
304
+ objects.push(out);
305
+ }
176
306
 
177
- return { includeDir, objects };
307
+ const anchorObject = resolve(objDir, "trust-anchors.o");
308
+ await compile(cc, [...prefix], anchors, anchorObject, includes);
309
+ objects.push(anchorObject);
310
+
311
+ const objectHashes = Object.fromEntries(
312
+ await Promise.all(objects.map(async (object) => [object.split("/").pop()!, await digest(object)] as const)),
313
+ );
314
+ const stagedManifest = `${manifestPath}.${process.pid}.${crypto.randomUUID()}`;
315
+ await writeFile(
316
+ stagedManifest,
317
+ JSON.stringify({
318
+ target: input.target,
319
+ command: [cc, ...prefix],
320
+ flags,
321
+ sources: sourceHashes,
322
+ anchors: anchorSha256,
323
+ objects: objectHashes,
324
+ }),
325
+ { mode: 0o444 },
326
+ );
327
+ await rename(stagedManifest, manifestPath);
328
+
329
+ return { includeDir, objects };
330
+ });
178
331
  }
179
332
 
180
333
  export const bearsslStamp = (): string => `bearssl/${BEARSSL_VERSION}`;
package/src/build.ts CHANGED
@@ -4,7 +4,8 @@ import { resolve } from "node:path";
4
4
  import { walkAssets, type AssetManifest } from "./assets";
5
5
  import { resourceRefs, type SproutboatConfig } from "./config";
6
6
  import { ensureSqliteObject } from "./sqlite";
7
- import { ensureBearssl } from "./bearssl";
7
+ import { sqliteStamp } from "./sqlite";
8
+ import { bearsslStamp, ensureBearssl } from "./bearssl";
8
9
  import { compileSprout, type Transport } from "./compile";
9
10
  import {
10
11
  ARTIFACT_SCHEMA_VERSION,
@@ -70,7 +71,29 @@ function digest(value: Uint8Array | string): `sha256:${string}` {
70
71
  export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
71
72
  const source = input.source ?? (await readFile(input.sourcePath));
72
73
  const sourceHash = digest(source);
73
- const artifactId = sourceHash.slice("sha256:".length, 24);
74
+ const target = input.target ?? "linux-x86_64";
75
+ const embedded = input.transport === "embedded";
76
+ // The directory identity must change whenever an input that changes the
77
+ // generated C or link line changes. A source digest alone reused an artifact
78
+ // path across compiler pins, targets, and standalone ABI settings.
79
+ const artifactId = digest(
80
+ JSON.stringify({
81
+ sourceHash,
82
+ target,
83
+ transport: input.transport ?? "broker",
84
+ optimize: input.optimize ?? "release",
85
+ compatibilityDate: input.config.compatibility_date,
86
+ config: input.config,
87
+ toolchain: toolchainStamp(),
88
+ compiler:
89
+ target === "host"
90
+ ? `host:${process.env.CC ?? "cc"}`
91
+ : process.env.SPROUTBOAT_ZIG
92
+ ? `override:${process.env.SPROUTBOAT_ZIG}`
93
+ : "managed-zig",
94
+ native: embedded ? [sqliteStamp(), bearsslStamp()] : [],
95
+ }),
96
+ ).slice("sha256:".length, 24);
74
97
  const artifactDir = input.outputDirectory ?? resolve(input.projectDir, ".sproutboat/dist", artifactId);
75
98
  const sproutPath = resolve(artifactDir, "sprout");
76
99
  await mkdir(artifactDir, { recursive: true });
@@ -116,8 +139,6 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
116
139
  // #15 — an embedded sprout carries its own storage and TLS instead of talking
117
140
  // to a broker: SQLite and BearSSL are compiled once per target and added to
118
141
  // the link line, and BearSSL's header to the compile line.
119
- const embedded = input.transport === "embedded";
120
- const target = input.target ?? "linux-x86_64";
121
142
  const sqliteObject = embedded ? await ensureSqliteObject({ target, zigBin }) : null;
122
143
  const tls = embedded ? await ensureBearssl({ target, zigBin }) : null;
123
144
  const extraLink = [...(sqliteObject ? [sqliteObject] : []), ...(tls ? tls.objects : [])];
package/src/compile.ts CHANGED
@@ -14,7 +14,7 @@ import { existsSync } from "node:fs";
14
14
  import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
15
15
  import { dirname, resolve } from "node:path";
16
16
  import { ensurePorfforPatched } from "./patch-porffor";
17
- import { ensureUWebSockets, ensureUWebSocketsHost, UwsUnavailableError } from "./toolchain";
17
+ import { ensureUWebSockets, ensureUWebSocketsHost, resolveHostCompiler, UwsUnavailableError } from "./toolchain";
18
18
  import { ensurePorffor } from "./porffor-toolchain";
19
19
  // The prelude + transports live in @sproutboat/runtime (next to wrap.ts, which
20
20
  // locates them by file URL). `preludePath`/`transportPath` read them from there
@@ -109,11 +109,20 @@ export async function loadPrelude(transport: Transport = "broker"): Promise<stri
109
109
  }
110
110
 
111
111
  /** Child env for the Porffor run. `SB_EXTRA_LINK` is read by the patched link
112
- * step (#15) and is absent entirely for a normal build. */
113
- function compileEnv(path: string, extraLink?: string[], extraCflags?: string[]) {
112
+ * step (#15) and is absent entirely for a normal build. `cc`/`cxx` override
113
+ * Porffor's own `CC`/`CXX` read for a **host** build (musl builds hardcode
114
+ * `zig cc` themselves and ignore both). */
115
+ function compileEnv(
116
+ path: string,
117
+ extraLink?: string[],
118
+ extraCflags?: string[],
119
+ compiler?: { cc: string[]; cxx: string[] },
120
+ ) {
114
121
  const link = extraLink && extraLink.length > 0 ? extraLink.join(" ") : undefined;
115
122
  const cflags = extraCflags && extraCflags.length > 0 ? extraCflags.join(" ") : undefined;
116
- return { ...process.env, PATH: path, SB_EXTRA_LINK: link, SB_EXTRA_CFLAGS: cflags };
123
+ const env = { ...process.env, PATH: path, SB_EXTRA_LINK: link, SB_EXTRA_CFLAGS: cflags };
124
+ if (compiler) return { ...env, CC: compiler.cc.join(" "), CXX: compiler.cxx.join(" ") };
125
+ return env;
117
126
  }
118
127
 
119
128
  /**
@@ -130,6 +139,7 @@ export function porfforArgs(
130
139
  outPath: string,
131
140
  target: CompileInput["target"],
132
141
  optimize: CompileInput["optimize"],
142
+ usingZig = false,
133
143
  ): string[] {
134
144
  const args = ["native", generatedPath, "-o", outPath];
135
145
  if (target !== "host") args.push("--musl");
@@ -138,6 +148,10 @@ export function porfforArgs(
138
148
  // three times faster to compile, so dev takes it and everything else does
139
149
  // not: a deployable artifact is never built with it.
140
150
  if (optimize === "dev" && target === "host") args.push("-O0");
151
+ // Porffor defaults LTO on for a non-musl link. Zig's cc needs `-fuse-ld=lld`
152
+ // wired up for that, which nothing here does, so turn it off rather than
153
+ // fail every host build that falls back to Zig for its C toolchain.
154
+ if (usingZig) args.push("--no-flto");
141
155
  return args;
142
156
  }
143
157
 
@@ -199,8 +213,11 @@ export async function compileSprout(input: CompileInput): Promise<void> {
199
213
  );
200
214
 
201
215
  const launcher = resolve(porffor, "runtime/index.js");
202
- // A host build never shells `zig`, so it has no zigBin to contribute.
216
+ // A musl build shells `zig` bare (Porffor hardcodes the literal command), so
217
+ // it needs its dir on PATH; a host build passes its compiler via CC/CXX
218
+ // below instead and has no zigBin to contribute here.
203
219
  const zigDir = input.zigBin ? `${dirname(input.zigBin)}:` : "";
220
+ const hostCompiler = input.target === "host" ? await resolveHostCompiler() : undefined;
204
221
  // The per-platform binary ships esbuild next to it; a compiled build finds it
205
222
  // there. Running from the npm package under Bun, `process.execPath` is Bun
206
223
  // itself and `npm i -g` puts no dependency `.bin` on PATH, so resolve the
@@ -221,12 +238,15 @@ export async function compileSprout(input: CompileInput): Promise<void> {
221
238
  ? [process.execPath, "__porffor", launcher]
222
239
  : [process.execPath, resolve(import.meta.dir, "main.ts"), "__porffor", launcher];
223
240
 
224
- const child = Bun.spawn([...command, ...porfforArgs(generatedPath, input.outPath, input.target, input.optimize)], {
225
- cwd: outDir,
226
- stdout: "pipe",
227
- stderr: "pipe",
228
- env: compileEnv(path, input.extraLink, input.extraCflags),
229
- });
241
+ const child = Bun.spawn(
242
+ [...command, ...porfforArgs(generatedPath, input.outPath, input.target, input.optimize, hostCompiler?.usingZig)],
243
+ {
244
+ cwd: outDir,
245
+ stdout: "pipe",
246
+ stderr: "pipe",
247
+ env: compileEnv(path, input.extraLink, input.extraCflags, hostCompiler),
248
+ },
249
+ );
230
250
  let timedOut = false;
231
251
  const timer = setTimeout(() => {
232
252
  timedOut = true;
package/src/main.ts CHANGED
@@ -19,6 +19,7 @@ import { notifyIfOutdated } from "./update-check";
19
19
  import { controlVersionWarning } from "./api-version";
20
20
  import { amber, bold, dim, leaf, ok, rose } from "./style";
21
21
  import { pathToFileURL } from "node:url";
22
+ import { inspectToolchain } from "./toolchain";
22
23
 
23
24
  // A compiled Bun executable is not a general-purpose `bun` command. Porffor
24
25
  // is acquired after installation, so run its external ESM launcher through an
@@ -1194,6 +1195,42 @@ function usage(): never {
1194
1195
  process.exit(2);
1195
1196
  }
1196
1197
 
1198
+ async function toolchain(args: string[]): Promise<void> {
1199
+ if (args[0] !== "doctor" || args.slice(1).some((arg) => arg !== "--json"))
1200
+ usageError("expected toolchain doctor", "toolchain doctor [--json]");
1201
+ const report = inspectToolchain();
1202
+ if (args.includes("--json")) {
1203
+ console.log(JSON.stringify(report, null, 2));
1204
+ return;
1205
+ }
1206
+ console.log(`host: ${report.host}`);
1207
+ console.log(`cache: ${report.cacheRoot}`);
1208
+ console.log(`Porffor: ${report.porffor.version} (${report.porffor.present ? "ready" : "acquired on first build"})`);
1209
+ console.log(` ${report.porffor.path}${report.porffor.override ? " (override)" : ""}`);
1210
+ console.log(`Zig: ${report.zig.version} (${report.zig.platform ?? "unsupported host"})`);
1211
+ console.log(` ${report.zig.path ?? "set SPROUTBOAT_ZIG to a supported Zig binary"}`);
1212
+ console.log(
1213
+ ` ${report.zig.present ? "ready" : "acquired on first deployment build"}${report.zig.override ? " (override)" : ""}`,
1214
+ );
1215
+ console.log(
1216
+ `SQLite: ${report.sqlite.version} (${report.sqlite.present ? "cached" : "acquired for standalone builds"})`,
1217
+ );
1218
+ console.log(` ${report.sqlite.path}`);
1219
+ console.log(
1220
+ `BearSSL: ${report.bearssl.version} (${report.bearssl.present ? "cached" : "acquired for standalone builds"})`,
1221
+ );
1222
+ console.log(` ${report.bearssl.path}`);
1223
+ console.log(
1224
+ `host compiler: ${report.prerequisites.cc ?? "missing: install Xcode Command Line Tools or a C compiler"}`,
1225
+ );
1226
+ console.log(`archiver: ${report.prerequisites.ar ?? "missing: install an archiver"}`);
1227
+ console.log(`archive extractor: ${report.prerequisites.tar ?? "missing: install tar with xz support"}`);
1228
+ if (process.platform === "darwin")
1229
+ console.log(`macOS SDK tools: ${report.prerequisites.xcrun ?? "missing: run xcode-select --install"}`);
1230
+ if (process.platform === "darwin")
1231
+ console.log(`macOS SDK: ${report.prerequisites.sdk ?? "missing: run xcode-select --install"}`);
1232
+ }
1233
+
1197
1234
  const [command, ...args] = process.argv.slice(2);
1198
1235
  if (command === undefined || command === "help" || command === "-h" || command === "--help") help();
1199
1236
  if (command === "--version" || command === "-v") {
@@ -1223,6 +1260,9 @@ switch (command) {
1223
1260
  else await build(directory, hostBuild ? "host" : "linux-x86_64");
1224
1261
  break;
1225
1262
  }
1263
+ case "toolchain":
1264
+ await toolchain(args);
1265
+ break;
1226
1266
  case "login":
1227
1267
  await login(args);
1228
1268
  break;