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.
- package/CHANGELOG.md +45 -1
- package/README.md +114 -13
- package/SURFACE.md +40 -20
- package/package.json +7 -7
- package/src/account-field.ts +69 -0
- package/src/build.ts +12 -1
- package/src/cloud-deploy.ts +752 -0
- package/src/cloud-fake.ts +633 -0
- package/src/cloud-secrets.ts +121 -0
- package/src/cloud.ts +375 -0
- package/src/compile.ts +16 -3
- 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/fake-login.ts +28 -0
- package/src/global-flags.ts +113 -0
- package/src/init-package.ts +100 -0
- package/src/main.ts +1184 -81
- package/src/options.ts +233 -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 +187 -59
- package/src/target.ts +95 -0
|
@@ -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,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
|
-
|
|
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: "🌱",
|
|
94
|
+
short: "Scaffold a new project",
|
|
70
95
|
args: "[name]",
|
|
71
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
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: "[
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
130
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
{
|
|
223
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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} ${
|
|
501
|
+
lines.push(` ${invocation.padEnd(width)} ${command.emoji} ${command.short}`);
|
|
374
502
|
}
|
|
375
503
|
}
|
|
376
504
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
}
|