sproutboat 0.11.10 → 0.11.12

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
@@ -8,6 +8,28 @@ a breaking change.
8
8
  Reconstructed from git history on 2026-09-03 for everything through v0.4.11;
9
9
  maintained going forward by the `release` skill.
10
10
 
11
+ ## [0.11.12] - 2026-09-24
12
+
13
+ ### Changed
14
+ - Porffor alpha 9 handles ESM modules without the external esbuild executable.
15
+ The CLI now requires `@sproutboat/toolchain@^0.4.12`, runtime `^0.12.1`,
16
+ and wire `^0.9.1`.
17
+ Removed the CLI's esbuild dependency, platform binary copies, and compile-time
18
+ PATH lookup. Handler validation and shimming still use Bun's built-in bundler.
19
+ The required `esbuildVersion` manifest field remains for older artifact
20
+ readers and now records `unused`.
21
+ - Local `dev` rebuilds keep a stable generated entry path for each process, so
22
+ Porffor alpha 9 can reuse unchanged compiled C units. In a local `hello`
23
+ handler-edit comparison, compilation took 549 ms with the stable path versus
24
+ 1,505 ms with changing paths. Candidate binaries remain isolated.
25
+
26
+ ## [0.11.11] - 2026-09-24
27
+ ### Fixed
28
+ - Updated the native runtime to `0.12.0` so repeated HMAC signing uses the raw
29
+ digest bytes between rounds, matching WebCrypto and existing password hashes.
30
+ - Updated the toolchain to `0.4.11` so `Uint8Array.from({ length: 16 }, mapFn)`
31
+ produces the intended key bytes in standalone native builds.
32
+ - Added the two-round HMAC vector to broker and standalone conformance checks.
11
33
  ## [0.11.10] - 2026-09-18
12
34
  ### Changed
13
35
  - Bumped `@sproutboat/toolchain` to `0.4.10`, pinning Porffor to the
@@ -588,7 +610,9 @@ its own package.
588
610
  - Renamed the package to `sproutboat` (was `@sproutboat/cli`); dropped the
589
611
  `sprout` bin alias in favour of a user-defined shell alias.
590
612
 
591
- [Unreleased]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.9...HEAD
613
+ [Unreleased]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.12...HEAD
614
+ [0.11.12]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.11...v0.11.12
615
+ [0.11.11]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.10...v0.11.11
592
616
  [0.11.10]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.9...v0.11.10
593
617
  [0.11.9]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.8...v0.11.9
594
618
  [0.11.8]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.7...v0.11.8
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.11.10 · runs on Bun (use `bunx`, not `npx`)
6
+ **Package:** `sproutboat` 0.11.12 · runs on Bun (use `bunx`, not `npx`)
7
7
 
8
8
  ## Commands
9
9
 
@@ -29,9 +29,10 @@
29
29
  | `login` | `[--api-url <url>] [--token <token>]` | Device-code browser flow, or store <token> for <url> directly. |
30
30
  | `logout` | `[--api-url <url>]` | Forget the stored credential for the active endpoint, or for <url>. |
31
31
  | `whoami` | — | Show the active endpoint and the account the stored token belongs to. |
32
+ | `complete` | `[bash | zsh | fish]` | Print a shell completion script for the top-level commands (defaults to $SHELL). |
32
33
 
33
34
  ```
34
- usage: sproutboat <init [name] | check [project-dir] | types [project-dir] | dev [project-dir] [--port <n>] [--no-watch] | build [project-dir] [--target host] [--standalone] | toolchain doctor [--json] | deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision] | versions <list | view <version-id>> [project-dir] | rollback <version-id> [project-dir] | tail [project-dir] [--sprout] | kv <list | create <name> | info <name> | rename <name> <new> | delete <name>> | <key | bulk | export> ... | d1 <list | create <name> | info <name> | rename <name> <new> | delete <name>> | r2 <list | create <name> | info <name> | rename <name> <new> | delete <name>> | queues <list | create <name> | info <name> | rename <name> <new> | delete <name>> | domains <list | add <host> | verify <host> | delete <host>> [project-dir] | secrets <list | put <NAME> [--value <value>] | delete <NAME>> [project-dir] | delete [project-dir] [--name <project>] --yes | login [--api-url <url>] [--token <token>] | logout [--api-url <url>] | whoami>
35
+ usage: sproutboat <init [name] | check [project-dir] | types [project-dir] | dev [project-dir] [--port <n>] [--no-watch] | build [project-dir] [--target host] [--standalone] | toolchain doctor [--json] | deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision] | versions <list | view <version-id>> [project-dir] | rollback <version-id> [project-dir] | tail [project-dir] [--sprout] | kv <list | create <name> | info <name> | rename <name> <new> | delete <name>> | <key | bulk | export> ... | d1 <list | create <name> | info <name> | rename <name> <new> | delete <name>> | r2 <list | create <name> | info <name> | rename <name> <new> | delete <name>> | queues <list | create <name> | info <name> | rename <name> <new> | delete <name>> | domains <list | add <host> | verify <host> | delete <host>> [project-dir] | secrets <list | put <NAME> [--value <value>] | delete <NAME>> [project-dir] | delete [project-dir] [--name <project>] --yes | login [--api-url <url>] [--token <token>] | logout [--api-url <url>] | whoami | complete [bash | zsh | fish]>
35
36
  ```
36
37
 
37
38
  ## Environment variables
@@ -57,6 +58,7 @@ usage: sproutboat <init [name] | check [project-dir] | types [project-dir] | dev
57
58
  | `NO_COLOR` | When set, disables coloured terminal output (https://no-color.org). Output is also plain whenever stdout is not a TTY. |
58
59
  | `SPROUTBOAT_NO_UPDATE_CHECK` | When set, skips the once-a-day npm check for a newer `sproutboat` release (also skipped when CI is set). |
59
60
  | `XDG_CONFIG_HOME` | Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset. |
61
+ | `SHELL` | Picks the default shell for `complete` with no argument (bash/zsh/fish). |
60
62
  | `PORFFOR_VERSION` | Override the Porffor identity string recorded in the manifest. |
61
63
  | `SB_BROKER_PORT` | Loopback port of the binding broker, read by the compiled sprout at runtime (set by the control plane, or by `src/broker.ts` for local runs). |
62
64
  | `SB_BROKER_TOKEN` | Per-deployment auth token the sprout sends on every broker frame, and the broker sends back on scheduled/queue triggers (paired with SB_BROKER_PORT). |
@@ -74,7 +76,7 @@ usage: sproutboat <init [name] | check [project-dir] | types [project-dir] | dev
74
76
  | | |
75
77
  | --- | --- |
76
78
  | Zig | `0.16.0` (`zig cc -target x86_64-linux-musl`, static) |
77
- | Provenance stamp | `zig-musl/0.16.0+porffor/8f01541+uws/360c276d` |
79
+ | Provenance stamp | `zig-musl/0.16.0+porffor/de4eb58+uws/360c276d` |
78
80
  | Artifact schema | `2` |
79
81
  | Runtime | `native-fetch` |
80
82
  | Capability profile | `http-sync-v0` |
@@ -6,10 +6,9 @@ to its upstream source.
6
6
 
7
7
  | Component | Version or revision | Distribution role | License | Source |
8
8
  | --- | --- | --- | --- | --- |
9
- | Porffor | `a415d194e74948f0ac32b9d608153ea8720c71fd` | Acquired on first application build | MIT | https://github.com/CanadaHonk/porffor |
9
+ | Porffor | `de4eb588264885b3a1596f75010e371a2052033f` | Acquired on first application build | MIT | https://github.com/CanadaHonk/porffor |
10
10
  | Zig | `0.16.0` | Acquired cross-compiler | MIT | https://ziglang.org |
11
11
  | uWebSockets and uSockets | `360c276d609d59af56ae6932adb95154ace9f15f` | Embedded source and static archive | Apache-2.0 | https://github.com/uNetworking/uWebSockets |
12
- | esbuild | `0.28.2` | Embedded platform executable | MIT | https://esbuild.github.io |
13
12
  | SQLite | `3.50.4` | Acquired for standalone builds | Public domain | https://sqlite.org |
14
13
  | BearSSL | `0.6` | Acquired for standalone builds | MIT | https://bearssl.org |
15
14
 
@@ -17,7 +16,7 @@ to its upstream source.
17
16
 
18
17
  | Component | SHA-256 |
19
18
  | --- | --- |
20
- | Porffor source archive | `677779c8be49efc28686378f665cd6fc4a8a2dd81e7b15a89298103ded960829` |
19
+ | Porffor source archive | `1a62187a93356b36cb6ac703ce9770c8917e4fa65545fd7c33ee8a180ae5a4c9` |
21
20
  | Embedded uWebSockets archive | `e83736f3f8cf9d56a1ebe6ea61625a7af12386763374d47c14cff472ada7484a` |
22
21
  | SQLite amalgamation archive | `1d3049dd0f830a025a53105fc79fd2ab9431aea99e137809d064d8ee8356b032` |
23
22
  | BearSSL source archive | `6705bba1714961b41a728dfc5debbe348d2966c117649392f8c8139efc83ff14` |
@@ -25,5 +24,4 @@ to its upstream source.
25
24
 
26
25
  The complete Apache-2.0 notice for uWebSockets and uSockets is retained in the
27
26
  embedded source archive. Porffor and BearSSL retain their MIT notices in the
28
- source archives acquired at the revisions above. esbuild's MIT notice is
29
- available from its upstream distribution.
27
+ source archives acquired at the revisions above.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutboat",
3
- "version": "0.11.10",
3
+ "version": "0.11.12",
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",
@@ -62,12 +62,11 @@
62
62
  },
63
63
  "dependencies": {
64
64
  "@sproutboat/artifact": "^0.2.0",
65
- "@sproutboat/assets": "^0.2.0",
66
- "@sproutboat/config": "^0.3.0",
67
- "@sproutboat/runtime": "^0.9.6",
68
- "@sproutboat/toolchain": "^0.4.10",
69
- "@sproutboat/wire": "^0.7.0",
70
- "esbuild": "^0.28.2"
65
+ "@sproutboat/assets": "^0.3.0",
66
+ "@sproutboat/config": "^0.4.0",
67
+ "@sproutboat/runtime": "^0.12.1",
68
+ "@sproutboat/toolchain": "^0.4.12",
69
+ "@sproutboat/wire": "^0.9.1"
71
70
  },
72
71
  "devDependencies": {
73
72
  "@oxlint/plugins": "1.81.0",
@@ -78,10 +77,10 @@
78
77
  "typescript": "7.0.2"
79
78
  },
80
79
  "optionalDependencies": {
81
- "@sproutboat/cli-darwin-arm64": "0.11.10",
82
- "@sproutboat/cli-darwin-x64": "0.11.10",
83
- "@sproutboat/cli-linux-arm64": "0.11.10",
84
- "@sproutboat/cli-linux-x64": "0.11.10"
80
+ "@sproutboat/cli-darwin-arm64": "0.11.12",
81
+ "@sproutboat/cli-darwin-x64": "0.11.12",
82
+ "@sproutboat/cli-linux-arm64": "0.11.12",
83
+ "@sproutboat/cli-linux-x64": "0.11.12"
85
84
  },
86
85
  "engines": {
87
86
  "node": ">=18"
@@ -15,7 +15,7 @@
15
15
  * GitHub's only x64 macOS image (`macos-13`) routinely sits queued for half an
16
16
  * hour while every other leg finishes in one minute.
17
17
  */
18
- import { chmod, copyFile, mkdir } from "node:fs/promises";
18
+ import { chmod, copyFile, mkdir, rm } from "node:fs/promises";
19
19
  import { resolve } from "node:path";
20
20
  import { ensureZigArchive, type ZigPlatform } from "../src/toolchain";
21
21
 
@@ -39,49 +39,11 @@ function hostTarget(): Target {
39
39
 
40
40
  const root = resolve(import.meta.dir, "..");
41
41
 
42
- /** esbuild ships one binary per platform (`@esbuild/<os>-<arch>`); a cross
43
- * target's copy is never installed locally (npm/bun refuse it — the package
44
- * declares `os`/`cpu` and gets skipped for any other host), so fetch that
45
- * platform's tarball from the registry instead, same shape as ensureZig's
46
- * prebuilt-archive fetches. */
47
- async function esbuildBinaryFor(target: Target): Promise<string> {
48
- const host = hostTarget();
49
- if (target.platform === host.platform && target.arch === host.arch) {
50
- try {
51
- return Bun.resolveSync(`@esbuild/${target.platform}-${target.arch}/bin/esbuild`, import.meta.dir);
52
- } catch {
53
- throw new Error("host esbuild binary is missing; run bun install first");
54
- }
55
- }
56
- // SAFETY: package.json is this repo's own manifest, with a required esbuild dependency entry.
57
- const pkg = (await Bun.file(resolve(root, "package.json")).json()) as { dependencies: { esbuild: string } };
58
- const version = pkg.dependencies.esbuild.replace(/^[\^~]/, "");
59
- const name = `${target.platform}-${target.arch}`;
60
- const cacheDir = resolve(
61
- process.env.SPROUTBOAT_TOOLCHAIN_CACHE ?? `${process.env.HOME}/.cache/sproutboat`,
62
- `esbuild-${version}-${name}`,
63
- );
64
- const cached = resolve(cacheDir, "esbuild");
65
- if (await Bun.file(cached).exists()) return cached;
66
- const res = await fetch(`https://registry.npmjs.org/@esbuild/${name}/-/${name}-${version}.tgz`);
67
- if (!res.ok) throw new Error(`could not fetch @esbuild/${name}@${version}: HTTP ${res.status}`);
68
- const tarPath = resolve(cacheDir, "esbuild.tgz");
69
- await mkdir(cacheDir, { recursive: true });
70
- await Bun.write(tarPath, await res.arrayBuffer());
71
- const untar = Bun.spawn(["tar", "-xzf", tarPath, "-C", cacheDir, "--strip-components=2", "package/bin/esbuild"], {
72
- stdout: "pipe",
73
- stderr: "pipe",
74
- });
75
- const [code, err] = await Promise.all([untar.exited, new Response(untar.stderr).text()]);
76
- if (code !== 0) throw new Error(`could not extract @esbuild/${name}: ${err.trim()}`);
77
- await chmod(cached, 0o755);
78
- return cached;
79
- }
80
-
81
42
  async function buildOne(target: Target): Promise<void> {
82
43
  const packageDir = resolve(root, "platform-packages", `${target.platform}-${target.arch}`);
83
44
  const out = resolve(packageDir, "bin", "sproutboat");
84
45
  await mkdir(resolve(out, ".."), { recursive: true });
46
+ await rm(resolve(packageDir, "bin", "esbuild"), { force: true });
85
47
  // SAFETY: package.json is this release's manifest and npm requires its version.
86
48
  const version = ((await Bun.file(resolve(root, "package.json")).json()) as { version: string }).version;
87
49
  // SAFETY: every checked-in platform package manifest owns a required string version.
@@ -103,9 +65,6 @@ async function buildOne(target: Target): Promise<void> {
103
65
  { cwd: root, stdout: "inherit", stderr: "inherit" },
104
66
  );
105
67
  if ((await child.exited) !== 0) throw new Error(`compile failed for ${target.platform}-${target.arch}`);
106
- const esbuild = await esbuildBinaryFor(target);
107
- const packagedEsbuild = resolve(packageDir, "bin", "esbuild");
108
- await copyFile(esbuild, packagedEsbuild);
109
68
  // The compressed archive (~50 MB), not the ~400 MB extracted install: Zig's
110
69
  // lib/ carries libc/libc++ sources for every target it can cross-compile to,
111
70
  // which only matters once, on extract. `ensureZig` (src/toolchain.ts) looks
@@ -114,7 +73,6 @@ async function buildOne(target: Target): Promise<void> {
114
73
  // same shape as the vendored uWebSockets archive.
115
74
  await copyFile(await ensureZigArchive(target.zigPlatform), resolve(packageDir, "bin", "zig.tar.xz"));
116
75
  await copyFile(resolve(root, "THIRD_PARTY_NOTICES.md"), resolve(packageDir, "THIRD_PARTY_NOTICES.md"));
117
- await chmod(packagedEsbuild, 0o755);
118
76
  await chmod(out, 0o755);
119
77
  }
120
78
 
package/src/build.ts CHANGED
@@ -47,6 +47,8 @@ export type BuildInput = {
47
47
  reuseSproutPath?: string;
48
48
  /** An isolated artifact directory, used by dev candidates to avoid mixing snapshots. */
49
49
  outputDirectory?: string;
50
+ /** Stable compiler input used only by the local dev loop. */
51
+ generatedPath?: string;
50
52
  };
51
53
 
52
54
  export type BuildOutput = {
@@ -171,10 +173,18 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
171
173
  bakedAssets = { manifest, files };
172
174
  }
173
175
 
176
+ // Hoisted above the compile: #126 bakes it into the binary, and the manifest
177
+ // records the same instant rather than a slightly later one.
178
+ const builtAt = new Date().toISOString();
179
+ const versionMetadata = input.config.version_metadata
180
+ ? { binding: input.config.version_metadata, id: artifactId, tag: input.config.name, timestamp: builtAt }
181
+ : undefined;
182
+
174
183
  if (input.reuseSproutPath) await cp(input.reuseSproutPath, sproutPath);
175
184
  else
176
185
  await compileSprout({
177
186
  sourcePath: input.sourcePath,
187
+ generatedPath: input.generatedPath,
178
188
  source: input.source,
179
189
  outPath: sproutPath,
180
190
  vars: input.config.vars ?? {},
@@ -188,6 +198,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
188
198
  extraLink,
189
199
  extraCflags,
190
200
  optimize: input.optimize,
201
+ versionMetadata,
191
202
  });
192
203
 
193
204
  const sprout = await readFile(sproutPath);
@@ -204,7 +215,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
204
215
  sourceHash,
205
216
  binaryHash: digest(sprout),
206
217
  binarySize: (await stat(sproutPath)).size,
207
- builtAt: new Date().toISOString(),
218
+ builtAt,
208
219
  };
209
220
  await writeFile(resolve(artifactDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
210
221
  // Bindings live beside the manifest, not in it: the artifact manifest schema is
package/src/bundle.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * #89 — bundle the handler before Porffor sees it.
3
3
  *
4
- * Porffor compiles one self-contained module, so until now a project was one
5
- * file with no imports: no router, no validation library, no SDK, no splitting
6
- * a codebase in two. Bundling first lifts that without touching the compiler.
4
+ * Porffor alpha 9 supports ESM imports. We still bundle first so capability
5
+ * validation sees every dependency and the runtime wrapper receives one
6
+ * controlled default export.
7
7
  *
8
8
  * Bun's bundler resolves relative imports across the project and bare
9
9
  * specifiers out of the project's own `node_modules`, then emits a single ESM
package/src/compile.ts CHANGED
@@ -10,7 +10,6 @@
10
10
  * unusable it stops with its specific integrity or archive error. Contributors
11
11
  * may explicitly opt into Porffor's source fallback.
12
12
  */
13
- import { existsSync } from "node:fs";
14
13
  import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
15
14
  import { dirname, resolve } from "node:path";
16
15
  import { ensurePorfforPatched } from "./patch-porffor";
@@ -50,6 +49,8 @@ const COMPILE_TIMEOUT_MS = Number(process.env.SPROUTBOAT_COMPILE_TIMEOUT_MS || 6
50
49
 
51
50
  export type CompileInput = {
52
51
  sourcePath: string;
52
+ /** Stable compiler input for dev rebuilds, independent of the candidate binary path. */
53
+ generatedPath?: string;
53
54
  /** The bundled module (#89). Falls back to reading `sourcePath` verbatim. */
54
55
  source?: string;
55
56
  outPath: string;
@@ -80,6 +81,8 @@ export type CompileInput = {
80
81
  optimize?: "dev" | "release";
81
82
  /** Cross-compiler for `linux-x86_64`. Not needed, and not used, for `host`. */
82
83
  zigBin?: string;
84
+ /** #126 — `env.<binding> = { id, tag, timestamp }`, baked directly into the module. */
85
+ versionMetadata?: { binding: string; id: string; tag: string; timestamp: string };
83
86
  /**
84
87
  * `linux-x86_64` (default) cross-compiles the static musl binary every box
85
88
  * runs. `host` compiles for the machine doing the build (#62) so `sproutboat
@@ -193,7 +196,8 @@ export async function compileSprout(input: CompileInput): Promise<void> {
193
196
  // OS may still be executing. The linker cannot overwrite either, so clear it
194
197
  // first rather than failing with "can't write output file".
195
198
  await rm(input.outPath, { force: true });
196
- const generatedPath = resolve(outDir, "sprout.generated.js");
199
+ const generatedPath = input.generatedPath ?? resolve(outDir, "sprout.generated.js");
200
+ await mkdir(dirname(generatedPath), { recursive: true });
197
201
  const [source, prelude] = await Promise.all([
198
202
  input.source === undefined ? readFile(input.sourcePath, "utf8") : Promise.resolve(input.source),
199
203
  loadPrelude(input.transport ?? "broker"),
@@ -210,6 +214,7 @@ export async function compileSprout(input: CompileInput): Promise<void> {
210
214
  input.appName,
211
215
  input.assets,
212
216
  input.transport,
217
+ input.versionMetadata,
213
218
  ),
214
219
  );
215
220
 
@@ -219,22 +224,7 @@ export async function compileSprout(input: CompileInput): Promise<void> {
219
224
  // below instead and has no zigBin to contribute here.
220
225
  const zigDir = input.zigBin ? `${dirname(input.zigBin)}:` : "";
221
226
  const hostCompiler = input.target === "host" ? await resolveHostCompiler() : undefined;
222
- // The per-platform binary ships esbuild next to it; a compiled build finds it
223
- // there. Running from the npm package under Bun, `process.execPath` is Bun
224
- // itself and `npm i -g` puts no dependency `.bin` on PATH, so resolve the
225
- // esbuild dependency directly. `Bun.which` is the last resort.
226
- const packagedEsbuild = resolve(dirname(process.execPath), "esbuild");
227
- let esbuild: string | null = existsSync(packagedEsbuild) ? packagedEsbuild : null;
228
- if (!esbuild) {
229
- try {
230
- esbuild = Bun.resolveSync("esbuild/bin/esbuild", import.meta.dir);
231
- } catch {
232
- esbuild = null;
233
- }
234
- }
235
- esbuild ??= Bun.which("esbuild");
236
- if (!esbuild) throw new Error("esbuild could not be located; install it or reinstall sproutboat");
237
- const path = `${zigDir}${dirname(esbuild)}:${process.env.PATH ?? ""}`;
227
+ const path = `${zigDir}${process.env.PATH ?? ""}`;
238
228
  const command = PACKAGED
239
229
  ? [process.execPath, "__porffor", launcher]
240
230
  : [process.execPath, resolve(import.meta.dir, "main.ts"), "__porffor", launcher];
@@ -59,23 +59,37 @@ export async function activeApiUrl(): Promise<string | undefined> {
59
59
  return (await readCredentials()).activeApiUrl;
60
60
  }
61
61
 
62
- /** #79 `logout`: drop one endpoint's token. Returns false when none was stored. */
63
- export async function forgetToken(apiUrl: string): Promise<boolean> {
62
+ /**
63
+ * #79 `logout`: drop one endpoint's token. Returns `removed: false` when none
64
+ * was stored. baronunread/sproutboat#204 — used to repoint `activeApiUrl` to
65
+ * an arbitrary surviving profile chosen by object key order; now it just
66
+ * clears the active endpoint, so the next command falls through to
67
+ * `resolveApiUrl()`'s documented default instead of a silently-picked one.
68
+ */
69
+ export async function forgetToken(apiUrl: string): Promise<{ removed: boolean; activeApiUrl?: string }> {
64
70
  const credentials = await readCredentials();
65
- if (!credentials.profiles[apiUrl]) return false;
71
+ if (!credentials.profiles[apiUrl]) return { removed: false };
66
72
  delete credentials.profiles[apiUrl];
67
- if (credentials.activeApiUrl === apiUrl) {
68
- credentials.activeApiUrl = Object.keys(credentials.profiles)[0];
69
- }
73
+ if (credentials.activeApiUrl === apiUrl) credentials.activeApiUrl = undefined;
70
74
  await writeCredentials(credentials);
71
- return true;
75
+ return { removed: true, activeApiUrl: credentials.activeApiUrl };
72
76
  }
73
77
 
74
- export async function saveToken(apiUrl: string, token: string): Promise<void> {
78
+ /**
79
+ * baronunread/sproutboat#204 — used to unconditionally repoint the
80
+ * machine-wide active endpoint to whatever was just logged into, so logging
81
+ * into a local dev control plane for one project silently became the deploy
82
+ * target for every unrelated project on the machine. Now the active endpoint
83
+ * is only set the first time (nothing saved yet); a later login to a second
84
+ * endpoint keeps the existing active endpoint and reports it, so the caller
85
+ * can say so instead of silently switching.
86
+ */
87
+ export async function saveToken(apiUrl: string, token: string): Promise<{ activeApiUrl: string }> {
75
88
  const credentials = await readCredentials();
76
89
  credentials.profiles[apiUrl] = { token };
77
- credentials.activeApiUrl = apiUrl;
90
+ if (!credentials.activeApiUrl) credentials.activeApiUrl = apiUrl;
78
91
  await writeCredentials(credentials);
92
+ return { activeApiUrl: credentials.activeApiUrl };
79
93
  }
80
94
 
81
95
  /** Atomic 0600 write of the whole credentials file. */
package/src/dev.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  * project, one port.
12
12
  */
13
13
  import { existsSync, watch, type FSWatcher } from "node:fs";
14
+ import { randomUUID } from "node:crypto";
14
15
  import { mkdir, readFile, rm } from "node:fs/promises";
15
16
  import { connect } from "node:net";
16
17
  import { dirname, resolve } from "node:path";
@@ -21,6 +22,7 @@ import { amber, dim, leaf, ok } from "./style";
21
22
  import type { SproutboatConfig } from "./config";
22
23
 
23
24
  const RESTART_DEBOUNCE_MS = 120;
25
+ const DEV_COMPILE_SESSION = randomUUID();
24
26
 
25
27
  export type DevInput = {
26
28
  projectDir: string;
@@ -121,6 +123,14 @@ async function start(input: DevInput, port: number): Promise<Running> {
121
123
  optimize: "dev",
122
124
  reuseSproutPath: input.reuseSproutPath,
123
125
  outputDirectory: candidateDir,
126
+ // Alpha 9 keys its C-unit cache by the entry's absolute path. Keep that
127
+ // path fixed across saves while each candidate keeps its own binary.
128
+ generatedPath: resolve(
129
+ input.projectDir,
130
+ ".sproutboat/dev-compile",
131
+ `${process.pid}-${DEV_COMPILE_SESSION}`,
132
+ "sprout.generated.js",
133
+ ),
124
134
  });
125
135
  } catch (error) {
126
136
  await rm(candidateDir, { recursive: true, force: true });
package/src/main.ts CHANGED
@@ -14,7 +14,7 @@ import { buildStandalone } from "./standalone-build";
14
14
  import { hostTarget, validateManifest, type ArtifactManifest } from "./manifest";
15
15
  import { CLI_VERSION, printDeployReport } from "./report";
16
16
  import { activeApiUrl, forgetToken, savedToken, saveToken } from "./credentials";
17
- import { helpText, STORAGE_PRODUCTS, STORAGE_VERBS, type StorageProduct } from "./surface";
17
+ import { CLI_NAME, COMMANDS, helpText, STORAGE_PRODUCTS, STORAGE_VERBS, type StorageProduct } from "./surface";
18
18
  import { notifyIfOutdated } from "./update-check";
19
19
  import { controlVersionWarning } from "./api-version";
20
20
  import { amber, bold, dim, leaf, ok, rose } from "./style";
@@ -35,6 +35,26 @@ if (internalModeAt > 0) {
35
35
 
36
36
  const defaultApiUrl = "https://dashboard.sproutboat.com";
37
37
 
38
+ type ApiUrlSource = "SPROUTBOAT_API_URL" | "--api-url" | "saved active endpoint" | "default";
39
+
40
+ /**
41
+ * baronunread/sproutboat#204 — the single place `defaultApiUrl` and
42
+ * `activeApiUrl()` are read. Used to be implemented three different ways
43
+ * across `apiCredentials()`, `whoami()` and `parseLoginArgs()`, none of which
44
+ * told the user which endpoint it picked; `whoami` could report "not logged
45
+ * in" on a machine where `deploy` would happily target prod. Precedence: an
46
+ * explicit `--api-url` flag, then `SPROUTBOAT_API_URL`, then the saved active
47
+ * endpoint, then the default.
48
+ */
49
+ async function resolveApiUrl(explicitApiUrl?: string): Promise<{ apiUrl: string; source: ApiUrlSource }> {
50
+ if (explicitApiUrl) return { apiUrl: explicitApiUrl.replace(/\/$/, ""), source: "--api-url" };
51
+ const envUrl = process.env.SPROUTBOAT_API_URL;
52
+ if (envUrl) return { apiUrl: envUrl.replace(/\/$/, ""), source: "SPROUTBOAT_API_URL" };
53
+ const saved = await activeApiUrl();
54
+ if (saved) return { apiUrl: saved.replace(/\/$/, ""), source: "saved active endpoint" };
55
+ return { apiUrl: defaultApiUrl, source: "default" };
56
+ }
57
+
38
58
  /** Warn at most once per run: every API response carries the headers, and one
39
59
  * command makes several calls. */
40
60
  let skewWarned = false;
@@ -407,10 +427,15 @@ async function provisionBindings(directory = process.cwd()): Promise<void> {
407
427
 
408
428
  /** #79 — wrangler parity: drop the stored credential for an endpoint. */
409
429
  async function logout(args: string[]) {
410
- const { apiUrl } = parseLoginArgs(args);
411
- console.log(
412
- (await forgetToken(apiUrl)) ? ok(`forgot the credential for ${apiUrl}`) : `no stored credential for ${apiUrl}`,
413
- );
430
+ const { apiUrl } = await parseLoginArgs(args);
431
+ const result = await forgetToken(apiUrl);
432
+ if (!result.removed) {
433
+ console.log(`no stored credential for ${apiUrl}`);
434
+ return;
435
+ }
436
+ console.log(ok(`forgot the credential for ${apiUrl}`));
437
+ if (result.activeApiUrl === undefined)
438
+ console.log(dim("no active endpoint remains — the next command falls through to the default"));
414
439
  }
415
440
 
416
441
  /**
@@ -419,13 +444,9 @@ async function logout(args: string[]) {
419
444
  * works rather than only reporting what is on disk.
420
445
  */
421
446
  async function whoami() {
422
- const apiUrl = process.env.SPROUTBOAT_API_URL || (await activeApiUrl());
423
- if (!apiUrl) {
424
- console.log("not logged in — run `sproutboat login`");
425
- return;
426
- }
447
+ const { apiUrl, source } = await resolveApiUrl();
427
448
  const token = process.env.SPROUTBOAT_TOKEN || (await savedToken(apiUrl));
428
- console.log(`endpoint ${apiUrl}`);
449
+ console.log(`endpoint ${apiUrl} (${source})`);
429
450
  if (!token) {
430
451
  console.log(`account ${dim("no stored token — run `sproutboat login`")}`);
431
452
  return;
@@ -489,7 +510,8 @@ async function deploy(args: string[]) {
489
510
  console.log("\n--dry-run: not uploading.");
490
511
  return;
491
512
  }
492
- const { apiUrl, token } = await apiCredentials();
513
+ const { apiUrl, token, source } = await apiCredentials();
514
+ console.log(`endpoint ${apiUrl} (${source})`);
493
515
  const form = new FormData();
494
516
  form.set("manifest", new File([await manifest.arrayBuffer()], "manifest.json", { type: "application/json" }));
495
517
  form.set("sprout", new File([await sprout.arrayBuffer()], "sprout", { type: "application/octet-stream" }));
@@ -516,7 +538,7 @@ async function deploy(args: string[]) {
516
538
  }
517
539
  }
518
540
 
519
- const response = await fetch(`${apiUrl.replace(/\/$/, "")}/api/projects/${projectName}/deployments`, {
541
+ const response = await fetch(`${apiUrl}/api/projects/${projectName}/deployments`, {
520
542
  method: "POST",
521
543
  headers: { "x-api-key": token },
522
544
  body: form,
@@ -579,25 +601,34 @@ async function waitForHealthy(url: string, timeoutMs: number): Promise<boolean>
579
601
  return false;
580
602
  }
581
603
 
582
- function parseLoginArgs(args: string[]) {
583
- let apiUrl = process.env.SPROUTBOAT_API_URL || defaultApiUrl;
604
+ async function parseLoginArgs(args: string[]) {
605
+ let explicitApiUrl: string | undefined;
584
606
  let token: string | undefined;
585
607
  for (let index = 0; index < args.length; index += 2) {
586
608
  const value = args[index + 1];
587
- if (args[index] === "--api-url" && value) apiUrl = value;
609
+ if (args[index] === "--api-url" && value) explicitApiUrl = value;
588
610
  else if (args[index] === "--token" && value) token = value;
589
611
  else usageError(`login: unexpected argument "${args[index]}"`, "login [--api-url <url>] [--token <token>]");
590
612
  }
591
- return { apiUrl: apiUrl.replace(/\/$/, ""), token };
613
+ const { apiUrl, source } = await resolveApiUrl(explicitApiUrl);
614
+ return { apiUrl, source, token };
615
+ }
616
+
617
+ /** Announce a login that left the machine's active endpoint pointed elsewhere. */
618
+ function reportActiveEndpoint(apiUrl: string, active: string): void {
619
+ if (active !== apiUrl)
620
+ console.log(dim(`active endpoint remains ${active} — \`sproutboat logout\` it first to switch`));
592
621
  }
593
622
 
594
623
  async function login(args: string[]) {
595
- const { apiUrl, token: directToken } = parseLoginArgs(args);
624
+ const { apiUrl, source, token: directToken } = await parseLoginArgs(args);
625
+ console.log(`endpoint ${apiUrl} (${source})`);
596
626
  // Self-hosted / non-interactive: skip the browser flow and store the token
597
627
  // the admin already holds (e.g. SPROUTBOAT_BOOTSTRAP_TOKEN).
598
628
  if (directToken) {
599
- await saveToken(apiUrl, directToken);
629
+ const { activeApiUrl: active } = await saveToken(apiUrl, directToken);
600
630
  console.log(`Saved credentials for ${apiUrl}.`);
631
+ reportActiveEndpoint(apiUrl, active);
601
632
  return;
602
633
  }
603
634
  const response = await fetch(`${apiUrl}/api/cli/authorizations`, { method: "POST" });
@@ -620,7 +651,7 @@ async function login(args: string[]) {
620
651
  console.log(`Confirm code: ${authorization.userCode}`);
621
652
  while (new Date(authorization.expiresAt).getTime() > Date.now()) {
622
653
  await Bun.sleep(Math.max(authorization.interval, 1) * 1000);
623
- const exchange = await fetch(`${apiUrl.replace(/\/$/, "")}/api/cli/authorizations/token`, {
654
+ const exchange = await fetch(`${apiUrl}/api/cli/authorizations/token`, {
624
655
  method: "POST",
625
656
  headers: { "content-type": "application/json" },
626
657
  body: JSON.stringify({ deviceCode: authorization.deviceCode }),
@@ -630,18 +661,19 @@ async function login(args: string[]) {
630
661
  if (!exchange.ok) fail(`login failed (${exchange.status}): ${result}`);
631
662
  const token = parseToken(result);
632
663
  if (!token) fail("login response did not include a CLI token");
633
- await saveToken(apiUrl, token);
664
+ const { activeApiUrl: active } = await saveToken(apiUrl, token);
634
665
  console.log(ok("login approved — credentials saved for this endpoint"));
666
+ reportActiveEndpoint(apiUrl, active);
635
667
  return;
636
668
  }
637
669
  fail("login expired before approval");
638
670
  }
639
671
 
640
672
  async function apiCredentials() {
641
- const apiUrl = (process.env.SPROUTBOAT_API_URL || (await activeApiUrl()) || defaultApiUrl).replace(/\/$/, "");
673
+ const { apiUrl, source } = await resolveApiUrl();
642
674
  const token = process.env.SPROUTBOAT_TOKEN || (await savedToken(apiUrl));
643
675
  if (!token) fail("not logged in; run sproutboat login or set SPROUTBOAT_TOKEN for this command");
644
- return { apiUrl, token };
676
+ return { apiUrl, token, source };
645
677
  }
646
678
 
647
679
  async function versions(args: string[]) {
@@ -1231,12 +1263,36 @@ async function toolchain(args: string[]): Promise<void> {
1231
1263
  console.log(`macOS SDK: ${report.prerequisites.sdk ?? "missing: run xcode-select --install"}`);
1232
1264
  }
1233
1265
 
1266
+ /** `sproutboat <command> --help`: that command's full usage line + summary, exit 0. */
1267
+ function commandHelp(name: string): never {
1268
+ const found = COMMANDS.find((c) => c.name === name);
1269
+ if (!found) usage();
1270
+ console.log(`${dim(`usage: ${CLI_NAME} ${name}${found.args ? ` ${found.args}` : ""}`)}\n\n${found.summary}`);
1271
+ process.exit(0);
1272
+ }
1273
+
1274
+ const BASH_COMPLETION = (names: string) =>
1275
+ `_sproutboat_complete() {\n local cur=\${COMP_WORDS[COMP_CWORD]}\n if [ "$COMP_CWORD" -eq 1 ]; then\n COMPREPLY=($(compgen -W "${names}" -- "$cur"))\n fi\n}\ncomplete -F _sproutboat_complete ${CLI_NAME}\n`;
1276
+ const ZSH_COMPLETION = (names: string) => `#compdef ${CLI_NAME}\n_arguments '1: :(${names})'\n`;
1277
+ const FISH_COMPLETION = (names: string) => `complete -c ${CLI_NAME} -n "__fish_use_subcommand" -a "${names}"\n`;
1278
+
1279
+ /** `sproutboat complete [shell]`: a static completion script for the top-level commands. */
1280
+ function complete(shell?: string): void {
1281
+ const names = COMMANDS.map((c) => c.name).join(" ");
1282
+ const scripts = { bash: BASH_COMPLETION(names), zsh: ZSH_COMPLETION(names), fish: FISH_COMPLETION(names) };
1283
+ const key = shell ?? process.env.SHELL?.split("/").pop() ?? "bash";
1284
+ const script = key === "bash" || key === "zsh" || key === "fish" ? scripts[key] : undefined;
1285
+ if (!script) usageError(`complete: unsupported shell "${key}"`, "complete [bash | zsh | fish]");
1286
+ console.log(script);
1287
+ }
1288
+
1234
1289
  const [command, ...args] = process.argv.slice(2);
1235
1290
  if (command === undefined || command === "help" || command === "-h" || command === "--help") help();
1236
1291
  if (command === "--version" || command === "-v") {
1237
1292
  console.log(`sproutboat ${CLI_VERSION}`);
1238
1293
  process.exit(0);
1239
1294
  }
1295
+ if (args.includes("-h") || args.includes("--help")) commandHelp(command);
1240
1296
 
1241
1297
  await notifyIfOutdated(CLI_VERSION);
1242
1298
 
@@ -1302,6 +1358,9 @@ switch (command) {
1302
1358
  case "delete":
1303
1359
  await deleteProject(args);
1304
1360
  break;
1361
+ case "complete":
1362
+ complete(args[0]);
1363
+ break;
1305
1364
  default:
1306
1365
  usage();
1307
1366
  }
package/src/surface.ts CHANGED
@@ -195,6 +195,13 @@ export const COMMANDS: readonly Command[] = [
195
195
  args: "",
196
196
  summary: "Show the active endpoint and the account the stored token belongs to.",
197
197
  },
198
+ {
199
+ name: "complete",
200
+ group: "Account",
201
+ emoji: "⌨",
202
+ args: "[bash | zsh | fish]",
203
+ summary: "Print a shell completion script for the top-level commands (defaults to $SHELL).",
204
+ },
198
205
  ];
199
206
 
200
207
  export type EnvVar = { name: string; purpose: string };
@@ -264,6 +271,7 @@ export const ENV_VARS: readonly EnvVar[] = [
264
271
  purpose: "When set, skips the once-a-day npm check for a newer `sproutboat` release (also skipped when CI is set).",
265
272
  },
266
273
  { name: "XDG_CONFIG_HOME", purpose: "Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset." },
274
+ { name: "SHELL", purpose: "Picks the default shell for `complete` with no argument (bash/zsh/fish)." },
267
275
  { name: "PORFFOR_VERSION", purpose: "Override the Porffor identity string recorded in the manifest." },
268
276
  {
269
277
  name: "SB_BROKER_PORT",
package/src/toolchain.ts CHANGED
@@ -677,14 +677,9 @@ export function porfforVersion(): string {
677
677
  }
678
678
 
679
679
  export function esbuildVersion(): string {
680
- try {
681
- const pkg = Bun.resolveSync("esbuild/package.json", import.meta.dir);
682
- // SAFETY: esbuild's package.json always has a string `version`; defaulted below.
683
- const parsed = JSON.parse(readFileSync(pkg, "utf8")) as { version?: string };
684
- return parsed.version || "unknown";
685
- } catch {
686
- return "unknown";
687
- }
680
+ // Legacy artifact field, retained so older control planes can read manifests.
681
+ // Handler bundling uses Bun.build and Porffor alpha 9 needs no esbuild binary.
682
+ return "unused";
688
683
  }
689
684
 
690
685
  function uwsCommit(): string {
package/src/types-gen.ts CHANGED
@@ -55,6 +55,9 @@ function entries(config: SproutboatConfig): Entry[] {
55
55
  if (config.assets?.binding) {
56
56
  out.push({ name: config.assets.binding, type: "Fetcher", note: `static assets: ${config.assets.directory}` });
57
57
  }
58
+ if (config.version_metadata) {
59
+ out.push({ name: config.version_metadata, type: "VersionMetadata" });
60
+ }
58
61
  return out;
59
62
  }
60
63
 
@@ -14,12 +14,19 @@
14
14
  declare global {
15
15
  // ---------------------------------------------------------------- KV
16
16
 
17
+ interface KVPutOptions {
18
+ /** Seconds from now until the key expires. Must be at least 60, matching Cloudflare. */
19
+ expirationTtl?: number;
20
+ /** Absolute expiration as unix seconds. */
21
+ expiration?: number;
22
+ }
23
+
17
24
  interface KVNamespace {
18
- /** The stored string, or `null`. Synchronous: no `await`. */
25
+ /** The stored string, or `null`. Synchronous: no `await`. An expired key reads as absent. */
19
26
  get(key: string): string | null;
20
- put(key: string, value: string): void;
27
+ put(key: string, value: string, options?: KVPutOptions): void;
21
28
  delete(key: string): void;
22
- /** Keys under `prefix` (all keys when omitted). */
29
+ /** Keys under `prefix` (all keys when omitted). Expired keys are omitted. */
23
30
  list(prefix?: string): string[];
24
31
  }
25
32
 
@@ -320,6 +327,20 @@ declare global {
320
327
  /** Runs after `storage.setAlarm`, with no request in flight. */
321
328
  alarm?(): void | Promise<void>;
322
329
  }
330
+
331
+ // ------------------------------------------------------- Version metadata
332
+
333
+ /**
334
+ * #126 — named by `version_metadata` in `sproutboat.jsonc`. Baked into the
335
+ * binary at build time from the artifact's own digest: `id` matches the
336
+ * `artifact` field `sproutboat versions list` prints for this build, `tag`
337
+ * is the project name, `timestamp` is when it was built.
338
+ */
339
+ interface VersionMetadata {
340
+ id: string;
341
+ tag: string;
342
+ timestamp: string;
343
+ }
323
344
  }
324
345
 
325
346
  export {};