create-oke 0.6.0 → 0.7.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 (72) hide show
  1. package/README.md +23 -8
  2. package/package.json +3 -3
  3. package/src/ai-setup/apply.ts +214 -0
  4. package/src/ai-setup/catalog.ts +263 -0
  5. package/src/ai-setup/detect-ollama.ts +180 -0
  6. package/src/ai-setup/from-pref.ts +99 -0
  7. package/src/ai-setup/prompts.ts +649 -0
  8. package/src/ai-setup/recommend.ts +203 -0
  9. package/src/cli.test.ts +385 -28
  10. package/src/cli.ts +332 -29
  11. package/src/create-defaults.test.ts +92 -0
  12. package/src/create-defaults.ts +246 -0
  13. package/src/customize-flow.test.ts +82 -0
  14. package/src/customize-flow.ts +432 -0
  15. package/src/drivers-catalog.ts +217 -0
  16. package/src/scaffold.ts +97 -10
  17. package/src/templates.ts +14 -9
  18. package/src/transform.test.ts +105 -0
  19. package/src/transform.ts +227 -0
  20. package/src/wizard-select.ts +50 -0
  21. package/{template → templates/advanced}/.env.example +40 -2
  22. package/templates/advanced/README.md +32 -0
  23. package/templates/advanced/oke.config.ts +74 -0
  24. package/templates/advanced/package.json +18 -0
  25. package/templates/advanced/src/app.ts +30 -0
  26. package/templates/advanced/src/channels.ts +13 -0
  27. package/templates/advanced/src/core.ts +8 -0
  28. package/{template → templates/advanced}/src/flows/main/index.ts +9 -6
  29. package/templates/advanced/src/flows/main/shapes.ts +1 -0
  30. package/templates/advanced/src/flows/main/signals.ts +1 -0
  31. package/templates/advanced/src/flows/notes/index.ts +224 -0
  32. package/templates/advanced/src/flows/notes/shapes.ts +52 -0
  33. package/templates/advanced/src/flows/notes/signals.ts +13 -0
  34. package/templates/advanced/src/gates.ts +12 -0
  35. package/templates/advanced/src/locales/ar.ts +18 -0
  36. package/templates/advanced/src/locales/en.ts +22 -0
  37. package/templates/advanced/src/schema.decl.ts +13 -0
  38. package/templates/advanced/src/vault.ts +7 -0
  39. package/templates/advanced/tests/advanced.test.ts +42 -0
  40. package/templates/standard/.env.example +94 -0
  41. package/templates/standard/README.md +46 -0
  42. package/templates/standard/drizzle.config.ts +25 -0
  43. package/{template → templates/standard}/oke.config.ts +9 -9
  44. package/{template → templates/standard}/package.json +1 -1
  45. package/templates/standard/src/app.ts +30 -0
  46. package/templates/standard/src/channels.ts +13 -0
  47. package/{template → templates/standard}/src/core.ts +1 -0
  48. package/templates/standard/src/flows/main/index.ts +34 -0
  49. package/templates/standard/src/flows/main/shapes.ts +1 -0
  50. package/templates/standard/src/flows/main/signals.ts +1 -0
  51. package/templates/standard/src/flows/notes/index.ts +143 -0
  52. package/templates/standard/src/flows/notes/shapes.ts +26 -0
  53. package/templates/standard/src/flows/notes/signals.ts +13 -0
  54. package/templates/standard/src/gates.ts +12 -0
  55. package/templates/standard/src/locales/ar.ts +18 -0
  56. package/templates/standard/src/locales/en.ts +22 -0
  57. package/templates/standard/src/schema.decl.ts +13 -0
  58. package/templates/standard/src/vault.ts +7 -0
  59. package/templates/standard/tests/standard.test.ts +51 -0
  60. package/template/README.md +0 -77
  61. package/template/bun.lock +0 -845
  62. package/template/src/app.ts +0 -18
  63. package/template/src/channels.ts +0 -4
  64. package/template/src/flows/main/shapes.ts +0 -2
  65. package/template/src/flows/main/signals.ts +0 -4
  66. package/template/src/gates.ts +0 -4
  67. package/template/src/locales/ar.ts +0 -20
  68. package/template/src/locales/en.ts +0 -26
  69. package/template/src/schema.decl.ts +0 -12
  70. package/template/src/vault.ts +0 -4
  71. package/template/tests/standard.test.ts +0 -18
  72. /package/{template → templates/advanced}/drizzle.config.ts +0 -0
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Customize wizard — pick local|docker first, walk facets for that side,
3
+ * then ask whether to customize the other (defaults if no).
4
+ */
5
+
6
+ import { confirm, isCancel } from "@clack/prompts";
7
+ import { toCreateDefaults, type CreateDefaults, type EnvDriverPins } from "./create-defaults.ts";
8
+ import {
9
+ CLOCK_CHOICES,
10
+ EMAIL_CHOICES,
11
+ FILES_CHOICES,
12
+ INDEX_CHOICES,
13
+ KV_CHOICES,
14
+ SIGNAL_CHOICES,
15
+ SQL_CHOICES,
16
+ TEMPLATE_DOCKER_PROD,
17
+ TEMPLATE_LOCAL,
18
+ TEMPLATE_TEST,
19
+ VAULT_CHOICES,
20
+ AI_PROVIDERS,
21
+ aiDriverForProvider,
22
+ customizeFacetsFor,
23
+ pinsDockerReady,
24
+ pinsLocalOnly,
25
+ type AiProviderId,
26
+ type CustomizeFacetId,
27
+ type DriverChoice,
28
+ } from "./drivers-catalog.ts";
29
+ import type { AiSetupApplyInput } from "./ai-setup/apply.ts";
30
+ import { aiPrefWithModels } from "./ai-setup/from-pref.ts";
31
+ import { askAiSetup } from "./ai-setup/prompts.ts";
32
+ import type { TemplateId } from "./templates.ts";
33
+ import { WIZARD_BACK, selectWithBack, type WizardBack } from "./wizard-select.ts";
34
+
35
+ export type { WizardBack };
36
+ export { WIZARD_BACK };
37
+
38
+ /** Dev env column being customized. */
39
+ export type CustomizeSide = "local" | "docker";
40
+
41
+ /** Partial pins accumulated per side before merge. */
42
+ export type SidePins = {
43
+ sql?: string;
44
+ kv?: string;
45
+ files?: string;
46
+ index?: string | null;
47
+ signal?: string;
48
+ clock?: string;
49
+ vault?: string;
50
+ email?: string;
51
+ enableIndex?: boolean;
52
+ };
53
+
54
+ /**
55
+ * Merge primary + other side into full {@link EnvDriverPins}.
56
+ *
57
+ * @param local - Local driver
58
+ * @param docker - Docker driver (copied to prod)
59
+ * @param test - Test pin
60
+ */
61
+ export function pinsFromSides(local: string, docker: string, test: string): EnvDriverPins {
62
+ return { local, docker, test, prod: docker };
63
+ }
64
+
65
+ /**
66
+ * Fill missing side from template defaults.
67
+ *
68
+ * @param side - Side that was customized
69
+ * @param picked - Values chosen for that side
70
+ * @param other - Optional values if the user also customized the other side
71
+ */
72
+ export function assembleDriverDefaults(
73
+ side: CustomizeSide,
74
+ picked: SidePins,
75
+ other: SidePins | null,
76
+ ): CreateDefaults["drivers"] {
77
+ const localSrc = side === "local" ? picked : (other ?? {});
78
+ const dockerSrc = side === "docker" ? picked : (other ?? {});
79
+
80
+ const sql = pinsFromSides(
81
+ localSrc.sql ?? TEMPLATE_LOCAL.sql,
82
+ dockerSrc.sql ?? TEMPLATE_DOCKER_PROD.sql,
83
+ TEMPLATE_TEST.sql,
84
+ );
85
+ const kv = pinsFromSides(
86
+ localSrc.kv ?? TEMPLATE_LOCAL.kv,
87
+ dockerSrc.kv ?? TEMPLATE_DOCKER_PROD.kv,
88
+ TEMPLATE_TEST.kv,
89
+ );
90
+ const files = pinsFromSides(
91
+ localSrc.files ?? TEMPLATE_LOCAL.files,
92
+ dockerSrc.files ?? TEMPLATE_DOCKER_PROD.files,
93
+ TEMPLATE_TEST.files,
94
+ );
95
+ const enableIndex = Boolean(
96
+ (side === "local" ? picked.enableIndex : other?.enableIndex) ||
97
+ (side === "docker" ? picked.enableIndex : other?.enableIndex),
98
+ );
99
+ const indexLocal = localSrc.index;
100
+ const indexDocker = dockerSrc.index;
101
+ const index =
102
+ enableIndex || indexLocal != null || indexDocker != null
103
+ ? pinsFromSides(
104
+ typeof indexLocal === "string" ? indexLocal : "memory",
105
+ typeof indexDocker === "string" ? indexDocker : "meilisearch",
106
+ "memory",
107
+ )
108
+ : null;
109
+
110
+ return {
111
+ store: { sql, kv, files, index },
112
+ signal: pinsFromSides(
113
+ localSrc.signal ?? TEMPLATE_LOCAL.signal,
114
+ dockerSrc.signal ?? TEMPLATE_DOCKER_PROD.signal,
115
+ TEMPLATE_TEST.signal,
116
+ ),
117
+ clock: pinsFromSides(
118
+ localSrc.clock ?? TEMPLATE_LOCAL.clock,
119
+ dockerSrc.clock ?? TEMPLATE_DOCKER_PROD.clock,
120
+ TEMPLATE_TEST.clock,
121
+ ),
122
+ vault: pinsFromSides(
123
+ localSrc.vault ?? TEMPLATE_LOCAL.vault,
124
+ dockerSrc.vault ?? TEMPLATE_DOCKER_PROD.vault,
125
+ TEMPLATE_TEST.vault,
126
+ ),
127
+ channel: {
128
+ email: pinsFromSides(
129
+ localSrc.email ?? TEMPLATE_LOCAL.email,
130
+ dockerSrc.email ?? TEMPLATE_DOCKER_PROD.email,
131
+ TEMPLATE_TEST.email,
132
+ ),
133
+ },
134
+ ai: null,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Customize drivers for one template.
140
+ *
141
+ * @param template - Starter id (filters facets)
142
+ */
143
+ export async function askCustomizeFlow(
144
+ template: TemplateId,
145
+ ): Promise<CreateDefaults | WizardBack | null> {
146
+ const primaryValue = await selectWithBack(
147
+ "Customize drivers for",
148
+ [
149
+ {
150
+ value: "local",
151
+ label: "◻ Local",
152
+ hint: "oke mode local — sqlite / memory / fs …",
153
+ },
154
+ {
155
+ value: "docker",
156
+ label: "▣ Docker",
157
+ hint: "oke mode docker — postgres / redis / s3 …",
158
+ },
159
+ ],
160
+ template === "advanced" ? "docker" : "local",
161
+ { allowBack: true },
162
+ );
163
+ if (primaryValue === null) return null;
164
+ if (primaryValue === WIZARD_BACK) return WIZARD_BACK;
165
+ const primary = primaryValue as CustomizeSide;
166
+
167
+ const facets = customizeFacetsFor(template);
168
+ const primaryPins = await walkFacetsForSide(facets, primary);
169
+ if (primaryPins === null) return null;
170
+ if (primaryPins === WIZARD_BACK) return WIZARD_BACK;
171
+
172
+ const otherSide: CustomizeSide = primary === "local" ? "docker" : "local";
173
+ const alsoOther = await confirm({
174
+ message:
175
+ otherSide === "local" ? "Also customize local drivers?" : "Also customize docker drivers?",
176
+ initialValue: false,
177
+ });
178
+ if (isCancel(alsoOther)) return null;
179
+
180
+ let otherPins: SidePins | null = null;
181
+ if (alsoOther) {
182
+ const walked = await walkFacetsForSide(facets, otherSide);
183
+ if (walked === null) return null;
184
+ if (walked === WIZARD_BACK) {
185
+ // Back from other side → treat as "no, use defaults"
186
+ otherPins = null;
187
+ } else {
188
+ otherPins = walked;
189
+ }
190
+ }
191
+
192
+ const drivers = assembleDriverDefaults(primary, primaryPins, otherPins);
193
+ const profile = primary === "docker" ? "docker-ready" : "local-only";
194
+
195
+ // AI — after env passes
196
+ let aiPins: EnvDriverPins | null = null;
197
+ let aiPref: CreateDefaults["ai"] = { enabled: false, provider: null, driver: null };
198
+ let aiApply: AiSetupApplyInput | null = null;
199
+
200
+ const wantAi = await selectWithBack(
201
+ "Configure AI?",
202
+ [
203
+ { value: "yes", label: "✓ Yes" },
204
+ { value: "no", label: "✗ No" },
205
+ ],
206
+ "no",
207
+ { allowBack: true },
208
+ );
209
+ if (wantAi === null) return null;
210
+ if (wantAi !== WIZARD_BACK && wantAi === "yes") {
211
+ const options = AI_PROVIDERS.map((p) => ({ value: p.value, label: p.label }));
212
+ const localProviderValue = await selectWithBack(
213
+ alsoOther || primary === "local" ? "AI Provider — local" : "AI Provider",
214
+ options,
215
+ "ollama",
216
+ { allowBack: true },
217
+ );
218
+ if (localProviderValue === null) return null;
219
+ if (localProviderValue !== WIZARD_BACK) {
220
+ const localProvider = localProviderValue as AiProviderId;
221
+ const localDriver = aiDriverForProvider(localProvider);
222
+ let dockerDriver = localDriver === "mock" ? "mock" : localDriver;
223
+
224
+ if (primary === "docker" || alsoOther) {
225
+ const dockerProviderValue = await selectWithBack(
226
+ "AI Provider — docker",
227
+ options,
228
+ "ollama",
229
+ {
230
+ allowBack: true,
231
+ },
232
+ );
233
+ if (dockerProviderValue === null) return null;
234
+ if (dockerProviderValue !== WIZARD_BACK) {
235
+ dockerDriver = aiDriverForProvider(dockerProviderValue);
236
+ if (dockerDriver === "mock") dockerDriver = "mock";
237
+ }
238
+ }
239
+
240
+ aiPins =
241
+ primary === "local" && !alsoOther
242
+ ? pinsLocalOnly(localDriver, dockerDriver, "mock")
243
+ : pinsDockerReady(localDriver, dockerDriver, "mock");
244
+
245
+ const setupProvider =
246
+ localProvider === "ollama" || aiPins.local === "ollama" || aiPins.docker === "ollama"
247
+ ? "ollama"
248
+ : localProvider;
249
+
250
+ if (setupProvider !== "mock") {
251
+ const picked = await askAiSetup({ provider: setupProvider });
252
+ if (picked === null) return null;
253
+ aiApply = picked;
254
+ } else {
255
+ aiApply = { driver: "mock" };
256
+ }
257
+
258
+ aiPref = aiPrefWithModels(
259
+ {
260
+ enabled: true,
261
+ provider: setupProvider,
262
+ driver: aiPins.local,
263
+ },
264
+ aiApply,
265
+ );
266
+ }
267
+ }
268
+
269
+ return toCreateDefaults({
270
+ template,
271
+ profile,
272
+ drivers: { ...drivers, ai: aiPins },
273
+ ai: aiPref,
274
+ });
275
+ }
276
+
277
+ /**
278
+ * Walk facet prompts for one env side.
279
+ *
280
+ * @param facets - Facet ids for the template
281
+ * @param side - local or docker
282
+ */
283
+ async function walkFacetsForSide(
284
+ facets: readonly CustomizeFacetId[],
285
+ side: CustomizeSide,
286
+ ): Promise<SidePins | WizardBack | null> {
287
+ const out: SidePins = {};
288
+ let i = 0;
289
+ while (i < facets.length) {
290
+ const facet = facets[i]!;
291
+ if (facet === "index" && !out.enableIndex) {
292
+ out.index = null;
293
+ i++;
294
+ continue;
295
+ }
296
+ const result = await askOneFacet(facet, side, out);
297
+ if (result === null) return null;
298
+ if (result === WIZARD_BACK) {
299
+ if (i === 0) return WIZARD_BACK;
300
+ i--;
301
+ while (i > 0 && facets[i] === "index" && !out.enableIndex) i--;
302
+ continue;
303
+ }
304
+ i++;
305
+ }
306
+ return out;
307
+ }
308
+
309
+ async function askOneFacet(
310
+ facet: CustomizeFacetId,
311
+ side: CustomizeSide,
312
+ state: SidePins,
313
+ ): Promise<true | WizardBack | null> {
314
+ const suffix = side === "local" ? "local" : "docker";
315
+ switch (facet) {
316
+ case "sql": {
317
+ const v = await askSideChoice(
318
+ `store.sql — ${suffix}`,
319
+ SQL_CHOICES,
320
+ side === "local" ? TEMPLATE_LOCAL.sql : TEMPLATE_DOCKER_PROD.sql,
321
+ );
322
+ if (v === null || v === WIZARD_BACK) return v;
323
+ state.sql = v;
324
+ return true;
325
+ }
326
+ case "kv": {
327
+ const v = await askSideChoice(
328
+ `store.kv — ${suffix}`,
329
+ KV_CHOICES,
330
+ side === "local" ? TEMPLATE_LOCAL.kv : TEMPLATE_DOCKER_PROD.kv,
331
+ );
332
+ if (v === null || v === WIZARD_BACK) return v;
333
+ state.kv = v;
334
+ return true;
335
+ }
336
+ case "files": {
337
+ const v = await askSideChoice(
338
+ `store.files — ${suffix}`,
339
+ FILES_CHOICES,
340
+ side === "local" ? TEMPLATE_LOCAL.files : TEMPLATE_DOCKER_PROD.files,
341
+ );
342
+ if (v === null || v === WIZARD_BACK) return v;
343
+ state.files = v;
344
+ return true;
345
+ }
346
+ case "enableIndex": {
347
+ const value = await selectWithBack(
348
+ `Enable store.index (search)? — ${suffix}`,
349
+ [
350
+ { value: "yes", label: "✓ Yes" },
351
+ { value: "no", label: "✗ No" },
352
+ ],
353
+ state.enableIndex ? "yes" : "no",
354
+ { allowBack: true },
355
+ );
356
+ if (value === null) return null;
357
+ if (value === WIZARD_BACK) return WIZARD_BACK;
358
+ state.enableIndex = value === "yes";
359
+ if (!state.enableIndex) state.index = null;
360
+ return true;
361
+ }
362
+ case "index": {
363
+ const v = await askSideChoice(
364
+ `store.index — ${suffix}`,
365
+ INDEX_CHOICES,
366
+ side === "local" ? "memory" : "meilisearch",
367
+ );
368
+ if (v === null || v === WIZARD_BACK) return v;
369
+ state.index = v;
370
+ return true;
371
+ }
372
+ case "signal": {
373
+ const v = await askSideChoice(
374
+ `signal — ${suffix}`,
375
+ SIGNAL_CHOICES,
376
+ side === "local" ? TEMPLATE_LOCAL.signal : TEMPLATE_DOCKER_PROD.signal,
377
+ );
378
+ if (v === null || v === WIZARD_BACK) return v;
379
+ state.signal = v;
380
+ return true;
381
+ }
382
+ case "clock": {
383
+ const v = await askSideChoice(
384
+ `clock — ${suffix}`,
385
+ CLOCK_CHOICES,
386
+ side === "local" ? TEMPLATE_LOCAL.clock : TEMPLATE_DOCKER_PROD.clock,
387
+ );
388
+ if (v === null || v === WIZARD_BACK) return v;
389
+ state.clock = v;
390
+ return true;
391
+ }
392
+ case "vault": {
393
+ const v = await askSideChoice(
394
+ `vault — ${suffix}`,
395
+ VAULT_CHOICES,
396
+ side === "local" ? TEMPLATE_LOCAL.vault : TEMPLATE_DOCKER_PROD.vault,
397
+ );
398
+ if (v === null || v === WIZARD_BACK) return v;
399
+ state.vault = v;
400
+ return true;
401
+ }
402
+ case "email": {
403
+ const v = await askSideChoice(
404
+ `channel.email — ${suffix}`,
405
+ EMAIL_CHOICES,
406
+ side === "local" ? TEMPLATE_LOCAL.email : TEMPLATE_DOCKER_PROD.email,
407
+ );
408
+ if (v === null || v === WIZARD_BACK) return v;
409
+ state.email = v;
410
+ return true;
411
+ }
412
+ default:
413
+ return null;
414
+ }
415
+ }
416
+
417
+ async function askSideChoice(
418
+ label: string,
419
+ choices: readonly DriverChoice[],
420
+ recommended: string,
421
+ ): Promise<string | WizardBack | null> {
422
+ return selectWithBack(
423
+ label,
424
+ choices.map((c) => ({
425
+ value: c.value,
426
+ label: c.label,
427
+ hint: c.value === recommended ? "recommended" : undefined,
428
+ })),
429
+ recommended,
430
+ { allowBack: true },
431
+ );
432
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Boot-implemented driver choices offered by the create-oke customize wizard.
3
+ *
4
+ * Never lists ids that fail loud at boot (signal postgres/nats, clock postgres,
5
+ * ai bedrock/vertex).
6
+ */
7
+
8
+ import type { CreateDefaults, CreateProfile, EnvDriverPins } from "./create-defaults.ts";
9
+ import type { TemplateId } from "./templates.ts";
10
+
11
+ /** One selectable driver option. */
12
+ export type DriverChoice = {
13
+ readonly value: string;
14
+ readonly label: string;
15
+ };
16
+
17
+ /** Standard template docker/prod pins (unchanged for local-only profile). */
18
+ export const TEMPLATE_DOCKER_PROD = {
19
+ sql: "postgres",
20
+ kv: "redis",
21
+ files: "s3",
22
+ signal: "redis",
23
+ clock: "file",
24
+ vault: "openbao",
25
+ email: "smtp",
26
+ } as const;
27
+
28
+ /** Standard template local defaults. */
29
+ export const TEMPLATE_LOCAL = {
30
+ sql: "sqlite",
31
+ kv: "memory",
32
+ files: "fs",
33
+ signal: "memory",
34
+ clock: "memory",
35
+ vault: "env",
36
+ email: "console",
37
+ } as const;
38
+
39
+ /** Test-column defaults (always applied). */
40
+ export const TEMPLATE_TEST = {
41
+ sql: "memory",
42
+ kv: "memory",
43
+ files: "memory",
44
+ signal: "memory",
45
+ clock: "frozen",
46
+ vault: "memory",
47
+ email: "console",
48
+ } as const;
49
+
50
+ export const SQL_CHOICES: readonly DriverChoice[] = [
51
+ { value: "sqlite", label: "sqlite" },
52
+ { value: "postgres", label: "postgres" },
53
+ { value: "libsql", label: "libsql" },
54
+ { value: "pglite", label: "pglite" },
55
+ { value: "memory", label: "memory" },
56
+ ];
57
+
58
+ export const KV_CHOICES: readonly DriverChoice[] = [
59
+ { value: "memory", label: "memory" },
60
+ { value: "redis", label: "redis" },
61
+ ];
62
+
63
+ export const FILES_CHOICES: readonly DriverChoice[] = [
64
+ { value: "fs", label: "fs" },
65
+ { value: "s3", label: "s3" },
66
+ { value: "memory", label: "memory" },
67
+ ];
68
+
69
+ export const INDEX_CHOICES: readonly DriverChoice[] = [
70
+ { value: "memory", label: "memory" },
71
+ { value: "pgvector", label: "pgvector" },
72
+ { value: "libsql", label: "libsql" },
73
+ { value: "meilisearch", label: "meilisearch" },
74
+ ];
75
+
76
+ export const SIGNAL_CHOICES: readonly DriverChoice[] = [
77
+ { value: "memory", label: "memory" },
78
+ { value: "redis", label: "redis" },
79
+ ];
80
+
81
+ export const CLOCK_CHOICES: readonly DriverChoice[] = [
82
+ { value: "memory", label: "memory" },
83
+ { value: "file", label: "file" },
84
+ { value: "frozen", label: "frozen" },
85
+ ];
86
+
87
+ export const VAULT_CHOICES: readonly DriverChoice[] = [
88
+ { value: "env", label: "env" },
89
+ { value: "openbao", label: "openbao" },
90
+ { value: "managed", label: "managed" },
91
+ { value: "memory", label: "memory" },
92
+ ];
93
+
94
+ export const EMAIL_CHOICES: readonly DriverChoice[] = [
95
+ { value: "console", label: "console" },
96
+ { value: "smtp", label: "smtp" },
97
+ { value: "resend", label: "resend" },
98
+ { value: "sndr", label: "sndr" },
99
+ ];
100
+
101
+ /** AI menu providers → protocol driver. */
102
+ export const AI_PROVIDERS = [
103
+ { value: "ollama", label: "◎ Ollama (Local)", driver: "ollama" },
104
+ { value: "openai", label: "◈ OpenAI", driver: "openai-compatible" },
105
+ { value: "anthropic", label: "◉ Anthropic", driver: "anthropic" },
106
+ { value: "gemini", label: "◇ Gemini", driver: "openai-compatible" },
107
+ { value: "lmstudio", label: "▣ LM Studio", driver: "openai-compatible" },
108
+ { value: "openrouter", label: "⇄ OpenRouter", driver: "openai-compatible" },
109
+ { value: "custom", label: "⋯ Custom OpenAI Compatible", driver: "openai-compatible" },
110
+ { value: "mock", label: "◌ Mock (dev only)", driver: "mock" },
111
+ ] as const;
112
+
113
+ export type AiProviderId = (typeof AI_PROVIDERS)[number]["value"];
114
+
115
+ /**
116
+ * Build env pins for local-only: user local + template docker/prod + test.
117
+ *
118
+ * @param local - Chosen local driver
119
+ * @param dockerProd - Template docker/prod pin for this facet
120
+ * @param test - Test pin
121
+ */
122
+ export function pinsLocalOnly(local: string, dockerProd: string, test: string): EnvDriverPins {
123
+ return { local, docker: dockerProd, test, prod: dockerProd };
124
+ }
125
+
126
+ /**
127
+ * Build env pins when the user chose both columns (docker-ready).
128
+ *
129
+ * @param local - Local driver
130
+ * @param docker - Docker driver (also copied to prod)
131
+ * @param test - Test pin
132
+ */
133
+ export function pinsDockerReady(local: string, docker: string, test: string): EnvDriverPins {
134
+ return { local, docker, test, prod: docker };
135
+ }
136
+
137
+ /**
138
+ * Default recommended create-defaults (matches template driver maps).
139
+ *
140
+ * @param profile - Profile assumption
141
+ * @param template - Starter id
142
+ */
143
+ export function recommendedDefaults(
144
+ profile: CreateProfile = "docker-ready",
145
+ template: TemplateId = "standard",
146
+ ): CreateDefaults {
147
+ const store = {
148
+ sql: pinsLocalOnly(TEMPLATE_LOCAL.sql, TEMPLATE_DOCKER_PROD.sql, TEMPLATE_TEST.sql),
149
+ kv: pinsLocalOnly(TEMPLATE_LOCAL.kv, TEMPLATE_DOCKER_PROD.kv, TEMPLATE_TEST.kv),
150
+ files: pinsLocalOnly(TEMPLATE_LOCAL.files, TEMPLATE_DOCKER_PROD.files, TEMPLATE_TEST.files),
151
+ index: null as EnvDriverPins | null,
152
+ };
153
+ return {
154
+ version: 1,
155
+ template,
156
+ profile,
157
+ drivers: {
158
+ store,
159
+ signal: pinsLocalOnly(
160
+ TEMPLATE_LOCAL.signal,
161
+ TEMPLATE_DOCKER_PROD.signal,
162
+ TEMPLATE_TEST.signal,
163
+ ),
164
+ clock: pinsLocalOnly(TEMPLATE_LOCAL.clock, TEMPLATE_DOCKER_PROD.clock, TEMPLATE_TEST.clock),
165
+ vault: pinsLocalOnly(TEMPLATE_LOCAL.vault, TEMPLATE_DOCKER_PROD.vault, TEMPLATE_TEST.vault),
166
+ channel: {
167
+ email: pinsLocalOnly(TEMPLATE_LOCAL.email, TEMPLATE_DOCKER_PROD.email, TEMPLATE_TEST.email),
168
+ },
169
+ ai: null,
170
+ },
171
+ ai: { enabled: false, provider: null, driver: null },
172
+ updatedAt: new Date().toISOString(),
173
+ };
174
+ }
175
+
176
+ /** Driver facet ids walked during customize (env columns only). */
177
+ export type CustomizeFacetId =
178
+ | "sql"
179
+ | "kv"
180
+ | "files"
181
+ | "enableIndex"
182
+ | "index"
183
+ | "signal"
184
+ | "clock"
185
+ | "vault"
186
+ | "email";
187
+
188
+ /**
189
+ * Facets shown for a template (AI is asked after both env passes).
190
+ *
191
+ * @param template - Starter id
192
+ */
193
+ export function customizeFacetsFor(template: TemplateId): readonly CustomizeFacetId[] {
194
+ if (template === "standard") return ["sql"];
195
+ return ["sql", "kv", "files", "enableIndex", "index", "signal", "clock", "vault", "email"];
196
+ }
197
+
198
+ /**
199
+ * Resolve protocol driver for an AI provider menu id.
200
+ *
201
+ * @param provider - Menu value
202
+ */
203
+ export function aiDriverForProvider(provider: string): string {
204
+ const hit = AI_PROVIDERS.find((p) => p.value === provider);
205
+ return hit?.driver ?? "mock";
206
+ }
207
+
208
+ /** Default image pins keyed by role (standard template). */
209
+ export const DEFAULT_IMAGES: Readonly<Record<string, string>> = {
210
+ "store.sql": "postgres:18-alpine",
211
+ "store.kv": "redis:8-alpine",
212
+ "store.files": "rustfs/rustfs:1.0.0-beta.11",
213
+ "channel.email": "axllent/mailpit:v1.22.3",
214
+ vault: "openbao/openbao:2.6.1",
215
+ "store.index": "getmeili/meilisearch:v1.37",
216
+ ai: "ollama/ollama:latest",
217
+ };