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/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 { basename, resolve } from "node:path";
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
- async function readProject(directory = process.cwd()) {
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
- const sourcePath = resolve(projectDirectory, parsed.value.main);
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: ${parsed.value.main}`);
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, projectDirectory);
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, (parsed.value.outbound ?? []).length > 0);
216
+ const supported = validateHttpSyncSource(bundle.code, (project.config.outbound ?? []).length > 0);
194
217
  if (!supported.ok) fail(supported.errors.join("\n"));
195
- return { directory: projectDirectory, config: parsed.value, sourcePath, source, bundle };
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 () => (await readProject(directory)).bundle.code,
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([readProject(args[0]), apiCredentials()]);
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([readProject(args[0]), apiCredentials()]);
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([readProject(args[1]), apiCredentials()]);
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([readProject(dir), apiCredentials()]);
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([readProject(args[0]), apiCredentials()]);
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([readProject(positional[0]), apiCredentials()]);
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 readProject(positional[0])).config.name;
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
- export const ARTIFACT_SCHEMA_VERSION = 2;
2
- export const RUNTIME = "native-fetch";
3
- export const CAPABILITY_PROFILE = "http-sync-v0";
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";