sproutboat 0.4.3 → 0.4.4

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.4 · 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.4",
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,6 +258,8 @@ 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}`));
@@ -280,7 +301,7 @@ function parseLoginArgs(args: string[]) {
280
301
  const value = args[index + 1];
281
302
  if (args[index] === "--api-url" && value) apiUrl = value;
282
303
  else if (args[index] === "--token" && value) token = value;
283
- else fail("usage: sproutboat login [--api-url <url>] [--token <token>]");
304
+ else usageError(`login: unexpected argument "${args[index]}"`, "login [--api-url <url>] [--token <token>]");
284
305
  }
285
306
  return { apiUrl: apiUrl.replace(/\/$/, ""), token };
286
307
  }
@@ -331,7 +352,7 @@ async function apiCredentials() {
331
352
  }
332
353
 
333
354
  async function versions(args: string[]) {
334
- if (args[0] !== "list") fail("usage: sproutboat versions list [project-directory]");
355
+ if (args[0] !== "list") usageError(args[0] ? `versions: unknown subcommand "${args[0]}"` : "versions: missing subcommand", "versions list [project-dir]");
335
356
  const [project, { apiUrl, token }] = await Promise.all([readProject(args[1]), apiCredentials()]);
336
357
  const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments`, { headers: { "x-api-key": token } });
337
358
  const deployments = parseVersionList(await responseText(response, "could not list versions"));
@@ -341,7 +362,7 @@ async function versions(args: string[]) {
341
362
 
342
363
  async function rollback(args: string[]) {
343
364
  const id = args[0];
344
- if (!id) fail("usage: sproutboat rollback <version-id> [project-directory]");
365
+ if (!id) usageError("rollback: missing <version-id>", "rollback <version-id> [project-dir]");
345
366
  const [project, { apiUrl, token }] = await Promise.all([readProject(args[1]), apiCredentials()]);
346
367
  const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments/${id}/activate`, { method: "POST", headers: { "x-api-key": token } });
347
368
  const deployment = parseUrlResponse(await responseText(response, "rollback rejected"));
@@ -379,7 +400,7 @@ function printDomain(domain: DomainView) {
379
400
  async function domains(args: string[]) {
380
401
  const sub = args[0] && !args[0].startsWith("-") && ["list", "add", "verify", "rm"].includes(args[0]) ? args.shift()! : "list";
381
402
  const host = sub === "list" ? undefined : args.shift();
382
- if (sub !== "list" && !host) fail(`usage: sproutboat domains ${sub} <hostname> [project-dir]`);
403
+ if (sub !== "list" && !host) usageError(`domains ${sub}: missing <hostname>`, `domains ${sub} <hostname> [project-dir]`);
383
404
  const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
384
405
  const base = `${apiUrl}/api/projects/${project.config.name}/domains`;
385
406
  const auth = { "x-api-key": token };
@@ -412,7 +433,7 @@ async function domains(args: string[]) {
412
433
  async function secrets(args: string[]) {
413
434
  const sub = args[0] && ["list", "set", "rm"].includes(args[0]) ? args.shift()! : "list";
414
435
  const name = sub === "list" ? undefined : args.shift();
415
- if (sub !== "list" && !name) fail(`usage: sproutboat secrets ${sub} <NAME> [project-dir]`);
436
+ if (sub !== "list" && !name) usageError(`secrets ${sub}: missing <NAME>`, `secrets ${sub} <NAME> [project-dir]`);
416
437
  if (name && !/^[A-Z][A-Z0-9_]*$/.test(name)) fail("secret name must be UPPER_SNAKE_CASE");
417
438
  const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
418
439
  const base = `${apiUrl}/api/projects/${project.config.name}/secrets`;
@@ -452,7 +473,7 @@ async function deleteProject(args: string[]) {
452
473
  if (arg === "--yes" || arg === "-y") confirmed = true;
453
474
  else if (arg === "--name") explicitName = args[(index += 1)];
454
475
  else if (!arg.startsWith("-")) positional.push(arg);
455
- else fail(`unknown flag ${arg}\nusage: sproutboat delete [project-dir] [--name <project>] --yes`);
476
+ else usageError(`delete: unknown flag "${arg}"`, "delete [project-dir] [--name <project>] --yes");
456
477
  }
457
478
 
458
479
  const { apiUrl, token } = await apiCredentials();
@@ -479,14 +500,18 @@ function help(): never {
479
500
  process.exit(0);
480
501
  }
481
502
 
482
- /** An unrecognised command: a short pointer on stderr, exit 1. */
503
+ /** An unrecognised command: a short pointer on stderr, exit 2 (misuse, not failure). */
483
504
  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);
505
+ console.error(`${rose("✗")} unknown command "${command}"\n ${dim("run `sproutboat` for the list of commands")}`);
506
+ process.exit(2);
486
507
  }
487
508
 
488
509
  const [command, ...args] = process.argv.slice(2);
489
510
  if (command === undefined || command === "help" || command === "-h" || command === "--help") help();
511
+ if (command === "--version" || command === "-v") { console.log(`sproutboat ${CLI_VERSION}`); process.exit(0); }
512
+
513
+ await notifyIfOutdated(CLI_VERSION);
514
+
490
515
  switch (command) {
491
516
  case "init": await init(args[0]); break;
492
517
  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
+ }