sproutboat 0.4.11 → 0.6.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/src/main.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  #!/usr/bin/env bun
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { basename, resolve } from "node:path";
4
+ import { isBoolean, isSafeInteger, isString, jsonObject, parseJsonValue, type JsonObject } from "./json";
5
+ import type { AssetFiles } from "./assets";
4
6
  import { parseConfig, pinBindingId, resourceRefs, type SproutboatConfig } from "./config";
5
7
  import { validateHttpSyncSource } from "./source";
6
8
  import { buildArtifact } from "./build";
7
- import { validateManifest, type ArtifactManifest } from "./manifest";
9
+ import { bundleHandler, BundleError, type BundleResult } from "./bundle";
10
+ import { runDev } from "./dev";
11
+ import { hostTarget, validateManifest, type ArtifactManifest } from "./manifest";
8
12
  import { CLI_VERSION, printDeployReport } from "./report";
9
- import { activeApiUrl, savedToken, saveToken } from "./credentials";
10
- import { helpText } from "./surface";
13
+ import { activeApiUrl, forgetToken, savedToken, saveToken } from "./credentials";
14
+ import { helpText, STORAGE_PRODUCTS, STORAGE_VERBS, type StorageProduct } from "./surface";
11
15
  import { notifyIfOutdated } from "./update-check";
12
16
  import { amber, bold, dim, leaf, ok, rose } from "./style";
13
17
 
@@ -18,29 +22,14 @@ async function responseText(response: Response, failure: string): Promise<string
18
22
  fail(`${failure} (${response.status}): ${await response.text()}`);
19
23
  }
20
24
 
21
- type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];
22
- type JsonObject = { [key: string]: JsonValue };
23
-
24
- function isString(value: JsonValue | undefined): value is string {
25
- return value !== undefined && value === String(value);
26
- }
27
-
28
- function isSafeInteger(value: JsonValue | undefined): value is number {
29
- return Number.isSafeInteger(value);
30
- }
31
-
32
- function parseJsonValue(source: string): JsonValue {
33
- const value = JSON.parse(source);
34
- if (value === null || value === true || value === false || value === String(value) || Number.isFinite(value) || value instanceof Object) return value;
35
- throw new Error("response was not valid JSON");
36
- }
37
-
38
- function jsonObject(value: JsonValue): JsonObject | undefined {
39
- return value instanceof Object && !Array.isArray(value) ? value : undefined;
40
- }
41
-
42
25
  type VersionSummary = { id: string; artifact: string; deployedAt: string; active: boolean };
43
- type CliAuthorization = { deviceCode: string; userCode: string; verificationUri: string; interval: number; expiresAt: string };
26
+ type CliAuthorization = {
27
+ deviceCode: string;
28
+ userCode: string;
29
+ verificationUri: string;
30
+ interval: number;
31
+ expiresAt: string;
32
+ };
44
33
 
45
34
  function parseVersionList(source: string): VersionSummary[] | undefined {
46
35
  const value = parseJsonValue(source);
@@ -48,13 +37,27 @@ function parseVersionList(source: string): VersionSummary[] | undefined {
48
37
  const deployments: VersionSummary[] = [];
49
38
  for (const item of value) {
50
39
  const record = jsonObject(item);
51
- if (!record || !isString(record.id) || !isString(record.artifact) || !isString(record.deployedAt) || (record.active !== true && record.active !== false)) return undefined;
52
- deployments.push({ id: record.id, artifact: record.artifact, deployedAt: record.deployedAt, active: record.active });
40
+ if (
41
+ !record ||
42
+ !isString(record.id) ||
43
+ !isString(record.artifact) ||
44
+ !isString(record.deployedAt) ||
45
+ (record.active !== true && record.active !== false)
46
+ )
47
+ return undefined;
48
+ deployments.push({
49
+ id: record.id,
50
+ artifact: record.artifact,
51
+ deployedAt: record.deployedAt,
52
+ active: record.active,
53
+ });
53
54
  }
54
55
  return deployments;
55
56
  }
56
57
 
57
- function parseUrlResponse(source: string): { url: string; id?: string; artifact?: string; unchanged: boolean } | undefined {
58
+ function parseUrlResponse(
59
+ source: string,
60
+ ): { url: string; id?: string; artifact?: string; unchanged: boolean } | undefined {
58
61
  const record = jsonObject(parseJsonValue(source));
59
62
  if (!record || !isString(record.url)) return undefined;
60
63
  return {
@@ -70,15 +73,35 @@ function parseUrlResponse(source: string): { url: string; id?: string; artifact?
70
73
  * the pin only changes by redeploying — and the alpha compiler's output can
71
74
  * differ between pins. */
72
75
  function parsePorfforDrift(source: string): { from: string; to: string } | undefined {
73
- const drift = (() => { try { return jsonObject(parseJsonValue(source))?.porfforDrift; } catch { return undefined; } })();
74
- const record = drift && jsonObject(drift as JsonValue);
76
+ const drift = (() => {
77
+ try {
78
+ return jsonObject(parseJsonValue(source))?.porfforDrift;
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ })();
83
+ const record = drift && jsonObject(drift);
75
84
  return record && isString(record.from) && isString(record.to) ? { from: record.from, to: record.to } : undefined;
76
85
  }
77
86
 
78
87
  function parseAuthorization(source: string): CliAuthorization | undefined {
79
88
  const record = jsonObject(parseJsonValue(source));
80
- if (!record || !isString(record.deviceCode) || !isString(record.userCode) || !isString(record.verificationUri) || !isSafeInteger(record.interval) || !isString(record.expiresAt)) return undefined;
81
- return { deviceCode: record.deviceCode, userCode: record.userCode, verificationUri: record.verificationUri, interval: record.interval, expiresAt: record.expiresAt };
89
+ if (
90
+ !record ||
91
+ !isString(record.deviceCode) ||
92
+ !isString(record.userCode) ||
93
+ !isString(record.verificationUri) ||
94
+ !isSafeInteger(record.interval) ||
95
+ !isString(record.expiresAt)
96
+ )
97
+ return undefined;
98
+ return {
99
+ deviceCode: record.deviceCode,
100
+ userCode: record.userCode,
101
+ verificationUri: record.verificationUri,
102
+ interval: record.interval,
103
+ expiresAt: record.expiresAt,
104
+ };
82
105
  }
83
106
 
84
107
  function parseToken(source: string): string | undefined {
@@ -99,6 +122,14 @@ const starterHandler = `export default {
99
122
  }
100
123
  };
101
124
  `;
125
+ // .sproutboat/ holds build output (dist/) and `dev`'s local broker state
126
+ // (dev/, including its SQLite files) — neither belongs in version control.
127
+ // .dev.vars carries secret values for `sproutboat dev`, same convention as
128
+ // Wrangler's file of the same name.
129
+ const starterGitignore = `.sproutboat/
130
+ .dev.vars
131
+ node_modules/
132
+ `;
102
133
 
103
134
  /** An operational failure — the command was invoked correctly but could not complete. Exit 1. */
104
135
  function fail(message: string): never {
@@ -134,21 +165,49 @@ async function readProject(directory = process.cwd()) {
134
165
  } catch {
135
166
  fail(`entry point not found: ${parsed.value.main}`);
136
167
  }
137
- const supported = validateHttpSyncSource(source, (parsed.value.outbound ?? []).length > 0);
168
+ // #89 — resolve imports first, then hold the *bundled* module to the
169
+ // capability rules. Validating the entry file instead would let a dependency
170
+ // smuggle in a Node API the handler is not allowed to touch.
171
+ let bundle: BundleResult;
172
+ try {
173
+ bundle = await bundleHandler(sourcePath, projectDirectory);
174
+ } catch (cause) {
175
+ fail(cause instanceof BundleError ? cause.message : String(cause));
176
+ }
177
+ const supported = validateHttpSyncSource(bundle.code, (parsed.value.outbound ?? []).length > 0);
138
178
  if (!supported.ok) fail(supported.errors.join("\n"));
139
- return { directory: projectDirectory, config: parsed.value, sourcePath, source };
179
+ return { directory: projectDirectory, config: parsed.value, sourcePath, source, bundle };
140
180
  }
141
181
 
142
182
  async function init(name = "hello") {
143
- if (!/^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?$/.test(name)) fail("project name must be a 3–32 character lowercase slug");
183
+ if (!/^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?$/.test(name))
184
+ fail("project name must be a 3–32 character lowercase slug");
144
185
  const directory = resolve(process.cwd(), name);
145
186
  const configPath = resolve(directory, "sproutboat.jsonc");
146
- if (await Bun.file(configPath).exists()) fail(`${basename(directory)} already contains sproutboat.jsonc`);
187
+ const handlerPath = resolve(directory, "src/index.js");
188
+ // Check both targets before writing either — `name` can collide with an
189
+ // unrelated existing directory, and a second `wx` write failing partway
190
+ // through used to crash with a raw EEXIST stack trace after already having
191
+ // created sproutboat.jsonc, leaving a half-scaffolded project behind.
192
+ for (const [path, label] of [
193
+ [configPath, "sproutboat.jsonc"],
194
+ [handlerPath, "src/index.js"],
195
+ ] as const) {
196
+ if (await Bun.file(path).exists()) fail(`${basename(directory)} already contains ${label}`);
197
+ }
147
198
  await mkdir(resolve(directory, "src"), { recursive: true });
148
199
  await writeFile(configPath, starterConfig(name), { flag: "wx" });
149
- await writeFile(resolve(directory, "src/index.js"), starterHandler, { flag: "wx" });
200
+ await writeFile(handlerPath, starterHandler, { flag: "wx" });
150
201
  console.log(`Created ${basename(directory)}/sproutboat.jsonc`);
151
202
  console.log(`Created ${basename(directory)}/src/index.js`);
203
+ // Unlike the two files above, an existing .gitignore here is not a sign this
204
+ // isn't a fresh project (`name` can collide with an unrelated directory) —
205
+ // leave it alone rather than failing init or clobbering it.
206
+ const gitignorePath = resolve(directory, ".gitignore");
207
+ if (!(await Bun.file(gitignorePath).exists())) {
208
+ await writeFile(gitignorePath, starterGitignore, { flag: "wx" });
209
+ console.log(`Created ${basename(directory)}/.gitignore`);
210
+ }
152
211
  }
153
212
 
154
213
  async function check(directory?: string) {
@@ -156,15 +215,50 @@ async function check(directory?: string) {
156
215
  console.log(ok(`check passed — ${project.config.name} (${project.config.main}, native-fetch)`));
157
216
  }
158
217
 
159
- async function build(directory?: string) {
218
+ async function build(directory?: string, target: "linux-x86_64" | "host" = "linux-x86_64") {
160
219
  const project = await readProject(directory);
161
- console.log(dim("Compiling the native-fetch server with Porffor + Zig (linux-x86_64, static)…"));
162
- const artifact = await buildArtifact({ projectDir: project.directory, config: project.config, sourcePath: project.sourcePath });
220
+ console.log(
221
+ target === "host"
222
+ ? dim(`Compiling the native-fetch server with Porffor for this machine (${hostTarget()}, local only)…`)
223
+ : dim("Compiling the native-fetch server with Porffor + Zig (linux-x86_64, static)…"),
224
+ );
225
+ const artifact = await buildArtifact({
226
+ projectDir: project.directory,
227
+ config: project.config,
228
+ sourcePath: project.sourcePath,
229
+ source: project.bundle.code,
230
+ target,
231
+ });
163
232
  console.log(ok(`built ${project.config.name}`));
233
+ if (target === "host")
234
+ console.log(dim(" host build — runs here, not deployable; drop --target host to build for a box"));
164
235
  console.log(artifact.artifactDir);
165
236
  return { project, artifact };
166
237
  }
167
238
 
239
+ /** #62 — build for this machine, run it against a real broker, rebuild on save. */
240
+ async function dev(args: string[]) {
241
+ const directory = args.find((arg) => !arg.startsWith("--") && !/^\d+$/.test(arg));
242
+ const portIndex = args.indexOf("--port");
243
+ const portArg = portIndex >= 0 ? args[portIndex + 1] : undefined;
244
+ const port = Number(portArg ?? 8787);
245
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65_535)
246
+ usageError(`invalid --port: ${portArg}`, "dev [project-dir] [--port <n>] [--no-watch]");
247
+ const project = await readProject(directory);
248
+ console.log(dim(`Building ${project.config.name} for this machine (${hostTarget()})…`));
249
+ await runDev({
250
+ projectDir: project.directory,
251
+ config: project.config,
252
+ sourcePath: project.sourcePath,
253
+ source: project.bundle.code,
254
+ port,
255
+ watch: !args.includes("--no-watch"),
256
+ // Re-read from disk on every rebuild: the point of watching is that the
257
+ // files changed, so the bundle captured at startup is stale by definition.
258
+ rebuild: async () => (await readProject(directory)).bundle.code,
259
+ });
260
+ }
261
+
168
262
  const PROVISION_FIELDS = [
169
263
  ["kv_namespaces", "kv"],
170
264
  ["d1_databases", "d1"],
@@ -211,6 +305,46 @@ async function provisionBindings(directory = process.cwd()): Promise<void> {
211
305
  await writeFile(configPath, source);
212
306
  }
213
307
 
308
+ /** #79 — wrangler parity: drop the stored credential for an endpoint. */
309
+ async function logout(args: string[]) {
310
+ const { apiUrl } = parseLoginArgs(args);
311
+ console.log(
312
+ (await forgetToken(apiUrl)) ? ok(`forgot the credential for ${apiUrl}`) : `no stored credential for ${apiUrl}`,
313
+ );
314
+ }
315
+
316
+ /**
317
+ * #79 — wrangler parity: which endpoint, and who the stored token belongs to.
318
+ * The account comes from the control plane, so this also proves the token still
319
+ * works rather than only reporting what is on disk.
320
+ */
321
+ async function whoami() {
322
+ const apiUrl = process.env.SPROUTBOAT_API_URL || (await activeApiUrl());
323
+ if (!apiUrl) {
324
+ console.log("not logged in — run `sproutboat login`");
325
+ return;
326
+ }
327
+ const token = process.env.SPROUTBOAT_TOKEN || (await savedToken(apiUrl));
328
+ console.log(`endpoint ${apiUrl}`);
329
+ if (!token) {
330
+ console.log(`account ${dim("no stored token — run `sproutboat login`")}`);
331
+ return;
332
+ }
333
+
334
+ const response = await fetch(`${apiUrl}/api/account`, { headers: { "x-api-key": token } });
335
+ if (!response.ok) {
336
+ console.log(
337
+ `account ${rose(response.status === 401 ? "token rejected — run `sproutboat login`" : `control plane said ${response.status}`)}`,
338
+ );
339
+ return;
340
+ }
341
+ const account = jsonObject(parseJsonValue(await response.text()));
342
+ const profile = jsonObject(account?.profile ?? null);
343
+ const user = jsonObject(account?.user ?? null);
344
+ console.log(`account ${isString(profile?.username) ? profile.username : "(no namespace reserved)"}`);
345
+ if (user && isString(user.email)) console.log(`email ${user.email}`);
346
+ }
347
+
214
348
  async function deploy(args: string[]) {
215
349
  const artifactIndex = args.indexOf("--artifact");
216
350
  const dryRun = args.includes("--dry-run");
@@ -221,7 +355,10 @@ async function deploy(args: string[]) {
221
355
  if (artifactIndex >= 0) {
222
356
  artifactDir = args[artifactIndex + 1]
223
357
  ? resolve(args[artifactIndex + 1])
224
- : usageError("deploy: --artifact needs a directory", "deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait]");
358
+ : usageError(
359
+ "deploy: --artifact needs a directory",
360
+ "deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait]",
361
+ );
225
362
  projectName = "";
226
363
  } else {
227
364
  // wrangler-style: create resources for id-less bindings and pin the ids
@@ -264,8 +401,12 @@ async function deploy(args: string[]) {
264
401
  }
265
402
  const assetsManifestFile = Bun.file(resolve(artifactDir, "assets.json"));
266
403
  if (await assetsManifestFile.exists()) {
267
- const assetsManifest: { files?: Record<string, unknown> } = await assetsManifestFile.json();
268
- form.set("assets_manifest", new File([await assetsManifestFile.arrayBuffer()], "assets.json", { type: "application/json" }));
404
+ // assets.json is written by `sproutboat build` from the AssetManifest contract.
405
+ const assetsManifest: { files?: AssetFiles } = await assetsManifestFile.json();
406
+ form.set(
407
+ "assets_manifest",
408
+ new File([await assetsManifestFile.arrayBuffer()], "assets.json", { type: "application/json" }),
409
+ );
269
410
  for (const key of Object.keys(assetsManifest.files ?? {})) {
270
411
  const file = Bun.file(resolve(artifactDir, "assets", `.${key}`));
271
412
  if (!(await file.exists())) fail(`assets.json lists ${key} but assets${key} is missing — rebuild`);
@@ -288,18 +429,29 @@ async function deploy(args: string[]) {
288
429
  }
289
430
  console.log(`\n${leaf("🌱")} ${bold(leaf(`Deployed ${projectName}`))}`);
290
431
  console.log(` ${bold(deployed.url)}`);
291
- if (deployed.id) console.log(dim(` version ${deployed.id}${deployed.artifact ? ` · artifact ${deployed.artifact.slice(0, 12)}` : ""}`));
432
+ if (deployed.id)
433
+ console.log(
434
+ dim(` version ${deployed.id}${deployed.artifact ? ` · artifact ${deployed.artifact.slice(0, 12)}` : ""}`),
435
+ );
292
436
  for (const cron of config?.triggers?.crons ?? []) console.log(dim(` schedule ${cron}`));
293
437
  const drift = parsePorfforDrift(body);
294
438
  if (drift) {
295
439
  console.warn(amber(`\n! Porffor pin changed: ${drift.from} -> ${drift.to}`));
296
440
  console.warn(dim(` The previous live version stays frozen at ${drift.from}; this one is built with ${drift.to}.`));
297
- console.warn(dim(` The alpha compiler's output can differ between pins (see COMPAT.md) — roll back if this version misbehaves.`));
441
+ console.warn(
442
+ dim(
443
+ ` The alpha compiler's output can differ between pins (see COMPAT.md) — roll back if this version misbehaves.`,
444
+ ),
445
+ );
298
446
  }
299
447
  // Verify the edge actually answers (cert issuance + sprout boot). Say nothing
300
448
  // on success — "Deployed" already implied that; only speak up if it doesn't.
301
449
  if (!args.includes("--no-wait") && !(await waitForHealthy(deployed.url, 90_000))) {
302
- console.warn(amber(" ! not serving after 90s — Caddy may still be issuing the cert, or the sprout is crashing (`sproutboat tail`)"));
450
+ console.warn(
451
+ amber(
452
+ " ! not serving after 90s — Caddy may still be issuing the cert, or the sprout is crashing (`sproutboat tail`)",
453
+ ),
454
+ );
303
455
  }
304
456
  }
305
457
 
@@ -316,7 +468,9 @@ async function waitForHealthy(url: string, timeoutMs: number): Promise<boolean>
316
468
  try {
317
469
  const response = await fetch(url, { method: "HEAD", redirect: "manual" });
318
470
  if (response.status < 500) return true;
319
- } catch { /* DNS / TLS-not-yet-issued / connection refused — keep waiting */ }
471
+ } catch {
472
+ /* DNS / TLS-not-yet-issued / connection refused — keep waiting */
473
+ }
320
474
  await Bun.sleep(Math.min(wait, Math.max(0, deadline - Date.now())));
321
475
  if (wait < 5000) wait += 1000;
322
476
  }
@@ -349,9 +503,17 @@ async function login(args: string[]) {
349
503
  const authorization = parseAuthorization(body);
350
504
  if (!authorization) fail("login response did not include a valid authorization request");
351
505
  const verificationUrl = new URL(authorization.verificationUri, `${apiUrl}/`).toString();
352
- const openCommand = process.platform === "darwin" ? ["open", verificationUrl] : process.platform === "win32" ? ["cmd", "/c", "start", "", verificationUrl] : ["xdg-open", verificationUrl];
353
- try { Bun.spawn(openCommand, { stdout: "ignore", stderr: "ignore" }); }
354
- catch { console.log(`Open ${verificationUrl}`); }
506
+ const openCommand =
507
+ process.platform === "darwin"
508
+ ? ["open", verificationUrl]
509
+ : process.platform === "win32"
510
+ ? ["cmd", "/c", "start", "", verificationUrl]
511
+ : ["xdg-open", verificationUrl];
512
+ try {
513
+ Bun.spawn(openCommand, { stdout: "ignore", stderr: "ignore" });
514
+ } catch {
515
+ console.log(`Open ${verificationUrl}`);
516
+ }
355
517
  console.log("Opening the browser to approve this CLI login.");
356
518
  console.log(`Confirm code: ${authorization.userCode}`);
357
519
  while (new Date(authorization.expiresAt).getTime() > Date.now()) {
@@ -374,26 +536,76 @@ async function login(args: string[]) {
374
536
  }
375
537
 
376
538
  async function apiCredentials() {
377
- const apiUrl = (process.env.SPROUTBOAT_API_URL || await activeApiUrl() || defaultApiUrl).replace(/\/$/, "");
378
- const token = process.env.SPROUTBOAT_TOKEN || await savedToken(apiUrl);
539
+ const apiUrl = (process.env.SPROUTBOAT_API_URL || (await activeApiUrl()) || defaultApiUrl).replace(/\/$/, "");
540
+ const token = process.env.SPROUTBOAT_TOKEN || (await savedToken(apiUrl));
379
541
  if (!token) fail("not logged in; run sproutboat login or set SPROUTBOAT_TOKEN for this command");
380
542
  return { apiUrl, token };
381
543
  }
382
544
 
383
545
  async function versions(args: string[]) {
384
- if (args[0] !== "list") usageError(args[0] ? `versions: unknown subcommand "${args[0]}"` : "versions: missing subcommand", "versions list [project-dir]");
385
- const [project, { apiUrl, token }] = await Promise.all([readProject(args[1]), apiCredentials()]);
386
- const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments`, { headers: { "x-api-key": token } });
546
+ const sub = args[0];
547
+ if (sub !== "list" && sub !== "view") {
548
+ usageError(
549
+ sub ? `versions: unknown subcommand "${sub}"` : "versions: missing subcommand",
550
+ "versions <list | view <version-id>> [project-dir]",
551
+ );
552
+ }
553
+ args.shift();
554
+
555
+ if (sub === "view") {
556
+ const id = args.shift();
557
+ if (!id) usageError("versions view: missing <version-id>", "versions view <version-id> [project-dir]");
558
+ const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
559
+ const body = await responseText(
560
+ await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments/${encodeURIComponent(id)}`, {
561
+ headers: { "x-api-key": token },
562
+ }),
563
+ "could not read that version",
564
+ );
565
+ const detail = jsonObject(parseJsonValue(body));
566
+ if (!detail) fail("could not parse version response");
567
+ const manifest = jsonObject(detail.manifest ?? null);
568
+ console.log(`${bold(String(detail.id))} ${detail.active ? ok("active") : dim("superseded")}`);
569
+ console.log(` route ${String(detail.hostname)}`);
570
+ console.log(` artifact ${String(detail.artifact)}`);
571
+ console.log(
572
+ ` deployed ${String(detail.deployedAt)}${isString(detail.deployedBy) ? ` by ${detail.deployedBy}` : ""}`,
573
+ );
574
+ if (manifest) {
575
+ console.log(
576
+ ` built ${String(manifest.builtAt)} · porffor ${String(manifest.porfforVersion)} · ${String(manifest.binarySize)} bytes`,
577
+ );
578
+ } else if (isString(detail.manifestError)) {
579
+ console.log(` ! manifest unavailable: ${detail.manifestError}`);
580
+ }
581
+ const resources = Array.isArray(detail.resources) ? detail.resources.map((entry) => jsonObject(entry)) : [];
582
+ for (const resource of resources) {
583
+ if (resource)
584
+ console.log(` bound ${String(resource.kind)} ${String(resource.name)} ${dim(String(resource.id))}`);
585
+ }
586
+ return;
587
+ }
588
+
589
+ const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
590
+ const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments`, {
591
+ headers: { "x-api-key": token },
592
+ });
387
593
  const deployments = parseVersionList(await responseText(response, "could not list versions"));
388
594
  if (!deployments) fail("could not parse versions response");
389
- for (const deployment of deployments) console.log(`${deployment.active ? "*" : " "} ${deployment.id} ${deployment.artifact.slice(0, 12)} ${deployment.deployedAt}`);
595
+ for (const deployment of deployments)
596
+ console.log(
597
+ `${deployment.active ? "*" : " "} ${deployment.id} ${deployment.artifact.slice(0, 12)} ${deployment.deployedAt}`,
598
+ );
390
599
  }
391
600
 
392
601
  async function rollback(args: string[]) {
393
602
  const id = args[0];
394
603
  if (!id) usageError("rollback: missing <version-id>", "rollback <version-id> [project-dir]");
395
604
  const [project, { apiUrl, token }] = await Promise.all([readProject(args[1]), apiCredentials()]);
396
- const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments/${id}/activate`, { method: "POST", headers: { "x-api-key": token } });
605
+ const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments/${id}/activate`, {
606
+ method: "POST",
607
+ headers: { "x-api-key": token },
608
+ });
397
609
  const deployment = parseUrlResponse(await responseText(response, "rollback rejected"));
398
610
  if (!deployment) fail("rollback response did not include a URL");
399
611
  console.log(ok(`rolled back ${project.config.name}`));
@@ -405,7 +617,9 @@ async function tail(args: string[]) {
405
617
  const dir = args.find((arg) => !arg.startsWith("-"));
406
618
  const [project, { apiUrl, token }] = await Promise.all([readProject(dir), apiCredentials()]);
407
619
  const path = sproutLog ? "logs/sprout" : "logs/recent";
408
- const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/${path}`, { headers: { "x-api-key": token } });
620
+ const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/${path}`, {
621
+ headers: { "x-api-key": token },
622
+ });
409
623
  process.stdout.write(await responseText(response, "could not read logs"));
410
624
  }
411
625
 
@@ -418,11 +632,20 @@ type DomainView = {
418
632
  };
419
633
  function parseDomain(source: string): DomainView | undefined {
420
634
  const record = jsonObject(parseJsonValue(source));
421
- if (!record || !isString(record.hostname) || typeof record.verified !== "boolean") return undefined;
635
+ if (!record || !isString(record.hostname) || !isBoolean(record.verified)) return undefined;
422
636
  const v = jsonObject(record.verification ?? null);
423
- const verification = v && isString(v.type) && isString(v.name) && isString(v.value) ? { type: v.type, name: v.name, value: v.value } : null;
637
+ const verification =
638
+ v && isString(v.type) && isString(v.name) && isString(v.value)
639
+ ? { type: v.type, name: v.name, value: v.value }
640
+ : null;
424
641
  const serverAddresses = Array.isArray(record.serverAddresses) ? record.serverAddresses.filter(isString) : [];
425
- return { hostname: record.hostname, verified: record.verified, verification, serverAddresses, warning: isString(record.warning) ? record.warning : undefined };
642
+ return {
643
+ hostname: record.hostname,
644
+ verified: record.verified,
645
+ verification,
646
+ serverAddresses,
647
+ warning: isString(record.warning) ? record.warning : undefined,
648
+ };
426
649
  }
427
650
  function printDomain(domain: DomainView) {
428
651
  const status = domain.verified ? "verified" : "unverified";
@@ -431,16 +654,22 @@ function printDomain(domain: DomainView) {
431
654
  console.log(" add these DNS records, then run: sproutboat domains verify " + domain.hostname);
432
655
  console.log(` ${domain.verification.type} ${domain.verification.name} "${domain.verification.value}"`);
433
656
  if (domain.serverAddresses[0]) {
434
- console.log(` A ${domain.hostname} ${domain.serverAddresses[0]} (point the hostname here, DNS-only / not proxied)`);
657
+ console.log(
658
+ ` A ${domain.hostname} ${domain.serverAddresses[0]} (point the hostname here, DNS-only / not proxied)`,
659
+ );
435
660
  }
436
661
  }
437
662
  if (domain.warning) console.log(amber(` ! ${domain.warning}`));
438
663
  }
439
664
 
440
665
  async function domains(args: string[]) {
441
- const sub = args[0] && !args[0].startsWith("-") && ["list", "add", "verify", "rm"].includes(args[0]) ? args.shift()! : "list";
666
+ const sub =
667
+ args[0] && !args[0].startsWith("-") && ["list", "add", "verify", "delete"].includes(args[0])
668
+ ? args.shift()!
669
+ : "list";
442
670
  const host = sub === "list" ? undefined : args.shift();
443
- if (sub !== "list" && !host) usageError(`domains ${sub}: missing <hostname>`, `domains ${sub} <hostname> [project-dir]`);
671
+ if (sub !== "list" && !host)
672
+ usageError(`domains ${sub}: missing <hostname>`, `domains ${sub} <hostname> [project-dir]`);
444
673
  const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
445
674
  const base = `${apiUrl}/api/projects/${project.config.name}/domains`;
446
675
  const auth = { "x-api-key": token };
@@ -450,20 +679,31 @@ async function domains(args: string[]) {
450
679
  const body = await responseText(response, "could not list domains");
451
680
  const list = parseJsonValue(body);
452
681
  if (!Array.isArray(list)) fail("could not parse domains response");
453
- if (list.length === 0) { console.log("no custom domains"); return; }
454
- for (const entry of list) { const d = parseDomain(JSON.stringify(entry)); if (d) printDomain(d); }
682
+ if (list.length === 0) {
683
+ console.log("no custom domains");
684
+ return;
685
+ }
686
+ for (const entry of list) {
687
+ const d = parseDomain(JSON.stringify(entry));
688
+ if (d) printDomain(d);
689
+ }
455
690
  return;
456
691
  }
457
- if (sub === "rm") {
692
+ if (sub === "delete") {
458
693
  const response = await fetch(`${base}/${host}`, { method: "DELETE", headers: auth });
459
694
  await responseText(response, "delete rejected");
460
695
  console.log(ok(`removed ${host}`));
461
696
  return;
462
697
  }
463
698
  const url = sub === "add" ? base : `${base}/${host}/verify`;
464
- const init = sub === "add"
465
- ? { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ hostname: host }) }
466
- : { method: "POST", headers: auth };
699
+ const init =
700
+ sub === "add"
701
+ ? {
702
+ method: "POST",
703
+ headers: { ...auth, "content-type": "application/json" },
704
+ body: JSON.stringify({ hostname: host }),
705
+ }
706
+ : { method: "POST", headers: auth };
467
707
  const response = await fetch(url, init);
468
708
  const domain = parseDomain(await responseText(response, `${sub} rejected`));
469
709
  if (!domain) fail(`${sub} response was not a domain record`);
@@ -471,11 +711,23 @@ async function domains(args: string[]) {
471
711
  }
472
712
 
473
713
  async function secrets(args: string[]) {
474
- const sub = args[0] && ["list", "set", "rm"].includes(args[0]) ? args.shift()! : "list";
475
- const name = sub === "list" ? undefined : args.shift();
714
+ const sub = args[0] && ["list", "put", "delete"].includes(args[0]) ? args.shift()! : "list";
715
+
716
+ // `--value` is opt-in; without it the value comes from stdin, so a secret does
717
+ // not land in shell history. It is also what disambiguates the positionals:
718
+ // `secrets put NAME <value> [project-dir]` could not tell a value from a path,
719
+ // and read the value as the project directory.
720
+ let inlineValue: string | undefined;
721
+ const positional: string[] = [];
722
+ for (let index = 0; index < args.length; index += 1) {
723
+ if (args[index] === "--value") inlineValue = args[(index += 1)];
724
+ else positional.push(args[index]);
725
+ }
726
+
727
+ const name = sub === "list" ? undefined : positional.shift();
476
728
  if (sub !== "list" && !name) usageError(`secrets ${sub}: missing <NAME>`, `secrets ${sub} <NAME> [project-dir]`);
477
729
  if (name && !/^[A-Z][A-Z0-9_]*$/.test(name)) fail("secret name must be UPPER_SNAKE_CASE");
478
- const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
730
+ const [project, { apiUrl, token }] = await Promise.all([readProject(positional[0]), apiCredentials()]);
479
731
  const base = `${apiUrl}/api/projects/${project.config.name}/secrets`;
480
732
  const auth = { "x-api-key": token };
481
733
 
@@ -486,78 +738,121 @@ async function secrets(args: string[]) {
486
738
  console.log(names.length ? names.join("\n") : "no secrets");
487
739
  return;
488
740
  }
489
- if (sub === "rm") {
741
+ if (sub === "delete") {
490
742
  await responseText(await fetch(`${base}/${name}`, { method: "DELETE", headers: auth }), "delete rejected");
491
743
  console.log(ok(`removed ${name}`));
492
744
  return;
493
745
  }
494
- // set: value from the next arg, else stdin (keeps it out of shell history).
495
- const value = args[1] && !args[1].startsWith("-") && args[1] !== project.directory
496
- ? args[1]
497
- : (await Bun.stdin.text()).replace(/\r?\n$/, "");
498
- if (!value) fail("no value — pass it as an argument or pipe it on stdin");
746
+
747
+ const value = inlineValue ?? (await Bun.stdin.text()).replace(/\r?\n$/, "");
748
+ if (!value) fail("no value — pipe it on stdin, or pass --value <value>");
499
749
  await responseText(
500
- await fetch(`${base}/${name}`, { method: "PUT", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ value }) }),
501
- "set rejected",
750
+ await fetch(`${base}/${name}`, {
751
+ method: "PUT",
752
+ headers: { ...auth, "content-type": "application/json" },
753
+ body: JSON.stringify({ value }),
754
+ }),
755
+ "put rejected",
502
756
  );
503
- console.log(`Set ${name} — applies on the next deploy or sprout restart`);
757
+ console.log(ok(`set ${name} — applies on the next deploy or sprout restart`));
504
758
  }
505
759
 
506
- const RESOURCE_KINDS = ["kv", "d1", "r2", "queue"];
507
-
508
760
  /**
509
- * #74 — account-level storage resources. Unlike `secrets`/`domains` these are
510
- * not project-scoped, so there's no `readProject` here. `create` prints the
511
- * `<kind>_<id>` handle to paste into `sproutboat.jsonc` bindings.
761
+ * #79 — one command per storage product (`kv`, `d1`, `r2`, `queues`), each with
762
+ * the same five verbs, over that product's own `/api/<product>` collection.
763
+ *
764
+ * Wrangler nests two of its four (`kv namespace create`, `r2 bucket create`)
765
+ * and leaves `d1 create` and `queues create` flat. The nesting is there to
766
+ * separate the container from its contents, which the verb already does — so
767
+ * ours are uniform, and contents take their own noun when they exist
768
+ * (`kv key get`, `r2 object put`).
512
769
  */
513
- async function resource(args: string[]) {
514
- const sub = args[0] && ["list", "create", "rename", "delete"].includes(args[0]) ? args.shift()! : "list";
770
+ /** The account's resources of one kind, by name. */
771
+ async function storageRows(base: string, auth: Record<string, string>, product: StorageProduct): Promise<JsonObject[]> {
772
+ const body = await responseText(await fetch(base, { headers: auth }), `could not list ${product.plural}`);
773
+ const parsed = jsonObject(parseJsonValue(body));
774
+ return (parsed && Array.isArray(parsed.resources) ? parsed.resources : [])
775
+ .map((entry) => jsonObject(entry))
776
+ .filter((entry): entry is JsonObject => Boolean(entry));
777
+ }
778
+
779
+ /** Resolve a name to its `<kind>_<id>` handle — the API addresses rows by id. */
780
+ function idForName(rows: JsonObject[], name: string, product: StorageProduct): string {
781
+ const match = rows.find((row) => row.name === name);
782
+ if (!match || !isString(match.id)) fail(`no ${product.noun} named "${name}"`);
783
+ return String(match.id);
784
+ }
785
+
786
+ async function storage(key: string, args: string[]) {
787
+ const product = STORAGE_PRODUCTS.find((entry) => entry.name === key)!;
788
+ const sub = args[0] && STORAGE_VERBS.some((verb) => verb === args[0]) ? args.shift()! : "list";
515
789
  const { apiUrl, token } = await apiCredentials();
516
- const base = `${apiUrl}/api/resources`;
790
+ const base = `${apiUrl}/api/${product.name}`;
517
791
  const auth = { "x-api-key": token };
518
792
 
519
793
  if (sub === "list") {
520
- const kindFilter = args[0];
521
- const body = await responseText(await fetch(base, { headers: auth }), "could not list resources");
522
- const parsed = jsonObject(parseJsonValue(body));
523
- const rows = (parsed && Array.isArray(parsed.resources) ? parsed.resources : [])
524
- .map((entry) => jsonObject(entry))
525
- .filter((entry): entry is JsonObject => Boolean(entry) && (!kindFilter || entry!.kind === kindFilter));
526
- if (rows.length === 0) { console.log(kindFilter ? `no ${kindFilter} resources` : "no resources"); return; }
527
- for (const row of rows) console.log(`${String(row.kind).padEnd(9)} ${String(row.id).padEnd(30)} ${String(row.name)}`);
794
+ const rows = await storageRows(base, auth, product);
795
+ if (rows.length === 0) {
796
+ console.log(`no ${product.plural}`);
797
+ return;
798
+ }
799
+ for (const row of rows) {
800
+ const bound = Array.isArray(row.projects) ? row.projects.filter(isString) : [];
801
+ console.log(
802
+ `${String(row.id).padEnd(30)} ${String(row.name).padEnd(24)} ${bound.length ? bound.join(", ") : dim("unbound")}`,
803
+ );
804
+ }
528
805
  return;
529
806
  }
530
807
 
808
+ const name = args.shift();
809
+ if (!name) usageError(`${key} ${sub}: missing <name>`, `${key} ${sub} <name>`);
810
+
531
811
  if (sub === "create") {
532
- const [kind, name] = args;
533
- if (!kind || !RESOURCE_KINDS.includes(kind)) usageError(`resource create: kind must be one of ${RESOURCE_KINDS.join(", ")}`, "resource create <kind> <name>");
534
- if (!name) usageError("resource create: missing <name>", "resource create <kind> <name>");
535
812
  const body = await responseText(
536
- await fetch(base, { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ kind, name }) }),
813
+ await fetch(base, {
814
+ method: "POST",
815
+ headers: { ...auth, "content-type": "application/json" },
816
+ body: JSON.stringify({ name }),
817
+ }),
537
818
  "create rejected",
538
819
  );
539
820
  const record = jsonObject(jsonObject(parseJsonValue(body))?.resource ?? null);
540
821
  if (!record || !isString(record.id)) fail("create response was not a resource");
541
- console.log(ok(`created ${kind} ${bold(String(record.name))}`));
822
+ console.log(ok(`created ${product.noun} ${bold(String(record.name))}`));
542
823
  console.log(record.id);
543
824
  return;
544
825
  }
545
826
 
827
+ const rows = await storageRows(base, auth, product);
828
+ const id = idForName(rows, name, product);
829
+
830
+ if (sub === "info") {
831
+ const row = rows.find((entry) => entry.id === id)!;
832
+ const bound = Array.isArray(row.projects) ? row.projects.filter(isString) : [];
833
+ console.log(`${bold(String(row.name))} ${dim(String(row.id))}`);
834
+ console.log(` created ${String(row.createdAt)}`);
835
+ console.log(` bound to ${bound.length ? bound.join(", ") : "nothing"}`);
836
+ return;
837
+ }
838
+
546
839
  if (sub === "rename") {
547
- const [id, name] = args;
548
- if (!id || !name) usageError("resource rename: need <id> <name>", "resource rename <id> <name>");
840
+ const next = args.shift();
841
+ if (!next) usageError(`${key} rename: missing <new-name>`, `${key} rename <name> <new-name>`);
549
842
  await responseText(
550
- await fetch(`${base}/${id}`, { method: "PATCH", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ name }) }),
843
+ await fetch(`${base}/${id}`, {
844
+ method: "PATCH",
845
+ headers: { ...auth, "content-type": "application/json" },
846
+ body: JSON.stringify({ name: next }),
847
+ }),
551
848
  "rename rejected",
552
849
  );
553
- console.log(ok(`renamed ${id} → ${name}`));
850
+ console.log(ok(`renamed ${name} → ${next}`));
554
851
  return;
555
852
  }
556
853
 
557
- const id = args[0];
558
- if (!id) usageError("resource delete: missing <id>", "resource delete <id>");
559
854
  await responseText(await fetch(`${base}/${id}`, { method: "DELETE", headers: auth }), "delete rejected");
560
- console.log(ok(`deleted ${id}`));
855
+ console.log(ok(`deleted ${product.noun} ${name}`));
561
856
  }
562
857
 
563
858
  async function deleteProject(args: string[]) {
@@ -578,10 +873,17 @@ async function deleteProject(args: string[]) {
578
873
  if (!confirmed) fail(`this permanently removes "${name}", every version, and its route — re-run with --yes`);
579
874
 
580
875
  const url = `${apiUrl}/api/projects/${encodeURIComponent(name)}?confirm=${encodeURIComponent(name)}`;
581
- const body = await responseText(await fetch(url, { method: "DELETE", headers: { "x-api-key": token } }), "delete rejected");
876
+ const body = await responseText(
877
+ await fetch(url, { method: "DELETE", headers: { "x-api-key": token } }),
878
+ "delete rejected",
879
+ );
582
880
 
583
881
  let result: JsonObject = {};
584
- try { result = jsonObject(parseJsonValue(body)) ?? {}; } catch { /* a 2xx already confirmed the delete */ }
882
+ try {
883
+ result = jsonObject(parseJsonValue(body)) ?? {};
884
+ } catch {
885
+ /* a 2xx already confirmed the delete */
886
+ }
585
887
  const versions = isSafeInteger(result.versionsRemoved) ? result.versionsRemoved : 0;
586
888
  const routes = Array.isArray(result.routeRemoved) ? result.routeRemoved.filter(isString) : [];
587
889
  const failed = Array.isArray(result.artifactCleanupFailed) ? result.artifactCleanupFailed.filter(isString) : [];
@@ -605,22 +907,67 @@ function usage(): never {
605
907
 
606
908
  const [command, ...args] = process.argv.slice(2);
607
909
  if (command === undefined || command === "help" || command === "-h" || command === "--help") help();
608
- if (command === "--version" || command === "-v") { console.log(`sproutboat ${CLI_VERSION}`); process.exit(0); }
910
+ if (command === "--version" || command === "-v") {
911
+ console.log(`sproutboat ${CLI_VERSION}`);
912
+ process.exit(0);
913
+ }
609
914
 
610
915
  await notifyIfOutdated(CLI_VERSION);
611
916
 
612
917
  switch (command) {
613
- case "init": await init(args[0]); break;
614
- case "check": await check(args[0]); break;
615
- case "build": await build(args[0]); break;
616
- case "login": await login(args); break;
617
- case "deploy": await deploy(args); break;
618
- case "versions": await versions(args); break;
619
- case "rollback": await rollback(args); break;
620
- case "domains": await domains(args); break;
621
- case "secrets": await secrets(args); break;
622
- case "resource": await resource(args); break;
623
- case "tail": await tail(args); break;
624
- case "delete": await deleteProject(args); break;
625
- default: usage();
918
+ case "init":
919
+ await init(args[0]);
920
+ break;
921
+ case "check":
922
+ await check(args[0]);
923
+ break;
924
+ case "dev":
925
+ await dev(args);
926
+ break;
927
+ case "build": {
928
+ const hostBuild = args.includes("--target") && args[args.indexOf("--target") + 1] === "host";
929
+ await build(
930
+ args.find((arg) => !arg.startsWith("--") && arg !== "host"),
931
+ hostBuild ? "host" : "linux-x86_64",
932
+ );
933
+ break;
934
+ }
935
+ case "login":
936
+ await login(args);
937
+ break;
938
+ case "logout":
939
+ await logout(args);
940
+ break;
941
+ case "whoami":
942
+ await whoami();
943
+ break;
944
+ case "deploy":
945
+ await deploy(args);
946
+ break;
947
+ case "versions":
948
+ await versions(args);
949
+ break;
950
+ case "rollback":
951
+ await rollback(args);
952
+ break;
953
+ case "domains":
954
+ await domains(args);
955
+ break;
956
+ case "secrets":
957
+ await secrets(args);
958
+ break;
959
+ case "kv":
960
+ case "d1":
961
+ case "r2":
962
+ case "queues":
963
+ await storage(command, args);
964
+ break;
965
+ case "tail":
966
+ await tail(args);
967
+ break;
968
+ case "delete":
969
+ await deleteProject(args);
970
+ break;
971
+ default:
972
+ usage();
626
973
  }