sproutboat 0.53.1 → 0.55.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/CHANGELOG.md +69 -1
- package/README.md +122 -9
- package/SURFACE.md +42 -22
- package/package.json +20 -12
- package/scripts/embed-templates.ts +87 -0
- package/src/account-field.ts +100 -0
- package/src/build.ts +13 -2
- package/src/cloud-bindings.ts +227 -0
- package/src/cloud-deploy.ts +794 -0
- package/src/cloud-domains.ts +55 -0
- package/src/cloud-fake.ts +719 -0
- package/src/cloud-secrets.ts +121 -0
- package/src/cloud.ts +375 -0
- package/src/compile.ts +34 -18
- package/src/credentials.ts +455 -52
- package/src/d1-cli.ts +455 -0
- package/src/data-errors.ts +139 -0
- package/src/deployments-cli.ts +184 -0
- package/src/dev.ts +67 -4
- package/src/fake-login.ts +28 -0
- package/src/global-flags.ts +113 -0
- package/src/init-package.ts +2 -1
- package/src/init.ts +328 -0
- package/src/main.ts +1247 -193
- package/src/options.ts +242 -0
- package/src/r2-objects.ts +365 -0
- package/src/run.ts +12 -6
- package/src/secrets-bulk.ts +51 -0
- package/src/sql-split.ts +68 -0
- package/src/standalone-build.ts +21 -3
- package/src/surface.ts +192 -64
- package/src/target.ts +95 -0
- package/src/templates-embedded.ts +72 -0
- package/src/templates.ts +53 -0
- package/src/toolchain.ts +3 -0
- package/src/types-cmd.ts +193 -0
- package/src/types-gen.ts +104 -68
- package/types/cloudflare-workers.d.ts +43 -0
- package/types/sproutboat.d.ts +160 -3
|
@@ -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
|
+
}
|
package/src/sql-split.ts
ADDED
|
@@ -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
|
+
}
|
package/src/standalone-build.ts
CHANGED
|
@@ -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(
|
|
54
|
-
|
|
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
|
|
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 = "
|
|
10
|
-
export const DOCS_URL = "https://
|
|
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,54 +57,82 @@ 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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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: "🌱",
|
|
70
|
-
|
|
94
|
+
short: "Scaffold a new project",
|
|
95
|
+
args: "[name] [--template <name>] [--list-templates] [--git | --no-git] [--install | --no-install] [-y, --yes]",
|
|
96
|
+
brief: "[name]",
|
|
71
97
|
summary:
|
|
72
|
-
"Scaffold sproutboat.jsonc, src/index.js and package.json (sproutboat devDependency, dev and
|
|
98
|
+
"Scaffold a project in ./<name> (the last path segment is the app's name), with the shape of `npm create cloudflare`. Without --template it writes sproutboat.jsonc, src/index.js and package.json (sproutboat devDependency, dev, deploy and check scripts). `--template <name>` writes a starter instead, standard Workers TypeScript with tests, a README and a current worker-configuration.d.ts: realtime-room (a chat room on Durable Objects and hibernating WebSockets), api-d1 (a JSON API on D1 with migrations and validation), site-api (a static site and its API in one Worker) or jobs (a cron trigger, a queue and KV); `--list-templates` prints them with their descriptions. In a terminal it asks for the template, the directory, whether to install dependencies and whether to use git (both default to yes); `-y` takes every default without asking, and anywhere else nothing runs that a flag did not ask for. `--install` runs the project's package manager (npm, pnpm, yarn or bun, by lockfile, else the tool that launched the CLI); `--git` runs `git init` and makes a first commit, and does nothing inside an existing repository. It never overwrites a file.",
|
|
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: "🧾",
|
|
85
|
-
|
|
113
|
+
short: "Write worker-configuration.d.ts for your editor",
|
|
114
|
+
args: "[path] [--env-interface <name>] [--strict-vars[=false]] [--include-runtime[=false]] [--include-env[=false]] [--check]",
|
|
115
|
+
brief: "[path]",
|
|
86
116
|
summary:
|
|
87
|
-
|
|
117
|
+
'Generate the project\'s `Env` as TypeScript from sproutboat.jsonc, like `wrangler types`: the file is worker-configuration.d.ts unless <path> names another; it declares `interface Env` (and `Cloudflare.Env`, which `import { env } from "cloudflare:workers"` reads) with a type per binding, literal types for vars (--strict-vars=false makes them `string`), and a Durable Object binding typed with its class when the entry is TypeScript. It references the runtime types shipped with the CLI (--include-runtime=false leaves that out, for a project that brings its own) instead of copying them in, and there is no --include-main-module. Type a handler as `export default { ... } satisfies ExportedHandler<Env>`. --check exits 1 when the file is missing or out of date, by the hash in its first lines. KV, D1, R2, Durable Object and Fetcher types follow the compatibility date, as the runtime does. `dev` and `deploy` refresh the default file when it was written by a plain `sproutboat types` and the config has moved on; they leave one made with other flags alone. A positional that is an existing directory is still read as the project directory. The file earlier releases wrote, sproutboat-env.d.ts, is removed when it is still as generated.',
|
|
88
118
|
},
|
|
89
119
|
{
|
|
90
120
|
name: "dev",
|
|
91
121
|
group: "Develop",
|
|
92
122
|
emoji: "⚡",
|
|
93
|
-
|
|
94
|
-
|
|
123
|
+
short: "Run the app locally, rebuilding on save",
|
|
124
|
+
args: "[project-dir] [--port <n>] [--ip <address>] [--no-watch] [-e, --env <name>] [--env-file <file>]...",
|
|
125
|
+
brief: "[project-dir]",
|
|
95
126
|
summary:
|
|
96
|
-
"Run the project on this machine
|
|
127
|
+
"Run the project on this machine with local storage, rebuilding on save. It listens on 127.0.0.1 unless `--ip` names another address (`--ip 0.0.0.0` is reachable from other machines on the network, as in Wrangler). 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: "[
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
131
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
|
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,70 @@ export const COMMANDS: readonly Command[] = [
|
|
|
164
222
|
name: "domains",
|
|
165
223
|
group: "Configure",
|
|
166
224
|
emoji: "🌐",
|
|
167
|
-
|
|
225
|
+
short: "Attach custom domains",
|
|
226
|
+
args: "<list | add <host> | verify <host> | delete <host>> [project-dir] [--api-url <url>] [--account <team>]",
|
|
168
227
|
brief: "<list | add | verify | delete>",
|
|
169
|
-
summary:
|
|
228
|
+
summary:
|
|
229
|
+
"List the project's domains, or attach one (TXT-verified). On the Cloud only list works: custom domains are added by an operator, and deploys keep them.",
|
|
170
230
|
},
|
|
171
231
|
{
|
|
172
232
|
name: "secrets",
|
|
233
|
+
aliases: ["secret"],
|
|
173
234
|
group: "Configure",
|
|
174
235
|
emoji: "🔑",
|
|
175
|
-
|
|
176
|
-
|
|
236
|
+
short: "Manage encrypted secrets",
|
|
237
|
+
args: "<list | put <NAME> [--value <value>] | delete <NAME> | bulk [file.json | file.env]> [project-dir] [--no-deploy] [--api-url <url>] [--account <team>]",
|
|
238
|
+
brief: "<list | put | delete | bulk>",
|
|
177
239
|
summary:
|
|
178
|
-
|
|
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.",
|
|
240
|
+
'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
241
|
},
|
|
188
|
-
|
|
189
242
|
{
|
|
190
243
|
name: "login",
|
|
191
244
|
group: "Account",
|
|
192
245
|
emoji: "🔓",
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
246
|
+
short: "Sign in",
|
|
247
|
+
args: "[--api-url <url>] [--token <token>] [--account <team>]",
|
|
248
|
+
brief: "",
|
|
249
|
+
summary:
|
|
250
|
+
"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
251
|
},
|
|
197
252
|
{
|
|
198
253
|
name: "logout",
|
|
199
254
|
group: "Account",
|
|
200
255
|
emoji: "🔒",
|
|
201
|
-
|
|
202
|
-
|
|
256
|
+
short: "Forget the stored credential",
|
|
257
|
+
args: "[--api-url <url>] [--account <team>]",
|
|
258
|
+
brief: "",
|
|
259
|
+
summary:
|
|
260
|
+
"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
261
|
},
|
|
204
262
|
{
|
|
205
263
|
name: "whoami",
|
|
206
264
|
group: "Account",
|
|
207
265
|
emoji: "👤",
|
|
208
|
-
|
|
209
|
-
|
|
266
|
+
short: "Show the profile and account you use",
|
|
267
|
+
args: "[--api-url <url>] [--account <team>]",
|
|
268
|
+
brief: "",
|
|
269
|
+
summary:
|
|
270
|
+
"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).",
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
name: "profile",
|
|
274
|
+
group: "Account",
|
|
275
|
+
emoji: "🪪",
|
|
276
|
+
short: "List login profiles or pick the default",
|
|
277
|
+
args: "<list | use <name>>",
|
|
278
|
+
brief: "<list | use>",
|
|
279
|
+
summary:
|
|
280
|
+
"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
281
|
},
|
|
211
282
|
{
|
|
212
283
|
name: "complete",
|
|
213
284
|
group: "Account",
|
|
214
|
-
emoji: "
|
|
285
|
+
emoji: "⌨️",
|
|
286
|
+
short: "Print a shell completion script",
|
|
215
287
|
args: "[bash | zsh | fish]",
|
|
288
|
+
brief: "[shell]",
|
|
216
289
|
summary: "Print a shell completion script for the top-level commands (defaults to $SHELL).",
|
|
217
290
|
},
|
|
218
291
|
];
|
|
@@ -220,8 +293,36 @@ export const COMMANDS: readonly Command[] = [
|
|
|
220
293
|
export type EnvVar = { name: string; purpose: string };
|
|
221
294
|
|
|
222
295
|
export const ENV_VARS: readonly EnvVar[] = [
|
|
223
|
-
{
|
|
224
|
-
|
|
296
|
+
{
|
|
297
|
+
name: "SPROUTBOAT_API_URL",
|
|
298
|
+
purpose:
|
|
299
|
+
"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.",
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
name: "SPROUTBOAT_PROFILE",
|
|
303
|
+
purpose:
|
|
304
|
+
"Stored login profile to use, like --profile (which wins). Its endpoint and credential apply unless --api-url or SPROUTBOAT_API_TOKEN overrides them.",
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
name: "SPROUTBOAT_API_TOKEN",
|
|
308
|
+
purpose:
|
|
309
|
+
"API token (an account API key on the Cloud), like CLOUDFLARE_API_TOKEN. Always wins over the stored login; use it in CI.",
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
name: "SPROUTBOAT_TOKEN",
|
|
313
|
+
purpose: "Older name for SPROUTBOAT_API_TOKEN; SPROUTBOAT_API_TOKEN wins when both are set.",
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
name: "SPROUTBOAT_ACCOUNT",
|
|
317
|
+
purpose:
|
|
318
|
+
"Cloud team (its URL name) a deploy targets, like account_id. Overridden by --account and the account field of sproutboat.jsonc.",
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
name: "SPROUTBOAT_SPROUTD",
|
|
322
|
+
purpose:
|
|
323
|
+
"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.",
|
|
324
|
+
},
|
|
325
|
+
{ name: "SPROUTBOAT_GENERATION", purpose: "Built sproutd generation to compile against (same as --generation)." },
|
|
225
326
|
{ name: "SPROUTBOAT_ZIG", purpose: "Path to a Zig binary to use instead of downloading the pinned one." },
|
|
226
327
|
{
|
|
227
328
|
name: "SPROUTBOAT_ZIG_URL",
|
|
@@ -251,7 +352,11 @@ export const ENV_VARS: readonly EnvVar[] = [
|
|
|
251
352
|
{
|
|
252
353
|
name: "SPROUTBOAT_PORFFOR_CHANNEL",
|
|
253
354
|
purpose:
|
|
254
|
-
"Porffor channel for standalone builds: `alpha-
|
|
355
|
+
"Porffor channel for standalone builds: `alpha-23` (default), `alpha-22` or `alpha-19` (the oldest compiler, kept as a fallback for one release).",
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
name: "SPROUTBOAT_RELEASE",
|
|
359
|
+
purpose: "Same as `build --standalone --release`: link with LTO. Any value but empty, `0` or `false` turns it on.",
|
|
255
360
|
},
|
|
256
361
|
{
|
|
257
362
|
name: "SPROUTBOAT_FLTO",
|
|
@@ -282,7 +387,11 @@ export const ENV_VARS: readonly EnvVar[] = [
|
|
|
282
387
|
name: "SPROUTBOAT_BINDINGS_JSON",
|
|
283
388
|
purpose: "The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line.",
|
|
284
389
|
},
|
|
285
|
-
{
|
|
390
|
+
{
|
|
391
|
+
name: "SPROUTBOAT_CONFIG_DIR",
|
|
392
|
+
purpose:
|
|
393
|
+
"Directory for credentials.json (default ~/Library/Preferences/sproutboat on macOS, ~/.config/sproutboat elsewhere; the old macOS place is still read).",
|
|
394
|
+
},
|
|
286
395
|
{
|
|
287
396
|
name: "NO_COLOR",
|
|
288
397
|
purpose:
|
|
@@ -355,11 +464,30 @@ export function usageLine(): string {
|
|
|
355
464
|
return `usage: ${CLI_NAME} <${parts.join(" | ")}>`;
|
|
356
465
|
}
|
|
357
466
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
467
|
+
export type GlobalFlag = { flags: string; purpose: string; type: string };
|
|
468
|
+
|
|
469
|
+
/** Flags every command accepts, listed by `sproutboat --help`. */
|
|
470
|
+
export const GLOBAL_FLAGS: readonly GlobalFlag[] = [
|
|
471
|
+
{ flags: "-c, --config", purpose: "Path to the project's config file", type: "string" },
|
|
472
|
+
{ flags: " --cwd", purpose: "Run as if sproutboat was started in this directory", type: "string" },
|
|
473
|
+
{
|
|
474
|
+
flags: "-e, --env",
|
|
475
|
+
purpose: "Environment for `dev`: selects the .dev.vars.<name> and .env.<name> files",
|
|
476
|
+
type: "string",
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
flags: " --env-file",
|
|
480
|
+
purpose: "Path to an .env file to load; repeat it, and later files override earlier ones",
|
|
481
|
+
type: "array",
|
|
482
|
+
},
|
|
483
|
+
{ flags: "-h, --help", purpose: "Show help", type: "boolean" },
|
|
484
|
+
{
|
|
485
|
+
flags: " --profile",
|
|
486
|
+
purpose: "Use a stored login profile (default: SPROUTBOAT_PROFILE, else the active profile)",
|
|
487
|
+
type: "string",
|
|
488
|
+
},
|
|
489
|
+
{ flags: "-v, --version", purpose: "Show version number", type: "boolean" },
|
|
490
|
+
];
|
|
363
491
|
|
|
364
492
|
/** Wrangler-style grouped help: `sproutboat` with no command, or `--help`. */
|
|
365
493
|
export function helpText(): string {
|
|
@@ -371,14 +499,14 @@ export function helpText(): string {
|
|
|
371
499
|
lines.push("", leaf(group.toUpperCase()));
|
|
372
500
|
for (const command of COMMANDS.filter((c) => c.group === group)) {
|
|
373
501
|
const invocation = `${command.name} ${command.brief ?? command.args}`.trim();
|
|
374
|
-
lines.push(` ${invocation.padEnd(width)} ${command.emoji} ${
|
|
502
|
+
lines.push(` ${invocation.padEnd(width)} ${command.emoji} ${command.short}`);
|
|
375
503
|
}
|
|
376
504
|
}
|
|
377
505
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
);
|
|
506
|
+
const flagWidth = Math.max(...GLOBAL_FLAGS.map((flag) => flag.flags.length));
|
|
507
|
+
lines.push("", leaf("GLOBAL FLAGS"));
|
|
508
|
+
for (const flag of GLOBAL_FLAGS) lines.push(` ${flag.flags.padEnd(flagWidth)} ${flag.purpose} [${flag.type}]`);
|
|
509
|
+
|
|
510
|
+
lines.push("", dim(`Run \`${CLI_NAME} <command> --help\` for a command's options.`), dim(`Docs: ${DOCS_URL}`));
|
|
383
511
|
return lines.join("\n");
|
|
384
512
|
}
|