create-eigen-game 0.13.0 → 0.14.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/index.ts ADDED
@@ -0,0 +1,974 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { appendFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, relative, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { ENGINE_PACKAGE, engineRange } from "./engine-range.js";
6
+ import { type FirebaseLink, readFirebaseLink } from "./link-firebase.js";
7
+
8
+ export type { FirebaseLink } from "./link-firebase.js";
9
+
10
+ export type PackageManager = "npm" | "pnpm";
11
+
12
+ export type Runner = (command: string, args: string[], cwd: string) => void;
13
+
14
+ /**
15
+ * Where a scaffold says what it is doing, so that presentation belongs to the
16
+ * caller and this module only has to name things.
17
+ *
18
+ * The default is the behaviour a library caller expects with no opinion:
19
+ * subprocesses inherit the terminal and warnings go to `console.warn`. The CLI
20
+ * substitutes one that captures each step's output and shows it only if the
21
+ * step fails.
22
+ */
23
+ export interface Reporter {
24
+ /** Runs `body` as one named step. Its subprocesses can be captured. */
25
+ step<T>(label: string, body: () => T): T;
26
+ /**
27
+ * Runs `body` as a step that owns the terminal, because something inside it
28
+ * asks questions. FlutterFire is the only one.
29
+ */
30
+ handOver<T>(label: string, body: () => T): T;
31
+ /** Output from a subprocess inside the current step. */
32
+ emit(output: string): void;
33
+ /** A failure the scaffold survives. */
34
+ warn(message: string): void;
35
+ /** Whether subprocesses must inherit the terminal rather than be captured. */
36
+ readonly interactive: boolean;
37
+ }
38
+
39
+ /**
40
+ * Gives a terminal that reports no width the width clack would have assumed.
41
+ *
42
+ * A pty can report itself as a TTY and set `columns` to 0. `script` does, and
43
+ * so do some container terminals. clack falls back to 80 for a stream with no
44
+ * `columns` at all, but `0` is a number and satisfies that check, so the width
45
+ * it draws its rules from goes negative and a scaffold dies partway through
46
+ * with `RangeError: Invalid string length`. Supplying the default its check
47
+ * misses is better than giving up the drawing.
48
+ */
49
+ export function normaliseTerminalWidth(stream: { isTTY?: boolean; columns?: number }): void {
50
+ if (stream.isTTY === true && (stream.columns ?? 0) < 1) stream.columns = 80;
51
+ }
52
+
53
+ /** Runs every step plainly, which is what a library caller gets by default. */
54
+ export const plainReporter: Reporter = {
55
+ step: (_label, body) => body(),
56
+ handOver: (_label, body) => body(),
57
+ emit: (output) => process.stdout.write(output),
58
+ warn: (message) => console.warn(`create-eigen-game: ${message}`),
59
+ interactive: true,
60
+ };
61
+
62
+ /**
63
+ * The runner a scaffold uses when the caller has not supplied one: it asks the
64
+ * reporter whether the terminal is spoken for.
65
+ *
66
+ * `interactive` inherits, which is the only way FlutterFire can put a question
67
+ * on screen and read the answer. Otherwise both streams are captured and
68
+ * handed to the reporter: on success so it can discard them, and on failure
69
+ * so the reason survives.
70
+ */
71
+ export function capturingRunner(reporter: Reporter): Runner {
72
+ return (command, args, cwd) => {
73
+ if (reporter.interactive) {
74
+ execFileSync(command, args, { cwd, stdio: "inherit" });
75
+ return;
76
+ }
77
+ try {
78
+ reporter.emit(execFileSync(command, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }));
79
+ } catch (error: unknown) {
80
+ // Both streams, because which one a tool failed on is its own business:
81
+ // pub explains itself on stdout, Gradle on stderr.
82
+ const { stdout, stderr } = error as { stdout?: string; stderr?: string };
83
+ reporter.emit(`${stdout ?? ""}${stderr ?? ""}`);
84
+ throw error;
85
+ }
86
+ };
87
+ }
88
+
89
+ export interface ScaffoldOptions {
90
+ directory: string;
91
+ org?: string;
92
+ packageManager?: PackageManager;
93
+ /**
94
+ * Run Flutter, package installation, and contract/payload generation.
95
+ * Kept as a programmatic test seam; the public CLI always bootstraps both
96
+ * halves of the combined project.
97
+ */
98
+ bootstrap?: boolean;
99
+ /**
100
+ * Emit the GitHub Actions workflows. Off by default; see the call site for
101
+ * why, and `addContinuousIntegration` for adding them to an existing
102
+ * project later.
103
+ */
104
+ ci?: boolean;
105
+ /**
106
+ * Initialise a repository and commit the scaffold. Defaults to `bootstrap`,
107
+ * because the commit is only worth having once the generated files exist.
108
+ */
109
+ git?: boolean;
110
+ /**
111
+ * Configure Firebase before the first commit, by running the Flutter
112
+ * client's `configure_firebase`. A string names the project to use; `true`
113
+ * lets FlutterFire ask, which is also where a project can be created.
114
+ *
115
+ * Explicit here, though the CLI turns it on by default: this is the one step
116
+ * that reaches outside the destination directory, and a library caller
117
+ * should say so rather than discover it. {@link firebaseReadiness} is how
118
+ * the CLI decides.
119
+ */
120
+ firebase?: boolean | string;
121
+ /**
122
+ * Runs the bootstrap subprocesses. A seam: the tests substitute a recorder,
123
+ * and `scripts/scaffold-e2e.mjs` wraps it to point the generated server at
124
+ * this workspace's engine rather than npm's copy.
125
+ */
126
+ run?: Runner;
127
+ /**
128
+ * Where the scaffold says what it is doing. See {@link Reporter}; the
129
+ * default runs every step plainly.
130
+ */
131
+ reporter?: Reporter;
132
+ }
133
+
134
+ /**
135
+ * What became of the repository, so the CLI's closing summary can say rather
136
+ * than guess. `failed` has already warned by the time it is returned, and
137
+ * carries no advice of its own.
138
+ */
139
+ export type GitOutcome = "committed" | "existing" | "skipped" | "failed";
140
+
141
+ export interface ScaffoldResult {
142
+ root: string;
143
+ name: string;
144
+ git: GitOutcome;
145
+ /**
146
+ * Whether Firebase was configured, so the summary can either stop naming the
147
+ * step or spell it out. `failed` has already warned.
148
+ */
149
+ firebase: "configured" | "failed" | "skipped";
150
+ /**
151
+ * What the Firebase step made knowable and this scaffold therefore filled
152
+ * in. Absent unless `firebase` is `configured`, since there is nothing to
153
+ * read otherwise.
154
+ */
155
+ link?: FirebaseLink;
156
+ }
157
+
158
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
159
+ const templatesRoot = resolve(packageRoot, "templates");
160
+
161
+ /**
162
+ * The engine range emitted into a scaffolded project's package.json.
163
+ *
164
+ * Read from this package's `@eigeninteractive/server` devDependency rather than
165
+ * from its own version. See `engine-range.ts` for why that is the version the
166
+ * templates are known to compile against, and what it replaced.
167
+ */
168
+ const engineVersion = engineRange((JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")) as { devDependencies?: Record<string, string> }).devDependencies?.[ENGINE_PACKAGE], () => {
169
+ // Only reached in this workspace, where the manifest still says
170
+ // `workspace:*`. A published tarball has no sibling here and never asks.
171
+ const sibling = resolve(packageRoot, "../server/package.json");
172
+ return existsSync(sibling) ? (JSON.parse(readFileSync(sibling, "utf8")) as { version: string }).version : undefined;
173
+ });
174
+
175
+ /**
176
+ * The Flutter client range installed into the app half.
177
+ *
178
+ * A stated version, not a derived or resolved one. The Dart templates import
179
+ * `package:eigen_flutter/eigen_flutter.dart` and
180
+ * `package:eigen_flutter/testing/twin_fixtures.dart` and are written against a
181
+ * specific Dart API. `eigen_flutter` is published and versioned separately from
182
+ * this package even though both now live in the platform monorepo, so a source
183
+ * location cannot compute which released version the template should request.
184
+ * Only compiling a scaffolded app establishes it, which the platform gate does
185
+ * on every change.
186
+ *
187
+ * This briefly resolved from pub.dev instead: "the newest `eigen_flutter` whose
188
+ * own `eigen_api` constraint targets the engine line being scaffolded". That
189
+ * predicate is wrong. The `eigen_api` constraint describes the WIRE the integration
190
+ * speaks, not the Dart API these templates call, and the two move
191
+ * independently: a future `eigen_flutter` may legitimately keep `eigen_api:
192
+ * ^0.2.0` while renaming everything the templates touch. It would have been
193
+ * selected, and the generated app would not compile.
194
+ *
195
+ * A caret RANGE rather than an exact version, so it still improves without a
196
+ * republish: `flutter pub add eigen_flutter@^0.4.0` already picks the newest
197
+ * 0.4.x at scaffold time, which is the part the pub.dev lookup was duplicating.
198
+ * What it deliberately cannot do is cross to 0.5.x, the one move that needs a
199
+ * human to confirm the templates still compile.
200
+ *
201
+ * Staleness is therefore a failing check rather than a broken scaffold: this is
202
+ * only ever a release behind, never wrong.
203
+ *
204
+ * Raised to 0.4.0 with the engine's session-snapshot wire. This is the one kind
205
+ * of bump that is not merely an improvement: a scaffold writes both halves, and
206
+ * the worker half is this repository's own line, so the client integration has to
207
+ * speak that line's socket. `eigen_flutter` 0.4.0 is the first that does, and
208
+ * every 0.3.x pins `eigen_api: ^0.2.0`, which cannot read a 0.3.x engine at all.
209
+ * A scaffold pinning 0.3.x would therefore resolve, compile, and then fail to
210
+ * render a game, which is the worst of the three.
211
+ *
212
+ * Raised to 0.4.1 for `EngineConfig.authDomain`, which the generated
213
+ * `main.dart` passes. Additive, so pre-1.0 it is a patch and `^0.4.0` would
214
+ * still resolve it, but `^0.4.0` also still resolves 0.4.0, where the parameter
215
+ * does not exist and the scaffold would not compile. The floor is what makes
216
+ * the generated code and the installed shell agree.
217
+ *
218
+ * Raised to 0.6.0 with the engine's opaque pagination cursors, and this is the
219
+ * same *required* kind of bump the 0.4.0 note describes rather than an
220
+ * improvement. The 0.4.x engine line takes `cursor` as an opaque string and
221
+ * returns `nextCursor` on every paged response; `eigen_flutter` 0.6.0 is the
222
+ * first integration that pins `eigen_api: ^0.4.0` and can read it. Everything below
223
+ * that pins `eigen_api: ^0.3.0` or older, so a scaffold left on `^0.4.1` would
224
+ * install the engine's current line beside a shell that cannot read a single
225
+ * paged list from it: it resolves, it compiles, and then the lobby and history
226
+ * are empty. 0.5.0 is skipped for the same reason, being a web-design release
227
+ * that still speaks the 0.3.x wire.
228
+ *
229
+ * Raised to 0.7.0 for the 0.5.x engine line and the `PlayerLimits` client API.
230
+ * vNext later simplified the wire again: versions are a contiguous prefix and
231
+ * generic mutation identities are gone. The next Flutter release must raise
232
+ * this floor before the corresponding scaffolder is published.
233
+ *
234
+ * Note that this floor and the engine range are raised in one commit on
235
+ * purpose. The scaffolder writes both halves, and `updateInternalDependencies`
236
+ * republishes this package whenever the engine version moves, so between an
237
+ * engine line crossing and this line moving there is a published scaffolder
238
+ * that pairs a new worker with an old shell. That window is real and this is
239
+ * what closes it.
240
+ *
241
+ * The 0.7.0 raise is the exception that shows where that promise cannot hold. A
242
+ * floor names a *published* `eigen_flutter`, and `eigen_flutter` publishes at the
243
+ * end of the release chain, after the npm packages this scaffolder ships with --
244
+ * so at the moment the engine crossed to 0.5.x there was no 0.7.0 to point at,
245
+ * and `create-eigen-game@0.12.0` went out pairing a `^0.5.0` worker with a 0.6.0
246
+ * integration. A Flutter line move therefore costs a follow-up scaffolder patch, and
247
+ * `scripts/scaffold-e2e.mjs` is what refuses to let it be forgotten: it resolves
248
+ * both halves for real and compares the wire lines they land on.
249
+ */
250
+ const flutterClientVersion = "^0.9.0";
251
+
252
+ /** Complete first-party app shell installed by the standard scaffold. */
253
+ const flutterShellVersion = "^0.1.0";
254
+
255
+ /** Optional Firebase adapter installed by the standard app scaffold. */
256
+ const firebaseAdapterVersion = "^0.2.0";
257
+
258
+ /** Development-only contract compiler installed into the generated app. */
259
+ const dartCodegenVersion = "^0.1.0";
260
+
261
+ const gameSlug = (value: string): string => {
262
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) {
263
+ throw new Error("destination directory name must be a lowercase kebab-case slug, for example my-game");
264
+ }
265
+ return value;
266
+ };
267
+
268
+ const dartName = (value: string): string => value.replaceAll("-", "_");
269
+
270
+ /**
271
+ * The Android `applicationId`, and iOS bundle id, a scaffold at `directory`
272
+ * will produce, so the CLI can show it rather than describe it.
273
+ *
274
+ * `flutter create --org X --project-name Y` derives `X.Y`. The organization is
275
+ * the *prefix*, not the whole identifier, which is the part worth seeing
276
+ * before answering: Google Play makes it permanent at first upload.
277
+ */
278
+ export function applicationId(directory: string, org?: string): string {
279
+ return `${org?.trim() || "com.example"}.${dartName(gameSlug(basename(resolve(directory))))}`;
280
+ }
281
+
282
+ const title = (value: string): string =>
283
+ value
284
+ .split("-")
285
+ .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
286
+ .join(" ");
287
+
288
+ const identifier = (value: string): string => {
289
+ const pascal = value
290
+ .split("-")
291
+ .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
292
+ .join("");
293
+ return /^[0-9]/.test(pascal) ? `Game${pascal}` : pascal;
294
+ };
295
+
296
+ export function detectPackageManager(userAgent = process.env.npm_config_user_agent): PackageManager | undefined {
297
+ if (userAgent?.startsWith("pnpm/")) return "pnpm";
298
+ if (userAgent?.startsWith("npm/")) return "npm";
299
+ return undefined;
300
+ }
301
+
302
+ /**
303
+ * Substitutes the scaffold's tokens, of which there are two deliberate kinds.
304
+ *
305
+ * `{{BRACED}}` for values that have no valid example: a package manager,
306
+ * an engine version, an Android package id. They appear only in manifests,
307
+ * READMEs, workflows and the `Appfile`: files nothing compiles.
308
+ *
309
+ * Bare words (`ExampleGame`, `example-game`, …) for names a real example game
310
+ * genuinely has. This is what keeps `templates/worker` and the Dart templates
311
+ * VALID, COMPILING SOURCE rather than text with holes in it, which the rest of
312
+ * this package leans on hard: `typecheck` runs `tsc` over `templates/worker`,
313
+ * the tests `import` its game module directly and build a real contract from
314
+ * it, and the `scaffold` job compiles the Dart templates. Bracing these would
315
+ * trade all of that for a marker.
316
+ *
317
+ * The cost, and the one rule for template authors: a template file cannot
318
+ * contain the literal string `example-game`, `Example Game`, `ExampleGame` or
319
+ * `example_game` and keep it; every occurrence is rewritten, in prose and in
320
+ * code alike. Write around it, or brace a new token instead.
321
+ *
322
+ * Note this means the set of files carrying a token changes whenever someone
323
+ * writes one into an existing file. Nothing needs to track that: `renderTree`
324
+ * decides what to rewrite from the bytes, not from a list of paths.
325
+ */
326
+ function render(contents: string, name: string, manager: PackageManager, packageId: string): string {
327
+ const replacements: ReadonlyArray<readonly [string, string]> = [
328
+ ["@game/example-game-server", `@game/${name}-server`],
329
+ ["{{PACKAGE_MANAGER}}", manager],
330
+ ["{{PACKAGE_ID}}", packageId],
331
+ ["{{ENGINE_VERSION}}", engineVersion],
332
+ ["ExampleGame", identifier(name)],
333
+ ["Example Game", title(name)],
334
+ ["example_game", dartName(name)],
335
+ ["example-game", name],
336
+ ];
337
+ return replacements.reduce((result, [from, to]) => result.replaceAll(from, to), contents);
338
+ }
339
+
340
+ /**
341
+ * True when the bytes are valid UTF-8.
342
+ *
343
+ * This is not a guess at whether a file is "binary"; it is the precondition
344
+ * of the operation. Token substitution is `String.replaceAll`, so a file that
345
+ * cannot be decoded cannot take part, and one that can is safe to rewrite.
346
+ *
347
+ * Deliberately not an extension list. A DENY list (`.png`, `.jar`, …) fails
348
+ * silently in the dangerous direction: the day someone adds a `.webp`, it is
349
+ * corrupted with no error. An ALLOW list fails the safe way but fits this tree
350
+ * badly: `Gemfile`, `Fastfile`, `.nvmrc`, `.fvmrc` and `.ruby-version` all
351
+ * need rendering and none carry a usable extension, so it would have to be a
352
+ * list of extensions *and* a list of bare filenames, both drifting.
353
+ *
354
+ * `.template` is not the signal either, despite the name: it marks files a
355
+ * language server must not claim before they have an enclosing package. Five
356
+ * of them contain no token at all, and nine token-bearing files are not
357
+ * marked, so the two concepts are orthogonal here.
358
+ *
359
+ * `renderTree`'s test pins the resulting classification for the whole tree, so
360
+ * this stays a reviewed decision rather than an invisible one.
361
+ */
362
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
363
+
364
+ export function decodeUtf8(bytes: Buffer): string | undefined {
365
+ try {
366
+ return utf8.decode(bytes);
367
+ } catch {
368
+ return undefined;
369
+ }
370
+ }
371
+
372
+ function renderTree(source: string, destination: string, name: string, manager: PackageManager, packageId: string): void {
373
+ cpSync(source, destination, { recursive: true });
374
+ // Walks the TEMPLATE, not the destination. The app overlay renders on top of
375
+ // a `flutter create` result, so walking the destination meant rewriting
376
+ // every file Flutter had just generated, including its binaries, for files
377
+ // this scaffolder does not own and has no business touching.
378
+ const visit = (directory: string): void => {
379
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
380
+ const templatePath = resolve(directory, entry.name);
381
+ if (entry.isDirectory()) {
382
+ visit(templatePath);
383
+ continue;
384
+ }
385
+ if (!entry.isFile()) continue;
386
+
387
+ const rendered = resolve(destination, relative(source, templatePath));
388
+ const text = decodeUtf8(readFileSync(templatePath));
389
+ // Undecodable files are already in place from the copy above, byte for
390
+ // byte. Only decodable ones are rewritten.
391
+ if (text !== undefined) {
392
+ writeFileSync(rendered, render(text, name, manager, packageId));
393
+ }
394
+ // Template-only source must not be claimed by language servers before
395
+ // it has an enclosing generated package. Restore the real extension
396
+ // only in the rendered project.
397
+ if (entry.name.endsWith(".template")) {
398
+ renameSync(rendered, rendered.slice(0, -".template".length));
399
+ }
400
+ }
401
+ };
402
+ visit(source);
403
+
404
+ // npm deliberately excludes files named `.gitignore` from package
405
+ // tarballs. Their packaged twins live outside the C3 template so a direct
406
+ // Git-based C3 render does not inherit a scaffolder-only helper file.
407
+ const packagedGitignore = resolve(templatesRoot, "scaffold", `${basename(source)}.gitignore`);
408
+ if (existsSync(packagedGitignore)) {
409
+ writeFileSync(resolve(destination, ".gitignore"), render(readFileSync(packagedGitignore, "utf8"), name, manager, packageId));
410
+ }
411
+ }
412
+
413
+ function packageCommand(manager: PackageManager, operation: "install" | "contract" | "cf-typegen"): [string, string[]] {
414
+ if (operation === "install") return [manager, ["install"]];
415
+ return [manager, ["run", operation]];
416
+ }
417
+
418
+ /**
419
+ * Appends `block` to a file this scaffolder does not own, as its own paragraph.
420
+ *
421
+ * **Append, never insert.** That is the rule the three callers below follow, and
422
+ * it is what keeps them from competing with `flutter create` and `flutterfire
423
+ * configure` for position in the same files. A pure append cannot collide with
424
+ * whatever those tools write above it; the one edit here that ever needed a
425
+ * position, prepending a Kotlin `import`, was broken by an AGP upgrade and has
426
+ * since been rewritten away. See MAINTAINERS.md for the whole doctrine, including
427
+ * why FlutterFire's `// START:`/`// END:` marker comments are deliberately not
428
+ * copied.
429
+ *
430
+ * `contents` is passed in rather than read here because every caller has already
431
+ * read the file to decide whether its block is present, and two of them assert
432
+ * on the shape of what they found. This owns the whitespace and nothing else.
433
+ *
434
+ * That is worth one function because it was previously written three times with
435
+ * two different answers, so the desugaring block got a blank line before it and
436
+ * the other two did not, and the two Gradle blocks ended up flush against each
437
+ * other in every generated project. Separation belongs to the append, so the
438
+ * block constants carry no leading or trailing newline of their own.
439
+ */
440
+ function appendBlock(path: string, contents: string, block: string): void {
441
+ appendFileSync(path, `${contents.endsWith("\n") ? "" : "\n"}\n${block}\n`);
442
+ }
443
+
444
+ const androidDesugaring = `// flutter_local_notifications requires desugaring in the application module.
445
+ // A library plugin cannot enable this compiler setting transitively.
446
+ android {
447
+ compileOptions {
448
+ isCoreLibraryDesugaringEnabled = true
449
+ }
450
+ }
451
+
452
+ dependencies {
453
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
454
+ }`;
455
+
456
+ function enableAndroidCoreLibraryDesugaring(appRoot: string): void {
457
+ const gradlePath = resolve(appRoot, "android/app/build.gradle.kts");
458
+ const gradle = readFileSync(gradlePath, "utf8");
459
+ if (!gradle.includes('id("com.android.application")')) {
460
+ throw new Error(`Flutter created an unexpected Android application build file: ${gradlePath}`);
461
+ }
462
+ const settingExists = gradle.includes("isCoreLibraryDesugaringEnabled");
463
+ const dependencyExists = gradle.includes("coreLibraryDesugaring(");
464
+ if (settingExists && dependencyExists) return;
465
+ if (settingExists || dependencyExists) {
466
+ throw new Error(`Flutter created an incomplete Android core library desugaring configuration: ${gradlePath}`);
467
+ }
468
+ appendBlock(gradlePath, gradle, androidDesugaring);
469
+ }
470
+
471
+ // A second top-level `android { }` block is valid Gradle Kotlin DSL, since Gradle
472
+ // merges repeated extension-configuration blocks in one file, which is what
473
+ // lets this stay a pure append, same as the desugaring block above. A second
474
+ // top-level `plugins { }` block is NOT valid (Gradle allows exactly one per
475
+ // script), which is why the Crashlytics Gradle plugin registration,
476
+ // `flutterfire configure`'s own territory, is deliberately left alone here.
477
+ //
478
+ // Parsed with the Kotlin stdlib rather than `java.util.Properties`, which is
479
+ // what keeps this appendable. `Properties` would need a real `import`, and
480
+ // Kotlin requires imports before every other top-level declaration, so using it
481
+ // meant also reaching into the very start of a file this scaffolder does not
482
+ // own. A fully-qualified `java.util.Properties()` reference was tried to avoid
483
+ // that, and does not resolve under AGP 9's Kotlin DSL script compilation
484
+ // (`flutter create`'s current default): "Unresolved reference 'util'".
485
+ // Confirmed by actually building a scaffolded project, not just reading it.
486
+ const androidReleaseSigning = `val releaseKeyProperties: Map<String, String> =
487
+ rootProject.file("key.properties")
488
+ .takeIf { it.exists() }
489
+ ?.readLines()
490
+ ?.map { it.trim() }
491
+ ?.filter { it.isNotEmpty() && !it.startsWith("#") && it.contains("=") }
492
+ ?.associate { it.substringBefore("=").trim() to it.substringAfter("=").trim() }
493
+ .orEmpty()
494
+
495
+ android {
496
+ signingConfigs {
497
+ create("release") {
498
+ storeFile = releaseKeyProperties["storeFile"]?.let { file(it) }
499
+ storePassword = releaseKeyProperties["storePassword"]
500
+ keyAlias = releaseKeyProperties["keyAlias"]
501
+ keyPassword = releaseKeyProperties["keyPassword"]
502
+ }
503
+ }
504
+ buildTypes {
505
+ release {
506
+ signingConfig = if (releaseKeyProperties["storeFile"] != null)
507
+ signingConfigs.getByName("release")
508
+ else
509
+ signingConfigs.getByName("debug")
510
+ isMinifyEnabled = true
511
+ isShrinkResources = true
512
+ proguardFiles(
513
+ getDefaultProguardFile("proguard-android-optimize.txt"),
514
+ "proguard-rules.pro",
515
+ )
516
+ }
517
+ }
518
+ }`;
519
+
520
+ function enableAndroidReleaseSigning(appRoot: string): void {
521
+ const gradlePath = resolve(appRoot, "android/app/build.gradle.kts");
522
+ const gradle = readFileSync(gradlePath, "utf8");
523
+ // Not `gradle.includes("signingConfigs")`, because Flutter's own template already
524
+ // contains that substring (`signingConfig = signingConfigs.getByName("debug")`),
525
+ // so that check would always short-circuit and never append anything.
526
+ if (gradle.includes("releaseKeyProperties")) return;
527
+ // A pure append. This previously also PREPENDED `import java.util.Properties`,
528
+ // the most position-dependent edit here and the one already broken once by an
529
+ // AGP upgrade; see the block above for what replaced it.
530
+ appendBlock(gradlePath, gradle, androidReleaseSigning);
531
+ }
532
+
533
+ // `flutter_launcher_icons`/`flutter_native_splash` read these as plain
534
+ // top-level pubspec keys, so appending is safe regardless of what
535
+ // `flutter create` already wrote under `flutter:`. Deliberately omits
536
+ // `ios`/`macos`, since `scaffoldGame` only creates `--platforms android,web`, and
537
+ // `flutter_launcher_icons` errors if told to target a platform whose
538
+ // directory doesn't exist.
539
+ // The colours are the EigenInteractive palette (ink #1B1E24, paper #F4F1EA) because the
540
+ // shipped placeholder art is the EigenInteractive mark: the adaptive foreground is the
541
+ // reversed, light-on-dark variant, so an ink background is what makes it
542
+ // legible. Rebranding a game means replacing the four PNGs *and* these
543
+ // colours together. See https://eigeninteractive.com/docs/ship-it/branding.
544
+ const launcherIconsAndSplashConfig = `flutter_launcher_icons:
545
+ android: true
546
+ web:
547
+ generate: true
548
+ image_path: "assets/icon/icon.png"
549
+ image_path: "assets/icon/icon.png"
550
+ adaptive_icon_background: "#1B1E24"
551
+ adaptive_icon_foreground: "assets/icon/icon_foreground.png"
552
+ min_sdk_android: 21
553
+
554
+ flutter_native_splash:
555
+ color: "#F4F1EA"
556
+ color_dark: "#1B1E24"
557
+ image: assets/icon/splash.png
558
+ image_dark: assets/icon/splash_dark.png
559
+ fullscreen: true
560
+ android_12:
561
+ color: "#F4F1EA"
562
+ color_dark: "#1B1E24"
563
+ image: assets/icon/splash.png
564
+ image_dark: assets/icon/splash_dark.png
565
+ icon_background_color: "#F4F1EA"
566
+ icon_background_color_dark: "#1B1E24"
567
+ web: true`;
568
+
569
+ function configureLauncherIconsAndSplash(appRoot: string): void {
570
+ const pubspecPath = resolve(appRoot, "pubspec.yaml");
571
+ const pubspec = readFileSync(pubspecPath, "utf8");
572
+ if (pubspec.includes("flutter_launcher_icons:")) return;
573
+ appendBlock(pubspecPath, pubspec, launcherIconsAndSplashConfig);
574
+ }
575
+
576
+ export interface AddContinuousIntegrationOptions {
577
+ directory: string;
578
+ /** Overrides the manager recorded in the project's own package.json. */
579
+ packageManager?: PackageManager;
580
+ }
581
+
582
+ /**
583
+ * Adds the GitHub Actions workflows to an existing generated project.
584
+ *
585
+ * The companion to `ci: false`: a game can be built and played locally for as
586
+ * long as its author likes, then gain the same PR gate and signed release
587
+ * pipeline a `--ci` scaffold would have produced.
588
+ */
589
+ export function addContinuousIntegration(options: AddContinuousIntegrationOptions): { root: string; files: string[] } {
590
+ const root = resolve(options.directory);
591
+ const manifestPath = resolve(root, "package.json");
592
+ if (!existsSync(manifestPath) || !existsSync(resolve(root, "server")) || !existsSync(resolve(root, "app"))) {
593
+ throw new Error(`not an EigenInteractive game project (expected package.json, server/ and app/): ${root}`);
594
+ }
595
+
596
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
597
+ name?: string;
598
+ scripts?: Record<string, string>;
599
+ };
600
+ const name = gameSlug(manifest.name ?? basename(root));
601
+
602
+ // The generated `contract` script embeds the manager the project was
603
+ // scaffolded with (`cd server && pnpm run contract && …`), which is a more
604
+ // reliable signal than a lockfile: it is present before anything is
605
+ // installed, and it is what the project actually uses.
606
+ const recorded = manifest.scripts?.contract?.includes("pnpm ") ? "pnpm" : manifest.scripts?.contract?.includes("npm ") ? "npm" : undefined;
607
+ const manager = options.packageManager ?? recorded ?? detectPackageManager() ?? "pnpm";
608
+
609
+ // Only `{{PACKAGE_MANAGER}}` appears in these templates, but the real
610
+ // package id is read back rather than reconstructed, so a project scaffolded
611
+ // under a different `--org` cannot be silently rewritten to `com.example`.
612
+ const appfile = resolve(root, "app/fastlane/Appfile");
613
+ const packageId = existsSync(appfile) ? (/package_name\("([^"]+)"\)/.exec(readFileSync(appfile, "utf8"))?.[1] ?? `com.example.${dartName(name)}`) : `com.example.${dartName(name)}`;
614
+
615
+ const workflows = resolve(root, ".github/workflows");
616
+ const existing = existsSync(workflows) ? readdirSync(workflows).filter((f) => f === "checks.yml" || f === "release.yml") : [];
617
+ if (existing.length > 0) {
618
+ throw new Error(`refusing to overwrite existing workflows: ${existing.join(", ")}`);
619
+ }
620
+
621
+ renderTree(resolve(templatesRoot, "ci"), root, name, manager, packageId);
622
+ return { root, files: [".github/workflows/checks.yml", ".github/workflows/release.yml"] };
623
+ }
624
+
625
+ /** Asks a command a question. `ok` is "ran and succeeded"; a tool that is not installed and one that fails are both `false`, because neither can be used. */
626
+ export type Probe = (command: string, args: string[]) => { ok: boolean; stdout: string };
627
+
628
+ const probeCommand: Probe = (command, args) => {
629
+ try {
630
+ return { ok: true, stdout: execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) };
631
+ } catch {
632
+ return { ok: false, stdout: "" };
633
+ }
634
+ };
635
+
636
+ /** One thing standing between this machine and a configured Firebase project, and the single command that clears it. */
637
+ export interface FirebaseProblem {
638
+ /** Said as a sentence fragment, so it reads after "Firebase is not set up here:". */
639
+ reason: string;
640
+ /** The one command that fixes this, and nothing else. */
641
+ fix: string;
642
+ }
643
+
644
+ export type FirebaseReadiness = { ready: true } | { ready: false; problems: FirebaseProblem[] };
645
+
646
+ /**
647
+ * Whether this machine can configure Firebase, asked *before* the scaffold
648
+ * rather than after it.
649
+ *
650
+ * `configure_firebase` runs the same three checks and is the authority on
651
+ * them; this one exists only to move the answer earlier. Learning that
652
+ * `flutterfire` is missing costs nothing here and costs two minutes of Flutter
653
+ * and pub at the far end, which is where the step actually runs.
654
+ *
655
+ * Every problem, not the first one. The checks are independent (two CLIs from
656
+ * two ecosystems, plus a sign-in) so short-circuiting turns one setup into a
657
+ * sequence of runs that each reveal the next missing piece. Reported in the
658
+ * order they have to be fixed in: the CLI, then the sign-in it stores, then
659
+ * the bridge that consumes both.
660
+ *
661
+ * The sign-in check fails open, for the same reason it does there: only an
662
+ * answer that positively reports no accounts counts as "no", so a `firebase`
663
+ * that errors or grows a different output shape is left to the command that
664
+ * actually needs the credentials.
665
+ */
666
+ export function firebaseReadiness(probe: Probe = probeCommand): FirebaseReadiness {
667
+ const problems: FirebaseProblem[] = [];
668
+
669
+ if (probe("firebase", ["--version"]).ok) {
670
+ if (signedOut(probe)) problems.push({ reason: "no Google account is signed in to the Firebase CLI", fix: "firebase login" });
671
+ } else {
672
+ // Nothing to say about the sign-in: the CLI that would answer is the one
673
+ // that is missing, and `firebase login` is what installing leads to anyway.
674
+ // Google's own installer, which picks a standalone binary or npm to suit
675
+ // the machine. `npm install -g firebase-tools` also works and is what this
676
+ // used to print, but it needs a global npm prefix the reader may not have
677
+ // write access to, and it is not the install the Firebase docs lead with.
678
+ problems.push({ reason: "the `firebase` CLI is not installed", fix: "curl -sL https://firebase.tools | bash" });
679
+ }
680
+
681
+ if (!probe("flutterfire", ["--version"]).ok) {
682
+ problems.push({ reason: "the `flutterfire` CLI is not installed", fix: "dart pub global activate flutterfire_cli" });
683
+ }
684
+
685
+ return problems.length === 0 ? { ready: true } : { ready: false, problems };
686
+ }
687
+
688
+ /**
689
+ * Whether the Firebase CLI holds no credentials.
690
+ *
691
+ * `login:list --json` prints the stored refresh and access tokens along with
692
+ * the account list. Read the shape, count the entries, and never surface the
693
+ * output: not in a warning, not in a captured stream, not on the failure
694
+ * path. That is why this returns a boolean rather than the response.
695
+ */
696
+ function signedOut(probe: Probe): boolean {
697
+ const accounts = probe("firebase", ["login:list", "--json"]);
698
+ if (!accounts.ok) return false;
699
+ try {
700
+ const { result } = JSON.parse(accounts.stdout) as { result?: unknown };
701
+ return Array.isArray(result) && result.length === 0;
702
+ } catch {
703
+ // Unparseable is not evidence of anything.
704
+ return false;
705
+ }
706
+ }
707
+
708
+ /**
709
+ * Whether the scaffold would land inside a repository that already exists.
710
+ *
711
+ * Asked of the nearest ancestor that exists, because the destination does not
712
+ * yet. The point is not to change what {@link scaffoldGame} does, which already
713
+ * declines to nest a repository, but to stop the CLI asking a question whose
714
+ * answer cannot matter.
715
+ */
716
+ /**
717
+ * Why this destination cannot be scaffolded into, said as a whole message, or
718
+ * `undefined` when there is nothing in the way.
719
+ *
720
+ * A directory that exists and is *empty* is not in the way, since people make
721
+ * one out of habit, so it is removed and rewritten rather than refused over
722
+ * nothing. Anything with contents in it is refused, and a cloned repository is
723
+ * not an exception: `.git` is in the way like any other entry, and the message
724
+ * names it. Publishing into one would mean parking `.git`, renaming staging
725
+ * over the top and putting it back, with a failure in the middle able to
726
+ * orphan a remote, which is a lot of care to spend on a flow that has a
727
+ * one-line alternative.
728
+ *
729
+ * Refused, and deliberately not offered as a "remove it and continue?"
730
+ * question, which is where several scaffolders in this family go. This one
731
+ * writes ninety-odd files and runs `flutter create`; the cost of getting that
732
+ * answer wrong against a mistyped path has no upper bound, and `rm -rf` is one
733
+ * command that belongs to the person who can see what is in there. Every other
734
+ * question this CLI asks is reversible. This one would not be.
735
+ *
736
+ * Exported so the CLI can ask it before the first question rather than after
737
+ * the last one. {@link scaffoldGame} asks it again, for callers that are not
738
+ * the CLI.
739
+ */
740
+ export function destinationProblem(directory: string): string | undefined {
741
+ const root = resolve(directory);
742
+ if (!existsSync(root)) return undefined;
743
+
744
+ if (!statSync(root).isDirectory()) return `${root} already exists, and is a file.`;
745
+
746
+ const entries = readdirSync(root);
747
+ if (entries.length === 0) return undefined;
748
+
749
+ // Named, not counted. "not empty" invites an `ls`; the three that are
750
+ // actually there usually settle whether this was the wrong path or a
751
+ // directory that has already been scaffolded once.
752
+ const shown = entries.slice(0, 3).sort().join(", ");
753
+ const rest = entries.length > 3 ? `, and ${entries.length - 3} more` : "";
754
+ return `${root} already exists, and is not empty: it holds ${shown}${rest}.\n\nScaffold somewhere else, or clear it out yourself. This will not delete a directory you already had.`;
755
+ }
756
+
757
+ export function insideWorkTree(directory: string, probe: Probe = probeCommand): boolean {
758
+ let candidate = resolve(directory);
759
+ while (!existsSync(candidate)) {
760
+ const parent = dirname(candidate);
761
+ if (parent === candidate) return false;
762
+ candidate = parent;
763
+ }
764
+ return probe("git", ["-C", candidate, "rev-parse", "--is-inside-work-tree"]).ok;
765
+ }
766
+
767
+ /**
768
+ * Runs the Flutter client's `configure_firebase` against the generated app.
769
+ *
770
+ * The command owns everything about how Firebase is configured: which
771
+ * platforms, which CLIs must exist, whether anyone is signed in, and what to
772
+ * do when no project has been chosen yet. This only decides *when*: before the
773
+ * scaffold commit, so a configured project is committed configured.
774
+ *
775
+ * Not fatal. Every failure here (a missing `flutterfire`, no Google login, a
776
+ * cancelled picker) leaves a complete and usable project that is exactly what
777
+ * a scaffold without `--firebase` produces, so it warns with the command to
778
+ * re-run and lets the commit happen.
779
+ */
780
+ function configureFirebase(appRoot: string, project: boolean | string, run: Runner, reporter: Reporter): "configured" | "failed" {
781
+ // FlutterFire asks before overwriting `firebase_options.dart`, and the
782
+ // scaffold has just written one, a throwing placeholder whose entire
783
+ // purpose is to be replaced by this step. Answering that is a question with
784
+ // one right answer, so the placeholder is moved out of the way and the
785
+ // question never gets asked.
786
+ const placeholder = resolve(appRoot, "lib/firebase_options.dart");
787
+ const parked = `${placeholder}.placeholder`;
788
+ const parking = existsSync(placeholder);
789
+ if (parking) renameSync(placeholder, parked);
790
+
791
+ try {
792
+ // `dart run` compiles the package executable on first use and says so.
793
+ // Doing it against `--help`, which returns immediately, moves that noise
794
+ // into a step whose output can be captured; the real run cannot be, since
795
+ // FlutterFire prompts through it.
796
+ reporter.step("Preparing the Firebase configurator", () => run("dart", ["run", "eigen_firebase:configure_firebase", "--help"], appRoot));
797
+ // `--worker` is what makes this fill in `FIREBASE_PROJECT_ID` as well as
798
+ // the app's own values. An app-only repository omits it and gets the app
799
+ // half; a combined scaffold has the Worker one directory over.
800
+ reporter.handOver("Configuring Firebase", () => run("dart", ["run", "eigen_firebase:configure_firebase", "--worker", "../server", ...(typeof project === "string" ? ["--project", project] : [])], appRoot));
801
+ if (parking) rmSync(parked, { force: true });
802
+ return "configured";
803
+ } catch {
804
+ // Back only if nothing took its place. FlutterFire writes
805
+ // `firebase_options.dart` before the service worker configuration is
806
+ // derived from it, so a late failure leaves a real one that is worth more
807
+ // than the placeholder.
808
+ if (parking) {
809
+ if (existsSync(placeholder)) rmSync(parked, { force: true });
810
+ else renameSync(parked, placeholder);
811
+ }
812
+ reporter.warn("Could not configure Firebase. The project is complete. Run `firebase:configure` yourself, then commit what it writes.");
813
+ return "failed";
814
+ }
815
+ }
816
+
817
+ /**
818
+ * Commits the scaffold, so the first `git diff` is the first game change.
819
+ *
820
+ * This matters more here than in a scaffolder that only writes source. The
821
+ * bootstrap runs `flutter_launcher_icons` and `flutter_native_splash`, which
822
+ * write generated-but-committed files across `android/`, `web/` and
823
+ * `assets/`; without a baseline the first branding change is indistinguishable
824
+ * from the ninety files the scaffolder happened to produce.
825
+ *
826
+ * Nothing here is fatal. The project is complete and valid before this runs,
827
+ * so a missing `git`, or the unconfigured `user.email` that a fresh CI image
828
+ * has, warns and leaves the tree alone rather than discarding a scaffold that
829
+ * took two minutes of Flutter and pub to produce.
830
+ */
831
+ function initialiseRepository(root: string, name: string, run: Runner, reporter: Reporter): GitOutcome {
832
+ // Scaffolding inside an existing checkout (a monorepo, or a repository
833
+ // created ahead of time) is a legitimate thing to do, and a nested
834
+ // repository there is silent breakage: the outer `git add` records a gitlink
835
+ // and the app's files never leave the machine. Asked directly rather than
836
+ // through `run`, because it is a question, not a step.
837
+ try {
838
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: root, stdio: "ignore" });
839
+ return "existing";
840
+ } catch {
841
+ // Not inside a work tree, which is the case this function is for.
842
+ }
843
+
844
+ try {
845
+ run("git", ["init", "--quiet"], root);
846
+ run("git", ["add", "--all"], root);
847
+ } catch {
848
+ reporter.warn("Could not initialise a git repository. The project is complete. Run `git init` yourself when ready.");
849
+ return "failed";
850
+ }
851
+
852
+ try {
853
+ run("git", ["commit", "--quiet", "--message", `Scaffold ${name}`], root);
854
+ } catch {
855
+ reporter.warn(`Initialised a repository and staged the scaffold, but could not commit it. Set \`user.name\` and \`user.email\`, then \`git commit -m "Scaffold ${name}"\`.`);
856
+ return "failed";
857
+ }
858
+
859
+ return "committed";
860
+ }
861
+
862
+ export function scaffoldGame(options: ScaffoldOptions): ScaffoldResult {
863
+ const root = resolve(options.directory);
864
+ const name = gameSlug(basename(root));
865
+ const manager = options.packageManager ?? detectPackageManager() ?? "pnpm";
866
+ const org = options.org?.trim() || "com.example";
867
+ // Fastlane's `Appfile` names the same package the build actually produces,
868
+ // and the CLI shows the same one before asking.
869
+ const packageId = applicationId(root, org);
870
+ const bootstrap = options.bootstrap ?? true;
871
+ const reporter = options.reporter ?? plainReporter;
872
+ const run = options.run ?? capturingRunner(reporter);
873
+
874
+ const occupied = destinationProblem(root);
875
+ if (occupied !== undefined) throw new Error(occupied);
876
+ // Empty, or `destinationProblem` would have said so. Removed rather than
877
+ // scaffolded into, because the rename below publishes the staging directory
878
+ // *as* this path and cannot do that while something is standing there.
879
+ if (existsSync(root)) rmdirSync(root);
880
+
881
+ mkdirSync(dirname(root), { recursive: true });
882
+
883
+ // Build beside the destination and publish it with one rename. Failed
884
+ // subprocesses never leave a half-scaffolded project at the requested path.
885
+ const stagingRoot = mkdtempSync(resolve(dirname(root), `.${basename(root)}-`));
886
+ try {
887
+ const serverRoot = resolve(stagingRoot, "server");
888
+ const appRoot = resolve(stagingRoot, "app");
889
+
890
+ renderTree(resolve(templatesRoot, "worker"), serverRoot, name, manager, packageId);
891
+ renderTree(resolve(templatesRoot, "project"), stagingRoot, name, manager, packageId);
892
+ // Opt-in. release.yml wants an upload keystore and a Play service account
893
+ // that a brand-new project does not have, so generating it by default
894
+ // means a red X on main from the first push until someone sets both up.
895
+ // `create-eigen-game add ci` writes the same files whenever it is wanted.
896
+ if (options.ci) renderTree(resolve(templatesRoot, "ci"), stagingRoot, name, manager, packageId);
897
+
898
+ if (bootstrap) {
899
+ reporter.step("Creating the Flutter app", () => {
900
+ run("flutter", ["create", "--empty", "--platforms", "android,web", "--project-name", dartName(name), "--org", org, appRoot], stagingRoot);
901
+ enableAndroidCoreLibraryDesugaring(appRoot);
902
+ enableAndroidReleaseSigning(appRoot);
903
+ });
904
+ } else {
905
+ mkdirSync(appRoot, { recursive: true });
906
+ }
907
+ renderTree(resolve(templatesRoot, "app-overlay"), appRoot, name, manager, packageId);
908
+
909
+ if (bootstrap) {
910
+ reporter.step("Adding the Flutter packages", () => {
911
+ configureLauncherIconsAndSplash(appRoot);
912
+ run("flutter", ["pub", "add", `eigen_flutter@${flutterClientVersion}`, `eigen_shell@${flutterShellVersion}`, `eigen_firebase@${firebaseAdapterVersion}`, "firebase_core@^4.9.0"], appRoot);
913
+ run("flutter", ["pub", "add", `dev:eigen_codegen@${dartCodegenVersion}`, "dev:flutter_launcher_icons", "dev:flutter_native_splash"], appRoot);
914
+ });
915
+ // Actually apply the icons rather than only configuring them. Both tools
916
+ // write generated files (mipmaps, `web/icons/`, splash drawables and
917
+ // styles) that are committed, not built; leaving them unrun would ship
918
+ // Flutter's own blue logo until a game author happened to notice.
919
+ reporter.step("Generating the icons and splash", () => {
920
+ run("dart", ["run", "flutter_launcher_icons"], appRoot);
921
+ run("dart", ["run", "flutter_native_splash:create"], appRoot);
922
+ });
923
+ const [install, installArgs] = packageCommand(manager, "install");
924
+ const [typegen, typegenArgs] = packageCommand(manager, "cf-typegen");
925
+ reporter.step("Installing the Worker packages", () => {
926
+ run(install, installArgs, serverRoot);
927
+ // `worker-configuration.d.ts` is committed, and its header stamps the
928
+ // workerd version that produced the runtime types. `wrangler` floats
929
+ // on a caret and Cloudflare ships workerd about weekly, so the copy in
930
+ // `templates/worker` is stale for every scaffold made more than a few
931
+ // days after it was last regenerated, and `wrangler dev` opens by
932
+ // saying the types might be out of date. Regenerating against the
933
+ // wrangler that was just installed, rather than the one this package
934
+ // was built with, is what makes that true. It has to follow the server
935
+ // install for the same reason it is worth doing: it reads the wrangler
936
+ // that install resolved.
937
+ run(typegen, typegenArgs, serverRoot);
938
+ // The root holds Biome, which lints and formats both the Worker and
939
+ // the repository's own JSON. Installed after the server so a failure
940
+ // here costs the cheaper of the two.
941
+ run(install, installArgs, stagingRoot);
942
+ });
943
+ const [contract, contractArgs] = packageCommand(manager, "contract");
944
+ reporter.step("Generating the contract and payloads", () => {
945
+ run(contract, contractArgs, serverRoot);
946
+ run("dart", ["run", "eigen_codegen:generate_payloads", "--contract", "../server/game-contract.json", "--output", "lib/game/generated/payloads.dart", "--fixtures-output", "test/fixtures"], appRoot);
947
+ });
948
+ }
949
+
950
+ renameSync(stagingRoot, root);
951
+ } catch (error) {
952
+ rmSync(stagingRoot, { recursive: true, force: true });
953
+ throw error;
954
+ }
955
+
956
+ // Both steps below are deliberately outside the staging guard: the project
957
+ // is published by this point, and neither a Firebase project that could not
958
+ // be configured nor a repository that failed to initialise is a reason to
959
+ // delete two minutes of Flutter and pub.
960
+ //
961
+ // Firebase first, so what it writes (`firebase.json`,
962
+ // `android/app/google-services.json`, the real `firebase_options.dart` and
963
+ // `web/firebase-config.js` in place of the throwing placeholders, and
964
+ // FlutterFire's two Gradle edits) lands in the scaffold commit rather than
965
+ // arriving as the project's first diff.
966
+ const firebase = options.firebase ? configureFirebase(resolve(root, "app"), options.firebase, run, reporter) : "skipped";
967
+ // What that step settled, so the summary can name what it did not.
968
+ // `configure_firebase` does the writing, including into the Worker's
969
+ // wrangler.jsonc, which is what `--worker` above is for.
970
+ const link = firebase === "configured" ? readFirebaseLink(root) : undefined;
971
+ const git = (options.git ?? bootstrap) ? initialiseRepository(root, name, run, reporter) : "skipped";
972
+
973
+ return { root, name, git, firebase, link };
974
+ }