sproutboat 0.52.0 → 0.53.0

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/src/main.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { mkdir, open, readFile, rename, writeFile } from "node:fs/promises";
3
- import { rmSync } from "node:fs";
3
+ import { existsSync, rmSync } from "node:fs";
4
4
  import { basename, dirname, relative, resolve } from "node:path";
5
5
  import { isBoolean, isSafeInteger, isString, jsonObject, parseJsonValue, type JsonObject } from "./json";
6
6
  import type { AssetFiles } from "./assets";
@@ -19,10 +19,14 @@ import { notifyIfOutdated } from "./update-check";
19
19
  import { controlVersionWarning } from "./api-version";
20
20
  import { amber, bold, dim, leaf, ok, rose } from "./style";
21
21
  import { pathToFileURL } from "node:url";
22
+ import { constants as osConstants } from "node:os";
22
23
  import { inspectToolchain } from "./toolchain";
23
24
  import { isLinuxTarget, parseTarget, TARGET_NAMES, type BuildTarget } from "./targets";
24
25
  import { writeDeployFiles } from "./standalone-deploy";
25
- import { parseTailArgs, runTail, TAIL_USAGE, tailQuery } from "./tail";
26
+ import { parseTailArgs, runTail, TAIL_USAGE, tailQuery, type TailArgs } from "./tail";
27
+ import { followLog, LogFilter, readLogStream } from "./tail-log";
28
+ import { EnvFileError, MissingSecretsError } from "./env-file";
29
+ import { parseRunArgs, planRun, RUN_USAGE, RunError } from "./run";
26
30
 
27
31
  // A compiled Bun executable is not a general-purpose `bun` command. Porffor
28
32
  // is acquired after installation, so run its external ESM launcher through an
@@ -168,11 +172,11 @@ const starterConfig = (name: string) => `{
168
172
  "$schema": "https://sproutboat.com/schema.json",
169
173
  "name": "${name}",
170
174
  "main": "src/index.js",
171
- "compatibility_date": "2026-08-26"
175
+ "compatibility_date": "2026-09-28"
172
176
  }
173
177
  `;
174
178
  const starterHandler = `export default {
175
- fetch() {
179
+ fetch(request, env, ctx) {
176
180
  return new Response("hello from Sproutboat");
177
181
  }
178
182
  };
@@ -398,13 +402,30 @@ async function emitOnly(directory: string | undefined, target: BuildTarget, stan
398
402
  }
399
403
 
400
404
  /** #62 — build for this machine, run it against a real broker, rebuild on save. */
405
+ const DEV_USAGE = "dev [project-dir] [--port <n>] [--no-watch] [--env <name>] [--env-file <file>]...";
406
+
401
407
  async function dev(args: string[]) {
402
- const directory = args.find((arg) => !arg.startsWith("--") && !/^\d+$/.test(arg));
403
- const portIndex = args.indexOf("--port");
404
- const portArg = portIndex >= 0 ? args[portIndex + 1] : undefined;
408
+ let directory: string | undefined;
409
+ let portArg: string | undefined;
410
+ let envName: string | undefined;
411
+ const envFiles: string[] = [];
412
+ for (let at = 0; at < args.length; at += 1) {
413
+ const arg = args[at];
414
+ if (arg === "--no-watch") continue;
415
+ if (arg === "--port" || arg === "--env" || arg === "--env-file") {
416
+ const value = args[at + 1];
417
+ if (value === undefined || value.startsWith("--")) usageError(`${arg} needs a value`, DEV_USAGE);
418
+ at += 1;
419
+ if (arg === "--port") portArg = value;
420
+ else if (arg === "--env") envName = value;
421
+ else envFiles.push(resolve(value));
422
+ } else if (!arg.startsWith("--") && directory === undefined) directory = arg;
423
+ }
405
424
  const port = Number(portArg ?? 8787);
406
- if (!Number.isSafeInteger(port) || port < 1 || port > 65_535)
407
- usageError(`invalid --port: ${portArg}`, "dev [project-dir] [--port <n>] [--no-watch]");
425
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) usageError(`invalid --port: ${portArg}`, DEV_USAGE);
426
+ if (envName !== undefined && !/^[A-Za-z0-9_-]+$/.test(envName))
427
+ usageError("--env takes a name (letters, digits, - and _)", DEV_USAGE);
428
+ for (const file of envFiles) if (!existsSync(file)) fail(`env file not found: ${file}`);
408
429
  const project = await readProject(directory);
409
430
  await refreshTypes(project.directory, project.config);
410
431
  console.log(dim(`Building ${project.config.name} for this machine (${hostTarget()})…`));
@@ -414,6 +435,8 @@ async function dev(args: string[]) {
414
435
  sourcePath: project.sourcePath,
415
436
  source: project.bundle.code,
416
437
  port,
438
+ envFiles,
439
+ envName,
417
440
  watch: !args.includes("--no-watch"),
418
441
  // Re-read from disk on every rebuild: the point of watching is that the
419
442
  // files changed, so the bundle captured at startup is stale by definition.
@@ -425,6 +448,33 @@ async function dev(args: string[]) {
425
448
  });
426
449
  }
427
450
 
451
+ /** #275: start a standalone binary with its port, data directory and secrets supplied at launch. */
452
+ async function run(args: string[]) {
453
+ const result = parseRunArgs(args);
454
+ if ("error" in result) usageError(result.error, RUN_USAGE);
455
+ let plan;
456
+ try {
457
+ plan = await planRun(result.args, { cwd: process.cwd(), inherited: process.env });
458
+ } catch (error) {
459
+ if (error instanceof RunError || error instanceof EnvFileError || error instanceof MissingSecretsError)
460
+ fail(error.message);
461
+ throw error;
462
+ }
463
+ for (const warning of plan.warnings) console.warn(amber(`! ${warning}`));
464
+ for (const line of plan.summary) console.log(dim(line));
465
+ if (result.args.check) {
466
+ console.log(ok(`ready to launch ${basename(plan.binary)} (not started: --check)`));
467
+ return;
468
+ }
469
+ console.log(ok(`launching ${plan.binary}`));
470
+ const child = Bun.spawn([plan.binary], { env: plan.env, stdin: "inherit", stdout: "inherit", stderr: "inherit" });
471
+ // Forward termination to the binary, which is the only process this started.
472
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.on(signal, () => child.kill(signal));
473
+ const code = await child.exited;
474
+ const signalNumber = child.signalCode ? osConstants.signals[child.signalCode] : undefined;
475
+ process.exit(signalNumber === undefined ? code : 128 + signalNumber);
476
+ }
477
+
428
478
  const PROVISION_FIELDS = [
429
479
  ["kv_namespaces", "kv"],
430
480
  ["d1_databases", "d1"],
@@ -792,10 +842,32 @@ async function rollback(args: string[]) {
792
842
  console.log(deployment.url);
793
843
  }
794
844
 
845
+ /** #268: follow a standalone binary's SB_LOG=json records from a file or stdin. Local only: no login. */
846
+ async function tailLog(parsed: TailArgs, file: string) {
847
+ // Ctrl-C (or a terminated pipe reader) ends the stream and exits 0.
848
+ const controller = new AbortController();
849
+ for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, () => controller.abort());
850
+ process.stdout.on("error", () => controller.abort());
851
+ const sink = {
852
+ format: parsed.format,
853
+ filter: new LogFilter({ status: parsed.status, method: parsed.method, search: parsed.search }),
854
+ signal: controller.signal,
855
+ stdout: (text: string) => void process.stdout.write(text),
856
+ stderr: (text: string) => void process.stderr.write(text),
857
+ };
858
+ try {
859
+ if (file === "-") await readLogStream(Bun.stdin.stream(), sink);
860
+ else await followLog({ ...sink, path: resolve(file), lines: parsed.lines, follow: parsed.follow });
861
+ } catch (error) {
862
+ fail(error instanceof Error ? error.message : String(error));
863
+ }
864
+ }
865
+
795
866
  async function tail(args: string[]) {
796
867
  const result = parseTailArgs(args);
797
868
  if ("error" in result) usageError(result.error, TAIL_USAGE);
798
869
  const parsed = result.args;
870
+ if (parsed.file !== undefined) return tailLog(parsed, parsed.file);
799
871
  const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(parsed.dir), apiCredentials()]);
800
872
  const base = `${apiUrl}/api/projects/${project.config.name}`;
801
873
  if (parsed.recent || parsed.sprout) {
@@ -1359,7 +1431,9 @@ if (command === "--version" || command === "-v") {
1359
1431
  }
1360
1432
  if (args.includes("-h") || args.includes("--help")) commandHelp(command);
1361
1433
 
1362
- await notifyIfOutdated(CLI_VERSION);
1434
+ // A local log needs no network: skip the version check for it (#268).
1435
+ if (!(command === "tail" && args.some((arg) => arg === "--file" || arg.startsWith("--file="))))
1436
+ await notifyIfOutdated(CLI_VERSION);
1363
1437
 
1364
1438
  switch (command) {
1365
1439
  case "init":
@@ -1374,6 +1448,9 @@ switch (command) {
1374
1448
  case "dev":
1375
1449
  await dev(args);
1376
1450
  break;
1451
+ case "run":
1452
+ await run(args);
1453
+ break;
1377
1454
  case "build": {
1378
1455
  const withRestore = args.includes("--with-restore");
1379
1456
  if (withRestore && (!args.includes("--standalone") || args.includes("--emit-only")))
package/src/run.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * `sproutboat run`: launch a standalone binary with the settings it takes at
3
+ * launch (#275), without baking any of them into it.
4
+ *
5
+ * A standalone binary reads its port, data directory and secrets from the
6
+ * environment and nothing from argv. This command builds that environment from
7
+ * three layers and starts the binary with it. Highest precedence first:
8
+ *
9
+ * 1. explicit launch options: `--port` (PORT) and `--data-dir` (SB_DATA_DIR);
10
+ * 2. the files given with `--env-file` (dotenv syntax, see env-file.ts; repeat
11
+ * the flag and a later file overrides an earlier one);
12
+ * 3. the environment this command inherited, which is how a service manager
13
+ * hands a production binary its settings.
14
+ *
15
+ * Unlike `dev`, which warns, a launch fails when a declared secret (the
16
+ * config's `secrets` plus any `--require`) is unset or empty, as
17
+ * `wrangler deploy` does for a missing required secret. Nothing starts. The same binary therefore moves between a
18
+ * laptop and a server with a different env file, port and data directory.
19
+ * Nothing is written to disk and no value is ever printed: the summary names
20
+ * secrets and the layer each came from.
21
+ */
22
+ import { existsSync } from "node:fs";
23
+ import { readFile } from "node:fs/promises";
24
+ import { resolve } from "node:path";
25
+ import { parseConfig } from "./config";
26
+ import {
27
+ mergeEntries,
28
+ MissingSecretsError,
29
+ missingSecrets,
30
+ readEnvFile,
31
+ resolveEnvironment,
32
+ type EnvEntry,
33
+ type EnvSource,
34
+ } from "./env-file";
35
+
36
+ export const RUN_USAGE =
37
+ "run [project-dir] [--binary <file>] [--env-file <file>]... [--port <n>] [--data-dir <dir>] [--require <NAME,...>] [--check]";
38
+
39
+ const VALUE_FLAGS = ["--binary", "--env-file", "--port", "--data-dir", "--require"] as const;
40
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
41
+
42
+ export type RunArgs = {
43
+ dir: string | undefined;
44
+ binary: string | undefined;
45
+ /** Every `--env-file`, in the order given. */
46
+ envFiles: string[];
47
+ port: number | undefined;
48
+ dataDir: string | undefined;
49
+ require: string[];
50
+ check: boolean;
51
+ };
52
+
53
+ /** Parse `run` arguments; `error` is a usage error message. */
54
+ export function parseRunArgs(args: string[]): { args: RunArgs } | { error: string } {
55
+ const parsed: RunArgs = {
56
+ dir: undefined,
57
+ binary: undefined,
58
+ envFiles: [],
59
+ port: undefined,
60
+ dataDir: undefined,
61
+ require: [],
62
+ check: false,
63
+ };
64
+ for (let index = 0; index < args.length; index += 1) {
65
+ const arg = args[index];
66
+ if (arg === "--check") parsed.check = true;
67
+ else if (arg.startsWith("--")) {
68
+ const eq = arg.indexOf("=");
69
+ const flag = eq < 0 ? arg : arg.slice(0, eq);
70
+ if (!VALUE_FLAGS.some((known) => known === flag)) return { error: `run: unknown flag "${flag}"` };
71
+ const value = eq < 0 ? args[++index] : arg.slice(eq + 1);
72
+ if (value === undefined || (eq < 0 && value.startsWith("--"))) return { error: `run: ${flag} needs a value` };
73
+ if (flag === "--binary") parsed.binary = value;
74
+ else if (flag === "--env-file") parsed.envFiles.push(value);
75
+ else if (flag === "--data-dir") parsed.dataDir = value;
76
+ else if (flag === "--port") {
77
+ const port = Number(value);
78
+ if (!Number.isInteger(port) || port < 1 || port > 65_535)
79
+ return { error: `run: --port must be a whole number from 1 to 65535 (got "${value}")` };
80
+ parsed.port = port;
81
+ } else {
82
+ for (const name of value.split(",").map((part) => part.trim())) {
83
+ if (name === "") continue;
84
+ if (!NAME.test(name)) return { error: `run: --require takes variable names (letters, digits, underscore)` };
85
+ parsed.require.push(name);
86
+ }
87
+ }
88
+ } else if (parsed.dir === undefined) parsed.dir = arg;
89
+ else return { error: `run: unexpected argument "${arg}"` };
90
+ }
91
+ return { args: parsed };
92
+ }
93
+
94
+ /** A launch that cannot proceed for a reason the user can fix; the message is safe to print. */
95
+ export class RunError extends Error {}
96
+
97
+ export type RunPlan = {
98
+ binary: string;
99
+ /** The complete environment the binary starts with. Contains secret values: never print it. */
100
+ env: Record<string, string>;
101
+ declared: string[];
102
+ /** Lines naming what was resolved and from where. No values of secrets. */
103
+ summary: string[];
104
+ /** Env-file lines that were skipped, as `file:line: reason`. Never a value. */
105
+ warnings: string[];
106
+ };
107
+
108
+ /** Resolve what to launch and with which environment, or throw. Starts nothing. */
109
+ export async function planRun(
110
+ args: RunArgs,
111
+ io: { cwd: string; inherited: Record<string, string | undefined> },
112
+ ): Promise<RunPlan> {
113
+ const directory = resolve(io.cwd, args.dir ?? ".");
114
+ const configPath = resolve(directory, "sproutboat.jsonc");
115
+ let name: string | undefined;
116
+ const declared: string[] = [];
117
+ if (existsSync(configPath)) {
118
+ const parsed = parseConfig(await readFile(configPath, "utf8"));
119
+ if (!parsed.ok) throw new RunError(parsed.errors.join("\n"));
120
+ name = parsed.value.name;
121
+ declared.push(...(parsed.value.secrets ?? []));
122
+ } else if (args.binary === undefined) {
123
+ throw new RunError(
124
+ `no sproutboat.jsonc found in ${directory}; pass --binary <file> to run a binary without a project`,
125
+ );
126
+ }
127
+ for (const required of args.require) if (!declared.includes(required)) declared.push(required);
128
+
129
+ const binary = args.binary !== undefined ? resolve(io.cwd, args.binary) : resolve(directory, "dist", name ?? "");
130
+ if (!existsSync(binary)) {
131
+ throw new RunError(
132
+ `no binary at ${binary}; build it first with: sproutboat build --standalone${args.dir ? ` ${args.dir}` : ""}`,
133
+ );
134
+ }
135
+
136
+ const layers: EnvEntry[][] = [];
137
+ const warnings: string[] = [];
138
+ for (const path of args.envFiles) {
139
+ const parsed = await readEnvFile(resolve(io.cwd, path));
140
+ layers.push(parsed.entries);
141
+ warnings.push(...parsed.warnings);
142
+ }
143
+ const file = mergeEntries(layers);
144
+ const options: Record<string, string> = {};
145
+ if (args.port !== undefined) options.PORT = String(args.port);
146
+ if (args.dataDir !== undefined) options.SB_DATA_DIR = resolve(io.cwd, args.dataDir);
147
+ const { env, sources } = resolveEnvironment({ inherited: io.inherited, file, options });
148
+
149
+ const missing = missingSecrets(declared, env);
150
+ if (missing.length > 0) {
151
+ throw new MissingSecretsError(
152
+ missing,
153
+ args.envFiles.length > 0 ? args.envFiles.join(" or ") : "an env file (--env-file)",
154
+ );
155
+ }
156
+
157
+ const origin = (key: string): EnvSource | undefined => sources.get(key);
158
+ const summary: string[] = [];
159
+ summary.push(
160
+ declared.length === 0
161
+ ? "secrets none declared"
162
+ : `secrets ${declared.map((secret) => `${secret} (${origin(secret) ?? "unset"})`).join(", ")}`,
163
+ );
164
+ if (env.PORT) summary.push(`port ${env.PORT} (${origin("PORT")})`);
165
+ const dataKey = env.SB_DATA_DIR ? "SB_DATA_DIR" : env.SPROUTBOAT_DATA ? "SPROUTBOAT_DATA" : undefined;
166
+ if (dataKey) summary.push(`data ${env[dataKey]} (${origin(dataKey)})`);
167
+ return { binary, env, declared, summary, warnings };
168
+ }
package/src/surface.ts CHANGED
@@ -89,9 +89,19 @@ export const COMMANDS: readonly Command[] = [
89
89
  name: "dev",
90
90
  group: "Develop",
91
91
  emoji: "⚡",
92
- args: "[project-dir] [--port <n>] [--no-watch]",
92
+ args: "[project-dir] [--port <n>] [--no-watch] [--env <name>] [--env-file <file>]...",
93
93
  brief: "[project-dir] [--port <n>]",
94
- summary: "Run the project on this machine against a real broker, rebuilding on save.",
94
+ summary:
95
+ "Run the project on this machine against a real broker, rebuilding on save. Secrets come from .dev.vars or the .env files (never both), as in Wrangler; a declared secret with no value is a warning.",
96
+ },
97
+ {
98
+ name: "run",
99
+ group: "Develop",
100
+ emoji: "🚀",
101
+ args: "[project-dir] [--binary <file>] [--env-file <file>]... [--port <n>] [--data-dir <dir>] [--require <NAME,...>] [--check]",
102
+ brief: "[--env-file <file>] [--port <n>]",
103
+ summary:
104
+ "Launch a standalone binary with its port, data directory and declared secrets supplied at launch, never baked in. Precedence: --port/--data-dir, then the --env-file files (later overrides earlier), then the environment. A missing declared secret fails the launch, like wrangler deploy; --check validates without starting.",
95
105
  },
96
106
  {
97
107
  name: "build",
@@ -141,10 +151,10 @@ export const COMMANDS: readonly Command[] = [
141
151
  name: "tail",
142
152
  group: "Ship",
143
153
  emoji: "📡",
144
- args: "[project-dir] [--format pretty|json] [--status ok|error|<code>|5xx] [--method GET,POST] [--sampling-rate <0..1>] [--search <text>] [--outcome <name>] [--recent] [--sprout]",
154
+ args: "[project-dir] [--format pretty|json] [--status ok|error|<code>|5xx] [--method GET,POST] [--sampling-rate <0..1>] [--search <text>] [--outcome <name>] [--recent] [--sprout] [--file <log|-> [--lines <n>] [--no-follow]]",
145
155
  brief: "[--status 5xx] [--search <text>]",
146
156
  summary:
147
- "Stream live request events, console output and exceptions (like wrangler tail); --recent prints the last 100 request records, --sprout the running sprout + broker stdout/stderr.",
157
+ "Stream live request events, console output and exceptions (like wrangler tail); --recent prints the last 100 request records, --sprout the running sprout + broker stdout/stderr. --file follows the JSON records a standalone binary writes with SB_LOG=json (no login needed).",
148
158
  },
149
159
 
150
160
  ...storageCommands,