sproutboat 0.53.1 → 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 +69 -1
- package/README.md +122 -9
- package/SURFACE.md +42 -22
- package/package.json +20 -12
- package/scripts/embed-templates.ts +87 -0
- package/src/account-field.ts +100 -0
- package/src/build.ts +13 -2
- package/src/cloud-bindings.ts +227 -0
- package/src/cloud-deploy.ts +794 -0
- package/src/cloud-domains.ts +55 -0
- package/src/cloud-fake.ts +719 -0
- package/src/cloud-secrets.ts +121 -0
- package/src/cloud.ts +375 -0
- package/src/compile.ts +34 -18
- package/src/credentials.ts +455 -52
- package/src/d1-cli.ts +455 -0
- package/src/data-errors.ts +139 -0
- package/src/deployments-cli.ts +184 -0
- package/src/dev.ts +67 -4
- package/src/fake-login.ts +28 -0
- package/src/global-flags.ts +113 -0
- package/src/init-package.ts +2 -1
- package/src/init.ts +328 -0
- package/src/main.ts +1247 -193
- package/src/options.ts +242 -0
- package/src/r2-objects.ts +365 -0
- package/src/run.ts +12 -6
- package/src/secrets-bulk.ts +51 -0
- package/src/sql-split.ts +68 -0
- package/src/standalone-build.ts +21 -3
- package/src/surface.ts +192 -64
- package/src/target.ts +95 -0
- 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/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
|
+
}
|