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/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-22` by default (the alpha 21 line's compiler with
71
- * the toolchain's alpha 19 server shim over its bridge; sproutd-alpha22-standalone-port.md), or
72
- * `alpha-19` (`SPROUTBOAT_PORFFOR_CHANNEL=alpha-19`: Porffor's native-fetch server, the fallback pin
73
- * kept for one release). The toolchain's `standaloneChannel()` decides, so the CLI and the runtime's
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` with
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) === "alpha-22") {
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 22 channel's native compile (sproutd-alpha22-standalone-port.md): the generated module is
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 alpha 22 compiler renders the split
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 alpha22 = await ensurePorfforA21("alpha-22");
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(alpha22, "runtime/index.js"),
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) throw new Error(`Porffor (alpha 22) render failed: ${(stderr || stdout).trim() || `exit ${code}`}`);
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 22 build produced no binary");
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://127.0.0.1:${input.port}`)}`));
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
- proxy.stop();
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
+ }