sproutboat 0.53.0 → 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/CHANGELOG.md +45 -1
- package/README.md +114 -13
- package/SURFACE.md +40 -20
- package/package.json +7 -7
- package/src/account-field.ts +69 -0
- package/src/build.ts +12 -1
- package/src/cloud-deploy.ts +752 -0
- package/src/cloud-fake.ts +633 -0
- package/src/cloud-secrets.ts +121 -0
- package/src/cloud.ts +375 -0
- package/src/compile.ts +16 -3
- package/src/credentials.ts +455 -52
- package/src/d1-cli.ts +455 -0
- package/src/data-errors.ts +139 -0
- package/src/deployments-cli.ts +184 -0
- package/src/fake-login.ts +28 -0
- package/src/global-flags.ts +113 -0
- package/src/init-package.ts +100 -0
- package/src/main.ts +1184 -81
- package/src/options.ts +233 -0
- package/src/r2-objects.ts +365 -0
- package/src/run.ts +12 -6
- package/src/secrets-bulk.ts +51 -0
- package/src/sql-split.ts +68 -0
- package/src/standalone-build.ts +21 -3
- package/src/surface.ts +187 -59
- package/src/target.ts +95 -0
package/src/main.ts
CHANGED
|
@@ -2,22 +2,134 @@
|
|
|
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 {
|
|
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 {
|
|
17
|
-
|
|
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";
|
|
132
|
+
import { detectPackageManager, installCommand, planPackageJson, runScriptCommand } from "./init-package";
|
|
21
133
|
import { pathToFileURL } from "node:url";
|
|
22
134
|
import { constants as osConstants } from "node:os";
|
|
23
135
|
import { inspectToolchain } from "./toolchain";
|
|
@@ -40,26 +152,56 @@ if (internalModeAt > 0) {
|
|
|
40
152
|
process.exit(0);
|
|
41
153
|
}
|
|
42
154
|
|
|
43
|
-
const defaultApiUrl =
|
|
44
|
-
|
|
45
|
-
type ApiUrlSource = "SPROUTBOAT_API_URL" | "--api-url" | "saved active endpoint" | "default";
|
|
155
|
+
const defaultApiUrl = DEFAULT_API_URL;
|
|
46
156
|
|
|
47
157
|
/**
|
|
48
|
-
* baronunread/sproutboat#204 — the single place `
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* in" on a machine where `deploy` would happily target prod. Precedence: an
|
|
53
|
-
* explicit `--api-url` flag, then `SPROUTBOAT_API_URL`, then the saved active
|
|
54
|
-
* 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.
|
|
55
162
|
*/
|
|
56
|
-
async function resolveApiUrl(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
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})`)}`);
|
|
63
205
|
}
|
|
64
206
|
|
|
65
207
|
/** Warn at most once per run: every API response carries the headers, and one
|
|
@@ -206,25 +348,43 @@ function usageError(what: string, usage: string): never {
|
|
|
206
348
|
process.exit(2);
|
|
207
349
|
}
|
|
208
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
|
+
|
|
209
361
|
/** Read the project identity and configuration without touching application code.
|
|
210
362
|
* Management commands must remain usable when a local checkout is incomplete. */
|
|
211
|
-
async function readProjectConfig(directory
|
|
212
|
-
const projectDirectory =
|
|
213
|
-
const configPath = resolve(projectDirectory, "sproutboat.jsonc");
|
|
363
|
+
async function readProjectConfig(directory?: string) {
|
|
364
|
+
const { directory: projectDirectory, configPath } = locate(directory);
|
|
214
365
|
let configSource: string;
|
|
215
366
|
try {
|
|
216
367
|
configSource = await readFile(configPath, "utf8");
|
|
217
368
|
} catch {
|
|
218
|
-
fail(
|
|
369
|
+
fail(
|
|
370
|
+
globalFlags().config === undefined
|
|
371
|
+
? `no sproutboat.jsonc found in ${projectDirectory}`
|
|
372
|
+
: `no config file at ${configPath}`,
|
|
373
|
+
);
|
|
219
374
|
}
|
|
220
|
-
const parsed =
|
|
375
|
+
const parsed = parseProjectConfig(configSource);
|
|
221
376
|
if (!parsed.ok) fail(parsed.errors.join("\n"));
|
|
222
377
|
for (const warning of parsed.warnings) console.warn(amber(`! ${warning}`));
|
|
223
|
-
return {
|
|
378
|
+
return {
|
|
379
|
+
directory: projectDirectory,
|
|
380
|
+
config: parsed.value,
|
|
381
|
+
account: configAccount(configSource),
|
|
382
|
+
migrationsDir: configMigrationsDir(configSource),
|
|
383
|
+
};
|
|
224
384
|
}
|
|
225
385
|
|
|
226
386
|
/** Full build input: configuration plus entry point, resolved imports and checks. */
|
|
227
|
-
async function readProject(directory
|
|
387
|
+
async function readProject(directory?: string) {
|
|
228
388
|
const project = await readProjectConfig(directory);
|
|
229
389
|
const sourcePath = resolve(project.directory, project.config.main);
|
|
230
390
|
let source: string;
|
|
@@ -265,6 +425,21 @@ async function init(name = "hello") {
|
|
|
265
425
|
] as const) {
|
|
266
426
|
if (await Bun.file(path).exists()) fail(`${basename(directory)} already contains ${label}`);
|
|
267
427
|
}
|
|
428
|
+
// Plan package.json before writing anything, so an unreadable one stops init
|
|
429
|
+
// instead of leaving a half-scaffolded project.
|
|
430
|
+
const packagePath = resolve(directory, "package.json");
|
|
431
|
+
let existingPackage: string | undefined;
|
|
432
|
+
try {
|
|
433
|
+
existingPackage = await readFile(packagePath, "utf8");
|
|
434
|
+
} catch {
|
|
435
|
+
existingPackage = undefined;
|
|
436
|
+
}
|
|
437
|
+
let packagePlan: ReturnType<typeof planPackageJson>;
|
|
438
|
+
try {
|
|
439
|
+
packagePlan = planPackageJson(name, existingPackage, CLI_VERSION);
|
|
440
|
+
} catch (cause) {
|
|
441
|
+
fail(`${basename(directory)}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
442
|
+
}
|
|
268
443
|
await mkdir(resolve(directory, "src"), { recursive: true });
|
|
269
444
|
await writeFile(configPath, starterConfig(name), { flag: "wx" });
|
|
270
445
|
await writeFile(handlerPath, starterHandler, { flag: "wx" });
|
|
@@ -278,6 +453,19 @@ async function init(name = "hello") {
|
|
|
278
453
|
await writeFile(gitignorePath, starterGitignore, { flag: "wx" });
|
|
279
454
|
console.log(`Created ${basename(directory)}/.gitignore`);
|
|
280
455
|
}
|
|
456
|
+
if (packagePlan.created || packagePlan.addedDependency || packagePlan.addedScripts.length > 0) {
|
|
457
|
+
await writeFile(packagePath, packagePlan.text);
|
|
458
|
+
console.log(`${packagePlan.created ? "Created" : "Updated"} ${basename(directory)}/package.json`);
|
|
459
|
+
}
|
|
460
|
+
for (const script of packagePlan.skippedScripts)
|
|
461
|
+
console.log(
|
|
462
|
+
amber(
|
|
463
|
+
` ! package.json already has a "${script}" script; left it as is (sproutboat's would be "sproutboat ${script}")`,
|
|
464
|
+
),
|
|
465
|
+
);
|
|
466
|
+
const manager = detectPackageManager(directory);
|
|
467
|
+
console.log(`\nNext: cd ${basename(directory)} && ${installCommand(manager)} && ${runScriptCommand(manager, "dev")}`);
|
|
468
|
+
console.log(dim(`After "sproutboat login", ship it with ${runScriptCommand(manager, "deploy")}.`));
|
|
281
469
|
}
|
|
282
470
|
|
|
283
471
|
async function check(directory?: string) {
|
|
@@ -349,9 +537,10 @@ async function buildStandaloneBinary(
|
|
|
349
537
|
target: BuildTarget,
|
|
350
538
|
withRestore = false,
|
|
351
539
|
deployFiles = false,
|
|
540
|
+
release = false,
|
|
352
541
|
) {
|
|
353
542
|
const project = await readProject(directory);
|
|
354
|
-
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"})…`));
|
|
355
544
|
try {
|
|
356
545
|
const result = await buildStandalone({
|
|
357
546
|
projectDir: project.directory,
|
|
@@ -360,9 +549,14 @@ async function buildStandaloneBinary(
|
|
|
360
549
|
source: project.bundle.code,
|
|
361
550
|
target,
|
|
362
551
|
withRestore,
|
|
552
|
+
release,
|
|
363
553
|
});
|
|
364
554
|
console.log(ok(`built ${result.outPath} (${(result.bytes / 1_000_000).toFixed(1)} MB)`));
|
|
365
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
|
+
);
|
|
366
560
|
if (target === "linux-arm64") console.log(dim(" linux-arm64: runs on an aarch64 Linux box, not on this machine"));
|
|
367
561
|
if (deployFiles && isLinuxTarget(target)) {
|
|
368
562
|
const written = await writeDeployFiles({ name: project.config.name, target, binary: result.outPath });
|
|
@@ -401,24 +595,43 @@ async function emitOnly(directory: string | undefined, target: BuildTarget, stan
|
|
|
401
595
|
}
|
|
402
596
|
}
|
|
403
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
|
+
|
|
404
618
|
/** #62 — build for this machine, run it against a real broker, rebuild on save. */
|
|
405
|
-
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>]...";
|
|
406
620
|
|
|
407
621
|
async function dev(args: string[]) {
|
|
408
622
|
let directory: string | undefined;
|
|
409
623
|
let portArg: string | undefined;
|
|
410
|
-
|
|
411
|
-
const
|
|
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));
|
|
412
627
|
for (let at = 0; at < args.length; at += 1) {
|
|
413
628
|
const arg = args[at];
|
|
414
629
|
if (arg === "--no-watch") continue;
|
|
415
|
-
if (arg === "--port"
|
|
630
|
+
if (arg === "--port") {
|
|
416
631
|
const value = args[at + 1];
|
|
417
632
|
if (value === undefined || value.startsWith("--")) usageError(`${arg} needs a value`, DEV_USAGE);
|
|
418
633
|
at += 1;
|
|
419
|
-
|
|
420
|
-
else if (arg === "--env") envName = value;
|
|
421
|
-
else envFiles.push(resolve(value));
|
|
634
|
+
portArg = value;
|
|
422
635
|
} else if (!arg.startsWith("--") && directory === undefined) directory = arg;
|
|
423
636
|
}
|
|
424
637
|
const port = Number(portArg ?? 8787);
|
|
@@ -452,9 +665,11 @@ async function dev(args: string[]) {
|
|
|
452
665
|
async function run(args: string[]) {
|
|
453
666
|
const result = parseRunArgs(args);
|
|
454
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);
|
|
455
670
|
let plan;
|
|
456
671
|
try {
|
|
457
|
-
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 });
|
|
458
673
|
} catch (error) {
|
|
459
674
|
if (error instanceof RunError || error instanceof EnvFileError || error instanceof MissingSecretsError)
|
|
460
675
|
fail(error.message);
|
|
@@ -488,10 +703,10 @@ const PROVISION_FIELDS = [
|
|
|
488
703
|
* deploy, and the id is written back into `sproutboat.jsonc`. `--no-provision`
|
|
489
704
|
* skips this — those bindings then get an ephemeral deploy-scoped store.
|
|
490
705
|
*/
|
|
491
|
-
async function provisionBindings(directory
|
|
492
|
-
const configPath =
|
|
706
|
+
async function provisionBindings(directory?: string): Promise<void> {
|
|
707
|
+
const { configPath } = locate(directory);
|
|
493
708
|
let source = await readFile(configPath, "utf8");
|
|
494
|
-
const parsed =
|
|
709
|
+
const parsed = parseProjectConfig(source);
|
|
495
710
|
if (!parsed.ok) return; // build() re-reads and reports the config error
|
|
496
711
|
|
|
497
712
|
const bare: Array<{ field: string; kind: string; binding: string }> = [];
|
|
@@ -523,7 +738,25 @@ async function provisionBindings(directory = process.cwd()): Promise<void> {
|
|
|
523
738
|
|
|
524
739
|
/** #79 — wrangler parity: drop the stored credential for an endpoint. */
|
|
525
740
|
async function logout(args: string[]) {
|
|
526
|
-
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
|
+
}
|
|
527
760
|
const result = await forgetToken(apiUrl);
|
|
528
761
|
if (!result.removed) {
|
|
529
762
|
console.log(`no stored credential for ${apiUrl}`);
|
|
@@ -534,17 +767,75 @@ async function logout(args: string[]) {
|
|
|
534
767
|
console.log(dim("no active endpoint remains — the next command falls through to the default"));
|
|
535
768
|
}
|
|
536
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
|
+
|
|
537
801
|
/**
|
|
538
802
|
* #79 — wrangler parity: which endpoint, and who the stored token belongs to.
|
|
539
803
|
* The account comes from the control plane, so this also proves the token still
|
|
540
804
|
* works rather than only reporting what is on disk.
|
|
541
805
|
*/
|
|
542
|
-
async function whoami() {
|
|
543
|
-
const { apiUrl,
|
|
544
|
-
const
|
|
545
|
-
|
|
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));
|
|
546
836
|
if (!token) {
|
|
547
837
|
console.log(`account ${dim("no stored token — run `sproutboat login`")}`);
|
|
838
|
+
process.exitCode = 1;
|
|
548
839
|
return;
|
|
549
840
|
}
|
|
550
841
|
|
|
@@ -553,6 +844,7 @@ async function whoami() {
|
|
|
553
844
|
console.log(
|
|
554
845
|
`account ${rose(response.status === 401 ? "token rejected — run `sproutboat login`" : `control plane said ${response.status}`)}`,
|
|
555
846
|
);
|
|
847
|
+
process.exitCode = 1;
|
|
556
848
|
return;
|
|
557
849
|
}
|
|
558
850
|
const account = jsonObject(parseJsonValue(await response.text()));
|
|
@@ -563,8 +855,20 @@ async function whoami() {
|
|
|
563
855
|
}
|
|
564
856
|
|
|
565
857
|
async function deploy(args: string[]) {
|
|
566
|
-
const
|
|
858
|
+
const flags = takeFlags(args, DEPLOY_FLAGS);
|
|
567
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");
|
|
568
872
|
const directory = args.find((arg, index) => !arg.startsWith("--") && index !== artifactIndex + 1);
|
|
569
873
|
let projectName: string;
|
|
570
874
|
let artifactDir: string;
|
|
@@ -606,8 +910,7 @@ async function deploy(args: string[]) {
|
|
|
606
910
|
console.log("\n--dry-run: not uploading.");
|
|
607
911
|
return;
|
|
608
912
|
}
|
|
609
|
-
const { apiUrl, token
|
|
610
|
-
console.log(`endpoint ${apiUrl} (${source})`);
|
|
913
|
+
const { apiUrl, token } = await apiCredentials(flags["--api-url"]);
|
|
611
914
|
const form = new FormData();
|
|
612
915
|
form.set("manifest", new File([await manifest.arrayBuffer()], "manifest.json", { type: "application/json" }));
|
|
613
916
|
form.set("sprout", new File([await sprout.arrayBuffer()], "sprout", { type: "application/octet-stream" }));
|
|
@@ -697,34 +1000,50 @@ async function waitForHealthy(url: string, timeoutMs: number): Promise<boolean>
|
|
|
697
1000
|
return false;
|
|
698
1001
|
}
|
|
699
1002
|
|
|
700
|
-
async function parseLoginArgs(args: string[]) {
|
|
701
|
-
let explicitApiUrl: string | undefined;
|
|
1003
|
+
async function parseLoginArgs(args: string[], options: { allowNewProfile?: boolean } = {}) {
|
|
702
1004
|
let token: string | undefined;
|
|
1005
|
+
let account: string | undefined;
|
|
1006
|
+
// --api-url is a global flag (global-flags.ts); resolveApiUrl reads it.
|
|
703
1007
|
for (let index = 0; index < args.length; index += 2) {
|
|
704
1008
|
const value = args[index + 1];
|
|
705
|
-
if (args[index] === "--
|
|
706
|
-
else if (args[index] === "--
|
|
707
|
-
else
|
|
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
|
+
);
|
|
708
1016
|
}
|
|
709
|
-
const { apiUrl, source } = await resolveApiUrl(
|
|
710
|
-
return { apiUrl, source, token };
|
|
1017
|
+
const { apiUrl, source, target } = await resolveApiUrl(undefined, options);
|
|
1018
|
+
return { apiUrl, source, target, token, account };
|
|
711
1019
|
}
|
|
712
1020
|
|
|
713
|
-
/**
|
|
714
|
-
function
|
|
715
|
-
|
|
716
|
-
|
|
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
|
+
);
|
|
717
1030
|
}
|
|
718
1031
|
|
|
719
1032
|
async function login(args: string[]) {
|
|
720
|
-
const { apiUrl,
|
|
721
|
-
|
|
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
|
+
}
|
|
722
1041
|
// Self-hosted / non-interactive: skip the browser flow and store the token
|
|
723
1042
|
// the admin already holds (e.g. SPROUTBOAT_BOOTSTRAP_TOKEN).
|
|
724
1043
|
if (directToken) {
|
|
725
|
-
const
|
|
1044
|
+
const saved = await saveToken(apiUrl, directToken, kind);
|
|
726
1045
|
console.log(`Saved credentials for ${apiUrl}.`);
|
|
727
|
-
|
|
1046
|
+
reportSaved(saved);
|
|
728
1047
|
return;
|
|
729
1048
|
}
|
|
730
1049
|
const response = await fetch(`${apiUrl}/api/cli/authorizations`, { method: "POST" });
|
|
@@ -757,21 +1076,427 @@ async function login(args: string[]) {
|
|
|
757
1076
|
if (!exchange.ok) fail(`login failed (${exchange.status}): ${result}`);
|
|
758
1077
|
const token = parseToken(result);
|
|
759
1078
|
if (!token) fail("login response did not include a CLI token");
|
|
760
|
-
const
|
|
1079
|
+
const saved = await saveToken(apiUrl, token, "community");
|
|
761
1080
|
console.log(ok("login approved — credentials saved for this endpoint"));
|
|
762
|
-
|
|
1081
|
+
reportSaved(saved);
|
|
763
1082
|
return;
|
|
764
1083
|
}
|
|
765
1084
|
fail("login expired before approval");
|
|
766
1085
|
}
|
|
767
1086
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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");
|
|
772
1401
|
return { apiUrl, token, source };
|
|
773
1402
|
}
|
|
774
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
|
+
|
|
775
1500
|
async function versions(args: string[]) {
|
|
776
1501
|
const sub = args[0];
|
|
777
1502
|
if (sub !== "list" && sub !== "view") {
|
|
@@ -982,7 +1707,11 @@ async function domains(args: string[]) {
|
|
|
982
1707
|
}
|
|
983
1708
|
|
|
984
1709
|
async function secrets(args: string[]) {
|
|
985
|
-
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);
|
|
986
1715
|
|
|
987
1716
|
// `--value` is opt-in; without it the value comes from stdin, so a secret does
|
|
988
1717
|
// not land in shell history. It is also what disambiguates the positionals:
|
|
@@ -990,14 +1719,26 @@ async function secrets(args: string[]) {
|
|
|
990
1719
|
// and read the value as the project directory.
|
|
991
1720
|
let inlineValue: string | undefined;
|
|
992
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>]`;
|
|
993
1723
|
for (let index = 0; index < args.length; index += 1) {
|
|
994
|
-
|
|
995
|
-
|
|
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);
|
|
996
1731
|
}
|
|
997
1732
|
|
|
1733
|
+
if (sub === "bulk") return bulkSecrets(positional, flags, noDeploy);
|
|
998
1734
|
const name = sub === "list" ? undefined : positional.shift();
|
|
999
1735
|
if (sub !== "list" && !name) usageError(`secrets ${sub}: missing <NAME>`, `secrets ${sub} <NAME> [project-dir]`);
|
|
1000
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
|
+
}
|
|
1001
1742
|
const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(positional[0]), apiCredentials()]);
|
|
1002
1743
|
const base = `${apiUrl}/api/projects/${project.config.name}/secrets`;
|
|
1003
1744
|
const auth = { "x-api-key": token };
|
|
@@ -1025,7 +1766,306 @@ async function secrets(args: string[]) {
|
|
|
1025
1766
|
}),
|
|
1026
1767
|
"put rejected",
|
|
1027
1768
|
);
|
|
1028
|
-
console.log(
|
|
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
|
+
}
|
|
1029
2069
|
}
|
|
1030
2070
|
|
|
1031
2071
|
/**
|
|
@@ -1057,6 +2097,9 @@ function idForName(rows: JsonObject[], name: string, product: StorageProduct): s
|
|
|
1057
2097
|
async function storage(key: string, args: string[]) {
|
|
1058
2098
|
const product = STORAGE_PRODUCTS.find((entry) => entry.name === key)!;
|
|
1059
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));
|
|
1060
2103
|
const { apiUrl, token } = await apiCredentials();
|
|
1061
2104
|
const base = `${apiUrl}/api/${product.name}`;
|
|
1062
2105
|
const auth = { "x-api-key": token };
|
|
@@ -1401,8 +2444,14 @@ async function toolchain(args: string[]): Promise<void> {
|
|
|
1401
2444
|
}
|
|
1402
2445
|
|
|
1403
2446
|
/** `sproutboat <command> --help`: that command's full usage line + summary, exit 0. */
|
|
1404
|
-
|
|
2447
|
+
/** The grammar line of a command, for usage errors. */
|
|
2448
|
+
function usageOf(name: string): string {
|
|
1405
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));
|
|
1406
2455
|
if (!found) usage();
|
|
1407
2456
|
console.log(`${dim(`usage: ${CLI_NAME} ${name}${found.args ? ` ${found.args}` : ""}`)}\n\n${found.summary}`);
|
|
1408
2457
|
process.exit(0);
|
|
@@ -1415,7 +2464,7 @@ const FISH_COMPLETION = (names: string) => `complete -c ${CLI_NAME} -n "__fish_u
|
|
|
1415
2464
|
|
|
1416
2465
|
/** `sproutboat complete [shell]`: a static completion script for the top-level commands. */
|
|
1417
2466
|
function complete(shell?: string): void {
|
|
1418
|
-
const names = COMMANDS.
|
|
2467
|
+
const names = COMMANDS.flatMap((c) => [c.name, ...(c.aliases ?? [])]).join(" ");
|
|
1419
2468
|
const scripts = { bash: BASH_COMPLETION(names), zsh: ZSH_COMPLETION(names), fish: FISH_COMPLETION(names) };
|
|
1420
2469
|
const key = shell ?? process.env.SHELL?.split("/").pop() ?? "bash";
|
|
1421
2470
|
const script = key === "bash" || key === "zsh" || key === "fish" ? scripts[key] : undefined;
|
|
@@ -1423,19 +2472,60 @@ function complete(shell?: string): void {
|
|
|
1423
2472
|
console.log(script);
|
|
1424
2473
|
}
|
|
1425
2474
|
|
|
1426
|
-
const
|
|
1427
|
-
|
|
1428
|
-
|
|
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) {
|
|
1429
2481
|
console.log(`sproutboat ${CLI_VERSION}`);
|
|
1430
2482
|
process.exit(0);
|
|
1431
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();
|
|
1432
2494
|
if (args.includes("-h") || args.includes("--help")) commandHelp(command);
|
|
1433
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
|
+
|
|
1434
2524
|
// A local log needs no network: skip the version check for it (#268).
|
|
1435
2525
|
if (!(command === "tail" && args.some((arg) => arg === "--file" || arg.startsWith("--file="))))
|
|
1436
2526
|
await notifyIfOutdated(CLI_VERSION);
|
|
1437
2527
|
|
|
1438
|
-
switch (command) {
|
|
2528
|
+
switch (resolveCommand(command)) {
|
|
1439
2529
|
case "init":
|
|
1440
2530
|
await init(args[0]);
|
|
1441
2531
|
break;
|
|
@@ -1479,7 +2569,8 @@ switch (command) {
|
|
|
1479
2569
|
"build --standalone --target linux-x64 --deploy-files [project-dir]",
|
|
1480
2570
|
);
|
|
1481
2571
|
if (args.includes("--emit-only")) await emitOnly(directory, target, args.includes("--standalone"));
|
|
1482
|
-
else if (args.includes("--standalone"))
|
|
2572
|
+
else if (args.includes("--standalone"))
|
|
2573
|
+
await buildStandaloneBinary(directory, target, withRestore, deployFiles, isReleaseBuild(args));
|
|
1483
2574
|
else await build(directory, target);
|
|
1484
2575
|
break;
|
|
1485
2576
|
}
|
|
@@ -1493,11 +2584,17 @@ switch (command) {
|
|
|
1493
2584
|
await logout(args);
|
|
1494
2585
|
break;
|
|
1495
2586
|
case "whoami":
|
|
1496
|
-
await whoami();
|
|
2587
|
+
await whoami(args);
|
|
2588
|
+
break;
|
|
2589
|
+
case "profile":
|
|
2590
|
+
await profile(args);
|
|
1497
2591
|
break;
|
|
1498
2592
|
case "deploy":
|
|
1499
2593
|
await deploy(args);
|
|
1500
2594
|
break;
|
|
2595
|
+
case "deployments":
|
|
2596
|
+
await deployments(args);
|
|
2597
|
+
break;
|
|
1501
2598
|
case "versions":
|
|
1502
2599
|
await versions(args);
|
|
1503
2600
|
break;
|
|
@@ -1515,7 +2612,13 @@ switch (command) {
|
|
|
1515
2612
|
else await storage(command, args);
|
|
1516
2613
|
break;
|
|
1517
2614
|
case "d1":
|
|
2615
|
+
if (["execute", "migrations"].includes(args[0] ?? "")) await d1Contents(args);
|
|
2616
|
+
else await storage(command, args);
|
|
2617
|
+
break;
|
|
1518
2618
|
case "r2":
|
|
2619
|
+
if (args[0] === "object") await r2Object(args);
|
|
2620
|
+
else await storage(command, args);
|
|
2621
|
+
break;
|
|
1519
2622
|
case "queues":
|
|
1520
2623
|
await storage(command, args);
|
|
1521
2624
|
break;
|