sproutboat 0.11.10 → 0.11.11

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,14 @@ 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.11] - 2026-09-24
12
+ ### Fixed
13
+ - Updated the native runtime to `0.12.0` so repeated HMAC signing uses the raw
14
+ digest bytes between rounds, matching WebCrypto and existing password hashes.
15
+ - Updated the toolchain to `0.4.11` so `Uint8Array.from({ length: 16 }, mapFn)`
16
+ produces the intended key bytes in standalone native builds.
17
+ - Added the two-round HMAC vector to broker and standalone conformance checks.
18
+
11
19
  ## [0.11.10] - 2026-09-18
12
20
  ### Changed
13
21
  - Bumped `@sproutboat/toolchain` to `0.4.10`, pinning Porffor to the
@@ -588,7 +596,8 @@ its own package.
588
596
  - Renamed the package to `sproutboat` (was `@sproutboat/cli`); dropped the
589
597
  `sprout` bin alias in favour of a user-defined shell alias.
590
598
 
591
- [Unreleased]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.9...HEAD
599
+ [Unreleased]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.11...HEAD
600
+ [0.11.11]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.10...v0.11.11
592
601
  [0.11.10]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.9...v0.11.10
593
602
  [0.11.9]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.8...v0.11.9
594
603
  [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.11 · 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). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutboat",
3
- "version": "0.11.10",
3
+ "version": "0.11.11",
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,11 +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",
65
+ "@sproutboat/assets": "^0.3.0",
66
+ "@sproutboat/config": "^0.4.0",
67
+ "@sproutboat/runtime": "^0.12.0",
68
+ "@sproutboat/toolchain": "^0.4.11",
69
+ "@sproutboat/wire": "^0.9.0",
70
70
  "esbuild": "^0.28.2"
71
71
  },
72
72
  "devDependencies": {
@@ -78,10 +78,10 @@
78
78
  "typescript": "7.0.2"
79
79
  },
80
80
  "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"
81
+ "@sproutboat/cli-darwin-arm64": "0.11.11",
82
+ "@sproutboat/cli-darwin-x64": "0.11.11",
83
+ "@sproutboat/cli-linux-arm64": "0.11.11",
84
+ "@sproutboat/cli-linux-x64": "0.11.11"
85
85
  },
86
86
  "engines": {
87
87
  "node": ">=18"
package/src/build.ts CHANGED
@@ -171,6 +171,13 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
171
171
  bakedAssets = { manifest, files };
172
172
  }
173
173
 
174
+ // Hoisted above the compile: #126 bakes it into the binary, and the manifest
175
+ // records the same instant rather than a slightly later one.
176
+ const builtAt = new Date().toISOString();
177
+ const versionMetadata = input.config.version_metadata
178
+ ? { binding: input.config.version_metadata, id: artifactId, tag: input.config.name, timestamp: builtAt }
179
+ : undefined;
180
+
174
181
  if (input.reuseSproutPath) await cp(input.reuseSproutPath, sproutPath);
175
182
  else
176
183
  await compileSprout({
@@ -188,6 +195,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
188
195
  extraLink,
189
196
  extraCflags,
190
197
  optimize: input.optimize,
198
+ versionMetadata,
191
199
  });
192
200
 
193
201
  const sprout = await readFile(sproutPath);
@@ -204,7 +212,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
204
212
  sourceHash,
205
213
  binaryHash: digest(sprout),
206
214
  binarySize: (await stat(sproutPath)).size,
207
- builtAt: new Date().toISOString(),
215
+ builtAt,
208
216
  };
209
217
  await writeFile(resolve(artifactDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
210
218
  // Bindings live beside the manifest, not in it: the artifact manifest schema is
package/src/compile.ts CHANGED
@@ -80,6 +80,8 @@ export type CompileInput = {
80
80
  optimize?: "dev" | "release";
81
81
  /** Cross-compiler for `linux-x86_64`. Not needed, and not used, for `host`. */
82
82
  zigBin?: string;
83
+ /** #126 — `env.<binding> = { id, tag, timestamp }`, baked directly into the module. */
84
+ versionMetadata?: { binding: string; id: string; tag: string; timestamp: string };
83
85
  /**
84
86
  * `linux-x86_64` (default) cross-compiles the static musl binary every box
85
87
  * runs. `host` compiles for the machine doing the build (#62) so `sproutboat
@@ -210,6 +212,7 @@ export async function compileSprout(input: CompileInput): Promise<void> {
210
212
  input.appName,
211
213
  input.assets,
212
214
  input.transport,
215
+ input.versionMetadata,
213
216
  ),
214
217
  );
215
218
 
@@ -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/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/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 {};