sproutboat 0.4.3 → 0.4.5

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/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.4.3 · runs on Bun (use `bunx`, not `npx`)
6
+ **Package:** `sproutboat` 0.4.5 · runs on Bun (use `bunx`, not `npx`)
7
7
 
8
8
  ## Commands
9
9
 
@@ -38,6 +38,7 @@ usage: sproutboat <init [name] | check [project-dir] | build [project-dir] | dep
38
38
  | `SPROUTBOAT_BINDINGS_JSON` | The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line. |
39
39
  | `SPROUTBOAT_CONFIG_DIR` | Directory for credentials.json (default ~/.config/sproutboat). |
40
40
  | `NO_COLOR` | When set, disables coloured terminal output (https://no-color.org). Output is also plain whenever stdout is not a TTY. |
41
+ | `SPROUTBOAT_NO_UPDATE_CHECK` | When set, skips the once-a-day npm check for a newer `sproutboat` release (also skipped when CI is set). |
41
42
  | `XDG_CONFIG_HOME` | Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset. |
42
43
  | `PORFFOR_VERSION` | Override the Porffor identity string recorded in the manifest. |
43
44
  | `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). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutboat",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Wrangler-shaped CLI for Sproutboat. Deploys workers to any control plane via --api-url / SPROUTBOAT_API_URL.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,7 +28,7 @@ function parseCredentials(value: JsonValue): Credentials | undefined {
28
28
  return { version: 1, activeApiUrl: isString(input.activeApiUrl) ? input.activeApiUrl : undefined, profiles };
29
29
  }
30
30
 
31
- function configDirectory(): string {
31
+ export function configDirectory(): string {
32
32
  const configured = process.env.SPROUTBOAT_CONFIG_DIR || process.env.XDG_CONFIG_HOME;
33
33
  if (configured && isAbsolute(configured)) return resolve(configured, "sproutboat");
34
34
  return resolve(homedir(), ".config", "sproutboat");
package/src/main.ts CHANGED
@@ -5,9 +5,10 @@ import { parseConfig, type SproutboatConfig } from "./config";
5
5
  import { validateHttpSyncSource } from "./source";
6
6
  import { buildArtifact } from "./build";
7
7
  import { validateManifest, type ArtifactManifest } from "./manifest";
8
- import { printDeployReport } from "./report";
8
+ import { CLI_VERSION, printDeployReport } from "./report";
9
9
  import { activeApiUrl, savedToken, saveToken } from "./credentials";
10
- import { helpText, usageLine } from "./surface";
10
+ import { helpText } from "./surface";
11
+ import { notifyIfOutdated } from "./update-check";
11
12
  import { amber, bold, dim, leaf, ok, rose } from "./style";
12
13
 
13
14
  const defaultApiUrl = "https://dashboard.sproutboat.com";
@@ -66,9 +67,14 @@ function parseVersionList(source: string): VersionSummary[] | undefined {
66
67
  return deployments;
67
68
  }
68
69
 
69
- function parseUrlResponse(source: string): { url: string } | undefined {
70
+ function parseUrlResponse(source: string): { url: string; id?: string; artifact?: string } | undefined {
70
71
  const record = jsonObject(parseJsonValue(source));
71
- return record && isString(record.url) ? { url: record.url } : undefined;
72
+ if (!record || !isString(record.url)) return undefined;
73
+ return {
74
+ url: record.url,
75
+ id: isString(record.id) ? record.id : undefined,
76
+ artifact: isString(record.artifact) ? record.artifact : undefined,
77
+ };
72
78
  }
73
79
 
74
80
  /** #55: `{ from, to }` when this deploy moves the live version onto a different
@@ -106,11 +112,22 @@ const starterHandler = `export default {
106
112
  };
107
113
  `;
108
114
 
115
+ /** An operational failure — the command was invoked correctly but could not complete. Exit 1. */
109
116
  function fail(message: string): never {
110
117
  console.error(`${rose("✗")} ${message}`);
111
118
  process.exit(1);
112
119
  }
113
120
 
121
+ /**
122
+ * The command was invoked wrong (missing/unknown arg). Exit 2, the getopt/argparse
123
+ * convention, so scripts can tell "you typed it wrong" apart from "it broke".
124
+ * `usage` is the grammar line without the `usage: ` prefix.
125
+ */
126
+ function usageError(what: string, usage: string): never {
127
+ console.error(`${rose("✗")} ${what}\n ${dim(`usage: sproutboat ${usage}`)}`);
128
+ process.exit(2);
129
+ }
130
+
114
131
  async function readProject(directory = process.cwd()) {
115
132
  const projectDirectory = resolve(directory);
116
133
  const configPath = resolve(projectDirectory, "sproutboat.jsonc");
@@ -168,7 +185,9 @@ async function deploy(args: string[]) {
168
185
  let artifactDir: string;
169
186
  let config: SproutboatConfig | undefined;
170
187
  if (artifactIndex >= 0) {
171
- artifactDir = args[artifactIndex + 1] ? resolve(args[artifactIndex + 1]) : fail("--artifact requires a directory");
188
+ artifactDir = args[artifactIndex + 1]
189
+ ? resolve(args[artifactIndex + 1])
190
+ : usageError("deploy: --artifact needs a directory", "deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait]");
172
191
  projectName = "";
173
192
  } else {
174
193
  const built = await build(directory);
@@ -239,17 +258,18 @@ async function deploy(args: string[]) {
239
258
  if (!deployed) fail("deployment response did not include a URL");
240
259
  console.log(`\n${leaf("🌱")} ${bold(leaf(`Deployed ${projectName}`))}`);
241
260
  console.log(` ${bold(deployed.url)}`);
261
+ if (deployed.id) console.log(dim(` version ${deployed.id}${deployed.artifact ? ` · artifact ${deployed.artifact.slice(0, 12)}` : ""}`));
262
+ for (const cron of config?.triggers?.crons ?? []) console.log(dim(` schedule ${cron}`));
242
263
  const drift = parsePorfforDrift(body);
243
264
  if (drift) {
244
265
  console.warn(amber(`\n! Porffor pin changed: ${drift.from} -> ${drift.to}`));
245
266
  console.warn(dim(` The previous live version stays frozen at ${drift.from}; this one is built with ${drift.to}.`));
246
267
  console.warn(dim(` The alpha compiler's output can differ between pins (see COMPAT.md) — roll back if this version misbehaves.`));
247
268
  }
248
- if (!args.includes("--no-wait")) {
249
- const healthy = await waitForHealthy(deployed.url, 90_000);
250
- console.log(healthy
251
- ? ` ${ok("serving")}`
252
- : amber(" ! not serving after 90s — Caddy may still be issuing the cert, or the sprout is crashing (`sproutboat tail`)"));
269
+ // Verify the edge actually answers (cert issuance + sprout boot). Say nothing
270
+ // on success "Deployed" already implied that; only speak up if it doesn't.
271
+ if (!args.includes("--no-wait") && !(await waitForHealthy(deployed.url, 90_000))) {
272
+ console.warn(amber(" ! not serving after 90s — Caddy may still be issuing the cert, or the sprout is crashing (`sproutboat tail`)"));
253
273
  }
254
274
  }
255
275
 
@@ -280,7 +300,7 @@ function parseLoginArgs(args: string[]) {
280
300
  const value = args[index + 1];
281
301
  if (args[index] === "--api-url" && value) apiUrl = value;
282
302
  else if (args[index] === "--token" && value) token = value;
283
- else fail("usage: sproutboat login [--api-url <url>] [--token <token>]");
303
+ else usageError(`login: unexpected argument "${args[index]}"`, "login [--api-url <url>] [--token <token>]");
284
304
  }
285
305
  return { apiUrl: apiUrl.replace(/\/$/, ""), token };
286
306
  }
@@ -331,7 +351,7 @@ async function apiCredentials() {
331
351
  }
332
352
 
333
353
  async function versions(args: string[]) {
334
- if (args[0] !== "list") fail("usage: sproutboat versions list [project-directory]");
354
+ if (args[0] !== "list") usageError(args[0] ? `versions: unknown subcommand "${args[0]}"` : "versions: missing subcommand", "versions list [project-dir]");
335
355
  const [project, { apiUrl, token }] = await Promise.all([readProject(args[1]), apiCredentials()]);
336
356
  const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments`, { headers: { "x-api-key": token } });
337
357
  const deployments = parseVersionList(await responseText(response, "could not list versions"));
@@ -341,7 +361,7 @@ async function versions(args: string[]) {
341
361
 
342
362
  async function rollback(args: string[]) {
343
363
  const id = args[0];
344
- if (!id) fail("usage: sproutboat rollback <version-id> [project-directory]");
364
+ if (!id) usageError("rollback: missing <version-id>", "rollback <version-id> [project-dir]");
345
365
  const [project, { apiUrl, token }] = await Promise.all([readProject(args[1]), apiCredentials()]);
346
366
  const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments/${id}/activate`, { method: "POST", headers: { "x-api-key": token } });
347
367
  const deployment = parseUrlResponse(await responseText(response, "rollback rejected"));
@@ -379,7 +399,7 @@ function printDomain(domain: DomainView) {
379
399
  async function domains(args: string[]) {
380
400
  const sub = args[0] && !args[0].startsWith("-") && ["list", "add", "verify", "rm"].includes(args[0]) ? args.shift()! : "list";
381
401
  const host = sub === "list" ? undefined : args.shift();
382
- if (sub !== "list" && !host) fail(`usage: sproutboat domains ${sub} <hostname> [project-dir]`);
402
+ if (sub !== "list" && !host) usageError(`domains ${sub}: missing <hostname>`, `domains ${sub} <hostname> [project-dir]`);
383
403
  const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
384
404
  const base = `${apiUrl}/api/projects/${project.config.name}/domains`;
385
405
  const auth = { "x-api-key": token };
@@ -412,7 +432,7 @@ async function domains(args: string[]) {
412
432
  async function secrets(args: string[]) {
413
433
  const sub = args[0] && ["list", "set", "rm"].includes(args[0]) ? args.shift()! : "list";
414
434
  const name = sub === "list" ? undefined : args.shift();
415
- if (sub !== "list" && !name) fail(`usage: sproutboat secrets ${sub} <NAME> [project-dir]`);
435
+ if (sub !== "list" && !name) usageError(`secrets ${sub}: missing <NAME>`, `secrets ${sub} <NAME> [project-dir]`);
416
436
  if (name && !/^[A-Z][A-Z0-9_]*$/.test(name)) fail("secret name must be UPPER_SNAKE_CASE");
417
437
  const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
418
438
  const base = `${apiUrl}/api/projects/${project.config.name}/secrets`;
@@ -452,7 +472,7 @@ async function deleteProject(args: string[]) {
452
472
  if (arg === "--yes" || arg === "-y") confirmed = true;
453
473
  else if (arg === "--name") explicitName = args[(index += 1)];
454
474
  else if (!arg.startsWith("-")) positional.push(arg);
455
- else fail(`unknown flag ${arg}\nusage: sproutboat delete [project-dir] [--name <project>] --yes`);
475
+ else usageError(`delete: unknown flag "${arg}"`, "delete [project-dir] [--name <project>] --yes");
456
476
  }
457
477
 
458
478
  const { apiUrl, token } = await apiCredentials();
@@ -479,14 +499,18 @@ function help(): never {
479
499
  process.exit(0);
480
500
  }
481
501
 
482
- /** An unrecognised command: a short pointer on stderr, exit 1. */
502
+ /** An unrecognised command: a short pointer on stderr, exit 2 (misuse, not failure). */
483
503
  function usage(): never {
484
- console.error(`unknown command "${command}"\n${usageLine()}\nrun \`sproutboat\` with no arguments for the grouped command list`);
485
- process.exit(1);
504
+ console.error(`${rose("✗")} unknown command "${command}"\n ${dim("run `sproutboat` for the list of commands")}`);
505
+ process.exit(2);
486
506
  }
487
507
 
488
508
  const [command, ...args] = process.argv.slice(2);
489
509
  if (command === undefined || command === "help" || command === "-h" || command === "--help") help();
510
+ if (command === "--version" || command === "-v") { console.log(`sproutboat ${CLI_VERSION}`); process.exit(0); }
511
+
512
+ await notifyIfOutdated(CLI_VERSION);
513
+
490
514
  switch (command) {
491
515
  case "init": await init(args[0]); break;
492
516
  case "check": await check(args[0]); break;
package/src/report.ts CHANGED
@@ -5,7 +5,7 @@ import type { ArtifactManifest } from "./manifest";
5
5
  import { bold, dim, leaf, sprout } from "./style";
6
6
 
7
7
  // Read from package.json so the banner never drifts from the published version.
8
- const CLI_VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
8
+ export const CLI_VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
9
9
 
10
10
  function bytes(n: number): string {
11
11
  if (n < 1024) return `${n} B`;
package/src/surface.ts CHANGED
@@ -66,6 +66,7 @@ export const ENV_VARS: readonly EnvVar[] = [
66
66
  { name: "SPROUTBOAT_BINDINGS_JSON", purpose: "The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line." },
67
67
  { name: "SPROUTBOAT_CONFIG_DIR", purpose: "Directory for credentials.json (default ~/.config/sproutboat)." },
68
68
  { name: "NO_COLOR", purpose: "When set, disables coloured terminal output (https://no-color.org). Output is also plain whenever stdout is not a TTY." },
69
+ { name: "SPROUTBOAT_NO_UPDATE_CHECK", purpose: "When set, skips the once-a-day npm check for a newer `sproutboat` release (also skipped when CI is set)." },
69
70
  { name: "XDG_CONFIG_HOME", purpose: "Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset." },
70
71
  { name: "PORFFOR_VERSION", purpose: "Override the Porffor identity string recorded in the manifest." },
71
72
  { name: "SB_BROKER_PORT", purpose: "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)." },
@@ -0,0 +1,55 @@
1
+ /**
2
+ * "update available 0.4.4" notice, wrangler-style. Checks npm at most once a
3
+ * day, caches the answer next to the credentials, and never fails or blocks the
4
+ * command for more than a second. Silent when up to date, offline, in CI, or
5
+ * when SPROUTBOAT_NO_UPDATE_CHECK is set.
6
+ */
7
+ import { readFile, writeFile } from "node:fs/promises";
8
+ import { resolve } from "node:path";
9
+ import { configDirectory } from "./credentials";
10
+ import { dim } from "./style";
11
+
12
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
13
+ const REGISTRY = "https://registry.npmjs.org/sproutboat/latest";
14
+
15
+ type Cache = { checkedAt: number; latest: string };
16
+
17
+ function cachePath(): string {
18
+ return resolve(configDirectory(), "update-check.json");
19
+ }
20
+
21
+ /** Numeric x.y.z compare; a trailing `-tag` (prerelease) sorts before its release. */
22
+ function isNewer(latest: string, current: string): boolean {
23
+ const parts = (v: string) => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
24
+ const [a, b] = [parts(latest), parts(current)];
25
+ for (let i = 0; i < 3; i++) {
26
+ if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0);
27
+ }
28
+ return false;
29
+ }
30
+
31
+ async function latestVersion(): Promise<string | undefined> {
32
+ try {
33
+ const cached = JSON.parse(await readFile(cachePath(), "utf8")) as Cache;
34
+ if (Date.now() - cached.checkedAt < CACHE_TTL_MS && typeof cached.latest === "string") return cached.latest;
35
+ } catch { /* no cache yet, or unreadable — fetch below */ }
36
+
37
+ try {
38
+ const response = await fetch(REGISTRY, { signal: AbortSignal.timeout(1000), headers: { accept: "application/json" } });
39
+ if (!response.ok) return undefined;
40
+ const latest = ((await response.json()) as { version?: string }).version;
41
+ if (typeof latest !== "string") return undefined;
42
+ await writeFile(cachePath(), JSON.stringify({ checkedAt: Date.now(), latest } satisfies Cache)).catch(() => {});
43
+ return latest;
44
+ } catch { /* offline / slow / DNS — skip silently */ }
45
+ return undefined;
46
+ }
47
+
48
+ /** Print one dim line to stderr if a newer `sproutboat` is on npm. Never throws. */
49
+ export async function notifyIfOutdated(current: string): Promise<void> {
50
+ if (process.env.SPROUTBOAT_NO_UPDATE_CHECK || process.env.CI) return;
51
+ const latest = await latestVersion();
52
+ if (latest && isNewer(latest, current)) {
53
+ console.error(dim(` update available: sproutboat ${current} → ${latest} · bump the dependency or run \`bunx sproutboat@latest\``));
54
+ }
55
+ }