sproutboat 0.4.5 → 0.4.7

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/SURFACE.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > Generated by `src/surface.test.ts` from `src/surface.ts` + the pinned
4
4
  > toolchain constants. Do not edit by hand — run `UPDATE_SURFACE=1 bun test`.
5
5
 
6
- **Package:** `sproutboat` 0.4.5 · runs on Bun (use `bunx`, not `npx`)
6
+ **Package:** `sproutboat` 0.4.7 · runs on Bun (use `bunx`, not `npx`)
7
7
 
8
8
  ## Commands
9
9
 
@@ -12,17 +12,18 @@
12
12
  | `init` | `[name]` | Scaffold sproutboat.jsonc + src/index.js in ./<name>. |
13
13
  | `check` | `[project-dir]` | Validate the config and entry point without building. |
14
14
  | `build` | `[project-dir]` | Cross-compile the native-fetch sprout (Porffor + Zig). |
15
- | `deploy` | `[project-dir] [--dry-run] [--artifact <dir>] [--no-wait]` | Build (unless --artifact), print the report, upload, wait until the URL serves. --dry-run stops before upload; --no-wait skips the health check. |
15
+ | `deploy` | `[project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision]` | Build (unless --artifact), auto-provision id-less storage bindings and pin their ids into sproutboat.jsonc, print the report, upload, wait until the URL serves. --dry-run stops before upload; --no-wait skips the health check; --no-provision leaves id-less bindings as ephemeral deploy-scoped stores. |
16
16
  | `versions` | `list [project-dir]` | List the project's deployed versions. |
17
17
  | `rollback` | `<version-id> [project-dir]` | Re-activate a previous version. |
18
18
  | `tail` | `[project-dir] [--sprout]` | Print recent request logs; --sprout prints the running sprout + broker stdout/stderr instead. |
19
19
  | `domains` | `[list | add <host> | verify <host> | rm <host>] [project-dir]` | Attach a custom domain to the project (TXT-verified). No sub-command lists. |
20
20
  | `secrets` | `[list | set <NAME> [value] | rm <NAME>] [project-dir]` | Manage encrypted project secrets (read as env.NAME). `set` takes the value from the arg or stdin; applies on next deploy. |
21
+ | `resource` | `[list [kind] | create <kind> <name> | rename <id> <name> | delete <id>]` | Manage account-level storage resources (kv | d1 | r2 | queue). `create` prints the id to reference from sproutboat.jsonc bindings. |
21
22
  | `delete` | `[project-dir] [--name <project>] --yes` | Delete the project, every version, and its route. |
22
23
  | `login` | `[--api-url <url>] [--token <token>]` | Device-code browser flow, or store <token> for <url> directly. |
23
24
 
24
25
  ```
25
- usage: sproutboat <init [name] | check [project-dir] | build [project-dir] | deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait] | versions list [project-dir] | rollback <version-id> [project-dir] | tail [project-dir] [--sprout] | domains [list | add <host> | verify <host> | rm <host>] [project-dir] | secrets [list | set <NAME> [value] | rm <NAME>] [project-dir] | delete [project-dir] [--name <project>] --yes | login [--api-url <url>] [--token <token>]>
26
+ usage: sproutboat <init [name] | check [project-dir] | build [project-dir] | deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision] | versions list [project-dir] | rollback <version-id> [project-dir] | tail [project-dir] [--sprout] | domains [list | add <host> | verify <host> | rm <host>] [project-dir] | secrets [list | set <NAME> [value] | rm <NAME>] [project-dir] | resource [list [kind] | create <kind> <name> | rename <id> <name> | delete <id>] | delete [project-dir] [--name <project>] --yes | login [--api-url <url>] [--token <token>]>
26
27
  ```
27
28
 
28
29
  ## Environment variables
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutboat",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "Wrangler-shaped CLI for Sproutboat. Deploys workers to any control plane via --api-url / SPROUTBOAT_API_URL.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/broker.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * re-validation and private-IP blocking are v2 — the exact-host allowlist is
18
18
  * the only SSRF control today.
19
19
  */
20
- import { Database } from "bun:sqlite";
20
+ import { Database, type Statement } from "bun:sqlite";
21
21
  import { createHash } from "node:crypto";
22
22
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
23
23
  import { dirname, join, normalize, resolve } from "node:path";
@@ -36,6 +36,13 @@ export type Bindings = {
36
36
  crons: string[];
37
37
  /** Static-asset binding name, or `""` when assets are edge-only. */
38
38
  assets: string;
39
+ /**
40
+ * #74 — binding name -> account-level resource it resolves to. A binding with
41
+ * an entry here stores in a per-resource SQLite file under `resourceDir`,
42
+ * keyed by `id`, shared across this owner's deployments; a binding without one
43
+ * falls back to the per-broker `db` partitioned by its own name (local dev).
44
+ */
45
+ resources: Record<string, { kind: "kv" | "d1" | "r2" | "queue"; id: string }>;
39
46
  };
40
47
  export type Frame = Record<string, unknown>;
41
48
 
@@ -43,6 +50,13 @@ export type BrokerOptions = {
43
50
  db?: string;
44
51
  /** Directory for per-D1-binding SQLite files. Defaults to `<dirname(db)>/d1`, or in-memory when `db` is `:memory:`. */
45
52
  dataDir?: string;
53
+ /**
54
+ * #74 — directory holding one `<resource-id>.sqlite` per account-level KV / R2 /
55
+ * queue / D1 resource. Defaults to `<dirname(db)>/resources`, in-memory when
56
+ * `db` is `:memory:`. The supervisor points this at an owner-stable path so the
57
+ * data outlives a redeploy.
58
+ */
59
+ resourceDir?: string;
46
60
  token?: string;
47
61
  bindings?: Partial<Bindings>;
48
62
  secrets?: Record<string, string>;
@@ -80,7 +94,7 @@ export type Broker = {
80
94
  };
81
95
 
82
96
  export function createBroker(opts: BrokerOptions = {}): Broker {
83
- const bindings: Bindings = { kv: [], secrets: [], outbound: [], d1: [], r2: [], queues: [], analytics: [], do: [], crons: [], assets: "", ...opts.bindings };
97
+ const bindings: Bindings = { kv: [], secrets: [], outbound: [], d1: [], r2: [], queues: [], analytics: [], do: [], crons: [], assets: "", resources: {}, ...opts.bindings };
84
98
  const secrets = opts.secrets ?? {};
85
99
 
86
100
  // Static assets: read the manifest once. Files are read from disk per request
@@ -106,24 +120,32 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
106
120
  const dbPath = opts.db ?? ":memory:";
107
121
  const inMemory = dbPath === ":memory:" || dbPath === "";
108
122
  const d1Dir = opts.dataDir ?? (inMemory ? null : join(dirname(resolve(dbPath)), "d1"));
123
+ const resourceDir = opts.resourceDir ?? (inMemory ? null : join(dirname(resolve(dbPath)), "resources"));
109
124
 
110
- const db = new Database(dbPath);
111
- db.exec("PRAGMA journal_mode = WAL");
112
125
  // WAL + NORMAL is the standard pairing: a write no longer fsyncs the WAL, so
113
126
  // host power loss can drop the last few committed txns, but a process crash
114
127
  // never can and the file never corrupts. Right trade for a single-VPS
115
128
  // KV/queue/DO store; on real block storage this is ~10-100x on writes.
116
- db.exec("PRAGMA synchronous = NORMAL");
117
- db.exec("CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (ns, key))");
118
- db.exec(
119
- "CREATE TABLE IF NOT EXISTS r2 (bucket TEXT NOT NULL, key TEXT NOT NULL, body TEXT NOT NULL, size INTEGER NOT NULL, " +
120
- "etag TEXT NOT NULL, uploaded TEXT NOT NULL, http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', " +
121
- "PRIMARY KEY (bucket, key))",
122
- );
123
- db.exec(
124
- "CREATE TABLE IF NOT EXISTS mq (queue TEXT NOT NULL, id TEXT PRIMARY KEY, body TEXT NOT NULL, " +
125
- "visible_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, dead INTEGER NOT NULL DEFAULT 0)",
126
- );
129
+ const openStore = (path: string): Database => {
130
+ const conn = new Database(path, { create: true });
131
+ conn.exec("PRAGMA journal_mode = WAL");
132
+ conn.exec("PRAGMA synchronous = NORMAL");
133
+ conn.exec("CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (ns, key))");
134
+ conn.exec(
135
+ "CREATE TABLE IF NOT EXISTS r2 (bucket TEXT NOT NULL, key TEXT NOT NULL, body TEXT NOT NULL, size INTEGER NOT NULL, " +
136
+ "etag TEXT NOT NULL, uploaded TEXT NOT NULL, http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', " +
137
+ "PRIMARY KEY (bucket, key))",
138
+ );
139
+ conn.exec(
140
+ "CREATE TABLE IF NOT EXISTS mq (queue TEXT NOT NULL, id TEXT PRIMARY KEY, body TEXT NOT NULL, " +
141
+ "visible_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, dead INTEGER NOT NULL DEFAULT 0)",
142
+ );
143
+ return conn;
144
+ };
145
+
146
+ const db = openStore(dbPath);
147
+ // Durable Object storage and Analytics Engine rows stay in the per-broker db —
148
+ // neither is an account-level resource (#74).
127
149
  db.exec(
128
150
  "CREATE TABLE IF NOT EXISTS do_storage (cls TEXT NOT NULL, id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, " +
129
151
  "PRIMARY KEY (cls, id, key))",
@@ -132,10 +154,51 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
132
154
  "CREATE TABLE IF NOT EXISTS ae (dataset TEXT NOT NULL, ts INTEGER NOT NULL, indexes_json TEXT NOT NULL, " +
133
155
  "blobs_json TEXT NOT NULL, doubles_json TEXT NOT NULL)",
134
156
  );
135
- const kvGet = db.query<{ value: string }, [string, string]>("SELECT value FROM kv WHERE ns = ? AND key = ?");
136
- const kvPut = db.query("INSERT INTO kv (ns, key, value) VALUES (?1, ?2, ?3) ON CONFLICT (ns, key) DO UPDATE SET value = ?3");
137
- const kvDel = db.query("DELETE FROM kv WHERE ns = ? AND key = ?");
138
- const kvList = db.query<{ key: string }, [string, string]>("SELECT key FROM kv WHERE ns = ? AND key LIKE ? || '%' ORDER BY key LIMIT 1000");
157
+
158
+ // #74 one SQLite file per account-level resource id, opened on first use.
159
+ const resourceDbs = new Map<string, Database>();
160
+ const resourceDb = (id: string): Database => {
161
+ let conn = resourceDbs.get(id);
162
+ if (!conn) {
163
+ if (resourceDir) mkdirSync(resourceDir, { recursive: true });
164
+ conn = openStore(resourceDir ? join(resourceDir, `${id}.sqlite`) : ":memory:");
165
+ resourceDbs.set(id, conn);
166
+ }
167
+ return conn;
168
+ };
169
+
170
+ /**
171
+ * Where a KV / R2 / queue binding stores: its resource's own file keyed by
172
+ * `id` when the config bound it to one, else the per-broker `db` partitioned
173
+ * by the binding name (bare-string bindings / local dev).
174
+ */
175
+ const storeFor = (kind: "kv" | "r2" | "queue", binding: string): { store: Database; part: string } => {
176
+ const resource = bindings.resources[binding];
177
+ return resource && resource.kind === kind
178
+ ? { store: resourceDb(resource.id), part: resource.id }
179
+ : { store: db, part: binding };
180
+ };
181
+
182
+ type KvStmts = {
183
+ get: Statement<{ value: string }, [string, string]>;
184
+ put: Statement<unknown, [string, string, string]>;
185
+ del: Statement<unknown, [string, string]>;
186
+ list: Statement<{ key: string }, [string, string]>;
187
+ };
188
+ const kvStmtCache = new Map<Database, KvStmts>();
189
+ const kvStmts = (store: Database): KvStmts => {
190
+ let stmts = kvStmtCache.get(store);
191
+ if (!stmts) {
192
+ stmts = {
193
+ get: store.query<{ value: string }, [string, string]>("SELECT value FROM kv WHERE ns = ? AND key = ?"),
194
+ put: store.query("INSERT INTO kv (ns, key, value) VALUES (?1, ?2, ?3) ON CONFLICT (ns, key) DO UPDATE SET value = ?3"),
195
+ del: store.query("DELETE FROM kv WHERE ns = ? AND key = ?"),
196
+ list: store.query<{ key: string }, [string, string]>("SELECT key FROM kv WHERE ns = ? AND key LIKE ? || '%' ORDER BY key LIMIT 1000"),
197
+ };
198
+ kvStmtCache.set(store, stmts);
199
+ }
200
+ return stmts;
201
+ };
139
202
 
140
203
  const bound = (list: string[], kind: string) => (name: unknown): string => {
141
204
  const n = str(name);
@@ -155,16 +218,22 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
155
218
  };
156
219
  const newId = () => createHash("sha256").update(`${Date.now()}:${Math.random()}`).digest("hex").slice(0, 24);
157
220
 
158
- // One SQLite database per bound D1 namethey are independent SQL namespaces.
221
+ // One SQLite database per bound D1 binding — independent SQL namespaces. A
222
+ // binding wired to an account-level resource (#74) opens `<resource-id>.sqlite`
223
+ // under `resourceDir` (survives redeploys); a bare-string binding keeps its
224
+ // per-broker `<name>.sqlite` under `d1Dir`.
159
225
  const d1Conns = new Map<string, Database>();
160
226
  const d1 = (name: string): Database => {
161
- let conn = d1Conns.get(name);
227
+ const resource = bindings.resources[name];
228
+ const key = resource && resource.kind === "d1" ? resource.id : name;
229
+ let conn = d1Conns.get(key);
162
230
  if (!conn) {
163
- if (d1Dir) mkdirSync(d1Dir, { recursive: true });
164
- conn = new Database(d1Dir ? join(d1Dir, `${name}.sqlite`) : ":memory:", { create: true });
231
+ const dir = resource && resource.kind === "d1" ? resourceDir : d1Dir;
232
+ if (dir) mkdirSync(dir, { recursive: true });
233
+ conn = new Database(dir ? join(dir, `${key}.sqlite`) : ":memory:", { create: true });
165
234
  conn.exec("PRAGMA journal_mode = WAL");
166
235
  conn.exec("PRAGMA synchronous = NORMAL");
167
- d1Conns.set(name, conn);
236
+ d1Conns.set(key, conn);
168
237
  }
169
238
  return conn;
170
239
  };
@@ -224,17 +293,24 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
224
293
  case "ping":
225
294
  return { ok: true, op: "pong", echo: msg.msg ?? null, pid: process.pid };
226
295
  case "kv.get": {
227
- const row = kvGet.get(requireKv(msg.ns), str(msg.key));
296
+ const { store, part } = storeFor("kv", requireKv(msg.ns));
297
+ const row = kvStmts(store).get.get(part, str(msg.key));
228
298
  return row ? { ok: true, found: true, value: row.value } : { ok: true, found: false, value: null };
229
299
  }
230
- case "kv.put":
231
- kvPut.run(requireKv(msg.ns), str(msg.key), str(msg.value));
300
+ case "kv.put": {
301
+ const { store, part } = storeFor("kv", requireKv(msg.ns));
302
+ kvStmts(store).put.run(part, str(msg.key), str(msg.value));
232
303
  return { ok: true };
233
- case "kv.delete":
234
- kvDel.run(requireKv(msg.ns), str(msg.key));
304
+ }
305
+ case "kv.delete": {
306
+ const { store, part } = storeFor("kv", requireKv(msg.ns));
307
+ kvStmts(store).del.run(part, str(msg.key));
235
308
  return { ok: true };
236
- case "kv.list":
237
- return { ok: true, keys: kvList.all(requireKv(msg.ns), str(msg.prefix)).map((r) => r.key) };
309
+ }
310
+ case "kv.list": {
311
+ const { store, part } = storeFor("kv", requireKv(msg.ns));
312
+ return { ok: true, keys: kvStmts(store).list.all(part, str(msg.prefix)).map((r) => r.key) };
313
+ }
238
314
  case "secret.get": {
239
315
  const name = str(msg.name);
240
316
  if (!bindings.secrets.includes(name)) throw new Error(`secret not bound: ${name}`);
@@ -261,10 +337,10 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
261
337
  }
262
338
 
263
339
  case "r2.put": {
264
- const bucket = requireR2(msg.bucket);
340
+ const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
265
341
  const body = str(msg.body);
266
342
  const etag = createHash("sha256").update(body).digest("hex");
267
- db.query(
343
+ store.query(
268
344
  "INSERT INTO r2 (bucket, key, body, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8) " +
269
345
  "ON CONFLICT (bucket, key) DO UPDATE SET body=?3, size=?4, etag=?5, uploaded=?6, http_json=?7, custom_json=?8",
270
346
  ).run(
@@ -281,22 +357,22 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
281
357
  }
282
358
  case "r2.get":
283
359
  case "r2.head": {
284
- const bucket = requireR2(msg.bucket);
285
- const row = db.query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?").get(bucket, str(msg.key));
360
+ const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
361
+ const row = store.query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?").get(bucket, str(msg.key));
286
362
  if (!row) return { ok: true, found: false };
287
363
  return { ok: true, found: true, object: r2Row(row), body: msg.op === "r2.get" ? row.body : undefined };
288
364
  }
289
365
  case "r2.delete": {
290
- const bucket = requireR2(msg.bucket);
291
- db.query("DELETE FROM r2 WHERE bucket = ? AND key = ?").run(bucket, str(msg.key));
366
+ const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
367
+ store.query("DELETE FROM r2 WHERE bucket = ? AND key = ?").run(bucket, str(msg.key));
292
368
  return { ok: true };
293
369
  }
294
370
  case "r2.list": {
295
- const bucket = requireR2(msg.bucket);
371
+ const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
296
372
  const prefix = str(msg.prefix);
297
373
  const cursor = str(msg.cursor);
298
374
  const limit = Math.min(Math.max(Number(msg.limit) || 1000, 1), 1000);
299
- const rows = db.query<R2Row, [string, string, string, number]>(
375
+ const rows = store.query<R2Row, [string, string, string, number]>(
300
376
  "SELECT * FROM r2 WHERE bucket = ? AND key LIKE ? || '%' AND key > ? ORDER BY key LIMIT ?",
301
377
  ).all(bucket, prefix, cursor, limit + 1);
302
378
  const truncated = rows.length > limit;
@@ -310,16 +386,16 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
310
386
  }
311
387
 
312
388
  case "queue.send": {
313
- const q = requireQueue(msg.queue);
389
+ const { store, part: q } = storeFor("queue", requireQueue(msg.queue));
314
390
  const at = Date.now() + Math.max(0, Number(msg.delaySeconds) || 0) * 1000;
315
- db.query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)").run(q, newId(), str(msg.body), at);
391
+ store.query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)").run(q, newId(), str(msg.body), at);
316
392
  return { ok: true };
317
393
  }
318
394
  case "queue.send_batch": {
319
- const q = requireQueue(msg.queue);
395
+ const { store, part: q } = storeFor("queue", requireQueue(msg.queue));
320
396
  const msgs = Array.isArray(msg.messages) ? (msg.messages as Frame[]) : [];
321
- const ins = db.query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)");
322
- db.transaction(() => {
397
+ const ins = store.query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)");
398
+ store.transaction(() => {
323
399
  for (const m of msgs) ins.run(q, newId(), str(m.body), Date.now() + Math.max(0, Number(m.delaySeconds) || 0) * 1000);
324
400
  })();
325
401
  return { ok: true, count: msgs.length };
@@ -444,18 +520,21 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
444
520
  function drainQueuesOnce(): void {
445
521
  if (!opts.sproutUrl || bindings.queues.length === 0) return;
446
522
  const now = Date.now();
447
- for (const q of bindings.queues) {
448
- const rows = db.query<{ id: string; body: string; attempts: number }, [string, number, number]>(
523
+ for (const binding of bindings.queues) {
524
+ // `store`/`part` route the rows to this queue's backing file (#74); the
525
+ // trigger payload still carries the binding name the sprout matches on.
526
+ const { store, part } = storeFor("queue", binding);
527
+ const rows = store.query<{ id: string; body: string; attempts: number }, [string, number, number]>(
449
528
  "SELECT id, body, attempts FROM mq WHERE queue = ? AND dead = 0 AND visible_at <= ? ORDER BY visible_at LIMIT ?",
450
- ).all(q, now, QUEUE_BATCH);
529
+ ).all(part, now, QUEUE_BATCH);
451
530
  if (rows.length === 0) continue;
452
531
  // hide the batch so the next tick doesn't re-deliver it while in flight
453
532
  const hideUntil = now + 30_000;
454
- const hide = db.query("UPDATE mq SET visible_at = ? WHERE id = ?");
533
+ const hide = store.query("UPDATE mq SET visible_at = ? WHERE id = ?");
455
534
  for (const r of rows) hide.run(hideUntil, r.id);
456
535
 
457
536
  void deliverTrigger("queue", {
458
- queue: q,
537
+ queue: binding,
459
538
  messages: rows.map((r) => ({ id: r.id, body: r.body, timestamp: now, attempts: r.attempts + 1 })),
460
539
  }).then(async (res) => {
461
540
  let ack: string[] = rows.map((r) => r.id); // default: ack all if the sprout did not say
@@ -469,9 +548,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
469
548
  } else {
470
549
  ack = []; retry = rows.map((r) => r.id); // delivery failed → retry all
471
550
  }
472
- const del = db.query("DELETE FROM mq WHERE id = ?");
551
+ const del = store.query("DELETE FROM mq WHERE id = ?");
473
552
  for (const id of ack) del.run(id);
474
- const bump = db.query(
553
+ const bump = store.query(
475
554
  "UPDATE mq SET attempts = attempts + 1, visible_at = ?, dead = CASE WHEN attempts + 1 >= ? THEN 1 ELSE 0 END WHERE id = ?",
476
555
  );
477
556
  for (const id of retry) bump.run(Date.now() + 5_000, QUEUE_MAX_ATTEMPTS, id);
@@ -501,6 +580,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
501
580
  close: () => {
502
581
  for (const t of timers) clearInterval(t);
503
582
  for (const conn of d1Conns.values()) conn.close();
583
+ for (const conn of resourceDbs.values()) conn.close();
504
584
  db.close();
505
585
  },
506
586
  };
@@ -572,6 +652,7 @@ if (import.meta.main) {
572
652
  token: { type: "string" },
573
653
  db: { type: "string" },
574
654
  "data-dir": { type: "string" },
655
+ "resource-dir": { type: "string" },
575
656
  bindings: { type: "string" },
576
657
  secrets: { type: "string" },
577
658
  "sprout-url": { type: "string" },
@@ -587,6 +668,7 @@ if (import.meta.main) {
587
668
  const broker = createBroker({
588
669
  db: values.db,
589
670
  dataDir: values["data-dir"],
671
+ resourceDir: values["resource-dir"],
590
672
  token: values.token ?? process.env.SB_BROKER_TOKEN,
591
673
  bindings,
592
674
  secrets,
package/src/build.ts CHANGED
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import { walkAssets, type AssetManifest } from "./assets";
5
- import type { SproutboatConfig } from "./config";
5
+ import { resourceRefs, type SproutboatConfig } from "./config";
6
6
  import { compileSprout } from "./compile";
7
7
  import { ARTIFACT_SCHEMA_VERSION, CAPABILITY_PROFILE, RUNTIME, type ArtifactManifest } from "./manifest";
8
8
  import { ensureZig, esbuildVersion, porfforVersion, toolchainStamp } from "./toolchain";
@@ -36,17 +36,31 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
36
36
  const sproutPath = resolve(artifactDir, "sprout");
37
37
  await mkdir(artifactDir, { recursive: true });
38
38
 
39
+ // #74 — split each storage-binding array into its binding-name list (the
40
+ // legacy shape the prelude/broker read) plus a `resources` map { binding ->
41
+ // { kind, id } } for the entries that name an account-level resource id.
42
+ const refsByKind = {
43
+ kv: resourceRefs(input.config.kv_namespaces),
44
+ d1: resourceRefs(input.config.d1_databases),
45
+ r2: resourceRefs(input.config.r2_buckets),
46
+ queue: resourceRefs(input.config.queues),
47
+ };
48
+ const resources: Record<string, { kind: string; id: string }> = {};
49
+ for (const [kind, refs] of Object.entries(refsByKind)) {
50
+ for (const ref of refs) if (ref.id) resources[ref.binding] = { kind, id: ref.id };
51
+ }
39
52
  const bindings = {
40
- kv: input.config.kv_namespaces ?? [],
53
+ kv: refsByKind.kv.map((ref) => ref.binding),
41
54
  secrets: input.config.secrets ?? [],
42
55
  outbound: input.config.outbound ?? [],
43
- d1: input.config.d1_databases ?? [],
44
- r2: input.config.r2_buckets ?? [],
45
- queues: input.config.queues ?? [],
56
+ d1: refsByKind.d1.map((ref) => ref.binding),
57
+ r2: refsByKind.r2.map((ref) => ref.binding),
58
+ queues: refsByKind.queue.map((ref) => ref.binding),
46
59
  analytics: input.config.analytics_engine_datasets ?? [],
47
60
  do: Object.entries(input.config.durable_objects ?? {}).map(([binding, className]) => ({ binding, className })),
48
61
  crons: input.config.triggers?.crons ?? [],
49
62
  assets: input.config.assets?.binding ?? "",
63
+ resources,
50
64
  };
51
65
 
52
66
  const zigBin = await ensureZig();
@@ -78,7 +92,9 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
78
92
  // frozen at v2. The control plane reads this to configure the per-deployment
79
93
  // broker (KV / D1 / R2 / queue names, secret names, outbound allowlist, cron
80
94
  // schedules, Durable Object classes).
81
- if (Object.values(bindings).some((names) => names.length > 0)) {
95
+ const hasBindings = Object.values(bindings).some((value) => Array.isArray(value) && value.length > 0)
96
+ || Object.keys(bindings.resources).length > 0;
97
+ if (hasBindings) {
82
98
  await writeFile(resolve(artifactDir, "bindings.json"), `${JSON.stringify(bindings, null, 2)}\n`);
83
99
  }
84
100
 
package/src/config.ts CHANGED
@@ -1,24 +1,58 @@
1
1
  const slugPattern = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?$/;
2
2
 
3
+ /**
4
+ * A storage binding entry (#74). Either a bare `"BINDING"` — resolved to an
5
+ * ephemeral local resource for `sproutboat dev`, rejected by a real deploy — or
6
+ * `{ binding, id }` pointing at an account-level resource created with
7
+ * `sproutboat resource create`. The id carries its own `<kind>_` prefix.
8
+ */
9
+ export type ResourceBinding = { binding: string; id: string };
10
+ export type ResourceRef = string | ResourceBinding;
11
+
12
+ /** Normalizes a storage-binding array to `{ binding, id? }` rows. */
13
+ export function resourceRefs(field: readonly ResourceRef[] | undefined): Array<{ binding: string; id?: string }> {
14
+ return (field ?? []).map((entry) => (isString(entry) ? { binding: entry } : { binding: entry.binding, id: entry.id }));
15
+ }
16
+
17
+ /**
18
+ * Rewrite a bare `"BINDING"` token inside `<field>: [ … ]` to
19
+ * `{ "binding": "BINDING", "id": "<id>" }`, leaving the rest of the source
20
+ * (comments, spacing) untouched — used by `deploy`'s auto-provisioner to pin an
21
+ * id back into `sproutboat.jsonc`. Binding names are UPPER_SNAKE, so a plain
22
+ * `"BINDING"` match inside the array is unambiguous. No-op if not found.
23
+ */
24
+ export function pinBindingId(source: string, field: string, binding: string, id: string): string {
25
+ const array = new RegExp(`("${field}"\\s*:\\s*\\[)([\\s\\S]*?)(\\])`);
26
+ // the bare token must be a whole array element — at the start of the array or
27
+ // right after a comma — never `"binding": "NAME"` inside an already-pinned
28
+ // { … } object.
29
+ const element = new RegExp(`(^\\s*|,\\s*)"${binding}"(\\s*,|\\s*$)`);
30
+ return source.replace(array, (whole, open: string, inner: string, close: string) =>
31
+ element.test(inner)
32
+ ? open + inner.replace(element, `$1{ "binding": "${binding}", "id": "${id}" }$2`) + close
33
+ : whole,
34
+ );
35
+ }
36
+
3
37
  export type SproutboatConfig = {
4
38
  $schema?: string;
5
39
  name: string;
6
40
  main: string;
7
41
  compatibility_date: string;
8
42
  vars?: Record<string, string>;
9
- /** KV namespace binding names, exposed as `env.<NAME>`. */
10
- kv_namespaces?: string[];
43
+ /** KV namespace bindings, exposed as `env.<NAME>`. */
44
+ kv_namespaces?: ResourceRef[];
11
45
  /** Secret binding names, exposed as `env.<NAME>` (value fetched at use). */
12
46
  secrets?: string[];
13
47
  /** Hostnames the sprout's `fetch()` may reach (exact host match). */
14
48
  outbound?: string[];
15
- /** D1 (SQLite) database binding names, exposed as `env.<NAME>`. */
16
- d1_databases?: string[];
17
- /** R2 (object storage) bucket binding names, exposed as `env.<NAME>`. */
18
- r2_buckets?: string[];
19
- /** Queue producer binding names, exposed as `env.<NAME>.send()`. A `queue(batch)` handler consumes them. */
20
- queues?: string[];
21
- /** Analytics Engine dataset binding names, exposed as `env.<NAME>.writeDataPoint()`. */
49
+ /** D1 (SQLite) database bindings, exposed as `env.<NAME>`. */
50
+ d1_databases?: ResourceRef[];
51
+ /** R2 (object storage) bucket bindings, exposed as `env.<NAME>`. */
52
+ r2_buckets?: ResourceRef[];
53
+ /** Queue producer bindings, exposed as `env.<NAME>.send()`. A `queue(batch)` handler consumes them. */
54
+ queues?: ResourceRef[];
55
+ /** Analytics Engine dataset binding names, exposed as `env.<NAME>.writeDataPoint()`. No id — the dataset is created on first write. */
22
56
  analytics_engine_datasets?: string[];
23
57
  /** Durable Object bindings: `{ BINDING_NAME: "ClassName" }`. The class is defined in the handler module. */
24
58
  durable_objects?: Record<string, string>;
@@ -101,7 +135,7 @@ function validateConfig(value: ConfigInput): ConfigValidation {
101
135
  const bindingName = /^[A-Z][A-Z0-9_]*$/;
102
136
  const hostPattern = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
103
137
  const stringArray = (
104
- field: "kv_namespaces" | "secrets" | "outbound" | "d1_databases" | "r2_buckets" | "queues" | "analytics_engine_datasets",
138
+ field: "secrets" | "outbound" | "analytics_engine_datasets",
105
139
  item: RegExp,
106
140
  label: string,
107
141
  ): string[] | undefined => {
@@ -118,13 +152,48 @@ function validateConfig(value: ConfigInput): ConfigValidation {
118
152
  }
119
153
  return out;
120
154
  };
121
- const kv_namespaces = stringArray("kv_namespaces", bindingName, "binding names (UPPER_SNAKE_CASE)");
155
+
156
+ /**
157
+ * A storage-binding array (#74): each entry is a bare `"BINDING"` or
158
+ * `{ binding: "BINDING", id: "<kind>_<24hex>" }`. `kind` is the field's own
159
+ * resource kind, so an r2 id can't be pasted into `kv_namespaces`.
160
+ */
161
+ const resourceArray = (
162
+ field: "kv_namespaces" | "d1_databases" | "r2_buckets" | "queues",
163
+ kind: string,
164
+ ): ResourceRef[] | undefined => {
165
+ if (value[field] === undefined) return undefined;
166
+ const raw = value[field];
167
+ if (!Array.isArray(raw)) {
168
+ errors.push(`${field} must be an array of binding names or { binding, id } objects`);
169
+ return undefined;
170
+ }
171
+ const idPattern = new RegExp(`^${kind}_[0-9a-f]{24}$`);
172
+ const out: ResourceRef[] = [];
173
+ for (const entry of raw) {
174
+ if (isString(entry)) {
175
+ if (bindingName.test(entry)) out.push(entry);
176
+ else errors.push(`${field}: "${entry}" must be an UPPER_SNAKE binding name`);
177
+ } else if (isRecord(entry) && isString(entry.binding) && isString(entry.id)
178
+ && bindingName.test(entry.binding) && idPattern.test(entry.id)
179
+ && Object.keys(entry).every((key) => key === "binding" || key === "id")) {
180
+ out.push({ binding: entry.binding, id: entry.id });
181
+ } else {
182
+ errors.push(`${field} entries must be an UPPER_SNAKE name or { binding: "NAME", id: "${kind}_…" }`);
183
+ }
184
+ }
185
+ return out;
186
+ };
187
+
122
188
  const secrets = stringArray("secrets", bindingName, "binding names (UPPER_SNAKE_CASE)");
123
189
  const outbound = stringArray("outbound", hostPattern, "hostnames");
124
- const d1_databases = stringArray("d1_databases", bindingName, "binding names (UPPER_SNAKE_CASE)");
125
- const r2_buckets = stringArray("r2_buckets", bindingName, "binding names (UPPER_SNAKE_CASE)");
126
- const queues = stringArray("queues", bindingName, "binding names (UPPER_SNAKE_CASE)");
190
+ // Analytics Engine datasets aren't provisioned the dataset name springs into
191
+ // existence on first writeDataPoint(), so there's no resource id to bind (#74).
127
192
  const analytics_engine_datasets = stringArray("analytics_engine_datasets", bindingName, "binding names (UPPER_SNAKE_CASE)");
193
+ const kv_namespaces = resourceArray("kv_namespaces", "kv");
194
+ const d1_databases = resourceArray("d1_databases", "d1");
195
+ const r2_buckets = resourceArray("r2_buckets", "r2");
196
+ const queues = resourceArray("queues", "queue");
128
197
 
129
198
  let durable_objects: Record<string, string> | undefined;
130
199
  if (value.durable_objects !== undefined) {
@@ -190,9 +259,11 @@ function validateConfig(value: ConfigInput): ConfigValidation {
190
259
  }
191
260
  }
192
261
 
262
+ const resourceNames = (refs: ResourceRef[] | undefined): string[] =>
263
+ resourceRefs(refs).map((ref) => ref.binding);
193
264
  const bindingSlots = [
194
- ...(kv_namespaces ?? []), ...(secrets ?? []), ...(d1_databases ?? []), ...(r2_buckets ?? []),
195
- ...(queues ?? []), ...(analytics_engine_datasets ?? []), ...Object.keys(durable_objects ?? {}), ...Object.keys(vars ?? {}),
265
+ ...resourceNames(kv_namespaces), ...(secrets ?? []), ...resourceNames(d1_databases), ...resourceNames(r2_buckets),
266
+ ...resourceNames(queues), ...(analytics_engine_datasets ?? []), ...Object.keys(durable_objects ?? {}), ...Object.keys(vars ?? {}),
196
267
  ...(assets?.binding ? [assets.binding] : []),
197
268
  ];
198
269
  if (new Set(bindingSlots).size !== bindingSlots.length) errors.push("vars and binding names must not collide");
package/src/main.ts CHANGED
@@ -1,7 +1,7 @@
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 { parseConfig, type SproutboatConfig } from "./config";
4
+ import { parseConfig, pinBindingId, resourceRefs, type SproutboatConfig } from "./config";
5
5
  import { validateHttpSyncSource } from "./source";
6
6
  import { buildArtifact } from "./build";
7
7
  import { validateManifest, type ArtifactManifest } from "./manifest";
@@ -177,6 +177,52 @@ async function build(directory?: string) {
177
177
  return { project, artifact };
178
178
  }
179
179
 
180
+ const PROVISION_FIELDS = [
181
+ ["kv_namespaces", "kv"],
182
+ ["d1_databases", "d1"],
183
+ ["r2_buckets", "r2"],
184
+ ["queues", "queue"],
185
+ ] as const;
186
+
187
+ /**
188
+ * #74 auto-provisioning, wrangler-style. A bare-string KV/D1/R2/queue binding
189
+ * with no id gets an account-level resource created (`<project>-<binding>`) on
190
+ * deploy, and the id is written back into `sproutboat.jsonc`. `--no-provision`
191
+ * skips this — those bindings then get an ephemeral deploy-scoped store.
192
+ */
193
+ async function provisionBindings(directory = process.cwd()): Promise<void> {
194
+ const configPath = resolve(directory, "sproutboat.jsonc");
195
+ let source = await readFile(configPath, "utf8");
196
+ const parsed = parseConfig(source);
197
+ if (!parsed.ok) return; // build() re-reads and reports the config error
198
+
199
+ const bare: Array<{ field: string; kind: string; binding: string }> = [];
200
+ for (const [field, kind] of PROVISION_FIELDS) {
201
+ for (const ref of resourceRefs(parsed.value[field])) {
202
+ if (!ref.id) bare.push({ field, kind, binding: ref.binding });
203
+ }
204
+ }
205
+ if (bare.length === 0) return;
206
+
207
+ const { apiUrl, token } = await apiCredentials();
208
+ for (const { field, kind, binding } of bare) {
209
+ const name = `${parsed.value.name}-${binding.toLowerCase().replace(/_/g, "-")}`;
210
+ const body = await responseText(
211
+ await fetch(`${apiUrl}/api/resources`, {
212
+ method: "POST",
213
+ headers: { "x-api-key": token, "content-type": "application/json" },
214
+ body: JSON.stringify({ kind, name, ifExists: "return" }),
215
+ }),
216
+ `could not provision a ${kind} resource for env.${binding}`,
217
+ );
218
+ const record = jsonObject(jsonObject(parseJsonValue(body))?.resource ?? null);
219
+ if (!record || !isString(record.id)) fail(`provision response for env.${binding} was not a resource`);
220
+ source = pinBindingId(source, field, binding, record.id);
221
+ console.log(ok(`provisioned ${kind} ${bold(name)} → ${record.id}`));
222
+ }
223
+ await writeFile(configPath, source);
224
+ }
225
+
180
226
  async function deploy(args: string[]) {
181
227
  const artifactIndex = args.indexOf("--artifact");
182
228
  const dryRun = args.includes("--dry-run");
@@ -190,6 +236,9 @@ async function deploy(args: string[]) {
190
236
  : usageError("deploy: --artifact needs a directory", "deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait]");
191
237
  projectName = "";
192
238
  } else {
239
+ // wrangler-style: create resources for id-less bindings and pin the ids
240
+ // back into sproutboat.jsonc before the build bakes them into the artifact.
241
+ if (!args.includes("--no-provision") && !dryRun) await provisionBindings(directory);
193
242
  const built = await build(directory);
194
243
  projectName = built.project.config.name;
195
244
  artifactDir = built.artifact.artifactDir;
@@ -462,6 +511,63 @@ async function secrets(args: string[]) {
462
511
  console.log(`Set ${name} — applies on the next deploy or sprout restart`);
463
512
  }
464
513
 
514
+ const RESOURCE_KINDS = ["kv", "d1", "r2", "queue"];
515
+
516
+ /**
517
+ * #74 — account-level storage resources. Unlike `secrets`/`domains` these are
518
+ * not project-scoped, so there's no `readProject` here. `create` prints the
519
+ * `<kind>_<id>` handle to paste into `sproutboat.jsonc` bindings.
520
+ */
521
+ async function resource(args: string[]) {
522
+ const sub = args[0] && ["list", "create", "rename", "delete"].includes(args[0]) ? args.shift()! : "list";
523
+ const { apiUrl, token } = await apiCredentials();
524
+ const base = `${apiUrl}/api/resources`;
525
+ const auth = { "x-api-key": token };
526
+
527
+ if (sub === "list") {
528
+ const kindFilter = args[0];
529
+ const body = await responseText(await fetch(base, { headers: auth }), "could not list resources");
530
+ const parsed = jsonObject(parseJsonValue(body));
531
+ const rows = (parsed && Array.isArray(parsed.resources) ? parsed.resources : [])
532
+ .map((entry) => jsonObject(entry))
533
+ .filter((entry): entry is JsonObject => Boolean(entry) && (!kindFilter || entry!.kind === kindFilter));
534
+ if (rows.length === 0) { console.log(kindFilter ? `no ${kindFilter} resources` : "no resources"); return; }
535
+ for (const row of rows) console.log(`${String(row.kind).padEnd(9)} ${String(row.id).padEnd(30)} ${String(row.name)}`);
536
+ return;
537
+ }
538
+
539
+ if (sub === "create") {
540
+ const [kind, name] = args;
541
+ if (!kind || !RESOURCE_KINDS.includes(kind)) usageError(`resource create: kind must be one of ${RESOURCE_KINDS.join(", ")}`, "resource create <kind> <name>");
542
+ if (!name) usageError("resource create: missing <name>", "resource create <kind> <name>");
543
+ const body = await responseText(
544
+ await fetch(base, { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ kind, name }) }),
545
+ "create rejected",
546
+ );
547
+ const record = jsonObject(jsonObject(parseJsonValue(body))?.resource ?? null);
548
+ if (!record || !isString(record.id)) fail("create response was not a resource");
549
+ console.log(ok(`created ${kind} ${bold(String(record.name))}`));
550
+ console.log(record.id);
551
+ return;
552
+ }
553
+
554
+ if (sub === "rename") {
555
+ const [id, name] = args;
556
+ if (!id || !name) usageError("resource rename: need <id> <name>", "resource rename <id> <name>");
557
+ await responseText(
558
+ await fetch(`${base}/${id}`, { method: "PATCH", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ name }) }),
559
+ "rename rejected",
560
+ );
561
+ console.log(ok(`renamed ${id} → ${name}`));
562
+ return;
563
+ }
564
+
565
+ const id = args[0];
566
+ if (!id) usageError("resource delete: missing <id>", "resource delete <id>");
567
+ await responseText(await fetch(`${base}/${id}`, { method: "DELETE", headers: auth }), "delete rejected");
568
+ console.log(ok(`deleted ${id}`));
569
+ }
570
+
465
571
  async function deleteProject(args: string[]) {
466
572
  // `sproutboat delete [project-dir] [--name <project>] --yes` — flags in any order.
467
573
  const positional: string[] = [];
@@ -521,6 +627,7 @@ switch (command) {
521
627
  case "rollback": await rollback(args); break;
522
628
  case "domains": await domains(args); break;
523
629
  case "secrets": await secrets(args); break;
630
+ case "resource": await resource(args); break;
524
631
  case "tail": await tail(args); break;
525
632
  case "delete": await deleteProject(args); break;
526
633
  default: usage();
package/src/report.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { gzipSync } from "bun";
3
- import type { SproutboatConfig } from "./config";
3
+ import { resourceRefs, type SproutboatConfig } from "./config";
4
4
  import type { ArtifactManifest } from "./manifest";
5
5
  import { bold, dim, leaf, sprout } from "./style";
6
6
 
@@ -33,11 +33,16 @@ function bindingRows(config: SproutboatConfig): string[][] {
33
33
  const list = (names: string[] | undefined, type: string, detail = "") => {
34
34
  for (const name of names ?? []) rows.push([`env.${name}`, type, detail]);
35
35
  };
36
- list(config.kv_namespaces, "kv");
36
+ const resourceList = (refs: Parameters<typeof resourceRefs>[0], type: string) => {
37
+ for (const ref of resourceRefs(refs)) {
38
+ rows.push([`env.${ref.binding}`, type, ref.id ?? "deploy-scoped store (--no-provision) — drop the flag to auto-provision a persistent resource"]);
39
+ }
40
+ };
41
+ resourceList(config.kv_namespaces, "kv");
37
42
  list(config.secrets, "secret", "value withheld — set with `sproutboat secrets`");
38
- list(config.d1_databases, "d1");
39
- list(config.r2_buckets, "r2");
40
- list(config.queues, "queue");
43
+ resourceList(config.d1_databases, "d1");
44
+ resourceList(config.r2_buckets, "r2");
45
+ resourceList(config.queues, "queue");
41
46
  list(config.analytics_engine_datasets, "analytics");
42
47
  for (const [name, className] of Object.entries(config.durable_objects ?? {})) rows.push([`env.${name}`, "durable object", className]);
43
48
  for (const host of config.outbound ?? []) rows.push([`fetch()`, "outbound", host]);
package/src/surface.ts CHANGED
@@ -31,8 +31,8 @@ export const COMMANDS: readonly Command[] = [
31
31
  summary: "Cross-compile the native-fetch sprout (Porffor + Zig)." },
32
32
 
33
33
  { name: "deploy", group: "Ship", emoji: "🚀",
34
- args: "[project-dir] [--dry-run] [--artifact <dir>] [--no-wait]", brief: "[project-dir] [--dry-run]",
35
- summary: "Build (unless --artifact), print the report, upload, wait until the URL serves. --dry-run stops before upload; --no-wait skips the health check." },
34
+ args: "[project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision]", brief: "[project-dir] [--dry-run]",
35
+ summary: "Build (unless --artifact), auto-provision id-less storage bindings and pin their ids into sproutboat.jsonc, print the report, upload, wait until the URL serves. --dry-run stops before upload; --no-wait skips the health check; --no-provision leaves id-less bindings as ephemeral deploy-scoped stores." },
36
36
  { name: "versions", group: "Ship", emoji: "📜", args: "list [project-dir]",
37
37
  summary: "List the project's deployed versions." },
38
38
  { name: "rollback", group: "Ship", emoji: "⏮", args: "<version-id> [project-dir]", brief: "<version-id>",
@@ -46,6 +46,9 @@ export const COMMANDS: readonly Command[] = [
46
46
  { name: "secrets", group: "Configure", emoji: "🔑",
47
47
  args: "[list | set <NAME> [value] | rm <NAME>] [project-dir]", brief: "[list | set | rm]",
48
48
  summary: "Manage encrypted project secrets (read as env.NAME). `set` takes the value from the arg or stdin; applies on next deploy." },
49
+ { name: "resource", group: "Configure", emoji: "📦",
50
+ args: "[list [kind] | create <kind> <name> | rename <id> <name> | delete <id>]", brief: "[list | create | rename | delete]",
51
+ summary: "Manage account-level storage resources (kv | d1 | r2 | queue). `create` prints the id to reference from sproutboat.jsonc bindings." },
49
52
  { name: "delete", group: "Configure", emoji: "🗑",
50
53
  args: "[project-dir] [--name <project>] --yes", brief: "[project-dir] --yes",
51
54
  summary: "Delete the project, every version, and its route." },