sproutboat 0.4.11 → 0.5.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,27 +22,6 @@ 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
26
  type CliAuthorization = { deviceCode: string; userCode: string; verificationUri: string; interval: number; expiresAt: string };
44
27
 
@@ -71,7 +54,7 @@ function parseUrlResponse(source: string): { url: string; id?: string; artifact?
71
54
  * differ between pins. */
72
55
  function parsePorfforDrift(source: string): { from: string; to: string } | undefined {
73
56
  const drift = (() => { try { return jsonObject(parseJsonValue(source))?.porfforDrift; } catch { return undefined; } })();
74
- const record = drift && jsonObject(drift as JsonValue);
57
+ const record = drift && jsonObject(drift);
75
58
  return record && isString(record.from) && isString(record.to) ? { from: record.from, to: record.to } : undefined;
76
59
  }
77
60
 
@@ -99,6 +82,14 @@ const starterHandler = `export default {
99
82
  }
100
83
  };
101
84
  `;
85
+ // .sproutboat/ holds build output (dist/) and `dev`'s local broker state
86
+ // (dev/, including its SQLite files) — neither belongs in version control.
87
+ // .dev.vars carries secret values for `sproutboat dev`, same convention as
88
+ // Wrangler's file of the same name.
89
+ const starterGitignore = `.sproutboat/
90
+ .dev.vars
91
+ node_modules/
92
+ `;
102
93
 
103
94
  /** An operational failure — the command was invoked correctly but could not complete. Exit 1. */
104
95
  function fail(message: string): never {
@@ -134,21 +125,45 @@ async function readProject(directory = process.cwd()) {
134
125
  } catch {
135
126
  fail(`entry point not found: ${parsed.value.main}`);
136
127
  }
137
- const supported = validateHttpSyncSource(source, (parsed.value.outbound ?? []).length > 0);
128
+ // #89 — resolve imports first, then hold the *bundled* module to the
129
+ // capability rules. Validating the entry file instead would let a dependency
130
+ // smuggle in a Node API the handler is not allowed to touch.
131
+ let bundle: BundleResult;
132
+ try {
133
+ bundle = await bundleHandler(sourcePath, projectDirectory);
134
+ } catch (cause) {
135
+ fail(cause instanceof BundleError ? cause.message : String(cause));
136
+ }
137
+ const supported = validateHttpSyncSource(bundle.code, (parsed.value.outbound ?? []).length > 0);
138
138
  if (!supported.ok) fail(supported.errors.join("\n"));
139
- return { directory: projectDirectory, config: parsed.value, sourcePath, source };
139
+ return { directory: projectDirectory, config: parsed.value, sourcePath, source, bundle };
140
140
  }
141
141
 
142
142
  async function init(name = "hello") {
143
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");
144
144
  const directory = resolve(process.cwd(), name);
145
145
  const configPath = resolve(directory, "sproutboat.jsonc");
146
- if (await Bun.file(configPath).exists()) fail(`${basename(directory)} already contains sproutboat.jsonc`);
146
+ const handlerPath = resolve(directory, "src/index.js");
147
+ // Check both targets before writing either — `name` can collide with an
148
+ // unrelated existing directory, and a second `wx` write failing partway
149
+ // through used to crash with a raw EEXIST stack trace after already having
150
+ // created sproutboat.jsonc, leaving a half-scaffolded project behind.
151
+ for (const [path, label] of [[configPath, "sproutboat.jsonc"], [handlerPath, "src/index.js"]] as const) {
152
+ if (await Bun.file(path).exists()) fail(`${basename(directory)} already contains ${label}`);
153
+ }
147
154
  await mkdir(resolve(directory, "src"), { recursive: true });
148
155
  await writeFile(configPath, starterConfig(name), { flag: "wx" });
149
- await writeFile(resolve(directory, "src/index.js"), starterHandler, { flag: "wx" });
156
+ await writeFile(handlerPath, starterHandler, { flag: "wx" });
150
157
  console.log(`Created ${basename(directory)}/sproutboat.jsonc`);
151
158
  console.log(`Created ${basename(directory)}/src/index.js`);
159
+ // Unlike the two files above, an existing .gitignore here is not a sign this
160
+ // isn't a fresh project (`name` can collide with an unrelated directory) —
161
+ // leave it alone rather than failing init or clobbering it.
162
+ const gitignorePath = resolve(directory, ".gitignore");
163
+ if (!(await Bun.file(gitignorePath).exists())) {
164
+ await writeFile(gitignorePath, starterGitignore, { flag: "wx" });
165
+ console.log(`Created ${basename(directory)}/.gitignore`);
166
+ }
152
167
  }
153
168
 
154
169
  async function check(directory?: string) {
@@ -156,15 +171,40 @@ async function check(directory?: string) {
156
171
  console.log(ok(`check passed — ${project.config.name} (${project.config.main}, native-fetch)`));
157
172
  }
158
173
 
159
- async function build(directory?: string) {
174
+ async function build(directory?: string, target: "linux-x86_64" | "host" = "linux-x86_64") {
160
175
  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 });
176
+ console.log(target === "host"
177
+ ? dim(`Compiling the native-fetch server with Porffor for this machine (${hostTarget()}, local only)…`)
178
+ : dim("Compiling the native-fetch server with Porffor + Zig (linux-x86_64, static)…"));
179
+ const artifact = await buildArtifact({ projectDir: project.directory, config: project.config, sourcePath: project.sourcePath, source: project.bundle.code, target });
163
180
  console.log(ok(`built ${project.config.name}`));
181
+ if (target === "host") console.log(dim(" host build — runs here, not deployable; drop --target host to build for a box"));
164
182
  console.log(artifact.artifactDir);
165
183
  return { project, artifact };
166
184
  }
167
185
 
186
+ /** #62 — build for this machine, run it against a real broker, rebuild on save. */
187
+ async function dev(args: string[]) {
188
+ const directory = args.find((arg) => !arg.startsWith("--") && !/^\d+$/.test(arg));
189
+ const portIndex = args.indexOf("--port");
190
+ const portArg = portIndex >= 0 ? args[portIndex + 1] : undefined;
191
+ const port = Number(portArg ?? 8787);
192
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) usageError(`invalid --port: ${portArg}`, "dev [project-dir] [--port <n>] [--no-watch]");
193
+ const project = await readProject(directory);
194
+ console.log(dim(`Building ${project.config.name} for this machine (${hostTarget()})…`));
195
+ await runDev({
196
+ projectDir: project.directory,
197
+ config: project.config,
198
+ sourcePath: project.sourcePath,
199
+ source: project.bundle.code,
200
+ port,
201
+ watch: !args.includes("--no-watch"),
202
+ // Re-read from disk on every rebuild: the point of watching is that the
203
+ // files changed, so the bundle captured at startup is stale by definition.
204
+ rebuild: async () => (await readProject(directory)).bundle.code,
205
+ });
206
+ }
207
+
168
208
  const PROVISION_FIELDS = [
169
209
  ["kv_namespaces", "kv"],
170
210
  ["d1_databases", "d1"],
@@ -211,6 +251,38 @@ async function provisionBindings(directory = process.cwd()): Promise<void> {
211
251
  await writeFile(configPath, source);
212
252
  }
213
253
 
254
+ /** #79 — wrangler parity: drop the stored credential for an endpoint. */
255
+ async function logout(args: string[]) {
256
+ const { apiUrl } = parseLoginArgs(args);
257
+ console.log(await forgetToken(apiUrl)
258
+ ? ok(`forgot the credential for ${apiUrl}`)
259
+ : `no stored credential for ${apiUrl}`);
260
+ }
261
+
262
+ /**
263
+ * #79 — wrangler parity: which endpoint, and who the stored token belongs to.
264
+ * The account comes from the control plane, so this also proves the token still
265
+ * works rather than only reporting what is on disk.
266
+ */
267
+ async function whoami() {
268
+ const apiUrl = process.env.SPROUTBOAT_API_URL || await activeApiUrl();
269
+ if (!apiUrl) { console.log("not logged in — run `sproutboat login`"); return; }
270
+ const token = process.env.SPROUTBOAT_TOKEN || await savedToken(apiUrl);
271
+ console.log(`endpoint ${apiUrl}`);
272
+ if (!token) { console.log(`account ${dim("no stored token — run `sproutboat login`")}`); return; }
273
+
274
+ const response = await fetch(`${apiUrl}/api/account`, { headers: { "x-api-key": token } });
275
+ if (!response.ok) {
276
+ console.log(`account ${rose(response.status === 401 ? "token rejected — run `sproutboat login`" : `control plane said ${response.status}`)}`);
277
+ return;
278
+ }
279
+ const account = jsonObject(parseJsonValue(await response.text()));
280
+ const profile = jsonObject(account?.profile ?? null);
281
+ const user = jsonObject(account?.user ?? null);
282
+ console.log(`account ${isString(profile?.username) ? profile.username : "(no namespace reserved)"}`);
283
+ if (user && isString(user.email)) console.log(`email ${user.email}`);
284
+ }
285
+
214
286
  async function deploy(args: string[]) {
215
287
  const artifactIndex = args.indexOf("--artifact");
216
288
  const dryRun = args.includes("--dry-run");
@@ -264,7 +336,8 @@ async function deploy(args: string[]) {
264
336
  }
265
337
  const assetsManifestFile = Bun.file(resolve(artifactDir, "assets.json"));
266
338
  if (await assetsManifestFile.exists()) {
267
- const assetsManifest: { files?: Record<string, unknown> } = await assetsManifestFile.json();
339
+ // assets.json is written by `sproutboat build` from the AssetManifest contract.
340
+ const assetsManifest: { files?: AssetFiles } = await assetsManifestFile.json();
268
341
  form.set("assets_manifest", new File([await assetsManifestFile.arrayBuffer()], "assets.json", { type: "application/json" }));
269
342
  for (const key of Object.keys(assetsManifest.files ?? {})) {
270
343
  const file = Bun.file(resolve(artifactDir, "assets", `.${key}`));
@@ -381,8 +454,40 @@ async function apiCredentials() {
381
454
  }
382
455
 
383
456
  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()]);
457
+ const sub = args[0];
458
+ if (sub !== "list" && sub !== "view") {
459
+ usageError(sub ? `versions: unknown subcommand "${sub}"` : "versions: missing subcommand", "versions <list | view <version-id>> [project-dir]");
460
+ }
461
+ args.shift();
462
+
463
+ if (sub === "view") {
464
+ const id = args.shift();
465
+ if (!id) usageError("versions view: missing <version-id>", "versions view <version-id> [project-dir]");
466
+ const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
467
+ const body = await responseText(
468
+ await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments/${encodeURIComponent(id)}`, { headers: { "x-api-key": token } }),
469
+ "could not read that version",
470
+ );
471
+ const detail = jsonObject(parseJsonValue(body));
472
+ if (!detail) fail("could not parse version response");
473
+ const manifest = jsonObject(detail.manifest ?? null);
474
+ console.log(`${bold(String(detail.id))} ${detail.active ? ok("active") : dim("superseded")}`);
475
+ console.log(` route ${String(detail.hostname)}`);
476
+ console.log(` artifact ${String(detail.artifact)}`);
477
+ console.log(` deployed ${String(detail.deployedAt)}${isString(detail.deployedBy) ? ` by ${detail.deployedBy}` : ""}`);
478
+ if (manifest) {
479
+ console.log(` built ${String(manifest.builtAt)} · porffor ${String(manifest.porfforVersion)} · ${String(manifest.binarySize)} bytes`);
480
+ } else if (isString(detail.manifestError)) {
481
+ console.log(` ! manifest unavailable: ${detail.manifestError}`);
482
+ }
483
+ const resources = Array.isArray(detail.resources) ? detail.resources.map((entry) => jsonObject(entry)) : [];
484
+ for (const resource of resources) {
485
+ if (resource) console.log(` bound ${String(resource.kind)} ${String(resource.name)} ${dim(String(resource.id))}`);
486
+ }
487
+ return;
488
+ }
489
+
490
+ const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
386
491
  const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments`, { headers: { "x-api-key": token } });
387
492
  const deployments = parseVersionList(await responseText(response, "could not list versions"));
388
493
  if (!deployments) fail("could not parse versions response");
@@ -418,7 +523,7 @@ type DomainView = {
418
523
  };
419
524
  function parseDomain(source: string): DomainView | undefined {
420
525
  const record = jsonObject(parseJsonValue(source));
421
- if (!record || !isString(record.hostname) || typeof record.verified !== "boolean") return undefined;
526
+ if (!record || !isString(record.hostname) || !isBoolean(record.verified)) return undefined;
422
527
  const v = jsonObject(record.verification ?? null);
423
528
  const verification = v && isString(v.type) && isString(v.name) && isString(v.value) ? { type: v.type, name: v.name, value: v.value } : null;
424
529
  const serverAddresses = Array.isArray(record.serverAddresses) ? record.serverAddresses.filter(isString) : [];
@@ -438,7 +543,7 @@ function printDomain(domain: DomainView) {
438
543
  }
439
544
 
440
545
  async function domains(args: string[]) {
441
- const sub = args[0] && !args[0].startsWith("-") && ["list", "add", "verify", "rm"].includes(args[0]) ? args.shift()! : "list";
546
+ const sub = args[0] && !args[0].startsWith("-") && ["list", "add", "verify", "delete"].includes(args[0]) ? args.shift()! : "list";
442
547
  const host = sub === "list" ? undefined : args.shift();
443
548
  if (sub !== "list" && !host) usageError(`domains ${sub}: missing <hostname>`, `domains ${sub} <hostname> [project-dir]`);
444
549
  const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
@@ -454,7 +559,7 @@ async function domains(args: string[]) {
454
559
  for (const entry of list) { const d = parseDomain(JSON.stringify(entry)); if (d) printDomain(d); }
455
560
  return;
456
561
  }
457
- if (sub === "rm") {
562
+ if (sub === "delete") {
458
563
  const response = await fetch(`${base}/${host}`, { method: "DELETE", headers: auth });
459
564
  await responseText(response, "delete rejected");
460
565
  console.log(ok(`removed ${host}`));
@@ -471,11 +576,23 @@ async function domains(args: string[]) {
471
576
  }
472
577
 
473
578
  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();
579
+ const sub = args[0] && ["list", "put", "delete"].includes(args[0]) ? args.shift()! : "list";
580
+
581
+ // `--value` is opt-in; without it the value comes from stdin, so a secret does
582
+ // not land in shell history. It is also what disambiguates the positionals:
583
+ // `secrets put NAME <value> [project-dir]` could not tell a value from a path,
584
+ // and read the value as the project directory.
585
+ let inlineValue: string | undefined;
586
+ const positional: string[] = [];
587
+ for (let index = 0; index < args.length; index += 1) {
588
+ if (args[index] === "--value") inlineValue = args[(index += 1)];
589
+ else positional.push(args[index]);
590
+ }
591
+
592
+ const name = sub === "list" ? undefined : positional.shift();
476
593
  if (sub !== "list" && !name) usageError(`secrets ${sub}: missing <NAME>`, `secrets ${sub} <NAME> [project-dir]`);
477
594
  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()]);
595
+ const [project, { apiUrl, token }] = await Promise.all([readProject(positional[0]), apiCredentials()]);
479
596
  const base = `${apiUrl}/api/projects/${project.config.name}/secrets`;
480
597
  const auth = { "x-api-key": token };
481
598
 
@@ -486,78 +603,104 @@ async function secrets(args: string[]) {
486
603
  console.log(names.length ? names.join("\n") : "no secrets");
487
604
  return;
488
605
  }
489
- if (sub === "rm") {
606
+ if (sub === "delete") {
490
607
  await responseText(await fetch(`${base}/${name}`, { method: "DELETE", headers: auth }), "delete rejected");
491
608
  console.log(ok(`removed ${name}`));
492
609
  return;
493
610
  }
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");
611
+
612
+ const value = inlineValue ?? (await Bun.stdin.text()).replace(/\r?\n$/, "");
613
+ if (!value) fail("no value — pipe it on stdin, or pass --value <value>");
499
614
  await responseText(
500
615
  await fetch(`${base}/${name}`, { method: "PUT", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ value }) }),
501
- "set rejected",
616
+ "put rejected",
502
617
  );
503
- console.log(`Set ${name} — applies on the next deploy or sprout restart`);
618
+ console.log(ok(`set ${name} — applies on the next deploy or sprout restart`));
504
619
  }
505
620
 
506
- const RESOURCE_KINDS = ["kv", "d1", "r2", "queue"];
507
-
508
621
  /**
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.
622
+ * #79 — one command per storage product (`kv`, `d1`, `r2`, `queues`), each with
623
+ * the same five verbs, over that product's own `/api/<product>` collection.
624
+ *
625
+ * Wrangler nests two of its four (`kv namespace create`, `r2 bucket create`)
626
+ * and leaves `d1 create` and `queues create` flat. The nesting is there to
627
+ * separate the container from its contents, which the verb already does — so
628
+ * ours are uniform, and contents take their own noun when they exist
629
+ * (`kv key get`, `r2 object put`).
512
630
  */
513
- async function resource(args: string[]) {
514
- const sub = args[0] && ["list", "create", "rename", "delete"].includes(args[0]) ? args.shift()! : "list";
631
+ /** The account's resources of one kind, by name. */
632
+ async function storageRows(base: string, auth: Record<string, string>, product: StorageProduct): Promise<JsonObject[]> {
633
+ const body = await responseText(await fetch(base, { headers: auth }), `could not list ${product.plural}`);
634
+ const parsed = jsonObject(parseJsonValue(body));
635
+ return (parsed && Array.isArray(parsed.resources) ? parsed.resources : [])
636
+ .map((entry) => jsonObject(entry))
637
+ .filter((entry): entry is JsonObject => Boolean(entry));
638
+ }
639
+
640
+ /** Resolve a name to its `<kind>_<id>` handle — the API addresses rows by id. */
641
+ function idForName(rows: JsonObject[], name: string, product: StorageProduct): string {
642
+ const match = rows.find((row) => row.name === name);
643
+ if (!match || !isString(match.id)) fail(`no ${product.noun} named "${name}"`);
644
+ return String(match.id);
645
+ }
646
+
647
+ async function storage(key: string, args: string[]) {
648
+ const product = STORAGE_PRODUCTS.find((entry) => entry.name === key)!;
649
+ const sub = args[0] && STORAGE_VERBS.some((verb) => verb === args[0]) ? args.shift()! : "list";
515
650
  const { apiUrl, token } = await apiCredentials();
516
- const base = `${apiUrl}/api/resources`;
651
+ const base = `${apiUrl}/api/${product.name}`;
517
652
  const auth = { "x-api-key": token };
518
653
 
519
654
  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)}`);
655
+ const rows = await storageRows(base, auth, product);
656
+ if (rows.length === 0) { console.log(`no ${product.plural}`); return; }
657
+ for (const row of rows) {
658
+ const bound = Array.isArray(row.projects) ? row.projects.filter(isString) : [];
659
+ console.log(`${String(row.id).padEnd(30)} ${String(row.name).padEnd(24)} ${bound.length ? bound.join(", ") : dim("unbound")}`);
660
+ }
528
661
  return;
529
662
  }
530
663
 
664
+ const name = args.shift();
665
+ if (!name) usageError(`${key} ${sub}: missing <name>`, `${key} ${sub} <name>`);
666
+
531
667
  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
668
  const body = await responseText(
536
- await fetch(base, { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ kind, name }) }),
669
+ await fetch(base, { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ name }) }),
537
670
  "create rejected",
538
671
  );
539
672
  const record = jsonObject(jsonObject(parseJsonValue(body))?.resource ?? null);
540
673
  if (!record || !isString(record.id)) fail("create response was not a resource");
541
- console.log(ok(`created ${kind} ${bold(String(record.name))}`));
674
+ console.log(ok(`created ${product.noun} ${bold(String(record.name))}`));
542
675
  console.log(record.id);
543
676
  return;
544
677
  }
545
678
 
679
+ const rows = await storageRows(base, auth, product);
680
+ const id = idForName(rows, name, product);
681
+
682
+ if (sub === "info") {
683
+ const row = rows.find((entry) => entry.id === id)!;
684
+ const bound = Array.isArray(row.projects) ? row.projects.filter(isString) : [];
685
+ console.log(`${bold(String(row.name))} ${dim(String(row.id))}`);
686
+ console.log(` created ${String(row.createdAt)}`);
687
+ console.log(` bound to ${bound.length ? bound.join(", ") : "nothing"}`);
688
+ return;
689
+ }
690
+
546
691
  if (sub === "rename") {
547
- const [id, name] = args;
548
- if (!id || !name) usageError("resource rename: need <id> <name>", "resource rename <id> <name>");
692
+ const next = args.shift();
693
+ if (!next) usageError(`${key} rename: missing <new-name>`, `${key} rename <name> <new-name>`);
549
694
  await responseText(
550
- await fetch(`${base}/${id}`, { method: "PATCH", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ name }) }),
695
+ await fetch(`${base}/${id}`, { method: "PATCH", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ name: next }) }),
551
696
  "rename rejected",
552
697
  );
553
- console.log(ok(`renamed ${id} → ${name}`));
698
+ console.log(ok(`renamed ${name} → ${next}`));
554
699
  return;
555
700
  }
556
701
 
557
- const id = args[0];
558
- if (!id) usageError("resource delete: missing <id>", "resource delete <id>");
559
702
  await responseText(await fetch(`${base}/${id}`, { method: "DELETE", headers: auth }), "delete rejected");
560
- console.log(ok(`deleted ${id}`));
703
+ console.log(ok(`deleted ${product.noun} ${name}`));
561
704
  }
562
705
 
563
706
  async function deleteProject(args: string[]) {
@@ -612,14 +755,21 @@ await notifyIfOutdated(CLI_VERSION);
612
755
  switch (command) {
613
756
  case "init": await init(args[0]); break;
614
757
  case "check": await check(args[0]); break;
615
- case "build": await build(args[0]); break;
758
+ case "dev": await dev(args); break;
759
+ case "build": {
760
+ const hostBuild = args.includes("--target") && args[args.indexOf("--target") + 1] === "host";
761
+ await build(args.find((arg) => !arg.startsWith("--") && arg !== "host"), hostBuild ? "host" : "linux-x86_64");
762
+ break;
763
+ }
616
764
  case "login": await login(args); break;
765
+ case "logout": await logout(args); break;
766
+ case "whoami": await whoami(); break;
617
767
  case "deploy": await deploy(args); break;
618
768
  case "versions": await versions(args); break;
619
769
  case "rollback": await rollback(args); break;
620
770
  case "domains": await domains(args); break;
621
771
  case "secrets": await secrets(args); break;
622
- case "resource": await resource(args); break;
772
+ case "kv": case "d1": case "r2": case "queues": await storage(command, args); break;
623
773
  case "tail": await tail(args); break;
624
774
  case "delete": await deleteProject(args); break;
625
775
  default: usage();
package/src/manifest.ts CHANGED
@@ -2,10 +2,22 @@ export const ARTIFACT_SCHEMA_VERSION = 2;
2
2
  export const RUNTIME = "native-fetch";
3
3
  export const CAPABILITY_PROFILE = "http-sync-v0";
4
4
 
5
+ /** The only target a deployed artifact may carry. Every box runs linux-x86_64. */
6
+ export const DEPLOY_TARGET = "linux-x86_64";
7
+
8
+ /**
9
+ * `<arch>-<platform>` of the machine doing the build, e.g. `arm64-darwin`.
10
+ * Only `sproutboat build --target host` produces one (#62): it runs on this
11
+ * machine for local dev and is deliberately not portable, so `validateManifest`
12
+ * rejects it and neither `deploy` nor the control plane will accept it.
13
+ */
14
+ export type HostTarget = `${string}-${string}`;
15
+ export const hostTarget = (): HostTarget => `${process.arch}-${process.platform}`;
16
+
5
17
  export type ArtifactManifest = {
6
18
  schemaVersion: 2;
7
19
  project: string;
8
- target: "linux-x86_64";
20
+ target: typeof DEPLOY_TARGET | HostTarget;
9
21
  runtime: "native-fetch";
10
22
  capabilityProfile: "http-sync-v0";
11
23
  porfforVersion: string;
@@ -56,8 +68,14 @@ export function validateManifest(value: ManifestInput): ManifestValidation {
56
68
  if (schemaVersion === null) errors.push("schemaVersion must be 2");
57
69
  const project = isString(value.project) && /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?$/.test(value.project) ? value.project : null;
58
70
  if (project === null) errors.push("project must be a valid slug");
59
- const target = value.target === "linux-x86_64" ? value.target : null;
60
- if (target === null) errors.push("target must be linux-x86_64");
71
+ const target = value.target === DEPLOY_TARGET ? value.target : null;
72
+ if (target === null) {
73
+ // A `--target host` artifact lands here: runnable where it was built, not
74
+ // on a box. Name that, so the failure reads as "wrong build" not "corrupt".
75
+ errors.push(isString(value.target) && value.target !== DEPLOY_TARGET
76
+ ? `target must be ${DEPLOY_TARGET}, got ${value.target} — \`--target host\` builds are for local dev and cannot be deployed`
77
+ : `target must be ${DEPLOY_TARGET}`);
78
+ }
61
79
  const runtime = value.runtime === RUNTIME ? value.runtime : null;
62
80
  if (runtime === null) errors.push("runtime must be native-fetch");
63
81
  const capabilityProfile = value.capabilityProfile === CAPABILITY_PROFILE ? value.capabilityProfile : null;