create-oke 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -2
  3. package/src/ai-setup/apply.ts +123 -31
  4. package/src/ai-setup/catalog.ts +3 -3
  5. package/src/ai-setup/from-pref.ts +1 -1
  6. package/src/ai-setup/prompts.ts +1 -1
  7. package/src/cli.test.ts +183 -142
  8. package/src/cli.ts +190 -32
  9. package/src/create-defaults.test.ts +21 -15
  10. package/src/create-defaults.ts +33 -13
  11. package/src/customize-flow.test.ts +30 -35
  12. package/src/customize-flow.ts +68 -229
  13. package/src/drivers-catalog.ts +42 -48
  14. package/src/local-okengine.test.ts +50 -41
  15. package/src/local-okengine.ts +28 -14
  16. package/src/locales.test.ts +128 -0
  17. package/src/locales.ts +321 -0
  18. package/src/scaffold.ts +277 -33
  19. package/src/templates.ts +2 -8
  20. package/src/transform.test.ts +102 -38
  21. package/src/transform.ts +378 -132
  22. package/templates/advanced/.env.example +23 -3
  23. package/templates/advanced/README.md +1 -1
  24. package/templates/advanced/drizzle.config.ts +10 -13
  25. package/templates/advanced/oke.config.ts +21 -62
  26. package/templates/advanced/package.json +3 -1
  27. package/templates/advanced/src/app.ts +3 -10
  28. package/templates/advanced/src/core.ts +58 -0
  29. package/templates/advanced/src/db/schema.decl.ts +1 -1
  30. package/templates/advanced/src/db/seed/index.ts +2 -2
  31. package/templates/advanced/src/flows/notes/index.ts +3 -15
  32. package/templates/advanced/src/locales/index.ts +6 -0
  33. package/templates/advanced/tests/advanced.test.ts +1 -1
  34. package/templates/advanced/tsconfig.json +3 -0
  35. package/templates/standard/.env.example +23 -3
  36. package/templates/standard/README.md +1 -1
  37. package/templates/standard/drizzle.config.ts +10 -13
  38. package/templates/standard/oke.config.ts +18 -60
  39. package/templates/standard/package.json +3 -1
  40. package/templates/standard/src/app.ts +3 -10
  41. package/templates/standard/src/core.ts +55 -0
  42. package/templates/standard/src/db/schema.decl.ts +1 -1
  43. package/templates/standard/src/db/seed/index.ts +2 -2
  44. package/templates/standard/src/flows/notes/index.ts +3 -12
  45. package/templates/standard/src/locales/index.ts +6 -0
  46. package/templates/standard/tests/standard.test.ts +1 -1
  47. package/templates/standard/tsconfig.json +3 -0
  48. package/templates/advanced/src/core/channels.ts +0 -13
  49. package/templates/advanced/src/core/gates.ts +0 -12
  50. package/templates/advanced/src/core/index.ts +0 -12
  51. package/templates/advanced/src/core/store.ts +0 -8
  52. package/templates/advanced/src/core/vault.ts +0 -7
  53. package/templates/advanced/src/locales/ar.ts +0 -18
  54. package/templates/standard/src/core/channels.ts +0 -13
  55. package/templates/standard/src/core/gates.ts +0 -12
  56. package/templates/standard/src/core/index.ts +0 -12
  57. package/templates/standard/src/core/store.ts +0 -5
  58. package/templates/standard/src/core/vault.ts +0 -7
  59. package/templates/standard/src/locales/ar.ts +0 -18
package/src/transform.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * workspace root; registry version otherwise)
10
10
  * 3. Drop monorepo-only files that import paths outside the source tree
11
11
  * (today: `tests/docker.test.ts`)
12
- * 4. Optional `--sql` / wizard choice → Drizzle dialect + `store.sql` pins
12
+ * 4. Optional `--sql` / wizard choice → `store.sql` pins
13
13
  */
14
14
 
15
15
  import { readFileSync } from "node:fs";
@@ -25,14 +25,14 @@ import {
25
25
  import { materializeLocalOkengineDependency } from "./local-okengine.ts";
26
26
  import { packageRoot } from "./templates.ts";
27
27
 
28
- /** SQL store drivers selectable at scaffold time. */
29
- export const SQL_DRIVERS = ["sqlite", "postgres"] as const;
28
+ /** SQL store drivers selectable at scaffold time (SQLite removed). */
29
+ export const SQL_DRIVERS = ["postgres"] as const;
30
30
 
31
31
  /** A known store.sql driver id for create-oke. */
32
32
  export type SqlDriverId = (typeof SQL_DRIVERS)[number];
33
33
 
34
34
  /** Default when `--sql` / wizard choice is omitted (matches template sources). */
35
- export const DEFAULT_SQL_DRIVER: SqlDriverId = "sqlite";
35
+ export const DEFAULT_SQL_DRIVER: SqlDriverId = "postgres";
36
36
 
37
37
  /**
38
38
  * Whether `value` is a known {@link SqlDriverId}.
@@ -124,64 +124,72 @@ export function shouldSkipTemplatePath(relativePath: string): boolean {
124
124
  }
125
125
 
126
126
  /**
127
- * Rewrite a Drizzle schema file to the dialect for `driver`.
127
+ * Real `DRIVER_DEFAULTS` mirrors (okengine/config) create-oke has no
128
+ * runtime dependency on okengine, so keep a local copy for sparse pins.
128
129
  *
129
- * Templates ship as `sqliteTable` / `drizzle-orm/sqlite-core`. Choosing
130
- * `postgres` swaps to `pgTable` / `pg-core` (and the reverse is idempotent).
131
- *
132
- * @param source - Schema TypeScript source
133
- * @param driver - Target store.sql driver
130
+ * Keys with no entry (e.g. `index`, `ai`) always emit every env column.
134
131
  */
135
- export function transformSchemaForSqlDriver(source: string, driver: SqlDriverId): string {
136
- if (driver === "postgres") {
137
- return source
138
- .replaceAll("drizzle-orm/sqlite-core", "drizzle-orm/pg-core")
139
- .replaceAll("sqliteTable", "pgTable");
140
- }
141
- return source
142
- .replaceAll("drizzle-orm/pg-core", "drizzle-orm/sqlite-core")
143
- .replaceAll("pgTable", "sqliteTable");
144
- }
132
+ const PIN_DEFAULTS: Readonly<Partial<Record<string, EnvDriverPins>>> = {
133
+ sql: { dev: "postgres", test: "pglite", prod: "postgres" },
134
+ kv: { dev: "redis", test: "memory", prod: "redis" },
135
+ files: { dev: "s3", test: "memory", prod: "s3" },
136
+ signal: { dev: "redis", test: "memory", prod: "redis" },
137
+ clock: { dev: "postgres", test: "frozen", prod: "postgres" },
138
+ vault: { dev: "env", test: "memory", prod: "vault" },
139
+ email: { dev: "smtp", test: "console", prod: "smtp" },
140
+ };
145
141
 
146
142
  /**
147
- * Pin `store.sql` `local` / `docker` / `prod` in `oke.config.ts` to `driver`.
143
+ * Pin `store.sql` `dev` / `prod` in `oke.config.ts` to `driver`.
148
144
  *
149
- * Leaves `test: "memory"` (and every other facet) untouched.
145
+ * Leaves `test` (and every other facet) untouched. When the template omits
146
+ * `store.sql` (defaults already match), this is a no-op for `postgres`.
150
147
  *
151
148
  * @param source - Config TypeScript source
152
149
  * @param driver - Target store.sql driver
153
150
  */
154
151
  export function transformConfigForSqlDriver(source: string, driver: SqlDriverId): string {
155
- return source.replace(/sql:\s*\{[\s\S]*?\n\s*\}/, (block) =>
156
- block
157
- .replace(/local:\s*"(?:sqlite|postgres)"/, `local: "${driver}"`)
158
- .replace(/docker:\s*"(?:sqlite|postgres)"/, `docker: "${driver}"`)
159
- .replace(/prod:\s*"(?:sqlite|postgres)"/, `prod: "${driver}"`),
160
- );
152
+ const sqlRe = /^( {6})sql:\s*\{[\s\S]*?\n\1\}/m;
153
+ if (sqlRe.test(source)) {
154
+ return source.replace(sqlRe, (block) =>
155
+ block
156
+ .replace(/dev:\s*"(?:postgres|pglite|memory)"/, `dev: "${driver}"`)
157
+ .replace(/prod:\s*"(?:postgres|pglite|memory)"/, `prod: "${driver}"`),
158
+ );
159
+ }
160
+ // Sparse template with no sql override — defaults already cover postgres.
161
+ if (driver === "postgres") return source;
162
+ return upsertStoreFacet(source, "sql", {
163
+ dev: driver,
164
+ test: "pglite",
165
+ prod: driver,
166
+ });
161
167
  }
162
168
 
163
169
  /**
164
170
  * Apply full customize answers to `oke.config.ts` source.
165
171
  *
166
172
  * Rewrites driver maps and syncs `images` keys for docker-backed roles.
173
+ * Pins that match {@link PIN_DEFAULTS} are omitted (or removed) so scaffolds
174
+ * stay override-only.
167
175
  *
168
176
  * @param source - Config TypeScript source
169
177
  * @param defaults - Customize / reuse payload
170
178
  */
171
179
  export function applyCreateAnswers(source: string, defaults: CreateDefaults): string {
172
180
  let next = source;
173
- next = replaceEnvMap(next, "sql", defaults.drivers.store.sql, { nestedUnderStore: true });
174
- next = replaceEnvMap(next, "kv", defaults.drivers.store.kv, { nestedUnderStore: true });
175
- next = replaceEnvMap(next, "files", defaults.drivers.store.files, { nestedUnderStore: true });
181
+ next = upsertEnvMap(next, "sql", defaults.drivers.store.sql, { nestedUnderStore: true });
182
+ next = upsertEnvMap(next, "kv", defaults.drivers.store.kv, { nestedUnderStore: true });
183
+ next = upsertEnvMap(next, "files", defaults.drivers.store.files, { nestedUnderStore: true });
176
184
 
177
185
  if (defaults.drivers.store.index) {
178
186
  next = upsertStoreIndex(next, defaults.drivers.store.index);
179
187
  }
180
188
 
181
- next = replaceEnvMap(next, "signal", defaults.drivers.signal, { nestedUnderStore: false });
182
- next = replaceEnvMap(next, "clock", defaults.drivers.clock, { nestedUnderStore: false });
183
- next = replaceEnvMap(next, "vault", defaults.drivers.vault, { nestedUnderStore: false });
184
- next = replaceChannelEmail(next, defaults.drivers.channel.email);
189
+ next = upsertEnvMap(next, "signal", defaults.drivers.signal, { nestedUnderStore: false });
190
+ next = upsertEnvMap(next, "clock", defaults.drivers.clock, { nestedUnderStore: false });
191
+ next = upsertEnvMap(next, "vault", defaults.drivers.vault, { nestedUnderStore: false });
192
+ next = upsertChannelEmail(next, defaults.drivers.channel.email);
185
193
 
186
194
  if (defaults.drivers.ai) {
187
195
  next = upsertAiDrivers(next, defaults.drivers.ai);
@@ -192,41 +200,175 @@ export function applyCreateAnswers(source: string, defaults: CreateDefaults): st
192
200
  }
193
201
 
194
202
  /**
195
- * Replace a top-level or store-nested env map block by key name.
203
+ * Env columns that differ from a known default map (or all columns when none).
204
+ *
205
+ * @param pins - Full three-env pins from the wizard
206
+ * @param defaults - Optional real defaults for this key
207
+ */
208
+ function sparsePinEntries(
209
+ pins: EnvDriverPins,
210
+ defaults: EnvDriverPins | undefined,
211
+ ): ReadonlyArray<readonly ["dev" | "test" | "prod", string]> {
212
+ const envs = ["dev", "test", "prod"] as const;
213
+ if (!defaults) {
214
+ return envs.map((env) => [env, pins[env]] as const);
215
+ }
216
+ return envs.filter((env) => pins[env] !== defaults[env]).map((env) => [env, pins[env]] as const);
217
+ }
218
+
219
+ /**
220
+ * Format an env driver map literal — only keys that differ from defaults.
221
+ *
222
+ * @param pins - Pins
223
+ * @param indentLevel - Indent of the map key (`signal` → 2, `sql`/`email` → 3;
224
+ * two spaces each). Inner fields use `indentLevel + 1`; the closing `}`
225
+ * aligns with the key.
226
+ * @param defaultsKey - {@link PIN_DEFAULTS} lookup key (omit for no defaults)
227
+ * @returns Formatted `{ … }` or `null` when every pin matches the default
228
+ */
229
+ function formatEnvMap(
230
+ pins: EnvDriverPins,
231
+ indentLevel: number,
232
+ defaultsKey?: string,
233
+ ): string | null {
234
+ const defaults = defaultsKey !== undefined ? PIN_DEFAULTS[defaultsKey] : undefined;
235
+ const entries = sparsePinEntries(pins, defaults);
236
+ if (entries.length === 0) return null;
237
+ const pad = " ".repeat(indentLevel + 1);
238
+ const close = " ".repeat(indentLevel);
239
+ const lines = entries.map(([env, value]) => `${pad}${env}: "${value}",`);
240
+ return `{\n${lines.join("\n")}\n${close}}`;
241
+ }
242
+
243
+ /**
244
+ * Match `key: { … }` at a fixed indentation (store facet = 6, drivers = 4).
245
+ *
246
+ * @param key - Map key
247
+ * @param indent - Leading spaces before the key
248
+ */
249
+ function envMapRe(key: string, indent: number): RegExp {
250
+ const pad = " ".repeat(indent);
251
+ return new RegExp(`^${pad}${key}:\\s*\\{[\\s\\S]*?\\n${pad}\\}`, "m");
252
+ }
253
+
254
+ /**
255
+ * Remove a matched env-map block and its trailing comma / blank line.
256
+ *
257
+ * @param source - Config source
258
+ * @param re - Block matcher
259
+ */
260
+ function removeMatchedBlock(source: string, re: RegExp): string {
261
+ return source.replace(re, "").replace(/\n{3,}/g, "\n\n");
262
+ }
263
+
264
+ /**
265
+ * Rewrite the `drivers: { … }` body, or throw when missing.
266
+ *
267
+ * @param source - Config source
268
+ * @param rewrite - Maps current body → next body
269
+ */
270
+ function mapDriversBody(source: string, rewrite: (body: string) => string): string {
271
+ const drivers = findBraceBlock(source, /drivers:\s*\{/);
272
+ if (!drivers) {
273
+ throw new Error("create-oke: oke.config.ts missing drivers block");
274
+ }
275
+ const body = source.slice(drivers.bodyStart, drivers.bodyEnd);
276
+ return `${source.slice(0, drivers.bodyStart)}${rewrite(body)}${source.slice(drivers.bodyEnd)}`;
277
+ }
278
+
279
+ /**
280
+ * Insert `store: { facet }` (or a facet into an existing store) into drivers.
281
+ *
282
+ * Scoped to the `drivers` block so `images.store` is never touched.
283
+ *
284
+ * @param source - Config source
285
+ * @param key - Store facet key
286
+ * @param pins - Pins (full; formatted sparsely inside)
287
+ */
288
+ function upsertStoreFacet(source: string, key: string, pins: EnvDriverPins): string {
289
+ const block = formatEnvMap(pins, 3, key);
290
+ const facetRe = envMapRe(key, 6);
291
+ if (block === null) {
292
+ return mapDriversBody(source, (body) => removeMatchedBlock(body, facetRe));
293
+ }
294
+ return mapDriversBody(source, (body) => {
295
+ if (facetRe.test(body)) {
296
+ return body.replace(facetRe, ` ${key}: ${block}`);
297
+ }
298
+ const storeRe = /^( {4})store:\s*\{/m;
299
+ if (storeRe.test(body)) {
300
+ return body.replace(storeRe, (open) => `${open}\n ${key}: ${block},`);
301
+ }
302
+ const entry = ` store: {\n ${key}: ${block},\n },\n`;
303
+ return `${body}${body.endsWith("\n") ? "" : "\n"}${entry}`;
304
+ });
305
+ }
306
+
307
+ /**
308
+ * Upsert a top-level or store-nested env map; omit when pins match defaults.
196
309
  *
197
310
  * @param source - Config source
198
311
  * @param key - Map key (`sql`, `signal`, …)
199
312
  * @param pins - New pins
200
313
  * @param options - Nesting
201
314
  */
202
- function replaceEnvMap(
315
+ function upsertEnvMap(
203
316
  source: string,
204
317
  key: string,
205
318
  pins: EnvDriverPins,
206
319
  options: { readonly nestedUnderStore: boolean },
207
320
  ): string {
208
- const block = formatEnvMap(pins, options.nestedUnderStore ? 3 : 2);
209
- // Match `key: { … }` at the driver indentation used in the template.
210
- const re = new RegExp(`${key}:\\s*\\{[\\s\\S]*?\\n\\s*\\}`, "m");
211
- if (!re.test(source)) {
212
- throw new Error(`create-oke: oke.config.ts missing drivers.${key} block`);
321
+ if (options.nestedUnderStore) {
322
+ return upsertStoreFacet(source, key, pins);
323
+ }
324
+ const indent = 2;
325
+ const block = formatEnvMap(pins, indent, key);
326
+ const re = envMapRe(key, 4);
327
+ if (block === null) {
328
+ return mapDriversBody(source, (body) => removeMatchedBlock(body, re));
213
329
  }
214
- return source.replace(re, `${key}: ${block}`);
330
+ return mapDriversBody(source, (body) => {
331
+ if (re.test(body)) {
332
+ return body.replace(re, ` ${key}: ${block}`);
333
+ }
334
+ const entry = ` ${key}: ${block},\n`;
335
+ return `${body}${body.endsWith("\n") ? "" : "\n"}${entry}`;
336
+ });
215
337
  }
216
338
 
217
339
  /**
218
- * Replace `channel.email` env map.
340
+ * Upsert `drivers.channel.email` (creates `drivers.channel` when needed).
341
+ *
342
+ * Never touches `images.channel` (string role pins at the same indent).
219
343
  *
220
344
  * @param source - Config source
221
345
  * @param pins - Email pins
222
346
  */
223
- function replaceChannelEmail(source: string, pins: EnvDriverPins): string {
224
- const block = formatEnvMap(pins, 3);
225
- const re = /email:\s*\{[\s\S]*?\n\s*\}/;
226
- if (!re.test(source)) {
227
- throw new Error("create-oke: oke.config.ts missing channel.email block");
347
+ function upsertChannelEmail(source: string, pins: EnvDriverPins): string {
348
+ const block = formatEnvMap(pins, 3, "email");
349
+ // Brace-map `email: {}` only exists under drivers.channel — images uses
350
+ // `email: "image:tag"`.
351
+ const emailRe = envMapRe("email", 6);
352
+ if (block === null) {
353
+ let next = removeMatchedBlock(source, emailRe);
354
+ next = next.replace(/^( {4})channel:\s*\{\s*\n\1\},?\n/m, "");
355
+ return next;
356
+ }
357
+ if (emailRe.test(source)) {
358
+ return source.replace(emailRe, ` email: ${block}`);
228
359
  }
229
- return source.replace(re, `email: ${block}`);
360
+ const drivers = findBraceBlock(source, /drivers:\s*\{/);
361
+ if (drivers) {
362
+ const body = source.slice(drivers.bodyStart, drivers.bodyEnd);
363
+ if (/^ {4}channel:\s*\{/m.test(body)) {
364
+ const nextBody = body.replace(
365
+ /^( {4})channel:\s*\{/m,
366
+ (open) => `${open}\n email: ${block},`,
367
+ );
368
+ return `${source.slice(0, drivers.bodyStart)}${nextBody}${source.slice(drivers.bodyEnd)}`;
369
+ }
370
+ }
371
+ return insertDriversEntry(source, ` channel: {\n email: ${block},\n },`);
230
372
  }
231
373
 
232
374
  /**
@@ -239,67 +381,86 @@ function replaceChannelEmail(source: string, pins: EnvDriverPins): string {
239
381
  * @param pins - Index pins
240
382
  */
241
383
  function upsertStoreIndex(source: string, pins: EnvDriverPins): string {
242
- const block = formatEnvMap(pins, 3);
243
- const indexRe = /^( {6})index:\s*\{[\s\S]*?\n\1\}/m;
244
- if (indexRe.test(source)) {
245
- return source.replace(indexRe, `$1index: ${block}`);
246
- }
247
- // Insert after files block inside store.
248
- const filesRe = /(files:\s*\{[\s\S]*?\n\s*\},)/;
249
- if (!filesRe.test(source)) {
250
- throw new Error("create-oke: oke.config.ts missing store.files to insert index");
251
- }
252
- return source.replace(filesRe, `$1\n index: ${block},`);
384
+ // No DRIVER_DEFAULTS table — emit every env column.
385
+ return upsertStoreFacet(source, "index", pins);
253
386
  }
254
387
 
255
388
  /**
256
- * Insert or replace top-level `drivers.ai` (sibling of `channel`, never nested).
257
- *
258
- * Indentation-anchored: only matches `ai:` / `channel:` at the drivers-body
259
- * indent (4 spaces). The old non-greedy `channel: { … },` regex closed on
260
- * `email`'s `},` and nested `ai` inside `channel` — hero then showed `ai: —`.
389
+ * Insert a drivers-body entry before the closing `}` of `drivers: { … }`.
261
390
  *
262
391
  * @param source - Config source
263
- * @param pins - AI pins
392
+ * @param entry - Full indented entry including trailing comma
264
393
  */
265
- export function upsertAiDrivers(source: string, pins: EnvDriverPins): string {
266
- const block = formatEnvMap(pins, 2);
267
- const aiRe = /^( {4})ai:\s*\{[\s\S]*?\n\1\}/m;
268
- if (aiRe.test(source)) {
269
- return source.replace(aiRe, `$1ai: ${block}`);
270
- }
271
- const channelRe = /^( {4})channel:\s*\{[\s\S]*?\n\1\},?\n/m;
272
- if (!channelRe.test(source)) {
273
- throw new Error("create-oke: oke.config.ts missing channel block to insert ai");
394
+ function insertDriversEntry(source: string, entry: string): string {
395
+ return mapDriversBody(source, (body) => {
396
+ const needsNl = !body.endsWith("\n");
397
+ return `${body}${needsNl ? "\n" : ""}${entry}\n`;
398
+ });
399
+ }
400
+
401
+ /**
402
+ * Locate a `{ }` block by opener regex and brace depth.
403
+ *
404
+ * @param source - Source text
405
+ * @param openRe - Matches through the opening `{`
406
+ */
407
+ function findBraceBlock(
408
+ source: string,
409
+ openRe: RegExp,
410
+ ): {
411
+ readonly start: number;
412
+ readonly bodyStart: number;
413
+ readonly bodyEnd: number;
414
+ readonly end: number;
415
+ } | null {
416
+ const m = openRe.exec(source);
417
+ if (!m) return null;
418
+ const start = m.index;
419
+ const openIdx = start + m[0].length - 1;
420
+ let depth = 0;
421
+ for (let i = openIdx; i < source.length; i++) {
422
+ const ch = source[i];
423
+ if (ch === "{") depth++;
424
+ else if (ch === "}") {
425
+ depth--;
426
+ if (depth === 0) return { start, bodyStart: openIdx + 1, bodyEnd: i, end: i + 1 };
427
+ }
274
428
  }
275
- return source.replace(channelRe, (match) => `${match} ai: ${block},\n`);
429
+ return null;
276
430
  }
277
431
 
278
432
  /**
279
- * Format an env driver map literal.
433
+ * Insert or replace top-level `drivers.ai` (never under `channel` / `images`).
280
434
  *
281
- * @param pins - Pins
282
- * @param indentLevel - Indent of the map key (`signal` 2, `sql`/`email` → 3;
283
- * two spaces each). Inner fields use `indentLevel + 1`; the closing `}`
284
- * aligns with the key. An off-by-one here made `email`'s `},` look like
285
- * `channel`'s close, so {@link upsertAiDrivers} nested `ai` under `channel`.
435
+ * Replaces an existing `drivers.ai` map; otherwise appends inside `drivers`
436
+ * so sparse templates (no `drivers.channel`) cannot match `images.channel`.
437
+ *
438
+ * @param source - Config source
439
+ * @param pins - AI pins
286
440
  */
287
- function formatEnvMap(pins: EnvDriverPins, indentLevel: number): string {
288
- const pad = " ".repeat(indentLevel + 1);
289
- const close = " ".repeat(indentLevel);
290
- return `{
291
- ${pad}local: "${pins.local}",
292
- ${pad}docker: "${pins.docker}",
293
- ${pad}test: "${pins.test}",
294
- ${pad}prod: "${pins.prod}",
295
- ${close}}`;
441
+ export function upsertAiDrivers(source: string, pins: EnvDriverPins): string {
442
+ // AI has no DRIVER_DEFAULTS — always emit all three envs.
443
+ const block = formatEnvMap(pins, 2);
444
+ if (block === null) {
445
+ throw new Error("create-oke: ai pins must not be empty");
446
+ }
447
+ const aiRe = envMapRe("ai", 4);
448
+ return mapDriversBody(source, (body) => {
449
+ if (aiRe.test(body)) {
450
+ return body.replace(aiRe, ` ai: ${block}`);
451
+ }
452
+ return `${body}${body.endsWith("\n") ? "" : "\n"} ai: ${block},\n`;
453
+ });
296
454
  }
297
455
 
298
456
  /** Env-column keys that must never appear under `images`. */
299
- const IMAGE_ENV_COLUMNS = new Set(["local", "docker", "test", "prod"]);
457
+ const IMAGE_ENV_COLUMNS = new Set(["dev", "test", "prod"]);
300
458
 
301
- /** Known compose role keys written into `images`. */
302
- const IMAGE_ROLE_KEY = /^(?:store\.(?:sql|kv|files|index)|channel\.email|vault|ai|pgdog)$/;
459
+ /** Known compose role keys written into `images` (dotted, post-flatten). */
460
+ const IMAGE_ROLE_KEY = /^(?:store\.(?:sql|kv|files|index)|channel\.email|vault|ai|pgdog|proxy)$/;
461
+
462
+ /** `images` sub-object keys that nest role facets (mirrors `drivers` nesting). */
463
+ const IMAGE_NEST_KEYS = ["store", "channel"] as const;
303
464
 
304
465
  /**
305
466
  * Keep `images` in sync with chosen docker drivers.
@@ -319,35 +480,34 @@ function syncImages(source: string, defaults: CreateDefaults): string {
319
480
  };
320
481
 
321
482
  if (
322
- d.store.sql.docker === "postgres" ||
483
+ d.store.sql.dev === "postgres" ||
323
484
  d.store.sql.prod === "postgres" ||
324
- d.store.sql.docker === "pgvector" ||
485
+ d.store.sql.dev === "pgvector" ||
325
486
  d.store.sql.prod === "pgvector"
326
487
  ) {
327
488
  pin("store.sql", DEFAULT_IMAGES["store.sql"]!);
328
- pin("pgdog", DEFAULT_IMAGES.pgdog!);
489
+ if (defaults.pgdog) {
490
+ pin("pgdog", DEFAULT_IMAGES.pgdog!);
491
+ }
329
492
  }
330
- if (d.store.kv.docker === "redis" || d.signal.docker === "redis") {
493
+ if (d.store.kv.dev === "redis" || d.signal.dev === "redis") {
331
494
  pin("store.kv", DEFAULT_IMAGES["store.kv"]!);
332
495
  }
333
- if (d.store.files.docker === "s3") {
496
+ if (d.store.files.dev === "s3") {
334
497
  pin("store.files", DEFAULT_IMAGES["store.files"]!);
335
498
  }
336
- if (d.channel.email.docker === "smtp") {
499
+ if (d.channel.email.dev === "smtp") {
337
500
  pin("channel.email", DEFAULT_IMAGES["channel.email"]!);
338
501
  }
339
- if (d.vault.docker === "openbao") {
340
- pin("vault", DEFAULT_IMAGES.vault!);
341
- }
342
- if (d.store.index?.docker === "meilisearch") {
502
+ if (d.store.index?.dev === "meilisearch") {
343
503
  pin("store.index", DEFAULT_IMAGES["store.index"]!);
344
504
  }
345
505
  if (
346
506
  d.ai &&
347
- (d.ai.docker === "ollama" ||
348
- d.ai.local === "ollama" ||
349
- d.ai.docker === "openai-compatible" ||
350
- d.ai.local === "openai-compatible")
507
+ (d.ai.dev === "ollama" ||
508
+ d.ai.prod === "ollama" ||
509
+ d.ai.dev === "openai-compatible" ||
510
+ d.ai.prod === "openai-compatible")
351
511
  ) {
352
512
  pin("ai", aiImageForDefaults(defaults));
353
513
  }
@@ -362,55 +522,141 @@ function syncImages(source: string, defaults: CreateDefaults): string {
362
522
  */
363
523
  function aiImageForDefaults(defaults: CreateDefaults): string {
364
524
  const provider = defaults.ai.provider;
365
- if (provider === "ollama" || defaults.drivers.ai?.local === "ollama") {
525
+ if (provider === "ollama" || defaults.drivers.ai?.dev === "ollama") {
366
526
  return OLLAMA_IMAGE;
367
527
  }
368
528
  if (provider === "vllm") return VLLM_IMAGE;
369
529
  if (provider === "sglang") return SGLANG_IMAGE;
370
- if (provider === "llama-cpp" || defaults.drivers.ai?.local === "openai-compatible") {
530
+ if (provider === "llama-cpp" || defaults.drivers.ai?.dev === "openai-compatible") {
371
531
  return LLAMA_CPP_IMAGE;
372
532
  }
373
533
  return DEFAULT_IMAGES.ai!;
374
534
  }
375
535
 
376
536
  /**
377
- * Parse role→image pins from an `images` block.
537
+ * Locate the `images: { }` block by brace depth (not the first `}`) so
538
+ * nested `store: { … }` / `channel: { … }` sub-blocks don't close the match
539
+ * early.
378
540
  *
379
- * Skips `//` comment lines and rejects env-column keys (`local`/`docker`/…).
541
+ * @param source - Config source
542
+ */
543
+ export function findImagesBlock(source: string): {
544
+ readonly start: number;
545
+ readonly bodyStart: number;
546
+ readonly bodyEnd: number;
547
+ readonly end: number;
548
+ } | null {
549
+ const m = /images:\s*\{/.exec(source);
550
+ if (!m) return null;
551
+ const start = m.index;
552
+ const openIdx = start + m[0].length - 1;
553
+ let depth = 0;
554
+ for (let i = openIdx; i < source.length; i++) {
555
+ const ch = source[i];
556
+ if (ch === "{") depth++;
557
+ else if (ch === "}") {
558
+ depth--;
559
+ if (depth === 0) return { start, bodyStart: openIdx + 1, bodyEnd: i, end: i + 1 };
560
+ }
561
+ }
562
+ return null;
563
+ }
564
+
565
+ /**
566
+ * Parse dotted role→image pins from an `images` block, flattening one level
567
+ * of `store` / `channel` nesting (mirrors {@link flattenImagesConfig} in
568
+ * `okengine/config`).
569
+ *
570
+ * Skips `//` comment lines and rejects env-column keys (`dev`/`test`/`prod`).
380
571
  *
381
572
  * @param source - Config source
382
573
  */
383
- function extractImages(source: string): Record<string, string> {
384
- const m = /images:\s*\{([\s\S]*?)\n\s*\},/.exec(source);
385
- if (!m) return {};
574
+ export function extractImages(source: string): Record<string, string> {
575
+ const block = findImagesBlock(source);
576
+ if (!block) return {};
386
577
  const out: Record<string, string> = {};
387
- for (const line of m[1]!.split("\n")) {
388
- const trimmed = line.trim();
389
- if (!trimmed || trimmed.startsWith("//")) continue;
390
- const hit = /["']?([\w.]+)["']?\s*:\s*"([^"]+)"/.exec(trimmed);
578
+ let context: (typeof IMAGE_NEST_KEYS)[number] | null = null;
579
+ for (const raw of source.slice(block.bodyStart, block.bodyEnd).split("\n")) {
580
+ const line = raw.trim();
581
+ if (!line || line.startsWith("//")) continue;
582
+ const nestOpen = /^(store|channel):\s*\{\s*$/.exec(line);
583
+ if (nestOpen) {
584
+ context = nestOpen[1] as (typeof IMAGE_NEST_KEYS)[number];
585
+ continue;
586
+ }
587
+ if (line === "}," || line === "}") {
588
+ context = null;
589
+ continue;
590
+ }
591
+ const hit = /^["']?([\w.]+)["']?\s*:\s*"([^"]+)"/.exec(line);
391
592
  if (!hit) continue;
392
- const key = hit[1]!;
393
- if (IMAGE_ENV_COLUMNS.has(key) || !IMAGE_ROLE_KEY.test(key)) continue;
593
+ const rawKey = hit[1]!;
594
+ if (IMAGE_ENV_COLUMNS.has(rawKey)) continue;
595
+ const key = context ? `${context}.${rawKey}` : rawKey;
596
+ if (!IMAGE_ROLE_KEY.test(key)) continue;
394
597
  out[key] = hit[2]!;
395
598
  }
396
599
  return out;
397
600
  }
398
601
 
602
+ /**
603
+ * Render dotted role→image pins back into a nested `images: { … }` literal —
604
+ * `store.*` / `channel.*` under their sub-object, everything else flat.
605
+ *
606
+ * @param images - Dotted role → image
607
+ */
608
+ function formatImagesBlock(images: Record<string, string>): string {
609
+ const store: Array<[string, string]> = [];
610
+ const channel: Array<[string, string]> = [];
611
+ const flat: Array<[string, string]> = [];
612
+ for (const [key, value] of Object.entries(images)) {
613
+ if (key.startsWith("store.")) store.push([key.slice("store.".length), value]);
614
+ else if (key.startsWith("channel.")) channel.push([key.slice("channel.".length), value]);
615
+ else flat.push([key, value]);
616
+ }
617
+ const lines: string[] = [];
618
+ if (store.length > 0) {
619
+ lines.push(" store: {");
620
+ for (const [k, v] of store) lines.push(` ${k}: "${v}",`);
621
+ lines.push(" },");
622
+ }
623
+ if (channel.length > 0) {
624
+ lines.push(" channel: {");
625
+ for (const [k, v] of channel) lines.push(` ${k}: "${v}",`);
626
+ lines.push(" },");
627
+ }
628
+ for (const [k, v] of flat) lines.push(` ${k}: "${v}",`);
629
+ return `images: {\n${lines.join("\n")}\n }`;
630
+ }
631
+
399
632
  /**
400
633
  * @param source - Config source
401
- * @param images - Role → image
634
+ * @param images - Dotted role → image
402
635
  */
403
- function replaceImagesBlock(source: string, images: Record<string, string>): string {
404
- const lines = Object.entries(images).map(([k, v]) => {
405
- const key = k.includes(".") ? `"${k}"` : k;
406
- return ` ${key}: "${v}",`;
407
- });
408
- const block = `images: {\n${lines.join("\n")}\n }`;
409
- const re = /images:\s*\{[\s\S]*?\n\s*\}/;
410
- if (!re.test(source)) {
636
+ export function replaceImagesBlock(source: string, images: Record<string, string>): string {
637
+ const block = findImagesBlock(source);
638
+ if (!block) {
411
639
  throw new Error("create-oke: oke.config.ts missing images block");
412
640
  }
413
- return source.replace(re, block);
641
+ return `${source.slice(0, block.start)}${formatImagesBlock(images)}${source.slice(block.end)}`;
642
+ }
643
+
644
+ /**
645
+ * Add or remove the `images.pgdog` pin (PgDog in front of Postgres).
646
+ *
647
+ * @param source - `oke.config.ts` source
648
+ * @param enabled - When true, pin the default PgDog image
649
+ */
650
+ export function applyPgDogToConfig(source: string, enabled: boolean): string {
651
+ const images = extractImages(source);
652
+ if (enabled) {
653
+ if (images.pgdog) return source;
654
+ images.pgdog = DEFAULT_IMAGES.pgdog!;
655
+ } else {
656
+ if (!images.pgdog) return source;
657
+ delete images.pgdog;
658
+ }
659
+ return replaceImagesBlock(source, images);
414
660
  }
415
661
 
416
662
  /**