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,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
|
+
}
|
package/src/dev.ts
CHANGED
|
@@ -32,6 +32,8 @@ export type DevInput = {
|
|
|
32
32
|
/** The bundled module (#89) — already validated by the caller. */
|
|
33
33
|
source: string;
|
|
34
34
|
port: number;
|
|
35
|
+
/** `--ip`: the address the public port listens on. Default 127.0.0.1; 0.0.0.0 makes it reachable on the network. */
|
|
36
|
+
host?: string;
|
|
35
37
|
watch: boolean;
|
|
36
38
|
/** #275: `--env-file` paths (absolute), in order. Default: `.dev.vars`, else the `.env` files. */
|
|
37
39
|
envFiles?: string[];
|
|
@@ -105,6 +107,52 @@ export type Running = {
|
|
|
105
107
|
disableDispatch: () => void;
|
|
106
108
|
};
|
|
107
109
|
|
|
110
|
+
/** One browser WebSocket and the connection to the sprout that answers it. */
|
|
111
|
+
type SocketRelay = {
|
|
112
|
+
url: string;
|
|
113
|
+
protocols: string[];
|
|
114
|
+
/** Frames that arrived before the sprout side finished opening. */
|
|
115
|
+
queued: Array<string | Uint8Array<ArrayBuffer>>;
|
|
116
|
+
upstream?: WebSocket;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const socketRelay: Bun.WebSocketHandler<SocketRelay> = {
|
|
120
|
+
open(client) {
|
|
121
|
+
const relay = client.data;
|
|
122
|
+
const upstream = new WebSocket(relay.url, relay.protocols);
|
|
123
|
+
upstream.binaryType = "arraybuffer";
|
|
124
|
+
relay.upstream = upstream;
|
|
125
|
+
upstream.addEventListener("open", () => {
|
|
126
|
+
for (const frame of relay.queued.splice(0)) upstream.send(frame);
|
|
127
|
+
});
|
|
128
|
+
upstream.addEventListener("message", (event) => {
|
|
129
|
+
if (client.readyState !== 1) return;
|
|
130
|
+
// SAFETY: binaryType is "arraybuffer", so a frame is a string or an ArrayBuffer.
|
|
131
|
+
client.send(event.data as string | ArrayBuffer);
|
|
132
|
+
});
|
|
133
|
+
upstream.addEventListener("close", (event) => {
|
|
134
|
+
// 1005 and 1006 are reserved for the receiver to report; they cannot be sent.
|
|
135
|
+
const code =
|
|
136
|
+
event.code >= 1000 && event.code < 5000 && event.code !== 1005 && event.code !== 1006 ? event.code : 1000;
|
|
137
|
+
client.close(code, event.reason);
|
|
138
|
+
});
|
|
139
|
+
upstream.addEventListener("error", () => client.close(1011, "sprout unavailable"));
|
|
140
|
+
},
|
|
141
|
+
message(client, message) {
|
|
142
|
+
const upstream = client.data.upstream;
|
|
143
|
+
const frame = message instanceof Uint8Array ? new Uint8Array(message) : message;
|
|
144
|
+
if (upstream && upstream.readyState === 1) upstream.send(frame);
|
|
145
|
+
else client.data.queued.push(frame);
|
|
146
|
+
},
|
|
147
|
+
close(client, code, reason) {
|
|
148
|
+
const upstream = client.data.upstream;
|
|
149
|
+
if (!upstream || upstream.readyState > 1) return;
|
|
150
|
+
try {
|
|
151
|
+
upstream.close(code === 1005 || code === 1006 ? 1000 : code, reason);
|
|
152
|
+
} catch {}
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
108
156
|
async function start(input: DevInput, port: number): Promise<Running> {
|
|
109
157
|
const candidateDir = resolve(
|
|
110
158
|
input.projectDir,
|
|
@@ -285,12 +333,26 @@ export async function runDev(input: DevInput): Promise<void> {
|
|
|
285
333
|
// what lets a failed startup leave the last known-good process reachable.
|
|
286
334
|
let proxy: ReturnType<typeof Bun.serve>;
|
|
287
335
|
try {
|
|
288
|
-
proxy = Bun.serve({
|
|
289
|
-
hostname: "127.0.0.1",
|
|
336
|
+
proxy = Bun.serve<SocketRelay>({
|
|
337
|
+
hostname: input.host ?? "127.0.0.1",
|
|
290
338
|
port: input.port,
|
|
291
339
|
maxRequestBodySize: 5 * 1024 * 1024 * 1024,
|
|
340
|
+
websocket: socketRelay,
|
|
292
341
|
fetch(request, server) {
|
|
293
342
|
const target = new URL(request.url);
|
|
343
|
+
// A WebSocket upgrade cannot go through fetch(): relay it, frame by frame, to the sprout.
|
|
344
|
+
if (request.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
345
|
+
target.protocol = "ws:";
|
|
346
|
+
target.host = `127.0.0.1:${activePort}`;
|
|
347
|
+
const protocols = request.headers.get("sec-websocket-protocol");
|
|
348
|
+
const data: SocketRelay = {
|
|
349
|
+
url: target.toString(),
|
|
350
|
+
protocols: protocols ? protocols.split(",").map((entry) => entry.trim()) : [],
|
|
351
|
+
queued: [],
|
|
352
|
+
};
|
|
353
|
+
if (server.upgrade(request, { data })) return undefined;
|
|
354
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
355
|
+
}
|
|
294
356
|
const directTransfer = /^\/__sb\/r2\/transfer\/[A-Z][A-Z0-9_]*\/[0-9a-f]{24}$/.test(target.pathname);
|
|
295
357
|
if (directTransfer) server.timeout(request, 255);
|
|
296
358
|
if (directTransfer && !activeTransferPort) return new Response("direct transfers unavailable", { status: 503 });
|
|
@@ -308,7 +370,7 @@ export async function runDev(input: DevInput): Promise<void> {
|
|
|
308
370
|
throw error;
|
|
309
371
|
}
|
|
310
372
|
watchExit(running);
|
|
311
|
-
console.log(ok(`${input.config.name} running on ${leaf(`http
|
|
373
|
+
console.log(ok(`${input.config.name} running on ${leaf(`http://${input.host ?? "127.0.0.1"}:${input.port}`)}`));
|
|
312
374
|
if (input.watch) console.log(dim(" watching for changes — ctrl-c to stop"));
|
|
313
375
|
|
|
314
376
|
let watchers: FSWatcher[] = [];
|
|
@@ -319,7 +381,8 @@ export async function runDev(input: DevInput): Promise<void> {
|
|
|
319
381
|
if (shuttingDown) return;
|
|
320
382
|
shuttingDown = true;
|
|
321
383
|
for (const watcher of watchers) watcher.close();
|
|
322
|
-
|
|
384
|
+
// Close open sockets too: a graceful stop waits for a WebSocket the browser keeps open.
|
|
385
|
+
proxy.stop(true);
|
|
323
386
|
if (pendingCandidate) stop(pendingCandidate);
|
|
324
387
|
stop(running);
|
|
325
388
|
resolveShutdown?.();
|
|
@@ -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",
|