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/scaffold.ts CHANGED
@@ -13,17 +13,17 @@ import {
13
13
  writeFileSync,
14
14
  } from "node:fs";
15
15
  import { basename, join, relative, resolve } from "node:path";
16
- import {
17
- resolveLocalOkengineRoot,
18
- resolveTemplateDir,
19
- TEMPLATE_DEFAULT_MODE,
20
- type TemplateId,
21
- } from "./templates.ts";
16
+ import { pathToFileURL } from "node:url";
17
+ import { resolveLocalOkengineRoot, resolveTemplateDir, type TemplateId } from "./templates.ts";
22
18
  import { agentsMdContent } from "./agents-md.ts";
23
19
  import type { CreateDefaults } from "./create-defaults.ts";
20
+ import { DEFAULT_IMAGES, TEMPLATE_DEV } from "./drivers-catalog.ts";
21
+ import { applyLocalesToProject } from "./locales.ts";
24
22
  import {
25
23
  DEFAULT_SQL_DRIVER,
26
24
  applyCreateAnswers,
25
+ applyPgDogToConfig,
26
+ extractImages,
27
27
  sanitizeProjectName,
28
28
  shouldSkipTemplatePath,
29
29
  transformConfigForSqlDriver,
@@ -47,13 +47,23 @@ export type ScaffoldOptions = {
47
47
  /** Write root `AGENTS.md` (default true). */
48
48
  readonly writeAgentsMd?: boolean;
49
49
  /**
50
- * Store SQL driver — pins `oke.config.ts` `store.sql` local/docker/prod
51
- * when `postgres`. Default `sqlite` keeps the dual-mode config.
50
+ * Store SQL driver — pins `oke.config.ts` `store.sql` `dev`/`prod`
51
+ * when set. Default `postgres` matches the Docker-first templates.
52
52
  * Ignored when {@link createDefaults} is set.
53
53
  */
54
54
  readonly sqlDriver?: SqlDriverId;
55
55
  /** Full customize / reuse answers — applied after copy. */
56
56
  readonly createDefaults?: CreateDefaults;
57
+ /**
58
+ * Extra locales beyond English. When omitted, uses
59
+ * {@link CreateDefaults.locales} or English-only.
60
+ */
61
+ readonly locales?: readonly string[];
62
+ /**
63
+ * Pin PgDog in front of Postgres. When omitted, uses
64
+ * {@link CreateDefaults.pgdog} or `false`.
65
+ */
66
+ readonly pgdog?: boolean;
57
67
  };
58
68
 
59
69
  /** Result of a successful scaffold. */
@@ -68,6 +78,10 @@ export type ScaffoldResult = {
68
78
  readonly sqlDriver: SqlDriverId;
69
79
  /** Customize answers applied, if any. */
70
80
  readonly createDefaults?: CreateDefaults;
81
+ /** Extra locales applied beyond English. */
82
+ readonly locales: readonly string[];
83
+ /** Whether `images.pgdog` was pinned. */
84
+ readonly pgdog: boolean;
71
85
  /** Relative paths written (POSIX), sorted. */
72
86
  readonly files: readonly string[];
73
87
  };
@@ -101,14 +115,14 @@ export function targetDirectoryBlockReason(targetDir: string): string | null {
101
115
  *
102
116
  * @param options - Name, source, destination
103
117
  */
104
- export function scaffold(options: ScaffoldOptions): ScaffoldResult {
118
+ export async function scaffold(options: ScaffoldOptions): Promise<ScaffoldResult> {
105
119
  const name = sanitizeProjectName(options.name);
106
120
  const targetDir = resolve(options.targetDir);
107
121
  const sourceDir = resolveTemplateDir(options.source.id);
108
122
  const label = options.source.id;
109
123
  const createDefaults = options.createDefaults;
110
124
  const sqlDriver =
111
- createDefaults?.drivers.store.sql.local === "postgres" || options.sqlDriver === "postgres"
125
+ createDefaults?.drivers.store.sql.dev === "postgres" || options.sqlDriver === "postgres"
112
126
  ? "postgres"
113
127
  : (options.sqlDriver ?? DEFAULT_SQL_DRIVER);
114
128
 
@@ -144,6 +158,8 @@ export function scaffold(options: ScaffoldOptions): ScaffoldResult {
144
158
  if (!written.includes("AGENTS.md")) written.push("AGENTS.md");
145
159
  }
146
160
 
161
+ ensureVaultEnvNotes(targetDir, createDefaults?.drivers.vault.dev ?? TEMPLATE_DEV.vault);
162
+
147
163
  const envExample = join(targetDir, ".env.example");
148
164
  const envLocal = join(targetDir, ".env.local");
149
165
  if (existsSync(envExample) && !existsSync(envLocal)) {
@@ -151,9 +167,31 @@ export function scaffold(options: ScaffoldOptions): ScaffoldResult {
151
167
  if (!written.includes(".env.local")) written.push(".env.local");
152
168
  }
153
169
 
154
- // Seed `.oke/mode` so `oke dev` does not re-ask local vs docker.
155
- writeProjectDevMode(targetDir, resolveInitialDevMode(createDefaults, options.source.id));
156
- if (!written.includes(".oke/mode")) written.push(".oke/mode");
170
+ const locales = options.locales ?? createDefaults?.locales ?? [];
171
+ for (const rel of applyLocalesToProject(targetDir, locales)) {
172
+ if (!written.includes(rel) && existsSync(join(targetDir, rel))) written.push(rel);
173
+ // Drop removed locale files from the written list when English-only.
174
+ if (!existsSync(join(targetDir, rel))) {
175
+ const i = written.indexOf(rel);
176
+ if (i >= 0) written.splice(i, 1);
177
+ }
178
+ }
179
+
180
+ const pgdog = options.pgdog ?? createDefaults?.pgdog ?? false;
181
+ const configPath = join(targetDir, "oke.config.ts");
182
+ if (existsSync(configPath)) {
183
+ const prev = readFileSync(configPath, "utf8");
184
+ const next = applyPgDogToConfig(prev, pgdog);
185
+ if (next !== prev) writeFileSync(configPath, next, "utf8");
186
+ }
187
+
188
+ const composeFiles = await writeScaffoldCompose(targetDir);
189
+ for (const rel of composeFiles) {
190
+ if (!written.includes(rel)) written.push(rel);
191
+ }
192
+
193
+ const systemSchema = await writeSystemSchema(targetDir);
194
+ if (systemSchema && !written.includes(systemSchema)) written.push(systemSchema);
157
195
 
158
196
  written.sort();
159
197
  return {
@@ -164,6 +202,8 @@ export function scaffold(options: ScaffoldOptions): ScaffoldResult {
164
202
  okengineDependency,
165
203
  sqlDriver,
166
204
  ...(createDefaults !== undefined ? { createDefaults } : {}),
205
+ locales,
206
+ pgdog,
167
207
  files: written,
168
208
  };
169
209
  } catch (e) {
@@ -173,12 +213,10 @@ export function scaffold(options: ScaffoldOptions): ScaffoldResult {
173
213
  }
174
214
 
175
215
  /**
176
- * For postgres, pin `store.sql` local/docker/prod in `oke.config.ts`.
216
+ * For postgres, pin `store.sql` `dev`/`prod` in `oke.config.ts`.
177
217
  *
178
- * The default template ships abstract `src/db/schema.decl.ts` — dialect is
179
- * emitted from the active `store.sql` driver, so there is no hand-written
180
- * `sqliteTable`/`pgTable` source to rewrite. `sqlite` keeps the template
181
- * dual-mode config (`local: sqlite` · `docker`/`prod: postgres`) untouched.
218
+ * The default template already ships postgres for `dev`/`prod` — this is a
219
+ * no-op unless customize changed the SQL driver and `--sql postgres` restores it.
182
220
  *
183
221
  * @param targetDir - Scaffolded project root
184
222
  * @param sqlDriver - Chosen store.sql driver
@@ -193,6 +231,38 @@ function applySqlDriverTransforms(targetDir: string, sqlDriver: SqlDriverId): vo
193
231
  }
194
232
  }
195
233
 
234
+ /** Marker that the built-in vault key note is already present. */
235
+ const VAULT_MASTER_KEY_MARKER = "OKE_VAULT_MASTER_KEY";
236
+
237
+ /**
238
+ * Make sure `.env.example` explains the built-in vault's master key.
239
+ *
240
+ * Only the built-in `vault` backend has a key to hold: `env` reads the dotenv
241
+ * layers directly, and `managed` gets credentials from the provider secret
242
+ * store. The bundled templates already carry the note, so this only fires
243
+ * for a template that dropped it.
244
+ *
245
+ * @param targetDir - Scaffolded project root
246
+ * @param vaultDriver - Chosen `drivers.vault` dev pin
247
+ */
248
+ function ensureVaultEnvNotes(targetDir: string, vaultDriver: string): void {
249
+ if (vaultDriver !== "vault") return;
250
+ const envExample = join(targetDir, ".env.example");
251
+ if (!existsSync(envExample)) return;
252
+ const source = readFileSync(envExample, "utf8");
253
+ if (source.includes(VAULT_MASTER_KEY_MARKER)) return;
254
+ writeFileSync(
255
+ envExample,
256
+ `${source.trimEnd()}\n
257
+ # ── vault — built-in encrypted-at-rest store ────────────────
258
+ # \`oke vault init\` prints the master key once; every later boot unseals with it.
259
+ # In production read it from your KMS instead of committing it here.
260
+ # OKE_VAULT_MASTER_KEY=
261
+ `,
262
+ "utf8",
263
+ );
264
+ }
265
+
196
266
  /**
197
267
  * Apply full customize / reuse defaults to `oke.config.ts`.
198
268
  *
@@ -209,29 +279,203 @@ function applyCreateDefaultsTransforms(targetDir: string, defaults: CreateDefaul
209
279
  }
210
280
 
211
281
  /**
212
- * Map create-oke profile / template persisted `oke dev` mode.
282
+ * Emit `.oke/schema/oke.ts` (system / auth / plugin stubs) when the local
283
+ * okengine source tree is available. Published create-oke regenerates after
284
+ * `bun install` via `oke schema generate`.
213
285
  *
214
- * @param defaults - Customize / reuse answers, or undefined for recommended
215
- * @param template - Starter id (recommended path uses template default mode)
286
+ * @param targetDir - Scaffolded project root
287
+ * @returns Relative path written, or `undefined` when skipped
216
288
  */
217
- export function resolveInitialDevMode(
218
- defaults: CreateDefaults | undefined,
219
- template: TemplateId = "standard",
220
- ): "local" | "docker" {
221
- if (defaults?.profile === "docker-ready") return "docker";
222
- if (defaults?.profile === "local-only") return "local";
223
- return TEMPLATE_DEFAULT_MODE[template];
289
+ async function writeSystemSchema(targetDir: string): Promise<string | undefined> {
290
+ const okengineRoot = resolveLocalOkengineRoot();
291
+ if (!okengineRoot) return undefined;
292
+ try {
293
+ const schemaUrl = pathToFileURL(join(okengineRoot, "src/cli/schema.ts")).href;
294
+ const mod = (await import(schemaUrl)) as {
295
+ runSchemaGenerate: (opts: {
296
+ cwd?: string;
297
+ write?: (text: string) => void;
298
+ }) => Promise<number>;
299
+ SCHEMA_OUT: string;
300
+ };
301
+ const code = await mod.runSchemaGenerate({ cwd: targetDir, write: () => {} });
302
+ if (code !== 0) return undefined;
303
+ return mod.SCHEMA_OUT;
304
+ } catch {
305
+ return undefined;
306
+ }
224
307
  }
225
308
 
226
309
  /**
227
- * Write project-local `.oke/mode` (same shape as `oke mode` / `oke dev`).
310
+ * Derive `docker/docker-compose.yml` via okengine's {@link deriveInfrastructure}
311
+ * when the monorepo root is available; otherwise write a minimal postgres+redis stub.
228
312
  *
229
313
  * @param targetDir - Scaffolded project root
230
- * @param mode - Mode to save
314
+ * @returns Relative docker paths written
231
315
  */
232
- function writeProjectDevMode(targetDir: string, mode: "local" | "docker"): void {
233
- mkdirSync(join(targetDir, ".oke"), { recursive: true });
234
- writeFileSync(join(targetDir, ".oke", "mode"), `${mode}\n`, "utf8");
316
+ async function writeScaffoldCompose(targetDir: string): Promise<string[]> {
317
+ const configPath = join(targetDir, "oke.config.ts");
318
+ const images = existsSync(configPath)
319
+ ? extractImages(readFileSync(configPath, "utf8"))
320
+ : { ...DEFAULT_IMAGES };
321
+ if (Object.keys(images).length === 0) {
322
+ Object.assign(images, DEFAULT_IMAGES);
323
+ }
324
+
325
+ const okengineRoot = resolveLocalOkengineRoot();
326
+ if (okengineRoot) {
327
+ try {
328
+ const deriveUrl = pathToFileURL(join(okengineRoot, "src/docker/derive.ts")).href;
329
+ const mod = (await import(deriveUrl)) as {
330
+ deriveInfrastructure: (opts: {
331
+ images: Readonly<Record<string, string>>;
332
+ app?: string;
333
+ composeDir?: string;
334
+ includeApp?: boolean;
335
+ prod?: boolean;
336
+ layout?: "single" | "split" | "stack";
337
+ }) => {
338
+ files: readonly { path: string; content: string }[];
339
+ };
340
+ writeDerivedFiles: (
341
+ result: { files: readonly { path: string; content: string }[] },
342
+ outDir: string,
343
+ options?: { writeStackEnv?: boolean },
344
+ ) => Promise<readonly string[]>;
345
+ };
346
+ const result = mod.deriveInfrastructure({
347
+ images,
348
+ app: "app",
349
+ composeDir: "docker",
350
+ includeApp: true,
351
+ prod: true,
352
+ layout: "single",
353
+ });
354
+ const dockerDir = join(targetDir, "docker");
355
+ await mod.writeDerivedFiles(result, dockerDir, { writeStackEnv: false });
356
+ return result.files
357
+ .filter(
358
+ (f) => f.path.endsWith(".yml") || f.path === "Dockerfile" || f.path.endsWith(".toml"),
359
+ )
360
+ .map((f) => `docker/${f.path}`);
361
+ } catch {
362
+ // Fall through to stub when derive fails (published create-oke, etc.).
363
+ }
364
+ }
365
+
366
+ return writeMinimalComposeStub(targetDir);
367
+ }
368
+
369
+ /**
370
+ * Minimal committed-style single-file compose with postgres + redis healthchecks.
371
+ *
372
+ * @param targetDir - Project root
373
+ */
374
+ function writeMinimalComposeStub(targetDir: string): string[] {
375
+ const dockerDir = join(targetDir, "docker");
376
+ mkdirSync(dockerDir, { recursive: true });
377
+ const compose = `# Generated by create-oke (minimal stub when okengine derive is unavailable).
378
+ # Local overrides: docker-compose.override.yml (do not commit secrets).
379
+
380
+ name: oke-app
381
+
382
+ networks:
383
+ oke:
384
+ driver: bridge
385
+
386
+ services:
387
+ # App — okengine runtime
388
+ app:
389
+ image: oke-app:latest
390
+ build:
391
+ context: ..
392
+ dockerfile: Dockerfile
393
+ ports:
394
+ - "6530:6530"
395
+ env_file:
396
+ - .env.docker
397
+ depends_on:
398
+ store-sql:
399
+ condition: service_healthy
400
+ store-kv:
401
+ condition: service_healthy
402
+ networks:
403
+ - oke
404
+ healthcheck:
405
+ test:
406
+ - CMD
407
+ - bun
408
+ - -e
409
+ - fetch("http://127.0.0.1:6530/_/ready").then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))
410
+ interval: 10s
411
+ timeout: 3s
412
+ retries: 3
413
+ start_period: 60s
414
+ stop_grace_period: 30s
415
+ deploy:
416
+ replicas: 1
417
+ resources:
418
+ limits:
419
+ cpus: "0.72"
420
+ memory: 1475M
421
+
422
+ # store.sql — Postgres
423
+ store-sql:
424
+ image: postgres:18-alpine
425
+ ports:
426
+ - "127.0.0.1:5432:5432"
427
+ networks:
428
+ - oke
429
+ env_file:
430
+ - .env.docker
431
+ environment:
432
+ POSTGRES_USER: \${OKE_STORE_SQL_USER}
433
+ POSTGRES_PASSWORD: \${OKE_STORE_SQL_PASSWORD}
434
+ POSTGRES_DB: \${OKE_STORE_SQL_DB}
435
+ healthcheck:
436
+ test:
437
+ - CMD-SHELL
438
+ - pg_isready -U $$POSTGRES_USER
439
+ interval: 5s
440
+ timeout: 3s
441
+ retries: 10
442
+ deploy:
443
+ resources:
444
+ limits:
445
+ cpus: "1.08"
446
+ memory: 2212M
447
+
448
+ # store.kv — Redis
449
+ store-kv:
450
+ image: redis:8-alpine
451
+ ports:
452
+ - "127.0.0.1:6379:6379"
453
+ networks:
454
+ - oke
455
+ env_file:
456
+ - .env.docker
457
+ command:
458
+ - sh
459
+ - -c
460
+ - exec redis-server --requirepass "$$OKE_STORE_KV_PASSWORD"
461
+ healthcheck:
462
+ test:
463
+ - CMD
464
+ - redis-cli
465
+ - -a
466
+ - \${OKE_STORE_KV_PASSWORD}
467
+ - ping
468
+ interval: 5s
469
+ timeout: 3s
470
+ retries: 10
471
+ deploy:
472
+ resources:
473
+ limits:
474
+ cpus: "0.54"
475
+ memory: 1106M
476
+ `;
477
+ writeFileSync(join(dockerDir, "docker-compose.yml"), compose, "utf8");
478
+ return ["docker/docker-compose.yml"];
235
479
  }
236
480
 
237
481
  /**
package/src/templates.ts CHANGED
@@ -17,14 +17,8 @@ export const DEFAULT_TEMPLATE: TemplateId = "standard";
17
17
 
18
18
  /** One-line purpose for the starter (interactive select + help). */
19
19
  export const TEMPLATE_PURPOSES: Readonly<Record<TemplateId, string>> = {
20
- standard: "Notes app — local-first (sqlite · memory · console email)",
21
- advanced: "Notes app — docker-ready (postgres · redis · s3 · openbao)",
22
- };
23
-
24
- /** Recommended `.oke/mode` when using recommended defaults (no customize). */
25
- export const TEMPLATE_DEFAULT_MODE: Readonly<Record<TemplateId, "local" | "docker">> = {
26
- standard: "local",
27
- advanced: "docker",
20
+ standard: "Notes app — Docker-first (postgres · redis · s3 · smtp)",
21
+ advanced: "Notes app — Docker-first + store.index (meilisearch)",
28
22
  };
29
23
 
30
24
  /**
@@ -6,9 +6,9 @@ import { describe, expect, test } from "bun:test";
6
6
  import { readFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { toCreateDefaults } from "./create-defaults.ts";
9
- import { pinsDockerReady, pinsLocalOnly } from "./drivers-catalog.ts";
9
+ import { pinsDockerReady, recommendedDefaults, VAULT_CHOICES } from "./drivers-catalog.ts";
10
10
  import { resolveTemplateDir } from "./templates.ts";
11
- import { applyCreateAnswers, upsertAiDrivers } from "./transform.ts";
11
+ import { applyCreateAnswers, extractImages, upsertAiDrivers } from "./transform.ts";
12
12
  import type { EnvDriverPins } from "./create-defaults.ts";
13
13
 
14
14
  function templateConfig(id: "standard" | "advanced" = "advanced"): string {
@@ -21,7 +21,11 @@ function evalConfig(source: string): {
21
21
  ai?: unknown;
22
22
  channel?: { ai?: unknown; email?: unknown };
23
23
  };
24
- images?: Record<string, string>;
24
+ images?: {
25
+ store?: Record<string, string>;
26
+ channel?: Record<string, string>;
27
+ ai?: string;
28
+ };
25
29
  } {
26
30
  const body = source
27
31
  .replace(/^import\s+[\s\S]*?from\s+["'][^"']+["'];?\s*/m, "")
@@ -30,67 +34,126 @@ function evalConfig(source: string): {
30
34
  return new Function("defineConfig", body)(defineConfig) as ReturnType<typeof evalConfig>;
31
35
  }
32
36
 
33
- function defaultsWithIndex(indexLocal: string, indexDocker: string) {
37
+ function defaultsWithIndex(indexDev: string) {
34
38
  return toCreateDefaults({
35
39
  template: "advanced",
36
40
  profile: "docker-ready",
37
41
  drivers: {
38
42
  store: {
39
- sql: pinsDockerReady("libsql", "postgres", "memory"),
40
- kv: pinsLocalOnly("memory", "redis", "memory"),
41
- files: pinsLocalOnly("fs", "s3", "memory"),
42
- index: pinsDockerReady(indexLocal, indexDocker, "memory"),
43
+ sql: pinsDockerReady("postgres", "pglite"),
44
+ kv: pinsDockerReady("redis", "memory"),
45
+ files: pinsDockerReady("s3", "memory"),
46
+ index: pinsDockerReady(indexDev, "memory"),
43
47
  },
44
- signal: pinsLocalOnly("memory", "redis", "memory"),
45
- clock: pinsLocalOnly("memory", "file", "frozen"),
46
- vault: pinsLocalOnly("env", "openbao", "memory"),
47
- channel: { email: pinsLocalOnly("console", "smtp", "console") },
48
+ signal: pinsDockerReady("redis", "memory"),
49
+ clock: pinsDockerReady("postgres", "frozen"),
50
+ vault: pinsDockerReady("vault", "memory"),
51
+ channel: { email: pinsDockerReady("smtp", "console") },
48
52
  ai: null,
49
53
  },
50
54
  ai: { enabled: false, provider: null, driver: null },
55
+ locales: [],
56
+ pgdog: false,
51
57
  });
52
58
  }
53
59
 
54
60
  describe("applyCreateAnswers images", () => {
55
- test("index: libsql does not poison images with driver pins", () => {
56
- const next = applyCreateAnswers(templateConfig(), defaultsWithIndex("libsql", "libsql"));
57
- expect(next).toContain('local: "libsql"');
58
- expect(next).toMatch(/index:\s*\{\s*local: "libsql"/);
61
+ test("index: pgvector does not poison images with driver pins", () => {
62
+ const next = applyCreateAnswers(templateConfig(), defaultsWithIndex("pgvector"));
63
+ expect(next).toContain('dev: "pgvector"');
64
+ expect(next).toMatch(/index:\s*\{\s*dev: "pgvector"/);
59
65
  // Role pins only — never env-column keys or bare driver ids as images.
60
- expect(next).not.toMatch(/images:\s*\{[^}]*\blocal:\s*"/s);
61
- expect(next).not.toMatch(/images:\s*\{[^}]*\bdocker:\s*"/s);
62
- expect(next).not.toMatch(/images:\s*\{[^}]*:\s*"libsql"/s);
63
- expect(next).toContain('"store.sql": "postgres:18-alpine"');
66
+ expect(next).not.toMatch(/images:\s*\{[^}]*\bdev:\s*"/s);
67
+ expect(next).not.toMatch(/images:\s*\{[^}]*\btest:\s*"memory"/s);
68
+ expect(next).not.toMatch(/images:\s*\{[^}]*:\s*"pgvector"/s);
69
+ expect(next).toMatch(/images:\s*\{\s*store:\s*\{[^}]*\bsql: "postgres:18-alpine"/s);
64
70
  });
65
71
 
66
72
  test("index meilisearch pins store.index image without comment leakage", () => {
73
+ const next = applyCreateAnswers(templateConfig(), defaultsWithIndex("meilisearch"));
74
+ expect(next).toMatch(/images:\s*\{\s*store:\s*\{[^}]*\bindex: "getmeili\/meilisearch:v1.37"/s);
75
+ expect(next).not.toMatch(/images:\s*\{[^}]*\btest:\s*"memory"/s);
76
+ });
77
+ });
78
+
79
+ describe("vault backend defaults", () => {
80
+ test("recommended defaults pick the built-in encrypted store", () => {
81
+ const defaults = recommendedDefaults("docker-ready", "standard");
82
+ expect(defaults.drivers.vault).toEqual({ dev: "vault", test: "memory", prod: "vault" });
83
+ });
84
+
85
+ test("the wizard offers env, vault, managed, and memory", () => {
86
+ const values = VAULT_CHOICES.map((c) => c.value);
87
+ expect(values).toEqual(["env", "vault", "managed", "memory"]);
88
+ expect(VAULT_CHOICES.find((c) => c.value === "vault")?.label).toContain("recommended");
89
+ });
90
+
91
+ test("both templates pin only vault.dev (built-in) — other drivers use defaults", () => {
92
+ for (const id of ["standard", "advanced"] as const) {
93
+ const source = templateConfig(id);
94
+ expect(source, id).toMatch(/vault:\s*\{\s*dev: "vault",?\s*\}/);
95
+ expect(source, id).not.toMatch(/^\s*sql:\s*\{/m);
96
+ expect(source, id).not.toMatch(/^\s*signal:\s*\{/m);
97
+ expect(extractImages(source).vault, id).toBeUndefined();
98
+ }
99
+ });
100
+
101
+ test("both templates document the master key in .env.example", () => {
102
+ for (const id of ["standard", "advanced"] as const) {
103
+ const env = readFileSync(join(resolveTemplateDir(id), ".env.example"), "utf8");
104
+ expect(env, id).toContain("OKE_VAULT_MASTER_KEY");
105
+ expect(env, id).toContain("oke vault init");
106
+ }
107
+ });
108
+
109
+ test("managed vault does not pin a compose image", () => {
67
110
  const next = applyCreateAnswers(
68
- templateConfig(),
69
- defaultsWithIndex("meilisearch", "meilisearch"),
111
+ templateConfig("standard"),
112
+ toCreateDefaults({
113
+ template: "standard",
114
+ profile: "docker-ready",
115
+ drivers: {
116
+ store: {
117
+ sql: pinsDockerReady("postgres", "pglite"),
118
+ kv: pinsDockerReady("redis", "memory"),
119
+ files: pinsDockerReady("s3", "memory"),
120
+ index: null,
121
+ },
122
+ signal: pinsDockerReady("redis", "memory"),
123
+ clock: pinsDockerReady("postgres", "frozen"),
124
+ vault: pinsDockerReady("managed", "memory"),
125
+ channel: { email: pinsDockerReady("smtp", "console") },
126
+ ai: null,
127
+ },
128
+ ai: { enabled: false, provider: null, driver: null },
129
+ locales: [],
130
+ pgdog: false,
131
+ }),
70
132
  );
71
- expect(next).toContain('"store.index": "getmeili/meilisearch:v1.37"');
72
- expect(next).not.toMatch(/images:\s*\{[^}]*\btest:\s*"memory"/s);
133
+ expect(next).toMatch(/vault:\s*\{\s*dev: "managed"/);
134
+ expect(extractImages(next).vault).toBeUndefined();
73
135
  });
74
136
  });
75
137
 
76
138
  describe("upsertAiDrivers", () => {
77
139
  const ollamaPins: EnvDriverPins = {
78
- local: "ollama",
79
- docker: "ollama",
140
+ dev: "ollama",
80
141
  test: "mock",
81
142
  prod: "ollama",
82
143
  };
83
144
 
84
- test("inserts drivers.ai as sibling of channel (not inside email)", () => {
145
+ test("inserts drivers.ai inside drivers (not images / channel)", () => {
85
146
  const next = upsertAiDrivers(templateConfig("advanced"), ollamaPins);
86
147
  const config = evalConfig(next);
87
148
  expect(config.drivers?.ai).toEqual(ollamaPins);
88
149
  expect(config.drivers?.channel?.ai).toBeUndefined();
89
- expect(config.drivers?.channel?.email).toBeDefined();
150
+ // Sparse templates omit drivers.channel — images.channel stays a string pin.
151
+ expect(config.images?.channel?.email).toBe("axllent/mailpit:v1.22.3");
152
+ expect(config.images?.ai).toBeUndefined();
90
153
  });
91
154
 
92
155
  test("applyCreateAnswers with ai pins keeps top-level drivers.ai", () => {
93
- const llamaPins = pinsDockerReady("openai-compatible", "openai-compatible", "mock");
156
+ const llamaPins = pinsDockerReady("openai-compatible", "mock");
94
157
  for (const id of ["standard", "advanced"] as const) {
95
158
  const next = applyCreateAnswers(
96
159
  templateConfig(id),
@@ -99,24 +162,25 @@ describe("upsertAiDrivers", () => {
99
162
  profile: "docker-ready",
100
163
  drivers: {
101
164
  store: {
102
- sql: pinsDockerReady("libsql", "postgres", "memory"),
103
- kv: pinsLocalOnly("memory", "redis", "memory"),
104
- files: pinsLocalOnly("fs", "s3", "memory"),
165
+ sql: pinsDockerReady("postgres", "pglite"),
166
+ kv: pinsDockerReady("redis", "memory"),
167
+ files: pinsDockerReady("s3", "memory"),
105
168
  index: null,
106
169
  },
107
- signal: pinsLocalOnly("memory", "redis", "memory"),
108
- clock: pinsLocalOnly("memory", "file", "frozen"),
109
- vault: pinsLocalOnly("env", "openbao", "memory"),
110
- channel: { email: pinsLocalOnly("console", "smtp", "console") },
170
+ signal: pinsDockerReady("redis", "memory"),
171
+ clock: pinsDockerReady("postgres", "frozen"),
172
+ vault: pinsDockerReady("vault", "memory"),
173
+ channel: { email: pinsDockerReady("smtp", "console") },
111
174
  ai: llamaPins,
112
175
  },
113
176
  ai: { enabled: true, provider: "llama-cpp", driver: "openai-compatible" },
177
+ locales: [],
178
+ pgdog: false,
114
179
  }),
115
180
  );
116
181
  const config = evalConfig(next);
117
182
  expect(config.drivers?.ai, id).toEqual({
118
- local: "openai-compatible",
119
- docker: "openai-compatible",
183
+ dev: "openai-compatible",
120
184
  test: "mock",
121
185
  prod: "openai-compatible",
122
186
  });