sproutboat 0.53.0 → 0.54.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.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `secret bulk [file]`: the input of Wrangler's command, JSON `{"NAME": "value", "OTHER": null}` (a null deletes) or a
3
+ * `.env` file (`NAME=value`, no deletes), read from the file or from stdin, with at most 100 entries. Names only in every
4
+ * message, never a value.
5
+ */
6
+ import { CloudError } from "./cloud";
7
+ import { SECRET_NAME_RE } from "./cloud-secrets";
8
+ import { parseDotenv } from "./env-file";
9
+ import { isString, jsonObject, parseJsonValue } from "./json";
10
+
11
+ export const BULK_MAX = 100;
12
+
13
+ export type BulkPlan = { set: Record<string, string>; delete: string[]; warnings: string[] };
14
+
15
+ export function parseBulkSecrets(text: string, label: string): BulkPlan {
16
+ const set: Record<string, string> = {};
17
+ const remove: string[] = [];
18
+ const warnings: string[] = [];
19
+ const bad: string[] = [];
20
+ if (text.trim().startsWith("{")) {
21
+ let parsed;
22
+ try {
23
+ parsed = jsonObject(parseJsonValue(text));
24
+ } catch {
25
+ throw new CloudError(`${label}: not valid JSON; expected {"NAME": "value", "OTHER": null}`);
26
+ }
27
+ if (!parsed) throw new CloudError(`${label}: expected a JSON object of NAME: value`);
28
+ for (const [name, value] of Object.entries(parsed)) {
29
+ if (!SECRET_NAME_RE.test(name)) bad.push(name);
30
+ else if (value === null) remove.push(name);
31
+ else if (isString(value) && value !== "") set[name] = value;
32
+ else throw new CloudError(`${label}: the value of ${name} must be a non-empty string, or null to delete it`);
33
+ }
34
+ } else {
35
+ const env = parseDotenv(text, label);
36
+ warnings.push(...env.warnings);
37
+ const empty: string[] = [];
38
+ for (const entry of env.entries) {
39
+ if (!SECRET_NAME_RE.test(entry.key)) bad.push(entry.key);
40
+ else if (entry.value === "") empty.push(entry.key);
41
+ else set[entry.key] = entry.value;
42
+ }
43
+ if (empty.length > 0) warnings.push(`${label}: not sent, empty value: ${empty.join(", ")}`);
44
+ }
45
+ if (bad.length > 0)
46
+ throw new CloudError(`${label}: secret names must be UPPER_SNAKE_CASE; not valid: ${bad.join(", ")}`);
47
+ const total = Object.keys(set).length + remove.length;
48
+ if (total === 0) throw new CloudError(`${label}: no secrets to set or delete`);
49
+ if (total > BULK_MAX) throw new CloudError(`${label}: ${total} entries; at most ${BULK_MAX} per command`);
50
+ return { set, delete: remove, warnings };
51
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Split a SQL script into its statements, the way `d1 execute --file` and a migration need them one by one: at a
3
+ * semicolon that is not inside a quoted string, a quoted identifier, a comment or the body of a trigger. Statements are
4
+ * returned trimmed, without the final semicolon, and empty ones (a stray `;`, a trailing comment) are dropped.
5
+ */
6
+ export function splitSql(script: string): string[] {
7
+ const statements: string[] = [];
8
+ let current = "";
9
+ let depth = 0; // BEGIN ... END nesting inside CREATE TRIGGER (and CASE ... END inside it)
10
+ let word = "";
11
+ const flushWord = () => {
12
+ const upper = word.toUpperCase();
13
+ if (
14
+ upper === "BEGIN" &&
15
+ /^\s*CREATE\s+(TEMP(ORARY)?\s+)?TRIGGER\b/i.test(stripComments(current.slice(0, current.length - word.length)))
16
+ )
17
+ depth++;
18
+ else if (upper === "CASE" && depth > 0) depth++;
19
+ else if (upper === "END" && depth > 0) depth--;
20
+ word = "";
21
+ };
22
+ const push = () => {
23
+ if (stripComments(current).trim() !== "") statements.push(current.trim());
24
+ current = "";
25
+ };
26
+ for (let i = 0; i < script.length; i++) {
27
+ const ch = script[i]!;
28
+ if (word && !/[A-Za-z0-9_]/.test(ch)) flushWord();
29
+ if (ch === "'" || ch === '"' || ch === "`" || ch === "[") {
30
+ flushWord();
31
+ const close = ch === "[" ? "]" : ch;
32
+ let j = i + 1;
33
+ for (; j < script.length; j++) {
34
+ if (script[j] === close) {
35
+ if (close !== "]" && script[j + 1] === close) j++; // doubled quote is an escaped quote
36
+ else break;
37
+ }
38
+ }
39
+ current += script.slice(i, j + 1);
40
+ i = j;
41
+ } else if (ch === "-" && script[i + 1] === "-") {
42
+ flushWord();
43
+ const end = script.indexOf("\n", i);
44
+ const stop = end < 0 ? script.length : end;
45
+ current += script.slice(i, stop);
46
+ i = stop - 1;
47
+ } else if (ch === "/" && script[i + 1] === "*") {
48
+ flushWord();
49
+ const end = script.indexOf("*/", i + 2);
50
+ const stop = end < 0 ? script.length : end + 2;
51
+ current += script.slice(i, stop);
52
+ i = stop - 1;
53
+ } else if (ch === ";" && depth === 0) {
54
+ flushWord();
55
+ push();
56
+ } else {
57
+ if (/[A-Za-z0-9_]/.test(ch)) word += ch;
58
+ current += ch;
59
+ }
60
+ }
61
+ flushWord();
62
+ push();
63
+ return statements;
64
+ }
65
+
66
+ function stripComments(sql: string): string {
67
+ return sql.replace(/--[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
68
+ }
@@ -15,8 +15,21 @@ import type { Bindings } from "./wrap";
15
15
  export type StandaloneBuildInput = BuildInput & {
16
16
  /** Where to write the executable. Defaults to `<projectDir>/dist/<name>`. */
17
17
  outPath?: string;
18
+ /**
19
+ * The release build: `-flto -O3`, the binary to publish. Off by default: units still compile at -O3 but
20
+ * the link skips LTO, which is 1.5 to 2x faster cold and links in ~90 MB instead of 1.1 to 1.6 GB, at some
21
+ * request throughput (docs/standalone.md, "Dev and release builds").
22
+ */
23
+ release?: boolean;
18
24
  };
19
25
 
26
+ /** `--release` or `SPROUTBOAT_RELEASE=1` (`0`, empty or unset: not a release build). */
27
+ export function isReleaseBuild(args: string[], env: Record<string, string | undefined> = process.env): boolean {
28
+ if (args.includes("--release")) return true;
29
+ const value = env.SPROUTBOAT_RELEASE;
30
+ return value !== undefined && value !== "" && value !== "0" && value.toLowerCase() !== "false";
31
+ }
32
+
20
33
  export type StandaloneBuildResult = { outPath: string; bytes: number };
21
34
 
22
35
  /**
@@ -50,14 +63,19 @@ function assertStandaloneSupported(input: BuildInput): void {
50
63
  * compiles, at the same path. Every check that fails a standalone build fails
51
64
  * this too; nothing native is acquired or compiled and no binary is copied out.
52
65
  */
53
- export async function emitStandalone(input: BuildInput): Promise<{ generatedPath: string }> {
54
- assertStandaloneSupported(input);
66
+ export async function emitStandalone(
67
+ input: BuildInput,
68
+ /** The Cloud compiles this unit and its hosts supply service bindings, so a deploy to the Cloud keeps them. */
69
+ options: { forCloud?: boolean } = {},
70
+ ): Promise<{ generatedPath: string }> {
71
+ if (!options.forCloud) assertStandaloneSupported(input);
55
72
  return emitGenerated({ ...input, transport: "embedded" });
56
73
  }
57
74
 
58
75
  export async function buildStandalone(input: StandaloneBuildInput): Promise<StandaloneBuildResult> {
59
76
  assertStandaloneSupported(input);
60
- const artifact = await buildArtifact({ ...input, transport: "embedded" });
77
+ const { release, ...rest } = input;
78
+ const artifact = await buildArtifact({ ...rest, transport: "embedded", lto: release === true });
61
79
 
62
80
  // The sprout *is* the binary: assets and bindings are compiled into it, and
63
81
  // everything else it needs (port, data dir, secrets) arrives at run time.
package/src/surface.ts CHANGED
@@ -6,19 +6,23 @@
6
6
  import { bold, dim, leaf } from "./style";
7
7
 
8
8
  export const CLI_NAME = "sproutboat";
9
- export const TAGLINE = "Deploy JavaScript handlers as tiny native binaries on your own VPS.";
10
- export const DOCS_URL = "https://docs.sproutboat.com";
9
+ export const TAGLINE = "Build and deploy JavaScript handlers as tiny native binaries.";
10
+ export const DOCS_URL = "https://sproutboat.com/docs";
11
11
 
12
12
  export type Group = "Develop" | "Ship" | "Storage" | "Configure" | "Account";
13
13
 
14
14
  export type Command = {
15
15
  name: string;
16
+ /** Other names that run the same command (`secret` for `secrets`, as in Wrangler). */
17
+ aliases?: readonly string[];
16
18
  /** Full argument grammar — used by SURFACE.md and the one-line usage string. */
17
19
  args: string;
18
20
  /** Short argument hint shown in the grouped help list (falls back to `args`). */
19
21
  brief?: string;
20
22
  group: Group;
21
23
  emoji: string;
24
+ /** One short line (about 60 characters) for the grouped help list. Detail lives in `summary`, shown by `<command> --help`. */
25
+ short: string;
22
26
  summary: string;
23
27
  };
24
28
 
@@ -42,8 +46,8 @@ export type StorageProduct = {
42
46
  };
43
47
 
44
48
  export const STORAGE_PRODUCTS: readonly StorageProduct[] = [
45
- { name: "kv", noun: "namespace", plural: "KV namespaces", emoji: "🗄" },
46
- { name: "d1", noun: "database", plural: "D1 databases", emoji: "🛢" },
49
+ { name: "kv", noun: "namespace", plural: "KV namespaces", emoji: "🗄\uFE0F" },
50
+ { name: "d1", noun: "database", plural: "D1 databases", emoji: "🛢\uFE0F" },
47
51
  { name: "r2", noun: "bucket", plural: "R2 buckets", emoji: "🪣" },
48
52
  { name: "queues", noun: "queue", plural: "queues", emoji: "📨" },
49
53
  ];
@@ -53,35 +57,62 @@ export const STORAGE_VERBS = ["list", "create", "info", "rename", "delete"] as c
53
57
 
54
58
  const STORAGE_ARGS = "<list | create <name> | info <name> | rename <name> <new> | delete <name>>";
55
59
 
60
+ /** What each storage command offers, in the words the grouped help uses. */
61
+ const STORAGE_SHORT = {
62
+ kv: "Manage KV namespaces, keys, bulk data and exports",
63
+ d1: "Manage D1 databases, run SQL, apply migrations",
64
+ r2: "Manage R2 buckets, and get, put or delete objects",
65
+ queues: "Manage queues",
66
+ } satisfies Record<StorageProduct["name"], string>;
67
+
56
68
  const storageCommands: readonly Command[] = STORAGE_PRODUCTS.map((product) => ({
57
69
  name: product.name,
58
70
  group: "Storage" as const,
59
71
  emoji: product.emoji,
60
- args: product.name === "kv" ? `${STORAGE_ARGS} | <key | bulk | export> ...` : STORAGE_ARGS,
61
- brief: `<${STORAGE_VERBS.join(" | ")}>`,
62
- summary: `${product.plural[0].toUpperCase()}${product.plural.slice(1)}. \`create\` prints the id to bind from sproutboat.jsonc${product.name === "kv" ? "; key and bulk operations manage contents, and export writes a restorable JSON dump" : ""}${product.name === "queues" ? "; consumers deliver in batches with retries, and stop after 5 attempts" : ""}.`,
72
+ short: STORAGE_SHORT[product.name],
73
+ args:
74
+ product.name === "kv"
75
+ ? `${STORAGE_ARGS} | <key | bulk | export> ...`
76
+ : product.name === "d1"
77
+ ? `${STORAGE_ARGS} | execute <database> (--command <sql> | --file <path>) [--json] [--local | --remote] | migrations <create | list | apply> <database> ...`
78
+ : product.name === "r2"
79
+ ? `${STORAGE_ARGS} | object <get <bucket>/<key> [--file <path> | --pipe] | put <bucket>/<key> (--file <path> | --pipe) [--content-type <t>] [--cache-control <v>] ... | delete <bucket>/<key>>`
80
+ : STORAGE_ARGS,
81
+ brief: "<command>",
82
+ summary: `${product.plural[0].toUpperCase()}${product.plural.slice(1)}. \`create\` prints the id to bind from sproutboat.jsonc${product.name === "kv" ? "; key and bulk operations manage contents, and export writes a restorable JSON dump" : ""}${product.name === "d1" ? `; \`execute\` runs SQL against the local dev database (.sproutboat/dev/d1/<BINDING>.sqlite) unless --remote, as in Wrangler, and prints JSON when stdout is not a terminal; \`migrations create <database> <name>\` writes migrations/NNNN_<name>.sql (migrations_dir in sproutboat.jsonc changes the folder), \`migrations list\` shows the ones not applied, and \`migrations apply\` applies them in order, each with its row in the d1_migrations table in one transaction, after a confirmation in a terminal. <database> is the binding name from d1_databases. --remote needs a Sproutboat Cloud endpoint (app:write to change data, app:read for SELECT only); self-hosted Community has no remote D1 route yet and says so` : ""}${product.name === "r2" ? "; `object get | put | delete <bucket>/<key>` read and write the objects of a Cloud app's bucket (<bucket> is the binding name from r2_buckets) with Wrangler's flags (--file, --pipe, --content-type, --content-disposition, --content-encoding, --content-language, --cache-control, --expires; `get` without --file writes ./<key>), 64 MiB per object, remote only (--local is refused). A key is 1 to 1024 bytes with no control character, backslash, or empty, . or .. segment, checked before anything is sent. The key needs the data:read grant to get and data:write to put or delete (the error says which it lacks and how to get it). On self-hosted Community `get` reads through the existing route by bucket name, and `put` and `delete` say they are not supported yet" : ""}${product.name === "queues" ? "; consumers deliver in batches with retries, and stop after 5 attempts" : ""}.`,
63
83
  }));
64
84
 
85
+ /** The command a typed name runs: an alias maps to its command, anything else is returned as typed. */
86
+ export const resolveCommand = (name: string): string =>
87
+ COMMANDS.find((c) => c.name === name || c.aliases?.includes(name))?.name ?? name;
88
+
65
89
  export const COMMANDS: readonly Command[] = [
66
90
  {
67
91
  name: "init",
68
92
  group: "Develop",
69
93
  emoji: "🌱",
94
+ short: "Scaffold a new project",
70
95
  args: "[name]",
71
- summary: "Scaffold sproutboat.jsonc + src/index.js in ./<name>.",
96
+ brief: "[name]",
97
+ summary:
98
+ "Scaffold sproutboat.jsonc, src/index.js and package.json (sproutboat devDependency, dev, deploy and check scripts) in ./<name>.",
72
99
  },
73
100
  {
74
101
  name: "check",
75
102
  group: "Develop",
76
103
  emoji: "🔍",
104
+ short: "Validate the config and entry point",
77
105
  args: "[project-dir]",
106
+ brief: "[project-dir]",
78
107
  summary: "Validate the config and entry point without building.",
79
108
  },
80
109
  {
81
110
  name: "types",
82
111
  group: "Develop",
83
112
  emoji: "🧾",
113
+ short: "Write sproutboat-env.d.ts for your editor",
84
114
  args: "[project-dir]",
115
+ brief: "[project-dir]",
85
116
  summary:
86
117
  "Write sproutboat-env.d.ts from the config, so an editor knows what `env` holds. `dev` and `deploy` refresh it when the config is newer.",
87
118
  },
@@ -89,17 +120,19 @@ export const COMMANDS: readonly Command[] = [
89
120
  name: "dev",
90
121
  group: "Develop",
91
122
  emoji: "⚡",
92
- args: "[project-dir] [--port <n>] [--no-watch] [--env <name>] [--env-file <file>]...",
93
- brief: "[project-dir] [--port <n>]",
123
+ short: "Run the app locally, rebuilding on save",
124
+ args: "[project-dir] [--port <n>] [--no-watch] [-e, --env <name>] [--env-file <file>]...",
125
+ brief: "[project-dir]",
94
126
  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.",
127
+ "Run the project on this machine with local storage, 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
128
  },
97
129
  {
98
130
  name: "run",
99
131
  group: "Develop",
100
- emoji: "🚀",
132
+ emoji: "🏃",
133
+ short: "Launch a built standalone binary",
101
134
  args: "[project-dir] [--binary <file>] [--env-file <file>]... [--port <n>] [--data-dir <dir>] [--require <NAME,...>] [--check]",
102
- brief: "[--env-file <file>] [--port <n>]",
135
+ brief: "[project-dir]",
103
136
  summary:
104
137
  "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.",
105
138
  },
@@ -107,15 +140,17 @@ export const COMMANDS: readonly Command[] = [
107
140
  name: "build",
108
141
  group: "Develop",
109
142
  emoji: "🔨",
110
- args: "[project-dir] [--target host|linux-x64|linux-arm64] [--standalone] [--deploy-files] [--with-restore] [--emit-only]",
143
+ short: "Compile the app to a native binary",
144
+ args: "[project-dir] [--target host|linux-x64|linux-arm64] [--standalone] [--release] [--deploy-files] [--with-restore] [--emit-only]",
111
145
  brief: "[project-dir]",
112
146
  summary:
113
- "Cross-compile the native-fetch sprout (Porffor + Zig). `--target host` builds for this machine instead, to run locally, not deployable. `--standalone` emits one executable carrying its own bindings, with SQLite compiled in (~2 MB) and no broker process; add `--target linux-arm64` for an aarch64 box (the default is `linux-x64`) and `--deploy-files` to write a Dockerfile, a systemd socket and service, a fly.toml and a push script beside it. `--with-restore` adds an offline `APP ltx-restore --generation DIR --output NEW_DB [--txid N]` startup command to a standalone executable; it runs before app initialization. `--emit-only` stops after writing `.sproutboat/porffor-units/<shape>/sprout.generated.js`, the module Porffor would compile, and prints its path: every check still runs, nothing is compiled.",
147
+ "Compile the app to a native binary (Porffor + Zig), cross-compiled for Linux x64 by default. `--target host` builds for this machine instead, to run locally, not deployable. `--standalone` emits one executable carrying its own bindings, with SQLite compiled in (~2 MB) and no separate storage process; add `--target linux-arm64` for an aarch64 box (the default is `linux-x64`) and `--deploy-files` to write a Dockerfile, a systemd socket and service, a fly.toml and a push script beside it. `--release` makes a standalone build the full `-flto -O3` one to publish; without it the link skips LTO (1.5 to 2x faster cold, ~90 MB link memory instead of 1.1 to 1.6 GB, fewer requests per second); `SPROUTBOAT_RELEASE=1` does the same, and a non-standalone build is always the release build. `--with-restore` adds an offline `APP ltx-restore --generation DIR --output NEW_DB [--txid N]` startup command to a standalone executable; it runs before app initialization. `--emit-only` stops after writing `.sproutboat/porffor-units/<shape>/sprout.generated.js`, the module Porffor would compile, and prints its path: every check still runs, nothing is compiled.",
114
148
  },
115
149
  {
116
150
  name: "toolchain",
117
151
  group: "Develop",
118
152
  emoji: "🩺",
153
+ short: "Check the compiler toolchain",
119
154
  args: "doctor [--json]",
120
155
  brief: "doctor",
121
156
  summary:
@@ -126,24 +161,37 @@ export const COMMANDS: readonly Command[] = [
126
161
  name: "deploy",
127
162
  group: "Ship",
128
163
  emoji: "🚀",
129
- args: "[project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision]",
130
- brief: "[project-dir] [--dry-run]",
164
+ short: "Build the app and publish it",
165
+ args: "[project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision] [--api-url <url>] [--account <team>] [--module <file.so>] [--sproutd <dir> --generation <dir>] [--secrets-file <file>]",
166
+ brief: "[project-dir]",
131
167
  summary:
132
- "Build (unless --artifact), auto-provision id-less storage bindings and pin their ids into sproutboat.jsonc, print the report, upload, wait until the URL serves. The control plane skips an upload that matches the live artifact byte-for-byte. --dry-run stops before upload; --no-wait skips the health check; --no-provision leaves id-less bindings as ephemeral deploy-scoped stores.",
168
+ "Build (unless --artifact), create the storage bindings that have no id yet and write their ids into sproutboat.jsonc, print the report, upload, wait until the URL serves. The control plane skips an upload that matches the live artifact byte-for-byte. --dry-run stops before upload; --no-wait skips the health check; --no-provision leaves those bindings as temporary stores that last only for the deploy. On a Sproutboat Cloud endpoint (the default, https://dash.sproutboat.com) it bundles the project, uploads the source by sha256 (skipped when unchanged) and the assets by hash, has the Cloud build it while streaming the build log (Ctrl-C cancels the build), waits for a host to apply the generation the build wrote and prints https://<app>.<team>.sproutboat.app; --no-wait returns once the build is accepted and prints /v1/builds/<id>, and --dry-run says it would build in the Cloud. --module and --sproutd with --generation are the operator paths (an uploaded or locally compiled module, refused for team keys); regular deploys do not need them. On the Cloud, `services` in sproutboat.jsonc become service bindings to the team's Cloud apps of those names (each must be deployed first; compatibility_date 2026-11-18 or later; an app cannot bind itself), resolved before anything is uploaded; --secrets-file <file> (.dev.vars or .env format) sets those secrets once the build is ready (and cannot be combined with --no-wait), and a deploy never removes secrets it does not name (manage them with `secrets`); logged out in a terminal it starts the login, with no terminal it needs SPROUTBOAT_API_TOKEN.",
169
+ },
170
+ {
171
+ name: "deployments",
172
+ group: "Ship",
173
+ emoji: "🚢",
174
+ short: "List deployments, show what is live, follow a build",
175
+ args: "<list | status | tail <id>> [--json] [--api-url <url>] [--account <team>]",
176
+ brief: "<list | status | tail>",
177
+ summary:
178
+ "The deployments of the project in this directory. `list` shows the 10 most recent, newest first, with the live one marked; `status` shows what is live and whether a host applied it; both take --json. On a Cloud endpoint a deployment is a build: `tail <id>` follows it server-side (its log, then its end state), from its first line, so a deploy that was detached with Ctrl-C can be picked up again; Ctrl-C stops following and never cancels the build. On self-hosted Community a deployment is a version (as in `versions list`), `status` shows the active one, and `tail` says there is nothing to follow, since a deploy there is finished when `deploy` returns.",
133
179
  },
134
180
  {
135
181
  name: "versions",
136
182
  group: "Ship",
137
183
  emoji: "📜",
138
- args: "<list | view <version-id>> [project-dir]",
184
+ short: "List or view deployed versions",
185
+ args: "<list | view <version-id>> [project-dir] [--api-url <url>]",
139
186
  brief: "<list | view>",
140
187
  summary: "List the project's deployed versions, or show one version's artifact and bindings.",
141
188
  },
142
189
  {
143
190
  name: "rollback",
144
191
  group: "Ship",
145
- emoji: "⏮",
146
- args: "<version-id> [project-dir]",
192
+ emoji: "⏪",
193
+ short: "Re-activate a previous version",
194
+ args: "<version-id> [project-dir] [--api-url <url>]",
147
195
  brief: "<version-id>",
148
196
  summary: "Re-activate a previous version.",
149
197
  },
@@ -151,10 +199,21 @@ export const COMMANDS: readonly Command[] = [
151
199
  name: "tail",
152
200
  group: "Ship",
153
201
  emoji: "📡",
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]]",
155
- brief: "[--status 5xx] [--search <text>]",
202
+ short: "Stream live requests, logs and exceptions",
203
+ 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]] [--api-url <url>]",
204
+ brief: "[project-dir]",
156
205
  summary:
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).",
206
+ "Stream live request events, console output and exceptions (like wrangler tail); --recent prints the last 100 request records, --sprout the running app's own stdout and stderr logs. --file follows the JSON records a standalone binary writes with SB_LOG=json (no login needed).",
207
+ },
208
+
209
+ {
210
+ name: "delete",
211
+ group: "Ship",
212
+ emoji: "🗑️",
213
+ short: "Delete the app and all its versions",
214
+ args: "[project-dir] [--name <project>] --yes [--api-url <url>]",
215
+ brief: "[project-dir]",
216
+ summary: "Delete the project, every version, and its route.",
158
217
  },
159
218
 
160
219
  ...storageCommands,
@@ -163,55 +222,69 @@ export const COMMANDS: readonly Command[] = [
163
222
  name: "domains",
164
223
  group: "Configure",
165
224
  emoji: "🌐",
166
- args: "<list | add <host> | verify <host> | delete <host>> [project-dir]",
225
+ short: "Attach custom domains",
226
+ args: "<list | add <host> | verify <host> | delete <host>> [project-dir] [--api-url <url>]",
167
227
  brief: "<list | add | verify | delete>",
168
228
  summary: "Attach a custom domain to the project (TXT-verified). No sub-command lists.",
169
229
  },
170
230
  {
171
231
  name: "secrets",
232
+ aliases: ["secret"],
172
233
  group: "Configure",
173
234
  emoji: "🔑",
174
- args: "<list | put <NAME> [--value <value>] | delete <NAME>> [project-dir]",
175
- brief: "<list | put | delete>",
235
+ short: "Manage encrypted secrets",
236
+ args: "<list | put <NAME> [--value <value>] | delete <NAME> | bulk [file.json | file.env]> [project-dir] [--no-deploy] [--api-url <url>] [--account <team>]",
237
+ brief: "<list | put | delete | bulk>",
176
238
  summary:
177
- "Manage encrypted project secrets (read as env.NAME). `put` reads the value from stdin unless --value is given, so it stays out of shell history; applies on next deploy.",
178
- },
179
- {
180
- name: "delete",
181
- group: "Configure",
182
- emoji: "🗑",
183
- args: "[project-dir] [--name <project>] --yes",
184
- brief: "[project-dir] --yes",
185
- summary: "Delete the project, every version, and its route.",
239
+ 'Manage encrypted project secrets (read as env.NAME); `secret` is the same command, as in Wrangler. `put` reads the value from stdin unless --value is given, so it stays out of shell history. `bulk` takes the file of `wrangler secret bulk`: JSON {"NAME": "value", "OLD": null} (null deletes) or a .env file, from the file or stdin, at most 100 entries. On a Cloud endpoint secrets are per app and independent of deploys: put, delete and bulk act on the deployed app, and by default wait for a host to apply them as a new version of the running module (like wrangler secret, which deploys at once); --no-deploy returns as soon as the change is stored. Permissions secret:write and secret:list; an app that is not deployed yet says to deploy first or pass --secrets-file on the first deploy. Self-hosted Community applies secrets on the next deploy or restart of the app and has no redeploy route yet, so --no-deploy changes nothing there.',
186
240
  },
187
-
188
241
  {
189
242
  name: "login",
190
243
  group: "Account",
191
244
  emoji: "🔓",
192
- args: "[--api-url <url>] [--token <token>]",
193
- brief: "[--token <token>]",
194
- summary: "Device-code browser flow, or store <token> for <url> directly.",
245
+ short: "Sign in",
246
+ args: "[--api-url <url>] [--token <token>] [--account <team>]",
247
+ brief: "",
248
+ summary:
249
+ "Sign in, as the profile named by --profile (the first login makes `default`). On the Cloud: the device-code flow (RFC 8628), which prints a code and a link, opens the browser when it can, and stores an account API key named `CLI on <hostname>` for the team you approve (--account preselects it). Elsewhere the browser flow, or store <token> for <url> directly.",
195
250
  },
196
251
  {
197
252
  name: "logout",
198
253
  group: "Account",
199
254
  emoji: "🔒",
200
- args: "[--api-url <url>]",
201
- summary: "Forget the stored credential for the active endpoint, or for <url>.",
255
+ short: "Forget the stored credential",
256
+ args: "[--api-url <url>] [--account <team>]",
257
+ brief: "",
258
+ summary:
259
+ "Forget the stored credential of the profile in use, or for <url>. On the Cloud it also revokes the key on the server (the default team, or --account).",
202
260
  },
203
261
  {
204
262
  name: "whoami",
205
263
  group: "Account",
206
264
  emoji: "👤",
207
- args: "",
208
- summary: "Show the active endpoint and the account the stored token belongs to.",
265
+ short: "Show the profile and account you use",
266
+ args: "[--api-url <url>] [--account <team>]",
267
+ brief: "",
268
+ summary:
269
+ "Show the profile and endpoint in use, and the account or team the credential belongs to (exits 1 when not logged in or the key is rejected).",
270
+ },
271
+ {
272
+ name: "profile",
273
+ group: "Account",
274
+ emoji: "🪪",
275
+ short: "List login profiles or pick the default",
276
+ args: "<list | use <name>>",
277
+ brief: "<list | use>",
278
+ summary:
279
+ "A profile is an endpoint, its stored credential and a default team. `list` shows them and marks the default, which a command uses when nothing else names one; `use <name>` makes another profile the default. `login --profile <name>` creates one, and `--profile <name>` or SPROUTBOAT_PROFILE picks one for a single command.",
209
280
  },
210
281
  {
211
282
  name: "complete",
212
283
  group: "Account",
213
- emoji: "⌨",
284
+ emoji: "⌨️",
285
+ short: "Print a shell completion script",
214
286
  args: "[bash | zsh | fish]",
287
+ brief: "[shell]",
215
288
  summary: "Print a shell completion script for the top-level commands (defaults to $SHELL).",
216
289
  },
217
290
  ];
@@ -219,8 +292,36 @@ export const COMMANDS: readonly Command[] = [
219
292
  export type EnvVar = { name: string; purpose: string };
220
293
 
221
294
  export const ENV_VARS: readonly EnvVar[] = [
222
- { name: "SPROUTBOAT_API_URL", purpose: "Control-plane URL. Overrides the saved active endpoint." },
223
- { name: "SPROUTBOAT_TOKEN", purpose: "API token. Overrides the saved credential for the endpoint." },
295
+ {
296
+ name: "SPROUTBOAT_API_URL",
297
+ purpose:
298
+ "Control-plane URL, the same as --api-url (which wins). Overrides the active profile's endpoint; it must match SPROUTBOAT_PROFILE when both are set.",
299
+ },
300
+ {
301
+ name: "SPROUTBOAT_PROFILE",
302
+ purpose:
303
+ "Stored login profile to use, like --profile (which wins). Its endpoint and credential apply unless --api-url or SPROUTBOAT_API_TOKEN overrides them.",
304
+ },
305
+ {
306
+ name: "SPROUTBOAT_API_TOKEN",
307
+ purpose:
308
+ "API token (an account API key on the Cloud), like CLOUDFLARE_API_TOKEN. Always wins over the stored login; use it in CI.",
309
+ },
310
+ {
311
+ name: "SPROUTBOAT_TOKEN",
312
+ purpose: "Older name for SPROUTBOAT_API_TOKEN; SPROUTBOAT_API_TOKEN wins when both are set.",
313
+ },
314
+ {
315
+ name: "SPROUTBOAT_ACCOUNT",
316
+ purpose:
317
+ "Cloud team (its URL name) a deploy targets, like account_id. Overridden by --account and the account field of sproutboat.jsonc.",
318
+ },
319
+ {
320
+ name: "SPROUTBOAT_SPROUTD",
321
+ purpose:
322
+ "Operators only: sproutd checkout whose compile-app turns the app into a Cloud module (same as --sproutd). Regular deploys are built by the Cloud and do not need it.",
323
+ },
324
+ { name: "SPROUTBOAT_GENERATION", purpose: "Built sproutd generation to compile against (same as --generation)." },
224
325
  { name: "SPROUTBOAT_ZIG", purpose: "Path to a Zig binary to use instead of downloading the pinned one." },
225
326
  {
226
327
  name: "SPROUTBOAT_ZIG_URL",
@@ -252,6 +353,10 @@ export const ENV_VARS: readonly EnvVar[] = [
252
353
  purpose:
253
354
  "Porffor channel for standalone builds: `alpha-22` (default) or `alpha-19` (the previous compiler, kept as a fallback for one release).",
254
355
  },
356
+ {
357
+ name: "SPROUTBOAT_RELEASE",
358
+ purpose: "Same as `build --standalone --release`: link with LTO. Any value but empty, `0` or `false` turns it on.",
359
+ },
255
360
  {
256
361
  name: "SPROUTBOAT_FLTO",
257
362
  purpose: "Overrides link-time optimization on alpha 22 builds: `1` turns it on, `0` turns it off.",
@@ -281,7 +386,11 @@ export const ENV_VARS: readonly EnvVar[] = [
281
386
  name: "SPROUTBOAT_BINDINGS_JSON",
282
387
  purpose: "The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line.",
283
388
  },
284
- { name: "SPROUTBOAT_CONFIG_DIR", purpose: "Directory for credentials.json (default ~/.config/sproutboat)." },
389
+ {
390
+ name: "SPROUTBOAT_CONFIG_DIR",
391
+ purpose:
392
+ "Directory for credentials.json (default ~/Library/Preferences/sproutboat on macOS, ~/.config/sproutboat elsewhere; the old macOS place is still read).",
393
+ },
285
394
  {
286
395
  name: "NO_COLOR",
287
396
  purpose:
@@ -354,11 +463,30 @@ export function usageLine(): string {
354
463
  return `usage: ${CLI_NAME} <${parts.join(" | ")}>`;
355
464
  }
356
465
 
357
- /** First sentence of a summary — enough for the at-a-glance command list. */
358
- function firstSentence(text: string): string {
359
- const end = text.indexOf(". ");
360
- return end === -1 ? text.replace(/\.$/, "") : text.slice(0, end);
361
- }
466
+ export type GlobalFlag = { flags: string; purpose: string; type: string };
467
+
468
+ /** Flags every command accepts, listed by `sproutboat --help`. */
469
+ export const GLOBAL_FLAGS: readonly GlobalFlag[] = [
470
+ { flags: "-c, --config", purpose: "Path to the project's config file", type: "string" },
471
+ { flags: " --cwd", purpose: "Run as if sproutboat was started in this directory", type: "string" },
472
+ {
473
+ flags: "-e, --env",
474
+ purpose: "Environment for `dev`: selects the .dev.vars.<name> and .env.<name> files",
475
+ type: "string",
476
+ },
477
+ {
478
+ flags: " --env-file",
479
+ purpose: "Path to an .env file to load; repeat it, and later files override earlier ones",
480
+ type: "array",
481
+ },
482
+ { flags: "-h, --help", purpose: "Show help", type: "boolean" },
483
+ {
484
+ flags: " --profile",
485
+ purpose: "Use a stored login profile (default: SPROUTBOAT_PROFILE, else the active profile)",
486
+ type: "string",
487
+ },
488
+ { flags: "-v, --version", purpose: "Show version number", type: "boolean" },
489
+ ];
362
490
 
363
491
  /** Wrangler-style grouped help: `sproutboat` with no command, or `--help`. */
364
492
  export function helpText(): string {
@@ -370,14 +498,14 @@ export function helpText(): string {
370
498
  lines.push("", leaf(group.toUpperCase()));
371
499
  for (const command of COMMANDS.filter((c) => c.group === group)) {
372
500
  const invocation = `${command.name} ${command.brief ?? command.args}`.trim();
373
- lines.push(` ${invocation.padEnd(width)} ${command.emoji} ${firstSentence(command.summary)}`);
501
+ lines.push(` ${invocation.padEnd(width)} ${command.emoji} ${command.short}`);
374
502
  }
375
503
  }
376
504
 
377
- lines.push(
378
- "",
379
- dim(`Run \`${CLI_NAME} <command>\` with no/invalid args to see that command's full usage.`),
380
- dim(`Docs: ${DOCS_URL}`),
381
- );
505
+ const flagWidth = Math.max(...GLOBAL_FLAGS.map((flag) => flag.flags.length));
506
+ lines.push("", leaf("GLOBAL FLAGS"));
507
+ for (const flag of GLOBAL_FLAGS) lines.push(` ${flag.flags.padEnd(flagWidth)} ${flag.purpose} [${flag.type}]`);
508
+
509
+ lines.push("", dim(`Run \`${CLI_NAME} <command> --help\` for a command's options.`), dim(`Docs: ${DOCS_URL}`));
382
510
  return lines.join("\n");
383
511
  }