elm-ssr 1.0.6 → 1.0.7

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/bin/elm-ssr.mjs CHANGED
@@ -410,6 +410,10 @@ switch (command) {
410
410
  try {
411
411
  const result = await addAuthProvider(rootPath, appConfig, providerArg);
412
412
  console.log(`Added ${result.name} to ${appConfig.name}`);
413
+ if (result.warnings && result.warnings.length > 0) {
414
+ console.log(`\nWarnings:`);
415
+ for (const warning of result.warnings) console.log(` - ${warning}`);
416
+ }
413
417
  console.log(`\nNext steps:`);
414
418
  if (result.provider === "better-auth") {
415
419
  console.log(` 1. Run: bun install`);
@@ -23,7 +23,75 @@ export const elmJsonTemplate = ({ http = false } = {}) => ({
23
23
  }
24
24
  });
25
25
 
26
- export const sharedTemplate = (namespace, auth) => `module ${namespace}.View.Shared exposing (head, layout)
26
+ export const sharedTemplate = (namespace, auth) => auth ? `module ${namespace}.View.Shared exposing (head, layoutFor, User, sessionDecoder)
27
+
28
+ import ElmSsr.Html exposing (Node, a, div, header, main_, nav, span, text)
29
+ import ElmSsr.Html.Attributes exposing (class, href)
30
+ import ElmSsr.Page as Page
31
+ import Json.Decode as Decode
32
+
33
+
34
+ head : List (Node msg)
35
+ head =
36
+ [ Page.metaCharset "utf-8"
37
+ , Page.metaViewport "width=device-width, initial-scale=1"
38
+ , Page.stylesheet "/styles.css"
39
+ ]
40
+
41
+
42
+ type alias User =
43
+ { email : String
44
+ , name : Maybe String
45
+ }
46
+
47
+
48
+ userDecoder : Decode.Decoder User
49
+ userDecoder =
50
+ Decode.map2 User
51
+ (Decode.field "email" Decode.string)
52
+ (Decode.maybe (Decode.field "name" Decode.string))
53
+
54
+
55
+ {-| Decodes the session payload's "user" field, if present. Pair with
56
+ \`Loader.session Shared.sessionDecoder |> Loader.map (Maybe.andThen identity)\`
57
+ to get \`Maybe User\` — outer Maybe is "no session", inner is "session, no user".
58
+ -}
59
+ sessionDecoder : Decode.Decoder (Maybe User)
60
+ sessionDecoder =
61
+ Decode.oneOf
62
+ [ Decode.field "user" (Decode.nullable userDecoder)
63
+ , Decode.succeed Nothing
64
+ ]
65
+
66
+
67
+ {-| Session-aware page chrome — nav shows "Sign in" or the signed-in user. -}
68
+ layoutFor : String -> Maybe User -> List (Node msg) -> Node msg
69
+ layoutFor pageTitle maybeUser body =
70
+ div [ class "page" ]
71
+ [ header [ class "header" ]
72
+ [ div [ class "header-inner" ]
73
+ [ a [ class "brand", href "/" ]
74
+ [ span [ class "brand-icon" ] [ text "◆" ]
75
+ , text "elm-ssr"
76
+ ]
77
+ , nav [ class "nav" ]
78
+ [ a [ class "nav-link", href "/" ] [ text "Home" ]
79
+ , a [ class "nav-link", href "/counter" ] [ text "Counter" ]
80
+ , case maybeUser of
81
+ Just user ->
82
+ a [ class "nav-link", href "/profile" ]
83
+ [ text (Maybe.withDefault user.email user.name) ]
84
+
85
+ Nothing ->
86
+ a [ class "nav-link", href "/login" ] [ text "Sign in" ]
87
+ ]
88
+ ]
89
+ ]
90
+ , main_ [ class "main" ]
91
+ [ div [ class "container" ] body
92
+ ]
93
+ ]
94
+ ` : `module ${namespace}.View.Shared exposing (head, layout)
27
95
 
28
96
  import ElmSsr.Html exposing (Node, a, div, header, main_, nav, span, text)
29
97
  import ElmSsr.Html.Attributes exposing (class, href)
@@ -49,8 +117,67 @@ layout pageTitle body =
49
117
  ]
50
118
  , nav [ class "nav" ]
51
119
  [ a [ class "nav-link", href "/" ] [ text "Home" ]
52
- , a [ class "nav-link", href "/counter" ] [ text "Counter" ]${auth ? `
53
- , a [ class "nav-link", href "/login" ] [ text "Sign in" ]` : ""}
120
+ , a [ class "nav-link", href "/counter" ] [ text "Counter" ]
121
+ ]
122
+ ]
123
+ ]
124
+ , main_ [ class "main" ]
125
+ [ div [ class "container" ] body
126
+ ]
127
+ ]
128
+ `;
129
+
130
+ // Additive block appended to an existing (non-auth) View/Shared.elm by `auth add`.
131
+ // Deliberately does NOT touch the existing `layout` function or its call sites —
132
+ // existing pages keep compiling unchanged; only newly-added Login/Profile pages
133
+ // (and any page the user migrates by hand) use `layoutFor`.
134
+ export const sharedAuthAddition = () => `
135
+
136
+ type alias User =
137
+ { email : String
138
+ , name : Maybe String
139
+ }
140
+
141
+
142
+ userDecoder : Decode.Decoder User
143
+ userDecoder =
144
+ Decode.map2 User
145
+ (Decode.field "email" Decode.string)
146
+ (Decode.maybe (Decode.field "name" Decode.string))
147
+
148
+
149
+ {-| Decodes the session payload's "user" field, if present. Pair with
150
+ \`Loader.session Shared.sessionDecoder |> Loader.map (Maybe.andThen identity)\`
151
+ to get \`Maybe User\` — outer Maybe is "no session", inner is "session, no user".
152
+ -}
153
+ sessionDecoder : Decode.Decoder (Maybe User)
154
+ sessionDecoder =
155
+ Decode.oneOf
156
+ [ Decode.field "user" (Decode.nullable userDecoder)
157
+ , Decode.succeed Nothing
158
+ ]
159
+
160
+
161
+ {-| Session-aware page chrome — nav shows "Sign in" or the signed-in user. -}
162
+ layoutFor : String -> Maybe User -> List (Node msg) -> Node msg
163
+ layoutFor pageTitle maybeUser body =
164
+ div [ class "page" ]
165
+ [ header [ class "header" ]
166
+ [ div [ class "header-inner" ]
167
+ [ a [ class "brand", href "/" ]
168
+ [ span [ class "brand-icon" ] [ text "◆" ]
169
+ , text "elm-ssr"
170
+ ]
171
+ , nav [ class "nav" ]
172
+ [ a [ class "nav-link", href "/" ] [ text "Home" ]
173
+ , a [ class "nav-link", href "/counter" ] [ text "Counter" ]
174
+ , case maybeUser of
175
+ Just user ->
176
+ a [ class "nav-link", href "/profile" ]
177
+ [ text (Maybe.withDefault user.email user.name) ]
178
+
179
+ Nothing ->
180
+ a [ class "nav-link", href "/login" ] [ text "Sign in" ]
54
181
  ]
55
182
  ]
56
183
  ]
@@ -60,7 +187,71 @@ layout pageTitle body =
60
187
  ]
61
188
  `;
62
189
 
63
- export const indexRouteTemplate = (namespace, auth) => `module ${namespace}.Routes.Index exposing (page, action)
190
+ export const indexRouteTemplate = (namespace, auth) => auth ? `module ${namespace}.Routes.Index exposing (page, action)
191
+
192
+ import ElmSsr.Action as Action exposing (Action)
193
+ import ElmSsr.Document exposing (Document)
194
+ import ElmSsr.Html exposing (a, div, h1, h2, p, section, span, text)
195
+ import ElmSsr.Html.Attributes exposing (class, href)
196
+ import ElmSsr.Loader as Loader exposing (Loader)
197
+ import ElmSsr.Page as Page
198
+ import ElmSsr.Route exposing (Request)
199
+ import ${namespace}.View.Shared as Shared
200
+
201
+
202
+ page : Request -> Loader (Document Never)
203
+ page _ =
204
+ Loader.session Shared.sessionDecoder
205
+ |> Loader.map (Maybe.andThen identity)
206
+ |> Loader.map view
207
+
208
+
209
+ action : Request -> Action (Document Never)
210
+ action _ =
211
+ Action.fail 405 "Method not allowed"
212
+
213
+
214
+ view : Maybe Shared.User -> Document Never
215
+ view maybeUser =
216
+ Page.page
217
+ { title = "elm-ssr"
218
+ , head = Shared.head
219
+ , body =
220
+ [ Shared.layoutFor "Home"
221
+ maybeUser
222
+ [ section [ class "hero" ]
223
+ [ h1 [ class "hero-title" ] [ text "Ship fast." ]
224
+ , p [ class "hero-subtitle" ]
225
+ [ text "Type-safe server-side rendering with interactive islands. Runs on Cloudflare Workers and Bun." ]
226
+ , div [ class "hero-actions" ]
227
+ ([ a [ class "btn btn-primary", href "/counter" ] [ text "Try the counter" ] ]
228
+ ++ (case maybeUser of
229
+ Just _ ->
230
+ [ a [ class "btn btn-secondary", href "/profile" ] [ text "Your profile" ] ]
231
+
232
+ Nothing ->
233
+ [ a [ class "btn btn-secondary", href "/login" ] [ text "Sign in" ] ]
234
+ )
235
+ )
236
+ ]
237
+ , section [ class "features" ]
238
+ [ featureCard "⚡" "Edge-first" "Renders in milliseconds at the edge. No cold starts."
239
+ , featureCard "🦺" "Fully typed" "End-to-end Elm types from DB to HTML. No runtime surprises."
240
+ , featureCard "🏝️" "Islands" "Add interactivity exactly where you need it. Zero JS elsewhere."
241
+ ]
242
+ ]
243
+ ]
244
+ }
245
+
246
+
247
+ featureCard : String -> String -> String -> ElmSsr.Html.Node msg
248
+ featureCard icon title_ body =
249
+ div [ class "feature-card" ]
250
+ [ span [ class "feature-icon" ] [ text icon ]
251
+ , h2 [ class "feature-title" ] [ text title_ ]
252
+ , p [ class "feature-body" ] [ text body ]
253
+ ]
254
+ ` : `module ${namespace}.Routes.Index exposing (page, action)
64
255
 
65
256
  import ElmSsr.Action as Action exposing (Action)
66
257
  import ElmSsr.Document exposing (Document)
@@ -94,9 +285,7 @@ view =
94
285
  , p [ class "hero-subtitle" ]
95
286
  [ text "Type-safe server-side rendering with interactive islands. Runs on Cloudflare Workers and Bun." ]
96
287
  , div [ class "hero-actions" ]
97
- [ a [ class "btn btn-primary", href "/counter" ] [ text "Try the counter" ]${auth ? `
98
- , a [ class "btn btn-secondary", href "/login" ] [ text "Sign in" ]` : ""}
99
- ]
288
+ [ a [ class "btn btn-primary", href "/counter" ] [ text "Try the counter" ] ]
100
289
  ]
101
290
  , section [ class "features" ]
102
291
  [ featureCard "⚡" "Edge-first" "Renders in milliseconds at the edge. No cold starts."
@@ -117,7 +306,50 @@ featureCard icon title_ body =
117
306
  ]
118
307
  `;
119
308
 
120
- export const counterRouteTemplate = (namespace) => `module ${namespace}.Routes.Counter exposing (page, action)
309
+ export const counterRouteTemplate = (namespace, auth) => auth ? `module ${namespace}.Routes.Counter exposing (page, action)
310
+
311
+ import ElmSsr.Action as Action exposing (Action)
312
+ import ElmSsr.Document exposing (Document)
313
+ import ElmSsr.Html exposing (div, h1, p, text)
314
+ import ElmSsr.Html.Attributes exposing (class)
315
+ import ElmSsr.Loader as Loader exposing (Loader)
316
+ import ElmSsr.Page as Page
317
+ import ElmSsr.Route exposing (Request)
318
+ import ${namespace}.Islands.Counter as Counter
319
+ import ${namespace}.View.Shared as Shared
320
+
321
+
322
+ page : Request -> Loader (Document Never)
323
+ page _ =
324
+ Loader.session Shared.sessionDecoder
325
+ |> Loader.map (Maybe.andThen identity)
326
+ |> Loader.map view
327
+
328
+
329
+ action : Request -> Action (Document Never)
330
+ action _ =
331
+ Action.fail 405 "Method not allowed"
332
+
333
+
334
+ view : Maybe Shared.User -> Document Never
335
+ view maybeUser =
336
+ Page.page
337
+ { title = "Counter | elm-ssr"
338
+ , head = Shared.head
339
+ , body =
340
+ [ Shared.layoutFor "Counter"
341
+ maybeUser
342
+ [ div [ class "page-header" ]
343
+ [ h1 [] [ text "Interactive Counter" ]
344
+ , p [ class "page-subtitle" ]
345
+ [ text "This page is server-rendered. Only the counter widget below is a client-side island — zero JS elsewhere." ]
346
+ ]
347
+ , div [ class "card" ]
348
+ [ Counter.embed { start = 0 } ]
349
+ ]
350
+ ]
351
+ }
352
+ ` : `module ${namespace}.Routes.Counter exposing (page, action)
121
353
 
122
354
  import ElmSsr.Action as Action exposing (Action)
123
355
  import ElmSsr.Document exposing (Document)
@@ -252,7 +484,47 @@ view model =
252
484
  ]
253
485
  `;
254
486
 
255
- export const notFoundRouteTemplate = (namespace) => `module ${namespace}.Routes.NotFound exposing (page, action)
487
+ export const notFoundRouteTemplate = (namespace, auth) => auth ? `module ${namespace}.Routes.NotFound exposing (page, action)
488
+
489
+ import ElmSsr.Action as Action exposing (Action)
490
+ import ElmSsr.Document exposing (Document)
491
+ import ElmSsr.Html exposing (a, div, h1, p, text)
492
+ import ElmSsr.Html.Attributes exposing (class, href)
493
+ import ElmSsr.Loader as Loader exposing (Loader)
494
+ import ElmSsr.Page as Page
495
+ import ElmSsr.Route exposing (Request)
496
+ import ${namespace}.View.Shared as Shared
497
+
498
+
499
+ page : Request -> Loader (Document Never)
500
+ page _ =
501
+ Loader.session Shared.sessionDecoder
502
+ |> Loader.map (Maybe.andThen identity)
503
+ |> Loader.map view
504
+
505
+
506
+ action : Request -> Action (Document Never)
507
+ action _ =
508
+ Action.fail 405 "Method not allowed"
509
+
510
+
511
+ view : Maybe Shared.User -> Document Never
512
+ view maybeUser =
513
+ Page.notFound
514
+ { title = "Not Found | elm-ssr"
515
+ , head = Shared.head
516
+ , body =
517
+ [ Shared.layoutFor "Not Found"
518
+ maybeUser
519
+ [ div [ class "error-page" ]
520
+ [ h1 [ class "error-code" ] [ text "404" ]
521
+ , p [ class "error-message" ] [ text "This page doesn't exist." ]
522
+ , a [ class "btn btn-primary", href "/" ] [ text "Go home" ]
523
+ ]
524
+ ]
525
+ ]
526
+ }
527
+ ` : `module ${namespace}.Routes.NotFound exposing (page, action)
256
528
 
257
529
  import ElmSsr.Action as Action exposing (Action)
258
530
  import ElmSsr.Document exposing (Document)
@@ -3,14 +3,14 @@ import { dirname, resolve } from "node:path";
3
3
  import {
4
4
  betterAuthProviderCode,
5
5
  auth0ProviderCode,
6
- betterAuthEndpointTemplate,
7
- auth0EndpointTemplate,
8
6
  loginRouteTemplateBetterAuth,
9
7
  loginRouteTemplateAuth0,
10
8
  profileRouteTemplate,
11
9
  loginIslandTemplate,
12
10
  betterAuthMigrationTemplate
13
11
  } from "./auth-templates.mjs";
12
+ import { sharedAuthAddition } from "./app-templates.mjs";
13
+ import { authPeerDependencies } from "./auth-deps.mjs";
14
14
 
15
15
  const PROVIDER_NAMES = { "better-auth": "betterAuth", auth0: "auth0" };
16
16
 
@@ -28,59 +28,42 @@ const detectProviders = (runtimeContent) => {
28
28
  return found;
29
29
  };
30
30
 
31
- // Add auth imports after the effects import line.
31
+ // Add auth imports after the effects import line. composeAuthProviders comes
32
+ // from the elm-ssr library itself; only the concrete provider (configured by
33
+ // the user's app-specific env shape) lives in the generated Auth.ts.
32
34
  const addAuthImports = (content, provider) => {
33
35
  const providerName = provider === "better-auth" ? "betterAuthProvider" : "auth0Provider";
34
- const sessionImport = `import { memorySessionStore } from "elm-ssr/sessions";`;
35
36
  const anchor = `import { inMemoryEffects, cloudflareEffects } from "elm-ssr/effects";`;
36
37
  let next = content;
37
38
 
38
- if (!next.includes(sessionImport)) {
39
- next = next.replace(anchor, `${anchor}\n${sessionImport}`);
39
+ if (!next.includes(`import { memorySessionStore } from "elm-ssr/sessions";`)) {
40
+ next = next.replace(anchor, `${anchor}\nimport { memorySessionStore } from "elm-ssr/sessions";`);
41
+ }
42
+ if (!next.includes(`import { composeAuthProviders } from "elm-ssr/auth";`)) {
43
+ next = next.replace(anchor, `${anchor}\nimport { composeAuthProviders } from "elm-ssr/auth";`);
40
44
  }
41
45
 
42
46
  const endpointImport = next.match(/import \{ ([^}]+) \} from "\.\/src\/Endpoints\/Auth";/);
43
47
  if (endpointImport) {
44
48
  const names = endpointImport[1].split(",").map((name) => name.trim()).filter(Boolean);
45
- for (const name of [providerName, "composeAuthProviders"]) {
46
- if (!names.includes(name)) names.push(name);
47
- }
49
+ if (!names.includes(providerName)) names.push(providerName);
48
50
  return next.replace(endpointImport[0], `import { ${names.join(", ")} } from "./src/Endpoints/Auth";`);
49
51
  }
50
52
 
51
- return next.replace(anchor, `${anchor}\nimport { ${providerName}, composeAuthProviders } from "./src/Endpoints/Auth";`);
53
+ return next.replace(anchor, `${anchor}\nimport { ${providerName} } from "./src/Endpoints/Auth";`);
52
54
  };
53
55
 
54
- const betterAuthRuntimeEnvBlock = `let bunAuthDb: any = undefined;
55
- if (typeof (globalThis as any).Bun !== "undefined") {
56
- const sqliteModule = "bun" + ":sqlite";
57
- const { Database } = require(sqliteModule);
58
- bunAuthDb = new Database(import.meta.dir + "/app.db");
59
- }
60
-
61
- const getAuthEnv = (env: any): any =>
62
- bunAuthDb && !env?.DB ? { ...(env ?? {}), DB: bunAuthDb } : env;
63
-
64
- `;
65
-
66
- // Build the auth init block that goes before createWorkerApp.
67
- const buildAuthInitBlock = (provider, hasDb) => {
68
- if (provider === "better-auth") {
69
- return `
70
- // elm-ssr-auth:start
71
- ${betterAuthRuntimeEnvBlock}
72
- export const sessionStore = memorySessionStore();
73
-
74
- const authMiddleware = composeAuthProviders([
75
- betterAuthProvider({ getEnv: getAuthEnv }),
76
- ]);`;
77
- }
56
+ // Build the auth init block that goes before createWorkerApp. The provider
57
+ // itself (sourced from Auth.ts) is already fully configured — no per-runtime
58
+ // env-resolution glue needed here.
59
+ const buildAuthInitBlock = (provider) => {
60
+ const providerCall = provider === "better-auth" ? "betterAuthProvider" : "auth0Provider";
78
61
  return `
79
62
  // elm-ssr-auth:start
80
63
  export const sessionStore = memorySessionStore();
81
64
 
82
65
  const authMiddleware = composeAuthProviders([
83
- auth0Provider(),
66
+ ${providerCall},
84
67
  ]);`;
85
68
  };
86
69
 
@@ -122,19 +105,19 @@ ${effectsLine}
122
105
  // elm-ssr-auth:end\n`;
123
106
 
124
107
  const withImports = addAuthImports(base, provider);
125
- return withImports + buildAuthInitBlock(provider, hasDb) + newWorker;
108
+ return withImports + buildAuthInitBlock(provider) + newWorker;
126
109
  };
127
110
 
128
111
  // Adds a second provider to an already-auth runtime.ts.
129
112
  const addProviderToRuntime = (content, provider) => {
130
113
  let next = addAuthImports(content, provider);
131
- const providerCall = provider === "better-auth"
132
- ? "betterAuthProvider({ getEnv: getAuthEnv })"
133
- : "auth0Provider()";
134
- if (next.includes(providerCall)) return next;
135
- if (provider === "better-auth" && !next.includes("const getAuthEnv =")) {
136
- next = next.replace("// elm-ssr-auth:start\n", `// elm-ssr-auth:start\n${betterAuthRuntimeEnvBlock}`);
137
- }
114
+ const providerCall = provider === "better-auth" ? "betterAuthProvider" : "auth0Provider";
115
+ // Scope the idempotency check to the composeAuthProviders array body —
116
+ // providerCall is now a bare identifier that also appears in the import
117
+ // line addAuthImports just added, so a whole-file `.includes` would always
118
+ // (wrongly) short-circuit here.
119
+ const arrayMatch = next.match(/composeAuthProviders\(\[\n([\s\S]*?)\]\)/);
120
+ if (arrayMatch && arrayMatch[1].includes(providerCall)) return next;
138
121
  return next.replace(
139
122
  /composeAuthProviders\(\[\n([\s\S]*?)\]\)/,
140
123
  (_, inner) => `composeAuthProviders([\n${inner} ${providerCall},\n])`
@@ -152,19 +135,14 @@ const patchRuntimeForAuth = (content, provider, hasDb) => {
152
135
  return injectAuthIntoRuntime(content, provider, hasDb);
153
136
  };
154
137
 
155
- // Adds a provider to Auth.ts (creates full file if missing, appends code otherwise).
138
+ // Adds a provider to Auth.ts (creates the file if missing, appends otherwise).
139
+ // Each provider's glue is self-contained (no shared contract preamble to
140
+ // manage), so a fresh file and an append are the same operation.
156
141
  const updateAuthTs = (existingContent, provider) => {
157
142
  const providerFn = provider === "better-auth" ? "betterAuthProvider" : "auth0Provider";
158
143
  if (existingContent.includes(providerFn)) return null; // already present
159
144
  const snippet = provider === "better-auth" ? betterAuthProviderCode : auth0ProviderCode;
160
- // If file already has the contract section, just append provider code.
161
- if (existingContent.includes("composeAuthProviders")) {
162
- return existingContent.trimEnd() + "\n" + snippet + "\n";
163
- }
164
- // No contract yet — generate full file.
165
- return provider === "better-auth"
166
- ? betterAuthEndpointTemplate()
167
- : auth0EndpointTemplate();
145
+ return existingContent ? existingContent.trimEnd() + "\n" + snippet + "\n" : snippet;
168
146
  };
169
147
 
170
148
  // Returns provider-specific env vars to add to .dev.vars / .env.
@@ -184,6 +162,44 @@ const missingEnvVars = (existingContent, provider) => {
184
162
  return vars.filter(([key]) => !existingContent.includes(`${key}=`));
185
163
  };
186
164
 
165
+ // Adds `User` / `sessionDecoder` / `layoutFor` to an existing (non-auth)
166
+ // View/Shared.elm so the new Login/Profile pages have something to import.
167
+ // This is purely additive: the existing `layout` function and its call sites
168
+ // in Index/Counter/NotFound (or any hand-written page) are left untouched, so
169
+ // the app keeps compiling exactly as it did before — those pages just don't
170
+ // get session-aware nav until migrated to `layoutFor` by hand (a warning
171
+ // points at the new function so that's a deliberate, easy follow-up).
172
+ const upgradeSharedForAuth = async (appRoot, namespace) => {
173
+ const sharedPath = resolve(appRoot, `src/${namespace.replace(/\./g, "/")}/View/Shared.elm`);
174
+ let existing = "";
175
+ try { existing = await readFile(sharedPath, "utf8"); } catch { return []; }
176
+ if (existing.includes("sessionDecoder")) return []; // already upgraded
177
+
178
+ let next = existing;
179
+
180
+ const exposingMatch = next.match(/^module ([\w.]+) exposing \(([^)]*)\)/m);
181
+ if (exposingMatch) {
182
+ const [fullMatch, moduleName, exposed] = exposingMatch;
183
+ const names = exposed.split(",").map((n) => n.trim()).filter(Boolean);
184
+ for (const name of ["layoutFor", "User", "sessionDecoder"]) {
185
+ if (!names.includes(name)) names.push(name);
186
+ }
187
+ next = next.replace(fullMatch, `module ${moduleName} exposing (${names.join(", ")})`);
188
+ }
189
+
190
+ if (!next.includes("import Json.Decode")) {
191
+ next = next.replace(/^import ElmSsr\.Page as Page$/m, (line) => `${line}\nimport Json.Decode as Decode`);
192
+ }
193
+
194
+ next = next.trimEnd() + "\n" + sharedAuthAddition();
195
+
196
+ await writeFile(sharedPath, next, "utf8");
197
+
198
+ return [
199
+ `View/Shared.elm gained User/sessionDecoder/layoutFor for session-aware nav. The original "layout" function (used by your existing pages) is untouched and still shows a static nav — switch a page to "Shared.layoutFor pageTitle maybeUser body" (reading the session like Routes/Profile.elm does) when you want it to reflect signed-in state.`
200
+ ];
201
+ };
202
+
187
203
  /**
188
204
  * Add an auth provider to an existing elm-ssr app.
189
205
  * Idempotent: running it twice has no effect.
@@ -192,6 +208,7 @@ export const addAuthProvider = async (rootPath, appConfig, rawProvider) => {
192
208
  const provider = normaliseProvider(rawProvider);
193
209
  const appRoot = resolve(rootPath, appConfig.root);
194
210
  const namespace = appConfig.module;
211
+ const warnings = await upgradeSharedForAuth(appRoot, namespace);
195
212
 
196
213
  // ── Auth.ts ───────────────────────────────────────────────────────────────
197
214
  const authTsPath = resolve(appRoot, "src/Endpoints/Auth.ts");
@@ -270,8 +287,12 @@ export const addAuthProvider = async (rootPath, appConfig, rawProvider) => {
270
287
  const pkgPath = resolve(rootPath, "package.json");
271
288
  try {
272
289
  const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
273
- if (!pkg.devDependencies?.["better-auth"]) {
274
- pkg.devDependencies = { ...pkg.devDependencies, "better-auth": "1.6.22" };
290
+ const peerDeps = await authPeerDependencies();
291
+ const addDeps = {};
292
+ if (!pkg.devDependencies?.["better-auth"]) addDeps["better-auth"] = peerDeps["better-auth"] ?? "latest";
293
+ if (!pkg.devDependencies?.["@better-auth/infra"]) addDeps["@better-auth/infra"] = peerDeps["@better-auth/infra"] ?? "latest";
294
+ if (Object.keys(addDeps).length > 0) {
295
+ pkg.devDependencies = { ...pkg.devDependencies, ...addDeps };
275
296
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
276
297
  }
277
298
  } catch {}
@@ -288,7 +309,7 @@ export const addAuthProvider = async (rootPath, appConfig, rawProvider) => {
288
309
  }
289
310
  }
290
311
 
291
- return { provider, name: PROVIDER_NAMES[provider] };
312
+ return { provider, name: PROVIDER_NAMES[provider], warnings };
292
313
  };
293
314
 
294
315
  /**
@@ -0,0 +1,16 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+
4
+ // elm-ssr's own package.json declares better-auth / @better-auth/infra as
5
+ // peerDependencies — the single source of truth for what version
6
+ // elm-ssr/auth/better-auth was built and tested against. The scaffold reads
7
+ // from there instead of keeping its own separate hardcoded version strings
8
+ // that could silently drift out of sync.
9
+ let cached = null;
10
+ export const authPeerDependencies = async () => {
11
+ if (cached) return cached;
12
+ const pkgPath = resolve(new URL(".", import.meta.url).pathname, "../../package.json");
13
+ const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
14
+ cached = pkg.peerDependencies ?? {};
15
+ return cached;
16
+ };