sproutboat 0.4.2 → 0.4.4
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 +5 -4
- package/package.json +1 -1
- package/src/credentials.ts +1 -1
- package/src/main.ts +46 -13
- package/src/report.ts +1 -1
- package/src/surface.ts +80 -16
- 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.4 · runs on Bun (use `bunx`, not `npx`)
|
|
7
7
|
|
|
8
8
|
## Commands
|
|
9
9
|
|
|
@@ -13,16 +13,16 @@
|
|
|
13
13
|
| `check` | `[project-dir]` | Validate the config and entry point without building. |
|
|
14
14
|
| `build` | `[project-dir]` | Cross-compile the native-fetch sprout (Porffor + Zig). |
|
|
15
15
|
| `deploy` | `[project-dir] [--dry-run] [--artifact <dir>] [--no-wait]` | Build (unless --artifact), print the report, upload, wait until the URL serves. --dry-run stops before upload; --no-wait skips the health check. |
|
|
16
|
-
| `login` | `[--api-url <url>] [--token <token>]` | Device-code browser flow, or store <token> for <url> directly. |
|
|
17
|
-
| `tail` | `[project-dir] [--sprout]` | Print recent request logs; --sprout prints the running sprout + broker stdout/stderr instead. |
|
|
18
16
|
| `versions` | `list [project-dir]` | List the project's deployed versions. |
|
|
19
17
|
| `rollback` | `<version-id> [project-dir]` | Re-activate a previous version. |
|
|
18
|
+
| `tail` | `[project-dir] [--sprout]` | Print recent request logs; --sprout prints the running sprout + broker stdout/stderr instead. |
|
|
20
19
|
| `domains` | `[list | add <host> | verify <host> | rm <host>] [project-dir]` | Attach a custom domain to the project (TXT-verified). No sub-command lists. |
|
|
21
20
|
| `secrets` | `[list | set <NAME> [value] | rm <NAME>] [project-dir]` | Manage encrypted project secrets (read as env.NAME). `set` takes the value from the arg or stdin; applies on next deploy. |
|
|
22
21
|
| `delete` | `[project-dir] [--name <project>] --yes` | Delete the project, every version, and its route. |
|
|
22
|
+
| `login` | `[--api-url <url>] [--token <token>]` | Device-code browser flow, or store <token> for <url> directly. |
|
|
23
23
|
|
|
24
24
|
```
|
|
25
|
-
usage: sproutboat <init [name] | check [project-dir] | build [project-dir] | deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait] |
|
|
25
|
+
usage: sproutboat <init [name] | check [project-dir] | build [project-dir] | deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait] | versions list [project-dir] | rollback <version-id> [project-dir] | tail [project-dir] [--sprout] | domains [list | add <host> | verify <host> | rm <host>] [project-dir] | secrets [list | set <NAME> [value] | rm <NAME>] [project-dir] | delete [project-dir] [--name <project>] --yes | login [--api-url <url>] [--token <token>]>
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
## Environment variables
|
|
@@ -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 {
|
|
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,6 +258,8 @@ 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}`));
|
|
@@ -280,7 +301,7 @@ function parseLoginArgs(args: string[]) {
|
|
|
280
301
|
const value = args[index + 1];
|
|
281
302
|
if (args[index] === "--api-url" && value) apiUrl = value;
|
|
282
303
|
else if (args[index] === "--token" && value) token = value;
|
|
283
|
-
else
|
|
304
|
+
else usageError(`login: unexpected argument "${args[index]}"`, "login [--api-url <url>] [--token <token>]");
|
|
284
305
|
}
|
|
285
306
|
return { apiUrl: apiUrl.replace(/\/$/, ""), token };
|
|
286
307
|
}
|
|
@@ -331,7 +352,7 @@ async function apiCredentials() {
|
|
|
331
352
|
}
|
|
332
353
|
|
|
333
354
|
async function versions(args: string[]) {
|
|
334
|
-
if (args[0] !== "list")
|
|
355
|
+
if (args[0] !== "list") usageError(args[0] ? `versions: unknown subcommand "${args[0]}"` : "versions: missing subcommand", "versions list [project-dir]");
|
|
335
356
|
const [project, { apiUrl, token }] = await Promise.all([readProject(args[1]), apiCredentials()]);
|
|
336
357
|
const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments`, { headers: { "x-api-key": token } });
|
|
337
358
|
const deployments = parseVersionList(await responseText(response, "could not list versions"));
|
|
@@ -341,7 +362,7 @@ async function versions(args: string[]) {
|
|
|
341
362
|
|
|
342
363
|
async function rollback(args: string[]) {
|
|
343
364
|
const id = args[0];
|
|
344
|
-
if (!id)
|
|
365
|
+
if (!id) usageError("rollback: missing <version-id>", "rollback <version-id> [project-dir]");
|
|
345
366
|
const [project, { apiUrl, token }] = await Promise.all([readProject(args[1]), apiCredentials()]);
|
|
346
367
|
const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments/${id}/activate`, { method: "POST", headers: { "x-api-key": token } });
|
|
347
368
|
const deployment = parseUrlResponse(await responseText(response, "rollback rejected"));
|
|
@@ -379,7 +400,7 @@ function printDomain(domain: DomainView) {
|
|
|
379
400
|
async function domains(args: string[]) {
|
|
380
401
|
const sub = args[0] && !args[0].startsWith("-") && ["list", "add", "verify", "rm"].includes(args[0]) ? args.shift()! : "list";
|
|
381
402
|
const host = sub === "list" ? undefined : args.shift();
|
|
382
|
-
if (sub !== "list" && !host)
|
|
403
|
+
if (sub !== "list" && !host) usageError(`domains ${sub}: missing <hostname>`, `domains ${sub} <hostname> [project-dir]`);
|
|
383
404
|
const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
|
|
384
405
|
const base = `${apiUrl}/api/projects/${project.config.name}/domains`;
|
|
385
406
|
const auth = { "x-api-key": token };
|
|
@@ -412,7 +433,7 @@ async function domains(args: string[]) {
|
|
|
412
433
|
async function secrets(args: string[]) {
|
|
413
434
|
const sub = args[0] && ["list", "set", "rm"].includes(args[0]) ? args.shift()! : "list";
|
|
414
435
|
const name = sub === "list" ? undefined : args.shift();
|
|
415
|
-
if (sub !== "list" && !name)
|
|
436
|
+
if (sub !== "list" && !name) usageError(`secrets ${sub}: missing <NAME>`, `secrets ${sub} <NAME> [project-dir]`);
|
|
416
437
|
if (name && !/^[A-Z][A-Z0-9_]*$/.test(name)) fail("secret name must be UPPER_SNAKE_CASE");
|
|
417
438
|
const [project, { apiUrl, token }] = await Promise.all([readProject(args[0]), apiCredentials()]);
|
|
418
439
|
const base = `${apiUrl}/api/projects/${project.config.name}/secrets`;
|
|
@@ -452,7 +473,7 @@ async function deleteProject(args: string[]) {
|
|
|
452
473
|
if (arg === "--yes" || arg === "-y") confirmed = true;
|
|
453
474
|
else if (arg === "--name") explicitName = args[(index += 1)];
|
|
454
475
|
else if (!arg.startsWith("-")) positional.push(arg);
|
|
455
|
-
else
|
|
476
|
+
else usageError(`delete: unknown flag "${arg}"`, "delete [project-dir] [--name <project>] --yes");
|
|
456
477
|
}
|
|
457
478
|
|
|
458
479
|
const { apiUrl, token } = await apiCredentials();
|
|
@@ -473,12 +494,24 @@ async function deleteProject(args: string[]) {
|
|
|
473
494
|
if (failed.length) console.log(` ! ${failed.length} artifact file(s) left on disk — remove them manually`);
|
|
474
495
|
}
|
|
475
496
|
|
|
497
|
+
/** `sproutboat` alone or with -h/--help/help: the friendly grouped list, exit 0. */
|
|
498
|
+
function help(): never {
|
|
499
|
+
console.log(helpText());
|
|
500
|
+
process.exit(0);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** An unrecognised command: a short pointer on stderr, exit 2 (misuse, not failure). */
|
|
476
504
|
function usage(): never {
|
|
477
|
-
console.error(
|
|
478
|
-
process.exit(
|
|
505
|
+
console.error(`${rose("✗")} unknown command "${command}"\n ${dim("run `sproutboat` for the list of commands")}`);
|
|
506
|
+
process.exit(2);
|
|
479
507
|
}
|
|
480
508
|
|
|
481
509
|
const [command, ...args] = process.argv.slice(2);
|
|
510
|
+
if (command === undefined || command === "help" || command === "-h" || command === "--help") help();
|
|
511
|
+
if (command === "--version" || command === "-v") { console.log(`sproutboat ${CLI_VERSION}`); process.exit(0); }
|
|
512
|
+
|
|
513
|
+
await notifyIfOutdated(CLI_VERSION);
|
|
514
|
+
|
|
482
515
|
switch (command) {
|
|
483
516
|
case "init": await init(args[0]); break;
|
|
484
517
|
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
|
@@ -1,24 +1,57 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The CLI's public surface, as data. `main.ts` builds its usage
|
|
3
|
-
* this, and `surface.test.ts` renders it to `SURFACE.md` and checks that
|
|
4
|
-
* command switch, the referenced env vars, and the doc can't drift apart.
|
|
2
|
+
* The CLI's public surface, as data. `main.ts` builds its help + usage strings
|
|
3
|
+
* from this, and `surface.test.ts` renders it to `SURFACE.md` and checks that
|
|
4
|
+
* the command switch, the referenced env vars, and the doc can't drift apart.
|
|
5
5
|
*/
|
|
6
|
+
import { bold, dim, leaf } from "./style";
|
|
7
|
+
|
|
6
8
|
export const CLI_NAME = "sproutboat";
|
|
9
|
+
export const TAGLINE = "Deploy JavaScript handlers as tiny native binaries on your own VPS.";
|
|
10
|
+
export const REPO_URL = "https://github.com/baronunread/sproutboat";
|
|
11
|
+
|
|
12
|
+
export type Group = "Develop" | "Ship" | "Configure" | "Account";
|
|
7
13
|
|
|
8
|
-
export type Command = {
|
|
14
|
+
export type Command = {
|
|
15
|
+
name: string;
|
|
16
|
+
/** Full argument grammar — used by SURFACE.md and the one-line usage string. */
|
|
17
|
+
args: string;
|
|
18
|
+
/** Short argument hint shown in the grouped help list (falls back to `args`). */
|
|
19
|
+
brief?: string;
|
|
20
|
+
group: Group;
|
|
21
|
+
emoji: string;
|
|
22
|
+
summary: string;
|
|
23
|
+
};
|
|
9
24
|
|
|
10
25
|
export const COMMANDS: readonly Command[] = [
|
|
11
|
-
{ name: "init",
|
|
12
|
-
|
|
13
|
-
{ name: "
|
|
14
|
-
|
|
15
|
-
{ name: "
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
{ name: "
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
{ name: "
|
|
26
|
+
{ name: "init", group: "Develop", emoji: "🌱", args: "[name]",
|
|
27
|
+
summary: "Scaffold sproutboat.jsonc + src/index.js in ./<name>." },
|
|
28
|
+
{ name: "check", group: "Develop", emoji: "🔍", args: "[project-dir]",
|
|
29
|
+
summary: "Validate the config and entry point without building." },
|
|
30
|
+
{ name: "build", group: "Develop", emoji: "🔨", args: "[project-dir]",
|
|
31
|
+
summary: "Cross-compile the native-fetch sprout (Porffor + Zig)." },
|
|
32
|
+
|
|
33
|
+
{ name: "deploy", group: "Ship", emoji: "🚀",
|
|
34
|
+
args: "[project-dir] [--dry-run] [--artifact <dir>] [--no-wait]", brief: "[project-dir] [--dry-run]",
|
|
35
|
+
summary: "Build (unless --artifact), print the report, upload, wait until the URL serves. --dry-run stops before upload; --no-wait skips the health check." },
|
|
36
|
+
{ name: "versions", group: "Ship", emoji: "📜", args: "list [project-dir]",
|
|
37
|
+
summary: "List the project's deployed versions." },
|
|
38
|
+
{ name: "rollback", group: "Ship", emoji: "⏮", args: "<version-id> [project-dir]", brief: "<version-id>",
|
|
39
|
+
summary: "Re-activate a previous version." },
|
|
40
|
+
{ name: "tail", group: "Ship", emoji: "📡", args: "[project-dir] [--sprout]",
|
|
41
|
+
summary: "Print recent request logs; --sprout prints the running sprout + broker stdout/stderr instead." },
|
|
42
|
+
|
|
43
|
+
{ name: "domains", group: "Configure", emoji: "🌐",
|
|
44
|
+
args: "[list | add <host> | verify <host> | rm <host>] [project-dir]", brief: "[list | add | verify | rm]",
|
|
45
|
+
summary: "Attach a custom domain to the project (TXT-verified). No sub-command lists." },
|
|
46
|
+
{ name: "secrets", group: "Configure", emoji: "🔑",
|
|
47
|
+
args: "[list | set <NAME> [value] | rm <NAME>] [project-dir]", brief: "[list | set | rm]",
|
|
48
|
+
summary: "Manage encrypted project secrets (read as env.NAME). `set` takes the value from the arg or stdin; applies on next deploy." },
|
|
49
|
+
{ name: "delete", group: "Configure", emoji: "🗑",
|
|
50
|
+
args: "[project-dir] [--name <project>] --yes", brief: "[project-dir] --yes",
|
|
51
|
+
summary: "Delete the project, every version, and its route." },
|
|
52
|
+
|
|
53
|
+
{ name: "login", group: "Account", emoji: "🔓", args: "[--api-url <url>] [--token <token>]", brief: "[--token <token>]",
|
|
54
|
+
summary: "Device-code browser flow, or store <token> for <url> directly." },
|
|
22
55
|
];
|
|
23
56
|
|
|
24
57
|
export type EnvVar = { name: string; purpose: string };
|
|
@@ -33,6 +66,7 @@ export const ENV_VARS: readonly EnvVar[] = [
|
|
|
33
66
|
{ name: "SPROUTBOAT_BINDINGS_JSON", purpose: "The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line." },
|
|
34
67
|
{ name: "SPROUTBOAT_CONFIG_DIR", purpose: "Directory for credentials.json (default ~/.config/sproutboat)." },
|
|
35
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)." },
|
|
36
70
|
{ name: "XDG_CONFIG_HOME", purpose: "Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset." },
|
|
37
71
|
{ name: "PORFFOR_VERSION", purpose: "Override the Porffor identity string recorded in the manifest." },
|
|
38
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)." },
|
|
@@ -40,8 +74,38 @@ export const ENV_VARS: readonly EnvVar[] = [
|
|
|
40
74
|
{ name: "SB_SPROUT_URL", purpose: "http://127.0.0.1:<PORT> of the sprout; when set, `src/broker.ts` runs the cron scheduler and queue consumer and delivers triggers to it." },
|
|
41
75
|
];
|
|
42
76
|
|
|
43
|
-
|
|
77
|
+
const GROUP_ORDER: readonly Group[] = ["Develop", "Ship", "Configure", "Account"];
|
|
78
|
+
|
|
79
|
+
/** One-line usage string, e.g. for `usage()` and SURFACE.md. */
|
|
44
80
|
export function usageLine(): string {
|
|
45
81
|
const parts = COMMANDS.map((c) => (c.args ? `${c.name} ${c.args}` : c.name));
|
|
46
82
|
return `usage: ${CLI_NAME} <${parts.join(" | ")}>`;
|
|
47
83
|
}
|
|
84
|
+
|
|
85
|
+
/** First sentence of a summary — enough for the at-a-glance command list. */
|
|
86
|
+
function firstSentence(text: string): string {
|
|
87
|
+
const end = text.indexOf(". ");
|
|
88
|
+
return end === -1 ? text.replace(/\.$/, "") : text.slice(0, end);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Wrangler-style grouped help: `sproutboat` with no command, or `--help`. */
|
|
92
|
+
export function helpText(): string {
|
|
93
|
+
const invocations = COMMANDS.map((c) => `${c.name} ${c.brief ?? c.args}`.trim());
|
|
94
|
+
const width = Math.max(...invocations.map((s) => s.length));
|
|
95
|
+
const lines: string[] = [`${bold(CLI_NAME)} — ${TAGLINE}`, "", leaf("USAGE"), ` ${CLI_NAME} <command> [options]`];
|
|
96
|
+
|
|
97
|
+
for (const group of GROUP_ORDER) {
|
|
98
|
+
lines.push("", leaf(group.toUpperCase()));
|
|
99
|
+
for (const command of COMMANDS.filter((c) => c.group === group)) {
|
|
100
|
+
const invocation = `${command.name} ${command.brief ?? command.args}`.trim();
|
|
101
|
+
lines.push(` ${invocation.padEnd(width)} ${command.emoji} ${firstSentence(command.summary)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
lines.push(
|
|
106
|
+
"",
|
|
107
|
+
dim(`Run \`${CLI_NAME} <command>\` with no/invalid args to see that command's full usage.`),
|
|
108
|
+
dim(`Docs: ${REPO_URL} · runs on Bun — use \`bunx\`, not \`npx\`.`),
|
|
109
|
+
);
|
|
110
|
+
return lines.join("\n");
|
|
111
|
+
}
|
|
@@ -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
|
+
}
|