sproutboat 0.53.1 → 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,36 +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]",
96
+ brief: "[name]",
71
97
  summary:
72
- "Scaffold sproutboat.jsonc, src/index.js and package.json (sproutboat devDependency, dev and deploy scripts) in ./<name>.",
98
+ "Scaffold sproutboat.jsonc, src/index.js and package.json (sproutboat devDependency, dev, deploy and check scripts) in ./<name>.",
73
99
  },
74
100
  {
75
101
  name: "check",
76
102
  group: "Develop",
77
103
  emoji: "🔍",
104
+ short: "Validate the config and entry point",
78
105
  args: "[project-dir]",
106
+ brief: "[project-dir]",
79
107
  summary: "Validate the config and entry point without building.",
80
108
  },
81
109
  {
82
110
  name: "types",
83
111
  group: "Develop",
84
112
  emoji: "🧾",
113
+ short: "Write sproutboat-env.d.ts for your editor",
85
114
  args: "[project-dir]",
115
+ brief: "[project-dir]",
86
116
  summary:
87
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.",
88
118
  },
@@ -90,17 +120,19 @@ export const COMMANDS: readonly Command[] = [
90
120
  name: "dev",
91
121
  group: "Develop",
92
122
  emoji: "⚡",
93
- args: "[project-dir] [--port <n>] [--no-watch] [--env <name>] [--env-file <file>]...",
94
- 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]",
95
126
  summary:
96
- "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.",
97
128
  },
98
129
  {
99
130
  name: "run",
100
131
  group: "Develop",
101
- emoji: "🚀",
132
+ emoji: "🏃",
133
+ short: "Launch a built standalone binary",
102
134
  args: "[project-dir] [--binary <file>] [--env-file <file>]... [--port <n>] [--data-dir <dir>] [--require <NAME,...>] [--check]",
103
- brief: "[--env-file <file>] [--port <n>]",
135
+ brief: "[project-dir]",
104
136
  summary:
105
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.",
106
138
  },
@@ -108,15 +140,17 @@ export const COMMANDS: readonly Command[] = [
108
140
  name: "build",
109
141
  group: "Develop",
110
142
  emoji: "🔨",
111
- 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]",
112
145
  brief: "[project-dir]",
113
146
  summary:
114
- "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.",
115
148
  },
116
149
  {
117
150
  name: "toolchain",
118
151
  group: "Develop",
119
152
  emoji: "🩺",
153
+ short: "Check the compiler toolchain",
120
154
  args: "doctor [--json]",
121
155
  brief: "doctor",
122
156
  summary:
@@ -127,24 +161,37 @@ export const COMMANDS: readonly Command[] = [
127
161
  name: "deploy",
128
162
  group: "Ship",
129
163
  emoji: "🚀",
130
- args: "[project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision]",
131
- 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]",
132
167
  summary:
133
- "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.",
134
179
  },
135
180
  {
136
181
  name: "versions",
137
182
  group: "Ship",
138
183
  emoji: "📜",
139
- 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>]",
140
186
  brief: "<list | view>",
141
187
  summary: "List the project's deployed versions, or show one version's artifact and bindings.",
142
188
  },
143
189
  {
144
190
  name: "rollback",
145
191
  group: "Ship",
146
- emoji: "⏮",
147
- args: "<version-id> [project-dir]",
192
+ emoji: "⏪",
193
+ short: "Re-activate a previous version",
194
+ args: "<version-id> [project-dir] [--api-url <url>]",
148
195
  brief: "<version-id>",
149
196
  summary: "Re-activate a previous version.",
150
197
  },
@@ -152,10 +199,21 @@ export const COMMANDS: readonly Command[] = [
152
199
  name: "tail",
153
200
  group: "Ship",
154
201
  emoji: "📡",
155
- 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]]",
156
- 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]",
157
205
  summary:
158
- "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.",
159
217
  },
160
218
 
161
219
  ...storageCommands,
@@ -164,55 +222,69 @@ export const COMMANDS: readonly Command[] = [
164
222
  name: "domains",
165
223
  group: "Configure",
166
224
  emoji: "🌐",
167
- 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>]",
168
227
  brief: "<list | add | verify | delete>",
169
228
  summary: "Attach a custom domain to the project (TXT-verified). No sub-command lists.",
170
229
  },
171
230
  {
172
231
  name: "secrets",
232
+ aliases: ["secret"],
173
233
  group: "Configure",
174
234
  emoji: "🔑",
175
- args: "<list | put <NAME> [--value <value>] | delete <NAME>> [project-dir]",
176
- 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>",
177
238
  summary:
178
- "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.",
179
- },
180
- {
181
- name: "delete",
182
- group: "Configure",
183
- emoji: "🗑",
184
- args: "[project-dir] [--name <project>] --yes",
185
- brief: "[project-dir] --yes",
186
- 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.',
187
240
  },
188
-
189
241
  {
190
242
  name: "login",
191
243
  group: "Account",
192
244
  emoji: "🔓",
193
- args: "[--api-url <url>] [--token <token>]",
194
- brief: "[--token <token>]",
195
- 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.",
196
250
  },
197
251
  {
198
252
  name: "logout",
199
253
  group: "Account",
200
254
  emoji: "🔒",
201
- args: "[--api-url <url>]",
202
- 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).",
203
260
  },
204
261
  {
205
262
  name: "whoami",
206
263
  group: "Account",
207
264
  emoji: "👤",
208
- args: "",
209
- 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.",
210
280
  },
211
281
  {
212
282
  name: "complete",
213
283
  group: "Account",
214
- emoji: "⌨",
284
+ emoji: "⌨️",
285
+ short: "Print a shell completion script",
215
286
  args: "[bash | zsh | fish]",
287
+ brief: "[shell]",
216
288
  summary: "Print a shell completion script for the top-level commands (defaults to $SHELL).",
217
289
  },
218
290
  ];
@@ -220,8 +292,36 @@ export const COMMANDS: readonly Command[] = [
220
292
  export type EnvVar = { name: string; purpose: string };
221
293
 
222
294
  export const ENV_VARS: readonly EnvVar[] = [
223
- { name: "SPROUTBOAT_API_URL", purpose: "Control-plane URL. Overrides the saved active endpoint." },
224
- { 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)." },
225
325
  { name: "SPROUTBOAT_ZIG", purpose: "Path to a Zig binary to use instead of downloading the pinned one." },
226
326
  {
227
327
  name: "SPROUTBOAT_ZIG_URL",
@@ -253,6 +353,10 @@ export const ENV_VARS: readonly EnvVar[] = [
253
353
  purpose:
254
354
  "Porffor channel for standalone builds: `alpha-22` (default) or `alpha-19` (the previous compiler, kept as a fallback for one release).",
255
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
+ },
256
360
  {
257
361
  name: "SPROUTBOAT_FLTO",
258
362
  purpose: "Overrides link-time optimization on alpha 22 builds: `1` turns it on, `0` turns it off.",
@@ -282,7 +386,11 @@ export const ENV_VARS: readonly EnvVar[] = [
282
386
  name: "SPROUTBOAT_BINDINGS_JSON",
283
387
  purpose: "The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line.",
284
388
  },
285
- { 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
+ },
286
394
  {
287
395
  name: "NO_COLOR",
288
396
  purpose:
@@ -355,11 +463,30 @@ export function usageLine(): string {
355
463
  return `usage: ${CLI_NAME} <${parts.join(" | ")}>`;
356
464
  }
357
465
 
358
- /** First sentence of a summary — enough for the at-a-glance command list. */
359
- function firstSentence(text: string): string {
360
- const end = text.indexOf(". ");
361
- return end === -1 ? text.replace(/\.$/, "") : text.slice(0, end);
362
- }
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
+ ];
363
490
 
364
491
  /** Wrangler-style grouped help: `sproutboat` with no command, or `--help`. */
365
492
  export function helpText(): string {
@@ -371,14 +498,14 @@ export function helpText(): string {
371
498
  lines.push("", leaf(group.toUpperCase()));
372
499
  for (const command of COMMANDS.filter((c) => c.group === group)) {
373
500
  const invocation = `${command.name} ${command.brief ?? command.args}`.trim();
374
- lines.push(` ${invocation.padEnd(width)} ${command.emoji} ${firstSentence(command.summary)}`);
501
+ lines.push(` ${invocation.padEnd(width)} ${command.emoji} ${command.short}`);
375
502
  }
376
503
  }
377
504
 
378
- lines.push(
379
- "",
380
- dim(`Run \`${CLI_NAME} <command>\` with no/invalid args to see that command's full usage.`),
381
- dim(`Docs: ${DOCS_URL}`),
382
- );
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}`));
383
510
  return lines.join("\n");
384
511
  }