sproutboat 0.53.1 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/main.ts CHANGED
@@ -2,19 +2,130 @@
2
2
  import { mkdir, open, readFile, rename, writeFile } from "node:fs/promises";
3
3
  import { existsSync, rmSync } from "node:fs";
4
4
  import { basename, dirname, relative, resolve } from "node:path";
5
+ import { tmpdir } from "node:os";
5
6
  import { isBoolean, isSafeInteger, isString, jsonObject, parseJsonValue, type JsonObject } from "./json";
6
7
  import type { AssetFiles } from "./assets";
7
- import { parseConfig, pinBindingId, resourceRefs, type SproutboatConfig } from "./config";
8
+ import { pinBindingId, resourceRefs, type SproutboatConfig } from "./config";
8
9
  import { validateHttpSyncSource } from "./source";
9
10
  import { buildArtifact, emitGenerated } from "./build";
10
11
  import { generateTypes, TYPES_FILE } from "./types-gen";
11
12
  import { bundleHandler, BundleError, type BundleResult } from "./bundle";
12
13
  import { runDev } from "./dev";
13
- import { buildStandalone, emitStandalone } from "./standalone-build";
14
+ import { buildStandalone, emitStandalone, isReleaseBuild } from "./standalone-build";
14
15
  import { hostTarget, validateManifest, type ArtifactManifest } from "./manifest";
15
16
  import { CLI_VERSION, printDeployReport } from "./report";
16
- import { activeApiUrl, forgetToken, savedToken, saveToken } from "./credentials";
17
- import { CLI_NAME, COMMANDS, helpText, STORAGE_PRODUCTS, STORAGE_VERBS, type StorageProduct } from "./surface";
17
+ import {
18
+ activeProfileName,
19
+ forgetToken,
20
+ listProfiles,
21
+ PROFILE_NAME,
22
+ savedProfile,
23
+ savedProfileName,
24
+ savedToken,
25
+ saveToken,
26
+ selectProfile,
27
+ useProfile,
28
+ } from "./credentials";
29
+ import { extractGlobalFlags, globalFlags, locateProject, setGlobalFlags } from "./global-flags";
30
+ import { checkOptions, ENDPOINT_COMMANDS } from "./options";
31
+ import { chooseTarget, TargetError, type Target } from "./target";
32
+ import { configAccount, configMigrationsDir, parseProjectConfig } from "./account-field";
33
+ import { DATA_ERROR_STATUSES } from "./data-errors";
34
+ import {
35
+ cloudLogout,
36
+ CloudError,
37
+ DEFAULT_API_URL,
38
+ detectKind,
39
+ parseBody,
40
+ envToken,
41
+ fetchWhoami,
42
+ loginAndStore,
43
+ resolveSession,
44
+ storeGivenKey,
45
+ type CloudIo,
46
+ } from "./cloud";
47
+ import {
48
+ buildAndDeploy,
49
+ buildAssetBundle,
50
+ checkServiceBindings,
51
+ CLOUD_PROJECT_RE,
52
+ compileModule,
53
+ deployToCloud,
54
+ formatKiB,
55
+ hostFor,
56
+ Interrupted,
57
+ makeCall,
58
+ tailBuild,
59
+ waitForApply,
60
+ } from "./cloud-deploy";
61
+ import {
62
+ APPLY_USAGE,
63
+ applyMigrations,
64
+ CREATE_USAGE,
65
+ createMigration,
66
+ D1UsageError,
67
+ DEFAULT_MIGRATIONS_DIR,
68
+ EXECUTE_USAGE,
69
+ executeSql,
70
+ LIST_USAGE,
71
+ listMigrations,
72
+ localDatabasePath,
73
+ localRunner,
74
+ parseD1Flags,
75
+ pickBinding,
76
+ remoteRunner,
77
+ type D1Io,
78
+ type Runner,
79
+ } from "./d1-cli";
80
+ import {
81
+ deleteCloudSecret,
82
+ listCloudSecrets,
83
+ patchCloudSecrets,
84
+ putCloudSecret,
85
+ readSecretsFile,
86
+ } from "./cloud-secrets";
87
+ import { parseBulkSecrets, type BulkPlan } from "./secrets-bulk";
88
+ import {
89
+ cloudTarget,
90
+ communityTarget,
91
+ DELETE_USAGE as R2_DELETE_USAGE,
92
+ deleteObject,
93
+ GET_USAGE as R2_GET_USAGE,
94
+ getObject,
95
+ OBJECT_MAX_BYTES,
96
+ readCapped,
97
+ parseObjectArgs,
98
+ parseObjectPath,
99
+ PUT_USAGE as R2_PUT_USAGE,
100
+ putObject,
101
+ R2UsageError,
102
+ type ObjectTarget,
103
+ } from "./r2-objects";
104
+ import {
105
+ COMMUNITY_TAIL,
106
+ DeploymentsUsageError,
107
+ fetchDeployments,
108
+ fetchLive,
109
+ formatCommunityVersion,
110
+ formatDeployment,
111
+ formatStatus,
112
+ LIST_USAGE as DEPLOYMENTS_LIST_USAGE,
113
+ parseCommunityVersions,
114
+ parseDeploymentsArgs,
115
+ STATUS_USAGE as DEPLOYMENTS_STATUS_USAGE,
116
+ TAIL_USAGE as DEPLOYMENTS_TAIL_USAGE,
117
+ tailSummary,
118
+ } from "./deployments-cli";
119
+ import { hostname as osHostname, platform as osPlatform, arch as osArch } from "node:os";
120
+ import {
121
+ CLI_NAME,
122
+ COMMANDS,
123
+ helpText,
124
+ resolveCommand,
125
+ STORAGE_PRODUCTS,
126
+ STORAGE_VERBS,
127
+ type StorageProduct,
128
+ } from "./surface";
18
129
  import { notifyIfOutdated } from "./update-check";
19
130
  import { controlVersionWarning } from "./api-version";
20
131
  import { amber, bold, dim, leaf, ok, rose } from "./style";
@@ -41,26 +152,56 @@ if (internalModeAt > 0) {
41
152
  process.exit(0);
42
153
  }
43
154
 
44
- const defaultApiUrl = "https://dashboard.sproutboat.com";
45
-
46
- type ApiUrlSource = "SPROUTBOAT_API_URL" | "--api-url" | "saved active endpoint" | "default";
155
+ const defaultApiUrl = DEFAULT_API_URL;
47
156
 
48
157
  /**
49
- * baronunread/sproutboat#204 — the single place `defaultApiUrl` and
50
- * `activeApiUrl()` are read. Used to be implemented three different ways
51
- * across `apiCredentials()`, `whoami()` and `parseLoginArgs()`, none of which
52
- * told the user which endpoint it picked; `whoami` could report "not logged
53
- * in" on a machine where `deploy` would happily target prod. Precedence: an
54
- * explicit `--api-url` flag, then `SPROUTBOAT_API_URL`, then the saved active
55
- * endpoint, then the default.
158
+ * baronunread/sproutboat#204 — the single place the endpoint and the stored profile are chosen. `chooseTarget`
159
+ * (target.ts) holds the precedence: the flags, then the environment, then the machine's active profile, then the
160
+ * default endpoint, and a profile and a URL that name different places are an error. The choice is also what the
161
+ * credential lookups use for the rest of the run.
56
162
  */
57
- async function resolveApiUrl(explicitApiUrl?: string): Promise<{ apiUrl: string; source: ApiUrlSource }> {
58
- if (explicitApiUrl) return { apiUrl: explicitApiUrl.replace(/\/$/, ""), source: "--api-url" };
59
- const envUrl = process.env.SPROUTBOAT_API_URL;
60
- if (envUrl) return { apiUrl: envUrl.replace(/\/$/, ""), source: "SPROUTBOAT_API_URL" };
61
- const saved = await activeApiUrl();
62
- if (saved) return { apiUrl: saved.replace(/\/$/, ""), source: "saved active endpoint" };
63
- return { apiUrl: defaultApiUrl, source: "default" };
163
+ async function resolveApiUrl(
164
+ explicitApiUrl?: string,
165
+ options: { allowNewProfile?: boolean } = {},
166
+ ): Promise<{ apiUrl: string; source: string; target: Target }> {
167
+ const profiles: Record<string, { apiUrl: string }> = {};
168
+ for (const { name, profile } of await listProfiles()) profiles[name] = { apiUrl: profile.apiUrl };
169
+ let target: Target;
170
+ try {
171
+ target = chooseTarget({
172
+ flagUrl: explicitApiUrl ?? globalFlags().apiUrl,
173
+ flagProfile: globalFlags().profile,
174
+ envUrl: process.env.SPROUTBOAT_API_URL || undefined,
175
+ envProfile: process.env.SPROUTBOAT_PROFILE || undefined,
176
+ profiles,
177
+ activeProfile: await activeProfileName(),
178
+ defaultUrl: defaultApiUrl,
179
+ allowNew: options.allowNewProfile,
180
+ });
181
+ } catch (error) {
182
+ if (error instanceof TargetError) fail(error.message);
183
+ throw error;
184
+ }
185
+ selectProfile(target.profile?.name);
186
+ return { apiUrl: target.apiUrl, source: target.source, target };
187
+ }
188
+
189
+ /** The two lines that say where a command is about to act: which profile, and which endpoint. */
190
+ async function printTarget(target: Target): Promise<void> {
191
+ const profile = target.profile;
192
+ const name = profile?.name ?? (await savedProfileName(target.apiUrl));
193
+ const why = profile
194
+ ? `${profile.source}${profile.exists ? "" : ", new"}`
195
+ : name
196
+ ? "the stored login for this endpoint"
197
+ : "nothing stored for this endpoint";
198
+ const credential = envToken();
199
+ console.log(
200
+ `profile ${name ?? "-"} ${dim(`(${why}${credential ? `; the credential comes from ${credential.source}` : ""})`)}`,
201
+ );
202
+ const sourceLabel =
203
+ target.source === "profile" ? `profile ${profile?.name}` : target.source === "default" ? "default" : target.source;
204
+ console.log(`endpoint ${target.apiUrl} ${dim(`(${sourceLabel})`)}`);
64
205
  }
65
206
 
66
207
  /** Warn at most once per run: every API response carries the headers, and one
@@ -207,25 +348,43 @@ function usageError(what: string, usage: string): never {
207
348
  process.exit(2);
208
349
  }
209
350
 
351
+ /** The project directory and config file, from a positional directory and `--config`. */
352
+ function locate(directory?: string) {
353
+ try {
354
+ return locateProject(directory);
355
+ } catch (error) {
356
+ if (error instanceof Error) return fail(error.message);
357
+ throw error;
358
+ }
359
+ }
360
+
210
361
  /** Read the project identity and configuration without touching application code.
211
362
  * Management commands must remain usable when a local checkout is incomplete. */
212
- async function readProjectConfig(directory = process.cwd()) {
213
- const projectDirectory = resolve(directory);
214
- const configPath = resolve(projectDirectory, "sproutboat.jsonc");
363
+ async function readProjectConfig(directory?: string) {
364
+ const { directory: projectDirectory, configPath } = locate(directory);
215
365
  let configSource: string;
216
366
  try {
217
367
  configSource = await readFile(configPath, "utf8");
218
368
  } catch {
219
- fail(`no sproutboat.jsonc found in ${projectDirectory}`);
369
+ fail(
370
+ globalFlags().config === undefined
371
+ ? `no sproutboat.jsonc found in ${projectDirectory}`
372
+ : `no config file at ${configPath}`,
373
+ );
220
374
  }
221
- const parsed = parseConfig(configSource);
375
+ const parsed = parseProjectConfig(configSource);
222
376
  if (!parsed.ok) fail(parsed.errors.join("\n"));
223
377
  for (const warning of parsed.warnings) console.warn(amber(`! ${warning}`));
224
- return { directory: projectDirectory, config: parsed.value };
378
+ return {
379
+ directory: projectDirectory,
380
+ config: parsed.value,
381
+ account: configAccount(configSource),
382
+ migrationsDir: configMigrationsDir(configSource),
383
+ };
225
384
  }
226
385
 
227
386
  /** Full build input: configuration plus entry point, resolved imports and checks. */
228
- async function readProject(directory = process.cwd()) {
387
+ async function readProject(directory?: string) {
229
388
  const project = await readProjectConfig(directory);
230
389
  const sourcePath = resolve(project.directory, project.config.main);
231
390
  let source: string;
@@ -378,9 +537,10 @@ async function buildStandaloneBinary(
378
537
  target: BuildTarget,
379
538
  withRestore = false,
380
539
  deployFiles = false,
540
+ release = false,
381
541
  ) {
382
542
  const project = await readProject(directory);
383
- console.log(dim(`Building a standalone ${project.config.name} (${target})…`));
543
+ console.log(dim(`Building a standalone ${project.config.name} (${target}, ${release ? "release, LTO" : "no LTO"})…`));
384
544
  try {
385
545
  const result = await buildStandalone({
386
546
  projectDir: project.directory,
@@ -389,9 +549,14 @@ async function buildStandaloneBinary(
389
549
  source: project.bundle.code,
390
550
  target,
391
551
  withRestore,
552
+ release,
392
553
  });
393
554
  console.log(ok(`built ${result.outPath} (${(result.bytes / 1_000_000).toFixed(1)} MB)`));
394
555
  console.log(dim(` run it: ${result.outPath} · state lands in ./${project.config.name}.data`));
556
+ if (!release)
557
+ console.log(
558
+ dim(" built without LTO (faster to build, slower to serve); add --release for the binary you publish"),
559
+ );
395
560
  if (target === "linux-arm64") console.log(dim(" linux-arm64: runs on an aarch64 Linux box, not on this machine"));
396
561
  if (deployFiles && isLinuxTarget(target)) {
397
562
  const written = await writeDeployFiles({ name: project.config.name, target, binary: result.outPath });
@@ -430,24 +595,43 @@ async function emitOnly(directory: string | undefined, target: BuildTarget, stan
430
595
  }
431
596
  }
432
597
 
598
+ /** The standalone source unit the Cloud compile starts from (what `build --standalone --emit-only` prints). */
599
+ async function emitStandaloneUnit(directory: string | undefined): Promise<string> {
600
+ const project = await readProject(directory);
601
+ try {
602
+ const { generatedPath } = await emitStandalone(
603
+ {
604
+ projectDir: project.directory,
605
+ config: project.config,
606
+ sourcePath: project.sourcePath,
607
+ source: project.bundle.code,
608
+ target: "linux-x86_64",
609
+ },
610
+ { forCloud: true },
611
+ );
612
+ return generatedPath;
613
+ } catch (error) {
614
+ return fail(error instanceof Error ? error.message : String(error));
615
+ }
616
+ }
617
+
433
618
  /** #62 — build for this machine, run it against a real broker, rebuild on save. */
434
- const DEV_USAGE = "dev [project-dir] [--port <n>] [--no-watch] [--env <name>] [--env-file <file>]...";
619
+ const DEV_USAGE = "dev [project-dir] [--port <n>] [--no-watch] [-e, --env <name>] [--env-file <file>]...";
435
620
 
436
621
  async function dev(args: string[]) {
437
622
  let directory: string | undefined;
438
623
  let portArg: string | undefined;
439
- let envName: string | undefined;
440
- const envFiles: string[] = [];
624
+ // --env and --env-file are global flags, already taken out of `args` (global-flags.ts).
625
+ const envName = globalFlags().env;
626
+ const envFiles = globalFlags().envFiles.map((file) => resolve(file));
441
627
  for (let at = 0; at < args.length; at += 1) {
442
628
  const arg = args[at];
443
629
  if (arg === "--no-watch") continue;
444
- if (arg === "--port" || arg === "--env" || arg === "--env-file") {
630
+ if (arg === "--port") {
445
631
  const value = args[at + 1];
446
632
  if (value === undefined || value.startsWith("--")) usageError(`${arg} needs a value`, DEV_USAGE);
447
633
  at += 1;
448
- if (arg === "--port") portArg = value;
449
- else if (arg === "--env") envName = value;
450
- else envFiles.push(resolve(value));
634
+ portArg = value;
451
635
  } else if (!arg.startsWith("--") && directory === undefined) directory = arg;
452
636
  }
453
637
  const port = Number(portArg ?? 8787);
@@ -481,9 +665,11 @@ async function dev(args: string[]) {
481
665
  async function run(args: string[]) {
482
666
  const result = parseRunArgs(args);
483
667
  if ("error" in result) usageError(result.error, RUN_USAGE);
668
+ // --env-file is a global flag, already taken out of `args` (global-flags.ts).
669
+ result.args.envFiles.unshift(...globalFlags().envFiles);
484
670
  let plan;
485
671
  try {
486
- plan = await planRun(result.args, { cwd: process.cwd(), inherited: process.env });
672
+ plan = await planRun(result.args, { cwd: process.cwd(), inherited: process.env, config: globalFlags().config });
487
673
  } catch (error) {
488
674
  if (error instanceof RunError || error instanceof EnvFileError || error instanceof MissingSecretsError)
489
675
  fail(error.message);
@@ -517,10 +703,10 @@ const PROVISION_FIELDS = [
517
703
  * deploy, and the id is written back into `sproutboat.jsonc`. `--no-provision`
518
704
  * skips this — those bindings then get an ephemeral deploy-scoped store.
519
705
  */
520
- async function provisionBindings(directory = process.cwd()): Promise<void> {
521
- const configPath = resolve(directory, "sproutboat.jsonc");
706
+ async function provisionBindings(directory?: string): Promise<void> {
707
+ const { configPath } = locate(directory);
522
708
  let source = await readFile(configPath, "utf8");
523
- const parsed = parseConfig(source);
709
+ const parsed = parseProjectConfig(source);
524
710
  if (!parsed.ok) return; // build() re-reads and reports the config error
525
711
 
526
712
  const bare: Array<{ field: string; kind: string; binding: string }> = [];
@@ -552,7 +738,25 @@ async function provisionBindings(directory = process.cwd()): Promise<void> {
552
738
 
553
739
  /** #79 — wrangler parity: drop the stored credential for an endpoint. */
554
740
  async function logout(args: string[]) {
555
- const { apiUrl } = await parseLoginArgs(args);
741
+ const { apiUrl, account, target } = await parseLoginArgs(args);
742
+ await printTarget(target);
743
+ if ((await savedProfile(apiUrl))?.kind === "cloud") {
744
+ const outcome = await guard(() => cloudLogout(apiUrl, account));
745
+ if (outcome.kind === "none") {
746
+ console.log(`no stored login for ${account ? `the team "${account}" on ` : ""}${apiUrl}`);
747
+ return;
748
+ }
749
+ const how =
750
+ outcome.server === "revoked"
751
+ ? "revoked its key on the server"
752
+ : outcome.server === "already-gone"
753
+ ? "its key was already revoked on the server"
754
+ : "could not reach the server, so the key may still be live: delete it under Settings, API keys";
755
+ console.log(ok(`logged out of ${outcome.slug} on ${apiUrl} (${how})`));
756
+ if (outcome.remaining.length > 0) console.log(dim(`still logged in to: ${outcome.remaining.join(", ")}`));
757
+ if (envToken()) console.log(dim("SPROUTBOAT_API_TOKEN is set in this shell and still applies"));
758
+ return;
759
+ }
556
760
  const result = await forgetToken(apiUrl);
557
761
  if (!result.removed) {
558
762
  console.log(`no stored credential for ${apiUrl}`);
@@ -563,17 +767,75 @@ async function logout(args: string[]) {
563
767
  console.log(dim("no active endpoint remains — the next command falls through to the default"));
564
768
  }
565
769
 
770
+ /** `profile list` and `profile use <name>`: the stored profiles, and which one a command uses by default. */
771
+ async function profile(args: string[]) {
772
+ const sub = args[0] ?? "list";
773
+ if (sub === "use") {
774
+ const name = args[1];
775
+ if (!name || args.length > 2) usageError("profile use needs a profile name", "profile <list | use <name>>");
776
+ if (!(await useProfile(name))) {
777
+ const known = (await listProfiles()).map((entry) => entry.name);
778
+ fail(
779
+ `no profile named "${name}"${known.length > 0 ? ` (profiles: ${known.join(", ")})` : ""}; create it with \`sproutboat login --profile ${name}\``,
780
+ );
781
+ }
782
+ console.log(ok(`${name} is now the default profile`));
783
+ return;
784
+ }
785
+ if (sub !== "list" || args.length > 1)
786
+ usageError(`profile: unknown subcommand "${sub}"`, "profile <list | use <name>>");
787
+ const entries = await listProfiles();
788
+ if (entries.length === 0) {
789
+ console.log("no profiles stored; run `sproutboat login`");
790
+ return;
791
+ }
792
+ const width = Math.max(...entries.map((entry) => entry.name.length));
793
+ for (const { name, active, profile: stored } of entries) {
794
+ const kind = stored.kind === "cloud" ? "Cloud" : stored.kind === "community" ? "Community" : "";
795
+ const team = stored.kind === "cloud" && stored.team ? ` team ${stored.team}` : "";
796
+ console.log(`${active ? "*" : " "} ${name.padEnd(width)} ${stored.apiUrl} ${dim(`${kind}${team}`.trim())}`);
797
+ }
798
+ console.log(dim("* is the default profile; pick another for one command with --profile <name>"));
799
+ }
800
+
566
801
  /**
567
802
  * #79 — wrangler parity: which endpoint, and who the stored token belongs to.
568
803
  * The account comes from the control plane, so this also proves the token still
569
804
  * works rather than only reporting what is on disk.
570
805
  */
571
- async function whoami() {
572
- const { apiUrl, source } = await resolveApiUrl();
573
- const token = process.env.SPROUTBOAT_TOKEN || (await savedToken(apiUrl));
574
- console.log(`endpoint ${apiUrl} (${source})`);
806
+ async function whoami(args: string[]) {
807
+ const { apiUrl, target, account: wanted } = await parseLoginArgs(args);
808
+ const fromEnv = envToken();
809
+ const saved = await savedProfile(apiUrl);
810
+ await printTarget(target);
811
+ const kind = saved?.kind ?? (fromEnv ? await kindOf(apiUrl) : undefined);
812
+ if (kind === "cloud") {
813
+ const team = wanted ?? saved?.team ?? Object.keys(saved?.teams ?? {})[0];
814
+ const token = fromEnv?.token ?? (team ? saved?.teams[team]?.token : undefined) ?? saved?.token;
815
+ if (!token) {
816
+ const hint = wanted
817
+ ? `not logged in to the team "${wanted}"; run \`sproutboat login --account ${wanted}\``
818
+ : "not logged in; run `sproutboat login`";
819
+ console.log(`account ${rose(hint)}`);
820
+ process.exitCode = 1;
821
+ return;
822
+ }
823
+ const who = await guard(() => fetchWhoami(apiUrl, token, fromEnv !== undefined));
824
+ console.log(`kind Sproutboat Cloud`);
825
+ console.log(`team ${who.slug}${who.name !== who.slug ? ` (${who.name})` : ""}`);
826
+ console.log(
827
+ `key ${who.keyName || who.keyId} ${dim(fromEnv ? `token from ${fromEnv.source}` : "stored login")}`,
828
+ );
829
+ if (who.permissions) console.log(`access ${who.permissions}`);
830
+ if (who.person) console.log(`email ${who.person.email}`);
831
+ const stored = Object.keys(saved?.teams ?? {});
832
+ if (!fromEnv && stored.length > 1) console.log(dim(`logged in to ${stored.length} teams: ${stored.join(", ")}`));
833
+ return;
834
+ }
835
+ const token = fromEnv?.token || (await savedToken(apiUrl));
575
836
  if (!token) {
576
837
  console.log(`account ${dim("no stored token — run `sproutboat login`")}`);
838
+ process.exitCode = 1;
577
839
  return;
578
840
  }
579
841
 
@@ -582,6 +844,7 @@ async function whoami() {
582
844
  console.log(
583
845
  `account ${rose(response.status === 401 ? "token rejected — run `sproutboat login`" : `control plane said ${response.status}`)}`,
584
846
  );
847
+ process.exitCode = 1;
585
848
  return;
586
849
  }
587
850
  const account = jsonObject(parseJsonValue(await response.text()));
@@ -592,8 +855,20 @@ async function whoami() {
592
855
  }
593
856
 
594
857
  async function deploy(args: string[]) {
595
- const artifactIndex = args.indexOf("--artifact");
858
+ const flags = takeFlags(args, DEPLOY_FLAGS);
596
859
  const dryRun = args.includes("--dry-run");
860
+ const { apiUrl: targetUrl, target } = await resolveApiUrl(flags["--api-url"]);
861
+ await printTarget(target);
862
+ const kind = await deployKind(targetUrl, dryRun);
863
+ if (kind === "cloud") {
864
+ if (args.includes("--artifact"))
865
+ fail("--artifact deploys a prebuilt Community artifact; the Cloud builds your source: run deploy without it");
866
+ await guard(() => deployCloud(args, targetUrl, flags));
867
+ return;
868
+ }
869
+ if (flags["--secrets-file"] !== undefined)
870
+ fail("--secrets-file is for the Cloud; on this endpoint set secrets with `sproutboat secrets put <NAME>`");
871
+ const artifactIndex = args.indexOf("--artifact");
597
872
  const directory = args.find((arg, index) => !arg.startsWith("--") && index !== artifactIndex + 1);
598
873
  let projectName: string;
599
874
  let artifactDir: string;
@@ -635,8 +910,7 @@ async function deploy(args: string[]) {
635
910
  console.log("\n--dry-run: not uploading.");
636
911
  return;
637
912
  }
638
- const { apiUrl, token, source } = await apiCredentials();
639
- console.log(`endpoint ${apiUrl} (${source})`);
913
+ const { apiUrl, token } = await apiCredentials(flags["--api-url"]);
640
914
  const form = new FormData();
641
915
  form.set("manifest", new File([await manifest.arrayBuffer()], "manifest.json", { type: "application/json" }));
642
916
  form.set("sprout", new File([await sprout.arrayBuffer()], "sprout", { type: "application/octet-stream" }));
@@ -726,34 +1000,50 @@ async function waitForHealthy(url: string, timeoutMs: number): Promise<boolean>
726
1000
  return false;
727
1001
  }
728
1002
 
729
- async function parseLoginArgs(args: string[]) {
730
- let explicitApiUrl: string | undefined;
1003
+ async function parseLoginArgs(args: string[], options: { allowNewProfile?: boolean } = {}) {
731
1004
  let token: string | undefined;
1005
+ let account: string | undefined;
1006
+ // --api-url is a global flag (global-flags.ts); resolveApiUrl reads it.
732
1007
  for (let index = 0; index < args.length; index += 2) {
733
1008
  const value = args[index + 1];
734
- if (args[index] === "--api-url" && value) explicitApiUrl = value;
735
- else if (args[index] === "--token" && value) token = value;
736
- else usageError(`login: unexpected argument "${args[index]}"`, "login [--api-url <url>] [--token <token>]");
1009
+ if (args[index] === "--token" && value) token = value;
1010
+ else if (args[index] === "--account" && value) account = value;
1011
+ else
1012
+ usageError(
1013
+ `unexpected argument "${args[index]}"`,
1014
+ "login [--api-url <url>] [--token <token>] [--account <team>]",
1015
+ );
737
1016
  }
738
- const { apiUrl, source } = await resolveApiUrl(explicitApiUrl);
739
- return { apiUrl, source, token };
1017
+ const { apiUrl, source, target } = await resolveApiUrl(undefined, options);
1018
+ return { apiUrl, source, target, token, account };
740
1019
  }
741
1020
 
742
- /** Announce a login that left the machine's active endpoint pointed elsewhere. */
743
- function reportActiveEndpoint(apiUrl: string, active: string): void {
744
- if (active !== apiUrl)
745
- console.log(dim(`active endpoint remains ${active} — \`sproutboat logout\` it first to switch`));
1021
+ /** Say which profile a login went to, and when it left the machine's default profile pointed elsewhere. */
1022
+ function reportSaved(saved: { profile: string; activeProfile: string; activeApiUrl: string }): void {
1023
+ console.log(dim(` stored as the profile "${saved.profile}"`));
1024
+ if (saved.activeProfile !== saved.profile)
1025
+ console.log(
1026
+ dim(
1027
+ ` the default profile stays "${saved.activeProfile}" (${saved.activeApiUrl}); use --profile ${saved.profile}, or \`sproutboat profile use ${saved.profile}\``,
1028
+ ),
1029
+ );
746
1030
  }
747
1031
 
748
1032
  async function login(args: string[]) {
749
- const { apiUrl, source, token: directToken } = await parseLoginArgs(args);
750
- console.log(`endpoint ${apiUrl} (${source})`);
1033
+ const { apiUrl, target, token: directToken, account } = await parseLoginArgs(args, { allowNewProfile: true });
1034
+ await printTarget(target);
1035
+ const kind = await kindOf(apiUrl);
1036
+ if (kind === undefined && !directToken) fail(`cannot reach ${apiUrl}; check the address and your network`);
1037
+ if (kind === "cloud") {
1038
+ await guard(() => cloudLogin(apiUrl, directToken, account));
1039
+ return;
1040
+ }
751
1041
  // Self-hosted / non-interactive: skip the browser flow and store the token
752
1042
  // the admin already holds (e.g. SPROUTBOAT_BOOTSTRAP_TOKEN).
753
1043
  if (directToken) {
754
- const { activeApiUrl: active } = await saveToken(apiUrl, directToken);
1044
+ const saved = await saveToken(apiUrl, directToken, kind);
755
1045
  console.log(`Saved credentials for ${apiUrl}.`);
756
- reportActiveEndpoint(apiUrl, active);
1046
+ reportSaved(saved);
757
1047
  return;
758
1048
  }
759
1049
  const response = await fetch(`${apiUrl}/api/cli/authorizations`, { method: "POST" });
@@ -786,21 +1076,427 @@ async function login(args: string[]) {
786
1076
  if (!exchange.ok) fail(`login failed (${exchange.status}): ${result}`);
787
1077
  const token = parseToken(result);
788
1078
  if (!token) fail("login response did not include a CLI token");
789
- const { activeApiUrl: active } = await saveToken(apiUrl, token);
1079
+ const saved = await saveToken(apiUrl, token, "community");
790
1080
  console.log(ok("login approved — credentials saved for this endpoint"));
791
- reportActiveEndpoint(apiUrl, active);
1081
+ reportSaved(saved);
792
1082
  return;
793
1083
  }
794
1084
  fail("login expired before approval");
795
1085
  }
796
1086
 
797
- async function apiCredentials() {
798
- const { apiUrl, source } = await resolveApiUrl();
799
- const token = process.env.SPROUTBOAT_TOKEN || (await savedToken(apiUrl));
800
- if (!token) fail("not logged in; run sproutboat login or set SPROUTBOAT_TOKEN for this command");
1087
+ const DEPLOY_FLAGS = ["--api-url", "--account", "--module", "--sproutd", "--generation", "--secrets-file"] as const;
1088
+ type DeployFlag = (typeof DEPLOY_FLAGS)[number];
1089
+
1090
+ function guardSync(step: () => void): void {
1091
+ try {
1092
+ step();
1093
+ } catch (error) {
1094
+ if (error instanceof CloudError) return fail(error.message);
1095
+ throw error;
1096
+ }
1097
+ }
1098
+
1099
+ async function readSecretsFileOrFail(path: string) {
1100
+ try {
1101
+ return await readSecretsFile(path);
1102
+ } catch (error) {
1103
+ if (error instanceof CloudError || error instanceof EnvFileError) return fail(error.message);
1104
+ throw error;
1105
+ }
1106
+ }
1107
+
1108
+ /** Run a Cloud step; its CloudError becomes a printed failure. */
1109
+ async function guard<T>(step: () => Promise<T>): Promise<T> {
1110
+ try {
1111
+ return await step();
1112
+ } catch (error) {
1113
+ if (error instanceof CloudError) return fail(error.message);
1114
+ throw error;
1115
+ }
1116
+ }
1117
+
1118
+ /** Remove `--name value` pairs from `args` (in place) and return their values. */
1119
+ function takeFlags<Name extends string>(args: string[], names: readonly Name[]) {
1120
+ const values: { [flag in Name]?: string } = {};
1121
+ for (const name of names) {
1122
+ const at = args.indexOf(name);
1123
+ if (at < 0) continue;
1124
+ const value = args[at + 1];
1125
+ if (value === undefined || value.startsWith("--"))
1126
+ usageError(`${name} needs a value`, "deploy [project-dir] [${name} <value>]");
1127
+ values[name] = value;
1128
+ args.splice(at, 2);
1129
+ }
1130
+ return values;
1131
+ }
1132
+
1133
+ /** Cloud or Community, or undefined when the endpoint cannot be reached. */
1134
+ async function kindOf(apiUrl: string) {
1135
+ try {
1136
+ return await detectKind(apiUrl);
1137
+ } catch {
1138
+ return undefined;
1139
+ }
1140
+ }
1141
+
1142
+ async function deployKind(apiUrl: string, dryRun: boolean) {
1143
+ const stored = envToken() ? undefined : (await savedProfile(apiUrl))?.kind;
1144
+ if (stored) return stored;
1145
+ const kind = await kindOf(apiUrl);
1146
+ if (kind === undefined && !dryRun) fail(`cannot reach ${apiUrl}; check the address and your network`);
1147
+ return kind ?? "community";
1148
+ }
1149
+
1150
+ function openInBrowser(url: string): boolean {
1151
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
1152
+ const headless =
1153
+ process.platform === "linux" &&
1154
+ (!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY) || process.env.SSH_CONNECTION);
1155
+ if (headless || !Bun.which(opener)) return false;
1156
+ try {
1157
+ Bun.spawn(opener === "cmd" ? ["cmd", "/c", "start", "", url] : [opener, url], {
1158
+ stdout: "ignore",
1159
+ stderr: "ignore",
1160
+ });
1161
+ return true;
1162
+ } catch {
1163
+ return false;
1164
+ }
1165
+ }
1166
+
1167
+ function terminalIo(): CloudIo {
1168
+ return {
1169
+ log: (line) => console.log(line),
1170
+ warn: (line) => console.warn(amber(line)),
1171
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
1172
+ openUrl: openInBrowser,
1173
+ sleep: (ms) => Bun.sleep(ms),
1174
+ choose: async (question, choices) => {
1175
+ const { createInterface } = await import("node:readline/promises");
1176
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1177
+ try {
1178
+ console.log(question);
1179
+ choices.forEach((choice, at) => console.log(` ${at + 1}) ${choice}`));
1180
+ for (;;) {
1181
+ const answer = (await rl.question("> ")).trim();
1182
+ const picked = choices[Number(answer) - 1] ?? choices.find((choice) => choice === answer);
1183
+ if (picked) return picked;
1184
+ }
1185
+ } finally {
1186
+ rl.close();
1187
+ }
1188
+ },
1189
+ };
1190
+ }
1191
+
1192
+ async function cloudLogin(apiUrl: string, directToken: string | undefined, account: string | undefined) {
1193
+ if (directToken) {
1194
+ const stored = await storeGivenKey(apiUrl, directToken);
1195
+ console.log(ok(`saved the key for ${stored.slug} on ${apiUrl}`));
1196
+ return;
1197
+ }
1198
+ const cancel = () => {
1199
+ console.log("\nlogin cancelled");
1200
+ process.exit(130);
1201
+ };
1202
+ process.once("SIGINT", cancel);
1203
+ const done = await loginAndStore({
1204
+ apiUrl,
1205
+ io: terminalIo(),
1206
+ hostname: osHostname(),
1207
+ os: `${osPlatform()} ${osArch()}`,
1208
+ account,
1209
+ });
1210
+ process.off("SIGINT", cancel);
1211
+ console.log(
1212
+ ok(
1213
+ `logged in to ${bold(done.slug)}${done.team.role ? ` as ${done.team.role}` : ""} with the key "${done.keyName}"`,
1214
+ ),
1215
+ );
1216
+ console.log(dim(" revoke it any time under Settings, API keys, or with `sproutboat logout`"));
1217
+ reportSaved(done);
1218
+ }
1219
+
1220
+ async function deployCloud(args: string[], apiUrl: string, flags: ReturnType<typeof takeFlags<DeployFlag>>) {
1221
+ const dryRun = args.includes("--dry-run");
1222
+ const io = terminalIo();
1223
+ const directory = args.find((arg) => !arg.startsWith("--"));
1224
+ const project = await readProjectConfig(directory);
1225
+ const name = project.config.name;
1226
+ if (!CLOUD_PROJECT_RE.test(name))
1227
+ fail(`"${name}" is not a valid Cloud app name (3 to 32 characters: lowercase letters, digits, dashes)`);
1228
+
1229
+ const account = flags["--account"] ?? project.account;
1230
+ let session: Awaited<ReturnType<typeof resolveSession>> | undefined;
1231
+ try {
1232
+ session = await resolveSession({
1233
+ apiUrl,
1234
+ io,
1235
+ account,
1236
+ login: async (hint) => {
1237
+ if (dryRun) throw new CloudError("not logged in (--dry-run does not start a login)");
1238
+ process.once("SIGINT", () => process.exit(130));
1239
+ return loginAndStore({ apiUrl, io, hostname: osHostname(), os: `${osPlatform()} ${osArch()}`, account: hint });
1240
+ },
1241
+ });
1242
+ } catch (error) {
1243
+ if (!dryRun || !(error instanceof CloudError)) throw error;
1244
+ io.warn(`! ${error.message}; the plan below uses a placeholder team`);
1245
+ }
1246
+ const team = session?.slug ?? account ?? "<team>";
1247
+ const hosts = [hostFor(name, team)];
1248
+
1249
+ const cfg = project.config;
1250
+ const unsent = Object.entries({
1251
+ kv_namespaces: cfg.kv_namespaces?.length,
1252
+ d1_databases: cfg.d1_databases?.length,
1253
+ r2_buckets: cfg.r2_buckets?.length,
1254
+ queues: cfg.queues?.length,
1255
+ analytics_engine_datasets: cfg.analytics_engine_datasets?.length,
1256
+ durable_objects: Object.keys(cfg.durable_objects ?? {}).length,
1257
+ ratelimiters: cfg.ratelimiters?.length,
1258
+ "triggers.crons": cfg.triggers?.crons?.length,
1259
+ })
1260
+ .filter(([, count]) => (count ?? 0) > 0)
1261
+ .map(([key]) => key);
1262
+ if (unsent.length > 0)
1263
+ io.warn(
1264
+ `! ${unsent.join(", ")} in sproutboat.jsonc are not sent to the Cloud by the CLI yet; the app runs without them`,
1265
+ );
1266
+
1267
+ const services = cfg.services ?? [];
1268
+ guardSync(() => checkServiceBindings(name, services, cfg.compatibility_date));
1269
+ let secretValues: Record<string, string> | undefined;
1270
+ const secretsFile = flags["--secrets-file"];
1271
+ if (secretsFile !== undefined) {
1272
+ const read = await readSecretsFileOrFail(resolve(secretsFile));
1273
+ for (const warning of read.warnings) io.warn(`! ${warning}`);
1274
+ secretValues = read.values;
1275
+ }
1276
+
1277
+ const assets = project.config.assets ? buildAssetBundle(project.directory, project.config.assets) : undefined;
1278
+ // Regular deploys build in the Cloud from the bundled source. --module (a prebuilt module) and --sproutd with
1279
+ // --generation (compile here) are the operator paths; the Cloud refuses a module from a team key.
1280
+ let modulePath = flags["--module"];
1281
+ const compiled =
1282
+ modulePath === undefined && Boolean(flags["--sproutd"] ?? flags["--generation"] ?? process.env.SPROUTBOAT_SPROUTD);
1283
+ const fromSource = modulePath === undefined && !compiled;
1284
+ if (compiled) {
1285
+ modulePath = resolve(tmpdir(), `sproutboat-${name}-${process.pid}.so`);
1286
+ console.log(dim("Compiling the module (sproutd)…"));
1287
+ await compileModule({
1288
+ project: name,
1289
+ emit: () => emitStandaloneUnit(directory),
1290
+ sproutd: flags["--sproutd"] ?? process.env.SPROUTBOAT_SPROUTD,
1291
+ generation: flags["--generation"] ?? process.env.SPROUTBOAT_GENERATION,
1292
+ out: modulePath,
1293
+ bun: process.versions.bun ? process.execPath : (Bun.which("bun") ?? "bun"),
1294
+ });
1295
+ } else if (modulePath !== undefined && !existsSync(modulePath)) fail(`no such module: ${modulePath}`);
1296
+
1297
+ console.log(`app ${bold(name)}`);
1298
+ console.log(`team ${team}${session ? "" : dim(" (not resolved)")}`);
1299
+ console.log(`endpoint ${apiUrl}`);
1300
+ console.log(`route https://${hosts[0]}`);
1301
+ if (assets) console.log(`assets ${assets.files} files`);
1302
+ if (services.length > 0)
1303
+ console.log(`services ${services.map((entry) => `${entry.binding} -> ${entry.service}`).join(", ")}`);
1304
+ if (secretValues) console.log(`secrets ${Object.keys(secretValues).length} from ${secretsFile} (set, others kept)`);
1305
+ if (fromSource) console.log(`build in the Cloud, from your bundled source`);
1306
+ if (dryRun) {
1307
+ console.log("\n--dry-run: not uploading.");
1308
+ if (fromSource) console.log(dim(" A real deploy bundles your source, uploads it and has the Cloud build it."));
1309
+ if (compiled) rmSync(modulePath!, { force: true });
1310
+ return;
1311
+ }
1312
+ const tokenFromEnv = session!.source !== "stored login";
1313
+ const noWait = args.includes("--no-wait");
1314
+ const printResult = (result: {
1315
+ created: boolean;
1316
+ generation: number | undefined;
1317
+ url: string | undefined;
1318
+ applied: boolean | "skipped";
1319
+ buildId?: string;
1320
+ }) => {
1321
+ if (result.generation === undefined) {
1322
+ console.log(`\n${leaf("🌱")} ${bold(leaf(`Build queued for ${name}`))}`);
1323
+ console.log(dim(` status ${apiUrl}/v1/builds/${result.buildId}`));
1324
+ return;
1325
+ }
1326
+ console.log(`\n${leaf("🌱")} ${bold(leaf(`${result.created ? "Created" : "Updated"} ${name}`))}`);
1327
+ if (result.url) {
1328
+ console.log(` ${bold(result.url)}`);
1329
+ }
1330
+ console.log(dim(` generation ${result.generation} · team ${team}`));
1331
+ if (result.applied === false) {
1332
+ fail(
1333
+ `generation ${result.generation} is saved but no host has applied it after 120 s; it applies when a host is available (check with: sproutboat whoami, or the dashboard)`,
1334
+ );
1335
+ }
1336
+ };
1337
+ if (fromSource) {
1338
+ const sourcePath = await emitStandaloneUnit(directory);
1339
+ console.log(` Bundling ${cfg.main} (${formatKiB(Bun.file(sourcePath).size)})`);
1340
+ const controller = new AbortController();
1341
+ const interrupt = () => controller.abort();
1342
+ process.once("SIGINT", interrupt);
1343
+ try {
1344
+ const result = await buildAndDeploy(
1345
+ {
1346
+ apiUrl,
1347
+ token: session!.token,
1348
+ tokenFromEnv,
1349
+ project: name,
1350
+ services,
1351
+ compatibilityDate: cfg.compatibility_date,
1352
+ secrets: secretValues,
1353
+ hosts,
1354
+ sourcePath,
1355
+ cli: { version: CLI_VERSION },
1356
+ assets,
1357
+ noWait,
1358
+ },
1359
+ io,
1360
+ controller.signal,
1361
+ );
1362
+ printResult(result);
1363
+ } catch (error) {
1364
+ if (error instanceof Interrupted) {
1365
+ console.error(error.message);
1366
+ process.exit(130);
1367
+ }
1368
+ throw error;
1369
+ } finally {
1370
+ process.off("SIGINT", interrupt);
1371
+ }
1372
+ return;
1373
+ }
1374
+ try {
1375
+ const result = await deployToCloud(
1376
+ {
1377
+ apiUrl,
1378
+ token: session!.token,
1379
+ tokenFromEnv,
1380
+ project: name,
1381
+ hosts,
1382
+ modulePath: modulePath!,
1383
+ assets,
1384
+ noWait,
1385
+ services,
1386
+ compatibilityDate: cfg.compatibility_date,
1387
+ secrets: secretValues,
1388
+ },
1389
+ io,
1390
+ );
1391
+ printResult(result);
1392
+ } finally {
1393
+ if (compiled) rmSync(modulePath!, { force: true });
1394
+ }
1395
+ }
1396
+
1397
+ async function apiCredentials(explicitApiUrl?: string) {
1398
+ const { apiUrl, source } = await resolveApiUrl(explicitApiUrl);
1399
+ const token = envToken()?.token || (await savedToken(apiUrl));
1400
+ if (!token) fail("not logged in; run sproutboat login or set SPROUTBOAT_API_TOKEN for this command");
801
1401
  return { apiUrl, token, source };
802
1402
  }
803
1403
 
1404
+ /**
1405
+ * `deployments list | status | tail <id>`. A Cloud deployment is a build; a Community one is a version. `tail` follows a
1406
+ * build server-side and never cancels it, which is what a deploy detached with Ctrl-C needs.
1407
+ */
1408
+ async function deployments(args: string[]) {
1409
+ const sub = args.shift();
1410
+ if (sub !== "list" && sub !== "status" && sub !== "tail")
1411
+ usageError(
1412
+ sub ? `deployments: unknown subcommand "${sub}"` : "deployments: missing subcommand",
1413
+ "deployments <list | status | tail <id>>",
1414
+ );
1415
+ const usage =
1416
+ sub === "list" ? DEPLOYMENTS_LIST_USAGE : sub === "status" ? DEPLOYMENTS_STATUS_USAGE : DEPLOYMENTS_TAIL_USAGE;
1417
+ let parsed: ReturnType<typeof parseDeploymentsArgs>;
1418
+ try {
1419
+ parsed = parseDeploymentsArgs(args, usage);
1420
+ } catch (error) {
1421
+ if (error instanceof DeploymentsUsageError) return usageError(error.message, error.usage);
1422
+ throw error;
1423
+ }
1424
+ const { positional, flags } = parsed;
1425
+ const id = positional[0];
1426
+ if (sub === "tail" && !id) usageError("deployments tail: missing <id>", usage);
1427
+ if (positional.length > (sub === "tail" ? 1 : 0)) usageError(`unexpected argument "${positional.at(-1)}"`, usage);
1428
+ const project = await readProjectConfig();
1429
+ const app = project.config.name;
1430
+ const { apiUrl: targetUrl } = await resolveApiUrl();
1431
+ const kind = await deployKind(targetUrl, false);
1432
+
1433
+ if (kind !== "cloud") {
1434
+ if (sub === "tail") return fail(COMMUNITY_TAIL);
1435
+ const { apiUrl, token } = await apiCredentials();
1436
+ const body = await responseText(
1437
+ await fetch(`${apiUrl}/api/projects/${app}/deployments`, { headers: { "x-api-key": token } }),
1438
+ "could not list deployments",
1439
+ );
1440
+ const list = parseJsonValue(body);
1441
+ const versions = parseCommunityVersions(
1442
+ Array.isArray(list)
1443
+ ? list.flatMap((entry) => {
1444
+ const item = jsonObject(entry);
1445
+ return item ? [item] : [];
1446
+ })
1447
+ : [],
1448
+ );
1449
+ if (sub === "status") {
1450
+ const active = versions.find((version) => version.active);
1451
+ if (flags.json) console.log(JSON.stringify(active ?? null, null, 2));
1452
+ else console.log(active ? formatCommunityVersion(active) : `${app} has no active deployment`);
1453
+ return;
1454
+ }
1455
+ const shown = versions.slice(0, flags.limit);
1456
+ if (flags.json) console.log(JSON.stringify(shown, null, 2));
1457
+ else console.log(shown.length ? shown.map(formatCommunityVersion).join("\n\n") : "no deployments");
1458
+ return;
1459
+ }
1460
+
1461
+ await guard(async () => {
1462
+ const credentials = await cloudCredentials(targetUrl, flags.account ?? project.account);
1463
+ if (sub === "tail") {
1464
+ // Ctrl-C only stops following: the build keeps going, and tail can be run again.
1465
+ const controller = new AbortController();
1466
+ let detached = false;
1467
+ process.once("SIGINT", () => {
1468
+ detached = true;
1469
+ controller.abort();
1470
+ });
1471
+ let view;
1472
+ try {
1473
+ view = await tailBuild(credentials, id!, terminalIo(), controller.signal);
1474
+ } catch (error) {
1475
+ if (detached || error instanceof Interrupted) {
1476
+ console.error(
1477
+ `detached: the deployment keeps running; \`sproutboat deployments tail ${id}\` follows it again`,
1478
+ );
1479
+ process.exit(130);
1480
+ }
1481
+ throw error;
1482
+ }
1483
+ console.log(tailSummary(view));
1484
+ if (view.state !== "ready") process.exitCode = 1;
1485
+ return;
1486
+ }
1487
+ if (sub === "status") {
1488
+ const live = await fetchLive(credentials, app);
1489
+ const { deployments: recent } = live ? await fetchDeployments(credentials, app, 1) : { deployments: [] };
1490
+ if (flags.json) console.log(JSON.stringify({ application: app, live, latest: recent[0] ?? null }, null, 2));
1491
+ else console.log(formatStatus(app, live, recent[0]));
1492
+ return;
1493
+ }
1494
+ const { deployments: recent } = await fetchDeployments(credentials, app, flags.limit);
1495
+ if (flags.json) console.log(JSON.stringify(recent, null, 2));
1496
+ else console.log(recent.length ? recent.map(formatDeployment).join("\n\n") : `no deployments of ${app} yet`);
1497
+ });
1498
+ }
1499
+
804
1500
  async function versions(args: string[]) {
805
1501
  const sub = args[0];
806
1502
  if (sub !== "list" && sub !== "view") {
@@ -1011,7 +1707,11 @@ async function domains(args: string[]) {
1011
1707
  }
1012
1708
 
1013
1709
  async function secrets(args: string[]) {
1014
- const sub = args[0] && ["list", "put", "delete"].includes(args[0]) ? args.shift()! : "list";
1710
+ const sub = args[0] && ["list", "put", "delete", "bulk"].includes(args[0]) ? args.shift()! : "list";
1711
+ // `--no-deploy`: change the secrets without waiting for a host to apply them as a new version.
1712
+ const noDeploy = args.includes("--no-deploy");
1713
+ if (noDeploy) args.splice(args.indexOf("--no-deploy"), 1);
1714
+ const flags = takeFlags(args, ["--account"] as const);
1015
1715
 
1016
1716
  // `--value` is opt-in; without it the value comes from stdin, so a secret does
1017
1717
  // not land in shell history. It is also what disambiguates the positionals:
@@ -1019,14 +1719,26 @@ async function secrets(args: string[]) {
1019
1719
  // and read the value as the project directory.
1020
1720
  let inlineValue: string | undefined;
1021
1721
  const positional: string[] = [];
1722
+ const grammar = `secrets ${sub} ${sub === "list" ? "" : sub === "bulk" ? "[file.json | file.env] " : "<NAME> "}[project-dir] [--api-url <url>] [--account <team>]`;
1022
1723
  for (let index = 0; index < args.length; index += 1) {
1023
- if (args[index] === "--value") inlineValue = args[(index += 1)];
1024
- else positional.push(args[index]);
1724
+ const arg = args[index];
1725
+ if (arg === "--value" || arg.startsWith("--value=")) {
1726
+ if (sub !== "put") usageError(`secrets ${sub} does not take --value`, grammar);
1727
+ inlineValue = arg === "--value" ? args[(index += 1)] : arg.slice("--value=".length);
1728
+ if (inlineValue === undefined) usageError("--value needs a value", grammar);
1729
+ } else if (arg.startsWith("-") && arg !== "-") usageError(`unknown option ${arg.split("=")[0]}`, grammar);
1730
+ else positional.push(arg);
1025
1731
  }
1026
1732
 
1733
+ if (sub === "bulk") return bulkSecrets(positional, flags, noDeploy);
1027
1734
  const name = sub === "list" ? undefined : positional.shift();
1028
1735
  if (sub !== "list" && !name) usageError(`secrets ${sub}: missing <NAME>`, `secrets ${sub} <NAME> [project-dir]`);
1029
1736
  if (name && !/^[A-Z][A-Z0-9_]*$/.test(name)) fail("secret name must be UPPER_SNAKE_CASE");
1737
+ const { apiUrl: targetUrl } = await resolveApiUrl();
1738
+ if ((await deployKind(targetUrl, false)) === "cloud") {
1739
+ await guard(() => cloudSecrets(sub, name, inlineValue, positional[0], targetUrl, flags["--account"], noDeploy));
1740
+ return;
1741
+ }
1030
1742
  const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(positional[0]), apiCredentials()]);
1031
1743
  const base = `${apiUrl}/api/projects/${project.config.name}/secrets`;
1032
1744
  const auth = { "x-api-key": token };
@@ -1054,7 +1766,306 @@ async function secrets(args: string[]) {
1054
1766
  }),
1055
1767
  "put rejected",
1056
1768
  );
1057
- console.log(ok(`set ${name} — applies on the next deploy or sprout restart`));
1769
+ console.log(
1770
+ ok(
1771
+ `set ${name}; self-hosted Community applies it on the next deploy or restart of the app (it has no redeploy route yet)`,
1772
+ ),
1773
+ );
1774
+ }
1775
+
1776
+ /**
1777
+ * `secrets bulk [file]`: the input of `wrangler secret bulk` (JSON with null to delete, or .env; stdin when no file).
1778
+ * On a Cloud endpoint it is one PATCH and so one new version; on self-hosted Community it is one call per name.
1779
+ */
1780
+ async function bulkSecrets(positional: string[], flags: ReturnType<typeof takeFlags<"--account">>, noDeploy: boolean) {
1781
+ const file = positional.shift();
1782
+ const directory = positional[0];
1783
+ if (file === undefined && process.stdin.isTTY)
1784
+ usageError(
1785
+ "secrets bulk: missing <file>, and nothing is piped on stdin",
1786
+ "secrets bulk [file.json | file.env] [project-dir]",
1787
+ );
1788
+ let text: string;
1789
+ try {
1790
+ text = file === undefined ? await Bun.stdin.text() : await readFile(resolve(file), "utf8");
1791
+ } catch {
1792
+ return fail(`${file}: cannot read the file`);
1793
+ }
1794
+ let plan: BulkPlan | undefined;
1795
+ guardSync(() => {
1796
+ plan = parseBulkSecrets(text, file ?? "stdin");
1797
+ });
1798
+ const bulk = plan!;
1799
+ for (const warning of bulk.warnings) console.warn(amber(`! ${warning}`));
1800
+ const sets = Object.keys(bulk.set).length;
1801
+ const summary = `${sets} set, ${bulk.delete.length} deleted`;
1802
+ const { apiUrl: targetUrl } = await resolveApiUrl();
1803
+ const project = await readProjectConfig(directory);
1804
+ const app = project.config.name;
1805
+ if ((await deployKind(targetUrl, false)) === "cloud") {
1806
+ await guard(async () => {
1807
+ const credentials = await cloudCredentials(targetUrl, flags["--account"] ?? project.account);
1808
+ const generation = await patchCloudSecrets(credentials, app, bulk);
1809
+ await finishCloudSecretChange({ credentials, app, generation, noDeploy, what: `${summary} on ${app}` });
1810
+ });
1811
+ return;
1812
+ }
1813
+ const { apiUrl, token } = await apiCredentials();
1814
+ const base = `${apiUrl}/api/projects/${app}/secrets`;
1815
+ const auth = { "x-api-key": token };
1816
+ for (const [secretName, value] of Object.entries(bulk.set))
1817
+ await responseText(
1818
+ await fetch(`${base}/${secretName}`, {
1819
+ method: "PUT",
1820
+ headers: { ...auth, "content-type": "application/json" },
1821
+ body: JSON.stringify({ value }),
1822
+ }),
1823
+ `put ${secretName} rejected`,
1824
+ );
1825
+ for (const secretName of bulk.delete)
1826
+ await responseText(
1827
+ await fetch(`${base}/${secretName}`, { method: "DELETE", headers: auth }),
1828
+ `delete ${secretName} rejected`,
1829
+ );
1830
+ console.log(ok(`${summary}; self-hosted Community applies them on the next deploy or restart of the app`));
1831
+ }
1832
+
1833
+ /**
1834
+ * After a secrets write on the Cloud: it is a new record generation, which a host applies as a new version of the
1835
+ * running module (the same module, the new secrets). Wait for that, as `wrangler secret put` deploys at once, unless
1836
+ * --no-deploy asks to return as soon as the write is stored.
1837
+ */
1838
+ async function finishCloudSecretChange(input: {
1839
+ credentials: Awaited<ReturnType<typeof cloudCredentials>>;
1840
+ app: string;
1841
+ generation: number | undefined;
1842
+ noDeploy: boolean;
1843
+ what: string;
1844
+ }) {
1845
+ const { credentials, app, generation, noDeploy, what } = input;
1846
+ if (generation === undefined || noDeploy) {
1847
+ console.log(ok(`${what}; applies when a host picks up the new record`));
1848
+ return;
1849
+ }
1850
+ const io = terminalIo();
1851
+ const applied = await waitForApply(makeCall(credentials), { project: app }, generation, io);
1852
+ if (applied) console.log(ok(`${what}; a new version is live (generation ${generation})`));
1853
+ else
1854
+ console.log(
1855
+ amber(`! ${what}; generation ${generation} is saved, but no host has applied it yet: it goes live when one does`),
1856
+ );
1857
+ }
1858
+
1859
+ /** The signed-in session for a Cloud endpoint as the credentials every Cloud call takes; logs in first when it can. */
1860
+ async function cloudCredentials(apiUrl: string, account: string | undefined) {
1861
+ const io = terminalIo();
1862
+ const session = await resolveSession({
1863
+ apiUrl,
1864
+ io,
1865
+ account,
1866
+ login: (hint) =>
1867
+ loginAndStore({ apiUrl, io, hostname: osHostname(), os: `${osPlatform()} ${osArch()}`, account: hint }),
1868
+ });
1869
+ return { apiUrl, token: session.token, tokenFromEnv: session.source !== "stored login" };
1870
+ }
1871
+
1872
+ /** `secrets` against a Sproutboat Cloud app: by name, apart from deploys, which never wipe them (Wrangler's model). */
1873
+ async function cloudSecrets(
1874
+ sub: string,
1875
+ name: string | undefined,
1876
+ inlineValue: string | undefined,
1877
+ directory: string | undefined,
1878
+ apiUrl: string,
1879
+ accountFlag: string | undefined,
1880
+ noDeploy: boolean,
1881
+ ) {
1882
+ const project = await readProjectConfig(directory);
1883
+ const app = project.config.name;
1884
+ const credentials = await cloudCredentials(apiUrl, accountFlag ?? project.account);
1885
+ if (sub === "list") {
1886
+ const names = await listCloudSecrets(credentials, app);
1887
+ console.log(names.length ? names.join("\n") : "no secrets");
1888
+ return;
1889
+ }
1890
+ if (sub === "delete") {
1891
+ const generation = await deleteCloudSecret(credentials, app, name!);
1892
+ await finishCloudSecretChange({ credentials, app, generation, noDeploy, what: `removed ${name} from ${app}` });
1893
+ return;
1894
+ }
1895
+ const value = inlineValue ?? (await Bun.stdin.text()).replace(/\r?\n$/, "");
1896
+ if (!value) fail("no value: pipe it on stdin, or pass --value <value>");
1897
+ const generation = await putCloudSecret(credentials, app, name!, value);
1898
+ await finishCloudSecretChange({ credentials, app, generation, noDeploy, what: `set ${name} on ${app}` });
1899
+ }
1900
+
1901
+ async function confirmOnTerminal(question: string): Promise<boolean> {
1902
+ const { createInterface } = await import("node:readline/promises");
1903
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
1904
+ try {
1905
+ return !/^n/i.test((await rl.question(`${question} [Y/n] `)).trim());
1906
+ } finally {
1907
+ rl.close();
1908
+ }
1909
+ }
1910
+
1911
+ /** `d1 execute` and `d1 migrations create | list | apply`: the local dev database unless --remote, as in Wrangler. */
1912
+ async function d1Contents(args: string[]) {
1913
+ const area = args.shift()!;
1914
+ let sub = "execute";
1915
+ if (area === "migrations") {
1916
+ const word = args.shift();
1917
+ if (word !== "create" && word !== "list" && word !== "apply")
1918
+ usageError(
1919
+ word ? `d1 migrations: unknown subcommand "${word}"` : "d1 migrations: missing subcommand",
1920
+ "d1 migrations <create | list | apply> <database> ...",
1921
+ );
1922
+ sub = word;
1923
+ }
1924
+ const usage =
1925
+ sub === "execute" ? EXECUTE_USAGE : sub === "create" ? CREATE_USAGE : sub === "list" ? LIST_USAGE : APPLY_USAGE;
1926
+ let parsed: ReturnType<typeof parseD1Flags>;
1927
+ try {
1928
+ parsed = parseD1Flags(args, usage);
1929
+ } catch (error) {
1930
+ if (error instanceof D1UsageError) return usageError(error.message, error.usage);
1931
+ throw error;
1932
+ }
1933
+ const { positional, flags } = parsed;
1934
+ const [database, message] = positional;
1935
+ if (!database) usageError(`${sub === "execute" ? "d1 execute" : `d1 migrations ${sub}`}: missing <database>`, usage);
1936
+ if (sub === "create" && (!message || positional.length > 2))
1937
+ usageError("d1 migrations create: missing <name>", usage);
1938
+ if (sub !== "create" && positional.length > 1) usageError(`unexpected argument "${positional[1]}"`, usage);
1939
+ if (sub === "create" && (flags.local || flags.remote))
1940
+ usageError("d1 migrations create works on files only; drop --local and --remote", usage);
1941
+
1942
+ const project = await readProjectConfig();
1943
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
1944
+ const io: D1Io = {
1945
+ log: (line) => console.log(line),
1946
+ note: (line) => console.error(line),
1947
+ interactive,
1948
+ confirm: confirmOnTerminal,
1949
+ };
1950
+ try {
1951
+ await guard(() => d1Run());
1952
+ } catch (error) {
1953
+ if (error instanceof D1UsageError) usageError(error.message, error.usage);
1954
+ throw error;
1955
+ }
1956
+
1957
+ async function d1Run() {
1958
+ const binding = pickBinding(project.config, database!);
1959
+ const dir = resolve(project.directory, project.migrationsDir ?? DEFAULT_MIGRATIONS_DIR);
1960
+ if (sub === "create") {
1961
+ await createMigration(dir, message!, io);
1962
+ return;
1963
+ }
1964
+ let runner: Runner;
1965
+ if (flags.remote) {
1966
+ const { apiUrl: targetUrl } = await resolveApiUrl();
1967
+ const app = project.config.name;
1968
+ if ((await deployKind(targetUrl, false)) === "cloud") {
1969
+ const credentials = await cloudCredentials(targetUrl, flags.account ?? project.account);
1970
+ const call = makeCall(credentials);
1971
+ runner = remoteRunner({
1972
+ label: `${app} database ${binding.binding}`,
1973
+ project: app,
1974
+ binding: binding.binding,
1975
+ community: false,
1976
+ send: async (body) => {
1977
+ const reply = await call({
1978
+ method: "POST",
1979
+ path: `/v1/apps/${app}/d1/${binding.binding}/query`,
1980
+ json: body,
1981
+ ok: DATA_ERROR_STATUSES,
1982
+ });
1983
+ return { status: reply.status, body: reply.body, headers: reply.headers };
1984
+ },
1985
+ });
1986
+ } else {
1987
+ const { apiUrl, token } = await apiCredentials();
1988
+ runner = remoteRunner({
1989
+ label: `${app} database ${binding.binding}`,
1990
+ project: app,
1991
+ binding: binding.binding,
1992
+ community: true,
1993
+ send: async (body) => {
1994
+ const response = await fetch(`${apiUrl}/api/projects/${app}/d1/${binding.binding}/query`, {
1995
+ method: "POST",
1996
+ headers: { "x-api-key": token, "content-type": "application/json" },
1997
+ body: JSON.stringify(body),
1998
+ });
1999
+ return { status: response.status, body: parseBody(await response.text().catch(() => "")) };
2000
+ },
2001
+ });
2002
+ }
2003
+ } else {
2004
+ runner = localRunner(localDatabasePath(project.directory, binding, flags.persistTo));
2005
+ }
2006
+ if (sub === "execute") await executeSql({ flags, runner, remote: flags.remote, io, cwd: process.cwd() });
2007
+ else if (sub === "list") await listMigrations(runner, dir, io);
2008
+ else await applyMigrations(runner, dir, io);
2009
+ }
2010
+ }
2011
+
2012
+ /** `r2 object get | put | delete <bucket>/<key>`: remote only; on the Cloud the bucket is a binding of the project. */
2013
+ async function r2Object(args: string[]) {
2014
+ args.shift(); // "object"
2015
+ const sub = args.shift();
2016
+ if (sub !== "get" && sub !== "put" && sub !== "delete")
2017
+ usageError(
2018
+ sub ? `r2 object: unknown subcommand "${sub}"` : "r2 object: missing subcommand",
2019
+ "r2 object <get | put | delete> <bucket>/<key>",
2020
+ );
2021
+ const usage = sub === "get" ? R2_GET_USAGE : sub === "put" ? R2_PUT_USAGE : R2_DELETE_USAGE;
2022
+ try {
2023
+ const { positional, flags } = parseObjectArgs(args, usage);
2024
+ if (positional.length > 1) usageError(`unexpected argument "${positional[1]}"`, usage);
2025
+ const { bucket, key } = parseObjectPath(positional[0], usage);
2026
+ const project = await readProjectConfig();
2027
+ const app = project.config.name;
2028
+ const { apiUrl: targetUrl } = await resolveApiUrl();
2029
+ let target: ObjectTarget;
2030
+ if ((await deployKind(targetUrl, false)) === "cloud") {
2031
+ const bindings = (project.config.r2_buckets ?? []).map((entry) => (isString(entry) ? entry : entry.binding));
2032
+ if (!bindings.includes(bucket))
2033
+ fail(
2034
+ bindings.length === 0
2035
+ ? `sproutboat.jsonc has no r2_buckets, so there is no bucket "${bucket}" (add "r2_buckets": ["${bucket}"])`
2036
+ : `no R2 binding "${bucket}" in sproutboat.jsonc (it has: ${bindings.join(", ")})`,
2037
+ );
2038
+ const credentials = await guard(() => cloudCredentials(targetUrl, flags.account ?? project.account));
2039
+ target = cloudTarget(credentials.apiUrl, credentials.token, app, bucket);
2040
+ } else {
2041
+ const { apiUrl, token } = await apiCredentials();
2042
+ const rows = await storageRows(`${apiUrl}/api/r2`, { "x-api-key": token }, STORAGE_PRODUCTS[2]);
2043
+ target = communityTarget(apiUrl, token, app, bucket, idForName(rows, bucket, STORAGE_PRODUCTS[2]));
2044
+ }
2045
+ const io = {
2046
+ log: (line: string) => console.log(line),
2047
+ note: (line: string) => console.error(line),
2048
+ stdout: { write: (bytes: Uint8Array) => void process.stdout.write(bytes) },
2049
+ };
2050
+ await guard(async () => {
2051
+ if (sub === "get") await getObject(target, key, flags, io, process.cwd());
2052
+ else if (sub === "delete") await deleteObject(target, key, io);
2053
+ else
2054
+ await putObject(target, key, flags, io, process.cwd(), () =>
2055
+ readCapped(
2056
+ Bun.stdin.stream(),
2057
+ OBJECT_MAX_BYTES,
2058
+ () =>
2059
+ new CloudError(
2060
+ `the piped object is larger than ${OBJECT_MAX_BYTES / 1024 / 1024} MiB, the most the Cloud takes`,
2061
+ ),
2062
+ ),
2063
+ );
2064
+ });
2065
+ } catch (error) {
2066
+ if (error instanceof R2UsageError) usageError(error.message, error.usage);
2067
+ throw error;
2068
+ }
1058
2069
  }
1059
2070
 
1060
2071
  /**
@@ -1086,6 +2097,9 @@ function idForName(rows: JsonObject[], name: string, product: StorageProduct): s
1086
2097
  async function storage(key: string, args: string[]) {
1087
2098
  const product = STORAGE_PRODUCTS.find((entry) => entry.name === key)!;
1088
2099
  const sub = args[0] && STORAGE_VERBS.some((verb) => verb === args[0]) ? args.shift()! : "list";
2100
+ // The five verbs take names, never options (the contents commands and `checkOptions` handle the others).
2101
+ const stray = args.find((arg) => arg.startsWith("-") && arg !== "-");
2102
+ if (stray) usageError(`${key}: unknown option ${stray.split("=")[0]}`, usageOf(key));
1089
2103
  const { apiUrl, token } = await apiCredentials();
1090
2104
  const base = `${apiUrl}/api/${product.name}`;
1091
2105
  const auth = { "x-api-key": token };
@@ -1430,8 +2444,14 @@ async function toolchain(args: string[]): Promise<void> {
1430
2444
  }
1431
2445
 
1432
2446
  /** `sproutboat <command> --help`: that command's full usage line + summary, exit 0. */
1433
- function commandHelp(name: string): never {
2447
+ /** The grammar line of a command, for usage errors. */
2448
+ function usageOf(name: string): string {
1434
2449
  const found = COMMANDS.find((c) => c.name === name);
2450
+ return found?.args ? `${name} ${found.args}` : name;
2451
+ }
2452
+
2453
+ function commandHelp(name: string): never {
2454
+ const found = COMMANDS.find((c) => c.name === resolveCommand(name));
1435
2455
  if (!found) usage();
1436
2456
  console.log(`${dim(`usage: ${CLI_NAME} ${name}${found.args ? ` ${found.args}` : ""}`)}\n\n${found.summary}`);
1437
2457
  process.exit(0);
@@ -1444,7 +2464,7 @@ const FISH_COMPLETION = (names: string) => `complete -c ${CLI_NAME} -n "__fish_u
1444
2464
 
1445
2465
  /** `sproutboat complete [shell]`: a static completion script for the top-level commands. */
1446
2466
  function complete(shell?: string): void {
1447
- const names = COMMANDS.map((c) => c.name).join(" ");
2467
+ const names = COMMANDS.flatMap((c) => [c.name, ...(c.aliases ?? [])]).join(" ");
1448
2468
  const scripts = { bash: BASH_COMPLETION(names), zsh: ZSH_COMPLETION(names), fish: FISH_COMPLETION(names) };
1449
2469
  const key = shell ?? process.env.SHELL?.split("/").pop() ?? "bash";
1450
2470
  const script = key === "bash" || key === "zsh" || key === "fish" ? scripts[key] : undefined;
@@ -1452,19 +2472,60 @@ function complete(shell?: string): void {
1452
2472
  console.log(script);
1453
2473
  }
1454
2474
 
1455
- const [command, ...args] = process.argv.slice(2);
1456
- if (command === undefined || command === "help" || command === "-h" || command === "--help") help();
1457
- if (command === "--version" || command === "-v") {
2475
+ const GLOBAL_USAGE =
2476
+ "<command> [-c, --config <file>] [--cwd <dir>] [-e, --env <name>] [--env-file <file>]... [--profile <name>] [--api-url <url>] [-v, --version]";
2477
+ const parsedArgv = extractGlobalFlags(process.argv.slice(2));
2478
+ if ("error" in parsedArgv) usageError(parsedArgv.error, GLOBAL_USAGE);
2479
+ const globals = parsedArgv.flags;
2480
+ if (globals.version) {
1458
2481
  console.log(`sproutboat ${CLI_VERSION}`);
1459
2482
  process.exit(0);
1460
2483
  }
2484
+ if (globals.cwd !== undefined) {
2485
+ try {
2486
+ process.chdir(globals.cwd);
2487
+ } catch {
2488
+ fail(`--cwd: no such directory: ${globals.cwd}`);
2489
+ }
2490
+ }
2491
+ setGlobalFlags(globals);
2492
+ const [command, ...args] = parsedArgv.rest;
2493
+ if (command === undefined || command === "help" || command === "-h" || command === "--help") help();
1461
2494
  if (args.includes("-h") || args.includes("--help")) commandHelp(command);
1462
2495
 
2496
+ // A flag a command cannot honour is an error, not a quiet no-op: `deploy --env staging` must not deploy.
2497
+ if (globals.env !== undefined && command !== "dev")
2498
+ usageError(
2499
+ `${command} does not use --env: it only selects .dev.vars.<name> and the .env.<name> files for \`dev\`, and sproutboat.jsonc has no environments`,
2500
+ GLOBAL_USAGE,
2501
+ );
2502
+ if (globals.envFiles.length > 0 && command !== "dev" && command !== "run")
2503
+ usageError(`${command} does not read env files; --env-file is for \`dev\` and \`run\``, GLOBAL_USAGE);
2504
+ if (globals.profile !== undefined && !PROFILE_NAME.test(globals.profile))
2505
+ usageError(`"${globals.profile}" is not a profile name (letters, digits, . _ -; at most 64)`, GLOBAL_USAGE);
2506
+ if (globals.apiUrl !== undefined) {
2507
+ if (!ENDPOINT_COMMANDS.has(command))
2508
+ usageError(
2509
+ `${command} does not talk to a control plane, so --api-url means nothing here; to choose where the other commands go use --profile <name> or SPROUTBOAT_API_URL`,
2510
+ GLOBAL_USAGE,
2511
+ );
2512
+ if (!/^https?:\/\/[^\s/]+/i.test(globals.apiUrl))
2513
+ usageError(
2514
+ `--api-url "${globals.apiUrl}" is not an http(s) URL, such as https://dash.sproutboat.com`,
2515
+ GLOBAL_USAGE,
2516
+ );
2517
+ }
2518
+ // What each command accepts: an unknown flag is an error, `--flag=value` becomes `--flag value`, and a URL is never
2519
+ // read as a project directory (options.ts).
2520
+ const checked = checkOptions(command, args);
2521
+ if ("error" in checked) usageError(`${command}: ${checked.error}`, usageOf(command));
2522
+ args.splice(0, args.length, ...checked.args);
2523
+
1463
2524
  // A local log needs no network: skip the version check for it (#268).
1464
2525
  if (!(command === "tail" && args.some((arg) => arg === "--file" || arg.startsWith("--file="))))
1465
2526
  await notifyIfOutdated(CLI_VERSION);
1466
2527
 
1467
- switch (command) {
2528
+ switch (resolveCommand(command)) {
1468
2529
  case "init":
1469
2530
  await init(args[0]);
1470
2531
  break;
@@ -1508,7 +2569,8 @@ switch (command) {
1508
2569
  "build --standalone --target linux-x64 --deploy-files [project-dir]",
1509
2570
  );
1510
2571
  if (args.includes("--emit-only")) await emitOnly(directory, target, args.includes("--standalone"));
1511
- else if (args.includes("--standalone")) await buildStandaloneBinary(directory, target, withRestore, deployFiles);
2572
+ else if (args.includes("--standalone"))
2573
+ await buildStandaloneBinary(directory, target, withRestore, deployFiles, isReleaseBuild(args));
1512
2574
  else await build(directory, target);
1513
2575
  break;
1514
2576
  }
@@ -1522,11 +2584,17 @@ switch (command) {
1522
2584
  await logout(args);
1523
2585
  break;
1524
2586
  case "whoami":
1525
- await whoami();
2587
+ await whoami(args);
2588
+ break;
2589
+ case "profile":
2590
+ await profile(args);
1526
2591
  break;
1527
2592
  case "deploy":
1528
2593
  await deploy(args);
1529
2594
  break;
2595
+ case "deployments":
2596
+ await deployments(args);
2597
+ break;
1530
2598
  case "versions":
1531
2599
  await versions(args);
1532
2600
  break;
@@ -1544,7 +2612,13 @@ switch (command) {
1544
2612
  else await storage(command, args);
1545
2613
  break;
1546
2614
  case "d1":
2615
+ if (["execute", "migrations"].includes(args[0] ?? "")) await d1Contents(args);
2616
+ else await storage(command, args);
2617
+ break;
1547
2618
  case "r2":
2619
+ if (args[0] === "object") await r2Object(args);
2620
+ else await storage(command, args);
2621
+ break;
1548
2622
  case "queues":
1549
2623
  await storage(command, args);
1550
2624
  break;