sproutboat 0.54.0 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +32 -8
- package/SURFACE.md +7 -7
- package/package.json +20 -12
- package/scripts/embed-templates.ts +87 -0
- package/src/account-field.ts +33 -2
- package/src/build.ts +3 -3
- package/src/cloud-bindings.ts +227 -0
- package/src/cloud-deploy.ts +67 -25
- package/src/cloud-domains.ts +55 -0
- package/src/cloud-fake.ts +90 -4
- package/src/compile.ts +18 -15
- package/src/dev.ts +67 -4
- package/src/init.ts +328 -0
- package/src/main.ts +112 -132
- package/src/options.ts +13 -4
- package/src/surface.ts +12 -11
- package/src/templates-embedded.ts +72 -0
- package/src/templates.ts +53 -0
- package/src/toolchain.ts +3 -0
- package/src/types-cmd.ts +193 -0
- package/src/types-gen.ts +104 -68
- package/types/cloudflare-workers.d.ts +43 -0
- package/types/sproutboat.d.ts +160 -3
package/src/compile.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
ensurePorfforA21,
|
|
32
32
|
SHARED_PRELUDE_FILE,
|
|
33
33
|
STANDALONE_A21_COMPILE_FLAGS,
|
|
34
|
+
standaloneA21Channel,
|
|
34
35
|
standaloneChannel,
|
|
35
36
|
type StandaloneChannel,
|
|
36
37
|
} from "@sproutboat/toolchain/a21";
|
|
@@ -67,16 +68,16 @@ export {
|
|
|
67
68
|
const COMPILE_TIMEOUT_MS = Number(process.env.SPROUTBOAT_COMPILE_TIMEOUT_MS || 600_000);
|
|
68
69
|
|
|
69
70
|
/**
|
|
70
|
-
* The Porffor channel a native compile uses: `alpha-
|
|
71
|
-
* the toolchain's alpha 19 server shim over its bridge; sproutd-alpha22-standalone-port.md),
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* native tests agree on the default.
|
|
71
|
+
* The Porffor channel a native compile uses: `alpha-23` by default (the alpha 21 line's compiler with
|
|
72
|
+
* the toolchain's alpha 19 server shim over its bridge; sproutd-alpha22-standalone-port.md), `alpha-22`
|
|
73
|
+
* (`SPROUTBOAT_PORFFOR_CHANNEL=alpha-22`: the same line one Porffor release back), or `alpha-19`
|
|
74
|
+
* (`SPROUTBOAT_PORFFOR_CHANNEL=alpha-19`: Porffor's native-fetch server, the fallback pin kept for one release).
|
|
75
|
+
* The toolchain's `standaloneChannel()` decides, so the CLI and the runtime's native tests agree on the default.
|
|
75
76
|
*
|
|
76
77
|
* `--with-restore` stays on alpha 19: its restore objects are built against alpha 19's native-fetch
|
|
77
78
|
* C API, which the alpha 22 bridge does not provide, so they do not link there. Unless the channel is
|
|
78
|
-
* set explicitly, such a build uses the alpha 19 compiler; `SPROUTBOAT_PORFFOR_CHANNEL=alpha-22`
|
|
79
|
-
* `--with-restore` is an error rather than a link failure.
|
|
79
|
+
* set explicitly, such a build uses the alpha 19 compiler; `SPROUTBOAT_PORFFOR_CHANNEL=alpha-22` or
|
|
80
|
+
* `alpha-23` with `--with-restore` is an error rather than a link failure.
|
|
80
81
|
*/
|
|
81
82
|
export type PorfforChannel = StandaloneChannel;
|
|
82
83
|
export function porfforChannel(withRestore = false): PorfforChannel {
|
|
@@ -84,7 +85,7 @@ export function porfforChannel(withRestore = false): PorfforChannel {
|
|
|
84
85
|
if (!withRestore || channel === "alpha-19") return channel;
|
|
85
86
|
if (process.env.SPROUTBOAT_PORFFOR_CHANNEL)
|
|
86
87
|
throw new Error(
|
|
87
|
-
"--with-restore is not available on Porffor alpha 22 yet: unset SPROUTBOAT_PORFFOR_CHANNEL or set it to alpha-19",
|
|
88
|
+
"--with-restore is not available on Porffor alpha 22 or 23 yet: unset SPROUTBOAT_PORFFOR_CHANNEL or set it to alpha-19",
|
|
88
89
|
);
|
|
89
90
|
return "alpha-19";
|
|
90
91
|
}
|
|
@@ -396,7 +397,7 @@ export async function compileSprout(input: CompileInput): Promise<void> {
|
|
|
396
397
|
})
|
|
397
398
|
: [];
|
|
398
399
|
|
|
399
|
-
if (porfforChannel(input.withRestore)
|
|
400
|
+
if (porfforChannel(input.withRestore) !== "alpha-19") {
|
|
400
401
|
await compileSproutA21(input, {
|
|
401
402
|
alpha19Root: porffor,
|
|
402
403
|
generatedPath,
|
|
@@ -449,9 +450,9 @@ export async function compileSprout(input: CompileInput): Promise<void> {
|
|
|
449
450
|
}
|
|
450
451
|
|
|
451
452
|
/**
|
|
452
|
-
* The alpha
|
|
453
|
+
* The alpha 21 line's native compile, alpha 23 or alpha 22 (sproutd-alpha22-standalone-port.md): the generated module is
|
|
453
454
|
* compiled in sproutd's worker shape (the toolchain composes `shared-prelude.js` from it: alpha 19
|
|
454
|
-
* helper names, the alpha 19 web classes, the dispatch shim), the
|
|
455
|
+
* helper names, the alpha 19 web classes, the dispatch shim), the line's compiler renders the split
|
|
455
456
|
* C units, and the toolchain's build recipe compiles them with the native-fetch bridge and the alpha
|
|
456
457
|
* 19 uWebSockets shim rewritten over flat data, then links with this build's native objects (SQLite,
|
|
457
458
|
* BearSSL, the restore objects) and the uSockets this target already has. Same inputs, same outputs,
|
|
@@ -469,7 +470,8 @@ async function compileSproutA21(
|
|
|
469
470
|
uwsVariant?: string;
|
|
470
471
|
},
|
|
471
472
|
): Promise<void> {
|
|
472
|
-
const
|
|
473
|
+
const a21Channel = standaloneA21Channel(porfforChannel(input.withRestore));
|
|
474
|
+
const a21 = await ensurePorfforA21(a21Channel);
|
|
473
475
|
const dir = dirname(build.generatedPath);
|
|
474
476
|
const composed = composeStandaloneA21(await readFile(build.generatedPath, "utf8"), build.alpha19Root);
|
|
475
477
|
// beside sprout.generated.js (which stays the wrapped module, for emit-only, dev and the cache key)
|
|
@@ -482,7 +484,7 @@ async function compileSproutA21(
|
|
|
482
484
|
const render = Bun.spawn(
|
|
483
485
|
[
|
|
484
486
|
...build.launch,
|
|
485
|
-
resolve(
|
|
487
|
+
resolve(a21, "runtime/index.js"),
|
|
486
488
|
"c",
|
|
487
489
|
entry,
|
|
488
490
|
"-o",
|
|
@@ -496,7 +498,8 @@ async function compileSproutA21(
|
|
|
496
498
|
new Response(render.stdout).text(),
|
|
497
499
|
new Response(render.stderr).text(),
|
|
498
500
|
]);
|
|
499
|
-
if (code !== 0)
|
|
501
|
+
if (code !== 0)
|
|
502
|
+
throw new Error(`Porffor (${a21Channel}) render failed: ${(stderr || stdout).trim() || `exit ${code}`}`);
|
|
500
503
|
|
|
501
504
|
const host = input.target === "host";
|
|
502
505
|
const triple = host ? undefined : linuxTriple(input.target);
|
|
@@ -524,5 +527,5 @@ async function compileSproutA21(
|
|
|
524
527
|
musl: !host,
|
|
525
528
|
strip: true,
|
|
526
529
|
});
|
|
527
|
-
if (!(await Bun.file(input.outPath).exists())) throw new Error("alpha
|
|
530
|
+
if (!(await Bun.file(input.outPath).exists())) throw new Error("alpha 21 line build produced no binary");
|
|
528
531
|
}
|
package/src/dev.ts
CHANGED
|
@@ -32,6 +32,8 @@ export type DevInput = {
|
|
|
32
32
|
/** The bundled module (#89) — already validated by the caller. */
|
|
33
33
|
source: string;
|
|
34
34
|
port: number;
|
|
35
|
+
/** `--ip`: the address the public port listens on. Default 127.0.0.1; 0.0.0.0 makes it reachable on the network. */
|
|
36
|
+
host?: string;
|
|
35
37
|
watch: boolean;
|
|
36
38
|
/** #275: `--env-file` paths (absolute), in order. Default: `.dev.vars`, else the `.env` files. */
|
|
37
39
|
envFiles?: string[];
|
|
@@ -105,6 +107,52 @@ export type Running = {
|
|
|
105
107
|
disableDispatch: () => void;
|
|
106
108
|
};
|
|
107
109
|
|
|
110
|
+
/** One browser WebSocket and the connection to the sprout that answers it. */
|
|
111
|
+
type SocketRelay = {
|
|
112
|
+
url: string;
|
|
113
|
+
protocols: string[];
|
|
114
|
+
/** Frames that arrived before the sprout side finished opening. */
|
|
115
|
+
queued: Array<string | Uint8Array<ArrayBuffer>>;
|
|
116
|
+
upstream?: WebSocket;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const socketRelay: Bun.WebSocketHandler<SocketRelay> = {
|
|
120
|
+
open(client) {
|
|
121
|
+
const relay = client.data;
|
|
122
|
+
const upstream = new WebSocket(relay.url, relay.protocols);
|
|
123
|
+
upstream.binaryType = "arraybuffer";
|
|
124
|
+
relay.upstream = upstream;
|
|
125
|
+
upstream.addEventListener("open", () => {
|
|
126
|
+
for (const frame of relay.queued.splice(0)) upstream.send(frame);
|
|
127
|
+
});
|
|
128
|
+
upstream.addEventListener("message", (event) => {
|
|
129
|
+
if (client.readyState !== 1) return;
|
|
130
|
+
// SAFETY: binaryType is "arraybuffer", so a frame is a string or an ArrayBuffer.
|
|
131
|
+
client.send(event.data as string | ArrayBuffer);
|
|
132
|
+
});
|
|
133
|
+
upstream.addEventListener("close", (event) => {
|
|
134
|
+
// 1005 and 1006 are reserved for the receiver to report; they cannot be sent.
|
|
135
|
+
const code =
|
|
136
|
+
event.code >= 1000 && event.code < 5000 && event.code !== 1005 && event.code !== 1006 ? event.code : 1000;
|
|
137
|
+
client.close(code, event.reason);
|
|
138
|
+
});
|
|
139
|
+
upstream.addEventListener("error", () => client.close(1011, "sprout unavailable"));
|
|
140
|
+
},
|
|
141
|
+
message(client, message) {
|
|
142
|
+
const upstream = client.data.upstream;
|
|
143
|
+
const frame = message instanceof Uint8Array ? new Uint8Array(message) : message;
|
|
144
|
+
if (upstream && upstream.readyState === 1) upstream.send(frame);
|
|
145
|
+
else client.data.queued.push(frame);
|
|
146
|
+
},
|
|
147
|
+
close(client, code, reason) {
|
|
148
|
+
const upstream = client.data.upstream;
|
|
149
|
+
if (!upstream || upstream.readyState > 1) return;
|
|
150
|
+
try {
|
|
151
|
+
upstream.close(code === 1005 || code === 1006 ? 1000 : code, reason);
|
|
152
|
+
} catch {}
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
108
156
|
async function start(input: DevInput, port: number): Promise<Running> {
|
|
109
157
|
const candidateDir = resolve(
|
|
110
158
|
input.projectDir,
|
|
@@ -285,12 +333,26 @@ export async function runDev(input: DevInput): Promise<void> {
|
|
|
285
333
|
// what lets a failed startup leave the last known-good process reachable.
|
|
286
334
|
let proxy: ReturnType<typeof Bun.serve>;
|
|
287
335
|
try {
|
|
288
|
-
proxy = Bun.serve({
|
|
289
|
-
hostname: "127.0.0.1",
|
|
336
|
+
proxy = Bun.serve<SocketRelay>({
|
|
337
|
+
hostname: input.host ?? "127.0.0.1",
|
|
290
338
|
port: input.port,
|
|
291
339
|
maxRequestBodySize: 5 * 1024 * 1024 * 1024,
|
|
340
|
+
websocket: socketRelay,
|
|
292
341
|
fetch(request, server) {
|
|
293
342
|
const target = new URL(request.url);
|
|
343
|
+
// A WebSocket upgrade cannot go through fetch(): relay it, frame by frame, to the sprout.
|
|
344
|
+
if (request.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
345
|
+
target.protocol = "ws:";
|
|
346
|
+
target.host = `127.0.0.1:${activePort}`;
|
|
347
|
+
const protocols = request.headers.get("sec-websocket-protocol");
|
|
348
|
+
const data: SocketRelay = {
|
|
349
|
+
url: target.toString(),
|
|
350
|
+
protocols: protocols ? protocols.split(",").map((entry) => entry.trim()) : [],
|
|
351
|
+
queued: [],
|
|
352
|
+
};
|
|
353
|
+
if (server.upgrade(request, { data })) return undefined;
|
|
354
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
355
|
+
}
|
|
294
356
|
const directTransfer = /^\/__sb\/r2\/transfer\/[A-Z][A-Z0-9_]*\/[0-9a-f]{24}$/.test(target.pathname);
|
|
295
357
|
if (directTransfer) server.timeout(request, 255);
|
|
296
358
|
if (directTransfer && !activeTransferPort) return new Response("direct transfers unavailable", { status: 503 });
|
|
@@ -308,7 +370,7 @@ export async function runDev(input: DevInput): Promise<void> {
|
|
|
308
370
|
throw error;
|
|
309
371
|
}
|
|
310
372
|
watchExit(running);
|
|
311
|
-
console.log(ok(`${input.config.name} running on ${leaf(`http
|
|
373
|
+
console.log(ok(`${input.config.name} running on ${leaf(`http://${input.host ?? "127.0.0.1"}:${input.port}`)}`));
|
|
312
374
|
if (input.watch) console.log(dim(" watching for changes — ctrl-c to stop"));
|
|
313
375
|
|
|
314
376
|
let watchers: FSWatcher[] = [];
|
|
@@ -319,7 +381,8 @@ export async function runDev(input: DevInput): Promise<void> {
|
|
|
319
381
|
if (shuttingDown) return;
|
|
320
382
|
shuttingDown = true;
|
|
321
383
|
for (const watcher of watchers) watcher.close();
|
|
322
|
-
|
|
384
|
+
// Close open sockets too: a graceful stop waits for a WebSocket the browser keeps open.
|
|
385
|
+
proxy.stop(true);
|
|
323
386
|
if (pendingCandidate) stop(pendingCandidate);
|
|
324
387
|
stop(running);
|
|
325
388
|
resolveShutdown?.();
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sproutboat init`: scaffold a project, in the shape of `npm create cloudflare` (create-cloudflare, "C3").
|
|
3
|
+
*
|
|
4
|
+
* sproutboat init [name] [--template <name>] [--git | --no-git] [--install | --no-install] [-y]
|
|
5
|
+
* sproutboat init --list-templates
|
|
6
|
+
*
|
|
7
|
+
* Interactive in a terminal (it asks for the template, the directory, git and installing, with C3's defaults);
|
|
8
|
+
* silent everywhere else, where nothing runs that a flag did not ask for. Everything outside the filesystem
|
|
9
|
+
* (prompts, git, the package manager) goes through `InitIo`, so tests drive the whole flow without a terminal.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
13
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
14
|
+
import { parseConfig } from "./config";
|
|
15
|
+
import {
|
|
16
|
+
detectPackageManager,
|
|
17
|
+
installCommand,
|
|
18
|
+
planPackageJson,
|
|
19
|
+
runScriptCommand,
|
|
20
|
+
type PackagePlan,
|
|
21
|
+
} from "./init-package";
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_TEMPLATE,
|
|
24
|
+
firstRunScript,
|
|
25
|
+
isTemplate,
|
|
26
|
+
listTemplates,
|
|
27
|
+
templateFiles,
|
|
28
|
+
type TemplateFile,
|
|
29
|
+
} from "./templates";
|
|
30
|
+
import { amber, dim } from "./style";
|
|
31
|
+
import { parseTypesArgs, planTypes } from "./types-cmd";
|
|
32
|
+
|
|
33
|
+
export type InitOptions = {
|
|
34
|
+
name?: string;
|
|
35
|
+
template?: string;
|
|
36
|
+
git?: boolean;
|
|
37
|
+
install?: boolean;
|
|
38
|
+
/** `-y`: take every default without asking, git and install included. */
|
|
39
|
+
yes: boolean;
|
|
40
|
+
list: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type Choice = { value: string; label: string };
|
|
44
|
+
|
|
45
|
+
export type InitIo = {
|
|
46
|
+
cwd: string;
|
|
47
|
+
cliVersion: string;
|
|
48
|
+
/** A person is here to answer: stdin and stdout are terminals. */
|
|
49
|
+
interactive: boolean;
|
|
50
|
+
log(line: string): void;
|
|
51
|
+
warn(line: string): void;
|
|
52
|
+
ask(question: string, fallback: string): Promise<string>;
|
|
53
|
+
/** Pick one of `choices` by number, `fallback` (a value) on an empty answer. */
|
|
54
|
+
choose(question: string, choices: readonly Choice[], fallback: string): Promise<string>;
|
|
55
|
+
confirm(question: string, fallback: boolean): Promise<boolean>;
|
|
56
|
+
/** Run a command with the terminal attached; resolves to its exit code (127 when it cannot start). */
|
|
57
|
+
run(command: string, args: readonly string[], cwd: string): Promise<number>;
|
|
58
|
+
/** Run a command and return its exit code and output. */
|
|
59
|
+
capture(command: string, args: readonly string[], cwd: string): Promise<{ code: number; stdout: string }>;
|
|
60
|
+
/** Called once when init ends, however it ends. */
|
|
61
|
+
close?(): void;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** A failure the user can act on; `main.ts` prints the message and exits 1. */
|
|
65
|
+
export class InitError extends Error {}
|
|
66
|
+
|
|
67
|
+
const NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?$/;
|
|
68
|
+
const NAME_ERROR = "project name must be a 3–32 character lowercase slug";
|
|
69
|
+
|
|
70
|
+
export function parseInitArgs(args: readonly string[]): InitOptions | { error: string } {
|
|
71
|
+
const options: InitOptions = { yes: false, list: false };
|
|
72
|
+
for (let at = 0; at < args.length; at += 1) {
|
|
73
|
+
const arg = args[at]!;
|
|
74
|
+
if (arg === "--template") {
|
|
75
|
+
const value = args[(at += 1)];
|
|
76
|
+
if (value === undefined || value.startsWith("-")) return { error: "--template needs a template name" };
|
|
77
|
+
options.template = value;
|
|
78
|
+
} else if (arg === "--git") options.git = true;
|
|
79
|
+
else if (arg === "--no-git") options.git = false;
|
|
80
|
+
else if (arg === "--install") options.install = true;
|
|
81
|
+
else if (arg === "--no-install") options.install = false;
|
|
82
|
+
else if (arg === "-y" || arg === "--yes" || arg === "--accept-defaults") options.yes = true;
|
|
83
|
+
else if (arg === "--list-templates") options.list = true;
|
|
84
|
+
else if (arg.startsWith("-")) return { error: `unknown option ${arg}` };
|
|
85
|
+
else if (options.name === undefined) options.name = arg;
|
|
86
|
+
else return { error: `unexpected argument "${arg}": init takes one project name` };
|
|
87
|
+
}
|
|
88
|
+
return options;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function templateListing(): string {
|
|
92
|
+
const width = Math.max(...listTemplates().map((template) => template.name.length));
|
|
93
|
+
return listTemplates()
|
|
94
|
+
.map(({ name, description }) => ` ${name.padEnd(width)} ${description}`)
|
|
95
|
+
.join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// -------------------------------------------------------------- the hello starter
|
|
99
|
+
|
|
100
|
+
const helloConfig = (name: string) => `{
|
|
101
|
+
"$schema": "https://sproutboat.com/schema.json",
|
|
102
|
+
"name": "${name}",
|
|
103
|
+
"main": "src/index.js",
|
|
104
|
+
"compatibility_date": "2026-09-28"
|
|
105
|
+
}
|
|
106
|
+
`;
|
|
107
|
+
const helloHandler = `export default {
|
|
108
|
+
fetch(request, env, ctx) {
|
|
109
|
+
return new Response("hello from Sproutboat");
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
`;
|
|
113
|
+
// .sproutboat/ holds build output (dist/) and `dev`'s local broker state
|
|
114
|
+
// (dev/, including its SQLite files), neither of which belongs in version control.
|
|
115
|
+
// .dev.vars carries secret values for `sproutboat dev`, same convention as
|
|
116
|
+
// Wrangler's file of the same name.
|
|
117
|
+
const gitignore = `.sproutboat/
|
|
118
|
+
.dev.vars
|
|
119
|
+
node_modules/
|
|
120
|
+
`;
|
|
121
|
+
|
|
122
|
+
// -------------------------------------------------------------- the flow
|
|
123
|
+
|
|
124
|
+
export async function runInit(options: InitOptions, io: InitIo): Promise<void> {
|
|
125
|
+
try {
|
|
126
|
+
await scaffold(options, io);
|
|
127
|
+
} finally {
|
|
128
|
+
io.close?.();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function scaffold(options: InitOptions, io: InitIo): Promise<void> {
|
|
133
|
+
if (options.list) {
|
|
134
|
+
io.log(`Templates (sproutboat init --template <name>):\n${templateListing()}`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const asking = io.interactive && !options.yes;
|
|
138
|
+
|
|
139
|
+
let template = options.template;
|
|
140
|
+
if (template !== undefined && !isTemplate(template))
|
|
141
|
+
throw new InitError(`unknown template "${template}". Templates:\n${templateListing()}`);
|
|
142
|
+
if (template === undefined)
|
|
143
|
+
template = asking
|
|
144
|
+
? await io.choose(
|
|
145
|
+
"Which template would you like to use?",
|
|
146
|
+
listTemplates().map(({ name, description }) => ({ value: name, label: `${name}: ${description}` })),
|
|
147
|
+
DEFAULT_TEMPLATE,
|
|
148
|
+
)
|
|
149
|
+
: DEFAULT_TEMPLATE;
|
|
150
|
+
|
|
151
|
+
const fallbackName = template === DEFAULT_TEMPLATE ? "hello" : template;
|
|
152
|
+
const given =
|
|
153
|
+
options.name ??
|
|
154
|
+
(asking ? await io.ask("In which directory do you want to create your application?", fallbackName) : fallbackName);
|
|
155
|
+
// C3 takes a directory and names the app after it; so does this: `init apps/blog` makes ./apps/blog named "blog".
|
|
156
|
+
const directory = resolve(io.cwd, given);
|
|
157
|
+
const name = basename(directory);
|
|
158
|
+
if (!NAME_PATTERN.test(name)) throw new InitError(NAME_ERROR);
|
|
159
|
+
const shown = basename(directory);
|
|
160
|
+
|
|
161
|
+
const hello = template === DEFAULT_TEMPLATE;
|
|
162
|
+
const files: TemplateFile[] = hello
|
|
163
|
+
? [
|
|
164
|
+
{ path: "sproutboat.jsonc", content: helloConfig(name) },
|
|
165
|
+
{ path: "src/index.js", content: helloHandler },
|
|
166
|
+
]
|
|
167
|
+
: // An existing .gitignore is not a sign this is not a fresh project (the name can collide with an unrelated
|
|
168
|
+
// directory): keep it rather than fail or clobber it.
|
|
169
|
+
templateFiles(template, name, io.cliVersion).filter(
|
|
170
|
+
(file) => file.path !== ".gitignore" || !existsSync(join(directory, ".gitignore")),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// Check every target before writing any: `name` can collide with an unrelated existing directory, and a write
|
|
174
|
+
// failing partway through would leave a half-scaffolded project behind.
|
|
175
|
+
for (const file of files)
|
|
176
|
+
if (existsSync(join(directory, file.path))) throw new InitError(`${shown} already contains ${file.path}`);
|
|
177
|
+
|
|
178
|
+
// The hello starter merges into an existing package.json (and keeps an existing .gitignore); plan that before
|
|
179
|
+
// writing anything, so an unreadable one stops init instead of half-scaffolding.
|
|
180
|
+
const packagePath = join(directory, "package.json");
|
|
181
|
+
let packagePlan: PackagePlan | undefined;
|
|
182
|
+
if (hello) {
|
|
183
|
+
const existing = existsSync(packagePath) ? await readFile(packagePath, "utf8") : undefined;
|
|
184
|
+
try {
|
|
185
|
+
packagePlan = planPackageJson(name, existing, io.cliVersion);
|
|
186
|
+
} catch (cause) {
|
|
187
|
+
throw new InitError(`${shown}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const file of files) {
|
|
192
|
+
const path = join(directory, file.path);
|
|
193
|
+
await mkdir(dirname(path), { recursive: true });
|
|
194
|
+
await writeFile(path, file.content, { flag: "wx" });
|
|
195
|
+
io.log(`Created ${shown}/${file.path}`);
|
|
196
|
+
}
|
|
197
|
+
if (packagePlan) {
|
|
198
|
+
if (!existsSync(join(directory, ".gitignore"))) {
|
|
199
|
+
await writeFile(join(directory, ".gitignore"), gitignore, { flag: "wx" });
|
|
200
|
+
io.log(`Created ${shown}/.gitignore`);
|
|
201
|
+
}
|
|
202
|
+
if (packagePlan.created || packagePlan.addedDependency || packagePlan.addedScripts.length > 0) {
|
|
203
|
+
await writeFile(packagePath, packagePlan.text);
|
|
204
|
+
io.log(`${packagePlan.created ? "Created" : "Updated"} ${shown}/package.json`);
|
|
205
|
+
}
|
|
206
|
+
for (const script of packagePlan.skippedScripts)
|
|
207
|
+
io.warn(
|
|
208
|
+
` ! package.json already has a "${script}" script; left it as is (sproutboat's would be "sproutboat ${script}")`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// A TypeScript template ships without worker-configuration.d.ts: it is written here, for this project's name.
|
|
213
|
+
if (existsSync(join(directory, "tsconfig.json"))) {
|
|
214
|
+
const config = parseConfig(await readFile(join(directory, "sproutboat.jsonc"), "utf8"));
|
|
215
|
+
const args = parseTypesArgs([]);
|
|
216
|
+
if (!config.ok || "error" in args) throw new InitError(`could not write worker-configuration.d.ts for ${shown}`);
|
|
217
|
+
const plan = await planTypes(args, config.value, directory, directory);
|
|
218
|
+
await writeFile(plan.path, plan.text);
|
|
219
|
+
io.log(`Created ${shown}/${basename(plan.path)}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const manager = detectPackageManager(directory);
|
|
223
|
+
const install =
|
|
224
|
+
options.install ??
|
|
225
|
+
(options.yes
|
|
226
|
+
? true
|
|
227
|
+
: asking
|
|
228
|
+
? await io.confirm(`Do you want to install dependencies with ${manager}?`, true)
|
|
229
|
+
: false);
|
|
230
|
+
const git =
|
|
231
|
+
options.git ??
|
|
232
|
+
(options.yes ? true : asking ? await io.confirm("Do you want to use git for version control?", true) : false);
|
|
233
|
+
|
|
234
|
+
let installed = false;
|
|
235
|
+
if (install) {
|
|
236
|
+
io.log(`Installing dependencies with ${manager}...`);
|
|
237
|
+
installed = (await io.run(manager, ["install"], directory)) === 0;
|
|
238
|
+
if (!installed) io.warn(` ! ${installCommand(manager)} failed; run it yourself in ${shown}`);
|
|
239
|
+
}
|
|
240
|
+
if (git) await initGit(directory, shown, io);
|
|
241
|
+
|
|
242
|
+
const setup = firstRunScript(template);
|
|
243
|
+
const steps = [
|
|
244
|
+
`cd ${shown}`,
|
|
245
|
+
...(installed ? [] : [installCommand(manager)]),
|
|
246
|
+
...(setup ? [runScriptCommand(manager, setup)] : []),
|
|
247
|
+
runScriptCommand(manager, "dev"),
|
|
248
|
+
];
|
|
249
|
+
io.log(`\nNext: ${steps.join(" && ")}`);
|
|
250
|
+
io.log(dim(`After "sproutboat login", ship it with ${runScriptCommand(manager, "deploy")}.`));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function initGit(directory: string, shown: string, io: InitIo): Promise<void> {
|
|
254
|
+
if ((await io.capture("git", ["--version"], directory)).code !== 0) {
|
|
255
|
+
io.warn(" ! git is not installed; skipped git init");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// A project made inside someone's repository belongs to that repository: do not nest a second one in it.
|
|
259
|
+
const inside = await io.capture("git", ["rev-parse", "--is-inside-work-tree"], directory);
|
|
260
|
+
if (inside.code === 0 && inside.stdout.trim() === "true") {
|
|
261
|
+
io.log(` git: ${shown} is already inside a git repository; not creating another`);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if ((await io.capture("git", ["init", "--quiet"], directory)).code !== 0) {
|
|
265
|
+
io.warn(" ! git init failed");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
await io.capture("git", ["add", "-A"], directory);
|
|
269
|
+
const commit = await io.capture("git", ["commit", "--quiet", "-m", "Initial commit (by sproutboat init)"], directory);
|
|
270
|
+
io.log(
|
|
271
|
+
commit.code === 0
|
|
272
|
+
? ` git: initialized ${shown} with a first commit`
|
|
273
|
+
: ` git: initialized ${shown}; the first commit failed (is user.name set?)`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// -------------------------------------------------------------- the terminal
|
|
278
|
+
|
|
279
|
+
export function terminalInitIo(cliVersion: string): InitIo {
|
|
280
|
+
let lines: import("node:readline/promises").Interface | undefined;
|
|
281
|
+
const question = async (text: string): Promise<string> => {
|
|
282
|
+
const { createInterface } = await import("node:readline/promises");
|
|
283
|
+
lines ??= createInterface({ input: process.stdin, output: process.stdout });
|
|
284
|
+
return (await lines.question(text)).trim();
|
|
285
|
+
};
|
|
286
|
+
const spawn = (command: string, args: readonly string[], cwd: string) => {
|
|
287
|
+
try {
|
|
288
|
+
return Bun.spawnSync([command, ...args], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
289
|
+
} catch {
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
return {
|
|
294
|
+
cwd: process.cwd(),
|
|
295
|
+
cliVersion,
|
|
296
|
+
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
297
|
+
log: (line) => console.log(line),
|
|
298
|
+
warn: (line) => console.log(amber(line)),
|
|
299
|
+
ask: async (text, fallback) => (await question(`${text} (${fallback}) `)) || fallback,
|
|
300
|
+
choose: async (text, choices, fallback) => {
|
|
301
|
+
console.log(text);
|
|
302
|
+
choices.forEach((choice, at) => console.log(` ${at + 1}) ${choice.label}`));
|
|
303
|
+
for (;;) {
|
|
304
|
+
const answer = await question(`> (${choices.findIndex((choice) => choice.value === fallback) + 1}) `);
|
|
305
|
+
if (answer === "") return fallback;
|
|
306
|
+
const picked = choices[Number(answer) - 1] ?? choices.find((choice) => choice.value === answer);
|
|
307
|
+
if (picked) return picked.value;
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
confirm: async (text, fallback) => {
|
|
311
|
+
const answer = (await question(`${text} ${fallback ? "[Y/n]" : "[y/N]"} `)).toLowerCase();
|
|
312
|
+
return answer === "" ? fallback : answer.startsWith("y");
|
|
313
|
+
},
|
|
314
|
+
run: async (command, args, cwd) => {
|
|
315
|
+
try {
|
|
316
|
+
const child = Bun.spawn([command, ...args], { cwd, stdin: "inherit", stdout: "inherit", stderr: "inherit" });
|
|
317
|
+
return await child.exited;
|
|
318
|
+
} catch {
|
|
319
|
+
return 127;
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
capture: async (command, args, cwd) => {
|
|
323
|
+
const result = spawn(command, args, cwd);
|
|
324
|
+
return { code: result?.exitCode ?? 127, stdout: result?.stdout.toString() ?? "" };
|
|
325
|
+
},
|
|
326
|
+
close: () => lines?.close(),
|
|
327
|
+
};
|
|
328
|
+
}
|