sproutboat 0.2.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 ADDED
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env bun
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { basename, resolve } from "node:path";
4
+ import { parseConfig, type SproutboatConfig } from "./config";
5
+ import { validateHttpSyncSource } from "./source";
6
+ import { buildArtifact } from "./build";
7
+ import { validateManifest, type ArtifactManifest } from "./manifest";
8
+ import { printDeployReport } from "./report";
9
+ import { activeApiUrl, savedToken, saveToken } from "./credentials";
10
+ import { usageLine } from "./surface";
11
+
12
+ const defaultApiUrl = "https://dashboard.sproutboat.com";
13
+
14
+ async function responseText(response: Response, failure: string): Promise<string> {
15
+ if (response.ok) return response.text();
16
+ fail(`${failure} (${response.status}): ${await response.text()}`);
17
+ }
18
+
19
+ type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];
20
+ type JsonObject = { [key: string]: JsonValue };
21
+
22
+ function isString(value: JsonValue | undefined): value is string {
23
+ return value !== undefined && value === String(value);
24
+ }
25
+
26
+ function isSafeInteger(value: JsonValue | undefined): value is number {
27
+ return Number.isSafeInteger(value);
28
+ }
29
+
30
+ function parseJsonValue(source: string): JsonValue {
31
+ const value = JSON.parse(source);
32
+ if (value === null || value === true || value === false || value === String(value) || Number.isFinite(value) || value instanceof Object) return value;
33
+ throw new Error("response was not valid JSON");
34
+ }
35
+
36
+ function jsonObject(value: JsonValue): JsonObject | undefined {
37
+ return value instanceof Object && !Array.isArray(value) ? value : undefined;
38
+ }
39
+
40
+ type DeploymentSummary = { artifact: string; hostname: string; active: boolean };
41
+ type VersionSummary = { id: string; artifact: string; deployedAt: string; active: boolean };
42
+ type CliAuthorization = { deviceCode: string; userCode: string; verificationUri: string; interval: number; expiresAt: string };
43
+
44
+ function parseDeploymentList(source: string): DeploymentSummary[] | undefined {
45
+ const value = parseJsonValue(source);
46
+ if (!Array.isArray(value)) return undefined;
47
+ const deployments: DeploymentSummary[] = [];
48
+ for (const item of value) {
49
+ const record = jsonObject(item);
50
+ if (!record || !isString(record.artifact) || !isString(record.hostname) || (record.active !== true && record.active !== false)) return undefined;
51
+ deployments.push({ artifact: record.artifact, hostname: record.hostname, active: record.active });
52
+ }
53
+ return deployments;
54
+ }
55
+
56
+ function parseVersionList(source: string): VersionSummary[] | undefined {
57
+ const value = parseJsonValue(source);
58
+ if (!Array.isArray(value)) return undefined;
59
+ const deployments: VersionSummary[] = [];
60
+ for (const item of value) {
61
+ const record = jsonObject(item);
62
+ if (!record || !isString(record.id) || !isString(record.artifact) || !isString(record.deployedAt) || (record.active !== true && record.active !== false)) return undefined;
63
+ deployments.push({ id: record.id, artifact: record.artifact, deployedAt: record.deployedAt, active: record.active });
64
+ }
65
+ return deployments;
66
+ }
67
+
68
+ function parseUrlResponse(source: string): { url: string } | undefined {
69
+ const record = jsonObject(parseJsonValue(source));
70
+ return record && isString(record.url) ? { url: record.url } : undefined;
71
+ }
72
+
73
+ function parseAuthorization(source: string): CliAuthorization | undefined {
74
+ const record = jsonObject(parseJsonValue(source));
75
+ if (!record || !isString(record.deviceCode) || !isString(record.userCode) || !isString(record.verificationUri) || !isSafeInteger(record.interval) || !isString(record.expiresAt)) return undefined;
76
+ return { deviceCode: record.deviceCode, userCode: record.userCode, verificationUri: record.verificationUri, interval: record.interval, expiresAt: record.expiresAt };
77
+ }
78
+
79
+ function parseToken(source: string): string | undefined {
80
+ const record = jsonObject(parseJsonValue(source));
81
+ return record && isString(record.token) ? record.token : undefined;
82
+ }
83
+
84
+ const starterConfig = (name: string) => `{
85
+ "$schema": "https://sproutboat.com/schema.json",
86
+ "name": "${name}",
87
+ "main": "src/index.js",
88
+ "compatibility_date": "2026-08-26"
89
+ }
90
+ `;
91
+ const starterHandler = `export default {
92
+ fetch() {
93
+ return new Response("hello from Sproutboat");
94
+ }
95
+ };
96
+ `;
97
+
98
+ function fail(message: string): never {
99
+ console.error(`sproutboat: ${message}`);
100
+ process.exit(1);
101
+ }
102
+
103
+ async function readProject(directory = process.cwd()) {
104
+ const projectDirectory = resolve(directory);
105
+ const configPath = resolve(projectDirectory, "sproutboat.jsonc");
106
+ let configSource: string;
107
+ try {
108
+ configSource = await readFile(configPath, "utf8");
109
+ } catch {
110
+ fail(`no sproutboat.jsonc found in ${projectDirectory}`);
111
+ }
112
+ const parsed = parseConfig(configSource);
113
+ if (!parsed.ok) fail(parsed.errors.join("\n"));
114
+ const sourcePath = resolve(projectDirectory, parsed.value.main);
115
+ let source: string;
116
+ try {
117
+ source = await readFile(sourcePath, "utf8");
118
+ } catch {
119
+ fail(`entry point not found: ${parsed.value.main}`);
120
+ }
121
+ const supported = validateHttpSyncSource(source, (parsed.value.outbound ?? []).length > 0);
122
+ if (!supported.ok) fail(supported.errors.join("\n"));
123
+ return { directory: projectDirectory, config: parsed.value, sourcePath, source };
124
+ }
125
+
126
+ async function init(name = "hello") {
127
+ 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");
128
+ const directory = resolve(process.cwd(), name);
129
+ const configPath = resolve(directory, "sproutboat.jsonc");
130
+ if (await Bun.file(configPath).exists()) fail(`${basename(directory)} already contains sproutboat.jsonc`);
131
+ await mkdir(resolve(directory, "src"), { recursive: true });
132
+ await writeFile(configPath, starterConfig(name), { flag: "wx" });
133
+ await writeFile(resolve(directory, "src/index.js"), starterHandler, { flag: "wx" });
134
+ console.log(`Created ${basename(directory)}/sproutboat.jsonc`);
135
+ console.log(`Created ${basename(directory)}/src/index.js`);
136
+ }
137
+
138
+ async function check(directory?: string) {
139
+ const project = await readProject(directory);
140
+ console.log(`check passed: ${project.config.name} (${project.config.main}, native-fetch)`);
141
+ }
142
+
143
+ async function build(directory?: string) {
144
+ const project = await readProject(directory);
145
+ console.log("Compiling the native-fetch server with Porffor + Zig (linux-x86_64, static)...");
146
+ const artifact = await buildArtifact({ projectDir: project.directory, config: project.config, sourcePath: project.sourcePath });
147
+ console.log(`Built ${project.config.name}`);
148
+ console.log(artifact.artifactDir);
149
+ return { project, artifact };
150
+ }
151
+
152
+ async function deploy(args: string[]) {
153
+ const artifactIndex = args.indexOf("--artifact");
154
+ const dryRun = args.includes("--dry-run");
155
+ const directory = args.find((arg, index) => !arg.startsWith("--") && index !== artifactIndex + 1);
156
+ let projectName: string;
157
+ let artifactDir: string;
158
+ let config: SproutboatConfig | undefined;
159
+ if (artifactIndex >= 0) {
160
+ artifactDir = args[artifactIndex + 1] ? resolve(args[artifactIndex + 1]) : fail("--artifact requires a directory");
161
+ projectName = "";
162
+ } else {
163
+ const built = await build(directory);
164
+ projectName = built.project.config.name;
165
+ artifactDir = built.artifact.artifactDir;
166
+ config = built.project.config;
167
+ }
168
+ const manifest = Bun.file(resolve(artifactDir, "manifest.json"));
169
+ const worker = Bun.file(resolve(artifactDir, "worker"));
170
+ if (!(await manifest.exists()) || !(await worker.exists())) fail("artifact must contain manifest.json and worker");
171
+ const manifestValidation = validateManifest(await manifest.json());
172
+ if (!manifestValidation.ok) fail(`invalid artifact manifest: ${manifestValidation.errors.join(", ")}`);
173
+ const artifactManifest: ArtifactManifest = manifestValidation.value;
174
+ if (artifactIndex >= 0) projectName = artifactManifest.project;
175
+
176
+ printDeployReport(
177
+ config ?? { name: projectName, main: "", compatibility_date: "(prebuilt artifact)" },
178
+ artifactManifest,
179
+ new Uint8Array(await worker.arrayBuffer()),
180
+ manifest.size,
181
+ );
182
+ if (dryRun) {
183
+ console.log("\n--dry-run: not uploading.");
184
+ return;
185
+ }
186
+ const { apiUrl, token } = await apiCredentials();
187
+ const digest = artifactManifest.binaryHash.replace(/^sha256:/, "");
188
+ if (digest) {
189
+ const response = await fetch(`${apiUrl.replace(/\/$/, "")}/api/projects/${projectName}/deployments`, { headers: { "x-api-key": token } });
190
+ const deployments = parseDeploymentList(await responseText(response, "could not check existing deployments"));
191
+ if (!deployments) fail("could not parse deployment list response");
192
+ const active = deployments.find((deployment) => deployment.active && deployment.artifact === digest);
193
+ if (active) {
194
+ console.log(`Nothing to deploy — artifact ${digest.slice(0, 12)} is already active`);
195
+ console.log(`https://${active.hostname}`);
196
+ return;
197
+ }
198
+ }
199
+ const form = new FormData();
200
+ form.set("manifest", new File([await manifest.arrayBuffer()], "manifest.json", { type: "application/json" }));
201
+ form.set("worker", new File([await worker.arrayBuffer()], "worker", { type: "application/octet-stream" }));
202
+ const response = await fetch(`${apiUrl.replace(/\/$/, "")}/api/projects/${projectName}/deployments`, {
203
+ method: "POST",
204
+ headers: { "x-api-key": token },
205
+ body: form,
206
+ });
207
+ const body = await responseText(response, "deployment rejected");
208
+ const deployed = parseUrlResponse(body);
209
+ if (!deployed) fail("deployment response did not include a URL");
210
+ console.log(`\nDeployed ${projectName}`);
211
+ console.log(` ${deployed.url}`);
212
+ }
213
+
214
+ function parseLoginArgs(args: string[]) {
215
+ let apiUrl = process.env.SPROUTBOAT_API_URL || defaultApiUrl;
216
+ let token: string | undefined;
217
+ for (let index = 0; index < args.length; index += 2) {
218
+ const value = args[index + 1];
219
+ if (args[index] === "--api-url" && value) apiUrl = value;
220
+ else if (args[index] === "--token" && value) token = value;
221
+ else fail("usage: sproutboat login [--api-url <url>] [--token <token>]");
222
+ }
223
+ return { apiUrl: apiUrl.replace(/\/$/, ""), token };
224
+ }
225
+
226
+ async function login(args: string[]) {
227
+ const { apiUrl, token: directToken } = parseLoginArgs(args);
228
+ // Self-hosted / non-interactive: skip the browser flow and store the token
229
+ // the admin already holds (e.g. SPROUTBOAT_BOOTSTRAP_TOKEN).
230
+ if (directToken) {
231
+ await saveToken(apiUrl, directToken);
232
+ console.log(`Saved credentials for ${apiUrl}.`);
233
+ return;
234
+ }
235
+ const response = await fetch(`${apiUrl}/api/cli/authorizations`, { method: "POST" });
236
+ const body = await responseText(response, "could not start login");
237
+ const authorization = parseAuthorization(body);
238
+ if (!authorization) fail("login response did not include a valid authorization request");
239
+ const verificationUrl = new URL(authorization.verificationUri, `${apiUrl}/`).toString();
240
+ const openCommand = process.platform === "darwin" ? ["open", verificationUrl] : process.platform === "win32" ? ["cmd", "/c", "start", "", verificationUrl] : ["xdg-open", verificationUrl];
241
+ try { Bun.spawn(openCommand, { stdout: "ignore", stderr: "ignore" }); }
242
+ catch { console.log(`Open ${verificationUrl}`); }
243
+ console.log("Opening the browser to approve this CLI login.");
244
+ console.log(`Confirm code: ${authorization.userCode}`);
245
+ while (new Date(authorization.expiresAt).getTime() > Date.now()) {
246
+ await Bun.sleep(Math.max(authorization.interval, 1) * 1000);
247
+ const exchange = await fetch(`${apiUrl.replace(/\/$/, "")}/api/cli/authorizations/token`, {
248
+ method: "POST",
249
+ headers: { "content-type": "application/json" },
250
+ body: JSON.stringify({ deviceCode: authorization.deviceCode }),
251
+ });
252
+ if (exchange.status === 428) continue;
253
+ const result = await exchange.text();
254
+ if (!exchange.ok) fail(`login failed (${exchange.status}): ${result}`);
255
+ const token = parseToken(result);
256
+ if (!token) fail("login response did not include a CLI token");
257
+ await saveToken(apiUrl, token);
258
+ console.log("Login approved. Credentials were saved locally for this API endpoint.");
259
+ return;
260
+ }
261
+ fail("login expired before approval");
262
+ }
263
+
264
+ async function apiCredentials() {
265
+ const apiUrl = (process.env.SPROUTBOAT_API_URL || await activeApiUrl() || defaultApiUrl).replace(/\/$/, "");
266
+ const token = process.env.SPROUTBOAT_TOKEN || await savedToken(apiUrl);
267
+ if (!token) fail("not logged in; run sproutboat login or set SPROUTBOAT_TOKEN for this command");
268
+ return { apiUrl, token };
269
+ }
270
+
271
+ async function versions(args: string[]) {
272
+ if (args[0] !== "list") fail("usage: sproutboat versions list [project-directory]");
273
+ const [project, { apiUrl, token }] = await Promise.all([readProject(args[1]), apiCredentials()]);
274
+ const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments`, { headers: { "x-api-key": token } });
275
+ const deployments = parseVersionList(await responseText(response, "could not list versions"));
276
+ if (!deployments) fail("could not parse versions response");
277
+ for (const deployment of deployments) console.log(`${deployment.active ? "*" : " "} ${deployment.id} ${deployment.artifact.slice(0, 12)} ${deployment.deployedAt}`);
278
+ }
279
+
280
+ async function rollback(args: string[]) {
281
+ const id = args[0];
282
+ if (!id) fail("usage: sproutboat rollback <version-id> [project-directory]");
283
+ const [project, { apiUrl, token }] = await Promise.all([readProject(args[1]), apiCredentials()]);
284
+ const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments/${id}/activate`, { method: "POST", headers: { "x-api-key": token } });
285
+ const deployment = parseUrlResponse(await responseText(response, "rollback rejected"));
286
+ if (!deployment) fail("rollback response did not include a URL");
287
+ console.log(`Rolled back ${project.config.name}`);
288
+ console.log(deployment.url);
289
+ }
290
+
291
+ async function tail(args: string[]) {
292
+ const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
293
+ const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/logs/recent`, { headers: { "x-api-key": token } });
294
+ process.stdout.write(await responseText(response, "could not read logs"));
295
+ }
296
+
297
+ async function deleteProject(args: string[]) {
298
+ if (args[0] !== "--yes") fail("refusing to delete without --yes");
299
+ const [project, { apiUrl, token }] = await Promise.all([readProject(args[1]), apiCredentials()]);
300
+ const response = await fetch(`${apiUrl}/api/projects/${project.config.name}`, { method: "DELETE", headers: { "x-api-key": token } });
301
+ await responseText(response, "delete rejected");
302
+ console.log(`Deleted ${project.config.name}`);
303
+ }
304
+
305
+ function usage(): never {
306
+ console.error(usageLine());
307
+ process.exit(1);
308
+ }
309
+
310
+ const [command, ...args] = process.argv.slice(2);
311
+ switch (command) {
312
+ case "init": await init(args[0]); break;
313
+ case "check": await check(args[0]); break;
314
+ case "build": await build(args[0]); break;
315
+ case "login": await login(args); break;
316
+ case "deploy": await deploy(args); break;
317
+ case "versions": await versions(args); break;
318
+ case "rollback": await rollback(args); break;
319
+ case "tail": await tail(args); break;
320
+ case "delete": await deleteProject(args); break;
321
+ default: usage();
322
+ }
@@ -0,0 +1,80 @@
1
+ export const ARTIFACT_SCHEMA_VERSION = 2;
2
+ export const RUNTIME = "native-fetch";
3
+ export const CAPABILITY_PROFILE = "http-sync-v0";
4
+
5
+ export type ArtifactManifest = {
6
+ schemaVersion: 2;
7
+ project: string;
8
+ target: "linux-x86_64";
9
+ runtime: "native-fetch";
10
+ capabilityProfile: "http-sync-v0";
11
+ porfforVersion: string;
12
+ esbuildVersion: string;
13
+ /** Build provenance, e.g. `zig-musl/0.16.0+porffor/a415d19+uws/360c276d`. */
14
+ buildImage: string;
15
+ sourceHash: `sha256:${string}`;
16
+ binaryHash: `sha256:${string}`;
17
+ binarySize: number;
18
+ builtAt: string;
19
+ };
20
+
21
+ export type ManifestValidation =
22
+ | { ok: true; value: ArtifactManifest }
23
+ | { ok: false; errors: string[] };
24
+
25
+ type JsonValue = string | number | boolean | null | ManifestJsonObject | JsonValue[];
26
+
27
+ interface ManifestJsonObject {
28
+ readonly [key: string]: JsonValue;
29
+ }
30
+
31
+ type ManifestInput = JsonValue | undefined;
32
+
33
+ function isRecord(value: ManifestInput): value is ManifestJsonObject {
34
+ return value !== null && Object(value) === value && !Array.isArray(value)
35
+ && !(value instanceof Function);
36
+ }
37
+
38
+ function isString(value: ManifestInput): value is string {
39
+ return Object(value) !== value && value === String(value);
40
+ }
41
+
42
+ function sha256(value: ManifestInput): value is `sha256:${string}` {
43
+ return isString(value) && /^sha256:[a-f0-9]{64}$/.test(value);
44
+ }
45
+
46
+ function isPositiveInteger(value: ManifestInput): value is number {
47
+ return Number.isSafeInteger(value) && value === Number(value) && Number(value) > 0;
48
+ }
49
+
50
+ export function validateManifest(value: ManifestInput): ManifestValidation {
51
+ if (!isRecord(value)) return { ok: false, errors: ["manifest must be an object"] };
52
+ const errors: string[] = [];
53
+ const required = ["schemaVersion", "project", "target", "runtime", "capabilityProfile", "porfforVersion", "esbuildVersion", "buildImage", "sourceHash", "binaryHash", "binarySize", "builtAt"];
54
+ for (const field of required) if (!(field in value)) errors.push(`missing manifest field: ${field}`);
55
+ const schemaVersion = value.schemaVersion === ARTIFACT_SCHEMA_VERSION ? ARTIFACT_SCHEMA_VERSION : null;
56
+ if (schemaVersion === null) errors.push("schemaVersion must be 2");
57
+ const project = isString(value.project) && /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?$/.test(value.project) ? value.project : null;
58
+ if (project === null) errors.push("project must be a valid slug");
59
+ const target = value.target === "linux-x86_64" ? value.target : null;
60
+ if (target === null) errors.push("target must be linux-x86_64");
61
+ const runtime = value.runtime === RUNTIME ? value.runtime : null;
62
+ if (runtime === null) errors.push("runtime must be native-fetch");
63
+ const capabilityProfile = value.capabilityProfile === CAPABILITY_PROFILE ? value.capabilityProfile : null;
64
+ if (capabilityProfile === null) errors.push("capabilityProfile must be http-sync-v0");
65
+ const versions = ["porfforVersion", "esbuildVersion", "buildImage"] as const;
66
+ const [porfforVersion, esbuildVersion, buildImage] = versions.map((field) => isString(value[field]) && value[field] ? value[field] : null);
67
+ for (const [field, version] of versions.map((field, index) => [field, [porfforVersion, esbuildVersion, buildImage][index]] as const)) {
68
+ if (version === null) errors.push(`${field} must be a non-empty string`);
69
+ }
70
+ const sourceHash = sha256(value.sourceHash) ? value.sourceHash : null;
71
+ if (sourceHash === null) errors.push("sourceHash must be a sha256 digest");
72
+ const binaryHash = sha256(value.binaryHash) ? value.binaryHash : null;
73
+ if (binaryHash === null) errors.push("binaryHash must be a sha256 digest");
74
+ const binarySize = isPositiveInteger(value.binarySize) ? value.binarySize : null;
75
+ if (binarySize === null) errors.push("binarySize must be a positive integer");
76
+ const builtAt = isString(value.builtAt) && !Number.isNaN(Date.parse(value.builtAt)) ? value.builtAt : null;
77
+ if (builtAt === null) errors.push("builtAt must be an ISO-8601 timestamp");
78
+ if (errors.length || schemaVersion === null || project === null || target === null || runtime === null || capabilityProfile === null || porfforVersion === null || esbuildVersion === null || buildImage === null || sourceHash === null || binaryHash === null || binarySize === null || builtAt === null) return { ok: false, errors };
79
+ return { ok: true, value: { ...value, schemaVersion, project, target, runtime, capabilityProfile, porfforVersion, esbuildVersion, buildImage, sourceHash, binaryHash, binarySize, builtAt } };
80
+ }