Skip to content

Commit ecd2947

Browse files
bloveclaude
andauthored
feat(examples-chat): App mode — itinerary map cockpit on the langgraph canonical demo (#772)
* docs(spec): App mode on the langchain canonical demo — itinerary cockpit Design for porting the ag-ui App-mode map/itinerary cockpit to examples/chat (langgraph), single shared agent. Key divergence: itinerary lives in graph state (per-thread checkpoint) alongside messages, synced client-authoritatively via input.state + updateState; no localStorage. Local-first; deploy deferred. * docs(spec): drop seed data — empty start, agent builds the plan No seed/seeding: a fresh thread starts empty; users prompt the agent (via welcome suggestions) to generate a trip plan. Adds a first-class Planner behavior & prompt-tuning section (recommend + populate app state via client tools), empty-state CTA, and empty-thread live gate. * docs(plan): implementation plan — App mode on the langchain canonical demo 16 tasks across 5 phases (spike → backend state/planner → frontend port → shell/wiring → verify). TDD, subagent-executable, local-first. * feat(chat-graph): spike client-tool binding + client-tool-aware routing (#itinerary) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(chat-graph): add itinerary Stop state channel Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(chat-graph): planner framing + itinerary context injection when client tools present * feat(examples-chat): wire Google Maps key via inject-env (local only) Mirrors examples/ag-ui/angular: inject-env.mjs reads GOOGLE_MAPS_API_KEY/ GOOGLE_MAPS_MAP_ID from the repo-root .env and writes a gitignored generated-keys.local.ts, swapped in via project.json fileReplacements. Ships empty in CI; local/preview builds get the real value. * feat(examples-chat): port map-bounds, geocoding, google-maps-loader * feat(examples-chat): port ItineraryStore — empty start, value hydration, no localStorage * feat(examples-chat): port itinerary panel/map/day-card/clear-day UI (langgraph agent) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(examples-chat): port itinerary client tools (drop get_itinerary, use demo agent) Copies examples/ag-ui/angular/src/app/client-tools.ts into examples/chat/angular, removing the get_itinerary tool, ITINERARY_AGENT ref, and ItineraryState interface since the chat demo's DEMO_AGENT_REF/DemoState already covers agent typing and the model now sees itinerary state via injected graph-state context instead of a read round-trip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(examples-chat): sync itinerary — submit state + value hydration + SDK checkpoint push * fix(examples-chat): import vitest globals in client-tools.spec (build compiles specs) tsconfig.app.json includes src/**/*.ts, so spec files are type-checked in the AOT build; the T8 client-tools.spec relied on ambient describe/it/expect and broke the build. Import them explicitly, matching sibling specs. * feat(examples-chat): App-mode toggle + map-compatible routing (embed↔sidebar coercion) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(examples-chat): App-mode cockpit layout (map bg + itinerary overlay + sidenav→drawer) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(examples-chat): wire client tools + cockpit into modes; context-aware welcome suggestions Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(examples-chat): drop dangling get_itinerary reference in client-tool error strings get_itinerary was removed (the model sees the itinerary via injected graph context); the move_stop/reorder_stop 'not found' errors no longer point at a nonexistent tool. * test(examples-chat): e2e App-mode cockpit (empty state + embed coercion) * fix(examples-chat): reliably persist itinerary to checkpoint (retry mid-run 409) The live gate caught that reload lost the trip: the client-tool resume loop keeps the agent loading for the whole plan, so the run-gated updateState push never fired with the final itinerary and the checkpoint kept the empty list sent at first submit. Drop the isLoading gate and retry the updateState on the 409 a mid-run write returns until the run settles, so the final itinerary always lands. Also guard hydration so a behind/empty server snapshot can't wipe a populated local working copy mid-plan. Verified live: plan → reload restores. * fix(examples-chat): retry checkpoint push only on 409, capped (review) Address final-review findings: the retry now discriminates — only a mid-run 409 (conflict) is retried, and only up to MAX_PUSH_RETRIES, so a persistently failing thread (404/auth/500) can't spin an unbounded background loop. Track the retry timer, and correct the now-stale run-gated/500ms comment. Adds isConflict unit tests. * feat(examples-chat): dark map via colorScheme, drop cloud-style dependency The map's dark theme was a cloud-based map style bound to a specific GCP Map ID — brittle (lives in the Console, tied to one project/billing acct, invisible to git). Replace it with an in-code colorScheme: DARK on the vector map, which keeps AdvancedMarkers, works with any mapId (incl. the DEMO_MAP_ID fallback), and is version-controlled + project-independent. Kept as a plain string so the field carries no runtime google.maps reference (map-canvas builds under jsdom in the shell specs). * feat(examples-chat): map light/dark follows the app color scheme The map now tracks the app's light/dark toggle (the <html data-color-scheme> the shell reflects), not the gen-UI mode. mapColorScheme is derived from a MutationObserver-backed signal; since Google's colorScheme is init-only, the template remounts <google-map> via a scheme-keyed @for when it flips. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 26a517c commit ecd2947

45 files changed

Lines changed: 4053 additions & 66 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,5 @@ libs/licensing/src/lib/license-public-key.generated.ts
6565

6666
# AG-UI example generated API keys (injected from .env at build time)
6767
examples/ag-ui/angular/src/environments/generated-keys.local.ts
68+
# Chat example generated API keys (injected from .env at build time)
69+
examples/chat/angular/src/environments/generated-keys.local.ts

docs/superpowers/plans/2026-07-06-langgraph-canonical-app-mode-itinerary.md

Lines changed: 1049 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# App Mode on the LangChain Canonical Demo — Itinerary Cockpit (Design)
2+
3+
**Date:** 2026-07-06
4+
**Status:** Approved design, pending spec review → implementation plan
5+
**Owner:** Brian Love
6+
**Branch:** `feat/langgraph-app-mode-itinerary`
7+
8+
## Goal
9+
10+
Bring the App-mode map cockpit — the travel-itinerary trip planner currently exclusive to `examples/ag-ui` — to the **langchain canonical demo** (`examples/chat`, backed by `@threadplane/langgraph`). This proves the langgraph adapter reaches App-mode feature parity with ag-ui, using a single shared agent.
11+
12+
The one deliberate architectural divergence from ag-ui: **the itinerary lives in the langgraph graph state (checkpointed per thread) alongside `messages`**, rather than in a frontend-only store. This showcases langgraph's durable per-thread state.
13+
14+
The demo starts **empty** — there is no seed data. Users explore and prompt the agent (typically via a welcome suggestion) to generate a trip plan; the agent both **recommends** places and **populates the app state** as it goes, building the itinerary/map live.
15+
16+
## Scope
17+
18+
**In scope (this effort — local parity):**
19+
- Full App-mode cockpit in `examples/chat`: map background, floating itinerary overlay, `appMode` toggle, map-compatible routing (sidebar/popup), welcome suggestions, app-mode promo.
20+
- Itinerary as first-class langgraph graph state, synced client-authoritatively (see State Design).
21+
- Backend: extend the existing `chat` graph (bind client tools + inject itinerary context) — **no second agent/graph**.
22+
- Google Maps key wired into `examples/chat` locally via the `inject-env` `GENERATED_KEYS` mechanism.
23+
- Verification: unit tests at each seam + `examples/chat` e2e for the App-mode cockpit + a live Chrome-MCP smoke against the running langgraph stack (real LLM).
24+
25+
**Explicit non-goals (deferred / rejected):**
26+
- **Deployment** — no Vercel Maps key, no update to the deployed langgraph graph, no prod smoke. Local-first; deploy is a follow-up.
27+
- **Shared library extraction** — the itinerary/map surface is **duplicated** into `examples/chat`, not promoted to a lib. This follows the standalone-examples convention (examples own their code; never share across examples).
28+
- **Server-side geocoding** — geocoding stays in the browser (Maps JS geocoder). No server Google Geocoding key.
29+
- **Second agent / dedicated itinerary graph** — rejected; App mode is a pure layout over the single shared `chat` agent.
30+
31+
## Architecture Overview
32+
33+
App mode is a **presentational layer** over the same agent that powers plain chat. The single `chat` graph gains:
34+
1. an `itinerary` key in its `State` (checkpointed per thread), and
35+
2. the ability to bind frontend-declared client tools (`threadplane-middleware`'s `bind_client_tools`) and to see the current itinerary in the `generate` node's context.
36+
37+
The frontend keeps a live `ItineraryStore` (Angular signals) as the **working copy** driving the map/panel; the graph checkpoint is the **durable record**, synced from the client.
38+
39+
```
40+
User / Agent edits ──▶ ItineraryStore (signals, live)
41+
│ map + panel render from this instantly
42+
43+
submit(): input.state.itinerary = store.stops() ─┐ (turn start: model sees the trip)
44+
updateState({ itinerary }) after run / on edit ─┴─▶ langgraph checkpoint (durable, per thread)
45+
46+
thread switch ────┘ hydrate store from agent.values().itinerary
47+
new thread ───────── empty itinerary → prompt-to-plan (agent populates live)
48+
```
49+
50+
## State Design (the core decision)
51+
52+
### Graph state shape (`examples/chat/python/src/graph.py`)
53+
54+
The current `State` is extended with a flat itinerary list that mirrors the frontend `ItineraryStop` shape exactly (so the panel/map render logic ports 1:1):
55+
56+
```python
57+
from typing_extensions import TypedDict, NotRequired
58+
from typing import Annotated, Optional
59+
60+
class Stop(TypedDict):
61+
id: str
62+
day: int
63+
place: str
64+
note: NotRequired[str]
65+
lat: NotRequired[float]
66+
lng: NotRequired[float]
67+
68+
class State(TypedDict):
69+
messages: Annotated[list, add_messages]
70+
model: Optional[str]
71+
reasoning_effort: Optional[str]
72+
gen_ui_mode: Optional[str]
73+
itinerary: list[Stop] # NEW — plain key = last-write-wins overwrite
74+
```
75+
76+
- **Flat list, day-as-field.** Grouping-by-day stays a pure frontend view (`days()` computed). Matches the store's canonical shape.
77+
- **Plain (non-`Annotated`) key → last-write-wins.** The client always sends the full list (via `input.state` and `updateState`), so an append-style reducer would only duplicate entries. Overwrite is correct for a single agent.
78+
- **No seed data, no seeding concept.** A fresh thread starts with an empty `itinerary` (`[]`). The user drives plan creation by prompting the agent; the agent populates the itinerary via the client tools. The graph tolerates an absent/empty `itinerary` everywhere.
79+
80+
### Ownership & mutation model: client tools + state sync
81+
82+
Mutations run in the **browser** (reusing the ag-ui client tools almost verbatim — browser geocoding, compute-next-stops), and the result is synced into the durable checkpoint:
83+
84+
1. **Turn start** — the shell's `submit` wrapper injects `state.itinerary = store.stops()` (the identical mechanism already used for `model`/`reasoning_effort`/`gen_ui_mode`). The `generate` node folds a compact summary into context, so the model always sees the current trip. **`get_itinerary` is removed** (no read round-trip).
85+
2. **During a turn** — the model calls `add_stop` / `move_stop` / `clear_day` (client tools). The browser executes them: geocodes, updates the `ItineraryStore` (live map/panel), returns the result as the tool message.
86+
3. **Sync to checkpoint** — after a run settles, the shell calls `agent.updateState({ itinerary: store.stops() })` to capture the agent's edits. Direct user panel edits (drag-reorder, add, clear) between runs call the same `updateState` immediately (no active run to conflict with).
87+
4. **Hydration** — switching threads loads the store from `agent.values().itinerary` (server truth). A brand-new thread starts **empty** (no seed) and shows the prompt-to-plan empty state until the user asks the agent to build a plan.
88+
89+
**localStorage is dropped** — the checkpoint is now the durable store. Consequence: a brand-new thread opens on an **empty** map + itinerary with a prompt-to-plan empty state, and the itinerary is **per thread** — switching threads swaps the map to that thread's plan.
90+
91+
### Ephemeral UI state stays frontend
92+
93+
`recentlyChangedId` (agent-edit pulse) and `focusedStopId` (map focus) are transient view concerns — they remain frontend signals and are **not** persisted to graph state.
94+
95+
### Verified enabling APIs (`@threadplane/langgraph`)
96+
97+
- `agent.values(): Signal<T>` — current graph state values (the `values` stream mode is already enabled). Frontend renders the itinerary from `values().itinerary`.
98+
- `agent.updateState(values, signal, { asNode })` — writes a checkpoint as if a node produced the values. Used for the client → checkpoint sync.
99+
- `provideAgent(..., { initialValues })` — available for first-paint defaults; here it is omitted or `{ itinerary: [] }` (no seed data).
100+
- `mergeClientTools` / `createClientToolsCapability` (TS) + `threadplane.middleware.langgraph.bind_client_tools` (Python) — the frontend-client-tool binding path, already used by ag-ui.
101+
102+
## Backend Changes (`examples/chat/python`)
103+
104+
Additive to the existing `chat` graph — plain chat is unaffected when no client-tool catalog is sent.
105+
106+
- Add `threadplane-middleware>=0.0.1` to `pyproject.toml`.
107+
- `State` gains the `itinerary: list[Stop]` key (above).
108+
- `generate` node: when `state["itinerary"]` is non-empty, inject a compact JSON summary into the system context so the model reasons over the current trip.
109+
- `generate` node: bind the frontend client-tool catalog via `bind_client_tools` (only affects runs where the App-mode frontend sends the catalog), composing with the existing server tools (`search_documents`, `request_approval`, `research`, `gen_ui_tool`).
110+
- No new graph, no new `graph_id` in `langgraph.json`.
111+
112+
### Planner behavior & prompt tuning
113+
114+
With no seed data, the whole experience depends on the agent turning a request into a populated plan. The prompt/tool tuning is a first-class deliverable, not an afterthought:
115+
116+
- **System prompt (App-mode / planner framing):** when the itinerary client tools are bound, the `generate` node's system context casts the agent as a trip-planning assistant that, given a request, (1) **recommends** concrete places (with a one-line note each) grouped into days, and (2) **populates the app state** by calling `add_stop` for each recommendation and `day_card` to surface a day — rather than only describing the plan in prose. It revises via `move_stop` / `clear_day` when the user asks. The current `state["itinerary"]` (possibly empty) is injected so the agent knows what already exists and only adds what's missing.
117+
- **Tool descriptions:** tuned so the model reliably *acts* (calls `add_stop` as it recommends) instead of narrating. Descriptions are the primary steering; the system framing above is light supplemental coaching, warranted here because there is no seed to imply the pattern.
118+
- **Welcome suggestions** are concrete trip-planning starters that both invite exploration and trigger plan generation — e.g. "Plan a long weekend in Paris", "3 days in Tokyo with great food", "A week on the California coast". Selecting one sends the prompt and the agent builds the plan live.
119+
- **Definition of done for the prompt:** a cold thread + one welcome suggestion yields multiple `add_stop` calls that populate the map + panel with recommended, geocoded stops across days — verified in the live Chrome-MCP gate.
120+
121+
## Frontend Changes (`examples/chat/angular`)
122+
123+
### Duplicated surface (ported from `examples/ag-ui`, `@threadplane/ag-ui``@threadplane/langgraph`)
124+
125+
~1,300 LOC copied and re-wired (agent import, `submit` state field, `values`/`updateState` sync):
126+
`map-canvas.component` (neutral default view when empty; fit-to-bounds once stops exist), `itinerary-panel.component` (+spec; **empty-state CTA** — "Ask the assistant to plan a trip" — shown when there are no stops), `itinerary-store` (localStorage **and** the `SEED` constant removed; empty initial state; hydrate-from-`values` added), `map-bounds` (+spec), `geocoding.service` (+spec), `google-maps-loader`, `client-tools` (get_itinerary dropped; results sync to checkpoint), `day-card.component`, `clear-day-confirm.component`, plus `modes/app-mode-promo.component` and `modes/welcome-suggestions` retuned to trip-planning starters.
127+
128+
### Shell reconciliation (`shell/demo-shell.component`) — layout ①
129+
130+
- Add an `appMode` toggle to the toolbar + `hasMapsKey` gate (from `environment.googleMapsApiKey`).
131+
- **In App mode:** the thread sidenav auto-collapses to the hamburger drawer (demo-shell already has drawer mode + hamburger). The map renders full-bleed background, the itinerary floats as a left overlay, chat is the right rail (sidebar) / bubble (popup). Faithful to ag-ui's cockpit; reuses demo-shell's existing drawer machinery.
132+
- Port ag-ui-shell's `appMode` param-sync effect (persist to URL; App mode valid in sidebar/popup; coerce `embed → sidebar` when App mode is on), coexisting with demo-shell's `<mode>/:threadId` route matcher.
133+
- `submit` wrapper extended to inject `state.itinerary`; post-run hook calls `updateState`.
134+
135+
### Config
136+
137+
- `environment.ts` / `environment.development.ts`: add `googleMapsApiKey` + `googleMapsMapId` from `GENERATED_KEYS` (port the `inject-env` wiring from ag-ui). Local `.env` only.
138+
- `app.config.ts`: `provideAgent(...)` wires the itinerary client-tools registry; **no seed** (omit `initialValues`, or pass `{ itinerary: [] }`).
139+
140+
## Testing Strategy
141+
142+
- **Unit (vitest):** `itinerary-store` starts empty (no `SEED`) + hydrate-from-`values` + no-localStorage; `map-bounds`; `geocoding.service`; the `submit`-wrapper injects `state.itinerary`; `updateState` called on run-settle and on user edit; empty-state CTA renders with zero stops; App-mode routing coercion (`embed → sidebar`).
143+
- **Backend (pytest):** `generate` binds client tools when catalog present and not otherwise; itinerary context injected when `state["itinerary"]` non-empty and tolerated when empty/absent; the planner framing is applied only when the catalog is bound; plain-chat path unchanged.
144+
- **e2e (`examples/chat` Playwright):** App-mode cockpit renders (map + overlay + right-rail chat); a fresh thread shows the empty-state prompt-to-plan; sidenav collapses to drawer in App mode; per-thread itinerary swaps on thread switch. (Map tiles gated on a local Maps key — assert DOM/layout, not tile pixels, per the Maps-canvas harness lesson.)
145+
- **Live gate (Chrome MCP):** against the running `:4200` + `:2024` stack with a real LLM — start from an **empty** thread, send a planning suggestion ("plan 3 days in Tokyo"), confirm the agent recommends stops and populates the map/panel live via multiple `add_stop` calls, then reload mid-thread and confirm the plan restores from the checkpoint.
146+
147+
## Risks & Mitigations
148+
149+
- **Client-tool payload key alignment** (TS `client_tools` vs Python `bind_client_tools` reading `state["tools"]`): de-risk with a tiny spike early — send the itinerary catalog from `examples/chat` and confirm the model can call `add_stop`. The mechanism is proven in ag-ui; only the langgraph-adapter payload shape needs confirming.
150+
- **`updateState` vs active run**: only call post-run (run settled) for agent edits; user edits occur between runs. Never call during an active run.
151+
- **Shell coexistence**: the appMode param-sync effect and the `<mode>/:threadId` matcher both navigate — port ag-ui's `untracked(mode)` + absolute-`/sidebar` discipline to avoid the bootstrap `→ /embed` bounce.
152+
- **Maps key footgun** (worktree has no local `.env`): symlink the main-checkout `.env` into the worktree root before serving (per the established runbook).
153+
154+
## Open Questions
155+
156+
None blocking. Deploy wiring, lib extraction, and server geocoding are explicit non-goals for this effort.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// SPDX-License-Identifier: MIT
2+
// App-mode itinerary cockpit — structural e2e. These assert layout/DOM, NOT
3+
// map tiles (the WebGL/vector map does not render reliably in the automated
4+
// browser, and the bundle is keyless in CI). App mode is reached via the
5+
// URL (`?appmode=on`), which is honored regardless of the Maps key, so these
6+
// run in keyless CI.
7+
import { test, expect } from '@playwright/test';
8+
import { openDemo } from './test-helpers';
9+
10+
test.describe('App mode — itinerary cockpit', () => {
11+
test('shows the cockpit with an empty prompt-to-plan state', async ({ page }) => {
12+
await openDemo(page, '/sidebar?appmode=on');
13+
14+
// The map canvas (in the sidebar content slot) and the floating itinerary
15+
// overlay both mount in App mode.
16+
await expect(page.locator('app-map-canvas')).toBeAttached();
17+
await expect(page.locator('app-itinerary-panel')).toBeVisible();
18+
19+
// Empty start (no seed) → the panel invites the user to ask for a plan.
20+
await expect(page.locator('app-itinerary-panel')).toContainText(/plan/i);
21+
22+
// Layout ①: the thread sidenav collapses to the hamburger drawer in App mode.
23+
await expect(page.locator('.demo-shell__hamburger')).toBeVisible();
24+
});
25+
26+
test('selecting Embed turns App mode off (coercion)', async ({ page }) => {
27+
await openDemo(page, '/sidebar?appmode=on');
28+
await expect(page.locator('app-map-canvas')).toBeAttached();
29+
30+
await page.getByRole('button', { name: 'Embed', exact: true }).click();
31+
32+
// Embed can't coexist with the map → route drops to /embed and the cockpit
33+
// tears down.
34+
await expect(page).toHaveURL(/\/embed/);
35+
await expect(page.locator('app-map-canvas')).toHaveCount(0);
36+
await expect(page.locator('app-itinerary-panel')).toHaveCount(0);
37+
});
38+
});

examples/chat/angular/project.json

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,18 @@
55
"projectType": "application",
66
"prefix": "app",
77
"targets": {
8+
"inject-env": {
9+
"executor": "nx:run-commands",
10+
"options": {
11+
"command": "node examples/chat/angular/scripts/inject-env.mjs",
12+
"cwd": "{workspaceRoot}"
13+
}
14+
},
815
"build": {
916
"executor": "@angular/build:application",
17+
"dependsOn": [
18+
"inject-env"
19+
],
1020
"outputs": [
1121
"{options.outputPath.base}"
1222
],
@@ -49,7 +59,13 @@
4959
"maximumError": "16kb"
5060
}
5161
],
52-
"outputHashing": "all"
62+
"outputHashing": "all",
63+
"fileReplacements": [
64+
{
65+
"replace": "examples/chat/angular/src/environments/generated-keys.ts",
66+
"with": "examples/chat/angular/src/environments/generated-keys.local.ts"
67+
}
68+
]
5369
},
5470
"production-debug": {
5571
"define": {
@@ -80,6 +96,10 @@
8096
{
8197
"replace": "examples/chat/angular/src/environments/environment.ts",
8298
"with": "examples/chat/angular/src/environments/environment.development.ts"
99+
},
100+
{
101+
"replace": "examples/chat/angular/src/environments/generated-keys.ts",
102+
"with": "examples/chat/angular/src/environments/generated-keys.local.ts"
83103
}
84104
]
85105
}
@@ -89,6 +109,9 @@
89109
"serve": {
90110
"continuous": true,
91111
"executor": "@angular/build:dev-server",
112+
"dependsOn": [
113+
"inject-env"
114+
],
92115
"options": {
93116
"port": 4200
94117
},
358 KB
Loading
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// SPDX-License-Identifier: MIT
2+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
3+
import { resolve, dirname } from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
6+
const __dirname = dirname(fileURLToPath(import.meta.url));
7+
const repoRoot = resolve(__dirname, '../../../..');
8+
9+
function readDotEnv() {
10+
const envPath = resolve(repoRoot, '.env');
11+
if (!existsSync(envPath)) return {};
12+
const raw = readFileSync(envPath, 'utf8');
13+
const out = {};
14+
for (const line of raw.split('\n')) {
15+
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
16+
if (m) out[m[1]] = m[2].replace(/^"|"$/g, '');
17+
}
18+
return out;
19+
}
20+
21+
const env = { ...readDotEnv(), ...process.env };
22+
const key = env.GOOGLE_MAPS_API_KEY ?? '';
23+
const mapId = env.GOOGLE_MAPS_MAP_ID ?? '';
24+
25+
const targetPath = resolve(__dirname, '../src/environments/generated-keys.local.ts');
26+
const contents = `// SPDX-License-Identifier: MIT
27+
// AUTO-GENERATED by scripts/inject-env.mjs. Do not edit by hand.
28+
export const GENERATED_KEYS = {
29+
googleMaps: ${JSON.stringify(key)},
30+
googleMapsMapId: ${JSON.stringify(mapId)},
31+
} as const;
32+
`;
33+
writeFileSync(targetPath, contents);
34+
console.log(`[inject-env] wrote generated-keys.local.ts (key length: ${key.length}, mapId: ${mapId ? 'set' : 'unset'})`);

0 commit comments

Comments
 (0)