sproutboat 0.4.3 → 0.4.5
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/SURFACE.md +2 -1
- package/package.json +1 -1
- package/src/credentials.ts +1 -1
- package/src/main.ts +43 -19
- package/src/report.ts +1 -1
- package/src/surface.ts +1 -0
- package/src/update-check.ts +55 -0
package/SURFACE.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> Generated by `src/surface.test.ts` from `src/surface.ts` + the pinned
|
|
4
4
|
> toolchain constants. Do not edit by hand — run `UPDATE_SURFACE=1 bun test`.
|
|
5
5
|
|
|
6
|
-
**Package:** `sproutboat` 0.4.
|
|
6
|
+
**Package:** `sproutboat` 0.4.5 · runs on Bun (use `bunx`, not `npx`)
|
|
7
7
|
|
|
8
8
|
## Commands
|
|
9
9
|
|
|
@@ -38,6 +38,7 @@ usage: sproutboat <init [name] | check [project-dir] | build [project-dir] | dep
|
|
|
38
38
|
| `SPROUTBOAT_BINDINGS_JSON` | The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line. |
|
|
39
39
|
| `SPROUTBOAT_CONFIG_DIR` | Directory for credentials.json (default ~/.config/sproutboat). |
|
|
40
40
|
| `NO_COLOR` | When set, disables coloured terminal output (https://no-color.org). Output is also plain whenever stdout is not a TTY. |
|
|
41
|
+
| `SPROUTBOAT_NO_UPDATE_CHECK` | When set, skips the once-a-day npm check for a newer `sproutboat` release (also skipped when CI is set). |
|
|
41
42
|
| `XDG_CONFIG_HOME` | Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset. |
|
|
42
43
|
| `PORFFOR_VERSION` | Override the Porffor identity string recorded in the manifest. |
|
|
43
44
|
| `SB_BROKER_PORT` | Loopback port of the binding broker, read by the compiled sprout at runtime (set by the control plane, or by `src/broker.ts` for local runs). |
|
package/package.json
CHANGED
package/src/credentials.ts
CHANGED
|
@@ -28,7 +28,7 @@ function parseCredentials(value: JsonValue): Credentials | undefined {
|
|
|
28
28
|
return { version: 1, activeApiUrl: isString(input.activeApiUrl) ? input.activeApiUrl : undefined, profiles };
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
function configDirectory(): string {
|
|
31
|
+
export function configDirectory(): string {
|
|
32
32
|
const configured = process.env.SPROUTBOAT_CONFIG_DIR || process.env.XDG_CONFIG_HOME;
|
|
33
33
|
if (configured && isAbsolute(configured)) return resolve(configured, "sproutboat");
|
|
34
34
|
return resolve(homedir(), ".config", "sproutboat");
|
package/src/main.ts
CHANGED
|
@@ -5,9 +5,10 @@ import { parseConfig, type SproutboatConfig } from "./config";
|
|
|
5
5
|
import { validateHttpSyncSource } from "./source";
|
|
6
6
|
import { buildArtifact } from "./build";
|
|
7
7
|
import { validateManifest, type ArtifactManifest } from "./manifest";
|
|
8
|
-
import { printDeployReport } from "./report";
|
|
8
|
+
import { CLI_VERSION, printDeployReport } from "./report";
|
|
9
9
|
import { activeApiUrl, savedToken, saveToken } from "./credentials";
|
|
10
|
-
import { helpText
|
|
10
|
+
import { helpText } from "./surface";
|
|
11
|
+
import { notifyIfOutdated } from "./update-check";
|
|
11
12
|
import { amber, bold, dim, leaf, ok, rose } from "./style";
|
|
12
13
|
|
|
13
14
|
const defaultApiUrl = "https://dashboard.sproutboat.com";
|
|
@@ -66,9 +67,14 @@ function parseVersionList(source: string): VersionSummary[] | undefined {
|
|
|
66
67
|
return deployments;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
function parseUrlResponse(source: string): { url: string } | undefined {
|
|
70
|
+
function parseUrlResponse(source: string): { url: string; id?: string; artifact?: string } | undefined {
|
|
70
71
|
const record = jsonObject(parseJsonValue(source));
|
|
71
|
-
|
|
72
|
+
if (!record || !isString(record.url)) return undefined;
|
|
73
|
+
return {
|
|
74
|
+
url: record.url,
|
|
75
|
+
id: isString(record.id) ? record.id : undefined,
|
|
76
|
+
artifact: isString(record.artifact) ? record.artifact : undefined,
|
|
77
|
+
};
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
/** #55: `{ from, to }` when this deploy moves the live version onto a different
|
|
@@ -106,11 +112,22 @@ const starterHandler = `export default {
|
|
|
106
112
|
};
|
|
107
113
|
`;
|
|
108
114
|
|
|
115
|
+
/** An operational failure — the command was invoked correctly but could not complete. Exit 1. */
|
|
109
116
|
function fail(message: string): never {
|
|
110
117
|
console.error(`${rose("✗")} ${message}`);
|
|
111
118
|
process.exit(1);
|
|
112
119
|
}
|
|
113
120
|
|
|
121
|
+
/**
|
|
122
|
+
* The command was invoked wrong (missing/unknown arg). Exit 2, the getopt/argparse
|
|
123
|
+
* convention, so scripts can tell "you typed it wrong" apart from "it broke".
|
|
124
|
+
* `usage` is the grammar line without the `usage: ` prefix.
|
|
125
|
+
*/
|
|
126
|
+
function usageError(what: string, usage: string): never {
|
|
127
|
+
console.error(`${rose("✗")} ${what}\n ${dim(`usage: sproutboat ${usage}`)}`);
|
|
128
|
+
process.exit(2);
|
|
129
|
+
}
|
|
130
|
+
|
|
114
131
|
async function readProject(directory = process.cwd()) {
|
|
115
132
|
const projectDirectory = resolve(directory);
|
|
116
133
|
const configPath = resolve(projectDirectory, "sproutboat.jsonc");
|
|
@@ -168,7 +185,9 @@ async function deploy(args: string[]) {
|
|
|
168
185
|
let artifactDir: string;
|
|
169
186
|
let config: SproutboatConfig | undefined;
|
|
170
187
|
if (artifactIndex >= 0) {
|
|
171
|
-
artifactDir = args[artifactIndex + 1]
|
|
188
|
+
artifactDir = args[artifactIndex + 1]
|
|
189
|
+
? resolve(args[artifactIndex + 1])
|
|
190
|
+
: usageError("deploy: --artifact needs a directory", "deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait]");
|
|
172
191
|
projectName = "";
|
|
173
192
|
} else {
|
|
174
193
|
const built = await build(directory);
|
|
@@ -239,17 +258,18 @@ async function deploy(args: string[]) {
|
|
|
239
258
|
if (!deployed) fail("deployment response did not include a URL");
|
|
240
259
|
console.log(`\n${leaf("🌱")} ${bold(leaf(`Deployed ${projectName}`))}`);
|
|
241
260
|
console.log(` ${bold(deployed.url)}`);
|
|
261
|
+
if (deployed.id) console.log(dim(` version ${deployed.id}${deployed.artifact ? ` · artifact ${deployed.artifact.slice(0, 12)}` : ""}`));
|
|
262
|
+
for (const cron of config?.triggers?.crons ?? []) console.log(dim(` schedule ${cron}`));
|
|
242
263
|
const drift = parsePorfforDrift(body);
|
|
243
264
|
if (drift) {
|
|
244
265
|
console.warn(amber(`\n! Porffor pin changed: ${drift.from} -> ${drift.to}`));
|
|
245
266
|
console.warn(dim(` The previous live version stays frozen at ${drift.from}; this one is built with ${drift.to}.`));
|
|
246
267
|
console.warn(dim(` The alpha compiler's output can differ between pins (see COMPAT.md) — roll back if this version misbehaves.`));
|
|
247
268
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
: amber(" ! not serving after 90s — Caddy may still be issuing the cert, or the sprout is crashing (`sproutboat tail`)"));
|
|
269
|
+
// Verify the edge actually answers (cert issuance + sprout boot). Say nothing
|
|
270
|
+
// on success — "Deployed" already implied that; only speak up if it doesn't.
|
|
271
|
+
if (!args.includes("--no-wait") && !(await waitForHealthy(deployed.url, 90_000))) {
|
|
272
|
+
console.warn(amber(" ! not serving after 90s — Caddy may still be issuing the cert, or the sprout is crashing (`sproutboat tail`)"));
|
|
253
273
|
}
|
|
254
274
|
}
|
|
255
275
|
|
|
@@ -280,7 +300,7 @@ function parseLoginArgs(args: string[]) {
|
|
|
280
300
|
const value = args[index + 1];
|
|
281
301
|
if (args[index] === "--api-url" && value) apiUrl = value;
|
|
282
302
|
else if (args[index] === "--token" && value) token = value;
|
|
283
|
-
else
|
|
303
|
+
else usageError(`login: unexpected argument "${args[index]}"`, "login [--api-url <url>] [--token <token>]");
|
|
284
304
|
}
|
|
285
305
|
return { apiUrl: apiUrl.replace(/\/$/, ""), token };
|
|
286
306
|
}
|
|
@@ -331,7 +351,7 @@ async function apiCredentials() {
|
|
|
331
351
|
}
|
|
332
352
|
|
|
333
353
|
async function versions(args: string[]) {
|
|
334
|
-
if (args[0] !== "list")
|
|
354
|
+
if (args[0] !== "list") usageError(args[0] ? `versions: unknown subcommand "${args[0]}"` : "versions: missing subcommand", "versions list [project-dir]");
|
|
335
355
|
const [project, { apiUrl, token }] = await Promise.all([readProject(args[1]), apiCredentials()]);
|
|
336
356
|
const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments`, { headers: { "x-api-key": token } });
|
|
337
357
|
const deployments = parseVersionList(await responseText(response, "could not list versions"));
|
|
@@ -341,7 +361,7 @@ async function versions(args: string[]) {
|
|
|
341
361
|
|
|
342
362
|
async function rollback(args: string[]) {
|
|
343
363
|
const id = args[0];
|
|
344
|
-
if (!id)
|
|
364
|
+
if (!id) usageError("rollback: missing <version-id>", "rollback <version-id> [project-dir]");
|
|
345
365
|
const [project, { apiUrl, token }] = await Promise.all([readProject(args[1]), apiCredentials()]);
|
|
346
366
|
const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments/${id}/activate`, { method: "POST", headers: { "x-api-key": token } });
|
|
347
367
|
const deployment = parseUrlResponse(await responseText(response, "rollback rejected"));
|
|
@@ -379,7 +399,7 @@ function printDomain(domain: DomainView) {
|
|
|
379
399
|
async function domains(args: string[]) {
|
|
380
400
|
const sub = args[0] && !args[0].startsWith("-") && ["list", "add", "verify", "rm"].includes(args[0]) ? args.shift()! : "list";
|
|
381
401
|
const host = sub === "list" ? undefined : args.shift();
|
|
382
|
-
if (sub !== "list" && !host)
|
|
402
|
+
if (sub !== "list" && !host) usageError(`domains ${sub}: missing <hostname>`, `domains ${sub} <hostname> [project-dir]`);
|
|
383
403
|
const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
|
|
384
404
|
const base = `${apiUrl}/api/projects/${project.config.name}/domains`;
|
|
385
405
|
const auth = { "x-api-key": token };
|
|
@@ -412,7 +432,7 @@ async function domains(args: string[]) {
|
|
|
412
432
|
async function secrets(args: string[]) {
|
|
413
433
|
const sub = args[0] && ["list", "set", "rm"].includes(args[0]) ? args.shift()! : "list";
|
|
414
434
|
const name = sub === "list" ? undefined : args.shift();
|
|
415
|
-
if (sub !== "list" && !name)
|
|
435
|
+
if (sub !== "list" && !name) usageError(`secrets ${sub}: missing <NAME>`, `secrets ${sub} <NAME> [project-dir]`);
|
|
416
436
|
if (name && !/^[A-Z][A-Z0-9_]*$/.test(name)) fail("secret name must be UPPER_SNAKE_CASE");
|
|
417
437
|
const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
|
|
418
438
|
const base = `${apiUrl}/api/projects/${project.config.name}/secrets`;
|
|
@@ -452,7 +472,7 @@ async function deleteProject(args: string[]) {
|
|
|
452
472
|
if (arg === "--yes" || arg === "-y") confirmed = true;
|
|
453
473
|
else if (arg === "--name") explicitName = args[(index += 1)];
|
|
454
474
|
else if (!arg.startsWith("-")) positional.push(arg);
|
|
455
|
-
else
|
|
475
|
+
else usageError(`delete: unknown flag "${arg}"`, "delete [project-dir] [--name <project>] --yes");
|
|
456
476
|
}
|
|
457
477
|
|
|
458
478
|
const { apiUrl, token } = await apiCredentials();
|
|
@@ -479,14 +499,18 @@ function help(): never {
|
|
|
479
499
|
process.exit(0);
|
|
480
500
|
}
|
|
481
501
|
|
|
482
|
-
/** An unrecognised command: a short pointer on stderr, exit
|
|
502
|
+
/** An unrecognised command: a short pointer on stderr, exit 2 (misuse, not failure). */
|
|
483
503
|
function usage(): never {
|
|
484
|
-
console.error(
|
|
485
|
-
process.exit(
|
|
504
|
+
console.error(`${rose("✗")} unknown command "${command}"\n ${dim("run `sproutboat` for the list of commands")}`);
|
|
505
|
+
process.exit(2);
|
|
486
506
|
}
|
|
487
507
|
|
|
488
508
|
const [command, ...args] = process.argv.slice(2);
|
|
489
509
|
if (command === undefined || command === "help" || command === "-h" || command === "--help") help();
|
|
510
|
+
if (command === "--version" || command === "-v") { console.log(`sproutboat ${CLI_VERSION}`); process.exit(0); }
|
|
511
|
+
|
|
512
|
+
await notifyIfOutdated(CLI_VERSION);
|
|
513
|
+
|
|
490
514
|
switch (command) {
|
|
491
515
|
case "init": await init(args[0]); break;
|
|
492
516
|
case "check": await check(args[0]); break;
|
package/src/report.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { ArtifactManifest } from "./manifest";
|
|
|
5
5
|
import { bold, dim, leaf, sprout } from "./style";
|
|
6
6
|
|
|
7
7
|
// Read from package.json so the banner never drifts from the published version.
|
|
8
|
-
const CLI_VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
|
|
8
|
+
export const CLI_VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
|
|
9
9
|
|
|
10
10
|
function bytes(n: number): string {
|
|
11
11
|
if (n < 1024) return `${n} B`;
|
package/src/surface.ts
CHANGED
|
@@ -66,6 +66,7 @@ export const ENV_VARS: readonly EnvVar[] = [
|
|
|
66
66
|
{ name: "SPROUTBOAT_BINDINGS_JSON", purpose: "The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line." },
|
|
67
67
|
{ name: "SPROUTBOAT_CONFIG_DIR", purpose: "Directory for credentials.json (default ~/.config/sproutboat)." },
|
|
68
68
|
{ name: "NO_COLOR", purpose: "When set, disables coloured terminal output (https://no-color.org). Output is also plain whenever stdout is not a TTY." },
|
|
69
|
+
{ name: "SPROUTBOAT_NO_UPDATE_CHECK", purpose: "When set, skips the once-a-day npm check for a newer `sproutboat` release (also skipped when CI is set)." },
|
|
69
70
|
{ name: "XDG_CONFIG_HOME", purpose: "Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset." },
|
|
70
71
|
{ name: "PORFFOR_VERSION", purpose: "Override the Porffor identity string recorded in the manifest." },
|
|
71
72
|
{ name: "SB_BROKER_PORT", purpose: "Loopback port of the binding broker, read by the compiled sprout at runtime (set by the control plane, or by `src/broker.ts` for local runs)." },
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "update available 0.4.4" notice, wrangler-style. Checks npm at most once a
|
|
3
|
+
* day, caches the answer next to the credentials, and never fails or blocks the
|
|
4
|
+
* command for more than a second. Silent when up to date, offline, in CI, or
|
|
5
|
+
* when SPROUTBOAT_NO_UPDATE_CHECK is set.
|
|
6
|
+
*/
|
|
7
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { configDirectory } from "./credentials";
|
|
10
|
+
import { dim } from "./style";
|
|
11
|
+
|
|
12
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
const REGISTRY = "https://registry.npmjs.org/sproutboat/latest";
|
|
14
|
+
|
|
15
|
+
type Cache = { checkedAt: number; latest: string };
|
|
16
|
+
|
|
17
|
+
function cachePath(): string {
|
|
18
|
+
return resolve(configDirectory(), "update-check.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Numeric x.y.z compare; a trailing `-tag` (prerelease) sorts before its release. */
|
|
22
|
+
function isNewer(latest: string, current: string): boolean {
|
|
23
|
+
const parts = (v: string) => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
24
|
+
const [a, b] = [parts(latest), parts(current)];
|
|
25
|
+
for (let i = 0; i < 3; i++) {
|
|
26
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0);
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function latestVersion(): Promise<string | undefined> {
|
|
32
|
+
try {
|
|
33
|
+
const cached = JSON.parse(await readFile(cachePath(), "utf8")) as Cache;
|
|
34
|
+
if (Date.now() - cached.checkedAt < CACHE_TTL_MS && typeof cached.latest === "string") return cached.latest;
|
|
35
|
+
} catch { /* no cache yet, or unreadable — fetch below */ }
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const response = await fetch(REGISTRY, { signal: AbortSignal.timeout(1000), headers: { accept: "application/json" } });
|
|
39
|
+
if (!response.ok) return undefined;
|
|
40
|
+
const latest = ((await response.json()) as { version?: string }).version;
|
|
41
|
+
if (typeof latest !== "string") return undefined;
|
|
42
|
+
await writeFile(cachePath(), JSON.stringify({ checkedAt: Date.now(), latest } satisfies Cache)).catch(() => {});
|
|
43
|
+
return latest;
|
|
44
|
+
} catch { /* offline / slow / DNS — skip silently */ }
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Print one dim line to stderr if a newer `sproutboat` is on npm. Never throws. */
|
|
49
|
+
export async function notifyIfOutdated(current: string): Promise<void> {
|
|
50
|
+
if (process.env.SPROUTBOAT_NO_UPDATE_CHECK || process.env.CI) return;
|
|
51
|
+
const latest = await latestVersion();
|
|
52
|
+
if (latest && isNewer(latest, current)) {
|
|
53
|
+
console.error(dim(` update available: sproutboat ${current} → ${latest} · bump the dependency or run \`bunx sproutboat@latest\``));
|
|
54
|
+
}
|
|
55
|
+
}
|