Skip to content
This repository was archived by the owner on Sep 10, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/fill-in-firebase-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"create-eigen-game": patch
---

A scaffold now fills in the deployment values it already knows, instead of leaving four empty strings for the reader to chase.

- `app-config.json` ships with `API_BASE_URL` pointing at `http://localhost:8787`, so `pnpm dev` and `flutter run` agree with nothing edited.
- `FIREBASE_PROJECT_ID` is written into `server/wrangler.jsonc` from the project FlutterFire recorded, and `GOOGLE_WEB_CLIENT_ID` into `app-config.json` from the OAuth client Firebase created for it. Left empty, as `FIREBASE_PROJECT_ID` was before, every authenticated request answers 500.

The writing belongs to `eigen_flutter`'s `configure_firebase`, which this now invokes as `--worker ../server`, and which the generated `firebase:configure` script passes too. One implementation, so a project configured by re-running that command ends up exactly where a freshly scaffolded one does. It needs an `eigen_flutter` new enough to accept `--worker`.

What is left is what genuinely cannot be derived, and the closing summary lists exactly that: the Google sign-in provider when it is off, since the OAuth client does not exist until somebody enables it in the console, and `FIREBASE_VAPID_KEY`, which is a Web Push certificate the Firebase CLI does not serve. `WEB_APP_ORIGIN` was already defaulted to the port the scaffold trusts.
20 changes: 18 additions & 2 deletions packages/create-eigen-game/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { appendFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync
import { basename, dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { ENGINE_PACKAGE, engineRange } from "./engine-range.js";
import { type FirebaseLink, readFirebaseLink } from "./link-firebase.js";

export type { FirebaseLink } from "./link-firebase.js";

export type PackageManager = "npm" | "pnpm";

Expand Down Expand Up @@ -144,6 +147,12 @@ export interface ScaffoldResult {
* step or spell it out. `failed` has already warned.
*/
firebase: "configured" | "failed" | "skipped";
/**
* What the Firebase step made knowable and this scaffold therefore filled
* in. Absent unless `firebase` is `configured`, since there is nothing to
* read otherwise.
*/
link?: FirebaseLink;
}

const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
Expand Down Expand Up @@ -711,7 +720,10 @@ function configureFirebase(appRoot: string, project: boolean | string, run: Runn
// into a step whose output can be captured; the real run cannot be, since
// FlutterFire prompts through it.
reporter.step("Preparing the Firebase configurator", () => run("dart", ["run", "eigen_flutter:configure_firebase", "--help"], appRoot));
reporter.handOver("Configuring Firebase", () => run("dart", ["run", "eigen_flutter:configure_firebase", ...(typeof project === "string" ? ["--project", project] : [])], appRoot));
// `--worker` is what makes this fill in `FIREBASE_PROJECT_ID` as well as
// the app's own values. An app-only repository omits it and gets the app
// half; a combined scaffold has the Worker one directory over.
reporter.handOver("Configuring Firebase", () => run("dart", ["run", "eigen_flutter:configure_firebase", "--worker", "../server", ...(typeof project === "string" ? ["--project", project] : [])], appRoot));
if (parking) rmSync(parked, { force: true });
return "configured";
} catch {
Expand Down Expand Up @@ -878,7 +890,11 @@ export function scaffoldGame(options: ScaffoldOptions): ScaffoldResult {
// FlutterFire's two Gradle edits) lands in the scaffold commit rather than
// arriving as the project's first diff.
const firebase = options.firebase ? configureFirebase(resolve(root, "app"), options.firebase, run, reporter) : "skipped";
// What that step settled, so the summary can name what it did not.
// `configure_firebase` does the writing, including into the Worker's
// wrangler.jsonc, which is what `--worker` above is for.
const link = firebase === "configured" ? readFirebaseLink(root) : undefined;
const git = (options.git ?? bootstrap) ? initialiseRepository(root, name, run, reporter) : "skipped";

return { root, name, git, firebase };
return { root, name, git, firebase, link };
}
70 changes: 70 additions & 0 deletions packages/create-eigen-game/src/link-firebase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Reads the two deployment values that exist only once a Firebase project
* does, so the closing summary can name what is still missing.
*
* Reading only. `configure_firebase` writes them, into `wrangler.jsonc` and
* `app-config.json`, because it is also what a project runs later when
* reconfiguring, and one implementation is what keeps the scaffold-time path
* and the re-run path identical. This module exists so the summary can tell
* the difference between "filled in" and "still owed", not to do it again.
*
* Its own module for the same reason `summary.ts` is: `index.ts` runs a
* scaffold, and this is worth asserting without one. The package `exports` map
* does not expose it.
*/
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";

/** What the Firebase step managed to settle, and what it left for the console. */
export interface FirebaseLink {
/** The project now in `wrangler.jsonc`, or null when FlutterFire recorded none. */
projectId: string | null;
/** The OAuth web client now in `app-config.json`, or null when Google sign-in is not enabled yet. */
googleWebClientId: string | null;
}

function readJson(path: string): unknown {
if (!existsSync(path)) return undefined;
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch {
// A file this step only reads is never worth failing a finished scaffold
// for. The value counts as unset, and the summary says so.
return undefined;
}
}

/**
* The project FlutterFire recorded for the Dart output this app uses.
*
* `firebase.json` is FlutterFire's own record rather than something this
* scaffold writes, so it is read defensively: a shape that does not match is
* treated as "not configured", which is the same answer as a run that never
* happened.
*/
export function configuredProject(appRoot: string): string | null {
const record = readJson(resolve(appRoot, "firebase.json")) as { flutter?: { platforms?: { dart?: Record<string, { projectId?: unknown }> } } } | undefined;
const projectId = record?.flutter?.platforms?.dart?.["lib/firebase_options.dart"]?.projectId;
return typeof projectId === "string" && projectId !== "" ? projectId : null;
}

/**
* The OAuth web client id that `configure_firebase` copied into
* `app-config.json`, when there was one to copy.
*
* Empty is an ordinary outcome rather than a failure. Firebase creates that
* client when the Google sign-in provider is enabled, which is a console
* action no CLI can perform, so a project that has never had it enabled has
* nothing to offer here.
*/
export function configuredWebClientId(appRoot: string): string | null {
const config = readJson(resolve(appRoot, "app-config.json")) as Record<string, unknown> | undefined;
const value = config?.GOOGLE_WEB_CLIENT_ID;
return typeof value === "string" && value !== "" ? value : null;
}

/** What the Firebase step settled, for the summary to report. */
export function readFirebaseLink(root: string): FirebaseLink {
const appRoot = resolve(root, "app");
return { projectId: configuredProject(appRoot), googleWebClientId: configuredWebClientId(appRoot) };
}
16 changes: 16 additions & 0 deletions packages/create-eigen-game/src/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ export function summarise(result: ScaffoldResult, manager: PackageManager, workf
// `failed` has warned already, and the step reappears below as the next
// thing to run, which is the whole of the advice.
if (result.firebase === "configured") status.push(result.git === "committed" ? "Firebase is configured, and everything it generated is in that commit" : "Firebase is configured");
if (result.link?.projectId != null) status.push(`FIREBASE_PROJECT_ID is set to ${result.link.projectId}, and app-config.json points at the local Worker`);

// What is left, and only what is left. Everything here comes from the
// Firebase console rather than from any CLI, which is why a scaffold that
// filled in everything it could still ends with a list: the web target
// refuses to start without a VAPID key, and there is no OAuth client to copy
// until somebody enables the provider that creates it.
const remaining: [string, string][] = [];
if (result.link !== undefined) {
if (result.link.googleWebClientId === null) {
remaining.push(["Enable Google sign-in", "Console > Authentication > Sign-in method"]);
remaining.push(["GOOGLE_WEB_CLIENT_ID", "that same page, under Web SDK configuration"]);
}
remaining.push(["FIREBASE_VAPID_KEY", "Console > Project settings > Cloud Messaging > Web configuration"]);
}

const next = [
"Write the game",
Expand All @@ -61,6 +76,7 @@ export function summarise(result: ScaffoldResult, manager: PackageManager, workf
// once.
...(result.firebase === "configured" ? [] : [[`${run} firebase:configure`, "connect Firebase before the app runs"] as [string, string]]),
]),
...(remaining.length === 0 ? [] : ["", "Before the app runs, from the Firebase console", ...columns(remaining), "", " The values go in app/app-config.json. Both are public, not secrets."]),
].join("\n");

const footnotes = ["Docs: https://eigeninteractive.com"];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"API_BASE_URL": "",
"API_BASE_URL": "http://localhost:8787",
"APP_HOST": "",
"GOOGLE_WEB_CLIENT_ID": "",
"FIREBASE_VAPID_KEY": ""
Expand Down
18 changes: 15 additions & 3 deletions packages/create-eigen-game/templates/project/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,21 @@ to satisfy it as shipped.

The initial scaffold already generates Dart payloads from the Worker contract.
Implement the client-side legality, preview and presentation rules under
`app/lib/game/`, then configure Firebase and fill the public build-time values
in `app/app-config.json`. They are Dart compilation environment declarations,
not secrets; private credentials belong only in the Worker.
`app/lib/game/`.

`app/app-config.json` holds the public build-time values. They are Dart
compilation environment declarations, not secrets; private credentials belong
only in the Worker. Scaffolding fills in `API_BASE_URL` for local development
and, once Firebase is configured, `GOOGLE_WEB_CLIENT_ID`. Two are left:

- `FIREBASE_VAPID_KEY`, from Firebase Console > Project settings > Cloud
Messaging > Web configuration. The web app will not start without it.
- `APP_HOST`, your hostname at deploy time, which turns on invite and replay
sharing.

An empty `GOOGLE_WEB_CLIENT_ID` means the Google sign-in provider was not
enabled when the project was configured. Turn it on under Authentication >
Sign-in method, then copy the Web SDK configuration client id.

See `app/lib/game/README.md` for the regeneration command.

Expand Down
2 changes: 1 addition & 1 deletion packages/create-eigen-game/templates/project/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"contract": "cd server && {{PACKAGE_MANAGER}} run contract && cd ../app && dart run eigen_flutter:generate_payloads --contract ../server/game-contract.json --output lib/game/generated/payloads.dart --fixtures-output test/fixtures",
"contract:check": "cd server && {{PACKAGE_MANAGER}} run contract:check && cd ../app && dart run eigen_flutter:generate_payloads --contract ../server/game-contract.json --output lib/game/generated/payloads.dart --fixtures-output test/fixtures --check",
"deploy": "{{PACKAGE_MANAGER}} run build:web && cd server && {{PACKAGE_MANAGER}} run deploy",
"firebase:configure": "cd app && dart run eigen_flutter:configure_firebase",
"firebase:configure": "cd app && dart run eigen_flutter:configure_firebase --worker ../server",
"format": "biome check --write .",
"lint": "biome check ."
},
Expand Down
72 changes: 72 additions & 0 deletions packages/create-eigen-game/test/link-firebase.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { configuredProject, configuredWebClientId, readFirebaseLink } from "../src/link-firebase.js";

let root: string;

/** A scaffold as it stands the moment `configure_firebase` returns. */
function project(options: { projectId?: string; clientId?: string } = {}): void {
mkdirSync(resolve(root, "app"), { recursive: true });
writeFileSync(resolve(root, "app/app-config.json"), `${JSON.stringify({ API_BASE_URL: "http://localhost:8787", APP_HOST: "", GOOGLE_WEB_CLIENT_ID: options.clientId ?? "", FIREBASE_VAPID_KEY: "" }, null, 2)}\n`);
if (options.projectId !== undefined) {
writeFileSync(resolve(root, "app/firebase.json"), JSON.stringify({ flutter: { platforms: { dart: { "lib/firebase_options.dart": { projectId: options.projectId, configurations: { web: "1:1:web:1" } } } } } }));
}
}

beforeEach(() => {
root = mkdtempSync(resolve(tmpdir(), "eigen-link-"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});

describe("readFirebaseLink", () => {
it("reports what the Firebase step settled", () => {
project({ projectId: "go-fish-1a2b3", clientId: "1-web.apps.googleusercontent.com" });

expect(readFirebaseLink(root)).toEqual({ projectId: "go-fish-1a2b3", googleWebClientId: "1-web.apps.googleusercontent.com" });
});

it("reads an empty client id as nothing to report", () => {
// What a project whose Google sign-in provider was never enabled looks
// like: configured, but with no OAuth client for `configure_firebase` to
// have copied.
project({ projectId: "go-fish-1a2b3" });

expect(readFirebaseLink(root)).toEqual({ projectId: "go-fish-1a2b3", googleWebClientId: null });
});

it("reports nothing when Firebase wrote nothing to read", () => {
project();

expect(readFirebaseLink(root)).toEqual({ projectId: null, googleWebClientId: null });
});

it("treats an unreadable file as an absent one", () => {
project({ projectId: "go-fish-1a2b3" });
writeFileSync(resolve(root, "app/firebase.json"), "{ not json");

// Nothing here is worth failing a scaffold that has already published a
// complete project for.
expect(() => readFirebaseLink(root)).not.toThrow();
expect(readFirebaseLink(root).projectId).toBeNull();
});
});

describe("configuredProject", () => {
it("ignores a firebase.json whose shape is not the one FlutterFire writes", () => {
project();
writeFileSync(resolve(root, "app/firebase.json"), JSON.stringify({ flutter: { platforms: { android: { default: { projectId: "go-fish" } } } } }));

// An Android entry is not the Dart output this app reads its options from.
expect(configuredProject(resolve(root, "app"))).toBeNull();
});
});

describe("configuredWebClientId", () => {
it("is null when there is no app-config.json at all", () => {
expect(configuredWebClientId(resolve(root, "app"))).toBeNull();
});
});
14 changes: 10 additions & 4 deletions packages/create-eigen-game/test/scaffold.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,12 @@ describe("scaffoldGame", () => {
expect(appMain).toContain("DefaultFirebaseOptions.currentPlatform");
expect(appMain).not.toContain("REPLACE_ME");
const appConfig = readFileSync(resolve(root, "app/app-config.json"), "utf8");
expect(appConfig).toContain('"API_BASE_URL": ""');
// Pointed at the local Worker, so `pnpm dev` and `flutter run` agree with
// no editing. The two that stay empty cannot be defaulted: one is the
// deployed hostname, the other comes from the Firebase console.
expect(appConfig).toContain('"API_BASE_URL": "http://localhost:8787"');
expect(appConfig).toContain('"APP_HOST": ""');
expect(appConfig).toContain('"FIREBASE_VAPID_KEY": ""');
expect(existsSync(resolve(root, "app/web-config.json"))).toBe(false);
expect(readFileSync(resolve(root, "README.md"), "utf8")).toContain("npm run contract");
const rootGitignore = readFileSync(resolve(root, ".gitignore"), "utf8");
Expand Down Expand Up @@ -165,7 +169,9 @@ describe("scaffoldGame", () => {
expect(rootManifest.scripts["build:web"]).toContain("--output ../server/public");
expect(rootManifest.scripts["build:web"]).toContain("--dart-define-from-file=app-config.json");
expect(rootManifest.scripts.deploy).toContain("run build:web");
expect(rootManifest.scripts["firebase:configure"]).toBe("cd app && dart run eigen_flutter:configure_firebase");
// `--worker` is what lets a re-run fill in the Worker's FIREBASE_PROJECT_ID,
// so the command a project keeps does exactly what scaffolding did.
expect(rootManifest.scripts["firebase:configure"]).toBe("cd app && dart run eigen_flutter:configure_firebase --worker ../server");
});

it("uses ecosystem CLIs to bootstrap both halves", () => {
Expand Down Expand Up @@ -615,7 +621,7 @@ describe("repository initialisation", () => {
// Not the `--help` warm-up ahead of it, which exists only to move `dart
// run`'s "Building package executable" out of the interactive step.
const configure = calls.findIndex(([command, args]) => command === "dart" && !args.includes("--help"));
expect(calls[configure]).toEqual(["dart", ["run", "eigen_flutter:configure_firebase", "--project", "example-project"], resolve(root, "app")]);
expect(calls[configure]).toEqual(["dart", ["run", "eigen_flutter:configure_firebase", "--worker", "../server", "--project", "example-project"], resolve(root, "app")]);
// The whole reason for doing this here: `firebase.json`,
// `google-services.json`, the generated `firebase_options.dart` and
// FlutterFire's two Gradle edits are in the scaffold commit rather than
Expand Down Expand Up @@ -708,7 +714,7 @@ describe("repository initialisation", () => {

// Passing no `--project` is what makes FlutterFire prompt, which is also
// the only route to creating a project from here.
expect(calls).toContainEqual(["run", "eigen_flutter:configure_firebase"]);
expect(calls).toContainEqual(["run", "eigen_flutter:configure_firebase", "--worker", "../server"]);
});

it("keeps a scaffold that Firebase could not be configured for", () => {
Expand Down
25 changes: 25 additions & 0 deletions packages/create-eigen-game/test/summary.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,31 @@ describe("summarise", () => {
expect(summarise(result("skipped", "configured"), "pnpm", false).status).toContain("Firebase is configured");
});

it("names what the scaffold filled in, and only what the console still owes", () => {
const linked = { ...result("committed", "configured"), link: { projectId: "go-fish-1a2b3", googleWebClientId: "1-web.apps.googleusercontent.com" } };
const { status, next } = summarise(linked, "pnpm", false);

expect(status.join("\n")).toContain("FIREBASE_PROJECT_ID is set to go-fish-1a2b3");
// The one value no CLI can produce, so it survives every path.
expect(next).toContain("FIREBASE_VAPID_KEY");
// Found, so there is nothing to enable and nothing to paste.
expect(next).not.toContain("Enable Google sign-in");
expect(next).not.toContain("GOOGLE_WEB_CLIENT_ID");
});

it("asks for the sign-in provider when Firebase had not created its client yet", () => {
const unlinked = { ...result("committed", "configured"), link: { projectId: "go-fish-1a2b3", googleWebClientId: null } };

expect(summarise(unlinked, "pnpm", false).next).toContain("Enable Google sign-in");
expect(summarise(unlinked, "pnpm", false).next).toContain("GOOGLE_WEB_CLIENT_ID");
});

it("says nothing about console values when Firebase never ran", () => {
// Nothing was read, so nothing is known to be missing, and the step to run
// is `firebase:configure` rather than a list of fields.
expect(summarise(result("committed"), "pnpm", false).next).not.toContain("FIREBASE_VAPID_KEY");
});

it("mentions the missing workflows only when they were not emitted", () => {
expect(summarise(result("committed"), "pnpm", false).footnotes.join("\n")).toContain("create-eigen-game add workflows");
expect(summarise(result("committed"), "pnpm", true).footnotes.join("\n")).not.toContain("add workflows");
Expand Down