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.
- package/CHANGELOG.md +37 -1
- package/README.md +94 -5
- 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 +2 -1
- package/src/main.ts +1155 -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 +186 -59
- package/src/target.ts +95 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sproutboat deployments list | status | tail <id>`. On the Cloud a deployment is a build (the deploy-from-source flow):
|
|
3
|
+
* `GET /v1/apps/<app>/builds` lists them, `GET /v1/apps/<app>` says what is live, and `tail` follows one build's event
|
|
4
|
+
* stream server-side, which is what a deploy that was detached with Ctrl-C needs. On self-hosted Community a deployment is a
|
|
5
|
+
* version of the project (`/api/projects/<app>/deployments`), and there is nothing to follow: a deploy there is finished
|
|
6
|
+
* when the command returns.
|
|
7
|
+
*/
|
|
8
|
+
import { makeCall, parseBuildView, type BuildView, type CloudCredentials } from "./cloud-deploy";
|
|
9
|
+
import { isSafeInteger, isString, jsonObject, type JsonObject } from "./json";
|
|
10
|
+
import { bold, dim, ok, rose } from "./style";
|
|
11
|
+
|
|
12
|
+
export type DeploymentsFlags = { json: boolean; limit: number; account?: string };
|
|
13
|
+
export const LIST_LIMIT = 10;
|
|
14
|
+
export const LIST_USAGE = "deployments list [--json]";
|
|
15
|
+
export const STATUS_USAGE = "deployments status [--json]";
|
|
16
|
+
export const TAIL_USAGE = "deployments tail <id>";
|
|
17
|
+
|
|
18
|
+
export class DeploymentsUsageError extends Error {
|
|
19
|
+
constructor(
|
|
20
|
+
message: string,
|
|
21
|
+
readonly usage: string,
|
|
22
|
+
) {
|
|
23
|
+
super(message);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Positionals and flags after `deployments <sub>`. */
|
|
28
|
+
export function parseDeploymentsArgs(args: readonly string[], usage: string) {
|
|
29
|
+
const flags: DeploymentsFlags = { json: false, limit: LIST_LIMIT };
|
|
30
|
+
const positional: string[] = [];
|
|
31
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
32
|
+
const arg = args[index]!;
|
|
33
|
+
const equals = arg.startsWith("--") ? arg.indexOf("=") : -1;
|
|
34
|
+
const name = equals > 0 ? arg.slice(0, equals) : arg;
|
|
35
|
+
if (name === "--json") {
|
|
36
|
+
if (equals > 0) throw new DeploymentsUsageError("--json takes no value", usage);
|
|
37
|
+
flags.json = true;
|
|
38
|
+
} else if (name === "--account") {
|
|
39
|
+
const value = equals > 0 ? arg.slice(equals + 1) : args[(index += 1)];
|
|
40
|
+
if (!value || (equals < 0 && value.startsWith("--")))
|
|
41
|
+
throw new DeploymentsUsageError(`${name} needs a value`, usage);
|
|
42
|
+
flags.account = value;
|
|
43
|
+
} else if (arg.startsWith("-")) throw new DeploymentsUsageError(`unknown option ${arg}`, usage);
|
|
44
|
+
else positional.push(arg);
|
|
45
|
+
}
|
|
46
|
+
return { positional, flags };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type Live = {
|
|
50
|
+
recordGeneration: number | null;
|
|
51
|
+
appliedGeneration: number | null;
|
|
52
|
+
state: string | null;
|
|
53
|
+
error: string | null;
|
|
54
|
+
hosts: string[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** What the Cloud runs for an app now: the record generation, whether a host applied it, and its hostnames. */
|
|
58
|
+
export function parseLive(body: JsonObject | undefined): Live {
|
|
59
|
+
const apply = jsonObject(body?.apply ?? null);
|
|
60
|
+
const spec = jsonObject(body?.spec ?? null);
|
|
61
|
+
const hosts = Array.isArray(spec?.hosts) ? spec.hosts.filter(isString) : [];
|
|
62
|
+
return {
|
|
63
|
+
recordGeneration: isSafeInteger(body?.record_generation) ? body.record_generation : null,
|
|
64
|
+
appliedGeneration: isSafeInteger(apply?.applied_generation) ? apply.applied_generation : null,
|
|
65
|
+
state: isString(apply?.state) ? apply.state : null,
|
|
66
|
+
error: isString(apply?.error) ? apply.error : null,
|
|
67
|
+
hosts,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type CloudDeployment = {
|
|
72
|
+
id: string;
|
|
73
|
+
state: string;
|
|
74
|
+
generation: number | null;
|
|
75
|
+
cached: boolean;
|
|
76
|
+
createdAt: string | null;
|
|
77
|
+
finishedAt: string | null;
|
|
78
|
+
hosts: string[];
|
|
79
|
+
wallMs: number | null;
|
|
80
|
+
error: string | null;
|
|
81
|
+
/** The build whose record generation the host has applied. */
|
|
82
|
+
live: boolean;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function parseDeployments(body: JsonObject | undefined, live: Live | null): CloudDeployment[] {
|
|
86
|
+
const list = Array.isArray(body?.builds) ? body.builds : [];
|
|
87
|
+
return list.flatMap((entry) => {
|
|
88
|
+
const item = jsonObject(entry);
|
|
89
|
+
if (!item || !isString(item.id) || !isString(item.state)) return [];
|
|
90
|
+
const view = parseBuildView(item);
|
|
91
|
+
const stats = jsonObject(item.stats ?? null);
|
|
92
|
+
const generation = view.recordGeneration ?? null;
|
|
93
|
+
return [
|
|
94
|
+
{
|
|
95
|
+
id: view.id,
|
|
96
|
+
state: view.state,
|
|
97
|
+
generation,
|
|
98
|
+
cached: view.cached,
|
|
99
|
+
createdAt: isString(item.queued_at) ? item.queued_at : null,
|
|
100
|
+
finishedAt: isString(item.finished_at) ? item.finished_at : null,
|
|
101
|
+
hosts: Array.isArray(item.hosts) ? item.hosts.filter(isString) : [],
|
|
102
|
+
wallMs: isSafeInteger(stats?.wall_ms) ? stats.wall_ms : null,
|
|
103
|
+
error: view.error?.message ?? null,
|
|
104
|
+
live:
|
|
105
|
+
view.state === "ready" &&
|
|
106
|
+
generation !== null &&
|
|
107
|
+
live?.appliedGeneration === generation &&
|
|
108
|
+
live.state === "applied",
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function fetchLive(credentials: CloudCredentials, project: string): Promise<Live | null> {
|
|
115
|
+
const reply = await makeCall(credentials)({ method: "GET", path: `/v1/apps/${project}`, ok: [404] });
|
|
116
|
+
return reply.status === 404 ? null : parseLive(reply.body);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function fetchDeployments(credentials: CloudCredentials, project: string, limit = LIST_LIMIT) {
|
|
120
|
+
const live = await fetchLive(credentials, project);
|
|
121
|
+
const reply = await makeCall(credentials)({ method: "GET", path: `/v1/apps/${project}/builds?limit=${limit}` });
|
|
122
|
+
return { live, deployments: parseDeployments(reply.body, live) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const when = (iso: string | null) => iso ?? "unknown";
|
|
126
|
+
|
|
127
|
+
export function formatDeployment(item: CloudDeployment): string {
|
|
128
|
+
const lines = [
|
|
129
|
+
`${bold("Deployment:")} ${item.id}${item.live ? ` ${ok("live")}` : ""}`,
|
|
130
|
+
`Created: ${when(item.createdAt)}`,
|
|
131
|
+
`State: ${item.state}${item.wallMs !== null && !item.cached ? ` (built in ${(item.wallMs / 1000).toFixed(1)} s)` : ""}${item.cached ? " (cached build)" : ""}`,
|
|
132
|
+
];
|
|
133
|
+
if (item.generation !== null) lines.push(`Generation: ${item.generation}`);
|
|
134
|
+
if (item.hosts.length > 0) lines.push(`Hosts: ${item.hosts.join(", ")}`);
|
|
135
|
+
if (item.error) lines.push(`Error: ${rose(item.error.split("\n")[0]!)}`);
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function formatStatus(project: string, live: Live | null, latest: CloudDeployment | undefined): string {
|
|
140
|
+
if (!live) return `${project} is not deployed yet: run \`sproutboat deploy\` first`;
|
|
141
|
+
const applied = live.state === "applied" && live.appliedGeneration === live.recordGeneration;
|
|
142
|
+
const lines = [
|
|
143
|
+
`${bold("Application:")} ${project}`,
|
|
144
|
+
`Generation: ${live.recordGeneration ?? "unknown"}${applied ? ` ${ok("applied by a host")}` : ""}`,
|
|
145
|
+
];
|
|
146
|
+
if (!applied)
|
|
147
|
+
lines.push(`Host apply: ${live.state ?? "waiting for a host"}${live.error ? ` (${live.error})` : ""}`);
|
|
148
|
+
if (live.hosts.length > 0) lines.push(`Hosts: ${live.hosts.join(", ")}`);
|
|
149
|
+
if (latest) lines.push(`Latest build: ${latest.id} ${latest.state}${latest.live ? " (live)" : ""}`);
|
|
150
|
+
return lines.join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------- self-hosted Community
|
|
154
|
+
|
|
155
|
+
export type CommunityVersion = { id: string; artifact: string; deployedAt: string; active: boolean };
|
|
156
|
+
|
|
157
|
+
/** `GET /api/projects/<app>/deployments`: an array of { id, artifact, deployedAt, active }. */
|
|
158
|
+
export function parseCommunityVersions(value: JsonObject[]): CommunityVersion[] {
|
|
159
|
+
return value.flatMap((item) =>
|
|
160
|
+
isString(item.id) && isString(item.artifact) && isString(item.deployedAt)
|
|
161
|
+
? [{ id: item.id, artifact: item.artifact, deployedAt: item.deployedAt, active: item.active === true }]
|
|
162
|
+
: [],
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function formatCommunityVersion(item: CommunityVersion): string {
|
|
167
|
+
return [
|
|
168
|
+
`${bold("Deployment:")} ${item.id}${item.active ? ` ${ok("active")}` : ""}`,
|
|
169
|
+
`Created: ${item.deployedAt}`,
|
|
170
|
+
`Artifact: ${item.artifact.slice(0, 12)}`,
|
|
171
|
+
].join("\n");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const COMMUNITY_TAIL =
|
|
175
|
+
"deployments tail is not supported on self-hosted Community: a deploy there finishes before `sproutboat deploy` returns, so there is nothing to follow (see `sproutboat versions list` for past deploys)";
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------- tail
|
|
178
|
+
|
|
179
|
+
export function tailSummary(view: BuildView): string {
|
|
180
|
+
if (view.state === "ready")
|
|
181
|
+
return `${ok("ready")}${view.recordGeneration !== undefined ? `; record generation ${view.recordGeneration}` : ""}`;
|
|
182
|
+
if (view.state === "canceled") return `${dim("canceled")}${view.error ? `: ${view.error.message}` : ""}`;
|
|
183
|
+
return `${rose("failed")}: ${view.error?.message.split("\n").at(-1) ?? "no reason given"}`;
|
|
184
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test helper: a config directory holding two stored logins, a decoy that is the default and the fake Cloud under a
|
|
3
|
+
* name of its own, so a test can tell `--profile` from "whatever is stored". Nothing listens at the decoy.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { FAKE_KEY } from "./cloud-fake";
|
|
9
|
+
|
|
10
|
+
export const DECOY_URL = "http://127.0.0.1:9";
|
|
11
|
+
|
|
12
|
+
export function fakeLogin(cloudUrl: string) {
|
|
13
|
+
const dir = mkdtempSync(join(tmpdir(), "sb-fake-login-"));
|
|
14
|
+
mkdirSync(join(dir, "sproutboat"), { recursive: true });
|
|
15
|
+
writeFileSync(
|
|
16
|
+
join(dir, "sproutboat", "credentials.json"),
|
|
17
|
+
JSON.stringify({
|
|
18
|
+
version: 1,
|
|
19
|
+
activeApiUrl: DECOY_URL,
|
|
20
|
+
profiles: { [DECOY_URL]: { token: "nobody", kind: "community" }, [cloudUrl]: { token: FAKE_KEY, kind: "cloud" } },
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
return {
|
|
24
|
+
dir,
|
|
25
|
+
profile: `127.0.0.1-${new URL(cloudUrl).port}`,
|
|
26
|
+
env: { SPROUTBOAT_CONFIG_DIR: dir, SPROUTBOAT_API_URL: "", SPROUTBOAT_PROFILE: "", SPROUTBOAT_API_TOKEN: "" },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags that every command accepts, parsed in one place and removed from argv before a command sees it.
|
|
3
|
+
* Semantics follow Wrangler's global flags:
|
|
4
|
+
*
|
|
5
|
+
* -c, --config <file> the project's config file (its directory is the project directory)
|
|
6
|
+
* --cwd <dir> run as if started in <dir>
|
|
7
|
+
* -e, --env <name> environment: selects .dev.vars.<name> and the .env.<name> files
|
|
8
|
+
* --env-file <file> repeatable; values from later files override earlier ones
|
|
9
|
+
* --profile <name> the credential profile (also SPROUTBOAT_PROFILE)
|
|
10
|
+
* --api-url <url> the control plane (also SPROUTBOAT_API_URL); only for commands that talk to one
|
|
11
|
+
* -v, --version print the version
|
|
12
|
+
*
|
|
13
|
+
* They can sit anywhere on the line, before or after the command, as `--flag value` or `--flag=value`. A
|
|
14
|
+
* repeated single-value flag takes its last value (a script's fixed flags can be overridden by appending).
|
|
15
|
+
* Everything after a bare `--` is left alone, and so is the value of a flag whose value is free text.
|
|
16
|
+
* `-h/--help` stays in the arguments: the command decides what to print.
|
|
17
|
+
*/
|
|
18
|
+
import { dirname, resolve } from "node:path";
|
|
19
|
+
|
|
20
|
+
export type GlobalFlags = {
|
|
21
|
+
config?: string;
|
|
22
|
+
cwd?: string;
|
|
23
|
+
env?: string;
|
|
24
|
+
/** Every `--env-file`, in the order given. */
|
|
25
|
+
envFiles: string[];
|
|
26
|
+
profile?: string;
|
|
27
|
+
/** `--api-url`: taken out here so every command that talks to a control plane honours it, in both forms. */
|
|
28
|
+
apiUrl?: string;
|
|
29
|
+
version: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type GlobalParse = { flags: GlobalFlags; rest: string[] } | { error: string };
|
|
33
|
+
|
|
34
|
+
type Spec = { names: readonly string[]; key: "config" | "cwd" | "env" | "envFiles" | "profile" | "apiUrl" };
|
|
35
|
+
|
|
36
|
+
const VALUE_FLAGS: readonly Spec[] = [
|
|
37
|
+
{ names: ["-c", "--config"], key: "config" },
|
|
38
|
+
{ names: ["--cwd"], key: "cwd" },
|
|
39
|
+
{ names: ["-e", "--env"], key: "env" },
|
|
40
|
+
{ names: ["--env-file"], key: "envFiles" },
|
|
41
|
+
{ names: ["--profile"], key: "profile" },
|
|
42
|
+
{ names: ["--api-url"], key: "apiUrl" },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/** Per-command flags whose value is free text: it must never be read as a global flag (`--value -e`). */
|
|
46
|
+
const FREE_TEXT_FLAGS = new Set(["--value", "--token", "--search", "--outcome", "--name", "--command"]);
|
|
47
|
+
|
|
48
|
+
/** Split argv into the global flags and what is left for the command. Does not touch the filesystem. */
|
|
49
|
+
export function extractGlobalFlags(argv: readonly string[]): GlobalParse {
|
|
50
|
+
const flags: GlobalFlags = { envFiles: [], version: false };
|
|
51
|
+
const rest: string[] = [];
|
|
52
|
+
for (let at = 0; at < argv.length; at += 1) {
|
|
53
|
+
const arg = argv[at];
|
|
54
|
+
if (arg === "--") {
|
|
55
|
+
rest.push(...argv.slice(at));
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
if (FREE_TEXT_FLAGS.has(arg)) {
|
|
59
|
+
rest.push(arg);
|
|
60
|
+
if (at + 1 < argv.length) rest.push(argv[(at += 1)]);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (arg === "-v" || arg === "--version") {
|
|
64
|
+
flags.version = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const eq = arg.startsWith("-") ? arg.indexOf("=") : -1;
|
|
68
|
+
const name = eq < 0 ? arg : arg.slice(0, eq);
|
|
69
|
+
const spec = VALUE_FLAGS.find((candidate) => candidate.names.includes(name));
|
|
70
|
+
if (!spec) {
|
|
71
|
+
rest.push(arg);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const value = eq < 0 ? argv[(at += 1)] : arg.slice(eq + 1);
|
|
75
|
+
if (value === undefined || value === "" || (eq < 0 && value.startsWith("-") && value !== "-"))
|
|
76
|
+
return { error: `${name} needs a value` };
|
|
77
|
+
if (spec.key === "envFiles") flags.envFiles.push(value);
|
|
78
|
+
else flags[spec.key] = value;
|
|
79
|
+
}
|
|
80
|
+
return { flags, rest };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The global flags of this run. Set once by `main.ts` before any command runs. */
|
|
84
|
+
let current: GlobalFlags = { envFiles: [], version: false };
|
|
85
|
+
|
|
86
|
+
export function setGlobalFlags(flags: GlobalFlags): void {
|
|
87
|
+
current = flags;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function globalFlags(): GlobalFlags {
|
|
91
|
+
return current;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type ProjectLocation = { directory: string; configPath: string };
|
|
95
|
+
|
|
96
|
+
/** What `--config` and a positional project directory say, together. */
|
|
97
|
+
export function locateProject(
|
|
98
|
+
positional: string | undefined,
|
|
99
|
+
config: string | undefined = current.config,
|
|
100
|
+
cwd: string = process.cwd(),
|
|
101
|
+
): ProjectLocation {
|
|
102
|
+
if (config === undefined) {
|
|
103
|
+
const directory = resolve(cwd, positional ?? ".");
|
|
104
|
+
return { directory, configPath: resolve(directory, "sproutboat.jsonc") };
|
|
105
|
+
}
|
|
106
|
+
const configPath = resolve(cwd, config);
|
|
107
|
+
const directory = dirname(configPath);
|
|
108
|
+
if (positional !== undefined && resolve(cwd, positional) !== directory)
|
|
109
|
+
throw new Error(
|
|
110
|
+
`--config ${config} is in ${directory}, but the project directory says ${resolve(cwd, positional)}; pass one`,
|
|
111
|
+
);
|
|
112
|
+
return { directory, configPath };
|
|
113
|
+
}
|
package/src/init-package.ts
CHANGED
|
@@ -5,7 +5,8 @@ import { jsonObject, parseJsonValue, type JsonObject } from "./json";
|
|
|
5
5
|
export type PackageManager = "npm" | "pnpm" | "yarn" | "bun";
|
|
6
6
|
|
|
7
7
|
// Same conventions as Wrangler's create-cloudflare templates: a private
|
|
8
|
-
// package at 0.0.0, `dev` and `
|
|
8
|
+
// package at 0.0.0, `dev`, `deploy` and `check` scripts, and the CLI as a
|
|
9
|
+
// devDependency.
|
|
9
10
|
export const INIT_SCRIPTS = {
|
|
10
11
|
dev: "sproutboat dev",
|
|
11
12
|
deploy: "sproutboat deploy",
|