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