create-oke 0.9.0 → 0.10.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 (56) hide show
  1. package/README.md +7 -5
  2. package/package.json +2 -3
  3. package/src/agents-md.ts +1 -1
  4. package/src/ai-setup/apply.ts +66 -23
  5. package/src/ai-setup/catalog.ts +1316 -35
  6. package/src/ai-setup/detect-ollama.ts +48 -0
  7. package/src/ai-setup/from-pref.ts +32 -0
  8. package/src/ai-setup/prompts.ts +430 -486
  9. package/src/ai-setup/recommend.ts +118 -101
  10. package/src/cli.test.ts +36 -7
  11. package/src/cli.ts +21 -16
  12. package/src/customize-flow.test.ts +43 -13
  13. package/src/customize-flow.ts +136 -61
  14. package/src/drivers-catalog.ts +29 -12
  15. package/src/local-okengine.test.ts +59 -0
  16. package/src/local-okengine.ts +137 -0
  17. package/src/scaffold.ts +1 -1
  18. package/src/transform.test.ts +54 -32
  19. package/src/transform.ts +46 -9
  20. package/src/wizard-select.ts +5 -6
  21. package/templates/advanced/.github/workflows/ci.yml +24 -0
  22. package/templates/advanced/.vscode/settings.json +15 -0
  23. package/templates/advanced/README.md +15 -4
  24. package/templates/advanced/drizzle.config.ts +6 -4
  25. package/templates/advanced/oke.config.ts +6 -2
  26. package/templates/advanced/package.json +5 -2
  27. package/templates/advanced/src/app.ts +2 -14
  28. package/templates/advanced/src/core/index.ts +12 -0
  29. package/templates/advanced/src/{core.ts → core/store.ts} +1 -1
  30. package/templates/advanced/src/db/migrations/.gitkeep +0 -0
  31. package/templates/{standard/src → advanced/src/db}/schema.decl.ts +1 -1
  32. package/templates/advanced/src/{seed → db/seed}/index.ts +2 -2
  33. package/templates/advanced/src/flows/notes/index.ts +2 -4
  34. package/templates/advanced/tests/advanced.test.ts +10 -9
  35. package/templates/advanced/tsconfig.json +23 -0
  36. package/templates/standard/.github/workflows/ci.yml +24 -0
  37. package/templates/standard/.vscode/settings.json +15 -0
  38. package/templates/standard/README.md +20 -9
  39. package/templates/standard/drizzle.config.ts +6 -4
  40. package/templates/standard/oke.config.ts +4 -0
  41. package/templates/standard/package.json +5 -2
  42. package/templates/standard/src/app.ts +2 -14
  43. package/templates/standard/src/core/index.ts +12 -0
  44. package/templates/standard/src/{core.ts → core/store.ts} +1 -1
  45. package/templates/standard/src/db/migrations/.gitkeep +0 -0
  46. package/templates/{advanced/src → standard/src/db}/schema.decl.ts +1 -1
  47. package/templates/standard/src/{seed → db/seed}/index.ts +2 -2
  48. package/templates/standard/src/flows/notes/index.ts +2 -4
  49. package/templates/standard/tests/standard.test.ts +13 -10
  50. package/templates/standard/tsconfig.json +23 -0
  51. /package/templates/advanced/src/{channels.ts → core/channels.ts} +0 -0
  52. /package/templates/advanced/src/{gates.ts → core/gates.ts} +0 -0
  53. /package/templates/advanced/src/{vault.ts → core/vault.ts} +0 -0
  54. /package/templates/standard/src/{channels.ts → core/channels.ts} +0 -0
  55. /package/templates/standard/src/{gates.ts → core/gates.ts} +0 -0
  56. /package/templates/standard/src/{vault.ts → core/vault.ts} +0 -0
@@ -1,32 +1,21 @@
1
1
  /**
2
- * Smart local-model recommendations machine-tier fit + use-case scoring.
2
+ * Machine fit helpers for local Ollama model picks.
3
3
  *
4
4
  * Catalog `ramGb` is the **recommended machine tier** for that model (not the
5
- * download size). A 24GB laptop must never be told it will "download a 24GB
6
- * model" — that number was machine RAM shown in the wrong place.
7
- *
8
- * Most dev machines are 8–16GB. Prefer picks that leave ~4GB for OS / IDE /
9
- * browser when several models fit.
5
+ * download size). Prefer picks that leave ~4GB for OS / IDE / browser.
10
6
  */
11
7
 
12
- import { CHAT_MODELS, VISION_MODELS, type CatalogModel } from "./catalog.ts";
8
+ import {
9
+ CHAT_MODELS,
10
+ MODEL_TIERS,
11
+ type CatalogModel,
12
+ type ModelTier,
13
+ recommendForTier,
14
+ } from "./catalog.ts";
13
15
 
14
16
  /** Reserved for OS + IDE + browser while the model runs. */
15
17
  export const OS_HEADROOM_GB = 4;
16
18
 
17
- /** What the developer mainly wants from the local model. */
18
- export type AiUseCase = "coding" | "general" | "reasoning" | "balanced";
19
-
20
- /** Speed vs quality preference. */
21
- export type AiPriority = "speed" | "balanced" | "quality";
22
-
23
- /** Answers from the short Ollama questionnaire. */
24
- export type AiNeeds = {
25
- readonly useCase: AiUseCase;
26
- readonly priority: AiPriority;
27
- readonly wantVision: boolean;
28
- };
29
-
30
19
  /**
31
20
  * RAM left after reserving OS/IDE headroom.
32
21
  *
@@ -60,7 +49,6 @@ export function modelFitsComfortably(model: CatalogModel, totalRamGb: number | n
60
49
  return Boolean(model.recommended);
61
50
  }
62
51
  const smallest = [...CHAT_MODELS].sort((a, b) => a.ramGb - b.ramGb)[0]!;
63
- // Always allow the entry-level model on machines that meet its tier.
64
52
  if (model.id === smallest.id) return model.ramGb <= totalRamGb;
65
53
  return model.ramGb + OS_HEADROOM_GB <= totalRamGb;
66
54
  }
@@ -101,103 +89,132 @@ export function comfortableChatModels(totalRamGb: number | null): readonly Catal
101
89
  }
102
90
 
103
91
  /**
104
- * Score a fitting chat model for the user's needs (higher = better).
92
+ * Suggest a tier for the machine's fit RAM budget.
105
93
  *
106
- * @param model - Candidate
107
- * @param needs - Questionnaire answers
108
- * @param totalRamGb - Machine RAM (tight-fit penalty)
94
+ * @param totalRamGb - Machine RAM
109
95
  */
110
- export function scoreChatModel(
111
- model: CatalogModel,
112
- needs: AiNeeds,
113
- totalRamGb: number | null = null,
114
- ): number {
115
- let score = 0;
116
- if (needs.priority === "speed") score += 100 - model.ramGb * 3;
117
- else if (needs.priority === "quality") score += model.ramGb * 4;
118
- else score += model.ramGb * 2;
119
-
120
- if (needs.useCase === "coding") {
121
- if (model.id.startsWith("qwen")) score += 40;
122
- if (model.id.includes("deepseek")) score += 15;
123
- } else if (needs.useCase === "reasoning") {
124
- if (model.id.includes("deepseek") || model.id.includes("r1")) score += 45;
125
- if (model.id.startsWith("qwen")) score += 20;
126
- } else if (needs.useCase === "general") {
127
- if (model.id.includes("llama") || model.id.includes("gemma")) score += 35;
128
- if (model.id.startsWith("qwen")) score += 20;
129
- } else {
130
- if (model.recommended) score += 25;
131
- if (model.id.startsWith("qwen3.5:9b")) score += 30;
132
- if (model.id.startsWith("gemma")) score += 20;
133
- }
134
-
135
- // Prefer leaving OS/IDE headroom unless the user asked for max quality.
136
- if (isTightFit(model, totalRamGb) && needs.priority !== "quality") {
137
- score -= 35;
138
- }
139
- return score;
96
+ export function suggestTierForRam(totalRamGb: number | null): ModelTier {
97
+ if (totalRamGb === null || !Number.isFinite(totalRamGb)) return "fast";
98
+ const budget = usableRamGb(totalRamGb);
99
+ if (budget >= 24) return "smart";
100
+ if (budget >= 8) return "balanced";
101
+ if (budget >= 4) return "fast";
102
+ return "ultra-fast";
140
103
  }
141
104
 
142
105
  /**
143
- * Pick the best chat model for RAM + needs.
106
+ * Recommend a chat model for host RAM (balanced default — no quiz).
144
107
  *
145
108
  * @param totalRamGb - Machine RAM
146
- * @param needs - Questionnaire (defaults to balanced/speed-friendly)
147
109
  */
148
- export function recommendChatForNeeds(
149
- totalRamGb: number | null,
150
- needs: AiNeeds = { useCase: "balanced", priority: "balanced", wantVision: false },
151
- ): CatalogModel {
152
- // Prefer comfortable pool; quality may still score a tight tier-fit higher.
153
- const pool =
154
- needs.priority === "quality"
155
- ? fittingChatModels(totalRamGb)
156
- : comfortableChatModels(totalRamGb);
157
- let best = pool[0]!;
158
- let bestScore = Number.NEGATIVE_INFINITY;
159
- for (const m of pool) {
160
- const s = scoreChatModel(m, needs, totalRamGb);
161
- if (s > bestScore) {
162
- best = m;
163
- bestScore = s;
164
- }
165
- }
166
- return best;
110
+ export function recommendChatForNeeds(totalRamGb: number | null): CatalogModel {
111
+ return recommendForTier(suggestTierForRam(totalRamGb), totalRamGb);
167
112
  }
168
113
 
169
114
  /**
170
- * Vision model that fits; null if user skipped or none fit.
115
+ * Shared hardware lines for local AI banners.
171
116
  *
172
- * @param totalRamGb - Machine RAM
173
- * @param wantVision - From questionnaire
117
+ * @param machine - Detected hardware
174
118
  */
175
- export function recommendVisionForNeeds(
176
- totalRamGb: number | null,
177
- wantVision: boolean,
178
- ): CatalogModel | null {
179
- if (!wantVision) return null;
180
- const fits = VISION_MODELS.filter((m) => modelFitsOnMachine(m, totalRamGb));
181
- return fits.find((m) => m.recommended) ?? fits[0] ?? null;
119
+ function formatMachineBannerLines(machine: {
120
+ readonly osName: string;
121
+ readonly cpuCount: number | null;
122
+ readonly ramGb: number | null;
123
+ }): readonly string[] {
124
+ const ram =
125
+ machine.ramGb !== null && Number.isFinite(machine.ramGb)
126
+ ? `~${machine.ramGb}GB RAM`
127
+ : "RAM unknown";
128
+ const cpu =
129
+ machine.cpuCount !== null && Number.isFinite(machine.cpuCount) ? String(machine.cpuCount) : "?";
130
+ const fit =
131
+ machine.ramGb !== null && Number.isFinite(machine.ramGb)
132
+ ? `~${usableRamGb(machine.ramGb)}GB RAM`
133
+ : "unknown";
134
+ const sep = " · ";
135
+ return [[`OS ${machine.osName}`, `CPU ${cpu}`, `RAM ${ram}`].join(sep), `Fit RAM ${fit}`];
182
136
  }
183
137
 
184
138
  /**
185
- * Human summary line for the recommendation panel.
139
+ * Ollama banner lines OS · CPU · RAM · fit · detected · tiers.
186
140
  *
187
- * Separates **machine RAM** from **model tier** so "~24GB" is never read as
188
- * download size.
141
+ * @param machine - Detected hardware
142
+ * @param detectedIds - Installed model ids
143
+ */
144
+ export function formatOllamaBanner(
145
+ machine: {
146
+ readonly osName: string;
147
+ readonly cpuCount: number | null;
148
+ readonly ramGb: number | null;
149
+ },
150
+ detectedIds: readonly string[],
151
+ ): string {
152
+ const sep = " · ";
153
+ const lines = [
154
+ ...formatMachineBannerLines(machine),
155
+ "",
156
+ "Detected local models",
157
+ ...(detectedIds.length > 0 ? detectedIds.map((id) => id) : ["(none detected)"]),
158
+ "",
159
+ `Tiers${sep}${MODEL_TIERS.map((t) => t.label).join(sep)}`,
160
+ ];
161
+ return lines.join("\n");
162
+ }
163
+
164
+ /**
165
+ * llama.cpp banner — OS · CPU · RAM · fit · Docker Hub `ai/` source · tiers.
189
166
  *
190
- * @param totalRamGb - Machine RAM
191
- * @param chat - Chosen chat model
167
+ * @param machine - Detected hardware
192
168
  */
193
- export function formatMachineSummary(totalRamGb: number | null, chat: CatalogModel): string {
194
- if (totalRamGb === null || !Number.isFinite(totalRamGb)) {
195
- return `⭐ Suggested: ${chat.label} (≈${chat.ramGb}GB-class machine · not download size)`;
196
- }
197
- const budget = usableRamGb(totalRamGb);
198
- const tight = isTightFit(chat, totalRamGb) ? " · tight on this machine" : "";
199
- return [
200
- `Your machine: ~${totalRamGb}GB RAM (keep ~${OS_HEADROOM_GB}GB free for OS/IDE → ~${budget}GB model budget)`,
201
- `⭐ For you: ${chat.label} — ≈${chat.ramGb}GB-class (Ollama pull is usually much smaller than RAM)${tight}`,
202
- ].join("\n");
169
+ export function formatLlamaCppBanner(machine: {
170
+ readonly osName: string;
171
+ readonly cpuCount: number | null;
172
+ readonly ramGb: number | null;
173
+ }): string {
174
+ const sep = " · ";
175
+ const lines = [
176
+ ...formatMachineBannerLines(machine),
177
+ "",
178
+ "Model source",
179
+ "Docker Hub ai/ (curated) — pulled on first container start",
180
+ "",
181
+ `Tiers${sep}${MODEL_TIERS.map((t) => t.label).join(sep)}`,
182
+ ];
183
+ return lines.join("\n");
184
+ }
185
+
186
+ /** Model name column width (monospace CLI table). */
187
+ const MODEL_COL_NAME = 26;
188
+ /** RAM column width, right-aligned (`≈32GB`). */
189
+ const MODEL_COL_RAM = 7;
190
+
191
+ /**
192
+ * Clip or pad a cell for aligned CLI columns.
193
+ *
194
+ * @param value - Cell text
195
+ * @param width - Fixed width
196
+ */
197
+ function clipPad(value: string, width: number): string {
198
+ if (value.length === width) return value;
199
+ if (value.length < width) return value.padEnd(width);
200
+ return `${value.slice(0, Math.max(0, width - 1))}…`;
201
+ }
202
+
203
+ /**
204
+ * Column header for the model pick list (aligns with {@link formatModelRow}).
205
+ */
206
+ export function formatModelTableHeader(): string {
207
+ return `${clipPad("Model", MODEL_COL_NAME)} ${"RAM".padStart(MODEL_COL_RAM)} Caps`;
208
+ }
209
+
210
+ /**
211
+ * One model row as aligned columns: Model | RAM | Caps.
212
+ *
213
+ * @param model - Catalog entry
214
+ */
215
+ export function formatModelRow(model: CatalogModel): string {
216
+ const name = clipPad(model.label, MODEL_COL_NAME);
217
+ const ram = `≈${model.ramGb}GB`.padStart(MODEL_COL_RAM);
218
+ const caps = model.modalities.join(" · ");
219
+ return `${name} ${ram} ${caps}`;
203
220
  }
package/src/cli.test.ts CHANGED
@@ -200,7 +200,7 @@ describe("wizard ← Back", () => {
200
200
  const base = [{ value: "memory", label: "memory" }];
201
201
  expect(withBackOption(base, false)).toEqual(base);
202
202
  const withBack = withBackOption(base, true);
203
- expect(withBack.at(-1)).toEqual({ value: WIZARD_BACK, label: "Back" });
203
+ expect(withBack.at(-1)).toEqual({ value: WIZARD_BACK, label: "Back" });
204
204
  expect(WIZARD_BACK).toBe("__back__");
205
205
  });
206
206
  });
@@ -536,6 +536,8 @@ describe("scaffold structure", () => {
536
536
  );
537
537
  expect(result.files).toContain(".gitignore");
538
538
  expect(result.files).toContain("README.md");
539
+ expect(result.files).toContain(".github/workflows/ci.yml");
540
+ expect(result.files).toContain("tsconfig.json");
539
541
  expect(result.sqlDriver).toBe("sqlite");
540
542
  expect(readFileSync(join(result.targetDir, "AGENTS.md"), "utf8")).toMatch(
541
543
  /one law|on\(Trigger\)/i,
@@ -544,13 +546,37 @@ describe("scaffold structure", () => {
544
546
  expect(readme).toMatch(/oke dev/);
545
547
  expect(readme).toMatch(new RegExp(`Notes \\(${id}\\)`, "i"));
546
548
  expect(readme).toMatch(/notes\.(create|attach|digest)|main\.health/);
549
+ expect(readme).toMatch(/scaffold|Included vs you build/i);
550
+ expect(readme).toMatch(/\.github\/workflows\/ci\.yml/);
547
551
  expect(readFileSync(join(result.targetDir, ".gitignore"), "utf8")).toMatch(/node_modules/);
552
+ const ciYml = readFileSync(join(result.targetDir, ".github/workflows/ci.yml"), "utf8");
553
+ expect(() => Bun.YAML.parse(ciYml)).not.toThrow();
554
+ const ci = Bun.YAML.parse(ciYml) as {
555
+ name?: string;
556
+ on?: unknown;
557
+ jobs?: { check?: { steps?: unknown[] } };
558
+ };
559
+ expect(ci.name).toBe("CI");
560
+ expect(ci.on).toBeTruthy();
561
+ expect(ci.jobs?.check).toBeTruthy();
562
+ expect(ciYml).toMatch(/bun run typecheck/);
563
+ expect(ciYml).toMatch(/bun test/);
564
+ const appTs = readFileSync(join(result.targetDir, "src/app.ts"), "utf8");
565
+ expect(appTs).not.toMatch(/Object\.assign/);
566
+ expect(appTs).not.toMatch(/env:\s*["']test["']/);
567
+ expect(appTs).toMatch(/stores:\s*\[/);
568
+ expect(appTs).toMatch(/oke\(\{[\s\S]*stores:/);
548
569
  const pkg = JSON.parse(readFileSync(join(result.targetDir, "package.json"), "utf8")) as {
549
570
  name: string;
550
571
  dependencies: { okengine: string };
572
+ scripts: { typecheck?: string; test?: string };
573
+ devDependencies: { typescript?: string };
551
574
  };
552
575
  expect(pkg.name).toBe(`app-${id}`);
553
576
  expect(pkg.dependencies.okengine).not.toMatch(/^file:\.\./);
577
+ expect(pkg.scripts.typecheck).toBe("tsc --noEmit");
578
+ expect(pkg.scripts.test).toBe("bun test");
579
+ expect(pkg.devDependencies.typescript).toBeTruthy();
554
580
  } finally {
555
581
  rmSync(dir, { recursive: true, force: true });
556
582
  }
@@ -566,17 +592,20 @@ describe("scaffold structure", () => {
566
592
  source: { kind: "template", id: "standard" },
567
593
  });
568
594
  for (const path of [
569
- "src/gates.ts",
570
- "src/vault.ts",
571
- "src/channels.ts",
595
+ "src/core/gates.ts",
596
+ "src/core/vault.ts",
597
+ "src/core/channels.ts",
598
+ "src/core/store.ts",
599
+ "src/core/index.ts",
572
600
  "src/locales/en.ts",
573
601
  "src/locales/ar.ts",
574
602
  "src/flows/main/shapes.ts",
575
603
  "src/flows/main/signals.ts",
576
604
  "src/flows/notes/index.ts",
577
- "src/core.ts",
578
- "src/schema.decl.ts",
605
+ "src/db/schema.decl.ts",
606
+ "src/db/seed/index.ts",
579
607
  "src/app.ts",
608
+ ".vscode/settings.json",
580
609
  ]) {
581
610
  expect(result.files).toContain(path);
582
611
  }
@@ -604,7 +633,7 @@ describe("scaffold structure", () => {
604
633
  });
605
634
  expect(result.sqlDriver).toBe("postgres");
606
635
  // Abstract decl is dialect-agnostic — emit picks pgTable at sync time.
607
- const decl = readFileSync(join(result.targetDir, "src/schema.decl.ts"), "utf8");
636
+ const decl = readFileSync(join(result.targetDir, "src/db/schema.decl.ts"), "utf8");
608
637
  expect(decl).toContain("store.schema.table(");
609
638
  expect(decl).not.toContain("sqliteTable");
610
639
  expect(decl).not.toContain("pgTable");
package/src/cli.ts CHANGED
@@ -108,7 +108,7 @@ export function defaultsBranchOptions(
108
108
  const options: DefaultsBranchOption[] = [
109
109
  {
110
110
  value: "recommended",
111
- label: "Yes, use recommended defaults",
111
+ label: "Yes, use recommended defaults",
112
112
  hint:
113
113
  template === "advanced"
114
114
  ? "Notes · docker-ready pins · .oke/mode docker"
@@ -118,13 +118,13 @@ export function defaultsBranchOptions(
118
118
  if (hasPreviousForTemplate) {
119
119
  options.push({
120
120
  value: "reuse",
121
- label: "No, reuse previous settings",
121
+ label: "No, reuse previous settings",
122
122
  hint: `~/.oke/create-defaults.json (${template})`,
123
123
  });
124
124
  }
125
125
  options.push({
126
126
  value: "customize",
127
- label: "No, customize settings",
127
+ label: "No, customize settings",
128
128
  hint:
129
129
  template === "standard"
130
130
  ? "local|docker · SQL · optional AI"
@@ -155,7 +155,7 @@ export type ScaffoldCallArgs = {
155
155
  readonly agentsMd: boolean;
156
156
  readonly sqlDriver: SqlDriverId;
157
157
  readonly createDefaults?: CreateDefaults;
158
- /** Apply `src/ai.ts` + `.env.local` after scaffold (before install). */
158
+ /** Apply `src/core/ai.ts` + `.env.local` after scaffold (before install). */
159
159
  readonly aiApply?: AiSetupApplyInput | null;
160
160
  };
161
161
 
@@ -355,7 +355,8 @@ Template:
355
355
  ${templateLines}
356
356
 
357
357
  On a TTY: pick standard|advanced, then recommended defaults, customize
358
- (local or docker first; optional other side; saved to ~/.oke/create-defaults.json),
358
+ (local or docker first; store.index with none; AI setup Recommended /
359
+ Customize / Off; optional other side; saved to ~/.oke/create-defaults.json),
359
360
  or reuse when saved for that template. Non-TTY / --yes stay zero-prompt.
360
361
  `;
361
362
  }
@@ -398,7 +399,7 @@ export function scaffoldArgsFromCli(args: CliArgs): ScaffoldCallArgs {
398
399
  source: sourceFromArgs(args),
399
400
  agentsMd: args.agentsMd,
400
401
  sqlDriver: args.sqlDriver,
401
- aiApply: args.ai === "force" ? nonInteractiveAiApply("ollama") : null,
402
+ aiApply: args.ai === "force" ? nonInteractiveAiApply("llama-cpp") : null,
402
403
  };
403
404
  }
404
405
 
@@ -512,7 +513,7 @@ export async function askInteractiveAnswers(
512
513
  message: "Starter template",
513
514
  options: TEMPLATES.map((id) => ({
514
515
  value: id,
515
- label: id === "standard" ? "standard" : "advanced",
516
+ label: id === "standard" ? "standard" : "advanced",
516
517
  hint: TEMPLATE_PURPOSES[id],
517
518
  })),
518
519
  initialValue: partial.template ?? DEFAULT_TEMPLATE,
@@ -604,7 +605,7 @@ export async function askInteractiveAnswers(
604
605
  }
605
606
 
606
607
  /**
607
- * Provider passed to `oke ai setup` — prefer Ollama when either env uses it.
608
+ * Provider passed to `oke ai setup` — prefer native Ollama when either env uses it.
608
609
  *
609
610
  * @param localProvider - Menu id chosen for local
610
611
  * @param pins - Resolved driver pins
@@ -612,9 +613,11 @@ export async function askInteractiveAnswers(
612
613
  export function aiSetupProviderFor(localProvider: string, pins: EnvDriverPins): string {
613
614
  if (localProvider === "ollama" || pins.local === "ollama") return "ollama";
614
615
  if (pins.docker === "ollama") return "ollama";
616
+ if (localProvider === "llama-cpp") return "llama-cpp";
617
+ if (localProvider === "vllm" || localProvider === "sglang") return localProvider;
615
618
  if (localProvider === "mock") {
616
619
  if (pins.docker === "anthropic") return "anthropic";
617
- if (pins.docker === "openai-compatible") return "openai";
620
+ if (pins.docker === "openai-compatible") return "llama-cpp";
618
621
  }
619
622
  return localProvider;
620
623
  }
@@ -798,7 +801,7 @@ async function runScaffold(
798
801
  });
799
802
  if (spun) spun.stop("Scaffolded.");
800
803
 
801
- // Models were chosen in the wizard — write env + src/ai.ts before install
804
+ // Models were chosen in the wizard — write env + src/core/ai.ts before install
802
805
  // so `--no-install` still gets a complete AI project. Preserve per-env
803
806
  // `drivers.ai` pins from customize; flatten only when pins were never written.
804
807
  if (runPostScaffold && aiApply) {
@@ -814,23 +817,25 @@ async function runScaffold(
814
817
  }
815
818
 
816
819
  if (runPostScaffold && install) {
817
- const installSpun = interactive ? spinner() : undefined;
818
- installSpun?.start("Installing dependencies…");
819
- const installOk = await runCommand(["bun", "install"], targetDir);
820
+ // Inherit bun's progress a silent spinner feels stuck on cold caches.
821
+ if (interactive) log.step("Installing dependencies…");
822
+ const installOk = await runCommand(["bun", "install"], targetDir, {
823
+ inherit: interactive,
824
+ });
820
825
  if (!installOk) {
821
- installSpun?.stop("Install failed.");
826
+ if (interactive) log.error("Install failed.");
822
827
  cleanup();
823
828
  console.error("create-oke: bun install failed");
824
829
  return 1;
825
830
  }
826
- installSpun?.stop("Installed.");
831
+ if (interactive) log.success("Installed.");
827
832
  }
828
833
 
829
834
  const message = nextStepsText(result);
830
835
  if (interactive) {
831
836
  note(
832
837
  [
833
- `App http://127.0.0.1:6530`,
838
+ `Backend http://127.0.0.1:6530`,
834
839
  `Console http://127.0.0.1:6533`,
835
840
  `MCP http://127.0.0.1:6535`,
836
841
  `Docs ${docsUrl("/docs")}`,
@@ -3,8 +3,14 @@
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
- import { TEMPLATE_DOCKER_PROD, TEMPLATE_LOCAL, customizeFacetsFor } from "./drivers-catalog.ts";
7
- import { assembleDriverDefaults, pinsFromSides } from "./customize-flow.ts";
6
+ import {
7
+ EMAIL_CHOICES,
8
+ INDEX_CHOICES,
9
+ TEMPLATE_DOCKER_PROD,
10
+ TEMPLATE_LOCAL,
11
+ customizeFacetsFor,
12
+ } from "./drivers-catalog.ts";
13
+ import { assembleDriverDefaults, pinsFromSides, recommendedAiApply } from "./customize-flow.ts";
8
14
 
9
15
  describe("customizeFacetsFor", () => {
10
16
  test("standard is lean (sql only)", () => {
@@ -16,7 +22,6 @@ describe("customizeFacetsFor", () => {
16
22
  "sql",
17
23
  "kv",
18
24
  "files",
19
- "enableIndex",
20
25
  "index",
21
26
  "signal",
22
27
  "clock",
@@ -26,6 +31,35 @@ describe("customizeFacetsFor", () => {
26
31
  });
27
32
  });
28
33
 
34
+ describe("catalog labels", () => {
35
+ test("store.index offers none before drivers", () => {
36
+ expect(INDEX_CHOICES.map((c) => c.value)).toEqual([
37
+ "none",
38
+ "memory",
39
+ "pgvector",
40
+ "libsql",
41
+ "meilisearch",
42
+ ]);
43
+ });
44
+
45
+ test("channel.email labels taqnyat-mail as taqnyat", () => {
46
+ const row = EMAIL_CHOICES.find((c) => c.value === "taqnyat-mail");
47
+ expect(row?.label).toBe("taqnyat");
48
+ });
49
+ });
50
+
51
+ describe("recommendedAiApply", () => {
52
+ test("returns llama.cpp (openai-compatible) with curated ai/ model", () => {
53
+ const apply = recommendedAiApply();
54
+ expect(apply.driver).toBe("openai-compatible");
55
+ expect(apply.chatModel).toBe("smollm2");
56
+ expect(apply.baseUrl).toContain("8080");
57
+ expect(apply.image).toContain("llama.cpp");
58
+ expect(apply.image).not.toContain("latest");
59
+ expect(apply.visionModel).toBeNull();
60
+ });
61
+ });
62
+
29
63
  describe("pinsFromSides", () => {
30
64
  test("prod copies docker", () => {
31
65
  expect(pinsFromSides("sqlite", "postgres", "memory")).toEqual({
@@ -38,7 +72,7 @@ describe("pinsFromSides", () => {
38
72
  });
39
73
 
40
74
  describe("assembleDriverDefaults", () => {
41
- test("primary docker + decline other → local pins === template defaults", () => {
75
+ test("primary docker + skip other → local pins === template defaults", () => {
42
76
  const drivers = assembleDriverDefaults(
43
77
  "docker",
44
78
  { sql: "libsql", kv: "redis", files: "s3", signal: "nats" },
@@ -61,17 +95,13 @@ describe("assembleDriverDefaults", () => {
61
95
  expect(drivers.store.kv.docker).toBe(TEMPLATE_DOCKER_PROD.kv);
62
96
  });
63
97
 
64
- test("index stays null unless enableIndex", () => {
65
- const drivers = assembleDriverDefaults("local", { sql: "sqlite" }, null);
66
- expect(drivers.store.index).toBeNull();
98
+ test("index stays null for none / unset", () => {
99
+ expect(assembleDriverDefaults("local", { sql: "sqlite" }, null).store.index).toBeNull();
100
+ expect(assembleDriverDefaults("docker", { index: "none" }, null).store.index).toBeNull();
67
101
  });
68
102
 
69
- test("enableIndex on primary fills both columns", () => {
70
- const drivers = assembleDriverDefaults(
71
- "docker",
72
- { enableIndex: true, index: "meilisearch" },
73
- null,
74
- );
103
+ test("index driver on primary fills both columns", () => {
104
+ const drivers = assembleDriverDefaults("docker", { index: "meilisearch" }, null);
75
105
  expect(drivers.store.index).toEqual({
76
106
  local: "memory",
77
107
  docker: "meilisearch",