From 96438c450d481db74f40fed4e32f9690a7938294 Mon Sep 17 00:00:00 2001 From: Seenu K Date: Sun, 9 Aug 2026 23:24:08 +0530 Subject: [PATCH] Auto fill Vars in Scaffold --- .changeset/fill-in-firebase-values.md | 12 ++++ packages/create-eigen-game/src/index.ts | 20 +++++- .../create-eigen-game/src/link-firebase.ts | 70 ++++++++++++++++++ packages/create-eigen-game/src/summary.ts | 16 +++++ .../app-overlay/app-config.json.template | 2 +- .../templates/project/README.md | 18 ++++- .../templates/project/package.json | 2 +- .../test/link-firebase.spec.ts | 72 +++++++++++++++++++ .../create-eigen-game/test/scaffold.spec.ts | 14 ++-- .../create-eigen-game/test/summary.spec.ts | 25 +++++++ 10 files changed, 240 insertions(+), 11 deletions(-) create mode 100644 .changeset/fill-in-firebase-values.md create mode 100644 packages/create-eigen-game/src/link-firebase.ts create mode 100644 packages/create-eigen-game/test/link-firebase.spec.ts diff --git a/.changeset/fill-in-firebase-values.md b/.changeset/fill-in-firebase-values.md new file mode 100644 index 0000000..99d69f0 --- /dev/null +++ b/.changeset/fill-in-firebase-values.md @@ -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. diff --git a/packages/create-eigen-game/src/index.ts b/packages/create-eigen-game/src/index.ts index 615f996..7a7ae5d 100644 --- a/packages/create-eigen-game/src/index.ts +++ b/packages/create-eigen-game/src/index.ts @@ -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"; @@ -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)), ".."); @@ -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 { @@ -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 }; } diff --git a/packages/create-eigen-game/src/link-firebase.ts b/packages/create-eigen-game/src/link-firebase.ts new file mode 100644 index 0000000..d8714cd --- /dev/null +++ b/packages/create-eigen-game/src/link-firebase.ts @@ -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 } } } | 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 | 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) }; +} diff --git a/packages/create-eigen-game/src/summary.ts b/packages/create-eigen-game/src/summary.ts index 7d599a5..5910cf1 100644 --- a/packages/create-eigen-game/src/summary.ts +++ b/packages/create-eigen-game/src/summary.ts @@ -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", @@ -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"]; diff --git a/packages/create-eigen-game/templates/app-overlay/app-config.json.template b/packages/create-eigen-game/templates/app-overlay/app-config.json.template index 4045187..672a823 100644 --- a/packages/create-eigen-game/templates/app-overlay/app-config.json.template +++ b/packages/create-eigen-game/templates/app-overlay/app-config.json.template @@ -1,5 +1,5 @@ { - "API_BASE_URL": "", + "API_BASE_URL": "http://localhost:8787", "APP_HOST": "", "GOOGLE_WEB_CLIENT_ID": "", "FIREBASE_VAPID_KEY": "" diff --git a/packages/create-eigen-game/templates/project/README.md b/packages/create-eigen-game/templates/project/README.md index 5c99b17..ef07284 100644 --- a/packages/create-eigen-game/templates/project/README.md +++ b/packages/create-eigen-game/templates/project/README.md @@ -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. diff --git a/packages/create-eigen-game/templates/project/package.json b/packages/create-eigen-game/templates/project/package.json index 1db92d3..8a472f3 100644 --- a/packages/create-eigen-game/templates/project/package.json +++ b/packages/create-eigen-game/templates/project/package.json @@ -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 ." }, diff --git a/packages/create-eigen-game/test/link-firebase.spec.ts b/packages/create-eigen-game/test/link-firebase.spec.ts new file mode 100644 index 0000000..9e89cf8 --- /dev/null +++ b/packages/create-eigen-game/test/link-firebase.spec.ts @@ -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(); + }); +}); diff --git a/packages/create-eigen-game/test/scaffold.spec.ts b/packages/create-eigen-game/test/scaffold.spec.ts index ffef22f..5d8c4b4 100644 --- a/packages/create-eigen-game/test/scaffold.spec.ts +++ b/packages/create-eigen-game/test/scaffold.spec.ts @@ -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"); @@ -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", () => { @@ -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 @@ -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", () => { diff --git a/packages/create-eigen-game/test/summary.spec.ts b/packages/create-eigen-game/test/summary.spec.ts index 766ec03..993c093 100644 --- a/packages/create-eigen-game/test/summary.spec.ts +++ b/packages/create-eigen-game/test/summary.spec.ts @@ -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");