sproutboat 0.53.1 → 0.55.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 +69 -1
- package/README.md +122 -9
- package/SURFACE.md +42 -22
- package/package.json +20 -12
- package/scripts/embed-templates.ts +87 -0
- package/src/account-field.ts +100 -0
- package/src/build.ts +13 -2
- package/src/cloud-bindings.ts +227 -0
- package/src/cloud-deploy.ts +794 -0
- package/src/cloud-domains.ts +55 -0
- package/src/cloud-fake.ts +719 -0
- package/src/cloud-secrets.ts +121 -0
- package/src/cloud.ts +375 -0
- package/src/compile.ts +34 -18
- 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/dev.ts +67 -4
- package/src/fake-login.ts +28 -0
- package/src/global-flags.ts +113 -0
- package/src/init-package.ts +2 -1
- package/src/init.ts +328 -0
- package/src/main.ts +1247 -193
- package/src/options.ts +242 -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 +192 -64
- package/src/target.ts +95 -0
- package/src/templates-embedded.ts +72 -0
- package/src/templates.ts +53 -0
- package/src/toolchain.ts +3 -0
- package/src/types-cmd.ts +193 -0
- package/src/types-gen.ts +104 -68
- package/types/cloudflare-workers.d.ts +43 -0
- package/types/sproutboat.d.ts +160 -3
package/src/main.ts
CHANGED
|
@@ -1,24 +1,150 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { mkdir, open, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, open, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { existsSync, rmSync } from "node:fs";
|
|
4
|
+
import { isIP } from "node:net";
|
|
4
5
|
import { basename, dirname, relative, resolve } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
5
7
|
import { isBoolean, isSafeInteger, isString, jsonObject, parseJsonValue, type JsonObject } from "./json";
|
|
6
8
|
import type { AssetFiles } from "./assets";
|
|
7
|
-
import {
|
|
9
|
+
import { pinBindingId, resourceRefs, type SproutboatConfig } from "./config";
|
|
8
10
|
import { validateHttpSyncSource } from "./source";
|
|
9
11
|
import { buildArtifact, emitGenerated } from "./build";
|
|
10
|
-
import {
|
|
12
|
+
import { LEGACY_TYPES_FILE, TYPES_FILE } from "./types-gen";
|
|
13
|
+
import { checkTypes, parseTypesArgs, planTypes, refreshTypes, removeLegacyTypes, TYPES_USAGE } from "./types-cmd";
|
|
11
14
|
import { bundleHandler, BundleError, type BundleResult } from "./bundle";
|
|
12
15
|
import { runDev } from "./dev";
|
|
13
|
-
import { buildStandalone, emitStandalone } from "./standalone-build";
|
|
16
|
+
import { buildStandalone, emitStandalone, isReleaseBuild } from "./standalone-build";
|
|
14
17
|
import { hostTarget, validateManifest, type ArtifactManifest } from "./manifest";
|
|
15
18
|
import { CLI_VERSION, printDeployReport } from "./report";
|
|
16
|
-
import {
|
|
17
|
-
|
|
19
|
+
import {
|
|
20
|
+
activeProfileName,
|
|
21
|
+
forgetToken,
|
|
22
|
+
listProfiles,
|
|
23
|
+
PROFILE_NAME,
|
|
24
|
+
savedProfile,
|
|
25
|
+
savedProfileName,
|
|
26
|
+
savedToken,
|
|
27
|
+
saveToken,
|
|
28
|
+
selectProfile,
|
|
29
|
+
useProfile,
|
|
30
|
+
} from "./credentials";
|
|
31
|
+
import { extractGlobalFlags, globalFlags, locateProject, setGlobalFlags } from "./global-flags";
|
|
32
|
+
import { checkOptions, ENDPOINT_COMMANDS } from "./options";
|
|
33
|
+
import { chooseTarget, TargetError, type Target } from "./target";
|
|
34
|
+
import {
|
|
35
|
+
ASSETS_ONLY_MAIN,
|
|
36
|
+
assetsOnlySource,
|
|
37
|
+
configAccount,
|
|
38
|
+
configMigrationsDir,
|
|
39
|
+
parseProjectConfig,
|
|
40
|
+
} from "./account-field";
|
|
41
|
+
import { DATA_ERROR_STATUSES } from "./data-errors";
|
|
42
|
+
import {
|
|
43
|
+
cloudLogout,
|
|
44
|
+
CloudError,
|
|
45
|
+
DEFAULT_API_URL,
|
|
46
|
+
detectKind,
|
|
47
|
+
parseBody,
|
|
48
|
+
envToken,
|
|
49
|
+
fetchWhoami,
|
|
50
|
+
loginAndStore,
|
|
51
|
+
resolveSession,
|
|
52
|
+
storeGivenKey,
|
|
53
|
+
type CloudIo,
|
|
54
|
+
} from "./cloud";
|
|
55
|
+
import {
|
|
56
|
+
buildAndDeploy,
|
|
57
|
+
buildAssetBundle,
|
|
58
|
+
checkServiceBindings,
|
|
59
|
+
CLOUD_PROJECT_RE,
|
|
60
|
+
compileModule,
|
|
61
|
+
deployToCloud,
|
|
62
|
+
formatKiB,
|
|
63
|
+
hostFor,
|
|
64
|
+
Interrupted,
|
|
65
|
+
makeCall,
|
|
66
|
+
tailBuild,
|
|
67
|
+
waitForApply,
|
|
68
|
+
} from "./cloud-deploy";
|
|
69
|
+
import {
|
|
70
|
+
checkDurableObjectExports,
|
|
71
|
+
describeCloudBindings,
|
|
72
|
+
mapCloudBindings,
|
|
73
|
+
type CloudBindings,
|
|
74
|
+
} from "./cloud-bindings";
|
|
75
|
+
import {
|
|
76
|
+
APPLY_USAGE,
|
|
77
|
+
applyMigrations,
|
|
78
|
+
CREATE_USAGE,
|
|
79
|
+
createMigration,
|
|
80
|
+
D1UsageError,
|
|
81
|
+
DEFAULT_MIGRATIONS_DIR,
|
|
82
|
+
EXECUTE_USAGE,
|
|
83
|
+
executeSql,
|
|
84
|
+
LIST_USAGE,
|
|
85
|
+
listMigrations,
|
|
86
|
+
localDatabasePath,
|
|
87
|
+
localRunner,
|
|
88
|
+
parseD1Flags,
|
|
89
|
+
pickBinding,
|
|
90
|
+
remoteRunner,
|
|
91
|
+
type D1Io,
|
|
92
|
+
type Runner,
|
|
93
|
+
} from "./d1-cli";
|
|
94
|
+
import {
|
|
95
|
+
deleteCloudSecret,
|
|
96
|
+
listCloudSecrets,
|
|
97
|
+
patchCloudSecrets,
|
|
98
|
+
putCloudSecret,
|
|
99
|
+
readSecretsFile,
|
|
100
|
+
} from "./cloud-secrets";
|
|
101
|
+
import { cloudDomainsUnsupported, describeCloudRoutes, listCloudRoutes } from "./cloud-domains";
|
|
102
|
+
import { parseBulkSecrets, type BulkPlan } from "./secrets-bulk";
|
|
103
|
+
import {
|
|
104
|
+
cloudTarget,
|
|
105
|
+
communityTarget,
|
|
106
|
+
DELETE_USAGE as R2_DELETE_USAGE,
|
|
107
|
+
deleteObject,
|
|
108
|
+
GET_USAGE as R2_GET_USAGE,
|
|
109
|
+
getObject,
|
|
110
|
+
OBJECT_MAX_BYTES,
|
|
111
|
+
readCapped,
|
|
112
|
+
parseObjectArgs,
|
|
113
|
+
parseObjectPath,
|
|
114
|
+
PUT_USAGE as R2_PUT_USAGE,
|
|
115
|
+
putObject,
|
|
116
|
+
R2UsageError,
|
|
117
|
+
type ObjectTarget,
|
|
118
|
+
} from "./r2-objects";
|
|
119
|
+
import {
|
|
120
|
+
COMMUNITY_TAIL,
|
|
121
|
+
DeploymentsUsageError,
|
|
122
|
+
fetchDeployments,
|
|
123
|
+
fetchLive,
|
|
124
|
+
formatCommunityVersion,
|
|
125
|
+
formatDeployment,
|
|
126
|
+
formatStatus,
|
|
127
|
+
LIST_USAGE as DEPLOYMENTS_LIST_USAGE,
|
|
128
|
+
parseCommunityVersions,
|
|
129
|
+
parseDeploymentsArgs,
|
|
130
|
+
STATUS_USAGE as DEPLOYMENTS_STATUS_USAGE,
|
|
131
|
+
TAIL_USAGE as DEPLOYMENTS_TAIL_USAGE,
|
|
132
|
+
tailSummary,
|
|
133
|
+
} from "./deployments-cli";
|
|
134
|
+
import { hostname as osHostname, platform as osPlatform, arch as osArch } from "node:os";
|
|
135
|
+
import {
|
|
136
|
+
CLI_NAME,
|
|
137
|
+
COMMANDS,
|
|
138
|
+
helpText,
|
|
139
|
+
resolveCommand,
|
|
140
|
+
STORAGE_PRODUCTS,
|
|
141
|
+
STORAGE_VERBS,
|
|
142
|
+
type StorageProduct,
|
|
143
|
+
} from "./surface";
|
|
18
144
|
import { notifyIfOutdated } from "./update-check";
|
|
19
145
|
import { controlVersionWarning } from "./api-version";
|
|
20
146
|
import { amber, bold, dim, leaf, ok, rose } from "./style";
|
|
21
|
-
import {
|
|
147
|
+
import { InitError, parseInitArgs, runInit, terminalInitIo } from "./init";
|
|
22
148
|
import { pathToFileURL } from "node:url";
|
|
23
149
|
import { constants as osConstants } from "node:os";
|
|
24
150
|
import { inspectToolchain } from "./toolchain";
|
|
@@ -41,26 +167,56 @@ if (internalModeAt > 0) {
|
|
|
41
167
|
process.exit(0);
|
|
42
168
|
}
|
|
43
169
|
|
|
44
|
-
const defaultApiUrl =
|
|
45
|
-
|
|
46
|
-
type ApiUrlSource = "SPROUTBOAT_API_URL" | "--api-url" | "saved active endpoint" | "default";
|
|
170
|
+
const defaultApiUrl = DEFAULT_API_URL;
|
|
47
171
|
|
|
48
172
|
/**
|
|
49
|
-
* baronunread/sproutboat#204 — the single place `
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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.
|
|
173
|
+
* baronunread/sproutboat#204 — the single place the endpoint and the stored profile are chosen. `chooseTarget`
|
|
174
|
+
* (target.ts) holds the precedence: the flags, then the environment, then the machine's active profile, then the
|
|
175
|
+
* default endpoint, and a profile and a URL that name different places are an error. The choice is also what the
|
|
176
|
+
* credential lookups use for the rest of the run.
|
|
56
177
|
*/
|
|
57
|
-
async function resolveApiUrl(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
178
|
+
async function resolveApiUrl(
|
|
179
|
+
explicitApiUrl?: string,
|
|
180
|
+
options: { allowNewProfile?: boolean } = {},
|
|
181
|
+
): Promise<{ apiUrl: string; source: string; target: Target }> {
|
|
182
|
+
const profiles: Record<string, { apiUrl: string }> = {};
|
|
183
|
+
for (const { name, profile } of await listProfiles()) profiles[name] = { apiUrl: profile.apiUrl };
|
|
184
|
+
let target: Target;
|
|
185
|
+
try {
|
|
186
|
+
target = chooseTarget({
|
|
187
|
+
flagUrl: explicitApiUrl ?? globalFlags().apiUrl,
|
|
188
|
+
flagProfile: globalFlags().profile,
|
|
189
|
+
envUrl: process.env.SPROUTBOAT_API_URL || undefined,
|
|
190
|
+
envProfile: process.env.SPROUTBOAT_PROFILE || undefined,
|
|
191
|
+
profiles,
|
|
192
|
+
activeProfile: await activeProfileName(),
|
|
193
|
+
defaultUrl: defaultApiUrl,
|
|
194
|
+
allowNew: options.allowNewProfile,
|
|
195
|
+
});
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (error instanceof TargetError) fail(error.message);
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
selectProfile(target.profile?.name);
|
|
201
|
+
return { apiUrl: target.apiUrl, source: target.source, target };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The two lines that say where a command is about to act: which profile, and which endpoint. */
|
|
205
|
+
async function printTarget(target: Target): Promise<void> {
|
|
206
|
+
const profile = target.profile;
|
|
207
|
+
const name = profile?.name ?? (await savedProfileName(target.apiUrl));
|
|
208
|
+
const why = profile
|
|
209
|
+
? `${profile.source}${profile.exists ? "" : ", new"}`
|
|
210
|
+
: name
|
|
211
|
+
? "the stored login for this endpoint"
|
|
212
|
+
: "nothing stored for this endpoint";
|
|
213
|
+
const credential = envToken();
|
|
214
|
+
console.log(
|
|
215
|
+
`profile ${name ?? "-"} ${dim(`(${why}${credential ? `; the credential comes from ${credential.source}` : ""})`)}`,
|
|
216
|
+
);
|
|
217
|
+
const sourceLabel =
|
|
218
|
+
target.source === "profile" ? `profile ${profile?.name}` : target.source === "default" ? "default" : target.source;
|
|
219
|
+
console.log(`endpoint ${target.apiUrl} ${dim(`(${sourceLabel})`)}`);
|
|
64
220
|
}
|
|
65
221
|
|
|
66
222
|
/** Warn at most once per run: every API response carries the headers, and one
|
|
@@ -169,28 +325,6 @@ function parseToken(source: string): string | undefined {
|
|
|
169
325
|
return record && isString(record.token) ? record.token : undefined;
|
|
170
326
|
}
|
|
171
327
|
|
|
172
|
-
const starterConfig = (name: string) => `{
|
|
173
|
-
"$schema": "https://sproutboat.com/schema.json",
|
|
174
|
-
"name": "${name}",
|
|
175
|
-
"main": "src/index.js",
|
|
176
|
-
"compatibility_date": "2026-09-28"
|
|
177
|
-
}
|
|
178
|
-
`;
|
|
179
|
-
const starterHandler = `export default {
|
|
180
|
-
fetch(request, env, ctx) {
|
|
181
|
-
return new Response("hello from Sproutboat");
|
|
182
|
-
}
|
|
183
|
-
};
|
|
184
|
-
`;
|
|
185
|
-
// .sproutboat/ holds build output (dist/) and `dev`'s local broker state
|
|
186
|
-
// (dev/, including its SQLite files) — neither belongs in version control.
|
|
187
|
-
// .dev.vars carries secret values for `sproutboat dev`, same convention as
|
|
188
|
-
// Wrangler's file of the same name.
|
|
189
|
-
const starterGitignore = `.sproutboat/
|
|
190
|
-
.dev.vars
|
|
191
|
-
node_modules/
|
|
192
|
-
`;
|
|
193
|
-
|
|
194
328
|
/** An operational failure — the command was invoked correctly but could not complete. Exit 1. */
|
|
195
329
|
function fail(message: string): never {
|
|
196
330
|
console.error(`${rose("✗")} ${message}`);
|
|
@@ -207,27 +341,55 @@ function usageError(what: string, usage: string): never {
|
|
|
207
341
|
process.exit(2);
|
|
208
342
|
}
|
|
209
343
|
|
|
344
|
+
/** The project directory and config file, from a positional directory and `--config`. */
|
|
345
|
+
function locate(directory?: string) {
|
|
346
|
+
try {
|
|
347
|
+
return locateProject(directory);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
if (error instanceof Error) return fail(error.message);
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
210
354
|
/** Read the project identity and configuration without touching application code.
|
|
211
355
|
* Management commands must remain usable when a local checkout is incomplete. */
|
|
212
|
-
async function readProjectConfig(directory
|
|
213
|
-
const projectDirectory =
|
|
214
|
-
const configPath = resolve(projectDirectory, "sproutboat.jsonc");
|
|
356
|
+
async function readProjectConfig(directory?: string) {
|
|
357
|
+
const { directory: projectDirectory, configPath } = locate(directory);
|
|
215
358
|
let configSource: string;
|
|
216
359
|
try {
|
|
217
360
|
configSource = await readFile(configPath, "utf8");
|
|
218
361
|
} catch {
|
|
219
|
-
fail(
|
|
362
|
+
fail(
|
|
363
|
+
globalFlags().config === undefined
|
|
364
|
+
? `no sproutboat.jsonc found in ${projectDirectory}`
|
|
365
|
+
: `no config file at ${configPath}`,
|
|
366
|
+
);
|
|
220
367
|
}
|
|
221
|
-
const parsed =
|
|
368
|
+
const parsed = parseProjectConfig(configSource);
|
|
222
369
|
if (!parsed.ok) fail(parsed.errors.join("\n"));
|
|
223
370
|
for (const warning of parsed.warnings) console.warn(amber(`! ${warning}`));
|
|
224
|
-
return {
|
|
371
|
+
return {
|
|
372
|
+
directory: projectDirectory,
|
|
373
|
+
config: parsed.value,
|
|
374
|
+
account: configAccount(configSource),
|
|
375
|
+
migrationsDir: configMigrationsDir(configSource),
|
|
376
|
+
};
|
|
225
377
|
}
|
|
226
378
|
|
|
379
|
+
/** What to call the entry in messages: its path, or "assets only" when the project has no `main`. */
|
|
380
|
+
const entryLabel = (config: { main: string }) => (config.main === ASSETS_ONLY_MAIN ? "assets only" : config.main);
|
|
381
|
+
|
|
227
382
|
/** Full build input: configuration plus entry point, resolved imports and checks. */
|
|
228
|
-
async function readProject(directory
|
|
383
|
+
async function readProject(directory?: string) {
|
|
229
384
|
const project = await readProjectConfig(directory);
|
|
230
|
-
|
|
385
|
+
let sourcePath = resolve(project.directory, project.config.main);
|
|
386
|
+
if (project.config.main === ASSETS_ONLY_MAIN) {
|
|
387
|
+
// A Worker with only static assets: there is no entry file, so the small one that hands every miss to the assets is written
|
|
388
|
+
// into .sproutboat/ (the local state folder) and bundled like any other.
|
|
389
|
+
sourcePath = resolve(project.directory, ".sproutboat", "assets-only.js");
|
|
390
|
+
await mkdir(dirname(sourcePath), { recursive: true });
|
|
391
|
+
await writeFile(sourcePath, assetsOnlySource(project.config.assets?.binding ?? "ASSETS"));
|
|
392
|
+
}
|
|
231
393
|
let source: string;
|
|
232
394
|
try {
|
|
233
395
|
source = await readFile(sourcePath, "utf8");
|
|
@@ -250,97 +412,57 @@ async function readProject(directory = process.cwd()) {
|
|
|
250
412
|
return { ...project, sourcePath, source, bundle };
|
|
251
413
|
}
|
|
252
414
|
|
|
253
|
-
async function init(
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const directory = resolve(process.cwd(), name);
|
|
257
|
-
const configPath = resolve(directory, "sproutboat.jsonc");
|
|
258
|
-
const handlerPath = resolve(directory, "src/index.js");
|
|
259
|
-
// Check both targets before writing either — `name` can collide with an
|
|
260
|
-
// unrelated existing directory, and a second `wx` write failing partway
|
|
261
|
-
// through used to crash with a raw EEXIST stack trace after already having
|
|
262
|
-
// created sproutboat.jsonc, leaving a half-scaffolded project behind.
|
|
263
|
-
for (const [path, label] of [
|
|
264
|
-
[configPath, "sproutboat.jsonc"],
|
|
265
|
-
[handlerPath, "src/index.js"],
|
|
266
|
-
] as const) {
|
|
267
|
-
if (await Bun.file(path).exists()) fail(`${basename(directory)} already contains ${label}`);
|
|
268
|
-
}
|
|
269
|
-
// Plan package.json before writing anything, so an unreadable one stops init
|
|
270
|
-
// instead of leaving a half-scaffolded project.
|
|
271
|
-
const packagePath = resolve(directory, "package.json");
|
|
272
|
-
let existingPackage: string | undefined;
|
|
415
|
+
async function init(args: string[]) {
|
|
416
|
+
const parsed = parseInitArgs(args);
|
|
417
|
+
if ("error" in parsed) usageError(parsed.error, usageOf("init"));
|
|
273
418
|
try {
|
|
274
|
-
|
|
275
|
-
} catch {
|
|
276
|
-
existingPackage = undefined;
|
|
277
|
-
}
|
|
278
|
-
let packagePlan: ReturnType<typeof planPackageJson>;
|
|
279
|
-
try {
|
|
280
|
-
packagePlan = planPackageJson(name, existingPackage, CLI_VERSION);
|
|
419
|
+
await runInit(parsed, terminalInitIo(CLI_VERSION));
|
|
281
420
|
} catch (cause) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
await writeFile(configPath, starterConfig(name), { flag: "wx" });
|
|
286
|
-
await writeFile(handlerPath, starterHandler, { flag: "wx" });
|
|
287
|
-
console.log(`Created ${basename(directory)}/sproutboat.jsonc`);
|
|
288
|
-
console.log(`Created ${basename(directory)}/src/index.js`);
|
|
289
|
-
// Unlike the two files above, an existing .gitignore here is not a sign this
|
|
290
|
-
// isn't a fresh project (`name` can collide with an unrelated directory) —
|
|
291
|
-
// leave it alone rather than failing init or clobbering it.
|
|
292
|
-
const gitignorePath = resolve(directory, ".gitignore");
|
|
293
|
-
if (!(await Bun.file(gitignorePath).exists())) {
|
|
294
|
-
await writeFile(gitignorePath, starterGitignore, { flag: "wx" });
|
|
295
|
-
console.log(`Created ${basename(directory)}/.gitignore`);
|
|
296
|
-
}
|
|
297
|
-
if (packagePlan.created || packagePlan.addedDependency || packagePlan.addedScripts.length > 0) {
|
|
298
|
-
await writeFile(packagePath, packagePlan.text);
|
|
299
|
-
console.log(`${packagePlan.created ? "Created" : "Updated"} ${basename(directory)}/package.json`);
|
|
300
|
-
}
|
|
301
|
-
for (const script of packagePlan.skippedScripts)
|
|
302
|
-
console.log(
|
|
303
|
-
amber(
|
|
304
|
-
` ! package.json already has a "${script}" script; left it as is (sproutboat's would be "sproutboat ${script}")`,
|
|
305
|
-
),
|
|
306
|
-
);
|
|
307
|
-
const manager = detectPackageManager(directory);
|
|
308
|
-
console.log(`\nNext: cd ${basename(directory)} && ${installCommand(manager)} && ${runScriptCommand(manager, "dev")}`);
|
|
309
|
-
console.log(dim(`After "sproutboat login", ship it with ${runScriptCommand(manager, "deploy")}.`));
|
|
421
|
+
if (cause instanceof InitError) fail(cause.message);
|
|
422
|
+
throw cause;
|
|
423
|
+
}
|
|
310
424
|
}
|
|
311
425
|
|
|
312
426
|
async function check(directory?: string) {
|
|
313
427
|
const project = await readProject(directory);
|
|
314
|
-
console.log(ok(`check passed — ${project.config.name} (${project.config
|
|
428
|
+
console.log(ok(`check passed — ${project.config.name} (${entryLabel(project.config)}, native-fetch)`));
|
|
315
429
|
}
|
|
316
430
|
|
|
317
|
-
async function types(
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
431
|
+
async function types(args: string[]) {
|
|
432
|
+
const parsed = parseTypesArgs(args);
|
|
433
|
+
if ("error" in parsed) usageError(parsed.error, TYPES_USAGE);
|
|
434
|
+
// A positional that is a directory is the project directory, as it was before the positional became the output file.
|
|
435
|
+
const project = await readProjectConfig(
|
|
436
|
+
parsed.positional !== undefined && existsSync(parsed.positional) && (await stat(parsed.positional)).isDirectory()
|
|
437
|
+
? parsed.positional
|
|
438
|
+
: undefined,
|
|
439
|
+
);
|
|
440
|
+
const plan = await planTypes(parsed, project.config, project.directory);
|
|
441
|
+
const shown = relative(process.cwd(), plan.path) || TYPES_FILE;
|
|
442
|
+
if (parsed.check) {
|
|
443
|
+
const state = await checkTypes(plan);
|
|
444
|
+
if (state === "current") {
|
|
445
|
+
console.log(ok(`types at ${shown} are up to date`));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
console.error(
|
|
449
|
+
`${rose("✗")} ${state === "missing" ? `${shown} does not exist` : `${shown} is out of date`}. Run \`sproutboat types\` to write it.`,
|
|
450
|
+
);
|
|
451
|
+
process.exit(1);
|
|
452
|
+
}
|
|
453
|
+
await mkdir(dirname(plan.path), { recursive: true });
|
|
454
|
+
await writeFile(plan.path, plan.text);
|
|
455
|
+
console.log(ok(`wrote ${shown}`));
|
|
456
|
+
if (await removeLegacyTypes(project.directory))
|
|
457
|
+
console.log(dim(` removed ${LEGACY_TYPES_FILE}, which this file replaces`));
|
|
458
|
+
console.log(dim(' Commit it, and add it to the "include" of your tsconfig. Rerun after changing sproutboat.jsonc.'));
|
|
323
459
|
}
|
|
324
460
|
|
|
325
|
-
/**
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
* differs, which keeps it out of the way of git and of file watchers.
|
|
331
|
-
*/
|
|
332
|
-
async function refreshTypes(projectDir: string, config: SproutboatConfig): Promise<void> {
|
|
333
|
-
const path = resolve(projectDir, TYPES_FILE);
|
|
334
|
-
const wanted = generateTypes(config);
|
|
335
|
-
try {
|
|
336
|
-
if ((await readFile(path, "utf8")) === wanted) return;
|
|
337
|
-
} catch {
|
|
338
|
-
// No file yet: only write one if the project already opted in by having a
|
|
339
|
-
// tsconfig. Generating into a plain JavaScript project is noise.
|
|
340
|
-
if (!(await Bun.file(resolve(projectDir, "tsconfig.json")).exists())) return;
|
|
341
|
-
}
|
|
342
|
-
await writeFile(path, wanted);
|
|
343
|
-
console.log(dim(` ${TYPES_FILE} refreshed from sproutboat.jsonc`));
|
|
461
|
+
/** `dev` and `deploy`: keep the default generated types current (types-cmd.ts says when it does and does not). */
|
|
462
|
+
async function refreshProjectTypes(projectDir: string, config: SproutboatConfig): Promise<void> {
|
|
463
|
+
const { wrote, removedLegacy } = await refreshTypes(projectDir, config);
|
|
464
|
+
if (wrote) console.log(dim(` ${TYPES_FILE} refreshed from sproutboat.jsonc`));
|
|
465
|
+
if (removedLegacy) console.log(dim(` removed ${LEGACY_TYPES_FILE}, which it replaces`));
|
|
344
466
|
}
|
|
345
467
|
|
|
346
468
|
async function build(directory?: string, target: BuildTarget = "linux-x86_64") {
|
|
@@ -378,9 +500,10 @@ async function buildStandaloneBinary(
|
|
|
378
500
|
target: BuildTarget,
|
|
379
501
|
withRestore = false,
|
|
380
502
|
deployFiles = false,
|
|
503
|
+
release = false,
|
|
381
504
|
) {
|
|
382
505
|
const project = await readProject(directory);
|
|
383
|
-
console.log(dim(`Building a standalone ${project.config.name} (${target})…`));
|
|
506
|
+
console.log(dim(`Building a standalone ${project.config.name} (${target}, ${release ? "release, LTO" : "no LTO"})…`));
|
|
384
507
|
try {
|
|
385
508
|
const result = await buildStandalone({
|
|
386
509
|
projectDir: project.directory,
|
|
@@ -389,9 +512,14 @@ async function buildStandaloneBinary(
|
|
|
389
512
|
source: project.bundle.code,
|
|
390
513
|
target,
|
|
391
514
|
withRestore,
|
|
515
|
+
release,
|
|
392
516
|
});
|
|
393
517
|
console.log(ok(`built ${result.outPath} (${(result.bytes / 1_000_000).toFixed(1)} MB)`));
|
|
394
518
|
console.log(dim(` run it: ${result.outPath} · state lands in ./${project.config.name}.data`));
|
|
519
|
+
if (!release)
|
|
520
|
+
console.log(
|
|
521
|
+
dim(" built without LTO (faster to build, slower to serve); add --release for the binary you publish"),
|
|
522
|
+
);
|
|
395
523
|
if (target === "linux-arm64") console.log(dim(" linux-arm64: runs on an aarch64 Linux box, not on this machine"));
|
|
396
524
|
if (deployFiles && isLinuxTarget(target)) {
|
|
397
525
|
const written = await writeDeployFiles({ name: project.config.name, target, binary: result.outPath });
|
|
@@ -430,33 +558,61 @@ async function emitOnly(directory: string | undefined, target: BuildTarget, stan
|
|
|
430
558
|
}
|
|
431
559
|
}
|
|
432
560
|
|
|
561
|
+
/** The standalone source unit the Cloud compile starts from (what `build --standalone --emit-only` prints). */
|
|
562
|
+
async function emitStandaloneUnit(directory: string | undefined): Promise<string> {
|
|
563
|
+
const project = await readProject(directory);
|
|
564
|
+
try {
|
|
565
|
+
const { generatedPath } = await emitStandalone(
|
|
566
|
+
{
|
|
567
|
+
projectDir: project.directory,
|
|
568
|
+
config: project.config,
|
|
569
|
+
sourcePath: project.sourcePath,
|
|
570
|
+
source: project.bundle.code,
|
|
571
|
+
target: "linux-x86_64",
|
|
572
|
+
},
|
|
573
|
+
{ forCloud: true },
|
|
574
|
+
);
|
|
575
|
+
return generatedPath;
|
|
576
|
+
} catch (error) {
|
|
577
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
433
581
|
/** #62 — build for this machine, run it against a real broker, rebuild on save. */
|
|
434
|
-
const DEV_USAGE =
|
|
582
|
+
const DEV_USAGE =
|
|
583
|
+
"dev [project-dir] [--port <n>] [--ip <address>] [--no-watch] [-e, --env <name>] [--env-file <file>]...";
|
|
435
584
|
|
|
436
585
|
async function dev(args: string[]) {
|
|
437
586
|
let directory: string | undefined;
|
|
438
587
|
let portArg: string | undefined;
|
|
439
|
-
let
|
|
440
|
-
|
|
588
|
+
let host: string | undefined;
|
|
589
|
+
// --env and --env-file are global flags, already taken out of `args` (global-flags.ts).
|
|
590
|
+
const envName = globalFlags().env;
|
|
591
|
+
const envFiles = globalFlags().envFiles.map((file) => resolve(file));
|
|
441
592
|
for (let at = 0; at < args.length; at += 1) {
|
|
442
593
|
const arg = args[at];
|
|
443
594
|
if (arg === "--no-watch") continue;
|
|
444
|
-
if (arg === "--port"
|
|
595
|
+
if (arg === "--port") {
|
|
596
|
+
const value = args[at + 1];
|
|
597
|
+
if (value === undefined || value.startsWith("--")) usageError(`${arg} needs a value`, DEV_USAGE);
|
|
598
|
+
at += 1;
|
|
599
|
+
portArg = value;
|
|
600
|
+
} else if (arg === "--ip") {
|
|
445
601
|
const value = args[at + 1];
|
|
446
602
|
if (value === undefined || value.startsWith("--")) usageError(`${arg} needs a value`, DEV_USAGE);
|
|
447
603
|
at += 1;
|
|
448
|
-
|
|
449
|
-
else if (arg === "--env") envName = value;
|
|
450
|
-
else envFiles.push(resolve(value));
|
|
604
|
+
host = value;
|
|
451
605
|
} else if (!arg.startsWith("--") && directory === undefined) directory = arg;
|
|
452
606
|
}
|
|
453
607
|
const port = Number(portArg ?? 8787);
|
|
454
608
|
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) usageError(`invalid --port: ${portArg}`, DEV_USAGE);
|
|
609
|
+
if (host !== undefined && !isIP(host) && host !== "localhost")
|
|
610
|
+
usageError(`invalid --ip: ${host} (an IPv4 or IPv6 address, or localhost)`, DEV_USAGE);
|
|
455
611
|
if (envName !== undefined && !/^[A-Za-z0-9_-]+$/.test(envName))
|
|
456
612
|
usageError("--env takes a name (letters, digits, - and _)", DEV_USAGE);
|
|
457
613
|
for (const file of envFiles) if (!existsSync(file)) fail(`env file not found: ${file}`);
|
|
458
614
|
const project = await readProject(directory);
|
|
459
|
-
await
|
|
615
|
+
await refreshProjectTypes(project.directory, project.config);
|
|
460
616
|
console.log(dim(`Building ${project.config.name} for this machine (${hostTarget()})…`));
|
|
461
617
|
await runDev({
|
|
462
618
|
projectDir: project.directory,
|
|
@@ -464,6 +620,7 @@ async function dev(args: string[]) {
|
|
|
464
620
|
sourcePath: project.sourcePath,
|
|
465
621
|
source: project.bundle.code,
|
|
466
622
|
port,
|
|
623
|
+
host,
|
|
467
624
|
envFiles,
|
|
468
625
|
envName,
|
|
469
626
|
watch: !args.includes("--no-watch"),
|
|
@@ -471,7 +628,7 @@ async function dev(args: string[]) {
|
|
|
471
628
|
// files changed, so the bundle captured at startup is stale by definition.
|
|
472
629
|
rebuild: async () => {
|
|
473
630
|
const next = await readProject(directory);
|
|
474
|
-
await
|
|
631
|
+
await refreshProjectTypes(next.directory, next.config);
|
|
475
632
|
return { config: next.config, sourcePath: next.sourcePath, source: next.bundle.code };
|
|
476
633
|
},
|
|
477
634
|
});
|
|
@@ -481,9 +638,11 @@ async function dev(args: string[]) {
|
|
|
481
638
|
async function run(args: string[]) {
|
|
482
639
|
const result = parseRunArgs(args);
|
|
483
640
|
if ("error" in result) usageError(result.error, RUN_USAGE);
|
|
641
|
+
// --env-file is a global flag, already taken out of `args` (global-flags.ts).
|
|
642
|
+
result.args.envFiles.unshift(...globalFlags().envFiles);
|
|
484
643
|
let plan;
|
|
485
644
|
try {
|
|
486
|
-
plan = await planRun(result.args, { cwd: process.cwd(), inherited: process.env });
|
|
645
|
+
plan = await planRun(result.args, { cwd: process.cwd(), inherited: process.env, config: globalFlags().config });
|
|
487
646
|
} catch (error) {
|
|
488
647
|
if (error instanceof RunError || error instanceof EnvFileError || error instanceof MissingSecretsError)
|
|
489
648
|
fail(error.message);
|
|
@@ -517,10 +676,10 @@ const PROVISION_FIELDS = [
|
|
|
517
676
|
* deploy, and the id is written back into `sproutboat.jsonc`. `--no-provision`
|
|
518
677
|
* skips this — those bindings then get an ephemeral deploy-scoped store.
|
|
519
678
|
*/
|
|
520
|
-
async function provisionBindings(directory
|
|
521
|
-
const configPath =
|
|
679
|
+
async function provisionBindings(directory?: string): Promise<void> {
|
|
680
|
+
const { configPath } = locate(directory);
|
|
522
681
|
let source = await readFile(configPath, "utf8");
|
|
523
|
-
const parsed =
|
|
682
|
+
const parsed = parseProjectConfig(source);
|
|
524
683
|
if (!parsed.ok) return; // build() re-reads and reports the config error
|
|
525
684
|
|
|
526
685
|
const bare: Array<{ field: string; kind: string; binding: string }> = [];
|
|
@@ -552,7 +711,25 @@ async function provisionBindings(directory = process.cwd()): Promise<void> {
|
|
|
552
711
|
|
|
553
712
|
/** #79 — wrangler parity: drop the stored credential for an endpoint. */
|
|
554
713
|
async function logout(args: string[]) {
|
|
555
|
-
const { apiUrl } = await parseLoginArgs(args);
|
|
714
|
+
const { apiUrl, account, target } = await parseLoginArgs(args);
|
|
715
|
+
await printTarget(target);
|
|
716
|
+
if ((await savedProfile(apiUrl))?.kind === "cloud") {
|
|
717
|
+
const outcome = await guard(() => cloudLogout(apiUrl, account));
|
|
718
|
+
if (outcome.kind === "none") {
|
|
719
|
+
console.log(`no stored login for ${account ? `the team "${account}" on ` : ""}${apiUrl}`);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
const how =
|
|
723
|
+
outcome.server === "revoked"
|
|
724
|
+
? "revoked its key on the server"
|
|
725
|
+
: outcome.server === "already-gone"
|
|
726
|
+
? "its key was already revoked on the server"
|
|
727
|
+
: "could not reach the server, so the key may still be live: delete it under Settings, API keys";
|
|
728
|
+
console.log(ok(`logged out of ${outcome.slug} on ${apiUrl} (${how})`));
|
|
729
|
+
if (outcome.remaining.length > 0) console.log(dim(`still logged in to: ${outcome.remaining.join(", ")}`));
|
|
730
|
+
if (envToken()) console.log(dim("SPROUTBOAT_API_TOKEN is set in this shell and still applies"));
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
556
733
|
const result = await forgetToken(apiUrl);
|
|
557
734
|
if (!result.removed) {
|
|
558
735
|
console.log(`no stored credential for ${apiUrl}`);
|
|
@@ -563,17 +740,75 @@ async function logout(args: string[]) {
|
|
|
563
740
|
console.log(dim("no active endpoint remains — the next command falls through to the default"));
|
|
564
741
|
}
|
|
565
742
|
|
|
743
|
+
/** `profile list` and `profile use <name>`: the stored profiles, and which one a command uses by default. */
|
|
744
|
+
async function profile(args: string[]) {
|
|
745
|
+
const sub = args[0] ?? "list";
|
|
746
|
+
if (sub === "use") {
|
|
747
|
+
const name = args[1];
|
|
748
|
+
if (!name || args.length > 2) usageError("profile use needs a profile name", "profile <list | use <name>>");
|
|
749
|
+
if (!(await useProfile(name))) {
|
|
750
|
+
const known = (await listProfiles()).map((entry) => entry.name);
|
|
751
|
+
fail(
|
|
752
|
+
`no profile named "${name}"${known.length > 0 ? ` (profiles: ${known.join(", ")})` : ""}; create it with \`sproutboat login --profile ${name}\``,
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
console.log(ok(`${name} is now the default profile`));
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
if (sub !== "list" || args.length > 1)
|
|
759
|
+
usageError(`profile: unknown subcommand "${sub}"`, "profile <list | use <name>>");
|
|
760
|
+
const entries = await listProfiles();
|
|
761
|
+
if (entries.length === 0) {
|
|
762
|
+
console.log("no profiles stored; run `sproutboat login`");
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const width = Math.max(...entries.map((entry) => entry.name.length));
|
|
766
|
+
for (const { name, active, profile: stored } of entries) {
|
|
767
|
+
const kind = stored.kind === "cloud" ? "Cloud" : stored.kind === "community" ? "Community" : "";
|
|
768
|
+
const team = stored.kind === "cloud" && stored.team ? ` team ${stored.team}` : "";
|
|
769
|
+
console.log(`${active ? "*" : " "} ${name.padEnd(width)} ${stored.apiUrl} ${dim(`${kind}${team}`.trim())}`);
|
|
770
|
+
}
|
|
771
|
+
console.log(dim("* is the default profile; pick another for one command with --profile <name>"));
|
|
772
|
+
}
|
|
773
|
+
|
|
566
774
|
/**
|
|
567
775
|
* #79 — wrangler parity: which endpoint, and who the stored token belongs to.
|
|
568
776
|
* The account comes from the control plane, so this also proves the token still
|
|
569
777
|
* works rather than only reporting what is on disk.
|
|
570
778
|
*/
|
|
571
|
-
async function whoami() {
|
|
572
|
-
const { apiUrl,
|
|
573
|
-
const
|
|
574
|
-
|
|
779
|
+
async function whoami(args: string[]) {
|
|
780
|
+
const { apiUrl, target, account: wanted } = await parseLoginArgs(args);
|
|
781
|
+
const fromEnv = envToken();
|
|
782
|
+
const saved = await savedProfile(apiUrl);
|
|
783
|
+
await printTarget(target);
|
|
784
|
+
const kind = saved?.kind ?? (fromEnv ? await kindOf(apiUrl) : undefined);
|
|
785
|
+
if (kind === "cloud") {
|
|
786
|
+
const team = wanted ?? saved?.team ?? Object.keys(saved?.teams ?? {})[0];
|
|
787
|
+
const token = fromEnv?.token ?? (team ? saved?.teams[team]?.token : undefined) ?? saved?.token;
|
|
788
|
+
if (!token) {
|
|
789
|
+
const hint = wanted
|
|
790
|
+
? `not logged in to the team "${wanted}"; run \`sproutboat login --account ${wanted}\``
|
|
791
|
+
: "not logged in; run `sproutboat login`";
|
|
792
|
+
console.log(`account ${rose(hint)}`);
|
|
793
|
+
process.exitCode = 1;
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
const who = await guard(() => fetchWhoami(apiUrl, token, fromEnv !== undefined));
|
|
797
|
+
console.log(`kind Sproutboat Cloud`);
|
|
798
|
+
console.log(`team ${who.slug}${who.name !== who.slug ? ` (${who.name})` : ""}`);
|
|
799
|
+
console.log(
|
|
800
|
+
`key ${who.keyName || who.keyId} ${dim(fromEnv ? `token from ${fromEnv.source}` : "stored login")}`,
|
|
801
|
+
);
|
|
802
|
+
if (who.permissions) console.log(`access ${who.permissions}`);
|
|
803
|
+
if (who.person) console.log(`email ${who.person.email}`);
|
|
804
|
+
const stored = Object.keys(saved?.teams ?? {});
|
|
805
|
+
if (!fromEnv && stored.length > 1) console.log(dim(`logged in to ${stored.length} teams: ${stored.join(", ")}`));
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const token = fromEnv?.token || (await savedToken(apiUrl));
|
|
575
809
|
if (!token) {
|
|
576
810
|
console.log(`account ${dim("no stored token — run `sproutboat login`")}`);
|
|
811
|
+
process.exitCode = 1;
|
|
577
812
|
return;
|
|
578
813
|
}
|
|
579
814
|
|
|
@@ -582,6 +817,7 @@ async function whoami() {
|
|
|
582
817
|
console.log(
|
|
583
818
|
`account ${rose(response.status === 401 ? "token rejected — run `sproutboat login`" : `control plane said ${response.status}`)}`,
|
|
584
819
|
);
|
|
820
|
+
process.exitCode = 1;
|
|
585
821
|
return;
|
|
586
822
|
}
|
|
587
823
|
const account = jsonObject(parseJsonValue(await response.text()));
|
|
@@ -592,8 +828,20 @@ async function whoami() {
|
|
|
592
828
|
}
|
|
593
829
|
|
|
594
830
|
async function deploy(args: string[]) {
|
|
595
|
-
const
|
|
831
|
+
const flags = takeFlags(args, DEPLOY_FLAGS);
|
|
596
832
|
const dryRun = args.includes("--dry-run");
|
|
833
|
+
const { apiUrl: targetUrl, target } = await resolveApiUrl(flags["--api-url"]);
|
|
834
|
+
await printTarget(target);
|
|
835
|
+
const kind = await deployKind(targetUrl, dryRun);
|
|
836
|
+
if (kind === "cloud") {
|
|
837
|
+
if (args.includes("--artifact"))
|
|
838
|
+
fail("--artifact deploys a prebuilt Community artifact; the Cloud builds your source: run deploy without it");
|
|
839
|
+
await guard(() => deployCloud(args, targetUrl, flags));
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (flags["--secrets-file"] !== undefined)
|
|
843
|
+
fail("--secrets-file is for the Cloud; on this endpoint set secrets with `sproutboat secrets put <NAME>`");
|
|
844
|
+
const artifactIndex = args.indexOf("--artifact");
|
|
597
845
|
const directory = args.find((arg, index) => !arg.startsWith("--") && index !== artifactIndex + 1);
|
|
598
846
|
let projectName: string;
|
|
599
847
|
let artifactDir: string;
|
|
@@ -615,7 +863,7 @@ async function deploy(args: string[]) {
|
|
|
615
863
|
artifactDir = built.artifact.artifactDir;
|
|
616
864
|
config = built.project.config;
|
|
617
865
|
// After provisioning: ids may have been pinned into the config just now.
|
|
618
|
-
await
|
|
866
|
+
await refreshProjectTypes(built.project.directory, built.project.config);
|
|
619
867
|
}
|
|
620
868
|
const manifest = Bun.file(resolve(artifactDir, "manifest.json"));
|
|
621
869
|
const sprout = Bun.file(resolve(artifactDir, "sprout"));
|
|
@@ -635,8 +883,7 @@ async function deploy(args: string[]) {
|
|
|
635
883
|
console.log("\n--dry-run: not uploading.");
|
|
636
884
|
return;
|
|
637
885
|
}
|
|
638
|
-
const { apiUrl, token
|
|
639
|
-
console.log(`endpoint ${apiUrl} (${source})`);
|
|
886
|
+
const { apiUrl, token } = await apiCredentials(flags["--api-url"]);
|
|
640
887
|
const form = new FormData();
|
|
641
888
|
form.set("manifest", new File([await manifest.arrayBuffer()], "manifest.json", { type: "application/json" }));
|
|
642
889
|
form.set("sprout", new File([await sprout.arrayBuffer()], "sprout", { type: "application/octet-stream" }));
|
|
@@ -726,34 +973,50 @@ async function waitForHealthy(url: string, timeoutMs: number): Promise<boolean>
|
|
|
726
973
|
return false;
|
|
727
974
|
}
|
|
728
975
|
|
|
729
|
-
async function parseLoginArgs(args: string[]) {
|
|
730
|
-
let explicitApiUrl: string | undefined;
|
|
976
|
+
async function parseLoginArgs(args: string[], options: { allowNewProfile?: boolean } = {}) {
|
|
731
977
|
let token: string | undefined;
|
|
978
|
+
let account: string | undefined;
|
|
979
|
+
// --api-url is a global flag (global-flags.ts); resolveApiUrl reads it.
|
|
732
980
|
for (let index = 0; index < args.length; index += 2) {
|
|
733
981
|
const value = args[index + 1];
|
|
734
|
-
if (args[index] === "--
|
|
735
|
-
else if (args[index] === "--
|
|
736
|
-
else
|
|
982
|
+
if (args[index] === "--token" && value) token = value;
|
|
983
|
+
else if (args[index] === "--account" && value) account = value;
|
|
984
|
+
else
|
|
985
|
+
usageError(
|
|
986
|
+
`unexpected argument "${args[index]}"`,
|
|
987
|
+
"login [--api-url <url>] [--token <token>] [--account <team>]",
|
|
988
|
+
);
|
|
737
989
|
}
|
|
738
|
-
const { apiUrl, source } = await resolveApiUrl(
|
|
739
|
-
return { apiUrl, source, token };
|
|
990
|
+
const { apiUrl, source, target } = await resolveApiUrl(undefined, options);
|
|
991
|
+
return { apiUrl, source, target, token, account };
|
|
740
992
|
}
|
|
741
993
|
|
|
742
|
-
/**
|
|
743
|
-
function
|
|
744
|
-
|
|
745
|
-
|
|
994
|
+
/** Say which profile a login went to, and when it left the machine's default profile pointed elsewhere. */
|
|
995
|
+
function reportSaved(saved: { profile: string; activeProfile: string; activeApiUrl: string }): void {
|
|
996
|
+
console.log(dim(` stored as the profile "${saved.profile}"`));
|
|
997
|
+
if (saved.activeProfile !== saved.profile)
|
|
998
|
+
console.log(
|
|
999
|
+
dim(
|
|
1000
|
+
` the default profile stays "${saved.activeProfile}" (${saved.activeApiUrl}); use --profile ${saved.profile}, or \`sproutboat profile use ${saved.profile}\``,
|
|
1001
|
+
),
|
|
1002
|
+
);
|
|
746
1003
|
}
|
|
747
1004
|
|
|
748
1005
|
async function login(args: string[]) {
|
|
749
|
-
const { apiUrl,
|
|
750
|
-
|
|
1006
|
+
const { apiUrl, target, token: directToken, account } = await parseLoginArgs(args, { allowNewProfile: true });
|
|
1007
|
+
await printTarget(target);
|
|
1008
|
+
const kind = await kindOf(apiUrl);
|
|
1009
|
+
if (kind === undefined && !directToken) fail(`cannot reach ${apiUrl}; check the address and your network`);
|
|
1010
|
+
if (kind === "cloud") {
|
|
1011
|
+
await guard(() => cloudLogin(apiUrl, directToken, account));
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
751
1014
|
// Self-hosted / non-interactive: skip the browser flow and store the token
|
|
752
1015
|
// the admin already holds (e.g. SPROUTBOAT_BOOTSTRAP_TOKEN).
|
|
753
1016
|
if (directToken) {
|
|
754
|
-
const
|
|
1017
|
+
const saved = await saveToken(apiUrl, directToken, kind);
|
|
755
1018
|
console.log(`Saved credentials for ${apiUrl}.`);
|
|
756
|
-
|
|
1019
|
+
reportSaved(saved);
|
|
757
1020
|
return;
|
|
758
1021
|
}
|
|
759
1022
|
const response = await fetch(`${apiUrl}/api/cli/authorizations`, { method: "POST" });
|
|
@@ -786,21 +1049,419 @@ async function login(args: string[]) {
|
|
|
786
1049
|
if (!exchange.ok) fail(`login failed (${exchange.status}): ${result}`);
|
|
787
1050
|
const token = parseToken(result);
|
|
788
1051
|
if (!token) fail("login response did not include a CLI token");
|
|
789
|
-
const
|
|
1052
|
+
const saved = await saveToken(apiUrl, token, "community");
|
|
790
1053
|
console.log(ok("login approved — credentials saved for this endpoint"));
|
|
791
|
-
|
|
1054
|
+
reportSaved(saved);
|
|
792
1055
|
return;
|
|
793
1056
|
}
|
|
794
1057
|
fail("login expired before approval");
|
|
795
1058
|
}
|
|
796
1059
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
1060
|
+
const DEPLOY_FLAGS = ["--api-url", "--account", "--module", "--sproutd", "--generation", "--secrets-file"] as const;
|
|
1061
|
+
type DeployFlag = (typeof DEPLOY_FLAGS)[number];
|
|
1062
|
+
|
|
1063
|
+
function guardSync(step: () => void): void {
|
|
1064
|
+
try {
|
|
1065
|
+
step();
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
if (error instanceof CloudError) return fail(error.message);
|
|
1068
|
+
throw error;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
async function readSecretsFileOrFail(path: string) {
|
|
1073
|
+
try {
|
|
1074
|
+
return await readSecretsFile(path);
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
if (error instanceof CloudError || error instanceof EnvFileError) return fail(error.message);
|
|
1077
|
+
throw error;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** Run a Cloud step; its CloudError becomes a printed failure. */
|
|
1082
|
+
async function guard<T>(step: () => Promise<T>): Promise<T> {
|
|
1083
|
+
try {
|
|
1084
|
+
return await step();
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
if (error instanceof CloudError) return fail(error.message);
|
|
1087
|
+
throw error;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/** Remove `--name value` pairs from `args` (in place) and return their values. */
|
|
1092
|
+
function takeFlags<Name extends string>(args: string[], names: readonly Name[]) {
|
|
1093
|
+
const values: { [flag in Name]?: string } = {};
|
|
1094
|
+
for (const name of names) {
|
|
1095
|
+
const at = args.indexOf(name);
|
|
1096
|
+
if (at < 0) continue;
|
|
1097
|
+
const value = args[at + 1];
|
|
1098
|
+
if (value === undefined || value.startsWith("--"))
|
|
1099
|
+
usageError(`${name} needs a value`, "deploy [project-dir] [${name} <value>]");
|
|
1100
|
+
values[name] = value;
|
|
1101
|
+
args.splice(at, 2);
|
|
1102
|
+
}
|
|
1103
|
+
return values;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** Cloud or Community, or undefined when the endpoint cannot be reached. */
|
|
1107
|
+
async function kindOf(apiUrl: string) {
|
|
1108
|
+
try {
|
|
1109
|
+
return await detectKind(apiUrl);
|
|
1110
|
+
} catch {
|
|
1111
|
+
return undefined;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
async function deployKind(apiUrl: string, dryRun: boolean) {
|
|
1116
|
+
const stored = envToken() ? undefined : (await savedProfile(apiUrl))?.kind;
|
|
1117
|
+
if (stored) return stored;
|
|
1118
|
+
const kind = await kindOf(apiUrl);
|
|
1119
|
+
if (kind === undefined && !dryRun) fail(`cannot reach ${apiUrl}; check the address and your network`);
|
|
1120
|
+
return kind ?? "community";
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function openInBrowser(url: string): boolean {
|
|
1124
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
1125
|
+
const headless =
|
|
1126
|
+
process.platform === "linux" &&
|
|
1127
|
+
(!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY) || process.env.SSH_CONNECTION);
|
|
1128
|
+
if (headless || !Bun.which(opener)) return false;
|
|
1129
|
+
try {
|
|
1130
|
+
Bun.spawn(opener === "cmd" ? ["cmd", "/c", "start", "", url] : [opener, url], {
|
|
1131
|
+
stdout: "ignore",
|
|
1132
|
+
stderr: "ignore",
|
|
1133
|
+
});
|
|
1134
|
+
return true;
|
|
1135
|
+
} catch {
|
|
1136
|
+
return false;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function terminalIo(): CloudIo {
|
|
1141
|
+
return {
|
|
1142
|
+
log: (line) => console.log(line),
|
|
1143
|
+
warn: (line) => console.warn(amber(line)),
|
|
1144
|
+
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
1145
|
+
openUrl: openInBrowser,
|
|
1146
|
+
sleep: (ms) => Bun.sleep(ms),
|
|
1147
|
+
choose: async (question, choices) => {
|
|
1148
|
+
const { createInterface } = await import("node:readline/promises");
|
|
1149
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1150
|
+
try {
|
|
1151
|
+
console.log(question);
|
|
1152
|
+
choices.forEach((choice, at) => console.log(` ${at + 1}) ${choice}`));
|
|
1153
|
+
for (;;) {
|
|
1154
|
+
const answer = (await rl.question("> ")).trim();
|
|
1155
|
+
const picked = choices[Number(answer) - 1] ?? choices.find((choice) => choice === answer);
|
|
1156
|
+
if (picked) return picked;
|
|
1157
|
+
}
|
|
1158
|
+
} finally {
|
|
1159
|
+
rl.close();
|
|
1160
|
+
}
|
|
1161
|
+
},
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
async function cloudLogin(apiUrl: string, directToken: string | undefined, account: string | undefined) {
|
|
1166
|
+
if (directToken) {
|
|
1167
|
+
const stored = await storeGivenKey(apiUrl, directToken);
|
|
1168
|
+
console.log(ok(`saved the key for ${stored.slug} on ${apiUrl}`));
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
const cancel = () => {
|
|
1172
|
+
console.log("\nlogin cancelled");
|
|
1173
|
+
process.exit(130);
|
|
1174
|
+
};
|
|
1175
|
+
process.once("SIGINT", cancel);
|
|
1176
|
+
const done = await loginAndStore({
|
|
1177
|
+
apiUrl,
|
|
1178
|
+
io: terminalIo(),
|
|
1179
|
+
hostname: osHostname(),
|
|
1180
|
+
os: `${osPlatform()} ${osArch()}`,
|
|
1181
|
+
account,
|
|
1182
|
+
});
|
|
1183
|
+
process.off("SIGINT", cancel);
|
|
1184
|
+
console.log(
|
|
1185
|
+
ok(
|
|
1186
|
+
`logged in to ${bold(done.slug)}${done.team.role ? ` as ${done.team.role}` : ""} with the key "${done.keyName}"`,
|
|
1187
|
+
),
|
|
1188
|
+
);
|
|
1189
|
+
console.log(dim(" revoke it any time under Settings, API keys, or with `sproutboat logout`"));
|
|
1190
|
+
reportSaved(done);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
async function deployCloud(args: string[], apiUrl: string, flags: ReturnType<typeof takeFlags<DeployFlag>>) {
|
|
1194
|
+
const dryRun = args.includes("--dry-run");
|
|
1195
|
+
const io = terminalIo();
|
|
1196
|
+
const directory = args.find((arg) => !arg.startsWith("--"));
|
|
1197
|
+
const project = await readProjectConfig(directory);
|
|
1198
|
+
const name = project.config.name;
|
|
1199
|
+
if (!CLOUD_PROJECT_RE.test(name))
|
|
1200
|
+
fail(`"${name}" is not a valid Cloud app name (3 to 32 characters: lowercase letters, digits, dashes)`);
|
|
1201
|
+
|
|
1202
|
+
const account = flags["--account"] ?? project.account;
|
|
1203
|
+
let session: Awaited<ReturnType<typeof resolveSession>> | undefined;
|
|
1204
|
+
try {
|
|
1205
|
+
session = await resolveSession({
|
|
1206
|
+
apiUrl,
|
|
1207
|
+
io,
|
|
1208
|
+
account,
|
|
1209
|
+
login: async (hint) => {
|
|
1210
|
+
if (dryRun) throw new CloudError("not logged in (--dry-run does not start a login)");
|
|
1211
|
+
process.once("SIGINT", () => process.exit(130));
|
|
1212
|
+
return loginAndStore({ apiUrl, io, hostname: osHostname(), os: `${osPlatform()} ${osArch()}`, account: hint });
|
|
1213
|
+
},
|
|
1214
|
+
});
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
if (!dryRun || !(error instanceof CloudError)) throw error;
|
|
1217
|
+
io.warn(`! ${error.message}; the plan below uses a placeholder team`);
|
|
1218
|
+
}
|
|
1219
|
+
const team = session?.slug ?? account ?? "<team>";
|
|
1220
|
+
const hosts = [hostFor(name, team)];
|
|
1221
|
+
|
|
1222
|
+
const cfg = project.config;
|
|
1223
|
+
const services = cfg.services ?? [];
|
|
1224
|
+
guardSync(() => checkServiceBindings(name, services, cfg.compatibility_date));
|
|
1225
|
+
let cloudBindings!: CloudBindings;
|
|
1226
|
+
guardSync(() => {
|
|
1227
|
+
cloudBindings = mapCloudBindings(cfg, services.length);
|
|
1228
|
+
checkDurableObjectExports(cfg, project.directory);
|
|
1229
|
+
});
|
|
1230
|
+
for (const warning of cloudBindings.warnings) io.warn(`! ${warning}`);
|
|
1231
|
+
let secretValues: Record<string, string> | undefined;
|
|
1232
|
+
const secretsFile = flags["--secrets-file"];
|
|
1233
|
+
if (secretsFile !== undefined) {
|
|
1234
|
+
const read = await readSecretsFileOrFail(resolve(secretsFile));
|
|
1235
|
+
for (const warning of read.warnings) io.warn(`! ${warning}`);
|
|
1236
|
+
secretValues = read.values;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const assets = project.config.assets ? buildAssetBundle(project.directory, project.config.assets) : undefined;
|
|
1240
|
+
// Regular deploys build in the Cloud from the bundled source. --module (a prebuilt module) and --sproutd with
|
|
1241
|
+
// --generation (compile here) are the operator paths; the Cloud refuses a module from a team key.
|
|
1242
|
+
let modulePath = flags["--module"];
|
|
1243
|
+
const compiled =
|
|
1244
|
+
modulePath === undefined && Boolean(flags["--sproutd"] ?? flags["--generation"] ?? process.env.SPROUTBOAT_SPROUTD);
|
|
1245
|
+
const fromSource = modulePath === undefined && !compiled;
|
|
1246
|
+
if (compiled) {
|
|
1247
|
+
modulePath = resolve(tmpdir(), `sproutboat-${name}-${process.pid}.so`);
|
|
1248
|
+
console.log(dim("Compiling the module (sproutd)…"));
|
|
1249
|
+
await compileModule({
|
|
1250
|
+
project: name,
|
|
1251
|
+
emit: () => emitStandaloneUnit(directory),
|
|
1252
|
+
sproutd: flags["--sproutd"] ?? process.env.SPROUTBOAT_SPROUTD,
|
|
1253
|
+
generation: flags["--generation"] ?? process.env.SPROUTBOAT_GENERATION,
|
|
1254
|
+
out: modulePath,
|
|
1255
|
+
bun: process.versions.bun ? process.execPath : (Bun.which("bun") ?? "bun"),
|
|
1256
|
+
});
|
|
1257
|
+
} else if (modulePath !== undefined && !existsSync(modulePath)) fail(`no such module: ${modulePath}`);
|
|
1258
|
+
|
|
1259
|
+
console.log(`app ${bold(name)}`);
|
|
1260
|
+
console.log(`team ${team}${session ? "" : dim(" (not resolved)")}`);
|
|
1261
|
+
console.log(`endpoint ${apiUrl}`);
|
|
1262
|
+
console.log(`route https://${hosts[0]}`);
|
|
1263
|
+
if (assets) console.log(`assets ${assets.files} files`);
|
|
1264
|
+
for (const line of describeCloudBindings(cloudBindings)) console.log(`binding ${line}`);
|
|
1265
|
+
if (services.length > 0)
|
|
1266
|
+
console.log(`services ${services.map((entry) => `${entry.binding} -> ${entry.service}`).join(", ")}`);
|
|
1267
|
+
if (secretValues) console.log(`secrets ${Object.keys(secretValues).length} from ${secretsFile} (set, others kept)`);
|
|
1268
|
+
if (fromSource) console.log(`build in the Cloud, from your bundled source`);
|
|
1269
|
+
if (dryRun) {
|
|
1270
|
+
console.log("\n--dry-run: not uploading.");
|
|
1271
|
+
if (fromSource) console.log(dim(" A real deploy bundles your source, uploads it and has the Cloud build it."));
|
|
1272
|
+
if (compiled) rmSync(modulePath!, { force: true });
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
const tokenFromEnv = session!.source !== "stored login";
|
|
1276
|
+
const noWait = args.includes("--no-wait");
|
|
1277
|
+
const printResult = (result: {
|
|
1278
|
+
created: boolean;
|
|
1279
|
+
generation: number | undefined;
|
|
1280
|
+
url: string | undefined;
|
|
1281
|
+
applied: boolean | "skipped";
|
|
1282
|
+
buildId?: string;
|
|
1283
|
+
}) => {
|
|
1284
|
+
if (result.generation === undefined) {
|
|
1285
|
+
console.log(`\n${leaf("🌱")} ${bold(leaf(`Build queued for ${name}`))}`);
|
|
1286
|
+
console.log(dim(` status ${apiUrl}/v1/builds/${result.buildId}`));
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
console.log(`\n${leaf("🌱")} ${bold(leaf(`${result.created ? "Created" : "Updated"} ${name}`))}`);
|
|
1290
|
+
if (result.url) {
|
|
1291
|
+
console.log(` ${bold(result.url)}`);
|
|
1292
|
+
}
|
|
1293
|
+
console.log(dim(` generation ${result.generation} · team ${team}`));
|
|
1294
|
+
if (result.applied === false) {
|
|
1295
|
+
fail(
|
|
1296
|
+
`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)`,
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
};
|
|
1300
|
+
if (fromSource) {
|
|
1301
|
+
const sourcePath = await emitStandaloneUnit(directory);
|
|
1302
|
+
console.log(` Bundling ${entryLabel(cfg)} (${formatKiB(Bun.file(sourcePath).size)})`);
|
|
1303
|
+
const controller = new AbortController();
|
|
1304
|
+
const interrupt = () => controller.abort();
|
|
1305
|
+
process.once("SIGINT", interrupt);
|
|
1306
|
+
try {
|
|
1307
|
+
const result = await buildAndDeploy(
|
|
1308
|
+
{
|
|
1309
|
+
apiUrl,
|
|
1310
|
+
token: session!.token,
|
|
1311
|
+
tokenFromEnv,
|
|
1312
|
+
project: name,
|
|
1313
|
+
services,
|
|
1314
|
+
cloudBindings,
|
|
1315
|
+
compatibilityDate: cfg.compatibility_date,
|
|
1316
|
+
secrets: secretValues,
|
|
1317
|
+
hosts,
|
|
1318
|
+
sourcePath,
|
|
1319
|
+
cli: { version: CLI_VERSION },
|
|
1320
|
+
assets,
|
|
1321
|
+
noWait,
|
|
1322
|
+
},
|
|
1323
|
+
io,
|
|
1324
|
+
controller.signal,
|
|
1325
|
+
);
|
|
1326
|
+
printResult(result);
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
if (error instanceof Interrupted) {
|
|
1329
|
+
console.error(error.message);
|
|
1330
|
+
process.exit(130);
|
|
1331
|
+
}
|
|
1332
|
+
throw error;
|
|
1333
|
+
} finally {
|
|
1334
|
+
process.off("SIGINT", interrupt);
|
|
1335
|
+
}
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
try {
|
|
1339
|
+
const result = await deployToCloud(
|
|
1340
|
+
{
|
|
1341
|
+
apiUrl,
|
|
1342
|
+
token: session!.token,
|
|
1343
|
+
tokenFromEnv,
|
|
1344
|
+
project: name,
|
|
1345
|
+
hosts,
|
|
1346
|
+
modulePath: modulePath!,
|
|
1347
|
+
assets,
|
|
1348
|
+
noWait,
|
|
1349
|
+
services,
|
|
1350
|
+
cloudBindings,
|
|
1351
|
+
compatibilityDate: cfg.compatibility_date,
|
|
1352
|
+
secrets: secretValues,
|
|
1353
|
+
},
|
|
1354
|
+
io,
|
|
1355
|
+
);
|
|
1356
|
+
printResult(result);
|
|
1357
|
+
} finally {
|
|
1358
|
+
if (compiled) rmSync(modulePath!, { force: true });
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
async function apiCredentials(explicitApiUrl?: string) {
|
|
1363
|
+
const { apiUrl, source } = await resolveApiUrl(explicitApiUrl);
|
|
1364
|
+
const token = envToken()?.token || (await savedToken(apiUrl));
|
|
1365
|
+
if (!token) fail("not logged in; run sproutboat login or set SPROUTBOAT_API_TOKEN for this command");
|
|
801
1366
|
return { apiUrl, token, source };
|
|
802
1367
|
}
|
|
803
1368
|
|
|
1369
|
+
/**
|
|
1370
|
+
* `deployments list | status | tail <id>`. A Cloud deployment is a build; a Community one is a version. `tail` follows a
|
|
1371
|
+
* build server-side and never cancels it, which is what a deploy detached with Ctrl-C needs.
|
|
1372
|
+
*/
|
|
1373
|
+
async function deployments(args: string[]) {
|
|
1374
|
+
const sub = args.shift();
|
|
1375
|
+
if (sub !== "list" && sub !== "status" && sub !== "tail")
|
|
1376
|
+
usageError(
|
|
1377
|
+
sub ? `deployments: unknown subcommand "${sub}"` : "deployments: missing subcommand",
|
|
1378
|
+
"deployments <list | status | tail <id>>",
|
|
1379
|
+
);
|
|
1380
|
+
const usage =
|
|
1381
|
+
sub === "list" ? DEPLOYMENTS_LIST_USAGE : sub === "status" ? DEPLOYMENTS_STATUS_USAGE : DEPLOYMENTS_TAIL_USAGE;
|
|
1382
|
+
let parsed: ReturnType<typeof parseDeploymentsArgs>;
|
|
1383
|
+
try {
|
|
1384
|
+
parsed = parseDeploymentsArgs(args, usage);
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
if (error instanceof DeploymentsUsageError) return usageError(error.message, error.usage);
|
|
1387
|
+
throw error;
|
|
1388
|
+
}
|
|
1389
|
+
const { positional, flags } = parsed;
|
|
1390
|
+
const id = positional[0];
|
|
1391
|
+
if (sub === "tail" && !id) usageError("deployments tail: missing <id>", usage);
|
|
1392
|
+
if (positional.length > (sub === "tail" ? 1 : 0)) usageError(`unexpected argument "${positional.at(-1)}"`, usage);
|
|
1393
|
+
const project = await readProjectConfig();
|
|
1394
|
+
const app = project.config.name;
|
|
1395
|
+
const { apiUrl: targetUrl } = await resolveApiUrl();
|
|
1396
|
+
const kind = await deployKind(targetUrl, false);
|
|
1397
|
+
|
|
1398
|
+
if (kind !== "cloud") {
|
|
1399
|
+
if (sub === "tail") return fail(COMMUNITY_TAIL);
|
|
1400
|
+
const { apiUrl, token } = await apiCredentials();
|
|
1401
|
+
const body = await responseText(
|
|
1402
|
+
await fetch(`${apiUrl}/api/projects/${app}/deployments`, { headers: { "x-api-key": token } }),
|
|
1403
|
+
"could not list deployments",
|
|
1404
|
+
);
|
|
1405
|
+
const list = parseJsonValue(body);
|
|
1406
|
+
const versions = parseCommunityVersions(
|
|
1407
|
+
Array.isArray(list)
|
|
1408
|
+
? list.flatMap((entry) => {
|
|
1409
|
+
const item = jsonObject(entry);
|
|
1410
|
+
return item ? [item] : [];
|
|
1411
|
+
})
|
|
1412
|
+
: [],
|
|
1413
|
+
);
|
|
1414
|
+
if (sub === "status") {
|
|
1415
|
+
const active = versions.find((version) => version.active);
|
|
1416
|
+
if (flags.json) console.log(JSON.stringify(active ?? null, null, 2));
|
|
1417
|
+
else console.log(active ? formatCommunityVersion(active) : `${app} has no active deployment`);
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
const shown = versions.slice(0, flags.limit);
|
|
1421
|
+
if (flags.json) console.log(JSON.stringify(shown, null, 2));
|
|
1422
|
+
else console.log(shown.length ? shown.map(formatCommunityVersion).join("\n\n") : "no deployments");
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
await guard(async () => {
|
|
1427
|
+
const credentials = await cloudCredentials(targetUrl, flags.account ?? project.account);
|
|
1428
|
+
if (sub === "tail") {
|
|
1429
|
+
// Ctrl-C only stops following: the build keeps going, and tail can be run again.
|
|
1430
|
+
const controller = new AbortController();
|
|
1431
|
+
let detached = false;
|
|
1432
|
+
process.once("SIGINT", () => {
|
|
1433
|
+
detached = true;
|
|
1434
|
+
controller.abort();
|
|
1435
|
+
});
|
|
1436
|
+
let view;
|
|
1437
|
+
try {
|
|
1438
|
+
view = await tailBuild(credentials, id!, terminalIo(), controller.signal);
|
|
1439
|
+
} catch (error) {
|
|
1440
|
+
if (detached || error instanceof Interrupted) {
|
|
1441
|
+
console.error(
|
|
1442
|
+
`detached: the deployment keeps running; \`sproutboat deployments tail ${id}\` follows it again`,
|
|
1443
|
+
);
|
|
1444
|
+
process.exit(130);
|
|
1445
|
+
}
|
|
1446
|
+
throw error;
|
|
1447
|
+
}
|
|
1448
|
+
console.log(tailSummary(view));
|
|
1449
|
+
if (view.state !== "ready") process.exitCode = 1;
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
if (sub === "status") {
|
|
1453
|
+
const live = await fetchLive(credentials, app);
|
|
1454
|
+
const { deployments: recent } = live ? await fetchDeployments(credentials, app, 1) : { deployments: [] };
|
|
1455
|
+
if (flags.json) console.log(JSON.stringify({ application: app, live, latest: recent[0] ?? null }, null, 2));
|
|
1456
|
+
else console.log(formatStatus(app, live, recent[0]));
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
const { deployments: recent } = await fetchDeployments(credentials, app, flags.limit);
|
|
1460
|
+
if (flags.json) console.log(JSON.stringify(recent, null, 2));
|
|
1461
|
+
else console.log(recent.length ? recent.map(formatDeployment).join("\n\n") : `no deployments of ${app} yet`);
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
|
|
804
1465
|
async function versions(args: string[]) {
|
|
805
1466
|
const sub = args[0];
|
|
806
1467
|
if (sub !== "list" && sub !== "view") {
|
|
@@ -967,9 +1628,24 @@ async function domains(args: string[]) {
|
|
|
967
1628
|
args[0] && !args[0].startsWith("-") && ["list", "add", "verify", "delete"].includes(args[0])
|
|
968
1629
|
? args.shift()!
|
|
969
1630
|
: "list";
|
|
1631
|
+
const flags = takeFlags(args, ["--account"] as const);
|
|
970
1632
|
const host = sub === "list" ? undefined : args.shift();
|
|
971
1633
|
if (sub !== "list" && !host)
|
|
972
1634
|
usageError(`domains ${sub}: missing <hostname>`, `domains ${sub} <hostname> [project-dir]`);
|
|
1635
|
+
const { apiUrl: targetUrl } = await resolveApiUrl();
|
|
1636
|
+
if ((await deployKind(targetUrl, false)) === "cloud") {
|
|
1637
|
+
await guard(async () => {
|
|
1638
|
+
if (host !== undefined) throw new CloudError(cloudDomainsUnsupported(sub, host));
|
|
1639
|
+
const project = await readProjectConfig(args[0]);
|
|
1640
|
+
const credentials = await cloudCredentials(targetUrl, flags["--account"] ?? project.account);
|
|
1641
|
+
for (const line of describeCloudRoutes(
|
|
1642
|
+
project.config.name,
|
|
1643
|
+
await listCloudRoutes(credentials, project.config.name),
|
|
1644
|
+
))
|
|
1645
|
+
console.log(line);
|
|
1646
|
+
});
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
973
1649
|
const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(args[0]), apiCredentials()]);
|
|
974
1650
|
const base = `${apiUrl}/api/projects/${project.config.name}/domains`;
|
|
975
1651
|
const auth = { "x-api-key": token };
|
|
@@ -1011,7 +1687,11 @@ async function domains(args: string[]) {
|
|
|
1011
1687
|
}
|
|
1012
1688
|
|
|
1013
1689
|
async function secrets(args: string[]) {
|
|
1014
|
-
const sub = args[0] && ["list", "put", "delete"].includes(args[0]) ? args.shift()! : "list";
|
|
1690
|
+
const sub = args[0] && ["list", "put", "delete", "bulk"].includes(args[0]) ? args.shift()! : "list";
|
|
1691
|
+
// `--no-deploy`: change the secrets without waiting for a host to apply them as a new version.
|
|
1692
|
+
const noDeploy = args.includes("--no-deploy");
|
|
1693
|
+
if (noDeploy) args.splice(args.indexOf("--no-deploy"), 1);
|
|
1694
|
+
const flags = takeFlags(args, ["--account"] as const);
|
|
1015
1695
|
|
|
1016
1696
|
// `--value` is opt-in; without it the value comes from stdin, so a secret does
|
|
1017
1697
|
// not land in shell history. It is also what disambiguates the positionals:
|
|
@@ -1019,14 +1699,26 @@ async function secrets(args: string[]) {
|
|
|
1019
1699
|
// and read the value as the project directory.
|
|
1020
1700
|
let inlineValue: string | undefined;
|
|
1021
1701
|
const positional: string[] = [];
|
|
1702
|
+
const grammar = `secrets ${sub} ${sub === "list" ? "" : sub === "bulk" ? "[file.json | file.env] " : "<NAME> "}[project-dir] [--api-url <url>] [--account <team>]`;
|
|
1022
1703
|
for (let index = 0; index < args.length; index += 1) {
|
|
1023
|
-
|
|
1024
|
-
|
|
1704
|
+
const arg = args[index];
|
|
1705
|
+
if (arg === "--value" || arg.startsWith("--value=")) {
|
|
1706
|
+
if (sub !== "put") usageError(`secrets ${sub} does not take --value`, grammar);
|
|
1707
|
+
inlineValue = arg === "--value" ? args[(index += 1)] : arg.slice("--value=".length);
|
|
1708
|
+
if (inlineValue === undefined) usageError("--value needs a value", grammar);
|
|
1709
|
+
} else if (arg.startsWith("-") && arg !== "-") usageError(`unknown option ${arg.split("=")[0]}`, grammar);
|
|
1710
|
+
else positional.push(arg);
|
|
1025
1711
|
}
|
|
1026
1712
|
|
|
1713
|
+
if (sub === "bulk") return bulkSecrets(positional, flags, noDeploy);
|
|
1027
1714
|
const name = sub === "list" ? undefined : positional.shift();
|
|
1028
1715
|
if (sub !== "list" && !name) usageError(`secrets ${sub}: missing <NAME>`, `secrets ${sub} <NAME> [project-dir]`);
|
|
1029
1716
|
if (name && !/^[A-Z][A-Z0-9_]*$/.test(name)) fail("secret name must be UPPER_SNAKE_CASE");
|
|
1717
|
+
const { apiUrl: targetUrl } = await resolveApiUrl();
|
|
1718
|
+
if ((await deployKind(targetUrl, false)) === "cloud") {
|
|
1719
|
+
await guard(() => cloudSecrets(sub, name, inlineValue, positional[0], targetUrl, flags["--account"], noDeploy));
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1030
1722
|
const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(positional[0]), apiCredentials()]);
|
|
1031
1723
|
const base = `${apiUrl}/api/projects/${project.config.name}/secrets`;
|
|
1032
1724
|
const auth = { "x-api-key": token };
|
|
@@ -1054,7 +1746,306 @@ async function secrets(args: string[]) {
|
|
|
1054
1746
|
}),
|
|
1055
1747
|
"put rejected",
|
|
1056
1748
|
);
|
|
1057
|
-
console.log(
|
|
1749
|
+
console.log(
|
|
1750
|
+
ok(
|
|
1751
|
+
`set ${name}; self-hosted Community applies it on the next deploy or restart of the app (it has no redeploy route yet)`,
|
|
1752
|
+
),
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/**
|
|
1757
|
+
* `secrets bulk [file]`: the input of `wrangler secret bulk` (JSON with null to delete, or .env; stdin when no file).
|
|
1758
|
+
* On a Cloud endpoint it is one PATCH and so one new version; on self-hosted Community it is one call per name.
|
|
1759
|
+
*/
|
|
1760
|
+
async function bulkSecrets(positional: string[], flags: ReturnType<typeof takeFlags<"--account">>, noDeploy: boolean) {
|
|
1761
|
+
const file = positional.shift();
|
|
1762
|
+
const directory = positional[0];
|
|
1763
|
+
if (file === undefined && process.stdin.isTTY)
|
|
1764
|
+
usageError(
|
|
1765
|
+
"secrets bulk: missing <file>, and nothing is piped on stdin",
|
|
1766
|
+
"secrets bulk [file.json | file.env] [project-dir]",
|
|
1767
|
+
);
|
|
1768
|
+
let text: string;
|
|
1769
|
+
try {
|
|
1770
|
+
text = file === undefined ? await Bun.stdin.text() : await readFile(resolve(file), "utf8");
|
|
1771
|
+
} catch {
|
|
1772
|
+
return fail(`${file}: cannot read the file`);
|
|
1773
|
+
}
|
|
1774
|
+
let plan: BulkPlan | undefined;
|
|
1775
|
+
guardSync(() => {
|
|
1776
|
+
plan = parseBulkSecrets(text, file ?? "stdin");
|
|
1777
|
+
});
|
|
1778
|
+
const bulk = plan!;
|
|
1779
|
+
for (const warning of bulk.warnings) console.warn(amber(`! ${warning}`));
|
|
1780
|
+
const sets = Object.keys(bulk.set).length;
|
|
1781
|
+
const summary = `${sets} set, ${bulk.delete.length} deleted`;
|
|
1782
|
+
const { apiUrl: targetUrl } = await resolveApiUrl();
|
|
1783
|
+
const project = await readProjectConfig(directory);
|
|
1784
|
+
const app = project.config.name;
|
|
1785
|
+
if ((await deployKind(targetUrl, false)) === "cloud") {
|
|
1786
|
+
await guard(async () => {
|
|
1787
|
+
const credentials = await cloudCredentials(targetUrl, flags["--account"] ?? project.account);
|
|
1788
|
+
const generation = await patchCloudSecrets(credentials, app, bulk);
|
|
1789
|
+
await finishCloudSecretChange({ credentials, app, generation, noDeploy, what: `${summary} on ${app}` });
|
|
1790
|
+
});
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
const { apiUrl, token } = await apiCredentials();
|
|
1794
|
+
const base = `${apiUrl}/api/projects/${app}/secrets`;
|
|
1795
|
+
const auth = { "x-api-key": token };
|
|
1796
|
+
for (const [secretName, value] of Object.entries(bulk.set))
|
|
1797
|
+
await responseText(
|
|
1798
|
+
await fetch(`${base}/${secretName}`, {
|
|
1799
|
+
method: "PUT",
|
|
1800
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
1801
|
+
body: JSON.stringify({ value }),
|
|
1802
|
+
}),
|
|
1803
|
+
`put ${secretName} rejected`,
|
|
1804
|
+
);
|
|
1805
|
+
for (const secretName of bulk.delete)
|
|
1806
|
+
await responseText(
|
|
1807
|
+
await fetch(`${base}/${secretName}`, { method: "DELETE", headers: auth }),
|
|
1808
|
+
`delete ${secretName} rejected`,
|
|
1809
|
+
);
|
|
1810
|
+
console.log(ok(`${summary}; self-hosted Community applies them on the next deploy or restart of the app`));
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* After a secrets write on the Cloud: it is a new record generation, which a host applies as a new version of the
|
|
1815
|
+
* running module (the same module, the new secrets). Wait for that, as `wrangler secret put` deploys at once, unless
|
|
1816
|
+
* --no-deploy asks to return as soon as the write is stored.
|
|
1817
|
+
*/
|
|
1818
|
+
async function finishCloudSecretChange(input: {
|
|
1819
|
+
credentials: Awaited<ReturnType<typeof cloudCredentials>>;
|
|
1820
|
+
app: string;
|
|
1821
|
+
generation: number | undefined;
|
|
1822
|
+
noDeploy: boolean;
|
|
1823
|
+
what: string;
|
|
1824
|
+
}) {
|
|
1825
|
+
const { credentials, app, generation, noDeploy, what } = input;
|
|
1826
|
+
if (generation === undefined || noDeploy) {
|
|
1827
|
+
console.log(ok(`${what}; applies when a host picks up the new record`));
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
const io = terminalIo();
|
|
1831
|
+
const applied = await waitForApply(makeCall(credentials), { project: app }, generation, io);
|
|
1832
|
+
if (applied) console.log(ok(`${what}; a new version is live (generation ${generation})`));
|
|
1833
|
+
else
|
|
1834
|
+
console.log(
|
|
1835
|
+
amber(`! ${what}; generation ${generation} is saved, but no host has applied it yet: it goes live when one does`),
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
/** The signed-in session for a Cloud endpoint as the credentials every Cloud call takes; logs in first when it can. */
|
|
1840
|
+
async function cloudCredentials(apiUrl: string, account: string | undefined) {
|
|
1841
|
+
const io = terminalIo();
|
|
1842
|
+
const session = await resolveSession({
|
|
1843
|
+
apiUrl,
|
|
1844
|
+
io,
|
|
1845
|
+
account,
|
|
1846
|
+
login: (hint) =>
|
|
1847
|
+
loginAndStore({ apiUrl, io, hostname: osHostname(), os: `${osPlatform()} ${osArch()}`, account: hint }),
|
|
1848
|
+
});
|
|
1849
|
+
return { apiUrl, token: session.token, tokenFromEnv: session.source !== "stored login" };
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/** `secrets` against a Sproutboat Cloud app: by name, apart from deploys, which never wipe them (Wrangler's model). */
|
|
1853
|
+
async function cloudSecrets(
|
|
1854
|
+
sub: string,
|
|
1855
|
+
name: string | undefined,
|
|
1856
|
+
inlineValue: string | undefined,
|
|
1857
|
+
directory: string | undefined,
|
|
1858
|
+
apiUrl: string,
|
|
1859
|
+
accountFlag: string | undefined,
|
|
1860
|
+
noDeploy: boolean,
|
|
1861
|
+
) {
|
|
1862
|
+
const project = await readProjectConfig(directory);
|
|
1863
|
+
const app = project.config.name;
|
|
1864
|
+
const credentials = await cloudCredentials(apiUrl, accountFlag ?? project.account);
|
|
1865
|
+
if (sub === "list") {
|
|
1866
|
+
const names = await listCloudSecrets(credentials, app);
|
|
1867
|
+
console.log(names.length ? names.join("\n") : "no secrets");
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
if (sub === "delete") {
|
|
1871
|
+
const generation = await deleteCloudSecret(credentials, app, name!);
|
|
1872
|
+
await finishCloudSecretChange({ credentials, app, generation, noDeploy, what: `removed ${name} from ${app}` });
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
const value = inlineValue ?? (await Bun.stdin.text()).replace(/\r?\n$/, "");
|
|
1876
|
+
if (!value) fail("no value: pipe it on stdin, or pass --value <value>");
|
|
1877
|
+
const generation = await putCloudSecret(credentials, app, name!, value);
|
|
1878
|
+
await finishCloudSecretChange({ credentials, app, generation, noDeploy, what: `set ${name} on ${app}` });
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
async function confirmOnTerminal(question: string): Promise<boolean> {
|
|
1882
|
+
const { createInterface } = await import("node:readline/promises");
|
|
1883
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
1884
|
+
try {
|
|
1885
|
+
return !/^n/i.test((await rl.question(`${question} [Y/n] `)).trim());
|
|
1886
|
+
} finally {
|
|
1887
|
+
rl.close();
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
/** `d1 execute` and `d1 migrations create | list | apply`: the local dev database unless --remote, as in Wrangler. */
|
|
1892
|
+
async function d1Contents(args: string[]) {
|
|
1893
|
+
const area = args.shift()!;
|
|
1894
|
+
let sub = "execute";
|
|
1895
|
+
if (area === "migrations") {
|
|
1896
|
+
const word = args.shift();
|
|
1897
|
+
if (word !== "create" && word !== "list" && word !== "apply")
|
|
1898
|
+
usageError(
|
|
1899
|
+
word ? `d1 migrations: unknown subcommand "${word}"` : "d1 migrations: missing subcommand",
|
|
1900
|
+
"d1 migrations <create | list | apply> <database> ...",
|
|
1901
|
+
);
|
|
1902
|
+
sub = word;
|
|
1903
|
+
}
|
|
1904
|
+
const usage =
|
|
1905
|
+
sub === "execute" ? EXECUTE_USAGE : sub === "create" ? CREATE_USAGE : sub === "list" ? LIST_USAGE : APPLY_USAGE;
|
|
1906
|
+
let parsed: ReturnType<typeof parseD1Flags>;
|
|
1907
|
+
try {
|
|
1908
|
+
parsed = parseD1Flags(args, usage);
|
|
1909
|
+
} catch (error) {
|
|
1910
|
+
if (error instanceof D1UsageError) return usageError(error.message, error.usage);
|
|
1911
|
+
throw error;
|
|
1912
|
+
}
|
|
1913
|
+
const { positional, flags } = parsed;
|
|
1914
|
+
const [database, message] = positional;
|
|
1915
|
+
if (!database) usageError(`${sub === "execute" ? "d1 execute" : `d1 migrations ${sub}`}: missing <database>`, usage);
|
|
1916
|
+
if (sub === "create" && (!message || positional.length > 2))
|
|
1917
|
+
usageError("d1 migrations create: missing <name>", usage);
|
|
1918
|
+
if (sub !== "create" && positional.length > 1) usageError(`unexpected argument "${positional[1]}"`, usage);
|
|
1919
|
+
if (sub === "create" && (flags.local || flags.remote))
|
|
1920
|
+
usageError("d1 migrations create works on files only; drop --local and --remote", usage);
|
|
1921
|
+
|
|
1922
|
+
const project = await readProjectConfig();
|
|
1923
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1924
|
+
const io: D1Io = {
|
|
1925
|
+
log: (line) => console.log(line),
|
|
1926
|
+
note: (line) => console.error(line),
|
|
1927
|
+
interactive,
|
|
1928
|
+
confirm: confirmOnTerminal,
|
|
1929
|
+
};
|
|
1930
|
+
try {
|
|
1931
|
+
await guard(() => d1Run());
|
|
1932
|
+
} catch (error) {
|
|
1933
|
+
if (error instanceof D1UsageError) usageError(error.message, error.usage);
|
|
1934
|
+
throw error;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
async function d1Run() {
|
|
1938
|
+
const binding = pickBinding(project.config, database!);
|
|
1939
|
+
const dir = resolve(project.directory, project.migrationsDir ?? DEFAULT_MIGRATIONS_DIR);
|
|
1940
|
+
if (sub === "create") {
|
|
1941
|
+
await createMigration(dir, message!, io);
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
let runner: Runner;
|
|
1945
|
+
if (flags.remote) {
|
|
1946
|
+
const { apiUrl: targetUrl } = await resolveApiUrl();
|
|
1947
|
+
const app = project.config.name;
|
|
1948
|
+
if ((await deployKind(targetUrl, false)) === "cloud") {
|
|
1949
|
+
const credentials = await cloudCredentials(targetUrl, flags.account ?? project.account);
|
|
1950
|
+
const call = makeCall(credentials);
|
|
1951
|
+
runner = remoteRunner({
|
|
1952
|
+
label: `${app} database ${binding.binding}`,
|
|
1953
|
+
project: app,
|
|
1954
|
+
binding: binding.binding,
|
|
1955
|
+
community: false,
|
|
1956
|
+
send: async (body) => {
|
|
1957
|
+
const reply = await call({
|
|
1958
|
+
method: "POST",
|
|
1959
|
+
path: `/v1/apps/${app}/d1/${binding.binding}/query`,
|
|
1960
|
+
json: body,
|
|
1961
|
+
ok: DATA_ERROR_STATUSES,
|
|
1962
|
+
});
|
|
1963
|
+
return { status: reply.status, body: reply.body, headers: reply.headers };
|
|
1964
|
+
},
|
|
1965
|
+
});
|
|
1966
|
+
} else {
|
|
1967
|
+
const { apiUrl, token } = await apiCredentials();
|
|
1968
|
+
runner = remoteRunner({
|
|
1969
|
+
label: `${app} database ${binding.binding}`,
|
|
1970
|
+
project: app,
|
|
1971
|
+
binding: binding.binding,
|
|
1972
|
+
community: true,
|
|
1973
|
+
send: async (body) => {
|
|
1974
|
+
const response = await fetch(`${apiUrl}/api/projects/${app}/d1/${binding.binding}/query`, {
|
|
1975
|
+
method: "POST",
|
|
1976
|
+
headers: { "x-api-key": token, "content-type": "application/json" },
|
|
1977
|
+
body: JSON.stringify(body),
|
|
1978
|
+
});
|
|
1979
|
+
return { status: response.status, body: parseBody(await response.text().catch(() => "")) };
|
|
1980
|
+
},
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
} else {
|
|
1984
|
+
runner = localRunner(localDatabasePath(project.directory, binding, flags.persistTo));
|
|
1985
|
+
}
|
|
1986
|
+
if (sub === "execute") await executeSql({ flags, runner, remote: flags.remote, io, cwd: process.cwd() });
|
|
1987
|
+
else if (sub === "list") await listMigrations(runner, dir, io);
|
|
1988
|
+
else await applyMigrations(runner, dir, io);
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
/** `r2 object get | put | delete <bucket>/<key>`: remote only; on the Cloud the bucket is a binding of the project. */
|
|
1993
|
+
async function r2Object(args: string[]) {
|
|
1994
|
+
args.shift(); // "object"
|
|
1995
|
+
const sub = args.shift();
|
|
1996
|
+
if (sub !== "get" && sub !== "put" && sub !== "delete")
|
|
1997
|
+
usageError(
|
|
1998
|
+
sub ? `r2 object: unknown subcommand "${sub}"` : "r2 object: missing subcommand",
|
|
1999
|
+
"r2 object <get | put | delete> <bucket>/<key>",
|
|
2000
|
+
);
|
|
2001
|
+
const usage = sub === "get" ? R2_GET_USAGE : sub === "put" ? R2_PUT_USAGE : R2_DELETE_USAGE;
|
|
2002
|
+
try {
|
|
2003
|
+
const { positional, flags } = parseObjectArgs(args, usage);
|
|
2004
|
+
if (positional.length > 1) usageError(`unexpected argument "${positional[1]}"`, usage);
|
|
2005
|
+
const { bucket, key } = parseObjectPath(positional[0], usage);
|
|
2006
|
+
const project = await readProjectConfig();
|
|
2007
|
+
const app = project.config.name;
|
|
2008
|
+
const { apiUrl: targetUrl } = await resolveApiUrl();
|
|
2009
|
+
let target: ObjectTarget;
|
|
2010
|
+
if ((await deployKind(targetUrl, false)) === "cloud") {
|
|
2011
|
+
const bindings = (project.config.r2_buckets ?? []).map((entry) => (isString(entry) ? entry : entry.binding));
|
|
2012
|
+
if (!bindings.includes(bucket))
|
|
2013
|
+
fail(
|
|
2014
|
+
bindings.length === 0
|
|
2015
|
+
? `sproutboat.jsonc has no r2_buckets, so there is no bucket "${bucket}" (add "r2_buckets": ["${bucket}"])`
|
|
2016
|
+
: `no R2 binding "${bucket}" in sproutboat.jsonc (it has: ${bindings.join(", ")})`,
|
|
2017
|
+
);
|
|
2018
|
+
const credentials = await guard(() => cloudCredentials(targetUrl, flags.account ?? project.account));
|
|
2019
|
+
target = cloudTarget(credentials.apiUrl, credentials.token, app, bucket);
|
|
2020
|
+
} else {
|
|
2021
|
+
const { apiUrl, token } = await apiCredentials();
|
|
2022
|
+
const rows = await storageRows(`${apiUrl}/api/r2`, { "x-api-key": token }, STORAGE_PRODUCTS[2]);
|
|
2023
|
+
target = communityTarget(apiUrl, token, app, bucket, idForName(rows, bucket, STORAGE_PRODUCTS[2]));
|
|
2024
|
+
}
|
|
2025
|
+
const io = {
|
|
2026
|
+
log: (line: string) => console.log(line),
|
|
2027
|
+
note: (line: string) => console.error(line),
|
|
2028
|
+
stdout: { write: (bytes: Uint8Array) => void process.stdout.write(bytes) },
|
|
2029
|
+
};
|
|
2030
|
+
await guard(async () => {
|
|
2031
|
+
if (sub === "get") await getObject(target, key, flags, io, process.cwd());
|
|
2032
|
+
else if (sub === "delete") await deleteObject(target, key, io);
|
|
2033
|
+
else
|
|
2034
|
+
await putObject(target, key, flags, io, process.cwd(), () =>
|
|
2035
|
+
readCapped(
|
|
2036
|
+
Bun.stdin.stream(),
|
|
2037
|
+
OBJECT_MAX_BYTES,
|
|
2038
|
+
() =>
|
|
2039
|
+
new CloudError(
|
|
2040
|
+
`the piped object is larger than ${OBJECT_MAX_BYTES / 1024 / 1024} MiB, the most the Cloud takes`,
|
|
2041
|
+
),
|
|
2042
|
+
),
|
|
2043
|
+
);
|
|
2044
|
+
});
|
|
2045
|
+
} catch (error) {
|
|
2046
|
+
if (error instanceof R2UsageError) usageError(error.message, error.usage);
|
|
2047
|
+
throw error;
|
|
2048
|
+
}
|
|
1058
2049
|
}
|
|
1059
2050
|
|
|
1060
2051
|
/**
|
|
@@ -1086,6 +2077,9 @@ function idForName(rows: JsonObject[], name: string, product: StorageProduct): s
|
|
|
1086
2077
|
async function storage(key: string, args: string[]) {
|
|
1087
2078
|
const product = STORAGE_PRODUCTS.find((entry) => entry.name === key)!;
|
|
1088
2079
|
const sub = args[0] && STORAGE_VERBS.some((verb) => verb === args[0]) ? args.shift()! : "list";
|
|
2080
|
+
// The five verbs take names, never options (the contents commands and `checkOptions` handle the others).
|
|
2081
|
+
const stray = args.find((arg) => arg.startsWith("-") && arg !== "-");
|
|
2082
|
+
if (stray) usageError(`${key}: unknown option ${stray.split("=")[0]}`, usageOf(key));
|
|
1089
2083
|
const { apiUrl, token } = await apiCredentials();
|
|
1090
2084
|
const base = `${apiUrl}/api/${product.name}`;
|
|
1091
2085
|
const auth = { "x-api-key": token };
|
|
@@ -1430,8 +2424,14 @@ async function toolchain(args: string[]): Promise<void> {
|
|
|
1430
2424
|
}
|
|
1431
2425
|
|
|
1432
2426
|
/** `sproutboat <command> --help`: that command's full usage line + summary, exit 0. */
|
|
1433
|
-
|
|
2427
|
+
/** The grammar line of a command, for usage errors. */
|
|
2428
|
+
function usageOf(name: string): string {
|
|
1434
2429
|
const found = COMMANDS.find((c) => c.name === name);
|
|
2430
|
+
return found?.args ? `${name} ${found.args}` : name;
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
function commandHelp(name: string): never {
|
|
2434
|
+
const found = COMMANDS.find((c) => c.name === resolveCommand(name));
|
|
1435
2435
|
if (!found) usage();
|
|
1436
2436
|
console.log(`${dim(`usage: ${CLI_NAME} ${name}${found.args ? ` ${found.args}` : ""}`)}\n\n${found.summary}`);
|
|
1437
2437
|
process.exit(0);
|
|
@@ -1444,7 +2444,7 @@ const FISH_COMPLETION = (names: string) => `complete -c ${CLI_NAME} -n "__fish_u
|
|
|
1444
2444
|
|
|
1445
2445
|
/** `sproutboat complete [shell]`: a static completion script for the top-level commands. */
|
|
1446
2446
|
function complete(shell?: string): void {
|
|
1447
|
-
const names = COMMANDS.
|
|
2447
|
+
const names = COMMANDS.flatMap((c) => [c.name, ...(c.aliases ?? [])]).join(" ");
|
|
1448
2448
|
const scripts = { bash: BASH_COMPLETION(names), zsh: ZSH_COMPLETION(names), fish: FISH_COMPLETION(names) };
|
|
1449
2449
|
const key = shell ?? process.env.SHELL?.split("/").pop() ?? "bash";
|
|
1450
2450
|
const script = key === "bash" || key === "zsh" || key === "fish" ? scripts[key] : undefined;
|
|
@@ -1452,27 +2452,68 @@ function complete(shell?: string): void {
|
|
|
1452
2452
|
console.log(script);
|
|
1453
2453
|
}
|
|
1454
2454
|
|
|
1455
|
-
const
|
|
1456
|
-
|
|
1457
|
-
|
|
2455
|
+
const GLOBAL_USAGE =
|
|
2456
|
+
"<command> [-c, --config <file>] [--cwd <dir>] [-e, --env <name>] [--env-file <file>]... [--profile <name>] [--api-url <url>] [-v, --version]";
|
|
2457
|
+
const parsedArgv = extractGlobalFlags(process.argv.slice(2));
|
|
2458
|
+
if ("error" in parsedArgv) usageError(parsedArgv.error, GLOBAL_USAGE);
|
|
2459
|
+
const globals = parsedArgv.flags;
|
|
2460
|
+
if (globals.version) {
|
|
1458
2461
|
console.log(`sproutboat ${CLI_VERSION}`);
|
|
1459
2462
|
process.exit(0);
|
|
1460
2463
|
}
|
|
2464
|
+
if (globals.cwd !== undefined) {
|
|
2465
|
+
try {
|
|
2466
|
+
process.chdir(globals.cwd);
|
|
2467
|
+
} catch {
|
|
2468
|
+
fail(`--cwd: no such directory: ${globals.cwd}`);
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
setGlobalFlags(globals);
|
|
2472
|
+
const [command, ...args] = parsedArgv.rest;
|
|
2473
|
+
if (command === undefined || command === "help" || command === "-h" || command === "--help") help();
|
|
1461
2474
|
if (args.includes("-h") || args.includes("--help")) commandHelp(command);
|
|
1462
2475
|
|
|
2476
|
+
// A flag a command cannot honour is an error, not a quiet no-op: `deploy --env staging` must not deploy.
|
|
2477
|
+
if (globals.env !== undefined && command !== "dev")
|
|
2478
|
+
usageError(
|
|
2479
|
+
`${command} does not use --env: it only selects .dev.vars.<name> and the .env.<name> files for \`dev\`, and sproutboat.jsonc has no environments`,
|
|
2480
|
+
GLOBAL_USAGE,
|
|
2481
|
+
);
|
|
2482
|
+
if (globals.envFiles.length > 0 && command !== "dev" && command !== "run")
|
|
2483
|
+
usageError(`${command} does not read env files; --env-file is for \`dev\` and \`run\``, GLOBAL_USAGE);
|
|
2484
|
+
if (globals.profile !== undefined && !PROFILE_NAME.test(globals.profile))
|
|
2485
|
+
usageError(`"${globals.profile}" is not a profile name (letters, digits, . _ -; at most 64)`, GLOBAL_USAGE);
|
|
2486
|
+
if (globals.apiUrl !== undefined) {
|
|
2487
|
+
if (!ENDPOINT_COMMANDS.has(command))
|
|
2488
|
+
usageError(
|
|
2489
|
+
`${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`,
|
|
2490
|
+
GLOBAL_USAGE,
|
|
2491
|
+
);
|
|
2492
|
+
if (!/^https?:\/\/[^\s/]+/i.test(globals.apiUrl))
|
|
2493
|
+
usageError(
|
|
2494
|
+
`--api-url "${globals.apiUrl}" is not an http(s) URL, such as https://dash.sproutboat.com`,
|
|
2495
|
+
GLOBAL_USAGE,
|
|
2496
|
+
);
|
|
2497
|
+
}
|
|
2498
|
+
// What each command accepts: an unknown flag is an error, `--flag=value` becomes `--flag value`, and a URL is never
|
|
2499
|
+
// read as a project directory (options.ts).
|
|
2500
|
+
const checked = checkOptions(command, args);
|
|
2501
|
+
if ("error" in checked) usageError(`${command}: ${checked.error}`, usageOf(command));
|
|
2502
|
+
args.splice(0, args.length, ...checked.args);
|
|
2503
|
+
|
|
1463
2504
|
// A local log needs no network: skip the version check for it (#268).
|
|
1464
2505
|
if (!(command === "tail" && args.some((arg) => arg === "--file" || arg.startsWith("--file="))))
|
|
1465
2506
|
await notifyIfOutdated(CLI_VERSION);
|
|
1466
2507
|
|
|
1467
|
-
switch (command) {
|
|
2508
|
+
switch (resolveCommand(command)) {
|
|
1468
2509
|
case "init":
|
|
1469
|
-
await init(args
|
|
2510
|
+
await init(args);
|
|
1470
2511
|
break;
|
|
1471
2512
|
case "check":
|
|
1472
2513
|
await check(args[0]);
|
|
1473
2514
|
break;
|
|
1474
2515
|
case "types":
|
|
1475
|
-
await types(args
|
|
2516
|
+
await types(args);
|
|
1476
2517
|
break;
|
|
1477
2518
|
case "dev":
|
|
1478
2519
|
await dev(args);
|
|
@@ -1508,7 +2549,8 @@ switch (command) {
|
|
|
1508
2549
|
"build --standalone --target linux-x64 --deploy-files [project-dir]",
|
|
1509
2550
|
);
|
|
1510
2551
|
if (args.includes("--emit-only")) await emitOnly(directory, target, args.includes("--standalone"));
|
|
1511
|
-
else if (args.includes("--standalone"))
|
|
2552
|
+
else if (args.includes("--standalone"))
|
|
2553
|
+
await buildStandaloneBinary(directory, target, withRestore, deployFiles, isReleaseBuild(args));
|
|
1512
2554
|
else await build(directory, target);
|
|
1513
2555
|
break;
|
|
1514
2556
|
}
|
|
@@ -1522,11 +2564,17 @@ switch (command) {
|
|
|
1522
2564
|
await logout(args);
|
|
1523
2565
|
break;
|
|
1524
2566
|
case "whoami":
|
|
1525
|
-
await whoami();
|
|
2567
|
+
await whoami(args);
|
|
2568
|
+
break;
|
|
2569
|
+
case "profile":
|
|
2570
|
+
await profile(args);
|
|
1526
2571
|
break;
|
|
1527
2572
|
case "deploy":
|
|
1528
2573
|
await deploy(args);
|
|
1529
2574
|
break;
|
|
2575
|
+
case "deployments":
|
|
2576
|
+
await deployments(args);
|
|
2577
|
+
break;
|
|
1530
2578
|
case "versions":
|
|
1531
2579
|
await versions(args);
|
|
1532
2580
|
break;
|
|
@@ -1544,7 +2592,13 @@ switch (command) {
|
|
|
1544
2592
|
else await storage(command, args);
|
|
1545
2593
|
break;
|
|
1546
2594
|
case "d1":
|
|
2595
|
+
if (["execute", "migrations"].includes(args[0] ?? "")) await d1Contents(args);
|
|
2596
|
+
else await storage(command, args);
|
|
2597
|
+
break;
|
|
1547
2598
|
case "r2":
|
|
2599
|
+
if (args[0] === "object") await r2Object(args);
|
|
2600
|
+
else await storage(command, args);
|
|
2601
|
+
break;
|
|
1548
2602
|
case "queues":
|
|
1549
2603
|
await storage(command, args);
|
|
1550
2604
|
break;
|