sproutboat 0.8.0 → 0.10.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 +365 -0
- package/README.md +49 -4
- package/SURFACE.md +12 -5
- package/bin/sproutboat.cjs +32 -0
- package/package.json +27 -10
- package/scripts/build-cli.ts +44 -0
- package/src/assets.ts +4 -121
- package/src/broker.ts +4 -1139
- package/src/build.ts +31 -16
- package/src/compile.ts +80 -28
- package/src/config.ts +4 -379
- package/src/dev.ts +263 -60
- package/src/json.ts +3 -38
- package/src/main.ts +269 -16
- package/src/manifest.ts +4 -167
- package/src/patch-porffor.ts +5 -144
- package/src/porffor-toolchain.ts +5 -0
- package/src/report.ts +5 -7
- package/src/source.ts +4 -44
- package/src/surface.ts +33 -3
- package/src/toolchain.ts +228 -44
- package/src/types-gen.ts +93 -0
- package/src/wrap.ts +5 -267
- package/types/sproutboat.d.ts +248 -0
- package/src/native-fetch-prelude.js +0 -950
- package/src/transport-broker.js +0 -290
- package/src/transport-embedded.js +0 -1567
package/src/main.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
-
import {
|
|
2
|
+
import { mkdir, open, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { rmSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, relative, resolve } from "node:path";
|
|
4
5
|
import { isBoolean, isSafeInteger, isString, jsonObject, parseJsonValue, type JsonObject } from "./json";
|
|
5
6
|
import type { AssetFiles } from "./assets";
|
|
6
7
|
import { parseConfig, pinBindingId, resourceRefs, type SproutboatConfig } from "./config";
|
|
7
8
|
import { validateHttpSyncSource } from "./source";
|
|
8
9
|
import { buildArtifact } from "./build";
|
|
10
|
+
import { generateTypes, TYPES_FILE } from "./types-gen";
|
|
9
11
|
import { bundleHandler, BundleError, type BundleResult } from "./bundle";
|
|
10
12
|
import { runDev } from "./dev";
|
|
11
13
|
import { buildStandalone } from "./standalone-build";
|
|
@@ -16,6 +18,19 @@ import { helpText, STORAGE_PRODUCTS, STORAGE_VERBS, type StorageProduct } from "
|
|
|
16
18
|
import { notifyIfOutdated } from "./update-check";
|
|
17
19
|
import { controlVersionWarning } from "./api-version";
|
|
18
20
|
import { amber, bold, dim, leaf, ok, rose } from "./style";
|
|
21
|
+
import { pathToFileURL } from "node:url";
|
|
22
|
+
|
|
23
|
+
// A compiled Bun executable is not a general-purpose `bun` command. Porffor
|
|
24
|
+
// is acquired after installation, so run its external ESM launcher through an
|
|
25
|
+
// explicit private mode inside the packaged runtime.
|
|
26
|
+
const internalModeAt = process.argv.slice(1, 3).indexOf("__porffor") + 1;
|
|
27
|
+
if (internalModeAt > 0) {
|
|
28
|
+
const launcher = process.argv[internalModeAt + 1];
|
|
29
|
+
if (!launcher) throw new Error("internal Porffor mode requires a launcher path");
|
|
30
|
+
process.argv.splice(1, internalModeAt + 1, launcher);
|
|
31
|
+
await import(pathToFileURL(launcher).href);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
19
34
|
|
|
20
35
|
const defaultApiUrl = "https://dashboard.sproutboat.com";
|
|
21
36
|
|
|
@@ -163,7 +178,9 @@ function usageError(what: string, usage: string): never {
|
|
|
163
178
|
process.exit(2);
|
|
164
179
|
}
|
|
165
180
|
|
|
166
|
-
|
|
181
|
+
/** Read the project identity and configuration without touching application code.
|
|
182
|
+
* Management commands must remain usable when a local checkout is incomplete. */
|
|
183
|
+
async function readProjectConfig(directory = process.cwd()) {
|
|
167
184
|
const projectDirectory = resolve(directory);
|
|
168
185
|
const configPath = resolve(projectDirectory, "sproutboat.jsonc");
|
|
169
186
|
let configSource: string;
|
|
@@ -174,25 +191,31 @@ async function readProject(directory = process.cwd()) {
|
|
|
174
191
|
}
|
|
175
192
|
const parsed = parseConfig(configSource);
|
|
176
193
|
if (!parsed.ok) fail(parsed.errors.join("\n"));
|
|
177
|
-
|
|
194
|
+
return { directory: projectDirectory, config: parsed.value };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Full build input: configuration plus entry point, resolved imports and checks. */
|
|
198
|
+
async function readProject(directory = process.cwd()) {
|
|
199
|
+
const project = await readProjectConfig(directory);
|
|
200
|
+
const sourcePath = resolve(project.directory, project.config.main);
|
|
178
201
|
let source: string;
|
|
179
202
|
try {
|
|
180
203
|
source = await readFile(sourcePath, "utf8");
|
|
181
204
|
} catch {
|
|
182
|
-
fail(`entry point not found: ${
|
|
205
|
+
fail(`entry point not found: ${project.config.main}`);
|
|
183
206
|
}
|
|
184
207
|
// #89 — resolve imports first, then hold the *bundled* module to the
|
|
185
208
|
// capability rules. Validating the entry file instead would let a dependency
|
|
186
209
|
// smuggle in a Node API the handler is not allowed to touch.
|
|
187
210
|
let bundle: BundleResult;
|
|
188
211
|
try {
|
|
189
|
-
bundle = await bundleHandler(sourcePath,
|
|
212
|
+
bundle = await bundleHandler(sourcePath, project.directory);
|
|
190
213
|
} catch (cause) {
|
|
191
214
|
fail(cause instanceof BundleError ? cause.message : String(cause));
|
|
192
215
|
}
|
|
193
|
-
const supported = validateHttpSyncSource(bundle.code, (
|
|
216
|
+
const supported = validateHttpSyncSource(bundle.code, (project.config.outbound ?? []).length > 0);
|
|
194
217
|
if (!supported.ok) fail(supported.errors.join("\n"));
|
|
195
|
-
return {
|
|
218
|
+
return { ...project, sourcePath, source, bundle };
|
|
196
219
|
}
|
|
197
220
|
|
|
198
221
|
async function init(name = "hello") {
|
|
@@ -231,6 +254,35 @@ async function check(directory?: string) {
|
|
|
231
254
|
console.log(ok(`check passed — ${project.config.name} (${project.config.main}, native-fetch)`));
|
|
232
255
|
}
|
|
233
256
|
|
|
257
|
+
async function types(directory?: string) {
|
|
258
|
+
const project = await readProject(directory);
|
|
259
|
+
const path = resolve(project.directory, TYPES_FILE);
|
|
260
|
+
await writeFile(path, generateTypes(project.config));
|
|
261
|
+
console.log(ok(`wrote ${relative(process.cwd(), path) || TYPES_FILE}`));
|
|
262
|
+
console.log(dim(" Commit it. Add it to tsconfig `include` if your project has one."));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Rewrite the generated types when the config has moved on, and only then.
|
|
267
|
+
*
|
|
268
|
+
* Called from `dev` and `deploy` so a binding added to sproutboat.jsonc cannot
|
|
269
|
+
* sit unreflected in the editor. It writes only when the content actually
|
|
270
|
+
* differs, which keeps it out of the way of git and of file watchers.
|
|
271
|
+
*/
|
|
272
|
+
async function refreshTypes(projectDir: string, config: SproutboatConfig): Promise<void> {
|
|
273
|
+
const path = resolve(projectDir, TYPES_FILE);
|
|
274
|
+
const wanted = generateTypes(config);
|
|
275
|
+
try {
|
|
276
|
+
if ((await readFile(path, "utf8")) === wanted) return;
|
|
277
|
+
} catch {
|
|
278
|
+
// No file yet: only write one if the project already opted in by having a
|
|
279
|
+
// tsconfig. Generating into a plain JavaScript project is noise.
|
|
280
|
+
if (!(await Bun.file(resolve(projectDir, "tsconfig.json")).exists())) return;
|
|
281
|
+
}
|
|
282
|
+
await writeFile(path, wanted);
|
|
283
|
+
console.log(dim(` ${TYPES_FILE} refreshed from sproutboat.jsonc`));
|
|
284
|
+
}
|
|
285
|
+
|
|
234
286
|
async function build(directory?: string, target: "linux-x86_64" | "host" = "linux-x86_64") {
|
|
235
287
|
const project = await readProject(directory);
|
|
236
288
|
console.log(
|
|
@@ -287,6 +339,7 @@ async function dev(args: string[]) {
|
|
|
287
339
|
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535)
|
|
288
340
|
usageError(`invalid --port: ${portArg}`, "dev [project-dir] [--port <n>] [--no-watch]");
|
|
289
341
|
const project = await readProject(directory);
|
|
342
|
+
await refreshTypes(project.directory, project.config);
|
|
290
343
|
console.log(dim(`Building ${project.config.name} for this machine (${hostTarget()})…`));
|
|
291
344
|
await runDev({
|
|
292
345
|
projectDir: project.directory,
|
|
@@ -297,7 +350,11 @@ async function dev(args: string[]) {
|
|
|
297
350
|
watch: !args.includes("--no-watch"),
|
|
298
351
|
// Re-read from disk on every rebuild: the point of watching is that the
|
|
299
352
|
// files changed, so the bundle captured at startup is stale by definition.
|
|
300
|
-
rebuild: async () =>
|
|
353
|
+
rebuild: async () => {
|
|
354
|
+
const next = await readProject(directory);
|
|
355
|
+
await refreshTypes(next.directory, next.config);
|
|
356
|
+
return { config: next.config, sourcePath: next.sourcePath, source: next.bundle.code };
|
|
357
|
+
},
|
|
301
358
|
});
|
|
302
359
|
}
|
|
303
360
|
|
|
@@ -410,6 +467,8 @@ async function deploy(args: string[]) {
|
|
|
410
467
|
projectName = built.project.config.name;
|
|
411
468
|
artifactDir = built.artifact.artifactDir;
|
|
412
469
|
config = built.project.config;
|
|
470
|
+
// After provisioning: ids may have been pinned into the config just now.
|
|
471
|
+
await refreshTypes(built.project.directory, built.project.config);
|
|
413
472
|
}
|
|
414
473
|
const manifest = Bun.file(resolve(artifactDir, "manifest.json"));
|
|
415
474
|
const sprout = Bun.file(resolve(artifactDir, "sprout"));
|
|
@@ -597,7 +656,7 @@ async function versions(args: string[]) {
|
|
|
597
656
|
if (sub === "view") {
|
|
598
657
|
const id = args.shift();
|
|
599
658
|
if (!id) usageError("versions view: missing <version-id>", "versions view <version-id> [project-dir]");
|
|
600
|
-
const [project, { apiUrl, token }] = await Promise.all([
|
|
659
|
+
const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(args[0]), apiCredentials()]);
|
|
601
660
|
const body = await responseText(
|
|
602
661
|
await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments/${encodeURIComponent(id)}`, {
|
|
603
662
|
headers: { "x-api-key": token },
|
|
@@ -628,7 +687,7 @@ async function versions(args: string[]) {
|
|
|
628
687
|
return;
|
|
629
688
|
}
|
|
630
689
|
|
|
631
|
-
const [project, { apiUrl, token }] = await Promise.all([
|
|
690
|
+
const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(args[0]), apiCredentials()]);
|
|
632
691
|
const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments`, {
|
|
633
692
|
headers: { "x-api-key": token },
|
|
634
693
|
});
|
|
@@ -643,7 +702,7 @@ async function versions(args: string[]) {
|
|
|
643
702
|
async function rollback(args: string[]) {
|
|
644
703
|
const id = args[0];
|
|
645
704
|
if (!id) usageError("rollback: missing <version-id>", "rollback <version-id> [project-dir]");
|
|
646
|
-
const [project, { apiUrl, token }] = await Promise.all([
|
|
705
|
+
const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(args[1]), apiCredentials()]);
|
|
647
706
|
const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments/${id}/activate`, {
|
|
648
707
|
method: "POST",
|
|
649
708
|
headers: { "x-api-key": token },
|
|
@@ -657,7 +716,7 @@ async function rollback(args: string[]) {
|
|
|
657
716
|
async function tail(args: string[]) {
|
|
658
717
|
const sproutLog = args.includes("--sprout");
|
|
659
718
|
const dir = args.find((arg) => !arg.startsWith("-"));
|
|
660
|
-
const [project, { apiUrl, token }] = await Promise.all([
|
|
719
|
+
const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(dir), apiCredentials()]);
|
|
661
720
|
const path = sproutLog ? "logs/sprout" : "logs/recent";
|
|
662
721
|
const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/${path}`, {
|
|
663
722
|
headers: { "x-api-key": token },
|
|
@@ -712,7 +771,7 @@ async function domains(args: string[]) {
|
|
|
712
771
|
const host = sub === "list" ? undefined : args.shift();
|
|
713
772
|
if (sub !== "list" && !host)
|
|
714
773
|
usageError(`domains ${sub}: missing <hostname>`, `domains ${sub} <hostname> [project-dir]`);
|
|
715
|
-
const [project, { apiUrl, token }] = await Promise.all([
|
|
774
|
+
const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(args[0]), apiCredentials()]);
|
|
716
775
|
const base = `${apiUrl}/api/projects/${project.config.name}/domains`;
|
|
717
776
|
const auth = { "x-api-key": token };
|
|
718
777
|
|
|
@@ -769,7 +828,7 @@ async function secrets(args: string[]) {
|
|
|
769
828
|
const name = sub === "list" ? undefined : positional.shift();
|
|
770
829
|
if (sub !== "list" && !name) usageError(`secrets ${sub}: missing <NAME>`, `secrets ${sub} <NAME> [project-dir]`);
|
|
771
830
|
if (name && !/^[A-Z][A-Z0-9_]*$/.test(name)) fail("secret name must be UPPER_SNAKE_CASE");
|
|
772
|
-
const [project, { apiUrl, token }] = await Promise.all([
|
|
831
|
+
const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(positional[0]), apiCredentials()]);
|
|
773
832
|
const base = `${apiUrl}/api/projects/${project.config.name}/secrets`;
|
|
774
833
|
const auth = { "x-api-key": token };
|
|
775
834
|
|
|
@@ -897,6 +956,194 @@ async function storage(key: string, args: string[]) {
|
|
|
897
956
|
console.log(ok(`deleted ${product.noun} ${name}`));
|
|
898
957
|
}
|
|
899
958
|
|
|
959
|
+
function option(args: string[], name: string): string | undefined {
|
|
960
|
+
const index = args.indexOf(name);
|
|
961
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
async function writeAtomic(
|
|
965
|
+
path: string,
|
|
966
|
+
chunks: AsyncIterable<string> | readonly string[],
|
|
967
|
+
overwrite: boolean,
|
|
968
|
+
): Promise<void> {
|
|
969
|
+
const destination = resolve(path);
|
|
970
|
+
if (!overwrite && (await Bun.file(destination).exists())) fail(`${path} already exists; pass --force to replace it`);
|
|
971
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
972
|
+
const temporary = `${destination}.tmp-${process.pid}-${Date.now()}`;
|
|
973
|
+
const cleanup = () => {
|
|
974
|
+
try {
|
|
975
|
+
rmSync(temporary, { force: true });
|
|
976
|
+
} catch {
|
|
977
|
+
/* best effort during process shutdown */
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
process.once("exit", cleanup);
|
|
981
|
+
const file = await open(temporary, "wx", 0o600);
|
|
982
|
+
try {
|
|
983
|
+
for await (const chunk of chunks) await file.write(chunk);
|
|
984
|
+
await file.sync();
|
|
985
|
+
await file.close();
|
|
986
|
+
await rename(temporary, destination);
|
|
987
|
+
process.off("exit", cleanup);
|
|
988
|
+
} catch (error) {
|
|
989
|
+
await file.close().catch(() => undefined);
|
|
990
|
+
cleanup();
|
|
991
|
+
process.off("exit", cleanup);
|
|
992
|
+
throw error;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
async function kvContents(args: string[]): Promise<void> {
|
|
997
|
+
const area = args.shift();
|
|
998
|
+
if (!area || !["key", "bulk", "export"].includes(area))
|
|
999
|
+
usageError("kv: unknown contents command", "kv <key | bulk | export> ...");
|
|
1000
|
+
const namespace = area === "export" ? args.shift() : args[1];
|
|
1001
|
+
if (!namespace) usageError(`kv ${area}: missing <namespace>`, `kv ${area} <namespace> ...`);
|
|
1002
|
+
const { apiUrl, token } = await apiCredentials();
|
|
1003
|
+
const rows = await storageRows(`${apiUrl}/api/kv`, { "x-api-key": token }, STORAGE_PRODUCTS[0]);
|
|
1004
|
+
const id = idForName(rows, namespace, STORAGE_PRODUCTS[0]);
|
|
1005
|
+
const base = `${apiUrl}/api/kv/${id}`;
|
|
1006
|
+
const auth = { "x-api-key": token };
|
|
1007
|
+
|
|
1008
|
+
if (area === "key") {
|
|
1009
|
+
const verb = args.shift();
|
|
1010
|
+
args.shift();
|
|
1011
|
+
if (!verb || !["list", "get", "put", "delete"].includes(verb))
|
|
1012
|
+
usageError("kv key: unknown operation", "kv key <list | get | put | delete> <namespace> ...");
|
|
1013
|
+
if (verb === "list") {
|
|
1014
|
+
const query = new URLSearchParams();
|
|
1015
|
+
for (const flag of ["--prefix", "--cursor", "--limit"] as const) {
|
|
1016
|
+
const value = option(args, flag);
|
|
1017
|
+
if (value !== undefined) query.set(flag.slice(2), value);
|
|
1018
|
+
}
|
|
1019
|
+
const body = await responseText(await fetch(`${base}/keys?${query}`, { headers: auth }), "list rejected");
|
|
1020
|
+
console.log(body);
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
const key = args.shift();
|
|
1024
|
+
if (!key) usageError(`kv key ${verb}: missing <key>`, `kv key ${verb} <namespace> <key>`);
|
|
1025
|
+
const url = `${base}/keys/${encodeURIComponent(key)}`;
|
|
1026
|
+
if (verb === "get") {
|
|
1027
|
+
const body = await responseText(await fetch(url, { headers: auth }), "get rejected");
|
|
1028
|
+
const record = jsonObject(parseJsonValue(body));
|
|
1029
|
+
if (!record || !isString(record.value)) fail("get response did not contain a value");
|
|
1030
|
+
const output = option(args, "--output");
|
|
1031
|
+
if (output) await writeAtomic(output, [record.value], args.includes("--force"));
|
|
1032
|
+
else console.log(args.includes("--text") ? record.value : JSON.stringify({ key, value: record.value }, null, 2));
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
if (verb === "put") {
|
|
1036
|
+
const path = option(args, "--path");
|
|
1037
|
+
const positional = args.find((arg, index) => !arg.startsWith("-") && args[index - 1] !== "--path");
|
|
1038
|
+
if (path && positional)
|
|
1039
|
+
usageError(
|
|
1040
|
+
"kv key put: use a value or --path, not both",
|
|
1041
|
+
"kv key put <namespace> <key> [value] [--path <file>]",
|
|
1042
|
+
);
|
|
1043
|
+
const value = path ? await readFile(resolve(path), "utf8") : (positional ?? (await Bun.stdin.text()));
|
|
1044
|
+
await responseText(
|
|
1045
|
+
await fetch(url, {
|
|
1046
|
+
method: "PUT",
|
|
1047
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
1048
|
+
body: JSON.stringify({ value }),
|
|
1049
|
+
}),
|
|
1050
|
+
"put rejected",
|
|
1051
|
+
);
|
|
1052
|
+
console.log(ok(`set ${key}`));
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
if (!args.includes("--yes")) fail(`this permanently deletes "${key}"; re-run with --yes`);
|
|
1056
|
+
await responseText(await fetch(url, { method: "DELETE", headers: auth }), "delete rejected");
|
|
1057
|
+
console.log(ok(`deleted ${key}`));
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (area === "bulk") {
|
|
1062
|
+
const verb = args.shift();
|
|
1063
|
+
args.shift();
|
|
1064
|
+
const input = args.shift();
|
|
1065
|
+
if (!verb || !["get", "put", "delete"].includes(verb) || !input)
|
|
1066
|
+
usageError(
|
|
1067
|
+
"kv bulk: expected an operation, namespace, and JSON file",
|
|
1068
|
+
"kv bulk <get | put | delete> <namespace> <file>",
|
|
1069
|
+
);
|
|
1070
|
+
if (verb === "delete" && !args.includes("--yes")) fail("bulk deletion requires --yes");
|
|
1071
|
+
const parsed = parseJsonValue(await readFile(resolve(input), "utf8"));
|
|
1072
|
+
if (!Array.isArray(parsed) || parsed.length === 0) fail("bulk input must be a non-empty JSON array");
|
|
1073
|
+
const responses: unknown[] = [];
|
|
1074
|
+
let changed = 0;
|
|
1075
|
+
const failures: unknown[] = [];
|
|
1076
|
+
for (let start = 0; start < parsed.length; start += 100) {
|
|
1077
|
+
const body = await responseText(
|
|
1078
|
+
await fetch(`${base}/bulk/${verb}`, {
|
|
1079
|
+
method: "POST",
|
|
1080
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
1081
|
+
body: JSON.stringify(parsed.slice(start, start + 100)),
|
|
1082
|
+
}),
|
|
1083
|
+
`bulk ${verb} rejected`,
|
|
1084
|
+
);
|
|
1085
|
+
const result = parseJsonValue(body);
|
|
1086
|
+
if (verb === "get" && Array.isArray(result)) responses.push(...result);
|
|
1087
|
+
else {
|
|
1088
|
+
const summary = jsonObject(result);
|
|
1089
|
+
changed += Number(summary?.written ?? summary?.deleted ?? 0);
|
|
1090
|
+
if (Array.isArray(summary?.failures)) failures.push(...summary.failures);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
const result = verb === "get" ? responses : { [verb === "put" ? "written" : "deleted"]: changed, failures };
|
|
1094
|
+
const output = option(args, "--output");
|
|
1095
|
+
if (output) await writeAtomic(output, [JSON.stringify(result, null, 2) + "\n"], args.includes("--force"));
|
|
1096
|
+
else console.log(JSON.stringify(result, null, 2));
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const output = option(args, "--output");
|
|
1101
|
+
if (!output)
|
|
1102
|
+
usageError(
|
|
1103
|
+
"kv export: missing --output <file>",
|
|
1104
|
+
"kv export <namespace> --output <dump.json> [--prefix <prefix>] [--force]",
|
|
1105
|
+
);
|
|
1106
|
+
const prefix = option(args, "--prefix") ?? "";
|
|
1107
|
+
async function* dump(): AsyncGenerator<string> {
|
|
1108
|
+
yield "[\n";
|
|
1109
|
+
let cursor = "";
|
|
1110
|
+
let first = true;
|
|
1111
|
+
do {
|
|
1112
|
+
const query = new URLSearchParams({ prefix, limit: "100" });
|
|
1113
|
+
if (cursor) query.set("cursor", cursor);
|
|
1114
|
+
const listed = jsonObject(
|
|
1115
|
+
parseJsonValue(
|
|
1116
|
+
await responseText(await fetch(`${base}/keys?${query}`, { headers: auth }), "export list rejected"),
|
|
1117
|
+
),
|
|
1118
|
+
);
|
|
1119
|
+
const keys = Array.isArray(listed?.keys) ? listed.keys.filter(isString) : [];
|
|
1120
|
+
if (keys.length) {
|
|
1121
|
+
const values = parseJsonValue(
|
|
1122
|
+
await responseText(
|
|
1123
|
+
await fetch(`${base}/bulk/get`, {
|
|
1124
|
+
method: "POST",
|
|
1125
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
1126
|
+
body: JSON.stringify(keys),
|
|
1127
|
+
}),
|
|
1128
|
+
"export read rejected",
|
|
1129
|
+
),
|
|
1130
|
+
);
|
|
1131
|
+
if (!Array.isArray(values)) fail("export response was not an array");
|
|
1132
|
+
for (const entry of values) {
|
|
1133
|
+
const record = jsonObject(entry);
|
|
1134
|
+
if (!record || !isString(record.key) || !isString(record.value)) continue;
|
|
1135
|
+
yield `${first ? "" : ",\n"} ${JSON.stringify({ key: record.key, value: record.value })}`;
|
|
1136
|
+
first = false;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
cursor = isString(listed?.cursor) ? listed.cursor : "";
|
|
1140
|
+
} while (cursor);
|
|
1141
|
+
yield "\n]\n";
|
|
1142
|
+
}
|
|
1143
|
+
await writeAtomic(output, dump(), args.includes("--force"));
|
|
1144
|
+
console.log(ok(`exported ${namespace} to ${output}`));
|
|
1145
|
+
}
|
|
1146
|
+
|
|
900
1147
|
async function deleteProject(args: string[]) {
|
|
901
1148
|
// `sproutboat delete [project-dir] [--name <project>] --yes` — flags in any order.
|
|
902
1149
|
const positional: string[] = [];
|
|
@@ -911,7 +1158,7 @@ async function deleteProject(args: string[]) {
|
|
|
911
1158
|
}
|
|
912
1159
|
|
|
913
1160
|
const { apiUrl, token } = await apiCredentials();
|
|
914
|
-
const name = explicitName ?? (await
|
|
1161
|
+
const name = explicitName ?? (await readProjectConfig(positional[0])).config.name;
|
|
915
1162
|
if (!confirmed) fail(`this permanently removes "${name}", every version, and its route — re-run with --yes`);
|
|
916
1163
|
|
|
917
1164
|
const url = `${apiUrl}/api/projects/${encodeURIComponent(name)}?confirm=${encodeURIComponent(name)}`;
|
|
@@ -963,6 +1210,9 @@ switch (command) {
|
|
|
963
1210
|
case "check":
|
|
964
1211
|
await check(args[0]);
|
|
965
1212
|
break;
|
|
1213
|
+
case "types":
|
|
1214
|
+
await types(args[0]);
|
|
1215
|
+
break;
|
|
966
1216
|
case "dev":
|
|
967
1217
|
await dev(args);
|
|
968
1218
|
break;
|
|
@@ -998,6 +1248,9 @@ switch (command) {
|
|
|
998
1248
|
await secrets(args);
|
|
999
1249
|
break;
|
|
1000
1250
|
case "kv":
|
|
1251
|
+
if (["key", "bulk", "export"].includes(args[0] ?? "")) await kvContents(args);
|
|
1252
|
+
else await storage(command, args);
|
|
1253
|
+
break;
|
|
1001
1254
|
case "d1":
|
|
1002
1255
|
case "r2":
|
|
1003
1256
|
case "queues":
|
package/src/manifest.ts
CHANGED
|
@@ -1,167 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
17
|
-
export type ArtifactManifest = {
|
|
18
|
-
schemaVersion: 2;
|
|
19
|
-
project: string;
|
|
20
|
-
target: typeof DEPLOY_TARGET | HostTarget;
|
|
21
|
-
runtime: "native-fetch";
|
|
22
|
-
capabilityProfile: "http-sync-v0";
|
|
23
|
-
porfforVersion: string;
|
|
24
|
-
esbuildVersion: string;
|
|
25
|
-
/** Build provenance, e.g. `zig-musl/0.16.0+porffor/a415d19+uws/360c276d`. */
|
|
26
|
-
buildImage: string;
|
|
27
|
-
/**
|
|
28
|
-
* The project's `compatibility_date`, as `YYYY-MM-DD`. Optional: artifacts
|
|
29
|
-
* built before this field existed have none, and every reader must treat that
|
|
30
|
-
* as the baseline (see `BASELINE_COMPATIBILITY_DATE` in `wrap.ts`). This is
|
|
31
|
-
* how a runtime behaviour change ships without breaking deployed apps — new
|
|
32
|
-
* builds opt in by moving their date, old binaries keep the old semantics —
|
|
33
|
-
* so it is deliberately *not* part of `schemaVersion`, which stays frozen
|
|
34
|
-
* at 2.
|
|
35
|
-
*/
|
|
36
|
-
compatibilityDate?: string;
|
|
37
|
-
sourceHash: `sha256:${string}`;
|
|
38
|
-
binaryHash: `sha256:${string}`;
|
|
39
|
-
binarySize: number;
|
|
40
|
-
builtAt: string;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
export type ManifestValidation = { ok: true; value: ArtifactManifest } | { ok: false; errors: string[] };
|
|
44
|
-
|
|
45
|
-
type JsonValue = string | number | boolean | null | ManifestJsonObject | JsonValue[];
|
|
46
|
-
|
|
47
|
-
interface ManifestJsonObject {
|
|
48
|
-
readonly [key: string]: JsonValue;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
type ManifestInput = JsonValue | undefined;
|
|
52
|
-
|
|
53
|
-
function isRecord(value: ManifestInput): value is ManifestJsonObject {
|
|
54
|
-
return value !== null && Object(value) === value && !Array.isArray(value) && !(value instanceof Function);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function isString(value: ManifestInput): value is string {
|
|
58
|
-
return Object(value) !== value && value === String(value);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function sha256(value: ManifestInput): value is `sha256:${string}` {
|
|
62
|
-
return isString(value) && /^sha256:[a-f0-9]{64}$/.test(value);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function isPositiveInteger(value: ManifestInput): value is number {
|
|
66
|
-
return Number.isSafeInteger(value) && value === Number(value) && Number(value) > 0;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function validateManifest(value: ManifestInput): ManifestValidation {
|
|
70
|
-
if (!isRecord(value)) return { ok: false, errors: ["manifest must be an object"] };
|
|
71
|
-
const errors: string[] = [];
|
|
72
|
-
const required = [
|
|
73
|
-
"schemaVersion",
|
|
74
|
-
"project",
|
|
75
|
-
"target",
|
|
76
|
-
"runtime",
|
|
77
|
-
"capabilityProfile",
|
|
78
|
-
"porfforVersion",
|
|
79
|
-
"esbuildVersion",
|
|
80
|
-
"buildImage",
|
|
81
|
-
"sourceHash",
|
|
82
|
-
"binaryHash",
|
|
83
|
-
"binarySize",
|
|
84
|
-
"builtAt",
|
|
85
|
-
];
|
|
86
|
-
for (const field of required) if (!(field in value)) errors.push(`missing manifest field: ${field}`);
|
|
87
|
-
const schemaVersion = value.schemaVersion === ARTIFACT_SCHEMA_VERSION ? ARTIFACT_SCHEMA_VERSION : null;
|
|
88
|
-
if (schemaVersion === null) errors.push("schemaVersion must be 2");
|
|
89
|
-
const project =
|
|
90
|
-
isString(value.project) && /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?$/.test(value.project) ? value.project : null;
|
|
91
|
-
if (project === null) errors.push("project must be a valid slug");
|
|
92
|
-
const target = value.target === DEPLOY_TARGET ? value.target : null;
|
|
93
|
-
if (target === null) {
|
|
94
|
-
// A `--target host` artifact lands here: runnable where it was built, not
|
|
95
|
-
// on a box. Name that, so the failure reads as "wrong build" not "corrupt".
|
|
96
|
-
errors.push(
|
|
97
|
-
isString(value.target) && value.target !== DEPLOY_TARGET
|
|
98
|
-
? `target must be ${DEPLOY_TARGET}, got ${value.target} — \`--target host\` builds are for local dev and cannot be deployed`
|
|
99
|
-
: `target must be ${DEPLOY_TARGET}`,
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
const runtime = value.runtime === RUNTIME ? value.runtime : null;
|
|
103
|
-
if (runtime === null) errors.push("runtime must be native-fetch");
|
|
104
|
-
const capabilityProfile = value.capabilityProfile === CAPABILITY_PROFILE ? value.capabilityProfile : null;
|
|
105
|
-
if (capabilityProfile === null) errors.push("capabilityProfile must be http-sync-v0");
|
|
106
|
-
const versions = ["porfforVersion", "esbuildVersion", "buildImage"] as const;
|
|
107
|
-
const [porfforVersion, esbuildVersion, buildImage] = versions.map((field) =>
|
|
108
|
-
isString(value[field]) && value[field] ? value[field] : null,
|
|
109
|
-
);
|
|
110
|
-
for (const [field, version] of versions.map(
|
|
111
|
-
(field, index) => [field, [porfforVersion, esbuildVersion, buildImage][index]] as const,
|
|
112
|
-
)) {
|
|
113
|
-
if (version === null) errors.push(`${field} must be a non-empty string`);
|
|
114
|
-
}
|
|
115
|
-
const sourceHash = sha256(value.sourceHash) ? value.sourceHash : null;
|
|
116
|
-
if (sourceHash === null) errors.push("sourceHash must be a sha256 digest");
|
|
117
|
-
const binaryHash = sha256(value.binaryHash) ? value.binaryHash : null;
|
|
118
|
-
if (binaryHash === null) errors.push("binaryHash must be a sha256 digest");
|
|
119
|
-
const binarySize = isPositiveInteger(value.binarySize) ? value.binarySize : null;
|
|
120
|
-
if (binarySize === null) errors.push("binarySize must be a positive integer");
|
|
121
|
-
const builtAt = isString(value.builtAt) && !Number.isNaN(Date.parse(value.builtAt)) ? value.builtAt : null;
|
|
122
|
-
if (builtAt === null) errors.push("builtAt must be an ISO-8601 timestamp");
|
|
123
|
-
// Optional by design: an artifact from before the field existed is valid and
|
|
124
|
-
// must stay deployable, so "absent" is not an error — only "present and
|
|
125
|
-
// malformed" is.
|
|
126
|
-
let compatibilityDate: string | undefined;
|
|
127
|
-
if (value.compatibilityDate !== undefined) {
|
|
128
|
-
if (isString(value.compatibilityDate) && /^\d{4}-\d{2}-\d{2}$/.test(value.compatibilityDate))
|
|
129
|
-
compatibilityDate = value.compatibilityDate;
|
|
130
|
-
else errors.push("compatibilityDate must be YYYY-MM-DD");
|
|
131
|
-
}
|
|
132
|
-
if (
|
|
133
|
-
errors.length ||
|
|
134
|
-
schemaVersion === null ||
|
|
135
|
-
project === null ||
|
|
136
|
-
target === null ||
|
|
137
|
-
runtime === null ||
|
|
138
|
-
capabilityProfile === null ||
|
|
139
|
-
porfforVersion === null ||
|
|
140
|
-
esbuildVersion === null ||
|
|
141
|
-
buildImage === null ||
|
|
142
|
-
sourceHash === null ||
|
|
143
|
-
binaryHash === null ||
|
|
144
|
-
binarySize === null ||
|
|
145
|
-
builtAt === null
|
|
146
|
-
)
|
|
147
|
-
return { ok: false, errors };
|
|
148
|
-
return {
|
|
149
|
-
ok: true,
|
|
150
|
-
value: {
|
|
151
|
-
...value,
|
|
152
|
-
schemaVersion,
|
|
153
|
-
project,
|
|
154
|
-
target,
|
|
155
|
-
runtime,
|
|
156
|
-
capabilityProfile,
|
|
157
|
-
porfforVersion,
|
|
158
|
-
esbuildVersion,
|
|
159
|
-
buildImage,
|
|
160
|
-
sourceHash,
|
|
161
|
-
binaryHash,
|
|
162
|
-
binarySize,
|
|
163
|
-
builtAt,
|
|
164
|
-
compatibilityDate,
|
|
165
|
-
},
|
|
166
|
-
};
|
|
167
|
-
}
|
|
1
|
+
// Re-exported from @sproutboat/artifact (moved verbatim). This shim keeps
|
|
2
|
+
// every `./manifest` importer and the `sproutboat/runtime/manifest` export
|
|
3
|
+
// path working; the implementation lives in the shared package.
|
|
4
|
+
export * from "@sproutboat/artifact";
|