create-oke 0.18.5 → 0.19.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 (62) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/agents-md.ts +6 -4
  4. package/src/ai-setup/apply.test.ts +278 -0
  5. package/src/ai-setup/apply.ts +430 -52
  6. package/src/ai-setup/catalog.ts +250 -1343
  7. package/src/ai-setup/from-pref.ts +3 -71
  8. package/src/ai-setup/prompts.ts +60 -443
  9. package/src/cli.test.ts +21 -15
  10. package/src/cli.ts +20 -11
  11. package/src/create-defaults.test.ts +4 -4
  12. package/src/create-defaults.ts +3 -3
  13. package/src/customize-flow.test.ts +9 -15
  14. package/src/customize-flow.ts +59 -66
  15. package/src/drivers-catalog.ts +18 -23
  16. package/src/transform.test.ts +69 -10
  17. package/src/transform.ts +10 -35
  18. package/templates/advanced/.env.example +28 -11
  19. package/templates/advanced/.github/workflows/ci.yml +1 -1
  20. package/templates/advanced/oke.config.ts +3 -4
  21. package/templates/advanced/package.json +11 -10
  22. package/templates/advanced/src/app.ts +36 -1
  23. package/templates/advanced/src/core.ts +12 -11
  24. package/templates/advanced/src/db/schema.decl.ts +6 -2
  25. package/templates/advanced/src/db/seed/index.ts +4 -4
  26. package/templates/advanced/src/flows/main/route.ts +3 -2
  27. package/templates/advanced/src/flows/notes/[id]/archive.ts +5 -8
  28. package/templates/advanced/src/flows/notes/[id]/attach.ts +1 -4
  29. package/templates/advanced/src/flows/notes/[id]/get.ts +4 -7
  30. package/templates/advanced/src/flows/notes/[id]/summarize.ts +3 -4
  31. package/templates/advanced/src/flows/notes/create.ts +4 -6
  32. package/templates/advanced/src/flows/notes/digest.ts +6 -5
  33. package/templates/advanced/src/flows/notes/list.ts +4 -5
  34. package/templates/advanced/src/flows/notes/shapes.ts +13 -3
  35. package/templates/advanced/src/flows/notes/signals.ts +1 -2
  36. package/templates/advanced/src/vault.ts +138 -0
  37. package/templates/advanced/tests/advanced.test.ts +11 -8
  38. package/templates/advanced/web/src/App.css +7 -0
  39. package/templates/advanced/web/src/App.tsx +101 -1
  40. package/templates/advanced/web/src/client.ts +16 -3
  41. package/templates/advanced/web/vite.config.ts +1 -0
  42. package/templates/standard/.env.example +22 -11
  43. package/templates/standard/.github/workflows/ci.yml +1 -1
  44. package/templates/standard/oke.config.ts +3 -3
  45. package/templates/standard/package.json +11 -10
  46. package/templates/standard/src/app.ts +6 -1
  47. package/templates/standard/src/core.ts +10 -11
  48. package/templates/standard/src/db/schema.decl.ts +6 -2
  49. package/templates/standard/src/db/seed/index.ts +3 -3
  50. package/templates/standard/src/flows/main/route.ts +3 -2
  51. package/templates/standard/src/flows/notes/[id]/archive.ts +5 -8
  52. package/templates/standard/src/flows/notes/[id]/get.ts +4 -7
  53. package/templates/standard/src/flows/notes/create.ts +4 -6
  54. package/templates/standard/src/flows/notes/list.ts +4 -5
  55. package/templates/standard/src/flows/notes/shapes.ts +12 -2
  56. package/templates/standard/src/flows/notes/signals.ts +1 -2
  57. package/templates/standard/src/vault.ts +123 -0
  58. package/templates/standard/tests/standard.test.ts +3 -1
  59. package/templates/standard/web/src/client.ts +2 -2
  60. package/templates/standard/web/vite.config.ts +1 -0
  61. package/src/ai-setup/detect-ollama.ts +0 -228
  62. package/src/ai-setup/recommend.ts +0 -220
package/src/cli.test.ts CHANGED
@@ -282,24 +282,24 @@ describe("wizard ← Back", () => {
282
282
  });
283
283
 
284
284
  describe("aiSetupProviderFor", () => {
285
- test("prefers ollama when prod/dev is ollama even if menu is mock", () => {
285
+ test("maps mock menu to custom when openai-compatible is pinned", () => {
286
286
  expect(
287
287
  aiSetupProviderFor("mock", {
288
- dev: "mock",
288
+ dev: "openai-compatible",
289
289
  test: "mock",
290
- prod: "ollama",
290
+ prod: "openai-compatible",
291
291
  }),
292
- ).toBe("ollama");
292
+ ).toBe("custom");
293
293
  });
294
294
 
295
- test("keeps menu ollama", () => {
295
+ test("keeps menu lmstudio", () => {
296
296
  expect(
297
- aiSetupProviderFor("ollama", {
298
- dev: "ollama",
297
+ aiSetupProviderFor("lmstudio", {
298
+ dev: "openai-compatible",
299
299
  test: "mock",
300
- prod: "anthropic",
300
+ prod: "openai-compatible",
301
301
  }),
302
- ).toBe("ollama");
302
+ ).toBe("lmstudio");
303
303
  });
304
304
  });
305
305
 
@@ -339,7 +339,7 @@ describe("interactive branches", () => {
339
339
  expect(existsSync(join(dir, "src", "flows", "notes", "digest.ts"))).toBe(true);
340
340
  expect(existsSync(join(dir, "src", "flows", "notes", "[id]", "attach.ts"))).toBe(true);
341
341
  const digest = readFileSync(join(dir, "src", "flows", "notes", "digest.ts"), "utf8");
342
- expect(digest).toContain('every("1d")');
342
+ expect(digest).toContain('clock("notes.digest", { every: "1d" })');
343
343
  const attach = readFileSync(join(dir, "src", "flows", "notes", "[id]", "attach.ts"), "utf8");
344
344
  expect(attach).toContain("export const attach");
345
345
  expect(readFileSync(join(dir, "oke.config.ts"), "utf8")).toMatch(
@@ -616,7 +616,11 @@ describe("scaffold structure", () => {
616
616
  expect(appTs).toMatch(/oke\(\{\s*name:\s*["']notes["']/);
617
617
  const pkg = JSON.parse(readFileSync(join(result.targetDir, "package.json"), "utf8")) as {
618
618
  name: string;
619
- dependencies: { okengine: string; "@duckdb/node-api"?: string };
619
+ dependencies: {
620
+ okengine: string;
621
+ "@duckdb/node-api"?: string;
622
+ "oxc-parser"?: string;
623
+ };
620
624
  trustedDependencies?: readonly string[];
621
625
  scripts: { typecheck?: string; test?: string; web?: string; "web:build"?: string };
622
626
  devDependencies: {
@@ -628,7 +632,8 @@ describe("scaffold structure", () => {
628
632
  };
629
633
  expect(pkg.name).toBe(`app-${id}`);
630
634
  expect(pkg.dependencies.okengine).not.toMatch(/^file:\.\./);
631
- expect(pkg.dependencies["@duckdb/node-api"]).toBe("^1.5.5-r.2");
635
+ expect(pkg.dependencies["@duckdb/node-api"]).toBe("^1.5.5-r.4");
636
+ expect(pkg.dependencies["oxc-parser"]).toBe("^0.149.0");
632
637
  expect(pkg.trustedDependencies).toContain("@duckdb/node-api");
633
638
  expect(pkg.scripts.typecheck).toContain("tsc --noEmit");
634
639
  expect(pkg.scripts.typecheck).toContain("tsc -b -p web/tsconfig.json");
@@ -638,9 +643,9 @@ describe("scaffold structure", () => {
638
643
  expect(result.files).toContain("web/vite.config.ts");
639
644
  expect(result.files).toContain("web/src/client.ts");
640
645
  expect(pkg.devDependencies.typescript).toBeTruthy();
641
- expect(pkg.devDependencies.vite).toBe("^8.2.0");
642
- expect(pkg.devDependencies["@electric-sql/pglite"]).toBe("^0.5.4");
643
- expect(pkg.devDependencies["@electric-sql/pglite-pgvector"]).toBe("^0.0.5");
646
+ expect(pkg.devDependencies.vite).toBe("^8.3.0");
647
+ expect(pkg.devDependencies["@electric-sql/pglite"]).toBe("^0.5.8");
648
+ expect(pkg.devDependencies["@electric-sql/pglite-pgvector"]).toBe("^0.0.9");
644
649
  const drizzle = readFileSync(join(result.targetDir, "drizzle.config.ts"), "utf8");
645
650
  expect(drizzle).toContain('dialect: "postgresql"');
646
651
  expect(drizzle).toContain("DATABASE_URL");
@@ -667,6 +672,7 @@ describe("scaffold structure", () => {
667
672
  });
668
673
  for (const path of [
669
674
  "src/core.ts",
675
+ "src/vault.ts",
670
676
  "src/locales/en.ts",
671
677
  "src/locales/index.ts",
672
678
  "src/flows/main/shapes.ts",
package/src/cli.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  log,
17
17
  note,
18
18
  outro,
19
+ password,
19
20
  select,
20
21
  spinner,
21
22
  text,
@@ -494,7 +495,7 @@ export function scaffoldArgsFromCli(args: CliArgs): ScaffoldCallArgs {
494
495
  source: sourceFromArgs(args),
495
496
  agentsMd: args.agentsMd,
496
497
  sqlDriver: args.sqlDriver,
497
- aiApply: args.ai === "force" ? nonInteractiveAiApply("llama-cpp") : null,
498
+ aiApply: args.ai === "force" ? nonInteractiveAiApply("openrouter") : null,
498
499
  locales: args.locales,
499
500
  pgdog: args.pgdog ?? false,
500
501
  proxy: args.proxy ?? "none",
@@ -715,6 +716,17 @@ export async function askInteractiveAnswers(
715
716
  ai: aiPrefWithModels(prev.ai, picked),
716
717
  updatedAt: new Date().toISOString(),
717
718
  });
719
+ } else if (aiApply?.apiKeyEnv && !aiApply.apiKey) {
720
+ // Prefs never store the token — ask again so `.env.local` + Vault get it.
721
+ const token = await password({
722
+ message: `API token (${aiApply.apiKeyEnv})`,
723
+ validate: (v) => {
724
+ if (!v?.trim()) return "API token is required";
725
+ return undefined;
726
+ },
727
+ });
728
+ if (isCancel(token)) return null;
729
+ aiApply = { ...aiApply, apiKey: String(token).trim() };
718
730
  }
719
731
  }
720
732
  note(`Reusing settings from ${io.path}`, "Previous settings");
@@ -724,10 +736,12 @@ export async function askInteractiveAnswers(
724
736
  const customized = await askCustomizeFlow(template);
725
737
  if (customized === null) return null;
726
738
  if (customized === WIZARD_BACK) continue;
727
- createDefaults = customized;
739
+ createDefaults = customized.defaults;
728
740
  persistDefaults = true;
729
- if (customized.ai.enabled && partial.ai !== "skip") {
730
- aiApply = applyInputFromAiPref(customized.ai);
741
+ // Use the live apply payload (includes apiKey). Never rebuild from prefs
742
+ // alone — that drops the token and leaves `# OPENROUTER_API_KEY=` empty.
743
+ if (partial.ai !== "skip") {
744
+ aiApply = customized.aiApply;
731
745
  }
732
746
  break;
733
747
  }
@@ -893,21 +907,16 @@ async function askProxy(initial: CreateProxyId = "none"): Promise<CreateProxyId
893
907
  }
894
908
 
895
909
  /**
896
- * Provider passed to `oke ai setup` — prefer native Ollama when either env uses it.
910
+ * Provider passed to `oke ai setup` — map mock pins to a concrete menu id.
897
911
  *
898
912
  * @param menuProvider - Menu id chosen in the wizard
899
913
  * @param pins - Resolved driver pins
900
914
  */
901
915
  export function aiSetupProviderFor(menuProvider: string, pins: EnvDriverPins): string {
902
- if (menuProvider === "ollama" || pins.dev === "ollama" || pins.prod === "ollama") {
903
- return "ollama";
904
- }
905
- if (menuProvider === "llama-cpp") return "llama-cpp";
906
- if (menuProvider === "vllm" || menuProvider === "sglang") return menuProvider;
907
916
  if (menuProvider === "mock") {
908
917
  if (pins.dev === "anthropic" || pins.prod === "anthropic") return "anthropic";
909
918
  if (pins.dev === "openai-compatible" || pins.prod === "openai-compatible") {
910
- return "llama-cpp";
919
+ return "custom";
911
920
  }
912
921
  }
913
922
  return menuProvider;
@@ -73,9 +73,9 @@ describe("read/write round-trip", () => {
73
73
  clock: pinsDockerReady("file", "frozen"),
74
74
  vault: pinsDockerReady("vault", "memory"),
75
75
  channel: { email: pinsDockerReady("smtp", "console") },
76
- ai: pinsDockerReady("ollama", "mock"),
76
+ ai: pinsDockerReady("openai-compatible", "mock"),
77
77
  },
78
- ai: { enabled: true, provider: "ollama", driver: "ollama" },
78
+ ai: { enabled: true, provider: "openrouter", driver: "openai-compatible" },
79
79
  locales: ["ar"],
80
80
  pgdog: true,
81
81
  proxy: "caddy",
@@ -85,8 +85,8 @@ describe("read/write round-trip", () => {
85
85
  expect(loaded).not.toBeNull();
86
86
  expect(loaded!.template).toBe("advanced");
87
87
  expect(loaded!.drivers.store.sql.dev).toBe("postgres");
88
- expect(loaded!.drivers.ai?.dev).toBe("ollama");
89
- expect(loaded!.ai.provider).toBe("ollama");
88
+ expect(loaded!.drivers.ai?.dev).toBe("openai-compatible");
89
+ expect(loaded!.ai.provider).toBe("openrouter");
90
90
  expect(loaded!.profile).toBe("docker-ready");
91
91
  expect(loaded!.locales).toEqual(["ar"]);
92
92
  expect(loaded!.pgdog).toBe(true);
@@ -26,15 +26,15 @@ export type EnvDriverPins = {
26
26
  /** Optional AI preference saved with create defaults. */
27
27
  export type CreateAiPref = {
28
28
  readonly enabled: boolean;
29
- /** Menu id: ollama · openai · anthropic · gemini · lmstudio · openrouter · custom · mock */
29
+ /** Menu id: openrouter · openai · anthropic · gemini · lmstudio · custom · mock · */
30
30
  readonly provider: string | null;
31
31
  /** Protocol driver id written to `drivers.ai`. */
32
32
  readonly driver: string | null;
33
33
  /** Chat model id (from wizard / `oke ai setup`). */
34
34
  readonly chatModel?: string | null;
35
- /** Vision model id (ollama). */
35
+ /** Vision model id. */
36
36
  readonly visionModel?: string | null;
37
- /** Embedding model id (ollama). */
37
+ /** Embedding model id. */
38
38
  readonly embedModel?: string | null;
39
39
  /** Optional OpenAI-compatible base URL. */
40
40
  readonly baseUrl?: string | null;
@@ -47,21 +47,15 @@ describe("catalog labels", () => {
47
47
  });
48
48
 
49
49
  describe("recommendedAiApply", () => {
50
- test("returns llama.cpp (openai-compatible) with curated ai/ model", () => {
51
- const prev = process.env.OKE_AI_URL;
52
- delete process.env.OKE_AI_URL;
53
- try {
54
- const apply = recommendedAiApply();
55
- expect(apply.driver).toBe("openai-compatible");
56
- expect(apply.chatModel).toBe("granite3.3:2b");
57
- expect(apply.baseUrl).toContain("8080");
58
- expect(apply.image).toContain("llama.cpp");
59
- expect(apply.image).not.toContain("latest");
60
- expect(apply.visionModel).toBeNull();
61
- } finally {
62
- if (prev !== undefined) process.env.OKE_AI_URL = prev;
63
- else delete process.env.OKE_AI_URL;
64
- }
50
+ test("returns OpenRouter with openrouter/free (no Docker image)", () => {
51
+ const apply = recommendedAiApply();
52
+ expect(apply.driver).toBe("openai-compatible");
53
+ expect(apply.provider).toBe("openrouter");
54
+ expect(apply.chatModel).toBe("openrouter/free");
55
+ expect(apply.baseUrl).toBeUndefined();
56
+ expect(apply.apiKeyEnv).toBe("OPENROUTER_API_KEY");
57
+ expect(apply.image).toBeUndefined();
58
+ expect(apply.visionModel).toBeNull();
65
59
  });
66
60
  });
67
61
 
@@ -2,6 +2,7 @@
2
2
  * Customize wizard — walk Docker-first facets once, assemble `{ dev, test, prod }`.
3
3
  */
4
4
 
5
+ import { isCancel, password } from "@clack/prompts";
5
6
  import { toCreateDefaults, type CreateDefaults, type EnvDriverPins } from "./create-defaults.ts";
6
7
  import {
7
8
  CLOCK_CHOICES,
@@ -14,20 +15,18 @@ import {
14
15
  TEMPLATE_DEV,
15
16
  TEMPLATE_TEST,
16
17
  VAULT_CHOICES,
17
- AI_PROVIDERS,
18
- LLAMA_CPP_IMAGE,
19
- OLLAMA_IMAGE,
20
- SGLANG_IMAGE,
21
- VLLM_IMAGE,
22
- aiDriverForProvider,
23
18
  customizeFacetsFor,
24
19
  pinsDockerReady,
25
20
  pinsEnv,
26
- type AiProviderId,
27
21
  type CustomizeFacetId,
28
22
  type DriverChoice,
29
23
  } from "./drivers-catalog.ts";
30
24
  import type { AiSetupApplyInput } from "./ai-setup/apply.ts";
25
+ import {
26
+ aiDriverForMenuProvider,
27
+ aiProviderSelectOptions,
28
+ cloudApplyDefaults,
29
+ } from "./ai-setup/catalog.ts";
31
30
  import { aiPrefWithModels } from "./ai-setup/from-pref.ts";
32
31
  import { askAiSetup } from "./ai-setup/prompts.ts";
33
32
  import type { TemplateId } from "./templates.ts";
@@ -95,54 +94,42 @@ export function assembleDriverDefaults(picked: SidePins): CreateDefaults["driver
95
94
  }
96
95
 
97
96
  /**
98
- * Balanced llama.cpp defaults for "AI setup → Recommended" (no quiz).
99
- * openai-compatible + curated Docker Hub `ai/` model (`granite3.3:2b`).
97
+ * Cloud defaults for "AI setup → Recommended" (no quiz).
98
+ * OpenRouter registry provider + `openrouter/free` (zero Docker).
100
99
  */
101
100
  export function recommendedAiApply(): AiSetupApplyInput {
102
- return {
103
- driver: "openai-compatible",
104
- baseUrl: process.env.OKE_AI_URL ?? "http://127.0.0.1:8080/v1",
105
- chatModel: "granite3.3:2b",
106
- visionModel: null,
107
- embedModel: null,
108
- image: LLAMA_CPP_IMAGE,
109
- };
101
+ return cloudApplyDefaults("openrouter");
110
102
  }
111
103
 
112
104
  /**
113
- * Attach the compose image pin for a local/self-hosted AI provider menu id.
105
+ * Recommended path OpenRouter defaults, then prompt for `OPENROUTER_API_KEY`.
114
106
  *
115
- * @param input - Apply input from prompts
116
- * @param provider - Menu provider id
107
+ * @returns Apply input with token, or null on cancel
117
108
  */
118
- export function withLocalAiImage(input: AiSetupApplyInput, provider: string): AiSetupApplyInput {
119
- if (provider === "llama-cpp") {
120
- return {
121
- ...input,
122
- image: LLAMA_CPP_IMAGE,
123
- baseUrl: input.baseUrl ?? "http://127.0.0.1:8080/v1",
124
- };
125
- }
126
- if (provider === "ollama") {
127
- return { ...input, image: OLLAMA_IMAGE };
128
- }
129
- if (provider === "vllm") {
130
- return {
131
- ...input,
132
- image: VLLM_IMAGE,
133
- baseUrl: input.baseUrl ?? "http://127.0.0.1:8000/v1",
134
- };
135
- }
136
- if (provider === "sglang") {
137
- return {
138
- ...input,
139
- image: SGLANG_IMAGE,
140
- baseUrl: input.baseUrl ?? "http://127.0.0.1:30000/v1",
141
- };
142
- }
143
- return input;
109
+ export async function askRecommendedAiApply(): Promise<AiSetupApplyInput | null> {
110
+ const base = recommendedAiApply();
111
+ const apiKeyEnv = base.apiKeyEnv ?? "OPENROUTER_API_KEY";
112
+ const token = await password({
113
+ message: `API token (${apiKeyEnv})`,
114
+ validate: (v) => {
115
+ if (!v?.trim()) return "API token is required";
116
+ return undefined;
117
+ },
118
+ });
119
+ if (isCancel(token)) return null;
120
+ return { ...base, apiKey: String(token).trim() };
144
121
  }
145
122
 
123
+ /**
124
+ * Result of a completed Customize pass — defaults for persistence plus the
125
+ * live {@link AiSetupApplyInput} (includes the API token; never write the
126
+ * token into `create-defaults.json`).
127
+ */
128
+ export type CustomizeFlowResult = {
129
+ readonly defaults: CreateDefaults;
130
+ readonly aiApply: AiSetupApplyInput | null;
131
+ };
132
+
146
133
  /**
147
134
  * Customize drivers for one template.
148
135
  *
@@ -150,7 +137,7 @@ export function withLocalAiImage(input: AiSetupApplyInput, provider: string): Ai
150
137
  */
151
138
  export async function askCustomizeFlow(
152
139
  template: TemplateId,
153
- ): Promise<CreateDefaults | WizardBack | null> {
140
+ ): Promise<CustomizeFlowResult | WizardBack | null> {
154
141
  const facets = customizeFacetsFor(template);
155
142
  const primaryPins = await walkFacets(facets);
156
143
  if (primaryPins === null) return null;
@@ -163,14 +150,14 @@ export async function askCustomizeFlow(
163
150
  let aiPref: CreateDefaults["ai"] = { enabled: false, provider: null, driver: null };
164
151
  let aiApply: AiSetupApplyInput | null = null;
165
152
 
166
- aiSetup: for (;;) {
153
+ for (;;) {
167
154
  const aiSetup = await selectWithBack(
168
155
  "AI setup",
169
156
  [
170
157
  {
171
158
  value: "recommended",
172
159
  label: "Recommended",
173
- hint: "llama.cpp · openai-compatible · granite3.3:2b",
160
+ hint: "openrouter · openrouter/free",
174
161
  },
175
162
  {
176
163
  value: "customize",
@@ -193,11 +180,13 @@ export async function askCustomizeFlow(
193
180
  if (aiSetup === "off") break;
194
181
  if (aiSetup === "recommended") {
195
182
  aiPins = pinsDockerReady("openai-compatible", "mock");
196
- aiApply = recommendedAiApply();
183
+ const picked = await askRecommendedAiApply();
184
+ if (picked === null) return null;
185
+ aiApply = picked;
197
186
  aiPref = aiPrefWithModels(
198
187
  {
199
188
  enabled: true,
200
- provider: "llama-cpp",
189
+ provider: "openrouter",
201
190
  driver: "openai-compatible",
202
191
  },
203
192
  aiApply,
@@ -205,21 +194,21 @@ export async function askCustomizeFlow(
205
194
  break;
206
195
  }
207
196
 
208
- const options = AI_PROVIDERS.map((p) => ({ value: p.value, label: p.label }));
209
- const providerValue = await selectWithBack("AI Provider", options, "llama-cpp", {
197
+ const options = aiProviderSelectOptions({ includeMock: true });
198
+ const providerValue = await selectWithBack("AI Provider", options, "openrouter", {
210
199
  allowBack: true,
211
200
  });
212
201
  if (providerValue === null) return null;
213
202
  if (providerValue === WIZARD_BACK) continue;
214
203
 
215
- const provider = providerValue as AiProviderId;
216
- const driver = aiDriverForProvider(provider);
204
+ const provider = providerValue;
205
+ const driver = aiDriverForMenuProvider(provider);
217
206
  aiPins = pinsDockerReady(driver, "mock");
218
207
 
219
208
  if (provider !== "mock") {
220
209
  const picked = await askAiSetup({ provider });
221
210
  if (picked === null) return null;
222
- aiApply = withLocalAiImage(picked, provider);
211
+ aiApply = picked;
223
212
  } else {
224
213
  aiApply = { driver: "mock" };
225
214
  }
@@ -235,16 +224,20 @@ export async function askCustomizeFlow(
235
224
  break;
236
225
  }
237
226
 
238
- return toCreateDefaults({
239
- template,
240
- profile: "docker-ready",
241
- drivers: { ...drivers, ai: aiPins },
242
- ai: aiPref,
243
- // Locales / PgDog / proxy are asked once in the main wizard (not per customize pass).
244
- locales: [],
245
- pgdog: false,
246
- proxy: "none",
247
- });
227
+ return {
228
+ defaults: toCreateDefaults({
229
+ template,
230
+ profile: "docker-ready",
231
+ drivers: { ...drivers, ai: aiPins },
232
+ ai: aiPref,
233
+ // Locales / PgDog / proxy are asked once in the main wizard (not per customize pass).
234
+ locales: [],
235
+ pgdog: false,
236
+ proxy: "none",
237
+ }),
238
+ // Keep the live token here — prefs persist only `apiKeyEnv`, never the value.
239
+ aiApply,
240
+ };
248
241
  }
249
242
 
250
243
  /**
@@ -108,29 +108,25 @@ export const EMAIL_CHOICES: readonly DriverChoice[] = [
108
108
  { value: "taqnyat-mail", label: "taqnyat" },
109
109
  ];
110
110
 
111
- /**
112
- * Pinned local-inference images — must match `src/docker/recipes/*` pins.
113
- * Never `latest` (GGUF-parser CVE floors: llama.cpp ≥ b8146, Ollama ≥ 0.17.1).
114
- */
115
- export const LLAMA_CPP_IMAGE = "ghcr.io/ggml-org/llama.cpp:server-b10450";
116
- /** @see LLAMA_CPP_IMAGE */
117
- export const OLLAMA_IMAGE = "ollama/ollama:0.32.13";
118
- /** @see LLAMA_CPP_IMAGE */
119
- export const VLLM_IMAGE = "vllm/vllm-openai:v0.27.1";
120
- /** @see LLAMA_CPP_IMAGE */
121
- export const SGLANG_IMAGE = "lmsysorg/sglang:v0.5.17-runtime";
122
-
123
- /** AI menu providers → protocol driver. */
111
+ /** AI menu providers → protocol driver (kept for call-site compat). */
124
112
  export const AI_PROVIDERS = [
125
- { value: "llama-cpp", label: "llama.cpp (Local)", driver: "openai-compatible" },
126
- { value: "ollama", label: "Ollama (Local)", driver: "ollama" },
127
- { value: "vllm", label: "vLLM (self-hosted GPU)", driver: "openai-compatible" },
128
- { value: "sglang", label: "SGLang (self-hosted GPU)", driver: "openai-compatible" },
113
+ {
114
+ value: "openrouter",
115
+ label: "OpenRouter",
116
+ driver: "openai-compatible",
117
+ hint: "recommended · zero Docker · openrouter/free",
118
+ },
129
119
  { value: "openai", label: "OpenAI", driver: "openai-compatible" },
130
120
  { value: "anthropic", label: "Anthropic", driver: "anthropic" },
131
- { value: "gemini", label: "Gemini", driver: "openai-compatible" },
121
+ { value: "groq", label: "Groq", driver: "openai-compatible" },
122
+ { value: "together", label: "Together AI", driver: "openai-compatible" },
123
+ { value: "deepseek", label: "DeepSeek", driver: "openai-compatible" },
124
+ { value: "mistral", label: "Mistral", driver: "openai-compatible" },
125
+ { value: "xai", label: "xAI (Grok)", driver: "openai-compatible" },
126
+ { value: "deepinfra", label: "DeepInfra", driver: "openai-compatible" },
127
+ { value: "vercel", label: "Vercel AI Gateway", driver: "openai-compatible" },
128
+ { value: "gemini", label: "Google Gemini", driver: "openai-compatible" },
132
129
  { value: "lmstudio", label: "LM Studio", driver: "openai-compatible" },
133
- { value: "openrouter", label: "OpenRouter", driver: "openai-compatible" },
134
130
  { value: "custom", label: "Custom OpenAI Compatible", driver: "openai-compatible" },
135
131
  { value: "mock", label: "Mock (dev only)", driver: "mock" },
136
132
  ] as const;
@@ -230,12 +226,11 @@ export function aiDriverForProvider(provider: string): string {
230
226
  /** Default image pins keyed by role (standard template). */
231
227
  export const DEFAULT_IMAGES: Readonly<Record<string, string>> = {
232
228
  "store.sql": "postgres:18-alpine",
233
- pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.53",
229
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.57",
234
230
  "store.kv": "redis:8-alpine",
235
- "store.files": "rustfs/rustfs:1.0.0-rc.2",
236
- "channel.email": "axllent/mailpit:v1.30.7",
231
+ "store.files": "rustfs/rustfs:1.0.0-rc.5",
232
+ "channel.email": "axllent/mailpit:v1.31.1",
237
233
  "store.index": "getmeili/meilisearch:v1.53",
238
- ai: LLAMA_CPP_IMAGE,
239
234
  proxy: "caddy:2-alpine",
240
235
  };
241
236
 
@@ -104,6 +104,23 @@ describe("vault backend defaults", () => {
104
104
  const env = readFileSync(join(resolveTemplateDir(id), ".env.example"), "utf8");
105
105
  expect(env, id).toContain("OKE_VAULT_MASTER_KEY");
106
106
  expect(env, id).toContain("oke vault init");
107
+ expect(env, id).toContain("src/vault.ts");
108
+ }
109
+ });
110
+
111
+ test("both templates declare stack vault.secret / vault.config contracts", () => {
112
+ for (const id of ["standard", "advanced"] as const) {
113
+ const vault = readFileSync(join(resolveTemplateDir(id), "src/vault.ts"), "utf8");
114
+ expect(vault, id).toContain('vault.secret("APP_WEBHOOK_SECRET"');
115
+ expect(vault, id).toContain('vault.secret("DATABASE_URL"');
116
+ expect(vault, id).toContain('vault.config("OKE_APP_URL"');
117
+ expect(vault, id).toContain('vault.config("PUBLIC_API_URL"');
118
+ expect(vault, id).toContain('dev: "http://127.0.0.1:6530"');
119
+ expect(vault, id).not.toMatch(/PUBLIC_API_URL[\s\S]*?dev:\s*""/);
120
+ expect(vault, id).toContain("export const NOTES_VAULT");
121
+ expect(vault, id).toContain("vault.fromDocker");
122
+ const app = readFileSync(join(resolveTemplateDir(id), "src/app.ts"), "utf8");
123
+ expect(app, id).toContain("secrets: NOTES_VAULT");
107
124
  }
108
125
  });
109
126
 
@@ -138,24 +155,24 @@ describe("vault backend defaults", () => {
138
155
  });
139
156
 
140
157
  describe("upsertAiDrivers", () => {
141
- const ollamaPins: EnvDriverPins = {
142
- dev: "ollama",
158
+ const localAiPins: EnvDriverPins = {
159
+ dev: "openai-compatible",
143
160
  test: "mock",
144
- prod: "ollama",
161
+ prod: "openai-compatible",
145
162
  };
146
163
 
147
164
  test("inserts drivers.ai inside drivers (not images / channel)", () => {
148
- const next = upsertAiDrivers(templateConfig("advanced"), ollamaPins);
165
+ const next = upsertAiDrivers(templateConfig("advanced"), localAiPins);
149
166
  const config = evalConfig(next);
150
- expect(config.drivers?.ai).toEqual(ollamaPins);
167
+ expect(config.drivers?.ai).toEqual(localAiPins);
151
168
  expect(config.drivers?.channel?.ai).toBeUndefined();
152
169
  // Sparse templates omit drivers.channel — images.channel stays a string pin.
153
- expect(config.images?.channel?.email).toBe("axllent/mailpit:v1.30.7");
170
+ expect(config.images?.channel?.email).toBe("axllent/mailpit:v1.31.1");
154
171
  expect(config.images?.ai).toBeUndefined();
155
172
  });
156
173
 
157
174
  test("applyCreateAnswers with ai pins keeps top-level drivers.ai", () => {
158
- const llamaPins = pinsDockerReady("openai-compatible", "mock");
175
+ const aiPins = pinsDockerReady("openai-compatible", "mock");
159
176
  for (const id of ["standard", "advanced"] as const) {
160
177
  const next = applyCreateAnswers(
161
178
  templateConfig(id),
@@ -173,9 +190,9 @@ describe("upsertAiDrivers", () => {
173
190
  clock: pinsDockerReady("postgres", "frozen"),
174
191
  vault: pinsDockerReady("vault", "memory"),
175
192
  channel: { email: pinsDockerReady("smtp", "console") },
176
- ai: llamaPins,
193
+ ai: aiPins,
177
194
  },
178
- ai: { enabled: true, provider: "llama-cpp", driver: "openai-compatible" },
195
+ ai: { enabled: true, provider: "openrouter", driver: "openai-compatible" },
179
196
  locales: [],
180
197
  pgdog: false,
181
198
  proxy: "none",
@@ -188,7 +205,49 @@ describe("upsertAiDrivers", () => {
188
205
  prod: "openai-compatible",
189
206
  });
190
207
  expect(config.drivers?.channel?.ai, id).toBeUndefined();
191
- expect(config.images?.ai, id).toBe("ghcr.io/ggml-org/llama.cpp:server-b10450");
208
+ expect(config.images?.ai, id).toBeUndefined();
192
209
  }
193
210
  });
211
+
212
+ test("applyCreateAnswers with OpenRouter does not pin images.ai", () => {
213
+ const cloudPins = pinsDockerReady("openai-compatible", "mock");
214
+ const next = applyCreateAnswers(
215
+ templateConfig("advanced"),
216
+ toCreateDefaults({
217
+ template: "advanced",
218
+ profile: "docker-ready",
219
+ drivers: {
220
+ store: {
221
+ sql: pinsDockerReady("postgres", "pglite"),
222
+ kv: pinsDockerReady("redis", "memory"),
223
+ files: pinsDockerReady("s3", "memory"),
224
+ index: null,
225
+ },
226
+ signal: pinsDockerReady("redis", "memory"),
227
+ clock: pinsDockerReady("postgres", "frozen"),
228
+ vault: pinsDockerReady("vault", "memory"),
229
+ channel: { email: pinsDockerReady("smtp", "console") },
230
+ ai: cloudPins,
231
+ },
232
+ ai: {
233
+ enabled: true,
234
+ provider: "openrouter",
235
+ driver: "openai-compatible",
236
+ chatModel: "openrouter/free",
237
+ apiKeyEnv: "OPENROUTER_API_KEY",
238
+ },
239
+ locales: [],
240
+ pgdog: false,
241
+ proxy: "none",
242
+ }),
243
+ );
244
+ const config = evalConfig(next);
245
+ expect(config.drivers?.ai).toEqual({
246
+ dev: "openai-compatible",
247
+ test: "mock",
248
+ prod: "openai-compatible",
249
+ });
250
+ expect(config.images?.ai).toBeUndefined();
251
+ expect(extractImages(next).ai).toBeUndefined();
252
+ });
194
253
  });