create-oke 0.10.3 → 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 (57) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -2
  3. package/src/ai-setup/apply.ts +113 -17
  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 +96 -36
  21. package/src/transform.ts +285 -110
  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 +12 -57
  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 +1 -13
  32. package/templates/advanced/src/locales/index.ts +6 -0
  33. package/templates/advanced/tests/advanced.test.ts +1 -1
  34. package/templates/standard/.env.example +23 -3
  35. package/templates/standard/README.md +1 -1
  36. package/templates/standard/drizzle.config.ts +10 -13
  37. package/templates/standard/oke.config.ts +10 -56
  38. package/templates/standard/package.json +3 -1
  39. package/templates/standard/src/app.ts +3 -10
  40. package/templates/standard/src/core.ts +55 -0
  41. package/templates/standard/src/db/schema.decl.ts +1 -1
  42. package/templates/standard/src/db/seed/index.ts +2 -2
  43. package/templates/standard/src/flows/notes/index.ts +1 -10
  44. package/templates/standard/src/locales/index.ts +6 -0
  45. package/templates/standard/tests/standard.test.ts +1 -1
  46. package/templates/advanced/src/core/channels.ts +0 -13
  47. package/templates/advanced/src/core/gates.ts +0 -12
  48. package/templates/advanced/src/core/index.ts +0 -12
  49. package/templates/advanced/src/core/store.ts +0 -8
  50. package/templates/advanced/src/core/vault.ts +0 -7
  51. package/templates/advanced/src/locales/ar.ts +0 -18
  52. package/templates/standard/src/core/channels.ts +0 -13
  53. package/templates/standard/src/core/gates.ts +0 -12
  54. package/templates/standard/src/core/index.ts +0 -12
  55. package/templates/standard/src/core/store.ts +0 -5
  56. package/templates/standard/src/core/vault.ts +0 -7
  57. 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);
213
323
  }
214
- return source.replace(re, `${key}: ${block}`);
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));
329
+ }
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;
228
356
  }
229
- return source.replace(re, `email: ${block}`);
357
+ if (emailRe.test(source)) {
358
+ return source.replace(emailRe, ` email: ${block}`);
359
+ }
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,64 +381,80 @@ 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
459
  /** Known compose role keys written into `images` (dotted, post-flatten). */
302
460
  const IMAGE_ROLE_KEY = /^(?:store\.(?:sql|kv|files|index)|channel\.email|vault|ai|pgdog|proxy)$/;
@@ -322,35 +480,34 @@ function syncImages(source: string, defaults: CreateDefaults): string {
322
480
  };
323
481
 
324
482
  if (
325
- d.store.sql.docker === "postgres" ||
483
+ d.store.sql.dev === "postgres" ||
326
484
  d.store.sql.prod === "postgres" ||
327
- d.store.sql.docker === "pgvector" ||
485
+ d.store.sql.dev === "pgvector" ||
328
486
  d.store.sql.prod === "pgvector"
329
487
  ) {
330
488
  pin("store.sql", DEFAULT_IMAGES["store.sql"]!);
331
- pin("pgdog", DEFAULT_IMAGES.pgdog!);
489
+ if (defaults.pgdog) {
490
+ pin("pgdog", DEFAULT_IMAGES.pgdog!);
491
+ }
332
492
  }
333
- if (d.store.kv.docker === "redis" || d.signal.docker === "redis") {
493
+ if (d.store.kv.dev === "redis" || d.signal.dev === "redis") {
334
494
  pin("store.kv", DEFAULT_IMAGES["store.kv"]!);
335
495
  }
336
- if (d.store.files.docker === "s3") {
496
+ if (d.store.files.dev === "s3") {
337
497
  pin("store.files", DEFAULT_IMAGES["store.files"]!);
338
498
  }
339
- if (d.channel.email.docker === "smtp") {
499
+ if (d.channel.email.dev === "smtp") {
340
500
  pin("channel.email", DEFAULT_IMAGES["channel.email"]!);
341
501
  }
342
- if (d.vault.docker === "openbao") {
343
- pin("vault", DEFAULT_IMAGES.vault!);
344
- }
345
- if (d.store.index?.docker === "meilisearch") {
502
+ if (d.store.index?.dev === "meilisearch") {
346
503
  pin("store.index", DEFAULT_IMAGES["store.index"]!);
347
504
  }
348
505
  if (
349
506
  d.ai &&
350
- (d.ai.docker === "ollama" ||
351
- d.ai.local === "ollama" ||
352
- d.ai.docker === "openai-compatible" ||
353
- 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")
354
511
  ) {
355
512
  pin("ai", aiImageForDefaults(defaults));
356
513
  }
@@ -365,12 +522,12 @@ function syncImages(source: string, defaults: CreateDefaults): string {
365
522
  */
366
523
  function aiImageForDefaults(defaults: CreateDefaults): string {
367
524
  const provider = defaults.ai.provider;
368
- if (provider === "ollama" || defaults.drivers.ai?.local === "ollama") {
525
+ if (provider === "ollama" || defaults.drivers.ai?.dev === "ollama") {
369
526
  return OLLAMA_IMAGE;
370
527
  }
371
528
  if (provider === "vllm") return VLLM_IMAGE;
372
529
  if (provider === "sglang") return SGLANG_IMAGE;
373
- if (provider === "llama-cpp" || defaults.drivers.ai?.local === "openai-compatible") {
530
+ if (provider === "llama-cpp" || defaults.drivers.ai?.dev === "openai-compatible") {
374
531
  return LLAMA_CPP_IMAGE;
375
532
  }
376
533
  return DEFAULT_IMAGES.ai!;
@@ -410,7 +567,7 @@ export function findImagesBlock(source: string): {
410
567
  * of `store` / `channel` nesting (mirrors {@link flattenImagesConfig} in
411
568
  * `okengine/config`).
412
569
  *
413
- * Skips `//` comment lines and rejects env-column keys (`local`/`docker`/…).
570
+ * Skips `//` comment lines and rejects env-column keys (`dev`/`test`/`prod`).
414
571
  *
415
572
  * @param source - Config source
416
573
  */
@@ -484,6 +641,24 @@ export function replaceImagesBlock(source: string, images: Record<string, string
484
641
  return `${source.slice(0, block.start)}${formatImagesBlock(images)}${source.slice(block.end)}`;
485
642
  }
486
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);
660
+ }
661
+
487
662
  /**
488
663
  * Sanitize a directory / package name for npm.
489
664
  *
@@ -6,6 +6,25 @@
6
6
  # --- Vault (app secrets) ---
7
7
  # Declare secrets in src/vault.ts with `vault.secret(...)`, then uncomment lines here.
8
8
  #
9
+ # ── vault — built-in encrypted-at-rest store ────────────────
10
+ # `drivers.vault` is "vault": secrets live encrypted in Postgres, no extra service.
11
+ # Nothing below is required to start — the dotenv layers above resolve first.
12
+ #
13
+ # oke vault init # creates the store, prints the master key ONCE
14
+ # export OKE_VAULT_MASTER_KEY=… # every later command / boot unseals with this
15
+ # oke vault status # sealed / initialized / kek generation
16
+ # oke vault audit # hash-chained operation log
17
+ #
18
+ # Keep the master key out of this file in production — read it from your KMS or
19
+ # secret manager at boot. Committing it defeats encryption at rest.
20
+ # OKE_VAULT_MASTER_KEY=
21
+ #
22
+ # For `drivers.vault: "managed"` instead, point at your provider secret store:
23
+ # OKE_VAULT_PROVIDER=aws-secrets-manager
24
+ # OKE_VAULT_REGION=us-east-1
25
+ # Optional prefix for secret names (defaults to oke/<env>/):
26
+ # OKE_VAULT_MOUNT=oke/prod/
27
+ #
9
28
  # --- Infrastructure ---
10
29
  # Same keys as `docker/.env.docker` after `oke dev --docker`.
11
30
  # Uncomment to override compose credentials for a host-managed service.
@@ -87,8 +106,9 @@
87
106
  # POSTGRES_INITDB_ARGS=--data-checksums
88
107
 
89
108
  # ── AI ──────────────────────────────────────────────────────
90
- # OKE_AI_DRIVER=ollama
91
- # OKE_AI_URL=http://127.0.0.1:11434
92
- # OKE_AI_MODEL=qwen3.5:9b
109
+ # Default local: llama.cpp → openai-compatible (Ollama remains supported)
110
+ # OKE_AI_DRIVER=openai-compatible
111
+ # OKE_AI_URL=http://127.0.0.1:8080/v1
112
+ # OKE_AI_MODEL=granite3.3:2b
93
113
  # ANTHROPIC_API_KEY=
94
114
  # OPENAI_API_KEY=
@@ -46,5 +46,5 @@ Without AI, summarize returns a local excerpt (`via: "fallback"`).
46
46
 
47
47
  ## Drivers
48
48
 
49
- See `oke.config.ts`: local sqlite/memory/fs · docker postgres/redis/s3/openbao/smtp.
49
+ See `oke.config.ts`: local env/memory/fs · docker postgres/redis/s3/smtp; vault built-in.
50
50
  Optional Meilisearch index pin is commented — enable via create-oke customize.
@@ -3,25 +3,22 @@ import { defineConfig } from "drizzle-kit";
3
3
  /**
4
4
  * Domain schema sync for `oke db push|generate|migrate`.
5
5
  *
6
- * Dialect comes from OKE's `store.sql` driver map (resolved by the CLI and
7
- * injected as `OKE_DRIZZLE_DIALECT`) never inferred from `DATABASE_URL`
8
- * presence. `src/db/schema.generated.ts` is emitted from `src/db/schema.decl.ts`
9
- * for the active dialect as a pre-step of `oke db` / `oke dev`.
6
+ * SQL is PostgreSQL-only (postgres / pglite). Domain tables come from
7
+ * `src/db/schema.decl.ts` `src/db/schema.drizzle.ts` (emitted as a
8
+ * pre-step of `oke db` / `oke dev`).
10
9
  *
11
- * Versioned prod migrations land in `./src/db/migrations` never mix with `.oke/`.
10
+ * System / auth / plugin stubs are separate: `oke schema generate` →
11
+ * `.oke/schema/oke.ts` (gitignored with `.oke/`). Re-run after adding plugins.
12
+ *
13
+ * Versioned prod migrations land in `./src/db/migrations`.
12
14
  */
13
- const dialect = (process.env.OKE_DRIZZLE_DIALECT ?? "sqlite") as "sqlite" | "postgresql";
14
-
15
15
  export default defineConfig({
16
- dialect,
17
- schema: "./src/db/schema.generated.ts",
16
+ dialect: "postgresql",
17
+ schema: "./src/db/schema.drizzle.ts",
18
18
  out: "./src/db/migrations",
19
19
  // Core runtime tables (oke_crons, …) are created by drivers — not domain schema.
20
20
  tablesFilter: ["!oke_*"],
21
21
  dbCredentials: {
22
- url:
23
- dialect === "postgresql"
24
- ? (process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL!)
25
- : (process.env.OKE_SQLITE_URL ?? "file:.oke/app.sqlite"),
22
+ url: process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL!,
26
23
  },
27
24
  });