sproutboat 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,35 @@ maintained going forward by the `release` skill.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.10.2] - 2026-09-11
14
+ ### Fixed
15
+ - The one-time Zig download no longer depends on ziglang.org being reachable.
16
+ It now pulls from a random Zig community mirror, with ziglang.org kept only
17
+ as a last resort, and tries the mirrors in turn on failure. ziglang.org
18
+ rate-limits the multi-megabyte tarballs and had been timing out mid-build,
19
+ with no fallback (baronunread/sproutboat#166). The archive sha256 is still
20
+ enforced whatever the source. `SPROUTBOAT_ZIG_URL` overrides the source;
21
+ `SPROUTBOAT_ZIG` still points at a prebuilt binary.
22
+ - A leftover `~/.cache/sproutboat/zig-<version>/` directory from an earlier
23
+ cache layout is removed on the next build instead of sitting unused.
24
+
25
+ ## [0.10.1] - 2026-09-11
26
+ ### Fixed
27
+ - `npm install sproutboat` / `bunx sproutboat` work again. 0.10.0's launcher
28
+ hard-required per-platform `@sproutboat/cli-*` packages that are not published
29
+ yet, so it always failed with "optional package … is missing". The launcher
30
+ now falls back to running `src/main.ts` with Bun when no platform binary is
31
+ installed (the v0.9.0 model), and `optionalDependencies` are dropped until the
32
+ binaries actually ship. 0.10.0 is deprecated on npm.
33
+ - `sproutboat --version` and the deploy banner report the real version instead
34
+ of a hard-coded `0.9.0` when run from the npm package rather than a
35
+ `bun build --compile` binary.
36
+ - `sproutboat build` / `deploy` locate `esbuild` via the installed dependency,
37
+ not just `process.execPath`'s directory, so bundling works on the Bun
38
+ fallback path.
39
+ - The launcher no longer hangs when it receives `SIGINT` / `SIGTERM` while
40
+ forwarding a signal to a running platform binary.
41
+
13
42
  ## [0.10.0] - 2026-09-11
14
43
  ### Added
15
44
  - Per-platform native CLI: `npm install sproutboat` pulls a prebuilt binary for
@@ -341,7 +370,9 @@ its own package.
341
370
  - Renamed the package to `sproutboat` (was `@sproutboat/cli`); dropped the
342
371
  `sprout` bin alias in favour of a user-defined shell alias.
343
372
 
344
- [Unreleased]: https://github.com/baronunread/sproutboat-cli/compare/v0.10.0...HEAD
373
+ [Unreleased]: https://github.com/baronunread/sproutboat-cli/compare/v0.10.2...HEAD
374
+ [0.10.2]: https://github.com/baronunread/sproutboat-cli/compare/v0.10.1...v0.10.2
375
+ [0.10.1]: https://github.com/baronunread/sproutboat-cli/compare/v0.10.0...v0.10.1
345
376
  [0.10.0]: https://github.com/baronunread/sproutboat-cli/compare/v0.9.0...v0.10.0
346
377
  [0.9.0]: https://github.com/baronunread/sproutboat-cli/compare/v0.8.0...v0.9.0
347
378
  [0.8.0]: https://github.com/baronunread/sproutboat-cli/compare/v0.7.0...v0.8.0
package/SURFACE.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > Generated by `src/surface.test.ts` from `src/surface.ts` + the pinned
4
4
  > toolchain constants. Do not edit by hand — run `UPDATE_SURFACE=1 bun test`.
5
5
 
6
- **Package:** `sproutboat` 0.9.0 · runs on Bun (use `bunx`, not `npx`)
6
+ **Package:** `sproutboat` 0.10.2 · runs on Bun (use `bunx`, not `npx`)
7
7
 
8
8
  ## Commands
9
9
 
@@ -40,6 +40,7 @@ usage: sproutboat <init [name] | check [project-dir] | types [project-dir] | dev
40
40
  | `SPROUTBOAT_API_URL` | Control-plane URL. Overrides the saved active endpoint. |
41
41
  | `SPROUTBOAT_TOKEN` | API token. Overrides the saved credential for the endpoint. |
42
42
  | `SPROUTBOAT_ZIG` | Path to a Zig binary to use instead of downloading the pinned one. |
43
+ | `SPROUTBOAT_ZIG_URL` | URL to download the pinned Zig tarball from, instead of a Zig community mirror. The sha256 is still enforced. |
43
44
  | `SPROUTBOAT_UWS_TARBALL` | Path to a uWebSockets source tarball to seed the Porffor cache with, instead of the one vendored in the package. Both targets are seeded from it. |
44
45
  | `CC` | C compiler used to build uSockets for a host build (default `cc`). A host build needs one regardless: it is what Porffor compiles its own generated C with. |
45
46
  | `AR` | Archiver used to assemble uSockets.a for a host build (default `ar`). |
@@ -1,32 +1,50 @@
1
1
  #!/usr/bin/env node
2
- // npm only selects the packaged executable. The CLI itself always runs in Bun.
2
+ // npm selects the packaged executable for this host when it is installed; that
3
+ // is a native single-file binary that needs no Bun. When it is absent (it is
4
+ // not published yet — see issue #134), fall back to running the TypeScript
5
+ // entry point with Bun, which is how the CLI shipped through v0.9.0.
3
6
  const { spawn } = require("node:child_process");
4
- const { resolve } = require("node:path");
7
+ const { join } = require("node:path");
5
8
 
6
9
  const platform = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : null;
7
10
  const arch = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : null;
8
- if (!platform || !arch) {
9
- console.error(`sproutboat: unsupported platform ${process.platform}/${process.arch}`);
10
- process.exit(1);
11
- }
12
- const packageName = `@sproutboat/cli-${platform}-${arch}`;
13
- let executable;
11
+
12
+ let command;
13
+ let args;
14
+ let viaBun = false;
14
15
  try {
15
- executable = require.resolve(`${packageName}/bin/sproutboat`);
16
+ if (!platform || !arch) throw new Error(`unsupported host ${process.platform}/${process.arch}`);
17
+ command = require.resolve(`@sproutboat/cli-${platform}-${arch}/bin/sproutboat`);
18
+ args = process.argv.slice(2);
16
19
  } catch {
17
- console.error(`sproutboat: optional package ${packageName} is missing for ${platform}/${arch}`);
18
- console.error("Reinstall sproutboat without --omit=optional, or use a direct release download.");
19
- process.exit(1);
20
+ viaBun = true;
21
+ // SPROUTBOAT_BUN is an escape hatch for an unusual install; otherwise PATH.
22
+ command = process.env.SPROUTBOAT_BUN || "bun";
23
+ args = [join(__dirname, "..", "src", "main.ts"), ...process.argv.slice(2)];
20
24
  }
21
- const child = spawn(resolve(executable), process.argv.slice(2), { stdio: "inherit" });
25
+
26
+ const child = spawn(command, args, { stdio: "inherit", windowsHide: true });
27
+
22
28
  child.once("error", (error) => {
23
- console.error(`sproutboat: could not start bundled executable: ${error.message}`);
29
+ if (viaBun && error.code === "ENOENT") {
30
+ console.error("sproutboat: this build needs Bun on PATH. Install it: https://bun.sh");
31
+ } else {
32
+ console.error(`sproutboat: could not start: ${error.message}`);
33
+ }
24
34
  process.exit(1);
25
35
  });
36
+
26
37
  for (const signal of ["SIGINT", "SIGTERM"]) {
27
38
  process.on(signal, () => child.kill(signal));
28
39
  }
40
+
29
41
  child.once("exit", (code, signal) => {
30
- if (signal) process.kill(process.pid, signal);
31
- else process.exit(code ?? 1);
42
+ if (signal) {
43
+ // Re-raise so the parent's exit reflects the signal — but drop our own
44
+ // handler first, or the re-raise re-enters it and the process hangs.
45
+ process.removeAllListeners(signal);
46
+ process.kill(process.pid, signal);
47
+ } else {
48
+ process.exit(code ?? 1);
49
+ }
32
50
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutboat",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Wrangler-shaped CLI for Sproutboat. Deploys workers to any control plane via --api-url / SPROUTBOAT_API_URL.",
5
5
  "keywords": [
6
6
  "cli",
@@ -76,12 +76,6 @@
76
76
  "oxlint": "1.81.0",
77
77
  "typescript": "7.0.2"
78
78
  },
79
- "optionalDependencies": {
80
- "@sproutboat/cli-darwin-arm64": "0.10.0",
81
- "@sproutboat/cli-darwin-x64": "0.10.0",
82
- "@sproutboat/cli-linux-arm64": "0.10.0",
83
- "@sproutboat/cli-linux-x64": "0.10.0"
84
- },
85
79
  "engines": {
86
80
  "node": ">=18"
87
81
  }
package/src/compile.ts CHANGED
@@ -201,10 +201,21 @@ export async function compileSprout(input: CompileInput): Promise<void> {
201
201
  const launcher = resolve(porffor, "runtime/index.js");
202
202
  // A host build never shells `zig`, so it has no zigBin to contribute.
203
203
  const zigDir = input.zigBin ? `${dirname(input.zigBin)}:` : "";
204
+ // The per-platform binary ships esbuild next to it; a compiled build finds it
205
+ // there. Running from the npm package under Bun, `process.execPath` is Bun
206
+ // itself and `npm i -g` puts no dependency `.bin` on PATH, so resolve the
207
+ // esbuild dependency directly. `Bun.which` is the last resort.
204
208
  const packagedEsbuild = resolve(dirname(process.execPath), "esbuild");
205
- const esbuild = existsSync(packagedEsbuild) ? packagedEsbuild : Bun.which("esbuild");
206
- if (!esbuild)
207
- throw new Error("the packaged esbuild executable is missing; reinstall the Sproutboat platform package");
209
+ let esbuild: string | null = existsSync(packagedEsbuild) ? packagedEsbuild : null;
210
+ if (!esbuild) {
211
+ try {
212
+ esbuild = Bun.resolveSync("esbuild/bin/esbuild", import.meta.dir);
213
+ } catch {
214
+ esbuild = null;
215
+ }
216
+ }
217
+ esbuild ??= Bun.which("esbuild");
218
+ if (!esbuild) throw new Error("esbuild could not be located; install it or reinstall sproutboat");
208
219
  const path = `${zigDir}${dirname(esbuild)}:${process.env.PATH ?? ""}`;
209
220
  const command = PACKAGED
210
221
  ? [process.execPath, "__porffor", launcher]
package/src/report.ts CHANGED
@@ -2,10 +2,13 @@ import { gzipSync } from "bun";
2
2
  import { resourceRefs, type SproutboatConfig } from "./config";
3
3
  import type { ArtifactManifest } from "./manifest";
4
4
  import { bold, dim, leaf, sprout } from "./style";
5
+ import pkg from "../package.json" with { type: "json" };
5
6
 
6
- // `bun build --compile` has no package-relative filesystem at runtime. The
7
- // release build injects this value; source development keeps the package value.
8
- export const CLI_VERSION = process.env.SPROUTBOAT_CLI_VERSION ?? "0.9.0";
7
+ // `bun build --compile` has no package-relative filesystem at runtime, so the
8
+ // release build injects this via `--define`. Running from source (or the npm
9
+ // package under Bun) falls back to the bundled package.json version — `bun build
10
+ // --compile` inlines the JSON import, so this branch is correct there too.
11
+ export const CLI_VERSION = process.env.SPROUTBOAT_CLI_VERSION ?? pkg.version;
9
12
 
10
13
  function bytes(n: number): string {
11
14
  if (n < 1024) return `${n} B`;
package/src/surface.ts CHANGED
@@ -194,6 +194,11 @@ export const ENV_VARS: readonly EnvVar[] = [
194
194
  { name: "SPROUTBOAT_API_URL", purpose: "Control-plane URL. Overrides the saved active endpoint." },
195
195
  { name: "SPROUTBOAT_TOKEN", purpose: "API token. Overrides the saved credential for the endpoint." },
196
196
  { name: "SPROUTBOAT_ZIG", purpose: "Path to a Zig binary to use instead of downloading the pinned one." },
197
+ {
198
+ name: "SPROUTBOAT_ZIG_URL",
199
+ purpose:
200
+ "URL to download the pinned Zig tarball from, instead of a Zig community mirror. The sha256 is still enforced.",
201
+ },
197
202
  {
198
203
  name: "SPROUTBOAT_UWS_TARBALL",
199
204
  purpose:
package/src/toolchain.ts CHANGED
@@ -2,8 +2,11 @@
2
2
  * The build toolchain: a pinned Zig (the linux-x86_64 cross-compiler Porffor
3
3
  * shells out to for `--musl`) plus version stamps for the artifact manifest.
4
4
  *
5
- * Zig is fetched once to ~/.cache/sproutboat/zig-<version>/ and reused. No
6
- * Docker, no root. Override with SPROUTBOAT_ZIG=/path/to/zig.
5
+ * Zig is fetched once to ~/.cache/sproutboat/zig-<version>-<platform>/ and
6
+ * reused. No Docker, no root. It comes from a random Zig community mirror with
7
+ * ziglang.org as the last resort, because ziglang.org rate-limits the large
8
+ * tarballs and has been timing out. Override the binary with
9
+ * SPROUTBOAT_ZIG=/path/to/zig, or the download source with SPROUTBOAT_ZIG_URL.
7
10
  */
8
11
  import { createHash } from "node:crypto";
9
12
  import { existsSync, readFileSync } from "node:fs";
@@ -101,24 +104,69 @@ async function zigComplete(dir: string, key: ZigPlatform, expectedArchive: strin
101
104
  }
102
105
  }
103
106
 
107
+ // Zig asks tooling not to hammer ziglang.org for the multi-megabyte tarballs:
108
+ // pick a random community mirror and keep the official host only as a last
109
+ // resort. https://ziglang.org/download/community-mirrors.txt
110
+ const ZIG_MIRRORS_URL = "https://ziglang.org/download/community-mirrors.txt";
111
+
112
+ function shuffle<T>(items: T[]): T[] {
113
+ for (let i = items.length - 1; i > 0; i--) {
114
+ const j = Math.floor(Math.random() * (i + 1));
115
+ [items[i], items[j]] = [items[j], items[i]];
116
+ }
117
+ return items;
118
+ }
119
+
120
+ /**
121
+ * Ordered list of URLs to try for the pinned Zig tarball: the community mirrors
122
+ * (shuffled) followed by ziglang.org. Falls back to ziglang.org alone if the
123
+ * mirror list itself is unreachable.
124
+ */
125
+ async function zigDownloadUrls(
126
+ key: ZigPlatform,
127
+ fetcher: NonNullable<EnsureZigOptions["fetcher"]>,
128
+ timeoutMs: number,
129
+ ): Promise<string[]> {
130
+ const file = `zig-${key}-${ZIG_VERSION}.tar.xz`;
131
+ const official = `https://ziglang.org/download/${ZIG_VERSION}/${file}`;
132
+ try {
133
+ const response = await fetcher(ZIG_MIRRORS_URL, { signal: AbortSignal.timeout(Math.min(timeoutMs, 10_000)) });
134
+ if (response.ok) {
135
+ const mirrors = (await response.text())
136
+ .split(/\s+/)
137
+ .filter((line) => line.startsWith("https://"))
138
+ .map((base) => `${base.replace(/\/+$/, "")}/${file}`);
139
+ if (mirrors.length > 0) return [...shuffle(mirrors), official];
140
+ }
141
+ } catch {
142
+ // Mirror list unreachable: the official host is the only option left.
143
+ }
144
+ return [official];
145
+ }
146
+
104
147
  async function downloadZig(
105
- url: string,
148
+ urls: string[],
106
149
  path: string,
107
150
  fetcher: NonNullable<EnsureZigOptions["fetcher"]>,
108
151
  timeoutMs: number,
109
152
  ): Promise<void> {
110
- let last: unknown;
111
- for (let attempt = 0; attempt < 2; attempt++) {
112
- try {
113
- const response = await fetcher(url, { signal: AbortSignal.timeout(timeoutMs) });
114
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
115
- await writeFile(path, new Uint8Array(await response.arrayBuffer()));
116
- return;
117
- } catch (error) {
118
- last = error;
153
+ // One shot per mirror; two at the single fallback URL so a lone flaky host
154
+ // still gets a retry.
155
+ const attemptsPer = urls.length > 1 ? 1 : 2;
156
+ const failures: string[] = [];
157
+ for (const url of urls) {
158
+ for (let attempt = 0; attempt < attemptsPer; attempt++) {
159
+ try {
160
+ const response = await fetcher(url, { signal: AbortSignal.timeout(timeoutMs) });
161
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
162
+ await writeFile(path, new Uint8Array(await response.arrayBuffer()));
163
+ return;
164
+ } catch (error) {
165
+ failures.push(`${url}: ${String(error)}`);
166
+ }
119
167
  }
120
168
  }
121
- throw new ZigToolchainError("download", `could not download pinned Zig from ${url}: ${String(last)}`);
169
+ throw new ZigToolchainError("download", `could not download pinned Zig\n ${failures.join("\n ")}`);
122
170
  }
123
171
 
124
172
  async function waitForZigPublisher(
@@ -151,6 +199,10 @@ export async function ensureZig(options: EnsureZigOptions = {}): Promise<string>
151
199
  const root = resolve(
152
200
  options.cacheRoot ?? process.env.SPROUTBOAT_TOOLCHAIN_CACHE ?? resolve(homedir(), ".cache/sproutboat"),
153
201
  );
202
+ // Older builds cached Zig at `zig-<version>/` with no platform suffix; nothing
203
+ // reads that layout now, so drop it rather than leave it as dead weight.
204
+ const legacy = resolve(root, `zig-${ZIG_VERSION}`);
205
+ if (existsSync(legacy)) await rm(legacy, { recursive: true, force: true }).catch(() => {});
154
206
  const dir = resolve(root, `zig-${ZIG_VERSION}-${key}`);
155
207
  const expected = options.expectedSha256 ?? ZIG_SHA256[key];
156
208
  if (await zigComplete(dir, key, expected)) return resolve(dir, "zig");
@@ -164,14 +216,16 @@ export async function ensureZig(options: EnsureZigOptions = {}): Promise<string>
164
216
  return ensureZig(options);
165
217
  }
166
218
  const stage = resolve(root, `.zig-${ZIG_VERSION}-${key}-${process.pid}-${crypto.randomUUID()}`);
167
- const url = options.url ?? `https://ziglang.org/download/${ZIG_VERSION}/zig-${key}-${ZIG_VERSION}.tar.xz`;
219
+ const fetcher = options.fetcher ?? fetch;
220
+ const sourceUrl = options.url ?? process.env.SPROUTBOAT_ZIG_URL;
221
+ const urls = sourceUrl ? [sourceUrl] : await zigDownloadUrls(key, fetcher, options.timeoutMs ?? 30_000);
168
222
  console.log(`Fetching Zig ${ZIG_VERSION} (${key}, one-time)...`);
169
223
  try {
170
224
  if (await zigComplete(dir, key, expected)) return resolve(dir, "zig");
171
225
  await rm(dir, { recursive: true, force: true });
172
226
  await mkdir(stage);
173
227
  const archive = resolve(stage, "zig.tar.xz");
174
- await downloadZig(url, archive, options.fetcher ?? fetch, options.timeoutMs ?? 30_000);
228
+ await downloadZig(urls, archive, fetcher, options.timeoutMs ?? 60_000);
175
229
  const actual = await sha256File(archive);
176
230
  if (actual !== expected)
177
231
  throw new ZigToolchainError(